diff --git a/.github/workflows/_diffcalc_processor.yml b/.github/workflows/_diffcalc_processor.yml new file mode 100644 index 0000000000..4e221d0550 --- /dev/null +++ b/.github/workflows/_diffcalc_processor.yml @@ -0,0 +1,228 @@ +name: "🔒diffcalc (do not use)" + +on: + workflow_call: + inputs: + id: + type: string + head-sha: + type: string + pr-url: + type: string + pr-text: + type: string + dispatch-inputs: + type: string + outputs: + target: + description: The comparison target. + value: ${{ jobs.generator.outputs.target }} + sheet: + description: The comparison spreadsheet. + value: ${{ jobs.generator.outputs.sheet }} + secrets: + DIFFCALC_GOOGLE_CREDENTIALS: + required: true + +env: + GENERATOR_DIR: ${{ github.workspace }}/${{ inputs.id }} + GENERATOR_ENV: ${{ github.workspace }}/${{ inputs.id }}/.env + +defaults: + run: + shell: bash -euo pipefail {0} + +jobs: + generator: + name: Run + runs-on: self-hosted + timeout-minutes: 720 + + outputs: + target: ${{ steps.run.outputs.target }} + sheet: ${{ steps.run.outputs.sheet }} + + steps: + - name: Checkout diffcalc-sheet-generator + uses: actions/checkout@v4 + with: + path: ${{ inputs.id }} + repository: 'smoogipoo/diffcalc-sheet-generator' + + - name: Add base environment + env: + GOOGLE_CREDS_FILE: ${{ github.workspace }}/${{ inputs.id }}/google-credentials.json + VARS_JSON: ${{ (vars != null && toJSON(vars)) || '' }} + run: | + # Required by diffcalc-sheet-generator + cp '${{ env.GENERATOR_DIR }}/.env.sample' "${{ env.GENERATOR_ENV }}" + + # Add Google credentials + echo '${{ secrets.DIFFCALC_GOOGLE_CREDENTIALS }}' | base64 -d > "${{ env.GOOGLE_CREDS_FILE }}" + + # Add repository variables + echo "${VARS_JSON}" | jq -c '. | to_entries | .[]' | while read -r line; do + opt=$(jq -r '.key' <<< ${line}) + val=$(jq -r '.value' <<< ${line}) + + if [[ "${opt}" =~ ^DIFFCALC_ ]]; then + optNoPrefix=$(echo "${opt}" | cut -d '_' -f2-) + sed -i "s;^${optNoPrefix}=.*$;${optNoPrefix}=${val};" "${{ env.GENERATOR_ENV }}" + fi + done + + - name: Add HEAD environment + run: | + sed -i "s;^OSU_A=.*$;OSU_A=${{ inputs.head-sha }};" "${{ env.GENERATOR_ENV }}" + + - name: Add pull-request environment + if: ${{ inputs.pr-url != '' }} + run: | + sed -i "s;^OSU_B=.*$;OSU_B=${{ inputs.pr-url }};" "${{ env.GENERATOR_ENV }}" + + - name: Add comment environment + if: ${{ inputs.pr-text != '' }} + env: + PR_TEXT: ${{ inputs.pr-text }} + run: | + # Add comment environment + echo "${PR_TEXT}" | sed -r 's/\r$//' | { grep -E '^\w+=' || true; } | while read -r line; do + opt=$(echo "${line}" | cut -d '=' -f1) + sed -i "s;^${opt}=.*$;${line};" "${{ env.GENERATOR_ENV }}" + done + + - name: Add dispatch environment + if: ${{ inputs.dispatch-inputs != '' }} + env: + DISPATCH_INPUTS_JSON: ${{ inputs.dispatch-inputs }} + run: | + function get_input() { + echo "${DISPATCH_INPUTS_JSON}" | jq -r ".\"$1\"" + } + + osu_a=$(get_input 'osu-a') + osu_b=$(get_input 'osu-b') + ruleset=$(get_input 'ruleset') + generators=$(get_input 'generators') + difficulty_calculator_a=$(get_input 'difficulty-calculator-a') + difficulty_calculator_b=$(get_input 'difficulty-calculator-b') + score_processor_a=$(get_input 'score-processor-a') + score_processor_b=$(get_input 'score-processor-b') + converts=$(get_input 'converts') + ranked_only=$(get_input 'ranked-only') + + sed -i "s;^OSU_B=.*$;OSU_B=${osu_b};" "${{ env.GENERATOR_ENV }}" + sed -i "s/^RULESET=.*$/RULESET=${ruleset}/" "${{ env.GENERATOR_ENV }}" + sed -i "s/^GENERATORS=.*$/GENERATORS=${generators}/" "${{ env.GENERATOR_ENV }}" + + if [[ "${osu_a}" != 'latest' ]]; then + sed -i "s;^OSU_A=.*$;OSU_A=${osu_a};" "${{ env.GENERATOR_ENV }}" + fi + + if [[ "${difficulty_calculator_a}" != 'latest' ]]; then + sed -i "s;^DIFFICULTY_CALCULATOR_A=.*$;DIFFICULTY_CALCULATOR_A=${difficulty_calculator_a};" "${{ env.GENERATOR_ENV }}" + fi + + if [[ "${difficulty_calculator_b}" != 'latest' ]]; then + sed -i "s;^DIFFICULTY_CALCULATOR_B=.*$;DIFFICULTY_CALCULATOR_B=${difficulty_calculator_b};" "${{ env.GENERATOR_ENV }}" + fi + + if [[ "${score_processor_a}" != 'latest' ]]; then + sed -i "s;^SCORE_PROCESSOR_A=.*$;SCORE_PROCESSOR_A=${score_processor_a};" "${{ env.GENERATOR_ENV }}" + fi + + if [[ "${score_processor_b}" != 'latest' ]]; then + sed -i "s;^SCORE_PROCESSOR_B=.*$;SCORE_PROCESSOR_B=${score_processor_b};" "${{ env.GENERATOR_ENV }}" + fi + + if [[ "${converts}" == 'true' ]]; then + sed -i 's/^NO_CONVERTS=.*$/NO_CONVERTS=0/' "${{ env.GENERATOR_ENV }}" + else + sed -i 's/^NO_CONVERTS=.*$/NO_CONVERTS=1/' "${{ env.GENERATOR_ENV }}" + fi + + if [[ "${ranked_only}" == 'true' ]]; then + sed -i 's/^RANKED_ONLY=.*$/RANKED_ONLY=1/' "${{ env.GENERATOR_ENV }}" + else + sed -i 's/^RANKED_ONLY=.*$/RANKED_ONLY=0/' "${{ env.GENERATOR_ENV }}" + fi + + - name: Query latest scores + id: query-scores + run: | + ruleset=$(cat ${{ env.GENERATOR_ENV }} | grep -E '^RULESET=' | cut -d '=' -f2-) + performance_data_name=$(curl -s "https://data.ppy.sh/" | grep "performance_${ruleset}_top_1000\b" | tail -1 | awk -F "'" '{print $2}' | sed 's/\.tar\.bz2//g') + + echo "TARGET_DIR=${{ env.GENERATOR_DIR }}/sql/${ruleset}" >> "${GITHUB_OUTPUT}" + echo "DATA_NAME=${performance_data_name}" >> "${GITHUB_OUTPUT}" + echo "DATA_PKG=${performance_data_name}.tar.bz2" >> "${GITHUB_OUTPUT}" + + - name: Restore score cache + id: restore-score-cache + uses: maxnowack/local-cache@720e69c948191660a90aa1cf6a42fc4d2dacdf30 # v2 + with: + path: ${{ steps.query-scores.outputs.DATA_PKG }} + key: ${{ steps.query-scores.outputs.DATA_NAME }} + + - name: Download scores + if: steps.restore-score-cache.outputs.cache-hit != 'true' + run: | + wget -q -O "${{ steps.query-scores.outputs.DATA_PKG }}" "https://data.ppy.sh/${{ steps.query-scores.outputs.DATA_PKG }}" + + - name: Extract scores + run: | + tar -I lbzip2 -xf "${{ steps.query-scores.outputs.DATA_PKG }}" + rm -r "${{ steps.query-scores.outputs.TARGET_DIR }}" + mv "${{ steps.query-scores.outputs.DATA_NAME }}" "${{ steps.query-scores.outputs.TARGET_DIR }}" + + - name: Query latest beatmaps + id: query-beatmaps + run: | + beatmaps_data_name=$(curl -s "https://data.ppy.sh/" | grep "osu_files" | tail -1 | awk -F "'" '{print $2}' | sed 's/\.tar\.bz2//g') + + echo "TARGET_DIR=${{ env.GENERATOR_DIR }}/beatmaps" >> "${GITHUB_OUTPUT}" + echo "DATA_NAME=${beatmaps_data_name}" >> "${GITHUB_OUTPUT}" + echo "DATA_PKG=${beatmaps_data_name}.tar.bz2" >> "${GITHUB_OUTPUT}" + + - name: Restore beatmap cache + id: restore-beatmap-cache + uses: maxnowack/local-cache@720e69c948191660a90aa1cf6a42fc4d2dacdf30 # v2 + with: + path: ${{ steps.query-beatmaps.outputs.DATA_PKG }} + key: ${{ steps.query-beatmaps.outputs.DATA_NAME }} + + - name: Download beatmap + if: steps.restore-beatmap-cache.outputs.cache-hit != 'true' + run: | + wget -q -O "${{ steps.query-beatmaps.outputs.DATA_PKG }}" "https://data.ppy.sh/${{ steps.query-beatmaps.outputs.DATA_PKG }}" + + - name: Extract beatmap + run: | + tar -I lbzip2 -xf "${{ steps.query-beatmaps.outputs.DATA_PKG }}" + rm -r "${{ steps.query-beatmaps.outputs.TARGET_DIR }}" + mv "${{ steps.query-beatmaps.outputs.DATA_NAME }}" "${{ steps.query-beatmaps.outputs.TARGET_DIR }}" + + - name: Run + id: run + run: | + # Add the GitHub token. This needs to be done here because it's unique per-job. + sed -i 's/^GH_TOKEN=.*$/GH_TOKEN=${{ github.token }}/' "${{ env.GENERATOR_ENV }}" + + cd "${{ env.GENERATOR_DIR }}" + + docker compose up --build --detach + docker compose logs --follow & + docker compose wait generator + + link=$(docker compose logs --tail 10 generator | grep 'http' | sed -E 's/^.*(http.*)$/\1/') + target=$(cat "${{ env.GENERATOR_ENV }}" | grep -E '^OSU_B=' | cut -d '=' -f2-) + + echo "target=${target}" >> "${GITHUB_OUTPUT}" + echo "sheet=${link}" >> "${GITHUB_OUTPUT}" + + - name: Shutdown + if: ${{ always() }} + run: | + cd "${{ env.GENERATOR_DIR }}" + docker compose down --volumes + rm -rf "${{ env.GENERATOR_DIR }}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6fbb74dfba..d75f09f184 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,7 +88,7 @@ jobs: # Attempt to upload results even if test fails. # https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#always - name: Upload Test Results - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: ${{ always() }} with: name: osu-test-results-${{matrix.os.prettyname}}-${{matrix.threadingMode}} @@ -114,7 +114,7 @@ jobs: dotnet-version: "8.0.x" - name: Install .NET workloads - run: dotnet workload install maui-android + run: dotnet workload install android - name: Compile run: dotnet build -c Debug osu.Android.slnf @@ -133,10 +133,7 @@ jobs: dotnet-version: "8.0.x" - name: Install .NET Workloads - run: dotnet workload install maui-ios - - - name: Select Xcode 16 - run: sudo xcode-select -s /Applications/Xcode_16.app/Contents/Developer + run: dotnet workload install ios --from-rollback-file https://raw.githubusercontent.com/ppy/osu-framework/refs/heads/master/workloads.json - name: Build run: dotnet build -c Debug osu.iOS diff --git a/.github/workflows/diffcalc.yml b/.github/workflows/diffcalc.yml index 9f129a697c..4297a88e89 100644 --- a/.github/workflows/diffcalc.yml +++ b/.github/workflows/diffcalc.yml @@ -103,6 +103,10 @@ permissions: env: EXECUTION_ID: execution-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }} +defaults: + run: + shell: bash -euo pipefail {0} + jobs: check-permissions: name: Check permissions @@ -119,6 +123,20 @@ jobs: done exit 1 + run-diffcalc: + name: Run spreadsheet generator + needs: check-permissions + uses: ./.github/workflows/_diffcalc_processor.yml + with: + # Can't reference env... Why GitHub, WHY? + id: execution-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }} + head-sha: https://github.com/${{ github.repository }}/commit/${{ github.event.pull_request.head.sha || github.sha }} + pr-url: ${{ github.event.issue.pull_request.html_url || '' }} + pr-text: ${{ github.event.comment.body || '' }} + dispatch-inputs: ${{ (github.event.type == 'workflow_dispatch' && toJSON(inputs)) || '' }} + secrets: + DIFFCALC_GOOGLE_CREDENTIALS: ${{ secrets.DIFFCALC_GOOGLE_CREDENTIALS }} + create-comment: name: Create PR comment needs: check-permissions @@ -134,251 +152,43 @@ jobs: *This comment will update on completion* - directory: - name: Prepare directory - needs: check-permissions - runs-on: self-hosted - outputs: - GENERATOR_DIR: ${{ steps.set-outputs.outputs.GENERATOR_DIR }} - GENERATOR_ENV: ${{ steps.set-outputs.outputs.GENERATOR_ENV }} - GOOGLE_CREDS_FILE: ${{ steps.set-outputs.outputs.GOOGLE_CREDS_FILE }} - steps: - - name: Checkout diffcalc-sheet-generator - uses: actions/checkout@v4 - with: - path: ${{ env.EXECUTION_ID }} - repository: 'smoogipoo/diffcalc-sheet-generator' - - - name: Set outputs - id: set-outputs - run: | - echo "GENERATOR_DIR=${{ github.workspace }}/${{ env.EXECUTION_ID }}" >> "${GITHUB_OUTPUT}" - echo "GENERATOR_ENV=${{ github.workspace }}/${{ env.EXECUTION_ID }}/.env" >> "${GITHUB_OUTPUT}" - echo "GOOGLE_CREDS_FILE=${{ github.workspace }}/${{ env.EXECUTION_ID }}/google-credentials.json" >> "${GITHUB_OUTPUT}" - - environment: - name: Setup environment - needs: directory - runs-on: self-hosted - env: - VARS_JSON: ${{ toJSON(vars) }} - steps: - - name: Add base environment - run: | - # Required by diffcalc-sheet-generator - cp '${{ needs.directory.outputs.GENERATOR_DIR }}/.env.sample' "${{ needs.directory.outputs.GENERATOR_ENV }}" - - # Add Google credentials - echo '${{ secrets.DIFFCALC_GOOGLE_CREDENTIALS }}' | base64 -d > "${{ needs.directory.outputs.GOOGLE_CREDS_FILE }}" - - # Add repository variables - echo "${VARS_JSON}" | jq -c '. | to_entries | .[]' | while read -r line; do - opt=$(jq -r '.key' <<< ${line}) - val=$(jq -r '.value' <<< ${line}) - - if [[ "${opt}" =~ ^DIFFCALC_ ]]; then - optNoPrefix=$(echo "${opt}" | cut -d '_' -f2-) - sed -i "s;^${optNoPrefix}=.*$;${optNoPrefix}=${val};" "${{ needs.directory.outputs.GENERATOR_ENV }}" - fi - done - - - name: Add pull-request environment - if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request }} - run: | - sed -i "s;^OSU_B=.*$;OSU_B=${{ github.event.issue.pull_request.html_url }};" "${{ needs.directory.outputs.GENERATOR_ENV }}" - - - name: Add comment environment - if: ${{ github.event_name == 'issue_comment' }} - env: - COMMENT_BODY: ${{ github.event.comment.body }} - run: | - # Add comment environment - echo "$COMMENT_BODY" | sed -r 's/\r$//' | grep -E '^\w+=' | while read -r line; do - opt=$(echo "${line}" | cut -d '=' -f1) - sed -i "s;^${opt}=.*$;${line};" "${{ needs.directory.outputs.GENERATOR_ENV }}" - done - - - name: Add dispatch environment - if: ${{ github.event_name == 'workflow_dispatch' }} - run: | - sed -i 's;^OSU_B=.*$;OSU_B=${{ inputs.osu-b }};' "${{ needs.directory.outputs.GENERATOR_ENV }}" - sed -i 's/^RULESET=.*$/RULESET=${{ inputs.ruleset }}/' "${{ needs.directory.outputs.GENERATOR_ENV }}" - sed -i 's/^GENERATORS=.*$/GENERATORS=${{ inputs.generators }}/' "${{ needs.directory.outputs.GENERATOR_ENV }}" - - if [[ '${{ inputs.osu-a }}' != 'latest' ]]; then - sed -i 's;^OSU_A=.*$;OSU_A=${{ inputs.osu-a }};' "${{ needs.directory.outputs.GENERATOR_ENV }}" - fi - - if [[ '${{ inputs.difficulty-calculator-a }}' != 'latest' ]]; then - sed -i 's;^DIFFICULTY_CALCULATOR_A=.*$;DIFFICULTY_CALCULATOR_A=${{ inputs.difficulty-calculator-a }};' "${{ needs.directory.outputs.GENERATOR_ENV }}" - fi - - if [[ '${{ inputs.difficulty-calculator-b }}' != 'latest' ]]; then - sed -i 's;^DIFFICULTY_CALCULATOR_B=.*$;DIFFICULTY_CALCULATOR_B=${{ inputs.difficulty-calculator-b }};' "${{ needs.directory.outputs.GENERATOR_ENV }}" - fi - - if [[ '${{ inputs.score-processor-a }}' != 'latest' ]]; then - sed -i 's;^SCORE_PROCESSOR_A=.*$;SCORE_PROCESSOR_A=${{ inputs.score-processor-a }};' "${{ needs.directory.outputs.GENERATOR_ENV }}" - fi - - if [[ '${{ inputs.score-processor-b }}' != 'latest' ]]; then - sed -i 's;^SCORE_PROCESSOR_B=.*$;SCORE_PROCESSOR_B=${{ inputs.score-processor-b }};' "${{ needs.directory.outputs.GENERATOR_ENV }}" - fi - - if [[ '${{ inputs.converts }}' == 'true' ]]; then - sed -i 's/^NO_CONVERTS=.*$/NO_CONVERTS=0/' "${{ needs.directory.outputs.GENERATOR_ENV }}" - else - sed -i 's/^NO_CONVERTS=.*$/NO_CONVERTS=1/' "${{ needs.directory.outputs.GENERATOR_ENV }}" - fi - - if [[ '${{ inputs.ranked-only }}' == 'true' ]]; then - sed -i 's/^RANKED_ONLY=.*$/RANKED_ONLY=1/' "${{ needs.directory.outputs.GENERATOR_ENV }}" - else - sed -i 's/^RANKED_ONLY=.*$/RANKED_ONLY=0/' "${{ needs.directory.outputs.GENERATOR_ENV }}" - fi - - scores: - name: Setup scores - needs: [ directory, environment ] - runs-on: self-hosted - steps: - - name: Query latest data - id: query - run: | - ruleset=$(cat ${{ needs.directory.outputs.GENERATOR_ENV }} | grep -E '^RULESET=' | cut -d '=' -f2-) - performance_data_name=$(curl -s "https://data.ppy.sh/" | grep "performance_${ruleset}_top_1000\b" | tail -1 | awk -F "'" '{print $2}' | sed 's/\.tar\.bz2//g') - - echo "TARGET_DIR=${{ needs.directory.outputs.GENERATOR_DIR }}/sql/${ruleset}" >> "${GITHUB_OUTPUT}" - echo "DATA_NAME=${performance_data_name}" >> "${GITHUB_OUTPUT}" - - - name: Restore cache - id: restore-cache - uses: maxnowack/local-cache@720e69c948191660a90aa1cf6a42fc4d2dacdf30 # v2 - with: - path: ${{ steps.query.outputs.DATA_NAME }}.tar.bz2 - key: ${{ steps.query.outputs.DATA_NAME }} - - - name: Download - if: steps.restore-cache.outputs.cache-hit != 'true' - run: | - wget -q -nc "https://data.ppy.sh/${{ steps.query.outputs.DATA_NAME }}.tar.bz2" - - - name: Extract - run: | - tar -I lbzip2 -xf "${{ steps.query.outputs.DATA_NAME }}.tar.bz2" - rm -r "${{ steps.query.outputs.TARGET_DIR }}" - mv "${{ steps.query.outputs.DATA_NAME }}" "${{ steps.query.outputs.TARGET_DIR }}" - - beatmaps: - name: Setup beatmaps - needs: directory - runs-on: self-hosted - steps: - - name: Query latest data - id: query - run: | - beatmaps_data_name=$(curl -s "https://data.ppy.sh/" | grep "osu_files" | tail -1 | awk -F "'" '{print $2}' | sed 's/\.tar\.bz2//g') - - echo "TARGET_DIR=${{ needs.directory.outputs.GENERATOR_DIR }}/beatmaps" >> "${GITHUB_OUTPUT}" - echo "DATA_NAME=${beatmaps_data_name}" >> "${GITHUB_OUTPUT}" - - - name: Restore cache - id: restore-cache - uses: maxnowack/local-cache@720e69c948191660a90aa1cf6a42fc4d2dacdf30 # v2 - with: - path: ${{ steps.query.outputs.DATA_NAME }}.tar.bz2 - key: ${{ steps.query.outputs.DATA_NAME }} - - - name: Download - if: steps.restore-cache.outputs.cache-hit != 'true' - run: | - wget -q -nc "https://data.ppy.sh/${{ steps.query.outputs.DATA_NAME }}.tar.bz2" - - - name: Extract - run: | - tar -I lbzip2 -xf "${{ steps.query.outputs.DATA_NAME }}.tar.bz2" - rm -r "${{ steps.query.outputs.TARGET_DIR }}" - mv "${{ steps.query.outputs.DATA_NAME }}" "${{ steps.query.outputs.TARGET_DIR }}" - - generator: - name: Run generator - needs: [ directory, environment, scores, beatmaps ] - runs-on: self-hosted - timeout-minutes: 720 - outputs: - TARGET: ${{ steps.run.outputs.TARGET }} - SPREADSHEET_LINK: ${{ steps.run.outputs.SPREADSHEET_LINK }} - steps: - - name: Run - id: run - run: | - # Add the GitHub token. This needs to be done here because it's unique per-job. - sed -i 's/^GH_TOKEN=.*$/GH_TOKEN=${{ github.token }}/' "${{ needs.directory.outputs.GENERATOR_ENV }}" - - cd "${{ needs.directory.outputs.GENERATOR_DIR }}" - docker-compose up --build generator - - link=$(docker-compose logs generator -n 10 | grep 'http' | sed -E 's/^.*(http.*)$/\1/') - target=$(cat "${{ needs.directory.outputs.GENERATOR_ENV }}" | grep -E '^OSU_B=' | cut -d '=' -f2-) - - echo "TARGET=${target}" >> "${GITHUB_OUTPUT}" - echo "SPREADSHEET_LINK=${link}" >> "${GITHUB_OUTPUT}" - - - name: Shutdown - if: ${{ always() }} - run: | - cd "${{ needs.directory.outputs.GENERATOR_DIR }}" - docker-compose down -v - output-cli: - name: Output info - needs: generator + name: Info + needs: run-diffcalc runs-on: ubuntu-latest steps: - name: Output info run: | - echo "Target: ${{ needs.generator.outputs.TARGET }}" - echo "Spreadsheet: ${{ needs.generator.outputs.SPREADSHEET_LINK }}" - - cleanup: - name: Cleanup - needs: [ directory, generator ] - if: ${{ always() && needs.directory.result == 'success' }} - runs-on: self-hosted - steps: - - name: Cleanup - run: | - rm -rf "${{ needs.directory.outputs.GENERATOR_DIR }}" + echo "Target: ${{ needs.run-diffcalc.outputs.target }}" + echo "Spreadsheet: ${{ needs.run-diffcalc.outputs.sheet }}" update-comment: name: Update PR comment - needs: [ create-comment, generator ] + needs: [ create-comment, run-diffcalc ] runs-on: ubuntu-latest if: ${{ always() && needs.create-comment.result == 'success' }} steps: - name: Update comment on success - if: ${{ needs.generator.result == 'success' }} + if: ${{ needs.run-diffcalc.result == 'success' }} uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0 with: comment_tag: ${{ env.EXECUTION_ID }} - mode: upsert - create_if_not_exists: false + mode: recreate message: | - Target: ${{ needs.generator.outputs.TARGET }} - Spreadsheet: ${{ needs.generator.outputs.SPREADSHEET_LINK }} + Target: ${{ needs.run-diffcalc.outputs.target }} + Spreadsheet: ${{ needs.run-diffcalc.outputs.sheet }} - name: Update comment on failure - if: ${{ needs.generator.result == 'failure' }} + if: ${{ needs.run-diffcalc.result == 'failure' }} uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0 with: comment_tag: ${{ env.EXECUTION_ID }} - mode: upsert - create_if_not_exists: false + mode: recreate message: | Difficulty calculation failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - name: Update comment on cancellation - if: ${{ needs.generator.result == 'cancelled' }} + if: ${{ needs.run-diffcalc.result == 'cancelled' }} uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0 with: comment_tag: ${{ env.EXECUTION_ID }} diff --git a/.github/workflows/report-nunit.yml b/.github/workflows/report-nunit.yml index c44f46d70a..14f0208fc8 100644 --- a/.github/workflows/report-nunit.yml +++ b/.github/workflows/report-nunit.yml @@ -5,33 +5,40 @@ name: Annotate CI run with test results on: workflow_run: - workflows: ["Continuous Integration"] + workflows: [ "Continuous Integration" ] types: - completed -permissions: {} + +permissions: + contents: read + actions: read + checks: write + jobs: annotate: - permissions: - checks: write # to create checks (dorny/test-reporter) - name: Annotate CI run with test results runs-on: ubuntu-latest if: ${{ github.event.workflow_run.conclusion != 'cancelled' }} - strategy: - fail-fast: false - matrix: - os: - - { prettyname: Windows } - - { prettyname: macOS } - - { prettyname: Linux } - threadingMode: ['SingleThread', 'MultiThreaded'] timeout-minutes: 5 steps: + - name: Checkout + uses: actions/checkout@v4 + with: + repository: ${{ github.event.workflow_run.repository.full_name }} + ref: ${{ github.event.workflow_run.head_sha }} + + - name: Download results + uses: actions/download-artifact@v4 + with: + pattern: osu-test-results-* + merge-multiple: true + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + - name: Annotate CI run with test results uses: dorny/test-reporter@v1.8.0 with: - artifact: osu-test-results-${{matrix.os.prettyname}}-${{matrix.threadingMode}} - name: Test Results (${{matrix.os.prettyname}}, ${{matrix.threadingMode}}) + name: Results path: "*.trx" reporter: dotnet-trx list-suites: 'failed' diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 5b7a98f4ba..0793dcc76c 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,5 +1,6 @@ { "recommendations": [ - "ms-dotnettools.csharp" + "editorconfig.editorconfig", + "ms-dotnettools.csdevkit" ] } diff --git a/README.md b/README.md index cb722e5df3..6043497181 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Please make sure you have the following prerequisites: - A desktop platform with the [.NET 8.0 SDK](https://dotnet.microsoft.com/download) installed. -When working with the codebase, we recommend using an IDE with intelligent code completion and syntax highlighting, such as the latest version of [Visual Studio](https://visualstudio.microsoft.com/vs/), [JetBrains Rider](https://www.jetbrains.com/rider/), or [Visual Studio Code](https://code.visualstudio.com/) with the [EditorConfig](https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig) and [C#](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csharp) plugin installed. +When working with the codebase, we recommend using an IDE with intelligent code completion and syntax highlighting, such as the latest version of [Visual Studio](https://visualstudio.microsoft.com/vs/), [JetBrains Rider](https://www.jetbrains.com/rider/), or [Visual Studio Code](https://code.visualstudio.com/) with the [EditorConfig](https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig) and [C# Dev Kit](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csdevkit) plugin installed. ### Downloading the source code diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj index 7d43eb2b05..f77cda1533 100644 --- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj @@ -9,7 +9,7 @@ false - + diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj index 7dc8a1336b..47cabaddb1 100644 --- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj @@ -9,7 +9,7 @@ false - + diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj index 9c4c8217f0..a7d62291d0 100644 --- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj @@ -9,7 +9,7 @@ false - + diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj index 7dc8a1336b..47cabaddb1 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj @@ -9,7 +9,7 @@ false - + diff --git a/osu.Android.props b/osu.Android.props index 6b42258b49..4699beeac0 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -10,7 +10,7 @@ true - + Release Difference / ms // release_threshold if (isOverlapping) - holdAddition = 1 / (1 + Math.Exp(0.27 * (release_threshold - closestEndTime))); + holdAddition = DifficultyCalculationUtils.Logistic(x: closestEndTime, multiplier: 0.27, midpointOffset: release_threshold); // Decay and increase individualStrains in own column individualStrains[column] = applyDecay(individualStrains[column], startTime - startTimes[column], individual_decay_base); diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditBodyPiece.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditBodyPiece.cs index 6a12ec5088..5cfcf00b33 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditBodyPiece.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditBodyPiece.cs @@ -3,21 +3,39 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; -using osu.Game.Rulesets.Mania.Skinning.Default; namespace osu.Game.Rulesets.Mania.Edit.Blueprints.Components { - public partial class EditBodyPiece : DefaultBodyPiece + public partial class EditBodyPiece : CompositeDrawable { + private readonly Container border; + + public EditBodyPiece() + { + InternalChildren = new Drawable[] + { + border = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + BorderThickness = 3, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + }, + }, + }; + } + [BackgroundDependencyLoader] private void load(OsuColour colours) { - AccentColour.Value = colours.Yellow; - - Background.Alpha = 0.5f; + border.BorderColour = colours.YellowDarker; } - - protected override Drawable CreateForeground() => base.CreateForeground().With(d => d.Alpha = 0); } } diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditHoldNoteEndPiece.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditHoldNoteEndPiece.cs index 0aa72c28b8..d4b61b4661 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditHoldNoteEndPiece.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditHoldNoteEndPiece.cs @@ -4,6 +4,7 @@ using System; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; using osu.Game.Graphics; @@ -26,10 +27,11 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints.Components { Height = DefaultNotePiece.NOTE_HEIGHT; - CornerRadius = 5; - Masking = true; - - InternalChild = new DefaultNotePiece(); + InternalChild = new EditNotePiece + { + RelativeSizeAxes = Axes.Both, + Height = 1, + }; } protected override void LoadComplete() @@ -60,19 +62,23 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints.Components { base.OnDrag(e); Dragging?.Invoke(e.ScreenSpaceMousePosition); + updateState(); } protected override void OnDragEnd(DragEndEvent e) { base.OnDragEnd(e); DragEnded?.Invoke(); + updateState(); } private void updateState() { + InternalChild.Colour = Colour4.White; + var colour = colours.Yellow; - if (IsHovered) + if (IsHovered || IsDragged) colour = colour.Lighten(1); Colour = colour; diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditNotePiece.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditNotePiece.cs index 48dde29a9f..f68004db28 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditNotePiece.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditNotePiece.cs @@ -2,28 +2,63 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; -using osu.Game.Rulesets.Mania.Skinning.Default; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK; namespace osu.Game.Rulesets.Mania.Edit.Blueprints.Components { public partial class EditNotePiece : CompositeDrawable { + private readonly Container border; + private readonly Box box; + + [Resolved] + private Column? column { get; set; } + public EditNotePiece() { - Height = DefaultNotePiece.NOTE_HEIGHT; - - CornerRadius = 5; - Masking = true; - - InternalChild = new DefaultNotePiece(); + InternalChildren = new Drawable[] + { + border = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + BorderThickness = 3, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + }, + }, + box = new Box + { + RelativeSizeAxes = Axes.X, + Height = 3, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + }, + }; } [BackgroundDependencyLoader] private void load(OsuColour colours) { - Colour = colours.Yellow; + border.BorderColour = colours.YellowDark; + box.Colour = colours.YellowLight; + } + + protected override void Update() + { + base.Update(); + + if (column != null) + Scale = new Vector2(1, column.ScrollingInfo.Direction.Value == ScrollingDirection.Down ? 1 : -1); } } } diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs index 991b7f476c..13cfc5f691 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs @@ -4,8 +4,10 @@ using System; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Framework.Utils; +using osu.Game.Graphics; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Edit.Blueprints.Components; using osu.Game.Rulesets.Mania.Objects; @@ -17,9 +19,9 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints { public partial class HoldNotePlacementBlueprint : ManiaPlacementBlueprint { - private readonly EditBodyPiece bodyPiece; - private readonly EditNotePiece headPiece; - private readonly EditNotePiece tailPiece; + private EditBodyPiece bodyPiece = null!; + private Circle headPiece = null!; + private Circle tailPiece = null!; [Resolved] private IScrollingInfo scrollingInfo { get; set; } = null!; @@ -28,14 +30,29 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints public HoldNotePlacementBlueprint() : base(new HoldNote()) + { + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) { RelativeSizeAxes = Axes.Both; InternalChildren = new Drawable[] { bodyPiece = new EditBodyPiece { Origin = Anchor.TopCentre }, - headPiece = new EditNotePiece { Origin = Anchor.Centre }, - tailPiece = new EditNotePiece { Origin = Anchor.Centre } + headPiece = new Circle + { + Origin = Anchor.Centre, + Colour = colours.Yellow, + Height = 10 + }, + tailPiece = new Circle + { + Origin = Anchor.Centre, + Colour = colours.Yellow, + Height = 10 + }, }; } diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs index b8e6aa26a0..915706c044 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs @@ -2,14 +2,14 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; -using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Edit.Blueprints.Components; using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Screens.Edit; using osuTK; @@ -17,9 +17,6 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints { public partial class HoldNoteSelectionBlueprint : ManiaSelectionBlueprint { - [Resolved] - private OsuColour colours { get; set; } = null!; - [Resolved] private IEditorChangeHandler? changeHandler { get; set; } @@ -29,9 +26,12 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints [Resolved] private IPositionSnapProvider? positionSnapProvider { get; set; } + private EditBodyPiece body = null!; private EditHoldNoteEndPiece head = null!; private EditHoldNoteEndPiece tail = null!; + protected new DrawableHoldNote DrawableObject => (DrawableHoldNote)base.DrawableObject; + public HoldNoteSelectionBlueprint(HoldNote hold) : base(hold) { @@ -42,9 +42,17 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints { InternalChildren = new Drawable[] { + body = new EditBodyPiece + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + }, head = new EditHoldNoteEndPiece { RelativeSizeAxes = Axes.X, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, DragStarted = () => changeHandler?.BeginChange(), Dragging = pos => { @@ -64,6 +72,8 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints tail = new EditHoldNoteEndPiece { RelativeSizeAxes = Axes.X, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, DragStarted = () => changeHandler?.BeginChange(), Dragging = pos => { @@ -79,19 +89,6 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints }, DragEnded = () => changeHandler?.EndChange(), }, - new Container - { - RelativeSizeAxes = Axes.Both, - Masking = true, - BorderThickness = 1, - BorderColour = colours.Yellow, - Child = new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - AlwaysPresent = true, - } - } }; } @@ -99,11 +96,23 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints { base.Update(); + head.Height = DrawableObject.Head.DrawHeight; head.Y = HitObjectContainer.PositionAtTime(HitObject.Head.StartTime, HitObject.StartTime); + tail.Height = DrawableObject.Tail.DrawHeight; tail.Y = HitObjectContainer.PositionAtTime(HitObject.Tail.StartTime, HitObject.StartTime); Height = HitObjectContainer.LengthAtTime(HitObject.StartTime, HitObject.EndTime) + tail.DrawHeight; } + protected override void OnDirectionChanged(ValueChangedEvent direction) + { + Origin = direction.NewValue == ScrollingDirection.Down ? Anchor.BottomCentre : Anchor.TopCentre; + + foreach (var child in InternalChildren) + child.Anchor = Origin; + + head.Scale = tail.Scale = body.Scale = new Vector2(1, direction.NewValue == ScrollingDirection.Down ? 1 : -1); + } + public override Quad SelectionQuad => ScreenSpaceDrawQuad; public override Vector2 ScreenSpaceSelectionPoint => head.ScreenSpaceDrawQuad.Centre; diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs index c645ddd98d..4bb9d5f5c1 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs @@ -37,16 +37,10 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints protected override void LoadComplete() { base.LoadComplete(); - directionBindable.BindValueChanged(onDirectionChanged, true); + directionBindable.BindValueChanged(OnDirectionChanged, true); } - private void onDirectionChanged(ValueChangedEvent direction) - { - var anchor = direction.NewValue == ScrollingDirection.Up ? Anchor.TopCentre : Anchor.BottomCentre; - Anchor = Origin = anchor; - foreach (var child in InternalChildren) - child.Anchor = child.Origin = anchor; - } + protected abstract void OnDirectionChanged(ValueChangedEvent direction); protected override void Update() { diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/NotePlacementBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/NotePlacementBlueprint.cs index b3ec3ef3e4..422215db57 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/NotePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/NotePlacementBlueprint.cs @@ -1,10 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; +using osu.Game.Graphics; using osu.Game.Rulesets.Edit; -using osu.Game.Rulesets.Mania.Edit.Blueprints.Components; using osu.Game.Rulesets.Mania.Objects; using osuTK.Input; @@ -12,14 +14,25 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints { public partial class NotePlacementBlueprint : ManiaPlacementBlueprint { - private readonly EditNotePiece piece; + private Circle piece = null!; public NotePlacementBlueprint() : base(new Note()) { - RelativeSizeAxes = Axes.Both; + } - InternalChild = piece = new EditNotePiece { Origin = Anchor.Centre }; + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + RelativeSizeAxes = Axes.Both; + Masking = true; + + InternalChild = piece = new Circle + { + Origin = Anchor.Centre, + Colour = colours.Yellow, + Height = 10 + }; } public override void UpdateTimeAndPosition(SnapResult result) diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/NoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/NoteSelectionBlueprint.cs index 01c7bd502a..3476f91568 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/NoteSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/NoteSelectionBlueprint.cs @@ -1,18 +1,42 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Rulesets.Mania.Edit.Blueprints.Components; using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK; namespace osu.Game.Rulesets.Mania.Edit.Blueprints { public partial class NoteSelectionBlueprint : ManiaSelectionBlueprint { + private readonly EditNotePiece notePiece; + public NoteSelectionBlueprint(Note note) : base(note) { - AddInternal(new EditNotePiece { RelativeSizeAxes = Axes.X }); + Anchor = Anchor.BottomCentre; + Origin = Anchor.BottomCentre; + AddInternal(notePiece = new EditNotePiece + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + }); + } + + protected override void Update() + { + base.Update(); + + notePiece.Height = DrawableObject.DrawHeight; + } + + protected override void OnDirectionChanged(ValueChangedEvent direction) + { + notePiece.Scale = new Vector2(1, direction.NewValue == ScrollingDirection.Down ? 1 : -1); } } } diff --git a/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditorRuleset.cs b/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditorRuleset.cs index 4c4cf519ce..181bc7341c 100644 --- a/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditorRuleset.cs +++ b/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditorRuleset.cs @@ -44,7 +44,7 @@ namespace osu.Game.Rulesets.Mania.Edit protected override void Update() { - TargetTimeRange = TimelineTimeRange == null || ShowSpeedChanges.Value ? ComputeScrollTime(Config.Get(ManiaRulesetSetting.ScrollSpeed)) : TimelineTimeRange.Value; + TargetTimeRange = TimelineTimeRange == null || ShowSpeedChanges.Value ? ComputeScrollTime(Config.Get(ManiaRulesetSetting.ScrollSpeed)) : TimelineTimeRange.Value; base.Update(); } } diff --git a/osu.Game.Rulesets.Mania/Edit/EditorColumn.cs b/osu.Game.Rulesets.Mania/Edit/EditorColumn.cs new file mode 100644 index 0000000000..11d1848173 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Edit/EditorColumn.cs @@ -0,0 +1,45 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.Objects.Drawables; + +namespace osu.Game.Rulesets.Mania.Edit +{ + public partial class EditorColumn : Column + { + public EditorColumn(int index, bool isSpecial) + : base(index, isSpecial) + { + } + + protected override void OnNewDrawableHitObject(DrawableHitObject drawableHitObject) + { + base.OnNewDrawableHitObject(drawableHitObject); + drawableHitObject.ApplyCustomUpdateState += (dho, state) => + { + switch (dho) + { + // hold note heads are exempt from what follows due to the "freezing" mechanic + // which already ensures they'll never fade away on their own. + case DrawableHoldNoteHead: + break; + + // mania features instantaneous hitobject fade-outs. + // this means that without manual intervention stopping the clock at the precise time of hitting the object + // means the object will fade out. + // this is anti-user in editor contexts, as the user is expecting to continue the see the note on the receptor line. + // therefore, apply a crude workaround to prevent it from going away. + default: + { + if (state == ArmedState.Hit) + dho.FadeTo(1).Delay(1).FadeOut().Expire(); + break; + } + } + }; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Edit/EditorStage.cs b/osu.Game.Rulesets.Mania/Edit/EditorStage.cs new file mode 100644 index 0000000000..c5f93f6182 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Edit/EditorStage.cs @@ -0,0 +1,18 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.UI; + +namespace osu.Game.Rulesets.Mania.Edit +{ + public partial class EditorStage : Stage + { + public EditorStage(int firstColumnIndex, StageDefinition definition, ref ManiaAction columnStartAction) + : base(firstColumnIndex, definition, ref columnStartAction) + { + } + + protected override Column CreateColumn(int index, bool isSpecial) => new EditorColumn(index, isSpecial); + } +} diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaEditorPlayfield.cs b/osu.Game.Rulesets.Mania/Edit/ManiaEditorPlayfield.cs index 77e372d1d6..2dc2b8ae48 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaEditorPlayfield.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaEditorPlayfield.cs @@ -13,5 +13,8 @@ namespace osu.Game.Rulesets.Mania.Edit : base(stages) { } + + protected override Stage CreateStage(int firstColumnIndex, StageDefinition stageDefinition, ref ManiaAction columnAction) + => new EditorStage(firstColumnIndex, stageDefinition, ref columnAction); } } diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs b/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs index e3b4fa2fb7..926a4b2736 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs @@ -92,7 +92,7 @@ namespace osu.Game.Rulesets.Mania.Edit base.Update(); if (screenWithTimeline?.TimelineArea.Timeline != null) - drawableRuleset.TimelineTimeRange = EditorClock.TrackLength / screenWithTimeline.TimelineArea.Timeline.CurrentZoom / 2; + drawableRuleset.TimelineTimeRange = EditorClock.TrackLength / screenWithTimeline.TimelineArea.Timeline.CurrentZoom.Value / 2; } } } diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs b/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs index 7e0991a4d4..74e616ac3f 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs @@ -54,9 +54,8 @@ namespace osu.Game.Rulesets.Mania.Edit int firstColumn = flipOverOrigin ? 0 : selectedObjects.Min(ho => ho.Column); int lastColumn = flipOverOrigin ? (int)EditorBeatmap.BeatmapInfo.Difficulty.CircleSize - 1 : selectedObjects.Max(ho => ho.Column); - EditorBeatmap.PerformOnSelection(hitObject => + performOnSelection(maniaObject => { - var maniaObject = (ManiaHitObject)hitObject; maniaPlayfield.Remove(maniaObject); maniaObject.Column = firstColumn + (lastColumn - maniaObject.Column); maniaPlayfield.Add(maniaObject); @@ -71,7 +70,7 @@ namespace osu.Game.Rulesets.Mania.Edit double selectionStartTime = selectedObjects.Min(ho => ho.StartTime); double selectionEndTime = selectedObjects.Max(ho => ho.GetEndTime()); - EditorBeatmap.PerformOnSelection(hitObject => + performOnSelection(hitObject => { hitObject.StartTime = selectionStartTime + (selectionEndTime - hitObject.GetEndTime()); }); @@ -117,14 +116,21 @@ namespace osu.Game.Rulesets.Mania.Edit columnDelta = Math.Clamp(columnDelta, -minColumn, maniaPlayfield.TotalColumns - 1 - maxColumn); - EditorBeatmap.PerformOnSelection(h => + performOnSelection(h => { maniaPlayfield.Remove(h); - ((ManiaHitObject)h).Column += columnDelta; + h.Column += columnDelta; maniaPlayfield.Add(h); }); + } - // `HitObjectUsageEventBuffer`'s usage transferal flows and the playfield's `SetKeepAlive()` functionality do not combine well with this operation's usage pattern, + private void performOnSelection(Action action) + { + var selectedObjects = EditorBeatmap.SelectedHitObjects.OfType().ToArray(); + + EditorBeatmap.PerformOnSelection(h => action.Invoke((ManiaHitObject)h)); + + // `HitObjectUsageEventBuffer`'s usage transferal flows and the playfield's `SetKeepAlive()` functionality do not combine well with mania's usage patterns, // leading to selections being sometimes partially dropped if some of the objects being moved are off screen // (check blame for detailed explanation). // thus, ensure that selection is preserved manually. diff --git a/osu.Game.Rulesets.Mania/Edit/Setup/ManiaDifficultySection.cs b/osu.Game.Rulesets.Mania/Edit/Setup/ManiaDifficultySection.cs index 7168504309..a23988362a 100644 --- a/osu.Game.Rulesets.Mania/Edit/Setup/ManiaDifficultySection.cs +++ b/osu.Game.Rulesets.Mania/Edit/Setup/ManiaDifficultySection.cs @@ -19,12 +19,12 @@ namespace osu.Game.Rulesets.Mania.Edit.Setup { public override LocalisableString Title => EditorSetupStrings.DifficultyHeader; - private LabelledSliderBar keyCountSlider { get; set; } = null!; - private LabelledSwitchButton specialStyle { get; set; } = null!; - private LabelledSliderBar healthDrainSlider { get; set; } = null!; - private LabelledSliderBar overallDifficultySlider { get; set; } = null!; - private LabelledSliderBar baseVelocitySlider { get; set; } = null!; - private LabelledSliderBar tickRateSlider { get; set; } = null!; + private FormSliderBar keyCountSlider { get; set; } = null!; + private FormCheckBox specialStyle { get; set; } = null!; + private FormSliderBar healthDrainSlider { get; set; } = null!; + private FormSliderBar overallDifficultySlider { get; set; } = null!; + private FormSliderBar baseVelocitySlider { get; set; } = null!; + private FormSliderBar tickRateSlider { get; set; } = null!; [Resolved] private Editor? editor { get; set; } @@ -37,77 +37,81 @@ namespace osu.Game.Rulesets.Mania.Edit.Setup { Children = new Drawable[] { - keyCountSlider = new LabelledSliderBar + keyCountSlider = new FormSliderBar { - Label = BeatmapsetsStrings.ShowStatsCsMania, - FixedLabelWidth = LABEL_WIDTH, - Description = "The number of columns in the beatmap", + Caption = BeatmapsetsStrings.ShowStatsCsMania, + HintText = "The number of columns in the beatmap", Current = new BindableFloat(Beatmap.Difficulty.CircleSize) { Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, MinValue = 0, MaxValue = 10, Precision = 1, - } + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, }, - specialStyle = new LabelledSwitchButton + specialStyle = new FormCheckBox { - Label = "Use special (N+1) style", - FixedLabelWidth = LABEL_WIDTH, - Description = "Changes one column to act as a classic \"scratch\" or \"special\" column, which can be moved around by the user's skin (to the left/right/centre). Generally used in 6K (5+1) or 8K (7+1) configurations.", + Caption = "Use special (N+1) style", + HintText = "Changes one column to act as a classic \"scratch\" or \"special\" column, which can be moved around by the user's skin (to the left/right/centre). Generally used in 6K (5+1) or 8K (7+1) configurations.", Current = { Value = Beatmap.BeatmapInfo.SpecialStyle } }, - healthDrainSlider = new LabelledSliderBar + healthDrainSlider = new FormSliderBar { - Label = BeatmapsetsStrings.ShowStatsDrain, - FixedLabelWidth = LABEL_WIDTH, - Description = EditorSetupStrings.DrainRateDescription, + Caption = BeatmapsetsStrings.ShowStatsDrain, + HintText = EditorSetupStrings.DrainRateDescription, Current = new BindableFloat(Beatmap.Difficulty.DrainRate) { Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, MinValue = 0, MaxValue = 10, Precision = 0.1f, - } + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, }, - overallDifficultySlider = new LabelledSliderBar + overallDifficultySlider = new FormSliderBar { - Label = BeatmapsetsStrings.ShowStatsAccuracy, - FixedLabelWidth = LABEL_WIDTH, - Description = EditorSetupStrings.OverallDifficultyDescription, + Caption = BeatmapsetsStrings.ShowStatsAccuracy, + HintText = EditorSetupStrings.OverallDifficultyDescription, Current = new BindableFloat(Beatmap.Difficulty.OverallDifficulty) { Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, MinValue = 0, MaxValue = 10, Precision = 0.1f, - } + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, }, - baseVelocitySlider = new LabelledSliderBar + baseVelocitySlider = new FormSliderBar { - Label = EditorSetupStrings.BaseVelocity, - FixedLabelWidth = LABEL_WIDTH, - Description = EditorSetupStrings.BaseVelocityDescription, + Caption = EditorSetupStrings.BaseVelocity, + HintText = EditorSetupStrings.BaseVelocityDescription, Current = new BindableDouble(Beatmap.Difficulty.SliderMultiplier) { Default = 1.4, MinValue = 0.4, MaxValue = 3.6, Precision = 0.01f, - } + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, }, - tickRateSlider = new LabelledSliderBar + tickRateSlider = new FormSliderBar { - Label = EditorSetupStrings.TickRate, - FixedLabelWidth = LABEL_WIDTH, - Description = EditorSetupStrings.TickRateDescription, + Caption = EditorSetupStrings.TickRate, + HintText = EditorSetupStrings.TickRateDescription, Current = new BindableDouble(Beatmap.Difficulty.SliderTickRate) { Default = 1, MinValue = 1, MaxValue = 4, Precision = 1, - } + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, }, }; diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs index c01fa508fe..cdc7b0a951 100644 --- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs @@ -419,9 +419,12 @@ namespace osu.Game.Rulesets.Mania return new ManiaFilterCriteria(); } - public override IEnumerable CreateEditorSetupSections() => + public override IEnumerable CreateEditorSetupSections() => [ + new MetadataSection(), new ManiaDifficultySection(), + new ResourcesSection(), + new DesignSection(), ]; public int GetKeyCount(IBeatmapInfo beatmapInfo, IReadOnlyList? mods = null) diff --git a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs index 30eca0636c..17add32513 100644 --- a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs +++ b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs @@ -33,11 +33,11 @@ namespace osu.Game.Rulesets.Mania LabelText = RulesetSettingsStrings.ScrollingDirection, Current = config.GetBindable(ManiaRulesetSetting.ScrollDirection) }, - new SettingsSlider + new SettingsSlider { LabelText = RulesetSettingsStrings.ScrollSpeed, - Current = config.GetBindable(ManiaRulesetSetting.ScrollSpeed), - KeyboardStep = 5 + Current = config.GetBindable(ManiaRulesetSetting.ScrollSpeed), + KeyboardStep = 1 }, new SettingsCheckbox { @@ -48,7 +48,7 @@ namespace osu.Game.Rulesets.Mania }; } - private partial class ManiaScrollSlider : RoundedSliderBar + private partial class ManiaScrollSlider : RoundedSliderBar { public override LocalisableString TooltipText => RulesetSettingsStrings.ScrollSpeedTooltip((int)DrawableManiaRuleset.ComputeScrollTime(Current.Value), Current.Value); } diff --git a/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs b/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs index 0444394d87..dfd6ed6dd2 100644 --- a/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs +++ b/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs @@ -9,6 +9,7 @@ using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; namespace osu.Game.Rulesets.Mania.Scoring { @@ -58,6 +59,24 @@ namespace osu.Game.Rulesets.Mania.Scoring return GetBaseScoreForResult(result); } + public override ScoreRank RankFromScore(double accuracy, IReadOnlyDictionary results) + { + ScoreRank rank = base.RankFromScore(accuracy, results); + + if (rank != ScoreRank.S) + return rank; + + // SS is expected as long as all hitobjects have been hit with either a GREAT or PERFECT result. + + bool anyImperfect = + results.GetValueOrDefault(HitResult.Good) > 0 + || results.GetValueOrDefault(HitResult.Ok) > 0 + || results.GetValueOrDefault(HitResult.Meh) > 0 + || results.GetValueOrDefault(HitResult.Miss) > 0; + + return anyImperfect ? rank : ScoreRank.X; + } + private class JudgementOrderComparer : IComparer { public static readonly JudgementOrderComparer DEFAULT = new JudgementOrderComparer(); diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageBackground.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageBackground.cs index 758c8dd347..71618a4bc3 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageBackground.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageBackground.cs @@ -54,7 +54,8 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy }, columnBackgrounds = new ColumnFlow(stageDefinition) { - RelativeSizeAxes = Axes.Y + RelativeSizeAxes = Axes.Y, + Masking = false, }, new HitTargetInsetContainer { @@ -126,8 +127,8 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy }, new Container { + X = isLastColumn ? -0.16f : 0, Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, RelativeSizeAxes = Axes.Y, Width = rightLineWidth, Scale = new Vector2(0.740f, 1), diff --git a/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs b/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs index f444448797..5614a13a48 100644 --- a/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs +++ b/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs @@ -28,6 +28,12 @@ namespace osu.Game.Rulesets.Mania.UI private readonly FillFlowContainer> columns; private readonly StageDefinition stageDefinition; + public new bool Masking + { + get => base.Masking; + set => base.Masking = value; + } + public ColumnFlow(StageDefinition stageDefinition) { this.stageDefinition = stageDefinition; diff --git a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs index aed53e157a..d173ae4143 100644 --- a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs @@ -56,7 +56,7 @@ namespace osu.Game.Rulesets.Mania.UI protected new ManiaRulesetConfigManager Config => (ManiaRulesetConfigManager)base.Config; private readonly Bindable configDirection = new Bindable(); - private readonly BindableInt configScrollSpeed = new BindableInt(); + private readonly BindableDouble configScrollSpeed = new BindableDouble(); private double currentTimeRange; protected double TargetTimeRange; @@ -160,7 +160,7 @@ namespace osu.Game.Rulesets.Mania.UI /// /// The scroll speed. /// The scroll time. - public static double ComputeScrollTime(int scrollSpeed) => MAX_TIME_RANGE / scrollSpeed; + public static double ComputeScrollTime(double scrollSpeed) => MAX_TIME_RANGE / scrollSpeed; public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new ManiaPlayfieldAdjustmentContainer(); diff --git a/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs b/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs index 1f388144bd..a4ebb3347a 100644 --- a/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs +++ b/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using System; using System.Collections.Generic; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics.Primitives; using osu.Game.Rulesets.Mania.Beatmaps; @@ -71,7 +72,7 @@ namespace osu.Game.Rulesets.Mania.UI for (int i = 0; i < stageDefinitions.Count; i++) { - var newStage = new Stage(firstColumnIndex, stageDefinitions[i], ref columnAction); + var newStage = CreateStage(firstColumnIndex, stageDefinitions[i], ref columnAction); playfieldGrid.Content[0][i] = newStage; @@ -82,6 +83,9 @@ namespace osu.Game.Rulesets.Mania.UI } } + [Pure] + protected virtual Stage CreateStage(int firstColumnIndex, StageDefinition stageDefinition, ref ManiaAction columnAction) => new Stage(firstColumnIndex, stageDefinition, ref columnAction); + public override void Add(HitObject hitObject) => getStageByColumn(((ManiaHitObject)hitObject).Column).Add(hitObject); public override bool Remove(HitObject hitObject) => getStageByColumn(((ManiaHitObject)hitObject).Column).Remove(hitObject); diff --git a/osu.Game.Rulesets.Mania/UI/Stage.cs b/osu.Game.Rulesets.Mania/UI/Stage.cs index 86f2243561..9fb77a4995 100644 --- a/osu.Game.Rulesets.Mania/UI/Stage.cs +++ b/osu.Game.Rulesets.Mania/UI/Stage.cs @@ -3,6 +3,7 @@ using System; using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; @@ -134,12 +135,14 @@ namespace osu.Game.Rulesets.Mania.UI { bool isSpecial = definition.IsSpecialColumn(i); - var column = new Column(firstColumnIndex + i, isSpecial) + var action = columnStartAction; + columnStartAction++; + var column = CreateColumn(firstColumnIndex + i, isSpecial).With(c => { - RelativeSizeAxes = Axes.Both, - Width = 1, - Action = { Value = columnStartAction++ } - }; + c.RelativeSizeAxes = Axes.Both; + c.Width = 1; + c.Action.Value = action; + }); topLevelContainer.Add(column.TopLevelContainer.CreateProxy()); columnBackgrounds.Add(column.BackgroundContainer.CreateProxy()); @@ -154,6 +157,9 @@ namespace osu.Game.Rulesets.Mania.UI RegisterPool(50, 200); } + [Pure] + protected virtual Column CreateColumn(int index, bool isSpecial) => new Column(index, isSpecial); + [BackgroundDependencyLoader] private void load(ISkinSource skin) { diff --git a/osu.Game.Rulesets.Osu.Tests.iOS/osu.Game.Rulesets.Osu.Tests.iOS.csproj b/osu.Game.Rulesets.Osu.Tests.iOS/osu.Game.Rulesets.Osu.Tests.iOS.csproj index cc0233d7fd..cd4fbcc00e 100644 --- a/osu.Game.Rulesets.Osu.Tests.iOS/osu.Game.Rulesets.Osu.Tests.iOS.csproj +++ b/osu.Game.Rulesets.Osu.Tests.iOS/osu.Game.Rulesets.Osu.Tests.iOS.csproj @@ -17,4 +17,8 @@ + + + + diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs index b70ecfbba8..fb109ba6f9 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs @@ -9,6 +9,7 @@ using osu.Framework.Utils; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Osu.Edit; using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles; +using osu.Game.Rulesets.Osu.Objects; using osu.Game.Screens.Edit.Compose.Components; using osu.Game.Tests.Visual; using osu.Game.Utils; @@ -129,6 +130,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor private void gridActive(bool active) where T : PositionSnapGrid { + AddAssert($"grid type is {typeof(T).Name}", () => this.ChildrenOfType().Any()); AddStep("choose placement tool", () => InputManager.Key(Key.Number2)); AddStep("move cursor to spacing + (1, 1)", () => { @@ -161,7 +163,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor return grid switch { RectangularPositionSnapGrid rectangular => rectangular.StartPosition.Value + GeometryUtils.RotateVector(rectangular.Spacing.Value, -rectangular.GridLineRotation.Value), - TriangularPositionSnapGrid triangular => triangular.StartPosition.Value + GeometryUtils.RotateVector(new Vector2(triangular.Spacing.Value / 2, triangular.Spacing.Value / 2 * MathF.Sqrt(3)), -triangular.GridLineRotation.Value), + TriangularPositionSnapGrid triangular => triangular.StartPosition.Value + GeometryUtils.RotateVector( + new Vector2(triangular.Spacing.Value / 2, triangular.Spacing.Value / 2 * MathF.Sqrt(3)), -triangular.GridLineRotation.Value), CircularPositionSnapGrid circular => circular.StartPosition.Value + GeometryUtils.RotateVector(new Vector2(circular.Spacing.Value, 0), -45), _ => Vector2.Zero }; @@ -170,7 +173,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor [Test] public void TestGridSizeToggling() { - AddStep("enable rectangular grid", () => InputManager.Key(Key.T)); + AddStep("enable rectangular grid", () => InputManager.Key(Key.Y)); AddUntilStep("rectangular grid visible", () => this.ChildrenOfType().Any()); gridSizeIs(4); @@ -189,5 +192,97 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor private void gridSizeIs(int size) => AddAssert($"grid size is {size}", () => this.ChildrenOfType().Single().Spacing.Value == new Vector2(size) && EditorBeatmap.BeatmapInfo.GridSize == size); + + [Test] + public void TestGridTypeToggling() + { + AddStep("enable rectangular grid", () => InputManager.Key(Key.T)); + AddUntilStep("rectangular grid visible", () => this.ChildrenOfType().Any()); + gridActive(true); + + nextGridTypeIs(); + nextGridTypeIs(); + nextGridTypeIs(); + } + + private void nextGridTypeIs() where T : PositionSnapGrid + { + AddStep("toggle to next grid type", () => + { + InputManager.PressKey(Key.ShiftLeft); + InputManager.Key(Key.G); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + gridActive(true); + } + + [Test] + public void TestGridPlacementTool() + { + AddStep("enable rectangular grid", () => InputManager.Key(Key.T)); + + AddStep("start grid placement", () => InputManager.Key(Key.Number5)); + AddStep("move cursor to slider head + (1, 1)", () => + { + var composer = Editor.ChildrenOfType().Single(); + InputManager.MoveMouseTo(composer.ToScreenSpace(((Slider)EditorBeatmap.HitObjects.First()).Position + new Vector2(1, 1))); + }); + AddStep("left click", () => InputManager.Click(MouseButton.Left)); + AddStep("move cursor to slider tail + (1, 1)", () => + { + var composer = Editor.ChildrenOfType().Single(); + InputManager.MoveMouseTo(composer.ToScreenSpace(((Slider)EditorBeatmap.HitObjects.First()).EndPosition + new Vector2(1, 1))); + }); + AddStep("left click", () => InputManager.Click(MouseButton.Left)); + + gridActive(true); + AddAssert("grid position at slider head", () => + { + var composer = Editor.ChildrenOfType().Single(); + return Precision.AlmostEquals(((Slider)EditorBeatmap.HitObjects.First()).Position, composer.StartPosition.Value); + }); + AddAssert("grid spacing is distance to slider tail", () => + { + var composer = Editor.ChildrenOfType().Single(); + return Precision.AlmostEquals(composer.Spacing.Value.X, 32.05, 0.01) + && Precision.AlmostEquals(composer.Spacing.Value.X, composer.Spacing.Value.Y); + }); + AddAssert("grid rotation points to slider tail", () => + { + var composer = Editor.ChildrenOfType().Single(); + return Precision.AlmostEquals(composer.GridLineRotation.Value, 0.09, 0.01); + }); + + AddStep("start grid placement", () => InputManager.Key(Key.Number5)); + AddStep("move cursor to slider tail + (1, 1)", () => + { + var composer = Editor.ChildrenOfType().Single(); + InputManager.MoveMouseTo(composer.ToScreenSpace(((Slider)EditorBeatmap.HitObjects.First()).EndPosition + new Vector2(1, 1))); + }); + AddStep("double click", () => + { + InputManager.Click(MouseButton.Left); + InputManager.Click(MouseButton.Left); + }); + AddStep("move cursor to (0, 0)", () => + { + var composer = Editor.ChildrenOfType().Single(); + InputManager.MoveMouseTo(composer.ToScreenSpace(Vector2.Zero)); + }); + + gridActive(true); + AddAssert("grid position at slider tail", () => + { + var composer = Editor.ChildrenOfType().Single(); + return Precision.AlmostEquals(((Slider)EditorBeatmap.HitObjects.First()).EndPosition, composer.StartPosition.Value); + }); + AddAssert("grid spacing and rotation unchanged", () => + { + var composer = Editor.ChildrenOfType().Single(); + return Precision.AlmostEquals(composer.Spacing.Value.X, 32.05, 0.01) + && Precision.AlmostEquals(composer.Spacing.Value.X, composer.Spacing.Value.Y) + && Precision.AlmostEquals(composer.GridLineRotation.Value, 0.09, 0.01); + }); + } } } diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderDrawing.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderDrawing.cs new file mode 100644 index 0000000000..0e36c1dc45 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderDrawing.cs @@ -0,0 +1,150 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.UI; +using osu.Game.Screens.Edit.Components.RadioButtons; +using osu.Game.Screens.Edit.Compose.Components; +using osu.Game.Tests.Beatmaps; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Osu.Tests.Editor +{ + [TestFixture] + public partial class TestSceneSliderDrawing : TestSceneOsuEditor + { + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); + + [Test] + public void TestTouchInputPlaceHitCircleDirectly() + { + AddStep("tap circle", () => tap(this.ChildrenOfType().Single(b => b.Button.Label == "HitCircle"))); + + AddStep("tap to place circle", () => tap(this.ChildrenOfType().Single())); + AddAssert("circle placed correctly", () => + { + var circle = (HitCircle)EditorBeatmap.HitObjects.Single(h => h.StartTime == EditorClock.CurrentTimeAccurate); + Assert.Multiple(() => + { + Assert.That(circle.Position.X, Is.EqualTo(256f).Within(0.01f)); + Assert.That(circle.Position.Y, Is.EqualTo(192f).Within(0.01f)); + }); + + return true; + }); + } + + [Test] + public void TestTouchInputPlaceCircleAfterTouchingComposeArea() + { + AddStep("tap circle", () => tap(this.ChildrenOfType().Single(b => b.Button.Label == "HitCircle"))); + + AddStep("tap playfield", () => tap(this.ChildrenOfType().Single())); + AddAssert("circle placed", () => EditorBeatmap.HitObjects.Single(h => h.StartTime == EditorClock.CurrentTimeAccurate) is HitCircle); + + AddStep("move forward", () => InputManager.Key(Key.Right)); + + AddStep("tap to place circle", () => tap(this.ChildrenOfType().Single())); + AddAssert("circle placed correctly", () => + { + var circle = (HitCircle)EditorBeatmap.HitObjects.Single(h => h.StartTime == EditorClock.CurrentTimeAccurate); + Assert.Multiple(() => + { + Assert.That(circle.Position.X, Is.EqualTo(256f).Within(0.01f)); + Assert.That(circle.Position.Y, Is.EqualTo(192f).Within(0.01f)); + }); + + return true; + }); + } + + [Test] + public void TestTouchInputPlaceSliderDirectly() + { + AddStep("tap slider", () => tap(this.ChildrenOfType().Single(b => b.Button.Label == "Slider"))); + + AddStep("hold to draw slider", () => InputManager.BeginTouch(new Touch(TouchSource.Touch1, this.ChildrenOfType().Single().ToScreenSpace(new Vector2(50, 20))))); + AddStep("drag to draw", () => InputManager.MoveTouchTo(new Touch(TouchSource.Touch1, this.ChildrenOfType().Single().ToScreenSpace(new Vector2(200, 50))))); + AddAssert("selection not initiated", () => this.ChildrenOfType().All(d => d.State == Visibility.Hidden)); + AddAssert("blueprint visible", () => this.ChildrenOfType().Single().Alpha > 0); + AddStep("end", () => InputManager.EndTouch(new Touch(TouchSource.Touch1, InputManager.CurrentState.Touch.GetTouchPosition(TouchSource.Touch1)!.Value))); + AddAssert("slider placed correctly", () => + { + var slider = (Slider)EditorBeatmap.HitObjects.Single(h => h.StartTime == EditorClock.CurrentTimeAccurate); + Assert.Multiple(() => + { + Assert.That(slider.Position.X, Is.EqualTo(50f).Within(0.01f)); + Assert.That(slider.Position.Y, Is.EqualTo(20f).Within(0.01f)); + Assert.That(slider.Path.ControlPoints.Count, Is.EqualTo(2)); + Assert.That(slider.Path.ControlPoints[0].Position, Is.EqualTo(Vector2.Zero)); + + // the final position may be slightly off from the mouse position when drawing, account for that. + Assert.That(slider.Path.ControlPoints[1].Position.X, Is.EqualTo(150).Within(5)); + Assert.That(slider.Path.ControlPoints[1].Position.Y, Is.EqualTo(30).Within(5)); + }); + + return true; + }); + } + + [Test] + public void TestTouchInputPlaceSliderAfterTouchingComposeArea() + { + AddStep("tap slider", () => tap(this.ChildrenOfType().Single(b => b.Button.Label == "Slider"))); + + AddStep("tap playfield", () => tap(this.ChildrenOfType().Single())); + AddStep("tap and hold another spot", () => hold(this.ChildrenOfType().Single(), new Vector2(50, 0))); + AddUntilStep("wait for slider placement", () => EditorBeatmap.HitObjects.SingleOrDefault(h => h.StartTime == EditorClock.CurrentTimeAccurate) is Slider); + AddStep("end", () => InputManager.EndTouch(new Touch(TouchSource.Touch1, InputManager.CurrentState.Touch.GetTouchPosition(TouchSource.Touch1)!.Value))); + + AddStep("move forward", () => InputManager.Key(Key.Right)); + + AddStep("hold to draw slider", () => InputManager.BeginTouch(new Touch(TouchSource.Touch1, this.ChildrenOfType().Single().ToScreenSpace(new Vector2(50, 20))))); + AddStep("drag to draw", () => InputManager.MoveTouchTo(new Touch(TouchSource.Touch1, this.ChildrenOfType().Single().ToScreenSpace(new Vector2(200, 50))))); + AddAssert("selection not initiated", () => this.ChildrenOfType().All(d => d.State == Visibility.Hidden)); + AddAssert("blueprint visible", () => this.ChildrenOfType().Single().IsPresent); + AddStep("end", () => InputManager.EndTouch(new Touch(TouchSource.Touch1, InputManager.CurrentState.Touch.GetTouchPosition(TouchSource.Touch1)!.Value))); + AddAssert("slider placed correctly", () => + { + var slider = (Slider)EditorBeatmap.HitObjects.Single(h => h.StartTime == EditorClock.CurrentTimeAccurate); + Assert.Multiple(() => + { + Assert.That(slider.Position.X, Is.EqualTo(50f).Within(0.01f)); + Assert.That(slider.Position.Y, Is.EqualTo(20f).Within(0.01f)); + Assert.That(slider.Path.ControlPoints.Count, Is.EqualTo(2)); + Assert.That(slider.Path.ControlPoints[0].Position, Is.EqualTo(Vector2.Zero)); + + // the final position may be slightly off from the mouse position when drawing, account for that. + Assert.That(slider.Path.ControlPoints[1].Position.X, Is.EqualTo(150).Within(5)); + Assert.That(slider.Path.ControlPoints[1].Position.Y, Is.EqualTo(30).Within(5)); + }); + + return true; + }); + } + + private void tap(Drawable drawable, Vector2 offset = default) => tap(drawable.ToScreenSpace(drawable.LayoutRectangle.Centre + offset)); + + private void tap(Vector2 position) + { + hold(position); + InputManager.EndTouch(new Touch(TouchSource.Touch1, position)); + } + + private void hold(Drawable drawable, Vector2 offset = default) => hold(drawable.ToScreenSpace(drawable.LayoutRectangle.Centre + offset)); + + private void hold(Vector2 position) + { + InputManager.BeginTouch(new Touch(TouchSource.Touch1, position)); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs index 019565ae29..5831cc0a8a 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using NUnit.Framework; +using osu.Framework.Input; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Rulesets.Edit; @@ -392,6 +393,29 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor assertFinalControlPointType(3, null); } + [Test] + public void TestSliderDrawingViaTouch() + { + Vector2 startPoint = new Vector2(200); + + AddStep("move mouse to a random point", () => InputManager.MoveMouseTo(InputManager.ToScreenSpace(Vector2.Zero))); + AddStep("begin touch at start point", () => InputManager.BeginTouch(new Touch(TouchSource.Touch1, InputManager.ToScreenSpace(startPoint)))); + + for (int i = 1; i < 20; i++) + addTouchMovementStep(startPoint + new Vector2(i * 40, MathF.Sin(i * MathF.PI / 5) * 50)); + + AddStep("release touch at end point", () => InputManager.EndTouch(new Touch(TouchSource.Touch1, InputManager.CurrentState.Touch.GetTouchPosition(TouchSource.Touch1)!.Value))); + + assertPlaced(true); + assertLength(808, tolerance: 10); + assertControlPointCount(5); + assertFinalControlPointType(0, PathType.BSpline(4)); + assertFinalControlPointType(1, null); + assertFinalControlPointType(2, null); + assertFinalControlPointType(3, null); + assertFinalControlPointType(4, null); + } + [Test] public void TestPlacePerfectCurveSegmentAlmostLinearlyExterior() { @@ -492,6 +516,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor private void addMovementStep(Vector2 position) => AddStep($"move mouse to {position}", () => InputManager.MoveMouseTo(InputManager.ToScreenSpace(position))); + private void addTouchMovementStep(Vector2 position) => AddStep($"move touch1 to {position}", () => InputManager.MoveTouchTo(new Touch(TouchSource.Touch1, InputManager.ToScreenSpace(position)))); + private void addClickStep(MouseButton button) { AddStep($"click {button}", () => InputManager.Click(button)); diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs index d68cbe6265..d5bacc25bc 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs @@ -180,7 +180,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor { if (slider == null) return; - sample = new HitSampleInfo("hitwhistle", HitSampleInfo.BANK_SOFT, volume: 70); + sample = new HitSampleInfo("hitwhistle", HitSampleInfo.BANK_SOFT, volume: 70, editorAutoBank: false); slider.Samples.Add(sample.With()); }); diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneToolSwitching.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneToolSwitching.cs new file mode 100644 index 0000000000..d5ab349a16 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneToolSwitching.cs @@ -0,0 +1,55 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Tests.Visual; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Osu.Tests.Editor +{ + public partial class TestSceneToolSwitching : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + [Test] + public void TestSliderAnchorMoveOperationEndsOnSwitchingTool() + { + var initialPosition = Vector2.Zero; + + AddStep("store original anchor position", () => initialPosition = EditorBeatmap.HitObjects.OfType().First().Path.ControlPoints.ElementAt(1).Position); + AddStep("select first slider", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.OfType().First())); + AddStep("move to second anchor", () => InputManager.MoveMouseTo(this.ChildrenOfType>().ElementAt(1))); + AddStep("start dragging", () => InputManager.PressButton(MouseButton.Left)); + AddStep("drag away", () => InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + new Vector2(0, -200))); + AddStep("switch tool", () => InputManager.PressButton(MouseButton.Button1)); + AddStep("undo", () => Editor.Undo()); + AddAssert("anchor back at original position", + () => EditorBeatmap.HitObjects.OfType().First().Path.ControlPoints.ElementAt(1).Position, + () => Is.EqualTo(initialPosition)); + } + + [Test] + public void TestSliderAnchorCreationOperationEndsOnSwitchingTool() + { + AddStep("select first slider", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.OfType().First())); + AddStep("move to second anchor", () => InputManager.MoveMouseTo(this.ChildrenOfType>().ElementAt(1), new Vector2(-50, 0))); + AddStep("quick-create anchor", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.PressButton(MouseButton.Left); + InputManager.ReleaseKey(Key.ControlLeft); + }); + AddStep("drag away", () => InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + new Vector2(0, -200))); + AddStep("switch tool", () => InputManager.PressKey(Key.Number3)); + AddStep("drag away further", () => InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + new Vector2(0, -200))); + AddStep("select first slider", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.OfType().First())); + AddStep("undo", () => Editor.Undo()); + AddAssert("slider has three anchors again", () => EditorBeatmap.HitObjects.OfType().First().Path.ControlPoints, () => Has.Count.EqualTo(3)); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAlternate.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAlternate.cs index 7375617aa8..27ff26b438 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAlternate.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAlternate.cs @@ -23,7 +23,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods Mod = new OsuModAlternate(), PassCondition = () => Player.ScoreProcessor.Combo.Value == 4, Autoplay = false, - Beatmap = new Beatmap + CreateBeatmap = () => new Beatmap { HitObjects = new List { @@ -68,7 +68,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods Mod = new OsuModAlternate(), PassCondition = () => Player.ScoreProcessor.Combo.Value == 0 && Player.ScoreProcessor.HighestCombo.Value == 1, Autoplay = false, - Beatmap = new Beatmap + CreateBeatmap = () => new Beatmap { HitObjects = new List { @@ -101,7 +101,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods Mod = new OsuModAlternate(), PassCondition = () => Player.ScoreProcessor.Combo.Value == 1, Autoplay = false, - Beatmap = new Beatmap + CreateBeatmap = () => new Beatmap { HitObjects = new List { @@ -131,7 +131,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods Mod = new OsuModAlternate(), PassCondition = () => Player.ScoreProcessor.Combo.Value == 0 && Player.ScoreProcessor.HighestCombo.Value == 2, Autoplay = false, - Beatmap = new Beatmap + CreateBeatmap = () => new Beatmap { Breaks = { diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs index ace7f23989..8786b17b92 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs @@ -60,7 +60,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods CreateModTest(new ModTestData { Autoplay = true, - Beatmap = new Beatmap + CreateBeatmap = () => new Beatmap { HitObjects = new List { @@ -90,7 +90,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods { Autoplay = true, Mod = mod, - Beatmap = new Beatmap + CreateBeatmap = () => new Beatmap { HitObjects = { diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDifficultyAdjust.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDifficultyAdjust.cs index 472c341bdd..ca752fe918 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDifficultyAdjust.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDifficultyAdjust.cs @@ -21,7 +21,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods public void TestNoAdjustment() => CreateModTest(new ModTestData { Mod = new OsuModDifficultyAdjust(), - Beatmap = new Beatmap + CreateBeatmap = () => new Beatmap { BeatmapInfo = new BeatmapInfo { diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFlashlight.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFlashlight.cs index 075fdd88ca..6bd3f25bdb 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFlashlight.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFlashlight.cs @@ -68,7 +68,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods Player.GameplayClockContainer.CurrentTime < 1000 && Player.ChildrenOfType.Flashlight>().Single().FlashlightDim > 0; return Player.GameplayState.HasPassed && !sliderDimmedBeforeStartTime; }, - Beatmap = new OsuBeatmap + CreateBeatmap = () => new OsuBeatmap { HitObjects = new List { @@ -114,7 +114,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods Player.GameplayClockContainer.CurrentTime >= 1000 && Player.ChildrenOfType.Flashlight>().Single().FlashlightDim > 0; return Player.GameplayState.HasPassed && sliderDimmed; }, - Beatmap = new OsuBeatmap + CreateBeatmap = () => new OsuBeatmap { HitObjects = new List { @@ -153,7 +153,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods Player.GameplayClockContainer.CurrentTime >= 1000 && Player.ChildrenOfType.Flashlight>().Single().FlashlightDim > 0; return Player.GameplayState.HasPassed && sliderDimmed; }, - Beatmap = new OsuBeatmap + CreateBeatmap = () => new OsuBeatmap { HitObjects = new List { diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.cs index 58bdd805c1..c513f98f21 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.cs @@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods { Mod = new TestOsuModHidden(), Autoplay = true, - Beatmap = new Beatmap + CreateBeatmap = () => new Beatmap { HitObjects = new List { @@ -63,7 +63,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods { Mod = new TestOsuModHidden(), Autoplay = true, - Beatmap = new Beatmap + CreateBeatmap = () => new Beatmap { HitObjects = new List { @@ -98,7 +98,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods { Mod = new TestOsuModHidden(), Autoplay = true, - Beatmap = new Beatmap + CreateBeatmap = () => new Beatmap { HitObjects = new List { @@ -122,7 +122,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods { Mod = new OsuModHidden { OnlyFadeApproachCircles = { Value = true } }, Autoplay = true, - Beatmap = new Beatmap + CreateBeatmap = () => new Beatmap { HitObjects = new List { diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMirror.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMirror.cs new file mode 100644 index 0000000000..076cb9ae15 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMirror.cs @@ -0,0 +1,64 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.UI; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests.Mods +{ + public partial class TestSceneOsuModMirror : OsuModTestScene + { + [Test] + public void TestCorrectReflections([Values] OsuModMirror.MirrorType type, [Values] bool withStrictTracking) => CreateModTest(new ModTestData + { + Autoplay = true, + CreateBeatmap = () => new OsuBeatmap + { + HitObjects = + { + new Slider + { + Position = new Vector2(0), + Path = new SliderPath + { + ControlPoints = + { + new PathControlPoint(), + new PathControlPoint(new Vector2(100, 0)) + } + }, + TickDistanceMultiplier = 0.5, + RepeatCount = 1, + } + } + }, + Mods = withStrictTracking + ? [new OsuModMirror { Reflection = { Value = type } }, new OsuModStrictTracking()] + : [new OsuModMirror { Reflection = { Value = type } }], + PassCondition = () => + { + var slider = this.ChildrenOfType().SingleOrDefault(); + var playfield = this.ChildrenOfType().Single(); + + if (slider == null) + return false; + + return Precision.AlmostEquals(playfield.ToLocalSpace(slider.HeadCircle.ScreenSpaceDrawQuad.Centre), slider.HitObject.Position) + && Precision.AlmostEquals(playfield.ToLocalSpace(slider.TailCircle.ScreenSpaceDrawQuad.Centre), slider.HitObject.Position) + && Precision.AlmostEquals(playfield.ToLocalSpace(slider.NestedHitObjects.OfType().Single().ScreenSpaceDrawQuad.Centre), + slider.HitObject.Position + slider.HitObject.Path.PositionAt(1)) + && Precision.AlmostEquals(playfield.ToLocalSpace(slider.NestedHitObjects.OfType().First().ScreenSpaceDrawQuad.Centre), + slider.HitObject.Position + slider.HitObject.Path.PositionAt(0.7f)); + } + }); + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModNoScope.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModNoScope.cs index d3996ebc3b..b9559aeba3 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModNoScope.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModNoScope.cs @@ -31,7 +31,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods }, Autoplay = true, PassCondition = () => true, - Beatmap = new Beatmap + CreateBeatmap = () => new Beatmap { HitObjects = new List { @@ -71,7 +71,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods }, Autoplay = true, PassCondition = () => true, - Beatmap = new Beatmap + CreateBeatmap = () => new Beatmap { HitObjects = new List { @@ -113,7 +113,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods }, Autoplay = true, PassCondition = () => true, - Beatmap = new Beatmap + CreateBeatmap = () => new Beatmap { HitObjects = new List { diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModPerfect.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModPerfect.cs index b01bbbfca1..8498e53bf0 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModPerfect.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModPerfect.cs @@ -61,7 +61,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods Mod = new OsuModPerfect(), PassCondition = () => ((ModFailConditionTestPlayer)Player).CheckFailed(true), Autoplay = false, - Beatmap = new Beatmap + CreateBeatmap = () => new Beatmap { HitObjects = new List { diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModRandom.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModRandom.cs index 060a845137..3456fcbe84 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModRandom.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModRandom.cs @@ -36,7 +36,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods { AngleSharpness = { Value = angleSharpness } }, - Beatmap = jumpBeatmap, + CreateBeatmap = jumpBeatmap, Autoplay = true, PassCondition = () => true }); @@ -50,15 +50,15 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods { AngleSharpness = { Value = angleSharpness } }, - Beatmap = streamBeatmap, + CreateBeatmap = streamBeatmap, Autoplay = true, PassCondition = () => true }); - private OsuBeatmap jumpBeatmap => + private OsuBeatmap jumpBeatmap() => createHitCircleBeatmap(new[] { 100, 200, 300, 400 }, 8, 300, 2 * 300); - private OsuBeatmap streamBeatmap => + private OsuBeatmap streamBeatmap() => createHitCircleBeatmap(new[] { 10, 20, 30, 40, 50, 60, 70, 80 }, 16, 150, 4 * 150); private OsuBeatmap createHitCircleBeatmap(IEnumerable spacings, int objectsPerSpacing, int interval, int beatLength) diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSingleTap.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSingleTap.cs index bd2b205ac8..b0be70e85e 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSingleTap.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSingleTap.cs @@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods Mod = new OsuModSingleTap(), PassCondition = () => Player.ScoreProcessor.Combo.Value == 2, Autoplay = false, - Beatmap = new Beatmap + CreateBeatmap = () => new Beatmap { HitObjects = new List { @@ -62,7 +62,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods Mod = new OsuModSingleTap(), PassCondition = () => Player.ScoreProcessor.Combo.Value == 0 && Player.ScoreProcessor.HighestCombo.Value == 1, Autoplay = false, - Beatmap = new Beatmap + CreateBeatmap = () => new Beatmap { HitObjects = new List { @@ -100,7 +100,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods Mod = new OsuModSingleTap(), PassCondition = () => Player.ScoreProcessor.Combo.Value == 1, Autoplay = false, - Beatmap = new Beatmap + CreateBeatmap = () => new Beatmap { HitObjects = new List { @@ -130,7 +130,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods Mod = new OsuModSingleTap(), PassCondition = () => Player.ScoreProcessor.Combo.Value == 0 && Player.ScoreProcessor.HighestCombo.Value == 2, Autoplay = false, - Beatmap = new Beatmap + CreateBeatmap = () => new Beatmap { Breaks = { diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSpunOut.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSpunOut.cs index de3ea5f148..3706b9ac07 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSpunOut.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSpunOut.cs @@ -35,7 +35,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods { Mod = new OsuModSpunOut(), Autoplay = false, - Beatmap = singleSpinnerBeatmap, + CreateBeatmap = singleSpinnerBeatmap, PassCondition = () => { // Bind to the first spinner's results for further tracking. @@ -71,7 +71,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods { Mods = mods, Autoplay = false, - Beatmap = singleSpinnerBeatmap, + CreateBeatmap = singleSpinnerBeatmap, PassCondition = () => { var counter = Player.ChildrenOfType().SingleOrDefault(); @@ -101,7 +101,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods { Mod = new OsuModSpunOut(), Autoplay = false, - Beatmap = singleSpinnerBeatmap, + CreateBeatmap = singleSpinnerBeatmap, PassCondition = () => { // Bind to the first spinner's results for further tracking. @@ -130,7 +130,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods }); } - private Beatmap singleSpinnerBeatmap => new Beatmap + private Beatmap singleSpinnerBeatmap() => new Beatmap { HitObjects = new List { diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModStrictTracking.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModStrictTracking.cs index 726b415977..66a60e3542 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModStrictTracking.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModStrictTracking.cs @@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods { Mod = new OsuModStrictTracking(), Autoplay = false, - Beatmap = new Beatmap + CreateBeatmap = () => new Beatmap { HitObjects = new List { diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSuddenDeath.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSuddenDeath.cs index ea048aaa6e..688cf70f71 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSuddenDeath.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSuddenDeath.cs @@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods Mod = new OsuModSuddenDeath(), PassCondition = () => ((ModFailConditionTestPlayer)Player).CheckFailed(false), Autoplay = false, - Beatmap = new Beatmap + CreateBeatmap = () => new Beatmap { HitObjects = new List { @@ -55,7 +55,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods Mod = new OsuModSuddenDeath(), PassCondition = () => ((ModFailConditionTestPlayer)Player).CheckFailed(true), Autoplay = false, - Beatmap = new Beatmap + CreateBeatmap = () => new Beatmap { HitObjects = new List { diff --git a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs index e35cf10d95..efda3fa369 100644 --- a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs @@ -15,22 +15,22 @@ namespace osu.Game.Rulesets.Osu.Tests { protected override string ResourceAssembly => "osu.Game.Rulesets.Osu.Tests"; - [TestCase(6.710442985146793d, 239, "diffcalc-test")] - [TestCase(1.4386882251130073d, 54, "zero-length-sliders")] - [TestCase(0.42506480230838789d, 4, "very-fast-slider")] - [TestCase(0.14102693012101306d, 2, "nan-slider")] + [TestCase(6.7171144000821119d, 239, "diffcalc-test")] + [TestCase(1.4485749025771304d, 54, "zero-length-sliders")] + [TestCase(0.42630400627180914d, 4, "very-fast-slider")] + [TestCase(0.14143808967817237d, 2, "nan-slider")] public void Test(double expectedStarRating, int expectedMaxCombo, string name) => base.Test(expectedStarRating, expectedMaxCombo, name); - [TestCase(8.9742952703071666d, 239, "diffcalc-test")] - [TestCase(1.743180218215227d, 54, "zero-length-sliders")] - [TestCase(0.55071082800473514d, 4, "very-fast-slider")] + [TestCase(8.9825709931204205d, 239, "diffcalc-test")] + [TestCase(1.7550169162648608d, 54, "zero-length-sliders")] + [TestCase(0.55231632896800109d, 4, "very-fast-slider")] public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name) => Test(expectedStarRating, expectedMaxCombo, name, new OsuModDoubleTime()); - [TestCase(6.710442985146793d, 239, "diffcalc-test")] - [TestCase(1.4386882251130073d, 54, "zero-length-sliders")] - [TestCase(0.42506480230838789d, 4, "very-fast-slider")] + [TestCase(6.7171144000821119d, 239, "diffcalc-test")] + [TestCase(1.4485749025771304d, 54, "zero-length-sliders")] + [TestCase(0.42630400627180914d, 4, "very-fast-slider")] public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name) => Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic()); diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneMissHitWindowJudgements.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneMissHitWindowJudgements.cs index c37660831b..7a89140fc4 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneMissHitWindowJudgements.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneMissHitWindowJudgements.cs @@ -36,7 +36,7 @@ namespace osu.Game.Rulesets.Osu.Tests { Autoplay = false, Mod = new TestAutoMod(), - Beatmap = new Beatmap + CreateBeatmap = () => new Beatmap { HitObjects = { new HitCircle { Position = new Vector2(256, 192) } } }, @@ -47,18 +47,16 @@ namespace osu.Game.Rulesets.Osu.Tests [Test] public void TestMissViaNotHitting() { - var beatmap = new Beatmap - { - HitObjects = { new HitCircle { Position = new Vector2(256, 192) } } - }; - var hitWindows = new OsuHitWindows(); - hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); + hitWindows.SetDifficulty(IBeatmapDifficultyInfo.DEFAULT_DIFFICULTY); CreateModTest(new ModTestData { Autoplay = false, - Beatmap = beatmap, + CreateBeatmap = () => new Beatmap + { + HitObjects = { new HitCircle { Position = new Vector2(256, 192) } } + }, PassCondition = () => Player.Results.Count > 0 && Player.Results[0].TimeOffset >= hitWindows.WindowFor(HitResult.Meh) && !Player.Results[0].IsHit }); } diff --git a/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj b/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj index ea54c8d313..5ea231e606 100644 --- a/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj +++ b/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj @@ -1,7 +1,7 @@  - + diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs index 3d1939acac..9816f6d0a4 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs @@ -3,6 +3,7 @@ using System; using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Objects; @@ -33,6 +34,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators var osuLastObj = (OsuDifficultyHitObject)current.Previous(0); var osuLastLastObj = (OsuDifficultyHitObject)current.Previous(1); + const int radius = OsuDifficultyHitObject.NORMALISED_RADIUS; + const int diameter = OsuDifficultyHitObject.NORMALISED_DIAMETER; + // Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle. double currVelocity = osuCurrObj.LazyJumpDistance / osuCurrObj.StrainTime; @@ -77,14 +81,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators wideAngleBonus = calcWideAngleBonus(currAngle); acuteAngleBonus = calcAcuteAngleBonus(currAngle); - if (osuCurrObj.StrainTime > 100) // Only buff deltaTime exceeding 300 bpm 1/2. + if (DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.StrainTime, 2) < 300) // Only buff deltaTime exceeding 300 bpm 1/2. acuteAngleBonus = 0; else { acuteAngleBonus *= calcAcuteAngleBonus(lastAngle) // Multiply by previous angle, we don't want to buff unless this is a wiggle type pattern. - * Math.Min(angleBonus, 125 / osuCurrObj.StrainTime) // The maximum velocity we buff is equal to 125 / strainTime + * Math.Min(angleBonus, diameter * 1.25 / osuCurrObj.StrainTime) // The maximum velocity we buff is equal to 125 / strainTime * Math.Pow(Math.Sin(Math.PI / 2 * Math.Min(1, (100 - osuCurrObj.StrainTime) / 25)), 2) // scale buff from 150 bpm 1/4 to 200 bpm 1/4 - * Math.Pow(Math.Sin(Math.PI / 2 * (Math.Clamp(osuCurrObj.LazyJumpDistance, 50, 100) - 50) / 50), 2); // Buff distance exceeding 50 (radius) up to 100 (diameter). + * Math.Pow(Math.Sin(Math.PI / 2 * (Math.Clamp(osuCurrObj.LazyJumpDistance, radius, diameter) - radius) / radius), 2); // Buff distance exceeding radius up to diameter. } // Penalize wide angles if they're repeated, reducing the penalty as the lastAngle gets more acute. @@ -104,7 +108,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators double distRatio = Math.Pow(Math.Sin(Math.PI / 2 * Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity)), 2); // Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing. - double overlapVelocityBuff = Math.Min(125 / Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime), Math.Abs(prevVelocity - currVelocity)); + double overlapVelocityBuff = Math.Min(diameter * 1.25 / Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime), Math.Abs(prevVelocity - currVelocity)); velocityChangeBonus = overlapVelocityBuff * distRatio; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs index f2218a89a7..d503dd2bcc 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs @@ -2,7 +2,10 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; +using System.Linq; using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Objects; @@ -10,8 +13,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators { public static class RhythmEvaluator { - private const int history_time_max = 5000; // 5 seconds of calculatingRhythmBonus max. - private const double rhythm_multiplier = 0.75; + private const int history_time_max = 5 * 1000; // 5 seconds + private const int history_objects_max = 32; + private const double rhythm_overall_multiplier = 0.95; + private const double rhythm_ratio_multiplier = 12.0; /// /// Calculates a rhythm multiplier for the difficulty of the tap associated with historic data of the current . @@ -21,15 +26,22 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators if (current.BaseObject is Spinner) return 0; - int previousIslandSize = 0; - double rhythmComplexitySum = 0; - int islandSize = 1; + + double deltaDifferenceEpsilon = ((OsuDifficultyHitObject)current).HitWindowGreat * 0.3; + + var island = new Island(deltaDifferenceEpsilon); + var previousIsland = new Island(deltaDifferenceEpsilon); + + // we can't use dictionary here because we need to compare island with a tolerance + // which is impossible to pass into the hash comparer + var islandCounts = new List<(Island Island, int Count)>(); + double startRatio = 0; // store the ratio of the current start of an island to buff for tighter rhythms bool firstDeltaSwitch = false; - int historicalNoteCount = Math.Min(current.Index, 32); + int historicalNoteCount = Math.Min(current.Index, history_objects_max); int rhythmStart = 0; @@ -39,74 +51,175 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators OsuDifficultyHitObject prevObj = (OsuDifficultyHitObject)current.Previous(rhythmStart); OsuDifficultyHitObject lastObj = (OsuDifficultyHitObject)current.Previous(rhythmStart + 1); + // we go from the furthest object back to the current one for (int i = rhythmStart; i > 0; i--) { OsuDifficultyHitObject currObj = (OsuDifficultyHitObject)current.Previous(i - 1); - double currHistoricalDecay = (history_time_max - (current.StartTime - currObj.StartTime)) / history_time_max; // scales note 0 to 1 from history to now + // scales note 0 to 1 from history to now + double timeDecay = (history_time_max - (current.StartTime - currObj.StartTime)) / history_time_max; + double noteDecay = (double)(historicalNoteCount - i) / historicalNoteCount; - currHistoricalDecay = Math.Min((double)(historicalNoteCount - i) / historicalNoteCount, currHistoricalDecay); // either we're limited by time or limited by object count. + double currHistoricalDecay = Math.Min(noteDecay, timeDecay); // either we're limited by time or limited by object count. double currDelta = currObj.StrainTime; double prevDelta = prevObj.StrainTime; double lastDelta = lastObj.StrainTime; - double currRatio = 1.0 + 6.0 * Math.Min(0.5, Math.Pow(Math.Sin(Math.PI / (Math.Min(prevDelta, currDelta) / Math.Max(prevDelta, currDelta))), 2)); // fancy function to calculate rhythmbonuses. - double windowPenalty = Math.Min(1, Math.Max(0, Math.Abs(prevDelta - currDelta) - currObj.HitWindowGreat * 0.3) / (currObj.HitWindowGreat * 0.3)); + // calculate how much current delta difference deserves a rhythm bonus + // this function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e 100 and 200) + double deltaDifferenceRatio = Math.Min(prevDelta, currDelta) / Math.Max(prevDelta, currDelta); + double currRatio = 1.0 + rhythm_ratio_multiplier * Math.Min(0.5, Math.Pow(Math.Sin(Math.PI / deltaDifferenceRatio), 2)); - windowPenalty = Math.Min(1, windowPenalty); + // reduce ratio bonus if delta difference is too big + double fraction = Math.Max(prevDelta / currDelta, currDelta / prevDelta); + double fractionMultiplier = Math.Clamp(2.0 - fraction / 8.0, 0.0, 1.0); - double effectiveRatio = windowPenalty * currRatio; + double windowPenalty = Math.Min(1, Math.Max(0, Math.Abs(prevDelta - currDelta) - deltaDifferenceEpsilon) / deltaDifferenceEpsilon); + + double effectiveRatio = windowPenalty * currRatio * fractionMultiplier; if (firstDeltaSwitch) { - if (!(prevDelta > 1.25 * currDelta || prevDelta * 1.25 < currDelta)) + if (Math.Abs(prevDelta - currDelta) < deltaDifferenceEpsilon) { - if (islandSize < 7) - islandSize++; // island is still progressing, count size. + // island is still progressing + island.AddDelta((int)currDelta); } else { - if (currObj.BaseObject is Slider) // bpm change is into slider, this is easy acc window + // bpm change is into slider, this is easy acc window + if (currObj.BaseObject is Slider) effectiveRatio *= 0.125; - if (prevObj.BaseObject is Slider) // bpm change was from a slider, this is easier typically than circle -> circle - effectiveRatio *= 0.25; + // bpm change was from a slider, this is easier typically than circle -> circle + // unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty than bursts without sliders + if (prevObj.BaseObject is Slider) + effectiveRatio *= 0.3; - if (previousIslandSize == islandSize) // repeated island size (ex: triplet -> triplet) - effectiveRatio *= 0.25; + // repeated island polarity (2 -> 4, 3 -> 5) + if (island.IsSimilarPolarity(previousIsland)) + effectiveRatio *= 0.5; - if (previousIslandSize % 2 == islandSize % 2) // repeated island polartiy (2 -> 4, 3 -> 5) - effectiveRatio *= 0.50; - - if (lastDelta > prevDelta + 10 && prevDelta > currDelta + 10) // previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this. + // previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this. + if (lastDelta > prevDelta + deltaDifferenceEpsilon && prevDelta > currDelta + deltaDifferenceEpsilon) effectiveRatio *= 0.125; - rhythmComplexitySum += Math.Sqrt(effectiveRatio * startRatio) * currHistoricalDecay * Math.Sqrt(4 + islandSize) / 2 * Math.Sqrt(4 + previousIslandSize) / 2; + // repeated island size (ex: triplet -> triplet) + // TODO: remove this nerf since its staying here only for balancing purposes because of the flawed ratio calculation + if (previousIsland.DeltaCount == island.DeltaCount) + effectiveRatio *= 0.5; + + var islandCount = islandCounts.FirstOrDefault(x => x.Island.Equals(island)); + + if (islandCount != default) + { + int countIndex = islandCounts.IndexOf(islandCount); + + // only add island to island counts if they're going one after another + if (previousIsland.Equals(island)) + islandCount.Count++; + + // repeated island (ex: triplet -> triplet) + double power = DifficultyCalculationUtils.Logistic(island.Delta, maxValue: 2.75, multiplier: 0.24, midpointOffset: 58.33); + effectiveRatio *= Math.Min(3.0 / islandCount.Count, Math.Pow(1.0 / islandCount.Count, power)); + + islandCounts[countIndex] = (islandCount.Island, islandCount.Count); + } + else + { + islandCounts.Add((island, 1)); + } + + // scale down the difficulty if the object is doubletappable + double doubletapness = prevObj.GetDoubletapness(currObj); + effectiveRatio *= 1 - doubletapness * 0.75; + + rhythmComplexitySum += Math.Sqrt(effectiveRatio * startRatio) * currHistoricalDecay; startRatio = effectiveRatio; - previousIslandSize = islandSize; // log the last island size. + previousIsland = island; - if (prevDelta * 1.25 < currDelta) // we're slowing down, stop counting - firstDeltaSwitch = false; // if we're speeding up, this stays true and we keep counting island size. + if (prevDelta + deltaDifferenceEpsilon < currDelta) // we're slowing down, stop counting + firstDeltaSwitch = false; // if we're speeding up, this stays true and we keep counting island size. - islandSize = 1; + island = new Island((int)currDelta, deltaDifferenceEpsilon); } } - else if (prevDelta > 1.25 * currDelta) // we want to be speeding up. + else if (prevDelta > currDelta + deltaDifferenceEpsilon) // we're speeding up { // Begin counting island until we change speed again. firstDeltaSwitch = true; + + // bpm change is into slider, this is easy acc window + if (currObj.BaseObject is Slider) + effectiveRatio *= 0.6; + + // bpm change was from a slider, this is easier typically than circle -> circle + // unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty than bursts without sliders + if (prevObj.BaseObject is Slider) + effectiveRatio *= 0.6; + startRatio = effectiveRatio; - islandSize = 1; + + island = new Island((int)currDelta, deltaDifferenceEpsilon); } lastObj = prevObj; prevObj = currObj; } - return Math.Sqrt(4 + rhythmComplexitySum * rhythm_multiplier) / 2; //produces multiplier that can be applied to strain. range [1, infinity) (not really though) + return Math.Sqrt(4 + rhythmComplexitySum * rhythm_overall_multiplier) / 2.0; // produces multiplier that can be applied to strain. range [1, infinity) (not really though) + } + + private class Island : IEquatable + { + private readonly double deltaDifferenceEpsilon; + + public Island(double epsilon) + { + deltaDifferenceEpsilon = epsilon; + } + + public Island(int delta, double epsilon) + { + deltaDifferenceEpsilon = epsilon; + Delta = Math.Max(delta, OsuDifficultyHitObject.MIN_DELTA_TIME); + DeltaCount++; + } + + public int Delta { get; private set; } = int.MaxValue; + public int DeltaCount { get; private set; } + + public void AddDelta(int delta) + { + if (Delta == int.MaxValue) + Delta = Math.Max(delta, OsuDifficultyHitObject.MIN_DELTA_TIME); + + DeltaCount++; + } + + public bool IsSimilarPolarity(Island other) + { + // TODO: consider islands to be of similar polarity only if they're having the same average delta (we don't want to consider 3 singletaps similar to a triple) + // naively adding delta check here breaks _a lot_ of maps because of the flawed ratio calculation + return DeltaCount % 2 == other.DeltaCount % 2; + } + + public bool Equals(Island? other) + { + if (other == null) + return false; + + return Math.Abs(Delta - other.Delta) < deltaDifferenceEpsilon && + DeltaCount == other.DeltaCount; + } + + public override string ToString() + { + return $"{Delta}x{DeltaCount}"; + } } } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs index 37fd11391c..a5f6468f17 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs @@ -3,6 +3,7 @@ using System; using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Objects; @@ -10,9 +11,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators { public static class SpeedEvaluator { - private const double single_spacing_threshold = 125; // 1.25 circles distance between centers - private const double min_speed_bonus = 75; // ~200BPM + private const double single_spacing_threshold = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25; // 1.25 circles distance between centers + private const double min_speed_bonus = 200; // 200 BPM 1/4th private const double speed_balancing_factor = 40; + private const double distance_multiplier = 0.94; /// /// Evaluates the difficulty of tapping the current object, based on: @@ -30,32 +32,20 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators // derive strainTime for calculation var osuCurrObj = (OsuDifficultyHitObject)current; var osuPrevObj = current.Index > 0 ? (OsuDifficultyHitObject)current.Previous(0) : null; - var osuNextObj = (OsuDifficultyHitObject?)current.Next(0); double strainTime = osuCurrObj.StrainTime; - double doubletapness = 1; - - // Nerf doubletappable doubles. - if (osuNextObj != null) - { - double currDeltaTime = Math.Max(1, osuCurrObj.DeltaTime); - double nextDeltaTime = Math.Max(1, osuNextObj.DeltaTime); - double deltaDifference = Math.Abs(nextDeltaTime - currDeltaTime); - double speedRatio = currDeltaTime / Math.Max(currDeltaTime, deltaDifference); - double windowRatio = Math.Pow(Math.Min(1, currDeltaTime / osuCurrObj.HitWindowGreat), 2); - doubletapness = Math.Pow(speedRatio, 1 - windowRatio); - } + double doubletapness = 1.0 - osuCurrObj.GetDoubletapness((OsuDifficultyHitObject?)osuCurrObj.Next(0)); // Cap deltatime to the OD 300 hitwindow. // 0.93 is derived from making sure 260bpm OD8 streams aren't nerfed harshly, whilst 0.92 limits the effect of the cap. strainTime /= Math.Clamp((strainTime / osuCurrObj.HitWindowGreat) / 0.93, 0.92, 1); - // speedBonus will be 1.0 for BPM < 200 - double speedBonus = 1.0; + // speedBonus will be 0.0 for BPM < 200 + double speedBonus = 0.0; // Add additional scaling bonus for streams/bursts higher than 200bpm - if (strainTime < min_speed_bonus) - speedBonus = 1 + 0.75 * Math.Pow((min_speed_bonus - strainTime) / speed_balancing_factor, 2); + if (DifficultyCalculationUtils.MillisecondsToBPM(strainTime) > min_speed_bonus) + speedBonus = 0.75 * Math.Pow((DifficultyCalculationUtils.BPMToMilliseconds(min_speed_bonus) - strainTime) / speed_balancing_factor, 2); double travelDistance = osuPrevObj?.TravelDistance ?? 0; double distance = travelDistance + osuCurrObj.MinimumJumpDistance; @@ -63,11 +53,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators // Cap distance at single_spacing_threshold distance = Math.Min(distance, single_spacing_threshold); - // Max distance bonus is 2 at single_spacing_threshold - double distanceBonus = 1 + Math.Pow(distance / single_spacing_threshold, 3.5); + // Max distance bonus is 1 * `distance_multiplier` at single_spacing_threshold + double distanceBonus = Math.Pow(distance / single_spacing_threshold, 3.95) * distance_multiplier; // Base difficulty with all bonuses - double difficulty = speedBonus * distanceBonus * 1000 / strainTime; + double difficulty = (1 + speedBonus + distanceBonus) * 1000 / strainTime; // Apply penalty if there's doubletappable doubles return difficulty * doubletapness; diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs index 83538a2f42..a3c0209a08 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs @@ -46,6 +46,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty [JsonProperty("slider_factor")] public double SliderFactor { get; set; } + [JsonProperty("aim_difficult_strain_count")] + public double AimDifficultStrainCount { get; set; } + + [JsonProperty("speed_difficult_strain_count")] + public double SpeedDifficultStrainCount { get; set; } + /// /// The perceived approach rate inclusive of rate-adjusting mods (DT/HT/etc). /// @@ -99,6 +105,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty yield return (ATTRIB_ID_FLASHLIGHT, FlashlightDifficulty); yield return (ATTRIB_ID_SLIDER_FACTOR, SliderFactor); + + yield return (ATTRIB_ID_AIM_DIFFICULT_STRAIN_COUNT, AimDifficultStrainCount); + yield return (ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT, SpeedDifficultStrainCount); yield return (ATTRIB_ID_SPEED_NOTE_COUNT, SpeedNoteCount); } @@ -113,8 +122,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty StarRating = values[ATTRIB_ID_DIFFICULTY]; FlashlightDifficulty = values.GetValueOrDefault(ATTRIB_ID_FLASHLIGHT); SliderFactor = values[ATTRIB_ID_SLIDER_FACTOR]; + AimDifficultStrainCount = values[ATTRIB_ID_AIM_DIFFICULT_STRAIN_COUNT]; + SpeedDifficultStrainCount = values[ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT]; SpeedNoteCount = values[ATTRIB_ID_SPEED_NOTE_COUNT]; - DrainRate = onlineInfo.DrainRate; HitCircleCount = onlineInfo.CircleCount; SliderCount = onlineInfo.SliderCount; diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index c4fcd1f760..575e03051c 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -24,7 +24,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty { private const double difficulty_multiplier = 0.0675; - public override int Version => 20220902; + public override int Version => 20241007; public OsuDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) : base(ruleset, beatmap) @@ -48,6 +48,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty double sliderFactor = aimRating > 0 ? aimRatingNoSliders / aimRating : 1; + double aimDifficultyStrainCount = ((OsuStrainSkill)skills[0]).CountTopWeightedStrains(); + double speedDifficultyStrainCount = ((OsuStrainSkill)skills[2]).CountTopWeightedStrains(); + if (mods.Any(m => m is OsuModTouchDevice)) { aimRating = Math.Pow(aimRating, 0.8); @@ -100,6 +103,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty SpeedNoteCount = speedNotes, FlashlightDifficulty = flashlightRating, SliderFactor = sliderFactor, + AimDifficultStrainCount = aimDifficultyStrainCount, + SpeedDifficultStrainCount = speedDifficultyStrainCount, ApproachRate = preempt > 1200 ? (1800 - preempt) / 120 : (1200 - preempt) / 150 + 5, OverallDifficulty = (80 - hitWindowGreat) / 6, DrainRate = drainRate, diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 6defa9739c..31b00dba2b 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -14,7 +14,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty { public class OsuPerformanceCalculator : PerformanceCalculator { - public const double PERFORMANCE_BASE_MULTIPLIER = 1.05; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things. + public const double PERFORMANCE_BASE_MULTIPLIER = 1.15; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things. + + private bool usingClassicSliderAccuracy; private double accuracy; private int scoreMaxCombo; @@ -23,6 +25,19 @@ namespace osu.Game.Rulesets.Osu.Difficulty private int countMeh; private int countMiss; + /// + /// Missed slider ticks that includes missed reverse arrows. Will only be correct on non-classic scores + /// + private int countSliderTickMiss; + + /// + /// Amount of missed slider tails that don't break combo. Will only be correct on non-classic scores + /// + private int countSliderEndsDropped; + + /// + /// Estimated total amount of combo breaks + /// private double effectiveMissCount; public OsuPerformanceCalculator() @@ -34,13 +49,46 @@ namespace osu.Game.Rulesets.Osu.Difficulty { var osuAttributes = (OsuDifficultyAttributes)attributes; + usingClassicSliderAccuracy = score.Mods.OfType().Any(m => m.NoSliderHeadAccuracy.Value); + accuracy = score.Accuracy; scoreMaxCombo = score.MaxCombo; countGreat = score.Statistics.GetValueOrDefault(HitResult.Great); countOk = score.Statistics.GetValueOrDefault(HitResult.Ok); countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh); countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss); - effectiveMissCount = calculateEffectiveMissCount(osuAttributes); + countSliderEndsDropped = osuAttributes.SliderCount - score.Statistics.GetValueOrDefault(HitResult.SliderTailHit); + countSliderTickMiss = score.Statistics.GetValueOrDefault(HitResult.LargeTickMiss); + effectiveMissCount = countMiss; + + if (osuAttributes.SliderCount > 0) + { + if (usingClassicSliderAccuracy) + { + // Consider that full combo is maximum combo minus dropped slider tails since they don't contribute to combo but also don't break it + // In classic scores we can't know the amount of dropped sliders so we estimate to 10% of all sliders on the map + double fullComboThreshold = attributes.MaxCombo - 0.1 * osuAttributes.SliderCount; + + if (scoreMaxCombo < fullComboThreshold) + effectiveMissCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo); + + // In classic scores there can't be more misses than a sum of all non-perfect judgements + effectiveMissCount = Math.Min(effectiveMissCount, totalImperfectHits); + } + else + { + double fullComboThreshold = attributes.MaxCombo - countSliderEndsDropped; + + if (scoreMaxCombo < fullComboThreshold) + effectiveMissCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo); + + // Combine regular misses with tick misses since tick misses break combo as well + effectiveMissCount = Math.Min(effectiveMissCount, countSliderTickMiss + countMiss); + } + } + + effectiveMissCount = Math.Max(countMiss, effectiveMissCount); + effectiveMissCount = Math.Min(totalHits, effectiveMissCount); double multiplier = PERFORMANCE_BASE_MULTIPLIER; @@ -93,11 +141,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty (totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0); aimValue *= lengthBonus; - // Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses. if (effectiveMissCount > 0) - aimValue *= 0.97 * Math.Pow(1 - Math.Pow(effectiveMissCount / totalHits, 0.775), effectiveMissCount); - - aimValue *= getComboScalingFactor(attributes); + aimValue *= calculateMissPenalty(effectiveMissCount, attributes.AimDifficultStrainCount); double approachRateFactor = 0.0; if (attributes.ApproachRate > 10.33) @@ -123,8 +168,22 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (attributes.SliderCount > 0) { - double estimateSliderEndsDropped = Math.Clamp(Math.Min(countOk + countMeh + countMiss, attributes.MaxCombo - scoreMaxCombo), 0, estimateDifficultSliders); - double sliderNerfFactor = (1 - attributes.SliderFactor) * Math.Pow(1 - estimateSliderEndsDropped / estimateDifficultSliders, 3) + attributes.SliderFactor; + double estimateImproperlyFollowedDifficultSliders; + + if (usingClassicSliderAccuracy) + { + // When the score is considered classic (regardless if it was made on old client or not) we consider all missing combo to be dropped difficult sliders + int maximumPossibleDroppedSliders = totalImperfectHits; + estimateImproperlyFollowedDifficultSliders = Math.Clamp(Math.Min(maximumPossibleDroppedSliders, attributes.MaxCombo - scoreMaxCombo), 0, estimateDifficultSliders); + } + else + { + // We add tick misses here since they too mean that the player didn't follow the slider properly + // We however aren't adding misses here because missing slider heads has a harsh penalty by itself and doesn't mean that the rest of the slider wasn't followed properly + estimateImproperlyFollowedDifficultSliders = Math.Clamp(countSliderEndsDropped + countSliderTickMiss, 0, estimateDifficultSliders); + } + + double sliderNerfFactor = (1 - attributes.SliderFactor) * Math.Pow(1 - estimateImproperlyFollowedDifficultSliders / estimateDifficultSliders, 3) + attributes.SliderFactor; aimValue *= sliderNerfFactor; } @@ -146,11 +205,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty (totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0); speedValue *= lengthBonus; - // Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses. if (effectiveMissCount > 0) - speedValue *= 0.97 * Math.Pow(1 - Math.Pow(effectiveMissCount / totalHits, 0.775), Math.Pow(effectiveMissCount, .875)); - - speedValue *= getComboScalingFactor(attributes); + speedValue *= calculateMissPenalty(effectiveMissCount, attributes.SpeedDifficultStrainCount); double approachRateFactor = 0.0; if (attributes.ApproachRate > 10.33) @@ -177,7 +233,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty double relevantAccuracy = attributes.SpeedNoteCount == 0 ? 0 : (relevantCountGreat * 6.0 + relevantCountOk * 2.0 + relevantCountMeh) / (attributes.SpeedNoteCount * 6.0); // Scale the speed value with accuracy and OD. - speedValue *= (0.95 + Math.Pow(attributes.OverallDifficulty, 2) / 750) * Math.Pow((accuracy + relevantAccuracy) / 2.0, (14.5 - Math.Max(attributes.OverallDifficulty, 8)) / 2); + speedValue *= (0.95 + Math.Pow(attributes.OverallDifficulty, 2) / 750) * Math.Pow((accuracy + relevantAccuracy) / 2.0, (14.5 - attributes.OverallDifficulty) / 2); // Scale the speed value with # of 50s to punish doubletapping. speedValue *= Math.Pow(0.99, countMeh < totalHits / 500.0 ? 0 : countMeh - totalHits / 500.0); @@ -193,6 +249,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty // This percentage only considers HitCircles of any value - in this part of the calculation we focus on hitting the timing hit window. double betterAccuracyPercentage; int amountHitObjectsWithAccuracy = attributes.HitCircleCount; + if (!usingClassicSliderAccuracy) + amountHitObjectsWithAccuracy += attributes.SliderCount; if (amountHitObjectsWithAccuracy > 0) betterAccuracyPercentage = ((countGreat - (totalHits - amountHitObjectsWithAccuracy)) * 6 + countOk * 2 + countMeh) / (double)(amountHitObjectsWithAccuracy * 6); @@ -247,25 +305,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty return flashlightValue; } - private double calculateEffectiveMissCount(OsuDifficultyAttributes attributes) - { - // Guess the number of misses + slider breaks from combo - double comboBasedMissCount = 0.0; - - if (attributes.SliderCount > 0) - { - double fullComboThreshold = attributes.MaxCombo - 0.1 * attributes.SliderCount; - if (scoreMaxCombo < fullComboThreshold) - comboBasedMissCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo); - } - - // Clamp miss count to maximum amount of possible breaks - comboBasedMissCount = Math.Min(comboBasedMissCount, countOk + countMeh + countMiss); - - return Math.Max(countMiss, comboBasedMissCount); - } - + // Miss penalty assumes that a player will miss on the hardest parts of a map, + // so we use the amount of relatively difficult sections to adjust miss penalty + // to make it more punishing on maps with lower amount of hard sections. + private double calculateMissPenalty(double missCount, double difficultStrainCount) => 0.96 / ((missCount / (4 * Math.Pow(Math.Log(difficultStrainCount), 0.94))) + 1); private double getComboScalingFactor(OsuDifficultyAttributes attributes) => attributes.MaxCombo <= 0 ? 1.0 : Math.Min(Math.Pow(scoreMaxCombo, 0.8) / Math.Pow(attributes.MaxCombo, 0.8), 1.0); private int totalHits => countGreat + countOk + countMeh + countMiss; + private int totalImperfectHits => countOk + countMeh + countMiss; } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index 0e537632b1..5e4c5c1ee9 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -20,7 +20,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing /// public const int NORMALISED_RADIUS = 50; // Change radius to 50 to make 100 the diameter. Easier for mental maths. - private const int min_delta_time = 25; + public const int NORMALISED_DIAMETER = NORMALISED_RADIUS * 2; + + public const int MIN_DELTA_TIME = 25; + private const float maximum_slider_radius = NORMALISED_RADIUS * 2.4f; private const float assumed_slider_radius = NORMALISED_RADIUS * 1.8f; @@ -93,7 +96,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing this.lastObject = (OsuHitObject)lastObject; // Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects. - StrainTime = Math.Max(DeltaTime, min_delta_time); + StrainTime = Math.Max(DeltaTime, MIN_DELTA_TIME); if (BaseObject is Slider sliderObject) { @@ -136,6 +139,24 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing return Math.Clamp((time - fadeInStartTime) / fadeInDuration, 0.0, 1.0); } + /// + /// Returns how possible is it to doubletap this object together with the next one and get perfect judgement in range from 0 to 1 + /// + public double GetDoubletapness(OsuDifficultyHitObject? osuNextObj) + { + if (osuNextObj != null) + { + double currDeltaTime = Math.Max(1, DeltaTime); + double nextDeltaTime = Math.Max(1, osuNextObj.DeltaTime); + double deltaDifference = Math.Abs(nextDeltaTime - currDeltaTime); + double speedRatio = currDeltaTime / Math.Max(currDeltaTime, deltaDifference); + double windowRatio = Math.Pow(Math.Min(1, currDeltaTime / HitWindowGreat), 2); + return 1.0 - Math.Pow(speedRatio, 1 - windowRatio); + } + + return 0; + } + private void setDistances(double clockRate) { if (BaseObject is Slider currentSlider) @@ -143,7 +164,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing computeSliderCursorPosition(currentSlider); // Bonus for repeat sliders until a better per nested object strain system can be achieved. TravelDistance = currentSlider.LazyTravelDistance * (float)Math.Pow(1 + currentSlider.RepeatCount / 2.5, 1.0 / 2.5); - TravelTime = Math.Max(currentSlider.LazyTravelTime / clockRate, min_delta_time); + TravelTime = Math.Max(currentSlider.LazyTravelTime / clockRate, MIN_DELTA_TIME); } // We don't need to calculate either angle or distance when one of the last->curr objects is a spinner @@ -167,8 +188,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing if (lastObject is Slider lastSlider) { - double lastTravelTime = Math.Max(lastSlider.LazyTravelTime / clockRate, min_delta_time); - MinimumJumpTime = Math.Max(StrainTime - lastTravelTime, min_delta_time); + double lastTravelTime = Math.Max(lastSlider.LazyTravelTime / clockRate, MIN_DELTA_TIME); + MinimumJumpTime = Math.Max(StrainTime - lastTravelTime, MIN_DELTA_TIME); // // There are two types of slider-to-object patterns to consider in order to better approximate the real movement a player will take to jump between the hitobjects. diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 1fbe03395c..faf91e4652 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -23,7 +23,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills private double currentStrain; - private double skillMultiplier => 24.963; + private double skillMultiplier => 25.18; private double strainDecayBase => 0.15; private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index 0fe648c4e8..f87ec6ff8c 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -6,7 +6,6 @@ using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Evaluators; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; -using System.Collections.Generic; using System.Linq; namespace osu.Game.Rulesets.Osu.Difficulty.Skills @@ -22,8 +21,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills private double currentStrain; private double currentRhythm; - private readonly List objectStrains = new List(); - public Speed(Mod[] mods) : base(mods) { @@ -42,22 +39,19 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills double totalStrain = currentStrain * currentRhythm; - objectStrains.Add(totalStrain); - return totalStrain; } public double RelevantNoteCount() { - if (objectStrains.Count == 0) + if (ObjectStrains.Count == 0) return 0; - double maxStrain = objectStrains.Max(); - + double maxStrain = ObjectStrains.Max(); if (maxStrain == 0) return 0; - return objectStrains.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxStrain * 12.0 - 6.0)))); + return ObjectStrains.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxStrain * 12.0 - 6.0)))); } } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/GridPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/GridPlacementBlueprint.cs new file mode 100644 index 0000000000..163b42bcfd --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/GridPlacementBlueprint.cs @@ -0,0 +1,139 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; +using osu.Game.Rulesets.Edit; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Osu.Edit.Blueprints +{ + public partial class GridPlacementBlueprint : PlacementBlueprint + { + [Resolved] + private HitObjectComposer? hitObjectComposer { get; set; } + + private OsuGridToolboxGroup gridToolboxGroup = null!; + private Vector2 originalOrigin; + private float originalSpacing; + private float originalRotation; + + [BackgroundDependencyLoader] + private void load(OsuGridToolboxGroup gridToolboxGroup) + { + this.gridToolboxGroup = gridToolboxGroup; + originalOrigin = gridToolboxGroup.StartPosition.Value; + originalSpacing = gridToolboxGroup.Spacing.Value; + originalRotation = gridToolboxGroup.GridLinesRotation.Value; + } + + public override void EndPlacement(bool commit) + { + if (!commit && PlacementActive != PlacementState.Finished) + resetGridState(); + + base.EndPlacement(commit); + + // You typically only place the grid once, so we switch back to the last tool after placement. + if (commit && hitObjectComposer is OsuHitObjectComposer osuHitObjectComposer) + osuHitObjectComposer.SetLastTool(); + } + + protected override bool OnClick(ClickEvent e) + { + if (e.Button == MouseButton.Left) + { + switch (PlacementActive) + { + case PlacementState.Waiting: + BeginPlacement(true); + return true; + + case PlacementState.Active: + EndPlacement(true); + return true; + } + } + + return base.OnClick(e); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (e.Button == MouseButton.Right) + { + // Reset the grid to the default values. + gridToolboxGroup.StartPosition.Value = gridToolboxGroup.StartPosition.Default; + gridToolboxGroup.Spacing.Value = gridToolboxGroup.Spacing.Default; + if (!gridToolboxGroup.GridLinesRotation.Disabled) + gridToolboxGroup.GridLinesRotation.Value = gridToolboxGroup.GridLinesRotation.Default; + EndPlacement(true); + return true; + } + + return base.OnMouseDown(e); + } + + protected override bool OnDragStart(DragStartEvent e) + { + if (e.Button == MouseButton.Left) + { + BeginPlacement(true); + return true; + } + + return base.OnDragStart(e); + } + + protected override void OnDragEnd(DragEndEvent e) + { + if (PlacementActive == PlacementState.Active) + EndPlacement(true); + + base.OnDragEnd(e); + } + + public override SnapType SnapType => ~SnapType.GlobalGrids; + + public override void UpdateTimeAndPosition(SnapResult result) + { + if (State.Value == Visibility.Hidden) + return; + + var pos = ToLocalSpace(result.ScreenSpacePosition); + + if (PlacementActive != PlacementState.Active) + gridToolboxGroup.StartPosition.Value = pos; + else + { + // Default to the original spacing and rotation if the distance is too small. + if (Vector2.Distance(gridToolboxGroup.StartPosition.Value, pos) < 2) + { + gridToolboxGroup.Spacing.Value = originalSpacing; + if (!gridToolboxGroup.GridLinesRotation.Disabled) + gridToolboxGroup.GridLinesRotation.Value = originalRotation; + } + else + { + gridToolboxGroup.SetGridFromPoints(gridToolboxGroup.StartPosition.Value, pos); + } + } + } + + protected override void PopOut() + { + base.PopOut(); + resetGridState(); + } + + private void resetGridState() + { + gridToolboxGroup.StartPosition.Value = originalOrigin; + gridToolboxGroup.Spacing.Value = originalSpacing; + if (!gridToolboxGroup.GridLinesRotation.Disabled) + gridToolboxGroup.GridLinesRotation.Value = originalRotation; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs index df369dcef5..f114516300 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs @@ -107,7 +107,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components if (segment.Count == 0) return; - var first = segment[0]; + PathControlPoint first = segment[0]; if (first.Type != PathType.PERFECT_CURVE) return; @@ -178,7 +178,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components private bool isSplittable(PathControlPointPiece p) => // A hit object can only be split on control points which connect two different path segments. - p.ControlPoint.Type.HasValue && p != Pieces.FirstOrDefault() && p != Pieces.LastOrDefault(); + p.ControlPoint.Type.HasValue && p.ControlPoint != controlPoints.FirstOrDefault() && p.ControlPoint != controlPoints.LastOrDefault(); private void onControlPointsChanged(object sender, NotifyCollectionChangedEventArgs e) { @@ -273,10 +273,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components if (selectedPieces.Length != 1) return false; - var selectedPiece = selectedPieces.Single(); - var selectedPoint = selectedPiece.ControlPoint; + PathControlPointPiece selectedPiece = selectedPieces.Single(); + PathControlPoint selectedPoint = selectedPiece.ControlPoint; - var validTypes = path_types; + PathType?[] validTypes = path_types; if (selectedPoint == controlPoints[0]) validTypes = validTypes.Where(t => t != null).ToArray(); @@ -313,7 +313,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components if (Pieces.All(p => !p.IsSelected.Value)) return false; - var type = path_types[e.Key - Key.Number1]; + PathType? type = path_types[e.Key - Key.Number1]; // The first control point can never be inherit type if (Pieces[0].IsSelected.Value && type == null) @@ -333,6 +333,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components base.Dispose(isDisposing); foreach (var p in Pieces) p.ControlPoint.Changed -= controlPointChanged; + + if (draggedControlPointIndex >= 0) + DragEnded(); } private void selectionRequested(PathControlPointPiece piece, MouseButtonEvent e) @@ -353,9 +356,11 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components { changeHandler?.BeginChange(); + double originalDistance = hitObject.Path.Distance; + foreach (var p in Pieces.Where(p => p.IsSelected.Value)) { - var pointsInSegment = hitObject.Path.PointsInSegment(p.ControlPoint); + List pointsInSegment = hitObject.Path.PointsInSegment(p.ControlPoint); int indexInSegment = pointsInSegment.IndexOf(p.ControlPoint); if (type?.Type == SplineType.PerfectCurve) @@ -375,6 +380,11 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components EnsureValidPathTypes(); + if (hitObject.Path.Distance < originalDistance) + hitObject.SnapTo(distanceSnapProvider); + else + hitObject.Path.ExpectedDistance.Value = originalDistance; + changeHandler?.EndChange(); } @@ -385,7 +395,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components private Vector2[] dragStartPositions; private PathType?[] dragPathTypes; - private int draggedControlPointIndex; + private int draggedControlPointIndex = -1; private HashSet selectedControlPoints; private List curveTypeItems; @@ -405,14 +415,14 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components public void DragInProgress(DragEvent e) { Vector2[] oldControlPoints = hitObject.Path.ControlPoints.Select(cp => cp.Position).ToArray(); - var oldPosition = hitObject.Position; + Vector2 oldPosition = hitObject.Position; double oldStartTime = hitObject.StartTime; if (selectedControlPoints.Contains(hitObject.Path.ControlPoints[0])) { // Special handling for selections containing head control point - the position of the hit object changes which means the snapped position and time have to be taken into account Vector2 newHeadPosition = Parent!.ToScreenSpace(e.MousePosition + (dragStartPositions[0] - dragStartPositions[draggedControlPointIndex])); - var result = positionSnapProvider?.FindSnappedPositionAndTime(newHeadPosition); + SnapResult result = positionSnapProvider?.FindSnappedPositionAndTime(newHeadPosition); Vector2 movementDelta = Parent!.ToLocalSpace(result?.ScreenSpacePosition ?? newHeadPosition) - hitObject.Position; @@ -421,7 +431,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components for (int i = 1; i < hitObject.Path.ControlPoints.Count; i++) { - var controlPoint = hitObject.Path.ControlPoints[i]; + PathControlPoint controlPoint = hitObject.Path.ControlPoints[i]; // Since control points are relative to the position of the hit object, all points that are _not_ selected // need to be offset _back_ by the delta corresponding to the movement of the head point. // All other selected control points (if any) will move together with the head point @@ -432,13 +442,13 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components } else { - var result = positionSnapProvider?.FindSnappedPositionAndTime(Parent!.ToScreenSpace(e.MousePosition), SnapType.GlobalGrids); + SnapResult result = positionSnapProvider?.FindSnappedPositionAndTime(Parent!.ToScreenSpace(e.MousePosition), SnapType.GlobalGrids); Vector2 movementDelta = Parent!.ToLocalSpace(result?.ScreenSpacePosition ?? Parent!.ToScreenSpace(e.MousePosition)) - dragStartPositions[draggedControlPointIndex] - hitObject.Position; for (int i = 0; i < controlPoints.Count; ++i) { - var controlPoint = controlPoints[i]; + PathControlPoint controlPoint = controlPoints[i]; if (selectedControlPoints.Contains(controlPoint)) controlPoint.Position = dragStartPositions[i] + movementDelta; } @@ -466,7 +476,11 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components EnsureValidPathTypes(); } - public void DragEnded() => changeHandler?.EndChange(); + public void DragEnded() + { + changeHandler?.EndChange(); + draggedControlPointIndex = -1; + } #endregion @@ -488,8 +502,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components curveTypeItems = new List(); - foreach (PathType? type in path_types) + for (int i = 0; i < path_types.Length; ++i) { + PathType? type = path_types[i]; + // special inherit case if (type == null) { @@ -499,7 +515,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components curveTypeItems.Add(new OsuMenuItemSpacer()); } - curveTypeItems.Add(createMenuItemForPathType(type)); + curveTypeItems.Add(createMenuItemForPathType(type, InputKey.Number1 + i)); } if (selectedPieces.Any(piece => piece.ControlPoint.Type?.Type == SplineType.Catmull)) @@ -533,7 +549,15 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components return menuItems.ToArray(); - CurveTypeMenuItem createMenuItemForPathType(PathType? type) => new CurveTypeMenuItem(type, _ => updatePathTypeOfSelectedPieces(type)); + CurveTypeMenuItem createMenuItemForPathType(PathType? type, InputKey? key = null) + { + Hotkey hotkey = default; + + if (key != null) + hotkey = new Hotkey(new KeyCombination(InputKey.Alt, key.Value)); + + return new CurveTypeMenuItem(type, _ => updatePathTypeOfSelectedPieces(type)) { Hotkey = hotkey }; + } } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs index 6ffe27dc13..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; @@ -401,7 +408,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders if (state == SliderPlacementState.Drawing) HitObject.Path.ExpectedDistance.Value = (float)HitObject.Path.CalculatedDistance; else - HitObject.Path.ExpectedDistance.Value = distanceSnapProvider?.FindSnappedDistance(HitObject, (float)HitObject.Path.CalculatedDistance) ?? (float)HitObject.Path.CalculatedDistance; + HitObject.Path.ExpectedDistance.Value = distanceSnapProvider?.FindSnappedDistance(HitObject, (float)HitObject.Path.CalculatedDistance, DistanceSnapTarget.Start) ?? (float)HitObject.Path.CalculatedDistance; bodyPiece.UpdateFrom(HitObject); headCirclePiece.UpdateFrom(HitObject.HeadCircle); diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs index 1debb09099..34de81f1ba 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs @@ -11,6 +11,7 @@ using osu.Framework.Caching; using osu.Framework.Graphics; using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Utils; using osu.Game.Audio; @@ -177,6 +178,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders { base.OnDeselected(); + if (placementControlPoint != null) + endControlPointPlacement(); + updateVisualDefinition(); BodyPiece.RecyclePath(); } @@ -269,7 +273,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders { double minDistance = distanceSnapProvider?.GetBeatSnapDistanceAt(HitObject, false) * oldVelocityMultiplier ?? 1; // Add a small amount to the proposed distance to make it easier to snap to the full length of the slider. - proposedDistance = distanceSnapProvider?.FindSnappedDistance(HitObject, (float)proposedDistance + 1) ?? proposedDistance; + proposedDistance = distanceSnapProvider?.FindSnappedDistance(HitObject, (float)proposedDistance + 1, DistanceSnapTarget.Start) ?? proposedDistance; proposedDistance = MathHelper.Clamp(proposedDistance, minDistance, HitObject.Path.CalculatedDistance); } @@ -376,13 +380,16 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders protected override void OnMouseUp(MouseUpEvent e) { if (placementControlPoint != null) - { - if (IsDragged) - ControlPointVisualiser?.DragEnded(); + endControlPointPlacement(); + } - placementControlPoint = null; - changeHandler?.EndChange(); - } + private void endControlPointPlacement() + { + if (IsDragged) + ControlPointVisualiser?.DragEnded(); + + placementControlPoint = null; + changeHandler?.EndChange(); } protected override bool OnKeyDown(KeyDownEvent e) @@ -593,8 +600,14 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders changeHandler?.BeginChange(); addControlPoint(lastRightClickPosition); changeHandler?.EndChange(); - }), - new OsuMenuItem("Convert to stream", MenuItemType.Destructive, convertToStream), + }) + { + Hotkey = new Hotkey(new KeyCombination(InputKey.Control, InputKey.MouseLeft)) + }, + new OsuMenuItem("Convert to stream", MenuItemType.Destructive, convertToStream) + { + Hotkey = new Hotkey(new KeyCombination(InputKey.Control, InputKey.Shift, InputKey.F)) + }, }; // Always refer to the drawable object's slider body so subsequent movement deltas are calculated with updated positions. diff --git a/osu.Game.Rulesets.Osu/Edit/GridFromPointsTool.cs b/osu.Game.Rulesets.Osu/Edit/GridFromPointsTool.cs new file mode 100644 index 0000000000..626153a7fd --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/GridFromPointsTool.cs @@ -0,0 +1,29 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Tools; +using osu.Game.Rulesets.Osu.Edit.Blueprints; + +namespace osu.Game.Rulesets.Osu.Edit +{ + public partial class GridFromPointsTool : CompositionTool + { + public GridFromPointsTool() + : base("Grid") + { + TooltipText = """ + Left click to set the origin. + Left click again to set the spacing and rotation. + Right click to reset to default. + Click and drag to set the origin, spacing and rotation. + """; + } + + public override Drawable CreateIcon() => new SpriteIcon { Icon = FontAwesome.Solid.DraftingCompass }; + + public override PlacementBlueprint CreatePlacementBlueprint() => new GridPlacementBlueprint(); + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/OsuDistanceSnapProvider.cs b/osu.Game.Rulesets.Osu/Edit/OsuDistanceSnapProvider.cs index 522943df7d..4042cfa0e2 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuDistanceSnapProvider.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuDistanceSnapProvider.cs @@ -12,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.Edit { public partial class OsuDistanceSnapProvider : ComposerDistanceSnapProvider { - protected override double ReadCurrentDistanceSnap(HitObject before, HitObject after) + public override double ReadCurrentDistanceSnap(HitObject before, HitObject after) { float expectedDistance = DurationToDistance(before, after.StartTime - before.GetEndTime()); float actualDistance = Vector2.Distance(((OsuHitObject)before).EndPosition, ((OsuHitObject)after).Position); diff --git a/osu.Game.Rulesets.Osu/Edit/OsuGridToolboxGroup.cs b/osu.Game.Rulesets.Osu/Edit/OsuGridToolboxGroup.cs index 73ecb2fe7c..2fe0d51034 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuGridToolboxGroup.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuGridToolboxGroup.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 System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -37,7 +38,7 @@ namespace osu.Game.Rulesets.Osu.Edit { MinValue = 0f, MaxValue = OsuPlayfield.BASE_SIZE.X, - Precision = 1f + Precision = 0.01f, }; /// @@ -47,7 +48,7 @@ namespace osu.Game.Rulesets.Osu.Edit { MinValue = 0f, MaxValue = OsuPlayfield.BASE_SIZE.Y, - Precision = 1f + Precision = 0.01f, }; /// @@ -57,7 +58,7 @@ namespace osu.Game.Rulesets.Osu.Edit { MinValue = 4f, MaxValue = 128f, - Precision = 1f + Precision = 0.01f, }; /// @@ -67,14 +68,14 @@ namespace osu.Game.Rulesets.Osu.Edit { MinValue = -180f, MaxValue = 180f, - Precision = 1f + Precision = 0.01f, }; /// /// Read-only bindable representing the grid's origin. /// Equivalent to new Vector2(StartPositionX, StartPositionY) /// - public Bindable StartPosition { get; } = new Bindable(); + public Bindable StartPosition { get; } = new Bindable(OsuPlayfield.BASE_SIZE / 2); /// /// Read-only bindable representing the grid's spacing in both the X and Y dimension. @@ -97,6 +98,26 @@ namespace osu.Game.Rulesets.Osu.Edit private const float max_automatic_spacing = 64; + public void SetGridFromPoints(Vector2 point1, Vector2 point2) + { + StartPositionX.Value = point1.X; + StartPositionY.Value = point1.Y; + + // Get the angle between the two points and normalize to the valid range. + if (!GridLinesRotation.Disabled) + { + float period = GridLinesRotation.MaxValue - GridLinesRotation.MinValue; + GridLinesRotation.Value = normalizeRotation(MathHelper.RadiansToDegrees(MathF.Atan2(point2.Y - point1.Y, point2.X - point1.X)), period); + } + + // Divide the distance so that there is a good density of grid lines. + // This matches the maximum grid size of the grid size cycling hotkey. + float dist = Vector2.Distance(point1, point2); + while (dist >= max_automatic_spacing) + dist /= 2; + Spacing.Value = dist; + } + [BackgroundDependencyLoader] private void load() { @@ -160,22 +181,28 @@ namespace osu.Game.Rulesets.Osu.Edit StartPositionX.BindValueChanged(x => { - startPositionXSlider.ContractedLabelText = $"X: {x.NewValue:N0}"; - startPositionXSlider.ExpandedLabelText = $"X Offset: {x.NewValue:N0}"; + startPositionXSlider.ContractedLabelText = $"X: {x.NewValue:#,0.##}"; + startPositionXSlider.ExpandedLabelText = $"X Offset: {x.NewValue:#,0.##}"; StartPosition.Value = new Vector2(x.NewValue, StartPosition.Value.Y); }, true); StartPositionY.BindValueChanged(y => { - startPositionYSlider.ContractedLabelText = $"Y: {y.NewValue:N0}"; - startPositionYSlider.ExpandedLabelText = $"Y Offset: {y.NewValue:N0}"; + startPositionYSlider.ContractedLabelText = $"Y: {y.NewValue:#,0.##}"; + startPositionYSlider.ExpandedLabelText = $"Y Offset: {y.NewValue:#,0.##}"; StartPosition.Value = new Vector2(StartPosition.Value.X, y.NewValue); }, true); + StartPosition.BindValueChanged(pos => + { + StartPositionX.Value = pos.NewValue.X; + StartPositionY.Value = pos.NewValue.Y; + }); + Spacing.BindValueChanged(spacing => { - spacingSlider.ContractedLabelText = $"S: {spacing.NewValue:N0}"; - spacingSlider.ExpandedLabelText = $"Spacing: {spacing.NewValue:N0}"; + spacingSlider.ContractedLabelText = $"S: {spacing.NewValue:#,0.##}"; + spacingSlider.ExpandedLabelText = $"Spacing: {spacing.NewValue:#,0.##}"; SpacingVector.Value = new Vector2(spacing.NewValue); editorBeatmap.BeatmapInfo.GridSize = (int)spacing.NewValue; }, true); @@ -186,44 +213,50 @@ namespace osu.Game.Rulesets.Osu.Edit gridLinesRotationSlider.ExpandedLabelText = $"Rotation: {rotation.NewValue:#,0.##}"; }, true); - expandingContainer?.Expanded.BindValueChanged(v => - { - gridTypeButtons.FadeTo(v.NewValue ? 1f : 0f, 500, Easing.OutQuint); - gridTypeButtons.BypassAutoSizeAxes = !v.NewValue ? Axes.Y : Axes.None; - }, true); - GridType.BindValueChanged(v => { GridLinesRotation.Disabled = v.NewValue == PositionSnapGridType.Circle; + gridTypeButtons.Items[(int)v.NewValue].Select(); + switch (v.NewValue) { case PositionSnapGridType.Square: - GridLinesRotation.Value = ((GridLinesRotation.Value + 405) % 90) - 45; + GridLinesRotation.Value = normalizeRotation(GridLinesRotation.Value, 90); GridLinesRotation.MinValue = -45; GridLinesRotation.MaxValue = 45; break; case PositionSnapGridType.Triangle: - GridLinesRotation.Value = ((GridLinesRotation.Value + 390) % 60) - 30; + GridLinesRotation.Value = normalizeRotation(GridLinesRotation.Value, 60); GridLinesRotation.MinValue = -30; GridLinesRotation.MaxValue = 30; break; } }, true); + + expandingContainer?.Expanded.BindValueChanged(v => + { + gridTypeButtons.FadeTo(v.NewValue ? 1f : 0f, 500, Easing.OutQuint); + gridTypeButtons.BypassAutoSizeAxes = !v.NewValue ? Axes.Y : Axes.None; + }, true); } - private void nextGridSize() + private float normalizeRotation(float rotation, float period) { - Spacing.Value = Spacing.Value * 2 >= max_automatic_spacing ? Spacing.Value / 8 : Spacing.Value * 2; + return ((rotation + 360 + period * 0.5f) % period) - period * 0.5f; } public bool OnPressed(KeyBindingPressEvent e) { switch (e.Action) { - case GlobalAction.EditorCycleGridDisplayMode: - nextGridSize(); + case GlobalAction.EditorCycleGridSpacing: + Spacing.Value = Spacing.Value * 2 >= max_automatic_spacing ? Spacing.Value / 8 : Spacing.Value * 2; + return true; + + case GlobalAction.EditorCycleGridType: + GridType.Value = (PositionSnapGridType)(((int)GridType.Value + 1) % Enum.GetValues().Length); return true; } diff --git a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs index 2b5de8a5d8..7c50558b92 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs @@ -45,7 +45,8 @@ namespace osu.Game.Rulesets.Osu.Edit { new HitCircleCompositionTool(), new SliderCompositionTool(), - new SpinnerCompositionTool() + new SpinnerCompositionTool(), + new GridFromPointsTool() }; private readonly Bindable rectangularGridSnapToggle = new Bindable(); @@ -79,13 +80,12 @@ namespace osu.Game.Rulesets.Osu.Edit // Give a bit of breathing room around the playfield content. PlayfieldContentContainer.Padding = new MarginPadding(10); - LayerBelowRuleset.AddRange(new Drawable[] - { + LayerBelowRuleset.Add( distanceSnapGridContainer = new Container { RelativeSizeAxes = Axes.Both } - }); + ); selectedHitObjects = EditorBeatmap.SelectedHitObjects.GetBoundCopy(); selectedHitObjects.CollectionChanged += (_, _) => updateDistanceSnapGrid(); diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs index fc85865dd2..e3ab95c402 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs @@ -240,39 +240,74 @@ namespace osu.Game.Rulesets.Osu.Edit points = originalConvexHull!; foreach (var point in points) - { - scale = clampToBound(scale, point, Vector2.Zero); - scale = clampToBound(scale, point, OsuPlayfield.BASE_SIZE); - } + scale = clampToBounds(scale, point, Vector2.Zero, OsuPlayfield.BASE_SIZE); - return Vector2.ComponentMax(scale, new Vector2(Precision.FLOAT_EPSILON)); + return scale; - float minPositiveComponent(Vector2 v) => MathF.Min(v.X < 0 ? float.PositiveInfinity : v.X, v.Y < 0 ? float.PositiveInfinity : v.Y); - - Vector2 clampToBound(Vector2 s, Vector2 p, Vector2 bound) + // Clamps the scale vector s such that the point p scaled by s is within the rectangle defined by lowerBounds and upperBounds + Vector2 clampToBounds(Vector2 s, Vector2 p, Vector2 lowerBounds, Vector2 upperBounds) { p -= actualOrigin; - bound -= actualOrigin; + lowerBounds -= actualOrigin; + upperBounds -= actualOrigin; + // a.X is the rotated X component of p with respect to the X bounds + // a.Y is the rotated X component of p with respect to the Y bounds + // b.X is the rotated Y component of p with respect to the X bounds + // b.Y is the rotated Y component of p with respect to the Y bounds var a = new Vector2(cos * cos * p.X - sin * cos * p.Y, -sin * cos * p.X + sin * sin * p.Y); var b = new Vector2(sin * sin * p.X + sin * cos * p.Y, sin * cos * p.X + cos * cos * p.Y); + float sLowerBound, sUpperBound; + switch (adjustAxis) { case Axes.X: - s.X = MathF.Min(scale.X, minPositiveComponent(Vector2.Divide(bound - b, a))); + (sLowerBound, sUpperBound) = computeBounds(lowerBounds - b, upperBounds - b, a); + s.X = MathHelper.Clamp(s.X, sLowerBound, sUpperBound); break; case Axes.Y: - s.Y = MathF.Min(scale.Y, minPositiveComponent(Vector2.Divide(bound - a, b))); + (sLowerBound, sUpperBound) = computeBounds(lowerBounds - a, upperBounds - a, b); + s.Y = MathHelper.Clamp(s.Y, sLowerBound, sUpperBound); break; case Axes.Both: - s = Vector2.ComponentMin(s, s * minPositiveComponent(Vector2.Divide(bound, a * s.X + b * s.Y))); + // Here we compute the bounds for the magnitude multiplier of the scale vector + // Therefore the ratio s.X / s.Y will be maintained + (sLowerBound, sUpperBound) = computeBounds(lowerBounds, upperBounds, a * s.X + b * s.Y); + s.X = s.X < 0 + ? MathHelper.Clamp(s.X, s.X * sUpperBound, s.X * sLowerBound) + : MathHelper.Clamp(s.X, s.X * sLowerBound, s.X * sUpperBound); + s.Y = s.Y < 0 + ? MathHelper.Clamp(s.Y, s.Y * sUpperBound, s.Y * sLowerBound) + : MathHelper.Clamp(s.Y, s.Y * sLowerBound, s.Y * sUpperBound); break; } return s; } + + // Computes the bounds for the magnitude of the scaled point p with respect to the bounds lowerBounds and upperBounds + (float, float) computeBounds(Vector2 lowerBounds, Vector2 upperBounds, Vector2 p) + { + var sLowerBounds = Vector2.Divide(lowerBounds, p); + var sUpperBounds = Vector2.Divide(upperBounds, p); + + // If the point is negative, then the bounds are flipped + if (p.X < 0) + (sLowerBounds.X, sUpperBounds.X) = (sUpperBounds.X, sLowerBounds.X); + if (p.Y < 0) + (sLowerBounds.Y, sUpperBounds.Y) = (sUpperBounds.Y, sLowerBounds.Y); + + // If the point is at zero, then any scale will have no effect on the point so the bounds are infinite + // The float division would already give us infinity for the bounds, but the sign is not consistent so we have to manually set it + if (Precision.AlmostEquals(p.X, 0)) + (sLowerBounds.X, sUpperBounds.X) = (float.NegativeInfinity, float.PositiveInfinity); + if (Precision.AlmostEquals(p.Y, 0)) + (sLowerBounds.Y, sUpperBounds.Y) = (float.NegativeInfinity, float.PositiveInfinity); + + return (MathF.Max(sLowerBounds.X, sLowerBounds.Y), MathF.Min(sUpperBounds.X, sUpperBounds.Y)); + } } private void moveSelectionInBounds() diff --git a/osu.Game.Rulesets.Osu/Edit/PolygonGenerationPopover.cs b/osu.Game.Rulesets.Osu/Edit/PolygonGenerationPopover.cs index 6325de5851..695ff516b1 100644 --- a/osu.Game.Rulesets.Osu/Edit/PolygonGenerationPopover.cs +++ b/osu.Game.Rulesets.Osu/Edit/PolygonGenerationPopover.cs @@ -50,9 +50,16 @@ namespace osu.Game.Rulesets.Osu.Edit [Resolved] private HitObjectComposer composer { get; set; } = null!; + private Bindable newComboState = null!; + [BackgroundDependencyLoader] private void load() { + var selectionHandler = (EditorSelectionHandler)composer.BlueprintContainer.SelectionHandler; + newComboState = selectionHandler.SelectionNewComboState.GetBoundCopy(); + + AllowableAnchors = new[] { Anchor.CentreLeft, Anchor.CentreRight }; + Child = new FillFlowContainer { Width = 220, @@ -118,10 +125,11 @@ namespace osu.Game.Rulesets.Osu.Edit changeHandler?.BeginChange(); began = true; - distanceSnapInput.Current.BindValueChanged(_ => tryCreatePolygon()); - offsetAngleInput.Current.BindValueChanged(_ => tryCreatePolygon()); - repeatCountInput.Current.BindValueChanged(_ => tryCreatePolygon()); - pointInput.Current.BindValueChanged(_ => tryCreatePolygon()); + distanceSnapInput.Current.BindValueChanged(_ => Scheduler.AddOnce(tryCreatePolygon)); + offsetAngleInput.Current.BindValueChanged(_ => Scheduler.AddOnce(tryCreatePolygon)); + repeatCountInput.Current.BindValueChanged(_ => Scheduler.AddOnce(tryCreatePolygon)); + pointInput.Current.BindValueChanged(_ => Scheduler.AddOnce(tryCreatePolygon)); + newComboState.BindValueChanged(_ => Scheduler.AddOnce(tryCreatePolygon)); tryCreatePolygon(); } @@ -136,39 +144,69 @@ namespace osu.Game.Rulesets.Osu.Edit double length = distanceSnapInput.Current.Value * velocity * timeSpacing; float polygonRadius = (float)(length / (2 * Math.Sin(double.Pi / pointInput.Current.Value))); - editorBeatmap.RemoveRange(insertedCircles); - insertedCircles.Clear(); + int totalPoints = pointInput.Current.Value * repeatCountInput.Current.Value; - var selectionHandler = (EditorSelectionHandler)composer.BlueprintContainer.SelectionHandler; - bool first = true; - - for (int i = 1; i <= pointInput.Current.Value * repeatCountInput.Current.Value; ++i) + if (insertedCircles.Count > totalPoints) { - float angle = float.DegreesToRadians(offsetAngleInput.Current.Value) + i * (2 * float.Pi / pointInput.Current.Value); - var position = OsuPlayfield.BASE_SIZE / 2 + new Vector2(polygonRadius * float.Cos(angle), polygonRadius * float.Sin(angle)); + editorBeatmap.RemoveRange(insertedCircles.GetRange(totalPoints, insertedCircles.Count - totalPoints)); + insertedCircles.RemoveRange(totalPoints, insertedCircles.Count - totalPoints); + } - var circle = new HitCircle + var newlyAdded = new List(); + + for (int i = 0; i < totalPoints; ++i) + { + float angle = float.DegreesToRadians(offsetAngleInput.Current.Value) + (i + 1) * (2 * float.Pi / pointInput.Current.Value); + var position = OsuPlayfield.BASE_SIZE / 2 + new Vector2(polygonRadius * float.Cos(angle), polygonRadius * float.Sin(angle)); + bool newCombo = i == 0 && newComboState.Value == TernaryState.True; + + HitCircle circle; + + if (i < insertedCircles.Count) { - Position = position, - StartTime = startTime, - NewCombo = first && selectionHandler.SelectionNewComboState.Value == TernaryState.True, - }; - // TODO: probably ensure samples also follow current ternary status (not trivial) - circle.Samples.Add(circle.CreateHitSampleInfo()); + circle = insertedCircles[i]; + + circle.Position = position; + circle.StartTime = startTime; + circle.NewCombo = newCombo; + + editorBeatmap.Update(circle); + } + else + { + circle = new HitCircle + { + Position = position, + StartTime = startTime, + NewCombo = newCombo, + }; + + newlyAdded.Add(circle); + + // TODO: probably ensure samples also follow current ternary status (not trivial) + circle.Samples.Add(circle.CreateHitSampleInfo()); + } if (position.X < 0 || position.Y < 0 || position.X > OsuPlayfield.BASE_SIZE.X || position.Y > OsuPlayfield.BASE_SIZE.Y) { commitButton.Enabled.Value = false; + editorBeatmap.RemoveRange(insertedCircles); + insertedCircles.Clear(); return; } - insertedCircles.Add(circle); startTime = beatSnapProvider.SnapTime(startTime + timeSpacing); - - first = false; } - editorBeatmap.AddRange(insertedCircles); + var previousNewComboState = newComboState.Value; + + insertedCircles.AddRange(newlyAdded); + editorBeatmap.AddRange(newlyAdded); + + // When adding new hitObjects, newCombo state will get reset to false when no objects are selected. + // Since this is the case when this popover is showing, we need to restore the previous newCombo state + newComboState.Value = previousNewComboState; + commitButton.Enabled.Value = true; } diff --git a/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs b/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs index 352debf500..477d3b4e57 100644 --- a/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs +++ b/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs @@ -5,10 +5,14 @@ using System; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; 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; using osu.Game.Screens.Edit.Components.RadioButtons; using osu.Game.Screens.Edit.Compose.Components; @@ -22,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; @@ -38,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, @@ -55,6 +65,7 @@ namespace osu.Game.Rulesets.Osu.Edit MaxValue = 360, Precision = 1 }, + KeyboardStep = 1f, Instantaneous = true }, rotationOrigin = new EditorRadioButtonCollection @@ -62,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 }) } } @@ -91,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)); @@ -107,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)) }; @@ -126,14 +187,18 @@ namespace osu.Game.Rulesets.Osu.Edit if (IsLoaded) rotationHandler.Commit(); } + + public override bool OnPressed(KeyBindingPressEvent e) + { + if (e.Action == GlobalAction.Select && !e.Repeat) + { + this.HidePopover(); + return true; + } + + return base.OnPressed(e); + } } - 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 33b0c14185..e728290289 100644 --- a/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs +++ b/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs @@ -5,16 +5,21 @@ using System; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; 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; using osu.Game.Rulesets.Objects; 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 @@ -25,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!; @@ -38,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) @@ -49,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, @@ -64,12 +73,13 @@ namespace osu.Game.Rulesets.Osu.Edit { Current = scaleInputBindable = new BindableNumber { - MinValue = 0.5f, + MinValue = 0.05f, MaxValue = 2, Precision = 0.001f, Value = 1, Default = 1, }, + KeyboardStep = 0.01f, Instantaneous = true }, scaleOrigin = new EditorRadioButtonCollection @@ -78,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 }) } }, @@ -136,14 +146,81 @@ namespace osu.Game.Rulesets.Osu.Edit }); scaleInput.Current.BindValueChanged(scale => scaleInfo.Value = scaleInfo.Value with { Scale = scale.NewValue }); - xCheckBox.Current.BindValueChanged(x => setAxis(x.NewValue, yCheckBox.Current.Value)); - yCheckBox.Current.BindValueChanged(y => setAxis(xCheckBox.Current.Value, y.NewValue)); + xCheckBox.Current.BindValueChanged(_ => + { + if (!xCheckBox.Current.Value && !yCheckBox.Current.Value) + { + yCheckBox.Current.Value = true; + return; + } + + updateAxes(); + }); + yCheckBox.Current.BindValueChanged(_ => + { + if (!xCheckBox.Current.Value && !yCheckBox.Current.Value) + { + xCheckBox.Current.Value = true; + return; + } + + updateAxes(); + }); selectionCentreButton.Selected.Disabled = !(scaleHandler.CanScaleX.Value || scaleHandler.CanScaleY.Value); 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 => { @@ -152,9 +229,15 @@ namespace osu.Game.Rulesets.Osu.Edit }); } + private void updateAxes() + { + scaleInfo.Value = scaleInfo.Value with { XAxis = xCheckBox.Current.Value, YAxis = yCheckBox.Current.Value }; + updateMinMaxScale(); + } + private void updateAxisCheckBoxesEnabled() { - if (scaleInfo.Value.Origin != ScaleOrigin.SelectionCentre) + if (scaleInfo.Value.Origin != EditorOrigin.SelectionCentre) { toggleAxisAvailable(xCheckBox.Current, true); toggleAxisAvailable(yCheckBox.Current, true); @@ -175,12 +258,14 @@ namespace osu.Game.Rulesets.Osu.Edit axisBindable.Disabled = !available; } - private void updateMaxScale() + private void updateMinMaxScale() { if (!scaleHandler.OriginalSurroundingQuad.HasValue) return; + 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)); if (!scaleInfo.Value.XAxis) @@ -189,12 +274,21 @@ namespace osu.Game.Rulesets.Osu.Edit scale.Y = max_scale; scaleInputBindable.MaxValue = MathF.Max(1, MathF.Min(scale.X, scale.Y)); + + scale = scaleHandler.ClampScaleToPlayfieldBounds(new Vector2(min_scale), getOriginPosition(scaleInfo.Value), getAdjustAxis(scaleInfo.Value), getRotation(scaleInfo.Value)); + + if (!scaleInfo.Value.XAxis) + scale.X = min_scale; + if (!scaleInfo.Value.YAxis) + scale.Y = min_scale; + + 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 }; - updateMaxScale(); + updateMinMaxScale(); updateAxisCheckBoxesEnabled(); } @@ -202,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; @@ -219,21 +313,26 @@ namespace osu.Game.Rulesets.Osu.Edit } } - private Axes getAdjustAxis(PreciseScaleInfo scale) => scale.XAxis ? scale.YAxis ? Axes.Both : Axes.X : Axes.Y; - - private float getRotation(PreciseScaleInfo scale) => scale.Origin == ScaleOrigin.GridCentre ? gridToolbox.GridLinesRotation.Value : 0; - - private void setAxis(bool x, bool y) + private Axes getAdjustAxis(PreciseScaleInfo scale) { - scaleInfo.Value = scaleInfo.Value with { XAxis = x, YAxis = y }; - updateMaxScale(); + var result = Axes.None; + + if (scale.XAxis) + result |= Axes.X; + + if (scale.YAxis) + result |= Axes.Y; + + return result; } + private float getRotation(PreciseScaleInfo scale) => scale.Origin == EditorOrigin.GridCentre ? gridToolbox.GridLinesRotation.Value : 0; + protected override void PopIn() { base.PopIn(); scaleHandler.Begin(); - updateMaxScale(); + updateMinMaxScale(); } protected override void PopOut() @@ -242,14 +341,18 @@ namespace osu.Game.Rulesets.Osu.Edit if (IsLoaded) scaleHandler.Commit(); } + + public override bool OnPressed(KeyBindingPressEvent e) + { + if (e.Action == GlobalAction.Select && !e.Repeat) + { + this.HidePopover(); + return true; + } + + return base.OnPressed(e); + } } - 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.Osu/Edit/Setup/OsuDifficultySection.cs b/osu.Game.Rulesets.Osu/Edit/Setup/OsuDifficultySection.cs index b61faa0ae9..7a01646b35 100644 --- a/osu.Game.Rulesets.Osu/Edit/Setup/OsuDifficultySection.cs +++ b/osu.Game.Rulesets.Osu/Edit/Setup/OsuDifficultySection.cs @@ -16,13 +16,13 @@ namespace osu.Game.Rulesets.Osu.Edit.Setup { public partial class OsuDifficultySection : SetupSection { - private LabelledSliderBar circleSizeSlider { get; set; } = null!; - private LabelledSliderBar healthDrainSlider { get; set; } = null!; - private LabelledSliderBar approachRateSlider { get; set; } = null!; - private LabelledSliderBar overallDifficultySlider { get; set; } = null!; - private LabelledSliderBar baseVelocitySlider { get; set; } = null!; - private LabelledSliderBar tickRateSlider { get; set; } = null!; - private LabelledSliderBar stackLeniency { get; set; } = null!; + private FormSliderBar circleSizeSlider { get; set; } = null!; + private FormSliderBar healthDrainSlider { get; set; } = null!; + private FormSliderBar approachRateSlider { get; set; } = null!; + private FormSliderBar overallDifficultySlider { get; set; } = null!; + private FormSliderBar baseVelocitySlider { get; set; } = null!; + private FormSliderBar tickRateSlider { get; set; } = null!; + private FormSliderBar stackLeniency { get; set; } = null!; public override LocalisableString Title => EditorSetupStrings.DifficultyHeader; @@ -31,103 +31,110 @@ namespace osu.Game.Rulesets.Osu.Edit.Setup { Children = new Drawable[] { - circleSizeSlider = new LabelledSliderBar + circleSizeSlider = new FormSliderBar { - Label = BeatmapsetsStrings.ShowStatsCs, - FixedLabelWidth = LABEL_WIDTH, - Description = EditorSetupStrings.CircleSizeDescription, + Caption = BeatmapsetsStrings.ShowStatsCs, + HintText = EditorSetupStrings.CircleSizeDescription, Current = new BindableFloat(Beatmap.Difficulty.CircleSize) { Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, MinValue = 0, MaxValue = 10, Precision = 0.1f, - } + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, }, - healthDrainSlider = new LabelledSliderBar + healthDrainSlider = new FormSliderBar { - Label = BeatmapsetsStrings.ShowStatsDrain, - FixedLabelWidth = LABEL_WIDTH, - Description = EditorSetupStrings.DrainRateDescription, + Caption = BeatmapsetsStrings.ShowStatsDrain, + HintText = EditorSetupStrings.DrainRateDescription, Current = new BindableFloat(Beatmap.Difficulty.DrainRate) { Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, MinValue = 0, MaxValue = 10, Precision = 0.1f, - } + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, }, - approachRateSlider = new LabelledSliderBar + approachRateSlider = new FormSliderBar { - Label = BeatmapsetsStrings.ShowStatsAr, - FixedLabelWidth = LABEL_WIDTH, - Description = EditorSetupStrings.ApproachRateDescription, + Caption = BeatmapsetsStrings.ShowStatsAr, + HintText = EditorSetupStrings.ApproachRateDescription, Current = new BindableFloat(Beatmap.Difficulty.ApproachRate) { Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, MinValue = 0, MaxValue = 10, Precision = 0.1f, - } + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, }, - overallDifficultySlider = new LabelledSliderBar + overallDifficultySlider = new FormSliderBar { - Label = BeatmapsetsStrings.ShowStatsAccuracy, - FixedLabelWidth = LABEL_WIDTH, - Description = EditorSetupStrings.OverallDifficultyDescription, + Caption = BeatmapsetsStrings.ShowStatsAccuracy, + HintText = EditorSetupStrings.OverallDifficultyDescription, Current = new BindableFloat(Beatmap.Difficulty.OverallDifficulty) { Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, MinValue = 0, MaxValue = 10, Precision = 0.1f, - } + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, }, - baseVelocitySlider = new LabelledSliderBar + baseVelocitySlider = new FormSliderBar { - Label = EditorSetupStrings.BaseVelocity, - FixedLabelWidth = LABEL_WIDTH, - Description = EditorSetupStrings.BaseVelocityDescription, + Caption = EditorSetupStrings.BaseVelocity, + HintText = EditorSetupStrings.BaseVelocityDescription, Current = new BindableDouble(Beatmap.Difficulty.SliderMultiplier) { Default = 1.4, MinValue = 0.4, MaxValue = 3.6, Precision = 0.01f, - } + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, }, - tickRateSlider = new LabelledSliderBar + tickRateSlider = new FormSliderBar { - Label = EditorSetupStrings.TickRate, - FixedLabelWidth = LABEL_WIDTH, - Description = EditorSetupStrings.TickRateDescription, + Caption = EditorSetupStrings.TickRate, + HintText = EditorSetupStrings.TickRateDescription, Current = new BindableDouble(Beatmap.Difficulty.SliderTickRate) { Default = 1, MinValue = 1, MaxValue = 4, Precision = 1, - } + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, }, - stackLeniency = new LabelledSliderBar + stackLeniency = new FormSliderBar { - Label = "Stack Leniency", - FixedLabelWidth = LABEL_WIDTH, - Description = "In play mode, osu! automatically stacks notes which occur at the same location. Increasing this value means it is more likely to snap notes of further time-distance.", + Caption = "Stack Leniency", + HintText = "In play mode, osu! automatically stacks notes which occur at the same location. Increasing this value means it is more likely to snap notes of further time-distance.", Current = new BindableFloat(Beatmap.BeatmapInfo.StackLeniency) { Default = 0.7f, MinValue = 0, MaxValue = 1, Precision = 0.1f - } + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, }, }; - foreach (var item in Children.OfType>()) + foreach (var item in Children.OfType>()) item.Current.ValueChanged += _ => updateValues(); - foreach (var item in Children.OfType>()) + foreach (var item in Children.OfType>()) item.Current.ValueChanged += _ => updateValues(); } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModBarrelRoll.cs b/osu.Game.Rulesets.Osu/Mods/OsuModBarrelRoll.cs index 2394cf92fc..8898faf7b8 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModBarrelRoll.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModBarrelRoll.cs @@ -2,10 +2,13 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Diagnostics; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Osu.Mods { @@ -25,5 +28,14 @@ namespace osu.Game.Rulesets.Osu.Mods } }; } + + public override void Update(Playfield playfield) + { + base.Update(playfield); + OsuPlayfield osuPlayfield = (OsuPlayfield)playfield; + Debug.Assert(osuPlayfield.Cursor != null); + + osuPlayfield.Cursor.ActiveCursor.Rotation = -CurrentRotation; + } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModBloom.cs b/osu.Game.Rulesets.Osu/Mods/OsuModBloom.cs new file mode 100644 index 0000000000..c674074dc6 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Mods/OsuModBloom.cs @@ -0,0 +1,85 @@ +// 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.Bindables; +using osu.Framework.Localisation; +using osu.Framework.Utils; +using osu.Game.Configuration; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.UI.Cursor; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; +using osu.Game.Scoring; +using osu.Game.Screens.Play; + +namespace osu.Game.Rulesets.Osu.Mods +{ + public class OsuModBloom : Mod, IApplicableToScoreProcessor, IUpdatableByPlayfield, IApplicableToPlayer + { + public override string Name => "Bloom"; + public override string Acronym => "BM"; + public override ModType Type => ModType.Fun; + public override LocalisableString Description => "The cursor blooms into.. a larger cursor!"; + public override double ScoreMultiplier => 1; + protected const float MIN_SIZE = 1; + protected const float TRANSITION_DURATION = 100; + public override Type[] IncompatibleMods => new[] { typeof(OsuModFlashlight), typeof(OsuModNoScope), typeof(ModTouchDevice) }; + + protected readonly BindableNumber CurrentCombo = new BindableInt(); + protected readonly IBindable IsBreakTime = new Bindable(); + + private float currentSize; + + [SettingSource( + "Max size at combo", + "The combo count at which the cursor reaches its maximum size", + SettingControlType = typeof(SettingsSlider>) + )] + public BindableInt MaxSizeComboCount { get; } = new BindableInt(50) + { + MinValue = 5, + MaxValue = 100, + }; + + [SettingSource( + "Final size multiplier", + "The multiplier applied to cursor size when combo reaches maximum", + SettingControlType = typeof(SettingsSlider>) + )] + public BindableFloat MaxCursorSize { get; } = new BindableFloat(10f) + { + MinValue = 5f, + MaxValue = 15f, + Precision = 0.5f, + }; + + public ScoreRank AdjustRank(ScoreRank rank, double accuracy) => rank; + + public void ApplyToPlayer(Player player) + { + IsBreakTime.BindTo(player.IsBreakTime); + } + + public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) + { + CurrentCombo.BindTo(scoreProcessor.Combo); + CurrentCombo.BindValueChanged(combo => + { + currentSize = Math.Clamp(MaxCursorSize.Value * ((float)combo.NewValue / MaxSizeComboCount.Value), MIN_SIZE, MaxCursorSize.Value); + }, true); + } + + public void Update(Playfield playfield) + { + OsuCursor cursor = (OsuCursor)(playfield.Cursor!.ActiveCursor); + + if (IsBreakTime.Value) + cursor.ModScaleAdjust.Value = 1; + else + cursor.ModScaleAdjust.Value = (float)Interpolation.Lerp(cursor.ModScaleAdjust.Value, currentSize, Math.Clamp(cursor.Time.Elapsed / TRANSITION_DURATION, 0, 1)); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs b/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs index 5a6cc50082..3009530b50 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs @@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Osu.Mods public partial class OsuModFlashlight : ModFlashlight, IApplicableToDrawableHitObject { public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.12 : 1; - public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(OsuModBlinds)).ToArray(); + public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModBloom), typeof(OsuModBlinds) }).ToArray(); private const double default_follow_delay = 120; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModNoScope.cs b/osu.Game.Rulesets.Osu/Mods/OsuModNoScope.cs index d1bbae8e1a..57d540a7d4 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModNoScope.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModNoScope.cs @@ -20,6 +20,8 @@ namespace osu.Game.Rulesets.Osu.Mods { public override LocalisableString Description => "Where's the cursor?"; + public override Type[] IncompatibleMods => new[] { typeof(OsuModBloom) }; + private PeriodTracker spinnerPeriods = null!; public override BindableInt HiddenComboCount { get; } = new BindableInt(10) diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs b/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs index 2c9292c58b..7d2fd628f6 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs @@ -120,6 +120,7 @@ namespace osu.Game.Rulesets.Osu.Mods Position = Position + Path.PositionAt(e.PathProgress), StackHeight = StackHeight, Scale = Scale, + PathProgress = e.PathProgress, }); break; @@ -150,6 +151,7 @@ namespace osu.Game.Rulesets.Osu.Mods Position = Position + Path.PositionAt(e.PathProgress), StackHeight = StackHeight, Scale = Scale, + PathProgress = e.PathProgress, }); break; } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTouchDevice.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTouchDevice.cs index 917685cdad..a364190a00 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTouchDevice.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTouchDevice.cs @@ -9,7 +9,7 @@ namespace osu.Game.Rulesets.Osu.Mods { public class OsuModTouchDevice : ModTouchDevice { - public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAutopilot) }).ToArray(); + public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAutopilot), typeof(OsuModBloom) }).ToArray(); public override bool Ranked => UsesDefaultConfiguration; } } diff --git a/osu.Game.Rulesets.Osu/Objects/Slider.cs b/osu.Game.Rulesets.Osu/Objects/Slider.cs index 2b3bb18844..e484efb408 100644 --- a/osu.Game.Rulesets.Osu/Objects/Slider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Slider.cs @@ -204,6 +204,7 @@ namespace osu.Game.Rulesets.Osu.Objects SpanStartTime = e.SpanStartTime, StartTime = e.Time, Position = Position + Path.PositionAt(e.PathProgress), + PathProgress = e.PathProgress, StackHeight = StackHeight, }); break; @@ -236,6 +237,7 @@ namespace osu.Game.Rulesets.Osu.Objects StartTime = StartTime + (e.SpanIndex + 1) * SpanDuration, Position = Position + Path.PositionAt(e.PathProgress), StackHeight = StackHeight, + PathProgress = e.PathProgress, }); break; } @@ -248,14 +250,27 @@ namespace osu.Game.Rulesets.Osu.Objects { endPositionCache.Invalidate(); - if (HeadCircle != null) - HeadCircle.Position = Position; + foreach (var nested in NestedHitObjects) + { + switch (nested) + { + case SliderHeadCircle headCircle: + headCircle.Position = Position; + break; - if (TailCircle != null) - TailCircle.Position = EndPosition; + case SliderTailCircle tailCircle: + tailCircle.Position = EndPosition; + break; - if (LastRepeat != null) - LastRepeat.Position = RepeatCount % 2 == 0 ? Position : Position + Path.PositionAt(1); + case SliderRepeat repeat: + repeat.Position = Position + Path.PositionAt(repeat.PathProgress); + break; + + case SliderTick tick: + tick.Position = Position + Path.PositionAt(tick.PathProgress); + break; + } + } } protected void UpdateNestedSamples() diff --git a/osu.Game.Rulesets.Osu/Objects/SliderRepeat.cs b/osu.Game.Rulesets.Osu/Objects/SliderRepeat.cs index e95cfd369d..1bbd1e8070 100644 --- a/osu.Game.Rulesets.Osu/Objects/SliderRepeat.cs +++ b/osu.Game.Rulesets.Osu/Objects/SliderRepeat.cs @@ -5,6 +5,8 @@ namespace osu.Game.Rulesets.Osu.Objects { public class SliderRepeat : SliderEndCircle { + public double PathProgress { get; set; } + public SliderRepeat(Slider slider) : base(slider) { diff --git a/osu.Game.Rulesets.Osu/Objects/SliderTick.cs b/osu.Game.Rulesets.Osu/Objects/SliderTick.cs index 74ec4d6eb3..219c2be00b 100644 --- a/osu.Game.Rulesets.Osu/Objects/SliderTick.cs +++ b/osu.Game.Rulesets.Osu/Objects/SliderTick.cs @@ -13,6 +13,7 @@ namespace osu.Game.Rulesets.Osu.Objects { public int SpanIndex { get; set; } public double SpanStartTime { get; set; } + public double PathProgress { get; set; } protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty) { diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index be48ef9acc..25b1dd9b12 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; using osu.Framework.Localisation; @@ -39,6 +40,7 @@ using osu.Game.Scoring; using osu.Game.Screens.Edit.Setup; using osu.Game.Screens.Ranking.Statistics; using osu.Game.Skinning; +using osuTK; namespace osu.Game.Rulesets.Osu { @@ -212,7 +214,8 @@ namespace osu.Game.Rulesets.Osu new OsuModFreezeFrame(), new OsuModBubbles(), new OsuModSynesthesia(), - new OsuModDepth() + new OsuModDepth(), + new OsuModBloom() }; case ModType.System: @@ -336,10 +339,28 @@ namespace osu.Game.Rulesets.Osu }; } - public override IEnumerable CreateEditorSetupSections() => + public override IEnumerable CreateEditorSetupSections() => [ + new MetadataSection(), new OsuDifficultySection(), - new ColoursSection(), + new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(SetupScreen.SPACING), + Children = new Drawable[] + { + new ResourcesSection + { + RelativeSizeAxes = Axes.X, + }, + new ColoursSection + { + RelativeSizeAxes = Axes.X, + } + } + }, + new DesignSection(), ]; /// diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs b/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs index a4bccb0aff..5132dc2859 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs @@ -14,6 +14,7 @@ using osu.Framework.Graphics.Rendering.Vertices; using osu.Framework.Graphics.Shaders; using osu.Framework.Graphics.Shaders.Types; using osu.Framework.Graphics.Textures; +using osu.Framework.Graphics.Visualisation; using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Framework.Timing; @@ -23,6 +24,7 @@ using osuTK.Graphics.ES30; namespace osu.Game.Rulesets.Osu.UI.Cursor { + [DrawVisualiserHidden] public partial class CursorTrail : Drawable, IRequireHighFrequencyMousePosition { private const int max_sprites = 2048; diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs index 0bb316e0aa..c2f7d84f5e 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs @@ -38,6 +38,11 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor public IBindable CursorScale => cursorScale; + /// + /// Mods which want to adjust cursor size should do so via this bindable. + /// + public readonly Bindable ModScaleAdjust = new Bindable(1); + private readonly Bindable cursorScale = new BindableFloat(1); private Bindable userCursorScale = null!; @@ -67,6 +72,8 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor autoCursorScale = config.GetBindable(OsuSetting.AutoCursorSize); autoCursorScale.ValueChanged += _ => cursorScale.Value = CalculateCursorScale(); + ModScaleAdjust.ValueChanged += _ => cursorScale.Value = CalculateCursorScale(); + cursorScale.BindValueChanged(e => cursorScaleContainer.Scale = new Vector2(e.NewValue), true); } @@ -90,7 +97,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor protected virtual float CalculateCursorScale() { - float scale = userCursorScale.Value; + float scale = userCursorScale.Value * ModScaleAdjust.Value; if (autoCursorScale.Value && state != null) { diff --git a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs index e936c24c08..f27624a633 100644 --- a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs +++ b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs @@ -3,7 +3,6 @@ using System; using System.Linq; -using osu.Framework.Extensions.IEnumerableExtensions; using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Beatmaps; @@ -117,10 +116,9 @@ namespace osu.Game.Rulesets.Osu.Utils if (osuObject is not Slider slider) return; - void reflectNestedObject(OsuHitObject nested) => nested.Position = new Vector2(OsuPlayfield.BASE_SIZE.X - nested.Position.X, nested.Position.Y); static void reflectControlPoint(PathControlPoint point) => point.Position = new Vector2(-point.Position.X, point.Position.Y); - modifySlider(slider, reflectNestedObject, reflectControlPoint); + modifySlider(slider, reflectControlPoint); } /// @@ -134,10 +132,9 @@ namespace osu.Game.Rulesets.Osu.Utils if (osuObject is not Slider slider) return; - void reflectNestedObject(OsuHitObject nested) => nested.Position = new Vector2(nested.Position.X, OsuPlayfield.BASE_SIZE.Y - nested.Position.Y); static void reflectControlPoint(PathControlPoint point) => point.Position = new Vector2(point.Position.X, -point.Position.Y); - modifySlider(slider, reflectNestedObject, reflectControlPoint); + modifySlider(slider, reflectControlPoint); } /// @@ -146,10 +143,9 @@ namespace osu.Game.Rulesets.Osu.Utils /// The slider to be flipped. public static void FlipSliderInPlaceHorizontally(Slider slider) { - void flipNestedObject(OsuHitObject nested) => nested.Position = new Vector2(slider.X - (nested.X - slider.X), nested.Y); static void flipControlPoint(PathControlPoint point) => point.Position = new Vector2(-point.Position.X, point.Position.Y); - modifySlider(slider, flipNestedObject, flipControlPoint); + modifySlider(slider, flipControlPoint); } /// @@ -159,18 +155,13 @@ namespace osu.Game.Rulesets.Osu.Utils /// The angle, measured in radians, to rotate the slider by. public static void RotateSlider(Slider slider, float rotation) { - void rotateNestedObject(OsuHitObject nested) => nested.Position = rotateVector(nested.Position - slider.Position, rotation) + slider.Position; void rotateControlPoint(PathControlPoint point) => point.Position = rotateVector(point.Position, rotation); - modifySlider(slider, rotateNestedObject, rotateControlPoint); + modifySlider(slider, rotateControlPoint); } - private static void modifySlider(Slider slider, Action modifyNestedObject, Action modifyControlPoint) + private static void modifySlider(Slider slider, Action modifyControlPoint) { - // No need to update the head and tail circles, since slider handles that when the new slider path is set - slider.NestedHitObjects.OfType().ForEach(modifyNestedObject); - slider.NestedHitObjects.OfType().ForEach(modifyNestedObject); - var controlPoints = slider.Path.ControlPoints.Select(p => new PathControlPoint(p.Position, p.Type)).ToArray(); foreach (var point in controlPoints) modifyControlPoint(point); diff --git a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs index a9ae313a31..7073abbc89 100644 --- a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs +++ b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs @@ -232,8 +232,6 @@ namespace osu.Game.Rulesets.Osu.Utils slider.Position = workingObject.PositionModified = new Vector2(newX, newY); workingObject.EndPositionModified = slider.EndPosition; - shiftNestedObjects(slider, workingObject.PositionModified - workingObject.PositionOriginal); - return workingObject.PositionModified - previousPosition; } @@ -307,22 +305,6 @@ namespace osu.Game.Rulesets.Osu.Utils return new RectangleF(left, top, right - left, bottom - top); } - /// - /// Shifts all nested s and s by the specified shift. - /// - /// whose nested s and s should be shifted - /// The the 's nested s and s should be shifted by - private static void shiftNestedObjects(Slider slider, Vector2 shift) - { - foreach (var hitObject in slider.NestedHitObjects.Where(o => o is SliderTick || o is SliderRepeat)) - { - if (!(hitObject is OsuHitObject osuHitObject)) - continue; - - osuHitObject.Position += shift; - } - } - /// /// Clamp a position to playfield, keeping a specified distance from the edges. /// @@ -431,7 +413,6 @@ namespace osu.Game.Rulesets.Osu.Utils private class WorkingObject { public float RotationOriginal { get; } - public Vector2 PositionOriginal { get; } public Vector2 PositionModified { get; set; } public Vector2 EndPositionModified { get; set; } @@ -442,7 +423,7 @@ namespace osu.Game.Rulesets.Osu.Utils { PositionInfo = positionInfo; RotationOriginal = HitObject is Slider slider ? getSliderRotation(slider) : 0; - PositionModified = PositionOriginal = HitObject.Position; + PositionModified = HitObject.Position; EndPositionModified = HitObject.EndPosition; } } diff --git a/osu.Game.Rulesets.Taiko.Tests.iOS/osu.Game.Rulesets.Taiko.Tests.iOS.csproj b/osu.Game.Rulesets.Taiko.Tests.iOS/osu.Game.Rulesets.Taiko.Tests.iOS.csproj index ee2d4d703e..46ac59a1e7 100644 --- a/osu.Game.Rulesets.Taiko.Tests.iOS/osu.Game.Rulesets.Taiko.Tests.iOS.csproj +++ b/osu.Game.Rulesets.Taiko.Tests.iOS/osu.Game.Rulesets.Taiko.Tests.iOS.csproj @@ -13,7 +13,8 @@ - + + diff --git a/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneEditorPlacement.cs b/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneEditorPlacement.cs new file mode 100644 index 0000000000..c523652ae1 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneEditorPlacement.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Testing; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Objects.Drawables; +using osu.Game.Tests.Visual; +using osuTK.Input; + +namespace osu.Game.Rulesets.Taiko.Tests.Editor +{ + public partial class TestSceneEditorPlacement : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new TaikoRuleset(); + + [Test] + public void TestPlacementBlueprintDoesNotCauseCrashes() + { + AddStep("clear objects", () => EditorBeatmap.Clear()); + AddStep("add two objects", () => + { + EditorBeatmap.Add(new Hit { StartTime = 1818 }); + EditorBeatmap.Add(new Hit { StartTime = 1584 }); + }); + AddStep("seek back", () => EditorClock.Seek(1584)); + AddStep("choose hit placement tool", () => InputManager.Key(Key.Number2)); + AddStep("hover over first hit", () => InputManager.MoveMouseTo(Editor.ChildrenOfType().ElementAt(1))); + AddStep("hover over second hit", () => InputManager.MoveMouseTo(Editor.ChildrenOfType().ElementAt(0))); + AddStep("right click", () => InputManager.Click(MouseButton.Right)); + AddUntilStep("context menu open", () => Editor.ChildrenOfType().Any(menu => menu.State == MenuState.Open)); + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Judgements/JudgementTest.cs b/osu.Game.Rulesets.Taiko.Tests/Judgements/JudgementTest.cs index f3e37736b2..30ecec2366 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Judgements/JudgementTest.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Judgements/JudgementTest.cs @@ -43,6 +43,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Judgements AddStep("load player", () => { Beatmap.Value = CreateWorkingBeatmap(beatmap); + Ruleset.Value = new TaikoRuleset().RulesetInfo; SelectedMods.Value = mods ?? Array.Empty(); var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } }); diff --git a/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModHidden.cs b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModHidden.cs index 6e6be26e43..e6d5c51902 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModHidden.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModHidden.cs @@ -31,40 +31,42 @@ namespace osu.Game.Rulesets.Taiko.Tests.Mods { const double hit_time = 1; - var beatmap = new Beatmap - { - HitObjects = new List - { - new Hit - { - Type = HitType.Rim, - StartTime = hit_time, - }, - new Hit - { - Type = HitType.Centre, - StartTime = hit_time * 2, - }, - }, - BeatmapInfo = - { - Difficulty = new BeatmapDifficulty - { - SliderTickRate = 4, - OverallDifficulty = 0, - }, - Ruleset = new TaikoRuleset().RulesetInfo - }, - }; - - beatmap.ControlPointInfo.Add(0, new EffectControlPoint { ScrollSpeed = 0.1f }); - CreateModTest(new ModTestData { Mod = new TaikoModHidden(), Autoplay = true, PassCondition = checkAllMaxResultJudgements(2), - Beatmap = beatmap, + CreateBeatmap = () => + { + var beatmap = new Beatmap + { + HitObjects = new List + { + new Hit + { + Type = HitType.Rim, + StartTime = hit_time, + }, + new Hit + { + Type = HitType.Centre, + StartTime = hit_time * 2, + }, + }, + BeatmapInfo = + { + Difficulty = new BeatmapDifficulty + { + SliderTickRate = 4, + OverallDifficulty = 0, + }, + Ruleset = new TaikoRuleset().RulesetInfo + }, + }; + + beatmap.ControlPointInfo.Add(0, new EffectControlPoint { ScrollSpeed = 0.1f }); + return beatmap; + }, }); } } diff --git a/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModRelax.cs b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModRelax.cs index caf8aa8e76..fffe42f1f8 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModRelax.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModRelax.cs @@ -15,7 +15,26 @@ namespace osu.Game.Rulesets.Taiko.Tests.Mods [Test] public void TestRelax() { - var beatmap = new TaikoBeatmap + var beatmapForReplay = createBeatmap(); + + foreach (var ho in beatmapForReplay.HitObjects) + ho.ApplyDefaults(beatmapForReplay.ControlPointInfo, beatmapForReplay.Difficulty); + + var replay = new TaikoAutoGenerator(beatmapForReplay).Generate(); + + foreach (var frame in replay.Frames.OfType().Where(r => r.Actions.Any())) + frame.Actions = [TaikoAction.LeftCentre]; + + CreateModTest(new ModTestData + { + Mod = new TaikoModRelax(), + CreateBeatmap = createBeatmap, + ReplayFrames = replay.Frames, + Autoplay = false, + PassCondition = () => Player.ScoreProcessor.HasCompleted.Value && Player.ScoreProcessor.Accuracy.Value == 1, + }); + + TaikoBeatmap createBeatmap() => new TaikoBeatmap { HitObjects = { @@ -25,22 +44,6 @@ namespace osu.Game.Rulesets.Taiko.Tests.Mods new Swell { StartTime = 1250, Duration = 500 }, } }; - foreach (var ho in beatmap.HitObjects) - ho.ApplyDefaults(beatmap.ControlPointInfo, beatmap.Difficulty); - - var replay = new TaikoAutoGenerator(beatmap).Generate(); - - foreach (var frame in replay.Frames.OfType().Where(r => r.Actions.Any())) - frame.Actions = [TaikoAction.LeftCentre]; - - CreateModTest(new ModTestData - { - Mod = new TaikoModRelax(), - Beatmap = beatmap, - ReplayFrames = replay.Frames, - Autoplay = false, - PassCondition = () => Player.ScoreProcessor.HasCompleted.Value && Player.ScoreProcessor.Accuracy.Value == 1, - }); } } } diff --git a/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModSingleTap.cs b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModSingleTap.cs index 3a11a91f82..b12ac10d2d 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModSingleTap.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModSingleTap.cs @@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Mods { Mod = new TaikoModSingleTap(), Autoplay = false, - Beatmap = new Beatmap + CreateBeatmap = () => new Beatmap { HitObjects = new List { @@ -65,7 +65,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Mods { Mod = new TaikoModSingleTap(), Autoplay = false, - Beatmap = new Beatmap + CreateBeatmap = () => new Beatmap { HitObjects = new List { @@ -110,7 +110,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Mods { Mod = new TaikoModSingleTap(), Autoplay = false, - Beatmap = new Beatmap + CreateBeatmap = () => new Beatmap { HitObjects = new List { @@ -136,7 +136,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Mods { Mod = new TaikoModSingleTap(), Autoplay = false, - Beatmap = new Beatmap + CreateBeatmap = () => new Beatmap { HitObjects = new List { @@ -175,7 +175,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Mods { Mod = new TaikoModSingleTap(), Autoplay = false, - Beatmap = new Beatmap + CreateBeatmap = () => new Beatmap { Breaks = { diff --git a/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj b/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj index a2420fc679..2170009ae8 100644 --- a/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj +++ b/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj @@ -1,7 +1,7 @@  - + diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs index 9f63e84867..25428c8b2f 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs @@ -3,6 +3,7 @@ using System; using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour.Data; @@ -11,26 +12,12 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators { public class ColourEvaluator { - /// - /// A sigmoid function. It gives a value between (middle - height/2) and (middle + height/2). - /// - /// The input value. - /// The center of the sigmoid, where the largest gradient occurs and value is equal to middle. - /// The radius of the sigmoid, outside of which values are near the minimum/maximum. - /// The middle of the sigmoid output. - /// The height of the sigmoid output. This will be equal to max value - min value. - private static double sigmoid(double val, double center, double width, double middle, double height) - { - double sigmoid = Math.Tanh(Math.E * -(val - center) / width); - return sigmoid * (height / 2) + middle; - } - /// /// Evaluate the difficulty of the first note of a . /// public static double EvaluateDifficultyOf(MonoStreak monoStreak) { - return sigmoid(monoStreak.Index, 2, 2, 0.5, 1) * EvaluateDifficultyOf(monoStreak.Parent) * 0.5; + return DifficultyCalculationUtils.Logistic(exponent: Math.E * monoStreak.Index - 2 * Math.E) * EvaluateDifficultyOf(monoStreak.Parent) * 0.5; } /// @@ -38,7 +25,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators /// public static double EvaluateDifficultyOf(AlternatingMonoPattern alternatingMonoPattern) { - return sigmoid(alternatingMonoPattern.Index, 2, 2, 0.5, 1) * EvaluateDifficultyOf(alternatingMonoPattern.Parent); + return DifficultyCalculationUtils.Logistic(exponent: Math.E * alternatingMonoPattern.Index - 2 * Math.E) * EvaluateDifficultyOf(alternatingMonoPattern.Parent); } /// @@ -46,7 +33,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators /// public static double EvaluateDifficultyOf(RepeatingHitPatterns repeatingHitPattern) { - return 2 * (1 - sigmoid(repeatingHitPattern.RepetitionInterval, 2, 2, 0.5, 1)); + return 2 * (1 - DifficultyCalculationUtils.Logistic(exponent: Math.E * repeatingHitPattern.RepetitionInterval - 2 * Math.E)); } public static double EvaluateDifficultyOf(DifficultyHitObject hitObject) diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs index e528c70699..f6914039f0 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs @@ -1,33 +1,55 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Taiko.Difficulty.Evaluators; +using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; namespace osu.Game.Rulesets.Taiko.Difficulty.Skills { /// /// Calculates the stamina coefficient of taiko difficulty. /// - public class Stamina : StrainDecaySkill + public class Stamina : StrainSkill { - protected override double SkillMultiplier => 1.1; - protected override double StrainDecayBase => 0.4; + private double skillMultiplier => 1.1; + private double strainDecayBase => 0.4; + + private readonly bool singleColourStamina; + + private double currentStrain; /// /// Creates a skill. /// /// Mods for use in skill calculations. - public Stamina(Mod[] mods) + /// Reads when Stamina is from a single coloured pattern. + public Stamina(Mod[] mods, bool singleColourStamina) : base(mods) { + this.singleColourStamina = singleColourStamina; } - protected override double StrainValueOf(DifficultyHitObject current) + private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); + + protected override double StrainValueAt(DifficultyHitObject current) { - return StaminaEvaluator.EvaluateDifficultyOf(current); + currentStrain *= strainDecay(current.DeltaTime); + currentStrain += StaminaEvaluator.EvaluateDifficultyOf(current) * skillMultiplier; + + // Safely prevents previous strains from shifting as new notes are added. + var currentObject = current as TaikoDifficultyHitObject; + int index = currentObject?.Colour.MonoStreak?.HitObjects.IndexOf(currentObject) ?? 0; + + if (singleColourStamina) + return currentStrain / (1 + Math.Exp(-(index - 10) / 2.0)); + + return currentStrain; } + + protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => singleColourStamina ? 0 : currentStrain * strainDecay(time - current.Previous(0).StartTime); } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs index 1664c941f8..c8f0448767 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs @@ -16,6 +16,12 @@ namespace osu.Game.Rulesets.Taiko.Difficulty [JsonProperty("stamina_difficulty")] public double StaminaDifficulty { get; set; } + /// + /// The ratio of stamina difficulty from mono-color (single colour) streams to total stamina difficulty. + /// + [JsonProperty("mono_stamina_factor")] + public double MonoStaminaFactor { get; set; } + /// /// The difficulty corresponding to the rhythm skill. /// @@ -43,6 +49,15 @@ namespace osu.Game.Rulesets.Taiko.Difficulty [JsonProperty("great_hit_window")] public double GreatHitWindow { get; set; } + /// + /// The perceived hit window for an OK hit inclusive of rate-adjusting mods (DT/HT/etc). + /// + /// + /// Rate-adjusting mods don't directly affect the hit window, but have a perceived effect as a result of adjusting audio timing. + /// + [JsonProperty("ok_hit_window")] + public double OkHitWindow { get; set; } + public override IEnumerable<(int attributeId, object value)> ToDatabaseAttributes() { foreach (var v in base.ToDatabaseAttributes()) @@ -50,6 +65,8 @@ namespace osu.Game.Rulesets.Taiko.Difficulty yield return (ATTRIB_ID_DIFFICULTY, StarRating); yield return (ATTRIB_ID_GREAT_HIT_WINDOW, GreatHitWindow); + yield return (ATTRIB_ID_OK_HIT_WINDOW, OkHitWindow); + yield return (ATTRIB_ID_MONO_STAMINA_FACTOR, MonoStaminaFactor); } public override void FromDatabaseAttributes(IReadOnlyDictionary values, IBeatmapOnlineInfo onlineInfo) @@ -58,6 +75,8 @@ namespace osu.Game.Rulesets.Taiko.Difficulty StarRating = values[ATTRIB_ID_DIFFICULTY]; GreatHitWindow = values[ATTRIB_ID_GREAT_HIT_WINDOW]; + OkHitWindow = values[ATTRIB_ID_OK_HIT_WINDOW]; + MonoStaminaFactor = values[ATTRIB_ID_MONO_STAMINA_FACTOR]; } } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs index e3c550fbe9..7f2558c406 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs @@ -25,7 +25,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty private const double colour_skill_multiplier = 0.375 * difficulty_multiplier; private const double stamina_skill_multiplier = 0.375 * difficulty_multiplier; - public override int Version => 20221107; + public override int Version => 20241007; public TaikoDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) : base(ruleset, beatmap) @@ -38,7 +38,8 @@ namespace osu.Game.Rulesets.Taiko.Difficulty { new Rhythm(mods), new Colour(mods), - new Stamina(mods) + new Stamina(mods, false), + new Stamina(mods, true) }; } @@ -79,14 +80,26 @@ namespace osu.Game.Rulesets.Taiko.Difficulty Colour colour = (Colour)skills.First(x => x is Colour); Rhythm rhythm = (Rhythm)skills.First(x => x is Rhythm); Stamina stamina = (Stamina)skills.First(x => x is Stamina); + Stamina singleColourStamina = (Stamina)skills.Last(x => x is Stamina); double colourRating = colour.DifficultyValue() * colour_skill_multiplier; double rhythmRating = rhythm.DifficultyValue() * rhythm_skill_multiplier; double staminaRating = stamina.DifficultyValue() * stamina_skill_multiplier; + double monoStaminaRating = singleColourStamina.DifficultyValue() * stamina_skill_multiplier; + double monoStaminaFactor = staminaRating == 0 ? 1 : Math.Pow(monoStaminaRating / staminaRating, 5); double combinedRating = combinedDifficultyValue(rhythm, colour, stamina); double starRating = rescale(combinedRating * 1.4); + // TODO: This is temporary measure as we don't detect abuse of multiple-input playstyles of converts within the current system. + if (beatmap.BeatmapInfo.Ruleset.OnlineID == 0) + { + starRating *= 0.925; + // For maps with low colour variance and high stamina requirement, multiple inputs are more likely to be abused. + if (colourRating < 2 && staminaRating > 8) + starRating *= 0.80; + } + HitWindows hitWindows = new TaikoHitWindows(); hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); @@ -95,10 +108,12 @@ namespace osu.Game.Rulesets.Taiko.Difficulty StarRating = starRating, Mods = mods, StaminaDifficulty = staminaRating, + MonoStaminaFactor = monoStaminaFactor, RhythmDifficulty = rhythmRating, ColourDifficulty = colourRating, PeakDifficulty = combinedRating, GreatHitWindow = hitWindows.WindowFor(HitResult.Great) / clockRate, + OkHitWindow = hitWindows.WindowFor(HitResult.Ok) / clockRate, MaxCombo = beatmap.GetMaxCombo(), }; diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceAttributes.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceAttributes.cs index b12c0ca29d..7c74e43db1 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceAttributes.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceAttributes.cs @@ -18,6 +18,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty [JsonProperty("effective_miss_count")] public double EffectiveMissCount { get; set; } + [JsonProperty("estimated_unstable_rate")] + public double? EstimatedUnstableRate { get; set; } + public override IEnumerable GetAttributesForDisplay() { foreach (var attribute in base.GetAttributesForDisplay()) diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs index ac4462c18b..c672b7a1d9 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs @@ -9,6 +9,7 @@ using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Scoring; +using osu.Game.Utils; namespace osu.Game.Rulesets.Taiko.Difficulty { @@ -18,7 +19,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty private int countOk; private int countMeh; private int countMiss; - private double accuracy; + private double? estimatedUnstableRate; private double effectiveMissCount; @@ -35,24 +36,24 @@ namespace osu.Game.Rulesets.Taiko.Difficulty countOk = score.Statistics.GetValueOrDefault(HitResult.Ok); countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh); countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss); - accuracy = customAccuracy; + estimatedUnstableRate = computeDeviationUpperBound(taikoAttributes) * 10; // The effectiveMissCount is calculated by gaining a ratio for totalSuccessfulHits and increasing the miss penalty for shorter object counts lower than 1000. if (totalSuccessfulHits > 0) effectiveMissCount = Math.Max(1.0, 1000.0 / totalSuccessfulHits) * countMiss; - // TODO: The detection of rulesets is temporary until the leftover old skills have been reworked. + // Converts are detected and omitted from mod-specific bonuses due to the scope of current difficulty calculation. bool isConvert = score.BeatmapInfo!.Ruleset.OnlineID != 1; double multiplier = 1.13; - if (score.Mods.Any(m => m is ModHidden)) + if (score.Mods.Any(m => m is ModHidden) && !isConvert) multiplier *= 1.075; if (score.Mods.Any(m => m is ModEasy)) - multiplier *= 0.975; + multiplier *= 0.950; - double difficultyValue = computeDifficultyValue(score, taikoAttributes, isConvert); + double difficultyValue = computeDifficultyValue(score, taikoAttributes); double accuracyValue = computeAccuracyValue(score, taikoAttributes, isConvert); double totalValue = Math.Pow( @@ -65,11 +66,12 @@ namespace osu.Game.Rulesets.Taiko.Difficulty Difficulty = difficultyValue, Accuracy = accuracyValue, EffectiveMissCount = effectiveMissCount, + EstimatedUnstableRate = estimatedUnstableRate, Total = totalValue }; } - private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool isConvert) + private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes attributes) { double difficultyValue = Math.Pow(5 * Math.Max(1.0, attributes.StarRating / 0.115) - 4.0, 2.25) / 1150.0; @@ -79,41 +81,104 @@ namespace osu.Game.Rulesets.Taiko.Difficulty difficultyValue *= Math.Pow(0.986, effectiveMissCount); if (score.Mods.Any(m => m is ModEasy)) - difficultyValue *= 0.985; + difficultyValue *= 0.90; - if (score.Mods.Any(m => m is ModHidden) && !isConvert) + if (score.Mods.Any(m => m is ModHidden)) difficultyValue *= 1.025; if (score.Mods.Any(m => m is ModHardRock)) - difficultyValue *= 1.050; + difficultyValue *= 1.10; if (score.Mods.Any(m => m is ModFlashlight)) - difficultyValue *= 1.050 * lengthBonus; + difficultyValue *= Math.Max(1, 1.050 - Math.Min(attributes.MonoStaminaFactor / 50, 1) * lengthBonus); - return difficultyValue * Math.Pow(accuracy, 2.0); + if (estimatedUnstableRate == null) + return 0; + + // Scale accuracy more harshly on nearly-completely mono (single coloured) speed maps. + double accScalingExponent = 2 + attributes.MonoStaminaFactor; + double accScalingShift = 300 - 100 * attributes.MonoStaminaFactor; + + return difficultyValue * Math.Pow(SpecialFunctions.Erf(accScalingShift / (Math.Sqrt(2) * estimatedUnstableRate.Value)), accScalingExponent); } private double computeAccuracyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool isConvert) { - if (attributes.GreatHitWindow <= 0) + if (attributes.GreatHitWindow <= 0 || estimatedUnstableRate == null) return 0; - double accuracyValue = Math.Pow(60.0 / attributes.GreatHitWindow, 1.1) * Math.Pow(accuracy, 8.0) * Math.Pow(attributes.StarRating, 0.4) * 27.0; + double accuracyValue = Math.Pow(70 / estimatedUnstableRate.Value, 1.1) * Math.Pow(attributes.StarRating, 0.4) * 100.0; double lengthBonus = Math.Min(1.15, Math.Pow(totalHits / 1500.0, 0.3)); - accuracyValue *= lengthBonus; // Slight HDFL Bonus for accuracy. A clamp is used to prevent against negative values. if (score.Mods.Any(m => m is ModFlashlight) && score.Mods.Any(m => m is ModHidden) && !isConvert) - accuracyValue *= Math.Max(1.0, 1.1 * lengthBonus); + accuracyValue *= Math.Max(1.0, 1.05 * lengthBonus); return accuracyValue; } + /// + /// Computes an upper bound on the player's tap deviation based on the OD, number of circles and sliders, + /// and the hit judgements, assuming the player's mean hit error is 0. The estimation is consistent in that + /// two SS scores on the same map with the same settings will always return the same deviation. + /// + private double? computeDeviationUpperBound(TaikoDifficultyAttributes attributes) + { + if (totalSuccessfulHits == 0 || attributes.GreatHitWindow <= 0) + return null; + + double h300 = attributes.GreatHitWindow; + double h100 = attributes.OkHitWindow; + + const double z = 2.32634787404; // 99% critical value for the normal distribution (one-tailed). + + // The upper bound on deviation, calculated with the ratio of 300s to objects, and the great hit window. + double? calcDeviationGreatWindow() + { + if (countGreat == 0) return null; + + double n = totalHits; + + // Proportion of greats hit. + double p = countGreat / n; + + // We can be 99% confident that p is at least this value. + double pLowerBound = (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4); + + // We can be 99% confident that the deviation is not higher than: + return h300 / (Math.Sqrt(2) * SpecialFunctions.ErfInv(pLowerBound)); + } + + // The upper bound on deviation, calculated with the ratio of 300s + 100s to objects, and the good hit window. + // This will return a lower value than the first method when the number of 100s is high, but the miss count is low. + double? calcDeviationGoodWindow() + { + if (totalSuccessfulHits == 0) return null; + + double n = totalHits; + + // Proportion of greats + goods hit. + double p = totalSuccessfulHits / n; + + // We can be 99% confident that p is at least this value. + double pLowerBound = (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4); + + // We can be 99% confident that the deviation is not higher than: + return h100 / (Math.Sqrt(2) * SpecialFunctions.ErfInv(pLowerBound)); + } + + double? deviationGreatWindow = calcDeviationGreatWindow(); + double? deviationGoodWindow = calcDeviationGoodWindow(); + + if (deviationGreatWindow is null) + return deviationGoodWindow; + + return Math.Min(deviationGreatWindow.Value, deviationGoodWindow!.Value); + } + private int totalHits => countGreat + countOk + countMeh + countMiss; private int totalSuccessfulHits => countGreat + countOk + countMeh; - - private double customAccuracy => totalHits > 0 ? (countGreat * 300 + countOk * 150) / (totalHits * 300.0) : 0; } } diff --git a/osu.Game.Rulesets.Taiko/Edit/DrawableTaikoEditorRuleset.cs b/osu.Game.Rulesets.Taiko/Edit/DrawableTaikoEditorRuleset.cs index 217bb8139c..147ceb3ba1 100644 --- a/osu.Game.Rulesets.Taiko/Edit/DrawableTaikoEditorRuleset.cs +++ b/osu.Game.Rulesets.Taiko/Edit/DrawableTaikoEditorRuleset.cs @@ -7,6 +7,7 @@ using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Taiko.UI; +using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; namespace osu.Game.Rulesets.Taiko.Edit @@ -20,6 +21,8 @@ namespace osu.Game.Rulesets.Taiko.Edit { } + protected override Playfield CreatePlayfield() => new TaikoEditorPlayfield(); + protected override void LoadComplete() { base.LoadComplete(); diff --git a/osu.Game.Rulesets.Taiko/Edit/Setup/TaikoDifficultySection.cs b/osu.Game.Rulesets.Taiko/Edit/Setup/TaikoDifficultySection.cs index 2aaa16ee0b..52f7176b3f 100644 --- a/osu.Game.Rulesets.Taiko/Edit/Setup/TaikoDifficultySection.cs +++ b/osu.Game.Rulesets.Taiko/Edit/Setup/TaikoDifficultySection.cs @@ -16,10 +16,10 @@ namespace osu.Game.Rulesets.Taiko.Edit.Setup { public partial class TaikoDifficultySection : SetupSection { - private LabelledSliderBar healthDrainSlider { get; set; } = null!; - private LabelledSliderBar overallDifficultySlider { get; set; } = null!; - private LabelledSliderBar baseVelocitySlider { get; set; } = null!; - private LabelledSliderBar tickRateSlider { get; set; } = null!; + private FormSliderBar healthDrainSlider { get; set; } = null!; + private FormSliderBar overallDifficultySlider { get; set; } = null!; + private FormSliderBar baseVelocitySlider { get; set; } = null!; + private FormSliderBar tickRateSlider { get; set; } = null!; public override LocalisableString Title => EditorSetupStrings.DifficultyHeader; @@ -28,64 +28,68 @@ namespace osu.Game.Rulesets.Taiko.Edit.Setup { Children = new Drawable[] { - healthDrainSlider = new LabelledSliderBar + healthDrainSlider = new FormSliderBar { - Label = BeatmapsetsStrings.ShowStatsDrain, - FixedLabelWidth = LABEL_WIDTH, - Description = EditorSetupStrings.DrainRateDescription, + Caption = BeatmapsetsStrings.ShowStatsDrain, + HintText = EditorSetupStrings.DrainRateDescription, Current = new BindableFloat(Beatmap.Difficulty.DrainRate) { Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, MinValue = 0, MaxValue = 10, Precision = 0.1f, - } + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, }, - overallDifficultySlider = new LabelledSliderBar + overallDifficultySlider = new FormSliderBar { - Label = BeatmapsetsStrings.ShowStatsAccuracy, - FixedLabelWidth = LABEL_WIDTH, - Description = EditorSetupStrings.OverallDifficultyDescription, + Caption = BeatmapsetsStrings.ShowStatsAccuracy, + HintText = EditorSetupStrings.OverallDifficultyDescription, Current = new BindableFloat(Beatmap.Difficulty.OverallDifficulty) { Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, MinValue = 0, MaxValue = 10, Precision = 0.1f, - } + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, }, - baseVelocitySlider = new LabelledSliderBar + baseVelocitySlider = new FormSliderBar { - Label = EditorSetupStrings.BaseVelocity, - FixedLabelWidth = LABEL_WIDTH, - Description = EditorSetupStrings.BaseVelocityDescription, + Caption = EditorSetupStrings.BaseVelocity, + HintText = EditorSetupStrings.BaseVelocityDescription, Current = new BindableDouble(Beatmap.Difficulty.SliderMultiplier) { Default = 1.4, MinValue = 0.4, MaxValue = 3.6, Precision = 0.01f, - } + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, }, - tickRateSlider = new LabelledSliderBar + tickRateSlider = new FormSliderBar { - Label = EditorSetupStrings.TickRate, - FixedLabelWidth = LABEL_WIDTH, - Description = EditorSetupStrings.TickRateDescription, + Caption = EditorSetupStrings.TickRate, + HintText = EditorSetupStrings.TickRateDescription, Current = new BindableDouble(Beatmap.Difficulty.SliderTickRate) { Default = 1, MinValue = 1, MaxValue = 4, Precision = 1, - } + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, }, }; - foreach (var item in Children.OfType>()) + foreach (var item in Children.OfType>()) item.Current.ValueChanged += _ => updateValues(); - foreach (var item in Children.OfType>()) + foreach (var item in Children.OfType>()) item.Current.ValueChanged += _ => updateValues(); } diff --git a/osu.Game.Rulesets.Taiko/Edit/TaikoEditorPlayfield.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoEditorPlayfield.cs new file mode 100644 index 0000000000..760ed71662 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Edit/TaikoEditorPlayfield.cs @@ -0,0 +1,25 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Rulesets.Taiko.UI; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Taiko.Edit +{ + public partial class TaikoEditorPlayfield : TaikoPlayfield + { + [BackgroundDependencyLoader] + private void load() + { + // This is the simplest way to extend the taiko playfield beyond the left of the drum area. + // Required in the editor to not look weird underneath left toolbox area. + AddInternal(new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.PlayfieldBackgroundRight), _ => new PlayfieldBackgroundRight()) + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopRight, + }); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs index ae6dced9aa..be2a5ac144 100644 --- a/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs +++ b/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs @@ -6,6 +6,7 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Bindings; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; @@ -53,17 +54,17 @@ namespace osu.Game.Rulesets.Taiko.Edit public void SetStrongState(bool state) { - if (SelectedItems.OfType().All(h => h.IsStrong == state)) + if (SelectedItems.OfType().All(h => h.IsStrong == state)) return; EditorBeatmap.PerformOnSelection(h => { - if (!(h is Hit taikoHit)) return; + if (h is not TaikoStrongableHitObject strongable) return; - if (taikoHit.IsStrong != state) + if (strongable.IsStrong != state) { - taikoHit.IsStrong = state; - EditorBeatmap.Update(taikoHit); + strongable.IsStrong = state; + EditorBeatmap.Update(strongable); } }); } @@ -86,10 +87,22 @@ namespace osu.Game.Rulesets.Taiko.Edit protected override IEnumerable GetContextMenuItemsForSelection(IEnumerable> selection) { if (selection.All(s => s.Item is Hit)) - yield return new TernaryStateToggleMenuItem("Rim") { State = { BindTarget = selectionRimState } }; + { + yield return new TernaryStateToggleMenuItem("Rim") + { + State = { BindTarget = selectionRimState }, + Hotkey = new Hotkey(new KeyCombination(InputKey.W), new KeyCombination(InputKey.R)), + }; + } if (selection.All(s => s.Item is TaikoHitObject)) - yield return new TernaryStateToggleMenuItem("Strong") { State = { BindTarget = selectionStrongState } }; + { + yield return new TernaryStateToggleMenuItem("Strong") + { + State = { BindTarget = selectionStrongState }, + Hotkey = new Hotkey(new KeyCombination(InputKey.E)), + }; + } foreach (var item in base.GetContextMenuItemsForSelection(selection)) yield return item; diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs index 1af4719b02..547d0afe4a 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs @@ -31,6 +31,9 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables public override Quad ScreenSpaceDrawQuad => MainPiece.Drawable.ScreenSpaceDrawQuad; + // done strictly for editor purposes. + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => MainPiece.Drawable.ReceivePositionalInputAt(screenSpacePos); + /// /// Rolling number of tick hits. This increases for hits and decreases for misses. /// @@ -78,6 +81,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables { base.RecreatePieces(); updateColour(); + Height = HitObject.IsStrong ? TaikoStrongableHitObject.DEFAULT_STRONG_SIZE : TaikoHitObject.DEFAULT_SIZE; } protected override void OnFree() diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs index 0333fd71a9..64d2020edc 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs @@ -12,6 +12,7 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Taiko.Skinning.Default; using osu.Game.Skinning; +using osuTK; namespace osu.Game.Rulesets.Taiko.Objects.Drawables { @@ -44,6 +45,12 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables IsFirstTick.Value = HitObject.FirstTick; } + protected override void RecreatePieces() + { + base.RecreatePieces(); + Size = new Vector2(HitObject.IsStrong ? TaikoStrongableHitObject.DEFAULT_STRONG_SIZE : TaikoHitObject.DEFAULT_SIZE); + } + protected override void CheckForResult(bool userTriggered, double timeOffset) { if (!userTriggered) diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs index a5e63c373f..28831a6d2c 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs @@ -14,6 +14,7 @@ using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Skinning.Default; using osu.Game.Skinning; +using osuTK; namespace osu.Game.Rulesets.Taiko.Objects.Drawables { @@ -63,6 +64,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables { updateActionsFromType(); base.RecreatePieces(); + Size = new Vector2(HitObject.IsStrong ? TaikoStrongableHitObject.DEFAULT_STRONG_SIZE : TaikoHitObject.DEFAULT_SIZE); } protected override void OnFree() diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs index f2fcd185dd..28617b35f6 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs @@ -19,6 +19,7 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Taiko.Skinning.Default; using osu.Game.Screens.Play; using osu.Game.Skinning; +using osuTK; namespace osu.Game.Rulesets.Taiko.Objects.Drawables { @@ -34,6 +35,8 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables /// private const double ring_appear_offset = 100; + private Vector2 baseSize; + private readonly Container ticks; private readonly Container bodyContainer; private readonly CircularContainer targetRing; @@ -141,6 +144,12 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables Origin = Anchor.Centre, }); + protected override void RecreatePieces() + { + base.RecreatePieces(); + Size = baseSize = new Vector2(TaikoHitObject.DEFAULT_SIZE); + } + protected override void OnFree() { base.OnFree(); @@ -269,7 +278,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables { base.Update(); - Size = BaseSize * Parent!.RelativeChildSize; + Size = baseSize * Parent!.RelativeChildSize; // Make the swell stop at the hit target X = Math.Max(0, X); diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs index 3f4694d71d..0cf9651965 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs @@ -130,7 +130,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables public new TObject HitObject => (TObject)base.HitObject; - protected Vector2 BaseSize; protected SkinnableDrawable MainPiece; protected DrawableTaikoHitObject([CanBeNull] TObject hitObject) @@ -152,8 +151,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables protected virtual void RecreatePieces() { - Size = BaseSize = new Vector2(TaikoHitObject.DEFAULT_SIZE); - if (MainPiece != null) Content.Remove(MainPiece, true); diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoStrongableHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoStrongableHitObject.cs index 4d7cdf3243..7c3ff4f27e 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoStrongableHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoStrongableHitObject.cs @@ -8,7 +8,6 @@ using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; -using osuTK; namespace osu.Game.Rulesets.Taiko.Objects.Drawables { @@ -44,13 +43,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables isStrong.UnbindEvents(); } - protected override void RecreatePieces() - { - base.RecreatePieces(); - if (HitObject.IsStrong) - Size = BaseSize = new Vector2(TaikoStrongableHitObject.DEFAULT_STRONG_SIZE); - } - protected override void AddNestedHitObject(DrawableHitObject hitObject) { base.AddNestedHitObject(hitObject); diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs index 5543a31ec9..78be0ef643 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -9,6 +10,7 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Game.Graphics; using osu.Game.Skinning; +using osuTK; using osuTK.Graphics; namespace osu.Game.Rulesets.Taiko.Skinning.Legacy @@ -19,13 +21,22 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy { get { - var headDrawQuad = headCircle.ScreenSpaceDrawQuad; - var tailDrawQuad = tailCircle.ScreenSpaceDrawQuad; + // the reason why this calculation is so involved is that the head & tail sprites have different sizes/radii. + // therefore naively taking the SSDQs of them and making a quad out of them results in a trapezoid shape and not a box. + var headCentre = headCircle.ScreenSpaceDrawQuad.Centre; + var tailCentre = (tailCircle.ScreenSpaceDrawQuad.TopLeft + tailCircle.ScreenSpaceDrawQuad.BottomLeft) / 2; - return new Quad(headDrawQuad.TopLeft, tailDrawQuad.TopRight, headDrawQuad.BottomLeft, tailDrawQuad.BottomRight); + float headRadius = headCircle.ScreenSpaceDrawQuad.Height / 2; + float tailRadius = tailCircle.ScreenSpaceDrawQuad.Height / 2; + float radius = Math.Max(headRadius, tailRadius); + + var rectangle = new RectangleF(headCentre.X, headCentre.Y, tailCentre.X - headCentre.X, 0).Inflate(radius); + return new Quad(rectangle.TopLeft, rectangle.TopRight, rectangle.BottomLeft, rectangle.BottomRight); } } + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => ScreenSpaceDrawQuad.Contains(screenSpacePos); + private LegacyCirclePiece headCircle = null!; private Sprite body = null!; diff --git a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs index 2447a4a247..70e429a344 100644 --- a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs @@ -190,9 +190,12 @@ namespace osu.Game.Rulesets.Taiko public override HitObjectComposer CreateHitObjectComposer() => new TaikoHitObjectComposer(this); - public override IEnumerable CreateEditorSetupSections() => + public override IEnumerable CreateEditorSetupSections() => [ + new MetadataSection(), new TaikoDifficultySection(), + new ResourcesSection(), + new DesignSection(), ]; public override IBeatmapVerifier CreateBeatmapVerifier() => new TaikoBeatmapVerifier(); diff --git a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs index 64ea9d88cd..4185b67f4c 100644 --- a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs @@ -84,8 +84,11 @@ namespace osu.Game.Rulesets.Taiko.UI protected virtual double ComputeTimeRange() { - // Adjust when we're using constant algorithm to not be sluggish. - double multiplier = VisualisationMethod == ScrollVisualisationMethod.Constant ? 4 * Beatmap.Difficulty.SliderMultiplier : 1; + // Using the constant algorithm results in a sluggish scroll speed that's equal to 60 BPM. + // We need to adjust it to the expected default scroll speed (BPM * base SV multiplier). + double multiplier = VisualisationMethod == ScrollVisualisationMethod.Constant + ? (Beatmap.BeatmapInfo.BPM * Beatmap.Difficulty.SliderMultiplier) / 60 + : 1; return PlayfieldAdjustmentContainer.ComputeTimeRange() / multiplier; } diff --git a/osu.Game.Tests/Beatmaps/BeatmapUpdaterMetadataLookupTest.cs b/osu.Game.Tests/Beatmaps/BeatmapUpdaterMetadataLookupTest.cs index 11c4c54ea6..82e54875ef 100644 --- a/osu.Game.Tests/Beatmaps/BeatmapUpdaterMetadataLookupTest.cs +++ b/osu.Game.Tests/Beatmaps/BeatmapUpdaterMetadataLookupTest.cs @@ -241,8 +241,8 @@ namespace osu.Game.Tests.Beatmaps metadataLookup.Update(beatmapSet, preferOnlineFetch); - Assert.That(beatmap.Status, Is.EqualTo(BeatmapOnlineStatus.None)); - Assert.That(beatmap.OnlineID, Is.EqualTo(-1)); + Assert.That(beatmap.Status, Is.EqualTo(BeatmapOnlineStatus.Ranked)); + Assert.That(beatmap.OnlineID, Is.EqualTo(654321)); } [Test] @@ -273,34 +273,6 @@ namespace osu.Game.Tests.Beatmaps Assert.That(beatmap.OnlineID, Is.EqualTo(654321)); } - [Test] - public void TestMetadataLookupForBeatmapWithoutPopulatedIDAndIncorrectHash([Values] bool preferOnlineFetch) - { - var lookupResult = new OnlineBeatmapMetadata - { - BeatmapID = 654321, - BeatmapStatus = BeatmapOnlineStatus.Ranked, - MD5Hash = @"cafebabe", - }; - - var targetMock = preferOnlineFetch ? apiMetadataSourceMock : localCachedMetadataSourceMock; - targetMock.Setup(src => src.Available).Returns(true); - targetMock.Setup(src => src.TryLookup(It.IsAny(), out lookupResult)) - .Returns(true); - - var beatmap = new BeatmapInfo - { - MD5Hash = @"deadbeef" - }; - var beatmapSet = new BeatmapSetInfo(beatmap.Yield()); - beatmap.BeatmapSet = beatmapSet; - - metadataLookup.Update(beatmapSet, preferOnlineFetch); - - Assert.That(beatmap.Status, Is.EqualTo(BeatmapOnlineStatus.None)); - Assert.That(beatmap.OnlineID, Is.EqualTo(-1)); - } - [Test] public void TestReturnedMetadataHasDifferentHash([Values] bool preferOnlineFetch) { @@ -383,58 +355,5 @@ namespace osu.Game.Tests.Beatmaps Assert.That(beatmapSet.Status, Is.EqualTo(BeatmapOnlineStatus.None)); } - - [Test] - public void TestPartiallyMaliciousSet([Values] bool preferOnlineFetch) - { - var firstResult = new OnlineBeatmapMetadata - { - BeatmapID = 654321, - BeatmapStatus = BeatmapOnlineStatus.Ranked, - BeatmapSetStatus = BeatmapOnlineStatus.Ranked, - MD5Hash = @"cafebabe" - }; - var secondResult = new OnlineBeatmapMetadata - { - BeatmapStatus = BeatmapOnlineStatus.Ranked, - BeatmapSetStatus = BeatmapOnlineStatus.Ranked, - MD5Hash = @"dededede" - }; - - var targetMock = preferOnlineFetch ? apiMetadataSourceMock : localCachedMetadataSourceMock; - targetMock.Setup(src => src.Available).Returns(true); - targetMock.Setup(src => src.TryLookup(It.Is(bi => bi.OnlineID == 654321), out firstResult)) - .Returns(true); - targetMock.Setup(src => src.TryLookup(It.Is(bi => bi.OnlineID == 666666), out secondResult)) - .Returns(true); - - var firstBeatmap = new BeatmapInfo - { - OnlineID = 654321, - MD5Hash = @"cafebabe", - }; - var secondBeatmap = new BeatmapInfo - { - OnlineID = 666666, - MD5Hash = @"deadbeef" - }; - var beatmapSet = new BeatmapSetInfo(new[] - { - firstBeatmap, - secondBeatmap - }); - firstBeatmap.BeatmapSet = beatmapSet; - secondBeatmap.BeatmapSet = beatmapSet; - - metadataLookup.Update(beatmapSet, preferOnlineFetch); - - Assert.That(firstBeatmap.Status, Is.EqualTo(BeatmapOnlineStatus.Ranked)); - Assert.That(firstBeatmap.OnlineID, Is.EqualTo(654321)); - - Assert.That(secondBeatmap.Status, Is.EqualTo(BeatmapOnlineStatus.None)); - Assert.That(secondBeatmap.OnlineID, Is.EqualTo(-1)); - - Assert.That(beatmapSet.Status, Is.EqualTo(BeatmapOnlineStatus.None)); - } } } diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs index 54ebebeb7b..b5c299ed9d 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs @@ -1000,7 +1000,7 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.That(decoded.BeatmapInfo.WidescreenStoryboard, Is.False); Assert.That(decoded.BeatmapInfo.EpilepsyWarning, Is.False); Assert.That(decoded.BeatmapInfo.SamplesMatchPlaybackRate, Is.False); - Assert.That(decoded.BeatmapInfo.Countdown, Is.EqualTo(CountdownType.Normal)); + Assert.That(decoded.BeatmapInfo.Countdown, Is.EqualTo(CountdownType.None)); Assert.That(decoded.BeatmapInfo.CountdownOffset, Is.EqualTo(0)); Assert.That(decoded.BeatmapInfo.Metadata.PreviewTime, Is.EqualTo(-1)); Assert.That(decoded.BeatmapInfo.Ruleset.OnlineID, Is.EqualTo(0)); diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs index b931896898..c8a09786ec 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs @@ -120,11 +120,11 @@ namespace osu.Game.Tests.Beatmaps.Formats private void compareBeatmaps((IBeatmap beatmap, TestLegacySkin skin) expected, (IBeatmap beatmap, TestLegacySkin skin) actual) { // Check all control points that are still considered to be at a global level. - Assert.That(expected.beatmap.ControlPointInfo.TimingPoints.Serialize(), Is.EqualTo(actual.beatmap.ControlPointInfo.TimingPoints.Serialize())); - Assert.That(expected.beatmap.ControlPointInfo.EffectPoints.Serialize(), Is.EqualTo(actual.beatmap.ControlPointInfo.EffectPoints.Serialize())); + Assert.That(actual.beatmap.ControlPointInfo.TimingPoints.Serialize(), Is.EqualTo(expected.beatmap.ControlPointInfo.TimingPoints.Serialize())); + Assert.That(actual.beatmap.ControlPointInfo.EffectPoints.Serialize(), Is.EqualTo(expected.beatmap.ControlPointInfo.EffectPoints.Serialize())); // Check all hitobjects. - Assert.That(expected.beatmap.HitObjects.Serialize(), Is.EqualTo(actual.beatmap.HitObjects.Serialize())); + Assert.That(actual.beatmap.HitObjects.Serialize(), Is.EqualTo(expected.beatmap.HitObjects.Serialize())); // Check skin. Assert.IsTrue(areComboColoursEqual(expected.skin.Configuration, actual.skin.Configuration)); diff --git a/osu.Game.Tests/Beatmaps/IO/LegacyBeatmapExporterTest.cs b/osu.Game.Tests/Beatmaps/IO/LegacyBeatmapExporterTest.cs new file mode 100644 index 0000000000..8a95d26782 --- /dev/null +++ b/osu.Game.Tests/Beatmaps/IO/LegacyBeatmapExporterTest.cs @@ -0,0 +1,106 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.IO; +using System.Text; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.IO.Archives; +using osu.Game.Tests.Resources; +using osu.Game.Tests.Visual; +using MemoryStream = System.IO.MemoryStream; + +namespace osu.Game.Tests.Beatmaps.IO +{ + [HeadlessTest] + public partial class LegacyBeatmapExporterTest : OsuTestScene + { + [Resolved] + private BeatmapManager beatmaps { get; set; } = null!; + + [Test] + public void TestObjectsSnappedAfterTruncatingExport() + { + IWorkingBeatmap beatmap = null!; + MemoryStream outStream = null!; + + // Ensure importer encoding is correct + AddStep("import beatmap", () => beatmap = importBeatmapFromArchives(@"decimal-timing-beatmap.olz")); + AddAssert("timing point has decimal offset", () => beatmap.Beatmap.ControlPointInfo.TimingPoints[0].Time, () => Is.EqualTo(284.725).Within(0.001)); + AddAssert("kiai has decimal offset", () => beatmap.Beatmap.ControlPointInfo.EffectPoints[0].Time, () => Is.EqualTo(28520.019).Within(0.001)); + AddAssert("hit object has decimal offset", () => beatmap.Beatmap.HitObjects[0].StartTime, () => Is.EqualTo(28520.019).Within(0.001)); + + // Ensure exporter legacy conversion is correct + AddStep("export", () => + { + outStream = new MemoryStream(); + + new LegacyBeatmapExporter(LocalStorage) + .ExportToStream((BeatmapSetInfo)beatmap.BeatmapInfo.BeatmapSet!, outStream, null); + }); + + AddStep("import beatmap again", () => beatmap = importBeatmapFromStream(outStream)); + AddAssert("timing point has truncated offset", () => beatmap.Beatmap.ControlPointInfo.TimingPoints[0].Time, () => Is.EqualTo(284).Within(0.001)); + AddAssert("kiai is snapped", () => beatmap.Beatmap.ControlPointInfo.EffectPoints[0].Time, () => Is.EqualTo(28519).Within(0.001)); + AddAssert("hit object is snapped", () => beatmap.Beatmap.HitObjects[0].StartTime, () => Is.EqualTo(28519).Within(0.001)); + } + + [Test] + public void TestExportStability() + { + IWorkingBeatmap beatmap = null!; + MemoryStream firstExport = null!; + MemoryStream secondExport = null!; + + // Ensure importer encoding is correct + AddStep("import beatmap", () => beatmap = importBeatmapFromArchives(@"legacy-export-stability-test.olz")); + AddStep("export once", () => + { + firstExport = new MemoryStream(); + + new LegacyBeatmapExporter(LocalStorage) + .ExportToStream((BeatmapSetInfo)beatmap.BeatmapInfo.BeatmapSet!, firstExport, null); + }); + + AddStep("import beatmap again", () => beatmap = importBeatmapFromStream(firstExport)); + AddStep("export again", () => + { + secondExport = new MemoryStream(); + + new LegacyBeatmapExporter(LocalStorage) + .ExportToStream((BeatmapSetInfo)beatmap.BeatmapInfo.BeatmapSet!, secondExport, null); + }); + + const string osu_filename = @"legacy export - stability test (spaceman_atlas) [].osu"; + + AddAssert("exports are identical", + () => getStringContentsOf(osu_filename, firstExport.GetBuffer()), + () => Is.EqualTo(getStringContentsOf(osu_filename, secondExport.GetBuffer()))); + + string getStringContentsOf(string filename, byte[] archiveBytes) + { + using var memoryStream = new MemoryStream(archiveBytes); + using var archiveReader = new ZipArchiveReader(memoryStream); + byte[] fileContent = archiveReader.GetStream(filename).ReadAllBytesToArray(); + return Encoding.UTF8.GetString(fileContent); + } + } + + private IWorkingBeatmap importBeatmapFromStream(Stream stream) + { + var imported = beatmaps.Import(new ImportTask(stream, "filename.osz")).GetResultSafely(); + return imported.AsNonNull().PerformRead(s => beatmaps.GetWorkingBeatmap(s.Beatmaps[0])); + } + + private IWorkingBeatmap importBeatmapFromArchives(string filename) + { + var imported = beatmaps.Import(new ImportTask(TestResources.OpenResource($@"Archives/{filename}"), filename)).GetResultSafely(); + return imported.AsNonNull().PerformRead(s => beatmaps.GetWorkingBeatmap(s.Beatmaps[0])); + } + } +} diff --git a/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs b/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs index f9f9fa2622..c40624a3a0 100644 --- a/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs +++ b/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs @@ -22,9 +22,9 @@ namespace osu.Game.Tests.Database [HeadlessTest] public partial class BackgroundDataStoreProcessorTests : OsuTestScene, ILocalUserPlayInfo { - public IBindable IsPlaying => isPlaying; + public IBindable PlayingState => isPlaying; - private readonly Bindable isPlaying = new Bindable(); + private readonly Bindable isPlaying = new Bindable(); private BeatmapSetInfo importedSet = null!; @@ -37,7 +37,7 @@ namespace osu.Game.Tests.Database [SetUpSteps] public void SetUpSteps() { - AddStep("Set not playing", () => isPlaying.Value = false); + AddStep("Set not playing", () => isPlaying.Value = LocalUserPlayingState.NotPlaying); } [Test] @@ -89,7 +89,7 @@ namespace osu.Game.Tests.Database }); }); - AddStep("Set playing", () => isPlaying.Value = true); + AddStep("Set playing", () => isPlaying.Value = LocalUserPlayingState.Playing); AddStep("Reset difficulty", () => { @@ -117,7 +117,7 @@ namespace osu.Game.Tests.Database }); }); - AddStep("Set not playing", () => isPlaying.Value = false); + AddStep("Set not playing", () => isPlaying.Value = LocalUserPlayingState.NotPlaying); AddUntilStep("wait for difficulties repopulated", () => { diff --git a/osu.Game.Tests/Database/BeatmapImporterTests.cs b/osu.Game.Tests/Database/BeatmapImporterTests.cs index 0eac70f9c8..38746f2567 100644 --- a/osu.Game.Tests/Database/BeatmapImporterTests.cs +++ b/osu.Game.Tests/Database/BeatmapImporterTests.cs @@ -716,7 +716,7 @@ namespace osu.Game.Tests.Database { foreach (var entry in zip.Entries.ToArray()) { - if (entry.Key.EndsWith(".osu", StringComparison.InvariantCulture)) + if (entry.Key!.EndsWith(".osu", StringComparison.InvariantCulture)) zip.RemoveEntry(entry); } diff --git a/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs b/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs index cf8c3c6ef1..0f8583253b 100644 --- a/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs +++ b/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs @@ -112,6 +112,7 @@ namespace osu.Game.Tests.Editing { SliderVelocityMultiplier = slider_velocity }; + AddStep("add to beatmap", () => composer.EditorBeatmap.Add(referenceObject)); assertSnapDistance(base_distance * slider_velocity, referenceObject, true); assertSnappedDistance(base_distance * slider_velocity + 10, base_distance * slider_velocity, referenceObject); @@ -227,26 +228,65 @@ namespace osu.Game.Tests.Editing assertSnappedDistance(400, 400); } + [Test] + public void TestUnsnappedObject() + { + var slider = new Slider + { + StartTime = 0, + Path = new SliderPath + { + ControlPoints = + { + new PathControlPoint(), + // simulate object snapped to 1/3rds + // this object's end time will be 2000 / 3 = 666.66... ms + new PathControlPoint(new Vector2(200 / 3f, 0)), + } + } + }; + + AddStep("add slider", () => composer.EditorBeatmap.Add(slider)); + AddStep("set snap to 1/4", () => BeatDivisor.Value = 4); + + // with default beat length of 1000ms and snap at 1/4, the valid snap times are 500ms, 750ms, and 1000ms + // with default settings, the snapped distance will be a tenth of the difference of the time delta + + // (500 - 666.66...) / 10 = -16.66... = -100 / 6 + assertSnappedDistance(0, -100 / 6f, slider); + assertSnappedDistance(7, -100 / 6f, slider); + + // (750 - 666.66...) / 10 = 8.33... = 100 / 12 + assertSnappedDistance(9, 100 / 12f, slider); + assertSnappedDistance(33, 100 / 12f, slider); + + // (1000 - 666.66...) / 10 = 33.33... = 100 / 3 + assertSnappedDistance(34, 100 / 3f, slider); + } + [Test] public void TestUseCurrentSnap() { + ExpandableButton getCurrentSnapButton() => composer.ChildrenOfType().Single(g => g.Name == "snapping") + .ChildrenOfType().Single(); + AddStep("add objects to beatmap", () => { editorBeatmap.Add(new HitCircle { StartTime = 1000 }); editorBeatmap.Add(new HitCircle { Position = new Vector2(100), StartTime = 2000 }); }); - AddStep("hover use current snap button", () => InputManager.MoveMouseTo(composer.ChildrenOfType().Single())); - AddUntilStep("use current snap expanded", () => composer.ChildrenOfType().Single().Expanded.Value, () => Is.True); + AddStep("hover use current snap button", () => InputManager.MoveMouseTo(getCurrentSnapButton())); + AddUntilStep("use current snap expanded", () => getCurrentSnapButton().Expanded.Value, () => Is.True); AddStep("seek before first object", () => EditorClock.Seek(0)); - AddUntilStep("use current snap not available", () => composer.ChildrenOfType().Single().Enabled.Value, () => Is.False); + AddUntilStep("use current snap not available", () => getCurrentSnapButton().Enabled.Value, () => Is.False); AddStep("seek to between objects", () => EditorClock.Seek(1500)); - AddUntilStep("use current snap available", () => composer.ChildrenOfType().Single().Enabled.Value, () => Is.True); + AddUntilStep("use current snap available", () => getCurrentSnapButton().Enabled.Value, () => Is.True); AddStep("seek after last object", () => EditorClock.Seek(2500)); - AddUntilStep("use current snap not available", () => composer.ChildrenOfType().Single().Enabled.Value, () => Is.False); + AddUntilStep("use current snap not available", () => getCurrentSnapButton().Enabled.Value, () => Is.False); } private void assertSnapDistance(float expectedDistance, HitObject? referenceObject, bool includeSliderVelocity) @@ -262,7 +302,7 @@ namespace osu.Game.Tests.Editing => AddAssert($"distance = {distance} -> duration = {expectedDuration} (snapped)", () => composer.DistanceSnapProvider.FindSnappedDuration(referenceObject ?? new HitObject(), distance), () => Is.EqualTo(expectedDuration).Within(Precision.FLOAT_EPSILON)); private void assertSnappedDistance(float distance, float expectedDistance, HitObject? referenceObject = null) - => AddAssert($"distance = {distance} -> distance = {expectedDistance} (snapped)", () => composer.DistanceSnapProvider.FindSnappedDistance(referenceObject ?? new HitObject(), distance), () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON)); + => AddAssert($"distance = {distance} -> distance = {expectedDistance} (snapped)", () => composer.DistanceSnapProvider.FindSnappedDistance(referenceObject ?? new HitObject(), distance, DistanceSnapTarget.End), () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON)); private partial class TestHitObjectComposer : OsuHitObjectComposer { diff --git a/osu.Game.Tests/Editing/TimingSectionAdjustmentsTest.cs b/osu.Game.Tests/Editing/TimingSectionAdjustmentsTest.cs new file mode 100644 index 0000000000..5f5a1760ea --- /dev/null +++ b/osu.Game.Tests/Editing/TimingSectionAdjustmentsTest.cs @@ -0,0 +1,161 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Edit.Timing; + +namespace osu.Game.Tests.Editing +{ + [TestFixture] + public class TimingSectionAdjustmentsTest + { + [Test] + public void TestOffsetAdjustment() + { + var controlPoints = new ControlPointInfo(); + + controlPoints.Add(100, new TimingControlPoint { BeatLength = 100 }); + controlPoints.Add(50_000, new TimingControlPoint { BeatLength = 200 }); + controlPoints.Add(100_000, new TimingControlPoint { BeatLength = 50 }); + + var beatmap = new Beatmap + { + ControlPointInfo = controlPoints, + HitObjects = new List + { + new HitCircle { StartTime = 0 }, + new HitCircle { StartTime = 200 }, + new HitCircle { StartTime = 49_900 }, + new HitCircle { StartTime = 50_000 }, + new HitCircle { StartTime = 50_200 }, + new HitCircle { StartTime = 99_800 }, + new HitCircle { StartTime = 100_000 }, + new HitCircle { StartTime = 100_050 }, + new HitCircle { StartTime = 100_550 }, + } + }; + + moveTimingPoint(beatmap, 100, -50); + + Assert.Multiple(() => + { + Assert.That(beatmap.HitObjects[0].StartTime, Is.EqualTo(-50)); + Assert.That(beatmap.HitObjects[1].StartTime, Is.EqualTo(150)); + Assert.That(beatmap.HitObjects[2].StartTime, Is.EqualTo(49_850)); + Assert.That(beatmap.HitObjects[3].StartTime, Is.EqualTo(50_000)); + }); + + moveTimingPoint(beatmap, 50_000, 1_000); + + Assert.Multiple(() => + { + Assert.That(beatmap.HitObjects[2].StartTime, Is.EqualTo(49_850)); + Assert.That(beatmap.HitObjects[3].StartTime, Is.EqualTo(51_000)); + Assert.That(beatmap.HitObjects[4].StartTime, Is.EqualTo(51_200)); + Assert.That(beatmap.HitObjects[5].StartTime, Is.EqualTo(100_800)); + Assert.That(beatmap.HitObjects[6].StartTime, Is.EqualTo(100_000)); + }); + + moveTimingPoint(beatmap, 100_000, 10_000); + + Assert.Multiple(() => + { + Assert.That(beatmap.HitObjects[4].StartTime, Is.EqualTo(51_200)); + Assert.That(beatmap.HitObjects[5].StartTime, Is.EqualTo(110_800)); + Assert.That(beatmap.HitObjects[6].StartTime, Is.EqualTo(110_000)); + Assert.That(beatmap.HitObjects[7].StartTime, Is.EqualTo(110_050)); + Assert.That(beatmap.HitObjects[8].StartTime, Is.EqualTo(110_550)); + }); + } + + [Test] + public void TestBPMAdjustment() + { + var controlPoints = new ControlPointInfo(); + + controlPoints.Add(100, new TimingControlPoint { BeatLength = 100 }); + controlPoints.Add(50_000, new TimingControlPoint { BeatLength = 200 }); + controlPoints.Add(100_000, new TimingControlPoint { BeatLength = 50 }); + + var beatmap = new Beatmap + { + ControlPointInfo = controlPoints, + HitObjects = new List + { + new HitCircle { StartTime = 0 }, + new HitCircle { StartTime = 200 }, + new Spinner { StartTime = 500, EndTime = 1000 }, + new HitCircle { StartTime = 49_900 }, + new HitCircle { StartTime = 50_000 }, + new HitCircle { StartTime = 50_200 }, + new HitCircle { StartTime = 99_800 }, + new HitCircle { StartTime = 100_000 }, + new HitCircle { StartTime = 100_050 }, + new HitCircle { StartTime = 100_550 }, + } + }; + + adjustBeatLength(beatmap, 100, 50); + + Assert.Multiple(() => + { + Assert.That(beatmap.HitObjects[0].StartTime, Is.EqualTo(50)); + Assert.That(beatmap.HitObjects[1].StartTime, Is.EqualTo(150)); + Assert.That(beatmap.HitObjects[2].StartTime, Is.EqualTo(300)); + Assert.That(beatmap.HitObjects[2].GetEndTime(), Is.EqualTo(550)); + Assert.That(beatmap.HitObjects[3].StartTime, Is.EqualTo(25_000)); + Assert.That(beatmap.HitObjects[4].StartTime, Is.EqualTo(50_000)); + }); + + adjustBeatLength(beatmap, 50_000, 400); + + Assert.Multiple(() => + { + Assert.That(beatmap.HitObjects[2].StartTime, Is.EqualTo(300)); + Assert.That(beatmap.HitObjects[2].GetEndTime(), Is.EqualTo(550)); + Assert.That(beatmap.HitObjects[3].StartTime, Is.EqualTo(25_000)); + Assert.That(beatmap.HitObjects[4].StartTime, Is.EqualTo(50_000)); + Assert.That(beatmap.HitObjects[5].StartTime, Is.EqualTo(50_400)); + Assert.That(beatmap.HitObjects[6].StartTime, Is.EqualTo(149_600)); + Assert.That(beatmap.HitObjects[7].StartTime, Is.EqualTo(100_000)); + }); + + adjustBeatLength(beatmap, 100_000, 100); + + Assert.Multiple(() => + { + Assert.That(beatmap.HitObjects[5].StartTime, Is.EqualTo(50_400)); + Assert.That(beatmap.HitObjects[6].StartTime, Is.EqualTo(199_200)); + Assert.That(beatmap.HitObjects[7].StartTime, Is.EqualTo(100_000)); + Assert.That(beatmap.HitObjects[8].StartTime, Is.EqualTo(100_100)); + Assert.That(beatmap.HitObjects[9].StartTime, Is.EqualTo(101_100)); + }); + } + + private static void moveTimingPoint(IBeatmap beatmap, double originalTime, double adjustment) + { + var controlPoints = beatmap.ControlPointInfo; + var controlPointGroup = controlPoints.GroupAt(originalTime); + var timingPoint = controlPointGroup.ControlPoints.OfType().Single(); + controlPoints.RemoveGroup(controlPointGroup); + TimingSectionAdjustments.AdjustHitObjectOffset(beatmap, timingPoint, adjustment); + controlPoints.Add(originalTime - adjustment, timingPoint); + } + + private static void adjustBeatLength(IBeatmap beatmap, double groupTime, double newBeatLength) + { + var controlPoints = beatmap.ControlPointInfo; + var controlPointGroup = controlPoints.GroupAt(groupTime); + var timingPoint = controlPointGroup.ControlPoints.OfType().Single(); + double oldBeatLength = timingPoint.BeatLength; + timingPoint.BeatLength = newBeatLength; + TimingSectionAdjustments.SetHitObjectBPM(beatmap, timingPoint, oldBeatLength); + } + } +} diff --git a/osu.Game.Tests/Input/ConfineMouseTrackerTest.cs b/osu.Game.Tests/Input/ConfineMouseTrackerTest.cs index 6b43ab83c5..42f50efdbf 100644 --- a/osu.Game.Tests/Input/ConfineMouseTrackerTest.cs +++ b/osu.Game.Tests/Input/ConfineMouseTrackerTest.cs @@ -3,11 +3,13 @@ using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Configuration; using osu.Framework.Input; using osu.Framework.Testing; using osu.Game.Configuration; using osu.Game.Input; +using osu.Game.Screens.Play; using osu.Game.Tests.Visual; namespace osu.Game.Tests.Input @@ -15,9 +17,20 @@ namespace osu.Game.Tests.Input [HeadlessTest] public partial class ConfineMouseTrackerTest : OsuGameTestScene { + private readonly Bindable playingState = new Bindable(); + [Resolved] private FrameworkConfigManager frameworkConfigManager { get; set; } = null!; + [SetUpSteps] + public override void SetUpSteps() + { + base.SetUpSteps(); + + // a bit dodgy. + AddStep("bind playing state", () => ((IBindable)playingState).BindTo(((ILocalUserPlayInfo)Game).PlayingState)); + } + [TestCase(WindowMode.Windowed)] [TestCase(WindowMode.Borderless)] public void TestDisableConfining(WindowMode windowMode) @@ -88,7 +101,7 @@ namespace osu.Game.Tests.Input => AddStep($"set {mode} game-side", () => Game.LocalConfig.SetValue(OsuSetting.ConfineMouseMode, mode)); private void setLocalUserPlayingTo(bool playing) - => AddStep($"local user {(playing ? "playing" : "not playing")}", () => Game.LocalUserPlaying.Value = playing); + => AddStep($"local user {(playing ? "playing" : "not playing")}", () => playingState.Value = playing ? LocalUserPlayingState.Playing : LocalUserPlayingState.NotPlaying); private void gameSideModeIs(OsuConfineMouseMode mode) => AddAssert($"mode is {mode} game-side", () => Game.LocalConfig.Get(OsuSetting.ConfineMouseMode) == mode); diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs index 10e0e46f4c..1efcc8542d 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs @@ -148,6 +148,7 @@ namespace osu.Game.Tests.NonVisual.Filtering [TestCase("tags too", false)] [TestCase("version", false)] [TestCase("an auteur", true)] + [TestCase("unit", false)] public void TestCriteriaMatchingTerms(string terms, bool filtered) { var exampleBeatmapInfo = getExampleBeatmap(); @@ -175,6 +176,7 @@ namespace osu.Game.Tests.NonVisual.Filtering [TestCase("\"Artist\"!", true)] [TestCase("\"The Artist\"!", false)] [TestCase("\"the artist\"!", false)] + [TestCase("\"unit tests\"!", false)] [TestCase("\"\\\"", true)] // nasty case, covers properly escaping user input in underlying regex. public void TestCriteriaMatchingExactTerms(string terms, bool filtered) { diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs index 9ecfa72947..f4e324d7ba 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs @@ -501,6 +501,18 @@ namespace osu.Game.Tests.NonVisual.Filtering Assert.That(visibleBeatmaps, Is.EqualTo(expectedBeatmapIndexes)); } + [Test] + public void TestApplySourceQueries() + { + const string query = "find me songs with source=\"unit tests\" please"; + var filterCriteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(filterCriteria, query); + Assert.AreEqual("find me songs with please", filterCriteria.SearchText.Trim()); + Assert.AreEqual(5, filterCriteria.SearchTerms.Length); + Assert.AreEqual("unit tests", filterCriteria.Source.SearchTerm); + Assert.That(filterCriteria.Source.MatchMode, Is.EqualTo(FilterCriteria.MatchMode.IsolatedPhrase)); + } + private class CustomFilterCriteria : IRulesetFilterCriteria { public string? CustomValue { get; set; } @@ -627,6 +639,87 @@ namespace osu.Game.Tests.NonVisual.Filtering Assert.AreEqual(DateTimeOffset.MinValue.AddMilliseconds(1), filterCriteria.LastPlayed.Min); } + private static DateTimeOffset dateTimeOffsetFromDateOnly(int year, int month, int day) => + new DateTimeOffset(year, month, day, 0, 0, 0, TimeSpan.Zero); + + private static readonly object[] ranked_date_valid_test_cases = + { + new object[] { "ranked<2012", dateTimeOffsetFromDateOnly(2012, 1, 1), (FilterCriteria x) => x.DateRanked.Max }, + new object[] { "ranked<2012.03", dateTimeOffsetFromDateOnly(2012, 3, 1), (FilterCriteria x) => x.DateRanked.Max }, + new object[] { "ranked<2012/03/05", dateTimeOffsetFromDateOnly(2012, 3, 5), (FilterCriteria x) => x.DateRanked.Max }, + new object[] { "ranked<2012-3-5", dateTimeOffsetFromDateOnly(2012, 3, 5), (FilterCriteria x) => x.DateRanked.Max }, + + new object[] { "ranked<=2012", dateTimeOffsetFromDateOnly(2013, 1, 1), (FilterCriteria x) => x.DateRanked.Max }, + new object[] { "ranked<=2012.03", dateTimeOffsetFromDateOnly(2012, 4, 1), (FilterCriteria x) => x.DateRanked.Max }, + new object[] { "ranked<=2012/03/05", dateTimeOffsetFromDateOnly(2012, 3, 6), (FilterCriteria x) => x.DateRanked.Max }, + new object[] { "ranked<=2012-3-5", dateTimeOffsetFromDateOnly(2012, 3, 6), (FilterCriteria x) => x.DateRanked.Max }, + + new object[] { "ranked>2012", dateTimeOffsetFromDateOnly(2013, 1, 1), (FilterCriteria x) => x.DateRanked.Min }, + new object[] { "ranked>2012.03", dateTimeOffsetFromDateOnly(2012, 4, 1), (FilterCriteria x) => x.DateRanked.Min }, + new object[] { "ranked>2012/03/05", dateTimeOffsetFromDateOnly(2012, 3, 6), (FilterCriteria x) => x.DateRanked.Min }, + new object[] { "ranked>2012-3-5", dateTimeOffsetFromDateOnly(2012, 3, 6), (FilterCriteria x) => x.DateRanked.Min }, + + new object[] { "ranked>=2012", dateTimeOffsetFromDateOnly(2012, 1, 1), (FilterCriteria x) => x.DateRanked.Min }, + new object[] { "ranked>=2012.03", dateTimeOffsetFromDateOnly(2012, 3, 1), (FilterCriteria x) => x.DateRanked.Min }, + new object[] { "ranked>=2012/03/05", dateTimeOffsetFromDateOnly(2012, 3, 5), (FilterCriteria x) => x.DateRanked.Min }, + new object[] { "ranked>=2012-3-5", dateTimeOffsetFromDateOnly(2012, 3, 5), (FilterCriteria x) => x.DateRanked.Min }, + + new object[] { "ranked=2012", dateTimeOffsetFromDateOnly(2012, 1, 1), (FilterCriteria x) => x.DateRanked.Min }, + new object[] { "ranked=2012", dateTimeOffsetFromDateOnly(2012, 1, 1), (FilterCriteria x) => x.DateRanked.Min }, + new object[] { "ranked=2012-03", dateTimeOffsetFromDateOnly(2012, 3, 1), (FilterCriteria x) => x.DateRanked.Min }, + new object[] { "ranked=2012-03", dateTimeOffsetFromDateOnly(2012, 4, 1), (FilterCriteria x) => x.DateRanked.Max }, + new object[] { "ranked=2012-03-05", dateTimeOffsetFromDateOnly(2012, 3, 5), (FilterCriteria x) => x.DateRanked.Min }, + new object[] { "ranked=2012-03-05", dateTimeOffsetFromDateOnly(2012, 3, 6), (FilterCriteria x) => x.DateRanked.Max }, + }; + + [Test] + [TestCaseSource(nameof(ranked_date_valid_test_cases))] + public void TestValidRankedDateQueries(string query, DateTimeOffset expected, Func f) + { + var filterCriteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(filterCriteria, query); + Assert.AreEqual(true, filterCriteria.DateRanked.HasFilter); + Assert.AreEqual(expected, f(filterCriteria)); + } + + private static readonly object[] ranked_date_invalid_test_cases = + { + new object[] { "ranked<0" }, + new object[] { "ranked=99999" }, + new object[] { "ranked>=2012-03-05-04" }, + }; + + [Test] + [TestCaseSource(nameof(ranked_date_invalid_test_cases))] + public void TestInvalidRankedDateQueries(string query) + { + var filterCriteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(filterCriteria, query); + Assert.AreEqual(false, filterCriteria.DateRanked.HasFilter); + } + + private static readonly object[] submitted_date_test_cases = + { + new object[] { "submitted<2012", true }, + new object[] { "submitted<2012.03", true }, + new object[] { "submitted<2012/03/05", true }, + new object[] { "submitted<2012-3-5", true }, + + new object[] { "submitted<0", false }, + new object[] { "submitted=99999", false }, + new object[] { "submitted>=2012-03-05-04", false }, + new object[] { "submitted>=2012/03.05-04", false }, + }; + + [Test] + [TestCaseSource(nameof(submitted_date_test_cases))] + public void TestInvalidRankedDateQueries(string query, bool expected) + { + var filterCriteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(filterCriteria, query); + Assert.AreEqual(expected, filterCriteria.DateSubmitted.HasFilter); + } + private static readonly object[] played_query_tests = { new object[] { "0", DateTimeOffset.MinValue, true }, diff --git a/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs b/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs index d4b69c1be2..07d6d68e82 100644 --- a/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs +++ b/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs @@ -96,6 +96,7 @@ namespace osu.Game.Tests.NonVisual public override IAdjustableAudioComponent Audio { get; } public override Playfield Playfield { get; } + public override PlayfieldAdjustmentContainer PlayfieldAdjustmentContainer { get; } public override Container Overlays { get; } public override Container FrameStableComponents { get; } public override IFrameStableClock FrameStableClock { get; } diff --git a/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs b/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs index b4bbe274a5..559db16751 100644 --- a/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs +++ b/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs @@ -73,9 +73,9 @@ namespace osu.Game.Tests.NonVisual.Multiplayer AddStep("create room initially in gameplay", () => { var newRoom = new Room(); - newRoom.CopyFrom(SelectedRoom.Value); + newRoom.CopyFrom(SelectedRoom.Value!); - newRoom.RoomID.Value = null; + newRoom.RoomID = null; MultiplayerClient.RoomSetupAction = room => { room.State = MultiplayerRoomState.Playing; diff --git a/osu.Game.Tests/OnlinePlay/TestSceneCatchUpSyncManager.cs b/osu.Game.Tests/OnlinePlay/TestSceneCatchUpSyncManager.cs index 7b0b211899..8f6325c70b 100644 --- a/osu.Game.Tests/OnlinePlay/TestSceneCatchUpSyncManager.cs +++ b/osu.Game.Tests/OnlinePlay/TestSceneCatchUpSyncManager.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using NUnit.Framework; @@ -21,12 +19,12 @@ namespace osu.Game.Tests.OnlinePlay [HeadlessTest] public partial class TestSceneCatchUpSyncManager : OsuTestScene { - private GameplayClockContainer master; - private SpectatorSyncManager syncManager; + private GameplayClockContainer master = null!; + private SpectatorSyncManager syncManager = null!; - private Dictionary clocksById; - private SpectatorPlayerClock player1; - private SpectatorPlayerClock player2; + private Dictionary clocksById = null!; + private SpectatorPlayerClock player1 = null!; + private SpectatorPlayerClock player2 = null!; [SetUp] public void Setup() diff --git a/osu.Game.Tests/Resources/Archives/decimal-timing-beatmap.olz b/osu.Game.Tests/Resources/Archives/decimal-timing-beatmap.olz new file mode 100644 index 0000000000..38dedc35d1 Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/decimal-timing-beatmap.olz differ diff --git a/osu.Game.Tests/Resources/Archives/legacy-export-stability-test.olz b/osu.Game.Tests/Resources/Archives/legacy-export-stability-test.olz new file mode 100644 index 0000000000..c6cf33acaf Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/legacy-export-stability-test.olz differ diff --git a/osu.Game.Tests/Resources/mania-0-01-sv.osu b/osu.Game.Tests/Resources/mania-0-01-sv.osu new file mode 100644 index 0000000000..295a8a423a --- /dev/null +++ b/osu.Game.Tests/Resources/mania-0-01-sv.osu @@ -0,0 +1,39 @@ +osu file format v14 + +[General] +SampleSet: Normal +StackLeniency: 0.7 +Mode: 3 + +[Difficulty] +HPDrainRate:3 +CircleSize:5 +OverallDifficulty:8 +ApproachRate:8 +SliderMultiplier:3.59999990463257 +SliderTickRate:2 + +[TimingPoints] +24,352.941176470588,4,1,1,100,1,0 +6376,-10000,4,1,1,100,0,0 + +[HitObjects] +51,192,24,1,0,0:0:0:0: +153,192,200,1,0,0:0:0:0: +358,192,376,1,0,0:0:0:0: +460,192,553,1,0,0:0:0:0: +460,192,729,128,0,1435:0:0:0:0: +358,192,906,128,0,1612:0:0:0:0: +256,192,1082,128,0,1788:0:0:0:0: +153,192,1259,128,0,1965:0:0:0:0: +51,192,1435,128,0,2141:0:0:0:0: +51,192,2318,1,12,0:0:0:0: +153,192,2318,1,4,0:0:0:0: +256,192,2318,1,6,0:0:0:0: +358,192,2318,1,14,0:0:0:0: +460,192,2318,1,0,0:0:0:0: +51,192,2494,128,0,2582:0:0:0:0: +153,192,2494,128,14,2582:0:0:0:0: +256,192,2494,128,6,2582:0:0:0:0: +358,192,2494,128,4,2582:0:0:0:0: +460,192,2494,128,12,2582:0:0:0:0: diff --git a/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs b/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs index 747cf73baf..0f2f716a07 100644 --- a/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs +++ b/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs @@ -12,6 +12,7 @@ using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Collections; +using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; using osu.Game.Overlays.Dialog; using osu.Game.Rulesets; @@ -205,7 +206,9 @@ namespace osu.Game.Tests.Visual.Collections AddStep("click first delete button", () => { - InputManager.MoveMouseTo(dialog.ChildrenOfType().First(), new Vector2(5, 0)); + InputManager.MoveMouseTo(dialog + .ChildrenOfType().Single(i => i.Model.Value.Name == "1") + .ChildrenOfType().Single(), new Vector2(5, 0)); InputManager.Click(MouseButton.Left); }); @@ -213,9 +216,11 @@ namespace osu.Game.Tests.Visual.Collections assertCollectionCount(1); assertCollectionName(0, "2"); - AddStep("click first delete button", () => + AddStep("click second delete button", () => { - InputManager.MoveMouseTo(dialog.ChildrenOfType().First(), new Vector2(5, 0)); + InputManager.MoveMouseTo(dialog + .ChildrenOfType().Single(i => i.Model.Value.Name == "2") + .ChildrenOfType().Single(), new Vector2(5, 0)); InputManager.Click(MouseButton.Left); }); @@ -310,7 +315,7 @@ namespace osu.Game.Tests.Visual.Collections AddStep("focus first collection", () => { - InputManager.MoveMouseTo(firstItem = dialog.ChildrenOfType().First()); + InputManager.MoveMouseTo(firstItem = dialog.ChildrenOfType().Single(i => i.Model.Value.Name == "1")); InputManager.Click(MouseButton.Left); }); @@ -333,10 +338,44 @@ namespace osu.Game.Tests.Visual.Collections AddUntilStep("collection has new name", () => first.Name == "First"); } + [Test] + public void TestSearch() + { + BeatmapCollection first = null!; + + AddStep("add two collections", () => + { + Realm.Write(r => + { + r.Add(new[] + { + first = new BeatmapCollection(name: "1"), + new BeatmapCollection(name: "2"), + }); + }); + }); + + assertCollectionName(0, "1"); + assertCollectionName(1, "2"); + + AddStep("search for 1", () => dialog.ChildrenOfType().Single().Current.Value = "1"); + + assertCollectionCount(1); + + AddStep("change first collection name", () => Realm.Write(_ => first.Name = "First")); + + assertCollectionCount(0); + + AddStep("search for first", () => dialog.ChildrenOfType().Single().Current.Value = "firs"); + + assertCollectionCount(1); + } + private void assertCollectionCount(int count) - => AddUntilStep($"{count} collections shown", () => dialog.ChildrenOfType().Count() == count + 1); // +1 for placeholder + => AddUntilStep($"{count} collections shown", () => dialog.ChildrenOfType().Count(i => i.IsPresent) == count + 1); // +1 for placeholder private void assertCollectionName(int index, string name) - => AddUntilStep($"item {index + 1} has correct name", () => dialog.ChildrenOfType().ElementAt(index).ChildrenOfType().First().Text == name); + => AddUntilStep($"item {index + 1} has correct name", + () => dialog.ChildrenOfType().Single().OrderedItems.ElementAt(index).ChildrenOfType().First().Text == name); } } diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallenge.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallenge.cs index e10b3f76e6..0742ed5eb9 100644 --- a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallenge.cs +++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallenge.cs @@ -6,10 +6,12 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Screens; +using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Online.Metadata; using osu.Game.Online.Rooms; using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Tests.Resources; using osu.Game.Tests.Visual.Metadata; @@ -37,18 +39,18 @@ namespace osu.Game.Tests.Visual.DailyChallenge { var room = new Room { - RoomID = { Value = 1234 }, - Name = { Value = "Daily Challenge: June 4, 2024" }, + RoomID = 1234, + Name = "Daily Challenge: June 4, 2024", Playlist = - { + [ new PlaylistItem(TestResources.CreateTestBeatmapSetInfo().Beatmaps.First()) { RequiredMods = [new APIMod(new OsuModTraceable())], AllowedMods = [new APIMod(new OsuModDoubleTime())] } - }, - EndDate = { Value = DateTimeOffset.Now.AddHours(12) }, - Category = { Value = RoomCategory.DailyChallenge } + ], + EndDate = DateTimeOffset.Now.AddHours(12), + Category = RoomCategory.DailyChallenge }; AddStep("add room", () => API.Perform(new CreateRoomRequest(room))); @@ -60,18 +62,18 @@ namespace osu.Game.Tests.Visual.DailyChallenge { var room = new Room { - RoomID = { Value = 1234 }, - Name = { Value = "Daily Challenge: June 4, 2024" }, + RoomID = 1234, + Name = "Daily Challenge: June 4, 2024", Playlist = - { + [ new PlaylistItem(TestResources.CreateTestBeatmapSetInfo().Beatmaps.First()) { RequiredMods = [new APIMod(new OsuModTraceable())], AllowedMods = [new APIMod(new OsuModDoubleTime())] } - }, - EndDate = { Value = DateTimeOffset.Now.AddHours(12) }, - Category = { Value = RoomCategory.DailyChallenge } + ], + EndDate = DateTimeOffset.Now.AddHours(12), + Category = RoomCategory.DailyChallenge }; AddStep("add room", () => API.Perform(new CreateRoomRequest(room))); @@ -81,6 +83,38 @@ namespace osu.Game.Tests.Visual.DailyChallenge AddStep("push screen", () => LoadScreen(screen = new Screens.OnlinePlay.DailyChallenge.DailyChallenge(room))); AddUntilStep("wait for screen", () => screen.IsCurrentScreen()); AddStep("daily challenge ended", () => metadataClient.DailyChallengeInfo.Value = null); + AddAssert("notification posted", () => notificationOverlay.AllNotifications.OfType().Any(n => n.Text == DailyChallengeStrings.ChallengeEndedNotification)); + } + + [Test] + public void TestConclusionNotificationDoesNotFireOnDisconnect() + { + var room = new Room + { + RoomID = 1234, + Name = "Daily Challenge: June 4, 2024", + Playlist = + [ + new PlaylistItem(TestResources.CreateTestBeatmapSetInfo().Beatmaps.First()) + { + RequiredMods = [new APIMod(new OsuModTraceable())], + AllowedMods = [new APIMod(new OsuModDoubleTime())] + } + ], + EndDate = DateTimeOffset.Now.AddHours(12), + Category = RoomCategory.DailyChallenge + }; + + AddStep("add room", () => API.Perform(new CreateRoomRequest(room))); + AddStep("set daily challenge info", () => metadataClient.DailyChallengeInfo.Value = new DailyChallengeInfo { RoomID = 1234 }); + + Screens.OnlinePlay.DailyChallenge.DailyChallenge screen = null!; + AddStep("push screen", () => LoadScreen(screen = new Screens.OnlinePlay.DailyChallenge.DailyChallenge(room))); + AddUntilStep("wait for screen", () => screen.IsCurrentScreen()); + AddStep("disconnect from metadata server", () => metadataClient.Disconnect()); + AddUntilStep("wait for disconnection", () => metadataClient.DailyChallengeInfo.Value, () => Is.Null); + AddAssert("no notification posted", () => notificationOverlay.AllNotifications, () => Is.Empty); + AddStep("reconnect to metadata server", () => metadataClient.Reconnect()); } } } diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeCarousel.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeCarousel.cs index d53e386ad4..b9470f3be4 100644 --- a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeCarousel.cs +++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeCarousel.cs @@ -26,11 +26,6 @@ namespace osu.Game.Tests.Visual.DailyChallenge private readonly Bindable room = new Bindable(new Room()); - protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => new CachedModelDependencyContainer(base.CreateChildDependencies(parent)) - { - Model = { BindTarget = room } - }; - [Test] public void TestBasicAppearance() { @@ -98,7 +93,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge Origin = Anchor.Centre, Children = new Drawable[] { - new DailyChallengeTimeRemainingRing(), + new DailyChallengeTimeRemainingRing(room.Value), breakdown = new DailyChallengeScoreBreakdown(), } } @@ -125,8 +120,8 @@ namespace osu.Game.Tests.Visual.DailyChallenge AddSliderStep("update time remaining", 0f, 1f, 0f, progress => { var startedTimeAgo = TimeSpan.FromHours(24) * progress; - room.Value.StartDate.Value = DateTimeOffset.Now - startedTimeAgo; - room.Value.EndDate.Value = room.Value.StartDate.Value.Value.AddDays(1); + room.Value.StartDate = DateTimeOffset.Now - startedTimeAgo; + room.Value.EndDate = room.Value.StartDate.Value.AddDays(1); }); AddStep("add normal score", () => { diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeIntro.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeIntro.cs index f1a2d6b5f2..d6665e24a4 100644 --- a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeIntro.cs +++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeIntro.cs @@ -38,7 +38,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge Add(metadataClient); // add button to observe for daily challenge changes and perform its logic. - Add(new DailyChallengeButton(@"button-default-select", new Color4(102, 68, 204, 255), _ => { }, 0, Key.D)); + Add(new DailyChallengeButton(@"button-default-select", new Color4(102, 68, 204, 255), (_, _) => { }, 0, Key.D)); } [Test] @@ -68,19 +68,19 @@ namespace osu.Game.Tests.Visual.DailyChallenge { API.Perform(new CreateRoomRequest(room = new Room { - RoomID = { Value = roomId }, - Name = { Value = "Daily Challenge: June 4, 2024" }, + RoomID = roomId, + Name = "Daily Challenge: June 4, 2024", Playlist = - { + [ new PlaylistItem(CreateAPIBeatmap(new OsuRuleset().RulesetInfo)) { RequiredMods = [new APIMod(new OsuModTraceable())], AllowedMods = [new APIMod(new OsuModDoubleTime())] } - }, - StartDate = { Value = DateTimeOffset.Now }, - EndDate = { Value = DateTimeOffset.Now.AddHours(24) }, - Category = { Value = RoomCategory.DailyChallenge } + ], + StartDate = DateTimeOffset.Now, + EndDate = DateTimeOffset.Now.AddHours(24), + Category = RoomCategory.DailyChallenge })); }); AddStep("signal client", () => metadataClient.DailyChallengeUpdated(new DailyChallengeInfo { RoomID = roomId })); diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeLeaderboard.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeLeaderboard.cs index 5fff6bb010..d21ca22e1b 100644 --- a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeLeaderboard.cs +++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeLeaderboard.cs @@ -43,7 +43,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge return false; }; }); - AddStep("create leaderboard", () => Child = leaderboard = new DailyChallengeLeaderboard(new Room { RoomID = { Value = 1 } }, new PlaylistItem(Beatmap.Value.BeatmapInfo)) + AddStep("create leaderboard", () => Child = leaderboard = new DailyChallengeLeaderboard(new Room { RoomID = 1 }, new PlaylistItem(Beatmap.Value.BeatmapInfo)) { RelativeSizeAxes = Axes.Both, Anchor = Anchor.Centre, @@ -86,7 +86,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge return false; }; }); - AddStep("create leaderboard", () => Child = leaderboard = new DailyChallengeLeaderboard(new Room { RoomID = { Value = 1 } }, new PlaylistItem(Beatmap.Value.BeatmapInfo)) + AddStep("create leaderboard", () => Child = leaderboard = new DailyChallengeLeaderboard(new Room { RoomID = 1 }, new PlaylistItem(Beatmap.Value.BeatmapInfo)) { RelativeSizeAxes = Axes.Both, Anchor = Anchor.Centre, diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeTimeRemainingRing.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeTimeRemainingRing.cs index baa1eb8318..eebbd82190 100644 --- a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeTimeRemainingRing.cs +++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeTimeRemainingRing.cs @@ -18,11 +18,6 @@ namespace osu.Game.Tests.Visual.DailyChallenge { private readonly Bindable room = new Bindable(new Room()); - protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => new CachedModelDependencyContainer(base.CreateChildDependencies(parent)) - { - Model = { BindTarget = room } - }; - [Cached] private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Plum); @@ -38,7 +33,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge RelativeSizeAxes = Axes.Both, Colour = colourProvider.Background4, }, - ring = new DailyChallengeTimeRemainingRing + ring = new DailyChallengeTimeRemainingRing(room.Value) { RelativeSizeAxes = Axes.Both, Anchor = Anchor.Centre, @@ -59,29 +54,29 @@ namespace osu.Game.Tests.Visual.DailyChallenge AddStep("just started", () => { - room.Value.StartDate.Value = DateTimeOffset.Now.AddMinutes(-1); - room.Value.EndDate.Value = room.Value.StartDate.Value.Value.AddDays(1); + room.Value.StartDate = DateTimeOffset.Now.AddMinutes(-1); + room.Value.EndDate = room.Value.StartDate.Value.AddDays(1); }); AddStep("midway through", () => { - room.Value.StartDate.Value = DateTimeOffset.Now.AddHours(-12); - room.Value.EndDate.Value = room.Value.StartDate.Value.Value.AddDays(1); + room.Value.StartDate = DateTimeOffset.Now.AddHours(-12); + room.Value.EndDate = room.Value.StartDate.Value.AddDays(1); }); AddStep("nearing end", () => { - room.Value.StartDate.Value = DateTimeOffset.Now.AddDays(-1).AddMinutes(8); - room.Value.EndDate.Value = room.Value.StartDate.Value.Value.AddDays(1); + room.Value.StartDate = DateTimeOffset.Now.AddDays(-1).AddMinutes(8); + room.Value.EndDate = room.Value.StartDate.Value.AddDays(1); }); AddStep("already ended", () => { - room.Value.StartDate.Value = DateTimeOffset.Now.AddDays(-2); - room.Value.EndDate.Value = room.Value.StartDate.Value.Value.AddDays(1); + room.Value.StartDate = DateTimeOffset.Now.AddDays(-2); + room.Value.EndDate = room.Value.StartDate.Value.AddDays(1); }); AddSliderStep("manual progress", 0f, 1f, 0f, progress => { var startedTimeAgo = TimeSpan.FromHours(24) * progress; - room.Value.StartDate.Value = DateTimeOffset.Now - startedTimeAgo; - room.Value.EndDate.Value = room.Value.StartDate.Value.Value.AddDays(1); + room.Value.StartDate = DateTimeOffset.Now - startedTimeAgo; + room.Value.EndDate = room.Value.StartDate.Value.AddDays(1); }); } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneColoursSection.cs b/osu.Game.Tests/Visual/Editing/TestSceneColoursSection.cs new file mode 100644 index 0000000000..5a3329bbc9 --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneColoursSection.cs @@ -0,0 +1,123 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.Setup; +using osu.Game.Skinning; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.Editing +{ + [HeadlessTest] + public partial class TestSceneColoursSection : OsuManualInputManagerTestScene + { + [Test] + public void TestNoBeatmapSkinColours() + { + LegacyBeatmapSkin skin = null!; + ColoursSection coloursSection = null!; + + AddStep("create beatmap skin", () => skin = new LegacyBeatmapSkin(new BeatmapInfo(), null)); + AddStep("create colours section", () => Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = + [ + (typeof(EditorBeatmap), new EditorBeatmap(new Beatmap + { + BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo } + }, skin)), + (typeof(OverlayColourProvider), new OverlayColourProvider(OverlayColourScheme.Aquamarine)) + ], + Child = coloursSection = new ColoursSection + { + RelativeSizeAxes = Axes.X, + } + }); + AddAssert("beatmap skin has no colours", () => skin.Configuration.CustomComboColours, () => Is.Empty); + AddAssert("section displays default combo colours", + () => coloursSection.ChildrenOfType().Single().Colours, + () => Is.EquivalentTo(new Colour4[] + { + SkinConfiguration.DefaultComboColours[1], + SkinConfiguration.DefaultComboColours[2], + SkinConfiguration.DefaultComboColours[3], + SkinConfiguration.DefaultComboColours[0], + })); + + AddStep("add a colour", () => coloursSection.ChildrenOfType().Single().Colours.Add(Colour4.Aqua)); + AddAssert("beatmap skin has colours", + () => skin.Configuration.CustomComboColours, + () => Is.EquivalentTo(new[] + { + SkinConfiguration.DefaultComboColours[1], + SkinConfiguration.DefaultComboColours[2], + SkinConfiguration.DefaultComboColours[3], + Color4.Aqua, + SkinConfiguration.DefaultComboColours[0], + })); + } + + [Test] + public void TestExistingColours() + { + LegacyBeatmapSkin skin = null!; + ColoursSection coloursSection = null!; + + AddStep("create beatmap skin", () => + { + skin = new LegacyBeatmapSkin(new BeatmapInfo(), null); + skin.Configuration.CustomComboColours = new List + { + Color4.Azure, + Color4.Beige, + Color4.Chartreuse + }; + }); + AddStep("create colours section", () => Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = + [ + (typeof(EditorBeatmap), new EditorBeatmap(new Beatmap + { + BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo } + }, skin)), + (typeof(OverlayColourProvider), new OverlayColourProvider(OverlayColourScheme.Aquamarine)) + ], + Child = coloursSection = new ColoursSection + { + RelativeSizeAxes = Axes.X, + } + }); + AddAssert("section displays combo colours", + () => coloursSection.ChildrenOfType().Single().Colours, + () => Is.EquivalentTo(new[] + { + Colour4.Beige, + Colour4.Chartreuse, + Colour4.Azure, + })); + + AddStep("add a colour", () => coloursSection.ChildrenOfType().Single().Colours.Add(Colour4.Aqua)); + AddAssert("beatmap skin has colours", + () => skin.Configuration.CustomComboColours, + () => Is.EquivalentTo(new[] + { + Color4.Azure, + Color4.Beige, + Color4.Aqua, + Color4.Chartreuse + })); + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs b/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs index cbc9088d04..fd3431c08b 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.UserInterface; using osu.Framework.Testing; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets; @@ -82,7 +83,7 @@ namespace osu.Game.Tests.Visual.Editing } [Test] - public void TestNudgeSelection() + public void TestNudgeSelectionTime() { HitCircle[] addedObjects = null!; @@ -103,6 +104,51 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("objects reverted to original position", () => addedObjects[0].StartTime == 100); } + [Test] + public void TestNudgeSelectionPosition() + { + HitCircle addedObject = null!; + + AddStep("add hitobjects", () => EditorBeatmap.AddRange(new[] + { + addedObject = new HitCircle { StartTime = 200, Position = new Vector2(100) }, + })); + + AddStep("select object", () => EditorBeatmap.SelectedHitObjects.Add(addedObject)); + + AddStep("nudge up", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Key(Key.Up); + InputManager.ReleaseKey(Key.ControlLeft); + }); + AddAssert("object position moved up", () => addedObject.Position.Y, () => Is.EqualTo(99).Within(Precision.FLOAT_EPSILON)); + + AddStep("nudge down", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Key(Key.Down); + InputManager.ReleaseKey(Key.ControlLeft); + }); + AddAssert("object position moved down", () => addedObject.Position.Y, () => Is.EqualTo(100).Within(Precision.FLOAT_EPSILON)); + + AddStep("nudge left", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Key(Key.Left); + InputManager.ReleaseKey(Key.ControlLeft); + }); + AddAssert("object position moved left", () => addedObject.Position.X, () => Is.EqualTo(99).Within(Precision.FLOAT_EPSILON)); + + AddStep("nudge right", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Key(Key.Right); + InputManager.ReleaseKey(Key.ControlLeft); + }); + AddAssert("object position moved right", () => addedObject.Position.X, () => Is.EqualTo(100).Within(Precision.FLOAT_EPSILON)); + } + [Test] public void TestRotateHotkeys() { @@ -219,6 +265,51 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("2 hitobjects selected", () => EditorBeatmap.SelectedHitObjects.Count == 2 && !EditorBeatmap.SelectedHitObjects.Contains(addedObjects[1])); } + [Test] + public void TestMultiSelectWithDragBox() + { + var addedObjects = new[] + { + new HitCircle { StartTime = 100 }, + new HitCircle { StartTime = 200, Position = new Vector2(100) }, + new HitCircle { StartTime = 300, Position = new Vector2(512, 0) }, + new HitCircle { StartTime = 400, Position = new Vector2(412, 100) }, + }; + AddStep("add hitobjects", () => EditorBeatmap.AddRange(addedObjects)); + + AddStep("start dragging", () => + { + InputManager.MoveMouseTo(blueprintContainer.ScreenSpaceDrawQuad.Centre); + InputManager.PressButton(MouseButton.Left); + }); + AddStep("drag to left corner", () => InputManager.MoveMouseTo(blueprintContainer.ScreenSpaceDrawQuad.TopLeft - new Vector2(5))); + AddStep("end dragging", () => InputManager.ReleaseButton(MouseButton.Left)); + + AddAssert("2 hitobjects selected", () => EditorBeatmap.SelectedHitObjects, () => Has.Count.EqualTo(2)); + + AddStep("start dragging with control", () => + { + InputManager.MoveMouseTo(blueprintContainer.ScreenSpaceDrawQuad.Centre); + InputManager.PressButton(MouseButton.Left); + InputManager.PressKey(Key.ControlLeft); + }); + AddStep("drag to left corner", () => InputManager.MoveMouseTo(blueprintContainer.ScreenSpaceDrawQuad.TopRight + new Vector2(5, -5))); + AddStep("end dragging", () => InputManager.ReleaseButton(MouseButton.Left)); + AddStep("release control", () => InputManager.ReleaseKey(Key.ControlLeft)); + + AddAssert("4 hitobjects selected", () => EditorBeatmap.SelectedHitObjects, () => Has.Count.EqualTo(4)); + + AddStep("start dragging without control", () => + { + InputManager.MoveMouseTo(blueprintContainer.ScreenSpaceDrawQuad.Centre); + InputManager.PressButton(MouseButton.Left); + }); + AddStep("drag to left corner", () => InputManager.MoveMouseTo(blueprintContainer.ScreenSpaceDrawQuad.TopRight + new Vector2(5, -5))); + AddStep("end dragging", () => InputManager.ReleaseButton(MouseButton.Left)); + + AddAssert("2 hitobjects selected", () => EditorBeatmap.SelectedHitObjects, () => Has.Count.EqualTo(2)); + } + [Test] public void TestNearestSelection() { diff --git a/osu.Game.Tests/Visual/Editing/TestSceneDesignSection.cs b/osu.Game.Tests/Visual/Editing/TestSceneDesignSection.cs index 9a66e1676d..4dd27a7b6e 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneDesignSection.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneDesignSection.cs @@ -7,12 +7,14 @@ using System; using System.Globalization; using System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; using osu.Game.Rulesets.Osu; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Setup; @@ -25,6 +27,9 @@ namespace osu.Game.Tests.Visual.Editing private TestDesignSection designSection; private EditorBeatmap editorBeatmap { get; set; } + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + [SetUpSteps] public void SetUp() { @@ -42,7 +47,7 @@ namespace osu.Game.Tests.Visual.Editing { (typeof(EditorBeatmap), editorBeatmap) }, - Child = designSection = new TestDesignSection() + Child = designSection = new TestDesignSection { RelativeSizeAxes = Axes.X } }); } @@ -99,11 +104,11 @@ namespace osu.Game.Tests.Visual.Editing private partial class TestDesignSection : DesignSection { - public new LabelledSwitchButton EnableCountdown => base.EnableCountdown; + public new FormCheckBox EnableCountdown => base.EnableCountdown; public new FillFlowContainer CountdownSettings => base.CountdownSettings; - public new LabelledEnumDropdown CountdownSpeed => base.CountdownSpeed; - public new LabelledNumberBox CountdownOffset => base.CountdownOffset; + public new FormEnumDropdown CountdownSpeed => base.CountdownSpeed; + public new FormTextBox CountdownOffset => base.CountdownOffset; } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneDifficultyDelete.cs b/osu.Game.Tests/Visual/Editing/TestSceneDifficultyDelete.cs index d4bd77642c..62ff59c6b3 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneDifficultyDelete.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneDifficultyDelete.cs @@ -13,6 +13,7 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.Components.Menus; using osu.Game.Storyboards; using osu.Game.Tests.Beatmaps.IO; using osuTK.Input; @@ -60,7 +61,7 @@ namespace osu.Game.Tests.Visual.Editing beatmapSetHashBefore = Beatmap.Value.BeatmapSetInfo.Hash; }); - AddStep("click File", () => this.ChildrenOfType().First().TriggerClick()); + AddStep("click File", () => this.ChildrenOfType().First().TriggerClick()); if (i == 11) { @@ -107,7 +108,7 @@ namespace osu.Game.Tests.Visual.Editing EditorBeatmap.EndChange(); }); - AddStep("click File", () => this.ChildrenOfType().First().TriggerClick()); + AddStep("click File", () => this.ChildrenOfType().First().TriggerClick()); AddStep("click delete", () => getDeleteMenuItem().TriggerClick()); AddUntilStep("wait for dialog", () => DialogOverlay.CurrentDialog != null); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs b/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs index f2a015402a..c1a788cd22 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs @@ -199,7 +199,7 @@ namespace osu.Game.Tests.Visual.Editing public double FindSnappedDuration(HitObject referenceObject, float distance) => 0; - public float FindSnappedDistance(HitObject referenceObject, float distance) => 0; + public float FindSnappedDistance(HitObject referenceObject, float distance, DistanceSnapTarget target) => 0; } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSeeking.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSeeking.cs index da4f159cae..06facc546d 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSeeking.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSeeking.cs @@ -1,11 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Objects; using osuTK.Input; namespace osu.Game.Tests.Visual.Editing @@ -135,9 +137,42 @@ namespace osu.Game.Tests.Visual.Editing pressAndCheckTime(Key.Up, 0); } - private void pressAndCheckTime(Key key, double expectedTime) + [Test] + public void TestSeekBetweenObjects() { - AddStep($"press {key}", () => InputManager.Key(key)); + AddStep("add objects", () => + { + EditorBeatmap.Clear(); + EditorBeatmap.AddRange(new[] + { + new HitCircle { StartTime = 1000, }, + new HitCircle { StartTime = 2250, }, + new HitCircle { StartTime = 3600, }, + }); + }); + AddStep("seek to 0", () => EditorClock.Seek(0)); + + pressAndCheckTime(Key.Right, 1000, Key.ControlLeft); + pressAndCheckTime(Key.Right, 2250, Key.ControlLeft); + pressAndCheckTime(Key.Right, 3600, Key.ControlLeft); + pressAndCheckTime(Key.Right, 3600, Key.ControlLeft); + pressAndCheckTime(Key.Left, 2250, Key.ControlLeft); + pressAndCheckTime(Key.Left, 1000, Key.ControlLeft); + pressAndCheckTime(Key.Left, 1000, Key.ControlLeft); + } + + private void pressAndCheckTime(Key key, double expectedTime, params Key[] modifiers) + { + AddStep($"press {key} with {(modifiers.Any() ? string.Join(',', modifiers) : "no modifiers")}", () => + { + foreach (var modifier in modifiers) + InputManager.PressKey(modifier); + + InputManager.Key(key); + + foreach (var modifier in modifiers) + InputManager.ReleaseKey(modifier); + }); AddUntilStep($"time is {expectedTime}", () => EditorClock.CurrentTime, () => Is.EqualTo(expectedTime).Within(1)); } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs index f392841ac7..d7c92a64b1 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs @@ -19,6 +19,7 @@ using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Edit; using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.UI; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Components.RadioButtons; using osu.Game.Screens.Edit.Components.TernaryButtons; @@ -82,6 +83,45 @@ namespace osu.Game.Tests.Visual.Editing }); } + [Test] + public void TestPlacementOutsideComposeScreen() + { + AddStep("clear all control points and hitobjects", () => + { + editorBeatmap.ControlPointInfo.Clear(); + editorBeatmap.Clear(); + }); + + AddStep("Add timing point", () => editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint())); + AddStep("select circle", () => hitObjectComposer.ChildrenOfType().First(d => d.Button.Label == "HitCircle").TriggerClick()); + AddStep("move mouse to compose", () => InputManager.MoveMouseTo(hitObjectComposer.ChildrenOfType().Single())); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + AddAssert("circle placed", () => editorBeatmap.HitObjects.Count == 1); + + AddStep("move mouse outside compose", () => InputManager.MoveMouseTo(hitObjectComposer.ChildrenOfType().Single().ScreenSpaceDrawQuad.TopLeft - new Vector2(0f, 20f))); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + AddAssert("no circle placed", () => editorBeatmap.HitObjects.Count == 1); + } + + [Test] + public void TestDragSliderOutsideComposeScreen() + { + AddStep("clear all control points and hitobjects", () => + { + editorBeatmap.ControlPointInfo.Clear(); + editorBeatmap.Clear(); + }); + + AddStep("Add timing point", () => editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint())); + AddStep("select slider", () => hitObjectComposer.ChildrenOfType().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/Editing/TestSceneHitObjectSampleAdjustments.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSampleAdjustments.cs index 3c5277a4d9..5cc1e64197 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSampleAdjustments.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSampleAdjustments.cs @@ -362,6 +362,12 @@ namespace osu.Game.Tests.Visual.Editing } }); + AddStep("add whistle addition", () => + { + foreach (var h in EditorBeatmap.HitObjects) + h.Samples.Add(new HitSampleInfo(HitSampleInfo.HIT_WHISTLE, HitSampleInfo.BANK_SOFT)); + }); + AddStep("select both objects", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects)); hitObjectHasSampleBank(0, HitSampleInfo.BANK_SOFT); @@ -374,8 +380,10 @@ namespace osu.Game.Tests.Visual.Editing InputManager.ReleaseKey(Key.ShiftLeft); }); - hitObjectHasSampleBank(0, HitSampleInfo.BANK_NORMAL); - hitObjectHasSampleBank(1, HitSampleInfo.BANK_NORMAL); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_NORMAL); + hitObjectHasSampleNormalBank(1, HitSampleInfo.BANK_NORMAL); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_SOFT); + hitObjectHasSampleAdditionBank(1, HitSampleInfo.BANK_SOFT); AddStep("Press drum bank shortcut", () => { @@ -384,8 +392,10 @@ namespace osu.Game.Tests.Visual.Editing InputManager.ReleaseKey(Key.ShiftLeft); }); - hitObjectHasSampleBank(0, HitSampleInfo.BANK_DRUM); - hitObjectHasSampleBank(1, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleNormalBank(1, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_SOFT); + hitObjectHasSampleAdditionBank(1, HitSampleInfo.BANK_SOFT); AddStep("Press auto bank shortcut", () => { @@ -395,8 +405,47 @@ namespace osu.Game.Tests.Visual.Editing }); // Should be a noop. - hitObjectHasSampleBank(0, HitSampleInfo.BANK_DRUM); - hitObjectHasSampleBank(1, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleNormalBank(1, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_SOFT); + hitObjectHasSampleAdditionBank(1, HitSampleInfo.BANK_SOFT); + + AddStep("Press addition normal bank shortcut", () => + { + InputManager.PressKey(Key.AltLeft); + InputManager.Key(Key.W); + InputManager.ReleaseKey(Key.AltLeft); + }); + + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleNormalBank(1, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_NORMAL); + hitObjectHasSampleAdditionBank(1, HitSampleInfo.BANK_NORMAL); + + AddStep("Press addition drum bank shortcut", () => + { + InputManager.PressKey(Key.AltLeft); + InputManager.Key(Key.R); + InputManager.ReleaseKey(Key.AltLeft); + }); + + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleNormalBank(1, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleAdditionBank(1, HitSampleInfo.BANK_DRUM); + + AddStep("Press auto bank shortcut", () => + { + InputManager.PressKey(Key.AltLeft); + InputManager.Key(Key.Q); + InputManager.ReleaseKey(Key.AltLeft); + }); + + // Should be a noop. + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleNormalBank(1, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleAdditionBank(1, HitSampleInfo.BANK_DRUM); } [Test] @@ -414,7 +463,21 @@ namespace osu.Game.Tests.Visual.Editing InputManager.ReleaseKey(Key.ShiftLeft); }); - checkPlacementSample(HitSampleInfo.BANK_NORMAL); + AddStep("Press soft addition bank shortcut", () => + { + InputManager.PressKey(Key.AltLeft); + InputManager.Key(Key.E); + InputManager.ReleaseKey(Key.AltLeft); + }); + + checkPlacementSampleBank(HitSampleInfo.BANK_NORMAL); + + AddStep("Press finish sample shortcut", () => + { + InputManager.Key(Key.E); + }); + + checkPlacementSampleAdditionBank(HitSampleInfo.BANK_SOFT); AddStep("Press drum bank shortcut", () => { @@ -423,7 +486,18 @@ namespace osu.Game.Tests.Visual.Editing InputManager.ReleaseKey(Key.ShiftLeft); }); - checkPlacementSample(HitSampleInfo.BANK_DRUM); + checkPlacementSampleBank(HitSampleInfo.BANK_DRUM); + checkPlacementSampleAdditionBank(HitSampleInfo.BANK_SOFT); + + AddStep("Press drum addition bank shortcut", () => + { + InputManager.PressKey(Key.AltLeft); + InputManager.Key(Key.R); + InputManager.ReleaseKey(Key.AltLeft); + }); + + checkPlacementSampleBank(HitSampleInfo.BANK_DRUM); + checkPlacementSampleAdditionBank(HitSampleInfo.BANK_DRUM); AddStep("Press auto bank shortcut", () => { @@ -432,15 +506,29 @@ namespace osu.Game.Tests.Visual.Editing InputManager.ReleaseKey(Key.ShiftLeft); }); - checkPlacementSample(HitSampleInfo.BANK_NORMAL); + checkPlacementSampleBank(HitSampleInfo.BANK_NORMAL); + checkPlacementSampleAdditionBank(HitSampleInfo.BANK_DRUM); + + AddStep("Press auto addition bank shortcut", () => + { + InputManager.PressKey(Key.AltLeft); + InputManager.Key(Key.Q); + InputManager.ReleaseKey(Key.AltLeft); + }); + + checkPlacementSampleBank(HitSampleInfo.BANK_NORMAL); + checkPlacementSampleAdditionBank(HitSampleInfo.BANK_NORMAL); AddStep("Move after second object", () => EditorClock.Seek(750)); - checkPlacementSample(HitSampleInfo.BANK_SOFT); + checkPlacementSampleBank(HitSampleInfo.BANK_SOFT); + checkPlacementSampleAdditionBank(HitSampleInfo.BANK_SOFT); AddStep("Move to first object", () => EditorClock.Seek(0)); - checkPlacementSample(HitSampleInfo.BANK_NORMAL); + checkPlacementSampleBank(HitSampleInfo.BANK_NORMAL); + checkPlacementSampleAdditionBank(HitSampleInfo.BANK_NORMAL); - void checkPlacementSample(string expected) => AddAssert($"Placement sample is {expected}", () => EditorBeatmap.PlacementObject.Value.Samples.First().Bank, () => Is.EqualTo(expected)); + void checkPlacementSampleBank(string expected) => AddAssert($"Placement sample is {expected}", () => EditorBeatmap.PlacementObject.Value.Samples.First(s => s.Name == HitSampleInfo.HIT_NORMAL).Bank, () => Is.EqualTo(expected)); + void checkPlacementSampleAdditionBank(string expected) => AddAssert($"Placement sample addition is {expected}", () => EditorBeatmap.PlacementObject.Value.Samples.First(s => s.Name != HitSampleInfo.HIT_NORMAL).Bank, () => Is.EqualTo(expected)); } [Test] @@ -585,7 +673,29 @@ namespace osu.Game.Tests.Visual.Editing hitObjectHasSamples(2, HitSampleInfo.HIT_NORMAL); hitObjectNodeHasSampleBank(2, 0, HitSampleInfo.BANK_DRUM); hitObjectNodeHasSamples(2, 0, HitSampleInfo.HIT_NORMAL); - hitObjectNodeHasSampleBank(2, 1, HitSampleInfo.BANK_DRUM); + hitObjectNodeHasSampleNormalBank(2, 1, HitSampleInfo.BANK_DRUM); + hitObjectNodeHasSampleAdditionBank(2, 1, HitSampleInfo.BANK_SOFT); + hitObjectNodeHasSamples(2, 1, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_WHISTLE); + + AddStep("set normal addition bank", () => + { + InputManager.PressKey(Key.LAlt); + InputManager.Key(Key.W); + InputManager.ReleaseKey(Key.LAlt); + }); + + hitObjectHasSampleBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL); + + hitObjectHasSampleBank(1, HitSampleInfo.BANK_DRUM); + hitObjectHasSamples(1, HitSampleInfo.HIT_NORMAL); + + hitObjectHasSampleBank(2, HitSampleInfo.BANK_DRUM); + hitObjectHasSamples(2, HitSampleInfo.HIT_NORMAL); + hitObjectNodeHasSampleBank(2, 0, HitSampleInfo.BANK_DRUM); + hitObjectNodeHasSamples(2, 0, HitSampleInfo.HIT_NORMAL); + hitObjectNodeHasSampleNormalBank(2, 1, HitSampleInfo.BANK_DRUM); + hitObjectNodeHasSampleAdditionBank(2, 1, HitSampleInfo.BANK_NORMAL); hitObjectNodeHasSamples(2, 1, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_WHISTLE); } @@ -629,20 +739,37 @@ namespace osu.Game.Tests.Visual.Editing InputManager.ReleaseKey(Key.LShift); }); - hitObjectHasSampleBank(0, HitSampleInfo.BANK_SOFT); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_SOFT); hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_WHISTLE); - hitObjectNodeHasSampleBank(0, 0, HitSampleInfo.BANK_SOFT); + hitObjectNodeHasSampleNormalBank(0, 0, HitSampleInfo.BANK_SOFT); hitObjectNodeHasSamples(0, 0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_CLAP); - hitObjectNodeHasSampleBank(0, 1, HitSampleInfo.BANK_SOFT); + hitObjectNodeHasSampleNormalBank(0, 1, HitSampleInfo.BANK_SOFT); hitObjectNodeHasSamples(0, 1, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_WHISTLE); AddStep("unify whistle addition", () => InputManager.Key(Key.W)); - hitObjectHasSampleBank(0, HitSampleInfo.BANK_SOFT); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_SOFT); hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_WHISTLE); - hitObjectNodeHasSampleBank(0, 0, HitSampleInfo.BANK_SOFT); + hitObjectNodeHasSampleNormalBank(0, 0, HitSampleInfo.BANK_SOFT); hitObjectNodeHasSamples(0, 0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_CLAP, HitSampleInfo.HIT_WHISTLE); - hitObjectNodeHasSampleBank(0, 1, HitSampleInfo.BANK_SOFT); + hitObjectNodeHasSampleNormalBank(0, 1, HitSampleInfo.BANK_SOFT); + hitObjectNodeHasSamples(0, 1, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_WHISTLE); + + AddStep("set drum addition bank", () => + { + InputManager.PressKey(Key.LAlt); + InputManager.Key(Key.R); + InputManager.ReleaseKey(Key.LAlt); + }); + + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_SOFT); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_WHISTLE); + hitObjectNodeHasSampleNormalBank(0, 0, HitSampleInfo.BANK_SOFT); + hitObjectNodeHasSampleAdditionBank(0, 0, HitSampleInfo.BANK_DRUM); + hitObjectNodeHasSamples(0, 0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_CLAP, HitSampleInfo.HIT_WHISTLE); + hitObjectNodeHasSampleNormalBank(0, 1, HitSampleInfo.BANK_SOFT); + hitObjectNodeHasSampleAdditionBank(0, 1, HitSampleInfo.BANK_DRUM); hitObjectNodeHasSamples(0, 1, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_WHISTLE); } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneMetadataSection.cs b/osu.Game.Tests/Visual/Editing/TestSceneMetadataSection.cs index 8b6f31d599..743529d40c 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneMetadataSection.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneMetadataSection.cs @@ -6,11 +6,13 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Graphics; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; using osu.Game.Rulesets.Osu; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Setup; @@ -20,6 +22,9 @@ namespace osu.Game.Tests.Visual.Editing { public partial class TestSceneMetadataSection : OsuManualInputManagerTestScene { + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + [Cached] private EditorBeatmap editorBeatmap = new EditorBeatmap(new Beatmap { @@ -201,7 +206,7 @@ namespace osu.Game.Tests.Visual.Editing } private void createSection() - => AddStep("create metadata section", () => Child = metadataSection = new TestMetadataSection()); + => AddStep("create metadata section", () => Child = metadataSection = new TestMetadataSection { RelativeSizeAxes = Axes.X }); private void assertArtistMetadata(string expected) => AddAssert($"artist metadata is {expected}", () => editorBeatmap.Metadata.Artist, () => Is.EqualTo(expected)); @@ -226,11 +231,11 @@ namespace osu.Game.Tests.Visual.Editing private partial class TestMetadataSection : MetadataSection { - public new LabelledTextBox ArtistTextBox => base.ArtistTextBox; - public new LabelledTextBox RomanisedArtistTextBox => base.RomanisedArtistTextBox; + public new FormTextBox ArtistTextBox => base.ArtistTextBox; + public new FormTextBox RomanisedArtistTextBox => base.RomanisedArtistTextBox; - public new LabelledTextBox TitleTextBox => base.TitleTextBox; - public new LabelledTextBox RomanisedTitleTextBox => base.RomanisedTitleTextBox; + public new FormTextBox TitleTextBox => base.TitleTextBox; + public new FormTextBox RomanisedTitleTextBox => base.RomanisedTitleTextBox; } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneOpenEditorTimestamp.cs b/osu.Game.Tests/Visual/Editing/TestSceneOpenEditorTimestamp.cs index 971eb223eb..955ded97af 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneOpenEditorTimestamp.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneOpenEditorTimestamp.cs @@ -100,6 +100,20 @@ namespace osu.Game.Tests.Visual.Editing assertOnScreenAt(EditorScreenMode.Compose, 0); } + [Test] + public void TestUrlDecodingOfArgs() + { + setUpEditor(new OsuRuleset().RulesetInfo); + AddAssert("is osu! ruleset", () => editorBeatmap.BeatmapInfo.Ruleset.Equals(new OsuRuleset().RulesetInfo)); + + AddStep("jump to encoded link", () => Game.HandleLink("osu://edit/00:14:142%20(1)")); + + AddUntilStep("wait for seek", () => editorClock.SeekingOrStopped.Value); + + AddAssert("time is correct", () => editorClock.CurrentTime, () => Is.EqualTo(14_142)); + AddAssert("selected object is correct", () => editorBeatmap.SelectedHitObjects.Single().StartTime, () => Is.EqualTo(14_142)); + } + private void addStepClickLink(string timestamp, string step = "", bool waitForSeek = true) { AddStep($"{step} {timestamp}", () => diff --git a/osu.Game.Tests/Visual/Editing/TestScenePlacementBlueprint.cs b/osu.Game.Tests/Visual/Editing/TestScenePlacementBlueprint.cs index fe74e1b346..966e6513bb 100644 --- a/osu.Game.Tests/Visual/Editing/TestScenePlacementBlueprint.cs +++ b/osu.Game.Tests/Visual/Editing/TestScenePlacementBlueprint.cs @@ -165,7 +165,9 @@ namespace osu.Game.Tests.Visual.Editing AddStep("enable automatic bank assignment", () => { InputManager.PressKey(Key.LShift); + InputManager.PressKey(Key.LAlt); InputManager.Key(Key.Q); + InputManager.ReleaseKey(Key.LAlt); InputManager.ReleaseKey(Key.LShift); }); AddStep("select circle placement tool", () => InputManager.Key(Key.Number2)); @@ -228,7 +230,9 @@ namespace osu.Game.Tests.Visual.Editing AddStep("select drum bank", () => { InputManager.PressKey(Key.LShift); + InputManager.PressKey(Key.LAlt); InputManager.Key(Key.R); + InputManager.ReleaseKey(Key.LAlt); InputManager.ReleaseKey(Key.LShift); }); AddStep("enable clap addition", () => InputManager.Key(Key.R)); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs index c7f1eabab2..0f47c3cd27 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs @@ -1,14 +1,18 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.Play.PlayerSettings; using osu.Game.Tests.Resources; @@ -52,6 +56,39 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("No calibration button", () => !offsetControl.ChildrenOfType().Any()); } + [Test] + public void TestNotEnoughTimedHitEvents() + { + AddStep("Set short reference score", () => + { + List hitEvents = + [ + // 10 events total. one of them (head circle) being timed / having hitwindows, rest having no hitwindows + new HitEvent(30, 1, HitResult.LargeTickHit, new SliderHeadCircle { ClassicSliderBehaviour = true }, null, null), + new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), + new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), + new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), + new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), + new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), + new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), + new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), + new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), + new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), + ]; + + foreach (var ev in hitEvents) + ev.HitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + + offsetControl.ReferenceScore.Value = new ScoreInfo + { + HitEvents = hitEvents, + BeatmapInfo = Beatmap.Value.BeatmapInfo, + }; + }); + + AddAssert("No calibration button", () => !offsetControl.ChildrenOfType().Any()); + } + [Test] public void TestScoreFromDifferentBeatmap() { 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/Gameplay/TestSceneHitErrorMeter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs index e57177498d..2e646f2850 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs @@ -284,6 +284,7 @@ namespace osu.Game.Tests.Visual.Gameplay public override IAdjustableAudioComponent Audio { get; } public override Playfield Playfield { get; } + public override PlayfieldAdjustmentContainer PlayfieldAdjustmentContainer { get; } public override Container Overlays { get; } public override Container FrameStableComponents { get; } public override IFrameStableClock FrameStableClock { get; } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneOverlayActivation.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneOverlayActivation.cs index 269d104fa3..751405b1d6 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneOverlayActivation.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneOverlayActivation.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.Game.Overlays; @@ -12,7 +10,7 @@ namespace osu.Game.Tests.Visual.Gameplay { public partial class TestSceneOverlayActivation : OsuPlayerTestScene { - protected new OverlayTestPlayer Player => base.Player as OverlayTestPlayer; + protected new OverlayTestPlayer Player => (OverlayTestPlayer)base.Player; public override void SetUpSteps() { diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs index 1949808dfe..182193714b 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs @@ -18,6 +18,7 @@ using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; using osu.Game.Rulesets.Mods; @@ -207,7 +208,25 @@ namespace osu.Game.Tests.Visual.Gameplay } [Test] - public void TestBlockLoadViaFocus() + public void TestLoadNotBlockedViaArbitraryFocus() + { + AddStep("load dummy beatmap", () => resetPlayer(false)); + AddUntilStep("wait for current", () => loader.IsCurrentScreen()); + + AddUntilStep("click settings slider", () => + { + InputManager.MoveMouseTo(loader.ChildrenOfType>().First()); + InputManager.Click(MouseButton.Left); + + return InputManager.FocusedDrawable is OsuSliderBar; + }); + + AddUntilStep("wait for load ready", () => player?.LoadState == LoadState.Ready); + AddUntilStep("loads", () => !loader.IsCurrentScreen()); + } + + [Test] + public void TestBlockLoadViaOverlayFocus() { AddStep("load dummy beatmap", () => resetPlayer(false)); AddUntilStep("wait for current", () => loader.IsCurrentScreen()); @@ -523,7 +542,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("restart completed", () => getCurrentPlayer() != null && getCurrentPlayer() != previousPlayer); AddStep("release quick retry key", () => InputManager.ReleaseKey(Key.Tilde)); - AddUntilStep("wait for player", () => getCurrentPlayer()?.LoadState == LoadState.Ready); + AddUntilStep("wait for player", () => getCurrentPlayer()?.LoadState >= LoadState.Ready); AddUntilStep("time reached zero", () => getCurrentPlayer()?.GameplayClockContainer.CurrentTime > 0); AddUntilStep("skip button not visible", () => !checkSkipButtonVisible()); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs index aff6139c08..4f1a63341a 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs @@ -223,7 +223,7 @@ namespace osu.Game.Tests.Visual.Gameplay protected partial class OutroPlayer : TestPlayer { - public void ExitViaPause() => PerformExit(true); + public void ExitViaPause() => PerformExitWithConfirmation(); public new FailOverlay FailOverlay => base.FailOverlay; diff --git a/osu.Game.Tests/Visual/Menus/TestSceneMainMenu.cs b/osu.Game.Tests/Visual/Menus/TestSceneMainMenu.cs index aab3716463..f3ea20c1aa 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneMainMenu.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneMainMenu.cs @@ -42,14 +42,14 @@ namespace osu.Game.Tests.Visual.Menus beatmap.OnlineID = 1001; getRoomRequest.TriggerSuccess(new Room { - RoomID = { Value = 1234 }, - Name = { Value = "Aug 8, 2024" }, + RoomID = 1234, + Name = "Aug 8, 2024", Playlist = - { + [ new PlaylistItem(beatmap) - }, - StartDate = { Value = DateTimeOffset.Now.AddMinutes(-30) }, - EndDate = { Value = DateTimeOffset.Now.AddSeconds(60) } + ], + StartDate = DateTimeOffset.Now.AddMinutes(-30), + EndDate = DateTimeOffset.Now.AddSeconds(60) }); return true; diff --git a/osu.Game.Tests/Visual/Menus/TestSceneMusicActionHandling.cs b/osu.Game.Tests/Visual/Menus/TestSceneMusicActionHandling.cs index 03b3b94bd8..32009dc8c2 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneMusicActionHandling.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneMusicActionHandling.cs @@ -34,6 +34,8 @@ namespace osu.Game.Tests.Visual.Menus { Queue<(IWorkingBeatmap working, TrackChangeDirection changeDirection)> trackChangeQueue = null!; + AddStep("disable shuffle", () => Game.MusicController.Shuffle.Value = false); + // ensure we have at least two beatmaps available to identify the direction the music controller navigated to. AddRepeatStep("import beatmap", () => Game.BeatmapManager.Import(TestResources.CreateTestBeatmapSetInfo()), 5); @@ -77,5 +79,114 @@ namespace osu.Game.Tests.Visual.Menus trackChangeQueue.Peek().changeDirection == TrackChangeDirection.Next); AddAssert("track actually changed", () => !trackChangeQueue.First().working.BeatmapInfo.Equals(trackChangeQueue.Last().working.BeatmapInfo)); } + + [Test] + public void TestShuffleBackwards() + { + Queue<(IWorkingBeatmap working, TrackChangeDirection changeDirection)> trackChangeQueue = null!; + + AddStep("enable shuffle", () => Game.MusicController.Shuffle.Value = true); + + // ensure we have at least two beatmaps available to identify the direction the music controller navigated to. + AddRepeatStep("import beatmap", () => Game.BeatmapManager.Import(TestResources.CreateTestBeatmapSetInfo()), 5); + AddStep("ensure nonzero track duration", () => Game.Realm.Write(r => + { + // this was already supposed to be non-zero (see innards of `TestResources.CreateTestBeatmapSetInfo()`), + // but the non-zero value is being overwritten *to* zero by `BeatmapUpdater`. + // do a bit of a hack to change it back again - otherwise tracks are going to switch instantly and we won't be able to assert anything sane anymore. + foreach (var beatmap in r.All().Where(b => b.Length == 0)) + beatmap.Length = 60_000; + })); + + AddStep("bind to track change", () => + { + trackChangeQueue = new Queue<(IWorkingBeatmap, TrackChangeDirection)>(); + Game.MusicController.TrackChanged += (working, changeDirection) => trackChangeQueue.Enqueue((working, changeDirection)); + }); + + AddStep("seek track to 6 second", () => Game.MusicController.SeekTo(6000)); + AddUntilStep("wait for current time to update", () => Game.MusicController.CurrentTrack.CurrentTime > 5000); + + AddStep("press previous", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPrev)); + AddAssert("no track change", () => trackChangeQueue.Count == 0); + AddUntilStep("track restarted", () => Game.MusicController.CurrentTrack.CurrentTime < 5000); + + AddStep("press previous", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPrev)); + AddUntilStep("track changed", () => trackChangeQueue.Count == 1); + + AddStep("press previous", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPrev)); + AddUntilStep("track changed", () => trackChangeQueue.Count == 2); + + AddStep("press next", () => globalActionContainer.TriggerPressed(GlobalAction.MusicNext)); + AddUntilStep("track changed", () => + trackChangeQueue.Count == 3 && !trackChangeQueue.ElementAt(1).working.BeatmapInfo.Equals(trackChangeQueue.Last().working.BeatmapInfo)); + } + + [Test] + public void TestShuffleForwards() + { + Queue<(IWorkingBeatmap working, TrackChangeDirection changeDirection)> trackChangeQueue = null!; + + AddStep("enable shuffle", () => Game.MusicController.Shuffle.Value = true); + + // ensure we have at least two beatmaps available to identify the direction the music controller navigated to. + AddRepeatStep("import beatmap", () => Game.BeatmapManager.Import(TestResources.CreateTestBeatmapSetInfo()), 5); + AddStep("ensure nonzero track duration", () => Game.Realm.Write(r => + { + // this was already supposed to be non-zero (see innards of `TestResources.CreateTestBeatmapSetInfo()`), + // but the non-zero value is being overwritten *to* zero by `BeatmapUpdater`. + // do a bit of a hack to change it back again - otherwise tracks are going to switch instantly and we won't be able to assert anything sane anymore. + foreach (var beatmap in r.All().Where(b => b.Length == 0)) + beatmap.Length = 60_000; + })); + + AddStep("bind to track change", () => + { + trackChangeQueue = new Queue<(IWorkingBeatmap, TrackChangeDirection)>(); + Game.MusicController.TrackChanged += (working, changeDirection) => trackChangeQueue.Enqueue((working, changeDirection)); + }); + + AddStep("press next", () => globalActionContainer.TriggerPressed(GlobalAction.MusicNext)); + AddUntilStep("track changed", () => trackChangeQueue.Count == 1); + + AddStep("press next", () => globalActionContainer.TriggerPressed(GlobalAction.MusicNext)); + AddUntilStep("track changed", () => trackChangeQueue.Count == 2); + + AddStep("press previous", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPrev)); + AddUntilStep("track changed", () => + trackChangeQueue.Count == 3 && !trackChangeQueue.ElementAt(1).working.BeatmapInfo.Equals(trackChangeQueue.Last().working.BeatmapInfo)); + } + + [Test] + public void TestShuffleBackAndForth() + { + Queue<(IWorkingBeatmap working, TrackChangeDirection changeDirection)> trackChangeQueue = null!; + + AddStep("enable shuffle", () => Game.MusicController.Shuffle.Value = true); + + // ensure we have at least two beatmaps available to identify the direction the music controller navigated to. + AddRepeatStep("import beatmap", () => Game.BeatmapManager.Import(TestResources.CreateTestBeatmapSetInfo()), 5); + AddStep("ensure nonzero track duration", () => Game.Realm.Write(r => + { + // this was already supposed to be non-zero (see innards of `TestResources.CreateTestBeatmapSetInfo()`), + // but the non-zero value is being overwritten *to* zero by `BeatmapUpdater`. + // do a bit of a hack to change it back again - otherwise tracks are going to switch instantly and we won't be able to assert anything sane anymore. + foreach (var beatmap in r.All().Where(b => b.Length == 0)) + beatmap.Length = 60_000; + })); + + AddStep("bind to track change", () => + { + trackChangeQueue = new Queue<(IWorkingBeatmap, TrackChangeDirection)>(); + Game.MusicController.TrackChanged += (working, changeDirection) => trackChangeQueue.Enqueue((working, changeDirection)); + }); + + AddStep("press next", () => globalActionContainer.TriggerPressed(GlobalAction.MusicNext)); + AddUntilStep("track changed", () => trackChangeQueue.Count == 1); + + AddStep("press previous", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPrev)); + AddUntilStep("track changed", () => + trackChangeQueue.Count == 2 && !trackChangeQueue.First().working.BeatmapInfo.Equals(trackChangeQueue.Last().working.BeatmapInfo)); + } } } diff --git a/osu.Game.Tests/Visual/Mods/TestSceneModAccuracyChallenge.cs b/osu.Game.Tests/Visual/Mods/TestSceneModAccuracyChallenge.cs index c5e56c6453..e1c15863ad 100644 --- a/osu.Game.Tests/Visual/Mods/TestSceneModAccuracyChallenge.cs +++ b/osu.Game.Tests/Visual/Mods/TestSceneModAccuracyChallenge.cs @@ -35,7 +35,7 @@ namespace osu.Game.Tests.Visual.Mods MinimumAccuracy = { Value = 0.6 } }, Autoplay = false, - Beatmap = new Beatmap + CreateBeatmap = () => new Beatmap { HitObjects = Enumerable.Range(0, 5).Select(i => new HitCircle { @@ -56,7 +56,7 @@ namespace osu.Game.Tests.Visual.Mods AccuracyJudgeMode = { Value = ModAccuracyChallenge.AccuracyMode.Standard } }, Autoplay = false, - Beatmap = new Beatmap + CreateBeatmap = () => new Beatmap { HitObjects = Enumerable.Range(0, 5).Select(i => new HitCircle { diff --git a/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs b/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs index 906eea9553..1eb08ad3c8 100644 --- a/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs +++ b/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Collections.Specialized; @@ -31,7 +29,7 @@ namespace osu.Game.Tests.Visual.Multiplayer protected readonly BindableList MultiplayerUsers = new BindableList(); - protected MultiplayerGameplayLeaderboard Leaderboard { get; private set; } + protected MultiplayerGameplayLeaderboard? Leaderboard { get; private set; } protected virtual MultiplayerRoomUser CreateUser(int userId) => new MultiplayerRoomUser(userId); @@ -40,7 +38,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private readonly BindableList multiplayerUserIds = new BindableList(); private readonly BindableDictionary watchedUserStates = new BindableDictionary(); - private OsuConfigManager config; + private OsuConfigManager config = null!; private readonly Mock spectatorClient = new Mock(); private readonly Mock multiplayerClient = new Mock(); @@ -133,7 +131,7 @@ namespace osu.Game.Tests.Visual.Multiplayer LoadComponentAsync(Leaderboard = CreateLeaderboard(), Add); }); - AddUntilStep("wait for load", () => Leaderboard.IsLoaded); + AddUntilStep("wait for load", () => Leaderboard!.IsLoaded); AddStep("check watch requests were sent", () => { @@ -146,7 +144,7 @@ namespace osu.Game.Tests.Visual.Multiplayer public void TestScoreUpdates() { AddRepeatStep("update state", UpdateUserStatesRandomly, 100); - AddToggleStep("switch compact mode", expanded => Leaderboard.Expanded.Value = expanded); + AddToggleStep("switch compact mode", expanded => Leaderboard!.Expanded.Value = expanded); } [Test] diff --git a/osu.Game.Tests/Visual/Multiplayer/QueueModeTestScene.cs b/osu.Game.Tests/Visual/Multiplayer/QueueModeTestScene.cs index 8bcd5aab1c..2b738743ea 100644 --- a/osu.Game.Tests/Visual/Multiplayer/QueueModeTestScene.cs +++ b/osu.Game.Tests/Visual/Multiplayer/QueueModeTestScene.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; @@ -30,16 +28,16 @@ namespace osu.Game.Tests.Visual.Multiplayer { protected abstract QueueMode Mode { get; } - protected BeatmapInfo InitialBeatmap { get; private set; } - protected BeatmapInfo OtherBeatmap { get; private set; } + protected BeatmapInfo InitialBeatmap { get; private set; } = null!; + protected BeatmapInfo OtherBeatmap { get; private set; } = null!; protected IScreen CurrentScreen => multiplayerComponents.CurrentScreen; protected IScreen CurrentSubScreen => multiplayerComponents.MultiplayerScreen.CurrentSubScreen; - private BeatmapManager beatmaps; - private BeatmapSetInfo importedSet; + private BeatmapManager beatmaps = null!; + private BeatmapSetInfo importedSet = null!; - private TestMultiplayerComponents multiplayerComponents; + private TestMultiplayerComponents multiplayerComponents = null!; protected TestMultiplayerClient MultiplayerClient => multiplayerComponents.MultiplayerClient; @@ -75,15 +73,15 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for lounge", () => multiplayerComponents.ChildrenOfType().SingleOrDefault()?.IsLoaded == true); AddStep("open room", () => multiplayerComponents.ChildrenOfType().Single().Open(new Room { - Name = { Value = "Test Room" }, - QueueMode = { Value = Mode }, + Name = "Test Room", + QueueMode = Mode, Playlist = - { + [ new PlaylistItem(InitialBeatmap) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID } - } + ] })); AddUntilStep("wait for room open", () => this.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); @@ -98,7 +96,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestCreatedWithCorrectMode() { - AddUntilStep("room created with correct mode", () => MultiplayerClient.ClientAPIRoom?.QueueMode.Value == Mode); + AddUntilStep("room created with correct mode", () => MultiplayerClient.ClientAPIRoom?.QueueMode == Mode); } protected void RunGameplay() diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneCreateMultiplayerMatchButton.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneCreateMultiplayerMatchButton.cs index 11b0f8b91c..6d6d30d517 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneCreateMultiplayerMatchButton.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneCreateMultiplayerMatchButton.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 NUnit.Framework; using osu.Framework.Graphics; @@ -12,7 +10,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { public partial class TestSceneCreateMultiplayerMatchButton : MultiplayerTestScene { - private CreateMultiplayerMatchButton button; + private CreateMultiplayerMatchButton button = null!; public override void SetUpSteps() { @@ -29,7 +27,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestButtonEnableStateChanges() { - IDisposable joiningRoomOperation = null; + IDisposable joiningRoomOperation = null!; assertButtonEnableState(true); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableLoungeRoom.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableLoungeRoom.cs index 4de911b6b6..c5fb52461a 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableLoungeRoom.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableLoungeRoom.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 System.Threading; @@ -10,6 +8,7 @@ using System.Threading.Tasks; using Moq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; using osu.Framework.Testing; @@ -25,14 +24,14 @@ namespace osu.Game.Tests.Visual.Multiplayer { private readonly Room room = new Room { - HasPassword = { Value = true } + Password = "*" }; [Cached] protected readonly OverlayColourProvider ColourProvider = new OverlayColourProvider(OverlayColourScheme.Pink); - private DrawableLoungeRoom drawableRoom; - private SearchTextBox searchTextBox; + private DrawableLoungeRoom drawableRoom = null!; + private SearchTextBox searchTextBox = null!; private readonly ManualResetEventSlim allowResponseCallback = new ManualResetEventSlim(); @@ -78,6 +77,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { Anchor = Anchor.Centre, Origin = Anchor.Centre, + SelectedRoom = new Bindable() } } }; @@ -87,7 +87,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestFocusViaKeyboardCommit() { - DrawableLoungeRoom.PasswordEntryPopover popover = null; + DrawableLoungeRoom.PasswordEntryPopover? popover = null; AddAssert("search textbox has focus", () => checkFocus(searchTextBox)); AddStep("click room twice", () => @@ -103,11 +103,11 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("enter password", () => popover.ChildrenOfType().Single().Text = "password"); AddStep("commit via enter", () => InputManager.Key(Key.Enter)); - AddAssert("popover has focus", () => checkFocus(popover)); + AddAssert("popover has focus", () => checkFocus(popover!)); AddStep("attempt another enter", () => InputManager.Key(Key.Enter)); - AddAssert("popover still has focus", () => checkFocus(popover)); + AddAssert("popover still has focus", () => checkFocus(popover!)); AddStep("unblock response", () => allowResponseCallback.Set()); @@ -122,7 +122,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestFocusViaMouseCommit() { - DrawableLoungeRoom.PasswordEntryPopover popover = null; + DrawableLoungeRoom.PasswordEntryPopover? popover = null; AddAssert("search textbox has focus", () => checkFocus(searchTextBox)); AddStep("click room twice", () => @@ -144,11 +144,11 @@ namespace osu.Game.Tests.Visual.Multiplayer InputManager.Click(MouseButton.Left); }); - AddAssert("popover has focus", () => checkFocus(popover)); + AddAssert("popover has focus", () => checkFocus(popover!)); AddStep("attempt another click", () => InputManager.Click(MouseButton.Left)); - AddAssert("popover still has focus", () => checkFocus(popover)); + AddAssert("popover still has focus", () => checkFocus(popover!)); AddStep("unblock response", () => allowResponseCallback.Set()); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs index 98242e2d92..e5938a796c 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.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 NUnit.Framework; @@ -32,15 +30,40 @@ namespace osu.Game.Tests.Visual.Multiplayer [Cached] protected readonly OverlayColourProvider ColourProvider = new OverlayColourProvider(OverlayColourScheme.Plum); - private readonly Bindable selectedRoom = new Bindable(); + private readonly Bindable selectedRoom = new Bindable(); [Test] public void TestMultipleStatuses() { - FillFlowContainer rooms = null; + FillFlowContainer rooms = null!; AddStep("create rooms", () => { + PlaylistItem item1 = new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo) + { + BeatmapInfo = { StarRating = 2.5 } + }.BeatmapInfo); + + PlaylistItem item2 = new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo) + { + BeatmapInfo = { StarRating = 4.5 } + }.BeatmapInfo); + + PlaylistItem item3 = new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo) + { + BeatmapInfo = + { + StarRating = 2.5, + Metadata = + { + Artist = "very very very very very very very very very long artist", + ArtistUnicode = "very very very very very very very very very long artist", + Title = "very very very very very very very very very very very long title", + TitleUnicode = "very very very very very very very very very very very long title", + } + } + }.BeatmapInfo); + Child = rooms = new FillFlowContainer { Anchor = Anchor.Centre, @@ -52,86 +75,48 @@ namespace osu.Game.Tests.Visual.Multiplayer { createLoungeRoom(new Room { - Name = { Value = "Multiplayer room" }, - Status = { Value = new RoomStatusOpen() }, - EndDate = { Value = DateTimeOffset.Now.AddDays(1) }, - Type = { Value = MatchType.HeadToHead }, - Playlist = - { - new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo) - { - BeatmapInfo = - { - StarRating = 2.5 - } - }.BeatmapInfo) - } + Name = "Multiplayer room", + Status = new RoomStatusOpen(), + EndDate = DateTimeOffset.Now.AddDays(1), + Type = MatchType.HeadToHead, + Playlist = [item1], + CurrentPlaylistItem = item1 }), createLoungeRoom(new Room { - Name = { Value = "Private room" }, - Status = { Value = new RoomStatusOpenPrivate() }, - HasPassword = { Value = true }, - EndDate = { Value = DateTimeOffset.Now.AddDays(1) }, - Type = { Value = MatchType.HeadToHead }, - Playlist = - { - new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo) - { - BeatmapInfo = - { - StarRating = 2.5, - Metadata = - { - Artist = "very very very very very very very very very long artist", - ArtistUnicode = "very very very very very very very very very long artist", - Title = "very very very very very very very very very very very long title", - TitleUnicode = "very very very very very very very very very very very long title", - } - } - }.BeatmapInfo) - } + Name = "Private room", + Status = new RoomStatusOpenPrivate(), + Password = "*", + EndDate = DateTimeOffset.Now.AddDays(1), + Type = MatchType.HeadToHead, + Playlist = [item3], + CurrentPlaylistItem = item3 }), createLoungeRoom(new Room { - Name = { Value = "Playlist room with multiple beatmaps" }, - Status = { Value = new RoomStatusPlaying() }, - EndDate = { Value = DateTimeOffset.Now.AddDays(1) }, - Playlist = - { - new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo) - { - BeatmapInfo = - { - StarRating = 2.5 - } - }.BeatmapInfo), - new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo) - { - BeatmapInfo = - { - StarRating = 4.5 - } - }.BeatmapInfo) - } + Name = "Playlist room with multiple beatmaps", + Status = new RoomStatusPlaying(), + EndDate = DateTimeOffset.Now.AddDays(1), + Playlist = [item1, item2], + CurrentPlaylistItem = item1 }), createLoungeRoom(new Room { - Name = { Value = "Finished room" }, - Status = { Value = new RoomStatusEnded() }, - EndDate = { Value = DateTimeOffset.Now }, + Name = "Finished room", + Status = new RoomStatusEnded(), + EndDate = DateTimeOffset.Now, }), createLoungeRoom(new Room { - Name = { Value = "Spotlight room" }, - Status = { Value = new RoomStatusOpen() }, - Category = { Value = RoomCategory.Spotlight }, + Name = "Spotlight room", + Status = new RoomStatusOpen(), + Category = RoomCategory.Spotlight, }), createLoungeRoom(new Room { - Name = { Value = "Featured artist room" }, - Status = { Value = new RoomStatusOpen() }, - Category = { Value = RoomCategory.FeaturedArtist }, + Name = "Featured artist room", + Status = new RoomStatusOpen(), + Category = RoomCategory.FeaturedArtist, }), } }; @@ -145,24 +130,24 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestEnableAndDisablePassword() { - DrawableRoom drawableRoom = null; - Room room = null; + DrawableRoom drawableRoom = null!; + Room room = null!; AddStep("create room", () => Child = drawableRoom = createLoungeRoom(room = new Room { - Name = { Value = "Room with password" }, - Status = { Value = new RoomStatusOpen() }, - Type = { Value = MatchType.HeadToHead }, + Name = "Room with password", + Status = new RoomStatusOpen(), + Type = MatchType.HeadToHead, })); AddUntilStep("wait for panel load", () => drawableRoom.ChildrenOfType().Any()); AddAssert("password icon hidden", () => Precision.AlmostEquals(0, drawableRoom.ChildrenOfType().Single().Alpha)); - AddStep("set password", () => room.Password.Value = "password"); + AddStep("set password", () => room.Password = "password"); AddAssert("password icon visible", () => Precision.AlmostEquals(1, drawableRoom.ChildrenOfType().Single().Alpha)); - AddStep("unset password", () => room.Password.Value = string.Empty); + AddStep("unset password", () => room.Password = string.Empty); AddAssert("password icon hidden", () => Precision.AlmostEquals(0, drawableRoom.ChildrenOfType().Single().Alpha)); } @@ -179,43 +164,52 @@ namespace osu.Game.Tests.Visual.Multiplayer { new DrawableMatchRoom(new Room { - Name = { Value = "A host-only room" }, - QueueMode = { Value = QueueMode.HostOnly }, - Type = { Value = MatchType.HeadToHead } - }), + Name = "A host-only room", + QueueMode = QueueMode.HostOnly, + Type = MatchType.HeadToHead, + }) + { + SelectedItem = new Bindable() + }, new DrawableMatchRoom(new Room { - Name = { Value = "An all-players, team-versus room" }, - QueueMode = { Value = QueueMode.AllPlayers }, - Type = { Value = MatchType.TeamVersus } - }), + Name = "An all-players, team-versus room", + QueueMode = QueueMode.AllPlayers, + Type = MatchType.TeamVersus + }) + { + SelectedItem = new Bindable() + }, new DrawableMatchRoom(new Room { - Name = { Value = "A round-robin room" }, - QueueMode = { Value = QueueMode.AllPlayersRoundRobin }, - Type = { Value = MatchType.HeadToHead } - }), + Name = "A round-robin room", + QueueMode = QueueMode.AllPlayersRoundRobin, + Type = MatchType.HeadToHead + }) + { + SelectedItem = new Bindable() + }, } }); } private DrawableRoom createLoungeRoom(Room room) { - room.Host.Value ??= new APIUser { Username = "peppy", Id = 2 }; + room.Host ??= new APIUser { Username = "peppy", Id = 2 }; if (room.RecentParticipants.Count == 0) { - room.RecentParticipants.AddRange(Enumerable.Range(0, 20).Select(i => new APIUser + room.RecentParticipants = Enumerable.Range(0, 20).Select(i => new APIUser { Id = i, Username = $"User {i}" - })); + }).ToArray(); } return new DrawableLoungeRoom(room) { MatchingFilter = true, - SelectedRoom = { BindTarget = selectedRoom } + SelectedRoom = selectedRoom }; } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomParticipantsList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomParticipantsList.cs index 98abc93994..c1662bf944 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomParticipantsList.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomParticipantsList.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.Graphics; @@ -17,7 +15,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { public partial class TestSceneDrawableRoomParticipantsList : OnlinePlayTestScene { - private DrawableRoomParticipantsList list; + private DrawableRoomParticipantsList list = null!; public override void SetUpSteps() { @@ -27,18 +25,15 @@ namespace osu.Game.Tests.Visual.Multiplayer { SelectedRoom.Value = new Room { - Name = { Value = "test room" }, - Host = + Name = "test room", + Host = new APIUser { - Value = new APIUser - { - Id = 2, - Username = "peppy", - } + Id = 2, + Username = "peppy", } }; - Child = list = new DrawableRoomParticipantsList + Child = list = new DrawableRoomParticipantsList(SelectedRoom.Value) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -124,7 +119,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("4 circles displayed", () => list.ChildrenOfType().Count() == 4); AddAssert("46 hidden users", () => list.ChildrenOfType().Single().Count == 46); - AddStep("remove from end", () => removeUserAt(SelectedRoom.Value.RecentParticipants.Count - 1)); + AddStep("remove from end", () => removeUserAt(SelectedRoom.Value!.RecentParticipants.Count - 1)); AddAssert("4 circles displayed", () => list.ChildrenOfType().Count() == 4); AddAssert("45 hidden users", () => list.ChildrenOfType().Single().Count == 45); @@ -143,18 +138,18 @@ namespace osu.Game.Tests.Visual.Multiplayer private void addUser(int id) { - SelectedRoom.Value.RecentParticipants.Add(new APIUser + SelectedRoom.Value!.RecentParticipants = SelectedRoom.Value!.RecentParticipants.Append(new APIUser { Id = id, Username = $"User {id}" - }); - SelectedRoom.Value.ParticipantCount.Value++; + }).ToArray(); + SelectedRoom.Value!.ParticipantCount++; } private void removeUserAt(int index) { - SelectedRoom.Value.RecentParticipants.RemoveAt(index); - SelectedRoom.Value.ParticipantCount.Value--; + SelectedRoom.Value!.RecentParticipants = SelectedRoom.Value!.RecentParticipants.Where(u => !u.Equals(SelectedRoom.Value!.RecentParticipants[index])).ToArray(); + SelectedRoom.Value!.ParticipantCount--; } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs index 2ef56bd54e..18cd720bf2 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Diagnostics; @@ -39,9 +37,8 @@ namespace osu.Game.Tests.Visual.Multiplayer { public partial class TestSceneDrawableRoomPlaylist : MultiplayerTestScene { - private TestPlaylist playlist; - - private BeatmapManager manager; + private TestPlaylist playlist = null!; + private BeatmapManager manager = null!; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) @@ -199,14 +196,14 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestDownloadButtonHiddenWhenBeatmapExists() { - Live imported = null; + Live imported = null!; AddStep("import beatmap", () => { var beatmap = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo; Debug.Assert(beatmap.BeatmapSet != null); - imported = manager.Import(beatmap.BeatmapSet); + imported = manager.Import(beatmap.BeatmapSet)!; }); createPlaylistWithBeatmaps(() => imported.PerformRead(s => s.Beatmaps.Detach())); @@ -378,7 +375,7 @@ namespace osu.Game.Tests.Visual.Multiplayer } }); - private void createPlaylist(Action setupPlaylist = null) + private void createPlaylist(Action? setupPlaylist = null) { AddStep("create playlist", () => { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs index 4316653dde..fb54b89a4b 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Linq; @@ -26,9 +24,9 @@ namespace osu.Game.Tests.Visual.Multiplayer { public partial class TestSceneFreeModSelectOverlay : MultiplayerTestScene { - private FreeModSelectOverlay freeModSelectOverlay; - private FooterButtonFreeMods footerButtonFreeMods; - private ScreenFooter footer; + private FreeModSelectOverlay freeModSelectOverlay = null!; + private FooterButtonFreeMods footerButtonFreeMods = null!; + private ScreenFooter footer = null!; private readonly Bindable>> availableMods = new Bindable>>(); [BackgroundDependencyLoader] @@ -49,8 +47,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddToggleStep("toggle visibility", visible => { - if (freeModSelectOverlay != null) - freeModSelectOverlay.State.Value = visible ? Visibility.Visible : Visibility.Hidden; + freeModSelectOverlay.State.Value = visible ? Visibility.Visible : Visibility.Hidden; }); } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneGameplayChatDisplay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneGameplayChatDisplay.cs index d1a914300f..235d142820 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneGameplayChatDisplay.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneGameplayChatDisplay.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 Moq; using NUnit.Framework; @@ -20,19 +18,19 @@ namespace osu.Game.Tests.Visual.Multiplayer { public partial class TestSceneGameplayChatDisplay : OsuManualInputManagerTestScene { - private GameplayChatDisplay chatDisplay; + private GameplayChatDisplay chatDisplay = null!; [Cached(typeof(ILocalUserPlayInfo))] private ILocalUserPlayInfo localUserInfo; - private readonly Bindable localUserPlaying = new Bindable(); + private readonly Bindable playingState = new Bindable(); private TextBox textBox => chatDisplay.ChildrenOfType().First(); public TestSceneGameplayChatDisplay() { var mockLocalUserInfo = new Mock(); - mockLocalUserInfo.SetupGet(i => i.IsPlaying).Returns(localUserPlaying); + mockLocalUserInfo.SetupGet(i => i.PlayingState).Returns(playingState); localUserInfo = mockLocalUserInfo.Object; } @@ -124,6 +122,6 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert($"chat {(isFocused ? "focused" : "not focused")}", () => textBox.HasFocus == isFocused); private void setLocalUserPlaying(bool playing) => - AddStep($"local user {(playing ? "playing" : "not playing")}", () => localUserPlaying.Value = playing); + AddStep($"local user {(playing ? "playing" : "not playing")}", () => playingState.Value = playing ? LocalUserPlayingState.Playing : LocalUserPlayingState.NotPlaying); } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneHostOnlyQueueMode.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneHostOnlyQueueMode.cs index 78baa4a39b..55c9e8142f 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneHostOnlyQueueMode.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneHostOnlyQueueMode.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 NUnit.Framework; @@ -46,7 +44,7 @@ namespace osu.Game.Tests.Visual.Multiplayer QueueMode = QueueMode.AllPlayers }).WaitSafely()); - AddUntilStep("api room updated", () => MultiplayerClient.ClientAPIRoom?.QueueMode.Value == QueueMode.AllPlayers); + AddUntilStep("api room updated", () => MultiplayerClient.ClientAPIRoom?.QueueMode == QueueMode.AllPlayers); } [Test] @@ -70,13 +68,13 @@ namespace osu.Game.Tests.Visual.Multiplayer { RunGameplay(); - IBeatmapInfo firstBeatmap = null; - AddStep("get first playlist item beatmap", () => firstBeatmap = MultiplayerClient.ServerAPIRoom?.Playlist[0].Beatmap); + IBeatmapInfo firstBeatmap = null!; + AddStep("get first playlist item beatmap", () => firstBeatmap = MultiplayerClient.ServerAPIRoom!.Playlist[0].Beatmap); selectNewItem(() => OtherBeatmap); - AddUntilStep("first playlist item hasn't changed", () => MultiplayerClient.ServerAPIRoom?.Playlist[0].Beatmap == firstBeatmap); - AddUntilStep("second playlist item changed", () => MultiplayerClient.ClientAPIRoom?.Playlist[1].Beatmap != firstBeatmap); + AddUntilStep("first playlist item hasn't changed", () => MultiplayerClient.ServerAPIRoom!.Playlist[0].Beatmap == firstBeatmap); + AddUntilStep("second playlist item changed", () => MultiplayerClient.ClientAPIRoom!.Playlist[1].Beatmap != firstBeatmap); } [Test] @@ -103,7 +101,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for song select", () => CurrentSubScreen is Screens.Select.SongSelect select && select.BeatmapSetsLoaded); - BeatmapInfo otherBeatmap = null; + BeatmapInfo otherBeatmap = null!; AddStep("select other beatmap", () => ((Screens.Select.SongSelect)CurrentSubScreen).FinaliseSelection(otherBeatmap = beatmap())); AddUntilStep("wait for return to match", () => CurrentSubScreen is MultiplayerMatchSubScreen); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs index 0883c626fe..797b69ec72 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.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.Graphics; @@ -23,7 +21,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { protected new TestRoomManager RoomManager => (TestRoomManager)base.RoomManager; - private RoomsContainer container; + private RoomsContainer container = null!; public override void SetUpSteps() { @@ -55,20 +53,20 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("has 5 rooms", () => container.Rooms.Count == 5); AddAssert("all spotlights at top", () => container.Rooms - .SkipWhile(r => r.Room.Category.Value == RoomCategory.Spotlight) - .All(r => r.Room.Category.Value == RoomCategory.Normal)); + .SkipWhile(r => r.Room.Category == RoomCategory.Spotlight) + .All(r => r.Room.Category == RoomCategory.Normal)); - AddStep("remove first room", () => RoomManager.RemoveRoom(RoomManager.Rooms.First(r => r.RoomID.Value == 0))); + AddStep("remove first room", () => RoomManager.RemoveRoom(RoomManager.Rooms.First(r => r.RoomID == 0))); AddAssert("has 4 rooms", () => container.Rooms.Count == 4); - AddAssert("first room removed", () => container.Rooms.All(r => r.Room.RoomID.Value != 0)); + AddAssert("first room removed", () => container.Rooms.All(r => r.Room.RoomID != 0)); AddStep("select first room", () => container.Rooms.First().TriggerClick()); - AddAssert("first spotlight selected", () => checkRoomSelected(RoomManager.Rooms.First(r => r.Category.Value == RoomCategory.Spotlight))); + AddAssert("first spotlight selected", () => checkRoomSelected(RoomManager.Rooms.First(r => r.Category == RoomCategory.Spotlight))); - AddStep("remove last room", () => RoomManager.RemoveRoom(RoomManager.Rooms.MinBy(r => r.RoomID?.Value))); - AddAssert("first spotlight still selected", () => checkRoomSelected(RoomManager.Rooms.First(r => r.Category.Value == RoomCategory.Spotlight))); + AddStep("remove last room", () => RoomManager.RemoveRoom(RoomManager.Rooms.MinBy(r => r.RoomID)!)); + AddAssert("first spotlight still selected", () => checkRoomSelected(RoomManager.Rooms.First(r => r.Category == RoomCategory.Spotlight))); - AddStep("remove spotlight room", () => RoomManager.RemoveRoom(RoomManager.Rooms.Single(r => r.Category.Value == RoomCategory.Spotlight))); + AddStep("remove spotlight room", () => RoomManager.RemoveRoom(RoomManager.Rooms.Single(r => r.Category == RoomCategory.Spotlight))); AddAssert("selection vacated", () => checkRoomSelected(null)); } @@ -157,7 +155,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("add rooms", () => RoomManager.AddRooms(3, new CatchRuleset().RulesetInfo)); // Todo: What even is this case...? - AddStep("set empty filter criteria", () => container.Filter.Value = null); + AddStep("set empty filter criteria", () => container.Filter.Value = new FilterCriteria()); AddUntilStep("5 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 5); AddStep("filter osu! rooms", () => container.Filter.Value = new FilterCriteria { Ruleset = new OsuRuleset().RulesetInfo }); @@ -182,11 +180,11 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("filter public rooms", () => container.Filter.Value = new FilterCriteria { Permissions = RoomPermissionsFilter.Public }); - AddUntilStep("private room hidden", () => container.Rooms.All(r => !r.Room.HasPassword.Value)); + AddUntilStep("private room hidden", () => container.Rooms.All(r => !r.Room.HasPassword)); AddStep("filter private rooms", () => container.Filter.Value = new FilterCriteria { Permissions = RoomPermissionsFilter.Private }); - AddUntilStep("public room hidden", () => container.Rooms.All(r => r.Room.HasPassword.Value)); + AddUntilStep("public room hidden", () => container.Rooms.All(r => r.Room.HasPassword)); } [Test] @@ -195,9 +193,9 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("add rooms", () => RoomManager.AddRooms(3, withPassword: true)); } - private bool checkRoomSelected(Room room) => SelectedRoom.Value == room; + private bool checkRoomSelected(Room? room) => SelectedRoom.Value == room; - private Room getRoomInFlow(int index) => + private Room? getRoomInFlow(int index) => (container.ChildrenOfType>().First().FlowingChildren.ElementAt(index) as DrawableRoom)?.Room; } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs index 24d1b51ff8..813a420cbd 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.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.Linq; using osu.Framework.Graphics; using osu.Game.Online.API; using osu.Game.Online.Rooms; @@ -23,7 +24,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { SelectedRoom.Value = new Room(); - Child = new MatchBeatmapDetailArea + Child = new MatchBeatmapDetailArea(SelectedRoom.Value) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -35,7 +36,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private void createNewItem() { - SelectedRoom.Value.Playlist.Add(new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) + SelectedRoom.Value!.Playlist = SelectedRoom.Value.Playlist.Append(new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) { ID = SelectedRoom.Value.Playlist.Count, RulesetID = new OsuRuleset().RulesetInfo.OnlineID, @@ -45,7 +46,7 @@ namespace osu.Game.Tests.Visual.Multiplayer new APIMod(new OsuModDoubleTime()), new APIMod(new OsuModAutoplay()) } - }); + }).ToArray(); } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboard.cs index ea8fe8873d..38522db4d4 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboard.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboard.cs @@ -61,9 +61,9 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("create leaderboard", () => { - SelectedRoom.Value = new Room { RoomID = { Value = 3 } }; + SelectedRoom.Value = new Room { RoomID = 3 }; - Child = new MatchLeaderboard + Child = new MatchLeaderboard(SelectedRoom.Value) { Origin = Anchor.Centre, Anchor = Anchor.Centre, diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs index 2d61c26a6b..fb9c801fb4 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,18 +34,15 @@ 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(); - protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => - new CachedModelDependencyContainer(base.CreateChildDependencies(parent)) { Model = { BindTarget = room } }; - [BackgroundDependencyLoader] private void load() { @@ -106,31 +101,33 @@ namespace osu.Game.Tests.Visual.Multiplayer [SetUpSteps] public void SetUpSteps() { + PlaylistItem item = null!; + AddStep("reset state", () => { multiplayerClient.Invocations.Clear(); beatmapAvailability.Value = BeatmapAvailability.LocallyAvailable(); - var playlistItem = new PlaylistItem(Beatmap.Value.BeatmapInfo) + item = new PlaylistItem(Beatmap.Value.BeatmapInfo) { RulesetID = Beatmap.Value.BeatmapInfo.Ruleset.OnlineID }; room.Value = new Room { - Playlist = { playlistItem }, - CurrentPlaylistItem = { Value = playlistItem } + Playlist = [item], + CurrentPlaylistItem = item }; - localUser = new MultiplayerRoomUser(API.LocalUser.Value.Id) { User = API.LocalUser.Value }; + localUser = new MultiplayerRoomUser(API.LocalUser.Value.Id) + { + User = API.LocalUser.Value + }; multiplayerRoom = new MultiplayerRoom(0) { - Playlist = - { - TestMultiplayerClient.CreateMultiplayerPlaylistItem(playlistItem), - }, + Playlist = { TestMultiplayerClient.CreateMultiplayerPlaylistItem(item) }, Users = { localUser }, Host = localUser, }; @@ -143,6 +140,7 @@ namespace osu.Game.Tests.Visual.Multiplayer Anchor = Anchor.Centre, Origin = Anchor.Centre, Size = new Vector2(250, 50), + SelectedItem = new Bindable(item) }; }); } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs index 4bf2ebc1a4..3245b3c6a9 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.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.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -18,8 +16,8 @@ namespace osu.Game.Tests.Visual.Multiplayer { public partial class TestSceneMultiSpectatorLeaderboard : MultiplayerTestScene { - private Dictionary clocks; - private MultiSpectatorLeaderboard leaderboard; + private Dictionary clocks = null!; + private MultiSpectatorLeaderboard? leaderboard; [SetUpSteps] public override void SetUpSteps() @@ -55,13 +53,13 @@ namespace osu.Game.Tests.Visual.Multiplayer }, Add); }); - AddUntilStep("wait for load", () => leaderboard.IsLoaded); + AddUntilStep("wait for load", () => leaderboard!.IsLoaded); AddUntilStep("wait for user population", () => leaderboard.ChildrenOfType().Count() == 2); AddStep("add clock sources", () => { foreach ((int userId, var clock) in clocks) - leaderboard.AddClock(userId, clock); + leaderboard!.AddClock(userId, clock); }); } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs index 2b17f91e68..85352ada3c 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs @@ -455,7 +455,7 @@ namespace osu.Game.Tests.Visual.Multiplayer applyToBeatmap?.Invoke(Beatmap.Value); - LoadScreen(spectatorScreen = new MultiSpectatorScreen(SelectedRoom.Value, playingUsers.ToArray())); + LoadScreen(spectatorScreen = new MultiSpectatorScreen(SelectedRoom.Value!, playingUsers.ToArray())); }); AddUntilStep("wait for screen load", () => spectatorScreen.LoadState == LoadState.Loaded && (!waitForPlayerLoad || spectatorScreen.AllPlayersLoaded)); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs index df2021dbaf..9213a52c0e 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs @@ -103,14 +103,14 @@ namespace osu.Game.Tests.Visual.Multiplayer { createRoom(() => new Room { - Name = { Value = "Test Room" }, + Name = "Test Room", Playlist = - { + [ new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID } - } + ] }); AddRepeatStep("random stuff happens", performRandomAction, 30); @@ -238,17 +238,17 @@ namespace osu.Game.Tests.Visual.Multiplayer { createRoom(() => new Room { - Name = { Value = "Test Room" }, + Name = "Test Room", Playlist = - { + [ new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID } - } + ] }); - AddUntilStep("Check participant count correct", () => multiplayerClient.ClientAPIRoom?.ParticipantCount.Value == 1); + AddUntilStep("Check participant count correct", () => multiplayerClient.ClientAPIRoom?.ParticipantCount == 1); AddUntilStep("Check participant list contains user", () => multiplayerClient.ClientAPIRoom?.RecentParticipants.Count(u => u.Id == API.LocalUser.Value.Id) == 1); } @@ -259,14 +259,14 @@ namespace osu.Game.Tests.Visual.Multiplayer { roomManager.AddServerSideRoom(new Room { - Name = { Value = "Test Room" }, + Name = "Test Room", Playlist = - { + [ new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID } - } + ] }, API.LocalUser.Value); }); @@ -288,14 +288,14 @@ namespace osu.Game.Tests.Visual.Multiplayer { roomManager.AddServerSideRoom(new Room { - Name = { Value = "Test Room" }, + Name = "Test Room", Playlist = - { + [ new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID } - } + ] }, API.LocalUser.Value); }); @@ -308,7 +308,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for room open", () => this.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); AddUntilStep("wait for join", () => multiplayerClient.RoomJoined); - AddUntilStep("Check participant count correct", () => multiplayerClient.ClientAPIRoom?.ParticipantCount.Value == 1); + AddUntilStep("Check participant count correct", () => multiplayerClient.ClientAPIRoom?.ParticipantCount == 1); AddUntilStep("Check participant list contains user", () => multiplayerClient.ClientAPIRoom?.RecentParticipants.Count(u => u.Id == API.LocalUser.Value.Id) == 1); } @@ -317,18 +317,18 @@ namespace osu.Game.Tests.Visual.Multiplayer { createRoom(() => new Room { - Name = { Value = "Test Room" }, - Password = { Value = "password" }, + Name = "Test Room", + Password = "password", Playlist = - { + [ new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID } - } + ] }); - AddUntilStep("room has password", () => multiplayerClient.ClientAPIRoom?.Password.Value == "password"); + AddUntilStep("room has password", () => multiplayerClient.ClientAPIRoom?.Password == "password"); } [Test] @@ -338,15 +338,15 @@ namespace osu.Game.Tests.Visual.Multiplayer { roomManager.AddServerSideRoom(new Room { - Name = { Value = "Test Room" }, - Password = { Value = "password" }, + Name = "Test Room", + Password = "password", Playlist = - { + [ new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID } - } + ] }, API.LocalUser.Value); }); @@ -370,19 +370,19 @@ namespace osu.Game.Tests.Visual.Multiplayer { createRoom(() => new Room { - Name = { Value = "Test Room" }, - Password = { Value = "password" }, + Name = "Test Room", + Password = "password", Playlist = - { + [ new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID } - } + ] }); AddStep("change password", () => multiplayerClient.ChangeSettings(password: "password2")); - AddUntilStep("local password changed", () => multiplayerClient.ClientAPIRoom?.Password.Value == "password2"); + AddUntilStep("local password changed", () => multiplayerClient.ClientAPIRoom?.Password == "password2"); } [Test] @@ -401,14 +401,14 @@ namespace osu.Game.Tests.Visual.Multiplayer { createRoom(() => new Room { - Name = { Value = "Test Room" }, + Name = "Test Room", Playlist = - { + [ new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID } - } + ] }); pressReadyButton(); @@ -430,8 +430,8 @@ namespace osu.Game.Tests.Visual.Multiplayer }; return new Room { - Name = { Value = "Test Room" }, - Playlist = { item } + Name = "Test Room", + Playlist = [item] }; }); @@ -471,8 +471,8 @@ namespace osu.Game.Tests.Visual.Multiplayer }; return new Room { - Name = { Value = "Test Room" }, - Playlist = { item } + Name = "Test Room", + Playlist = [item] }; }); @@ -512,8 +512,8 @@ namespace osu.Game.Tests.Visual.Multiplayer }; return new Room { - Name = { Value = "Test Room" }, - Playlist = { item } + Name = "Test Room", + Playlist = [item] }; }); @@ -548,14 +548,14 @@ namespace osu.Game.Tests.Visual.Multiplayer { createRoom(() => new Room { - Name = { Value = "Test Room" }, + Name = "Test Room", Playlist = - { + [ new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID } - } + ] }); AddStep("join other user (ready, host)", () => @@ -581,14 +581,14 @@ namespace osu.Game.Tests.Visual.Multiplayer { createRoom(() => new Room { - Name = { Value = "Test Room" }, + Name = "Test Room", Playlist = - { + [ new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID } - } + ] }); AddStep("delete beatmap", () => beatmaps.Delete(importedSet)); @@ -620,14 +620,14 @@ namespace osu.Game.Tests.Visual.Multiplayer { createRoom(() => new Room { - Name = { Value = "Test Room" }, + Name = "Test Room", Playlist = - { + [ new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID } - } + ] }); AddStep("disconnect", () => multiplayerClient.Disconnect()); @@ -639,15 +639,15 @@ namespace osu.Game.Tests.Visual.Multiplayer { createRoom(() => new Room { - Name = { Value = "Test Room" }, + Name = "Test Room", Playlist = - { + [ new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID, AllowedMods = new[] { new APIMod(new OsuModHidden()) } } - } + ] }); AddStep("open mod overlay", () => this.ChildrenOfType().Single().TriggerClick()); @@ -679,14 +679,14 @@ namespace osu.Game.Tests.Visual.Multiplayer { createRoom(() => new Room { - Name = { Value = "Test Room" }, + Name = "Test Room", Playlist = - { + [ new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID, } - } + ] }); enterGameplay(); @@ -724,14 +724,14 @@ namespace osu.Game.Tests.Visual.Multiplayer createRoom(() => new Room { - Name = { Value = "Test Room" }, + Name = "Test Room", Playlist = - { + [ new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID, } - } + ] }); enterGameplay(); @@ -754,14 +754,14 @@ namespace osu.Game.Tests.Visual.Multiplayer { createRoom(() => new Room { - Name = { Value = "Test Room" }, + Name = "Test Room", Playlist = - { + [ new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID, } - } + ] }); pressReadyButton(); @@ -791,15 +791,15 @@ namespace osu.Game.Tests.Visual.Multiplayer { roomManager.AddServerSideRoom(new Room { - Name = { Value = "Test Room" }, - QueueMode = { Value = QueueMode.AllPlayers }, + Name = "Test Room", + QueueMode = QueueMode.AllPlayers, Playlist = - { + [ new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID, } - } + ] }, API.LocalUser.Value); }); @@ -810,12 +810,15 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("disable polling", () => this.ChildrenOfType().Single().TimeBetweenPolls.Value = 0); AddStep("change server-side settings", () => { - roomManager.ServerSideRooms[0].Name.Value = "New name"; - roomManager.ServerSideRooms[0].Playlist.Add(new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) - { - ID = 2, - RulesetID = new OsuRuleset().RulesetInfo.OnlineID, - }); + roomManager.ServerSideRooms[0].Name = "New name"; + roomManager.ServerSideRooms[0].Playlist = + [ + new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) + { + ID = 2, + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + } + ]; }); AddStep("join room", () => InputManager.Key(Key.Enter)); @@ -825,8 +828,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("local room has correct settings", () => { var localRoom = this.ChildrenOfType().Single().Room; - return localRoom.Name.Value == roomManager.ServerSideRooms[0].Name.Value - && localRoom.Playlist.SequenceEqual(roomManager.ServerSideRooms[0].Playlist); + return localRoom.Name == roomManager.ServerSideRooms[0].Name && localRoom.Playlist.Single().ID == 2; }); } @@ -836,15 +838,15 @@ namespace osu.Game.Tests.Visual.Multiplayer { createRoom(() => new Room { - Name = { Value = "Test Room" }, - QueueMode = { Value = QueueMode.AllPlayers }, + Name = "Test Room", + QueueMode = QueueMode.AllPlayers, Playlist = - { + [ new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID, } - } + ] }); AddStep("set spectating state", () => multiplayerClient.ChangeUserState(API.LocalUser.Value.OnlineID, MultiplayerUserState.Spectating)); @@ -872,15 +874,15 @@ namespace osu.Game.Tests.Visual.Multiplayer { createRoom(() => new Room { - Name = { Value = "Test Room" }, - QueueMode = { Value = QueueMode.AllPlayers }, + Name = "Test Room", + QueueMode = QueueMode.AllPlayers, Playlist = - { + [ new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID, } - } + ] }); AddStep("set spectating state", () => multiplayerClient.ChangeUserState(API.LocalUser.Value.OnlineID, MultiplayerUserState.Spectating)); @@ -911,15 +913,15 @@ namespace osu.Game.Tests.Visual.Multiplayer { createRoom(() => new Room { - Name = { Value = "Test Room" }, - QueueMode = { Value = QueueMode.AllPlayers }, + Name = "Test Room", + QueueMode = QueueMode.AllPlayers, Playlist = - { + [ new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID, } - } + ] }); enterGameplay(); @@ -942,15 +944,15 @@ namespace osu.Game.Tests.Visual.Multiplayer { createRoom(() => new Room { - Name = { Value = "Test Room" }, - QueueMode = { Value = QueueMode.AllPlayers }, + Name = "Test Room", + QueueMode = QueueMode.AllPlayers, Playlist = - { + [ new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID, } - } + ] }); enterGameplay(); @@ -976,14 +978,14 @@ namespace osu.Game.Tests.Visual.Multiplayer { createRoom(() => new Room { - Name = { Value = "Test Room" }, + Name = "Test Room", Playlist = - { + [ new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID } - } + ] }); AddStep("join other user and make host", () => @@ -1022,10 +1024,10 @@ namespace osu.Game.Tests.Visual.Multiplayer { createRoom(() => new Room { - Name = { Value = "Test Room" }, - QueueMode = { Value = QueueMode.AllPlayers }, + Name = "Test Room", + QueueMode = QueueMode.AllPlayers, Playlist = - { + [ new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID, @@ -1036,7 +1038,7 @@ namespace osu.Game.Tests.Visual.Multiplayer RulesetID = new TaikoRuleset().RulesetInfo.OnlineID, AllowedMods = new[] { new APIMod { Acronym = "HD" } }, }, - } + ] }); AddStep("select hidden", () => multiplayerClient.ChangeUserMods(new[] { new APIMod { Acronym = "HD" } })); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs index bafe373d57..2f232a6164 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs @@ -37,10 +37,10 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestPerUserMods() { - AddStep("first user has no mods", () => Assert.That(((TestLeaderboard)Leaderboard).UserMods[0], Is.Empty)); + AddStep("first user has no mods", () => Assert.That(((TestLeaderboard)Leaderboard!).UserMods[0], Is.Empty)); AddStep("last user has NF mod", () => { - Assert.That(((TestLeaderboard)Leaderboard).UserMods[TOTAL_USERS - 1], Has.One.Items); + Assert.That(((TestLeaderboard)Leaderboard!).UserMods[TOTAL_USERS - 1], Has.One.Items); Assert.That(((TestLeaderboard)Leaderboard).UserMods[TOTAL_USERS - 1].Single(), Is.TypeOf()); }); } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboardTeams.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboardTeams.cs index 37662ffce8..3f1db308c0 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboardTeams.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboardTeams.cs @@ -39,7 +39,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { LoadComponentAsync(new MatchScoreDisplay { - Team1Score = { BindTarget = Leaderboard.TeamScores[0] }, + Team1Score = { BindTarget = Leaderboard!.TeamScores[0] }, Team2Score = { BindTarget = Leaderboard.TeamScores[1] } }, Add); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerLoungeSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerLoungeSubScreen.cs index cf25e06799..9951f62c77 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerLoungeSubScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerLoungeSubScreen.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.Graphics.Containers; @@ -22,10 +20,9 @@ namespace osu.Game.Tests.Visual.Multiplayer { protected new TestRoomManager RoomManager => (TestRoomManager)base.RoomManager; - private LoungeSubScreen loungeScreen; - - private Room lastJoinedRoom; - private string lastJoinedPassword; + private LoungeSubScreen loungeScreen = null!; + private Room? lastJoinedRoom; + private string? lastJoinedPassword; public override void SetUpSteps() { @@ -87,7 +84,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestJoinRoomWithIncorrectPasswordViaButton() { - DrawableLoungeRoom.PasswordEntryPopover passwordEntryPopover = null; + DrawableLoungeRoom.PasswordEntryPopover? passwordEntryPopover = null; AddStep("add room", () => RoomManager.AddRooms(1, withPassword: true)); AddStep("select room", () => InputManager.Key(Key.Down)); @@ -97,14 +94,14 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("press join room button", () => passwordEntryPopover.ChildrenOfType().First().TriggerClick()); AddAssert("room not joined", () => loungeScreen.IsCurrentScreen()); - AddUntilStep("password prompt still visible", () => passwordEntryPopover.State.Value == Visibility.Visible); + AddUntilStep("password prompt still visible", () => passwordEntryPopover!.State.Value == Visibility.Visible); AddAssert("textbox still focused", () => InputManager.FocusedDrawable is OsuPasswordTextBox); } [Test] public void TestJoinRoomWithIncorrectPasswordViaEnter() { - DrawableLoungeRoom.PasswordEntryPopover passwordEntryPopover = null; + DrawableLoungeRoom.PasswordEntryPopover? passwordEntryPopover = null; AddStep("add room", () => RoomManager.AddRooms(1, withPassword: true)); AddStep("select room", () => InputManager.Key(Key.Down)); @@ -114,14 +111,14 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("press enter", () => InputManager.Key(Key.Enter)); AddAssert("room not joined", () => loungeScreen.IsCurrentScreen()); - AddUntilStep("password prompt still visible", () => passwordEntryPopover.State.Value == Visibility.Visible); + AddUntilStep("password prompt still visible", () => passwordEntryPopover!.State.Value == Visibility.Visible); AddAssert("textbox still focused", () => InputManager.FocusedDrawable is OsuPasswordTextBox); } [Test] public void TestJoinRoomWithCorrectPassword() { - DrawableLoungeRoom.PasswordEntryPopover passwordEntryPopover = null; + DrawableLoungeRoom.PasswordEntryPopover? passwordEntryPopover = null; AddStep("add room", () => RoomManager.AddRooms(1, withPassword: true)); AddStep("select room", () => InputManager.Key(Key.Down)); @@ -137,7 +134,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestJoinRoomWithPasswordViaKeyboardOnly() { - DrawableLoungeRoom.PasswordEntryPopover passwordEntryPopover = null; + DrawableLoungeRoom.PasswordEntryPopover? passwordEntryPopover = null; AddStep("add room", () => RoomManager.AddRooms(1, withPassword: true)); AddStep("select room", () => InputManager.Key(Key.Down)); @@ -150,7 +147,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("room join password correct", () => lastJoinedPassword == "password"); } - private void onRoomJoined(Room room, string password) + private void onRoomJoined(Room room, string? password) { lastJoinedRoom = room; lastJoinedPassword = password; diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs index c2d3b17ccb..edeb1708e0 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs @@ -1,9 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.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 @@ -27,7 +29,10 @@ namespace osu.Game.Tests.Visual.Multiplayer Origin = Anchor.Centre, RelativeSizeAxes = Axes.X, Height = 50, - Child = new MultiplayerMatchFooter() + Child = new MultiplayerMatchFooter + { + SelectedItem = new Bindable() + } } }; }); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs index 88cc7eb9b3..2a5f16d091 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Linq; @@ -15,6 +13,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; @@ -33,14 +32,16 @@ namespace osu.Game.Tests.Visual.Multiplayer { public partial class TestSceneMultiplayerMatchSongSelect : MultiplayerTestScene { - private BeatmapManager manager; - private RulesetStore rulesets; + private BeatmapManager manager = null!; + private RulesetStore rulesets = null!; - private IList beatmaps => importedBeatmapSet?.PerformRead(s => s.Beatmaps) ?? new List(); + private IList beatmaps => importedBeatmapSet.PerformRead(s => s.Beatmaps); - private TestMultiplayerMatchSongSelect songSelect; + private TestMultiplayerMatchSongSelect songSelect = null!; + private Live importedBeatmapSet = null!; - private Live importedBeatmapSet; + [Resolved] + private OsuConfigManager configManager { get; set; } = null!; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) @@ -52,15 +53,13 @@ namespace osu.Game.Tests.Visual.Multiplayer Dependencies.Cache(detachedBeatmapStore = new DetachedBeatmapStore()); Dependencies.Cache(Realm); - importedBeatmapSet = manager.Import(TestResources.CreateTestBeatmapSetInfo(8, rulesets.AvailableRulesets.ToArray())); + importedBeatmapSet = manager.Import(TestResources.CreateTestBeatmapSetInfo(8, rulesets.AvailableRulesets.ToArray()))!; Add(detachedBeatmapStore); } - public override void SetUpSteps() + private void setUp() { - base.SetUpSteps(); - AddStep("reset", () => { Ruleset.Value = new OsuRuleset().RulesetInfo; @@ -68,13 +67,15 @@ namespace osu.Game.Tests.Visual.Multiplayer SelectedMods.SetDefault(); }); - AddStep("create song select", () => LoadScreen(songSelect = new TestMultiplayerMatchSongSelect(SelectedRoom.Value))); + AddStep("create song select", () => LoadScreen(songSelect = new TestMultiplayerMatchSongSelect(SelectedRoom.Value!))); AddUntilStep("wait for present", () => songSelect.IsCurrentScreen() && songSelect.BeatmapSetsLoaded); } [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()); @@ -83,7 +84,9 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestBeatmapConfirmed() { - BeatmapInfo selectedBeatmap = null; + BeatmapInfo selectedBeatmap = null!; + + setUp(); AddStep("change ruleset", () => Ruleset.Value = new TaikoRuleset().RulesetInfo); AddStep("select beatmap", @@ -107,9 +110,11 @@ 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) }); + 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)! }); AddAssert("freemods empty", () => songSelect.FreeMods.Value.Count == 0); @@ -120,6 +125,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 +167,8 @@ namespace osu.Game.Tests.Visual.Multiplayer public new BeatmapCarousel Carousel => base.Carousel; - public TestMultiplayerMatchSongSelect(Room room) - : base(room) + public TestMultiplayerMatchSongSelect(Room room, PlaylistItem? itemToEdit = null) + : base(room, itemToEdit) { } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs index e2593e68e5..8ea52f8099 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs @@ -1,10 +1,7 @@ // 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 NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -42,10 +39,9 @@ namespace osu.Game.Tests.Visual.Multiplayer { public partial class TestSceneMultiplayerMatchSubScreen : MultiplayerTestScene { - private MultiplayerMatchSubScreen screen; - - private BeatmapManager beatmaps; - private BeatmapSetInfo importedSet; + private MultiplayerMatchSubScreen screen = null!; + private BeatmapManager beatmaps = null!; + private BeatmapSetInfo importedSet = null!; public TestSceneMultiplayerMatchSubScreen() : base(false) @@ -69,41 +65,25 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("load match", () => { - SelectedRoom.Value = new Room { Name = { Value = "Test Room" } }; - LoadScreen(screen = new TestMultiplayerMatchSubScreen(SelectedRoom.Value)); + SelectedRoom.Value = new Room { Name = "Test Room" }; + LoadScreen(screen = new TestMultiplayerMatchSubScreen(SelectedRoom.Value!)); }); AddUntilStep("wait for load", () => screen.IsCurrentScreen()); } [Test] - [FlakyTest] - /* - * Fail rate around 1.5% - * - * TearDown : System.AggregateException : One or more errors occurred. (Index was out of range. Must be non-negative and less than the size of the collection. (Parameter 'index')) - ----> System.ArgumentOutOfRangeException : Index was out of range. Must be non-negative and less than the size of the collection. (Parameter 'index') - * --TearDown - * at System.Threading.Tasks.Task.ThrowIfExceptional(Boolean includeTaskCanceledExceptions) - * at System.Threading.Tasks.Task.Wait(Int32 millisecondsTimeout, CancellationToken cancellationToken) - * at osu.Framework.Extensions.TaskExtensions.WaitSafely(Task task) - * at osu.Framework.Testing.TestScene.checkForErrors() - * at osu.Framework.Testing.TestScene.RunTestsFromNUnit() - *--ArgumentOutOfRangeException - * at osu.Framework.Bindables.BindableList`1.removeAt(Int32 index, BindableList`1 caller) - * at osu.Framework.Bindables.BindableList`1.removeAt(Int32 index, BindableList`1 caller) - * at osu.Framework.Bindables.BindableList`1.removeAt(Int32 index, BindableList`1 caller) - * at osu.Game.Online.Multiplayer.MultiplayerClient.<>c__DisplayClass106_0.b__0() in C:\BuildAgent\work\ecd860037212ac52\osu.Game\Online\Multiplayer\MultiplayerClient .cs:line 702 - * at osu.Framework.Threading.ScheduledDelegate.RunTaskInternal() - */ public void TestCreatedRoom() { AddStep("add playlist item", () => { - SelectedRoom.Value.Playlist.Add(new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) - { - RulesetID = new OsuRuleset().RulesetInfo.OnlineID - }); + SelectedRoom.Value!.Playlist = + [ + new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID + } + ]; }); ClickButtonWhenEnabled(); @@ -112,16 +92,18 @@ namespace osu.Game.Tests.Visual.Multiplayer } [Test] - [FlakyTest] // See above public void TestTaikoOnlyMod() { AddStep("add playlist item", () => { - SelectedRoom.Value.Playlist.Add(new PlaylistItem(new TestBeatmap(new TaikoRuleset().RulesetInfo).BeatmapInfo) - { - RulesetID = new TaikoRuleset().RulesetInfo.OnlineID, - AllowedMods = new[] { new APIMod(new TaikoModSwap()) } - }); + SelectedRoom.Value!.Playlist = + [ + new PlaylistItem(new TestBeatmap(new TaikoRuleset().RulesetInfo).BeatmapInfo) + { + RulesetID = new TaikoRuleset().RulesetInfo.OnlineID, + AllowedMods = new[] { new APIMod(new TaikoModSwap()) } + } + ]; }); ClickButtonWhenEnabled(); @@ -133,43 +115,45 @@ namespace osu.Game.Tests.Visual.Multiplayer } [Test] - [FlakyTest] // See above public void TestSettingValidity() { AddAssert("create button not enabled", () => !this.ChildrenOfType().Single().Enabled.Value); AddStep("set playlist", () => { - SelectedRoom.Value.Playlist.Add(new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) - { - RulesetID = new OsuRuleset().RulesetInfo.OnlineID - }); + SelectedRoom.Value!.Playlist = + [ + new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID + } + ]; }); AddAssert("create button enabled", () => this.ChildrenOfType().Single().Enabled.Value); } [Test] - [FlakyTest] // See above public void TestStartMatchWhileSpectating() { AddStep("set playlist", () => { - SelectedRoom.Value.Playlist.Add(new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()).BeatmapInfo) - { - RulesetID = new OsuRuleset().RulesetInfo.OnlineID - }); + SelectedRoom.Value!.Playlist = + [ + new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()).BeatmapInfo) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID + } + ]; }); ClickButtonWhenEnabled(); AddUntilStep("wait for room join", () => RoomJoined); - AddStep("join other user (ready)", () => - { - MultiplayerClient.AddUser(new APIUser { Id = PLAYER_1_ID }); - MultiplayerClient.ChangeUserState(PLAYER_1_ID, MultiplayerUserState.Ready); - }); + AddStep("join other user", void () => MultiplayerClient.AddUser(new APIUser { Id = PLAYER_1_ID })); + AddUntilStep("wait for user populated", () => MultiplayerClient.ClientRoom!.Users.Single(u => u.UserID == PLAYER_1_ID).User, () => Is.Not.Null); + AddStep("other user ready", () => MultiplayerClient.ChangeUserState(PLAYER_1_ID, MultiplayerUserState.Ready)); ClickButtonWhenEnabled(); @@ -181,16 +165,18 @@ namespace osu.Game.Tests.Visual.Multiplayer } [Test] - [FlakyTest] // See above public void TestFreeModSelectionHasAllowedMods() { AddStep("add playlist item with allowed mod", () => { - SelectedRoom.Value.Playlist.Add(new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) - { - RulesetID = new OsuRuleset().RulesetInfo.OnlineID, - AllowedMods = new[] { new APIMod(new OsuModDoubleTime()) } - }); + SelectedRoom.Value!.Playlist = + [ + new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + AllowedMods = new[] { new APIMod(new OsuModDoubleTime()) } + } + ]; }); ClickButtonWhenEnabled(); @@ -208,16 +194,18 @@ namespace osu.Game.Tests.Visual.Multiplayer } [Test] - [FlakyTest] // See above public void TestModSelectKeyWithAllowedMods() { AddStep("add playlist item with allowed mod", () => { - SelectedRoom.Value.Playlist.Add(new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) - { - RulesetID = new OsuRuleset().RulesetInfo.OnlineID, - AllowedMods = new[] { new APIMod(new OsuModDoubleTime()) } - }); + SelectedRoom.Value!.Playlist = + [ + new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + AllowedMods = new[] { new APIMod(new OsuModDoubleTime()) } + } + ]; }); ClickButtonWhenEnabled(); @@ -230,15 +218,17 @@ namespace osu.Game.Tests.Visual.Multiplayer } [Test] - [FlakyTest] // See above public void TestModSelectKeyWithNoAllowedMods() { AddStep("add playlist item with no allowed mods", () => { - SelectedRoom.Value.Playlist.Add(new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) - { - RulesetID = new OsuRuleset().RulesetInfo.OnlineID, - }); + SelectedRoom.Value!.Playlist = + [ + new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + } + ]; }); ClickButtonWhenEnabled(); @@ -251,13 +241,12 @@ namespace osu.Game.Tests.Visual.Multiplayer } [Test] - [FlakyTest] // See above public void TestNextPlaylistItemSelectedAfterCompletion() { AddStep("add two playlist items", () => { - SelectedRoom.Value.Playlist.AddRange(new[] - { + SelectedRoom.Value!.Playlist = + [ new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()).BeatmapInfo) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID @@ -266,7 +255,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { RulesetID = new OsuRuleset().RulesetInfo.OnlineID } - }); + ]; }); ClickButtonWhenEnabled(); @@ -288,24 +277,26 @@ namespace osu.Game.Tests.Visual.Multiplayer } [Test] - [FlakyTest] // See above public void TestModSelectOverlay() { AddStep("add playlist item", () => { - SelectedRoom.Value.Playlist.Add(new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) - { - RulesetID = new OsuRuleset().RulesetInfo.OnlineID, - RequiredMods = new[] + SelectedRoom.Value!.Playlist = + [ + new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) { - new APIMod(new OsuModDoubleTime { SpeedChange = { Value = 2.0 } }), - new APIMod(new OsuModStrictTracking()), - }, - AllowedMods = new[] - { - new APIMod(new OsuModFlashlight()), + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + RequiredMods = new[] + { + new APIMod(new OsuModDoubleTime { SpeedChange = { Value = 2.0 } }), + new APIMod(new OsuModStrictTracking()), + }, + AllowedMods = new[] + { + new APIMod(new OsuModFlashlight()), + } } - }); + ]; }); ClickButtonWhenEnabled(); @@ -325,8 +316,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private partial class TestMultiplayerMatchSubScreen : MultiplayerMatchSubScreen { [Resolved(canBeNull: true)] - [CanBeNull] - private IDialogOverlay dialogOverlay { get; set; } + private IDialogOverlay? dialogOverlay { get; set; } public TestMultiplayerMatchSubScreen(Room room) : base(room) diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlayer.cs index aaf85dab7c..94dd114c32 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlayer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlayer.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Linq; @@ -22,7 +20,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { public partial class TestSceneMultiplayerPlayer : MultiplayerTestScene { - private MultiplayerPlayer player; + private MultiplayerPlayer player = null!; [Test] public void TestGameplay() @@ -49,7 +47,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("score changed", () => player.GameplayState.ScoreProcessor.TotalScore.Value > 0); } - private void setup(Func> mods = null) + private void setup(Func>? mods = null) { AddStep("set beatmap", () => { @@ -64,10 +62,10 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("initialise gameplay", () => { - Stack.Push(player = new MultiplayerPlayer(MultiplayerClient.ServerAPIRoom, new PlaylistItem(Beatmap.Value.BeatmapInfo) + Stack.Push(player = new MultiplayerPlayer(MultiplayerClient.ServerAPIRoom!, new PlaylistItem(Beatmap.Value.BeatmapInfo) { RulesetID = Beatmap.Value.BeatmapInfo.Ruleset.OnlineID, - }, MultiplayerClient.ServerRoom?.Users.ToArray())); + }, MultiplayerClient.ServerRoom!.Users.ToArray())); }); AddUntilStep("wait for player to be current", () => player.IsCurrentScreen() && player.IsLoaded); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs index 2100f82886..36f5bba384 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,10 @@ namespace osu.Game.Tests.Visual.Multiplayer { public partial class TestSceneMultiplayerPlaylist : MultiplayerTestScene { - private MultiplayerPlaylist list; - private BeatmapManager beatmaps; - private BeatmapSetInfo importedSet; - private BeatmapInfo importedBeatmap; + private MultiplayerPlaylist list = null!; + private BeatmapManager beatmaps = null!; + private BeatmapSetInfo importedSet = null!; + private BeatmapInfo importedBeatmap = null!; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) @@ -49,12 +48,13 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("create list", () => { - Child = list = new MultiplayerPlaylist + Child = list = new MultiplayerPlaylist(SelectedRoom.Value!) { Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, - Size = new Vector2(0.4f, 0.8f) + Size = new Vector2(0.4f, 0.8f), + SelectedItem = new Bindable() }; }); @@ -164,9 +164,9 @@ namespace osu.Game.Tests.Visual.Multiplayer { RoomManager.CreateRoom(new Room { - Name = { Value = "test name" }, + Name = "test name", Playlist = - { + [ new PlaylistItem(new TestBeatmap(Ruleset.Value).BeatmapInfo) { RulesetID = Ruleset.Value.OnlineID @@ -176,7 +176,7 @@ namespace osu.Game.Tests.Visual.Multiplayer RulesetID = Ruleset.Value.OnlineID, Expired = true } - } + ] }); }); @@ -198,7 +198,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/TestSceneMultiplayerQueueList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs index 47fb4e06ea..3ef2e4ecf4 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.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 NUnit.Framework; @@ -27,10 +25,10 @@ namespace osu.Game.Tests.Visual.Multiplayer { public partial class TestSceneMultiplayerQueueList : MultiplayerTestScene { - private MultiplayerQueueList playlist; - private BeatmapManager beatmaps; - private BeatmapSetInfo importedSet; - private BeatmapInfo importedBeatmap; + private MultiplayerQueueList playlist = null!; + private BeatmapManager beatmaps = null!; + private BeatmapSetInfo importedSet = null!; + private BeatmapInfo importedBeatmap = null!; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) @@ -46,12 +44,17 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("create playlist", () => { - Child = playlist = new MultiplayerQueueList + Child = playlist = new MultiplayerQueueList(SelectedRoom.Value!) { Anchor = Anchor.Centre, Origin = Anchor.Centre, Size = new Vector2(500, 300), - Items = { BindTarget = MultiplayerClient.ClientAPIRoom!.Playlist } + }; + + MultiplayerClient.ClientAPIRoom!.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(Room.Playlist)) + playlist.Items.ReplaceRange(0, playlist.Items.Count, MultiplayerClient.ClientAPIRoom.Playlist); }; }); @@ -69,7 +72,7 @@ namespace osu.Game.Tests.Visual.Multiplayer public void TestDeleteButtonAlwaysVisibleForHost() { AddStep("set all players queue mode", () => MultiplayerClient.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.AllPlayers }).WaitSafely()); - AddUntilStep("wait for queue mode change", () => MultiplayerClient.ClientAPIRoom?.QueueMode.Value == QueueMode.AllPlayers); + AddUntilStep("wait for queue mode change", () => MultiplayerClient.ClientAPIRoom?.QueueMode == QueueMode.AllPlayers); addPlaylistItem(() => API.LocalUser.Value.OnlineID); assertDeleteButtonVisibility(1, true); @@ -81,7 +84,7 @@ namespace osu.Game.Tests.Visual.Multiplayer public void TestDeleteButtonOnlyVisibleForItemOwnerIfNotHost() { AddStep("set all players queue mode", () => MultiplayerClient.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.AllPlayers }).WaitSafely()); - AddUntilStep("wait for queue mode change", () => MultiplayerClient.ClientAPIRoom?.QueueMode.Value == QueueMode.AllPlayers); + AddUntilStep("wait for queue mode change", () => MultiplayerClient.ClientAPIRoom?.QueueMode == QueueMode.AllPlayers); AddStep("join other user", () => MultiplayerClient.AddUser(new APIUser { Id = 1234 })); AddStep("set other user as host", () => MultiplayerClient.TransferHost(1234)); @@ -100,7 +103,7 @@ namespace osu.Game.Tests.Visual.Multiplayer public void TestSingleItemDoesNotHaveDeleteButton() { AddStep("set all players queue mode", () => MultiplayerClient.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.AllPlayers }).WaitSafely()); - AddUntilStep("wait for queue mode change", () => MultiplayerClient.ClientAPIRoom?.QueueMode.Value == QueueMode.AllPlayers); + AddUntilStep("wait for queue mode change", () => MultiplayerClient.ClientAPIRoom?.QueueMode == QueueMode.AllPlayers); assertDeleteButtonVisibility(0, false); } @@ -109,7 +112,7 @@ namespace osu.Game.Tests.Visual.Multiplayer public void TestCurrentItemHasDeleteButtonIfNotSingle() { AddStep("set all players queue mode", () => MultiplayerClient.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.AllPlayers }).WaitSafely()); - AddUntilStep("wait for queue mode change", () => MultiplayerClient.ClientAPIRoom?.QueueMode.Value == QueueMode.AllPlayers); + AddUntilStep("wait for queue mode change", () => MultiplayerClient.ClientAPIRoom?.QueueMode == QueueMode.AllPlayers); addPlaylistItem(() => API.LocalUser.Value.OnlineID); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerResults.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerResults.cs index f030466fff..076c2c3cdd 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerResults.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerResults.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using NUnit.Framework; using osu.Game.Online.Rooms; using osu.Game.Rulesets.Osu; @@ -16,7 +14,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestDisplayResults() { - MultiplayerResultsScreen screen = null; + MultiplayerResultsScreen screen = null!; AddStep("show results screen", () => { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs index 816ba4ca32..1429f86164 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,11 @@ namespace osu.Game.Tests.Visual.Multiplayer { public partial class TestSceneMultiplayerSpectateButton : MultiplayerTestScene { - private MultiplayerSpectateButton spectateButton; - private MatchStartControl startControl; + private MultiplayerSpectateButton spectateButton = null!; + private MatchStartControl startControl = null!; - private readonly Bindable selectedItem = new Bindable(); - - private BeatmapSetInfo importedSet; - private BeatmapManager beatmaps; + private BeatmapSetInfo importedSet = null!; + private BeatmapManager beatmaps = null!; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) @@ -52,14 +48,12 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("create button", () => { - AvailabilityTracker.SelectedItem.BindTo(selectedItem); + PlaylistItem item = SelectedRoom.Value!.Playlist.First(); + + AvailabilityTracker.SelectedItem.Value = item; importedSet = beatmaps.GetAllUsableBeatmapSets().First(); Beatmap.Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()); - selectedItem.Value = new PlaylistItem(Beatmap.Value.BeatmapInfo) - { - RulesetID = Beatmap.Value.BeatmapInfo.Ruleset.OnlineID, - }; Child = new PopoverContainer { @@ -75,12 +69,14 @@ namespace osu.Game.Tests.Visual.Multiplayer Anchor = Anchor.Centre, Origin = Anchor.Centre, Size = new Vector2(200, 50), + SelectedItem = new Bindable(item) }, startControl = new MatchStartControl { Anchor = Anchor.Centre, Origin = Anchor.Centre, Size = new Vector2(200, 50), + SelectedItem = new Bindable(item) } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectatorPlayerGrid.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectatorPlayerGrid.cs index 8fd05dcaa9..2f461ad706 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectatorPlayerGrid.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectatorPlayerGrid.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.Graphics; @@ -16,7 +14,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { public partial class TestSceneMultiplayerSpectatorPlayerGrid : OsuManualInputManagerTestScene { - private PlayerGrid grid; + private PlayerGrid grid = null!; [SetUp] public void Setup() => Schedule(() => diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerTeamResults.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerTeamResults.cs index 68fd39a066..f77b6e8c68 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerTeamResults.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerTeamResults.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.Collections.Generic; using NUnit.Framework; using osu.Framework.Bindables; @@ -32,7 +30,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [TestCase(1048576, 1048576)] public void TestDisplayTeamResults(int team1Score, int team2Score) { - MultiplayerResultsScreen screen = null; + MultiplayerResultsScreen screen = null!; AddStep("show results screen", () => { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsRoomSettingsPlaylist.cs b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsRoomSettingsPlaylist.cs index ae27db0dd1..cd41884ba7 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsRoomSettingsPlaylist.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsRoomSettingsPlaylist.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Linq; @@ -28,18 +26,18 @@ namespace osu.Game.Tests.Visual.Multiplayer { public partial class TestScenePlaylistsRoomSettingsPlaylist : OnlinePlayTestScene { - private TestPlaylist playlist; + private TestPlaylist playlist = null!; [Test] public void TestItemRemovedOnDeletion() { - PlaylistItem selectedItem = null; + PlaylistItem selectedItem = null!; createPlaylist(); moveToItem(0); AddStep("click", () => InputManager.Click(MouseButton.Left)); - AddStep("retrieve selection", () => selectedItem = playlist.SelectedItem.Value); + AddStep("retrieve selection", () => selectedItem = playlist.SelectedItem.Value!); moveToDeleteButton(0); AddStep("click delete button", () => InputManager.Click(MouseButton.Left)); @@ -122,7 +120,7 @@ namespace osu.Game.Tests.Visual.Multiplayer InputManager.MoveMouseTo(item.ChildrenOfType().ElementAt(0), offset); }); - private void createPlaylist(Action setupPlaylist = null) + private void createPlaylist(Action? setupPlaylist = null) { AddStep("create playlist", () => { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs index cc78bed5de..fa1909254a 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.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 NUnit.Framework; @@ -27,9 +25,8 @@ namespace osu.Game.Tests.Visual.Multiplayer { public partial class TestScenePlaylistsSongSelect : OnlinePlayTestScene { - private BeatmapManager manager; - - private TestPlaylistsSongSelect songSelect; + private BeatmapManager manager = null!; + private TestPlaylistsSongSelect songSelect = null!; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) @@ -60,7 +57,7 @@ namespace osu.Game.Tests.Visual.Multiplayer SelectedMods.Value = Array.Empty(); }); - AddStep("create song select", () => LoadScreen(songSelect = new TestPlaylistsSongSelect(SelectedRoom.Value))); + AddStep("create song select", () => LoadScreen(songSelect = new TestPlaylistsSongSelect(SelectedRoom.Value!))); AddUntilStep("wait for present", () => songSelect.IsCurrentScreen() && songSelect.BeatmapSetsLoaded); } @@ -68,46 +65,41 @@ namespace osu.Game.Tests.Visual.Multiplayer public void TestItemAddedIfEmptyOnStart() { AddStep("finalise selection", () => songSelect.FinaliseSelection()); - AddAssert("playlist has 1 item", () => SelectedRoom.Value.Playlist.Count == 1); + AddAssert("playlist has 1 item", () => SelectedRoom.Value!.Playlist.Count == 1); } [Test] public void TestItemAddedWhenCreateNewItemClicked() { - AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem()); - AddAssert("playlist has 1 item", () => SelectedRoom.Value.Playlist.Count == 1); + AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem!()); + AddAssert("playlist has 1 item", () => SelectedRoom.Value!.Playlist.Count == 1); } [Test] public void TestItemNotAddedIfExistingOnStart() { - AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem()); + AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem!()); AddStep("finalise selection", () => songSelect.FinaliseSelection()); - AddAssert("playlist has 1 item", () => SelectedRoom.Value.Playlist.Count == 1); + AddAssert("playlist has 1 item", () => SelectedRoom.Value!.Playlist.Count == 1); } [Test] public void TestAddSameItemMultipleTimes() { - AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem()); - AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem()); - AddAssert("playlist has 2 items", () => SelectedRoom.Value.Playlist.Count == 2); + AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem!()); + AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem!()); + AddAssert("playlist has 2 items", () => SelectedRoom.Value!.Playlist.Count == 2); } [Test] public void TestAddItemAfterRearrangement() { - AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem()); - AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem()); - AddStep("rearrange", () => - { - var item = SelectedRoom.Value.Playlist[0]; - SelectedRoom.Value.Playlist.RemoveAt(0); - SelectedRoom.Value.Playlist.Add(item); - }); + AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem!()); + AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem!()); + AddStep("rearrange", () => SelectedRoom.Value!.Playlist = SelectedRoom.Value!.Playlist.Skip(1).Append(SelectedRoom.Value!.Playlist[0]).ToArray()); - AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem()); - AddAssert("new item has id 2", () => SelectedRoom.Value.Playlist.Last().ID == 2); + AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem!()); + AddAssert("new item has id 2", () => SelectedRoom.Value!.Playlist.Last().ID == 2); } /// @@ -117,19 +109,19 @@ namespace osu.Game.Tests.Visual.Multiplayer public void TestNewItemHasNewModInstances() { AddStep("set dt mod", () => SelectedMods.Value = new[] { new OsuModDoubleTime() }); - AddStep("create item", () => songSelect.BeatmapDetails.CreateNewItem()); + AddStep("create item", () => songSelect.BeatmapDetails.CreateNewItem!()); AddStep("change mod rate", () => ((OsuModDoubleTime)SelectedMods.Value[0]).SpeedChange.Value = 2); - AddStep("create item", () => songSelect.BeatmapDetails.CreateNewItem()); + AddStep("create item", () => songSelect.BeatmapDetails.CreateNewItem!()); AddAssert("item 1 has rate 1.5", () => { - var mod = (OsuModDoubleTime)SelectedRoom.Value.Playlist.First().RequiredMods[0].ToMod(new OsuRuleset()); + var mod = (OsuModDoubleTime)SelectedRoom.Value!.Playlist.First().RequiredMods[0].ToMod(new OsuRuleset()); return Precision.AlmostEquals(1.5, mod.SpeedChange.Value); }); AddAssert("item 2 has rate 2", () => { - var mod = (OsuModDoubleTime)SelectedRoom.Value.Playlist.Last().RequiredMods[0].ToMod(new OsuRuleset()); + var mod = (OsuModDoubleTime)SelectedRoom.Value!.Playlist.Last().RequiredMods[0].ToMod(new OsuRuleset()); return Precision.AlmostEquals(2, mod.SpeedChange.Value); }); } @@ -140,7 +132,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestGlobalModInstancesNotRetained() { - OsuModDoubleTime mod = null; + OsuModDoubleTime mod = null!; AddStep("set dt mod and store", () => { @@ -150,12 +142,12 @@ namespace osu.Game.Tests.Visual.Multiplayer mod = (OsuModDoubleTime)SelectedMods.Value[0]; }); - AddStep("create item", () => songSelect.BeatmapDetails.CreateNewItem()); + AddStep("create item", () => songSelect.BeatmapDetails.CreateNewItem!()); AddStep("change stored mod rate", () => mod.SpeedChange.Value = 2); AddAssert("item has rate 1.5", () => { - var m = (OsuModDoubleTime)SelectedRoom.Value.Playlist.First().RequiredMods[0].ToMod(new OsuRuleset()); + var m = (OsuModDoubleTime)SelectedRoom.Value!.Playlist.First().RequiredMods[0].ToMod(new OsuRuleset()); return Precision.AlmostEquals(1.5, m.SpeedChange.Value); }); } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneRankRangePill.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneRankRangePill.cs index d5f53bc354..9420ddf807 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneRankRangePill.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneRankRangePill.cs @@ -9,7 +9,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; -using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay.Lounge.Components; namespace osu.Game.Tests.Visual.Multiplayer @@ -18,10 +17,6 @@ namespace osu.Game.Tests.Visual.Multiplayer { private readonly Mock multiplayerClient = new Mock(); - protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => - // not used directly in component, but required due to it inheriting from OnlinePlayComposite. - new CachedModelDependencyContainer(base.CreateChildDependencies(parent)); - [BackgroundDependencyLoader] private void load() { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneStarRatingRangeDisplay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneStarRatingRangeDisplay.cs index b53a61f881..88afef7de2 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneStarRatingRangeDisplay.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneStarRatingRangeDisplay.cs @@ -20,7 +20,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { SelectedRoom.Value = new Room(); - Child = new StarRatingRangeDisplay + Child = new StarRatingRangeDisplay(SelectedRoom.Value) { Anchor = Anchor.Centre, Origin = Anchor.Centre @@ -33,11 +33,11 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("set playlist", () => { - SelectedRoom.Value.Playlist.AddRange(new[] - { + SelectedRoom.Value!.Playlist = + [ new PlaylistItem(new BeatmapInfo { StarRating = min }), new PlaylistItem(new BeatmapInfo { StarRating = max }), - }); + ]; }); } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneTeamVersus.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneTeamVersus.cs index 32e90153d8..05136ebee1 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneTeamVersus.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneTeamVersus.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 NUnit.Framework; @@ -29,10 +27,10 @@ namespace osu.Game.Tests.Visual.Multiplayer { public partial class TestSceneTeamVersus : ScreenTestScene { - private BeatmapManager beatmaps; - private BeatmapSetInfo importedSet; + private BeatmapManager beatmaps = null!; + private BeatmapSetInfo importedSet = null!; - private TestMultiplayerComponents multiplayerComponents; + private TestMultiplayerComponents multiplayerComponents = null!; private TestMultiplayerClient multiplayerClient => multiplayerComponents.MultiplayerClient; @@ -64,15 +62,15 @@ namespace osu.Game.Tests.Visual.Multiplayer { createRoom(() => new Room { - Name = { Value = "Test Room" }, - Type = { Value = MatchType.TeamVersus }, + Name = "Test Room", + Type = MatchType.TeamVersus, Playlist = - { + [ new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID } - } + ] }); AddUntilStep("room type is team vs", () => multiplayerClient.ClientRoom?.Settings.MatchType == MatchType.TeamVersus); @@ -84,15 +82,15 @@ namespace osu.Game.Tests.Visual.Multiplayer { createRoom(() => new Room { - Name = { Value = "Test Room" }, - Type = { Value = MatchType.TeamVersus }, + Name = "Test Room", + Type = MatchType.TeamVersus, Playlist = - { + [ new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID } - } + ] }); AddUntilStep("user on team 0", () => (multiplayerClient.ClientRoom?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 0); @@ -121,25 +119,25 @@ namespace osu.Game.Tests.Visual.Multiplayer { createRoom(() => new Room { - Name = { Value = "Test Room" }, - Type = { Value = MatchType.HeadToHead }, + Name = "Test Room", + Type = MatchType.HeadToHead, Playlist = - { + [ new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID, } - } + ] }); - AddUntilStep("match type head to head", () => multiplayerClient.ClientAPIRoom?.Type.Value == MatchType.HeadToHead); + AddUntilStep("match type head to head", () => multiplayerClient.ClientAPIRoom?.Type == MatchType.HeadToHead); AddStep("change match type", () => multiplayerClient.ChangeSettings(new MultiplayerRoomSettings { MatchType = MatchType.TeamVersus }).WaitSafely()); - AddUntilStep("api room updated to team versus", () => multiplayerClient.ClientAPIRoom?.Type.Value == MatchType.TeamVersus); + AddUntilStep("api room updated to team versus", () => multiplayerClient.ClientAPIRoom?.Type == MatchType.TeamVersus); } [Test] @@ -147,14 +145,14 @@ namespace osu.Game.Tests.Visual.Multiplayer { createRoom(() => new Room { - Name = { Value = "Test Room" }, + Name = "Test Room", Playlist = - { + [ new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID, } - } + ] }); AddUntilStep("room type is head to head", () => multiplayerClient.ClientRoom?.Settings.MatchType == MatchType.HeadToHead); diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs index b6445dec6b..3d6fe50d34 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs @@ -648,6 +648,34 @@ namespace osu.Game.Tests.Visual.Online AddUntilStep("Info message displayed", () => channelManager.CurrentChannel.Value.Messages.Last(), () => Is.InstanceOf(typeof(InfoMessage))); } + [Test] + public void TestFiltering() + { + AddStep("Show overlay", () => chatOverlay.Show()); + joinTestChannel(1); + joinTestChannel(3); + joinTestChannel(5); + joinChannel(new Channel(new APIUser { Id = 2001, Username = "alice" })); + joinChannel(new Channel(new APIUser { Id = 2002, Username = "bob" })); + joinChannel(new Channel(new APIUser { Id = 2003, Username = "charley the plant" })); + + AddStep("filter to \"c\"", () => chatOverlay.ChildrenOfType().Single().Text = "c"); + AddUntilStep("bob filtered out", () => chatOverlay.ChildrenOfType().Count(i => i.Alpha > 0), () => Is.EqualTo(5)); + + AddStep("filter to \"channel\"", () => chatOverlay.ChildrenOfType().Single().Text = "channel"); + AddUntilStep("only public channels left", () => chatOverlay.ChildrenOfType().Count(i => i.Alpha > 0), () => Is.EqualTo(3)); + + AddStep("commit textbox", () => + { + chatOverlay.ChildrenOfType().Single().TakeFocus(); + Schedule(() => InputManager.PressKey(Key.Enter)); + }); + AddUntilStep("#channel-2 active", () => channelManager.CurrentChannel.Value.Name, () => Is.EqualTo("#channel-2")); + + AddStep("filter to \"channel-3\"", () => chatOverlay.ChildrenOfType().Single().Text = "channel-3"); + AddUntilStep("no channels left", () => chatOverlay.ChildrenOfType().Count(i => i.Alpha > 0), () => Is.EqualTo(0)); + } + private void joinTestChannel(int i) { AddStep($"Join test channel {i}", () => channelManager.JoinChannel(testChannels[i])); 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/TestSceneReplayMissingBeatmap.cs b/osu.Game.Tests/Visual/Online/TestSceneReplayMissingBeatmap.cs index 60197e0eb7..b986901dcf 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneReplayMissingBeatmap.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneReplayMissingBeatmap.cs @@ -40,15 +40,13 @@ namespace osu.Game.Tests.Visual.Online AddStep("import score", () => { - using (var resourceStream = TestResources.OpenResource("Replays/mania-replay.osr")) - { - var importTask = new ImportTask(resourceStream, "replay.osr"); + var resourceStream = TestResources.OpenResource("Replays/mania-replay.osr"); + var importTask = new ImportTask(resourceStream, "replay.osr"); - Game.ScoreManager.Import(new[] { importTask }); - } + Game.ScoreManager.Import(new[] { importTask }); }); - AddUntilStep("Replay missing notification show", () => Game.Notifications.ChildrenOfType().Any()); + AddUntilStep("Replay missing notification shown", () => Game.Notifications.ChildrenOfType().Any()); } [Test] @@ -58,15 +56,13 @@ namespace osu.Game.Tests.Visual.Online AddStep("import score", () => { - using (var resourceStream = TestResources.OpenResource("Replays/mania-replay.osr")) - { - var importTask = new ImportTask(resourceStream, "replay.osr"); + var resourceStream = TestResources.OpenResource("Replays/mania-replay.osr"); + var importTask = new ImportTask(resourceStream, "replay.osr"); - Game.ScoreManager.Import(new[] { importTask }); - } + Game.ScoreManager.Import(new[] { importTask }); }); - AddUntilStep("Replay missing notification not show", () => !Game.Notifications.ChildrenOfType().Any()); + AddUntilStep("Replay missing notification not shown", () => !Game.Notifications.ChildrenOfType().Any()); } private void setupBeatmapResponse(APIBeatmap b) diff --git a/osu.Game.Tests/Visual/Online/TestSceneScoresContainer.cs b/osu.Game.Tests/Visual/Online/TestSceneScoresContainer.cs index 33f4d577bd..ad0c5f9247 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneScoresContainer.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneScoresContainer.cs @@ -8,11 +8,13 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Testing; using osu.Framework.Utils; +using osu.Game.Graphics.Sprites; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; @@ -90,6 +92,48 @@ namespace osu.Game.Tests.Visual.Online AddAssert("no best score displayed", () => scoresContainer.ChildrenOfType().Count() == 1); } + [Test] + public void TestHitResultsWithSameNameAreGrouped() + { + AddStep("Load scores without user best", () => + { + var allScores = createScores(); + allScores.UserScore = null; + scoresContainer.Scores = allScores; + }); + + AddUntilStep("wait for scores displayed", () => scoresContainer.ChildrenOfType().Any()); + AddAssert("only one column for slider end", () => + { + ScoreTable scoreTable = scoresContainer.ChildrenOfType().First(); + return scoreTable.Columns.Count(c => c.Header.Equals("slider end")) == 1; + }); + + AddAssert("all rows show non-zero slider ends", () => + { + ScoreTable scoreTable = scoresContainer.ChildrenOfType().First(); + int sliderEndColumnIndex = Array.FindIndex(scoreTable.Columns, c => c != null && c.Header.Equals("slider end")); + bool sliderEndFilledInEachRow = true; + + for (int i = 0; i < scoreTable.Content?.GetLength(0); i++) + { + switch (scoreTable.Content[i, sliderEndColumnIndex]) + { + case OsuSpriteText text: + if (text.Text.Equals(0.0d.ToLocalisableString(@"N0"))) + sliderEndFilledInEachRow = false; + break; + + default: + sliderEndFilledInEachRow = false; + break; + } + } + + return sliderEndFilledInEachRow; + }); + } + [Test] public void TestUserBest() { @@ -103,6 +147,18 @@ namespace osu.Game.Tests.Visual.Online AddUntilStep("wait for scores displayed", () => scoresContainer.ChildrenOfType().Any()); AddAssert("best score displayed", () => scoresContainer.ChildrenOfType().Count() == 2); + AddStep("Load scores with personal best FC", () => + { + var allScores = createScores(); + allScores.UserScore = createUserBest(); + allScores.UserScore.Score.Accuracy = 1; + scoresContainer.Beatmap.Value.MaxCombo = allScores.UserScore.Score.MaxCombo = 1337; + scoresContainer.Scores = allScores; + }); + + AddUntilStep("wait for scores displayed", () => scoresContainer.ChildrenOfType().Any()); + AddAssert("best score displayed", () => scoresContainer.ChildrenOfType().Count() == 2); + AddStep("Load scores with personal best (null position)", () => { var allScores = createScores(); @@ -287,13 +343,17 @@ namespace osu.Game.Tests.Visual.Online const int initial_great_count = 2000; const int initial_tick_count = 100; + const int initial_slider_end_count = 500; int greatCount = initial_great_count; int tickCount = initial_tick_count; + int sliderEndCount = initial_slider_end_count; - foreach (var s in scores.Scores) + foreach (var (score, index) in scores.Scores.Select((s, i) => (s, i))) { - s.Statistics = new Dictionary + HitResult sliderEndResult = index % 2 == 0 ? HitResult.SliderTailHit : HitResult.SmallTickHit; + + score.Statistics = new Dictionary { { HitResult.Great, greatCount }, { HitResult.LargeTickHit, tickCount }, @@ -301,10 +361,19 @@ namespace osu.Game.Tests.Visual.Online { HitResult.Meh, RNG.Next(100) }, { HitResult.Miss, initial_great_count - greatCount }, { HitResult.LargeTickMiss, initial_tick_count - tickCount }, + { sliderEndResult, sliderEndCount }, + }; + + // Some hit results, including SliderTailHit and SmallTickHit, are only displayed + // when the maximum number is known + score.MaximumStatistics = new Dictionary + { + { sliderEndResult, initial_slider_end_count }, }; greatCount -= 100; tickCount -= RNG.Next(1, 5); + sliderEndCount -= 20; } return scores; 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/Online/TestSceneUserProfileOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs index 006610dccd..d16ed46bd2 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; @@ -58,6 +59,16 @@ namespace osu.Game.Tests.Visual.Online return true; } + if (req is GetUserBeatmapsRequest getUserBeatmapsRequest) + { + getUserBeatmapsRequest.TriggerSuccess(new List + { + CreateAPIBeatmapSet(), + CreateAPIBeatmapSet() + }); + return true; + } + return false; }; }); diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs index 0c536cb1d4..8c8dc8d69a 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.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.Bindables; @@ -21,7 +19,7 @@ namespace osu.Game.Tests.Visual.Playlists { protected new TestRoomManager RoomManager => (TestRoomManager)base.RoomManager; - private TestLoungeSubScreen loungeScreen; + private TestLoungeSubScreen loungeScreen = null!; public override void SetUpSteps() { @@ -97,7 +95,7 @@ namespace osu.Game.Tests.Visual.Playlists private partial class TestLoungeSubScreen : PlaylistsLoungeSubScreen { - public new Bindable SelectedRoom => base.SelectedRoom; + public new Bindable SelectedRoom => base.SelectedRoom; } } } diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsMatchSettingsOverlay.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsMatchSettingsOverlay.cs index 9f7b20ad43..5868331451 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsMatchSettingsOverlay.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsMatchSettingsOverlay.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 NUnit.Framework; using osu.Framework.Bindables; @@ -22,7 +20,7 @@ namespace osu.Game.Tests.Visual.Playlists { protected new TestRoomManager RoomManager => (TestRoomManager)base.RoomManager; - private TestRoomSettings settings; + private TestRoomSettings settings = null!; protected override OnlinePlayTestSceneDependencies CreateOnlinePlayDependencies() => new TestDependencies(); @@ -34,7 +32,7 @@ namespace osu.Game.Tests.Visual.Playlists { SelectedRoom.Value = new Room(); - Child = settings = new TestRoomSettings(SelectedRoom.Value) + Child = settings = new TestRoomSettings(SelectedRoom.Value!) { RelativeSizeAxes = Axes.Both, State = { Value = Visibility.Visible } @@ -47,19 +45,19 @@ namespace osu.Game.Tests.Visual.Playlists { AddStep("clear name and beatmap", () => { - SelectedRoom.Value.Name.Value = ""; - SelectedRoom.Value.Playlist.Clear(); + SelectedRoom.Value!.Name = ""; + SelectedRoom.Value!.Playlist = []; }); AddAssert("button disabled", () => !settings.ApplyButton.Enabled.Value); - AddStep("set name", () => SelectedRoom.Value.Name.Value = "Room name"); + AddStep("set name", () => SelectedRoom.Value!.Name = "Room name"); AddAssert("button disabled", () => !settings.ApplyButton.Enabled.Value); - AddStep("set beatmap", () => SelectedRoom.Value.Playlist.Add(new PlaylistItem(CreateBeatmap(Ruleset.Value).BeatmapInfo))); + AddStep("set beatmap", () => SelectedRoom.Value!.Playlist = [new PlaylistItem(CreateBeatmap(Ruleset.Value).BeatmapInfo)]); AddAssert("button enabled", () => settings.ApplyButton.Enabled.Value); - AddStep("clear name", () => SelectedRoom.Value.Name.Value = ""); + AddStep("clear name", () => SelectedRoom.Value!.Name = ""); AddAssert("button disabled", () => !settings.ApplyButton.Enabled.Value); } @@ -69,13 +67,13 @@ namespace osu.Game.Tests.Visual.Playlists const string expected_name = "expected name"; TimeSpan expectedDuration = TimeSpan.FromMinutes(15); - Room createdRoom = null; + Room createdRoom = null!; AddStep("setup", () => { settings.NameField.Current.Value = expected_name; settings.DurationField.Current.Value = expectedDuration; - SelectedRoom.Value.Playlist.Add(new PlaylistItem(CreateBeatmap(Ruleset.Value).BeatmapInfo)); + SelectedRoom.Value!.Playlist = [new PlaylistItem(CreateBeatmap(Ruleset.Value).BeatmapInfo)]; RoomManager.CreateRequested = r => { @@ -85,8 +83,8 @@ namespace osu.Game.Tests.Visual.Playlists }); AddStep("create room", () => settings.ApplyButton.Action.Invoke()); - AddAssert("has correct name", () => createdRoom.Name.Value == expected_name); - AddAssert("has correct duration", () => createdRoom.Duration.Value == expectedDuration); + AddAssert("has correct name", () => createdRoom.Name == expected_name); + AddAssert("has correct duration", () => createdRoom.Duration == expectedDuration); } [Test] @@ -94,14 +92,14 @@ namespace osu.Game.Tests.Visual.Playlists { const string not_found_prefix = "beatmaps not found:"; - string errorMessage = null; + string errorMessage = null!; AddStep("setup", () => { var beatmap = CreateBeatmap(Ruleset.Value).BeatmapInfo; - SelectedRoom.Value.Name.Value = "Test Room"; - SelectedRoom.Value.Playlist.Add(new PlaylistItem(beatmap)); + SelectedRoom.Value!.Name = "Test Room"; + SelectedRoom.Value!.Playlist = [new PlaylistItem(beatmap)]; errorMessage = $"{not_found_prefix} {beatmap.OnlineID}"; @@ -109,13 +107,13 @@ namespace osu.Game.Tests.Visual.Playlists }); AddAssert("error not displayed", () => !settings.ErrorText.IsPresent); - AddAssert("playlist item valid", () => SelectedRoom.Value.Playlist[0].Valid.Value); + AddAssert("playlist item valid", () => SelectedRoom.Value!.Playlist[0].Valid.Value); AddStep("create room", () => settings.ApplyButton.Action.Invoke()); AddAssert("error displayed", () => settings.ErrorText.IsPresent); AddAssert("error has custom text", () => settings.ErrorText.Text != errorMessage); - AddAssert("playlist item marked invalid", () => !SelectedRoom.Value.Playlist[0].Valid.Value); + AddAssert("playlist item marked invalid", () => !SelectedRoom.Value!.Playlist[0].Valid.Value); } [Test] @@ -127,8 +125,8 @@ namespace osu.Game.Tests.Visual.Playlists AddStep("setup", () => { - SelectedRoom.Value.Name.Value = "Test Room"; - SelectedRoom.Value.Playlist.Add(new PlaylistItem(CreateBeatmap(Ruleset.Value).BeatmapInfo)); + SelectedRoom.Value!.Name = "Test Room"; + SelectedRoom.Value!.Playlist = [new PlaylistItem(CreateBeatmap(Ruleset.Value).BeatmapInfo)]; RoomManager.CreateRequested = _ => failText; }); @@ -169,7 +167,7 @@ namespace osu.Game.Tests.Visual.Playlists protected class TestRoomManager : IRoomManager { - public Func CreateRequested; + public Func? CreateRequested; public event Action RoomsUpdated { @@ -187,7 +185,7 @@ namespace osu.Game.Tests.Visual.Playlists public void ClearRooms() => throw new NotImplementedException(); - public void CreateRoom(Room room, Action onSuccess = null, Action onError = null) + public void CreateRoom(Room room, Action? onSuccess = null, Action? onError = null) { if (CreateRequested == null) return; @@ -200,7 +198,7 @@ namespace osu.Game.Tests.Visual.Playlists onSuccess?.Invoke(room); } - public void JoinRoom(Room room, string password, Action onSuccess = null, Action onError = null) => throw new NotImplementedException(); + public void JoinRoom(Room room, string? password, Action? onSuccess = null, Action? onError = null) => throw new NotImplementedException(); public void PartRoom() => throw new NotImplementedException(); } diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsParticipantsList.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsParticipantsList.cs index 3b60c28dc0..c60b208ffc 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsParticipantsList.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsParticipantsList.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.Linq; using NUnit.Framework; using osu.Framework.Graphics; using osu.Game.Online.API.Requests.Responses; @@ -19,17 +20,16 @@ namespace osu.Game.Tests.Visual.Playlists AddStep("create list", () => { - SelectedRoom.Value = new Room { RoomID = { Value = 7 } }; - - for (int i = 0; i < 50; i++) + SelectedRoom.Value = new Room { - SelectedRoom.Value.RecentParticipants.Add(new APIUser + RoomID = 7, + RecentParticipants = Enumerable.Range(0, 50).Select(_ => new APIUser { Username = "peppy", Statistics = new UserStatistics { GlobalRank = 1234 }, Id = 2 - }); - } + }).ToArray() + }; }); } @@ -38,7 +38,7 @@ namespace osu.Game.Tests.Visual.Playlists { AddStep("create component", () => { - Child = new ParticipantsDisplay(Direction.Horizontal) + Child = new ParticipantsDisplay(SelectedRoom.Value!, Direction.Horizontal) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -52,7 +52,7 @@ namespace osu.Game.Tests.Visual.Playlists { AddStep("create component", () => { - Child = new ParticipantsDisplay(Direction.Vertical) + Child = new ParticipantsDisplay(SelectedRoom.Value!, Direction.Vertical) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs index 7527647b9c..5977e67b0e 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs @@ -1,13 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Linq; using System.Net; -using JetBrains.Annotations; using Newtonsoft.Json.Linq; using NUnit.Framework; using osu.Framework.Graphics.Containers; @@ -34,14 +31,14 @@ namespace osu.Game.Tests.Visual.Playlists private const int scores_per_result = 10; private const int real_user_position = 200; - private TestResultsScreen resultsScreen; + private TestResultsScreen resultsScreen = null!; private int lowestScoreId; // Score ID of the lowest score in the list. private int highestScoreId; // Score ID of the highest score in the list. private bool requestComplete; private int totalCount; - private ScoreInfo userScore; + private ScoreInfo userScore = null!; [SetUpSteps] public override void SetUpSteps() @@ -205,7 +202,7 @@ namespace osu.Game.Tests.Visual.Playlists AddAssert("placeholder shown", () => this.ChildrenOfType().Count(), () => Is.EqualTo(1)); } - private void createResults(Func getScore = null) + private void createResults(Func? getScore = null) { AddStep("load results", () => { @@ -229,7 +226,7 @@ namespace osu.Game.Tests.Visual.Playlists AddWaitStep("wait for display", 5); } - private void bindHandler(bool delayed = false, ScoreInfo userScore = null, bool failRequests = false, bool noScores = false) => ((DummyAPIAccess)API).HandleRequest = request => + private void bindHandler(bool delayed = false, ScoreInfo? userScore = null, bool failRequests = false, bool noScores = false) => ((DummyAPIAccess)API).HandleRequest = request => { // pre-check for requests we should be handling (as they are scheduled below). switch (request) @@ -286,7 +283,7 @@ namespace osu.Game.Tests.Visual.Playlists req.TriggerFailure(new WebException("Failed.")); } - private MultiplayerScore createUserResponse([NotNull] ScoreInfo userScore) + private MultiplayerScore createUserResponse(ScoreInfo userScore) { var multiplayerUserScore = new MultiplayerScore { @@ -420,7 +417,7 @@ namespace osu.Game.Tests.Visual.Playlists public new LoadingSpinner RightSpinner => base.RightSpinner; public new ScorePanelList ScorePanelList => base.ScorePanelList; - public TestResultsScreen([CanBeNull] ScoreInfo score, int roomId, PlaylistItem playlistItem) + public TestResultsScreen(ScoreInfo? score, int roomId, PlaylistItem playlistItem) : base(score, roomId, playlistItem) { AllowRetry = true; diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomCreation.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomCreation.cs index 1636a3d4b8..0270840597 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomCreation.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomCreation.cs @@ -1,12 +1,9 @@ // 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 NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -35,11 +32,9 @@ namespace osu.Game.Tests.Visual.Playlists { public partial class TestScenePlaylistsRoomCreation : OnlinePlayTestScene { - private BeatmapManager manager; - - private TestPlaylistsRoomSubScreen match; - - private BeatmapSetInfo importedBeatmap; + private BeatmapManager manager = null!; + private TestPlaylistsRoomSubScreen match = null!; + private BeatmapSetInfo importedBeatmap = null!; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) @@ -52,11 +47,11 @@ namespace osu.Game.Tests.Visual.Playlists [SetUpSteps] public void SetupSteps() { - AddStep("set room", () => SelectedRoom!.Value = new Room()); + AddStep("set room", () => SelectedRoom.Value = new Room()); importBeatmap(); - AddStep("load match", () => LoadScreen(match = new TestPlaylistsRoomSubScreen(SelectedRoom!.Value))); + AddStep("load match", () => LoadScreen(match = new TestPlaylistsRoomSubScreen(SelectedRoom.Value!))); AddUntilStep("wait for load", () => match.IsCurrentScreen()); } @@ -65,14 +60,17 @@ namespace osu.Game.Tests.Visual.Playlists { setupAndCreateRoom(room => { - room.Name.Value = "my awesome room"; - room.Host.Value = API.LocalUser.Value; - room.RecentParticipants.Add(room.Host.Value); - room.EndDate.Value = DateTimeOffset.Now.AddMinutes(5); - room.Playlist.Add(new PlaylistItem(importedBeatmap.Beatmaps.First()) - { - RulesetID = new OsuRuleset().RulesetInfo.OnlineID - }); + room.Name = "my awesome room"; + room.Host = API.LocalUser.Value; + room.RecentParticipants = [room.Host]; + room.EndDate = DateTimeOffset.Now.AddMinutes(5); + room.Playlist = + [ + new PlaylistItem(importedBeatmap.Beatmaps.First()) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID + } + ]; }); AddUntilStep("Progress details are hidden", () => match.ChildrenOfType().FirstOrDefault()?.Parent!.Alpha == 0); @@ -88,15 +86,18 @@ namespace osu.Game.Tests.Visual.Playlists { setupAndCreateRoom(room => { - room.Name.Value = "my awesome room"; - room.MaxAttempts.Value = 5; - room.Host.Value = API.LocalUser.Value; - room.RecentParticipants.Add(room.Host.Value); - room.EndDate.Value = DateTimeOffset.Now.AddMinutes(5); - room.Playlist.Add(new PlaylistItem(importedBeatmap.Beatmaps.First()) - { - RulesetID = new OsuRuleset().RulesetInfo.OnlineID - }); + room.Name = "my awesome room"; + room.MaxAttempts = 5; + room.Host = API.LocalUser.Value; + room.RecentParticipants = [room.Host]; + room.EndDate = DateTimeOffset.Now.AddMinutes(5); + room.Playlist = + [ + new PlaylistItem(importedBeatmap.Beatmaps.First()) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID + } + ]; }); AddUntilStep("Progress details are visible", () => match.ChildrenOfType().FirstOrDefault()?.Parent!.Alpha == 1); @@ -107,21 +108,24 @@ namespace osu.Game.Tests.Visual.Playlists { setupAndCreateRoom(room => { - room.Name.Value = "my awesome room"; - room.Host.Value = API.LocalUser.Value; - room.Playlist.Add(new PlaylistItem(importedBeatmap.Beatmaps.First()) - { - RulesetID = new OsuRuleset().RulesetInfo.OnlineID - }); + room.Name = "my awesome room"; + room.Host = API.LocalUser.Value; + room.Playlist = + [ + new PlaylistItem(importedBeatmap.Beatmaps.First()) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID + } + ]; }); - AddAssert("first playlist item selected", () => match.SelectedItem.Value == SelectedRoom!.Value.Playlist[0]); + AddAssert("first playlist item selected", () => match.SelectedItem.Value == SelectedRoom.Value!.Playlist[0]); } [Test] public void TestBeatmapUpdatedOnReImport() { - string realHash = null; + string realHash = null!; int realOnlineId = 0; int realOnlineSetId = 0; @@ -139,40 +143,40 @@ namespace osu.Game.Tests.Visual.Playlists BeatmapInfo = { OnlineID = realOnlineId, - Metadata = new BeatmapMetadata(), - BeatmapSet = - { - OnlineID = realOnlineSetId - } + Metadata = new BeatmapMetadata() }, }; + Debug.Assert(modifiedBeatmap.BeatmapInfo.BeatmapSet != null); + modifiedBeatmap.BeatmapInfo.BeatmapSet!.OnlineID = realOnlineSetId; + modifiedBeatmap.HitObjects.Clear(); modifiedBeatmap.HitObjects.Add(new HitCircle { StartTime = 5000 }); - Debug.Assert(modifiedBeatmap.BeatmapInfo.BeatmapSet != null); - manager.Import(modifiedBeatmap.BeatmapInfo.BeatmapSet); }); // Create the room using the real beatmap values. setupAndCreateRoom(room => { - room.Name.Value = "my awesome room"; - room.Host.Value = API.LocalUser.Value; - room.Playlist.Add(new PlaylistItem(new BeatmapInfo - { - MD5Hash = realHash, - OnlineID = realOnlineId, - Metadata = new BeatmapMetadata(), - BeatmapSet = new BeatmapSetInfo + room.Name = "my awesome room"; + room.Host = API.LocalUser.Value; + room.Playlist = + [ + new PlaylistItem(new BeatmapInfo { - OnlineID = realOnlineSetId, + MD5Hash = realHash, + OnlineID = realOnlineId, + Metadata = new BeatmapMetadata(), + BeatmapSet = new BeatmapSetInfo + { + OnlineID = realOnlineSetId, + } + }) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID } - }) - { - RulesetID = new OsuRuleset().RulesetInfo.OnlineID - }); + ]; }); AddAssert("match has default beatmap", () => match.Beatmap.IsDefault); @@ -181,17 +185,11 @@ namespace osu.Game.Tests.Visual.Playlists { var originalBeatmap = new TestBeatmap(new OsuRuleset().RulesetInfo) { - BeatmapInfo = - { - OnlineID = realOnlineId, - BeatmapSet = - { - OnlineID = realOnlineSetId - } - }, + BeatmapInfo = { OnlineID = realOnlineId }, }; Debug.Assert(originalBeatmap.BeatmapInfo.BeatmapSet != null); + originalBeatmap.BeatmapInfo.BeatmapSet.OnlineID = realOnlineSetId; manager.Import(originalBeatmap.BeatmapInfo.BeatmapSet); }); @@ -201,7 +199,7 @@ namespace osu.Game.Tests.Visual.Playlists private void setupAndCreateRoom(Action room) { - AddStep("setup room", () => room(SelectedRoom!.Value)); + AddStep("setup room", () => room(SelectedRoom.Value!)); AddStep("click create button", () => { @@ -215,19 +213,17 @@ namespace osu.Game.Tests.Visual.Playlists var beatmap = CreateBeatmap(new OsuRuleset().RulesetInfo); Debug.Assert(beatmap.BeatmapInfo.BeatmapSet != null); - - importedBeatmap = manager.Import(beatmap.BeatmapInfo.BeatmapSet)?.Value.Detach(); + importedBeatmap = manager.Import(beatmap.BeatmapInfo.BeatmapSet)!.Value.Detach(); }); private partial class TestPlaylistsRoomSubScreen : PlaylistsRoomSubScreen { - public new Bindable SelectedItem => base.SelectedItem; + public new Bindable SelectedItem => base.SelectedItem; public new Bindable Beatmap => base.Beatmap; [Resolved(canBeNull: true)] - [CanBeNull] - private IDialogOverlay dialogOverlay { get; set; } + private IDialogOverlay? dialogOverlay { get; set; } public TestPlaylistsRoomSubScreen(Room room) : base(room) diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomSubScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomSubScreen.cs new file mode 100644 index 0000000000..4306fc1e6a --- /dev/null +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomSubScreen.cs @@ -0,0 +1,41 @@ +// 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 NUnit.Framework; +using osu.Framework.Screens; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Rooms; +using osu.Game.Online.Rooms.RoomStatuses; +using osu.Game.Screens.OnlinePlay.Playlists; +using osu.Game.Tests.Visual.OnlinePlay; + +namespace osu.Game.Tests.Visual.Playlists +{ + public partial class TestScenePlaylistsRoomSubScreen : OnlinePlayTestScene + { + protected new TestRoomManager RoomManager => (TestRoomManager)base.RoomManager; + + [Test] + public void TestStatusUpdateOnEnter() + { + Room room = null!; + PlaylistsRoomSubScreen roomScreen = null!; + + AddStep("create room", () => + { + RoomManager.AddRoom(room = new Room + { + Name = @"Test Room", + Host = new APIUser { Username = @"Host" }, + Category = RoomCategory.Normal, + EndDate = DateTimeOffset.Now.AddMinutes(-1) + }); + }); + + AddStep("push screen", () => LoadScreen(roomScreen = new PlaylistsRoomSubScreen(room))); + AddUntilStep("wait for screen load", () => roomScreen.IsCurrentScreen()); + AddAssert("status is still ended", () => roomScreen.Room.Status, Is.TypeOf); + } + } +} diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs b/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs index 3e38b66029..760210c370 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs @@ -40,6 +40,13 @@ namespace osu.Game.Tests.Visual.Ranking AddSliderStep("height", 0.0f, 1000.0f, height.Value, height.Set); } + [Test] + public void TestZeroEvents() + { + createTest(new List()); + AddStep("update offset", () => graph.UpdateOffset(10)); + } + [Test] public void TestManyDistributedEventsOffset() { 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/TestSceneAdvancedStats.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneAdvancedStats.cs index 3b89c70a63..ca6c4998d1 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneAdvancedStats.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneAdvancedStats.cs @@ -84,7 +84,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("select EZ mod", () => { var ruleset = advancedStats.BeatmapInfo.Ruleset.CreateInstance().AsNonNull(); - SelectedMods.Value = new[] { ruleset.CreateMod() }; + advancedStats.Mods.Value = new[] { ruleset.CreateMod() }; }); AddAssert("circle size bar is blue", () => barIsBlue(advancedStats.FirstValue)); @@ -101,7 +101,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("select HR mod", () => { var ruleset = advancedStats.BeatmapInfo.Ruleset.CreateInstance().AsNonNull(); - SelectedMods.Value = new[] { ruleset.CreateMod() }; + advancedStats.Mods.Value = new[] { ruleset.CreateMod() }; }); AddAssert("circle size bar is red", () => barIsRed(advancedStats.FirstValue)); @@ -120,7 +120,7 @@ namespace osu.Game.Tests.Visual.SongSelect var ruleset = advancedStats.BeatmapInfo.Ruleset.CreateInstance().AsNonNull(); var difficultyAdjustMod = ruleset.CreateMod().AsNonNull(); difficultyAdjustMod.ReadFromDifficulty(advancedStats.BeatmapInfo.Difficulty); - SelectedMods.Value = new[] { difficultyAdjustMod }; + advancedStats.Mods.Value = new[] { difficultyAdjustMod }; }); AddAssert("circle size bar is white", () => barIsWhite(advancedStats.FirstValue)); @@ -143,7 +143,7 @@ namespace osu.Game.Tests.Visual.SongSelect difficultyAdjustMod.ReadFromDifficulty(originalDifficulty); difficultyAdjustMod.DrainRate.Value = originalDifficulty.DrainRate - 0.5f; difficultyAdjustMod.ApproachRate.Value = originalDifficulty.ApproachRate + 2.2f; - SelectedMods.Value = new[] { difficultyAdjustMod }; + advancedStats.Mods.Value = new[] { difficultyAdjustMod }; }); AddAssert("circle size bar is white", () => barIsWhite(advancedStats.FirstValue)); diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs index fd102da026..d8573b2d03 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs @@ -1,12 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Linq; -using JetBrains.Annotations; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -23,7 +20,6 @@ using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Legacy; -using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Taiko; @@ -35,15 +31,11 @@ namespace osu.Game.Tests.Visual.SongSelect [TestFixture] public partial class TestSceneBeatmapInfoWedge : OsuTestScene { - private RulesetStore rulesets; - private TestBeatmapInfoWedge infoWedge; - private readonly List beatmaps = new List(); + [Resolved] + private RulesetStore rulesets { get; set; } = null!; - [BackgroundDependencyLoader] - private void load(RulesetStore rulesets) - { - this.rulesets = rulesets; - } + private TestBeatmapInfoWedge infoWedge = null!; + private readonly List beatmaps = new List(); protected override void LoadComplete() { @@ -156,7 +148,7 @@ namespace osu.Game.Tests.Visual.SongSelect IBeatmap beatmap = createTestBeatmap(new OsuRuleset().RulesetInfo); beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 60 * 1000 / bpm }); - OsuModDoubleTime doubleTime = null; + OsuModDoubleTime doubleTime = null!; selectBeatmap(beatmap); checkDisplayedBPM($"{bpm}"); @@ -173,7 +165,7 @@ namespace osu.Game.Tests.Visual.SongSelect [TestCase(120, 120.4, null, "120")] [TestCase(120, 120.6, "DT", "180-182 (mostly 180)")] [TestCase(120, 120.4, "DT", "180")] - public void TestVaryingBPM(double commonBpm, double otherBpm, string mod, string expectedDisplay) + public void TestVaryingBPM(double commonBpm, double otherBpm, string? mod, string expectedDisplay) { IBeatmap beatmap = createTestBeatmap(new OsuRuleset().RulesetInfo); beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 60 * 1000 / commonBpm }); @@ -203,7 +195,7 @@ namespace osu.Game.Tests.Visual.SongSelect double drain = beatmap.CalculateDrainLength(); beatmap.BeatmapInfo.Length = drain; - OsuModDoubleTime doubleTime = null; + OsuModDoubleTime doubleTime = null!; selectBeatmap(beatmap); checkDisplayedLength(drain); @@ -221,14 +213,15 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep($"check map drain ({displayedLength})", () => { - var label = infoWedge.DisplayedContent.ChildrenOfType().Single(l => l.Statistic.Name == BeatmapsetsStrings.ShowStatsTotalLength(displayedLength)); + var label = infoWedge.DisplayedContent.ChildrenOfType() + .Single(l => l.Statistic.Name == BeatmapsetsStrings.ShowStatsTotalLength(displayedLength)); return label.Statistic.Content == displayedLength.ToString(); }); } private void setRuleset(RulesetInfo rulesetInfo) { - Container containerBefore = null; + Container? containerBefore = null; AddStep("set ruleset", () => { @@ -242,9 +235,9 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep("wait for async load", () => infoWedge.DisplayedContent != containerBefore); } - private void selectBeatmap([CanBeNull] IBeatmap b) + private void selectBeatmap(IBeatmap? b) { - Container containerBefore = null; + Container? containerBefore = null; AddStep($"select {b?.Metadata.Title ?? "null"} beatmap", () => { @@ -307,11 +300,6 @@ namespace osu.Game.Tests.Visual.SongSelect public new WedgeInfoText Info => base.Info; } - private class TestHitObject : ConvertHitObject, IHasPosition - { - public float X => 0; - public float Y => 0; - public Vector2 Position { get; } = Vector2.Zero; - } + private class TestHitObject : ConvertHitObject; } } 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.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs index 6b8fa94336..3a95aca6b9 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs @@ -175,6 +175,29 @@ namespace osu.Game.Tests.Visual.SongSelect increaseModSpeed(); AddAssert("adaptive speed still active", () => songSelect!.Mods.Value.First() is ModAdaptiveSpeed); + OsuModDoubleTime dtWithAdjustPitch = new OsuModDoubleTime + { + SpeedChange = { Value = 1.05 }, + AdjustPitch = { Value = true }, + }; + changeMods(dtWithAdjustPitch); + + decreaseModSpeed(); + AddAssert("no mods selected", () => songSelect!.Mods.Value.Count == 0); + + decreaseModSpeed(); + AddAssert("half time activated at 0.95x", () => songSelect!.Mods.Value.OfType().Single().SpeedChange.Value, () => Is.EqualTo(0.95).Within(0.005)); + AddAssert("half time has adjust pitch active", () => songSelect!.Mods.Value.OfType().Single().AdjustPitch.Value, () => Is.True); + + AddStep("turn off adjust pitch", () => songSelect!.Mods.Value.OfType().Single().AdjustPitch.Value = false); + + increaseModSpeed(); + AddAssert("no mods selected", () => songSelect!.Mods.Value.Count == 0); + + increaseModSpeed(); + AddAssert("double time activated at 1.05x", () => songSelect!.Mods.Value.OfType().Single().SpeedChange.Value, () => Is.EqualTo(1.05).Within(0.005)); + AddAssert("double time has adjust pitch inactive", () => songSelect!.Mods.Value.OfType().Single().AdjustPitch.Value, () => Is.False); + void increaseModSpeed() => AddStep("increase mod speed", () => { InputManager.PressKey(Key.ControlLeft); diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapInfoWedge.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapInfoWedge.cs index 35bd4ee958..fbbab3a604 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapInfoWedge.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapInfoWedge.cs @@ -14,9 +14,7 @@ using osu.Game.Beatmaps; using osu.Game.Rulesets; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Legacy; -using osu.Game.Rulesets.Objects.Types; using osu.Game.Screens.Select; -using osuTK; namespace osu.Game.Tests.Visual.SongSelectV2 { @@ -209,11 +207,6 @@ namespace osu.Game.Tests.Visual.SongSelectV2 public new WedgeInfoText? Info => base.Info; } - private class TestHitObject : ConvertHitObject, IHasPosition - { - public float X => 0; - public float Y => 0; - public Vector2 Position { get; } = Vector2.Zero; - } + private class TestHitObject : ConvertHitObject; } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapAttributeText.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapAttributeText.cs new file mode 100644 index 0000000000..5acd6cb084 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapAttributeText.cs @@ -0,0 +1,251 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Models; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.UI; +using osu.Game.Scoring; +using osu.Game.Skinning.Components; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public partial class TestSceneBeatmapAttributeText : OsuTestScene + { + private readonly BeatmapAttributeText text; + + public TestSceneBeatmapAttributeText() + { + Child = text = new BeatmapAttributeText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }; + } + + [SetUp] + public void Setup() => Schedule(() => + { + SelectedMods.SetDefault(); + Ruleset.Value = new OsuRuleset().RulesetInfo; + Beatmap.Value = CreateWorkingBeatmap(new TestBeatmap(new OsuRuleset().RulesetInfo) + { + BeatmapInfo = + { + BPM = 100, + DifficultyName = "_Difficulty", + Status = BeatmapOnlineStatus.Loved, + Metadata = + { + Title = "_Title", + TitleUnicode = "_Title", + Artist = "_Artist", + ArtistUnicode = "_Artist", + Author = new RealmUser { Username = "_Creator" }, + Source = "_Source", + }, + Difficulty = + { + CircleSize = 1, + DrainRate = 2, + OverallDifficulty = 3, + ApproachRate = 4, + } + } + }); + }); + + [TestCase(BeatmapAttribute.CircleSize, "Circle Size: 1")] + [TestCase(BeatmapAttribute.HPDrain, "HP Drain: 2")] + [TestCase(BeatmapAttribute.Accuracy, "Accuracy: 3")] + [TestCase(BeatmapAttribute.ApproachRate, "Approach Rate: 4")] + [TestCase(BeatmapAttribute.Title, "Title: _Title")] + [TestCase(BeatmapAttribute.Artist, "Artist: _Artist")] + [TestCase(BeatmapAttribute.Creator, "Creator: _Creator")] + [TestCase(BeatmapAttribute.DifficultyName, "Difficulty: _Difficulty")] + [TestCase(BeatmapAttribute.Source, "Source: _Source")] + [TestCase(BeatmapAttribute.RankedStatus, "Beatmap Status: Loved")] + public void TestAttributeDisplay(BeatmapAttribute attribute, string expectedText) + { + AddStep($"set attribute: {attribute}", () => text.Attribute.Value = attribute); + AddAssert("check correct text", getText, () => Is.EqualTo(expectedText)); + } + + [Test] + public void TestChangeBeatmap() + { + AddStep("set title attribute", () => text.Attribute.Value = BeatmapAttribute.Title); + AddAssert("check initial title", getText, () => Is.EqualTo("Title: _Title")); + + AddStep("change to beatmap with another title", () => Beatmap.Value = CreateWorkingBeatmap(new TestBeatmap(new OsuRuleset().RulesetInfo) + { + BeatmapInfo = + { + Metadata = + { + Title = "Another" + } + } + })); + + AddAssert("check new title", getText, () => Is.EqualTo("Title: Another")); + } + + [Test] + public void TestWithMods() + { + AddStep("set beatmap", () => Beatmap.Value = CreateWorkingBeatmap(new TestBeatmap(new OsuRuleset().RulesetInfo) + { + BeatmapInfo = + { + BPM = 100, + Length = 30000, + Difficulty = + { + ApproachRate = 10, + CircleSize = 9.5f + } + } + })); + + test(BeatmapAttribute.BPM, new OsuModDoubleTime(), "BPM: 100", "BPM: 150"); + test(BeatmapAttribute.Length, new OsuModDoubleTime(), "Length: 00:30", "Length: 00:20"); + test(BeatmapAttribute.ApproachRate, new OsuModDoubleTime(), "Approach Rate: 10", "Approach Rate: 11"); + test(BeatmapAttribute.CircleSize, new OsuModHardRock(), "Circle Size: 9.5", "Circle Size: 10"); + + void test(BeatmapAttribute attribute, Mod mod, string before, string after) + { + AddStep($"set attribute: {attribute}", () => text.Attribute.Value = attribute); + AddAssert("check text is correct", getText, () => Is.EqualTo(before)); + + AddStep("add DT mod", () => SelectedMods.Value = new[] { mod }); + AddAssert("check text is correct", getText, () => Is.EqualTo(after)); + AddStep("clear mods", () => SelectedMods.SetDefault()); + } + } + + [Test] + public void TestStarRating() + { + AddStep("set test ruleset", () => Ruleset.Value = new TestRuleset().RulesetInfo); + AddStep("set star rating attribute", () => text.Attribute.Value = BeatmapAttribute.StarRating); + AddAssert("check star rating is 0", getText, () => Is.EqualTo("Star Rating: 0.00")); + + // Adding mod + TestMod mod = null!; + AddStep("add mod with difficulty 1", () => SelectedMods.Value = new[] { mod = new TestMod { Difficulty = { Value = 1 } } }); + AddUntilStep("check star rating is 1", getText, () => Is.EqualTo("Star Rating: 1.00")); + + // Changing mod setting + AddStep("change mod difficulty to 2", () => mod.Difficulty.Value = 2); + AddUntilStep("check star rating is 2", getText, () => Is.EqualTo("Star Rating: 2.00")); + } + + [Test] + public void TestMaxPp() + { + AddStep("set test ruleset", () => Ruleset.Value = new TestRuleset().RulesetInfo); + AddStep("set max pp attribute", () => text.Attribute.Value = BeatmapAttribute.MaxPP); + AddAssert("check max pp is 0", getText, () => Is.EqualTo("Max PP: 0")); + + // Adding mod + TestMod mod = null!; + AddStep("add mod with pp 1", () => SelectedMods.Value = new[] { mod = new TestMod { Performance = { Value = 1 } } }); + AddUntilStep("check max pp is 1", getText, () => Is.EqualTo("Max PP: 1")); + + // Changing mod setting + AddStep("change mod pp to 2", () => mod.Performance.Value = 2); + AddUntilStep("check max pp is 2", getText, () => Is.EqualTo("Max PP: 2")); + } + + private string getText() => text.ChildrenOfType().Single().Text.ToString(); + + private class TestRuleset : Ruleset + { + public override IEnumerable GetModsFor(ModType type) => new[] + { + new TestMod() + }; + + public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) + => new OsuRuleset().CreateBeatmapConverter(beatmap); + + public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) + => new TestDifficultyCalculator(new TestRuleset().RulesetInfo, beatmap); + + public override PerformanceCalculator CreatePerformanceCalculator() + => new TestPerformanceCalculator(new TestRuleset()); + + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList? mods = null) + => null!; + + public override string Description => string.Empty; + public override string ShortName => string.Empty; + } + + private class TestDifficultyCalculator : DifficultyCalculator + { + public TestDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) + : base(ruleset, beatmap) + { + } + + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + => new DifficultyAttributes(mods, mods.OfType().SingleOrDefault()?.Difficulty.Value ?? 0); + + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) + => Array.Empty(); + + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) + => Array.Empty(); + } + + private class TestPerformanceCalculator : PerformanceCalculator + { + public TestPerformanceCalculator(Ruleset ruleset) + : base(ruleset) + { + } + + protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo score, DifficultyAttributes attributes) + => new PerformanceAttributes { Total = score.Mods.OfType().SingleOrDefault()?.Performance.Value ?? 0 }; + } + + private class TestMod : Mod + { + [SettingSource("difficulty")] + public BindableDouble Difficulty { get; } = new BindableDouble(0); + + [SettingSource("performance")] + public BindableDouble Performance { get; } = new BindableDouble(0); + + [JsonConstructor] + public TestMod() + { + } + + public override string Name => string.Empty; + public override LocalisableString Description => string.Empty; + public override double ScoreMultiplier => 1.0; + public override string Acronym => "Test"; + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneButtonSystem.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneButtonSystem.cs index 8f72be37df..d8baca6d23 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneButtonSystem.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneButtonSystem.cs @@ -110,7 +110,7 @@ namespace osu.Game.Tests.Visual.UserInterface break; case Key.Q: - buttons.OnExit = action; + buttons.OnExit = _ => action(); break; case Key.O: diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneCommentEditor.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneCommentEditor.cs index e1d40882be..721e231577 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneCommentEditor.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneCommentEditor.cs @@ -12,6 +12,7 @@ using osu.Framework.Localisation; using osu.Framework.Testing; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.Comments; using osuTK; @@ -28,9 +29,10 @@ namespace osu.Game.Tests.Visual.UserInterface private TestCancellableCommentEditor cancellableCommentEditor = null!; private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; - [SetUp] - public void SetUp() => Schedule(() => - Add(new FillFlowContainer + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create content", () => Child = new FillFlowContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -43,7 +45,8 @@ namespace osu.Game.Tests.Visual.UserInterface commentEditor = new TestCommentEditor(), cancellableCommentEditor = new TestCancellableCommentEditor() } - })); + }); + } [Test] public void TestCommitViaKeyboard() @@ -133,6 +136,34 @@ namespace osu.Game.Tests.Visual.UserInterface assertLoggedInState(); } + [Test] + public void TestCommentsDisabled() + { + AddStep("no reason for disable", () => commentEditor.CommentableMeta.Value = new CommentableMeta + { + CurrentUserAttributes = new CommentableMeta.CommentableCurrentUserAttributes(), + }); + AddAssert("textbox enabled", () => commentEditor.ChildrenOfType().Single().ReadOnly, () => Is.False); + + AddStep("specific reason for disable", () => commentEditor.CommentableMeta.Value = new CommentableMeta + { + CurrentUserAttributes = new CommentableMeta.CommentableCurrentUserAttributes + { + CanNewCommentReason = "This comment section is disabled. For reasons.", + } + }); + AddAssert("textbox disabled", () => commentEditor.ChildrenOfType().Single().ReadOnly, () => Is.True); + + AddStep("entire commentable meta missing", () => commentEditor.CommentableMeta.Value = null); + AddAssert("textbox enabled", () => commentEditor.ChildrenOfType().Single().ReadOnly, () => Is.False); + + AddStep("current user attributes missing", () => commentEditor.CommentableMeta.Value = new CommentableMeta + { + CurrentUserAttributes = null, + }); + AddAssert("textbox enabled", () => commentEditor.ChildrenOfType().Single().ReadOnly, () => Is.True); + } + [Test] public void TestCancelAction() { @@ -167,8 +198,7 @@ namespace osu.Game.Tests.Visual.UserInterface protected override LocalisableString GetButtonText(bool isLoggedIn) => isLoggedIn ? @"Commit" : "You're logged out!"; - protected override LocalisableString GetPlaceholderText(bool isLoggedIn) => - isLoggedIn ? @"This text box is empty" : "Still empty, but now you can't type in it."; + protected override LocalisableString GetPlaceholderText() => @"This text box is empty"; } private partial class TestCancellableCommentEditor : CancellableCommentEditor @@ -189,7 +219,7 @@ namespace osu.Game.Tests.Visual.UserInterface } protected override LocalisableString GetButtonText(bool isLoggedIn) => @"Save"; - protected override LocalisableString GetPlaceholderText(bool isLoggedIn) => @"Multiline textboxes soon"; + protected override LocalisableString GetPlaceholderText() => @"Multiline textboxes soon"; } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFormControls.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFormControls.cs index 2a0b0515a1..c6fd65b973 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneFormControls.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFormControls.cs @@ -72,7 +72,7 @@ namespace osu.Game.Tests.Visual.UserInterface }, new FormSliderBar { - Caption = "Instantaneous slider", + Caption = "Slider", Current = new BindableFloat { MinValue = 0, @@ -82,19 +82,6 @@ namespace osu.Game.Tests.Visual.UserInterface }, TabbableContentContainer = this, }, - new FormSliderBar - { - Caption = "Non-instantaneous slider", - Current = new BindableFloat - { - MinValue = 0, - MaxValue = 10, - Value = 5, - Precision = 0.1f, - }, - Instantaneous = false, - TabbableContentContainer = this, - }, new FormEnumDropdown { Caption = EditorSetupStrings.EnableCountdown, @@ -105,6 +92,17 @@ namespace osu.Game.Tests.Visual.UserInterface Caption = "Audio file", PlaceholderText = "Select an audio file", }, + new FormColourPalette + { + Caption = "Combo colours", + Colours = + { + Colour4.Red, + Colour4.Green, + Colour4.Blue, + Colour4.Yellow, + } + }, }, }, } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFormSliderBar.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFormSliderBar.cs new file mode 100644 index 0000000000..97835a993d --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFormSliderBar.cs @@ -0,0 +1,63 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public partial class TestSceneFormSliderBar : OsuTestScene + { + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + + [Test] + public void TestTransferValueOnCommit() + { + OsuSpriteText text; + FormSliderBar slider = null!; + + AddStep("create content", () => + { + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 0.5f, + Direction = FillDirection.Vertical, + Spacing = new Vector2(10), + Children = new Drawable[] + { + text = new OsuSpriteText(), + slider = new FormSliderBar + { + Caption = "Slider", + Current = new BindableFloat + { + MinValue = 0, + MaxValue = 10, + Precision = 0.1f, + Default = 5f, + } + }, + } + }; + slider.Current.BindValueChanged(_ => text.Text = $"Current value is: {slider.Current.Value}", true); + }); + AddToggleStep("toggle transfer value on commit", b => + { + if (slider.IsNotNull()) + slider.TransferValueOnCommit = b; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneHotkeyDisplay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneHotkeyDisplay.cs new file mode 100644 index 0000000000..1c2c94dbf1 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneHotkeyDisplay.cs @@ -0,0 +1,28 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input; +using osu.Framework.Input.Bindings; +using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public partial class TestSceneHotkeyDisplay : ThemeComparisonTestScene + { + protected override Drawable CreateContent() => new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Vertical, + Children = new[] + { + new HotkeyDisplay { Hotkey = new Hotkey(new KeyCombination(InputKey.MouseLeft)) }, + new HotkeyDisplay { Hotkey = new Hotkey(GlobalAction.EditorDecreaseDistanceSpacing) }, + new HotkeyDisplay { Hotkey = new Hotkey(PlatformAction.Save) }, + } + }; + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneMainMenuButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneMainMenuButton.cs index 41543669eb..c091c089cf 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneMainMenuButton.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneMainMenuButton.cs @@ -30,7 +30,7 @@ namespace osu.Game.Tests.Visual.UserInterface public void TestStandardButton() { AddStep("add button", () => Child = new MainMenuButton( - ButtonSystemStrings.Solo, @"button-default-select", OsuIcon.Player, new Color4(102, 68, 204, 255), _ => { }, 0, Key.P) + ButtonSystemStrings.Solo, @"button-default-select", OsuIcon.Player, new Color4(102, 68, 204, 255), (_, _) => { }, 0, Key.P) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -53,13 +53,13 @@ namespace osu.Game.Tests.Visual.UserInterface beatmap.OnlineID = 1001; getRoomRequest.TriggerSuccess(new Room { - RoomID = { Value = 1234 }, + RoomID = 1234, Playlist = - { + [ new PlaylistItem(beatmap) - }, - StartDate = { Value = DateTimeOffset.Now.AddMinutes(-5) }, - EndDate = { Value = DateTimeOffset.Now.AddSeconds(30) } + ], + StartDate = DateTimeOffset.Now.AddMinutes(-5), + EndDate = DateTimeOffset.Now.AddSeconds(30) }); return true; @@ -87,7 +87,7 @@ namespace osu.Game.Tests.Visual.UserInterface Origin = Anchor.Centre, AutoSizeAxes = Axes.Both, CachedDependencies = [(typeof(INotificationOverlay), notificationOverlay)], - Child = new DailyChallengeButton(@"button-default-select", new Color4(102, 68, 204, 255), _ => { }, 0, Key.D) + Child = new DailyChallengeButton(@"button-default-select", new Color4(102, 68, 204, 255), (_, _) => { }, 0, Key.D) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -131,13 +131,13 @@ namespace osu.Game.Tests.Visual.UserInterface beatmap.OnlineID = 1001; getRoomRequest.TriggerSuccess(new Room { - RoomID = { Value = 1234 }, + RoomID = 1234, Playlist = - { + [ new PlaylistItem(beatmap) - }, - StartDate = { Value = DateTimeOffset.Now.AddMinutes(-50) }, - EndDate = { Value = DateTimeOffset.Now.AddSeconds(30) } + ], + StartDate = DateTimeOffset.Now.AddMinutes(-50), + EndDate = DateTimeOffset.Now.AddSeconds(30) }); return true; @@ -161,7 +161,7 @@ namespace osu.Game.Tests.Visual.UserInterface Origin = Anchor.Centre, AutoSizeAxes = Axes.Both, CachedDependencies = [(typeof(INotificationOverlay), notificationOverlay)], - Child = new DailyChallengeButton(@"button-default-select", new Color4(102, 68, 204, 255), _ => { }, 0, Key.D) + Child = new DailyChallengeButton(@"button-default-select", new Color4(102, 68, 204, 255), (_, _) => { }, 0, Key.D) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Tests/osu.Game.Tests.csproj b/osu.Game.Tests/osu.Game.Tests.csproj index c0bbdfb4ed..28a1d4d021 100644 --- a/osu.Game.Tests/osu.Game.Tests.csproj +++ b/osu.Game.Tests/osu.Game.Tests.csproj @@ -1,8 +1,8 @@  + - diff --git a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj index 8f1d7114b1..04683cd83b 100644 --- a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj +++ b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj @@ -4,7 +4,7 @@ osu.Game.Tournament.Tests.TournamentTestRunner - + diff --git a/osu.Game.Tournament/Screens/Editors/Components/DeleteRoundDialog.cs b/osu.Game.Tournament/Screens/Editors/Components/DeleteRoundDialog.cs index 769412bf94..6fff5111bd 100644 --- a/osu.Game.Tournament/Screens/Editors/Components/DeleteRoundDialog.cs +++ b/osu.Game.Tournament/Screens/Editors/Components/DeleteRoundDialog.cs @@ -2,18 +2,16 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osu.Framework.Graphics.Sprites; using osu.Game.Overlays.Dialog; using osu.Game.Tournament.Models; namespace osu.Game.Tournament.Screens.Editors.Components { - public partial class DeleteRoundDialog : DangerousActionDialog + public partial class DeleteRoundDialog : DeletionDialog { public DeleteRoundDialog(TournamentRound round, Action action) { HeaderText = round.Name.Value.Length > 0 ? $@"Delete round ""{round.Name.Value}""?" : @"Delete unnamed round?"; - Icon = FontAwesome.Solid.Trash; DangerousAction = action; } } diff --git a/osu.Game.Tournament/Screens/Editors/Components/DeleteTeamDialog.cs b/osu.Game.Tournament/Screens/Editors/Components/DeleteTeamDialog.cs index 65fb47cf94..cf1dffba0c 100644 --- a/osu.Game.Tournament/Screens/Editors/Components/DeleteTeamDialog.cs +++ b/osu.Game.Tournament/Screens/Editors/Components/DeleteTeamDialog.cs @@ -2,20 +2,18 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osu.Framework.Graphics.Sprites; using osu.Game.Overlays.Dialog; using osu.Game.Tournament.Models; namespace osu.Game.Tournament.Screens.Editors.Components { - public partial class DeleteTeamDialog : DangerousActionDialog + public partial class DeleteTeamDialog : DeletionDialog { public DeleteTeamDialog(TournamentTeam team, Action action) { HeaderText = team.FullName.Value.Length > 0 ? $@"Delete team ""{team.FullName.Value}""?" : team.Acronym.Value.Length > 0 ? $@"Delete team ""{team.Acronym.Value}""?" : @"Delete unnamed team?"; - Icon = FontAwesome.Solid.Trash; DangerousAction = action; } } diff --git a/osu.Game/.idea/.idea.osu.dir/.idea/.name b/osu.Game/.idea/.idea.osu.dir/.idea/.name new file mode 100644 index 0000000000..21cb4db60e --- /dev/null +++ b/osu.Game/.idea/.idea.osu.dir/.idea/.name @@ -0,0 +1 @@ +osu \ No newline at end of file diff --git a/osu.Game/.idea/.idea.osu.dir/.idea/indexLayout.xml b/osu.Game/.idea/.idea.osu.dir/.idea/indexLayout.xml new file mode 100644 index 0000000000..7b08163ceb --- /dev/null +++ b/osu.Game/.idea/.idea.osu.dir/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/osu.Game/.idea/.idea.osu.dir/.idea/vcs.xml b/osu.Game/.idea/.idea.osu.dir/.idea/vcs.xml new file mode 100644 index 0000000000..6c0b863585 --- /dev/null +++ b/osu.Game/.idea/.idea.osu.dir/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/osu.Game/.idea/.idea.osu.dir/.idea/workspace.xml b/osu.Game/.idea/.idea.osu.dir/.idea/workspace.xml new file mode 100644 index 0000000000..4be7e05a9a --- /dev/null +++ b/osu.Game/.idea/.idea.osu.dir/.idea/workspace.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/osu.Game/Audio/HitSampleInfo.cs b/osu.Game/Audio/HitSampleInfo.cs index ce5e217532..19273e3714 100644 --- a/osu.Game/Audio/HitSampleInfo.cs +++ b/osu.Game/Audio/HitSampleInfo.cs @@ -60,12 +60,18 @@ namespace osu.Game.Audio /// public int Volume { get; } - public HitSampleInfo(string name, string bank = SampleControlPoint.DEFAULT_BANK, string? suffix = null, int volume = 100) + /// + /// Whether this sample should automatically assign the bank of the normal sample whenever it is set in the editor. + /// + public bool EditorAutoBank { get; } + + public HitSampleInfo(string name, string bank = SampleControlPoint.DEFAULT_BANK, string? suffix = null, int volume = 100, bool editorAutoBank = true) { Name = name; Bank = bank; Suffix = suffix; Volume = volume; + EditorAutoBank = editorAutoBank; } /// @@ -92,9 +98,10 @@ namespace osu.Game.Audio /// An optional new sample bank. /// An optional new lookup suffix. /// An optional new volume. + /// An optional new editor auto bank flag. /// The new . - public virtual HitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newSuffix = default, Optional newVolume = default) - => new HitSampleInfo(newName.GetOr(Name), newBank.GetOr(Bank), newSuffix.GetOr(Suffix), newVolume.GetOr(Volume)); + public virtual HitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newSuffix = default, Optional newVolume = default, Optional newEditorAutoBank = default) + => new HitSampleInfo(newName.GetOr(Name), newBank.GetOr(Bank), newSuffix.GetOr(Suffix), newVolume.GetOr(Volume), newEditorAutoBank.GetOr(EditorAutoBank)); public virtual bool Equals(HitSampleInfo? other) => other != null && Name == other.Name && Bank == other.Bank && Suffix == other.Suffix; diff --git a/osu.Game/Beatmaps/APIBeatmapMetadataSource.cs b/osu.Game/Beatmaps/APIBeatmapMetadataSource.cs index a2eebe6161..34eedfb474 100644 --- a/osu.Game/Beatmaps/APIBeatmapMetadataSource.cs +++ b/osu.Game/Beatmaps/APIBeatmapMetadataSource.cs @@ -33,7 +33,7 @@ namespace osu.Game.Beatmaps Debug.Assert(beatmapInfo.BeatmapSet != null); - var req = new GetBeatmapRequest(beatmapInfo); + var req = new GetBeatmapRequest(md5Hash: beatmapInfo.MD5Hash, filename: beatmapInfo.Path); try { diff --git a/osu.Game/Beatmaps/BeatmapDifficultyCache.cs b/osu.Game/Beatmaps/BeatmapDifficultyCache.cs index 871faf5906..fc4175415c 100644 --- a/osu.Game/Beatmaps/BeatmapDifficultyCache.cs +++ b/osu.Game/Beatmaps/BeatmapDifficultyCache.cs @@ -4,12 +4,15 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using osu.Framework.Allocation; +using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Extensions; +using osu.Framework.Graphics.Textures; using osu.Framework.Lists; using osu.Framework.Logging; using osu.Framework.Threading; @@ -18,7 +21,11 @@ using osu.Game.Database; using osu.Game.Rulesets; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; +using osu.Game.Scoring; +using osu.Game.Skinning; +using osu.Game.Storyboards; namespace osu.Game.Beatmaps { @@ -237,10 +244,37 @@ namespace osu.Game.Beatmaps var ruleset = rulesetInfo.CreateInstance(); Debug.Assert(ruleset != null); - var calculator = ruleset.CreateDifficultyCalculator(beatmapManager.GetWorkingBeatmap(key.BeatmapInfo)); - var attributes = calculator.Calculate(key.OrderedMods, cancellationToken); + PlayableCachedWorkingBeatmap workingBeatmap = new PlayableCachedWorkingBeatmap(beatmapManager.GetWorkingBeatmap(key.BeatmapInfo)); + IBeatmap playableBeatmap = workingBeatmap.GetPlayableBeatmap(ruleset.RulesetInfo, key.OrderedMods, cancellationToken); - return new StarDifficulty(attributes); + var difficulty = ruleset.CreateDifficultyCalculator(workingBeatmap).Calculate(key.OrderedMods, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + + var performanceCalculator = ruleset.CreatePerformanceCalculator(); + if (performanceCalculator == null) + return new StarDifficulty(difficulty, new PerformanceAttributes()); + + ScoreProcessor scoreProcessor = ruleset.CreateScoreProcessor(); + scoreProcessor.Mods.Value = key.OrderedMods; + scoreProcessor.ApplyBeatmap(playableBeatmap); + cancellationToken.ThrowIfCancellationRequested(); + + ScoreInfo perfectScore = new ScoreInfo(key.BeatmapInfo, ruleset.RulesetInfo) + { + Passed = true, + Accuracy = 1, + Mods = key.OrderedMods, + MaxCombo = scoreProcessor.MaximumCombo, + Combo = scoreProcessor.MaximumCombo, + TotalScore = scoreProcessor.MaximumTotalScore, + Statistics = scoreProcessor.MaximumStatistics, + MaximumStatistics = scoreProcessor.MaximumStatistics + }; + + var performance = performanceCalculator.Calculate(perfectScore, difficulty); + cancellationToken.ThrowIfCancellationRequested(); + + return new StarDifficulty(difficulty, performance); } catch (OperationCanceledException) { @@ -276,7 +310,6 @@ namespace osu.Game.Beatmaps { public readonly BeatmapInfo BeatmapInfo; public readonly RulesetInfo Ruleset; - public readonly Mod[] OrderedMods; public DifficultyCacheLookup(BeatmapInfo beatmapInfo, RulesetInfo? ruleset, IEnumerable? mods) @@ -317,5 +350,42 @@ namespace osu.Game.Beatmaps CancellationToken = cancellationToken; } } + + /// + /// A working beatmap that caches its playable representation. + /// This is intended as single-use for when it is guaranteed that the playable beatmap can be reused. + /// + private class PlayableCachedWorkingBeatmap : IWorkingBeatmap + { + private readonly IWorkingBeatmap working; + private IBeatmap? playable; + + public PlayableCachedWorkingBeatmap(IWorkingBeatmap working) + { + this.working = working; + } + + public IBeatmap GetPlayableBeatmap(IRulesetInfo ruleset, IReadOnlyList mods) + => playable ??= working.GetPlayableBeatmap(ruleset, mods); + + public IBeatmap GetPlayableBeatmap(IRulesetInfo ruleset, IReadOnlyList mods, CancellationToken cancellationToken) + => playable ??= working.GetPlayableBeatmap(ruleset, mods, cancellationToken); + + IBeatmapInfo IWorkingBeatmap.BeatmapInfo => working.BeatmapInfo; + bool IWorkingBeatmap.BeatmapLoaded => working.BeatmapLoaded; + bool IWorkingBeatmap.TrackLoaded => working.TrackLoaded; + IBeatmap IWorkingBeatmap.Beatmap => working.Beatmap; + Texture IWorkingBeatmap.GetBackground() => working.GetBackground(); + Texture IWorkingBeatmap.GetPanelBackground() => working.GetPanelBackground(); + Waveform IWorkingBeatmap.Waveform => working.Waveform; + Storyboard IWorkingBeatmap.Storyboard => working.Storyboard; + ISkin IWorkingBeatmap.Skin => working.Skin; + Track IWorkingBeatmap.Track => working.Track; + Track IWorkingBeatmap.LoadTrack() => working.LoadTrack(); + Stream IWorkingBeatmap.GetStream(string storagePath) => working.GetStream(storagePath); + void IWorkingBeatmap.BeginAsyncLoad() => working.BeginAsyncLoad(); + void IWorkingBeatmap.CancelAsyncLoad() => working.CancelAsyncLoad(); + void IWorkingBeatmap.PrepareTrackForPreview(bool looping, double offsetFromPreviewPoint) => working.PrepareTrackForPreview(looping, offsetFromPreviewPoint); + } } } diff --git a/osu.Game/Beatmaps/BeatmapInfo.cs b/osu.Game/Beatmaps/BeatmapInfo.cs index 425fd98d27..d94c09d40f 100644 --- a/osu.Game/Beatmaps/BeatmapInfo.cs +++ b/osu.Game/Beatmaps/BeatmapInfo.cs @@ -62,7 +62,7 @@ namespace osu.Game.Beatmaps } [UsedImplicitly] - private BeatmapInfo() + protected BeatmapInfo() { } @@ -150,7 +150,7 @@ namespace osu.Game.Beatmaps public bool EpilepsyWarning { get; set; } - public bool SamplesMatchPlaybackRate { get; set; } = true; + public bool SamplesMatchPlaybackRate { get; set; } /// /// The time at which this beatmap was last played by the local user. @@ -181,7 +181,7 @@ namespace osu.Game.Beatmaps public double? EditorTimestamp { get; set; } [Ignored] - public CountdownType Countdown { get; set; } = CountdownType.Normal; + public CountdownType Countdown { get; set; } = CountdownType.None; /// /// The number of beats to move the countdown backwards (compared to its default location). diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index cd818941ff..4191771116 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -285,7 +285,8 @@ namespace osu.Game.Beatmaps /// /// The query. /// The first result for the provided query, or null if no results were found. - public BeatmapInfo? QueryBeatmap(Expression> query) => Realm.Run(r => r.All().Filter($"{nameof(BeatmapInfo.BeatmapSet)}.{nameof(BeatmapSetInfo.DeletePending)} == false").FirstOrDefault(query)?.Detach()); + public BeatmapInfo? QueryBeatmap(Expression> query) => Realm.Run(r => + r.All().Filter($"{nameof(BeatmapInfo.BeatmapSet)}.{nameof(BeatmapSetInfo.DeletePending)} == false").FirstOrDefault(query)?.Detach()); /// /// A default representation of a WorkingBeatmap to use when no beatmap is available. @@ -313,6 +314,23 @@ namespace osu.Game.Beatmaps }); } + public void ResetAllOffsets() + { + const string reset_complete_message = "All offsets have been reset!"; + Realm.Write(r => + { + var items = r.All(); + + foreach (var beatmap in items) + { + if (beatmap.UserSettings.Offset != 0) + beatmap.UserSettings.Offset = 0; + } + + PostNotification?.Invoke(new ProgressCompletionNotification { Text = reset_complete_message }); + }); + } + public void Delete(Expression>? filter = null, bool silent = false) { Realm.Run(r => 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/BeatmapUpdaterMetadataLookup.cs b/osu.Game/Beatmaps/BeatmapUpdaterMetadataLookup.cs index 034ec31ee4..364a0f9b4b 100644 --- a/osu.Game/Beatmaps/BeatmapUpdaterMetadataLookup.cs +++ b/osu.Game/Beatmaps/BeatmapUpdaterMetadataLookup.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; -using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Online.API; @@ -44,10 +43,19 @@ namespace osu.Game.Beatmaps foreach (var beatmapInfo in beatmapSet.Beatmaps) { + // note that these lookups DO NOT ACTUALLY FULLY GUARANTEE that the beatmap is what it claims it is, + // i.e. the correctness of this lookup should be treated as APPROXIMATE AT WORST. + // this is because the beatmap filename is used as a fallback in some scenarios where the MD5 of the beatmap may mismatch. + // this is considered to be an acceptable casualty so that things can continue to work as expected for users in some rare scenarios + // (stale beatmap files in beatmap packs, beatmap mirror desyncs). + // however, all this means that other places such as score submission ARE EXPECTED TO VERIFY THE MD5 OF THE BEATMAP AGAINST THE ONLINE ONE EXPLICITLY AGAIN. + // + // additionally note that the online ID stored to the map is EXPLICITLY NOT USED because some users in a silly attempt to "fix" things for themselves on stable + // would reuse online IDs of already submitted beatmaps, which means that information is pretty much expected to be bogus in a nonzero number of beatmapsets. if (!tryLookup(beatmapInfo, preferOnlineFetch, out var res)) continue; - if (res == null || shouldDiscardLookupResult(res, beatmapInfo)) + if (res == null) { beatmapInfo.ResetOnlineInfo(); lookupResults.Add(null); // mark lookup failure @@ -83,23 +91,6 @@ namespace osu.Game.Beatmaps } } - private bool shouldDiscardLookupResult(OnlineBeatmapMetadata result, BeatmapInfo beatmapInfo) - { - if (beatmapInfo.OnlineID > 0 && result.BeatmapID != beatmapInfo.OnlineID) - { - Logger.Log($"Discarding metadata lookup result due to mismatching online ID (expected: {beatmapInfo.OnlineID} actual: {result.BeatmapID})", LoggingTarget.Database); - return true; - } - - if (beatmapInfo.OnlineID == -1 && result.MD5Hash != beatmapInfo.MD5Hash) - { - Logger.Log($"Discarding metadata lookup result due to mismatching hash (expected: {beatmapInfo.MD5Hash} actual: {result.MD5Hash})", LoggingTarget.Database); - return true; - } - - return false; - } - /// /// Attempts to retrieve the for the given . /// diff --git a/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs b/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs index f18355505a..599d1b380a 100644 --- a/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs +++ b/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs @@ -86,7 +86,7 @@ namespace osu.Game.Beatmaps.Drawables }; Status = BeatmapOnlineStatus.None; - TextPadding = new MarginPadding { Horizontal = 5, Bottom = 1 }; + TextPadding = new MarginPadding { Horizontal = 4, Bottom = 1 }; } protected override void LoadComplete() diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCard.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCard.cs index 25e42bcbf7..56103c1d6d 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCard.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCard.cs @@ -20,9 +20,9 @@ namespace osu.Game.Beatmaps.Drawables.Cards public abstract partial class BeatmapCard : OsuClickableContainer, IHasContextMenu { public const float TRANSITION_DURATION = 340; - public const float CORNER_RADIUS = 10; + public const float CORNER_RADIUS = 8; - protected const float WIDTH = 430; + protected const float WIDTH = 345; public IBindable Expanded { get; } diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtra.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtra.cs index 2c2761ff0c..ebd0113379 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtra.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtra.cs @@ -22,7 +22,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards protected override Drawable IdleContent => idleBottomContent; protected override Drawable DownloadInProgressContent => downloadProgressBar; - private const float height = 140; + private const float height = 112; [Cached] private readonly BeatmapCardContent content; @@ -68,7 +68,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards Padding = new MarginPadding { Right = CORNER_RADIUS }, Child = leftIconArea = new FillFlowContainer { - Margin = new MarginPadding(5), + Margin = new MarginPadding(4), AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, Spacing = new Vector2(1) @@ -80,7 +80,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards Width = WIDTH - height + CORNER_RADIUS, FavouriteState = { BindTarget = FavouriteState }, ButtonsCollapsedWidth = CORNER_RADIUS, - ButtonsExpandedWidth = 30, + ButtonsExpandedWidth = 24, Children = new Drawable[] { new FillFlowContainer @@ -109,7 +109,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards new TruncatingSpriteText { Text = new RomanisableString(BeatmapSet.TitleUnicode, BeatmapSet.Title), - Font = OsuFont.Default.With(size: 22.5f, weight: FontWeight.SemiBold), + Font = OsuFont.Default.With(size: 18f, weight: FontWeight.SemiBold), RelativeSizeAxes = Axes.X, }, titleBadgeArea = new FillFlowContainer @@ -142,7 +142,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards new TruncatingSpriteText { Text = createArtistText(), - Font = OsuFont.Default.With(size: 17.5f, weight: FontWeight.SemiBold), + Font = OsuFont.Default.With(size: 14f, weight: FontWeight.SemiBold), RelativeSizeAxes = Axes.X, }, Empty() @@ -154,7 +154,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards RelativeSizeAxes = Axes.X, Text = BeatmapSet.Source, Shadow = false, - Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold), + Font = OsuFont.GetFont(size: 11f, weight: FontWeight.SemiBold), Colour = colourProvider.Content2 }, } @@ -173,18 +173,18 @@ namespace osu.Game.Beatmaps.Drawables.Cards RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 3), + Spacing = new Vector2(0, 2), AlwaysPresent = true, Children = new Drawable[] { new LinkFlowContainer(s => { s.Shadow = false; - s.Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold); + s.Font = OsuFont.GetFont(size: 11f, weight: FontWeight.SemiBold); }).With(d => { d.AutoSizeAxes = Axes.Both; - d.Margin = new MarginPadding { Top = 2 }; + d.Margin = new MarginPadding { Top = 1 }; d.AddText("mapped by ", t => t.Colour = colourProvider.Content2); d.AddUserLink(BeatmapSet.Author); }), @@ -215,7 +215,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards downloadProgressBar = new BeatmapCardDownloadProgressBar { RelativeSizeAxes = Axes.X, - Height = 6, + Height = 5, Anchor = Anchor.Centre, Origin = Anchor.Centre, State = { BindTarget = DownloadTracker.State }, @@ -231,17 +231,17 @@ namespace osu.Game.Beatmaps.Drawables.Cards { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Horizontal = 10, Vertical = 13 }, + Padding = new MarginPadding { Horizontal = 8, Vertical = 10 }, Child = new BeatmapCardDifficultyList(BeatmapSet) }; c.Expanded.BindTarget = Expanded; }); if (BeatmapSet.HasVideo) - leftIconArea.Add(new VideoIconPill { IconSize = new Vector2(20) }); + leftIconArea.Add(new VideoIconPill { IconSize = new Vector2(16) }); if (BeatmapSet.HasStoryboard) - leftIconArea.Add(new StoryboardIconPill { IconSize = new Vector2(20) }); + leftIconArea.Add(new StoryboardIconPill { IconSize = new Vector2(16) }); if (BeatmapSet.FeaturedInSpotlight) { @@ -249,7 +249,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards { Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, - Margin = new MarginPadding { Left = 5 } + Margin = new MarginPadding { Left = 4 } }); } @@ -259,7 +259,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards { Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, - Margin = new MarginPadding { Left = 5 } + Margin = new MarginPadding { Left = 4 } }); } @@ -269,7 +269,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards { Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, - Margin = new MarginPadding { Left = 5 } + Margin = new MarginPadding { Left = 4 } }; } @@ -288,7 +288,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards { BeatmapCardStatistic withMargin(BeatmapCardStatistic original) { - original.Margin = new MarginPadding { Right = 10 }; + original.Margin = new MarginPadding { Right = 8 }; return original; } diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtraInfoRow.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtraInfoRow.cs index 3a1b8f7e86..a11ef0f95c 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtraInfoRow.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtraInfoRow.cs @@ -25,7 +25,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Horizontal, - Spacing = new Vector2(4, 0), + Spacing = new Vector2(3, 0), Children = new Drawable[] { new BeatmapSetOnlineStatusPill @@ -33,13 +33,14 @@ namespace osu.Game.Beatmaps.Drawables.Cards AutoSizeAxes = Axes.Both, Status = beatmapSet.Status, Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft + Origin = Anchor.CentreLeft, + TextSize = 13f }, new DifficultySpectrumDisplay(beatmapSet) { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - DotSize = new Vector2(6, 12) + DotSize = new Vector2(5, 10) } } }; diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNormal.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNormal.cs index 46ab7ec5f6..724919f3bd 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNormal.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNormal.cs @@ -23,7 +23,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards protected override Drawable IdleContent => idleBottomContent; protected override Drawable DownloadInProgressContent => downloadProgressBar; - public const float HEIGHT = 100; + public const float HEIGHT = 80; [Cached] private readonly BeatmapCardContent content; @@ -69,7 +69,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards Padding = new MarginPadding { Right = CORNER_RADIUS }, Child = leftIconArea = new FillFlowContainer { - Margin = new MarginPadding(5), + Margin = new MarginPadding(4), AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, Spacing = new Vector2(1) @@ -81,7 +81,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards Width = WIDTH - HEIGHT + CORNER_RADIUS, FavouriteState = { BindTarget = FavouriteState }, ButtonsCollapsedWidth = CORNER_RADIUS, - ButtonsExpandedWidth = 30, + ButtonsExpandedWidth = 24, Children = new Drawable[] { new FillFlowContainer @@ -110,7 +110,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards new TruncatingSpriteText { Text = new RomanisableString(BeatmapSet.TitleUnicode, BeatmapSet.Title), - Font = OsuFont.Default.With(size: 22.5f, weight: FontWeight.SemiBold), + Font = OsuFont.Default.With(size: 18f, weight: FontWeight.SemiBold), RelativeSizeAxes = Axes.X, }, titleBadgeArea = new FillFlowContainer @@ -143,7 +143,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards new TruncatingSpriteText { Text = createArtistText(), - Font = OsuFont.Default.With(size: 17.5f, weight: FontWeight.SemiBold), + Font = OsuFont.Default.With(size: 14f, weight: FontWeight.SemiBold), RelativeSizeAxes = Axes.X, }, Empty() @@ -153,11 +153,11 @@ namespace osu.Game.Beatmaps.Drawables.Cards new LinkFlowContainer(s => { s.Shadow = false; - s.Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold); + s.Font = OsuFont.GetFont(size: 11f, weight: FontWeight.SemiBold); }).With(d => { d.AutoSizeAxes = Axes.Both; - d.Margin = new MarginPadding { Top = 2 }; + d.Margin = new MarginPadding { Top = 1 }; d.AddText("mapped by ", t => t.Colour = colourProvider.Content2); d.AddUserLink(BeatmapSet.Author); }), @@ -177,7 +177,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 3), + Spacing = new Vector2(0, 2), AlwaysPresent = true, Children = new Drawable[] { @@ -186,7 +186,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Horizontal, - Spacing = new Vector2(10, 0), + Spacing = new Vector2(8, 0), Alpha = 0, AlwaysPresent = true, ChildrenEnumerable = createStatistics() @@ -197,7 +197,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards downloadProgressBar = new BeatmapCardDownloadProgressBar { RelativeSizeAxes = Axes.X, - Height = 6, + Height = 5, Anchor = Anchor.Centre, Origin = Anchor.Centre, State = { BindTarget = DownloadTracker.State }, @@ -213,17 +213,17 @@ namespace osu.Game.Beatmaps.Drawables.Cards { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Horizontal = 10, Vertical = 13 }, + Padding = new MarginPadding { Horizontal = 8, Vertical = 10 }, Child = new BeatmapCardDifficultyList(BeatmapSet) }; c.Expanded.BindTarget = Expanded; }); if (BeatmapSet.HasVideo) - leftIconArea.Add(new VideoIconPill { IconSize = new Vector2(20) }); + leftIconArea.Add(new VideoIconPill { IconSize = new Vector2(16) }); if (BeatmapSet.HasStoryboard) - leftIconArea.Add(new StoryboardIconPill { IconSize = new Vector2(20) }); + leftIconArea.Add(new StoryboardIconPill { IconSize = new Vector2(16) }); if (BeatmapSet.FeaturedInSpotlight) { @@ -231,7 +231,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards { Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, - Margin = new MarginPadding { Left = 5 } + Margin = new MarginPadding { Left = 4 } }); } @@ -241,7 +241,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards { Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, - Margin = new MarginPadding { Left = 5 } + Margin = new MarginPadding { Left = 4 } }); } @@ -251,7 +251,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards { Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, - Margin = new MarginPadding { Left = 5 } + Margin = new MarginPadding { Left = 4 } }; } } diff --git a/osu.Game/Beatmaps/Drawables/Cards/Statistics/BeatmapCardStatistic.cs b/osu.Game/Beatmaps/Drawables/Cards/Statistics/BeatmapCardStatistic.cs index 6fd7142c05..ece52d0fa9 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/Statistics/BeatmapCardStatistic.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/Statistics/BeatmapCardStatistic.cs @@ -46,21 +46,21 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Statistics { AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, - Spacing = new Vector2(5, 0), + Spacing = new Vector2(4, 0), Children = new Drawable[] { spriteIcon = new SpriteIcon { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Size = new Vector2(10), + Size = new Vector2(8), Margin = new MarginPadding { Top = 1 } }, spriteText = new OsuSpriteText { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Font = OsuFont.Default.With(size: 14) + Font = OsuFont.Default.With(size: 11) } } }; diff --git a/osu.Game/Beatmaps/Drawables/DifficultyIconTooltip.cs b/osu.Game/Beatmaps/Drawables/DifficultyIconTooltip.cs index 36ddb6030e..8182fe24b2 100644 --- a/osu.Game/Beatmaps/Drawables/DifficultyIconTooltip.cs +++ b/osu.Game/Beatmaps/Drawables/DifficultyIconTooltip.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; +using osu.Game.Extensions; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Rulesets; @@ -146,7 +147,8 @@ namespace osu.Game.Beatmaps.Drawables approachRate.Text = @" AR: " + adjustedDifficulty.ApproachRate.ToString(@"0.##"); overallDifficulty.Text = @" OD: " + adjustedDifficulty.OverallDifficulty.ToString(@"0.##"); - length.Text = "Length: " + TimeSpan.FromMilliseconds(displayedContent.BeatmapInfo.Length / rate).ToString(@"mm\:ss"); + TimeSpan lengthTimeSpan = TimeSpan.FromMilliseconds(displayedContent.BeatmapInfo.Length / rate); + length.Text = "Length: " + lengthTimeSpan.ToFormattedDuration(); bpm.Text = " BPM: " + Math.Round(bpmAdjusted, 0); } diff --git a/osu.Game/Beatmaps/Drawables/UpdateableBeatmapBackgroundSprite.cs b/osu.Game/Beatmaps/Drawables/UpdateableBeatmapBackgroundSprite.cs index 6f71fa90b8..be6ca43f4b 100644 --- a/osu.Game/Beatmaps/Drawables/UpdateableBeatmapBackgroundSprite.cs +++ b/osu.Game/Beatmaps/Drawables/UpdateableBeatmapBackgroundSprite.cs @@ -14,9 +14,17 @@ namespace osu.Game.Beatmaps.Drawables /// public partial class UpdateableBeatmapBackgroundSprite : ModelBackedDrawable { - public readonly Bindable Beatmap = new Bindable(); + public readonly Bindable Beatmap = new Bindable(); - protected override double LoadDelay => 500; + /// + /// Delay before the background is loaded while on-screen. + /// + public double BackgroundLoadDelay { get; set; } = 500; + + /// + /// Delay before the background is unloaded while off-screen. + /// + public double BackgroundUnloadDelay { get; set; } = 10000; [Resolved] private BeatmapManager beatmaps { get; set; } = null!; @@ -29,10 +37,9 @@ namespace osu.Game.Beatmaps.Drawables this.beatmapSetCoverType = beatmapSetCoverType; } - /// - /// Delay before the background is unloaded while off-screen. - /// - protected virtual double UnloadDelay => 10000; + protected override double LoadDelay => BackgroundLoadDelay; + + protected virtual double UnloadDelay => BackgroundUnloadDelay; protected override DelayedLoadWrapper CreateDelayedLoadWrapper(Func createContentFunc, double timeBeforeLoad) => new DelayedLoadUnloadWrapper(createContentFunc, timeBeforeLoad, UnloadDelay) { RelativeSizeAxes = Axes.Both }; diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs index b068c87fbb..4d7ac355e0 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs @@ -38,8 +38,7 @@ namespace osu.Game.Beatmaps.Formats internal static RulesetStore? RulesetStore; private Beatmap beatmap = null!; - - private ConvertHitObjectParser? parser; + private ConvertHitObjectParser parser = null!; private LegacySampleBank defaultSampleBank; private int defaultSampleVolume = 100; @@ -80,6 +79,7 @@ namespace osu.Game.Beatmaps.Formats { this.beatmap = beatmap; this.beatmap.BeatmapInfo.BeatmapVersion = FormatVersion; + parser = new ConvertHitObjectParser(getOffsetTime(), FormatVersion); applyLegacyDefaults(this.beatmap.BeatmapInfo); @@ -162,7 +162,8 @@ namespace osu.Game.Beatmaps.Formats { if (hitObject is IHasRepeats hasRepeats) { - SampleControlPoint sampleControlPoint = (beatmap.ControlPointInfo as LegacyControlPointInfo)?.SamplePointAt(hitObject.StartTime + CONTROL_POINT_LENIENCY + 1) ?? SampleControlPoint.DEFAULT; + SampleControlPoint sampleControlPoint = (beatmap.ControlPointInfo as LegacyControlPointInfo)?.SamplePointAt(hitObject.StartTime + CONTROL_POINT_LENIENCY + 1) + ?? SampleControlPoint.DEFAULT; hitObject.Samples = hitObject.Samples.Select(o => sampleControlPoint.ApplyTo(o)).ToList(); for (int i = 0; i < hasRepeats.NodeSamples.Count; i++) @@ -175,7 +176,8 @@ namespace osu.Game.Beatmaps.Formats } else { - SampleControlPoint sampleControlPoint = (beatmap.ControlPointInfo as LegacyControlPointInfo)?.SamplePointAt(hitObject.GetEndTime() + CONTROL_POINT_LENIENCY) ?? SampleControlPoint.DEFAULT; + SampleControlPoint sampleControlPoint = (beatmap.ControlPointInfo as LegacyControlPointInfo)?.SamplePointAt(hitObject.GetEndTime() + CONTROL_POINT_LENIENCY) + ?? SampleControlPoint.DEFAULT; hitObject.Samples = hitObject.Samples.Select(o => sampleControlPoint.ApplyTo(o)).ToList(); } } @@ -190,7 +192,6 @@ namespace osu.Game.Beatmaps.Formats private static void applyLegacyDefaults(BeatmapInfo beatmapInfo) { beatmapInfo.WidescreenStoryboard = false; - beatmapInfo.SamplesMatchPlaybackRate = false; } protected override void ParseLine(Beatmap beatmap, Section section, string line) @@ -263,29 +264,7 @@ namespace osu.Game.Beatmaps.Formats break; case @"Mode": - int rulesetID = Parsing.ParseInt(pair.Value); - - beatmap.BeatmapInfo.Ruleset = RulesetStore?.GetRuleset(rulesetID) ?? throw new ArgumentException("Ruleset is not available locally."); - - switch (rulesetID) - { - case 0: - parser = new Rulesets.Objects.Legacy.Osu.ConvertHitObjectParser(getOffsetTime(), FormatVersion); - break; - - case 1: - parser = new Rulesets.Objects.Legacy.Taiko.ConvertHitObjectParser(getOffsetTime(), FormatVersion); - break; - - case 2: - parser = new Rulesets.Objects.Legacy.Catch.ConvertHitObjectParser(getOffsetTime(), FormatVersion); - break; - - case 3: - parser = new Rulesets.Objects.Legacy.Mania.ConvertHitObjectParser(getOffsetTime(), FormatVersion); - break; - } - + beatmap.BeatmapInfo.Ruleset = RulesetStore?.GetRuleset(Parsing.ParseInt(pair.Value)) ?? throw new ArgumentException("Ruleset is not available locally."); break; case @"LetterboxInBreaks": @@ -617,17 +596,10 @@ namespace osu.Game.Beatmaps.Formats private void handleHitObject(string line) { - // If the ruleset wasn't specified, assume the osu!standard ruleset. - parser ??= new Rulesets.Objects.Legacy.Osu.ConvertHitObjectParser(getOffsetTime(), FormatVersion); - var obj = parser.Parse(line); + obj.ApplyDefaults(beatmap.ControlPointInfo, beatmap.Difficulty); - if (obj != null) - { - obj.ApplyDefaults(beatmap.ControlPointInfo, beatmap.Difficulty); - - beatmap.HitObjects.Add(obj); - } + beatmap.HitObjects.Add(obj); } private int getOffsetTime(int time) => time + (ApplyOffsets ? offset : 0); diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs index b0173b3ae3..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(); @@ -539,7 +549,7 @@ namespace osu.Game.Beatmaps.Formats private string getSampleBank(IList samples, bool banksOnly = false) { LegacySampleBank normalBank = toLegacySampleBank(samples.SingleOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL)?.Bank); - LegacySampleBank addBank = toLegacySampleBank(samples.FirstOrDefault(s => !string.IsNullOrEmpty(s.Name) && s.Name != HitSampleInfo.HIT_NORMAL)?.Bank); + LegacySampleBank addBank = toLegacySampleBank(samples.FirstOrDefault(s => !string.IsNullOrEmpty(s.Name) && s.Name != HitSampleInfo.HIT_NORMAL && !s.EditorAutoBank)?.Bank); StringBuilder sb = new StringBuilder(); 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/Beatmaps/Legacy/LegacyHitObjectType.cs b/osu.Game/Beatmaps/Legacy/LegacyHitObjectType.cs index 07f170f996..6fab66bf70 100644 --- a/osu.Game/Beatmaps/Legacy/LegacyHitObjectType.cs +++ b/osu.Game/Beatmaps/Legacy/LegacyHitObjectType.cs @@ -6,7 +6,7 @@ using System; namespace osu.Game.Beatmaps.Legacy { [Flags] - internal enum LegacyHitObjectType + public enum LegacyHitObjectType { Circle = 1, Slider = 1 << 1, diff --git a/osu.Game/Beatmaps/LocalCachedBeatmapMetadataSource.cs b/osu.Game/Beatmaps/LocalCachedBeatmapMetadataSource.cs index eaa4d8ebfb..66fad6c8d8 100644 --- a/osu.Game/Beatmaps/LocalCachedBeatmapMetadataSource.cs +++ b/osu.Game/Beatmaps/LocalCachedBeatmapMetadataSource.cs @@ -90,8 +90,7 @@ namespace osu.Game.Beatmaps } if (string.IsNullOrEmpty(beatmapInfo.MD5Hash) - && string.IsNullOrEmpty(beatmapInfo.Path) - && beatmapInfo.OnlineID <= 0) + && string.IsNullOrEmpty(beatmapInfo.Path)) { onlineMetadata = null; return false; @@ -240,10 +239,9 @@ namespace osu.Game.Beatmaps using var cmd = db.CreateCommand(); cmd.CommandText = - @"SELECT beatmapset_id, beatmap_id, approved, user_id, checksum, last_update FROM osu_beatmaps WHERE checksum = @MD5Hash OR beatmap_id = @OnlineID OR filename = @Path"; + @"SELECT beatmapset_id, beatmap_id, approved, user_id, checksum, last_update FROM osu_beatmaps WHERE checksum = @MD5Hash OR filename = @Path"; cmd.Parameters.Add(new SqliteParameter(@"@MD5Hash", beatmapInfo.MD5Hash)); - cmd.Parameters.Add(new SqliteParameter(@"@OnlineID", beatmapInfo.OnlineID)); cmd.Parameters.Add(new SqliteParameter(@"@Path", beatmapInfo.Path)); using var reader = cmd.ExecuteReader(); @@ -281,11 +279,10 @@ namespace osu.Game.Beatmaps SELECT `b`.`beatmapset_id`, `b`.`beatmap_id`, `b`.`approved`, `b`.`user_id`, `b`.`checksum`, `b`.`last_update`, `s`.`submit_date`, `s`.`approved_date` FROM `osu_beatmaps` AS `b` JOIN `osu_beatmapsets` AS `s` ON `s`.`beatmapset_id` = `b`.`beatmapset_id` - WHERE `b`.`checksum` = @MD5Hash OR `b`.`beatmap_id` = @OnlineID OR `b`.`filename` = @Path + WHERE `b`.`checksum` = @MD5Hash OR `b`.`filename` = @Path """; cmd.Parameters.Add(new SqliteParameter(@"@MD5Hash", beatmapInfo.MD5Hash)); - cmd.Parameters.Add(new SqliteParameter(@"@OnlineID", beatmapInfo.OnlineID)); cmd.Parameters.Add(new SqliteParameter(@"@Path", beatmapInfo.Path)); using var reader = cmd.ExecuteReader(); diff --git a/osu.Game/Beatmaps/StarDifficulty.cs b/osu.Game/Beatmaps/StarDifficulty.cs index 6aac275a6a..9f7a92fe46 100644 --- a/osu.Game/Beatmaps/StarDifficulty.cs +++ b/osu.Game/Beatmaps/StarDifficulty.cs @@ -1,9 +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 JetBrains.Annotations; using osu.Framework.Utils; using osu.Game.Rulesets.Difficulty; @@ -25,30 +22,34 @@ namespace osu.Game.Beatmaps /// The difficulty attributes computed for the given beatmap. /// Might not be available if the star difficulty is associated with a beatmap that's not locally available. /// - [CanBeNull] - public readonly DifficultyAttributes Attributes; + public readonly DifficultyAttributes? DifficultyAttributes; /// - /// Creates a structure based on computed - /// by a . + /// The performance attributes computed for a perfect score on the given beatmap. + /// Might not be available if the star difficulty is associated with a beatmap that's not locally available. /// - public StarDifficulty([NotNull] DifficultyAttributes attributes) + public readonly PerformanceAttributes? PerformanceAttributes; + + /// + /// Creates a structure. + /// + public StarDifficulty(DifficultyAttributes difficulty, PerformanceAttributes performance) { - Stars = double.IsFinite(attributes.StarRating) ? attributes.StarRating : 0; - MaxCombo = attributes.MaxCombo; - Attributes = attributes; + Stars = double.IsFinite(difficulty.StarRating) ? difficulty.StarRating : 0; + MaxCombo = difficulty.MaxCombo; + DifficultyAttributes = difficulty; + PerformanceAttributes = performance; // Todo: Add more members (BeatmapInfo.DifficultyRating? Attributes? Etc...) } /// /// Creates a structure with a pre-populated star difficulty and max combo - /// in scenarios where computing is not feasible (i.e. when working with online sources). + /// in scenarios where computing is not feasible (i.e. when working with online sources). /// public StarDifficulty(double starDifficulty, int maxCombo) { Stars = double.IsFinite(starDifficulty) ? starDifficulty : 0; MaxCombo = maxCombo; - Attributes = null; } public DifficultyRating DifficultyRating => GetDifficultyRating(Stars); diff --git a/osu.Game/Beatmaps/WorkingBeatmap.cs b/osu.Game/Beatmaps/WorkingBeatmap.cs index 25159996f3..07bf4c028a 100644 --- a/osu.Game/Beatmaps/WorkingBeatmap.cs +++ b/osu.Game/Beatmaps/WorkingBeatmap.cs @@ -183,7 +183,14 @@ namespace osu.Game.Beatmaps #region Beatmap - public virtual bool BeatmapLoaded => beatmapLoadTask?.IsCompleted ?? false; + public virtual bool BeatmapLoaded + { + get + { + lock (beatmapFetchLock) + return beatmapLoadTask?.IsCompleted ?? false; + } + } public IBeatmap Beatmap { diff --git a/osu.Game/Collections/DeleteCollectionDialog.cs b/osu.Game/Collections/DeleteCollectionDialog.cs index 9edc213077..80a7fa4bc0 100644 --- a/osu.Game/Collections/DeleteCollectionDialog.cs +++ b/osu.Game/Collections/DeleteCollectionDialog.cs @@ -8,7 +8,7 @@ using osu.Game.Overlays.Dialog; namespace osu.Game.Collections { - public partial class DeleteCollectionDialog : DangerousActionDialog + public partial class DeleteCollectionDialog : DeletionDialog { public DeleteCollectionDialog(Live collection, Action deleteAction) { diff --git a/osu.Game/Collections/DrawableCollectionList.cs b/osu.Game/Collections/DrawableCollectionList.cs index 6fe38a3229..85af1d383d 100644 --- a/osu.Game/Collections/DrawableCollectionList.cs +++ b/osu.Game/Collections/DrawableCollectionList.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; @@ -20,6 +21,12 @@ namespace osu.Game.Collections /// public partial class DrawableCollectionList : OsuRearrangeableListContainer> { + public new MarginPadding Padding + { + get => base.Padding; + set => base.Padding = value; + } + protected override ScrollContainer CreateScrollContainer() => scroll = new Scroll(); [Resolved] @@ -29,7 +36,17 @@ namespace osu.Game.Collections private IDisposable? realmSubscription; - protected override FillFlowContainer>> CreateListFillFlowContainer() => new Flow + private Flow flow = null!; + + public IEnumerable OrderedItems => flow.FlowingChildren; + + public string SearchTerm + { + get => flow.SearchTerm; + set => flow.SearchTerm = value; + } + + protected override FillFlowContainer>> CreateListFillFlowContainer() => flow = new Flow { DragActive = { BindTarget = DragActive } }; @@ -41,10 +58,51 @@ namespace osu.Game.Collections realmSubscription = realm.RegisterForNotifications(r => r.All().OrderBy(c => c.Name), collectionsChanged); } + /// + /// When non-null, signifies that a new collection was created and should be presented to the user. + /// + private Guid? lastCreated; + + protected override void OnItemsChanged() + { + base.OnItemsChanged(); + + if (lastCreated != null) + { + var createdItem = flow.Children.SingleOrDefault(item => item.Model.Value.ID == lastCreated); + + if (createdItem != null) + scroll.ScrollTo(createdItem); + + lastCreated = null; + } + } + private void collectionsChanged(IRealmCollection collections, ChangeSet? changes) { - Items.Clear(); - Items.AddRange(collections.AsEnumerable().Select(c => c.ToLive(realm))); + if (changes == null) + { + Items.AddRange(collections.AsEnumerable().Select(c => c.ToLive(realm))); + return; + } + + foreach (int i in changes.DeletedIndices.OrderDescending()) + Items.RemoveAt(i); + + foreach (int i in changes.InsertedIndices) + Items.Insert(i, collections[i].ToLive(realm)); + + if (changes.InsertedIndices.Length == 1) + lastCreated = collections[changes.InsertedIndices[0]].ID; + + foreach (int i in changes.NewModifiedIndices) + + { + var updatedItem = collections[i]; + + Items.RemoveAt(i); + Items.Insert(i, updatedItem.ToLive(realm)); + } } protected override OsuRearrangeableListItem> CreateOsuDrawable(Live item) @@ -82,8 +140,7 @@ namespace osu.Game.Collections public Scroll() { - ScrollbarVisible = false; - Padding = new MarginPadding(10); + ScrollbarOverlapsContent = false; base.Content.Add(new FillFlowContainer { @@ -111,7 +168,7 @@ namespace osu.Game.Collections base.Update(); // AutoSizeAxes cannot be used as the height should represent the post-layout-transform height at all times, so that the placeholder doesn't bounce around. - content.Height = ((Flow)Child).Children.Sum(c => c.DrawHeight + 5); + content.Height = ((Flow)Child).Children.Sum(c => c.IsPresent ? c.DrawHeight + 5 : 0); } /// @@ -123,16 +180,41 @@ namespace osu.Game.Collections var previous = PlaceholderItem; placeholderContainer.Clear(false); - placeholderContainer.Add(PlaceholderItem = new DrawableCollectionListItem(new BeatmapCollection().ToLiveUnmanaged(), false)); + placeholderContainer.Add(PlaceholderItem = new NewCollectionEntryItem()); return previous; } } + private partial class NewCollectionEntryItem : DrawableCollectionListItem + { + [Resolved] + private RealmAccess realm { get; set; } = null!; + + public NewCollectionEntryItem() + : base(new BeatmapCollection().ToLiveUnmanaged(), false) + { + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + TextBox.OnCommit += (sender, newText) => + { + if (string.IsNullOrEmpty(TextBox.Text)) + return; + + realm.Write(r => r.Add(new BeatmapCollection(TextBox.Text))); + TextBox.Text = string.Empty; + }; + } + } + /// /// The flow of . Disables layout easing unless a drag is in progress. /// - private partial class Flow : FillFlowContainer>> + private partial class Flow : SearchContainer>> { public readonly IBindable DragActive = new Bindable(); @@ -140,6 +222,8 @@ namespace osu.Game.Collections { Spacing = new Vector2(0, 5); LayoutEasing = Easing.OutQuint; + + Padding = new MarginPadding { Right = 5 }; } protected override void LoadComplete() diff --git a/osu.Game/Collections/DrawableCollectionListItem.cs b/osu.Game/Collections/DrawableCollectionListItem.cs index e71368c079..e86254329f 100644 --- a/osu.Game/Collections/DrawableCollectionListItem.cs +++ b/osu.Game/Collections/DrawableCollectionListItem.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -10,6 +11,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Containers; @@ -23,11 +25,15 @@ namespace osu.Game.Collections /// /// Visualises a inside a . /// - public partial class DrawableCollectionListItem : OsuRearrangeableListItem> + public partial class DrawableCollectionListItem : OsuRearrangeableListItem>, IFilterable { private const float item_height = 35; private const float button_width = item_height * 0.75f; + protected TextBox TextBox => content.TextBox; + + private ItemContent content = null!; + /// /// Creates a new . /// @@ -48,7 +54,7 @@ namespace osu.Game.Collections CornerRadius = item_height / 2; } - protected override Drawable CreateContent() => new ItemContent(Model); + protected override Drawable CreateContent() => content = new ItemContent(Model); /// /// The main content of the . @@ -57,10 +63,7 @@ namespace osu.Game.Collections { private readonly Live collection; - private ItemTextBox textBox = null!; - - [Resolved] - private RealmAccess realm { get; set; } = null!; + public ItemTextBox TextBox { get; private set; } = null!; public ItemContent(Live collection) { @@ -80,7 +83,7 @@ namespace osu.Game.Collections { Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, - IsTextBoxHovered = v => textBox.ReceivePositionalInputAt(v) + IsTextBoxHovered = v => TextBox.ReceivePositionalInputAt(v) } : Empty(), new Container @@ -89,7 +92,7 @@ namespace osu.Game.Collections Padding = new MarginPadding { Right = collection.IsManaged ? button_width : 0 }, Children = new Drawable[] { - textBox = new ItemTextBox + TextBox = new ItemTextBox { RelativeSizeAxes = Axes.Both, Size = Vector2.One, @@ -107,18 +110,14 @@ namespace osu.Game.Collections base.LoadComplete(); // Bind late, as the collection name may change externally while still loading. - textBox.Current.Value = collection.PerformRead(c => c.IsValid ? c.Name : string.Empty); - textBox.OnCommit += onCommit; + TextBox.Current.Value = collection.PerformRead(c => c.IsValid ? c.Name : string.Empty); + TextBox.OnCommit += onCommit; } private void onCommit(TextBox sender, bool newText) { - if (collection.IsManaged) - collection.PerformWrite(c => c.Name = textBox.Current.Value); - else if (!string.IsNullOrEmpty(textBox.Current.Value)) - realm.Write(r => r.Add(new BeatmapCollection(textBox.Current.Value))); - - textBox.Text = string.Empty; + if (collection.IsManaged && collection.Value.Name != TextBox.Current.Value) + collection.PerformWrite(c => c.Name = TextBox.Current.Value); } } @@ -210,5 +209,25 @@ namespace osu.Game.Collections private void deleteCollection() => collection.PerformWrite(c => c.Realm!.Remove(c)); } + + public IEnumerable FilterTerms => [(LocalisableString)Model.Value.Name]; + + private bool matchingFilter = true; + + public bool MatchingFilter + { + get => matchingFilter; + set + { + matchingFilter = value; + + if (matchingFilter) + this.FadeIn(200); + else + Hide(); + } + } + + public bool FilteringActive { get; set; } } } diff --git a/osu.Game/Collections/ManageCollectionsDialog.cs b/osu.Game/Collections/ManageCollectionsDialog.cs index 9f8158af53..a738ae66cb 100644 --- a/osu.Game/Collections/ManageCollectionsDialog.cs +++ b/osu.Game/Collections/ManageCollectionsDialog.cs @@ -12,6 +12,7 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; +using osu.Game.Resources.Localisation.Web; using osuTK; namespace osu.Game.Collections @@ -26,6 +27,9 @@ namespace osu.Game.Collections private IDisposable? duckOperation; + private BasicSearchTextBox searchTextBox = null!; + private DrawableCollectionList list = null!; + [Resolved] private MusicController? musicController { get; set; } @@ -104,10 +108,31 @@ namespace osu.Game.Collections RelativeSizeAxes = Axes.Both, Colour = colours.GreySeaFoamDarker }, - new DrawableCollectionList + new Container { RelativeSizeAxes = Axes.Both, - } + Padding = new MarginPadding(10), + Children = new Drawable[] + { + searchTextBox = new BasicSearchTextBox + { + RelativeSizeAxes = Axes.X, + Y = 10, + Height = 40, + ReleaseFocusOnCommit = false, + HoldFocus = true, + PlaceholderText = HomeStrings.SearchPlaceholder, + }, + list = new DrawableCollectionList + { + Padding = new MarginPadding + { + Top = 60, + }, + RelativeSizeAxes = Axes.Both, + } + } + }, } } }, @@ -117,6 +142,16 @@ namespace osu.Game.Collections }; } + protected override void LoadComplete() + { + base.LoadComplete(); + + searchTextBox.Current.BindValueChanged(_ => + { + list.SearchTerm = searchTextBox.Current.Value; + }); + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index 8d6c244b35..33d99e9b0f 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; @@ -53,7 +54,7 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.ModSelectHotkeyStyle, ModSelectHotkeyStyle.Sequential); SetDefault(OsuSetting.ModSelectTextSearchStartsActive, true); - SetDefault(OsuSetting.ChatDisplayHeight, ChatOverlay.DEFAULT_HEIGHT, 0.2f, 1f); + SetDefault(OsuSetting.ChatDisplayHeight, ChatOverlay.DEFAULT_HEIGHT, 0.2f, 1f, 0.01f); SetDefault(OsuSetting.BeatmapListingCardSize, BeatmapCardSize.Normal); @@ -131,7 +132,7 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.Prefer24HourTime, !CultureInfoHelper.SystemCulture.DateTimeFormat.ShortTimePattern.Contains(@"tt")); // Gameplay - SetDefault(OsuSetting.PositionalHitsoundsLevel, 0.2f, 0, 1); + SetDefault(OsuSetting.PositionalHitsoundsLevel, 0.2f, 0, 1, 0.01f); SetDefault(OsuSetting.DimLevel, 0.7, 0, 1, 0.01); SetDefault(OsuSetting.BlurLevel, 0, 0, 1, 0.01); SetDefault(OsuSetting.LightenDuringBreaks, true); @@ -168,13 +169,13 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.Scaling, ScalingMode.Off); SetDefault(OsuSetting.SafeAreaConsiderations, true); - SetDefault(OsuSetting.ScalingBackgroundDim, 0.9f, 0.5f, 1f); + SetDefault(OsuSetting.ScalingBackgroundDim, 0.9f, 0.5f, 1f, 0.01f); - SetDefault(OsuSetting.ScalingSizeX, 0.8f, 0.2f, 1f); - SetDefault(OsuSetting.ScalingSizeY, 0.8f, 0.2f, 1f); + SetDefault(OsuSetting.ScalingSizeX, 0.8f, 0.2f, 1f, 0.01f); + SetDefault(OsuSetting.ScalingSizeY, 0.8f, 0.2f, 1f, 0.01f); - SetDefault(OsuSetting.ScalingPositionX, 0.5f, 0f, 1f); - SetDefault(OsuSetting.ScalingPositionY, 0.5f, 0f, 1f); + SetDefault(OsuSetting.ScalingPositionX, 0.5f, 0f, 1f, 0.01f); + SetDefault(OsuSetting.ScalingPositionY, 0.5f, 0f, 1f, 0.01f); SetDefault(OsuSetting.UIScale, 1f, 0.8f, 1.6f, 0.01f); @@ -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,9 +208,13 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.UserOnlineStatus, null); SetDefault(OsuSetting.EditorTimelineShowTimingChanges, true); + SetDefault(OsuSetting.EditorTimelineShowBreaks, true); SetDefault(OsuSetting.EditorTimelineShowTicks, true); + SetDefault(OsuSetting.EditorContractSidebars, false); + SetDefault(OsuSetting.AlwaysShowHoldForMenuButton, false); + SetDefault(OsuSetting.AlwaysRequireHoldingForPause, false); } protected override bool CheckLookupContainsPrivateInformation(OsuSetting lookup) @@ -431,6 +439,12 @@ namespace osu.Game.Configuration HideCountryFlags, EditorTimelineShowTimingChanges, EditorTimelineShowTicks, - AlwaysShowHoldForMenuButton + AlwaysShowHoldForMenuButton, + EditorContractSidebars, + EditorScaleOrigin, + EditorRotationOrigin, + EditorTimelineShowBreaks, + EditorAdjustExistingObjectsOnTimingChanges, + AlwaysRequireHoldingForPause } } diff --git a/osu.Game/Database/BackgroundDataStoreProcessor.cs b/osu.Game/Database/BackgroundDataStoreProcessor.cs index 0fa785e494..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!; @@ -606,7 +606,7 @@ namespace osu.Game.Database { // Importantly, also sleep if high performance session is active. // If we don't do this, memory usage can become runaway due to GC running in a more lenient mode. - while (localUserPlayInfo?.IsPlaying.Value == true || highPerformanceSessionManager?.IsSessionActive == true) + while (localUserPlayInfo?.PlayingState.Value != LocalUserPlayingState.NotPlaying || highPerformanceSessionManager?.IsSessionActive == true) { Logger.Log("Background processing sleeping due to active gameplay..."); Thread.Sleep(TimeToSleepDuringGameplay); diff --git a/osu.Game/Database/DetachedBeatmapStore.cs b/osu.Game/Database/DetachedBeatmapStore.cs index 64aeeccd9a..5b65f608b2 100644 --- a/osu.Game/Database/DetachedBeatmapStore.cs +++ b/osu.Game/Database/DetachedBeatmapStore.cs @@ -44,7 +44,7 @@ namespace osu.Game.Database { if (changes == null) { - if (detachedBeatmapSets.Count > 0 && sender.Count == 0) + if (sender is RealmResetEmptySet) { // Usually we'd reset stuff here, but doing so triggers a silly flow which ends up deadlocking realm. // Additionally, user should not be at song select when realm is blocking all operations in the first place. diff --git a/osu.Game/Database/LegacyBeatmapExporter.cs b/osu.Game/Database/LegacyBeatmapExporter.cs index 17c2c8c88d..eb48425588 100644 --- a/osu.Game/Database/LegacyBeatmapExporter.cs +++ b/osu.Game/Database/LegacyBeatmapExporter.cs @@ -59,7 +59,25 @@ namespace osu.Game.Database }; // Convert beatmap elements to be compatible with legacy format - // So we truncate time and position values to integers, and convert paths with multiple segments to bezier curves + // So we truncate time and position values to integers, and convert paths with multiple segments to Bézier curves + + // We must first truncate all timing points and move all objects in the timing section with it to ensure everything stays snapped + for (int i = 0; i < playableBeatmap.ControlPointInfo.TimingPoints.Count; i++) + { + var timingPoint = playableBeatmap.ControlPointInfo.TimingPoints[i]; + double offset = Math.Floor(timingPoint.Time) - timingPoint.Time; + double nextTimingPointTime = i + 1 < playableBeatmap.ControlPointInfo.TimingPoints.Count + ? playableBeatmap.ControlPointInfo.TimingPoints[i + 1].Time + : double.PositiveInfinity; + + // Offset all control points in the timing section (including the current one) + foreach (var controlPoint in playableBeatmap.ControlPointInfo.AllControlPoints.Where(o => o.Time >= timingPoint.Time && o.Time < nextTimingPointTime)) + controlPoint.Time += offset; + + // Offset all hit objects in the timing section + foreach (var hitObject in playableBeatmap.HitObjects.Where(o => o.StartTime >= timingPoint.Time && o.StartTime < nextTimingPointTime)) + hitObject.StartTime += offset; + } foreach (var controlPoint in playableBeatmap.ControlPointInfo.AllControlPoints) controlPoint.Time = Math.Floor(controlPoint.Time); diff --git a/osu.Game/Database/ModelDownloader.cs b/osu.Game/Database/ModelDownloader.cs index 8aece748a8..dfeec259fe 100644 --- a/osu.Game/Database/ModelDownloader.cs +++ b/osu.Game/Database/ModelDownloader.cs @@ -68,18 +68,23 @@ namespace osu.Game.Database { Task.Factory.StartNew(async () => { - bool importSuccessful; + bool importSuccessful = false; - if (originalModel != null) - importSuccessful = (await importer.ImportAsUpdate(notification, new ImportTask(filename), originalModel).ConfigureAwait(false)) != null; - else - importSuccessful = (await importer.Import(notification, new[] { new ImportTask(filename) }).ConfigureAwait(false)).Any(); + try + { + if (originalModel != null) + importSuccessful = (await importer.ImportAsUpdate(notification, new ImportTask(filename), originalModel).ConfigureAwait(false)) != null; + else + importSuccessful = (await importer.Import(notification, new[] { new ImportTask(filename) }).ConfigureAwait(false)).Any(); + } + finally + { + // for now a failed import will be marked as a failed download for simplicity. + if (!importSuccessful) + DownloadFailed?.Invoke(request); - // for now a failed import will be marked as a failed download for simplicity. - if (!importSuccessful) - DownloadFailed?.Invoke(request); - - CurrentDownloads.Remove(request); + CurrentDownloads.Remove(request); + } }, TaskCreationOptions.LongRunning); }; diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs index cb91d6923b..eb7182820b 100644 --- a/osu.Game/Database/RealmAccess.cs +++ b/osu.Game/Database/RealmAccess.cs @@ -93,8 +93,9 @@ namespace osu.Game.Database /// 40 2023-12-21 Add ScoreInfo.Version to keep track of which build scores were set on. /// 41 2024-04-17 Add ScoreInfo.TotalScoreWithoutMods for future mod multiplier rebalances. /// 42 2024-08-07 Update mania key bindings to reflect changes to ManiaAction + /// 43 2024-10-14 Reset keybind for toggling FPS display to avoid conflict with "convert to stream" in the editor, if not already changed by user. /// - private const int schema_version = 42; + private const int schema_version = 43; /// /// Lock object which is held during sections, blocking realm retrieval during blocking periods. @@ -375,10 +376,6 @@ namespace osu.Game.Database { foreach (var beatmap in beatmapSet.Beatmaps) { - // Cascade delete related scores, else they will have a null beatmap against the model's spec. - foreach (var score in beatmap.Scores) - realm.Remove(score); - realm.Remove(beatmap.Metadata); realm.Remove(beatmap); } @@ -568,7 +565,7 @@ namespace osu.Game.Database lock (notificationsResetMap) { // Store an action which is used when blocking to ensure consumers don't use results of a stale changeset firing. - notificationsResetMap.Add(action, () => callback(new EmptyRealmSet(), null)); + notificationsResetMap.Add(action, () => callback(new RealmResetEmptySet(), null)); } return RegisterCustomSubscription(action); @@ -1192,6 +1189,21 @@ namespace osu.Game.Database } break; + + case 43: + { + // Clear default bindings for "Toggle FPS Display", + // as it conflicts with "Convert to Stream" in the editor. + // Only apply change if set to the conflicting bind + // i.e. has been manually rebound by the user. + var keyBindings = migration.NewRealm.All(); + + var toggleFpsBind = keyBindings.FirstOrDefault(bind => bind.ActionInt == (int)GlobalAction.ToggleFPSDisplay); + if (toggleFpsBind != null && toggleFpsBind.KeyCombination.Keys.SequenceEqual(new[] { InputKey.Shift, InputKey.Control, InputKey.F })) + migration.NewRealm.Remove(toggleFpsBind); + + break; + } } Logger.Log($"Migration completed in {stopwatch.ElapsedMilliseconds}ms"); diff --git a/osu.Game/Database/RealmArchiveModelImporter.cs b/osu.Game/Database/RealmArchiveModelImporter.cs index cf0625c51c..e538530b79 100644 --- a/osu.Game/Database/RealmArchiveModelImporter.cs +++ b/osu.Game/Database/RealmArchiveModelImporter.cs @@ -105,7 +105,6 @@ namespace osu.Game.Database } notification.Progress = 0; - notification.Text = $"{HumanisedModelName.Humanize(LetterCasing.Title)} import is initialising..."; int current = 0; @@ -113,65 +112,78 @@ namespace osu.Game.Database parameters.Batch |= tasks.Length >= minimum_items_considered_batch_import; - await Task.WhenAll(tasks.Select(async task => + notification.Text = $"{HumanisedModelName.Humanize(LetterCasing.Title)} import is initialising..."; + notification.State = ProgressNotificationState.Active; + + await pauseIfNecessaryAsync(parameters, notification, notification.CancellationToken).ConfigureAwait(false); + + try { - if (notification.CancellationToken.IsCancellationRequested) - return; - - try + await Parallel.ForEachAsync(tasks, notification.CancellationToken, async (task, cancellation) => { - var model = await Import(task, parameters, notification.CancellationToken).ConfigureAwait(false); + cancellation.ThrowIfCancellationRequested(); - lock (imported) + try { - if (model != null) - imported.Add(model); - current++; + await pauseIfNecessaryAsync(parameters, notification, cancellation).ConfigureAwait(false); - notification.Text = $"Imported {current} of {tasks.Length} {HumanisedModelName}s"; - notification.Progress = (float)current / tasks.Length; + var model = await Import(task, parameters, cancellation).ConfigureAwait(false); + + lock (imported) + { + if (model != null) + imported.Add(model); + current++; + + notification.Text = $"Imported {current} of {tasks.Length} {HumanisedModelName}s"; + notification.Progress = (float)current / tasks.Length; + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception e) + { + Logger.Error(e, $@"Could not import ({task})", LoggingTarget.Database); + } + }).ConfigureAwait(false); + } + finally + { + if (imported.Count == 0) + { + if (notification.CancellationToken.IsCancellationRequested) + { + notification.State = ProgressNotificationState.Cancelled; + } + else + { + notification.Text = $"{HumanisedModelName.Humanize(LetterCasing.Title)} import failed! Check logs for more information."; + notification.State = ProgressNotificationState.Cancelled; } } - catch (OperationCanceledException) - { - } - catch (Exception e) - { - Logger.Error(e, $@"Could not import ({task})", LoggingTarget.Database); - } - })).ConfigureAwait(false); - - if (imported.Count == 0) - { - if (notification.CancellationToken.IsCancellationRequested) - { - notification.State = ProgressNotificationState.Cancelled; - return imported; - } - - notification.Text = $"{HumanisedModelName.Humanize(LetterCasing.Title)} import failed! Check logs for more information."; - notification.State = ProgressNotificationState.Cancelled; - } - else - { - if (tasks.Length > imported.Count) - notification.CompletionText = $"Imported {imported.Count} of {tasks.Length} {HumanisedModelName}s."; - else if (imported.Count > 1) - notification.CompletionText = $"Imported {imported.Count} {HumanisedModelName}s!"; else - notification.CompletionText = $"Imported {imported.First().GetDisplayString()}!"; - - if (imported.Count > 0 && PresentImport != null) { - notification.CompletionText += " Click to view."; - notification.CompletionClickAction = () => - { - PresentImport?.Invoke(imported); - return true; - }; - } + if (tasks.Length > imported.Count) + notification.CompletionText = $"Imported {imported.Count} of {tasks.Length} {HumanisedModelName}s."; + else if (imported.Count > 1) + notification.CompletionText = $"Imported {imported.Count} {HumanisedModelName}s!"; + else + notification.CompletionText = $"Imported {imported.First().GetDisplayString()}!"; - notification.State = ProgressNotificationState.Completed; + if (imported.Count > 0 && PresentImport != null) + { + notification.CompletionText += " Click to view."; + notification.CompletionClickAction = () => + { + PresentImport?.Invoke(imported); + return true; + }; + } + + notification.State = ProgressNotificationState.Completed; + } } return imported; @@ -286,8 +298,6 @@ namespace osu.Game.Database /// An optional cancellation token. public virtual Live? ImportModel(TModel item, ArchiveReader? archive = null, ImportParameters parameters = default, CancellationToken cancellationToken = default) => Realm.Run(realm => { - pauseIfNecessary(parameters, cancellationToken); - TModel? existing; if (parameters.Batch && archive != null) @@ -528,7 +538,8 @@ namespace osu.Game.Database /// The new model proposed for import. /// The current realm context. /// An existing model which matches the criteria to skip importing, else null. - protected TModel? CheckForExisting(TModel model, Realm realm) => string.IsNullOrEmpty(model.Hash) ? null : realm.All().FirstOrDefault(b => b.Hash == model.Hash); + protected TModel? CheckForExisting(TModel model, Realm realm) => + string.IsNullOrEmpty(model.Hash) ? null : realm.All().OrderBy(b => b.DeletePending).FirstOrDefault(b => b.Hash == model.Hash); /// /// Whether import can be skipped after finding an existing import early in the process. @@ -575,21 +586,29 @@ namespace osu.Game.Database /// Whether to perform deletion. protected virtual bool ShouldDeleteArchive(string path) => false; - private void pauseIfNecessary(ImportParameters importParameters, CancellationToken cancellationToken) + private async Task pauseIfNecessaryAsync(ImportParameters importParameters, ProgressNotification notification, CancellationToken cancellationToken) { if (!PauseImports || importParameters.ImportImmediately) return; Logger.Log($@"{GetType().Name} is being paused."); + // A paused state could obviously be entered mid-import (during the `Task.WhenAll` below), + // but in order to keep things simple let's focus on the most common scenario. + notification.Text = $"{HumanisedModelName.Humanize(LetterCasing.Title)} import is paused due to gameplay..."; + notification.State = ProgressNotificationState.Queued; + while (PauseImports) { cancellationToken.ThrowIfCancellationRequested(); - Thread.Sleep(500); + await Task.Delay(500, cancellationToken).ConfigureAwait(false); } cancellationToken.ThrowIfCancellationRequested(); Logger.Log($@"{GetType().Name} is being resumed."); + + notification.Text = $"{HumanisedModelName.Humanize(LetterCasing.Title)} import is resuming..."; + notification.State = ProgressNotificationState.Active; } private IEnumerable getIDs(IEnumerable files) diff --git a/osu.Game/Database/EmptyRealmSet.cs b/osu.Game/Database/RealmResetEmptySet.cs similarity index 82% rename from osu.Game/Database/EmptyRealmSet.cs rename to osu.Game/Database/RealmResetEmptySet.cs index c34974cb03..9f9a1ba6d7 100644 --- a/osu.Game/Database/EmptyRealmSet.cs +++ b/osu.Game/Database/RealmResetEmptySet.cs @@ -12,7 +12,13 @@ using Realms.Schema; namespace osu.Game.Database { - public class EmptyRealmSet : IRealmCollection + /// + /// This can arrive in callbacks to imply that realm access has been reset. + /// + /// + /// Usually implies that the original database may return soon and the callback can usually be silently ignored. + /// + public class RealmResetEmptySet : IRealmCollection { private IList emptySet => Array.Empty(); diff --git a/osu.Game/Database/UserLookupCache.cs b/osu.Game/Database/UserLookupCache.cs index e581d5ce82..8c96b34666 100644 --- a/osu.Game/Database/UserLookupCache.cs +++ b/osu.Game/Database/UserLookupCache.cs @@ -10,7 +10,7 @@ using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Database { - public partial class UserLookupCache : OnlineLookupCache + public partial class UserLookupCache : OnlineLookupCache { /// /// Perform an API lookup on the specified user, populating a model. @@ -28,8 +28,8 @@ namespace osu.Game.Database /// The populated users. May include null results for failed retrievals. public Task GetUsersAsync(int[] userIds, CancellationToken token = default) => LookupAsync(userIds, token); - protected override GetUsersRequest CreateRequest(IEnumerable ids) => new GetUsersRequest(ids.ToArray()); + protected override LookupUsersRequest CreateRequest(IEnumerable ids) => new LookupUsersRequest(ids.ToArray()); - protected override IEnumerable? RetrieveResults(GetUsersRequest request) => request.Response?.Users; + protected override IEnumerable? RetrieveResults(LookupUsersRequest request) => request.Response?.Users; } } diff --git a/osu.Game/Graphics/Containers/OsuScrollContainer.cs b/osu.Game/Graphics/Containers/OsuScrollContainer.cs index ffd28957ef..a3cd5a4902 100644 --- a/osu.Game/Graphics/Containers/OsuScrollContainer.cs +++ b/osu.Game/Graphics/Containers/OsuScrollContainer.cs @@ -42,8 +42,6 @@ namespace osu.Game.Graphics.Containers /// public double DistanceDecayOnRightMouseScrollbar = 0.02; - private bool shouldPerformRightMouseScroll(MouseButtonEvent e) => RightMouseScrollbar && e.Button == MouseButton.Right; - private bool rightMouseDragging; protected override bool IsDragging => base.IsDragging || rightMouseDragging; @@ -126,8 +124,15 @@ namespace osu.Game.Graphics.Containers return base.OnScroll(e); } - protected virtual void ScrollFromMouseEvent(MouseEvent e) => - ScrollTo(Clamp(ToLocalSpace(e.ScreenSpaceMousePosition)[ScrollDim] / DrawSize[ScrollDim] * Content.DrawSize[ScrollDim]), true, DistanceDecayOnRightMouseScrollbar); + protected virtual void ScrollFromMouseEvent(MouseEvent e) + { + float fromScrollbarPosition = FromScrollbarPosition(ToLocalSpace(e.ScreenSpaceMousePosition)[ScrollDim]); + float scrollbarCentreOffset = FromScrollbarPosition(Scrollbar.DrawHeight) * 0.5f; + + ScrollTo(Clamp(fromScrollbarPosition - scrollbarCentreOffset), true, DistanceDecayOnRightMouseScrollbar); + } + + private bool shouldPerformRightMouseScroll(MouseButtonEvent e) => RightMouseScrollbar && e.Button == MouseButton.Right; protected override ScrollbarContainer CreateScrollbar(Direction direction) => new OsuScrollbar(direction); diff --git a/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs b/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs index 06ef75cf58..cd44bd8fb9 100644 --- a/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs +++ b/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs @@ -3,8 +3,10 @@ #nullable disable +using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -20,12 +22,15 @@ namespace osu.Game.Graphics.UserInterface { public partial class DrawableOsuMenuItem : Menu.DrawableMenuItem { - public const int MARGIN_HORIZONTAL = 17; + public const int MARGIN_HORIZONTAL = 10; public const int MARGIN_VERTICAL = 4; - private const int text_size = 17; - private const int transition_length = 80; + public const int TEXT_SIZE = 17; + public const int TRANSITION_LENGTH = 80; + + public BindableBool ShowCheckbox { get; } = new BindableBool(); private TextContainer text; + private HotkeyDisplay hotkey; private HoverClickSounds hoverClickSounds; public DrawableOsuMenuItem(MenuItem item) @@ -39,43 +44,47 @@ namespace osu.Game.Graphics.UserInterface BackgroundColour = Color4.Transparent; BackgroundColourHover = Color4Extensions.FromHex(@"172023"); + AddInternal(hotkey = new HotkeyDisplay + { + Alpha = 0, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Margin = new MarginPadding { Right = 10, Top = 1 }, + }); AddInternal(hoverClickSounds = new HoverClickSounds()); - updateTextColour(); + updateText(); - bool hasSubmenu = Item.Items.Any(); - - // Only add right chevron if direction of menu items is vertical (i.e. width is relative size, see `DrawableMenuItem.SetFlowDirection()`). - if (hasSubmenu && RelativeSizeAxes == Axes.X) + if (showChevron) { AddInternal(new SpriteIcon { - Margin = new MarginPadding(6), + Margin = new MarginPadding { Horizontal = 10, }, Size = new Vector2(8), Icon = FontAwesome.Solid.ChevronRight, Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, }); - - text.Padding = new MarginPadding - { - // Add some padding for the chevron above. - Right = 5, - }; } } + // Only add right chevron if direction of menu items is vertical (i.e. width is relative size, see `DrawableMenuItem.SetFlowDirection()`). + private bool showChevron => Item.Items.Any() && RelativeSizeAxes == Axes.X; + protected override void LoadComplete() { base.LoadComplete(); + ShowCheckbox.BindValueChanged(_ => updateState()); Item.Action.BindDisabledChanged(_ => updateState(), true); FinishTransforms(); } - private void updateTextColour() + private void updateText() { - switch ((Item as OsuMenuItem)?.Type) + var osuMenuItem = Item as OsuMenuItem; + + switch (osuMenuItem?.Type) { default: case MenuItemType.Standard: @@ -90,6 +99,20 @@ namespace osu.Game.Graphics.UserInterface text.Colour = Color4Extensions.FromHex(@"ffcc22"); break; } + + hotkey.Hotkey = osuMenuItem?.Hotkey ?? default; + hotkey.Alpha = EqualityComparer.Default.Equals(hotkey.Hotkey, default) ? 0 : 1; + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + // this hack ensures that the menu can auto-size while leaving enough space for the hotkey display. + // the gist of it is that while the hotkey display is not in the text / "content" that determines sizing + // (because it cannot be, because we want the hotkey display to align to the *right* and not the left), + // enough padding to fit the hotkey with _its_ spacing is added as padding of the text to compensate. + text.Padding = new MarginPadding { Right = hotkey.Alpha > 0 || showChevron ? hotkey.DrawWidth + 15 : 0 }; } protected override bool OnHover(HoverEvent e) @@ -111,14 +134,16 @@ namespace osu.Game.Graphics.UserInterface if (IsHovered && IsActionable) { - text.BoldText.FadeIn(transition_length, Easing.OutQuint); - text.NormalText.FadeOut(transition_length, Easing.OutQuint); + text.BoldText.FadeIn(TRANSITION_LENGTH, Easing.OutQuint); + text.NormalText.FadeOut(TRANSITION_LENGTH, Easing.OutQuint); } else { - text.BoldText.FadeOut(transition_length, Easing.OutQuint); - text.NormalText.FadeIn(transition_length, Easing.OutQuint); + text.BoldText.FadeOut(TRANSITION_LENGTH, Easing.OutQuint); + text.NormalText.FadeIn(TRANSITION_LENGTH, Easing.OutQuint); } + + text.CheckboxContainer.Alpha = ShowCheckbox.Value ? 1 : 0; } protected sealed override Drawable CreateContent() => text = CreateTextContainer(); @@ -138,32 +163,53 @@ namespace osu.Game.Graphics.UserInterface public readonly SpriteText NormalText; public readonly SpriteText BoldText; + public readonly Container CheckboxContainer; public TextContainer() { - Anchor = Anchor.CentreLeft; - Origin = Anchor.CentreLeft; - AutoSizeAxes = Axes.Both; - Children = new Drawable[] + Child = new FillFlowContainer { - NormalText = new OsuSpriteText + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(10), + Direction = FillDirection.Horizontal, + Padding = new MarginPadding { Horizontal = MARGIN_HORIZONTAL, Vertical = MARGIN_VERTICAL, }, + + Children = new Drawable[] { - AlwaysPresent = true, // ensures that the menu item does not change width when switching between normal and bold text. - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Font = OsuFont.GetFont(size: text_size), - Margin = new MarginPadding { Horizontal = MARGIN_HORIZONTAL, Vertical = MARGIN_VERTICAL }, - }, - BoldText = new OsuSpriteText - { - AlwaysPresent = true, // ensures that the menu item does not change width when switching between normal and bold text. - Alpha = 0, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Font = OsuFont.GetFont(size: text_size, weight: FontWeight.Bold), - Margin = new MarginPadding { Horizontal = MARGIN_HORIZONTAL, Vertical = MARGIN_VERTICAL }, + CheckboxContainer = new Container + { + RelativeSizeAxes = Axes.Y, + Width = MARGIN_HORIZONTAL, + }, + new Container + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + NormalText = new OsuSpriteText + { + AlwaysPresent = true, // ensures that the menu item does not change width when switching between normal and bold text. + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: TEXT_SIZE), + }, + BoldText = new OsuSpriteText + { + AlwaysPresent = true, // ensures that the menu item does not change width when switching between normal and bold text. + Alpha = 0, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: TEXT_SIZE, weight: FontWeight.Bold), + } + } + }, } }; } diff --git a/osu.Game/Graphics/UserInterface/DrawableStatefulMenuItem.cs b/osu.Game/Graphics/UserInterface/DrawableStatefulMenuItem.cs index b9e81e1bf2..4206f77c98 100644 --- a/osu.Game/Graphics/UserInterface/DrawableStatefulMenuItem.cs +++ b/osu.Game/Graphics/UserInterface/DrawableStatefulMenuItem.cs @@ -46,12 +46,11 @@ namespace osu.Game.Graphics.UserInterface state = menuItem.State.GetBoundCopy(); - Add(stateIcon = new SpriteIcon + CheckboxContainer.Add(stateIcon = new SpriteIcon { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, Size = new Vector2(10), - Margin = new MarginPadding { Horizontal = MARGIN_HORIZONTAL }, AlwaysPresent = true, }); } @@ -62,14 +61,6 @@ namespace osu.Game.Graphics.UserInterface state.BindValueChanged(updateState, true); } - protected override void Update() - { - base.Update(); - - // Todo: This is bad. This can maybe be done better with a refactor of DrawableOsuMenuItem. - stateIcon.X = BoldText.DrawWidth + 10; - } - private void updateState(ValueChangedEvent state) { var icon = menuItem.GetIconForState(state.NewValue); 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/Graphics/UserInterface/Hotkey.cs b/osu.Game/Graphics/UserInterface/Hotkey.cs new file mode 100644 index 0000000000..8b3014bdc5 --- /dev/null +++ b/osu.Game/Graphics/UserInterface/Hotkey.cs @@ -0,0 +1,59 @@ +// 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.Input; +using osu.Framework.Input.Bindings; +using osu.Framework.Platform; +using osu.Game.Input; +using osu.Game.Input.Bindings; + +namespace osu.Game.Graphics.UserInterface +{ + public readonly record struct Hotkey + { + public KeyCombination[]? KeyCombinations { get; init; } + public GlobalAction? GlobalAction { get; init; } + public PlatformAction? PlatformAction { get; init; } + + public Hotkey(params KeyCombination[] keyCombinations) + { + KeyCombinations = keyCombinations; + } + + public Hotkey(GlobalAction globalAction) + { + GlobalAction = globalAction; + } + + public Hotkey(PlatformAction platformAction) + { + PlatformAction = platformAction; + } + + public IEnumerable ResolveKeyCombination(ReadableKeyCombinationProvider keyCombinationProvider, RealmKeyBindingStore keyBindingStore, GameHost gameHost) + { + var result = new List(); + + if (KeyCombinations != null) + { + result.AddRange(KeyCombinations.Select(keyCombinationProvider.GetReadableString)); + } + + if (GlobalAction != null) + { + result.AddRange(keyBindingStore.GetReadableKeyCombinationsFor(GlobalAction.Value)); + } + + if (PlatformAction != null) + { + var action = PlatformAction.Value; + var bindings = gameHost.PlatformKeyBindings.Where(kb => (PlatformAction)kb.Action == action); + result.AddRange(bindings.Select(b => keyCombinationProvider.GetReadableString(b.KeyCombination))); + } + + return result; + } + } +} diff --git a/osu.Game/Graphics/UserInterface/HotkeyDisplay.cs b/osu.Game/Graphics/UserInterface/HotkeyDisplay.cs new file mode 100644 index 0000000000..63970249d1 --- /dev/null +++ b/osu.Game/Graphics/UserInterface/HotkeyDisplay.cs @@ -0,0 +1,110 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input; +using osu.Framework.Platform; +using osu.Game.Graphics.Sprites; +using osu.Game.Input; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Graphics.UserInterface +{ + public partial class HotkeyDisplay : CompositeDrawable + { + private Hotkey hotkey; + + public Hotkey Hotkey + { + get => hotkey; + set + { + if (EqualityComparer.Default.Equals(hotkey, value)) + return; + + hotkey = value; + + if (IsLoaded) + updateState(); + } + } + + private FillFlowContainer flow = null!; + + [Resolved] + private ReadableKeyCombinationProvider readableKeyCombinationProvider { get; set; } = null!; + + [Resolved] + private RealmKeyBindingStore realmKeyBindingStore { get; set; } = null!; + + [Resolved] + private GameHost gameHost { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + + InternalChild = flow = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5) + }; + + updateState(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + updateState(); + } + + private void updateState() + { + flow.Clear(); + foreach (string h in hotkey.ResolveKeyCombination(readableKeyCombinationProvider, realmKeyBindingStore, gameHost)) + flow.Add(new HotkeyBox(h)); + } + + private partial class HotkeyBox : CompositeDrawable + { + private readonly string hotkey; + + public HotkeyBox(string hotkey) + { + this.hotkey = hotkey; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider? colourProvider, OsuColour colours) + { + AutoSizeAxes = Axes.Both; + Masking = true; + CornerRadius = 3; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider?.Background6 ?? Colour4.Black.Opacity(0.7f), + }, + new OsuSpriteText + { + Margin = new MarginPadding { Horizontal = 5, Bottom = 1, }, + Text = hotkey.ToUpperInvariant(), + Font = OsuFont.Default.With(size: 12, weight: FontWeight.Bold), + Colour = colourProvider?.Light1 ?? colours.GrayA, + } + }; + } + } + } +} diff --git a/osu.Game/Graphics/UserInterface/OsuMenu.cs b/osu.Game/Graphics/UserInterface/OsuMenu.cs index e2aac297e3..6e7dad2b5f 100644 --- a/osu.Game/Graphics/UserInterface/OsuMenu.cs +++ b/osu.Game/Graphics/UserInterface/OsuMenu.cs @@ -42,6 +42,25 @@ namespace osu.Game.Graphics.UserInterface sampleClose = audio.Samples.Get(@"UI/dropdown-close"); } + protected override void Update() + { + base.Update(); + + bool showCheckboxes = false; + + foreach (var drawableItem in ItemsContainer) + { + if (drawableItem.Item is StatefulMenuItem) + showCheckboxes = true; + } + + foreach (var drawableItem in ItemsContainer) + { + if (drawableItem is DrawableOsuMenuItem osuItem) + osuItem.ShowCheckbox.Value = showCheckboxes; + } + } + protected override void AnimateOpen() { if (!TopLevelMenu && !wasOpened) @@ -109,7 +128,7 @@ namespace osu.Game.Graphics.UserInterface Colour = BackgroundColourHover, RelativeSizeAxes = Axes.X, Height = 2f, - Width = 0.8f, + Width = 0.9f, }); } diff --git a/osu.Game/Graphics/UserInterface/OsuMenuItem.cs b/osu.Game/Graphics/UserInterface/OsuMenuItem.cs index 20461de08f..f122990a0f 100644 --- a/osu.Game/Graphics/UserInterface/OsuMenuItem.cs +++ b/osu.Game/Graphics/UserInterface/OsuMenuItem.cs @@ -11,6 +11,8 @@ namespace osu.Game.Graphics.UserInterface { public readonly MenuItemType Type; + public Hotkey Hotkey { get; init; } + public OsuMenuItem(LocalisableString text, MenuItemType type = MenuItemType.Standard) : this(text, type, null) { diff --git a/osu.Game/Graphics/UserInterface/RangeSlider.cs b/osu.Game/Graphics/UserInterface/RangeSlider.cs index f83dff6295..422c2ca4a3 100644 --- a/osu.Game/Graphics/UserInterface/RangeSlider.cs +++ b/osu.Game/Graphics/UserInterface/RangeSlider.cs @@ -98,7 +98,7 @@ namespace osu.Game.Graphics.UserInterface { const float vertical_offset = 13; - InternalChildren = new Drawable[] + InternalChildren = new[] { label = new OsuSpriteText { @@ -115,7 +115,9 @@ namespace osu.Game.Graphics.UserInterface KeyboardStep = 0.1f, RelativeSizeAxes = Axes.X, Y = vertical_offset, - } + }, + upperBound.Nub.CreateProxy(), + lowerBound.Nub.CreateProxy(), }; } @@ -160,6 +162,8 @@ namespace osu.Game.Graphics.UserInterface protected partial class BoundSlider : RoundedSliderBar { + public new Nub Nub => base.Nub; + public string? DefaultString; public LocalisableString? DefaultTooltip; public string? TooltipSuffix; diff --git a/osu.Game/Graphics/UserInterface/RoundedSliderBar.cs b/osu.Game/Graphics/UserInterface/RoundedSliderBar.cs index 56047173bb..aeab7c34b2 100644 --- a/osu.Game/Graphics/UserInterface/RoundedSliderBar.cs +++ b/osu.Game/Graphics/UserInterface/RoundedSliderBar.cs @@ -8,6 +8,7 @@ using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Game.Overlays; @@ -25,6 +26,8 @@ namespace osu.Game.Graphics.UserInterface private readonly HoverClickSounds hoverClickSounds; + private readonly Container mainContent; + private Color4 accentColour; public Color4 AccentColour @@ -62,7 +65,7 @@ namespace osu.Game.Graphics.UserInterface Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Padding = new MarginPadding { Horizontal = 2 }, - Child = new CircularContainer + Child = mainContent = new CircularContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, @@ -135,6 +138,26 @@ namespace osu.Game.Graphics.UserInterface }, true); } + protected override void OnFocus(FocusEvent e) + { + base.OnFocus(e); + + mainContent.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = AccentColour.Darken(1), + Hollow = true, + Radius = 2, + }; + } + + protected override void OnFocusLost(FocusLostEvent e) + { + base.OnFocusLost(e); + + mainContent.EdgeEffect = default; + } + protected override bool OnHover(HoverEvent e) { updateGlow(); diff --git a/osu.Game/Graphics/UserInterface/ShearedSliderBar.cs b/osu.Game/Graphics/UserInterface/ShearedSliderBar.cs index 0df1c1d204..a36b9c7a4c 100644 --- a/osu.Game/Graphics/UserInterface/ShearedSliderBar.cs +++ b/osu.Game/Graphics/UserInterface/ShearedSliderBar.cs @@ -8,6 +8,7 @@ using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Game.Overlays; @@ -26,6 +27,8 @@ namespace osu.Game.Graphics.UserInterface private readonly HoverClickSounds hoverClickSounds; + private readonly Container mainContent; + private Color4 accentColour; public Color4 AccentColour @@ -60,12 +63,13 @@ namespace osu.Game.Graphics.UserInterface RangePadding = EXPANDED_SIZE / 2; Children = new Drawable[] { - new Container + mainContent = new Container { RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 5, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Padding = new MarginPadding { Horizontal = 2 }, Child = new Container { RelativeSizeAxes = Axes.Both, @@ -138,6 +142,26 @@ namespace osu.Game.Graphics.UserInterface }, true); } + protected override void OnFocus(FocusEvent e) + { + base.OnFocus(e); + + mainContent.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = AccentColour.Darken(1), + Hollow = true, + Radius = 2, + }; + } + + protected override void OnFocusLost(FocusLostEvent e) + { + base.OnFocusLost(e); + + mainContent.EdgeEffect = default; + } + protected override bool OnHover(HoverEvent e) { updateGlow(); @@ -167,8 +191,8 @@ namespace osu.Game.Graphics.UserInterface protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); - LeftBox.Scale = new Vector2(Math.Clamp(RangePadding + Nub.DrawPosition.X - Nub.DrawWidth / 2.15f, 0, Math.Max(0, DrawWidth)), 1); - RightBox.Scale = new Vector2(Math.Clamp(DrawWidth - Nub.DrawPosition.X - RangePadding - Nub.DrawWidth / 2.15f, 0, Math.Max(0, DrawWidth)), 1); + LeftBox.Scale = new Vector2(Math.Clamp(RangePadding + Nub.DrawPosition.X - Nub.DrawWidth / 2.3f, 0, Math.Max(0, DrawWidth)), 1); + RightBox.Scale = new Vector2(Math.Clamp(DrawWidth - Nub.DrawPosition.X - RangePadding - Nub.DrawWidth / 2.3f, 0, Math.Max(0, DrawWidth)), 1); } protected override void UpdateValue(float value) diff --git a/osu.Game/Graphics/UserInterface/TernaryStateMenuItem.cs b/osu.Game/Graphics/UserInterface/TernaryStateMenuItem.cs index d2b6ff2dba..f98628a486 100644 --- a/osu.Game/Graphics/UserInterface/TernaryStateMenuItem.cs +++ b/osu.Game/Graphics/UserInterface/TernaryStateMenuItem.cs @@ -5,6 +5,7 @@ using System; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; namespace osu.Game.Graphics.UserInterface { @@ -20,7 +21,7 @@ namespace osu.Game.Graphics.UserInterface /// A function to inform what the next state should be when this item is clicked. /// The type of action which this performs. /// A delegate to be invoked when this is pressed. - protected TernaryStateMenuItem(string text, Func nextStateFunction, MenuItemType type = MenuItemType.Standard, Action action = null) + protected TernaryStateMenuItem(LocalisableString text, Func nextStateFunction, MenuItemType type = MenuItemType.Standard, Action action = null) : base(text, nextStateFunction, type, action) { } diff --git a/osu.Game/Graphics/UserInterface/TernaryStateRadioMenuItem.cs b/osu.Game/Graphics/UserInterface/TernaryStateRadioMenuItem.cs index 133362d3e6..30fea62cd7 100644 --- a/osu.Game/Graphics/UserInterface/TernaryStateRadioMenuItem.cs +++ b/osu.Game/Graphics/UserInterface/TernaryStateRadioMenuItem.cs @@ -4,6 +4,7 @@ #nullable disable using System; +using osu.Framework.Localisation; namespace osu.Game.Graphics.UserInterface { @@ -18,7 +19,7 @@ namespace osu.Game.Graphics.UserInterface /// The text to display. /// The type of action which this performs. /// A delegate to be invoked when this is pressed. - public TernaryStateRadioMenuItem(string text, MenuItemType type = MenuItemType.Standard, Action action = null) + public TernaryStateRadioMenuItem(LocalisableString text, MenuItemType type = MenuItemType.Standard, Action action = null) : base(text, getNextState, type, action) { } diff --git a/osu.Game/Graphics/UserInterfaceV2/FormColourPalette.cs b/osu.Game/Graphics/UserInterfaceV2/FormColourPalette.cs new file mode 100644 index 0000000000..fad58841e3 --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/FormColourPalette.cs @@ -0,0 +1,238 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Specialized; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osu.Game.Resources.Localisation.Web; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public partial class FormColourPalette : CompositeDrawable + { + public BindableList Colours { get; } = new BindableList(); + + public LocalisableString Caption { get; init; } + public LocalisableString HintText { get; init; } + + private Box background = null!; + private FormFieldCaption caption = null!; + private FillFlowContainer flow = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + Masking = true; + CornerRadius = 5; + + RoundedButton button; + + InternalChildren = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5, + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(9), + Spacing = new Vector2(7), + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + caption = new FormFieldCaption + { + Caption = Caption, + TooltipText = HintText, + }, + flow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Full, + Spacing = new Vector2(5), + Child = button = new RoundedButton + { + Action = addNewColour, + Size = new Vector2(70), + Text = "+", + } + } + }, + }, + }; + + flow.SetLayoutPosition(button, float.MaxValue); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Colours.BindCollectionChanged((_, args) => + { + if (args.Action != NotifyCollectionChangedAction.Replace) + updateColours(); + }, true); + updateState(); + } + + protected override bool OnHover(HoverEvent e) + { + updateState(); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + updateState(); + } + + private void addNewColour() + { + Color4 startingColour = Colours.Count > 0 + ? Colours.Last() + : Colour4.White; + + Colours.Add(startingColour); + flow.OfType().Last().TriggerClick(); + } + + private void updateState() + { + background.Colour = colourProvider.Background5; + caption.Colour = colourProvider.Content2; + + BorderThickness = IsHovered ? 2 : 0; + + if (IsHovered) + BorderColour = colourProvider.Light4; + } + + private void updateColours() + { + flow.RemoveAll(d => d is ColourButton, true); + + for (int i = 0; i < Colours.Count; ++i) + { + // copy to avoid accesses to modified closure. + int colourIndex = i; + var colourButton = new ColourButton { Current = { Value = Colours[colourIndex] } }; + colourButton.Current.BindValueChanged(colour => Colours[colourIndex] = colour.NewValue); + colourButton.DeleteRequested = () => Colours.RemoveAt(colourIndex); + flow.Add(colourButton); + } + } + + private partial class ColourButton : OsuClickableContainer, IHasPopover, IHasContextMenu + { + public Bindable Current { get; } = new Bindable(); + public Action? DeleteRequested { get; set; } + + private Box background = null!; + private OsuSpriteText hexCode = null!; + + [BackgroundDependencyLoader] + private void load() + { + Size = new Vector2(70); + + Masking = true; + CornerRadius = 35; + Action = this.ShowPopover; + + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + }, + hexCode = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(_ => updateState(), true); + } + + public Popover GetPopover() => new ColourPickerPopover + { + Current = { BindTarget = Current } + }; + + public MenuItem[] ContextMenuItems => new MenuItem[] + { + new OsuMenuItem(CommonStrings.ButtonsDelete, MenuItemType.Destructive, () => DeleteRequested?.Invoke()) + }; + + private void updateState() + { + background.Colour = Current.Value; + hexCode.Text = Current.Value.ToHex(); + hexCode.Colour = OsuColour.ForegroundTextColourFor(Current.Value); + } + } + + private partial class ColourPickerPopover : OsuPopover, IHasCurrentValue + { + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + public ColourPickerPopover() + : base(false) + { + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Child = new OsuColourPicker + { + Current = { BindTarget = Current } + }; + + Body.BorderThickness = 2; + Body.BorderColour = colourProvider.Highlight1; + Content.Padding = new MarginPadding(2); + } + } + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/FormFileSelector.cs b/osu.Game/Graphics/UserInterfaceV2/FormFileSelector.cs index 55cc026d7c..81023417a5 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FormFileSelector.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FormFileSelector.cs @@ -68,6 +68,8 @@ namespace osu.Game.Graphics.UserInterfaceV2 /// public LocalisableString PlaceholderText { get; init; } + public Container PreviewContainer { get; private set; } = null!; + private Box background = null!; private FormFieldCaption caption = null!; @@ -89,7 +91,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 private void load() { RelativeSizeAxes = Axes.X; - Height = 50; + AutoSizeAxes = Axes.Y; Masking = true; CornerRadius = 5; @@ -101,9 +103,23 @@ namespace osu.Game.Graphics.UserInterfaceV2 RelativeSizeAxes = Axes.Both, Colour = colourProvider.Background5, }, + PreviewContainer = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding + { + Horizontal = 1.5f, + Top = 1.5f, + Bottom = 50 + }, + }, new Container { - RelativeSizeAxes = Axes.Both, + RelativeSizeAxes = Axes.X, + Height = 50, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, Padding = new MarginPadding(9), Children = new Drawable[] { @@ -148,12 +164,13 @@ namespace osu.Game.Graphics.UserInterfaceV2 base.LoadComplete(); popoverState.BindValueChanged(_ => updateState()); + current.BindDisabledChanged(_ => updateState()); current.BindValueChanged(_ => { updateState(); onFileSelected(); - }); - current.BindDisabledChanged(_ => updateState(), true); + }, true); + FinishTransforms(true); game.RegisterImportHandler(this); } @@ -189,7 +206,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 private void updateState() { caption.Colour = Current.Disabled ? colourProvider.Foreground1 : colourProvider.Content2; - filenameText.Colour = Current.Disabled ? colourProvider.Foreground1 : colourProvider.Content1; + filenameText.Colour = Current.Disabled || Current.Value == null ? colourProvider.Foreground1 : colourProvider.Content1; if (!Current.Disabled) { @@ -244,6 +261,9 @@ namespace osu.Game.Graphics.UserInterfaceV2 Child = new Container { Size = new Vector2(600, 400), + // simplest solution to avoid underlying text to bleed through the bottom border + // https://github.com/ppy/osu/pull/30005#issuecomment-2378884430 + Padding = new MarginPadding { Bottom = 1 }, Child = new OsuFileSelector(chooserPath, handledExtensions) { RelativeSizeAxes = Axes.Both, diff --git a/osu.Game/Graphics/UserInterfaceV2/FormNumberBox.cs b/osu.Game/Graphics/UserInterfaceV2/FormNumberBox.cs index 66f1a45210..c3256e0038 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FormNumberBox.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FormNumberBox.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Globalization; + namespace osu.Game.Graphics.UserInterfaceV2 { public partial class FormNumberBox : FormTextBox @@ -10,6 +12,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 internal override InnerTextBox CreateTextBox() => new InnerNumberBox { AllowDecimals = AllowDecimals, + SelectAllOnFocus = true, }; internal partial class InnerNumberBox : InnerTextBox @@ -17,7 +20,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 public bool AllowDecimals { get; init; } protected override bool CanAddCharacter(char character) - => char.IsAsciiDigit(character) || (AllowDecimals && character == '.'); + => char.IsAsciiDigit(character) || (AllowDecimals && CultureInfo.CurrentCulture.NumberFormat.NumberDecimalSeparator.Contains(character)); } } } diff --git a/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs b/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs index ac3730598f..532423876e 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs @@ -17,6 +17,7 @@ using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; using osu.Game.Overlays; namespace osu.Game.Graphics.UserInterfaceV2 @@ -27,27 +28,23 @@ namespace osu.Game.Graphics.UserInterfaceV2 public Bindable Current { get => current.Current; - set => current.Current = value; - } - - private bool instantaneous = true; - - /// - /// Whether changes to the slider should instantaneously transfer to the text box (and vice versa). - /// If , the transfer will happen on text box commit (explicit, or implicit via focus loss), or on slider drag end. - /// - public bool Instantaneous - { - get => instantaneous; set { - instantaneous = value; - - if (slider.IsNotNull()) - slider.TransferValueOnCommit = !instantaneous; + current.Current = value; + currentNumberInstantaneous.Default = current.Default; } } + private readonly BindableNumberWithCurrent current = new BindableNumberWithCurrent(); + + private readonly BindableNumber currentNumberInstantaneous = new BindableNumber(); + + /// + /// Whether changes to the value should instantaneously transfer to outside bindables. + /// If , the transfer will happen on text box commit (explicit, or implicit via focus loss), or on slider commit. + /// + public bool TransferValueOnCommit { get; set; } + private CompositeDrawable? tabbableContentContainer; public CompositeDrawable? TabbableContentContainer @@ -61,8 +58,6 @@ namespace osu.Game.Graphics.UserInterfaceV2 } } - private readonly BindableNumberWithCurrent current = new BindableNumberWithCurrent(); - /// /// Caption describing this slider bar, displayed on top of the controls. /// @@ -76,15 +71,17 @@ namespace osu.Game.Graphics.UserInterfaceV2 private Box background = null!; private Box flashLayer = null!; private FormTextBox.InnerTextBox textBox = null!; - private Slider slider = null!; + private InnerSlider slider = null!; private FormFieldCaption caption = null!; private IFocusManager focusManager = null!; [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; + private readonly Bindable currentLanguage = new Bindable(); + [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OsuColour colours, OsuGame? game) { RelativeSizeAxes = Axes.X; Height = 50; @@ -107,7 +104,12 @@ namespace osu.Game.Graphics.UserInterfaceV2 new Container { RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding(9), + Padding = new MarginPadding + { + Vertical = 9, + Left = 9, + Right = 5, + }, Children = new Drawable[] { caption = new FormFieldCaption @@ -133,18 +135,21 @@ namespace osu.Game.Graphics.UserInterfaceV2 }, TabbableContentContainer = tabbableContentContainer, }, - slider = new Slider + slider = new InnerSlider { Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, RelativeSizeAxes = Axes.X, Width = 0.5f, - Current = Current, - TransferValueOnCommit = !instantaneous, + Current = currentNumberInstantaneous, + OnCommit = () => current.Value = currentNumberInstantaneous.Value, } }, }, }; + + if (game != null) + currentLanguage.BindTo(game.CurrentLanguage); } protected override void LoadComplete() @@ -158,11 +163,32 @@ namespace osu.Game.Graphics.UserInterfaceV2 textBox.Current.BindValueChanged(textChanged); slider.IsDragging.BindValueChanged(_ => updateState()); + slider.Focused.BindValueChanged(_ => updateState()); - current.BindValueChanged(_ => + current.ValueChanged += e => currentNumberInstantaneous.Value = e.NewValue; + current.MinValueChanged += v => currentNumberInstantaneous.MinValue = v; + current.MaxValueChanged += v => currentNumberInstantaneous.MaxValue = v; + current.PrecisionChanged += v => currentNumberInstantaneous.Precision = v; + current.DisabledChanged += disabled => { + if (disabled) + { + // revert any changes before disabling to make sure we are in a consistent state. + currentNumberInstantaneous.Value = current.Value; + } + + currentNumberInstantaneous.Disabled = disabled; + }; + + current.CopyTo(currentNumberInstantaneous); + currentLanguage.BindValueChanged(_ => Schedule(updateValueDisplay)); + currentNumberInstantaneous.BindValueChanged(e => + { + if (!TransferValueOnCommit) + current.Value = e.NewValue; + updateState(); - updateTextBoxFromSlider(); + updateValueDisplay(); }, true); } @@ -170,17 +196,15 @@ namespace osu.Game.Graphics.UserInterfaceV2 private void textChanged(ValueChangedEvent change) { - if (!instantaneous) return; - tryUpdateSliderFromTextBox(); } private void textCommitted(TextBox t, bool isNew) { tryUpdateSliderFromTextBox(); - // If the attempted update above failed, restore text box to match the slider. - Current.TriggerChange(); + currentNumberInstantaneous.TriggerChange(); + current.Value = currentNumberInstantaneous.Value; flashLayer.Colour = ColourInfo.GradientVertical(colourProvider.Dark2.Opacity(0), colourProvider.Dark2); flashLayer.FadeOutFromOne(800, Easing.OutQuint); @@ -192,7 +216,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 try { - switch (Current) + switch (currentNumberInstantaneous) { case Bindable bindableInt: bindableInt.Value = int.Parse(textBox.Current.Value); @@ -203,7 +227,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 break; default: - Current.Parse(textBox.Current.Value, CultureInfo.CurrentCulture); + currentNumberInstantaneous.Parse(textBox.Current.Value, CultureInfo.CurrentCulture); break; } } @@ -236,16 +260,18 @@ namespace osu.Game.Graphics.UserInterfaceV2 private void updateState() { + bool childHasFocus = slider.Focused.Value || textBox.Focused.Value; + textBox.Alpha = 1; - background.Colour = Current.Disabled ? colourProvider.Background4 : colourProvider.Background5; - caption.Colour = Current.Disabled ? colourProvider.Foreground1 : colourProvider.Content2; - textBox.Colour = Current.Disabled ? colourProvider.Foreground1 : colourProvider.Content1; + background.Colour = currentNumberInstantaneous.Disabled ? colourProvider.Background4 : colourProvider.Background5; + caption.Colour = currentNumberInstantaneous.Disabled ? colourProvider.Foreground1 : colourProvider.Content2; + textBox.Colour = currentNumberInstantaneous.Disabled ? colourProvider.Foreground1 : colourProvider.Content1; - BorderThickness = IsHovered || textBox.Focused.Value || slider.IsDragging.Value ? 2 : 0; - BorderColour = textBox.Focused.Value ? colourProvider.Highlight1 : colourProvider.Light4; + BorderThickness = childHasFocus || IsHovered || slider.IsDragging.Value ? 2 : 0; + BorderColour = childHasFocus ? colourProvider.Highlight1 : colourProvider.Light4; - if (textBox.Focused.Value) + if (childHasFocus) background.Colour = ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Dark3); else if (IsHovered || slider.IsDragging.Value) background.Colour = ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Dark4); @@ -253,16 +279,19 @@ namespace osu.Game.Graphics.UserInterfaceV2 background.Colour = colourProvider.Background5; } - private void updateTextBoxFromSlider() + private void updateValueDisplay() { if (updatingFromTextBox) return; - textBox.Text = slider.GetDisplayableValue(Current.Value).ToString(); + textBox.Text = slider.GetDisplayableValue(currentNumberInstantaneous.Value).ToString(); } - private partial class Slider : OsuSliderBar + private partial class InnerSlider : OsuSliderBar { + public BindableBool Focused { get; } = new BindableBool(); + public BindableBool IsDragging { get; set; } = new BindableBool(); + public Action? OnCommit { get; set; } private Box leftBox = null!; private Box rightBox = null!; @@ -320,7 +349,6 @@ namespace osu.Game.Graphics.UserInterfaceV2 protected override void LoadComplete() { base.LoadComplete(); - updateState(); } @@ -358,17 +386,41 @@ namespace osu.Game.Graphics.UserInterfaceV2 base.OnHoverLost(e); } + protected override void OnFocus(FocusEvent e) + { + updateState(); + Focused.Value = true; + base.OnFocus(e); + } + + protected override void OnFocusLost(FocusLostEvent e) + { + updateState(); + Focused.Value = false; + base.OnFocusLost(e); + } + private void updateState() { rightBox.Colour = colourProvider.Background6; - leftBox.Colour = IsHovered || IsDragged ? colourProvider.Highlight1.Opacity(0.5f) : colourProvider.Dark2; - nub.Colour = IsHovered || IsDragged ? colourProvider.Highlight1 : colourProvider.Light4; + leftBox.Colour = HasFocus || IsHovered || IsDragged ? colourProvider.Highlight1.Opacity(0.5f) : colourProvider.Dark2; + nub.Colour = HasFocus || IsHovered || IsDragged ? colourProvider.Highlight1 : colourProvider.Light4; } protected override void UpdateValue(float value) { nub.MoveToX(value, 200, Easing.OutPow10); } + + protected override bool Commit() + { + bool result = base.Commit(); + + if (result) + OnCommit?.Invoke(); + + return result; + } } } } diff --git a/osu.Game/Graphics/UserInterfaceV2/FormTextBox.cs b/osu.Game/Graphics/UserInterfaceV2/FormTextBox.cs index 9bbb5cba99..973419310c 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FormTextBox.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FormTextBox.cs @@ -202,6 +202,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 } else { + BorderThickness = 0; background.Colour = colourProvider.Background4; } } diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index aca0984e0f..02ede0a2f8 100644 --- a/osu.Game/Input/Bindings/GlobalActionContainer.cs +++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs @@ -101,7 +101,7 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.Alt, InputKey.Home }, GlobalAction.Home), - new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.F }, GlobalAction.ToggleFPSDisplay), + new KeyBinding(InputKey.None, GlobalAction.ToggleFPSDisplay), new KeyBinding(new[] { InputKey.Control, InputKey.T }, GlobalAction.ToggleToolbar), new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.S }, GlobalAction.ToggleSkinEditor), @@ -134,7 +134,8 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.Control, InputKey.D }, GlobalAction.EditorCloneSelection), new KeyBinding(new[] { InputKey.J }, GlobalAction.EditorNudgeLeft), new KeyBinding(new[] { InputKey.K }, GlobalAction.EditorNudgeRight), - new KeyBinding(new[] { InputKey.G }, GlobalAction.EditorCycleGridDisplayMode), + new KeyBinding(new[] { InputKey.G }, GlobalAction.EditorCycleGridSpacing), + new KeyBinding(new[] { InputKey.Shift, InputKey.G }, GlobalAction.EditorCycleGridType), new KeyBinding(new[] { InputKey.F5 }, GlobalAction.EditorTestGameplay), new KeyBinding(new[] { InputKey.T }, GlobalAction.EditorTapForBPM), new KeyBinding(new[] { InputKey.Control, InputKey.H }, GlobalAction.EditorFlipHorizontally), @@ -368,8 +369,8 @@ namespace osu.Game.Input.Bindings [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleChatFocus))] ToggleChatFocus, - [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorCycleGridDisplayMode))] - EditorCycleGridDisplayMode, + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorCycleGridSpacing))] + EditorCycleGridSpacing, [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorTestGameplay))] EditorTestGameplay, @@ -472,6 +473,9 @@ namespace osu.Game.Input.Bindings [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorSeekToNextSamplePoint))] EditorSeekToNextSamplePoint, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorCycleGridType))] + EditorCycleGridType, } public enum GlobalActionCategory diff --git a/osu.Game/Input/ConfineMouseTracker.cs b/osu.Game/Input/ConfineMouseTracker.cs index 926f68df45..eeda92a585 100644 --- a/osu.Game/Input/ConfineMouseTracker.cs +++ b/osu.Game/Input/ConfineMouseTracker.cs @@ -15,7 +15,7 @@ namespace osu.Game.Input { /// /// Connects with . - /// If is true, we should also confine the mouse cursor if it has been + /// If is playing, we should also confine the mouse cursor if it has been /// requested with . /// public partial class ConfineMouseTracker : Component @@ -25,7 +25,7 @@ namespace osu.Game.Input private Bindable frameworkMinimiseOnFocusLossInFullscreen; private Bindable osuConfineMode; - private IBindable localUserPlaying; + private IBindable localUserPlaying; [BackgroundDependencyLoader] private void load(ILocalUserPlayInfo localUserInfo, FrameworkConfigManager frameworkConfigManager, OsuConfigManager osuConfigManager) @@ -37,7 +37,7 @@ namespace osu.Game.Input frameworkMinimiseOnFocusLossInFullscreen.BindValueChanged(_ => updateConfineMode()); osuConfineMode = osuConfigManager.GetBindable(OsuSetting.ConfineMouseMode); - localUserPlaying = localUserInfo.IsPlaying.GetBoundCopy(); + localUserPlaying = localUserInfo.PlayingState.GetBoundCopy(); osuConfineMode.ValueChanged += _ => updateConfineMode(); localUserPlaying.BindValueChanged(_ => updateConfineMode(), true); @@ -63,7 +63,7 @@ namespace osu.Game.Input break; case OsuConfineMouseMode.DuringGameplay: - frameworkConfineMode.Value = localUserPlaying.Value ? ConfineMouseMode.Always : ConfineMouseMode.Never; + frameworkConfineMode.Value = localUserPlaying.Value == LocalUserPlayingState.Playing ? ConfineMouseMode.Always : ConfineMouseMode.Never; break; case OsuConfineMouseMode.Always: diff --git a/osu.Game/Input/OsuUserInputManager.cs b/osu.Game/Input/OsuUserInputManager.cs index b8fd0bb11c..2138a8b247 100644 --- a/osu.Game/Input/OsuUserInputManager.cs +++ b/osu.Game/Input/OsuUserInputManager.cs @@ -3,15 +3,16 @@ using osu.Framework.Bindables; using osu.Framework.Input; +using osu.Game.Screens.Play; using osuTK.Input; namespace osu.Game.Input { public partial class OsuUserInputManager : UserInputManager { - protected override bool AllowRightClickFromLongTouch => !LocalUserPlaying.Value; + protected override bool AllowRightClickFromLongTouch => PlayingState.Value != LocalUserPlayingState.Playing; - public readonly BindableBool LocalUserPlaying = new BindableBool(); + public readonly IBindable PlayingState = new Bindable(); internal OsuUserInputManager() { 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/DeleteConfirmationContentStrings.cs b/osu.Game/Localisation/DeleteConfirmationContentStrings.cs index d781fadbce..2b2f4dda54 100644 --- a/osu.Game/Localisation/DeleteConfirmationContentStrings.cs +++ b/osu.Game/Localisation/DeleteConfirmationContentStrings.cs @@ -19,6 +19,11 @@ namespace osu.Game.Localisation /// public static LocalisableString BeatmapVideos => new TranslatableString(getKey(@"beatmap_videos"), @"Are you sure you want to delete all beatmaps videos? This cannot be undone!"); + /// + /// "Are you sure you want to reset all local beatmap offsets? This cannot be undone!" + /// + public static LocalisableString Offsets => new TranslatableString(getKey(@"offsets"), @"Are you sure you want to reset all local beatmap offsets? This cannot be undone!"); + /// /// "Are you sure you want to delete all skins? This cannot be undone!" /// diff --git a/osu.Game/Localisation/DialogStrings.cs b/osu.Game/Localisation/DialogStrings.cs index 043a3f5b4c..a7634575b8 100644 --- a/osu.Game/Localisation/DialogStrings.cs +++ b/osu.Game/Localisation/DialogStrings.cs @@ -12,7 +12,12 @@ namespace osu.Game.Localisation /// /// "Caution" /// - public static LocalisableString Caution => new TranslatableString(getKey(@"header_text"), @"Caution"); + public static LocalisableString CautionHeaderText => new TranslatableString(getKey(@"header_text"), @"Caution"); + + /// + /// "Are you sure you want to delete the following:" + /// + public static LocalisableString DeletionHeaderText => new TranslatableString(getKey(@"deletion_header_text"), @"Are you sure you want to delete the following:"); /// /// "Yes. Go for it." diff --git a/osu.Game/Localisation/EditorStrings.cs b/osu.Game/Localisation/EditorStrings.cs index bcffc18d4d..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)" /// @@ -114,6 +119,11 @@ namespace osu.Game.Localisation /// public static LocalisableString LimitedDistanceSnap => new TranslatableString(getKey(@"limited_distance_snap_grid"), @"Limit distance snap placement to current time"); + /// + /// "Contract sidebars when not hovered" + /// + public static LocalisableString ContractSidebars => new TranslatableString(getKey(@"contract_sidebars"), @"Contract sidebars when not hovered"); + /// /// "Must be in edit mode to handle editor links" /// @@ -134,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/GameplaySettingsStrings.cs b/osu.Game/Localisation/GameplaySettingsStrings.cs index 6de61f7ebe..ff6a6102a7 100644 --- a/osu.Game/Localisation/GameplaySettingsStrings.cs +++ b/osu.Game/Localisation/GameplaySettingsStrings.cs @@ -89,6 +89,11 @@ namespace osu.Game.Localisation /// public static LocalisableString AlwaysShowHoldForMenuButton => new TranslatableString(getKey(@"always_show_hold_for_menu_button"), @"Always show hold for menu button"); + /// + /// "Require holding key to pause gameplay" + /// + public static LocalisableString AlwaysRequireHoldForMenu => new TranslatableString(getKey(@"require_holding_key_to_pause_gameplay"), @"Require holding key to pause gameplay"); + /// /// "Always play first combo break sound" /// diff --git a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs index 206db1a166..ed80704a0a 100644 --- a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs +++ b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs @@ -190,9 +190,14 @@ namespace osu.Game.Localisation public static LocalisableString EditorCloneSelection => new TranslatableString(getKey(@"editor_clone_selection"), @"Clone selection"); /// - /// "Cycle grid display mode" + /// "Cycle grid spacing" /// - public static LocalisableString EditorCycleGridDisplayMode => new TranslatableString(getKey(@"editor_cycle_grid_display_mode"), @"Cycle grid display mode"); + public static LocalisableString EditorCycleGridSpacing => new TranslatableString(getKey(@"editor_cycle_grid_spacing"), @"Cycle grid spacing"); + + /// + /// "Cycle grid type" + /// + public static LocalisableString EditorCycleGridType => new TranslatableString(getKey(@"editor_cycle_grid_type"), @"Cycle grid type"); /// /// "Test gameplay" diff --git a/osu.Game/Localisation/MaintenanceSettingsStrings.cs b/osu.Game/Localisation/MaintenanceSettingsStrings.cs index 03e15e8393..6d5e0d5e0e 100644 --- a/osu.Game/Localisation/MaintenanceSettingsStrings.cs +++ b/osu.Game/Localisation/MaintenanceSettingsStrings.cs @@ -59,6 +59,11 @@ namespace osu.Game.Localisation /// public static LocalisableString DeleteAllBeatmapVideos => new TranslatableString(getKey(@"delete_all_beatmap_videos"), @"Delete ALL beatmap videos"); + /// + /// "Reset ALL beatmap offsets" + /// + public static LocalisableString ResetAllOffsets => new TranslatableString(getKey(@"reset_all_offsets"), @"Reset ALL beatmap offsets"); + /// /// "Delete ALL scores" /// 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/Localisation/RulesetSettingsStrings.cs b/osu.Game/Localisation/RulesetSettingsStrings.cs index e3d51f1124..9434cd53de 100644 --- a/osu.Game/Localisation/RulesetSettingsStrings.cs +++ b/osu.Game/Localisation/RulesetSettingsStrings.cs @@ -80,9 +80,9 @@ namespace osu.Game.Localisation public static LocalisableString TimingBasedColouring => new TranslatableString(getKey(@"Timing_based_colouring"), @"Timing-based note colouring"); /// - /// "{0}ms (speed {1})" + /// "{0}ms (speed {1:N1})" /// - public static LocalisableString ScrollSpeedTooltip(int scrollTime, int scrollSpeed) => new TranslatableString(getKey(@"ruleset"), @"{0}ms (speed {1})", scrollTime, scrollSpeed); + public static LocalisableString ScrollSpeedTooltip(int scrollTime, double scrollSpeed) => new TranslatableString(getKey(@"ruleset"), @"{0}ms (speed {1:N1})", scrollTime, scrollSpeed); /// /// "Touch control scheme" diff --git a/osu.Game/Localisation/SkinComponents/BeatmapAttributeTextStrings.cs b/osu.Game/Localisation/SkinComponents/BeatmapAttributeTextStrings.cs index b2e2285faf..390a6f9ca4 100644 --- a/osu.Game/Localisation/SkinComponents/BeatmapAttributeTextStrings.cs +++ b/osu.Game/Localisation/SkinComponents/BeatmapAttributeTextStrings.cs @@ -12,23 +12,28 @@ namespace osu.Game.Localisation.SkinComponents /// /// "Attribute" /// - public static LocalisableString Attribute => new TranslatableString(getKey(@"attribute"), "Attribute"); + public static LocalisableString Attribute => new TranslatableString(getKey(@"attribute"), @"Attribute"); /// /// "The attribute to be displayed." /// - public static LocalisableString AttributeDescription => new TranslatableString(getKey(@"attribute_description"), "The attribute to be displayed."); + public static LocalisableString AttributeDescription => new TranslatableString(getKey(@"attribute_description"), @"The attribute to be displayed."); /// /// "Template" /// - public static LocalisableString Template => new TranslatableString(getKey(@"template"), "Template"); + public static LocalisableString Template => new TranslatableString(getKey(@"template"), @"Template"); /// /// "Supports {{Label}} and {{Value}}, but also including arbitrary attributes like {{StarRating}} (see attribute list for supported values)." /// public static LocalisableString TemplateDescription => new TranslatableString(getKey(@"template_description"), @"Supports {{Label}} and {{Value}}, but also including arbitrary attributes like {{StarRating}} (see attribute list for supported values)."); - private static string getKey(string key) => $"{prefix}:{key}"; + /// + /// "Max PP" + /// + public static LocalisableString MaxPP => new TranslatableString(getKey(@"max_pp"), @"Max PP"); + + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/SkinComponents/SkinnableComponentStrings.cs b/osu.Game/Localisation/SkinComponents/SkinnableComponentStrings.cs index 33fda23cb0..b21446e18a 100644 --- a/osu.Game/Localisation/SkinComponents/SkinnableComponentStrings.cs +++ b/osu.Game/Localisation/SkinComponents/SkinnableComponentStrings.cs @@ -79,6 +79,11 @@ namespace osu.Game.Localisation.SkinComponents /// public static LocalisableString TextColourDescription => new TranslatableString(getKey(@"text_colour_description"), @"The colour of the text."); + /// + /// "Use relative size" + /// + public static LocalisableString UseRelativeSize => new TranslatableString(getKey(@"use_relative_size"), @"Use relative size"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/SkinEditorStrings.cs b/osu.Game/Localisation/SkinEditorStrings.cs index 3c1d1ff40d..d96ea7dd9f 100644 --- a/osu.Game/Localisation/SkinEditorStrings.cs +++ b/osu.Game/Localisation/SkinEditorStrings.cs @@ -49,6 +49,51 @@ namespace osu.Game.Localisation /// public static LocalisableString RevertToDefaultDescription => new TranslatableString(getKey(@"revert_to_default_description"), @"All layout elements for layers in the current screen will be reset to defaults."); + /// + /// "Closest" + /// + public static LocalisableString Closest => new TranslatableString(getKey(@"closest"), @"Closest"); + + /// + /// "Anchor" + /// + public static LocalisableString Anchor => new TranslatableString(getKey(@"anchor"), @"Anchor"); + + /// + /// "Origin" + /// + public static LocalisableString Origin => new TranslatableString(getKey(@"origin"), @"Origin"); + + /// + /// "Reset position" + /// + public static LocalisableString ResetPosition => new TranslatableString(getKey(@"reset_position"), @"Reset position"); + + /// + /// "Reset rotation" + /// + public static LocalisableString ResetRotation => new TranslatableString(getKey(@"reset_rotation"), @"Reset rotation"); + + /// + /// "Reset scale" + /// + public static LocalisableString ResetScale => new TranslatableString(getKey(@"reset_scale"), @"Reset scale"); + + /// + /// "Bring to front" + /// + public static LocalisableString BringToFront => new TranslatableString(getKey(@"bring_to_front"), @"Bring to front"); + + /// + /// "Send to back" + /// + public static LocalisableString SendToBack => new TranslatableString(getKey(@"send_to_back"), @"Send to back"); + + /// + /// "Current working layer" + /// + public static LocalisableString CurrentWorkingLayer => new TranslatableString(getKey(@"current_working_layer"), @"Current working layer"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } 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/GetBeatmapRequest.cs b/osu.Game/Online/API/Requests/GetBeatmapRequest.cs index 3383d21dfc..14bb0d3122 100644 --- a/osu.Game/Online/API/Requests/GetBeatmapRequest.cs +++ b/osu.Game/Online/API/Requests/GetBeatmapRequest.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.Globalization; using osu.Framework.IO.Network; using osu.Game.Beatmaps; using osu.Game.Online.API.Requests.Responses; @@ -9,23 +10,30 @@ namespace osu.Game.Online.API.Requests { public class GetBeatmapRequest : APIRequest { - public readonly IBeatmapInfo BeatmapInfo; - public readonly string Filename; + public readonly int OnlineID; + public readonly string? MD5Hash; + public readonly string? Filename; public GetBeatmapRequest(IBeatmapInfo beatmapInfo) + : this(onlineId: beatmapInfo.OnlineID, md5Hash: beatmapInfo.MD5Hash, filename: (beatmapInfo as BeatmapInfo)?.Path) { - BeatmapInfo = beatmapInfo; - Filename = (beatmapInfo as BeatmapInfo)?.Path ?? string.Empty; + } + + public GetBeatmapRequest(int onlineId = -1, string? md5Hash = null, string? filename = null) + { + OnlineID = onlineId; + MD5Hash = md5Hash; + Filename = filename; } protected override WebRequest CreateWebRequest() { var request = base.CreateWebRequest(); - if (BeatmapInfo.OnlineID > 0) - request.AddParameter(@"id", BeatmapInfo.OnlineID.ToString()); - if (!string.IsNullOrEmpty(BeatmapInfo.MD5Hash)) - request.AddParameter(@"checksum", BeatmapInfo.MD5Hash); + if (OnlineID > 0) + request.AddParameter(@"id", OnlineID.ToString(CultureInfo.InvariantCulture)); + if (!string.IsNullOrEmpty(MD5Hash)) + request.AddParameter(@"checksum", MD5Hash); if (!string.IsNullOrEmpty(Filename)) request.AddParameter(@"filename", Filename); 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/GetUsersRequest.cs b/osu.Game/Online/API/Requests/GetUsersRequest.cs index 6f7e9c07d2..cd75ff4e31 100644 --- a/osu.Game/Online/API/Requests/GetUsersRequest.cs +++ b/osu.Game/Online/API/Requests/GetUsersRequest.cs @@ -2,9 +2,15 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Online.API.Requests { + /// + /// Looks up users with the given . + /// In comparison to , the response here contains , + /// but in exchange is subject to more stringent rate limiting. + /// public class GetUsersRequest : APIRequest { public readonly int[] UserIds; diff --git a/osu.Game/Online/API/Requests/LookupUsersRequest.cs b/osu.Game/Online/API/Requests/LookupUsersRequest.cs new file mode 100644 index 0000000000..6e98ce064e --- /dev/null +++ b/osu.Game/Online/API/Requests/LookupUsersRequest.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; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Online.API.Requests +{ + /// + /// Looks up users with the given . + /// In comparison to , the response here does not contain , + /// but in exchange is subject to less stringent rate limiting, making it suitable for mass user listings. + /// + public class LookupUsersRequest : APIRequest + { + public readonly int[] UserIds; + + private const int max_ids_per_request = 50; + + public LookupUsersRequest(int[] userIds) + { + if (userIds.Length > max_ids_per_request) + throw new ArgumentException($"{nameof(LookupUsersRequest)} calls only support up to {max_ids_per_request} IDs at once"); + + UserIds = userIds; + } + + protected override string Target => @"users/lookup/?ids[]=" + string.Join(@"&ids[]=", UserIds); + } +} 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/API/Requests/Responses/APIUser.cs b/osu.Game/Online/API/Requests/Responses/APIUser.cs index c69e45b3fd..5d80fde515 100644 --- a/osu.Game/Online/API/Requests/Responses/APIUser.cs +++ b/osu.Game/Online/API/Requests/Responses/APIUser.cs @@ -261,7 +261,7 @@ namespace osu.Game.Online.API.Requests.Responses public APIUserHistoryCount[] ReplaysWatchedCounts; /// - /// All user statistics per ruleset's short name (in the case of a response). + /// All user statistics per ruleset's short name (in the case of a or response). /// Otherwise empty. Can be altered for testing purposes. /// // todo: this should likely be moved to a separate UserCompact class at some point. diff --git a/osu.Game/Online/API/Requests/Responses/CommentableMeta.cs b/osu.Game/Online/API/Requests/Responses/CommentableMeta.cs index 1084f1c900..4b4595fef6 100644 --- a/osu.Game/Online/API/Requests/Responses/CommentableMeta.cs +++ b/osu.Game/Online/API/Requests/Responses/CommentableMeta.cs @@ -24,5 +24,14 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty("url")] public string Url { get; set; } = string.Empty; + + [JsonProperty("current_user_attributes")] + public CommentableCurrentUserAttributes? CurrentUserAttributes { get; set; } + + public struct CommentableCurrentUserAttributes + { + [JsonProperty("can_new_comment_reason")] + public string? CanNewCommentReason { get; set; } + } } } diff --git a/osu.Game/Online/Chat/ExternalLinkOpener.cs b/osu.Game/Online/Chat/ExternalLinkOpener.cs index 90fec5fafd..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 { @@ -46,7 +46,7 @@ namespace osu.Game.Online.Chat { public ExternalLinkDialog(string url, Action openExternalLinkAction, Action copyExternalLinkAction) { - HeaderText = DialogStrings.Caution; + HeaderText = DialogStrings.CautionHeaderText; BodyText = $"Are you sure you want to open the following link in a web browser?\n\n{url}"; Icon = FontAwesome.Solid.ExclamationTriangle; @@ -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/Chat/MessageFormatter.cs b/osu.Game/Online/Chat/MessageFormatter.cs index 77454c4775..f354eea027 100644 --- a/osu.Game/Online/Chat/MessageFormatter.cs +++ b/osu.Game/Online/Chat/MessageFormatter.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; +using System.Web; using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets.Edit; @@ -234,7 +235,7 @@ namespace osu.Game.Online.Chat return new LinkDetails(LinkAction.External, url); } - return new LinkDetails(linkType, args[2]); + return new LinkDetails(linkType, HttpUtility.UrlDecode(args[2])); case "osump": return new LinkDetails(LinkAction.JoinMultiplayerMatch, args[1]); diff --git a/osu.Game/Online/Chat/StandAloneChatDisplay.cs b/osu.Game/Online/Chat/StandAloneChatDisplay.cs index e100b5fe5b..187191d232 100644 --- a/osu.Game/Online/Chat/StandAloneChatDisplay.cs +++ b/osu.Game/Online/Chat/StandAloneChatDisplay.cs @@ -205,7 +205,6 @@ namespace osu.Game.Online.Chat protected partial class StandAloneMessage : ChatLine { - protected override float FontSize => 13; protected override float Spacing => 5; protected override float UsernameWidth => 90; 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/InvalidPasswordException.cs b/osu.Game/Online/Multiplayer/InvalidPasswordException.cs index d3da8f491b..b76a1cc05d 100644 --- a/osu.Game/Online/Multiplayer/InvalidPasswordException.cs +++ b/osu.Game/Online/Multiplayer/InvalidPasswordException.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Runtime.Serialization; using Microsoft.AspNetCore.SignalR; namespace osu.Game.Online.Multiplayer @@ -10,13 +9,5 @@ namespace osu.Game.Online.Multiplayer [Serializable] public class InvalidPasswordException : HubException { - public InvalidPasswordException() - { - } - - protected InvalidPasswordException(SerializationInfo info, StreamingContext context) - : base(info, context) - { - } } } diff --git a/osu.Game/Online/Multiplayer/InvalidStateChangeException.cs b/osu.Game/Online/Multiplayer/InvalidStateChangeException.cs index 4c793dba68..2bae31196a 100644 --- a/osu.Game/Online/Multiplayer/InvalidStateChangeException.cs +++ b/osu.Game/Online/Multiplayer/InvalidStateChangeException.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Runtime.Serialization; using Microsoft.AspNetCore.SignalR; namespace osu.Game.Online.Multiplayer @@ -14,10 +13,5 @@ namespace osu.Game.Online.Multiplayer : base($"Cannot change from {oldState} to {newState}") { } - - protected InvalidStateChangeException(SerializationInfo info, StreamingContext context) - : base(info, context) - { - } } } diff --git a/osu.Game/Online/Multiplayer/InvalidStateException.cs b/osu.Game/Online/Multiplayer/InvalidStateException.cs index 27b111a781..c9705e9e53 100644 --- a/osu.Game/Online/Multiplayer/InvalidStateException.cs +++ b/osu.Game/Online/Multiplayer/InvalidStateException.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Runtime.Serialization; using Microsoft.AspNetCore.SignalR; namespace osu.Game.Online.Multiplayer @@ -14,10 +13,5 @@ namespace osu.Game.Online.Multiplayer : base(message) { } - - protected InvalidStateException(SerializationInfo info, StreamingContext context) - : base(info, context) - { - } } } diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index 4aa0d92098..998a34931d 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; -using System.Text; using System.Threading; using System.Threading.Tasks; using osu.Framework.Allocation; @@ -15,6 +14,7 @@ using osu.Framework.Graphics; using osu.Game.Database; using osu.Game.Localisation; using osu.Game.Online.API; +using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer.Countdown; using osu.Game.Online.Rooms; @@ -180,15 +180,15 @@ namespace osu.Game.Online.Multiplayer await joinOrLeaveTaskChain.Add(async () => { - Debug.Assert(room.RoomID.Value != null); + Debug.Assert(room.RoomID != null); // Join the server-side room. - var joinedRoom = await JoinRoom(room.RoomID.Value.Value, password ?? room.Password.Value).ConfigureAwait(false); + var joinedRoom = await JoinRoom(room.RoomID.Value, password ?? room.Password).ConfigureAwait(false); Debug.Assert(joinedRoom != null); // Populate users. Debug.Assert(joinedRoom.Users != null); - await Task.WhenAll(joinedRoom.Users.Select(PopulateUser)).ConfigureAwait(false); + await PopulateUsers(joinedRoom.Users).ConfigureAwait(false); // Update the stored room (must be done on update thread for thread-safety). await runOnUpdateThreadAsync(() => @@ -200,12 +200,11 @@ namespace osu.Game.Online.Multiplayer Debug.Assert(joinedRoom.Playlist.Count > 0); - APIRoom.Playlist.Clear(); - APIRoom.Playlist.AddRange(joinedRoom.Playlist.Select(createPlaylistItem)); - APIRoom.CurrentPlaylistItem.Value = APIRoom.Playlist.Single(item => item.ID == joinedRoom.Settings.PlaylistItemId); + APIRoom.Playlist = joinedRoom.Playlist.Select(item => new PlaylistItem(item)).ToArray(); + APIRoom.CurrentPlaylistItem = 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. - APIRoom.EndDate.Value = null; + APIRoom.EndDate = null; Debug.Assert(LocalUser != null); addUserToAPIRoom(LocalUser); @@ -396,15 +395,15 @@ namespace osu.Game.Online.Multiplayer switch (state) { case MultiplayerRoomState.Open: - APIRoom.Status.Value = APIRoom.HasPassword.Value ? new RoomStatusOpenPrivate() : new RoomStatusOpen(); + APIRoom.Status = APIRoom.HasPassword ? new RoomStatusOpenPrivate() : new RoomStatusOpen(); break; case MultiplayerRoomState.Playing: - APIRoom.Status.Value = new RoomStatusPlaying(); + APIRoom.Status = new RoomStatusPlaying(); break; case MultiplayerRoomState.Closed: - APIRoom.Status.Value = new RoomStatusEnded(); + APIRoom.Status = new RoomStatusEnded(); break; } @@ -416,7 +415,7 @@ namespace osu.Game.Online.Multiplayer async Task IMultiplayerClient.UserJoined(MultiplayerRoomUser user) { - await PopulateUser(user).ConfigureAwait(false); + await PopulateUsers([user]).ConfigureAwait(false); Scheduler.Add(() => { @@ -458,7 +457,7 @@ namespace osu.Game.Online.Multiplayer if (apiUser == null || apiRoom == null) return; PostNotification?.Invoke( - new UserAvatarNotification(apiUser, NotificationsStrings.InvitedYouToTheMultiplayer(apiUser.Username, apiRoom.Name.Value)) + new UserAvatarNotification(apiUser, NotificationsStrings.InvitedYouToTheMultiplayer(apiUser.Username, apiRoom.Name)) { Activated = () => { @@ -486,12 +485,12 @@ namespace osu.Game.Online.Multiplayer { Debug.Assert(APIRoom != null); - APIRoom.RecentParticipants.Add(user.User ?? new APIUser + APIRoom.RecentParticipants = APIRoom.RecentParticipants.Append(user.User ?? new APIUser { Id = user.UserID, Username = "[Unresolved]" - }); - APIRoom.ParticipantCount.Value++; + }).ToArray(); + APIRoom.ParticipantCount++; } private Task handleUserLeft(MultiplayerRoomUser user, Action? callback) @@ -505,8 +504,8 @@ namespace osu.Game.Online.Multiplayer PlayingUserIds.Remove(user.UserID); Debug.Assert(APIRoom != null); - APIRoom.RecentParticipants.RemoveAll(u => u.Id == user.UserID); - APIRoom.ParticipantCount.Value--; + APIRoom.RecentParticipants = APIRoom.RecentParticipants.Where(u => u.Id != user.UserID).ToArray(); + APIRoom.ParticipantCount--; callback?.Invoke(user); RoomUpdated?.Invoke(); @@ -527,7 +526,7 @@ namespace osu.Game.Online.Multiplayer var user = Room.Users.FirstOrDefault(u => u.UserID == userId); Room.Host = user; - APIRoom.Host.Value = user?.User; + APIRoom.Host = user?.User; RoomUpdated?.Invoke(); }, false); @@ -733,7 +732,7 @@ namespace osu.Game.Online.Multiplayer Debug.Assert(APIRoom != null); Room.Playlist.Add(item); - APIRoom.Playlist.Add(createPlaylistItem(item)); + APIRoom.Playlist = APIRoom.Playlist.Append(new PlaylistItem(item)).ToArray(); ItemAdded?.Invoke(item); RoomUpdated?.Invoke(); @@ -752,7 +751,7 @@ namespace osu.Game.Online.Multiplayer Debug.Assert(APIRoom != null); Room.Playlist.Remove(Room.Playlist.Single(existing => existing.ID == playlistItemId)); - APIRoom.Playlist.RemoveAll(existing => existing.ID == playlistItemId); + APIRoom.Playlist = APIRoom.Playlist.Where(i => i.ID != playlistItemId).ToArray(); Debug.Assert(Room.Playlist.Count > 0); @@ -770,30 +769,10 @@ namespace osu.Game.Online.Multiplayer if (Room == null) return; - try - { - Debug.Assert(APIRoom != null); + Debug.Assert(APIRoom != null); - Room.Playlist[Room.Playlist.IndexOf(Room.Playlist.Single(existing => existing.ID == item.ID))] = item; - - int existingIndex = APIRoom.Playlist.IndexOf(APIRoom.Playlist.Single(existing => existing.ID == item.ID)); - - APIRoom.Playlist.RemoveAt(existingIndex); - APIRoom.Playlist.Insert(existingIndex, createPlaylistItem(item)); - } - catch (Exception ex) - { - // Temporary code to attempt to figure out long-term failing tests. - StringBuilder exceptionText = new StringBuilder(); - - exceptionText.AppendLine("MultiplayerClient test failure investigation"); - exceptionText.AppendLine($"Exception : {ex.ToString()}"); - exceptionText.AppendLine($"Lookup : {item.ID}"); - exceptionText.AppendLine($"Items in Room.Playlist : {string.Join(',', Room.Playlist.Select(i => i.ID))}"); - exceptionText.AppendLine($"Items in APIRoom.Playlist: {string.Join(',', APIRoom!.Playlist.Select(i => i.ID))}"); - - throw new AggregateException(exceptionText.ToString()); - } + Room.Playlist[Room.Playlist.IndexOf(Room.Playlist.Single(existing => existing.ID == item.ID))] = item; + APIRoom.Playlist = APIRoom.Playlist.Select((pi, i) => pi.ID == item.ID ? new PlaylistItem(item) : APIRoom.Playlist[i]).ToArray(); ItemChanged?.Invoke(item); RoomUpdated?.Invoke(); @@ -803,10 +782,26 @@ namespace osu.Game.Online.Multiplayer } /// - /// Populates the for a given . + /// Populates the for a given collection of s. /// - /// The to populate. - protected async Task PopulateUser(MultiplayerRoomUser multiplayerUser) => multiplayerUser.User ??= await userLookupCache.GetUserAsync(multiplayerUser.UserID).ConfigureAwait(false); + /// The s to populate. + protected async Task PopulateUsers(IEnumerable multiplayerUsers) + { + var request = new GetUsersRequest(multiplayerUsers.Select(u => u.UserID).Distinct().ToArray()); + + await API.PerformAsync(request).ConfigureAwait(false); + + if (request.Response == null) + return; + + Dictionary users = request.Response.Users.ToDictionary(user => user.Id); + + foreach (var multiplayerUser in multiplayerUsers) + { + if (users.TryGetValue(multiplayerUser.UserID, out var user)) + multiplayerUser.User = user; + } + } /// /// Updates the local room settings with the given . @@ -824,30 +819,18 @@ namespace osu.Game.Online.Multiplayer // Update a few properties of the room instantaneously. Room.Settings = settings; - APIRoom.Name.Value = Room.Settings.Name; - APIRoom.Password.Value = Room.Settings.Password; - APIRoom.Status.Value = string.IsNullOrEmpty(Room.Settings.Password) ? new RoomStatusOpen() : new RoomStatusOpenPrivate(); - APIRoom.Type.Value = Room.Settings.MatchType; - APIRoom.QueueMode.Value = Room.Settings.QueueMode; - APIRoom.AutoStartDuration.Value = Room.Settings.AutoStartDuration; - APIRoom.CurrentPlaylistItem.Value = APIRoom.Playlist.Single(item => item.ID == settings.PlaylistItemId); - APIRoom.AutoSkip.Value = Room.Settings.AutoSkip; + APIRoom.Name = Room.Settings.Name; + APIRoom.Password = Room.Settings.Password; + APIRoom.Status = string.IsNullOrEmpty(Room.Settings.Password) ? new RoomStatusOpen() : new RoomStatusOpenPrivate(); + APIRoom.Type = Room.Settings.MatchType; + APIRoom.QueueMode = Room.Settings.QueueMode; + APIRoom.AutoStartDuration = Room.Settings.AutoStartDuration; + APIRoom.CurrentPlaylistItem = APIRoom.Playlist.Single(item => item.ID == settings.PlaylistItemId); + APIRoom.AutoSkip = Room.Settings.AutoSkip; 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/Online/Multiplayer/NotHostException.cs b/osu.Game/Online/Multiplayer/NotHostException.cs index cd43b13e52..f4fd217c87 100644 --- a/osu.Game/Online/Multiplayer/NotHostException.cs +++ b/osu.Game/Online/Multiplayer/NotHostException.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Runtime.Serialization; using Microsoft.AspNetCore.SignalR; namespace osu.Game.Online.Multiplayer @@ -14,10 +13,5 @@ namespace osu.Game.Online.Multiplayer : base("User is attempting to perform a host level operation while not the host") { } - - protected NotHostException(SerializationInfo info, StreamingContext context) - : base(info, context) - { - } } } diff --git a/osu.Game/Online/Multiplayer/NotJoinedRoomException.cs b/osu.Game/Online/Multiplayer/NotJoinedRoomException.cs index 0a96406c16..72773e28db 100644 --- a/osu.Game/Online/Multiplayer/NotJoinedRoomException.cs +++ b/osu.Game/Online/Multiplayer/NotJoinedRoomException.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Runtime.Serialization; using Microsoft.AspNetCore.SignalR; namespace osu.Game.Online.Multiplayer @@ -14,10 +13,5 @@ namespace osu.Game.Online.Multiplayer : base("This user has not yet joined a multiplayer room.") { } - - protected NotJoinedRoomException(SerializationInfo info, StreamingContext context) - : base(info, context) - { - } } } diff --git a/osu.Game/Online/Multiplayer/UserBlockedException.cs b/osu.Game/Online/Multiplayer/UserBlockedException.cs index e964b13c75..58e86d9f32 100644 --- a/osu.Game/Online/Multiplayer/UserBlockedException.cs +++ b/osu.Game/Online/Multiplayer/UserBlockedException.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Runtime.Serialization; using Microsoft.AspNetCore.SignalR; namespace osu.Game.Online.Multiplayer @@ -16,10 +15,5 @@ namespace osu.Game.Online.Multiplayer : base(MESSAGE) { } - - protected UserBlockedException(SerializationInfo info, StreamingContext context) - : base(info, context) - { - } } } diff --git a/osu.Game/Online/Multiplayer/UserBlocksPMsException.cs b/osu.Game/Online/Multiplayer/UserBlocksPMsException.cs index 14ed6fc212..0ea583ae2c 100644 --- a/osu.Game/Online/Multiplayer/UserBlocksPMsException.cs +++ b/osu.Game/Online/Multiplayer/UserBlocksPMsException.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Runtime.Serialization; using Microsoft.AspNetCore.SignalR; namespace osu.Game.Online.Multiplayer @@ -16,10 +15,5 @@ namespace osu.Game.Online.Multiplayer : base(MESSAGE) { } - - protected UserBlocksPMsException(SerializationInfo info, StreamingContext context) - : base(info, context) - { - } } } diff --git a/osu.Game/Online/Placeholders/ClickablePlaceholder.cs b/osu.Game/Online/Placeholders/ClickablePlaceholder.cs index 9bef1d4b7a..ee8762fca3 100644 --- a/osu.Game/Online/Placeholders/ClickablePlaceholder.cs +++ b/osu.Game/Online/Placeholders/ClickablePlaceholder.cs @@ -17,19 +17,21 @@ namespace osu.Game.Online.Placeholders public ClickablePlaceholder(LocalisableString actionMessage, IconUsage icon) { + OsuAnimatedButton button; OsuTextFlowContainer textFlow; - AddArbitraryDrawable(new OsuAnimatedButton + AddArbitraryDrawable(button = new OsuAnimatedButton { AutoSizeAxes = Framework.Graphics.Axes.Both, - Child = textFlow = new OsuTextFlowContainer(cp => cp.Font = cp.Font.With(size: TEXT_SIZE)) - { - AutoSizeAxes = Framework.Graphics.Axes.Both, - Margin = new Framework.Graphics.MarginPadding(5) - }, Action = () => Action?.Invoke() }); + button.Add(textFlow = new OsuTextFlowContainer(cp => cp.Font = cp.Font.With(size: TEXT_SIZE)) + { + AutoSizeAxes = Framework.Graphics.Axes.Both, + Margin = new Framework.Graphics.MarginPadding(5) + }); + textFlow.AddIcon(icon, i => { i.Padding = new Framework.Graphics.MarginPadding { Right = 10 }; diff --git a/osu.Game/Online/Rooms/GetRoomsRequest.cs b/osu.Game/Online/Rooms/GetRoomsRequest.cs index 1b5e08c729..7feb709acb 100644 --- a/osu.Game/Online/Rooms/GetRoomsRequest.cs +++ b/osu.Game/Online/Rooms/GetRoomsRequest.cs @@ -1,12 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Collections.Generic; using osu.Framework.IO.Network; using osu.Game.Extensions; using osu.Game.Online.API; -using osu.Game.Online.Rooms.RoomStatuses; using osu.Game.Screens.OnlinePlay.Lounge.Components; namespace osu.Game.Online.Rooms @@ -35,25 +33,6 @@ namespace osu.Game.Online.Rooms return req; } - protected override void PostProcess() - { - base.PostProcess(); - - if (Response != null) - { - // API doesn't populate status so let's do it here. - foreach (var room in Response) - { - if (room.EndDate.Value != null && DateTimeOffset.Now >= room.EndDate.Value) - room.Status.Value = new RoomStatusEnded(); - else if (room.HasPassword.Value) - room.Status.Value = new RoomStatusOpenPrivate(); - else - room.Status.Value = new RoomStatusOpen(); - } - } - } - protected override string Target => "rooms"; } } diff --git a/osu.Game/Online/Rooms/JoinRoomRequest.cs b/osu.Game/Online/Rooms/JoinRoomRequest.cs index 9a73104b60..dfc7a53fb2 100644 --- a/osu.Game/Online/Rooms/JoinRoomRequest.cs +++ b/osu.Game/Online/Rooms/JoinRoomRequest.cs @@ -7,7 +7,7 @@ using osu.Game.Online.API; namespace osu.Game.Online.Rooms { - public class JoinRoomRequest : APIRequest + public class JoinRoomRequest : APIRequest { public readonly Room Room; public readonly string? Password; @@ -27,6 +27,6 @@ namespace osu.Game.Online.Rooms return req; } - protected override string Target => $@"rooms/{Room.RoomID.Value}/users/{User!.Id}"; + protected override string Target => $@"rooms/{Room.RoomID}/users/{User!.Id}"; } } diff --git a/osu.Game/Online/Rooms/PartRoomRequest.cs b/osu.Game/Online/Rooms/PartRoomRequest.cs index 2416833a1e..77b5619efb 100644 --- a/osu.Game/Online/Rooms/PartRoomRequest.cs +++ b/osu.Game/Online/Rooms/PartRoomRequest.cs @@ -23,6 +23,6 @@ namespace osu.Game.Online.Rooms return req; } - protected override string Target => $"rooms/{room.RoomID.Value}/users/{User!.Id}"; + protected override string Target => $"rooms/{room.RoomID}/users/{User!.Id}"; } } diff --git a/osu.Game/Online/Rooms/PlaylistExtensions.cs b/osu.Game/Online/Rooms/PlaylistExtensions.cs index 8591b5bb47..8afa7d90f8 100644 --- a/osu.Game/Online/Rooms/PlaylistExtensions.cs +++ b/osu.Game/Online/Rooms/PlaylistExtensions.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.Linq; using Humanizer; using Humanizer.Localisation; -using osu.Framework.Bindables; using osu.Game.Rulesets; using osu.Game.Utils; @@ -30,7 +29,7 @@ namespace osu.Game.Online.Rooms /// or the last-played if all items are expired, /// or if was empty. /// - public static PlaylistItem? GetCurrentItem(this ICollection playlist) + public static PlaylistItem? GetCurrentItem(this IReadOnlyCollection playlist) { if (playlist.Count == 0) return null; @@ -43,7 +42,7 @@ namespace osu.Game.Online.Rooms /// /// Returns the total duration from the in playlist order from the supplied , /// - public static string GetTotalDuration(this BindableList playlist, RulesetStore rulesetStore) => + public static string GetTotalDuration(this IReadOnlyList playlist, RulesetStore rulesetStore) => playlist.Select(p => { double rate = 1; diff --git a/osu.Game/Online/Rooms/PlaylistItem.cs b/osu.Game/Online/Rooms/PlaylistItem.cs index a900d8f3d7..47d4e163bf 100644 --- a/osu.Game/Online/Rooms/PlaylistItem.cs +++ b/osu.Game/Online/Rooms/PlaylistItem.cs @@ -120,18 +120,21 @@ namespace osu.Game.Online.Rooms #endregion - public PlaylistItem With(Optional beatmap = default, Optional playlistOrder = default) => new PlaylistItem(beatmap.GetOr(Beatmap)) + public PlaylistItem With(Optional id = default, Optional beatmap = default, Optional playlistOrder = default) { - ID = ID, - OwnerID = OwnerID, - RulesetID = RulesetID, - Expired = Expired, - PlaylistOrder = playlistOrder.GetOr(PlaylistOrder), - PlayedAt = PlayedAt, - AllowedMods = AllowedMods, - RequiredMods = RequiredMods, - valid = { Value = Valid.Value }, - }; + return new PlaylistItem(beatmap.GetOr(Beatmap)) + { + ID = id.GetOr(ID), + OwnerID = OwnerID, + RulesetID = RulesetID, + Expired = Expired, + PlaylistOrder = playlistOrder.GetOr(PlaylistOrder), + PlayedAt = PlayedAt, + AllowedMods = AllowedMods, + RequiredMods = RequiredMods, + valid = { Value = Valid.Value }, + }; + } public bool Equals(PlaylistItem? other) => ID == other?.ID diff --git a/osu.Game/Online/Rooms/Room.cs b/osu.Game/Online/Rooms/Room.cs index c39932c3bf..e1813c7e4e 100644 --- a/osu.Game/Online/Rooms/Room.cs +++ b/osu.Game/Online/Rooms/Room.cs @@ -1,13 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; +using System.Collections.Generic; +using System.ComponentModel; using System.Linq; +using System.Runtime.CompilerServices; +using System.Runtime.Serialization; using Newtonsoft.Json; -using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Game.IO.Serialization.Converters; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; @@ -16,162 +16,344 @@ using osu.Game.Online.Rooms.RoomStatuses; namespace osu.Game.Online.Rooms { [JsonObject(MemberSerialization.OptIn)] - public partial class Room : IDependencyInjectionCandidate + public partial class Room : INotifyPropertyChanged { - [Cached] - [JsonProperty("id")] - public readonly Bindable RoomID = new Bindable(); + public event PropertyChangedEventHandler? PropertyChanged; - [Cached] - [JsonProperty("name")] - public readonly Bindable Name = new Bindable(); - - [Cached] - [JsonProperty("host")] - public readonly Bindable Host = new Bindable(); - - [Cached] - [JsonProperty("playlist")] - public readonly BindableList Playlist = new BindableList(); - - [Cached] - [JsonProperty("channel_id")] - public readonly Bindable ChannelId = new Bindable(); - - [JsonProperty("current_playlist_item")] - [Cached] - public readonly Bindable CurrentPlaylistItem = new Bindable(); - - [JsonProperty("playlist_item_stats")] - [Cached] - public readonly Bindable PlaylistItemStats = new Bindable(); - - [JsonProperty("difficulty_range")] - [Cached] - public readonly Bindable DifficultyRange = new Bindable(); - - [Cached] - public readonly Bindable Category = new Bindable(); - - // Todo: osu-framework bug (https://github.com/ppy/osu-framework/issues/4106) - [JsonProperty("category")] - [JsonConverter(typeof(SnakeCaseStringEnumConverter))] - private RoomCategory category + /// + /// The online room ID. Will be null while the room has not yet been created. + /// + public long? RoomID { - get => Category.Value; - set => Category.Value = value; + get => roomId; + set => SetField(ref roomId, value); } - [Cached] - public readonly Bindable MaxAttempts = new Bindable(); - - [Cached] - public readonly Bindable Status = new Bindable(new RoomStatusOpen()); - - [Cached] - public readonly Bindable Availability = new Bindable(); - - [Cached] - public readonly Bindable Type = new Bindable(); - - // Todo: osu-framework bug (https://github.com/ppy/osu-framework/issues/4106) - [JsonConverter(typeof(SnakeCaseStringEnumConverter))] - [JsonProperty("type")] - private MatchType type + /// + /// The room name. + /// + public string Name { - get => Type.Value; - set => Type.Value = value; + get => name; + set => SetField(ref name, value); } - [Cached] - public readonly Bindable QueueMode = new Bindable(); - - [JsonConverter(typeof(SnakeCaseStringEnumConverter))] - [JsonProperty("queue_mode")] - private QueueMode queueMode + /// + /// Sets the room password. Will be null after the room is created. + /// + /// + /// To check if the room has a password, use . + /// + public string? Password { - get => QueueMode.Value; - set => QueueMode.Value = value; - } - - [Cached] - public readonly Bindable AutoStartDuration = new Bindable(); - - [JsonProperty("auto_start_duration")] - private ushort autoStartDuration - { - get => (ushort)AutoStartDuration.Value.TotalSeconds; - set => AutoStartDuration.Value = TimeSpan.FromSeconds(value); - } - - [Cached] - public readonly Bindable MaxParticipants = new Bindable(); - - [Cached] - [JsonProperty("current_user_score")] - public readonly Bindable UserScore = new Bindable(); - - [JsonProperty("has_password")] - public readonly Bindable HasPassword = new Bindable(); - - [Cached] - [JsonProperty("recent_participants")] - public readonly BindableList RecentParticipants = new BindableList(); - - [Cached] - [JsonProperty("participant_count")] - public readonly Bindable ParticipantCount = new Bindable(); - - #region Properties only used for room creation request - - [Cached(Name = nameof(Password))] - [JsonProperty("password")] - public readonly Bindable Password = new Bindable(); - - [Cached] - public readonly Bindable Duration = new Bindable(); - - [JsonProperty("duration")] - private int? duration - { - get => (int?)Duration.Value?.TotalMinutes; + get => password; set { - if (value == null) - Duration.Value = null; - else - Duration.Value = TimeSpan.FromMinutes(value.Value); + SetField(ref password, value); + HasPassword = !string.IsNullOrEmpty(value); } } - #endregion + /// + /// Whether the room has a password. + /// + /// + /// To set a password, use . + /// + [JsonProperty("has_password")] + public bool HasPassword + { + get => hasPassword; + private set => SetField(ref hasPassword, value); + } + + /// + /// The room host. Will be null while the room has not yet been created. + /// + public APIUser? Host + { + get => host; + set => SetField(ref host, value); + } + + /// + /// The room category. + /// + public RoomCategory Category + { + get => category; + set => SetField(ref category, value); + } + + /// + /// The duration for which the room will be open. Will be null after the room is created. + /// + /// + /// To check the room end time, use . + /// + public TimeSpan? Duration + { + get => duration == null ? null : TimeSpan.FromMinutes(duration.Value); + set => SetField(ref duration, value == null ? null : (int)value.Value.TotalMinutes); + } + + /// + /// The date at which the room was opened. Will be null while the room has not yet been created. + /// + public DateTimeOffset? StartDate + { + get => startDate; + set => SetField(ref startDate, value); + } + + /// + /// The date at which the room will be closed. + /// + /// + /// To set the room duration, use . + /// + public DateTimeOffset? EndDate + { + get => endDate; + set => SetField(ref endDate, value); + } + + /// + /// The maximum number of users allowed in the room. + /// + public int? MaxParticipants + { + get => maxParticipants; + set => SetField(ref maxParticipants, value); + } + + /// + /// The current number of users in the room. + /// + public int ParticipantCount + { + get => participantCount; + set => SetField(ref participantCount, value); + } + + /// + /// The set of most recent participants in the room. + /// + public IReadOnlyList RecentParticipants + { + get => recentParticipants; + set => SetList(ref recentParticipants, value); + } + + /// + /// The match type. + /// + public MatchType Type + { + get => type; + set => SetField(ref type, value); + } + + /// + /// The maximum number of attempts on the playlist. Only valid for playlist rooms. + /// + public int? MaxAttempts + { + get => maxAttempts; + set => SetField(ref maxAttempts, value); + } + + /// + /// The room playlist. + /// + public IReadOnlyList Playlist + { + get => playlist; + set => SetList(ref playlist, value); + } + + /// + /// Describes the items in the playlist. + /// + public RoomPlaylistItemStats? PlaylistItemStats + { + get => playlistItemStats; + set => SetField(ref playlistItemStats, value); + } + + /// + /// Describes the range of difficulty of the room. + /// + public RoomDifficultyRange? DifficultyRange + { + get => difficultyRange; + set => SetField(ref difficultyRange, value); + } + + /// + /// The playlist queueing mode. Only valid for multiplayer rooms. + /// + public QueueMode QueueMode + { + get => queueMode; + set => SetField(ref queueMode, value); + } + + /// + /// Whether to automatically skip map intros. Only valid for multiplayer rooms. + /// + public bool AutoSkip + { + get => autoSkip; + set => SetField(ref autoSkip, value); + } + + /// + /// The amount of time before the match is automatically started. Only valid for multiplayer rooms. + /// + public TimeSpan AutoStartDuration + { + get => TimeSpan.FromSeconds(autoStartDuration); + set => SetField(ref autoStartDuration, (ushort)value.TotalSeconds); + } + + /// + /// Provides some extra scoring statistics for the local user in the room. + /// + public PlaylistAggregateScore? UserScore + { + get => userScore; + set => SetField(ref userScore, value); + } + + /// + /// Represents the current item selected within the room. + /// + /// + /// Only valid for room listing requests (i.e. in the lounge screen), and may not be valid while inside the room. + /// + public PlaylistItem? CurrentPlaylistItem + { + get => currentPlaylistItem; + set => SetField(ref currentPlaylistItem, value); + } + + /// + /// The chat channel id for the room. Will be 0 while the room has not yet been created. + /// + public int ChannelId + { + get => channelId; + private set => SetField(ref channelId, value); + } + + /// + /// The current room status. + /// + public RoomStatus Status + { + get => status; + set => SetField(ref status, value); + } + + /// + /// Describes which players are able to join the room. + /// + public RoomAvailability Availability + { + get => availability; + set => SetField(ref availability, value); + } + + [OnDeserialized] + private void onDeserialised(StreamingContext context) + { + // API doesn't populate status so let's do it here. + if (EndDate != null && DateTimeOffset.Now >= EndDate) + Status = new RoomStatusEnded(); + else if (HasPassword) + Status = new RoomStatusOpenPrivate(); + else + Status = new RoomStatusOpen(); + } + + [JsonProperty("id")] + private long? roomId; + + [JsonProperty("name")] + private string name = string.Empty; + + [JsonProperty("password")] + private string? password; + + // Not serialised (internal use only). + private bool hasPassword; + + [JsonProperty("host")] + private APIUser? host; + + [JsonProperty("category")] + [JsonConverter(typeof(SnakeCaseStringEnumConverter))] + private RoomCategory category; + + [JsonProperty("duration")] + private int? duration; - // Only supports retrieval for now - [Cached] [JsonProperty("starts_at")] - public readonly Bindable StartDate = new Bindable(); + private DateTimeOffset? startDate; - // Only supports retrieval for now - [Cached] [JsonProperty("ends_at")] - public readonly Bindable EndDate = new Bindable(); + private DateTimeOffset? endDate; + + // Not yet serialised (not implemented). + private int? maxParticipants; + + [JsonProperty("participant_count")] + private int participantCount; + + [JsonProperty("recent_participants")] + private IReadOnlyList recentParticipants = []; - // Todo: Find a better way to do this (https://github.com/ppy/osu-framework/issues/1930) [JsonProperty("max_attempts", DefaultValueHandling = DefaultValueHandling.Ignore)] - private int? maxAttempts - { - get => MaxAttempts.Value; - set => MaxAttempts.Value = value; - } + private int? maxAttempts; + + [JsonProperty("playlist")] + private IReadOnlyList playlist = []; + + [JsonProperty("playlist_item_stats")] + private RoomPlaylistItemStats? playlistItemStats; + + [JsonProperty("difficulty_range")] + private RoomDifficultyRange? difficultyRange; + + [JsonConverter(typeof(SnakeCaseStringEnumConverter))] + [JsonProperty("type")] + private MatchType type; + + [JsonConverter(typeof(SnakeCaseStringEnumConverter))] + [JsonProperty("queue_mode")] + private QueueMode queueMode; - [Cached] [JsonProperty("auto_skip")] - public readonly Bindable AutoSkip = new Bindable(); + private bool autoSkip; - public Room() - { - Password.BindValueChanged(p => HasPassword.Value = !string.IsNullOrEmpty(p.NewValue)); - } + [JsonProperty("auto_start_duration")] + private ushort autoStartDuration; + + [JsonProperty("current_user_score")] + private PlaylistAggregateScore? userScore; + + [JsonProperty("current_playlist_item")] + private PlaylistItem? currentPlaylistItem; + + [JsonProperty("channel_id")] + private int channelId; + + // Not serialised (see: GetRoomsRequest). + private RoomStatus status = new RoomStatusOpen(); + + // Not yet serialised (not implemented). + private RoomAvailability availability; /// /// Copies values from another into this one. @@ -182,52 +364,27 @@ namespace osu.Game.Online.Rooms /// The to copy values from. public void CopyFrom(Room other) { - RoomID.Value = other.RoomID.Value; - Name.Value = other.Name.Value; - - Category.Value = other.Category.Value; - - if (other.Host.Value != null && Host.Value?.Id != other.Host.Value.Id) - Host.Value = other.Host.Value; - - ChannelId.Value = other.ChannelId.Value; - Status.Value = other.Status.Value; - Availability.Value = other.Availability.Value; - HasPassword.Value = other.HasPassword.Value; - Type.Value = other.Type.Value; - MaxParticipants.Value = other.MaxParticipants.Value; - ParticipantCount.Value = other.ParticipantCount.Value; - EndDate.Value = other.EndDate.Value; - UserScore.Value = other.UserScore.Value; - QueueMode.Value = other.QueueMode.Value; - AutoStartDuration.Value = other.AutoStartDuration.Value; - DifficultyRange.Value = other.DifficultyRange.Value; - PlaylistItemStats.Value = other.PlaylistItemStats.Value; - CurrentPlaylistItem.Value = other.CurrentPlaylistItem.Value; - AutoSkip.Value = other.AutoSkip.Value; - - other.RemoveExpiredPlaylistItems(); - - if (!Playlist.SequenceEqual(other.Playlist)) - { - Playlist.Clear(); - Playlist.AddRange(other.Playlist); - } - - if (!RecentParticipants.SequenceEqual(other.RecentParticipants)) - { - RecentParticipants.Clear(); - RecentParticipants.AddRange(other.RecentParticipants); - } - } - - public void RemoveExpiredPlaylistItems() - { - // Todo: This is not the best way/place to do this, but the intention is to display all playlist items when the room has ended, - // and display only the non-expired playlist items while the room is still active. In order to achieve this, all expired items are removed from the source Room. - // More refactoring is required before this can be done locally instead - DrawableRoomPlaylist is currently directly bound to the playlist to display items in the room. - if (!(Status.Value is RoomStatusEnded)) - Playlist.RemoveAll(i => i.Expired); + RoomID = other.RoomID; + Name = other.Name; + Category = other.Category; + Host = other.Host; + ChannelId = other.ChannelId; + Status = other.Status; + Availability = other.Availability; + HasPassword = other.HasPassword; + Type = other.Type; + MaxParticipants = other.MaxParticipants; + ParticipantCount = other.ParticipantCount; + EndDate = other.EndDate; + UserScore = other.UserScore; + QueueMode = other.QueueMode; + AutoStartDuration = other.AutoStartDuration; + DifficultyRange = other.DifficultyRange; + PlaylistItemStats = other.PlaylistItemStats; + CurrentPlaylistItem = other.CurrentPlaylistItem; + AutoSkip = other.AutoSkip; + Playlist = other.Playlist; + RecentParticipants = other.RecentParticipants; } [JsonObject(MemberSerialization.OptIn)] @@ -240,7 +397,7 @@ namespace osu.Game.Online.Rooms public int CountTotal; [JsonProperty("ruleset_ids")] - public int[] RulesetIDs; + public int[] RulesetIDs = []; } [JsonObject(MemberSerialization.OptIn)] @@ -252,5 +409,28 @@ namespace osu.Game.Online.Rooms [JsonProperty("max")] public double Max; } + + protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null!) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + + protected bool SetList(ref IReadOnlyList list, IReadOnlyList value, [CallerMemberName] string propertyName = null!) + { + if (list.SequenceEqual(value)) + return false; + + list = value; + OnPropertyChanged(propertyName); + return true; + } + + protected bool SetField(ref T field, T value, [CallerMemberName] string propertyName = null!) + { + if (EqualityComparer.Default.Equals(field, value)) + return false; + + field = value; + OnPropertyChanged(propertyName); + return true; + } } } diff --git a/osu.Game/Online/Spectator/FrameHeader.cs b/osu.Game/Online/Spectator/FrameHeader.cs index 45f920e65b..e0fd1f0682 100644 --- a/osu.Game/Online/Spectator/FrameHeader.cs +++ b/osu.Game/Online/Spectator/FrameHeader.cs @@ -3,8 +3,10 @@ using System; using System.Collections.Generic; +using System.Linq; using MessagePack; using Newtonsoft.Json; +using osu.Game.Online.API; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; @@ -56,6 +58,17 @@ namespace osu.Game.Online.Spectator [Key(6)] public DateTimeOffset ReceivedTime { get; set; } + /// + /// The set of mods currently active. + /// + /// + /// Nullable for backwards compatibility with older clients + /// (these structures are also used server-side, and will be used as marker that the data isn't there). + /// can be made non-nullable 20250407 + /// + [Key(7)] + public APIMod[]? Mods { get; set; } + /// /// Construct header summary information from a point-in-time reference to a score which is actively being played. /// @@ -69,6 +82,7 @@ namespace osu.Game.Online.Spectator MaxCombo = score.MaxCombo; // copy for safety Statistics = new Dictionary(score.Statistics); + Mods = score.APIMods.ToArray(); ScoreProcessorStatistics = statistics; } diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 44ba78762a..dce24c6ee7 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -175,14 +175,9 @@ namespace osu.Game /// public readonly IBindable OverlayActivationMode = new Bindable(); - /// - /// Whether the local user is currently interacting with the game in a way that should not be interrupted. - /// - /// - /// This is exclusively managed by . If other components are mutating this state, a more - /// resilient method should be used to ensure correct state. - /// - public Bindable LocalUserPlaying = new BindableBool(); + IBindable ILocalUserPlayInfo.PlayingState => playingState; + + private readonly Bindable playingState = new Bindable(); protected OsuScreenStack ScreenStack; @@ -302,7 +297,7 @@ namespace osu.Game protected override UserInputManager CreateUserInputManager() { var userInputManager = base.CreateUserInputManager(); - (userInputManager as OsuUserInputManager)?.LocalUserPlaying.BindTo(LocalUserPlaying); + (userInputManager as OsuUserInputManager)?.PlayingState.BindTo(playingState); return userInputManager; } @@ -391,11 +386,11 @@ namespace osu.Game // Transfer any runtime changes back to configuration file. SkinManager.CurrentSkinInfo.ValueChanged += skin => configSkin.Value = skin.NewValue.ID.ToString(); - LocalUserPlaying.BindValueChanged(p => + playingState.BindValueChanged(p => { - BeatmapManager.PauseImports = p.NewValue; - SkinManager.PauseImports = p.NewValue; - ScoreManager.PauseImports = p.NewValue; + BeatmapManager.PauseImports = p.NewValue != LocalUserPlayingState.NotPlaying; + SkinManager.PauseImports = p.NewValue != LocalUserPlayingState.NotPlaying; + ScoreManager.PauseImports = p.NewValue != LocalUserPlayingState.NotPlaying; }, true); IsActive.BindValueChanged(active => updateActiveState(active.NewValue), true); @@ -1553,6 +1548,16 @@ namespace osu.Game scope.SetTag(@"screen", newScreen?.GetType().ReadableName() ?? @"none"); }); + switch (current) + { + case Player player: + player.PlayingState.UnbindFrom(playingState); + + // reset for sanity. + playingState.Value = LocalUserPlayingState.NotPlaying; + break; + } + switch (newScreen) { case IntroScreen intro: @@ -1565,14 +1570,15 @@ namespace osu.Game versionManager?.Show(); break; + case Player player: + player.PlayingState.BindTo(playingState); + break; + default: versionManager?.Hide(); break; } - // reset on screen change for sanity. - LocalUserPlaying.Value = false; - if (current is IOsuScreen currentOsuScreen) { OverlayActivationMode.UnbindFrom(currentOsuScreen.OverlayActivationMode); @@ -1621,7 +1627,5 @@ namespace osu.Game if (newScreen == null) Exit(); } - - IBindable ILocalUserPlayInfo.IsPlaying => LocalUserPlaying; } } diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index ce0c288934..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)); @@ -409,6 +409,7 @@ namespace osu.Game KeyBindingStore = new RealmKeyBindingStore(realm, keyCombinationProvider); KeyBindingStore.Register(globalBindings, RulesetStore.AvailableRulesets); + dependencies.Cache(KeyBindingStore); dependencies.Cache(globalBindings); @@ -540,7 +541,10 @@ namespace osu.Game realmBlocker = realm.BlockAllOperations("migration"); success = true; } - catch { } + catch (Exception ex) + { + Logger.Log($"Attempting to block all operations failed: {ex}", LoggingTarget.Database); + } readyToRun.Set(); }, false); @@ -559,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/BeatmapListingOverlay.cs b/osu.Game/Overlays/BeatmapListingOverlay.cs index 9b2f26e8ae..f83368fa41 100644 --- a/osu.Game/Overlays/BeatmapListingOverlay.cs +++ b/osu.Game/Overlays/BeatmapListingOverlay.cs @@ -98,7 +98,7 @@ namespace osu.Game.Overlays apiUser.BindValueChanged(_ => Schedule(() => { if (api.IsLoggedIn) - replaceResultsAreaContent(Drawable.Empty()); + replaceResultsAreaContent(Empty()); })); } @@ -198,7 +198,6 @@ namespace osu.Game.Overlays { c.Anchor = Anchor.TopCentre; c.Origin = Anchor.TopCentre; - c.Scale = new Vector2(0.8f); })).ToArray(); private static ReverseChildIDFillFlowContainer createCardContainerFor(IEnumerable newCards) diff --git a/osu.Game/Overlays/BeatmapSet/MetadataSectionSource.cs b/osu.Game/Overlays/BeatmapSet/MetadataSectionSource.cs index 544dc0dfe4..4d55359e63 100644 --- a/osu.Game/Overlays/BeatmapSet/MetadataSectionSource.cs +++ b/osu.Game/Overlays/BeatmapSet/MetadataSectionSource.cs @@ -17,9 +17,9 @@ namespace osu.Game.Overlays.BeatmapSet protected override void AddMetadata(string metadata, LinkFlowContainer loaded) { if (SearchAction != null) - loaded.AddLink(metadata, () => SearchAction(metadata)); + loaded.AddLink(metadata, () => SearchAction($@"source=""""{metadata}""""")); else - loaded.AddLink(metadata, LinkAction.SearchBeatmapSet, metadata); + loaded.AddLink(metadata, LinkAction.SearchBeatmapSet, $@"source=""""{metadata}"""""); } } } diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs index a6868efb5d..c70c41feed 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs @@ -9,7 +9,6 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions; -using osu.Framework.Extensions.EnumExtensions; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -58,9 +57,11 @@ namespace osu.Game.Overlays.BeatmapSet.Scores } /// - /// The statistics that appear in the table, in order of appearance. + /// The names of the statistics that appear in the table. If multiple HitResults have the same + /// DisplayName (for example, "slider end" is the name for both and + /// in osu!) the name will only be listed once. /// - private readonly List<(HitResult result, LocalisableString displayName)> statisticResultTypes = new List<(HitResult, LocalisableString)>(); + private readonly List statisticResultNames = new List(); private bool showPerformancePoints; @@ -72,7 +73,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores return; showPerformancePoints = showPerformanceColumn; - statisticResultTypes.Clear(); + statisticResultNames.Clear(); for (int i = 0; i < scores.Count; i++) backgroundFlow.Add(new ScoreTableRowBackground(i, scores[i], row_height)); @@ -105,20 +106,18 @@ namespace osu.Game.Overlays.BeatmapSet.Scores var ruleset = scores.First().Ruleset.CreateInstance(); - foreach (var result in EnumExtensions.GetValuesInOrder()) + foreach (var resultGroup in ruleset.GetHitResults().GroupBy(r => r.displayName)) { - if (!allScoreStatistics.Contains(result)) + if (!resultGroup.Any(r => allScoreStatistics.Contains(r.result))) continue; // for the time being ignore bonus result types. // this is not being sent from the API and will be empty in all cases. - if (result.IsBonus()) + if (resultGroup.All(r => r.result.IsBonus())) continue; - var displayName = ruleset.GetDisplayNameForHitResult(result); - - columns.Add(new TableColumn(displayName, Anchor.CentreLeft, new Dimension(minSize: 35, maxSize: 60))); - statisticResultTypes.Add((result, displayName)); + columns.Add(new TableColumn(resultGroup.Key, Anchor.CentreLeft, new Dimension(minSize: 35, maxSize: 60))); + statisticResultNames.Add(resultGroup.Key); } if (showPerformancePoints) @@ -167,14 +166,25 @@ namespace osu.Game.Overlays.BeatmapSet.Scores #pragma warning restore 618 }; - var availableStatistics = score.GetStatisticsForDisplay().ToDictionary(tuple => tuple.Result); + var availableStatistics = score.GetStatisticsForDisplay().ToLookup(tuple => tuple.DisplayName); - foreach (var result in statisticResultTypes) + foreach (var columnName in statisticResultNames) { - if (!availableStatistics.TryGetValue(result.result, out var stat)) - stat = new HitResultDisplayStatistic(result.result, 0, null, result.displayName); + int count = 0; + int? maxCount = null; - content.Add(new StatisticText(stat.Count, stat.MaxCount, @"N0") { Colour = stat.Count == 0 ? Color4.Gray : Color4.White }); + if (availableStatistics.Contains(columnName)) + { + maxCount = 0; + + foreach (var s in availableStatistics[columnName]) + { + count += s.Count; + maxCount += s.MaxCount; + } + } + + content.Add(new StatisticText(count, maxCount, @"N0") { Colour = count == 0 ? Color4.Gray : Color4.White }); } if (showPerformancePoints) diff --git a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs index 17704f63ee..e8833fa0a3 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs @@ -96,10 +96,17 @@ namespace osu.Game.Overlays.BeatmapSet.Scores } [BackgroundDependencyLoader] - private void load() + private void load(OsuColour colours) { if (score != null) + { totalScoreColumn.Current = scoreManager.GetBindableTotalScoreString(score); + + if (score.Accuracy == 1.0) accuracyColumn.TextColour = colours.GreenLight; +#pragma warning disable CS0618 + if (score.MaxCombo == score.BeatmapInfo!.MaxCombo) maxComboColumn.TextColour = colours.GreenLight; +#pragma warning restore CS0618 + } } private ScoreInfo score; @@ -228,6 +235,11 @@ namespace osu.Game.Overlays.BeatmapSet.Scores set => text.Text = value; } + public Colour4 TextColour + { + set => text.Colour = value; + } + public Drawable Drawable { set diff --git a/osu.Game/Overlays/BeatmapSetOverlay.cs b/osu.Game/Overlays/BeatmapSetOverlay.cs index 873336bb6e..8de21129d3 100644 --- a/osu.Game/Overlays/BeatmapSetOverlay.cs +++ b/osu.Game/Overlays/BeatmapSetOverlay.cs @@ -3,8 +3,6 @@ #nullable disable -using System; -using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -16,8 +14,6 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.BeatmapSet; using osu.Game.Overlays.BeatmapSet.Scores; using osu.Game.Overlays.Comments; -using osu.Game.Rulesets.Mods; -using osu.Game.Screens.Select.Details; using osuTK; using osuTK.Graphics; @@ -37,14 +33,6 @@ namespace osu.Game.Overlays private (BeatmapSetLookupType type, int id)? lastLookup; - /// - /// Isolates the beatmap set overlay from the game-wide selected mods bindable - /// to avoid affecting the beatmap details section (i.e. ). - /// - [Cached] - [Cached(typeof(IBindable>))] - protected readonly Bindable> SelectedMods = new Bindable>(Array.Empty()); - public BeatmapSetOverlay() : base(OverlayColourScheme.Blue) { diff --git a/osu.Game/Overlays/Chat/ChannelList/ChannelList.cs b/osu.Game/Overlays/Chat/ChannelList/ChannelList.cs index e6fe97f3c6..fc0060d86a 100644 --- a/osu.Game/Overlays/Chat/ChannelList/ChannelList.cs +++ b/osu.Game/Overlays/Chat/ChannelList/ChannelList.cs @@ -9,13 +9,17 @@ using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; +using osu.Framework.Testing; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; using osu.Game.Online.Chat; using osu.Game.Overlays.Chat.Listing; using osu.Game.Resources.Localisation.Web; +using osuTK; namespace osu.Game.Overlays.Chat.ChannelList { @@ -34,11 +38,12 @@ namespace osu.Game.Overlays.Chat.ChannelList private readonly Dictionary channelMap = new Dictionary(); private OsuScrollContainer scroll = null!; - private FillFlowContainer groupFlow = null!; + private SearchContainer groupFlow = null!; private ChannelGroup announceChannelGroup = null!; private ChannelGroup publicChannelGroup = null!; private ChannelGroup privateChannelGroup = null!; private ChannelListItem selector = null!; + private TextBox searchTextBox = null!; [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) @@ -55,13 +60,23 @@ namespace osu.Game.Overlays.Chat.ChannelList RelativeSizeAxes = Axes.Both, ScrollbarAnchor = Anchor.TopRight, ScrollDistance = 35f, - Child = groupFlow = new FillFlowContainer + Child = groupFlow = new SearchContainer { Direction = FillDirection.Vertical, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Children = new Drawable[] { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = 10, Top = 8 }, + Child = searchTextBox = new ChannelSearchTextBox + { + RelativeSizeAxes = Axes.X, + } + }, announceChannelGroup = new ChannelGroup(ChatStrings.ChannelsListTitleANNOUNCE.ToUpper()), publicChannelGroup = new ChannelGroup(ChatStrings.ChannelsListTitlePUBLIC.ToUpper()), selector = new ChannelListItem(ChannelListingChannel), @@ -71,6 +86,19 @@ namespace osu.Game.Overlays.Chat.ChannelList }, }; + searchTextBox.Current.BindValueChanged(_ => groupFlow.SearchTerm = searchTextBox.Current.Value, true); + searchTextBox.OnCommit += (_, _) => + { + if (string.IsNullOrEmpty(searchTextBox.Current.Value)) + return; + + var firstMatchingItem = this.ChildrenOfType().FirstOrDefault(item => item.MatchingFilter); + if (firstMatchingItem == null) + return; + + OnRequestSelect?.Invoke(firstMatchingItem.Channel); + }; + selector.OnRequestSelect += chan => OnRequestSelect?.Invoke(chan); } @@ -168,5 +196,17 @@ namespace osu.Game.Overlays.Chat.ChannelList }; } } + + private partial class ChannelSearchTextBox : BasicSearchTextBox + { + protected override bool AllowCommit => true; + + public ChannelSearchTextBox() + { + const float scale_factor = 0.8f; + Scale = new Vector2(scale_factor); + Width = 1 / scale_factor; + } + } } } diff --git a/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.cs b/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.cs index e8c251e7fd..b197fe199d 100644 --- a/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.cs +++ b/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -9,6 +10,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -19,7 +21,7 @@ using osuTK; namespace osu.Game.Overlays.Chat.ChannelList { - public partial class ChannelListItem : OsuClickableContainer + public partial class ChannelListItem : OsuClickableContainer, IFilterable { public event Action? OnRequestSelect; public event Action? OnRequestLeave; @@ -186,5 +188,28 @@ namespace osu.Game.Overlays.Chat.ChannelList } private bool isSelector => Channel is ChannelListing.ChannelListingChannel; + + #region Filtering support + + public IEnumerable FilterTerms => isSelector ? Enumerable.Empty() : [Channel.Name]; + + private bool matchingFilter = true; + + public bool MatchingFilter + { + get => matchingFilter; + set + { + if (matchingFilter == value) + return; + + matchingFilter = value; + Alpha = matchingFilter ? 1 : 0; + } + } + + public bool FilteringActive { get; set; } + + #endregion } } diff --git a/osu.Game/Overlays/Chat/ChatLine.cs b/osu.Game/Overlays/Chat/ChatLine.cs index 3f8862de36..e386f2ac09 100644 --- a/osu.Game/Overlays/Chat/ChatLine.cs +++ b/osu.Game/Overlays/Chat/ChatLine.cs @@ -48,7 +48,7 @@ namespace osu.Game.Overlays.Chat public IReadOnlyCollection DrawableContentFlow => drawableContentFlow; - protected virtual float FontSize => 12; + private const float font_size = 13; protected virtual float Spacing => 15; @@ -183,13 +183,13 @@ namespace osu.Game.Overlays.Chat Anchor = Anchor.TopLeft, Origin = Anchor.TopLeft, Spacing = new Vector2(-1, 0), - Font = OsuFont.GetFont(size: FontSize, weight: FontWeight.SemiBold, fixedWidth: true), + Font = OsuFont.GetFont(size: font_size, weight: FontWeight.SemiBold, fixedWidth: true), AlwaysPresent = true, }, drawableUsername = new DrawableChatUsername(message.Sender) { Width = UsernameWidth, - FontSize = FontSize, + FontSize = font_size, AutoSizeAxes = Axes.Y, Origin = Anchor.TopRight, Anchor = Anchor.TopRight, @@ -258,7 +258,7 @@ namespace osu.Game.Overlays.Chat private void styleMessageContent(SpriteText text) { text.Shadow = false; - text.Font = text.Font.With(size: FontSize, italics: Message.IsAction, weight: isMention ? FontWeight.SemiBold : FontWeight.Medium); + text.Font = text.Font.With(size: font_size, italics: Message.IsAction, weight: isMention ? FontWeight.SemiBold : FontWeight.Medium); Color4 messageColour = colourProvider?.Content1 ?? Colour4.White; diff --git a/osu.Game/Overlays/Chat/DaySeparator.cs b/osu.Game/Overlays/Chat/DaySeparator.cs index fd6b15c778..c371877fcb 100644 --- a/osu.Game/Overlays/Chat/DaySeparator.cs +++ b/osu.Game/Overlays/Chat/DaySeparator.cs @@ -75,7 +75,7 @@ namespace osu.Game.Overlays.Chat Height = LineHeight, Colour = colourProvider?.Background5 ?? Colour4.White, }, - Drawable.Empty(), + Empty(), new OsuSpriteText { Anchor = Anchor.CentreRight, @@ -87,7 +87,7 @@ namespace osu.Game.Overlays.Chat } }, }, - Drawable.Empty(), + Empty(), new Circle { Anchor = Anchor.Centre, diff --git a/osu.Game/Overlays/Comments/CommentEditor.cs b/osu.Game/Overlays/Comments/CommentEditor.cs index 02bcbb9d05..c456592383 100644 --- a/osu.Game/Overlays/Comments/CommentEditor.cs +++ b/osu.Game/Overlays/Comments/CommentEditor.cs @@ -14,6 +14,8 @@ using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Resources.Localisation.Web; using osuTK; using osuTK.Graphics; @@ -21,6 +23,8 @@ namespace osu.Game.Overlays.Comments { public abstract partial class CommentEditor : CompositeDrawable { + public Bindable CommentableMeta { get; set; } = new Bindable(); + private const int side_padding = 8; protected abstract LocalisableString FooterText { get; } @@ -53,8 +57,7 @@ namespace osu.Game.Overlays.Comments /// /// Returns the placeholder text for the comment box. /// - /// Whether the current user is logged in. - protected abstract LocalisableString GetPlaceholderText(bool isLoggedIn); + protected abstract LocalisableString GetPlaceholderText(); protected bool ShowLoadingSpinner { @@ -65,7 +68,7 @@ namespace osu.Game.Overlays.Comments else loadingSpinner.Hide(); - updateCommitButtonState(); + updateState(); } } @@ -167,25 +170,33 @@ namespace osu.Game.Overlays.Comments protected override void LoadComplete() { base.LoadComplete(); - Current.BindValueChanged(_ => updateCommitButtonState(), true); - apiState.BindValueChanged(updateStateForLoggedIn, true); + Current.BindValueChanged(_ => updateState()); + apiState.BindValueChanged(_ => Scheduler.AddOnce(updateState)); + CommentableMeta.BindValueChanged(_ => Scheduler.AddOnce(updateState)); + updateState(); } protected abstract void OnCommit(string text); - private void updateCommitButtonState() => - commitButton.Enabled.Value = loadingSpinner.State.Value == Visibility.Hidden && !string.IsNullOrEmpty(Current.Value); - - private void updateStateForLoggedIn(ValueChangedEvent state) => Schedule(() => + private void updateState() { - bool isAvailable = state.NewValue > APIState.Offline; + bool isOnline = apiState.Value > APIState.Offline; + LocalisableString? canNewCommentReason = CommentEditor.canNewCommentReason(CommentableMeta.Value); + bool commentsDisabled = canNewCommentReason != null; + bool canComment = isOnline && !commentsDisabled; - TextBox.PlaceholderText = GetPlaceholderText(isAvailable); - TextBox.ReadOnly = !isAvailable; + if (!isOnline) + TextBox.PlaceholderText = AuthorizationStrings.RequireLogin; + else if (canNewCommentReason != null) + TextBox.PlaceholderText = canNewCommentReason.Value; + else + TextBox.PlaceholderText = GetPlaceholderText(); + TextBox.ReadOnly = !canComment; - if (isAvailable) + if (isOnline) { commitButton.Show(); + commitButton.Enabled.Value = !commentsDisabled && loadingSpinner.State.Value == Visibility.Hidden && !string.IsNullOrEmpty(Current.Value); logInButton.Hide(); } else @@ -193,7 +204,25 @@ namespace osu.Game.Overlays.Comments commitButton.Hide(); logInButton.Show(); } - }); + } + + // https://github.com/ppy/osu-web/blob/83816dbe24ad2927273cba968f2fcd2694a121a9/resources/js/components/comment-editor.tsx#L54-L60 + // careful here, logic is VERY finicky. + private static LocalisableString? canNewCommentReason(CommentableMeta? meta) + { + if (meta == null) + return null; + + if (meta.CurrentUserAttributes != null) + { + if (meta.CurrentUserAttributes.Value.CanNewCommentReason is string reason) + return reason; + + return null; + } + + return AuthorizationStrings.CommentStoreDisabled; + } private partial class EditorTextBox : OsuTextBox { diff --git a/osu.Game/Overlays/Comments/CommentsContainer.cs b/osu.Game/Overlays/Comments/CommentsContainer.cs index 2e5f13aa99..921c1682f5 100644 --- a/osu.Game/Overlays/Comments/CommentsContainer.cs +++ b/osu.Game/Overlays/Comments/CommentsContainer.cs @@ -20,6 +20,7 @@ using System.Collections.Generic; using JetBrains.Annotations; using osu.Framework.Localisation; using osu.Framework.Logging; +using osu.Game.Extensions; using osu.Game.Graphics.Sprites; using osu.Game.Resources.Localisation.Web; using osu.Game.Users.Drawables; @@ -49,6 +50,7 @@ namespace osu.Game.Overlays.Comments private int currentPage; private FillFlowContainer pinnedContent; + private NewCommentEditor newCommentEditor; private FillFlowContainer content; private DeletedCommentsCounter deletedCommentsCounter; private CommentsShowMoreButton moreButton; @@ -114,7 +116,7 @@ namespace osu.Game.Overlays.Comments Padding = new MarginPadding { Left = 60 }, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Child = new NewCommentEditor + Child = newCommentEditor = new NewCommentEditor { OnPost = prependPostedComments } @@ -242,6 +244,7 @@ namespace osu.Game.Overlays.Comments protected void OnSuccess(CommentBundle response) { commentCounter.Current.Value = response.Total; + newCommentEditor.CommentableMeta.Value = response.CommentableMeta.SingleOrDefault(m => m.Id == id.Value && m.Type == type.Value.ToString().ToSnakeCase().ToLowerInvariant()); if (!response.Comments.Any()) { @@ -413,8 +416,7 @@ namespace osu.Game.Overlays.Comments protected override LocalisableString GetButtonText(bool isLoggedIn) => isLoggedIn ? CommonStrings.ButtonsPost : CommentsStrings.GuestButtonNew; - protected override LocalisableString GetPlaceholderText(bool isLoggedIn) => - isLoggedIn ? CommentsStrings.PlaceholderNew : AuthorizationStrings.RequireLogin; + protected override LocalisableString GetPlaceholderText() => CommentsStrings.PlaceholderNew; protected override void OnCommit(string text) { diff --git a/osu.Game/Overlays/Comments/DrawableComment.cs b/osu.Game/Overlays/Comments/DrawableComment.cs index 296f90872e..d664a44be9 100644 --- a/osu.Game/Overlays/Comments/DrawableComment.cs +++ b/osu.Game/Overlays/Comments/DrawableComment.cs @@ -428,7 +428,7 @@ namespace osu.Game.Overlays.Comments if (replyEditorContainer.Count == 0) { replyEditorContainer.Show(); - replyEditorContainer.Add(new ReplyCommentEditor(Comment) + replyEditorContainer.Add(new ReplyCommentEditor(Comment, Meta) { OnPost = comments => { diff --git a/osu.Game/Overlays/Comments/ReplyCommentEditor.cs b/osu.Game/Overlays/Comments/ReplyCommentEditor.cs index d5ae4f92ab..8350887ec0 100644 --- a/osu.Game/Overlays/Comments/ReplyCommentEditor.cs +++ b/osu.Game/Overlays/Comments/ReplyCommentEditor.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Localisation; @@ -26,12 +27,12 @@ namespace osu.Game.Overlays.Comments protected override LocalisableString GetButtonText(bool isLoggedIn) => isLoggedIn ? CommonStrings.ButtonsReply : CommentsStrings.GuestButtonReply; - protected override LocalisableString GetPlaceholderText(bool isLoggedIn) => - isLoggedIn ? CommentsStrings.PlaceholderReply : AuthorizationStrings.RequireLogin; + protected override LocalisableString GetPlaceholderText() => CommentsStrings.PlaceholderReply; - public ReplyCommentEditor(Comment parent) + public ReplyCommentEditor(Comment parent, IEnumerable meta) { parentComment = parent; + CommentableMeta.Value = meta.SingleOrDefault(m => m.Id == parent.CommentableId && m.Type == parent.CommentableType); } protected override void LoadComplete() 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/Dialog/DangerousActionDialog.cs b/osu.Game/Overlays/Dialog/DangerousActionDialog.cs index 31160d1832..287b0fa2c6 100644 --- a/osu.Game/Overlays/Dialog/DangerousActionDialog.cs +++ b/osu.Game/Overlays/Dialog/DangerousActionDialog.cs @@ -30,9 +30,9 @@ namespace osu.Game.Overlays.Dialog protected DangerousActionDialog() { - HeaderText = DialogStrings.Caution; + HeaderText = DialogStrings.CautionHeaderText; - Icon = FontAwesome.Regular.TrashAlt; + Icon = FontAwesome.Solid.ExclamationTriangle; Buttons = new PopupDialogButton[] { diff --git a/osu.Game/Overlays/Dialog/DeletionDialog.cs b/osu.Game/Overlays/Dialog/DeletionDialog.cs new file mode 100644 index 0000000000..26a29068a9 --- /dev/null +++ b/osu.Game/Overlays/Dialog/DeletionDialog.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics.Sprites; +using osu.Game.Localisation; + +namespace osu.Game.Overlays.Dialog +{ + /// + /// A dialog which provides confirmation for deletion of something. + /// + public abstract partial class DeletionDialog : DangerousActionDialog + { + protected DeletionDialog() + { + HeaderText = DialogStrings.DeletionHeaderText; + Icon = FontAwesome.Solid.Trash; + } + } +} diff --git a/osu.Game/Overlays/Mods/DeleteModPresetDialog.cs b/osu.Game/Overlays/Mods/DeleteModPresetDialog.cs index 9788764453..5651ecb34c 100644 --- a/osu.Game/Overlays/Mods/DeleteModPresetDialog.cs +++ b/osu.Game/Overlays/Mods/DeleteModPresetDialog.cs @@ -7,7 +7,7 @@ using osu.Game.Rulesets.Mods; namespace osu.Game.Overlays.Mods { - public partial class DeleteModPresetDialog : DangerousActionDialog + public partial class DeleteModPresetDialog : DeletionDialog { public DeleteModPresetDialog(Live modPreset) { diff --git a/osu.Game/Overlays/Mods/ModPanel.cs b/osu.Game/Overlays/Mods/ModPanel.cs index 9f87a704c0..b85904f22b 100644 --- a/osu.Game/Overlays/Mods/ModPanel.cs +++ b/osu.Game/Overlays/Mods/ModPanel.cs @@ -6,6 +6,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Rulesets.Mods; @@ -58,6 +59,21 @@ namespace osu.Game.Overlays.Mods modState.ValidForSelection.BindValueChanged(_ => updateFilterState()); modState.MatchingTextFilter.BindValueChanged(_ => updateFilterState(), true); + modState.Preselected.BindValueChanged(b => + { + if (b.NewValue) + { + Content.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = AccentColour, + Hollow = true, + Radius = 2, + }; + } + else + Content.EdgeEffect = default; + }, true); } protected override void Select() diff --git a/osu.Game/Overlays/Mods/ModSelectOverlay.cs b/osu.Game/Overlays/Mods/ModSelectOverlay.cs index cdc0fbbd96..ed73340eeb 100644 --- a/osu.Game/Overlays/Mods/ModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/ModSelectOverlay.cs @@ -243,6 +243,9 @@ namespace osu.Game.Overlays.Mods { foreach (var column in columnFlow.Columns) column.SearchTerm = query.NewValue; + + if (SearchTextBox.HasFocus) + preselectMod(); }, true); // Start scrolling from the end, to give the user a sense that @@ -254,6 +257,26 @@ namespace osu.Game.Overlays.Mods }); } + private void preselectMod() + { + var visibleMods = columnFlow.Columns.OfType().Where(c => c.IsPresent).SelectMany(c => c.AvailableMods.Where(m => m.Visible)); + + // Search for an exact acronym or name match, or otherwise default to the first visible mod. + ModState? matchingMod = + visibleMods.FirstOrDefault(m => m.Mod.Acronym.Equals(SearchTerm, StringComparison.OrdinalIgnoreCase) || m.Mod.Name.Equals(SearchTerm, StringComparison.OrdinalIgnoreCase)) + ?? visibleMods.FirstOrDefault(); + var preselectedMod = matchingMod; + + foreach (var mod in AllAvailableMods) + mod.Preselected.Value = mod == preselectedMod && SearchTextBox.Current.Value.Length > 0; + } + + private void clearPreselection() + { + foreach (var mod in AllAvailableMods) + mod.Preselected.Value = false; + } + public new ModSelectFooterContent? DisplayedFooterContent => base.DisplayedFooterContent as ModSelectFooterContent; public override VisibilityContainer CreateFooterContent() => new ModSelectFooterContent(this) @@ -383,7 +406,7 @@ namespace osu.Game.Overlays.Mods { columnScroll.FadeColour(OsuColour.Gray(0.5f), 400, Easing.OutQuint); SearchTextBox.FadeColour(OsuColour.Gray(0.5f), 400, Easing.OutQuint); - SearchTextBox.KillFocus(); + setTextBoxFocus(false); } else { @@ -590,11 +613,11 @@ namespace osu.Game.Overlays.Mods return true; } - ModState? firstMod = columnFlow.Columns.OfType().FirstOrDefault(m => m.IsPresent)?.AvailableMods.FirstOrDefault(x => x.Visible); + var matchingMod = AllAvailableMods.SingleOrDefault(m => m.Preselected.Value); - if (firstMod is not null) + if (matchingMod is not null) { - firstMod.Active.Value = !firstMod.Active.Value; + matchingMod.Active.Value = !matchingMod.Active.Value; SearchTextBox.SelectAll(); } @@ -648,9 +671,15 @@ namespace osu.Game.Overlays.Mods private void setTextBoxFocus(bool focus) { if (focus) + { SearchTextBox.TakeFocus(); + preselectMod(); + } else + { SearchTextBox.KillFocus(); + clearPreselection(); + } } #endregion diff --git a/osu.Game/Overlays/Mods/ModState.cs b/osu.Game/Overlays/Mods/ModState.cs index 7a5bc0f3ae..48fde2fc44 100644 --- a/osu.Game/Overlays/Mods/ModState.cs +++ b/osu.Game/Overlays/Mods/ModState.cs @@ -22,6 +22,8 @@ namespace osu.Game.Overlays.Mods /// public BindableBool Active { get; } = new BindableBool(); + public BindableBool Preselected { get; } = new BindableBool(); + /// /// Whether the mod requires further customisation. /// This flag is read by the to determine if the customisation panel should be opened after a mod change diff --git a/osu.Game/Overlays/MusicController.cs b/osu.Game/Overlays/MusicController.cs index 63efdd5381..87920fdf55 100644 --- a/osu.Game/Overlays/MusicController.cs +++ b/osu.Game/Overlays/MusicController.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -13,8 +14,10 @@ using osu.Framework.Graphics.Audio; using osu.Framework.Graphics.Containers; using osu.Framework.Logging; using osu.Framework.Threading; +using osu.Framework.Utils; using osu.Game.Audio.Effects; using osu.Game.Beatmaps; +using osu.Game.Configuration; using osu.Game.Database; using osu.Game.Rulesets.Mods; @@ -43,6 +46,8 @@ namespace osu.Game.Overlays /// public readonly BindableBool AllowTrackControl = new BindableBool(true); + public readonly BindableBool Shuffle = new BindableBool(true); + /// /// Fired when the global has changed. /// Includes direction information for display purposes. @@ -66,12 +71,19 @@ namespace osu.Game.Overlays private AudioFilter audioDuckFilter = null!; + private readonly Bindable randomSelectAlgorithm = new Bindable(); + private readonly List> previousRandomSets = new List>(); + private int randomHistoryDirection; + private int lastRandomTrackDirection; + [BackgroundDependencyLoader] - private void load(AudioManager audio) + private void load(AudioManager audio, OsuConfigManager configManager) { AddInternal(audioDuckFilter = new AudioFilter(audio.TrackMixer)); audio.Tracks.AddAdjustment(AdjustableProperty.Volume, audioDuckVolume); sampleVolume = audio.VolumeSample.GetBoundCopy(); + + configManager.BindWith(OsuSetting.RandomSelectAlgorithm, randomSelectAlgorithm); } protected override void LoadComplete() @@ -238,12 +250,19 @@ namespace osu.Game.Overlays queuedDirection = TrackChangeDirection.Prev; - var playableSet = getBeatmapSets().AsEnumerable().TakeWhile(i => !i.Equals(current?.BeatmapSetInfo)).LastOrDefault(s => !s.Protected || allowProtectedTracks) - ?? getBeatmapSets().AsEnumerable().LastOrDefault(s => !s.Protected || allowProtectedTracks); + Live? playableSet; + + if (Shuffle.Value) + playableSet = getNextRandom(-1, allowProtectedTracks); + else + { + playableSet = getBeatmapSets().TakeWhile(i => !i.Value.Equals(current?.BeatmapSetInfo)).LastOrDefault(s => !s.Value.Protected || allowProtectedTracks) + ?? getBeatmapSets().LastOrDefault(s => !s.Value.Protected || allowProtectedTracks); + } if (playableSet != null) { - changeBeatmap(beatmaps.GetWorkingBeatmap(playableSet.Beatmaps.First())); + changeBeatmap(beatmaps.GetWorkingBeatmap(playableSet.Value.Beatmaps.First())); restartTrack(); return PreviousTrackResult.Previous; } @@ -327,10 +346,19 @@ namespace osu.Game.Overlays queuedDirection = TrackChangeDirection.Next; - var playableSet = getBeatmapSets().AsEnumerable().SkipWhile(i => !i.Equals(current?.BeatmapSetInfo) || (i.Protected && !allowProtectedTracks)).ElementAtOrDefault(1) - ?? getBeatmapSets().AsEnumerable().FirstOrDefault(i => !i.Protected || allowProtectedTracks); + Live? playableSet; - var playableBeatmap = playableSet?.Beatmaps.FirstOrDefault(); + if (Shuffle.Value) + playableSet = getNextRandom(1, allowProtectedTracks); + else + { + playableSet = getBeatmapSets().SkipWhile(i => !i.Value.Equals(current?.BeatmapSetInfo)) + .Where(i => !i.Value.Protected || allowProtectedTracks) + .ElementAtOrDefault(1) + ?? getBeatmapSets().FirstOrDefault(i => !i.Value.Protected || allowProtectedTracks); + } + + var playableBeatmap = playableSet?.Value.Beatmaps.FirstOrDefault(); if (playableBeatmap != null) { @@ -342,6 +370,84 @@ namespace osu.Game.Overlays return false; } + private Live? getNextRandom(int direction, bool allowProtectedTracks) + { + try + { + Live result; + + var possibleSets = getBeatmapSets().Where(s => !s.Value.Protected || allowProtectedTracks).ToList(); + + if (possibleSets.Count == 0) + return null; + + // if there is only one possible set left, play it, even if it is the same as the current track. + // looping is preferable over playing nothing. + if (possibleSets.Count == 1) + return possibleSets.Single(); + + // now that we actually know there is a choice, do not allow the current track to be played again. + possibleSets.RemoveAll(s => s.Value.Equals(current?.BeatmapSetInfo)); + + // condition below checks if the signs of `randomHistoryDirection` and `direction` are opposite and not zero. + // if that is the case, it means that the user had previously chosen next track `randomHistoryDirection` times and wants to go back, + // or that the user had previously chosen previous track `randomHistoryDirection` times and wants to go forward. + // in both cases, it means that we have a history of previous random selections that we can rewind. + if (randomHistoryDirection * direction < 0) + { + Debug.Assert(Math.Abs(randomHistoryDirection) == previousRandomSets.Count); + + // if the user has been shuffling backwards and now going forwards (or vice versa), + // the topmost item from history needs to be discarded because it's the *current* track. + if (direction * lastRandomTrackDirection < 0) + { + previousRandomSets.RemoveAt(previousRandomSets.Count - 1); + randomHistoryDirection += direction; + } + + if (previousRandomSets.Count > 0) + { + result = previousRandomSets[^1]; + previousRandomSets.RemoveAt(previousRandomSets.Count - 1); + return result; + } + } + + // if the early-return above didn't cover it, it means that we have no history to fall back on + // and need to actually choose something random. + switch (randomSelectAlgorithm.Value) + { + case RandomSelectAlgorithm.Random: + result = possibleSets[RNG.Next(possibleSets.Count)]; + break; + + case RandomSelectAlgorithm.RandomPermutation: + var notYetPlayedSets = possibleSets.Except(previousRandomSets).ToList(); + + if (notYetPlayedSets.Count == 0) + { + notYetPlayedSets = possibleSets; + previousRandomSets.Clear(); + randomHistoryDirection = 0; + } + + result = notYetPlayedSets[RNG.Next(notYetPlayedSets.Count)]; + break; + + default: + throw new ArgumentOutOfRangeException(nameof(randomSelectAlgorithm), randomSelectAlgorithm.Value, "Unsupported random select algorithm"); + } + + previousRandomSets.Add(result); + return result; + } + finally + { + randomHistoryDirection += direction; + lastRandomTrackDirection = direction; + } + } + private void restartTrack() { // if not scheduled, the previously track will be stopped one frame later (see ScheduleAfterChildren logic in GameBase). @@ -353,7 +459,9 @@ namespace osu.Game.Overlays private TrackChangeDirection? queuedDirection; - private IQueryable getBeatmapSets() => realm.Realm.All().Where(s => !s.DeletePending); + private IEnumerable> getBeatmapSets() => realm.Realm.All().Where(s => !s.DeletePending) + .AsEnumerable() + .Select(s => new RealmLive(s, realm)); private void changeBeatmap(WorkingBeatmap newWorking) { @@ -380,8 +488,8 @@ namespace osu.Game.Overlays else { // figure out the best direction based on order in playlist. - int last = getBeatmapSets().AsEnumerable().TakeWhile(b => !b.Equals(current.BeatmapSetInfo)).Count(); - int next = getBeatmapSets().AsEnumerable().TakeWhile(b => !b.Equals(newWorking.BeatmapSetInfo)).Count(); + int last = getBeatmapSets().TakeWhile(b => !b.Value.Equals(current.BeatmapSetInfo)).Count(); + int next = getBeatmapSets().TakeWhile(b => !b.Value.Equals(newWorking.BeatmapSetInfo)).Count(); direction = last > next ? TrackChangeDirection.Prev : TrackChangeDirection.Next; } diff --git a/osu.Game/Overlays/NowPlayingOverlay.cs b/osu.Game/Overlays/NowPlayingOverlay.cs index 76c8c237d5..f4da9a92dc 100644 --- a/osu.Game/Overlays/NowPlayingOverlay.cs +++ b/osu.Game/Overlays/NowPlayingOverlay.cs @@ -47,6 +47,7 @@ namespace osu.Game.Overlays private IconButton prevButton = null!; private IconButton playButton = null!; private IconButton nextButton = null!; + private MusicIconButton shuffleButton = null!; private IconButton playlistButton = null!; private ScrollingTextContainer title = null!, artist = null!; @@ -69,6 +70,7 @@ namespace osu.Game.Overlays private OsuColour colours { get; set; } = null!; private Bindable allowTrackControl = null!; + private readonly BindableBool shuffle = new BindableBool(true); public NowPlayingOverlay() { @@ -164,6 +166,14 @@ namespace osu.Game.Overlays }, } }, + shuffleButton = new MusicIconButton + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.Centre, + Position = new Vector2(bottom_black_area_height / 2, 0), + Action = shuffle.Toggle, + Icon = FontAwesome.Solid.Random, + }, playlistButton = new MusicIconButton { Origin = Anchor.Centre, @@ -227,6 +237,9 @@ namespace osu.Game.Overlays allowTrackControl = musicController.AllowTrackControl.GetBoundCopy(); allowTrackControl.BindValueChanged(_ => Scheduler.AddOnce(updateEnabledStates), true); + shuffle.BindTo(musicController.Shuffle); + shuffle.BindValueChanged(s => shuffleButton.FadeColour(s.NewValue ? colours.Yellow : Color4.White, 200, Easing.OutQuint), true); + musicController.TrackChanged += trackChanged; trackChanged(beatmap.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/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs b/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs index b237a0ee05..df657aa55b 100644 --- a/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs @@ -71,7 +71,7 @@ namespace osu.Game.Overlays.Profile.Sections.Beatmaps ? new BeatmapCardNormal(model) { Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, + Origin = Anchor.TopCentre } : null; } diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/HUDSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/HUDSettings.cs index f4dd319152..b4caaf7983 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/HUDSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/HUDSettings.cs @@ -41,6 +41,11 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay Current = config.GetBindable(OsuSetting.GameplayLeaderboard), }, new SettingsCheckbox + { + LabelText = GameplaySettingsStrings.AlwaysRequireHoldForMenu, + Current = config.GetBindable(OsuSetting.AlwaysRequireHoldingForPause), + }, + new SettingsCheckbox { LabelText = GameplaySettingsStrings.AlwaysShowHoldForMenuButton, Current = config.GetBindable(OsuSetting.AlwaysShowHoldForMenuButton), diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs index ce087f1807..f40a4c941f 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs @@ -268,7 +268,8 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics private void updateScreenModeWarning() { - if (RuntimeInfo.OS == RuntimeInfo.Platform.macOS) + // Can be removed once we stop supporting SDL2. + if (RuntimeInfo.OS == RuntimeInfo.Platform.macOS && !FrameworkEnvironment.UseSDL3) { if (windowModeDropdown.Current.Value == WindowMode.Fullscreen) windowModeDropdown.SetNoticeText(LayoutSettingsStrings.FullscreenMacOSNote, true); 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/Settings/Sections/Maintenance/BeatmapSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/BeatmapSettings.cs index d0a8fc7d2c..597e03fab2 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/BeatmapSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/BeatmapSettings.cs @@ -16,6 +16,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance private SettingsButton deleteBeatmapsButton = null!; private SettingsButton deleteBeatmapVideosButton = null!; + private SettingsButton resetOffsetsButton = null!; private SettingsButton restoreButton = null!; private SettingsButton undeleteButton = null!; @@ -47,6 +48,20 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance }, DeleteConfirmationContentStrings.BeatmapVideos)); } }); + + Add(resetOffsetsButton = new DangerousSettingsButton + { + Text = MaintenanceSettingsStrings.ResetAllOffsets, + Action = () => + { + dialogOverlay?.Push(new MassDeleteConfirmationDialog(() => + { + resetOffsetsButton.Enabled.Value = false; + Task.Run(beatmaps.ResetAllOffsets).ContinueWith(_ => Schedule(() => resetOffsetsButton.Enabled.Value = true)); + }, DeleteConfirmationContentStrings.Offsets)); + } + }); + AddRange(new Drawable[] { restoreButton = new SettingsButton diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/MassDeleteConfirmationDialog.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/MassDeleteConfirmationDialog.cs index 7ead815fe9..a7a7ee2590 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/MassDeleteConfirmationDialog.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/MassDeleteConfirmationDialog.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Overlays.Dialog; @@ -12,6 +13,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance public MassDeleteConfirmationDialog(Action deleteAction, LocalisableString deleteContent) { BodyText = deleteContent; + Icon = FontAwesome.Solid.Trash; DangerousAction = deleteAction; } } diff --git a/osu.Game/Overlays/Settings/SettingsButton.cs b/osu.Game/Overlays/Settings/SettingsButton.cs index a837444758..3f5d612eb8 100644 --- a/osu.Game/Overlays/Settings/SettingsButton.cs +++ b/osu.Game/Overlays/Settings/SettingsButton.cs @@ -20,13 +20,13 @@ namespace osu.Game.Overlays.Settings Padding = new MarginPadding { Left = SettingsPanel.CONTENT_MARGINS, Right = SettingsPanel.CONTENT_MARGINS }; } - public LocalisableString TooltipText { get; set; } - public IEnumerable Keywords { get; set; } = Array.Empty(); public BindableBool CanBeShown { get; } = new BindableBool(true); IBindable IConditionalFilterable.CanBeShown => CanBeShown; + public LocalisableString TooltipText { get; set; } + public override IEnumerable FilterTerms { get diff --git a/osu.Game/Overlays/Settings/SettingsSidebar.cs b/osu.Game/Overlays/Settings/SettingsSidebar.cs index ddbcd60ef6..d24c0a778c 100644 --- a/osu.Game/Overlays/Settings/SettingsSidebar.cs +++ b/osu.Game/Overlays/Settings/SettingsSidebar.cs @@ -66,7 +66,7 @@ namespace osu.Game.Overlays.Settings [BackgroundDependencyLoader] private void load() { - Size = new Vector2(SettingsSidebar.EXPANDED_WIDTH); + Size = new Vector2(EXPANDED_WIDTH); Padding = new MarginPadding(40); diff --git a/osu.Game/Overlays/SkinEditor/SkinEditor.cs b/osu.Game/Overlays/SkinEditor/SkinEditor.cs index 6f7781ee9c..d0ee2ccd71 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditor.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditor.cs @@ -34,11 +34,12 @@ using osu.Game.Screens.Edit.Components; using osu.Game.Screens.Edit.Components.Menus; using osu.Game.Skinning; using osu.Framework.Graphics.Cursor; +using osu.Game.Input.Bindings; namespace osu.Game.Overlays.SkinEditor { [Cached(typeof(SkinEditor))] - public partial class SkinEditor : VisibilityContainer, ICanAcceptFiles, IKeyBindingHandler, IEditorChangeHandler + public partial class SkinEditor : VisibilityContainer, ICanAcceptFiles, IKeyBindingHandler, IKeyBindingHandler, IEditorChangeHandler { public const double TRANSITION_DURATION = 300; @@ -155,7 +156,7 @@ namespace osu.Game.Overlays.SkinEditor { Items = new OsuMenuItem[] { - new EditorMenuItem(Web.CommonStrings.ButtonsSave, MenuItemType.Standard, () => Save()), + new EditorMenuItem(Web.CommonStrings.ButtonsSave, MenuItemType.Standard, () => Save()) { Hotkey = new Hotkey(PlatformAction.Save) }, new EditorMenuItem(CommonStrings.Export, MenuItemType.Standard, () => skins.ExportCurrentSkin()) { Action = { Disabled = !RuntimeInfo.IsDesktop } }, new OsuMenuItemSpacer(), new EditorMenuItem(CommonStrings.RevertToDefault, MenuItemType.Destructive, () => dialogOverlay?.Push(new RevertConfirmDialog(revert))), @@ -167,13 +168,13 @@ namespace osu.Game.Overlays.SkinEditor { Items = new OsuMenuItem[] { - undoMenuItem = new EditorMenuItem(CommonStrings.Undo, MenuItemType.Standard, Undo), - redoMenuItem = new EditorMenuItem(CommonStrings.Redo, MenuItemType.Standard, Redo), + undoMenuItem = new EditorMenuItem(CommonStrings.Undo, MenuItemType.Standard, Undo) { Hotkey = new Hotkey(PlatformAction.Undo) }, + redoMenuItem = new EditorMenuItem(CommonStrings.Redo, MenuItemType.Standard, Redo) { Hotkey = new Hotkey(PlatformAction.Redo) }, new OsuMenuItemSpacer(), - cutMenuItem = new EditorMenuItem(CommonStrings.Cut, MenuItemType.Standard, Cut), - copyMenuItem = new EditorMenuItem(CommonStrings.Copy, MenuItemType.Standard, Copy), - pasteMenuItem = new EditorMenuItem(CommonStrings.Paste, MenuItemType.Standard, Paste), - cloneMenuItem = new EditorMenuItem(CommonStrings.Clone, MenuItemType.Standard, Clone), + cutMenuItem = new EditorMenuItem(CommonStrings.Cut, MenuItemType.Standard, Cut) { Hotkey = new Hotkey(PlatformAction.Cut) }, + copyMenuItem = new EditorMenuItem(CommonStrings.Copy, MenuItemType.Standard, Copy) { Hotkey = new Hotkey(PlatformAction.Copy) }, + pasteMenuItem = new EditorMenuItem(CommonStrings.Paste, MenuItemType.Standard, Paste) { Hotkey = new Hotkey(PlatformAction.Paste) }, + cloneMenuItem = new EditorMenuItem(CommonStrings.Clone, MenuItemType.Standard, Clone) { Hotkey = new Hotkey(GlobalAction.EditorCloneSelection) }, } }, } @@ -313,6 +314,25 @@ namespace osu.Game.Overlays.SkinEditor { } + public bool OnPressed(KeyBindingPressEvent e) + { + if (e.Repeat) + return false; + + switch (e.Action) + { + case GlobalAction.EditorCloneSelection: + Clone(); + return true; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + public void UpdateTargetScreen(Drawable targetScreen) { this.targetScreen = targetScreen; @@ -361,7 +381,7 @@ namespace osu.Game.Overlays.SkinEditor componentsSidebar.Children = new[] { - new EditorSidebarSection("Current working layer") + new EditorSidebarSection(SkinEditorStrings.CurrentWorkingLayer) { Children = new Drawable[] { diff --git a/osu.Game/Overlays/SkinEditor/SkinSelectionHandler.cs b/osu.Game/Overlays/SkinEditor/SkinSelectionHandler.cs index 722ffd6d07..bc878b9214 100644 --- a/osu.Game/Overlays/SkinEditor/SkinSelectionHandler.cs +++ b/osu.Game/Overlays/SkinEditor/SkinSelectionHandler.cs @@ -13,6 +13,7 @@ using osu.Game.Extensions; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; using osu.Game.Screens.Edit.Compose.Components; +using osu.Game.Localisation; using osu.Game.Skinning; using osu.Game.Utils; using osuTK; @@ -101,19 +102,19 @@ namespace osu.Game.Overlays.SkinEditor protected override IEnumerable GetContextMenuItemsForSelection(IEnumerable> selection) { - var closestItem = new TernaryStateRadioMenuItem("Closest", MenuItemType.Standard, _ => applyClosestAnchors()) + var closestItem = new TernaryStateRadioMenuItem(SkinEditorStrings.Closest, MenuItemType.Standard, _ => applyClosestAnchors()) { State = { Value = GetStateFromSelection(selection, c => !c.Item.UsesFixedAnchor) } }; - yield return new OsuMenuItem("Anchor") + yield return new OsuMenuItem(SkinEditorStrings.Anchor) { Items = createAnchorItems((d, a) => d.UsesFixedAnchor && ((Drawable)d).Anchor == a, applyFixedAnchors) .Prepend(closestItem) .ToArray() }; - yield return originMenu = new OsuMenuItem("Origin"); + yield return originMenu = new OsuMenuItem(SkinEditorStrings.Origin); closestItem.State.BindValueChanged(s => { @@ -125,19 +126,19 @@ namespace osu.Game.Overlays.SkinEditor yield return new OsuMenuItemSpacer(); - yield return new OsuMenuItem("Reset position", MenuItemType.Standard, () => + yield return new OsuMenuItem(SkinEditorStrings.ResetPosition, MenuItemType.Standard, () => { foreach (var blueprint in SelectedBlueprints) ((Drawable)blueprint.Item).Position = Vector2.Zero; }); - yield return new OsuMenuItem("Reset rotation", MenuItemType.Standard, () => + yield return new OsuMenuItem(SkinEditorStrings.ResetRotation, MenuItemType.Standard, () => { foreach (var blueprint in SelectedBlueprints) ((Drawable)blueprint.Item).Rotation = 0; }); - yield return new OsuMenuItem("Reset scale", MenuItemType.Standard, () => + yield return new OsuMenuItem(SkinEditorStrings.ResetScale, MenuItemType.Standard, () => { foreach (var blueprint in SelectedBlueprints) { @@ -153,9 +154,9 @@ namespace osu.Game.Overlays.SkinEditor yield return new OsuMenuItemSpacer(); - yield return new OsuMenuItem("Bring to front", MenuItemType.Standard, () => skinEditor.BringSelectionToFront()); + yield return new OsuMenuItem(SkinEditorStrings.BringToFront, MenuItemType.Standard, () => skinEditor.BringSelectionToFront()); - yield return new OsuMenuItem("Send to back", MenuItemType.Standard, () => skinEditor.SendSelectionToBack()); + yield return new OsuMenuItem(SkinEditorStrings.SendToBack, MenuItemType.Standard, () => skinEditor.SendSelectionToBack()); yield return new OsuMenuItemSpacer(); 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 9690924b1c..7b6bc37a61 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs @@ -26,6 +26,10 @@ namespace osu.Game.Rulesets.Difficulty protected const int ATTRIB_ID_FLASHLIGHT = 17; protected const int ATTRIB_ID_SLIDER_FACTOR = 19; protected const int ATTRIB_ID_SPEED_NOTE_COUNT = 21; + 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/PerformanceBreakdownCalculator.cs b/osu.Game/Rulesets/Difficulty/PerformanceBreakdownCalculator.cs deleted file mode 100644 index 946d83b14b..0000000000 --- a/osu.Game/Rulesets/Difficulty/PerformanceBreakdownCalculator.cs +++ /dev/null @@ -1,121 +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 System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using JetBrains.Annotations; -using osu.Framework.Extensions.ObjectExtensions; -using osu.Game.Beatmaps; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Scoring; -using osu.Game.Scoring; - -namespace osu.Game.Rulesets.Difficulty -{ - public class PerformanceBreakdownCalculator - { - private readonly IBeatmap playableBeatmap; - private readonly BeatmapDifficultyCache difficultyCache; - - public PerformanceBreakdownCalculator(IBeatmap playableBeatmap, BeatmapDifficultyCache difficultyCache) - { - this.playableBeatmap = playableBeatmap; - this.difficultyCache = difficultyCache; - } - - [ItemCanBeNull] - public async Task CalculateAsync(ScoreInfo score, CancellationToken cancellationToken = default) - { - var attributes = await difficultyCache.GetDifficultyAsync(score.BeatmapInfo!, score.Ruleset, score.Mods, cancellationToken).ConfigureAwait(false); - - var performanceCalculator = score.Ruleset.CreateInstance().CreatePerformanceCalculator(); - - // Performance calculation requires the beatmap and ruleset to be locally available. If not, return a default value. - if (attributes?.Attributes == null || performanceCalculator == null) - return null; - - cancellationToken.ThrowIfCancellationRequested(); - - PerformanceAttributes[] performanceArray = await Task.WhenAll( - // compute actual performance - performanceCalculator.CalculateAsync(score, attributes.Value.Attributes, cancellationToken), - // compute performance for perfect play - getPerfectPerformance(score, cancellationToken) - ).ConfigureAwait(false); - - return new PerformanceBreakdown(performanceArray[0] ?? new PerformanceAttributes(), performanceArray[1] ?? new PerformanceAttributes()); - } - - [ItemCanBeNull] - private Task getPerfectPerformance(ScoreInfo score, CancellationToken cancellationToken = default) - { - return Task.Run(async () => - { - Ruleset ruleset = score.Ruleset.CreateInstance(); - ScoreInfo perfectPlay = score.DeepClone(); - perfectPlay.Accuracy = 1; - perfectPlay.Passed = true; - - // calculate max combo - // todo: Get max combo from difficulty calculator instead when diffcalc properly supports lazer-first scores - perfectPlay.MaxCombo = calculateMaxCombo(playableBeatmap); - - // create statistics assuming all hit objects have perfect hit result - var statistics = playableBeatmap.HitObjects - .SelectMany(getPerfectHitResults) - .GroupBy(hr => hr, (hr, list) => (hitResult: hr, count: list.Count())) - .ToDictionary(pair => pair.hitResult, pair => pair.count); - perfectPlay.Statistics = statistics; - perfectPlay.MaximumStatistics = statistics; - - // calculate total score - ScoreProcessor scoreProcessor = ruleset.CreateScoreProcessor(); - scoreProcessor.Mods.Value = perfectPlay.Mods; - scoreProcessor.ApplyBeatmap(playableBeatmap); - perfectPlay.TotalScore = scoreProcessor.MaximumTotalScore; - - // compute rank achieved - // default to SS, then adjust the rank with mods - perfectPlay.Rank = ScoreRank.X; - - foreach (IApplicableToScoreProcessor mod in perfectPlay.Mods.OfType()) - { - perfectPlay.Rank = mod.AdjustRank(perfectPlay.Rank, 1); - } - - // calculate performance for this perfect score - var difficulty = await difficultyCache.GetDifficultyAsync( - playableBeatmap.BeatmapInfo, - score.Ruleset, - score.Mods, - cancellationToken - ).ConfigureAwait(false); - - var performanceCalculator = ruleset.CreatePerformanceCalculator(); - - if (performanceCalculator == null || difficulty == null) - return null; - - return await performanceCalculator.CalculateAsync(perfectPlay, difficulty.Value.Attributes.AsNonNull(), cancellationToken).ConfigureAwait(false); - }, cancellationToken); - } - - private int calculateMaxCombo(IBeatmap beatmap) - { - return beatmap.HitObjects.SelectMany(getPerfectHitResults).Count(r => r.AffectsCombo()); - } - - private IEnumerable getPerfectHitResults(HitObject hitObject) - { - foreach (HitObject nested in hitObject.NestedHitObjects) - yield return nested.Judgement.MaxResult; - - yield return hitObject.Judgement.MaxResult; - } - } -} diff --git a/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs index b07e8399c0..3ba67793dc 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,29 @@ 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 + + if (consistentTopStrain == 0) + return ObjectStrains.Count; + + // 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/ComposerDistanceSnapProvider.cs b/osu.Game/Rulesets/Edit/ComposerDistanceSnapProvider.cs index 979492fd8b..5bf15aee8b 100644 --- a/osu.Game/Rulesets/Edit/ComposerDistanceSnapProvider.cs +++ b/osu.Game/Rulesets/Edit/ComposerDistanceSnapProvider.cs @@ -74,6 +74,7 @@ namespace osu.Game.Rulesets.Edit toolboxContainer.Add(toolboxGroup = new EditorToolboxGroup("snapping") { + Name = "snapping", Alpha = DistanceSpacingMultiplier.Disabled ? 0 : 1, Children = new Drawable[] { @@ -162,7 +163,7 @@ namespace osu.Game.Rulesets.Edit return (lastBefore, firstAfter); } - protected abstract double ReadCurrentDistanceSnap(HitObject before, HitObject after); + public abstract double ReadCurrentDistanceSnap(HitObject before, HitObject after); protected override void Update() { @@ -280,22 +281,36 @@ namespace osu.Game.Rulesets.Edit public virtual double FindSnappedDuration(HitObject referenceObject, float distance) => beatSnapProvider.SnapTime(referenceObject.StartTime + DistanceToDuration(referenceObject, distance), referenceObject.StartTime) - referenceObject.StartTime; - public virtual float FindSnappedDistance(HitObject referenceObject, float distance) + public virtual float FindSnappedDistance(HitObject referenceObject, float distance, DistanceSnapTarget target) { - double startTime = referenceObject.StartTime; + double referenceTime; - double actualDuration = startTime + DistanceToDuration(referenceObject, distance); + switch (target) + { + case DistanceSnapTarget.Start: + referenceTime = referenceObject.StartTime; + break; - double snappedEndTime = beatSnapProvider.SnapTime(actualDuration, startTime); + case DistanceSnapTarget.End: + referenceTime = referenceObject.GetEndTime(); + break; - double beatLength = beatSnapProvider.GetBeatLengthAtTime(startTime); + default: + throw new ArgumentOutOfRangeException(nameof(target), target, $"Unknown {nameof(DistanceSnapTarget)} value"); + } + + double actualDuration = referenceTime + DistanceToDuration(referenceObject, distance); + + double snappedTime = beatSnapProvider.SnapTime(actualDuration, referenceTime); + + double beatLength = beatSnapProvider.GetBeatLengthAtTime(referenceTime); // we don't want to exceed the actual duration and snap to a point in the future. // as we are snapping to beat length via SnapTime (which will round-to-nearest), check for snapping in the forward direction and reverse it. - if (snappedEndTime > actualDuration + 1) - snappedEndTime -= beatLength; + if (snappedTime > actualDuration + 1) + snappedTime -= beatLength; - return DurationToDistance(referenceObject, snappedEndTime - startTime); + return DurationToDistance(referenceObject, snappedTime - referenceTime); } #endregion diff --git a/osu.Game/Rulesets/Edit/ExpandableButton.cs b/osu.Game/Rulesets/Edit/ExpandableButton.cs index a708f76845..9139802d68 100644 --- a/osu.Game/Rulesets/Edit/ExpandableButton.cs +++ b/osu.Game/Rulesets/Edit/ExpandableButton.cs @@ -11,7 +11,7 @@ using osu.Game.Graphics.UserInterfaceV2; namespace osu.Game.Rulesets.Edit { - internal partial class ExpandableButton : RoundedButton, IExpandable + public partial class ExpandableButton : RoundedButton, IExpandable { private float actualHeight; diff --git a/osu.Game/Rulesets/Edit/ExpandableSpriteText.cs b/osu.Game/Rulesets/Edit/ExpandableSpriteText.cs new file mode 100644 index 0000000000..b45fce1d22 --- /dev/null +++ b/osu.Game/Rulesets/Edit/ExpandableSpriteText.cs @@ -0,0 +1,34 @@ +// 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.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Rulesets.Edit +{ + internal partial class ExpandableSpriteText : OsuSpriteText, IExpandable + { + public BindableBool Expanded { get; } = new BindableBool(); + + [Resolved(canBeNull: true)] + private IExpandingContainer? expandingContainer { get; set; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + expandingContainer?.Expanded.BindValueChanged(containerExpanded => + { + Expanded.Value = containerExpanded.NewValue; + }, true); + + Expanded.BindValueChanged(expanded => + { + this.FadeTo(expanded.NewValue ? 1 : 0, 150, Easing.OutQuint); + }, true); + } + } +} diff --git a/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs b/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs index c2ab5a6eb9..8af795f880 100644 --- a/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs +++ b/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs @@ -1,9 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Input.Events; +using osu.Game.Configuration; using osu.Game.Graphics.Containers; +using osu.Game.Screens.Edit; using osuTK; namespace osu.Game.Rulesets.Edit @@ -12,6 +16,15 @@ namespace osu.Game.Rulesets.Edit { protected override double HoverExpansionDelay => 250; + protected override bool ExpandOnHover => expandOnHover; + + private readonly Bindable contractSidebars = new Bindable(); + + private bool expandOnHover; + + [Resolved] + private Editor? editor { get; set; } + public ExpandingToolboxContainer(float contractedWidth, float expandedWidth) : base(contractedWidth, expandedWidth) { @@ -19,6 +32,27 @@ namespace osu.Game.Rulesets.Edit FillFlow.Spacing = new Vector2(5); FillFlow.Padding = new MarginPadding { Vertical = 5 }; + + Expanded.Value = true; + } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + config.BindWith(OsuSetting.EditorContractSidebars, contractSidebars); + } + + protected override void Update() + { + base.Update(); + + bool requireContracting = contractSidebars.Value || editor?.DrawSize.X / editor?.DrawSize.Y < 1.7f; + + if (expandOnHover != requireContracting) + { + expandOnHover = requireContracting; + Expanded.Value = !expandOnHover; + } } protected override bool ReceivePositionalInputAtSubTree(Vector2 screenSpacePos) => base.ReceivePositionalInputAtSubTree(screenSpacePos) && anyToolboxHovered(screenSpacePos); diff --git a/osu.Game/Rulesets/Edit/HitObjectComposer.cs b/osu.Game/Rulesets/Edit/HitObjectComposer.cs index 00de46b726..4b64548f9c 100644 --- a/osu.Game/Rulesets/Edit/HitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/HitObjectComposer.cs @@ -18,6 +18,7 @@ using osu.Framework.Input.Events; using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Graphics; using osu.Game.Overlays; using osu.Game.Rulesets.Configuration; using osu.Game.Rulesets.Edit.Tools; @@ -90,6 +91,9 @@ namespace osu.Game.Rulesets.Edit private Bindable autoSeekOnPlacement; private readonly Bindable composerFocusMode = new Bindable(); + [CanBeNull] + private RadioButton lastTool; + protected DrawableRuleset DrawableRuleset { get; private set; } protected HitObjectComposer(Ruleset ruleset) @@ -175,16 +179,56 @@ namespace osu.Game.Rulesets.Edit Spacing = new Vector2(0, 5), }, }, - new EditorToolboxGroup("bank (Shift-Q~R)") + new EditorToolboxGroup("bank (Shift/Alt-Q~R)") { - Child = sampleBankTogglesCollection = new FillFlowContainer + Child = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Vertical, Spacing = new Vector2(0, 5), - }, - } + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new ExpandableSpriteText + { + Text = "Normal", + AlwaysPresent = true, + AllowMultiline = false, + RelativePositionAxes = Axes.X, + X = 0.25f, + Origin = Anchor.TopCentre, + Anchor = Anchor.TopLeft, + Font = OsuFont.GetFont(weight: FontWeight.Regular, size: 17), + }, + new ExpandableSpriteText + { + Text = "Addition", + AlwaysPresent = true, + AllowMultiline = false, + RelativePositionAxes = Axes.X, + X = 0.75f, + Origin = Anchor.TopCentre, + Anchor = Anchor.TopLeft, + Font = OsuFont.GetFont(weight: FontWeight.Regular, size: 17), + }, + } + }, + sampleBankTogglesCollection = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5), + }, + } + } + }, } }, } @@ -213,8 +257,7 @@ namespace osu.Game.Rulesets.Edit }, }; - toolboxCollection.Items = CompositionTools - .Prepend(new SelectTool()) + toolboxCollection.Items = (CompositionTools.Prepend(new SelectTool())) .Select(t => new HitObjectCompositionToolButton(t, () => toolSelected(t))) .ToList(); @@ -229,9 +272,9 @@ namespace osu.Game.Rulesets.Edit TernaryStates = CreateTernaryButtons().ToArray(); togglesCollection.AddRange(TernaryStates.Select(b => new DrawableTernaryButton(b))); - sampleBankTogglesCollection.AddRange(BlueprintContainer.SampleBankTernaryStates.Select(b => new DrawableTernaryButton(b))); + sampleBankTogglesCollection.AddRange(BlueprintContainer.SampleBankTernaryStates.Zip(BlueprintContainer.SampleAdditionBankTernaryStates).Select(b => new SampleBankTernaryButton(b.First, b.Second))); - setSelectTool(); + SetSelectTool(); EditorBeatmap.SelectedHitObjects.CollectionChanged += selectionChanged; } @@ -256,7 +299,7 @@ namespace osu.Game.Rulesets.Edit { // it's important this is performed before the similar code in EditorRadioButton disables the button. if (!timing.NewValue) - setSelectTool(); + SetSelectTool(); }); EditorBeatmap.HasTiming.BindValueChanged(hasTiming => @@ -301,8 +344,8 @@ namespace osu.Game.Rulesets.Edit PlayfieldContentContainer.Anchor = Anchor.CentreLeft; PlayfieldContentContainer.Origin = Anchor.CentreLeft; - PlayfieldContentContainer.Width = Math.Max(1024, DrawWidth) - (TOOLBOX_CONTRACTED_SIZE_LEFT + TOOLBOX_CONTRACTED_SIZE_RIGHT); - PlayfieldContentContainer.X = TOOLBOX_CONTRACTED_SIZE_LEFT; + PlayfieldContentContainer.Width = Math.Max(1024, DrawWidth); + PlayfieldContentContainer.X = LeftToolbox.DrawWidth; } composerFocusMode.Value = PlayfieldContentContainer.Contains(InputManager.CurrentState.Mouse.Position) @@ -360,10 +403,10 @@ namespace osu.Game.Rulesets.Edit protected override bool OnKeyDown(KeyDownEvent e) { - if (e.ControlPressed || e.AltPressed || e.SuperPressed) + if (e.ControlPressed || e.SuperPressed) return false; - if (checkLeftToggleFromKey(e.Key, out int leftIndex)) + if (checkToolboxMappingFromKey(e.Key, out int leftIndex)) { var item = toolboxCollection.Items.ElementAtOrDefault(leftIndex); @@ -375,23 +418,35 @@ namespace osu.Game.Rulesets.Edit } } - if (checkRightToggleFromKey(e.Key, out int rightIndex)) + if (checkToggleMappingFromKey(e.Key, out int rightIndex)) { - var item = e.ShiftPressed - ? sampleBankTogglesCollection.ElementAtOrDefault(rightIndex) - : togglesCollection.ElementAtOrDefault(rightIndex); - - if (item is DrawableTernaryButton button) + if (e.ShiftPressed || e.AltPressed) { - button.Button.Toggle(); - return true; + if (sampleBankTogglesCollection.ElementAtOrDefault(rightIndex) is SampleBankTernaryButton sampleBankTernaryButton) + { + if (e.ShiftPressed) + sampleBankTernaryButton.NormalButton.Toggle(); + + if (e.AltPressed) + sampleBankTernaryButton.AdditionsButton.Toggle(); + + return true; + } + } + else + { + if (togglesCollection.ElementAtOrDefault(rightIndex) is DrawableTernaryButton button) + { + button.Button.Toggle(); + return true; + } } } return base.OnKeyDown(e); } - private bool checkLeftToggleFromKey(Key key, out int index) + private bool checkToolboxMappingFromKey(Key key, out int index) { if (key < Key.Number1 || key > Key.Number9) { @@ -403,7 +458,7 @@ namespace osu.Game.Rulesets.Edit return true; } - private bool checkRightToggleFromKey(Key key, out int index) + private bool checkToggleMappingFromKey(Key key, out int index) { switch (key) { @@ -460,14 +515,18 @@ namespace osu.Game.Rulesets.Edit if (EditorBeatmap.SelectedHitObjects.Any()) { // ensure in selection mode if a selection is made. - setSelectTool(); + SetSelectTool(); } } - private void setSelectTool() => toolboxCollection.Items.First().Select(); + public void SetSelectTool() => toolboxCollection.Items.First().Select(); + + public void SetLastTool() => (lastTool ?? toolboxCollection.Items.First()).Select(); private void toolSelected(CompositionTool tool) { + lastTool = toolboxCollection.Items.OfType().FirstOrDefault(i => i.Tool == BlueprintContainer.CurrentTool); + BlueprintContainer.CurrentTool = tool; if (!(tool is SelectTool)) @@ -478,22 +537,23 @@ namespace osu.Game.Rulesets.Edit #region IPlacementHandler - public void BeginPlacement(HitObject hitObject) + public void ShowPlacement(HitObject hitObject) { EditorBeatmap.PlacementObject.Value = hitObject; } - public void EndPlacement(HitObject hitObject, bool commit) + public void HidePlacement() { EditorBeatmap.PlacementObject.Value = null; + } - if (commit) - { - EditorBeatmap.Add(hitObject); + public void CommitPlacement(HitObject hitObject) + { + EditorBeatmap.PlacementObject.Value = null; + EditorBeatmap.Add(hitObject); - 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) => EditorBeatmap.Remove(hitObject); diff --git a/osu.Game/Rulesets/Edit/HitObjectPlacementBlueprint.cs b/osu.Game/Rulesets/Edit/HitObjectPlacementBlueprint.cs index 74025b4260..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; @@ -25,6 +26,11 @@ namespace osu.Game.Rulesets.Edit /// public bool AutomaticBankAssignment { get; set; } + /// + /// Whether the sample addition bank should be taken from the previous hit objects. + /// + public bool AutomaticAdditionBankAssignment { get; set; } + /// /// The that is being placed. /// @@ -58,22 +64,30 @@ 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(); } /// - /// Updates the time and position of this based on the provided snap information. + /// Updates the time and position of this based on the provided snap information. /// /// The snap result information. public override void UpdateTimeAndPosition(SnapResult result) @@ -87,26 +101,26 @@ namespace osu.Game.Rulesets.Edit } var lastHitObject = getPreviousHitObject(); + var lastHitNormal = lastHitObject?.Samples?.FirstOrDefault(o => o.Name == HitSampleInfo.HIT_NORMAL); - if (AutomaticBankAssignment) + if (AutomaticAdditionBankAssignment) { - // Create samples based on the sample settings of the previous hit object - if (lastHitObject != null) - { - for (int i = 0; i < HitObject.Samples.Count; i++) - HitObject.Samples[i] = lastHitObject.CreateHitSampleInfo(HitObject.Samples[i].Name); - } + // Inherit the addition bank from the previous hit object + // If there is no previous addition, inherit from the normal sample + var lastAddition = lastHitObject?.Samples?.FirstOrDefault(o => o.Name != HitSampleInfo.HIT_NORMAL) ?? lastHitNormal; + + if (lastAddition != null) + HitObject.Samples = HitObject.Samples.Select(s => s.Name != HitSampleInfo.HIT_NORMAL ? s.With(newBank: lastAddition.Bank) : s).ToList(); } - else - { - var lastHitNormal = lastHitObject?.Samples?.FirstOrDefault(o => o.Name == HitSampleInfo.HIT_NORMAL); - if (lastHitNormal != null) - { - // Only inherit the volume from the previous hit object - for (int i = 0; i < HitObject.Samples.Count; i++) - HitObject.Samples[i] = HitObject.Samples[i].With(newVolume: lastHitNormal.Volume); - } + if (lastHitNormal != null) + { + if (AutomaticBankAssignment) + // Inherit the bank from the previous hit object + HitObject.Samples = HitObject.Samples.Select(s => s.Name == HitSampleInfo.HIT_NORMAL ? s.With(newBank: lastHitNormal.Bank) : s).ToList(); + + // Inherit the volume from the previous hit object + HitObject.Samples = HitObject.Samples.Select(s => s.With(newVolume: lastHitNormal.Volume)).ToList(); } if (HitObject is IHasRepeats hasRepeats) @@ -122,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/IDistanceSnapProvider.cs b/osu.Game/Rulesets/Edit/IDistanceSnapProvider.cs index 380038eadf..17fae9e8b2 100644 --- a/osu.Game/Rulesets/Edit/IDistanceSnapProvider.cs +++ b/osu.Game/Rulesets/Edit/IDistanceSnapProvider.cs @@ -58,10 +58,17 @@ namespace osu.Game.Rulesets.Edit /// /// An object to be used as a reference point for this operation. /// The distance to convert. + /// Whether the distance measured should be from the start or the end of . /// /// A value that represents snapped to the closest beat of the timing point. /// The distance will always be less than or equal to the provided . /// - float FindSnappedDistance(HitObject referenceObject, float distance); + float FindSnappedDistance(HitObject referenceObject, float distance, DistanceSnapTarget target); + } + + public enum DistanceSnapTarget + { + Start, + End, } } diff --git a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs index d2a54e8e03..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; } @@ -71,6 +75,11 @@ namespace osu.Game.Rulesets.Edit PlacementActive = PlacementState.Finished; } + /// + /// Determines which objects to snap to for the snap result in . + /// + public virtual SnapType SnapType => SnapType.All; + /// /// Updates the time and position of this based on the provided snap information. /// @@ -99,8 +108,6 @@ namespace osu.Game.Rulesets.Edit { } - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; - protected override bool Handle(UIEvent e) { base.Handle(e); @@ -122,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/Rulesets/Mods/ModBarrelRoll.cs b/osu.Game/Rulesets/Mods/ModBarrelRoll.cs index 4f90496308..67f9da37be 100644 --- a/osu.Game/Rulesets/Mods/ModBarrelRoll.cs +++ b/osu.Game/Rulesets/Mods/ModBarrelRoll.cs @@ -42,7 +42,7 @@ namespace osu.Game.Rulesets.Mods private PlayfieldAdjustmentContainer playfieldAdjustmentContainer = null!; - public void Update(Playfield playfield) + public virtual void Update(Playfield playfield) { playfieldAdjustmentContainer.Rotation = CurrentRotation = (Direction.Value == RotationDirection.Counterclockwise ? -1 : 1) * 360 * (float)(playfield.Time.Current / 60000 * SpinSpeed.Value); } diff --git a/osu.Game/Rulesets/Objects/HitObject.cs b/osu.Game/Rulesets/Objects/HitObject.cs index c547a7a718..9f980769e2 100644 --- a/osu.Game/Rulesets/Objects/HitObject.cs +++ b/osu.Game/Rulesets/Objects/HitObject.cs @@ -233,7 +233,7 @@ namespace osu.Game.Rulesets.Objects // Fall back to using the normal sample bank otherwise. if (Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL) is HitSampleInfo existingNormal) - return existingNormal.With(newName: sampleName); + return existingNormal.With(newName: sampleName, newEditorAutoBank: true); return new HitSampleInfo(sampleName); } diff --git a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHit.cs b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHit.cs deleted file mode 100644 index 96c779e79b..0000000000 --- a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHit.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Rulesets.Objects.Types; -using osuTK; - -namespace osu.Game.Rulesets.Objects.Legacy.Catch -{ - /// - /// Legacy osu!catch Hit-type, used for parsing Beatmaps. - /// - internal sealed class ConvertHit : ConvertHitObject, IHasPosition - { - public float X => Position.X; - - public float Y => Position.Y; - - public Vector2 Position { get; set; } - } -} diff --git a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHitObjectParser.cs deleted file mode 100644 index a5c1a73fa7..0000000000 --- a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHitObjectParser.cs +++ /dev/null @@ -1,63 +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 osuTK; -using osu.Game.Audio; -using System.Collections.Generic; - -namespace osu.Game.Rulesets.Objects.Legacy.Catch -{ - /// - /// A HitObjectParser to parse legacy osu!catch Beatmaps. - /// - public class ConvertHitObjectParser : Legacy.ConvertHitObjectParser - { - private ConvertHitObject lastObject; - - public ConvertHitObjectParser(double offset, int formatVersion) - : base(offset, formatVersion) - { - } - - protected override HitObject CreateHit(Vector2 position, bool newCombo, int comboOffset) - { - return lastObject = new ConvertHit - { - Position = position, - NewCombo = FirstObject || lastObject is ConvertSpinner || newCombo, - ComboOffset = newCombo ? comboOffset : 0 - }; - } - - protected override HitObject CreateSlider(Vector2 position, bool newCombo, int comboOffset, PathControlPoint[] controlPoints, double? length, int repeatCount, - IList> nodeSamples) - { - return lastObject = new ConvertSlider - { - Position = position, - NewCombo = FirstObject || lastObject is ConvertSpinner || newCombo, - ComboOffset = newCombo ? comboOffset : 0, - Path = new SliderPath(controlPoints, length), - NodeSamples = nodeSamples, - RepeatCount = repeatCount - }; - } - - protected override HitObject CreateSpinner(Vector2 position, bool newCombo, int comboOffset, double duration) - { - return lastObject = new ConvertSpinner - { - Duration = duration, - NewCombo = newCombo - // Spinners cannot have combo offset. - }; - } - - protected override HitObject CreateHold(Vector2 position, bool newCombo, int comboOffset, double duration) - { - return lastObject = null; - } - } -} diff --git a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertSlider.cs b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertSlider.cs deleted file mode 100644 index bcf1c7fae2..0000000000 --- a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertSlider.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Rulesets.Objects.Types; -using osuTK; - -namespace osu.Game.Rulesets.Objects.Legacy.Catch -{ - /// - /// Legacy osu!catch Slider-type, used for parsing Beatmaps. - /// - internal sealed class ConvertSlider : Legacy.ConvertSlider, IHasPosition - { - public float X => Position.X; - - public float Y => Position.Y; - - public Vector2 Position { get; set; } - } -} diff --git a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertSpinner.cs b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertSpinner.cs deleted file mode 100644 index 5ef3d51cb3..0000000000 --- a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertSpinner.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Rulesets.Objects.Types; - -namespace osu.Game.Rulesets.Objects.Legacy.Catch -{ - /// - /// Legacy osu!catch Spinner-type, used for parsing Beatmaps. - /// - internal sealed class ConvertSpinner : ConvertHitObject, IHasDuration, IHasXPosition - { - public double EndTime => StartTime + Duration; - - public double Duration { get; set; } - - public float X => 256; // Required for CatchBeatmapConverter - } -} diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitCircle.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitCircle.cs new file mode 100644 index 0000000000..d1852d7032 --- /dev/null +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitCircle.cs @@ -0,0 +1,13 @@ +// 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.Rulesets.Objects.Legacy +{ + /// + /// Legacy "HitCircle" hit object type. + /// + /// + /// Only used for parsing beatmaps and not gameplay. + /// + internal sealed class ConvertHitCircle : ConvertHitObject; +} diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObject.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObject.cs index bb36aab0b3..28683583ee 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObject.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObject.cs @@ -1,21 +1,34 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Game.Beatmaps.Legacy; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Scoring; +using osuTK; namespace osu.Game.Rulesets.Objects.Legacy { /// - /// A hit object only used for conversion, not actual gameplay. + /// Represents a legacy hit object. /// - internal abstract class ConvertHitObject : HitObject, IHasCombo + /// + /// Only used for parsing beatmaps and not gameplay. + /// + internal abstract class ConvertHitObject : HitObject, IHasCombo, IHasPosition, IHasLegacyHitObjectType { public bool NewCombo { get; set; } public int ComboOffset { get; set; } + public float X => Position.X; + + public float Y => Position.Y; + + public Vector2 Position { get; set; } + + public LegacyHitObjectType LegacyType { get; set; } + public override Judgement CreateJudgement() => new IgnoreJudgement(); protected override HitWindows CreateHitWindows() => HitWindows.Empty; diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs index c518a3e8b2..f8bc0ce112 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.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 osuTK; using osu.Game.Rulesets.Objects.Types; using System; @@ -11,7 +9,6 @@ using System.IO; using osu.Game.Beatmaps.Formats; using osu.Game.Audio; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Utils; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Legacy; @@ -24,24 +21,32 @@ namespace osu.Game.Rulesets.Objects.Legacy /// /// A HitObjectParser to parse legacy Beatmaps. /// - public abstract class ConvertHitObjectParser : HitObjectParser + public class ConvertHitObjectParser : HitObjectParser { /// /// The offset to apply to all time values. /// - protected readonly double Offset; + private readonly double offset; /// /// The .osu format (beatmap) version. /// - protected readonly int FormatVersion; + private readonly int formatVersion; - protected bool FirstObject { get; private set; } = true; + /// + /// Whether the current hitobject is the first hitobject in the beatmap. + /// + private bool firstObject = true; - protected ConvertHitObjectParser(double offset, int formatVersion) + /// + /// The last parsed hitobject. + /// + private ConvertHitObject? lastObject; + + internal ConvertHitObjectParser(double offset, int formatVersion) { - Offset = offset; - FormatVersion = formatVersion; + this.offset = offset; + this.formatVersion = formatVersion; } public override HitObject Parse(string text) @@ -49,11 +54,11 @@ namespace osu.Game.Rulesets.Objects.Legacy string[] split = text.Split(','); Vector2 pos = - FormatVersion >= LegacyBeatmapEncoder.FIRST_LAZER_VERSION + formatVersion >= LegacyBeatmapEncoder.FIRST_LAZER_VERSION ? new Vector2(Parsing.ParseFloat(split[0], Parsing.MAX_COORDINATE_VALUE), Parsing.ParseFloat(split[1], Parsing.MAX_COORDINATE_VALUE)) : new Vector2((int)Parsing.ParseFloat(split[0], Parsing.MAX_COORDINATE_VALUE), (int)Parsing.ParseFloat(split[1], Parsing.MAX_COORDINATE_VALUE)); - double startTime = Parsing.ParseDouble(split[2]) + Offset; + double startTime = Parsing.ParseDouble(split[2]) + offset; LegacyHitObjectType type = (LegacyHitObjectType)Parsing.ParseInt(split[3]); @@ -66,11 +71,11 @@ namespace osu.Game.Rulesets.Objects.Legacy var soundType = (LegacyHitSoundType)Parsing.ParseInt(split[4]); var bankInfo = new SampleBankInfo(); - HitObject result = null; + ConvertHitObject? result = null; if (type.HasFlag(LegacyHitObjectType.Circle)) { - result = CreateHit(pos, combo, comboOffset); + result = createHitCircle(pos, combo, comboOffset); if (split.Length > 5) readCustomSampleBanks(split[5], bankInfo); @@ -145,13 +150,13 @@ namespace osu.Game.Rulesets.Objects.Legacy for (int i = 0; i < nodes; i++) nodeSamples.Add(convertSoundType(nodeSoundTypes[i], nodeBankInfos[i])); - result = CreateSlider(pos, combo, comboOffset, convertPathString(split[5], pos), length, repeatCount, nodeSamples); + result = createSlider(pos, combo, comboOffset, convertPathString(split[5], pos), length, repeatCount, nodeSamples); } else if (type.HasFlag(LegacyHitObjectType.Spinner)) { - double duration = Math.Max(0, Parsing.ParseDouble(split[5]) + Offset - startTime); + double duration = Math.Max(0, Parsing.ParseDouble(split[5]) + offset - startTime); - result = CreateSpinner(new Vector2(512, 384) / 2, combo, comboOffset, duration); + result = createSpinner(new Vector2(512, 384) / 2, combo, duration); if (split.Length > 6) readCustomSampleBanks(split[6], bankInfo); @@ -169,18 +174,19 @@ namespace osu.Game.Rulesets.Objects.Legacy readCustomSampleBanks(string.Join(':', ss.Skip(1)), bankInfo); } - result = CreateHold(pos, combo, comboOffset, endTime + Offset - startTime); + result = createHold(pos, endTime + offset - startTime); } if (result == null) throw new InvalidDataException($"Unknown hit object type: {split[3]}"); result.StartTime = startTime; + result.LegacyType = type; if (result.Samples.Count == 0) result.Samples = convertSoundType(soundType, bankInfo); - FirstObject = false; + firstObject = false; return result; } @@ -200,12 +206,19 @@ namespace osu.Game.Rulesets.Objects.Legacy if (!Enum.IsDefined(addBank)) addBank = LegacySampleBank.Normal; - string stringBank = bank.ToString().ToLowerInvariant(); + string? stringBank = bank.ToString().ToLowerInvariant(); + string? stringAddBank = addBank.ToString().ToLowerInvariant(); + if (stringBank == @"none") stringBank = null; - string stringAddBank = addBank.ToString().ToLowerInvariant(); + if (stringAddBank == @"none") + { + bankInfo.EditorAutoBank = true; stringAddBank = null; + } + else + bankInfo.EditorAutoBank = false; bankInfo.BankForNormal = stringBank; bankInfo.BankForAdditions = string.IsNullOrEmpty(stringAddBank) ? stringBank : stringAddBank; @@ -351,7 +364,7 @@ namespace osu.Game.Rulesets.Objects.Legacy { int endPointLength = endPoint == null ? 0 : 1; - if (FormatVersion < LegacyBeatmapEncoder.FIRST_LAZER_VERSION) + if (formatVersion < LegacyBeatmapEncoder.FIRST_LAZER_VERSION) { if (vertices.Length + endPointLength != 3) type = PathType.BEZIER; @@ -387,7 +400,7 @@ namespace osu.Game.Rulesets.Objects.Legacy // Legacy CATMULL sliders don't support multiple segments, so adjacent CATMULL segments should be treated as a single one. // Importantly, this is not applied to the first control point, which may duplicate the slider path's position // resulting in a duplicate (0,0) control point in the resultant list. - if (type == PathType.CATMULL && endIndex > 1 && FormatVersion < LegacyBeatmapEncoder.FIRST_LAZER_VERSION) + if (type == PathType.CATMULL && endIndex > 1 && formatVersion < LegacyBeatmapEncoder.FIRST_LAZER_VERSION) continue; // The last control point of each segment is not allowed to start a new implicit segment. @@ -436,7 +449,15 @@ namespace osu.Game.Rulesets.Objects.Legacy /// Whether the hit object creates a new combo. /// When starting a new combo, the offset of the new combo relative to the current one. /// The hit object. - protected abstract HitObject CreateHit(Vector2 position, bool newCombo, int comboOffset); + private ConvertHitObject createHitCircle(Vector2 position, bool newCombo, int comboOffset) + { + return lastObject = new ConvertHitCircle + { + Position = position, + NewCombo = firstObject || lastObject is ConvertSpinner || newCombo, + ComboOffset = newCombo ? comboOffset : 0 + }; + } /// /// Creats a legacy Slider-type hit object. @@ -449,27 +470,51 @@ namespace osu.Game.Rulesets.Objects.Legacy /// The slider repeat count. /// The samples to be played when the slider nodes are hit. This includes the head and tail of the slider. /// The hit object. - protected abstract HitObject CreateSlider(Vector2 position, bool newCombo, int comboOffset, PathControlPoint[] controlPoints, double? length, int repeatCount, - IList> nodeSamples); + private ConvertHitObject createSlider(Vector2 position, bool newCombo, int comboOffset, PathControlPoint[] controlPoints, double? length, int repeatCount, + IList> nodeSamples) + { + return lastObject = new ConvertSlider + { + Position = position, + NewCombo = firstObject || lastObject is ConvertSpinner || newCombo, + ComboOffset = newCombo ? comboOffset : 0, + Path = new SliderPath(controlPoints, length), + NodeSamples = nodeSamples, + RepeatCount = repeatCount + }; + } /// /// Creates a legacy Spinner-type hit object. /// /// The position of the hit object. /// Whether the hit object creates a new combo. - /// When starting a new combo, the offset of the new combo relative to the current one. /// The spinner duration. /// The hit object. - protected abstract HitObject CreateSpinner(Vector2 position, bool newCombo, int comboOffset, double duration); + private ConvertHitObject createSpinner(Vector2 position, bool newCombo, double duration) + { + return lastObject = new ConvertSpinner + { + Position = position, + Duration = duration, + NewCombo = newCombo + // Spinners cannot have combo offset. + }; + } /// /// Creates a legacy Hold-type hit object. /// /// The position of the hit object. - /// Whether the hit object creates a new combo. - /// When starting a new combo, the offset of the new combo relative to the current one. /// The hold duration. - protected abstract HitObject CreateHold(Vector2 position, bool newCombo, int comboOffset, double duration); + private ConvertHitObject createHold(Vector2 position, double duration) + { + return lastObject = new ConvertHold + { + Position = position, + Duration = duration + }; + } private List convertSoundType(LegacyHitSoundType type, SampleBankInfo bankInfo) { @@ -477,7 +522,7 @@ namespace osu.Game.Rulesets.Objects.Legacy if (string.IsNullOrEmpty(bankInfo.Filename)) { - soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_NORMAL, bankInfo.BankForNormal, bankInfo.Volume, bankInfo.CustomSampleBank, + soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_NORMAL, bankInfo.BankForNormal, bankInfo.Volume, true, bankInfo.CustomSampleBank, // if the sound type doesn't have the Normal flag set, attach it anyway as a layered sample. // None also counts as a normal non-layered sample: https://osu.ppy.sh/help/wiki/osu!_File_Formats/Osu_(file_format)#hitsounds type != LegacyHitSoundType.None && !type.HasFlag(LegacyHitSoundType.Normal))); @@ -489,13 +534,13 @@ namespace osu.Game.Rulesets.Objects.Legacy } if (type.HasFlag(LegacyHitSoundType.Finish)) - soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_FINISH, bankInfo.BankForAdditions, bankInfo.Volume, bankInfo.CustomSampleBank)); + soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_FINISH, bankInfo.BankForAdditions, bankInfo.Volume, bankInfo.EditorAutoBank, bankInfo.CustomSampleBank)); if (type.HasFlag(LegacyHitSoundType.Whistle)) - soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_WHISTLE, bankInfo.BankForAdditions, bankInfo.Volume, bankInfo.CustomSampleBank)); + soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_WHISTLE, bankInfo.BankForAdditions, bankInfo.Volume, bankInfo.EditorAutoBank, bankInfo.CustomSampleBank)); if (type.HasFlag(LegacyHitSoundType.Clap)) - soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_CLAP, bankInfo.BankForAdditions, bankInfo.Volume, bankInfo.CustomSampleBank)); + soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_CLAP, bankInfo.BankForAdditions, bankInfo.Volume, bankInfo.EditorAutoBank, bankInfo.CustomSampleBank)); return soundTypes; } @@ -505,21 +550,19 @@ namespace osu.Game.Rulesets.Objects.Legacy /// /// An optional overriding filename which causes all bank/sample specifications to be ignored. /// - public string Filename; + public string? Filename; /// /// The bank identifier to use for the base ("hitnormal") sample. /// Transferred to when appropriate. /// - [CanBeNull] - public string BankForNormal; + public string? BankForNormal; /// /// The bank identifier to use for additions ("hitwhistle", "hitfinish", "hitclap"). /// Transferred to when appropriate. /// - [CanBeNull] - public string BankForAdditions; + public string? BankForAdditions; /// /// Hit sample volume (0-100). @@ -534,11 +577,14 @@ namespace osu.Game.Rulesets.Objects.Legacy /// public int CustomSampleBank; + /// + /// Whether the bank for additions should be inherited from the normal sample in edit. + /// + public bool EditorAutoBank = true; + public SampleBankInfo Clone() => (SampleBankInfo)MemberwiseClone(); } -#nullable enable - public class LegacyHitSampleInfo : HitSampleInfo, IEquatable { public readonly int CustomSampleBank; @@ -558,21 +604,22 @@ namespace osu.Game.Rulesets.Objects.Legacy /// public bool BankSpecified; - public LegacyHitSampleInfo(string name, string? bank = null, int volume = 0, int customSampleBank = 0, bool isLayered = false) - : base(name, bank ?? SampleControlPoint.DEFAULT_BANK, customSampleBank >= 2 ? customSampleBank.ToString() : null, volume) + public LegacyHitSampleInfo(string name, string? bank = null, int volume = 0, bool editorAutoBank = false, int customSampleBank = 0, bool isLayered = false) + : base(name, bank ?? SampleControlPoint.DEFAULT_BANK, customSampleBank >= 2 ? customSampleBank.ToString() : null, volume, editorAutoBank) { CustomSampleBank = customSampleBank; BankSpecified = !string.IsNullOrEmpty(bank); IsLayered = isLayered; } - public sealed override HitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newSuffix = default, Optional newVolume = default) - => With(newName, newBank, newVolume); + public sealed override HitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newSuffix = default, Optional newVolume = default, + Optional newEditorAutoBank = default) + => With(newName, newBank, newVolume, newEditorAutoBank); public virtual LegacyHitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newVolume = default, - Optional newCustomSampleBank = default, - Optional newIsLayered = default) - => new LegacyHitSampleInfo(newName.GetOr(Name), newBank.GetOr(Bank), newVolume.GetOr(Volume), newCustomSampleBank.GetOr(CustomSampleBank), newIsLayered.GetOr(IsLayered)); + Optional newEditorAutoBank = default, Optional newCustomSampleBank = default, Optional newIsLayered = default) + => new LegacyHitSampleInfo(newName.GetOr(Name), newBank.GetOr(Bank), newVolume.GetOr(Volume), newEditorAutoBank.GetOr(EditorAutoBank), newCustomSampleBank.GetOr(CustomSampleBank), + newIsLayered.GetOr(IsLayered)); public bool Equals(LegacyHitSampleInfo? other) // The additions to equality checks here are *required* to ensure that pooling works correctly. @@ -605,8 +652,7 @@ namespace osu.Game.Rulesets.Objects.Legacy }; public sealed override LegacyHitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newVolume = default, - Optional newCustomSampleBank = default, - Optional newIsLayered = default) + Optional newEditorAutoBank = default, Optional newCustomSampleBank = default, Optional newIsLayered = default) => new FileHitSampleInfo(Filename, newVolume.GetOr(Volume)); public bool Equals(FileHitSampleInfo? other) diff --git a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSpinner.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHold.cs similarity index 54% rename from osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSpinner.cs rename to osu.Game/Rulesets/Objects/Legacy/ConvertHold.cs index c05aaceb9c..d74224892b 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSpinner.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHold.cs @@ -3,17 +3,18 @@ using osu.Game.Rulesets.Objects.Types; -namespace osu.Game.Rulesets.Objects.Legacy.Mania +namespace osu.Game.Rulesets.Objects.Legacy { /// - /// Legacy osu!mania Spinner-type, used for parsing Beatmaps. + /// Legacy "Hold" hit object type. Generally only valid in the mania ruleset. /// - internal sealed class ConvertSpinner : ConvertHitObject, IHasDuration, IHasXPosition + /// + /// Only used for parsing beatmaps and not gameplay. + /// + internal sealed class ConvertHold : ConvertHitObject, IHasDuration { public double Duration { get; set; } public double EndTime => StartTime + Duration; - - public float X { get; set; } } } diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs index 683eefa8f4..fee68f2f11 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Game.Rulesets.Objects.Types; using System.Collections.Generic; using Newtonsoft.Json; @@ -13,7 +11,13 @@ using osu.Game.Beatmaps.ControlPoints; namespace osu.Game.Rulesets.Objects.Legacy { - internal abstract class ConvertSlider : ConvertHitObject, IHasPathWithRepeats, IHasSliderVelocity + /// + /// Legacy "Slider" hit object type. + /// + /// + /// Only used for parsing beatmaps and not gameplay. + /// + internal class ConvertSlider : ConvertHitObject, IHasPathWithRepeats, IHasSliderVelocity, IHasGenerateTicks { /// /// Scoring distance with a speed-adjusted beat length of 1 second. @@ -50,6 +54,8 @@ namespace osu.Game.Rulesets.Objects.Legacy set => SliderVelocityMultiplierBindable.Value = value; } + public bool GenerateTicks { get; set; } = true; + protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty) { base.ApplyDefaultsToSelf(controlPointInfo, difficulty); diff --git a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSpinner.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertSpinner.cs similarity index 70% rename from osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSpinner.cs rename to osu.Game/Rulesets/Objects/Legacy/ConvertSpinner.cs index 1d5ecb1ef3..59551cd37a 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSpinner.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertSpinner.cs @@ -3,11 +3,14 @@ using osu.Game.Rulesets.Objects.Types; -namespace osu.Game.Rulesets.Objects.Legacy.Taiko +namespace osu.Game.Rulesets.Objects.Legacy { /// - /// Legacy osu!taiko Spinner-type, used for parsing Beatmaps. + /// Legacy "Spinner" hit object type. /// + /// + /// Only used for parsing beatmaps and not gameplay. + /// internal sealed class ConvertSpinner : ConvertHitObject, IHasDuration { public double Duration { get; set; } diff --git a/osu.Game/Rulesets/Objects/Legacy/IHasLegacyHitObjectType.cs b/osu.Game/Rulesets/Objects/Legacy/IHasLegacyHitObjectType.cs new file mode 100644 index 0000000000..71af57700d --- /dev/null +++ b/osu.Game/Rulesets/Objects/Legacy/IHasLegacyHitObjectType.cs @@ -0,0 +1,18 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Beatmaps.Legacy; + +namespace osu.Game.Rulesets.Objects.Legacy +{ + /// + /// A hit object from a legacy beatmap representation. + /// + public interface IHasLegacyHitObjectType + { + /// + /// The hit object type. + /// + LegacyHitObjectType LegacyType { get; } + } +} diff --git a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHit.cs b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHit.cs deleted file mode 100644 index 0b69817c13..0000000000 --- a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHit.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Rulesets.Objects.Types; - -namespace osu.Game.Rulesets.Objects.Legacy.Mania -{ - /// - /// Legacy osu!mania Hit-type, used for parsing Beatmaps. - /// - internal sealed class ConvertHit : ConvertHitObject, IHasXPosition - { - public float X { get; set; } - } -} diff --git a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHitObjectParser.cs deleted file mode 100644 index 386eb8d3ee..0000000000 --- a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHitObjectParser.cs +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osuTK; -using osu.Game.Audio; -using System.Collections.Generic; - -namespace osu.Game.Rulesets.Objects.Legacy.Mania -{ - /// - /// A HitObjectParser to parse legacy osu!mania Beatmaps. - /// - public class ConvertHitObjectParser : Legacy.ConvertHitObjectParser - { - public ConvertHitObjectParser(double offset, int formatVersion) - : base(offset, formatVersion) - { - } - - protected override HitObject CreateHit(Vector2 position, bool newCombo, int comboOffset) - { - return new ConvertHit - { - X = position.X - }; - } - - protected override HitObject CreateSlider(Vector2 position, bool newCombo, int comboOffset, PathControlPoint[] controlPoints, double? length, int repeatCount, - IList> nodeSamples) - { - return new ConvertSlider - { - X = position.X, - Path = new SliderPath(controlPoints, length), - NodeSamples = nodeSamples, - RepeatCount = repeatCount - }; - } - - protected override HitObject CreateSpinner(Vector2 position, bool newCombo, int comboOffset, double duration) - { - return new ConvertSpinner - { - X = position.X, - Duration = duration - }; - } - - protected override HitObject CreateHold(Vector2 position, bool newCombo, int comboOffset, double duration) - { - return new ConvertHold - { - X = position.X, - Duration = duration - }; - } - } -} diff --git a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHold.cs b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHold.cs deleted file mode 100644 index 2fa4766c1d..0000000000 --- a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHold.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Rulesets.Objects.Types; - -namespace osu.Game.Rulesets.Objects.Legacy.Mania -{ - internal sealed class ConvertHold : ConvertHitObject, IHasXPosition, IHasDuration - { - public float X { get; set; } - - public double Duration { get; set; } - - public double EndTime => StartTime + Duration; - } -} diff --git a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSlider.cs b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSlider.cs deleted file mode 100644 index 84cde5fa95..0000000000 --- a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSlider.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Rulesets.Objects.Types; - -namespace osu.Game.Rulesets.Objects.Legacy.Mania -{ - /// - /// Legacy osu!mania Slider-type, used for parsing Beatmaps. - /// - internal sealed class ConvertSlider : Legacy.ConvertSlider, IHasXPosition - { - public float X { get; set; } - } -} diff --git a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHit.cs b/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHit.cs deleted file mode 100644 index b7cd4b0dcc..0000000000 --- a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHit.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Rulesets.Objects.Types; -using osuTK; - -namespace osu.Game.Rulesets.Objects.Legacy.Osu -{ - /// - /// Legacy osu! Hit-type, used for parsing Beatmaps. - /// - internal sealed class ConvertHit : ConvertHitObject, IHasPosition - { - public Vector2 Position { get; set; } - - public float X => Position.X; - - public float Y => Position.Y; - } -} diff --git a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHitObjectParser.cs deleted file mode 100644 index 43c346b621..0000000000 --- a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHitObjectParser.cs +++ /dev/null @@ -1,64 +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 osuTK; -using System.Collections.Generic; -using osu.Game.Audio; - -namespace osu.Game.Rulesets.Objects.Legacy.Osu -{ - /// - /// A HitObjectParser to parse legacy osu! Beatmaps. - /// - public class ConvertHitObjectParser : Legacy.ConvertHitObjectParser - { - private ConvertHitObject lastObject; - - public ConvertHitObjectParser(double offset, int formatVersion) - : base(offset, formatVersion) - { - } - - protected override HitObject CreateHit(Vector2 position, bool newCombo, int comboOffset) - { - return lastObject = new ConvertHit - { - Position = position, - NewCombo = FirstObject || lastObject is ConvertSpinner || newCombo, - ComboOffset = newCombo ? comboOffset : 0 - }; - } - - protected override HitObject CreateSlider(Vector2 position, bool newCombo, int comboOffset, PathControlPoint[] controlPoints, double? length, int repeatCount, - IList> nodeSamples) - { - return lastObject = new ConvertSlider - { - Position = position, - NewCombo = FirstObject || lastObject is ConvertSpinner || newCombo, - ComboOffset = newCombo ? comboOffset : 0, - Path = new SliderPath(controlPoints, length), - NodeSamples = nodeSamples, - RepeatCount = repeatCount - }; - } - - protected override HitObject CreateSpinner(Vector2 position, bool newCombo, int comboOffset, double duration) - { - return lastObject = new ConvertSpinner - { - Position = position, - Duration = duration, - NewCombo = newCombo - // Spinners cannot have combo offset. - }; - } - - protected override HitObject CreateHold(Vector2 position, bool newCombo, int comboOffset, double duration) - { - return lastObject = null; - } - } -} diff --git a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertSlider.cs b/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertSlider.cs deleted file mode 100644 index 8c37154f95..0000000000 --- a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertSlider.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Rulesets.Objects.Types; -using osuTK; - -namespace osu.Game.Rulesets.Objects.Legacy.Osu -{ - /// - /// Legacy osu! Slider-type, used for parsing Beatmaps. - /// - internal sealed class ConvertSlider : Legacy.ConvertSlider, IHasPosition, IHasGenerateTicks - { - public Vector2 Position { get; set; } - - public float X => Position.X; - - public float Y => Position.Y; - - public bool GenerateTicks { get; set; } = true; - } -} diff --git a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertSpinner.cs b/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertSpinner.cs deleted file mode 100644 index d6e24b6bbf..0000000000 --- a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertSpinner.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Rulesets.Objects.Types; -using osuTK; - -namespace osu.Game.Rulesets.Objects.Legacy.Osu -{ - /// - /// Legacy osu! Spinner-type, used for parsing Beatmaps. - /// - internal sealed class ConvertSpinner : ConvertHitObject, IHasDuration, IHasPosition - { - public double Duration { get; set; } - - public double EndTime => StartTime + Duration; - - public Vector2 Position { get; set; } - - public float X => Position.X; - - public float Y => Position.Y; - } -} diff --git a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHit.cs b/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHit.cs deleted file mode 100644 index cb5178ce48..0000000000 --- a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHit.cs +++ /dev/null @@ -1,12 +0,0 @@ -// 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.Rulesets.Objects.Legacy.Taiko -{ - /// - /// Legacy osu!taiko Hit-type, used for parsing Beatmaps. - /// - internal sealed class ConvertHit : ConvertHitObject - { - } -} diff --git a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHitObjectParser.cs deleted file mode 100644 index d62e8cd04c..0000000000 --- a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHitObjectParser.cs +++ /dev/null @@ -1,51 +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 osuTK; -using System.Collections.Generic; -using osu.Game.Audio; - -namespace osu.Game.Rulesets.Objects.Legacy.Taiko -{ - /// - /// A HitObjectParser to parse legacy osu!taiko Beatmaps. - /// - public class ConvertHitObjectParser : Legacy.ConvertHitObjectParser - { - public ConvertHitObjectParser(double offset, int formatVersion) - : base(offset, formatVersion) - { - } - - protected override HitObject CreateHit(Vector2 position, bool newCombo, int comboOffset) - { - return new ConvertHit(); - } - - protected override HitObject CreateSlider(Vector2 position, bool newCombo, int comboOffset, PathControlPoint[] controlPoints, double? length, int repeatCount, - IList> nodeSamples) - { - return new ConvertSlider - { - Path = new SliderPath(controlPoints, length), - NodeSamples = nodeSamples, - RepeatCount = repeatCount - }; - } - - protected override HitObject CreateSpinner(Vector2 position, bool newCombo, int comboOffset, double duration) - { - return new ConvertSpinner - { - Duration = duration - }; - } - - protected override HitObject CreateHold(Vector2 position, bool newCombo, int comboOffset, double duration) - { - return null; - } - } -} diff --git a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSlider.cs b/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSlider.cs deleted file mode 100644 index 821554f7ee..0000000000 --- a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSlider.cs +++ /dev/null @@ -1,12 +0,0 @@ -// 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.Rulesets.Objects.Legacy.Taiko -{ - /// - /// Legacy osu!taiko Slider-type, used for parsing Beatmaps. - /// - internal sealed class ConvertSlider : Legacy.ConvertSlider - { - } -} diff --git a/osu.Game/Rulesets/Objects/SliderPathExtensions.cs b/osu.Game/Rulesets/Objects/SliderPathExtensions.cs index c03d3646da..a631274f74 100644 --- a/osu.Game/Rulesets/Objects/SliderPathExtensions.cs +++ b/osu.Game/Rulesets/Objects/SliderPathExtensions.cs @@ -17,7 +17,7 @@ namespace osu.Game.Rulesets.Objects public static void SnapTo(this THitObject hitObject, IDistanceSnapProvider? snapProvider) where THitObject : HitObject, IHasPath { - hitObject.Path.ExpectedDistance.Value = snapProvider?.FindSnappedDistance(hitObject, (float)hitObject.Path.CalculatedDistance) ?? hitObject.Path.CalculatedDistance; + hitObject.Path.ExpectedDistance.Value = snapProvider?.FindSnappedDistance(hitObject, (float)hitObject.Path.CalculatedDistance, DistanceSnapTarget.Start) ?? hitObject.Path.CalculatedDistance; } /// diff --git a/osu.Game/Rulesets/Ruleset.cs b/osu.Game/Rulesets/Ruleset.cs index 5af1fd386c..bd1f273b49 100644 --- a/osu.Game/Rulesets/Ruleset.cs +++ b/osu.Game/Rulesets/Ruleset.cs @@ -8,6 +8,7 @@ using System.Linq; using osu.Framework.Extensions; using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; using osu.Framework.IO.Stores; @@ -30,6 +31,7 @@ using osu.Game.Screens.Edit.Setup; using osu.Game.Screens.Ranking.Statistics; using osu.Game.Skinning; using osu.Game.Users; +using osuTK; namespace osu.Game.Rulesets { @@ -99,7 +101,7 @@ namespace osu.Game.Rulesets /// The acronym to query for . public Mod? CreateModFromAcronym(string acronym) { - return AllMods.FirstOrDefault(m => m.Acronym == acronym)?.CreateInstance(); + return AllMods.FirstOrDefault(m => string.Equals(m.Acronym, acronym, StringComparison.OrdinalIgnoreCase))?.CreateInstance(); } /// @@ -396,10 +398,28 @@ namespace osu.Game.Rulesets /// /// Can be overridden to add ruleset-specific sections to the editor beatmap setup screen. /// - public virtual IEnumerable CreateEditorSetupSections() => + public virtual IEnumerable CreateEditorSetupSections() => [ + new MetadataSection(), new DifficultySection(), - new ColoursSection(), + new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(25), + Children = new Drawable[] + { + new ResourcesSection + { + RelativeSizeAxes = Axes.X, + }, + new ColoursSection + { + RelativeSizeAxes = Axes.X, + } + } + }, + new DesignSection(), ]; /// diff --git a/osu.Game/Rulesets/Scoring/HitEventExtensions.cs b/osu.Game/Rulesets/Scoring/HitEventExtensions.cs index 6e2852676a..fc4eef13ba 100644 --- a/osu.Game/Rulesets/Scoring/HitEventExtensions.cs +++ b/osu.Game/Rulesets/Scoring/HitEventExtensions.cs @@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Scoring foreach (var e in hitEvents) { - if (!affectsUnstableRate(e)) + if (!AffectsUnstableRate(e)) continue; count++; @@ -57,7 +57,7 @@ namespace osu.Game.Rulesets.Scoring /// public static double? CalculateAverageHitError(this IEnumerable hitEvents) { - double[] timeOffsets = hitEvents.Where(affectsUnstableRate).Select(ev => ev.TimeOffset).ToArray(); + double[] timeOffsets = hitEvents.Where(AffectsUnstableRate).Select(ev => ev.TimeOffset).ToArray(); if (timeOffsets.Length == 0) return null; @@ -65,6 +65,6 @@ namespace osu.Game.Rulesets.Scoring return timeOffsets.Average(); } - private static bool affectsUnstableRate(HitEvent e) => !(e.HitObject.HitWindows is HitWindows.EmptyHitWindows) && e.Result.IsHit(); + public static bool AffectsUnstableRate(HitEvent e) => !(e.HitObject.HitWindows is HitWindows.EmptyHitWindows) && e.Result.IsHit(); } } diff --git a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs index 9752918dfb..7b5af9beda 100644 --- a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs +++ b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs @@ -119,6 +119,11 @@ namespace osu.Game.Rulesets.Scoring /// public long MaximumTotalScore { get; private set; } + /// + /// The maximum achievable combo. + /// + public int MaximumCombo { get; private set; } + /// /// The maximum sum of accuracy-affecting judgements at the current point in time. /// @@ -423,6 +428,7 @@ namespace osu.Game.Rulesets.Scoring MaximumResultCounts.AddRange(ScoreResultCounts); MaximumTotalScore = TotalScore.Value; + MaximumCombo = HighestCombo.Value; } ScoreResultCounts.Clear(); diff --git a/osu.Game/Rulesets/UI/DrawableRuleset.cs b/osu.Game/Rulesets/UI/DrawableRuleset.cs index a28b2716cb..ebd84fd91b 100644 --- a/osu.Game/Rulesets/UI/DrawableRuleset.cs +++ b/osu.Game/Rulesets/UI/DrawableRuleset.cs @@ -65,22 +65,20 @@ namespace osu.Game.Rulesets.UI /// public override Playfield Playfield => playfield.Value; + public override PlayfieldAdjustmentContainer PlayfieldAdjustmentContainer => playfieldAdjustmentContainer; + public override Container Overlays { get; } = new Container { RelativeSizeAxes = Axes.Both }; public override IAdjustableAudioComponent Audio => audioContainer; private readonly AudioContainer audioContainer = new AudioContainer { RelativeSizeAxes = Axes.Both }; - /// - /// A container which encapsulates the and provides any adjustments to - /// ensure correct scale and position. - /// - public virtual PlayfieldAdjustmentContainer PlayfieldAdjustmentContainer { get; private set; } - public override Container FrameStableComponents { get; } = new Container { RelativeSizeAxes = Axes.Both }; public override IFrameStableClock FrameStableClock => frameStabilityContainer; + private readonly PlayfieldAdjustmentContainer playfieldAdjustmentContainer; + private bool allowBackwardsSeeks; public override bool AllowBackwardsSeeks @@ -146,6 +144,7 @@ namespace osu.Game.Rulesets.UI RelativeSizeAxes = Axes.Both; KeyBindingInputManager = CreateInputManager(); + playfieldAdjustmentContainer = CreatePlayfieldAdjustmentContainer(); playfield = new Lazy(() => CreatePlayfield().With(p => { p.NewResult += (_, r) => NewResult?.Invoke(r); @@ -197,8 +196,7 @@ namespace osu.Game.Rulesets.UI audioContainer.WithChild(KeyBindingInputManager .WithChildren(new Drawable[] { - PlayfieldAdjustmentContainer = CreatePlayfieldAdjustmentContainer() - .WithChild(Playfield), + playfieldAdjustmentContainer.WithChild(Playfield), Overlays })), } @@ -456,6 +454,12 @@ namespace osu.Game.Rulesets.UI /// public abstract Playfield Playfield { get; } + /// + /// A container which encapsulates the and provides any adjustments to + /// ensure correct scale and position. + /// + public abstract PlayfieldAdjustmentContainer PlayfieldAdjustmentContainer { get; } + /// /// Content to be placed above hitobjects. Will be affected by frame stability and adjustments applied to . /// diff --git a/osu.Game/Scoring/ScoreInfo.cs b/osu.Game/Scoring/ScoreInfo.cs index 92c18c9c1e..a3dabc7945 100644 --- a/osu.Game/Scoring/ScoreInfo.cs +++ b/osu.Game/Scoring/ScoreInfo.cs @@ -165,7 +165,7 @@ namespace osu.Game.Scoring } [UsedImplicitly] // Realm - private ScoreInfo() + protected ScoreInfo() { } diff --git a/osu.Game/Screens/Edit/Components/Menus/EditorMenuBar.cs b/osu.Game/Screens/Edit/Components/Menus/EditorMenuBar.cs index ee954a7ea0..47a13dcfba 100644 --- a/osu.Game/Screens/Edit/Components/Menus/EditorMenuBar.cs +++ b/osu.Game/Screens/Edit/Components/Menus/EditorMenuBar.cs @@ -7,7 +7,10 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; using osuTK; @@ -78,8 +81,11 @@ namespace osu.Game.Screens.Edit.Components.Menus protected override DrawableMenuItem CreateDrawableMenuItem(MenuItem item) => new DrawableEditorBarMenuItem(item); - private partial class DrawableEditorBarMenuItem : DrawableOsuMenuItem + internal partial class DrawableEditorBarMenuItem : DrawableMenuItem { + private HoverClickSounds hoverClickSounds = null!; + private TextContainer text = null!; + public DrawableEditorBarMenuItem(MenuItem item) : base(item) { @@ -92,6 +98,8 @@ namespace osu.Game.Screens.Edit.Components.Menus BackgroundColour = colourProvider.Background2; ForegroundColourHover = colourProvider.Content1; BackgroundColourHover = colourProvider.Background1; + + AddInternal(hoverClickSounds = new HoverClickSounds()); } protected override void LoadComplete() @@ -100,6 +108,36 @@ namespace osu.Game.Screens.Edit.Components.Menus Foreground.Anchor = Anchor.CentreLeft; Foreground.Origin = Anchor.CentreLeft; + Item.Action.BindDisabledChanged(_ => updateState(), true); + } + + protected override bool OnHover(HoverEvent e) + { + updateState(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateState(); + base.OnHoverLost(e); + } + + private void updateState() + { + hoverClickSounds.Enabled.Value = IsActionable; + Alpha = IsActionable ? 1 : 0.2f; + + if (IsHovered && IsActionable) + { + text.BoldText.FadeIn(DrawableOsuMenuItem.TRANSITION_LENGTH, Easing.OutQuint); + text.NormalText.FadeOut(DrawableOsuMenuItem.TRANSITION_LENGTH, Easing.OutQuint); + } + else + { + text.BoldText.FadeOut(DrawableOsuMenuItem.TRANSITION_LENGTH, Easing.OutQuint); + text.NormalText.FadeIn(DrawableOsuMenuItem.TRANSITION_LENGTH, Easing.OutQuint); + } } protected override void UpdateBackgroundColour() @@ -118,16 +156,56 @@ namespace osu.Game.Screens.Edit.Components.Menus base.UpdateForegroundColour(); } - protected override DrawableOsuMenuItem.TextContainer CreateTextContainer() => new TextContainer(); + protected sealed override Drawable CreateContent() => text = new TextContainer(); + } - private new partial class TextContainer : DrawableOsuMenuItem.TextContainer + private partial class TextContainer : Container, IHasText + { + public LocalisableString Text { - public TextContainer() + get => NormalText.Text; + set { - NormalText.Font = OsuFont.TorusAlternate; - BoldText.Font = OsuFont.TorusAlternate.With(weight: FontWeight.Bold); + NormalText.Text = value; + BoldText.Text = value; } } + + public readonly SpriteText NormalText; + public readonly SpriteText BoldText; + + public TextContainer() + { + AutoSizeAxes = Axes.Both; + + Child = new Container + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + + AutoSizeAxes = Axes.Both, + Padding = new MarginPadding { Horizontal = 17, Vertical = DrawableOsuMenuItem.MARGIN_VERTICAL, }, + + Children = new Drawable[] + { + NormalText = new OsuSpriteText + { + AlwaysPresent = true, // ensures that the menu item does not change width when switching between normal and bold text. + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: DrawableOsuMenuItem.TEXT_SIZE), + }, + BoldText = new OsuSpriteText + { + AlwaysPresent = true, // ensures that the menu item does not change width when switching between normal and bold text. + Alpha = 0, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: DrawableOsuMenuItem.TEXT_SIZE, weight: FontWeight.Bold), + } + } + }; + } } private partial class SubMenu : OsuMenu diff --git a/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs b/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs index 95d5dd36d8..fcbc719f46 100644 --- a/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs +++ b/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs @@ -4,8 +4,10 @@ using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; @@ -14,14 +16,14 @@ using osuTK.Graphics; namespace osu.Game.Screens.Edit.Components.TernaryButtons { - public partial class DrawableTernaryButton : OsuButton + public partial class DrawableTernaryButton : OsuButton, IHasTooltip { private Color4 defaultBackgroundColour; private Color4 defaultIconColour; private Color4 selectedBackgroundColour; private Color4 selectedIconColour; - private Drawable icon = null!; + protected Drawable Icon { get; private set; } = null!; public readonly TernaryButton Button; @@ -43,7 +45,7 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons defaultIconColour = defaultBackgroundColour.Darken(0.5f); selectedIconColour = selectedBackgroundColour.Lighten(0.5f); - Add(icon = (Button.CreateIcon?.Invoke() ?? new Circle()).With(b => + Add(Icon = (Button.CreateIcon?.Invoke() ?? new Circle()).With(b => { b.Blending = BlendingParameters.Additive; b.Anchor = Anchor.CentreLeft; @@ -58,12 +60,16 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons base.LoadComplete(); Button.Bindable.BindValueChanged(_ => updateSelectionState(), true); + Button.Enabled.BindTo(Enabled); Action = onAction; } private void onAction() { + if (!Button.Enabled.Value) + return; + Button.Toggle(); } @@ -75,17 +81,17 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons switch (Button.Bindable.Value) { case TernaryState.Indeterminate: - icon.Colour = selectedIconColour.Darken(0.5f); + Icon.Colour = selectedIconColour.Darken(0.5f); BackgroundColour = selectedBackgroundColour.Darken(0.5f); break; case TernaryState.False: - icon.Colour = defaultIconColour; + Icon.Colour = defaultIconColour; BackgroundColour = defaultBackgroundColour; break; case TernaryState.True: - icon.Colour = selectedIconColour; + Icon.Colour = selectedIconColour; BackgroundColour = selectedBackgroundColour; break; } @@ -98,5 +104,7 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons Anchor = Anchor.CentreLeft, X = 40f }; + + public LocalisableString TooltipText => Button.Tooltip; } } diff --git a/osu.Game/Screens/Edit/Components/TernaryButtons/SampleBankTernaryButton.cs b/osu.Game/Screens/Edit/Components/TernaryButtons/SampleBankTernaryButton.cs new file mode 100644 index 0000000000..33eb2ac0b4 --- /dev/null +++ b/osu.Game/Screens/Edit/Components/TernaryButtons/SampleBankTernaryButton.cs @@ -0,0 +1,78 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Rulesets.Edit; + +namespace osu.Game.Screens.Edit.Components.TernaryButtons +{ + public partial class SampleBankTernaryButton : CompositeDrawable + { + public readonly TernaryButton NormalButton; + public readonly TernaryButton AdditionsButton; + + public SampleBankTernaryButton(TernaryButton normalButton, TernaryButton additionsButton) + { + NormalButton = normalButton; + AdditionsButton = additionsButton; + } + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Masking = true; + CornerRadius = 5; + + InternalChildren = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Width = 0.5f, + Padding = new MarginPadding { Right = 1 }, + Child = new InlineDrawableTernaryButton(NormalButton), + }, + new Container + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Width = 0.5f, + Padding = new MarginPadding { Left = 1 }, + Child = new InlineDrawableTernaryButton(AdditionsButton), + }, + }; + } + + private partial class InlineDrawableTernaryButton : DrawableTernaryButton + { + public InlineDrawableTernaryButton(TernaryButton button) + : base(button) + { + } + + [BackgroundDependencyLoader] + private void load() + { + Content.Masking = false; + Content.CornerRadius = 0; + Icon.X = 4.5f; + } + + protected override SpriteText CreateText() => new ExpandableSpriteText + { + Depth = -1, + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + X = 31f + }; + } + } +} diff --git a/osu.Game/Screens/Edit/Components/TernaryButtons/TernaryButton.cs b/osu.Game/Screens/Edit/Components/TernaryButtons/TernaryButton.cs index 0ff2aa83b5..b7aaf517f5 100644 --- a/osu.Game/Screens/Edit/Components/TernaryButtons/TernaryButton.cs +++ b/osu.Game/Screens/Edit/Components/TernaryButtons/TernaryButton.cs @@ -12,6 +12,8 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons { public readonly Bindable Bindable; + public readonly Bindable Enabled = new Bindable(true); + public readonly string Description; /// @@ -19,6 +21,8 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons /// public readonly Func? CreateIcon; + public string Tooltip { get; set; } = string.Empty; + public TernaryButton(Bindable bindable, string description, Func? createIcon = null) { Bindable = bindable; diff --git a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs index c66be90605..e12574f7ee 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs @@ -32,7 +32,12 @@ namespace osu.Game.Screens.Edit.Compose.Components { protected DragBox DragBox { get; private set; } - public Container> SelectionBlueprints { get; private set; } + public SelectionBlueprintContainer SelectionBlueprints { get; private set; } + + public partial class SelectionBlueprintContainer : Container> + { + public new virtual void ChangeChildDepth(SelectionBlueprint child, float newDepth) => base.ChangeChildDepth(child, newDepth); + } public SelectionHandler SelectionHandler { get; private set; } @@ -95,7 +100,7 @@ namespace osu.Game.Screens.Edit.Compose.Components }); } - protected virtual Container> CreateSelectionBlueprintContainer() => new Container> { RelativeSizeAxes = Axes.Both }; + protected virtual SelectionBlueprintContainer CreateSelectionBlueprintContainer() => new SelectionBlueprintContainer { RelativeSizeAxes = Axes.Both }; /// /// Creates a which outlines items and handles movement of selections. @@ -196,6 +201,11 @@ namespace osu.Game.Screens.Edit.Compose.Components DragBox.HandleDrag(e); DragBox.Show(); + + selectionBeforeDrag.Clear(); + if (e.ControlPressed) + selectionBeforeDrag.UnionWith(SelectedItems); + return true; } @@ -217,6 +227,7 @@ namespace osu.Game.Screens.Edit.Compose.Components } DragBox.Hide(); + selectionBeforeDrag.Clear(); } protected override void Update() @@ -227,7 +238,7 @@ namespace osu.Game.Screens.Edit.Compose.Components { lastDragEvent.Target = this; DragBox.HandleDrag(lastDragEvent); - UpdateSelectionFromDragBox(); + UpdateSelectionFromDragBox(selectionBeforeDrag); } } @@ -426,7 +437,7 @@ namespace osu.Game.Screens.Edit.Compose.Components private bool endClickSelection(MouseButtonEvent e) { // If already handled a selection, double-click, or drag, we don't want to perform a mouse up / click action. - if (clickSelectionHandled || doubleClickHandled || isDraggingBlueprint) return true; + if (clickSelectionHandled || doubleClickHandled || isDraggingBlueprint || wasDragStarted) return true; if (e.Button != MouseButton.Left) return false; @@ -442,7 +453,7 @@ namespace osu.Game.Screens.Edit.Compose.Components return false; } - if (!wasDragStarted && selectedBlueprintAlreadySelectedOnMouseDown && SelectedItems.Count == 1) + if (selectedBlueprintAlreadySelectedOnMouseDown && SelectedItems.Count == 1) { // If a click occurred and was handled by the currently selected blueprint but didn't result in a drag, // cycle between other blueprints which are also under the cursor. @@ -472,7 +483,7 @@ namespace osu.Game.Screens.Edit.Compose.Components /// /// Select all blueprints in a selection area specified by . /// - protected virtual void UpdateSelectionFromDragBox() + protected virtual void UpdateSelectionFromDragBox(HashSet selectionBeforeDrag) { var quad = DragBox.Box.ScreenSpaceDrawQuad; @@ -482,7 +493,7 @@ namespace osu.Game.Screens.Edit.Compose.Components { case SelectionState.Selected: // Selection is preserved even after blueprint becomes dead. - if (!quad.Contains(blueprint.ScreenSpaceSelectionPoint)) + if (!quad.Contains(blueprint.ScreenSpaceSelectionPoint) && !selectionBeforeDrag.Contains(blueprint.Item)) blueprint.Deselect(); break; @@ -535,6 +546,8 @@ namespace osu.Game.Screens.Edit.Compose.Components /// private bool wasDragStarted; + private readonly HashSet selectionBeforeDrag = new HashSet(); + /// /// Attempts to begin the movement of any selected blueprints. /// diff --git a/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs index 92fe52148c..bd750dac76 100644 --- a/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs +++ b/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs @@ -59,7 +59,7 @@ namespace osu.Game.Screens.Edit.Compose.Components // Picture the scenario where the user has just placed an object on a 1/2 snap, then changes to // 1/3 snap and expects to be able to place the next object on a valid 1/3 snap, regardless of the // fact that the 1/2 snap reference object is not valid for 1/3 snapping. - float offset = SnapProvider.FindSnappedDistance(ReferenceObject, 0); + float offset = SnapProvider.FindSnappedDistance(ReferenceObject, 0, DistanceSnapTarget.End); for (int i = 0; i < requiredCircles; i++) { @@ -104,7 +104,7 @@ namespace osu.Game.Screens.Edit.Compose.Components ? SnapProvider.DurationToDistance(ReferenceObject, editorClock.CurrentTime - ReferenceObject.GetEndTime()) // When interacting with the resolved snap provider, the distance spacing multiplier should first be removed // to allow for snapping at a non-multiplied ratio. - : SnapProvider.FindSnappedDistance(ReferenceObject, travelLength / distanceSpacingMultiplier); + : SnapProvider.FindSnappedDistance(ReferenceObject, travelLength / distanceSpacingMultiplier, DistanceSnapTarget.End); double snappedTime = StartTime + SnapProvider.DistanceToDuration(ReferenceObject, snappedDistance); diff --git a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs index f0296d45aa..0ffd1072cd 100644 --- a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs @@ -65,7 +65,11 @@ namespace osu.Game.Screens.Edit.Compose.Components private void load() { MainTernaryStates = CreateTernaryButtons().ToArray(); - SampleBankTernaryStates = createSampleBankTernaryButtons().ToArray(); + SampleBankTernaryStates = createSampleBankTernaryButtons(SelectionHandler.SelectionBankStates).ToArray(); + SampleAdditionBankTernaryStates = createSampleBankTernaryButtons(SelectionHandler.SelectionAdditionBankStates).ToArray(); + + SelectionHandler.AutoSelectionBankEnabled.BindValueChanged(_ => updateAutoBankTernaryButtonTooltip(), true); + SelectionHandler.SelectionAdditionBanksEnabled.BindValueChanged(_ => updateAdditionBankTernaryButtonTooltips(), true); AddInternal(new DrawableRulesetDependenciesProvidingContainer(Composer.Ruleset) { @@ -91,6 +95,9 @@ namespace osu.Game.Screens.Edit.Compose.Components foreach (var kvp in SelectionHandler.SelectionBankStates) kvp.Value.BindValueChanged(_ => updatePlacementSamples()); + + foreach (var kvp in SelectionHandler.SelectionAdditionBankStates) + kvp.Value.BindValueChanged(_ => updatePlacementSamples()); } protected override void TransferBlueprintFor(HitObject hitObject, DrawableHitObject drawableObject) @@ -179,6 +186,9 @@ namespace osu.Game.Screens.Edit.Compose.Components foreach (var kvp in SelectionHandler.SelectionBankStates) bankChanged(kvp.Key, kvp.Value.Value); + + foreach (var kvp in SelectionHandler.SelectionAdditionBankStates) + additionBankChanged(kvp.Key, kvp.Value.Value); } private void sampleChanged(string sampleName, TernaryState state) @@ -210,7 +220,17 @@ namespace osu.Game.Screens.Edit.Compose.Components if (bankName == EditorSelectionHandler.HIT_BANK_AUTO) CurrentHitObjectPlacement.AutomaticBankAssignment = state == TernaryState.True; else if (state == TernaryState.True) - CurrentHitObjectPlacement.HitObject.Samples = CurrentHitObjectPlacement.HitObject.Samples.Select(s => s.With(newBank: bankName)).ToList(); + CurrentHitObjectPlacement.HitObject.Samples = CurrentHitObjectPlacement.HitObject.Samples.Select(s => s.Name == HitSampleInfo.HIT_NORMAL ? s.With(newBank: bankName) : s).ToList(); + } + + private void additionBankChanged(string bankName, TernaryState state) + { + if (CurrentHitObjectPlacement == null) return; + + if (bankName == EditorSelectionHandler.HIT_BANK_AUTO) + CurrentHitObjectPlacement.AutomaticAdditionBankAssignment = state == TernaryState.True; + else if (state == TernaryState.True) + CurrentHitObjectPlacement.HitObject.Samples = CurrentHitObjectPlacement.HitObject.Samples.Select(s => s.Name != HitSampleInfo.HIT_NORMAL ? s.With(newBank: bankName) : s).ToList(); } public readonly Bindable NewCombo = new Bindable { Description = "New Combo" }; @@ -222,6 +242,8 @@ namespace osu.Game.Screens.Edit.Compose.Components public TernaryButton[] SampleBankTernaryStates { get; private set; } + public TernaryButton[] SampleAdditionBankTernaryStates { get; private set; } + /// /// Create all ternary states required to be displayed to the user. /// @@ -234,36 +256,21 @@ namespace osu.Game.Screens.Edit.Compose.Components yield return new TernaryButton(kvp.Value, kvp.Key.Replace("hit", string.Empty).Titleize(), () => GetIconForSample(kvp.Key)); } - private IEnumerable createSampleBankTernaryButtons() + private IEnumerable createSampleBankTernaryButtons(Dictionary> sampleBankStates) { - foreach (var kvp in SelectionHandler.SelectionBankStates) + foreach (var kvp in sampleBankStates) yield return new TernaryButton(kvp.Value, kvp.Key.Titleize(), () => getIconForBank(kvp.Key)); } private Drawable getIconForBank(string sampleName) { - return new Container + return new OsuSpriteText { - Size = new Vector2(30, 20), - Children = new Drawable[] - { - new SpriteIcon - { - Size = new Vector2(8), - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Icon = FontAwesome.Solid.VolumeOff - }, - new OsuSpriteText - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - X = 10, - Y = -1, - Font = OsuFont.Default.With(weight: FontWeight.Bold, size: 20), - Text = $"{char.ToUpperInvariant(sampleName.First())}" - } - } + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Y = -1, + Font = OsuFont.Default.With(weight: FontWeight.Bold, size: 20), + Text = $"{char.ToUpperInvariant(sampleName.First())}" }; } @@ -284,20 +291,43 @@ namespace osu.Game.Screens.Edit.Compose.Components return null; } + private void updateAutoBankTernaryButtonTooltip() + { + bool enabled = SelectionHandler.AutoSelectionBankEnabled.Value; + + var autoBankButton = SampleBankTernaryStates.Single(t => t.Bindable == SelectionHandler.SelectionBankStates[EditorSelectionHandler.HIT_BANK_AUTO]); + autoBankButton.Enabled.Value = enabled; + autoBankButton.Tooltip = !enabled ? "Auto normal bank can only be used during hit object placement" : string.Empty; + } + + private void updateAdditionBankTernaryButtonTooltips() + { + bool enabled = SelectionHandler.SelectionAdditionBanksEnabled.Value; + + foreach (var ternaryButton in SampleAdditionBankTernaryStates) + { + ternaryButton.Enabled.Value = enabled; + ternaryButton.Tooltip = !enabled ? "Add an addition sample first to be able to set a bank" : string.Empty; + } + } + #region Placement /// /// Refreshes the current placement tool. /// - private void refreshTool() + private void refreshPlacement() { - removePlacement(); + CurrentPlacement?.EndPlacement(false); + CurrentPlacement?.Expire(); + CurrentPlacement = null; + ensurePlacementCreated(); } - private void updatePlacementPosition() + private void updatePlacementTimeAndPosition() { - var snapResult = Composer.FindSnappedPositionAndTime(InputManager.CurrentState.Mouse.Position); + var snapResult = Composer.FindSnappedPositionAndTime(InputManager.CurrentState.Mouse.Position, CurrentPlacement.SnapType); // if no time was found from positional snapping, we should still quantize to the beat. snapResult.Time ??= Beatmap.SnapTime(EditorClock.CurrentTime, null); @@ -317,20 +347,33 @@ 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; } + + // updates the placement with the latest editor clock time. + updatePlacementTimeAndPosition(); } + } - if (Composer.CursorInPlacementArea) - ensurePlacementCreated(); - + protected override bool OnMouseMove(MouseMoveEvent e) + { + // updates the placement with the latest mouse position. if (CurrentPlacement != null) - updatePlacementPosition(); + updatePlacementTimeAndPosition(); + + return base.OnMouseMove(e); } protected sealed override SelectionBlueprint CreateBlueprintFor(HitObject item) @@ -349,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) @@ -367,7 +410,7 @@ namespace osu.Game.Screens.Edit.Compose.Components placementBlueprintContainer.Child = CurrentPlacement = blueprint; // Fixes a 1-frame position discrepancy due to the first mouse move event happening in the next frame - updatePlacementPosition(); + updatePlacementTimeAndPosition(); updatePlacementSamples(); @@ -378,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/DistanceSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs index 8aa2fa9f45..7003d632ca 100644 --- a/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs +++ b/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs @@ -155,7 +155,7 @@ namespace osu.Game.Screens.Edit.Compose.Components { var timingPoint = Beatmap.ControlPointInfo.TimingPointAt(StartTime); double beatLength = timingPoint.BeatLength / beatDivisor.Value; - int beatIndex = (int)Math.Round((StartTime - timingPoint.Time) / beatLength); + int beatIndex = (int)Math.Floor((StartTime - timingPoint.Time) / beatLength); var colour = BindableBeatDivisor.GetColourFor(BindableBeatDivisor.GetDivisorForBeatIndex(beatIndex + placementIndex + 1, beatDivisor.Value), Colours); diff --git a/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs index 378d378be3..7b046251e0 100644 --- a/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs @@ -9,7 +9,6 @@ using System.Collections.Specialized; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Game.Rulesets.Edit; @@ -136,7 +135,7 @@ namespace osu.Game.Screens.Edit.Compose.Components base.ApplySelectionOrder(blueprints) .OrderBy(b => Math.Min(Math.Abs(EditorClock.CurrentTime - b.Item.GetEndTime()), Math.Abs(EditorClock.CurrentTime - b.Item.StartTime))); - protected override Container> CreateSelectionBlueprintContainer() => new HitObjectOrderedSelectionContainer { RelativeSizeAxes = Axes.Both }; + protected override SelectionBlueprintContainer CreateSelectionBlueprintContainer() => new HitObjectOrderedSelectionContainer { RelativeSizeAxes = Axes.Both }; protected override SelectionHandler CreateSelectionHandler() => new EditorSelectionHandler(); 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/EditorSelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs index 472b48425f..6724a1dc4d 100644 --- a/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs @@ -3,13 +3,14 @@ using System; using System.Collections.Generic; +using System.Collections.Specialized; using System.Linq; using Humanizer; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Bindings; using osu.Game.Audio; -using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; @@ -36,7 +37,7 @@ namespace osu.Game.Screens.Edit.Compose.Components // bring in updates from selection changes EditorBeatmap.HitObjectUpdated += _ => Scheduler.AddOnce(UpdateTernaryStates); - SelectedItems.CollectionChanged += (_, _) => Scheduler.AddOnce(UpdateTernaryStates); + SelectedItems.CollectionChanged += onSelectedItemsChanged; } protected override void DeleteItems(IEnumerable items) => EditorBeatmap.RemoveRange(items); @@ -58,6 +59,21 @@ namespace osu.Game.Screens.Edit.Compose.Components /// public readonly Dictionary> SelectionBankStates = new Dictionary>(); + /// + /// The state of each sample addition bank type for all selected hitobjects. + /// + public readonly Dictionary> SelectionAdditionBankStates = new Dictionary>(); + + /// + /// Whether there is no selection and the auto can be used. + /// + public readonly Bindable AutoSelectionBankEnabled = new Bindable(); + + /// + /// Whether the selection contains any addition samples and the can be used. + /// + public readonly Bindable SelectionAdditionBanksEnabled = new Bindable(); + /// /// Set up ternary state bindables and bind them to selection/hitobject changes (in both directions) /// @@ -90,7 +106,7 @@ namespace osu.Game.Screens.Edit.Compose.Components // Never remove a sample bank. // These are basically radio buttons, not toggles. - if (SelectedItems.All(h => h.Samples.All(s => s.Bank == bankName))) + if (SelectedItems.All(h => h.Samples.Where(o => o.Name == HitSampleInfo.HIT_NORMAL).All(s => s.Bank == bankName))) bindable.Value = TernaryState.True; } @@ -127,8 +143,78 @@ namespace osu.Game.Screens.Edit.Compose.Components SelectionBankStates[bankName] = bindable; } - // start with normal selected. - SelectionBankStates[SampleControlPoint.DEFAULT_BANK].Value = TernaryState.True; + foreach (string bankName in HitSampleInfo.AllBanks.Prepend(HIT_BANK_AUTO)) + { + var bindable = new Bindable + { + Description = bankName.Titleize() + }; + + bindable.ValueChanged += state => + { + switch (state.NewValue) + { + case TernaryState.False: + if (SelectedItems.Count == 0) + { + // Ensure that if this is the last selected bank, it should remain selected. + if (SelectionAdditionBankStates.Values.All(b => b.Value == TernaryState.False)) + bindable.Value = TernaryState.True; + } + else + { + // Completely empty selections should be allowed in the case that none of the selected objects have any addition samples. + // This is also required to stop a bindable feedback loop when a HitObject has zero addition samples (and LINQ `All` below becomes true). + if (SelectedItems.SelectMany(enumerateAllSamples).All(h => h.All(o => o.Name == HitSampleInfo.HIT_NORMAL))) + break; + + // Never remove a sample bank. + // These are basically radio buttons, not toggles. + if (bankName == HIT_BANK_AUTO) + { + if (SelectedItems.SelectMany(enumerateAllSamples).All(h => h.Where(o => o.Name != HitSampleInfo.HIT_NORMAL).All(s => s.EditorAutoBank))) + bindable.Value = TernaryState.True; + } + else + { + if (SelectedItems.SelectMany(enumerateAllSamples).All(h => h.Where(o => o.Name != HitSampleInfo.HIT_NORMAL).All(s => s.Bank == bankName && !s.EditorAutoBank))) + bindable.Value = TernaryState.True; + } + } + + break; + + case TernaryState.True: + if (SelectedItems.Count == 0) + { + // Ensure the user can't stack multiple bank selections when there's no hitobject selection. + // Note that in normal scenarios this is sorted out by the feedback from applying the bank to the selected objects. + foreach (var other in SelectionAdditionBankStates.Values) + { + if (other != bindable) + other.Value = TernaryState.False; + } + } + else + { + // If none of the selected objects have any addition samples, we should not apply the addition bank. + if (SelectedItems.SelectMany(enumerateAllSamples).All(h => h.All(o => o.Name == HitSampleInfo.HIT_NORMAL))) + { + bindable.Value = TernaryState.False; + break; + } + + SetSampleAdditionBank(bankName); + } + + break; + } + }; + + SelectionAdditionBankStates[bankName] = bindable; + } + + resetTernaryStates(); foreach (string sampleName in HitSampleInfo.AllAdditions) { @@ -170,12 +256,23 @@ namespace osu.Game.Screens.Edit.Compose.Components }; } + private void resetTernaryStates() + { + SelectionNewComboState.Value = TernaryState.False; + AutoSelectionBankEnabled.Value = true; + SelectionAdditionBanksEnabled.Value = true; + SelectionBankStates[HIT_BANK_AUTO].Value = TernaryState.True; + SelectionAdditionBankStates[HIT_BANK_AUTO].Value = TernaryState.True; + } + /// /// Called when context menu ternary states may need to be recalculated (selection changed or hitobject updated). /// protected virtual void UpdateTernaryStates() { - SelectionNewComboState.Value = GetStateFromSelection(SelectedItems.OfType(), h => h.NewCombo); + if (SelectedItems.Any()) + SelectionNewComboState.Value = GetStateFromSelection(SelectedItems.OfType(), h => h.NewCombo); + AutoSelectionBankEnabled.Value = SelectedItems.Count == 0; var samplesInSelection = SelectedItems.SelectMany(enumerateAllSamples).ToArray(); @@ -186,18 +283,34 @@ namespace osu.Game.Screens.Edit.Compose.Components foreach ((string bankName, var bindable) in SelectionBankStates) { - bindable.Value = GetStateFromSelection(samplesInSelection.SelectMany(s => s), h => h.Bank == bankName); + bindable.Value = GetStateFromSelection(samplesInSelection.SelectMany(s => s).Where(o => o.Name == HitSampleInfo.HIT_NORMAL), h => h.Bank == bankName); } - IEnumerable> enumerateAllSamples(HitObject hitObject) - { - yield return hitObject.Samples; + SelectionAdditionBanksEnabled.Value = samplesInSelection.SelectMany(s => s).Any(o => o.Name != HitSampleInfo.HIT_NORMAL); - if (hitObject is IHasRepeats withRepeats) - { - foreach (var node in withRepeats.NodeSamples) - yield return node; - } + foreach ((string bankName, var bindable) in SelectionAdditionBankStates) + { + bindable.Value = GetStateFromSelection(samplesInSelection.SelectMany(s => s).Where(o => o.Name != HitSampleInfo.HIT_NORMAL), h => (bankName != HIT_BANK_AUTO && h.Bank == bankName && !h.EditorAutoBank) || (bankName == HIT_BANK_AUTO && h.EditorAutoBank)); + } + } + + private void onSelectedItemsChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + // Reset the ternary states when the selection is cleared. + if (e.OldStartingIndex >= 0 && e.NewStartingIndex < 0) + Scheduler.AddOnce(resetTernaryStates); + else + Scheduler.AddOnce(UpdateTernaryStates); + } + + private IEnumerable> enumerateAllSamples(HitObject hitObject) + { + yield return hitObject.Samples; + + if (hitObject is IHasRepeats withRepeats) + { + foreach (var node in withRepeats.NodeSamples) + yield return node; } } @@ -213,12 +326,12 @@ namespace osu.Game.Screens.Edit.Compose.Components { bool hasRelevantBank(HitObject hitObject) { - bool result = hitObject.Samples.All(s => s.Bank == bankName); + bool result = hitObject.Samples.Where(o => o.Name == HitSampleInfo.HIT_NORMAL).All(s => s.Bank == bankName); if (hitObject is IHasRepeats hasRepeats) { foreach (var node in hasRepeats.NodeSamples) - result &= node.All(s => s.Bank == bankName); + result &= node.Where(o => o.Name == HitSampleInfo.HIT_NORMAL).All(s => s.Bank == bankName); } return result; @@ -232,12 +345,47 @@ namespace osu.Game.Screens.Edit.Compose.Components if (hasRelevantBank(h)) return; - h.Samples = h.Samples.Select(s => s.With(newBank: bankName)).ToList(); + h.Samples = h.Samples.Select(s => s.Name == HitSampleInfo.HIT_NORMAL ? s.With(newBank: bankName) : s).ToList(); if (h is IHasRepeats hasRepeats) { for (int i = 0; i < hasRepeats.NodeSamples.Count; ++i) - hasRepeats.NodeSamples[i] = hasRepeats.NodeSamples[i].Select(s => s.With(newBank: bankName)).ToList(); + hasRepeats.NodeSamples[i] = hasRepeats.NodeSamples[i].Select(s => s.Name == HitSampleInfo.HIT_NORMAL ? s.With(newBank: bankName) : s).ToList(); + } + + EditorBeatmap.Update(h); + }); + } + + /// + /// Sets the sample addition bank for all selected s. + /// + /// The name of the sample bank. + public void SetSampleAdditionBank(string bankName) + { + bool hasRelevantBank(HitObject hitObject) => + bankName == HIT_BANK_AUTO + ? enumerateAllSamples(hitObject).SelectMany(o => o).Where(o => o.Name != HitSampleInfo.HIT_NORMAL).All(s => s.EditorAutoBank) + : enumerateAllSamples(hitObject).SelectMany(o => o).Where(o => o.Name != HitSampleInfo.HIT_NORMAL).All(s => s.Bank == bankName && !s.EditorAutoBank); + + if (SelectedItems.All(hasRelevantBank)) + return; + + EditorBeatmap.PerformOnSelection(h => + { + if (hasRelevantBank(h)) + return; + + string normalBank = h.Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL)?.Bank ?? HitSampleInfo.BANK_SOFT; + h.Samples = h.Samples.Select(s => s.Name != HitSampleInfo.HIT_NORMAL ? bankName == HIT_BANK_AUTO ? s.With(newBank: normalBank, newEditorAutoBank: true) : s.With(newBank: bankName, newEditorAutoBank: false) : s).ToList(); + + if (h is IHasRepeats hasRepeats) + { + for (int i = 0; i < hasRepeats.NodeSamples.Count; ++i) + { + normalBank = hasRepeats.NodeSamples[i].FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL)?.Bank ?? HitSampleInfo.BANK_SOFT; + hasRepeats.NodeSamples[i] = hasRepeats.NodeSamples[i].Select(s => s.Name != HitSampleInfo.HIT_NORMAL ? bankName == HIT_BANK_AUTO ? s.With(newBank: normalBank, newEditorAutoBank: true) : s.With(newBank: bankName, newEditorAutoBank: false) : s).ToList(); + } } EditorBeatmap.Update(h); @@ -281,9 +429,9 @@ namespace osu.Game.Screens.Edit.Compose.Components var hitSample = h.CreateHitSampleInfo(sampleName); - string? existingAdditionBank = node.FirstOrDefault(s => s.Name != HitSampleInfo.HIT_NORMAL)?.Bank; - if (existingAdditionBank != null) - hitSample = hitSample.With(newBank: existingAdditionBank); + HitSampleInfo? existingAddition = node.FirstOrDefault(s => s.Name != HitSampleInfo.HIT_NORMAL); + if (existingAddition != null) + hitSample = hitSample.With(newBank: existingAddition.Bank, newEditorAutoBank: existingAddition.EditorAutoBank); node.Add(hitSample); } @@ -350,18 +498,74 @@ namespace osu.Game.Screens.Edit.Compose.Components { if (SelectedBlueprints.All(b => b.Item is IHasComboInformation)) { - yield return new TernaryStateToggleMenuItem("New combo") { State = { BindTarget = SelectionNewComboState } }; + yield return new TernaryStateToggleMenuItem("New combo") + { + State = { BindTarget = SelectionNewComboState }, + Hotkey = new Hotkey(new KeyCombination(InputKey.Q)) + }; } - yield return new OsuMenuItem("Sample") + yield return new OsuMenuItem("Sample") { Items = getSampleSubmenuItems().ToArray(), }; + yield return new OsuMenuItem("Bank") { Items = getBankSubmenuItems().ToArray(), }; + } + + private IEnumerable getSampleSubmenuItems() + { + var whistle = SelectionSampleStates[HitSampleInfo.HIT_WHISTLE]; + yield return new TernaryStateToggleMenuItem(whistle.Description) { - Items = SelectionSampleStates.Select(kvp => - new TernaryStateToggleMenuItem(kvp.Value.Description) { State = { BindTarget = kvp.Value } }).ToArray() + State = { BindTarget = whistle }, + Hotkey = new Hotkey(new KeyCombination(InputKey.W)) }; - yield return new OsuMenuItem("Bank") + var finish = SelectionSampleStates[HitSampleInfo.HIT_FINISH]; + yield return new TernaryStateToggleMenuItem(finish.Description) { - Items = SelectionBankStates.Select(kvp => + State = { BindTarget = finish }, + Hotkey = new Hotkey(new KeyCombination(InputKey.E)) + }; + + var clap = SelectionSampleStates[HitSampleInfo.HIT_CLAP]; + yield return new TernaryStateToggleMenuItem(clap.Description) + { + State = { BindTarget = clap }, + Hotkey = new Hotkey(new KeyCombination(InputKey.R)) + }; + } + + private IEnumerable getBankSubmenuItems() + { + var auto = SelectionBankStates[HIT_BANK_AUTO]; + yield return new TernaryStateToggleMenuItem(auto.Description) + { + State = { BindTarget = auto }, + Hotkey = new Hotkey(new KeyCombination(InputKey.Shift, InputKey.Q)) + }; + + var normal = SelectionBankStates[HitSampleInfo.BANK_NORMAL]; + yield return new TernaryStateToggleMenuItem(normal.Description) + { + State = { BindTarget = normal }, + Hotkey = new Hotkey(new KeyCombination(InputKey.Shift, InputKey.W)) + }; + + var soft = SelectionBankStates[HitSampleInfo.BANK_SOFT]; + yield return new TernaryStateToggleMenuItem(soft.Description) + { + State = { BindTarget = soft }, + Hotkey = new Hotkey(new KeyCombination(InputKey.Shift, InputKey.E)) + }; + + var drum = SelectionBankStates[HitSampleInfo.BANK_DRUM]; + yield return new TernaryStateToggleMenuItem(drum.Description) + { + State = { BindTarget = drum }, + Hotkey = new Hotkey(new KeyCombination(InputKey.Shift, InputKey.R)) + }; + + yield return new OsuMenuItem("Addition bank") + { + Items = SelectionAdditionBankStates.Select(kvp => new TernaryStateToggleMenuItem(kvp.Value.Description) { State = { BindTarget = kvp.Value } }).ToArray() }; } diff --git a/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs b/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs index 8f54d55d5d..a7f8fd0d4c 100644 --- a/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs @@ -4,7 +4,6 @@ using osu.Framework.Allocation; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; @@ -14,7 +13,7 @@ namespace osu.Game.Screens.Edit.Compose.Components /// /// A container for ordered by their start times. /// - public sealed partial class HitObjectOrderedSelectionContainer : Container> + public sealed partial class HitObjectOrderedSelectionContainer : BlueprintContainer.SelectionBlueprintContainer { [Resolved] private EditorBeatmap editorBeatmap { get; set; } = null!; @@ -28,16 +27,18 @@ namespace osu.Game.Screens.Edit.Compose.Components public override void Add(SelectionBlueprint drawable) { - SortInternal(); + Sort(); base.Add(drawable); } public override bool Remove(SelectionBlueprint drawable, bool disposeImmediately) { - SortInternal(); + Sort(); return base.Remove(drawable, disposeImmediately); } + internal void Sort() => SortInternal(); + protected override int Compare(Drawable x, Drawable y) { var xObj = ((SelectionBlueprint)x).Item; diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs index d685fe74b0..2171ba696f 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs @@ -150,13 +150,25 @@ namespace osu.Game.Screens.Edit.Compose.Components switch (e.Key) { case Key.G: - return CanReverse && reverseButton?.TriggerClick() == true; + if (!CanReverse || reverseButton == null) + return false; + + reverseButton.TriggerAction(); + return true; case Key.Comma: - return canRotate.Value && rotateCounterClockwiseButton?.TriggerClick() == true; + if (!canRotate.Value || rotateCounterClockwiseButton == null) + return false; + + rotateCounterClockwiseButton.TriggerAction(); + return true; case Key.Period: - return canRotate.Value && rotateClockwiseButton?.TriggerClick() == true; + if (!canRotate.Value || rotateClockwiseButton == null) + return false; + + rotateClockwiseButton.TriggerAction(); + return true; } return base.OnKeyDown(e); @@ -285,7 +297,7 @@ namespace osu.Game.Screens.Edit.Compose.Components Action = action }; - button.OperationStarted += freezeButtonPosition; + button.Clicked += freezeButtonPosition; button.HoverLost += unfreezeButtonPosition; button.OperationStarted += operationStarted; diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxButton.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxButton.cs index e355add40b..8f263cdf4f 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxButton.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxButton.cs @@ -21,6 +21,8 @@ namespace osu.Game.Screens.Edit.Compose.Components public Action? Action; + public event Action? Clicked; + public event Action? HoverLost; public SelectionBoxButton(IconUsage iconUsage, string tooltip) @@ -49,11 +51,10 @@ namespace osu.Game.Screens.Edit.Compose.Components protected override bool OnClick(ClickEvent e) { - Circle.FlashColour(Colours.GrayF, 300); + Clicked?.Invoke(); + + TriggerAction(); - TriggerOperationStarted(); - Action?.Invoke(); - TriggerOperationEnded(); return true; } @@ -71,5 +72,14 @@ namespace osu.Game.Screens.Edit.Compose.Components } public LocalisableString TooltipText { get; } + + public void TriggerAction() + { + Circle.FlashColour(Colours.GrayF, 300); + + TriggerOperationStarted(); + Action?.Invoke(); + TriggerOperationEnded(); + } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs index 98807ad85d..39fff169b7 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs @@ -415,7 +415,10 @@ namespace osu.Game.Screens.Edit.Compose.Components if (SelectedBlueprints.Count == 1) items.AddRange(SelectedBlueprints[0].ContextMenuItems); - items.Add(new OsuMenuItem(CommonStrings.ButtonsDelete, MenuItemType.Destructive, DeleteSelected)); + items.Add(new OsuMenuItem(CommonStrings.ButtonsDelete, MenuItemType.Destructive, DeleteSelected) + { + Hotkey = new Hotkey { PlatformAction = PlatformAction.Delete, KeyCombinations = [new KeyCombination(InputKey.Shift, InputKey.MouseRight), new KeyCombination(InputKey.MouseMiddle)] } + }); return items.ToArray(); } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/HitObjectPointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/HitObjectPointPiece.cs index 4b357d3a62..76323ac08c 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/HitObjectPointPiece.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/HitObjectPointPiece.cs @@ -17,6 +17,8 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { protected OsuSpriteText Label { get; private set; } + protected Container LabelContainer { get; private set; } + [BackgroundDependencyLoader] private void load(OsuColour colours) { @@ -26,7 +28,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline InternalChildren = new Drawable[] { - new Container + LabelContainer = new Container { AutoSizeAxes = Axes.X, Height = 16, diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs index a8cf8723f2..c3a56c8df9 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs @@ -16,6 +16,7 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Framework.Utils; using osu.Game.Audio; +using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; @@ -40,6 +41,9 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline [Resolved] private Editor? editor { get; set; } + [Resolved] + private TimelineBlueprintContainer? timelineBlueprintContainer { get; set; } + public SamplePointPiece(HitObject hitObject) { HitObject = hitObject; @@ -53,15 +57,45 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline protected virtual double GetTime() => HitObject is IHasRepeats r ? HitObject.StartTime + r.Duration / r.SpanCount() / 2 : HitObject.StartTime; [BackgroundDependencyLoader] - private void load() + private void load(OsuConfigManager config) { HitObject.DefaultsApplied += _ => updateText(); + Label.AllowMultiline = false; + LabelContainer.AutoSizeAxes = Axes.None; updateText(); if (editor != null) editor.ShowSampleEditPopoverRequested += onShowSampleEditPopoverRequested; } + private readonly Bindable contracted = new Bindable(); + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (timelineBlueprintContainer != null) + contracted.BindTo(timelineBlueprintContainer.SamplePointContracted); + + contracted.BindValueChanged(v => + { + if (v.NewValue) + { + Label.FadeOut(200, Easing.OutQuint); + LabelContainer.ResizeTo(new Vector2(12), 200, Easing.OutQuint); + LabelContainer.CornerRadius = 6; + } + else + { + Label.FadeIn(200, Easing.OutQuint); + LabelContainer.ResizeTo(new Vector2(Label.Width, 16), 200, Easing.OutQuint); + LabelContainer.CornerRadius = 8; + } + }, true); + + FinishTransforms(); + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); @@ -87,6 +121,9 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private void updateText() { Label.Text = $"{abbreviateBank(GetBankValue(GetSamples()))} {GetVolumeValue(GetSamples())}"; + + if (!contracted.Value) + LabelContainer.ResizeWidthTo(Label.Width, 200, Easing.OutQuint); } private static string? abbreviateBank(string? bank) @@ -107,7 +144,11 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline public static string? GetAdditionBankValue(IEnumerable samples) { - return samples.FirstOrDefault(o => o.Name != HitSampleInfo.HIT_NORMAL)?.Bank; + var firstAddition = samples.FirstOrDefault(o => o.Name != HitSampleInfo.HIT_NORMAL); + if (firstAddition == null) + return null; + + return firstAddition.EditorAutoBank ? EditorSelectionHandler.HIT_BANK_AUTO : firstAddition.Bank; } public static int GetVolumeValue(ICollection samples) @@ -320,7 +361,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { for (int i = 0; i < relevantSamples.Count; i++) { - if (relevantSamples[i].Name != HitSampleInfo.HIT_NORMAL) continue; + if (relevantSamples[i].Name != HitSampleInfo.HIT_NORMAL && !relevantSamples[i].EditorAutoBank) continue; relevantSamples[i] = relevantSamples[i].With(newBank: newBank); } @@ -331,11 +372,20 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { updateAllRelevantSamples((_, relevantSamples) => { + string normalBank = relevantSamples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL)?.Bank ?? HitSampleInfo.BANK_SOFT; + for (int i = 0; i < relevantSamples.Count; i++) { - if (relevantSamples[i].Name == HitSampleInfo.HIT_NORMAL) continue; + if (relevantSamples[i].Name == HitSampleInfo.HIT_NORMAL) + continue; - relevantSamples[i] = relevantSamples[i].With(newBank: newBank); + // Addition samples with bank set to auto should inherit the bank of the normal sample + if (newBank == EditorSelectionHandler.HIT_BANK_AUTO) + { + relevantSamples[i] = relevantSamples[i].With(newBank: normalBank, newEditorAutoBank: true); + } + else + relevantSamples[i] = relevantSamples[i].With(newBank: newBank, newEditorAutoBank: false); } }); } @@ -383,7 +433,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline selectionSampleStates[sampleName] = bindable; } - banks.AddRange(HitSampleInfo.AllBanks); + banks.AddRange(HitSampleInfo.AllBanks.Prepend(EditorSelectionHandler.HIT_BANK_AUTO)); } private void updateTernaryStates() @@ -438,24 +488,31 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline protected override bool OnKeyDown(KeyDownEvent e) { - if (e.ControlPressed || e.AltPressed || e.SuperPressed || !checkRightToggleFromKey(e.Key, out int rightIndex)) + if (e.ControlPressed || e.SuperPressed || !checkRightToggleFromKey(e.Key, out int rightIndex)) return base.OnKeyDown(e); - if (e.ShiftPressed) + if (e.ShiftPressed || e.AltPressed) { string? newBank = banks.ElementAtOrDefault(rightIndex); if (string.IsNullOrEmpty(newBank)) return true; - setBank(newBank); - updatePrimaryBankState(); - setAdditionBank(newBank); - updateAdditionBankState(); + if (e.ShiftPressed && newBank != EditorSelectionHandler.HIT_BANK_AUTO) + { + setBank(newBank); + updatePrimaryBankState(); + } + + if (e.AltPressed) + { + setAdditionBank(newBank); + updateAdditionBankState(); + } } else { - var item = togglesCollection.ElementAtOrDefault(rightIndex); + var item = togglesCollection.ElementAtOrDefault(rightIndex - 1); if (item is not DrawableTernaryButton button) return base.OnKeyDown(e); @@ -469,18 +526,22 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { switch (key) { - case Key.W: + case Key.Q: index = 0; break; - case Key.E: + case Key.W: index = 1; break; - case Key.R: + case Key.E: index = 2; break; + case Key.R: + index = 3; + break; + default: index = -1; break; diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs index 740f0b6aac..a4083f58b6 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs @@ -19,17 +19,22 @@ using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Screens.Edit.Components.Timelines.Summary.Parts; using osuTK; using osuTK.Graphics; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { + [Cached] internal partial class TimelineBlueprintContainer : EditorBlueprintContainer { [Resolved(CanBeNull = true)] private Timeline timeline { get; set; } + [Resolved(CanBeNull = true)] + private EditorClock editorClock { get; set; } + private Bindable placement; private SelectionBlueprint placementBlueprint; @@ -91,7 +96,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } } - protected override Container> CreateSelectionBlueprintContainer() => new TimelineSelectionBlueprintContainer { RelativeSizeAxes = Axes.Both }; + protected override SelectionBlueprintContainer CreateSelectionBlueprintContainer() => new TimelineSelectionBlueprintContainer { RelativeSizeAxes = Axes.Both }; protected override bool OnDragStart(DragStartEvent e) { @@ -118,9 +123,53 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline base.Update(); + updateSamplePointContractedState(); updateStacking(); } + public Bindable SamplePointContracted = new Bindable(); + + private void updateSamplePointContractedState() + { + const double minimum_gap = 28; + + if (timeline == null || editorClock == null) + return; + + // Find the smallest time gap between any two sample point pieces + double smallestTimeGap = double.PositiveInfinity; + double lastTime = double.PositiveInfinity; + + // The blueprints are ordered in reverse chronological order + foreach (var selectionBlueprint in SelectionBlueprints) + { + var hitObject = selectionBlueprint.Item; + + // Only check the hit objects which are visible in the timeline + // SelectionBlueprints can contain hit objects which are not visible in the timeline due to selection keeping them alive + if (hitObject.StartTime > editorClock.CurrentTime + timeline.VisibleRange / 2) + continue; + + if (hitObject.GetEndTime() < editorClock.CurrentTime - timeline.VisibleRange / 2) + break; + + if (hitObject is IHasRepeats hasRepeats) + smallestTimeGap = Math.Min(smallestTimeGap, hasRepeats.Duration / hasRepeats.SpanCount() / 2); + + double gap = lastTime - hitObject.GetEndTime(); + + // If the gap is less than 1ms, we can assume that the objects are stacked on top of each other + // Contracting doesn't make sense in this case + if (gap > 1 && gap < smallestTimeGap) + smallestTimeGap = gap; + + lastTime = hitObject.StartTime; + } + + double smallestAbsoluteGap = ((TimelineSelectionBlueprintContainer)SelectionBlueprints).ContentRelativeToAbsoluteFactor.X * smallestTimeGap; + SamplePointContracted.Value = smallestAbsoluteGap < minimum_gap; + } + private readonly Stack currentConcurrentObjects = new Stack(); private void updateStacking() @@ -173,7 +222,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline protected sealed override DragBox CreateDragBox() => new TimelineDragBox(); - protected override void UpdateSelectionFromDragBox() + protected override void UpdateSelectionFromDragBox(HashSet selectionBeforeDrag) { Composer.BlueprintContainer.CommitIfPlacementActive(); @@ -191,6 +240,9 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline bool shouldBeSelected(HitObject hitObject) { + if (selectionBeforeDrag.Contains(hitObject)) + return true; + double midTime = (hitObject.StartTime + hitObject.GetEndTime()) / 2; return minTime <= midTime && midTime <= maxTime; } @@ -284,14 +336,29 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } } - protected partial class TimelineSelectionBlueprintContainer : Container> + protected partial class TimelineSelectionBlueprintContainer : SelectionBlueprintContainer { - protected override Container> Content { get; } + protected override HitObjectOrderedSelectionContainer Content { get; } + + public Vector2 ContentRelativeToAbsoluteFactor => Content.RelativeToAbsoluteFactor; public TimelineSelectionBlueprintContainer() { AddInternal(new TimelinePart>(Content = new HitObjectOrderedSelectionContainer { RelativeSizeAxes = Axes.Both }) { RelativeSizeAxes = Axes.Both }); } + + public override void ChangeChildDepth(SelectionBlueprint child, float newDepth) + { + // timeline blueprint container also contains a blueprint for current placement, if present + // (see `placementChanged()` callback above). + // because the current placement hitobject is generally going to be mutated during the placement, + // it is possible for `Content`'s children to become unsorted when the user moves the placement around, + // which can culminate in a critical failure when attempting to binary-search children here + // using `HitObjectOrderedSelectionContainer`'s custom comparer. + // thus, always force a re-sort of objects before attempting to change child depth to avoid this scenario. + Content.Sort(); + base.ChangeChildDepth(child, newDepth); + } } } } 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/Components/Timeline/ZoomableScrollContainer.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs index 848c8f9a0f..31a0936eb4 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Transforms; @@ -32,10 +33,10 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline protected override Container Content => zoomedContent; /// - /// The current zoom level of . + /// The current (final) zoom level of . /// It may differ from during transitions. /// - public float CurrentZoom { get; private set; } = 1; + public BindableFloat CurrentZoom { get; private set; } = new BindableFloat(1); private bool isZoomSetUp; @@ -98,7 +99,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline minZoom = minimum; maxZoom = maximum; - CurrentZoom = zoomTarget = initial; + CurrentZoom.Value = zoomTarget = initial; zoomedContentWidthCache.Invalidate(); isZoomSetUp = true; @@ -124,7 +125,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline if (IsLoaded) setZoomTarget(newZoom, ToSpaceOfOtherDrawable(new Vector2(DrawWidth / 2, 0), zoomedContent).X); else - CurrentZoom = zoomTarget = newZoom; + CurrentZoom.Value = zoomTarget = newZoom; } protected override void UpdateAfterChildren() @@ -154,7 +155,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private void updateZoomedContentWidth() { - zoomedContent.Width = DrawWidth * CurrentZoom; + zoomedContent.Width = DrawWidth * CurrentZoom.Value; zoomedContentWidthCache.Validate(); } @@ -238,7 +239,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline float expectedWidth = d.DrawWidth * newZoom; float targetOffset = expectedWidth * (focusPoint / contentSize) - focusOffset; - d.CurrentZoom = newZoom; + d.CurrentZoom.Value = newZoom; d.updateZoomedContentWidth(); // Temporarily here to make sure ScrollTo gets the correct DrawSize for scrollable area. @@ -247,7 +248,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline d.ScrollTo(targetOffset, false); } - protected override void ReadIntoStartValue(ZoomableScrollContainer d) => StartValue = d.CurrentZoom; + protected override void ReadIntoStartValue(ZoomableScrollContainer d) => StartValue = d.CurrentZoom.Value; } } } 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/DeleteDifficultyConfirmationDialog.cs b/osu.Game/Screens/Edit/DeleteDifficultyConfirmationDialog.cs index 8556949528..1aeb1d8a40 100644 --- a/osu.Game/Screens/Edit/DeleteDifficultyConfirmationDialog.cs +++ b/osu.Game/Screens/Edit/DeleteDifficultyConfirmationDialog.cs @@ -7,7 +7,7 @@ using osu.Game.Overlays.Dialog; namespace osu.Game.Screens.Edit { - public partial class DeleteDifficultyConfirmationDialog : DangerousActionDialog + public partial class DeleteDifficultyConfirmationDialog : DeletionDialog { public DeleteDifficultyConfirmationDialog(BeatmapInfo beatmapInfo, Action deleteAction) { diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index 9bb91af806..644e1afb3b 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -214,7 +214,9 @@ namespace osu.Game.Screens.Edit private Bindable editorAutoSeekOnPlacement; private Bindable editorLimitedDistanceSnap; private Bindable editorTimelineShowTimingChanges; + private Bindable editorTimelineShowBreaks; private Bindable editorTimelineShowTicks; + private Bindable editorContractSidebars; /// /// This controls the opacity of components like the timelines, sidebars, etc. @@ -322,7 +324,9 @@ 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); AddInternal(new OsuContextMenuContainer { @@ -362,13 +366,13 @@ namespace osu.Game.Screens.Edit { Items = new[] { - undoMenuItem = new EditorMenuItem(CommonStrings.Undo, MenuItemType.Standard, Undo), - redoMenuItem = new EditorMenuItem(CommonStrings.Redo, MenuItemType.Standard, Redo), + undoMenuItem = new EditorMenuItem(CommonStrings.Undo, MenuItemType.Standard, Undo) { Hotkey = new Hotkey(PlatformAction.Undo) }, + redoMenuItem = new EditorMenuItem(CommonStrings.Redo, MenuItemType.Standard, Redo) { Hotkey = new Hotkey(PlatformAction.Redo) }, new OsuMenuItemSpacer(), - cutMenuItem = new EditorMenuItem(CommonStrings.Cut, MenuItemType.Standard, Cut), - copyMenuItem = new EditorMenuItem(CommonStrings.Copy, MenuItemType.Standard, Copy), - pasteMenuItem = new EditorMenuItem(CommonStrings.Paste, MenuItemType.Standard, Paste), - cloneMenuItem = new EditorMenuItem(CommonStrings.Clone, MenuItemType.Standard, Clone), + cutMenuItem = new EditorMenuItem(CommonStrings.Cut, MenuItemType.Standard, Cut) { Hotkey = new Hotkey(PlatformAction.Cut) }, + copyMenuItem = new EditorMenuItem(CommonStrings.Copy, MenuItemType.Standard, Copy) { Hotkey = new Hotkey(PlatformAction.Copy) }, + pasteMenuItem = new EditorMenuItem(CommonStrings.Paste, MenuItemType.Standard, Paste) { Hotkey = new Hotkey(PlatformAction.Paste) }, + cloneMenuItem = new EditorMenuItem(CommonStrings.Clone, MenuItemType.Standard, Clone) { Hotkey = new Hotkey(GlobalAction.EditorCloneSelection) }, } }, new MenuItem(CommonStrings.MenuBarView) @@ -388,6 +392,10 @@ namespace osu.Game.Screens.Edit { State = { BindTarget = editorTimelineShowTicks } }, + new ToggleMenuItem(EditorStrings.TimelineShowBreaks) + { + State = { BindTarget = editorTimelineShowBreaks } + }, ] }, new BackgroundDimMenuItem(editorBackgroundDim), @@ -402,14 +410,18 @@ namespace osu.Game.Screens.Edit new ToggleMenuItem(EditorStrings.LimitedDistanceSnap) { State = { BindTarget = editorLimitedDistanceSnap }, - } + }, + new ToggleMenuItem(EditorStrings.ContractSidebars) + { + State = { BindTarget = editorContractSidebars } + }, } }, new MenuItem(EditorStrings.Timing) { Items = new MenuItem[] { - new EditorMenuItem(EditorStrings.SetPreviewPointToCurrent, MenuItemType.Standard, SetPreviewPointToCurrentTime) + new EditorMenuItem(EditorStrings.SetPreviewPointToCurrent, MenuItemType.Standard, SetPreviewPointToCurrentTime), } } } @@ -721,10 +733,16 @@ namespace osu.Game.Screens.Edit switch (e.Action) { case GlobalAction.EditorSeekToPreviousHitObject: + if (editorBeatmap.SelectedHitObjects.Any()) + return false; + seekHitObject(-1); return true; case GlobalAction.EditorSeekToNextHitObject: + if (editorBeatmap.SelectedHitObjects.Any()) + return false; + seekHitObject(1); return true; @@ -1092,8 +1110,12 @@ namespace osu.Game.Screens.Edit private void seekControlPoint(int direction) { - var found = direction < 1 - ? editorBeatmap.ControlPointInfo.AllControlPoints.LastOrDefault(p => p.Time < clock.CurrentTime) + // In the case of a backwards seek while playing, it can be hard to jump before a timing point. + // Adding some lenience here makes it more user-friendly. + double seekLenience = clock.IsRunning ? 1000 * ((IAdjustableClock)clock).Rate : 0; + + ControlPoint found = direction < 1 + ? editorBeatmap.ControlPointInfo.AllControlPoints.LastOrDefault(p => p.Time < clock.CurrentTime - seekLenience) : editorBeatmap.ControlPointInfo.AllControlPoints.FirstOrDefault(p => p.Time > clock.CurrentTime); if (found != null) @@ -1194,7 +1216,7 @@ namespace osu.Game.Screens.Edit yield return new EditorMenuItem(EditorStrings.DeleteDifficulty, MenuItemType.Standard, deleteDifficulty) { Action = { Disabled = Beatmap.Value.BeatmapSetInfo.Beatmaps.Count < 2 } }; yield return new OsuMenuItemSpacer(); - var save = new EditorMenuItem(WebCommonStrings.ButtonsSave, MenuItemType.Standard, () => attemptMutationOperation(Save)); + var save = new EditorMenuItem(WebCommonStrings.ButtonsSave, MenuItemType.Standard, () => attemptMutationOperation(Save)) { Hotkey = new Hotkey(PlatformAction.Save) }; saveRelatedMenuItems.Add(save); yield return save; diff --git a/osu.Game/Screens/Edit/Setup/ColoursSection.cs b/osu.Game/Screens/Edit/Setup/ColoursSection.cs index a5d79b5b52..8de7f86523 100644 --- a/osu.Game/Screens/Edit/Setup/ColoursSection.cs +++ b/osu.Game/Screens/Edit/Setup/ColoursSection.cs @@ -6,6 +6,7 @@ using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; +using osu.Game.Skinning; namespace osu.Game.Screens.Edit.Setup { @@ -13,23 +14,62 @@ namespace osu.Game.Screens.Edit.Setup { public override LocalisableString Title => EditorSetupStrings.ColoursHeader; - private LabelledColourPalette comboColours = null!; + private FormColourPalette comboColours = null!; [BackgroundDependencyLoader] private void load() { Children = new Drawable[] { - comboColours = new LabelledColourPalette + comboColours = new FormColourPalette { - Label = EditorSetupStrings.HitCircleSliderCombos, - FixedLabelWidth = LABEL_WIDTH, - ColourNamePrefix = EditorSetupStrings.ComboColourPrefix + Caption = EditorSetupStrings.HitCircleSliderCombos, } }; + } + private bool syncingColours; + + protected override void LoadComplete() + { if (Beatmap.BeatmapSkin != null) - comboColours.Colours.BindTo(Beatmap.BeatmapSkin.ComboColours); + comboColours.Colours.AddRange(Beatmap.BeatmapSkin.ComboColours); + + if (comboColours.Colours.Count == 0) + { + // compare ctor of `EditorBeatmapSkin` + for (int i = 0; i < SkinConfiguration.DefaultComboColours.Count; ++i) + comboColours.Colours.Add(SkinConfiguration.DefaultComboColours[(i + 1) % SkinConfiguration.DefaultComboColours.Count]); + } + + comboColours.Colours.BindCollectionChanged((_, _) => + { + if (Beatmap.BeatmapSkin != null) + { + if (syncingColours) + return; + + syncingColours = true; + + Beatmap.BeatmapSkin.ComboColours.Clear(); + Beatmap.BeatmapSkin.ComboColours.AddRange(comboColours.Colours); + + syncingColours = false; + } + }); + + Beatmap.BeatmapSkin?.ComboColours.BindCollectionChanged((_, _) => + { + if (syncingColours) + return; + + syncingColours = true; + + comboColours.Colours.Clear(); + comboColours.Colours.AddRange(Beatmap.BeatmapSkin?.ComboColours); + + syncingColours = false; + }); } } } diff --git a/osu.Game/Screens/Edit/Setup/DesignSection.cs b/osu.Game/Screens/Edit/Setup/DesignSection.cs index b05a073146..7def5394e6 100644 --- a/osu.Game/Screens/Edit/Setup/DesignSection.cs +++ b/osu.Game/Screens/Edit/Setup/DesignSection.cs @@ -15,77 +15,77 @@ using osu.Game.Localisation; namespace osu.Game.Screens.Edit.Setup { - internal partial class DesignSection : SetupSection + public partial class DesignSection : SetupSection { - protected LabelledSwitchButton EnableCountdown = null!; + protected FormCheckBox EnableCountdown = null!; protected FillFlowContainer CountdownSettings = null!; - protected LabelledEnumDropdown CountdownSpeed = null!; - protected LabelledNumberBox CountdownOffset = null!; + protected FormEnumDropdown CountdownSpeed = null!; + protected FormNumberBox CountdownOffset = null!; - private LabelledSwitchButton widescreenSupport = null!; - private LabelledSwitchButton epilepsyWarning = null!; - private LabelledSwitchButton letterboxDuringBreaks = null!; - private LabelledSwitchButton samplesMatchPlaybackRate = null!; + private FormCheckBox widescreenSupport = null!; + private FormCheckBox epilepsyWarning = null!; + private FormCheckBox letterboxDuringBreaks = null!; + private FormCheckBox samplesMatchPlaybackRate = null!; public override LocalisableString Title => EditorSetupStrings.DesignHeader; [BackgroundDependencyLoader] private void load() { - Children = new[] + Children = new Drawable[] { - EnableCountdown = new LabelledSwitchButton + EnableCountdown = new FormCheckBox { - Label = EditorSetupStrings.EnableCountdown, + Caption = EditorSetupStrings.EnableCountdown, + HintText = EditorSetupStrings.CountdownDescription, Current = { Value = Beatmap.BeatmapInfo.Countdown != CountdownType.None }, - Description = EditorSetupStrings.CountdownDescription }, CountdownSettings = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Spacing = new Vector2(10), + Spacing = new Vector2(5), Direction = FillDirection.Vertical, Children = new Drawable[] { - CountdownSpeed = new LabelledEnumDropdown + CountdownSpeed = new FormEnumDropdown { - Label = EditorSetupStrings.CountdownSpeed, + Caption = EditorSetupStrings.CountdownSpeed, Current = { Value = Beatmap.BeatmapInfo.Countdown != CountdownType.None ? Beatmap.BeatmapInfo.Countdown : CountdownType.Normal }, Items = Enum.GetValues().Where(type => type != CountdownType.None) }, - CountdownOffset = new LabelledNumberBox + CountdownOffset = new FormNumberBox { - Label = EditorSetupStrings.CountdownOffset, + Caption = EditorSetupStrings.CountdownOffset, + HintText = EditorSetupStrings.CountdownOffsetDescription, Current = { Value = Beatmap.BeatmapInfo.CountdownOffset.ToString() }, - Description = EditorSetupStrings.CountdownOffsetDescription, + TabbableContentContainer = this, } } }, - Empty(), - widescreenSupport = new LabelledSwitchButton + widescreenSupport = new FormCheckBox { - Label = EditorSetupStrings.WidescreenSupport, - Description = EditorSetupStrings.WidescreenSupportDescription, + Caption = EditorSetupStrings.WidescreenSupport, + HintText = EditorSetupStrings.WidescreenSupportDescription, Current = { Value = Beatmap.BeatmapInfo.WidescreenStoryboard } }, - epilepsyWarning = new LabelledSwitchButton + epilepsyWarning = new FormCheckBox { - Label = EditorSetupStrings.EpilepsyWarning, - Description = EditorSetupStrings.EpilepsyWarningDescription, + Caption = EditorSetupStrings.EpilepsyWarning, + HintText = EditorSetupStrings.EpilepsyWarningDescription, Current = { Value = Beatmap.BeatmapInfo.EpilepsyWarning } }, - letterboxDuringBreaks = new LabelledSwitchButton + letterboxDuringBreaks = new FormCheckBox { - Label = EditorSetupStrings.LetterboxDuringBreaks, - Description = EditorSetupStrings.LetterboxDuringBreaksDescription, + Caption = EditorSetupStrings.LetterboxDuringBreaks, + HintText = EditorSetupStrings.LetterboxDuringBreaksDescription, Current = { Value = Beatmap.BeatmapInfo.LetterboxInBreaks } }, - samplesMatchPlaybackRate = new LabelledSwitchButton + samplesMatchPlaybackRate = new FormCheckBox { - Label = EditorSetupStrings.SamplesMatchPlaybackRate, - Description = EditorSetupStrings.SamplesMatchPlaybackRateDescription, + Caption = EditorSetupStrings.SamplesMatchPlaybackRate, + HintText = EditorSetupStrings.SamplesMatchPlaybackRateDescription, Current = { Value = Beatmap.BeatmapInfo.SamplesMatchPlaybackRate } } }; diff --git a/osu.Game/Screens/Edit/Setup/DifficultySection.cs b/osu.Game/Screens/Edit/Setup/DifficultySection.cs index b9ba2d9cb7..88241451cf 100644 --- a/osu.Game/Screens/Edit/Setup/DifficultySection.cs +++ b/osu.Game/Screens/Edit/Setup/DifficultySection.cs @@ -15,12 +15,12 @@ namespace osu.Game.Screens.Edit.Setup { public partial class DifficultySection : SetupSection { - private LabelledSliderBar circleSizeSlider { get; set; } = null!; - private LabelledSliderBar healthDrainSlider { get; set; } = null!; - private LabelledSliderBar approachRateSlider { get; set; } = null!; - private LabelledSliderBar overallDifficultySlider { get; set; } = null!; - private LabelledSliderBar baseVelocitySlider { get; set; } = null!; - private LabelledSliderBar tickRateSlider { get; set; } = null!; + private FormSliderBar circleSizeSlider { get; set; } = null!; + private FormSliderBar healthDrainSlider { get; set; } = null!; + private FormSliderBar approachRateSlider { get; set; } = null!; + private FormSliderBar overallDifficultySlider { get; set; } = null!; + private FormSliderBar baseVelocitySlider { get; set; } = null!; + private FormSliderBar tickRateSlider { get; set; } = null!; public override LocalisableString Title => EditorSetupStrings.DifficultyHeader; @@ -29,90 +29,96 @@ namespace osu.Game.Screens.Edit.Setup { Children = new Drawable[] { - circleSizeSlider = new LabelledSliderBar + circleSizeSlider = new FormSliderBar { - Label = BeatmapsetsStrings.ShowStatsCs, - FixedLabelWidth = LABEL_WIDTH, - Description = EditorSetupStrings.CircleSizeDescription, + Caption = BeatmapsetsStrings.ShowStatsCs, + HintText = EditorSetupStrings.CircleSizeDescription, Current = new BindableFloat(Beatmap.Difficulty.CircleSize) { Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, MinValue = 0, MaxValue = 10, Precision = 0.1f, - } + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, }, - healthDrainSlider = new LabelledSliderBar + healthDrainSlider = new FormSliderBar { - Label = BeatmapsetsStrings.ShowStatsDrain, - FixedLabelWidth = LABEL_WIDTH, - Description = EditorSetupStrings.DrainRateDescription, + Caption = BeatmapsetsStrings.ShowStatsDrain, + HintText = EditorSetupStrings.DrainRateDescription, Current = new BindableFloat(Beatmap.Difficulty.DrainRate) { Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, MinValue = 0, MaxValue = 10, Precision = 0.1f, - } + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, }, - approachRateSlider = new LabelledSliderBar + approachRateSlider = new FormSliderBar { - Label = BeatmapsetsStrings.ShowStatsAr, - FixedLabelWidth = LABEL_WIDTH, - Description = EditorSetupStrings.ApproachRateDescription, + Caption = BeatmapsetsStrings.ShowStatsAr, + HintText = EditorSetupStrings.ApproachRateDescription, Current = new BindableFloat(Beatmap.Difficulty.ApproachRate) { Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, MinValue = 0, MaxValue = 10, Precision = 0.1f, - } + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, }, - overallDifficultySlider = new LabelledSliderBar + overallDifficultySlider = new FormSliderBar { - Label = BeatmapsetsStrings.ShowStatsAccuracy, - FixedLabelWidth = LABEL_WIDTH, - Description = EditorSetupStrings.OverallDifficultyDescription, + Caption = BeatmapsetsStrings.ShowStatsAccuracy, + HintText = EditorSetupStrings.OverallDifficultyDescription, Current = new BindableFloat(Beatmap.Difficulty.OverallDifficulty) { Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, MinValue = 0, MaxValue = 10, Precision = 0.1f, - } + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, }, - baseVelocitySlider = new LabelledSliderBar + baseVelocitySlider = new FormSliderBar { - Label = EditorSetupStrings.BaseVelocity, - FixedLabelWidth = LABEL_WIDTH, - Description = EditorSetupStrings.BaseVelocityDescription, + Caption = EditorSetupStrings.BaseVelocity, + HintText = EditorSetupStrings.BaseVelocityDescription, Current = new BindableDouble(Beatmap.Difficulty.SliderMultiplier) { Default = 1.4, MinValue = 0.4, MaxValue = 3.6, Precision = 0.01f, - } + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, }, - tickRateSlider = new LabelledSliderBar + tickRateSlider = new FormSliderBar { - Label = EditorSetupStrings.TickRate, - FixedLabelWidth = LABEL_WIDTH, - Description = EditorSetupStrings.TickRateDescription, + Caption = EditorSetupStrings.TickRate, + HintText = EditorSetupStrings.TickRateDescription, Current = new BindableDouble(Beatmap.Difficulty.SliderTickRate) { Default = 1, MinValue = 1, MaxValue = 4, Precision = 1, - } + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, }, }; - foreach (var item in Children.OfType>()) + foreach (var item in Children.OfType>()) item.Current.ValueChanged += _ => updateValues(); - foreach (var item in Children.OfType>()) + foreach (var item in Children.OfType>()) item.Current.ValueChanged += _ => updateValues(); } diff --git a/osu.Game/Screens/Edit/Setup/LabelledRomanisedTextBox.cs b/osu.Game/Screens/Edit/Setup/LabelledRomanisedTextBox.cs deleted file mode 100644 index 85c697bf14..0000000000 --- a/osu.Game/Screens/Edit/Setup/LabelledRomanisedTextBox.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Beatmaps; -using osu.Game.Graphics.UserInterface; -using osu.Game.Graphics.UserInterfaceV2; - -namespace osu.Game.Screens.Edit.Setup -{ - internal partial class LabelledRomanisedTextBox : LabelledTextBox - { - protected override OsuTextBox CreateTextBox() => new RomanisedTextBox(); - - private partial class RomanisedTextBox : OsuTextBox - { - protected override bool AllowIme => false; - - protected override bool CanAddCharacter(char character) - => MetadataUtils.IsRomanised(character); - } - } -} diff --git a/osu.Game/Screens/Edit/Setup/MetadataSection.cs b/osu.Game/Screens/Edit/Setup/MetadataSection.cs index 19071dc806..20c0a74d84 100644 --- a/osu.Game/Screens/Edit/Setup/MetadataSection.cs +++ b/osu.Game/Screens/Edit/Setup/MetadataSection.cs @@ -14,16 +14,16 @@ namespace osu.Game.Screens.Edit.Setup { public partial class MetadataSection : SetupSection { - protected LabelledTextBox ArtistTextBox = null!; - protected LabelledTextBox RomanisedArtistTextBox = null!; + protected FormTextBox ArtistTextBox = null!; + protected FormTextBox RomanisedArtistTextBox = null!; - protected LabelledTextBox TitleTextBox = null!; - protected LabelledTextBox RomanisedTitleTextBox = null!; + protected FormTextBox TitleTextBox = null!; + protected FormTextBox RomanisedTitleTextBox = null!; - private LabelledTextBox creatorTextBox = null!; - private LabelledTextBox difficultyTextBox = null!; - private LabelledTextBox sourceTextBox = null!; - private LabelledTextBox tagsTextBox = null!; + private FormTextBox creatorTextBox = null!; + private FormTextBox difficultyTextBox = null!; + private FormTextBox sourceTextBox = null!; + private FormTextBox tagsTextBox = null!; public override LocalisableString Title => EditorSetupStrings.MetadataHeader; @@ -34,33 +34,26 @@ namespace osu.Game.Screens.Edit.Setup Children = new[] { - ArtistTextBox = createTextBox(EditorSetupStrings.Artist, + ArtistTextBox = createTextBox(EditorSetupStrings.Artist, !string.IsNullOrEmpty(metadata.ArtistUnicode) ? metadata.ArtistUnicode : metadata.Artist), - RomanisedArtistTextBox = createTextBox(EditorSetupStrings.RomanisedArtist, + RomanisedArtistTextBox = createTextBox(EditorSetupStrings.RomanisedArtist, !string.IsNullOrEmpty(metadata.Artist) ? metadata.Artist : MetadataUtils.StripNonRomanisedCharacters(metadata.ArtistUnicode)), - - Empty(), - - TitleTextBox = createTextBox(EditorSetupStrings.Title, + TitleTextBox = createTextBox(EditorSetupStrings.Title, !string.IsNullOrEmpty(metadata.TitleUnicode) ? metadata.TitleUnicode : metadata.Title), - RomanisedTitleTextBox = createTextBox(EditorSetupStrings.RomanisedTitle, + RomanisedTitleTextBox = createTextBox(EditorSetupStrings.RomanisedTitle, !string.IsNullOrEmpty(metadata.Title) ? metadata.Title : MetadataUtils.StripNonRomanisedCharacters(metadata.ArtistUnicode)), - - Empty(), - - creatorTextBox = createTextBox(EditorSetupStrings.Creator, metadata.Author.Username), - difficultyTextBox = createTextBox(EditorSetupStrings.DifficultyName, Beatmap.BeatmapInfo.DifficultyName), - sourceTextBox = createTextBox(BeatmapsetsStrings.ShowInfoSource, metadata.Source), - tagsTextBox = createTextBox(BeatmapsetsStrings.ShowInfoTags, metadata.Tags) + creatorTextBox = createTextBox(EditorSetupStrings.Creator, metadata.Author.Username), + difficultyTextBox = createTextBox(EditorSetupStrings.DifficultyName, Beatmap.BeatmapInfo.DifficultyName), + sourceTextBox = createTextBox(BeatmapsetsStrings.ShowInfoSource, metadata.Source), + tagsTextBox = createTextBox(BeatmapsetsStrings.ShowInfoTags, metadata.Tags) }; } private TTextBox createTextBox(LocalisableString label, string initialValue) - where TTextBox : LabelledTextBox, new() + where TTextBox : FormTextBox, new() => new TTextBox { - Label = label, - FixedLabelWidth = LABEL_WIDTH, + Caption = label, Current = { Value = initialValue }, TabbableContentContainer = this }; @@ -75,13 +68,13 @@ namespace osu.Game.Screens.Edit.Setup ArtistTextBox.Current.BindValueChanged(artist => transferIfRomanised(artist.NewValue, RomanisedArtistTextBox)); TitleTextBox.Current.BindValueChanged(title => transferIfRomanised(title.NewValue, RomanisedTitleTextBox)); - foreach (var item in Children.OfType()) + foreach (var item in Children.OfType()) item.OnCommit += onCommit; updateReadOnlyState(); } - private void transferIfRomanised(string value, LabelledTextBox target) + private void transferIfRomanised(string value, FormTextBox target) { if (MetadataUtils.IsRomanised(value)) target.Current.Value = value; @@ -119,5 +112,18 @@ namespace osu.Game.Screens.Edit.Setup Beatmap.SaveState(); } + + private partial class FormRomanisedTextBox : FormTextBox + { + internal override InnerTextBox CreateTextBox() => new RomanisedTextBox(); + + private partial class RomanisedTextBox : InnerTextBox + { + protected override bool AllowIme => false; + + protected override bool CanAddCharacter(char character) + => MetadataUtils.IsRomanised(character); + } + } } } diff --git a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs index f6d20319cb..845c21b598 100644 --- a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs +++ b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs @@ -7,15 +7,16 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Overlays; using osu.Game.Localisation; namespace osu.Game.Screens.Edit.Setup { - internal partial class ResourcesSection : SetupSection + public partial class ResourcesSection : SetupSection { - private LabelledFileChooser audioTrackChooser = null!; - private LabelledFileChooser backgroundChooser = null!; + private FormFileSelector audioTrackChooser = null!; + private FormFileSelector backgroundChooser = null!; public override LocalisableString Title => EditorSetupStrings.ResourcesHeader; @@ -34,28 +35,33 @@ namespace osu.Game.Screens.Edit.Setup [Resolved] private Editor? editor { get; set; } - [Resolved] - private SetupScreenHeader header { get; set; } = null!; + private SetupScreenHeaderBackground headerBackground = null!; [BackgroundDependencyLoader] private void load() { + headerBackground = new SetupScreenHeaderBackground + { + RelativeSizeAxes = Axes.X, + Height = 110, + }; + Children = new Drawable[] { - backgroundChooser = new LabelledFileChooser(".jpg", ".jpeg", ".png") + backgroundChooser = new FormFileSelector(".jpg", ".jpeg", ".png") { - Label = GameplaySettingsStrings.BackgroundHeader, - FixedLabelWidth = LABEL_WIDTH, - TabbableContentContainer = this + Caption = GameplaySettingsStrings.BackgroundHeader, + PlaceholderText = EditorSetupStrings.ClickToSelectBackground, }, - audioTrackChooser = new LabelledFileChooser(".mp3", ".ogg") + audioTrackChooser = new FormFileSelector(".mp3", ".ogg") { - Label = EditorSetupStrings.AudioTrack, - FixedLabelWidth = LABEL_WIDTH, - TabbableContentContainer = this + Caption = EditorSetupStrings.AudioTrack, + PlaceholderText = EditorSetupStrings.ClickToSelectTrack, }, }; + backgroundChooser.PreviewContainer.Add(headerBackground); + if (!string.IsNullOrEmpty(working.Value.Metadata.BackgroundFile)) backgroundChooser.Current.Value = new FileInfo(working.Value.Metadata.BackgroundFile); @@ -64,8 +70,6 @@ namespace osu.Game.Screens.Edit.Setup backgroundChooser.Current.BindValueChanged(backgroundChanged); audioTrackChooser.Current.BindValueChanged(audioTrackChanged); - - updatePlaceholderText(); } public bool ChangeBackgroundImage(FileInfo source) @@ -92,7 +96,7 @@ namespace osu.Game.Screens.Edit.Setup editorBeatmap.SaveState(); working.Value.Metadata.BackgroundFile = destination.Name; - header.Background.UpdateBackground(); + headerBackground.UpdateBackground(); editor?.ApplyToBackground(bg => bg.RefreshBackground()); @@ -132,22 +136,12 @@ namespace osu.Game.Screens.Edit.Setup { if (file.NewValue == null || !ChangeBackgroundImage(file.NewValue)) backgroundChooser.Current.Value = file.OldValue; - - updatePlaceholderText(); } private void audioTrackChanged(ValueChangedEvent file) { if (file.NewValue == null || !ChangeAudioTrack(file.NewValue)) audioTrackChooser.Current.Value = file.OldValue; - - updatePlaceholderText(); - } - - private void updatePlaceholderText() - { - audioTrackChooser.Text = audioTrackChooser.Current.Value?.Name ?? EditorSetupStrings.ClickToSelectTrack; - backgroundChooser.Text = backgroundChooser.Current.Value?.Name ?? EditorSetupStrings.ClickToSelectBackground; } } } diff --git a/osu.Game/Screens/Edit/Setup/SetupScreen.cs b/osu.Game/Screens/Edit/Setup/SetupScreen.cs index 17bbc7daa2..f8c4998263 100644 --- a/osu.Game/Screens/Edit/Setup/SetupScreen.cs +++ b/osu.Game/Screens/Edit/Setup/SetupScreen.cs @@ -1,55 +1,81 @@ // 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.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Screens; using osu.Game.Graphics.Containers; using osu.Game.Overlays; +using osuTK; namespace osu.Game.Screens.Edit.Setup { public partial class SetupScreen : EditorScreen { - [Cached] - private SectionsContainer sections { get; } = new SetupScreenSectionsContainer(); - - [Cached] - private SetupScreenHeader header = new SetupScreenHeader(); + public const float COLUMN_WIDTH = 450; + public const float SPACING = 28; + public const float MAX_WIDTH = 2 * COLUMN_WIDTH + SPACING; public SetupScreen() : base(EditorScreenMode.SongSetup) { } + private OsuScrollContainer scroll = null!; + private FillFlowContainer flow = null!; + [BackgroundDependencyLoader] private void load(EditorBeatmap beatmap, OverlayColourProvider colourProvider) { var ruleset = beatmap.BeatmapInfo.Ruleset.CreateInstance(); - List sectionsEnumerable = - [ - new ResourcesSection(), - new MetadataSection() - ]; - - sectionsEnumerable.AddRange(ruleset.CreateEditorSetupSections()); - sectionsEnumerable.Add(new DesignSection()); - - Add(new Box + Children = new Drawable[] { - Colour = colourProvider.Background3, - RelativeSizeAxes = Axes.Both, - }); + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background3, + }, + scroll = new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(15), + Child = flow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Full, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Spacing = new Vector2(25), + ChildrenEnumerable = ruleset.CreateEditorSetupSections().Select(section => section.With(s => + { + s.Width = 450; + s.Anchor = Anchor.TopCentre; + s.Origin = Anchor.TopCentre; + })), + } + } + }; + } - Add(sections.With(s => + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + if (scroll.DrawWidth > MAX_WIDTH) { - s.RelativeSizeAxes = Axes.Both; - s.ChildrenEnumerable = sectionsEnumerable; - s.FixedHeader = header; - })); + flow.RelativeSizeAxes = Axes.None; + flow.Width = MAX_WIDTH; + } + else + { + flow.RelativeSizeAxes = Axes.X; + flow.Width = 1; + } } public override void OnExiting(ScreenExitEvent e) @@ -62,19 +88,5 @@ namespace osu.Game.Screens.Edit.Setup // (and potentially block the exit procedure for save). GetContainingFocusManager()?.TriggerFocusContention(this); } - - private partial class SetupScreenSectionsContainer : SectionsContainer - { - protected override UserTrackingScrollContainer CreateScrollContainer() - { - var scrollContainer = base.CreateScrollContainer(); - - // Workaround for masking issues (see https://github.com/ppy/osu-framework/issues/1675#issuecomment-910023157) - // Note that this actually causes the full scroll range to be reduced by 2px at the bottom, but it's not really noticeable. - scrollContainer.Margin = new MarginPadding { Top = 2 }; - - return scrollContainer; - } - } } } diff --git a/osu.Game/Screens/Edit/Setup/SetupScreenHeaderBackground.cs b/osu.Game/Screens/Edit/Setup/SetupScreenHeaderBackground.cs index 033e5361bb..5f3e6eb469 100644 --- a/osu.Game/Screens/Edit/Setup/SetupScreenHeaderBackground.cs +++ b/osu.Game/Screens/Edit/Setup/SetupScreenHeaderBackground.cs @@ -29,7 +29,8 @@ namespace osu.Game.Screens.Edit.Setup InternalChild = content = new Container { RelativeSizeAxes = Axes.Both, - Masking = true + Masking = true, + CornerRadius = 3.5f, }; } diff --git a/osu.Game/Screens/Edit/Setup/SetupSection.cs b/osu.Game/Screens/Edit/Setup/SetupSection.cs index 5f676798f1..bd1eb51b48 100644 --- a/osu.Game/Screens/Edit/Setup/SetupSection.cs +++ b/osu.Game/Screens/Edit/Setup/SetupSection.cs @@ -6,7 +6,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Localisation; using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osuTK; @@ -34,33 +34,25 @@ namespace osu.Game.Screens.Edit.Setup [BackgroundDependencyLoader] private void load() { - RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; - Padding = new MarginPadding - { - Vertical = 10, - Horizontal = 100 - }; - InternalChild = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Spacing = new Vector2(20), + Spacing = new Vector2(10), Direction = FillDirection.Vertical, Children = new Drawable[] { - new OsuSpriteText + new SectionHeader(Title) { - Font = OsuFont.GetFont(weight: FontWeight.Bold), - Text = Title + Margin = new MarginPadding { Left = 9, }, }, flow = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Spacing = new Vector2(10), + Spacing = new Vector2(5), Direction = FillDirection.Vertical, } } 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/ButtonSystem.cs b/osu.Game/Screens/Menu/ButtonSystem.cs index 0997ab8003..41920605b0 100644 --- a/osu.Game/Screens/Menu/ButtonSystem.cs +++ b/osu.Game/Screens/Menu/ButtonSystem.cs @@ -41,7 +41,7 @@ namespace osu.Game.Screens.Menu public Action? OnEditBeatmap; public Action? OnEditSkin; - public Action? OnExit; + public Action? OnExit; public Action? OnBeatmapListing; public Action? OnSolo; public Action? OnSettings; @@ -104,11 +104,11 @@ namespace osu.Game.Screens.Menu buttonArea.AddRange(new Drawable[] { - new MainMenuButton(ButtonSystemStrings.Settings, string.Empty, OsuIcon.Settings, new Color4(85, 85, 85, 255), _ => OnSettings?.Invoke(), Key.O, Key.S) + new MainMenuButton(ButtonSystemStrings.Settings, string.Empty, OsuIcon.Settings, new Color4(85, 85, 85, 255), (_, _) => OnSettings?.Invoke(), Key.O, Key.S) { Padding = new MarginPadding { Right = WEDGE_WIDTH }, }, - backButton = new MainMenuButton(ButtonSystemStrings.Back, @"back-to-top", OsuIcon.PrevCircle, new Color4(51, 58, 94, 255), _ => State = ButtonSystemState.TopLevel) + backButton = new MainMenuButton(ButtonSystemStrings.Back, @"back-to-top", OsuIcon.PrevCircle, new Color4(51, 58, 94, 255), (_, _) => State = ButtonSystemState.TopLevel) { Padding = new MarginPadding { Right = WEDGE_WIDTH }, VisibleStateMin = ButtonSystemState.Play, @@ -132,7 +132,7 @@ namespace osu.Game.Screens.Menu [BackgroundDependencyLoader] private void load(AudioManager audio, IdleTracker? idleTracker, GameHost host) { - buttonsPlay.Add(new MainMenuButton(ButtonSystemStrings.Solo, @"button-default-select", OsuIcon.Player, new Color4(102, 68, 204, 255), _ => OnSolo?.Invoke(), Key.P) + buttonsPlay.Add(new MainMenuButton(ButtonSystemStrings.Solo, @"button-default-select", OsuIcon.Player, new Color4(102, 68, 204, 255), (_, _) => OnSolo?.Invoke(), Key.P) { Padding = new MarginPadding { Left = WEDGE_WIDTH }, }); @@ -141,22 +141,22 @@ namespace osu.Game.Screens.Menu buttonsPlay.Add(new DailyChallengeButton(@"button-daily-select", new Color4(94, 63, 186, 255), onDailyChallenge, Key.D)); buttonsPlay.ForEach(b => b.VisibleState = ButtonSystemState.Play); - buttonsEdit.Add(new MainMenuButton(EditorStrings.BeatmapEditor.ToLower(), @"button-default-select", OsuIcon.Beatmap, new Color4(238, 170, 0, 255), _ => OnEditBeatmap?.Invoke(), Key.B, Key.E) + buttonsEdit.Add(new MainMenuButton(EditorStrings.BeatmapEditor.ToLower(), @"button-default-select", OsuIcon.Beatmap, new Color4(238, 170, 0, 255), (_, _) => OnEditBeatmap?.Invoke(), Key.B, Key.E) { Padding = new MarginPadding { Left = WEDGE_WIDTH }, }); - buttonsEdit.Add(new MainMenuButton(SkinEditorStrings.SkinEditor.ToLower(), @"button-default-select", OsuIcon.SkinB, new Color4(220, 160, 0, 255), _ => OnEditSkin?.Invoke(), Key.S)); + buttonsEdit.Add(new MainMenuButton(SkinEditorStrings.SkinEditor.ToLower(), @"button-default-select", OsuIcon.SkinB, new Color4(220, 160, 0, 255), (_, _) => OnEditSkin?.Invoke(), Key.S)); buttonsEdit.ForEach(b => b.VisibleState = ButtonSystemState.Edit); - buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Play, @"button-play-select", OsuIcon.Logo, new Color4(102, 68, 204, 255), _ => State = ButtonSystemState.Play, Key.P, Key.M, Key.L) + buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Play, @"button-play-select", OsuIcon.Logo, new Color4(102, 68, 204, 255), (_, _) => State = ButtonSystemState.Play, Key.P, Key.M, Key.L) { Padding = new MarginPadding { Left = WEDGE_WIDTH }, }); - buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Edit, @"button-play-select", OsuIcon.EditCircle, new Color4(238, 170, 0, 255), _ => State = ButtonSystemState.Edit, Key.E)); - buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Browse, @"button-default-select", OsuIcon.Beatmap, new Color4(165, 204, 0, 255), _ => OnBeatmapListing?.Invoke(), Key.B, Key.D)); + buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Edit, @"button-play-select", OsuIcon.EditCircle, new Color4(238, 170, 0, 255), (_, _) => State = ButtonSystemState.Edit, Key.E)); + buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Browse, @"button-default-select", OsuIcon.Beatmap, new Color4(165, 204, 0, 255), (_, _) => OnBeatmapListing?.Invoke(), Key.B, Key.D)); if (host.CanExit) - buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Exit, string.Empty, OsuIcon.CrossCircle, new Color4(238, 51, 153, 255), _ => OnExit?.Invoke(), Key.Q)); + buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Exit, string.Empty, OsuIcon.CrossCircle, new Color4(238, 51, 153, 255), (_, e) => OnExit?.Invoke(e), Key.Q)); buttonArea.AddRange(buttonsPlay); buttonArea.AddRange(buttonsEdit); @@ -179,7 +179,7 @@ namespace osu.Game.Screens.Menu sampleLogoSwoosh = audio.Samples.Get(@"Menu/osu-logo-swoosh"); } - private void onMultiplayer(MainMenuButton _) + private void onMultiplayer(MainMenuButton mainMenuButton, UIEvent uiEvent) { if (api.State.Value != APIState.Online) { @@ -190,7 +190,7 @@ namespace osu.Game.Screens.Menu OnMultiplayer?.Invoke(); } - private void onPlaylists(MainMenuButton _) + private void onPlaylists(MainMenuButton mainMenuButton, UIEvent uiEvent) { if (api.State.Value != APIState.Online) { @@ -201,7 +201,7 @@ namespace osu.Game.Screens.Menu OnPlaylists?.Invoke(); } - private void onDailyChallenge(MainMenuButton button) + private void onDailyChallenge(MainMenuButton button, UIEvent uiEvent) { if (api.State.Value != APIState.Online) { diff --git a/osu.Game/Screens/Menu/DailyChallengeButton.cs b/osu.Game/Screens/Menu/DailyChallengeButton.cs index 4dbebf0ae9..be22fc3c30 100644 --- a/osu.Game/Screens/Menu/DailyChallengeButton.cs +++ b/osu.Game/Screens/Menu/DailyChallengeButton.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; using osu.Framework.Threading; using osu.Framework.Utils; using osu.Game.Beatmaps.Drawables; @@ -50,7 +51,7 @@ namespace osu.Game.Screens.Menu [Resolved] private SessionStatics statics { get; set; } = null!; - public DailyChallengeButton(string sampleName, Color4 colour, Action? clickAction = null, params Key[] triggerKeys) + public DailyChallengeButton(string sampleName, Color4 colour, Action? clickAction = null, params Key[] triggerKeys) : base(ButtonSystemStrings.DailyChallenge, sampleName, OsuIcon.DailyChallenge, colour, clickAction, triggerKeys) { BaseSize = new Vector2(ButtonSystem.BUTTON_WIDTH * 1.3f, ButtonArea.BUTTON_AREA_HEIGHT); @@ -155,15 +156,15 @@ namespace osu.Game.Screens.Menu Room = room; cover.OnlineInfo = TooltipContent = room.Playlist.FirstOrDefault()?.Beatmap.BeatmapSet as APIBeatmapSet; - if (room.StartDate.Value != null && room.RoomID.Value != lastDailyChallengeRoomID) + if (room.StartDate != null && room.RoomID != lastDailyChallengeRoomID) { - lastDailyChallengeRoomID = room.RoomID.Value; + lastDailyChallengeRoomID = room.RoomID; // new challenge is live, reset intro played static. statics.SetValue(Static.DailyChallengeIntroPlayed, false); // we only want to notify the user if the new challenge just went live. - if (Math.Abs((DateTimeOffset.Now - room.StartDate.Value!.Value).TotalSeconds) < 1800) + if (Math.Abs((DateTimeOffset.Now - room.StartDate.Value).TotalSeconds) < 1800) notificationOverlay?.Post(new NewDailyChallengeNotification(room)); } @@ -179,7 +180,7 @@ namespace osu.Game.Screens.Menu if (Room == null) return; - var remaining = (Room.EndDate.Value - DateTimeOffset.Now) ?? TimeSpan.Zero; + var remaining = (Room.EndDate - DateTimeOffset.Now) ?? TimeSpan.Zero; if (remaining <= TimeSpan.Zero) { diff --git a/osu.Game/Screens/Menu/MainMenu.cs b/osu.Game/Screens/Menu/MainMenu.cs index 346bdcb751..35c6bab81b 100644 --- a/osu.Game/Screens/Menu/MainMenu.cs +++ b/osu.Game/Screens/Menu/MainMenu.cs @@ -153,9 +153,9 @@ namespace osu.Game.Screens.Menu else this.Push(new DailyChallengeIntro(room)); }, - OnExit = () => + OnExit = e => { - exitConfirmedViaHoldOrClick = true; + exitConfirmedViaHoldOrClick = e is MouseEvent; this.Exit(); } } diff --git a/osu.Game/Screens/Menu/MainMenuButton.cs b/osu.Game/Screens/Menu/MainMenuButton.cs index 4df5e6d309..f8824795d8 100644 --- a/osu.Game/Screens/Menu/MainMenuButton.cs +++ b/osu.Game/Screens/Menu/MainMenuButton.cs @@ -64,7 +64,7 @@ namespace osu.Game.Screens.Menu protected Vector2 BaseSize { get; init; } = new Vector2(ButtonSystem.BUTTON_WIDTH, ButtonArea.BUTTON_AREA_HEIGHT); - private readonly Action? clickAction; + private readonly Action? clickAction; private readonly Container background; private readonly Drawable backgroundContent; @@ -84,7 +84,7 @@ namespace osu.Game.Screens.Menu public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => background.ReceivePositionalInputAt(screenSpacePos); - public MainMenuButton(LocalisableString text, string sampleName, IconUsage symbol, Color4 colour, Action? clickAction = null, params Key[] triggerKeys) + public MainMenuButton(LocalisableString text, string sampleName, IconUsage symbol, Color4 colour, Action? clickAction = null, params Key[] triggerKeys) { this.sampleName = sampleName; this.clickAction = clickAction; @@ -263,7 +263,7 @@ namespace osu.Game.Screens.Menu protected override bool OnClick(ClickEvent e) { - trigger(); + trigger(e); return true; } @@ -274,19 +274,19 @@ namespace osu.Game.Screens.Menu if (TriggerKeys.Contains(e.Key)) { - trigger(); + trigger(e); return true; } return false; } - private void trigger() + private void trigger(UIEvent e) { sampleChannel = sampleClick?.GetChannel(); sampleChannel?.Play(); - clickAction?.Invoke(this); + clickAction?.Invoke(this, e); boxHoverLayer.ClearTransforms(); boxHoverLayer.Alpha = 0.9f; diff --git a/osu.Game/Screens/Menu/MenuTip.cs b/osu.Game/Screens/Menu/MenuTip.cs index da349373c3..58eeb7e82d 100644 --- a/osu.Game/Screens/Menu/MenuTip.cs +++ b/osu.Game/Screens/Menu/MenuTip.cs @@ -118,7 +118,6 @@ namespace osu.Game.Screens.Menu "You can create mod presets to make toggling your favorite mod combinations easier!", "Many mods have customisation settings that drastically change how they function. Click the Mod Customisation button in mod select to view settings!", "Press Ctrl-Shift-R to switch to a random skin!", - "Press Ctrl-Shift-F to toggle the FPS Counter. But make sure not to pay too much attention to it!", "While watching a replay, press Ctrl-H to toggle replay settings!", "You can easily copy the mods from scores on a leaderboard by right-clicking on them!", "Ctrl-Enter at song select will start a beatmap in autoplay mode!" diff --git a/osu.Game/Screens/Menu/SongTicker.cs b/osu.Game/Screens/Menu/SongTicker.cs index 3bdc0efe19..3aac365eee 100644 --- a/osu.Game/Screens/Menu/SongTicker.cs +++ b/osu.Game/Screens/Menu/SongTicker.cs @@ -8,6 +8,9 @@ using osu.Game.Graphics.Sprites; using osuTK; using osu.Game.Graphics; using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; using osu.Framework.Localisation; using osu.Game.Beatmaps; @@ -27,26 +30,60 @@ namespace osu.Game.Screens.Menu public SongTicker() { AutoSizeAxes = Axes.Both; - Child = new FillFlowContainer + InternalChildren = new Drawable[] { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 3), - Children = new Drawable[] + new Container { - title = new OsuSpriteText + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Position = new Vector2(5, -5), + Padding = new MarginPadding(-5), + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Font = OsuFont.GetFont(size: 24, weight: FontWeight.Light, italics: true) - }, - artist = new OsuSpriteText - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Font = OsuFont.GetFont(size: 16) + new CircularContainer + { + RelativeSizeAxes = Axes.Both, + Masking = true, + EdgeEffect = new EdgeEffectParameters + { + Radius = 75, + Type = EdgeEffectType.Shadow, + Colour = OsuColour.Gray(0.04f).Opacity(0.3f), + }, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + AlwaysPresent = true, + Alpha = 0, + }, + } + }, } - } + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 3), + Children = new Drawable[] + { + title = new OsuSpriteText + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Font = OsuFont.GetFont(size: 24, weight: FontWeight.Light, italics: true) + }, + artist = new OsuSpriteText + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Font = OsuFont.GetFont(size: 16) + } + } + }, }; } 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/Components/BeatmapTitle.cs b/osu.Game/Screens/OnlinePlay/Components/BeatmapTitle.cs index 7c57f5b4f5..5c8ac5ce73 100644 --- a/osu.Game/Screens/OnlinePlay/Components/BeatmapTitle.cs +++ b/osu.Game/Screens/OnlinePlay/Components/BeatmapTitle.cs @@ -1,32 +1,40 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.ComponentModel; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Online.Chat; +using osu.Game.Online.Rooms; namespace osu.Game.Screens.OnlinePlay.Components { - public partial class BeatmapTitle : OnlinePlayComposite + public partial class BeatmapTitle : CompositeDrawable { + private readonly Room room; private readonly LinkFlowContainer textFlow; - public BeatmapTitle() - { - AutoSizeAxes = Axes.Both; + [Resolved] + private OsuColour colours { get; set; } = null!; + public BeatmapTitle(Room room) + { + this.room = room; + + AutoSizeAxes = Axes.Both; InternalChild = textFlow = new LinkFlowContainer { AutoSizeAxes = Axes.Both }; } - [BackgroundDependencyLoader] - private void load() + protected override void LoadComplete() { - Playlist.CollectionChanged += (_, _) => updateText(); + base.LoadComplete(); + room.PropertyChanged += onRoomPropertyChanged; updateText(); } @@ -46,8 +54,11 @@ namespace osu.Game.Screens.OnlinePlay.Components } } - [Resolved] - private OsuColour colours { get; set; } = null!; + private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Room.Playlist)) + updateText(); + } private void updateText() { @@ -56,7 +67,7 @@ namespace osu.Game.Screens.OnlinePlay.Components textFlow.Clear(); - var beatmap = Playlist.FirstOrDefault()?.Beatmap; + var beatmap = room.Playlist.FirstOrDefault()?.Beatmap; if (beatmap == null) { @@ -78,5 +89,11 @@ namespace osu.Game.Screens.OnlinePlay.Components textFlow.AddLink(title, LinkAction.OpenBeatmap, beatmap.OnlineID.ToString(), "Open beatmap"); } } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + room.PropertyChanged -= onRoomPropertyChanged; + } } } diff --git a/osu.Game/Screens/OnlinePlay/Components/ListingPollingComponent.cs b/osu.Game/Screens/OnlinePlay/Components/ListingPollingComponent.cs index 4b38ea68b3..b213d424df 100644 --- a/osu.Game/Screens/OnlinePlay/Components/ListingPollingComponent.cs +++ b/osu.Game/Screens/OnlinePlay/Components/ListingPollingComponent.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 System.Threading.Tasks; using osu.Framework.Allocation; @@ -20,7 +18,7 @@ namespace osu.Game.Screens.OnlinePlay.Components public IBindable InitialRoomsReceived => initialRoomsReceived; private readonly Bindable initialRoomsReceived = new Bindable(); - public readonly Bindable Filter = new Bindable(); + public readonly Bindable Filter = new Bindable(); [BackgroundDependencyLoader] private void load() @@ -35,7 +33,7 @@ namespace osu.Game.Screens.OnlinePlay.Components }); } - private GetRoomsRequest lastPollRequest; + private GetRoomsRequest? lastPollRequest; protected override Task Poll() { @@ -53,19 +51,16 @@ namespace osu.Game.Screens.OnlinePlay.Components req.Success += result => { - result = result.Where(r => r.Category.Value != RoomCategory.DailyChallenge).ToList(); + result = result.Where(r => r.Category != RoomCategory.DailyChallenge).ToList(); foreach (var existing in RoomManager.Rooms.ToArray()) { - if (result.All(r => r.RoomID.Value != existing.RoomID.Value)) + if (result.All(r => r.RoomID != existing.RoomID)) RoomManager.RemoveRoom(existing); } foreach (var incoming in result) - { - incoming.RemoveExpiredPlaylistItems(); RoomManager.AddOrUpdateRoom(incoming); - } initialRoomsReceived.Value = true; tcs.SetResult(true); diff --git a/osu.Game/Screens/OnlinePlay/Components/MatchBeatmapDetailArea.cs b/osu.Game/Screens/OnlinePlay/Components/MatchBeatmapDetailArea.cs index b0ede8d9b5..1f2b2e3fc2 100644 --- a/osu.Game/Screens/OnlinePlay/Components/MatchBeatmapDetailArea.cs +++ b/osu.Game/Screens/OnlinePlay/Components/MatchBeatmapDetailArea.cs @@ -1,12 +1,9 @@ // 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.ComponentModel; using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics.UserInterfaceV2; @@ -14,23 +11,22 @@ using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Screens.Select; using osuTK; +using Container = osu.Framework.Graphics.Containers.Container; namespace osu.Game.Screens.OnlinePlay.Components { public partial class MatchBeatmapDetailArea : BeatmapDetailArea { - public Action CreateNewItem; - - public readonly Bindable SelectedItem = new Bindable(); - - [Resolved(typeof(Room))] - protected BindableList Playlist { get; private set; } + public Action? CreateNewItem; + private readonly Room room; private readonly GridContainer playlistArea; private readonly DrawableRoomPlaylist playlist; - public MatchBeatmapDetailArea() + public MatchBeatmapDetailArea(Room room) { + this.room = room; + Add(playlistArea = new GridContainer { RelativeSizeAxes = Axes.Both, @@ -72,10 +68,21 @@ namespace osu.Game.Screens.OnlinePlay.Components { base.LoadComplete(); - playlist.Items.BindTo(Playlist); - playlist.SelectedItem.BindTo(SelectedItem); + playlist.Items.BindCollectionChanged((_, __) => room.Playlist = playlist.Items.ToArray()); + + room.PropertyChanged += onRoomPropertyChanged; + updateRoomPlaylist(); } + private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Room.Playlist)) + updateRoomPlaylist(); + } + + private void updateRoomPlaylist() + => playlist.Items.ReplaceRange(0, playlist.Items.Count, room.Playlist); + protected override void OnTabChanged(BeatmapDetailAreaTabItem tab, bool selectedMods) { base.OnTabChanged(tab, selectedMods); @@ -93,5 +100,11 @@ namespace osu.Game.Screens.OnlinePlay.Components } protected override BeatmapDetailAreaTabItem[] CreateTabItems() => base.CreateTabItems().Prepend(new BeatmapDetailAreaPlaylistTabItem()).ToArray(); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + room.PropertyChanged -= onRoomPropertyChanged; + } } } diff --git a/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundSprite.cs b/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundSprite.cs deleted file mode 100644 index 0d4cd30090..0000000000 --- a/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundSprite.cs +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Game.Beatmaps.Drawables; -using osu.Game.Online.Rooms; - -namespace osu.Game.Screens.OnlinePlay.Components -{ - public partial class OnlinePlayBackgroundSprite : OnlinePlayComposite - { - protected readonly BeatmapSetCoverType BeatmapSetCoverType; - private UpdateableBeatmapBackgroundSprite sprite; - - public OnlinePlayBackgroundSprite(BeatmapSetCoverType beatmapSetCoverType = BeatmapSetCoverType.Cover) - { - BeatmapSetCoverType = beatmapSetCoverType; - } - - [BackgroundDependencyLoader] - private void load() - { - InternalChild = sprite = CreateBackgroundSprite(); - - CurrentPlaylistItem.BindValueChanged(_ => updateBeatmap()); - Playlist.CollectionChanged += (_, _) => updateBeatmap(); - - updateBeatmap(); - } - - private void updateBeatmap() - { - sprite.Beatmap.Value = CurrentPlaylistItem.Value?.Beatmap ?? Playlist.GetCurrentItem()?.Beatmap; - } - - protected virtual UpdateableBeatmapBackgroundSprite CreateBackgroundSprite() => new UpdateableBeatmapBackgroundSprite(BeatmapSetCoverType) { RelativeSizeAxes = Axes.Both }; - } -} diff --git a/osu.Game/Screens/OnlinePlay/Components/OverlinedHeader.cs b/osu.Game/Screens/OnlinePlay/Components/OverlinedHeader.cs index 09a3602cdd..d9cdcac7d7 100644 --- a/osu.Game/Screens/OnlinePlay/Components/OverlinedHeader.cs +++ b/osu.Game/Screens/OnlinePlay/Components/OverlinedHeader.cs @@ -16,7 +16,7 @@ namespace osu.Game.Screens.OnlinePlay.Components /// /// A header used in the multiplayer interface which shows text / details beneath a line. /// - public partial class OverlinedHeader : OnlinePlayComposite + public partial class OverlinedHeader : CompositeDrawable { private bool showLine = true; diff --git a/osu.Game/Screens/OnlinePlay/Components/OverlinedPlaylistHeader.cs b/osu.Game/Screens/OnlinePlay/Components/OverlinedPlaylistHeader.cs index dd728e460b..55d9f273e9 100644 --- a/osu.Game/Screens/OnlinePlay/Components/OverlinedPlaylistHeader.cs +++ b/osu.Game/Screens/OnlinePlay/Components/OverlinedPlaylistHeader.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.ComponentModel; using osu.Framework.Allocation; using osu.Game.Online.Rooms; using osu.Game.Rulesets; @@ -9,19 +10,38 @@ namespace osu.Game.Screens.OnlinePlay.Components { public partial class OverlinedPlaylistHeader : OverlinedHeader { + private readonly Room room; + [Resolved] private RulesetStore rulesets { get; set; } = null!; - public OverlinedPlaylistHeader() + public OverlinedPlaylistHeader(Room room) : base("Playlist") { + this.room = room; } protected override void LoadComplete() { base.LoadComplete(); - Playlist.BindCollectionChanged((_, _) => Details.Value = Playlist.GetTotalDuration(rulesets), true); + room.PropertyChanged += onRoomPropertyChanged; + updateDuration(); + } + + private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Room.Playlist)) + updateDuration(); + } + + private void updateDuration() + => Details.Value = room.Playlist.GetTotalDuration(rulesets); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + room.PropertyChanged -= onRoomPropertyChanged; } } } diff --git a/osu.Game/Screens/OnlinePlay/Components/ParticipantCountDisplay.cs b/osu.Game/Screens/OnlinePlay/Components/ParticipantCountDisplay.cs index 9f7e700ab3..db9cf3f92d 100644 --- a/osu.Game/Screens/OnlinePlay/Components/ParticipantCountDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Components/ParticipantCountDisplay.cs @@ -1,33 +1,36 @@ // 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.ComponentModel; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Online.Rooms; namespace osu.Game.Screens.OnlinePlay.Components { - public partial class ParticipantCountDisplay : OnlinePlayComposite + public partial class ParticipantCountDisplay : CompositeDrawable { private const float text_size = 30; private const float transition_duration = 100; - private OsuSpriteText slash, maxText; + private readonly Room room; - public ParticipantCountDisplay() + private OsuSpriteText slash = null!; + private OsuSpriteText maxText = null!; + private OsuSpriteText count = null!; + + public ParticipantCountDisplay(Room room) { + this.room = room; AutoSizeAxes = Axes.Both; } [BackgroundDependencyLoader] private void load() { - OsuSpriteText count; - InternalChild = new FillFlowContainer { AutoSizeAxes = Axes.Both, @@ -50,14 +53,33 @@ namespace osu.Game.Screens.OnlinePlay.Components }, } }; - - MaxParticipants.BindValueChanged(_ => updateMax(), true); - ParticipantCount.BindValueChanged(c => count.Text = c.NewValue.ToString("#,0"), true); } - private void updateMax() + protected override void LoadComplete() { - if (MaxParticipants.Value == null) + base.LoadComplete(); + + room.PropertyChanged += onRoomPropertyChanged; + updateRoomParticipantCount(); + } + + private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(Room.MaxParticipants): + updateRoomMaxParticipants(); + break; + + case nameof(Room.ParticipantCount): + updateRoomParticipantCount(); + break; + } + } + + private void updateRoomMaxParticipants() + { + if (room.MaxParticipants == null) { slash.FadeOut(transition_duration); maxText.FadeOut(transition_duration); @@ -65,9 +87,18 @@ namespace osu.Game.Screens.OnlinePlay.Components else { slash.FadeIn(transition_duration); - maxText.Text = MaxParticipants.Value.ToString(); + maxText.Text = room.MaxParticipants.ToString()!; maxText.FadeIn(transition_duration); } } + + private void updateRoomParticipantCount() + => count.Text = room.ParticipantCount.ToString("#,0"); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + room.PropertyChanged -= onRoomPropertyChanged; + } } } diff --git a/osu.Game/Screens/OnlinePlay/Components/ParticipantsDisplay.cs b/osu.Game/Screens/OnlinePlay/Components/ParticipantsDisplay.cs index 5128bc4c14..a12d843b0a 100644 --- a/osu.Game/Screens/OnlinePlay/Components/ParticipantsDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Components/ParticipantsDisplay.cs @@ -1,25 +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 osu.Framework.Allocation; +using System.ComponentModel; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Game.Graphics.Containers; +using osu.Game.Online.Rooms; namespace osu.Game.Screens.OnlinePlay.Components { - public partial class ParticipantsDisplay : OnlinePlayComposite + public partial class ParticipantsDisplay : CompositeDrawable { - public Bindable Details = new Bindable(); + public readonly Bindable Details = new Bindable(); - public ParticipantsDisplay(Direction direction) + private readonly Room room; + + public ParticipantsDisplay(Room room, Direction direction) { + this.room = room; OsuScrollContainer scroll; ParticipantsList list; AddInternal(scroll = new OsuScrollContainer(direction) { - Child = list = new ParticipantsList() + Child = list = new ParticipantsList(room) }); switch (direction) @@ -46,14 +51,32 @@ namespace osu.Game.Screens.OnlinePlay.Components } } - [BackgroundDependencyLoader] - private void load() + protected override void LoadComplete() { - ParticipantCount.BindValueChanged(_ => setParticipantCount()); - MaxParticipants.BindValueChanged(_ => setParticipantCount(), true); + base.LoadComplete(); + + room.PropertyChanged += onRoomPropertyChanged; + updateRoomParticipantCount(); } - private void setParticipantCount() => - Details.Value = MaxParticipants.Value != null ? $"{ParticipantCount.Value}/{MaxParticipants.Value}" : ParticipantCount.Value.ToString(); + private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(Room.MaxParticipants): + case nameof(Room.ParticipantCount): + updateRoomParticipantCount(); + break; + } + } + + private void updateRoomParticipantCount() + => Details.Value = room.MaxParticipants != null ? $"{room.ParticipantCount}/{room.MaxParticipants}" : room.ParticipantCount.ToString(); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + room.PropertyChanged -= onRoomPropertyChanged; + } } } diff --git a/osu.Game/Screens/OnlinePlay/Components/ParticipantsList.cs b/osu.Game/Screens/OnlinePlay/Components/ParticipantsList.cs index c4aefe4f99..79084a5285 100644 --- a/osu.Game/Screens/OnlinePlay/Components/ParticipantsList.cs +++ b/osu.Game/Screens/OnlinePlay/Components/ParticipantsList.cs @@ -1,21 +1,20 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - -using osu.Framework.Allocation; +using System.ComponentModel; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Threading; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Rooms; using osu.Game.Users.Drawables; using osuTK; namespace osu.Game.Screens.OnlinePlay.Components { - public partial class ParticipantsList : OnlinePlayComposite + public partial class ParticipantsList : CompositeDrawable { public const float TILE_SIZE = 35; @@ -57,15 +56,29 @@ namespace osu.Game.Screens.OnlinePlay.Components } } - [BackgroundDependencyLoader] - private void load() + private readonly Room room; + + public ParticipantsList(Room room) { - RecentParticipants.CollectionChanged += (_, _) => updateParticipants(); + this.room = room; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + room.PropertyChanged += onRoomPropertyChanged; updateParticipants(); } - private ScheduledDelegate scheduledUpdate; - private FillFlowContainer tiles; + private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Room.RecentParticipants)) + updateParticipants(); + } + + private ScheduledDelegate? scheduledUpdate; + private FillFlowContainer? tiles; private void updateParticipants() { @@ -83,8 +96,8 @@ namespace osu.Game.Screens.OnlinePlay.Components Spacing = Vector2.One }; - for (int i = 0; i < RecentParticipants.Count; i++) - tiles.Add(new UserTile { User = RecentParticipants[i] }); + for (int i = 0; i < room.RecentParticipants.Count; i++) + tiles.Add(new UserTile { User = room.RecentParticipants[i] }); AddInternal(tiles); @@ -92,9 +105,15 @@ namespace osu.Game.Screens.OnlinePlay.Components }); } + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + room.PropertyChanged -= onRoomPropertyChanged; + } + private partial class UserTile : CompositeDrawable { - public APIUser User + public APIUser? User { get => avatar.User; set => avatar.User = value; diff --git a/osu.Game/Screens/OnlinePlay/Components/RoomLocalUserInfo.cs b/osu.Game/Screens/OnlinePlay/Components/RoomLocalUserInfo.cs index 0c3b53266c..39b5edbd26 100644 --- a/osu.Game/Screens/OnlinePlay/Components/RoomLocalUserInfo.cs +++ b/osu.Game/Screens/OnlinePlay/Components/RoomLocalUserInfo.cs @@ -1,26 +1,28 @@ // 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.ComponentModel; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Online.Rooms; namespace osu.Game.Screens.OnlinePlay.Components { - public partial class RoomLocalUserInfo : OnlinePlayComposite + public partial class RoomLocalUserInfo : CompositeDrawable { - private OsuSpriteText attemptDisplay; + private readonly Room room; + private OsuSpriteText attemptDisplay = null!; [Resolved] - private OsuColour colours { get; set; } + private OsuColour colours { get; set; } = null!; - public RoomLocalUserInfo() + public RoomLocalUserInfo(Room room) { + this.room = room; AutoSizeAxes = Axes.Both; } @@ -45,19 +47,30 @@ namespace osu.Game.Screens.OnlinePlay.Components { base.LoadComplete(); - MaxAttempts.BindValueChanged(_ => updateAttempts()); - UserScore.BindValueChanged(_ => updateAttempts(), true); + room.PropertyChanged += onRoomPropertyChanged; + updateAttempts(); + } + + private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(Room.UserScore): + case nameof(Room.MaxAttempts): + updateAttempts(); + break; + } } private void updateAttempts() { - if (MaxAttempts.Value != null) + if (room.MaxAttempts != null) { - attemptDisplay.Text = $"Maximum attempts: {MaxAttempts.Value:N0}"; + attemptDisplay.Text = $"Maximum attempts: {room.MaxAttempts:N0}"; - if (UserScore.Value != null) + if (room.UserScore != null) { - int remaining = MaxAttempts.Value.Value - UserScore.Value.PlaylistItemAttempts.Sum(a => a.Attempts); + int remaining = room.MaxAttempts.Value - room.UserScore.PlaylistItemAttempts.Sum(a => a.Attempts); attemptDisplay.Text += $" ({remaining} remaining)"; if (remaining == 0) @@ -69,5 +82,11 @@ namespace osu.Game.Screens.OnlinePlay.Components attemptDisplay.Text = string.Empty; } } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + room.PropertyChanged -= onRoomPropertyChanged; + } } } diff --git a/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs b/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs index cb27d1ee61..73f980f0a3 100644 --- a/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs +++ b/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs @@ -1,13 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Development; @@ -20,18 +17,17 @@ namespace osu.Game.Screens.OnlinePlay.Components { public partial class RoomManager : Component, IRoomManager { - [CanBeNull] - public event Action RoomsUpdated; + public event Action? RoomsUpdated; private readonly BindableList rooms = new BindableList(); public IBindableList Rooms => rooms; - protected IBindable JoinedRoom => joinedRoom; - private readonly Bindable joinedRoom = new Bindable(); + protected IBindable JoinedRoom => joinedRoom; + private readonly Bindable joinedRoom = new Bindable(); [Resolved] - private IAPIProvider api { get; set; } + private IAPIProvider api { get; set; } = null!; public RoomManager() { @@ -44,9 +40,9 @@ namespace osu.Game.Screens.OnlinePlay.Components PartRoom(); } - public virtual void CreateRoom(Room room, Action onSuccess = null, Action onError = null) + public virtual void CreateRoom(Room room, Action? onSuccess = null, Action? onError = null) { - room.Host.Value = api.LocalUser.Value; + room.Host = api.LocalUser.Value; var req = new CreateRoomRequest(room); @@ -69,16 +65,20 @@ namespace osu.Game.Screens.OnlinePlay.Components api.Queue(req); } - private JoinRoomRequest currentJoinRoomRequest; + private JoinRoomRequest? currentJoinRoomRequest; - public virtual void JoinRoom(Room room, string password = null, Action onSuccess = null, Action onError = null) + public virtual void JoinRoom(Room room, string? password = null, Action? onSuccess = null, Action? onError = null) { currentJoinRoomRequest?.Cancel(); currentJoinRoomRequest = new JoinRoomRequest(room, password); - currentJoinRoomRequest.Success += () => + currentJoinRoomRequest.Success += result => { joinedRoom.Value = room; + + AddOrUpdateRoom(result); + room.CopyFrom(result); // Also copy back to the source model, since this is likely to have been stored elsewhere. + onSuccess?.Invoke(room); }; @@ -97,7 +97,7 @@ namespace osu.Game.Screens.OnlinePlay.Components { currentJoinRoomRequest?.Cancel(); - if (JoinedRoom.Value == null) + if (joinedRoom.Value == null) return; if (api.State.Value == APIState.Online) @@ -111,14 +111,14 @@ namespace osu.Game.Screens.OnlinePlay.Components public void AddOrUpdateRoom(Room room) { Debug.Assert(ThreadSafety.IsUpdateThread); - Debug.Assert(room.RoomID.Value != null); + Debug.Assert(room.RoomID != null); - if (ignoredRooms.Contains(room.RoomID.Value.Value)) + if (ignoredRooms.Contains(room.RoomID.Value)) return; try { - var existing = rooms.FirstOrDefault(e => e.RoomID.Value == room.RoomID.Value); + var existing = rooms.FirstOrDefault(e => e.RoomID == room.RoomID); if (existing == null) rooms.Add(room); else @@ -126,9 +126,9 @@ namespace osu.Game.Screens.OnlinePlay.Components } catch (Exception ex) { - Logger.Error(ex, $"Failed to update room: {room.Name.Value}."); + Logger.Error(ex, $"Failed to update room: {room.Name}."); - ignoredRooms.Add(room.RoomID.Value.Value); + ignoredRooms.Add(room.RoomID.Value); rooms.Remove(room); } diff --git a/osu.Game/Screens/OnlinePlay/Components/SelectionPollingComponent.cs b/osu.Game/Screens/OnlinePlay/Components/SelectionPollingComponent.cs index 780ee29e41..7cee8b3546 100644 --- a/osu.Game/Screens/OnlinePlay/Components/SelectionPollingComponent.cs +++ b/osu.Game/Screens/OnlinePlay/Components/SelectionPollingComponent.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.Threading.Tasks; using osu.Game.Online.Rooms; @@ -20,25 +18,24 @@ namespace osu.Game.Screens.OnlinePlay.Components this.room = room; } - private GetRoomRequest lastPollRequest; + private GetRoomRequest? lastPollRequest; protected override Task Poll() { if (!API.IsLoggedIn) return base.Poll(); - if (room.RoomID.Value == null) + if (room.RoomID == null) return base.Poll(); var tcs = new TaskCompletionSource(); lastPollRequest?.Cancel(); - var req = new GetRoomRequest(room.RoomID.Value.Value); + var req = new GetRoomRequest(room.RoomID.Value); req.Success += result => { - result.RemoveExpiredPlaylistItems(); RoomManager.AddOrUpdateRoom(result); tcs.SetResult(true); }; diff --git a/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs b/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs index 2ee3bb30dd..2bdb41ce12 100644 --- a/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs @@ -1,9 +1,8 @@ // 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.ComponentModel; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -13,22 +12,27 @@ using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics; +using osu.Game.Online.Rooms; using osuTK; +using Container = osu.Framework.Graphics.Containers.Container; namespace osu.Game.Screens.OnlinePlay.Components { - public partial class StarRatingRangeDisplay : OnlinePlayComposite + public partial class StarRatingRangeDisplay : CompositeDrawable { + private readonly Room room; + [Resolved] - private OsuColour colours { get; set; } + private OsuColour colours { get; set; } = null!; - private StarRatingDisplay minDisplay; - private Drawable minBackground; - private StarRatingDisplay maxDisplay; - private Drawable maxBackground; + private StarRatingDisplay minDisplay = null!; + private Drawable minBackground = null!; + private StarRatingDisplay maxDisplay = null!; + private Drawable maxBackground = null!; - public StarRatingRangeDisplay() + public StarRatingRangeDisplay(Room room) { + this.room = room; AutoSizeAxes = Axes.Both; } @@ -76,8 +80,19 @@ namespace osu.Game.Screens.OnlinePlay.Components { base.LoadComplete(); - DifficultyRange.BindValueChanged(_ => updateRange()); - Playlist.BindCollectionChanged((_, _) => updateRange(), true); + room.PropertyChanged += onRoomPropertyChanged; + updateRange(); + } + + private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(Room.Playlist): + case nameof(Room.DifficultyRange): + updateRange(); + break; + } } private void updateRange() @@ -85,16 +100,16 @@ namespace osu.Game.Screens.OnlinePlay.Components StarDifficulty minDifficulty; StarDifficulty maxDifficulty; - if (DifficultyRange.Value != null && Playlist.Count == 0) + if (room.DifficultyRange != null && room.Playlist.Count == 0) { // When Playlist is empty (in lounge) we take retrieved range - minDifficulty = new StarDifficulty(DifficultyRange.Value.Min, 0); - maxDifficulty = new StarDifficulty(DifficultyRange.Value.Max, 0); + minDifficulty = new StarDifficulty(room.DifficultyRange.Min, 0); + maxDifficulty = new StarDifficulty(room.DifficultyRange.Max, 0); } else { // When Playlist is not empty (in room) we compute actual range - var orderedDifficulties = Playlist.Select(p => p.Beatmap).OrderBy(b => b.StarRating).ToArray(); + var orderedDifficulties = room.Playlist.Select(p => p.Beatmap).OrderBy(b => b.StarRating).ToArray(); minDifficulty = new StarDifficulty(orderedDifficulties.Length > 0 ? orderedDifficulties[0].StarRating : 0, 0); maxDifficulty = new StarDifficulty(orderedDifficulties.Length > 0 ? orderedDifficulties[^1].StarRating : 0, 0); @@ -107,5 +122,11 @@ namespace osu.Game.Screens.OnlinePlay.Components minBackground.Colour = colours.ForStarDifficulty(minDifficulty.Stars); maxBackground.Colour = colours.ForStarDifficulty(maxDifficulty.Stars); } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + room.PropertyChanged -= onRoomPropertyChanged; + } } } diff --git a/osu.Game/Screens/OnlinePlay/Components/StatusColouredContainer.cs b/osu.Game/Screens/OnlinePlay/Components/StatusColouredContainer.cs index ed39021a73..2b1233506f 100644 --- a/osu.Game/Screens/OnlinePlay/Components/StatusColouredContainer.cs +++ b/osu.Game/Screens/OnlinePlay/Components/StatusColouredContainer.cs @@ -1,39 +1,52 @@ // 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.ComponentModel; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Game.Graphics; using osu.Game.Online.Rooms; +using Container = osu.Framework.Graphics.Containers.Container; namespace osu.Game.Screens.OnlinePlay.Components { public partial class StatusColouredContainer : Container { + [Resolved] + private OsuColour colours { get; set; } = null!; + private readonly double transitionDuration; + private readonly Room room; - [Resolved(typeof(Room), nameof(Room.Status))] - private Bindable status { get; set; } - - [Resolved(typeof(Room), nameof(Room.Category))] - private Bindable category { get; set; } - - public StatusColouredContainer(double transitionDuration = 100) + public StatusColouredContainer(Room room, double transitionDuration = 100) { + this.room = room; this.transitionDuration = transitionDuration; } - [BackgroundDependencyLoader] - private void load(OsuColour colours) + protected override void LoadComplete() { - status.BindValueChanged(s => - { - this.FadeColour(colours.ForRoomCategory(category.Value) ?? s.NewValue.GetAppropriateColour(colours), transitionDuration); - }, true); + base.LoadComplete(); + + room.PropertyChanged += onRoomPropertyChanged; + updateRoomStatus(); + } + + private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Room.Status)) + updateRoomStatus(); + } + + private void updateRoomStatus() + { + this.FadeColour(colours.ForRoomCategory(room.Category) ?? room.Status.GetAppropriateColour(colours), transitionDuration); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + room.PropertyChanged -= onRoomPropertyChanged; } } } diff --git a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallenge.cs b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallenge.cs index 5b341956bb..0dc7e7930a 100644 --- a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallenge.cs +++ b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallenge.cs @@ -119,14 +119,6 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge Padding = new MarginPadding { Horizontal = -HORIZONTAL_OVERFLOW_PADDING }; } - protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) - { - return new CachedModelDependencyContainer(base.CreateChildDependencies(parent)) - { - Model = { Value = room } - }; - } - [BackgroundDependencyLoader] private void load(AudioManager audio) { @@ -228,7 +220,7 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge Origin = Anchor.Centre, Children = new Drawable[] { - new DailyChallengeTimeRemainingRing(), + new DailyChallengeTimeRemainingRing(room), breakdown = new DailyChallengeScoreBreakdown(), totals = new DailyChallengeTotalsDisplay(), } @@ -301,7 +293,7 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge Spacing = new Vector2(10), Children = new Drawable[] { - new PlaylistsReadyButton + new PlaylistsReadyButton(room) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -353,12 +345,12 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge private void presentScore(long id) { if (this.IsCurrentScreen()) - this.Push(new PlaylistItemScoreResultsScreen(room.RoomID.Value!.Value, playlistItem, id)); + this.Push(new PlaylistItemScoreResultsScreen(room.RoomID!.Value, playlistItem, id)); } private void onRoomScoreSet(MultiplayerRoomScoreSetEvent e) { - if (e.RoomID != room.RoomID.Value || e.PlaylistItemID != playlistItem.ID) + if (e.RoomID != room.RoomID || e.PlaylistItemID != playlistItem.ID) return; userLookupCache.GetUserAsync(e.UserID).ContinueWith(t => @@ -410,7 +402,7 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge private void dailyChallengeChanged(ValueChangedEvent change) { - if (change.OldValue?.RoomID == room.RoomID.Value && change.NewValue == null) + if (change.OldValue?.RoomID == room.RoomID && change.NewValue == null && metadataClient.IsConnected.Value) { notificationOverlay?.Post(new SimpleNotification { Text = DailyChallengeStrings.ChallengeEndedNotification }); } @@ -437,7 +429,7 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge roomManager.JoinRoom(room); startLoopingTrack(this, musicController); - metadataClient.BeginWatchingMultiplayerRoom(room.RoomID.Value!.Value).ContinueWith(t => + metadataClient.BeginWatchingMultiplayerRoom(room.RoomID!.Value).ContinueWith(t => { if (t.Exception != null) { @@ -489,7 +481,7 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge this.Delay(WaveContainer.DISAPPEAR_DURATION).FadeOut(); roomManager.PartRoom(); - metadataClient.EndWatchingMultiplayerRoom(room.RoomID.Value!.Value).FireAndForget(); + metadataClient.EndWatchingMultiplayerRoom(room.RoomID!.Value).FireAndForget(); return base.OnExiting(e); } diff --git a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeIntro.cs b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeIntro.cs index 7f0f26097c..7fddb8d1c4 100644 --- a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeIntro.cs +++ b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeIntro.cs @@ -169,7 +169,7 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Text = room.Name.Value.Split(':', StringSplitOptions.TrimEntries).Last(), + Text = room.Name.Split(':', StringSplitOptions.TrimEntries).Last(), Margin = new MarginPadding { Horizontal = 10f, Vertical = 5f }, Shear = new Vector2(-OsuGame.SHEAR, 0f), Font = OsuFont.GetFont(size: 32, weight: FontWeight.Light, typeface: Typeface.TorusAlternate), diff --git a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeLeaderboard.cs b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeLeaderboard.cs index c9152393e7..9fe2b70a5a 100644 --- a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeLeaderboard.cs +++ b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeLeaderboard.cs @@ -138,7 +138,7 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge if (request?.CompletionState == APIRequestCompletionState.Waiting) return; - request = new IndexPlaylistScoresRequest(room.RoomID.Value!.Value, playlistItem.ID); + request = new IndexPlaylistScoresRequest(room.RoomID!.Value, playlistItem.ID); request.Success += req => Schedule(() => { diff --git a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeTimeRemainingRing.cs b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeTimeRemainingRing.cs index e86f26ad6b..bf01ee6b52 100644 --- a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeTimeRemainingRing.cs +++ b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeTimeRemainingRing.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.ComponentModel; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -10,15 +11,15 @@ using osu.Framework.Threading; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Rooms; using osu.Game.Overlays; using osuTK; namespace osu.Game.Screens.OnlinePlay.DailyChallenge { - public partial class DailyChallengeTimeRemainingRing : OnlinePlayComposite + public partial class DailyChallengeTimeRemainingRing : CompositeDrawable { - private CircularProgress progress = null!; - private OsuSpriteText timeText = null!; + private readonly Room room; [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; @@ -26,6 +27,14 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge [Resolved] private OsuColour colours { get; set; } = null!; + private CircularProgress progress = null!; + private OsuSpriteText timeText = null!; + + public DailyChallengeTimeRemainingRing(Room room) + { + this.room = room; + } + [BackgroundDependencyLoader] private void load() { @@ -90,12 +99,23 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge { base.LoadComplete(); - StartDate.BindValueChanged(_ => Scheduler.AddOnce(updateState)); - EndDate.BindValueChanged(_ => Scheduler.AddOnce(updateState)); + room.PropertyChanged += onRoomPropertyChanged; updateState(); + FinishTransforms(true); } + private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(Room.StartDate): + case nameof(Room.EndDate): + Scheduler.AddOnce(updateState); + break; + } + } + private ScheduledDelegate? scheduledUpdate; private void updateState() @@ -105,7 +125,7 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge const float transition_duration = 300; - if (StartDate.Value == null || EndDate.Value == null || EndDate.Value < DateTimeOffset.Now) + if (room.StartDate == null || room.EndDate == null || room.EndDate < DateTimeOffset.Now) { timeText.Text = TimeSpan.Zero.ToString(@"hh\:mm\:ss"); progress.Progress = 0; @@ -114,8 +134,8 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge return; } - var roomDuration = EndDate.Value.Value - StartDate.Value.Value; - var remaining = EndDate.Value.Value - DateTimeOffset.Now; + var roomDuration = room.EndDate.Value - room.StartDate.Value; + var remaining = room.EndDate.Value - DateTimeOffset.Now; timeText.Text = remaining.ToString(@"hh\:mm\:ss"); progress.Progress = remaining.TotalSeconds / roomDuration.TotalSeconds; @@ -138,5 +158,11 @@ namespace osu.Game.Screens.OnlinePlay.DailyChallenge scheduledUpdate = Scheduler.AddDelayed(updateState, 1000); } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + room.PropertyChanged -= onRoomPropertyChanged; + } } } diff --git a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylist.cs b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylist.cs index 5a1648c91f..207e0bdf55 100644 --- a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylist.cs +++ b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylist.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.Bindables; @@ -26,22 +24,22 @@ namespace osu.Game.Screens.OnlinePlay /// The currently-selected item. Selection is visually represented with a border. /// May be updated by clicking playlist items if is true. /// - public readonly Bindable SelectedItem = new Bindable(); + public readonly Bindable SelectedItem = new Bindable(); /// /// Invoked when an item is requested to be deleted. /// - public Action RequestDeletion; + public Action? RequestDeletion; /// /// Invoked when an item requests its results to be shown. /// - public Action RequestResults; + public Action? RequestResults; /// /// Invoked when an item requests to be edited. /// - public Action RequestEdit; + public Action? RequestEdit; private bool allowReordering; @@ -235,7 +233,7 @@ namespace osu.Game.Screens.OnlinePlay { var visibleItems = ListContainer.AsEnumerable().Where(r => r.IsPresent); - PlaylistItem item; + PlaylistItem? item; if (SelectedItem.Value == null) item = visibleItems.FirstOrDefault()?.Model; diff --git a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs index 43ffaf947e..7a773bb116 100644 --- a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs +++ b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Linq; @@ -54,23 +52,23 @@ namespace osu.Game.Screens.OnlinePlay /// /// Invoked when this item requests to be deleted. /// - public Action RequestDeletion; + public Action? RequestDeletion; /// /// Invoked when this item requests its results to be shown. /// - public Action RequestResults; + public Action? RequestResults; /// /// Invoked when this item requests to be edited. /// - public Action RequestEdit; + public Action? RequestEdit; /// /// The currently-selected item, used to show a border around this item. /// May be updated by this item if is true. /// - public readonly Bindable SelectedItem = new Bindable(); + public readonly Bindable SelectedItem = new Bindable(); public readonly PlaylistItem Item; @@ -79,48 +77,48 @@ namespace osu.Game.Screens.OnlinePlay private readonly DelayedLoadWrapper onScreenLoader = new DelayedLoadWrapper(Empty) { RelativeSizeAxes = Axes.Both }; private readonly IBindable valid = new Bindable(); - private IBeatmapInfo beatmap; - private IRulesetInfo ruleset; + private IBeatmapInfo? beatmap; + private IRulesetInfo? ruleset; private Mod[] requiredMods = Array.Empty(); - private Container borderContainer; - private FillFlowContainer difficultyIconContainer; - private LinkFlowContainer beatmapText; - private LinkFlowContainer authorText; - private ExplicitContentBeatmapBadge explicitContent; - private ModDisplay modDisplay; - private FillFlowContainer buttonsFlow; - private UpdateableAvatar ownerAvatar; - private Drawable showResultsButton; - private Drawable editButton; - private Drawable removeButton; - private PanelBackground panelBackground; - private FillFlowContainer mainFillFlow; - private BeatmapCardThumbnail thumbnail; + private Container? borderContainer; + private FillFlowContainer? difficultyIconContainer; + private LinkFlowContainer? beatmapText; + private LinkFlowContainer? authorText; + private ExplicitContentBeatmapBadge? explicitContent; + private ModDisplay? modDisplay; + private FillFlowContainer? buttonsFlow; + private UpdateableAvatar? ownerAvatar; + private Drawable? showResultsButton; + private Drawable? editButton; + private Drawable? removeButton; + private PanelBackground? panelBackground; + private FillFlowContainer? mainFillFlow; + private BeatmapCardThumbnail? thumbnail; [Resolved] - private RealmAccess realm { get; set; } + private RealmAccess realm { get; set; } = null!; [Resolved] - private RulesetStore rulesets { get; set; } + private RulesetStore rulesets { get; set; } = null!; [Resolved] - private BeatmapManager beatmaps { get; set; } + private BeatmapManager beatmaps { get; set; } = null!; [Resolved] - private OsuColour colours { get; set; } + private OsuColour colours { get; set; } = null!; [Resolved] - private UserLookupCache userLookupCache { get; set; } + private UserLookupCache userLookupCache { get; set; } = null!; [Resolved] - private BeatmapLookupCache beatmapLookupCache { get; set; } + private BeatmapLookupCache beatmapLookupCache { get; set; } = null!; [Resolved(CanBeNull = true)] - private BeatmapSetOverlay beatmapOverlay { get; set; } + private BeatmapSetOverlay? beatmapOverlay { get; set; } [Resolved(CanBeNull = true)] - private ManageCollectionsDialog manageCollectionsDialog { get; set; } + private ManageCollectionsDialog? manageCollectionsDialog { get; set; } public DrawableRoomPlaylistItem(PlaylistItem item) : base(item) @@ -136,7 +134,8 @@ namespace osu.Game.Screens.OnlinePlay [BackgroundDependencyLoader] private void load() { - borderContainer.BorderColour = colours.Yellow; + if (borderContainer != null) + borderContainer.BorderColour = colours.Yellow; ruleset = rulesets.GetRuleset(Item.RulesetID); var rulesetInstance = ruleset?.CreateInstance(); @@ -163,7 +162,8 @@ namespace osu.Game.Screens.OnlinePlay return; } - borderContainer.BorderThickness = IsSelectedItem ? border_thickness : 0; + if (borderContainer != null) + borderContainer.BorderThickness = IsSelectedItem ? border_thickness : 0; }, true); valid.BindValueChanged(_ => Scheduler.AddOnce(refresh)); @@ -177,7 +177,11 @@ namespace osu.Game.Screens.OnlinePlay if (showItemOwner) { var foundUser = await userLookupCache.GetUserAsync(Item.OwnerID).ConfigureAwait(false); - Schedule(() => ownerAvatar.User = foundUser); + Schedule(() => + { + if (ownerAvatar != null) + ownerAvatar.User = foundUser; + }); } beatmap = await beatmapLookupCache.GetBeatmapAsync(Item.Beatmap.OnlineID).ConfigureAwait(false); @@ -278,69 +282,89 @@ namespace osu.Game.Screens.OnlinePlay private void refresh() { - if (!valid.Value) + if (borderContainer != null) { - borderContainer.BorderThickness = border_thickness; - borderContainer.BorderColour = colours.Red; - } - - if (beatmap != null) - { - difficultyIconContainer.Children = new Drawable[] + if (!valid.Value) { - thumbnail = new BeatmapCardThumbnail(beatmap.BeatmapSet!, (IBeatmapSetOnlineInfo)beatmap.BeatmapSet!) - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Width = 60, - Masking = true, - CornerRadius = 10, - RelativeSizeAxes = Axes.Y, - Dimmed = { Value = IsHovered } - }, - new DifficultyIcon(beatmap, ruleset, requiredMods) - { - Size = new Vector2(24), - TooltipType = DifficultyIconTooltipType.Extended, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - }, - }; + borderContainer.BorderThickness = border_thickness; + borderContainer.BorderColour = colours.Red; + } } - else - difficultyIconContainer.Clear(); - panelBackground.Beatmap.Value = beatmap; - - beatmapText.Clear(); - - if (beatmap != null) + if (difficultyIconContainer != null) { - beatmapText.AddLink(beatmap.GetDisplayTitleRomanisable(includeCreator: false), - LinkAction.OpenBeatmap, - beatmap.OnlineID.ToString(), - null, - text => + if (beatmap != null) + { + difficultyIconContainer.Children = new Drawable[] { - text.Truncate = true; - }); + thumbnail = new BeatmapCardThumbnail(beatmap.BeatmapSet!, (IBeatmapSetOnlineInfo)beatmap.BeatmapSet!) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Width = 60, + Masking = true, + CornerRadius = 10, + RelativeSizeAxes = Axes.Y, + Dimmed = { Value = IsHovered } + }, + new DifficultyIcon(beatmap, ruleset, requiredMods) + { + Size = new Vector2(24), + TooltipType = DifficultyIconTooltipType.Extended, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + }; + } + else + difficultyIconContainer.Clear(); } - authorText.Clear(); + if (panelBackground != null) + panelBackground.Beatmap.Value = beatmap; - if (!string.IsNullOrEmpty(beatmap?.Metadata.Author.Username)) + if (beatmapText != null) { - authorText.AddText("mapped by "); - authorText.AddUserLink(beatmap.Metadata.Author); + beatmapText.Clear(); + + if (beatmap != null) + { + beatmapText.AddLink(beatmap.GetDisplayTitleRomanisable(includeCreator: false), + LinkAction.OpenBeatmap, + beatmap.OnlineID.ToString(), + null, + text => + { + text.Truncate = true; + }); + } } - bool hasExplicitContent = (beatmap?.BeatmapSet as IBeatmapSetOnlineInfo)?.HasExplicitContent == true; - explicitContent.Alpha = hasExplicitContent ? 1 : 0; + if (authorText != null) + { + authorText.Clear(); - modDisplay.Current.Value = requiredMods.ToArray(); + if (!string.IsNullOrEmpty(beatmap?.Metadata.Author.Username)) + { + authorText.AddText("mapped by "); + authorText.AddUserLink(beatmap.Metadata.Author); + } + } - buttonsFlow.Clear(); - buttonsFlow.ChildrenEnumerable = createButtons(); + if (explicitContent != null) + { + bool hasExplicitContent = (beatmap?.BeatmapSet as IBeatmapSetOnlineInfo)?.HasExplicitContent == true; + explicitContent.Alpha = hasExplicitContent ? 1 : 0; + } + + if (modDisplay != null) + modDisplay.Current.Value = requiredMods.ToArray(); + + if (buttonsFlow != null) + { + buttonsFlow.Clear(); + buttonsFlow.ChildrenEnumerable = createButtons(); + } difficultyIconContainer.FadeInFromZero(500, Easing.OutQuint); mainFillFlow.FadeInFromZero(500, Easing.OutQuint); @@ -601,7 +625,7 @@ namespace osu.Game.Screens.OnlinePlay private readonly IBeatmapInfo beatmap; [Resolved] - private BeatmapManager beatmapManager { get; set; } + private BeatmapManager beatmapManager { get; set; } = null!; // required for download tracking, as this button hides itself. can probably be removed with a bit of consideration. public override bool IsPresent => true; @@ -656,7 +680,7 @@ namespace osu.Game.Screens.OnlinePlay // For now, this is the same implementation as in PanelBackground, but supports a beatmap info rather than a working beatmap private partial class PanelBackground : Container // todo: should be a buffered container (https://github.com/ppy/osu-framework/issues/3222) { - public readonly Bindable Beatmap = new Bindable(); + public readonly Bindable Beatmap = new Bindable(); public PanelBackground() { diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs index ef06d21655..c39ca347c7 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs @@ -1,9 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; using System.Threading; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -16,6 +16,7 @@ using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Containers; @@ -26,30 +27,33 @@ using osu.Game.Overlays; using osu.Game.Screens.OnlinePlay.Components; using osuTK; using osuTK.Graphics; +using Container = osu.Framework.Graphics.Containers.Container; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { - public partial class DrawableRoom : CompositeDrawable + public abstract partial class DrawableRoom : CompositeDrawable { protected const float CORNER_RADIUS = 10; private const float height = 100; public readonly Room Room; - protected Container ButtonsContainer { get; private set; } + protected readonly Bindable SelectedItem = new Bindable(); + protected Container ButtonsContainer { get; private set; } = null!; private readonly Bindable roomType = new Bindable(); private readonly Bindable roomCategory = new Bindable(); private readonly Bindable hasPassword = new Bindable(); - private DrawableRoomParticipantsList drawableRoomParticipantsList; - private RoomSpecialCategoryPill specialCategoryPill; - private PasswordProtectedIcon passwordIcon; - private EndDateInfo endDateInfo; + private DrawableRoomParticipantsList? drawableRoomParticipantsList; + private RoomSpecialCategoryPill? specialCategoryPill; + private PasswordProtectedIcon? passwordIcon; + private EndDateInfo? endDateInfo; + private SpriteText? roomName; + private UpdateableBeatmapBackgroundSprite background = null!; + private DelayedLoadWrapper wrapper = null!; - private DelayedLoadWrapper wrapper; - - public DrawableRoom(Room room) + protected DrawableRoom(Room room) { Room = room; @@ -77,7 +81,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components AutoSizeAxes = Axes.X }; - InternalChildren = new[] + InternalChildren = new Drawable[] { // This resolves internal 1px gaps due to applying the (parenting) corner radius and masking across multiple filling background sprites. new Box @@ -85,7 +89,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components RelativeSizeAxes = Axes.Both, Colour = colours.Background5, }, - CreateBackground().With(d => + background = CreateBackground().With(d => { d.RelativeSizeAxes = Axes.Both; }), @@ -155,17 +159,17 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components Spacing = new Vector2(5), Children = new Drawable[] { - new RoomStatusPill + new RoomStatusPill(Room) { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft }, - specialCategoryPill = new RoomSpecialCategoryPill + specialCategoryPill = new RoomSpecialCategoryPill(Room) { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft }, - endDateInfo = new EndDateInfo + endDateInfo = new EndDateInfo(Room) { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, @@ -180,13 +184,15 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components Direction = FillDirection.Vertical, Children = new Drawable[] { - new TruncatingSpriteText + roomName = new TruncatingSpriteText { RelativeSizeAxes = Axes.X, - Font = OsuFont.GetFont(size: 28), - Current = { BindTarget = Room.Name } + Font = OsuFont.GetFont(size: 28) }, - new RoomStatusText() + new RoomStatusText(Room) + { + SelectedItem = { BindTarget = SelectedItem } + } } } }, @@ -218,7 +224,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components Children = new Drawable[] { ButtonsContainer, - drawableRoomParticipantsList = new DrawableRoomParticipantsList + drawableRoomParticipantsList = new DrawableRoomParticipantsList(Room) { Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, @@ -243,36 +249,71 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components { base.LoadComplete(); + Room.PropertyChanged += onRoomPropertyChanged; + wrapper.DelayedLoadComplete += _ => { + Debug.Assert(specialCategoryPill != null); + Debug.Assert(endDateInfo != null); + Debug.Assert(passwordIcon != null); + wrapper.FadeInFromZero(200); - roomCategory.BindTo(Room.Category); - roomCategory.BindValueChanged(c => - { - if (c.NewValue > RoomCategory.Normal) - specialCategoryPill.Show(); - else - specialCategoryPill.Hide(); - }, true); - - roomType.BindTo(Room.Type); - roomType.BindValueChanged(t => - { - endDateInfo.Alpha = t.NewValue == MatchType.Playlists ? 1 : 0; - }, true); - - hasPassword.BindTo(Room.HasPassword); - hasPassword.BindValueChanged(v => passwordIcon.Alpha = v.NewValue ? 1 : 0, true); + updateRoomName(); + updateRoomCategory(); + updateRoomType(); + updateRoomHasPassword(); }; + + SelectedItem.BindValueChanged(item => background.Beatmap.Value = item.NewValue?.Beatmap, true); } - protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) { - return new CachedModelDependencyContainer(base.CreateChildDependencies(parent)) + switch (e.PropertyName) { - Model = { Value = Room } - }; + case nameof(Room.Name): + updateRoomName(); + break; + + case nameof(Room.Category): + updateRoomCategory(); + break; + + case nameof(Room.Type): + updateRoomType(); + break; + + case nameof(Room.HasPassword): + updateRoomHasPassword(); + break; + } + } + + private void updateRoomName() + { + if (roomName != null) + roomName.Text = Room.Name; + } + + private void updateRoomCategory() + { + if (Room.Category > RoomCategory.Normal) + specialCategoryPill?.Show(); + else + specialCategoryPill?.Hide(); + } + + private void updateRoomType() + { + if (endDateInfo != null) + endDateInfo.Alpha = Room.Type == MatchType.Playlists ? 1 : 0; + } + + private void updateRoomHasPassword() + { + if (passwordIcon != null) + passwordIcon.Alpha = Room.HasPassword ? 1 : 0; } private int numberOfAvatars = 7; @@ -289,29 +330,29 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components } } - protected virtual Drawable CreateBackground() => new OnlinePlayBackgroundSprite(); + protected virtual UpdateableBeatmapBackgroundSprite CreateBackground() => new UpdateableBeatmapBackgroundSprite(); protected virtual IEnumerable CreateBottomDetails() { var pills = new List(); - if (Room.Type.Value != MatchType.Playlists) + if (Room.Type != MatchType.Playlists) { - pills.AddRange(new OnlinePlayComposite[] + pills.AddRange(new Drawable[] { - new MatchTypePill(), - new QueueModePill(), + new MatchTypePill(Room), + new QueueModePill(Room), }); } pills.AddRange(new Drawable[] { - new PlaylistCountPill + new PlaylistCountPill(Room) { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, }, - new StarRatingRangeDisplay + new StarRatingRangeDisplay(Room) { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, @@ -322,19 +363,29 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components return pills; } - private partial class RoomStatusText : OnlinePlayComposite + protected override void Dispose(bool isDisposing) { - [Resolved] - private OsuColour colours { get; set; } + base.Dispose(isDisposing); + Room.PropertyChanged -= onRoomPropertyChanged; + } + + private partial class RoomStatusText : CompositeDrawable + { + public readonly IBindable SelectedItem = new Bindable(); [Resolved] - private BeatmapLookupCache beatmapLookupCache { get; set; } + private OsuColour colours { get; set; } = null!; - private SpriteText statusText; - private LinkFlowContainer beatmapText; + [Resolved] + private BeatmapLookupCache beatmapLookupCache { get; set; } = null!; - public RoomStatusText() + private readonly Room room; + private SpriteText statusText = null!; + private LinkFlowContainer beatmapText = null!; + + public RoomStatusText(Room room) { + this.room = room; RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; } @@ -383,17 +434,17 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components protected override void LoadComplete() { base.LoadComplete(); - CurrentPlaylistItem.BindValueChanged(onSelectedItemChanged, true); + SelectedItem.BindValueChanged(onSelectedItemChanged, true); } - private CancellationTokenSource beatmapLookupCancellation; + private CancellationTokenSource? beatmapLookupCancellation; - private void onSelectedItemChanged(ValueChangedEvent item) + private void onSelectedItemChanged(ValueChangedEvent item) { beatmapLookupCancellation?.Cancel(); beatmapText.Clear(); - if (Type.Value == MatchType.Playlists) + if (room.Type == MatchType.Playlists) { statusText.Text = "Ready to play"; return; diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoomParticipantsList.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoomParticipantsList.cs index 60e05285d9..5bcc974c26 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoomParticipantsList.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoomParticipantsList.cs @@ -1,13 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - -using System.Collections.Specialized; -using System.Diagnostics; +using System.Collections.Generic; +using System.ComponentModel; using System.Linq; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -16,31 +13,33 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Rooms; using osu.Game.Overlays; using osu.Game.Users.Drawables; using osuTK; +using Container = osu.Framework.Graphics.Containers.Container; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { - public partial class DrawableRoomParticipantsList : OnlinePlayComposite + public partial class DrawableRoomParticipantsList : CompositeDrawable { public const float SHEAR_WIDTH = 12f; - private const float avatar_size = 36; - private const float height = 60f; - private static readonly Vector2 shear = new Vector2(SHEAR_WIDTH / height, 0); - private FillFlowContainer avatarFlow; + private readonly Room room; - private CircularAvatar hostAvatar; - private LinkFlowContainer hostText; - private HiddenUserCount hiddenUsers; - private OsuSpriteText totalCount; + private FillFlowContainer avatarFlow = null!; + private CircularAvatar hostAvatar = null!; + private LinkFlowContainer hostText = null!; + private HiddenUserCount hiddenUsers = null!; + private OsuSpriteText totalCount = null!; - public DrawableRoomParticipantsList() + public DrawableRoomParticipantsList(Room room) { + this.room = room; + AutoSizeAxes = Axes.X; Height = height; } @@ -165,14 +164,11 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components { base.LoadComplete(); - RecentParticipants.BindCollectionChanged(onParticipantsChanged, true); - ParticipantCount.BindValueChanged(_ => - { - updateHiddenUsers(); - totalCount.Text = ParticipantCount.Value.ToString(); - }, true); + room.PropertyChanged += onRoomPropertyChanged; - Host.BindValueChanged(onHostChanged, true); + updateRoomHost(); + updateRoomParticipantCount(); + updateRoomParticipants(); } private int numberOfCircles = 4; @@ -192,43 +188,38 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components // Reinitialising the list looks janky, but this is unlikely to be used in a setting where it's visible. clearUsers(); - foreach (var u in RecentParticipants) + foreach (var u in room.RecentParticipants) addUser(u); updateHiddenUsers(); } } - private void onParticipantsChanged(object sender, NotifyCollectionChangedEventArgs e) + private void updateRoomParticipants() { - switch (e.Action) + HashSet newUsers = room.RecentParticipants.ToHashSet(); + + avatarFlow.RemoveAll(a => { - case NotifyCollectionChangedAction.Add: - Debug.Assert(e.NewItems != null); + // Avatar with no user. Really shouldn't ever be the case but asserting it correctly is difficult. + if (a.User == null) + return false; - foreach (var added in e.NewItems.OfType()) - addUser(added); - break; + // User was previously and still is a participant. Keep them around but remove them from the new set. + // This will be useful when we add all remaining users (now just the new participants) to the flow. + if (newUsers.Contains(a.User)) + { + newUsers.Remove(a.User); + return false; + } - case NotifyCollectionChangedAction.Remove: - Debug.Assert(e.OldItems != null); + // User is no longer a participant. Remove them from the flow. + return true; + }, true); - foreach (var removed in e.OldItems.OfType()) - removeUser(removed); - break; - - case NotifyCollectionChangedAction.Reset: - clearUsers(); - break; - - case NotifyCollectionChangedAction.Replace: - case NotifyCollectionChangedAction.Move: - // Easiest is to just reinitialise the whole list. These are unlikely to ever be use cases. - clearUsers(); - foreach (var u in RecentParticipants) - addUser(u); - break; - } + // Add all remaining users to the flow. + foreach (var u in newUsers) + addUser(u); updateHiddenUsers(); } @@ -241,11 +232,6 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components avatarFlow.Add(new CircularAvatar { User = user }); } - private void removeUser(APIUser user) - { - avatarFlow.RemoveAll(a => a.User == user, true); - } - private void clearUsers() { avatarFlow.Clear(); @@ -255,8 +241,8 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components private void updateHiddenUsers() { int hiddenCount = 0; - if (RecentParticipants.Count > NumberOfCircles) - hiddenCount = ParticipantCount.Value - NumberOfCircles + 1; + if (room.RecentParticipants.Count > NumberOfCircles) + hiddenCount = room.ParticipantCount - NumberOfCircles + 1; hiddenUsers.Count = hiddenCount; @@ -264,26 +250,56 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components avatarFlow.Remove(avatarFlow.Last(), true); else if (displayedCircles < NumberOfCircles) { - var nextUser = RecentParticipants.FirstOrDefault(u => avatarFlow.All(a => a.User != u)); + var nextUser = room.RecentParticipants.FirstOrDefault(u => avatarFlow.All(a => a.User != u)); if (nextUser != null) addUser(nextUser); } } - private void onHostChanged(ValueChangedEvent host) + private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) { - hostAvatar.User = host.NewValue; + switch (e.PropertyName) + { + case nameof(Room.Host): + updateRoomHost(); + break; + + case nameof(Room.ParticipantCount): + updateRoomParticipantCount(); + break; + + case nameof(Room.RecentParticipants): + updateRoomParticipants(); + break; + } + } + + private void updateRoomHost() + { + hostAvatar.User = room.Host; hostText.Clear(); - if (host.NewValue != null) + if (room.Host != null) { hostText.AddText("hosted by "); - hostText.AddUserLink(host.NewValue); + hostText.AddUserLink(room.Host); } } + private void updateRoomParticipantCount() + { + updateHiddenUsers(); + totalCount.Text = room.ParticipantCount.ToString(); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + room.PropertyChanged -= onRoomPropertyChanged; + } + private partial class CircularAvatar : CompositeDrawable { - public APIUser User + public APIUser? User { get => avatar.User; set => avatar.User = value; diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/EndDateInfo.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/EndDateInfo.cs index 844991095e..3b03ce61f1 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/EndDateInfo.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/EndDateInfo.cs @@ -2,49 +2,69 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.ComponentModel; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Game.Graphics; +using osu.Game.Online.Rooms; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { - public partial class EndDateInfo : OnlinePlayComposite + public partial class EndDateInfo : CompositeDrawable { - public EndDateInfo() + private readonly Room room; + + public EndDateInfo(Room room) { + this.room = room; AutoSizeAxes = Axes.Both; } [BackgroundDependencyLoader] private void load() { - InternalChild = new EndDatePart + InternalChild = new EndDatePart(room) { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Font = OsuFont.GetFont(weight: FontWeight.SemiBold, size: 12), - EndDate = { BindTarget = EndDate } + Font = OsuFont.GetFont(weight: FontWeight.SemiBold, size: 12) }; } private partial class EndDatePart : DrawableDate { - public readonly IBindable EndDate = new Bindable(); + private readonly Room room; - public EndDatePart() + public EndDatePart(Room room) : base(DateTimeOffset.UtcNow) { - EndDate.BindValueChanged(date => - { - // If null, set a very large future date to prevent unnecessary schedules. - Date = date.NewValue ?? DateTimeOffset.Now.AddYears(1); - }, true); + this.room = room; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + room.PropertyChanged += onRoomPropertyChanged; + updateEndDate(); + } + + private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Room.EndDate)) + updateEndDate(); + } + + private void updateEndDate() + { + // If null, set a very large future date to prevent unnecessary schedules. + Date = room.EndDate ?? DateTimeOffset.Now.AddYears(1); } protected override string Format() { - if (EndDate.Value == null) + if (room.EndDate == null) return string.Empty; var diffToNow = Date.Subtract(DateTimeOffset.Now); @@ -60,6 +80,12 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components return $"Closing {base.Format()}"; } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + room.PropertyChanged -= onRoomPropertyChanged; + } } } } diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/FilterCriteria.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/FilterCriteria.cs index 3a687ad351..0f63718355 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/FilterCriteria.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/FilterCriteria.cs @@ -1,18 +1,16 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Game.Rulesets; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { public class FilterCriteria { - public string SearchString; + public string SearchString = string.Empty; public RoomStatusFilter Status; - public string Category; - public RulesetInfo Ruleset; + public string Category = string.Empty; + public RulesetInfo? Ruleset; public RoomPermissionsFilter Permissions; } } diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/MatchTypePill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/MatchTypePill.cs index e30d673b26..d5405c2d0e 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/MatchTypePill.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/MatchTypePill.cs @@ -1,7 +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 osu.Framework.Bindables; +using System.ComponentModel; using osu.Framework.Extensions; using osu.Game.Online.Rooms; @@ -9,16 +9,36 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components { public partial class MatchTypePill : OnlinePlayPill { + private readonly Room room; + + public MatchTypePill(Room room) + { + this.room = room; + } + protected override void LoadComplete() { base.LoadComplete(); - Type.BindValueChanged(onMatchTypeChanged, true); + room.PropertyChanged += onRoomPropertyChanged; + updateRoomType(); } - private void onMatchTypeChanged(ValueChangedEvent type) + private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) { - TextFlow.Text = type.NewValue.GetLocalisableDescription(); + if (e.PropertyName == nameof(Room.Type)) + updateRoomType(); + } + + private void updateRoomType() + { + TextFlow.Text = room.Type.GetLocalisableDescription(); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + room.PropertyChanged -= onRoomPropertyChanged; } } } diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/OnlinePlayPill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/OnlinePlayPill.cs index 3e6d7a2e54..c65a5e2469 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/OnlinePlayPill.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/OnlinePlayPill.cs @@ -3,13 +3,14 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; using osu.Game.Graphics.Containers; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { - public abstract partial class OnlinePlayPill : OnlinePlayComposite + public abstract partial class OnlinePlayPill : CompositeDrawable { protected PillContainer Pill { get; private set; } = null!; protected OsuTextFlowContainer TextFlow { get; private set; } = null!; diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/PlaylistCountPill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/PlaylistCountPill.cs index fe5ccb4f09..70ddf15abf 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/PlaylistCountPill.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/PlaylistCountPill.cs @@ -1,10 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.ComponentModel; using System.Linq; using Humanizer; using osu.Framework.Extensions.LocalisationExtensions; using osu.Game.Graphics; +using osu.Game.Online.Rooms; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { @@ -13,26 +15,50 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components /// public partial class PlaylistCountPill : OnlinePlayPill { + private readonly Room room; + + public PlaylistCountPill(Room room) + { + this.room = room; + } + protected override void LoadComplete() { base.LoadComplete(); - PlaylistItemStats.BindValueChanged(_ => updateCount()); - Playlist.BindCollectionChanged((_, _) => updateCount(), true); + room.PropertyChanged += onRoomPropertyChanged; + updateCount(); + } + + private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(Room.Playlist): + case nameof(Room.PlaylistItemStats): + updateCount(); + break; + } } private void updateCount() { - int activeItems = Playlist.Count > 0 || PlaylistItemStats.Value == null + int activeItems = room.Playlist.Count > 0 || room.PlaylistItemStats == null // For now, use the playlist as the source of truth if it has any items. // This allows the count to display correctly on the room screen (after joining a room). - ? Playlist.Count(i => !i.Expired) - : PlaylistItemStats.Value.CountActive; + ? room.Playlist.Count(i => !i.Expired) + : room.PlaylistItemStats.CountActive; TextFlow.Clear(); TextFlow.AddText(activeItems.ToLocalisableString(), s => s.Font = s.Font.With(weight: FontWeight.Bold)); TextFlow.AddText(" "); TextFlow.AddText("Beatmap".ToQuantity(activeItems, ShowQuantityAs.None)); } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + room.PropertyChanged -= onRoomPropertyChanged; + } } } diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/QueueModePill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/QueueModePill.cs index 23f4ecf8db..c7d7876644 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/QueueModePill.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/QueueModePill.cs @@ -1,24 +1,42 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Bindables; +using System.ComponentModel; using osu.Framework.Extensions; -using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { public partial class QueueModePill : OnlinePlayPill { + private readonly Room room; + + public QueueModePill(Room room) + { + this.room = room; + } + protected override void LoadComplete() { base.LoadComplete(); - QueueMode.BindValueChanged(onQueueModeChanged, true); + room.PropertyChanged += onRoomPropertyChanged; + updateRoomQueueMode(); } - private void onQueueModeChanged(ValueChangedEvent mode) + private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) { - TextFlow.Text = mode.NewValue.GetLocalisableDescription(); + if (e.PropertyName == nameof(Room.QueueMode)) + updateRoomQueueMode(); + } + + private void updateRoomQueueMode() + => TextFlow.Text = room.QueueMode.GetLocalisableDescription(); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + room.PropertyChanged -= onRoomPropertyChanged; } } } 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/Lounge/Components/RoomSpecialCategoryPill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomSpecialCategoryPill.cs index 9b8954bb33..9bb3a59d0c 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomSpecialCategoryPill.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomSpecialCategoryPill.cs @@ -1,21 +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.ComponentModel; using osu.Framework.Allocation; using osu.Framework.Extensions; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; +using osu.Game.Online.Rooms; using osuTK.Graphics; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { public partial class RoomSpecialCategoryPill : OnlinePlayPill { + private readonly Room room; + [Resolved] private OsuColour colours { get; set; } = null!; protected override FontUsage Font => base.Font.With(weight: FontWeight.SemiBold); + public RoomSpecialCategoryPill(Room room) + { + this.room = room; + } + protected override void LoadComplete() { base.LoadComplete(); @@ -23,11 +32,26 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components Pill.Background.Alpha = 1; TextFlow.Colour = Color4.Black; - Category.BindValueChanged(c => - { - TextFlow.Text = c.NewValue.GetLocalisableDescription(); - Pill.Background.Colour = colours.ForRoomCategory(c.NewValue) ?? colours.Pink; - }, true); + room.PropertyChanged += onRoomPropertyChanged; + updateRoomCategory(); + } + + private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Room.Category)) + updateRoomCategory(); + } + + private void updateRoomCategory() + { + TextFlow.Text = room.Category.GetLocalisableDescription(); + Pill.Background.Colour = colours.ForRoomCategory(room.Category) ?? colours.Pink; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + room.PropertyChanged -= onRoomPropertyChanged; } } } diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusPill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusPill.cs index 96d698a184..b3dc617fd6 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusPill.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusPill.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.ComponentModel; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; @@ -19,25 +20,47 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components protected override FontUsage Font => base.Font.With(weight: FontWeight.SemiBold); + private readonly Room room; + + public RoomStatusPill(Room room) + { + this.room = room; + } + protected override void LoadComplete() { base.LoadComplete(); - EndDate.BindValueChanged(_ => updateDisplay()); - Status.BindValueChanged(_ => updateDisplay(), true); - - FinishTransforms(true); - TextFlow.Colour = Colour4.Black; Pill.Background.Alpha = 1; + + room.PropertyChanged += onRoomPropertyChanged; + updateDisplay(); + + FinishTransforms(true); + } + + private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(Room.Status): + case nameof(Room.EndDate): + updateDisplay(); + break; + } } private void updateDisplay() { - RoomStatus status = Status.Value; + Pill.Background.FadeColour(room.Status.GetAppropriateColour(colours), 100); + TextFlow.Text = room.Status.Message; + } - Pill.Background.FadeColour(status.GetAppropriateColour(colours), 100); - TextFlow.Text = status.Message; + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + room.PropertyChanged -= onRoomPropertyChanged; } } } diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs index e842f8c436..17aed021b2 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Collections.Specialized; @@ -11,6 +9,7 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Bindings; @@ -24,8 +23,8 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components { public partial class RoomsContainer : CompositeDrawable, IKeyBindingHandler { - public readonly Bindable SelectedRoom = new Bindable(); - public readonly Bindable Filter = new Bindable(); + public readonly Bindable SelectedRoom = new Bindable(); + public readonly Bindable Filter = new Bindable(); public IReadOnlyList Rooms => roomFlow.FlowingChildren.Cast().ToArray(); @@ -33,7 +32,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components private readonly FillFlowContainer roomFlow; [Resolved] - private IRoomManager roomManager { get; set; } + private IRoomManager roomManager { get; set; } = null!; // handle deselection public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; @@ -67,10 +66,10 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components rooms.BindTo(roomManager.Rooms); - Filter?.BindValueChanged(criteria => applyFilterCriteria(criteria.NewValue), true); + Filter.BindValueChanged(criteria => applyFilterCriteria(criteria.NewValue), true); } - private void applyFilterCriteria(FilterCriteria criteria) + private void applyFilterCriteria(FilterCriteria? criteria) { roomFlow.Children.ForEach(r => { @@ -80,7 +79,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components { bool matchingFilter = true; - matchingFilter &= criteria.Ruleset == null || r.Room.PlaylistItemStats.Value?.RulesetIDs.Any(id => id == criteria.Ruleset.OnlineID) != false; + matchingFilter &= criteria.Ruleset == null || r.Room.PlaylistItemStats?.RulesetIDs.Any(id => id == criteria.Ruleset.OnlineID) != false; if (!string.IsNullOrEmpty(criteria.SearchString)) { @@ -102,10 +101,10 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components return true; case RoomPermissionsFilter.Public: - return !room.Room.HasPassword.Value; + return !room.Room.HasPassword; case RoomPermissionsFilter.Private: - return room.Room.HasPassword.Value; + return room.Room.HasPassword; default: throw new ArgumentOutOfRangeException(nameof(accessType), accessType, $"Unsupported {nameof(RoomPermissionsFilter)} in filter"); @@ -113,7 +112,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components } } - private void roomsChanged(object sender, NotifyCollectionChangedEventArgs args) + private void roomsChanged(object? sender, NotifyCollectionChangedEventArgs args) { switch (args.Action) { @@ -140,9 +139,9 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components private void addRooms(IEnumerable rooms) { foreach (var room in rooms) - roomFlow.Add(new DrawableLoungeRoom(room) { SelectedRoom = { BindTarget = SelectedRoom } }); + roomFlow.Add(new DrawableLoungeRoom(room) { SelectedRoom = SelectedRoom }); - applyFilterCriteria(Filter?.Value); + applyFilterCriteria(Filter.Value); } private void removeRooms(IEnumerable rooms) @@ -170,10 +169,10 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components { foreach (var room in roomFlow) { - roomFlow.SetLayoutPosition(room, room.Room.Category.Value > RoomCategory.Normal + roomFlow.SetLayoutPosition(room, room.Room.Category > RoomCategory.Normal // Always show spotlight playlists at the top of the listing. ? float.MinValue - : -(room.Room.RoomID.Value ?? 0)); + : -(room.Room.RoomID ?? 0)); } } @@ -213,7 +212,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components var visibleRooms = Rooms.AsEnumerable().Where(r => r.IsPresent); - Room room; + Room? room; if (SelectedRoom.Value == null) room = visibleRooms.FirstOrDefault()?.Room; @@ -236,7 +235,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components { base.Dispose(isDisposing); - if (roomManager != null) + if (roomManager.IsNotNull()) roomManager.RoomsUpdated -= updateSorting; } } diff --git a/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs b/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs index fed47e847a..d396d18b4f 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs @@ -1,9 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System.Collections.Generic; +using System.ComponentModel; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -28,6 +27,7 @@ using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Lounge.Components; using osuTK; using osuTK.Graphics; +using Container = osu.Framework.Graphics.Containers.Container; namespace osu.Game.Screens.OnlinePlay.Lounge { @@ -39,14 +39,19 @@ namespace osu.Game.Screens.OnlinePlay.Lounge private const float transition_duration = 60; private const float selection_border_width = 4; - public readonly Bindable SelectedRoom = new Bindable(); + public required Bindable SelectedRoom + { + get => selectedRoom; + set => selectedRoom.Current = value; + } [Resolved(canBeNull: true)] - private LoungeSubScreen lounge { get; set; } + private LoungeSubScreen? lounge { get; set; } - private Sample sampleSelect; - private Sample sampleJoin; - private Drawable selectionBox; + private readonly BindableWithCurrent selectedRoom = new BindableWithCurrent(); + private Sample? sampleSelect; + private Sample? sampleJoin; + private Drawable selectionBox = null!; public DrawableLoungeRoom(Room room) : base(room) @@ -61,7 +66,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge AddRangeInternal(new Drawable[] { - new StatusColouredContainer(transition_duration) + new StatusColouredContainer(Room, transition_duration) { RelativeSizeAxes = Axes.Both, Child = selectionBox = new Container @@ -89,12 +94,24 @@ namespace osu.Game.Screens.OnlinePlay.Lounge base.LoadComplete(); Alpha = matchingFilter ? 1 : 0; - selectionBox.Alpha = SelectedRoom.Value == Room ? 1 : 0; + selectionBox.Alpha = selectedRoom.Value == Room ? 1 : 0; - SelectedRoom.BindValueChanged(updateSelectedRoom); + selectedRoom.BindValueChanged(updateSelectedRoom); + + Room.PropertyChanged += onRoomPropertyChanged; + updateSelectedItem(); } - private void updateSelectedRoom(ValueChangedEvent selected) + private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Room.CurrentPlaylistItem)) + updateSelectedItem(); + } + + private void updateSelectedItem() + => SelectedItem.Value = Room.CurrentPlaylistItem; + + private void updateSelectedRoom(ValueChangedEvent selected) { if (selected.NewValue == Room) selectionBox.FadeIn(transition_duration); @@ -104,7 +121,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge public bool FilteringActive { get; set; } - public IEnumerable FilterTerms => new LocalisableString[] { Room.Name.Value }; + public IEnumerable FilterTerms => new LocalisableString[] { Room.Name }; private bool matchingFilter = true; @@ -140,7 +157,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge if (e.Repeat) return false; - if (SelectedRoom.Value != Room) + if (selectedRoom.Value != Room) return false; switch (e.Action) @@ -157,18 +174,18 @@ namespace osu.Game.Screens.OnlinePlay.Lounge { } - protected override bool ShouldBeConsideredForInput(Drawable child) => SelectedRoom.Value == Room || child is HoverSounds; + protected override bool ShouldBeConsideredForInput(Drawable child) => selectedRoom.Value == Room || child is HoverSounds; protected override bool OnClick(ClickEvent e) { - if (Room != SelectedRoom.Value) + if (Room != selectedRoom.Value) { sampleSelect?.Play(); - SelectedRoom.Value = Room; + selectedRoom.Value = Room; return true; } - if (Room.HasPassword.Value) + if (Room.HasPassword) { this.ShowPopover(); return true; @@ -179,12 +196,18 @@ namespace osu.Game.Screens.OnlinePlay.Lounge return true; } + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + Room.PropertyChanged -= onRoomPropertyChanged; + } + public partial class PasswordEntryPopover : OsuPopover { private readonly Room room; [Resolved(canBeNull: true)] - private LoungeSubScreen lounge { get; set; } + private LoungeSubScreen? lounge { get; set; } public override bool HandleNonPositionalInput => true; @@ -195,10 +218,10 @@ namespace osu.Game.Screens.OnlinePlay.Lounge this.room = room; } - private OsuPasswordTextBox passwordTextBox; - private RoundedButton joinButton; - private OsuSpriteText errorText; - private Sample sampleJoinFail; + private OsuPasswordTextBox passwordTextBox = null!; + private RoundedButton joinButton = null!; + private OsuSpriteText errorText = null!; + private Sample? sampleJoinFail; [BackgroundDependencyLoader] private void load(OsuColour colours, AudioManager audio) diff --git a/osu.Game/Screens/OnlinePlay/Lounge/LoungeBackgroundScreen.cs b/osu.Game/Screens/OnlinePlay/Lounge/LoungeBackgroundScreen.cs index b31c351b82..4a3985c386 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/LoungeBackgroundScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/LoungeBackgroundScreen.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.ComponentModel; using osu.Framework.Bindables; using osu.Framework.Screens; using osu.Game.Online.Rooms; @@ -19,21 +20,44 @@ namespace osu.Game.Screens.OnlinePlay.Lounge playlist.BindCollectionChanged((_, _) => PlaylistItem = playlist.GetCurrentItem()); } + protected override void LoadComplete() + { + base.LoadComplete(); + SelectedRoom.BindValueChanged(onSelectedRoomChanged, true); + } + private void onSelectedRoomChanged(ValueChangedEvent room) { if (room.OldValue != null) - playlist.UnbindFrom(room.OldValue.Playlist); + room.OldValue.PropertyChanged -= onRoomPropertyChanged; if (room.NewValue != null) - playlist.BindTo(room.NewValue.Playlist); - else - playlist.Clear(); + room.NewValue.PropertyChanged += onRoomPropertyChanged; + + updateCurrentItem(); } + private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Room.Playlist)) + updateCurrentItem(); + } + + private void updateCurrentItem() + => PlaylistItem = SelectedRoom.Value?.Playlist.GetCurrentItem(); + public override bool OnExiting(ScreenExitEvent e) { // This screen never exits. return true; } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (SelectedRoom.Value != null) + SelectedRoom.Value.PropertyChanged -= onRoomPropertyChanged; + } } } diff --git a/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs b/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs index 3792a67896..ac8caa6b88 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs @@ -1,13 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; @@ -54,42 +51,42 @@ namespace osu.Game.Screens.OnlinePlay.Lounge AutoSizeAxes = Axes.Both }; - protected ListingPollingComponent ListingPollingComponent { get; private set; } + protected ListingPollingComponent ListingPollingComponent { get; private set; } = null!; - protected readonly Bindable SelectedRoom = new Bindable(); + protected readonly Bindable SelectedRoom = new Bindable(); [Resolved] - private MusicController music { get; set; } + private MusicController music { get; set; } = null!; [Resolved(CanBeNull = true)] - private OngoingOperationTracker ongoingOperationTracker { get; set; } + private OngoingOperationTracker? ongoingOperationTracker { get; set; } [Resolved] - private IBindable ruleset { get; set; } + private IBindable ruleset { get; set; } = null!; [Resolved] - private IAPIProvider api { get; set; } + private IAPIProvider api { get; set; } = null!; - [CanBeNull] - private IDisposable joiningRoomOperation { get; set; } - - [CanBeNull] - private LeasedBindable selectionLease; + [Resolved(CanBeNull = true)] + private IdleTracker? idleTracker { get; set; } [Resolved] - protected OsuConfigManager Config { get; private set; } + protected OsuConfigManager Config { get; private set; } = null!; - private readonly Bindable filter = new Bindable(new FilterCriteria()); + private IDisposable? joiningRoomOperation { get; set; } + private LeasedBindable? selectionLease; + + private readonly Bindable filter = new Bindable(); private readonly IBindable operationInProgress = new Bindable(); private readonly IBindable isIdle = new BindableBool(); - private PopoverContainer popoverContainer; - private LoadingLayer loadingLayer; - private RoomsContainer roomsContainer; - private SearchTextBox searchTextBox; - private Dropdown statusDropdown; + private PopoverContainer popoverContainer = null!; + private LoadingLayer loadingLayer = null!; + private RoomsContainer roomsContainer = null!; + private SearchTextBox searchTextBox = null!; + private Dropdown statusDropdown = null!; [BackgroundDependencyLoader(true)] - private void load([CanBeNull] IdleTracker idleTracker) + private void load() { const float controls_area_height = 25f; @@ -208,7 +205,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge public void UpdateFilter() => Scheduler.AddOnce(updateFilter); - private ScheduledDelegate scheduledFilterUpdate; + private ScheduledDelegate? scheduledFilterUpdate; private void updateFilterDebounced() { @@ -259,10 +256,10 @@ namespace osu.Game.Screens.OnlinePlay.Lounge selectionLease.Return(); selectionLease = null; - if (SelectedRoom.Value?.RoomID.Value == null) + if (SelectedRoom.Value?.RoomID == null) SelectedRoom.Value = new Room(); - music?.EnsurePlayingSomething(); + music.EnsurePlayingSomething(); onReturning(); } @@ -299,7 +296,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge popoverContainer.HidePopover(); } - public virtual void Join(Room room, string password, Action onSuccess = null, Action onFailure = null) => Schedule(() => + public virtual void Join(Room room, string? password, Action? onSuccess = null, Action? onFailure = null) => Schedule(() => { if (joiningRoomOperation != null) return; @@ -326,23 +323,23 @@ namespace osu.Game.Screens.OnlinePlay.Lounge /// The room to copy. public void OpenCopy(Room room) { - Debug.Assert(room.RoomID.Value != null); + Debug.Assert(room.RoomID != null); if (joiningRoomOperation != null) return; joiningRoomOperation = ongoingOperationTracker?.BeginOperation(); - var req = new GetRoomRequest(room.RoomID.Value.Value); + var req = new GetRoomRequest(room.RoomID.Value); req.Success += r => { // ID must be unset as we use this as a marker for whether this is a client-side (not-yet-created) room or not. - r.RoomID.Value = null; + r.RoomID = null; // Null out dates because end date is not supported client-side and the settings overlay will populate a duration. - r.EndDate.Value = null; - r.Duration.Value = null; + r.EndDate = null; + r.Duration = null; Open(r); @@ -364,7 +361,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge /// Push a room as a new subscreen. /// /// An optional template to use when creating the room. - public void Open(Room room = null) => Schedule(() => + public void Open(Room? room = null) => Schedule(() => { // Handles the case where a room is clicked 3 times in quick succession if (!this.IsCurrentScreen()) diff --git a/osu.Game/Screens/OnlinePlay/Match/Components/MatchChatDisplay.cs b/osu.Game/Screens/OnlinePlay/Match/Components/MatchChatDisplay.cs index 8dc1704fcd..a81425102d 100644 --- a/osu.Game/Screens/OnlinePlay/Match/Components/MatchChatDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Match/Components/MatchChatDisplay.cs @@ -1,8 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.ComponentModel; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Game.Online.Chat; using osu.Game.Online.Rooms; @@ -10,8 +10,6 @@ namespace osu.Game.Screens.OnlinePlay.Match.Components { public partial class MatchChatDisplay : StandAloneChatDisplay { - private readonly IBindable channelId = new Bindable(); - [Resolved] private ChannelManager? channelManager { get; set; } @@ -29,23 +27,30 @@ namespace osu.Game.Screens.OnlinePlay.Match.Components { base.LoadComplete(); - // Required for the time being since this component is created prior to the room being joined. - channelId.BindTo(room.ChannelId); - channelId.BindValueChanged(_ => updateChannel(), true); + room.PropertyChanged += onRoomPropertyChanged; + updateChannel(); + } + + private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Room.ChannelId)) + updateChannel(); } private void updateChannel() { - if (room.RoomID.Value == null || channelId.Value == 0) + if (room.RoomID == null || room.ChannelId == 0) return; - Channel.Value = channelManager?.JoinChannel(new Channel { Id = channelId.Value, Type = ChannelType.Multiplayer, Name = $"#lazermp_{room.RoomID.Value}" }); + Channel.Value = channelManager?.JoinChannel(new Channel { Id = room.ChannelId, Type = ChannelType.Multiplayer, Name = $"#lazermp_{room.RoomID.Value}" }); } protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); + room.PropertyChanged -= onRoomPropertyChanged; + if (leaveChannelOnDispose) channelManager?.LeaveChannel(Channel.Value); } diff --git a/osu.Game/Screens/OnlinePlay/Match/Components/MatchLeaderboard.cs b/osu.Game/Screens/OnlinePlay/Match/Components/MatchLeaderboard.cs index 4627cd4072..a7148abcde 100644 --- a/osu.Game/Screens/OnlinePlay/Match/Components/MatchLeaderboard.cs +++ b/osu.Game/Screens/OnlinePlay/Match/Components/MatchLeaderboard.cs @@ -1,9 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.ComponentModel; using System.Threading; -using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Leaderboards; @@ -13,30 +12,44 @@ namespace osu.Game.Screens.OnlinePlay.Match.Components { public partial class MatchLeaderboard : Leaderboard { - [Resolved(typeof(Room), nameof(Room.RoomID))] - private Bindable roomId { get; set; } = null!; + private readonly Room room; - [BackgroundDependencyLoader] - private void load() + public MatchLeaderboard(Room room) { - roomId.BindValueChanged(id => - { - if (id.NewValue == null) - return; + this.room = room; + } - SetScores(null); - RefetchScores(); - }, true); + protected override void LoadComplete() + { + base.LoadComplete(); + + room.PropertyChanged += onRoomPropertyChanged; + fetchInitialScores(); + } + + private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Room.RoomID)) + fetchInitialScores(); + } + + private void fetchInitialScores() + { + if (room.RoomID == null) + return; + + SetScores(null); + RefetchScores(); } protected override bool IsOnlineScope => true; protected override APIRequest? FetchScores(CancellationToken cancellationToken) { - if (roomId.Value == null) + if (room.RoomID == null) return null; - var req = new GetRoomLeaderboardRequest(roomId.Value ?? 0); + var req = new GetRoomLeaderboardRequest(room.RoomID.Value); req.Success += r => Schedule(() => { @@ -52,6 +65,12 @@ namespace osu.Game.Screens.OnlinePlay.Match.Components protected override LeaderboardScore CreateDrawableScore(APIUserScoreAggregate model, int index) => new MatchLeaderboardScore(model, index); protected override LeaderboardScore CreateDrawableTopScore(APIUserScoreAggregate model) => new MatchLeaderboardScore(model, model.Position, false); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + room.PropertyChanged -= onRoomPropertyChanged; + } } public enum MatchLeaderboardScope diff --git a/osu.Game/Screens/OnlinePlay/Match/Components/MatchLeaderboardScore.cs b/osu.Game/Screens/OnlinePlay/Match/Components/MatchLeaderboardScore.cs index fabebc3859..2ea0f9eb84 100644 --- a/osu.Game/Screens/OnlinePlay/Match/Components/MatchLeaderboardScore.cs +++ b/osu.Game/Screens/OnlinePlay/Match/Components/MatchLeaderboardScore.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.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; @@ -17,7 +15,7 @@ namespace osu.Game.Screens.OnlinePlay.Match.Components { private readonly APIUserScoreAggregate score; - public override ScoreInfo TooltipContent => null; // match aggregate scores can't show statistics that the custom tooltip displays. + public override ScoreInfo? TooltipContent => null; // match aggregate scores can't show statistics that the custom tooltip displays. public MatchLeaderboardScore(APIUserScoreAggregate score, int? rank, bool isOnlineScope = true) : base(score.CreateScoreInfo(), rank, isOnlineScope) diff --git a/osu.Game/Screens/OnlinePlay/Match/Components/MatchTypePicker.cs b/osu.Game/Screens/OnlinePlay/Match/Components/MatchTypePicker.cs index 477336e8ea..51bd7a2801 100644 --- a/osu.Game/Screens/OnlinePlay/Match/Components/MatchTypePicker.cs +++ b/osu.Game/Screens/OnlinePlay/Match/Components/MatchTypePicker.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -26,7 +24,7 @@ namespace osu.Game.Screens.OnlinePlay.Match.Components protected override TabItem CreateTabItem(MatchType value) => new GameTypePickerItem(value); - protected override Dropdown CreateDropdown() => null; + protected override Dropdown? CreateDropdown() => null; public MatchTypePicker() { @@ -41,7 +39,8 @@ namespace osu.Game.Screens.OnlinePlay.Match.Components { private const float transition_duration = 200; - private readonly CircularContainer hover, selection; + private readonly CircularContainer hover; + private readonly CircularContainer selection; public GameTypePickerItem(MatchType value) : base(value) @@ -84,7 +83,7 @@ namespace osu.Game.Screens.OnlinePlay.Match.Components }; } - private Sample selectSample; + private Sample selectSample = null!; [BackgroundDependencyLoader] private void load(OsuColour colours, AudioManager audio) diff --git a/osu.Game/Screens/OnlinePlay/Match/Components/RoomAvailabilityPicker.cs b/osu.Game/Screens/OnlinePlay/Match/Components/RoomAvailabilityPicker.cs index 85fac9228b..56f02ba633 100644 --- a/osu.Game/Screens/OnlinePlay/Match/Components/RoomAvailabilityPicker.cs +++ b/osu.Game/Screens/OnlinePlay/Match/Components/RoomAvailabilityPicker.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework.Allocation; using osu.Framework.Extensions; using osu.Framework.Extensions.Color4Extensions; @@ -22,7 +20,7 @@ namespace osu.Game.Screens.OnlinePlay.Match.Components public partial class RoomAvailabilityPicker : DisableableTabControl { protected override TabItem CreateTabItem(RoomAvailability value) => new RoomAvailabilityPickerItem(value); - protected override Dropdown CreateDropdown() => null; + protected override Dropdown? CreateDropdown() => null; public RoomAvailabilityPicker() { diff --git a/osu.Game/Screens/OnlinePlay/Match/Components/RoomSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Match/Components/RoomSettingsOverlay.cs index 916b799d50..09aed176c4 100644 --- a/osu.Game/Screens/OnlinePlay/Match/Components/RoomSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Match/Components/RoomSettingsOverlay.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -23,7 +21,7 @@ namespace osu.Game.Screens.OnlinePlay.Match.Components protected const float TRANSITION_DURATION = 350; protected const float FIELD_PADDING = 25; - protected OnlinePlayComposite Settings { get; set; } + protected Drawable Settings { get; set; } = null!; protected override bool BlockScrollInput => false; @@ -50,7 +48,7 @@ namespace osu.Game.Screens.OnlinePlay.Match.Components protected abstract void SelectBeatmap(); - protected abstract OnlinePlayComposite CreateSettings(Room room); + protected abstract Drawable CreateSettings(Room room); protected override void PopIn() { diff --git a/osu.Game/Screens/OnlinePlay/Match/DrawableMatchRoom.cs b/osu.Game/Screens/OnlinePlay/Match/DrawableMatchRoom.cs index 3bda93c909..0c993f4abf 100644 --- a/osu.Game/Screens/OnlinePlay/Match/DrawableMatchRoom.cs +++ b/osu.Game/Screens/OnlinePlay/Match/DrawableMatchRoom.cs @@ -1,16 +1,13 @@ // 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 JetBrains.Annotations; +using System.ComponentModel; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Beatmaps.Drawables; using osu.Game.Online.API; -using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Rooms; using osu.Game.Resources.Localisation.Web; using osu.Game.Screens.OnlinePlay.Lounge.Components; @@ -21,26 +18,27 @@ namespace osu.Game.Screens.OnlinePlay.Match { public partial class DrawableMatchRoom : DrawableRoom { - public readonly IBindable SelectedItem = new Bindable(); - public Action OnEdit; + public Action? OnEdit; + + public new required Bindable SelectedItem + { + get => selectedItem; + set => selectedItem.Current = value; + } [Resolved] - private IAPIProvider api { get; set; } + private IAPIProvider api { get; set; } = null!; - private readonly IBindable host = new Bindable(); + private readonly BindableWithCurrent selectedItem = new BindableWithCurrent(); private readonly bool allowEdit; - - [CanBeNull] - private Drawable editButton; - - private BackgroundSprite background; + private Drawable? editButton; public DrawableMatchRoom(Room room, bool allowEdit = true) : base(room) { this.allowEdit = allowEdit; - host.BindTo(room.Host); + base.SelectedItem.BindTo(SelectedItem); } [BackgroundDependencyLoader] @@ -62,17 +60,31 @@ namespace osu.Game.Screens.OnlinePlay.Match { base.LoadComplete(); - if (editButton != null) - host.BindValueChanged(h => editButton.Alpha = h.NewValue?.Equals(api.LocalUser.Value) == true ? 1 : 0, true); - - SelectedItem.BindValueChanged(item => background.Beatmap.Value = item.NewValue?.Beatmap, true); + Room.PropertyChanged += onRoomPropertyChanged; + updateRoomHost(); } - protected override Drawable CreateBackground() => background = new BackgroundSprite(); - - private partial class BackgroundSprite : UpdateableBeatmapBackgroundSprite + private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) { - protected override double LoadDelay => 0; + if (e.PropertyName == nameof(Room.Host)) + updateRoomHost(); + } + + private void updateRoomHost() + { + if (editButton != null) + editButton.Alpha = Room.Host?.Equals(api.LocalUser.Value) == true ? 1 : 0; + } + + protected override UpdateableBeatmapBackgroundSprite CreateBackground() => base.CreateBackground().With(d => + { + d.BackgroundLoadDelay = 0; + }); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + Room.PropertyChanged -= onRoomPropertyChanged; } } } diff --git a/osu.Game/Screens/OnlinePlay/Match/RoomModSelectOverlay.cs b/osu.Game/Screens/OnlinePlay/Match/RoomModSelectOverlay.cs index 6a856d8d72..ffa4235167 100644 --- a/osu.Game/Screens/OnlinePlay/Match/RoomModSelectOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Match/RoomModSelectOverlay.cs @@ -16,7 +16,7 @@ namespace osu.Game.Screens.OnlinePlay.Match { public partial class RoomModSelectOverlay : UserModSelectOverlay { - public Bindable SelectedItem { get; } = new Bindable(); + public Bindable SelectedItem { get; } = new Bindable(); [Resolved] private RulesetStore rulesets { get; set; } = null!; diff --git a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs index 7c8931c04e..ffea3878fa 100644 --- a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs @@ -1,13 +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; using System.Collections.Generic; +using System.ComponentModel; using System.Diagnostics; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -29,14 +27,14 @@ using osu.Game.Rulesets.Mods; using osu.Game.Screens.Menu; using osu.Game.Screens.OnlinePlay.Match.Components; using osu.Game.Screens.OnlinePlay.Multiplayer; +using Container = osu.Framework.Graphics.Containers.Container; namespace osu.Game.Screens.OnlinePlay.Match { [Cached(typeof(IPreviewTrackOwner))] public abstract partial class RoomSubScreen : OnlinePlaySubScreen, IPreviewTrackOwner { - [Cached(typeof(IBindable))] - public readonly Bindable SelectedItem = new Bindable(); + public readonly Bindable SelectedItem = new Bindable(); public override bool? ApplyModTrackAdjustments => true; @@ -51,38 +49,39 @@ namespace osu.Game.Screens.OnlinePlay.Match /// A container that provides controls for selection of user mods. /// This will be shown/hidden automatically when applicable. /// - protected Drawable UserModsSection; + protected Drawable? UserModsSection; - private Sample sampleStart; + private Sample? sampleStart; /// /// Any mods applied by/to the local user. /// protected readonly Bindable> UserMods = new Bindable>(Array.Empty()); - protected readonly IBindable RoomId = new Bindable(); - [Resolved(CanBeNull = true)] - private IOverlayManager overlayManager { get; set; } + private IOverlayManager? overlayManager { get; set; } [Resolved] - private MusicController music { get; set; } + private MusicController music { get; set; } = null!; [Resolved] - private BeatmapManager beatmapManager { get; set; } + private BeatmapManager beatmapManager { get; set; } = null!; [Resolved] - protected RulesetStore Rulesets { get; private set; } + protected RulesetStore Rulesets { get; private set; } = null!; [Resolved] private IAPIProvider api { get; set; } = null!; [Resolved(canBeNull: true)] - protected OnlinePlayScreen ParentScreen { get; private set; } + protected OnlinePlayScreen? ParentScreen { get; private set; } [Resolved] private PreviewTrackManager previewTrackManager { get; set; } = null!; + [Resolved(canBeNull: true)] + private IDialogOverlay? dialogOverlay { get; set; } + [Cached] private readonly OnlinePlayBeatmapAvailabilityTracker beatmapAvailabilityTracker = new OnlinePlayBeatmapAvailabilityTracker(); @@ -91,13 +90,11 @@ namespace osu.Game.Screens.OnlinePlay.Match public readonly Room Room; private readonly bool allowEdit; - internal ModSelectOverlay UserModsSelectOverlay { get; private set; } + internal ModSelectOverlay UserModsSelectOverlay { get; private set; } = null!; - [CanBeNull] - private IDisposable userModsSelectOverlayRegistration; - - private RoomSettingsOverlay settingsOverlay; - private Drawable mainContent; + private IDisposable? userModsSelectOverlayRegistration; + private RoomSettingsOverlay settingsOverlay = null!; + private Drawable mainContent = null!; /// /// Creates a new . @@ -110,8 +107,6 @@ namespace osu.Game.Screens.OnlinePlay.Match this.allowEdit = allowEdit; Padding = new MarginPadding { Top = Header.HEIGHT }; - - RoomId.BindTo(room.RoomID); } [BackgroundDependencyLoader] @@ -164,7 +159,7 @@ namespace osu.Game.Screens.OnlinePlay.Match new DrawableMatchRoom(Room, allowEdit) { OnEdit = () => settingsOverlay.Show(), - SelectedItem = { BindTarget = SelectedItem } + SelectedItem = SelectedItem } }, null, @@ -253,22 +248,6 @@ namespace osu.Game.Screens.OnlinePlay.Match { base.LoadComplete(); - RoomId.BindValueChanged(id => - { - if (id.NewValue == null) - { - // A new room is being created. - // The main content should be hidden until the settings overlay is hidden, signaling the room is ready to be displayed. - mainContent.Hide(); - settingsOverlay.Show(); - } - else - { - mainContent.Show(); - settingsOverlay.Hide(); - } - }, true); - SelectedItem.BindValueChanged(_ => Scheduler.AddOnce(selectedItemChanged)); UserMods.BindValueChanged(_ => Scheduler.AddOnce(UpdateMods)); @@ -276,24 +255,38 @@ namespace osu.Game.Screens.OnlinePlay.Match beatmapAvailabilityTracker.Availability.BindValueChanged(_ => updateWorkingBeatmap()); userModsSelectOverlayRegistration = overlayManager?.RegisterBlockingOverlay(UserModsSelectOverlay); + + Room.PropertyChanged += onRoomPropertyChanged; + updateSetupState(); } - protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) { - return new CachedModelDependencyContainer(base.CreateChildDependencies(parent)) - { - Model = { Value = Room } - }; + if (e.PropertyName == nameof(Room.RoomID)) + updateSetupState(); } - [Resolved(canBeNull: true)] - private IDialogOverlay dialogOverlay { get; set; } + private void updateSetupState() + { + if (Room.RoomID == null) + { + // A new room is being created. + // The main content should be hidden until the settings overlay is hidden, signaling the room is ready to be displayed. + mainContent.Hide(); + settingsOverlay.Show(); + } + else + { + mainContent.Show(); + settingsOverlay.Hide(); + } + } protected virtual bool IsConnected => api.State.Value == APIState.Online; public override bool OnBackButton() { - if (Room.RoomID.Value == null) + if (Room.RoomID == null) { if (!ensureExitConfirmed()) return true; @@ -366,7 +359,7 @@ namespace osu.Game.Screens.OnlinePlay.Match if (!IsConnected) return true; - bool hasUnsavedChanges = Room.RoomID.Value == null && Room.Playlist.Count > 0; + bool hasUnsavedChanges = Room.RoomID == null && Room.Playlist.Count > 0; if (dialogOverlay == null || !hasUnsavedChanges) return true; @@ -390,6 +383,9 @@ namespace osu.Game.Screens.OnlinePlay.Match protected void StartPlay() { + if (SelectedItem.Value == null) + return; + // User may be at song select or otherwise when the host starts gameplay. // Ensure that they first return to this screen, else global bindables (beatmap etc.) may be in a bad state. if (!this.IsCurrentScreen()) @@ -403,29 +399,28 @@ namespace osu.Game.Screens.OnlinePlay.Match sampleStart?.Play(); // fallback is to allow this class to operate when there is no parent OnlineScreen (testing purposes). - var targetScreen = (Screen)ParentScreen ?? this; + var targetScreen = (Screen?)ParentScreen ?? this; - targetScreen.Push(CreateGameplayScreen()); + targetScreen.Push(CreateGameplayScreen(SelectedItem.Value)); } /// /// Creates the gameplay screen to be entered. /// + /// The playlist item about to be played. /// The screen to enter. - protected abstract Screen CreateGameplayScreen(); + protected abstract Screen CreateGameplayScreen(PlaylistItem selectedItem); private void selectedItemChanged() { updateWorkingBeatmap(); - var selected = SelectedItem.Value; - - if (selected == null) + if (SelectedItem.Value is not PlaylistItem selected) return; - var rulesetInstance = Rulesets.GetRuleset(SelectedItem.Value.RulesetID)?.CreateInstance(); + var rulesetInstance = Rulesets.GetRuleset(selected.RulesetID)?.CreateInstance(); Debug.Assert(rulesetInstance != null); - var allowedMods = SelectedItem.Value.AllowedMods.Select(m => m.ToMod(rulesetInstance)); + var allowedMods = selected.AllowedMods.Select(m => m.ToMod(rulesetInstance)); // Remove any user mods that are no longer allowed. UserMods.Value = UserMods.Value.Where(m => allowedMods.Any(a => m.GetType() == a.GetType())).ToList(); @@ -496,7 +491,7 @@ namespace osu.Game.Screens.OnlinePlay.Match cancelTrackLooping(); } - private void applyLoopingToTrack(ValueChangedEvent _ = null) + private void applyLoopingToTrack(ValueChangedEvent? _ = null) { if (!this.IsCurrentScreen()) return; @@ -505,8 +500,8 @@ namespace osu.Game.Screens.OnlinePlay.Match if (track != null) { - Beatmap.Value.PrepareTrackForPreview(true); - music?.EnsurePlayingSomething(); + Beatmap.Value!.PrepareTrackForPreview(true); + music.EnsurePlayingSomething(); } } @@ -539,6 +534,7 @@ namespace osu.Game.Screens.OnlinePlay.Match base.Dispose(isDisposing); userModsSelectOverlayRegistration?.Dispose(); + Room.PropertyChanged -= onRoomPropertyChanged; } } } diff --git a/osu.Game/Screens/OnlinePlay/Match/UserModSelectButton.cs b/osu.Game/Screens/OnlinePlay/Match/UserModSelectButton.cs index f3ea82be99..219bb6a2e3 100644 --- a/osu.Game/Screens/OnlinePlay/Match/UserModSelectButton.cs +++ b/osu.Game/Screens/OnlinePlay/Match/UserModSelectButton.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. -#nullable disable using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Game.Input.Bindings; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/CreateMultiplayerMatchButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/CreateMultiplayerMatchButton.cs index 7975597beb..9de32267a2 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/CreateMultiplayerMatchButton.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/CreateMultiplayerMatchButton.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Online.Multiplayer; @@ -12,14 +10,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { public partial class CreateMultiplayerMatchButton : CreateRoomButton { - private IBindable isConnected; - private IBindable operationInProgress; + private IBindable isConnected = null!; + private IBindable operationInProgress = null!; [Resolved] - private MultiplayerClient multiplayerClient { get; set; } + private MultiplayerClient multiplayerClient { get; set; } = null!; [Resolved] - private OngoingOperationTracker ongoingOperationTracker { get; set; } + private OngoingOperationTracker ongoingOperationTracker { get; set; } = null!; [BackgroundDependencyLoader] private void load() diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayChatDisplay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayChatDisplay.cs index d1a73457e3..9a03a131b4 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayChatDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayChatDisplay.cs @@ -1,9 +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 JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -20,12 +17,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer public partial class GameplayChatDisplay : MatchChatDisplay, IKeyBindingHandler { [Resolved(CanBeNull = true)] - [CanBeNull] - private ILocalUserPlayInfo localUserInfo { get; set; } + private ILocalUserPlayInfo? localUserInfo { get; set; } - private readonly IBindable localUserPlaying = new Bindable(); + private readonly IBindable localUserPlaying = new Bindable(); - public override bool PropagatePositionalInputSubTree => !localUserPlaying.Value; + public override bool PropagatePositionalInputSubTree => localUserPlaying.Value != LocalUserPlayingState.Playing; public Bindable Expanded = new Bindable(); @@ -58,7 +54,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer base.LoadComplete(); if (localUserInfo != null) - localUserPlaying.BindTo(localUserInfo.IsPlaying); + localUserPlaying.BindTo(localUserInfo.PlayingState); localUserPlaying.BindValueChanged(playing => { @@ -67,7 +63,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer TextBox.HoldFocus = false; // only hold focus (after sending a message) during breaks - TextBox.ReleaseFocusOnCommit = playing.NewValue; + TextBox.ReleaseFocusOnCommit = playing.NewValue == LocalUserPlayingState.Playing; }, true); Expanded.BindValueChanged(_ => updateExpandedState(), true); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs index ba3508b24f..0d90d44496 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs @@ -1,47 +1,54 @@ // 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; } + public required Bindable SelectedItem + { + get => selectedItem; + set => selectedItem.Current = value; + } - [CanBeNull] - private IDisposable clickOperation; + [Resolved] + 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!; + private readonly BindableWithCurrent selectedItem = new BindableWithCurrent(); 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 +98,29 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { base.LoadComplete(); - CurrentPlaylistItem.BindValueChanged(_ => updateState()); - } - - protected override void OnRoomUpdated() - { - base.OnRoomUpdated(); + SelectedItem.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 +133,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 +151,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 +159,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 +182,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 +213,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 + && SelectedItem.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 +251,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/MultiplayerCountdownButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerCountdownButton.cs index e1543eaceb..50e996d266 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerCountdownButton.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerCountdownButton.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 Humanizer; @@ -33,15 +31,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match TimeSpan.FromMinutes(2) }; - public new Action Action; - - public Action CancelAction; + public new required Action Action; + public required Action CancelAction; [Resolved] - private MultiplayerClient multiplayerClient { get; set; } + private MultiplayerClient multiplayerClient { get; set; } = null!; [Resolved] - private OsuColour colours { get; set; } + private OsuColour colours { get; set; } = null!; private readonly Drawable background; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs index fcb6480b58..2b592bd8b9 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs @@ -1,10 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Online.Rooms; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { @@ -13,6 +13,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match private const float ready_button_width = 600; private const float spectate_button_width = 200; + public required Bindable SelectedItem + { + get => selectedItem; + set => selectedItem.Current = value; + } + + private readonly BindableWithCurrent selectedItem = new BindableWithCurrent(); + public MultiplayerMatchFooter() { RelativeSizeAxes = Axes.Both; @@ -22,17 +30,19 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match RelativeSizeAxes = Axes.Both, Content = new[] { - new Drawable[] + new Drawable?[] { null, new MultiplayerSpectateButton { RelativeSizeAxes = Axes.Both, + SelectedItem = selectedItem }, null, new MatchStartControl { RelativeSizeAxes = Axes.Both, + SelectedItem = selectedItem }, null } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs index 5446211ced..79617f172c 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs @@ -4,6 +4,7 @@ using System; using System.ComponentModel; using System.Diagnostics; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; @@ -28,14 +29,20 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { public partial class MultiplayerMatchSettingsOverlay : RoomSettingsOverlay { - private MatchSettings settings = null!; + public required Bindable SelectedItem + { + get => selectedItem; + set => selectedItem.Current = value; + } protected override OsuButton SubmitButton => settings.ApplyButton; + protected override bool IsLoading => ongoingOperationTracker.InProgress.Value; [Resolved] private OngoingOperationTracker ongoingOperationTracker { get; set; } = null!; - protected override bool IsLoading => ongoingOperationTracker.InProgress.Value; + private readonly BindableWithCurrent selectedItem = new BindableWithCurrent(); + private MatchSettings settings = null!; public MultiplayerMatchSettingsOverlay(Room room) : base(room) @@ -44,19 +51,21 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match protected override void SelectBeatmap() => settings.SelectBeatmap(); - protected override OnlinePlayComposite CreateSettings(Room room) => settings = new MatchSettings(room) + protected override Drawable CreateSettings(Room room) => settings = new MatchSettings(room) { RelativeSizeAxes = Axes.Both, RelativePositionAxes = Axes.Y, - SettingsApplied = Hide + SettingsApplied = Hide, + SelectedItem = { BindTarget = SelectedItem } }; - protected partial class MatchSettings : OnlinePlayComposite + protected partial class MatchSettings : CompositeDrawable { private const float disabled_alpha = 0.2f; public override bool IsPresent => base.IsPresent || Scheduler.HasPendingTasks; + public readonly Bindable SelectedItem = new Bindable(); public Action? SettingsApplied; public OsuTextBox NameField = null!; @@ -66,7 +75,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match public OsuTextBox PasswordTextBox = null!; public OsuCheckbox AutoSkipCheckbox = null!; public RoundedButton ApplyButton = null!; - public OsuSpriteText ErrorText = null!; private OsuEnumDropdown startModeDropdown = null!; @@ -270,7 +278,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match drawablePlaylist = new DrawableRoomPlaylist { RelativeSizeAxes = Axes.X, - Height = DrawableRoomPlaylistItem.HEIGHT + Height = DrawableRoomPlaylistItem.HEIGHT, + SelectedItem = { BindTarget = SelectedItem } }, selectBeatmapButton = new RoundedButton { @@ -316,7 +325,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match Padding = new MarginPadding { Horizontal = OsuScreen.HORIZONTAL_OVERFLOW_PADDING }, Children = new Drawable[] { - ApplyButton = new CreateOrUpdateButton + ApplyButton = new CreateOrUpdateButton(room) { Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, @@ -343,14 +352,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match }; TypePicker.Current.BindValueChanged(type => typeLabel.Text = type.NewValue.GetLocalisableDescription(), true); - RoomName.BindValueChanged(name => NameField.Text = name.NewValue, true); - Type.BindValueChanged(type => TypePicker.Current.Value = type.NewValue, true); - MaxParticipants.BindValueChanged(count => MaxParticipantsField.Text = count.NewValue?.ToString(), true); - RoomID.BindValueChanged(roomId => playlistContainer.Alpha = roomId.NewValue == null ? 1 : 0, true); - Password.BindValueChanged(password => PasswordTextBox.Text = password.NewValue ?? string.Empty, true); - QueueMode.BindValueChanged(mode => QueueModeDropdown.Current.Value = mode.NewValue, true); - AutoStartDuration.BindValueChanged(duration => startModeDropdown.Current.Value = (StartMode)(int)duration.NewValue.TotalSeconds, true); - AutoSkip.BindValueChanged(autoSkip => AutoSkipCheckbox.Current.Value = autoSkip.NewValue, true); operationInProgress.BindTo(ongoingOperationTracker.InProgress); operationInProgress.BindValueChanged(v => @@ -366,15 +367,88 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { base.LoadComplete(); - drawablePlaylist.Items.BindTo(Playlist); - drawablePlaylist.SelectedItem.BindTo(CurrentPlaylistItem); + room.PropertyChanged += onRoomPropertyChanged; + + updateRoomName(); + updateRoomType(); + updateRoomQueueMode(); + updateRoomPassword(); + updateRoomAutoSkip(); + updateRoomMaxParticipants(); + updateRoomAutoStartDuration(); + updateRoomPlaylist(); + + drawablePlaylist.Items.BindCollectionChanged((_, __) => room.Playlist = drawablePlaylist.Items.ToArray()); } + private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(Room.Name): + updateRoomName(); + break; + + case nameof(Room.Type): + updateRoomName(); + break; + + case nameof(Room.QueueMode): + updateRoomQueueMode(); + break; + + case nameof(Room.Password): + updateRoomPassword(); + break; + + case nameof(Room.AutoSkip): + updateRoomAutoSkip(); + break; + + case nameof(Room.MaxParticipants): + updateRoomMaxParticipants(); + break; + + case nameof(Room.AutoStartDuration): + updateRoomAutoStartDuration(); + break; + + case nameof(Room.Playlist): + updateRoomPlaylist(); + break; + } + } + + private void updateRoomName() + => NameField.Text = room.Name; + + private void updateRoomType() + => TypePicker.Current.Value = room.Type; + + private void updateRoomQueueMode() + => QueueModeDropdown.Current.Value = room.QueueMode; + + private void updateRoomPassword() + => PasswordTextBox.Text = room.Password ?? string.Empty; + + private void updateRoomAutoSkip() + => AutoSkipCheckbox.Current.Value = room.AutoSkip; + + private void updateRoomMaxParticipants() + => MaxParticipantsField.Text = room.MaxParticipants?.ToString(); + + private void updateRoomAutoStartDuration() + => startModeDropdown.Current.Value = (StartMode)room.AutoStartDuration.TotalSeconds; + + private void updateRoomPlaylist() + => drawablePlaylist.Items.ReplaceRange(0, drawablePlaylist.Items.Count, room.Playlist); + protected override void Update() { base.Update(); - ApplyButton.Enabled.Value = Playlist.Count > 0 && NameField.Text.Length > 0 && !operationInProgress.Value; + ApplyButton.Enabled.Value = room.Playlist.Count > 0 && NameField.Text.Length > 0 && !operationInProgress.Value; + playlistContainer.Alpha = room.RoomID == null ? 1 : 0; } private void apply() @@ -387,8 +461,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match Debug.Assert(applyingSettingsOperation == null); applyingSettingsOperation = ongoingOperationTracker.BeginOperation(); - TimeSpan autoStartDuration = TimeSpan.FromSeconds((int)startModeDropdown.Current.Value); - // If the client is already in a room, update via the client. // Otherwise, update the room directly in preparation for it to be submitted to the API on match creation. if (client.Room != null) @@ -398,7 +470,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match password: PasswordTextBox.Text, matchType: TypePicker.Current.Value, queueMode: QueueModeDropdown.Current.Value, - autoStartDuration: autoStartDuration, + autoStartDuration: TimeSpan.FromSeconds((int)startModeDropdown.Current.Value), autoSkip: AutoSkipCheckbox.Current.Value) .ContinueWith(t => Schedule(() => { @@ -410,17 +482,17 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match } else { - room.Name.Value = NameField.Text; - room.Type.Value = TypePicker.Current.Value; - room.Password.Value = PasswordTextBox.Current.Value; - room.QueueMode.Value = QueueModeDropdown.Current.Value; - room.AutoStartDuration.Value = autoStartDuration; - room.AutoSkip.Value = AutoSkipCheckbox.Current.Value; + room.Name = NameField.Text; + room.Type = TypePicker.Current.Value; + room.Password = PasswordTextBox.Current.Value; + room.QueueMode = QueueModeDropdown.Current.Value; + room.AutoStartDuration = TimeSpan.FromSeconds((int)startModeDropdown.Current.Value); + room.AutoSkip = AutoSkipCheckbox.Current.Value; if (int.TryParse(MaxParticipantsField.Text, out int max)) - room.MaxParticipants.Value = max; + room.MaxParticipants = max; else - room.MaxParticipants.Value = null; + room.MaxParticipants = null; manager.CreateRoom(room, onSuccess, onError); } @@ -448,7 +520,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match if (text.StartsWith(not_found_prefix, StringComparison.Ordinal)) { ErrorText.Text = "The selected beatmap is not available online."; - CurrentPlaylistItem.Value.MarkInvalid(); + SelectedItem.Value?.MarkInvalid(); } else { @@ -460,17 +532,21 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match applyingSettingsOperation.Dispose(); applyingSettingsOperation = null; }); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + room.PropertyChanged -= onRoomPropertyChanged; + } } public partial class CreateOrUpdateButton : RoundedButton { - [Resolved(typeof(Room), nameof(Room.RoomID))] - private Bindable roomId { get; set; } = null!; + private readonly Room room; - protected override void LoadComplete() + public CreateOrUpdateButton(Room room) { - base.LoadComplete(); - roomId.BindValueChanged(id => Text = id.NewValue == null ? "Create" : "Update", true); + this.room = room; } [BackgroundDependencyLoader] @@ -478,6 +554,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { BackgroundColour = colours.YellowDark; } + + protected override void Update() + { + base.Update(); + + Text = room.RoomID == null ? "Create" : "Update"; + } } private enum StartMode diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs index 7ce3dde7c2..ca8bc0b262 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs @@ -1,14 +1,13 @@ // 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.Extensions.ObjectExtensions; using osu.Framework.Localisation; using osu.Framework.Threading; using osu.Game.Graphics; @@ -20,17 +19,16 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match public partial class MultiplayerReadyButton : ReadyButton { [Resolved] - private MultiplayerClient multiplayerClient { get; set; } + private MultiplayerClient multiplayerClient { get; set; } = null!; [Resolved] - private OsuColour colours { get; set; } + private OsuColour colours { get; set; } = null!; - [CanBeNull] - private MultiplayerRoom room => multiplayerClient.Room; + private MultiplayerRoom? room => multiplayerClient.Room; - private Sample countdownTickSample; - private Sample countdownWarnSample; - private Sample countdownWarnFinalSample; + private Sample? countdownTickSample; + private Sample? countdownWarnSample; + private Sample? countdownWarnFinalSample; [BackgroundDependencyLoader] private void load(AudioManager audio) @@ -48,13 +46,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match onRoomUpdated(); } - private MultiplayerCountdown countdown; + private MultiplayerCountdown? countdown; private double countdownChangeTime; - private ScheduledDelegate countdownUpdateDelegate; + private ScheduledDelegate? countdownUpdateDelegate; private void onRoomUpdated() => Scheduler.AddOnce(() => { - MultiplayerCountdown newCountdown = room?.ActiveCountdowns.SingleOrDefault(c => c is MatchStartCountdown); + MultiplayerCountdown? newCountdown = room?.ActiveCountdowns.SingleOrDefault(c => c is MatchStartCountdown); if (newCountdown != countdown) { @@ -171,6 +169,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { get { + Debug.Assert(countdown != null); + double timeElapsed = Time.Current - countdownChangeTime; TimeSpan remaining; @@ -224,7 +224,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { base.Dispose(isDisposing); - if (multiplayerClient != null) + if (multiplayerClient.IsNotNull()) multiplayerClient.RoomUpdated -= onRoomUpdated; } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs index ea7ab2dce3..3186cf89a4 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,18 +19,28 @@ using osuTK; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { - public partial class MultiplayerSpectateButton : MultiplayerRoomComposite + public partial class MultiplayerSpectateButton : CompositeDrawable { + public required Bindable SelectedItem + { + get => selectedItem; + set => selectedItem.Current = value; + } + [Resolved] private OngoingOperationTracker ongoingOperationTracker { get; set; } = null!; [Resolved] private OsuColour colours { get; set; } = null!; - private IBindable operationInProgress = null!; + [Resolved] + private MultiplayerClient client { get; set; } = null!; + private readonly BindableWithCurrent selectedItem = new BindableWithCurrent(); private readonly RoundedButton button; + private IBindable operationInProgress = null!; + public MultiplayerSpectateButton() { InternalChild = button = new RoundedButton @@ -44,9 +56,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { var clickOperation = ongoingOperationTracker.BeginOperation(); - Client.ToggleSpectate().ContinueWith(_ => endOperation()); + client.ToggleSpectate().ContinueWith(_ => endOperation()); - void endOperation() => clickOperation?.Dispose(); + void endOperation() => clickOperation.Dispose(); } [BackgroundDependencyLoader] @@ -63,19 +75,16 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { base.LoadComplete(); - CurrentPlaylistItem.BindValueChanged(_ => Scheduler.AddOnce(checkForAutomaticDownload), true); - } - - protected override void OnRoomUpdated() - { - base.OnRoomUpdated(); - + SelectedItem.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 +97,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 +121,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match private void checkForAutomaticDownload() { - PlaylistItem? currentItem = CurrentPlaylistItem.Value; + PlaylistItem? item = SelectedItem.Value; downloadCheckCancellation?.Cancel(); - if (currentItem == null) + if (item == null) return; if (!automaticallyDownload.Value) @@ -128,13 +137,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 +159,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..9feee0ae41 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,20 +15,36 @@ 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(); + public required Bindable SelectedItem + { + get => selectedItem; + set => selectedItem.Current = value; + } + /// /// 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!; + + private readonly Room room; + private readonly BindableWithCurrent selectedItem = new BindableWithCurrent(); + private MultiplayerPlaylistTabControl playlistTabControl = null!; + private MultiplayerQueueList queueList = null!; + private MultiplayerHistoryList historyList = null!; private bool firstPopulation = true; + public MultiplayerPlaylist(Room room) + { + this.room = room; + } + [BackgroundDependencyLoader] private void load() { @@ -51,17 +65,17 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist Masking = true, Children = new Drawable[] { - queueList = new MultiplayerQueueList + queueList = new MultiplayerQueueList(room) { RelativeSizeAxes = Axes.Both, - SelectedItem = { BindTarget = CurrentPlaylistItem }, + SelectedItem = { BindTarget = selectedItem }, RequestEdit = item => RequestEdit?.Invoke(item) }, historyList = new MultiplayerHistoryList { RelativeSizeAxes = Axes.Both, Alpha = 0, - SelectedItem = { BindTarget = CurrentPlaylistItem } + SelectedItem = { BindTarget = selectedItem } } } } @@ -73,7 +87,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 +102,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 +116,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 +150,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/Match/Playlist/MultiplayerQueueList.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerQueueList.cs index 77d82c4347..04bb9b69e6 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerQueueList.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerQueueList.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.Collections.Generic; +using System.ComponentModel; using System.Linq; using osu.Framework.Allocation; -using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Online.API; @@ -21,28 +20,49 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist /// public partial class MultiplayerQueueList : DrawableRoomPlaylist { - public MultiplayerQueueList() + private readonly Room room; + + private QueueFillFlowContainer flow = null!; + + public MultiplayerQueueList(Room room) { + this.room = room; ShowItemOwners = true; } - protected override FillFlowContainer> CreateListFillFlowContainer() => new QueueFillFlowContainer + protected override void LoadComplete() + { + base.LoadComplete(); + + room.PropertyChanged += onRoomPropertyChanged; + updateRoomPlaylist(); + } + + private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Room.Playlist)) + updateRoomPlaylist(); + } + + private void updateRoomPlaylist() + => flow.InvalidateLayout(); + + protected override FillFlowContainer> CreateListFillFlowContainer() => flow = new QueueFillFlowContainer { Spacing = new Vector2(0, 2) }; protected override DrawableRoomPlaylistItem CreateDrawablePlaylistItem(PlaylistItem item) => new QueuePlaylistItem(item); + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + room.PropertyChanged -= onRoomPropertyChanged; + } + private partial class QueueFillFlowContainer : FillFlowContainer> { - [Resolved(typeof(Room), nameof(Room.Playlist))] - private BindableList roomPlaylist { get; set; } - - protected override void LoadComplete() - { - base.LoadComplete(); - roomPlaylist.BindCollectionChanged((_, _) => InvalidateLayout()); - } + public new void InvalidateLayout() => base.InvalidateLayout(); public override IEnumerable FlowingChildren => base.FlowingChildren.OfType>().OrderBy(item => item.Model.PlaylistOrder); } @@ -50,10 +70,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist private partial class QueuePlaylistItem : DrawableRoomPlaylistItem { [Resolved] - private IAPIProvider api { get; set; } + private IAPIProvider api { get; set; } = null!; [Resolved] - private MultiplayerClient multiplayerClient { get; set; } + private MultiplayerClient multiplayerClient { get; set; } = null!; public QueuePlaylistItem(PlaylistItem item) : base(item) @@ -91,7 +111,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist { base.Dispose(isDisposing); - if (multiplayerClient != null) + if (multiplayerClient.IsNotNull()) multiplayerClient.RoomUpdated -= onRoomUpdated; } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs index a3a6fd2d8e..50358ea9d3 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.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.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -27,12 +25,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer public partial class MultiplayerLoungeSubScreen : LoungeSubScreen { [Resolved] - private IAPIProvider api { get; set; } + private IAPIProvider api { get; set; } = null!; [Resolved] - private MultiplayerClient client { get; set; } + private MultiplayerClient client { get; set; } = null!; - private Dropdown roomAccessTypeDropdown; + private Dropdown roomAccessTypeDropdown = null!; public override void OnResuming(ScreenTransitionEvent e) { @@ -73,8 +71,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer protected override Room CreateNewRoom() => new Room { - Name = { Value = $"{api.LocalUser}'s awesome room" }, - Type = { Value = MatchType.HeadToHead }, + Name = $"{api.LocalUser}'s awesome room", + Type = MatchType.HeadToHead, }; protected override RoomSubScreen CreateRoomSubScreen(Room room) => new MultiplayerMatchSubScreen(room); @@ -83,7 +81,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer protected override void OpenNewRoom(Room room) { - if (client?.IsConnected.Value != true) + if (!client.IsConnected.Value) { Logger.Log("Not currently connected to the multiplayer server.", LoggingTarget.Runtime, LogLevel.Important); return; @@ -95,7 +93,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer private partial class MultiplayerListingPollingComponent : ListingPollingComponent { [Resolved] - private MultiplayerClient client { get; set; } + private MultiplayerClient client { get; set; } = null!; private readonly IBindable isConnected = new Bindable(); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs index 873a1b0d50..4e03c19095 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs @@ -24,6 +24,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer [Resolved] private OngoingOperationTracker operationTracker { get; set; } = null!; + private readonly Room room; private readonly IBindable operationInProgress = new Bindable(); private readonly PlaylistItem? itemToEdit; @@ -38,6 +39,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer public MultiplayerMatchSongSelect(Room room, PlaylistItem? itemToEdit = null) : base(room, itemToEdit) { + this.room = room; this.itemToEdit = itemToEdit; } @@ -111,8 +113,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer } else { - Playlist.Clear(); - Playlist.Add(item); + room.Playlist = [item]; this.Exit(); } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs index a37314de0e..edc45dbf7c 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.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.Collections.Generic; using System.Diagnostics; using System.Linq; 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.Logging; @@ -45,17 +44,17 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer public override string ShortTitle => "room"; [Resolved] - private MultiplayerClient client { get; set; } + private MultiplayerClient client { get; set; } = null!; [Resolved(canBeNull: true)] - private OsuGame game { get; set; } + private OsuGame? game { get; set; } - private AddItemButton addItemButton; + private AddItemButton addItemButton = null!; public MultiplayerMatchSubScreen(Room room) : base(room) { - Title = room.RoomID.Value == null ? "New room" : room.Name.Value; + Title = room.RoomID == null ? "New room" : room.Name; Activity.Value = new UserActivity.InLobby(room); } @@ -95,7 +94,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer }, Content = new[] { - new Drawable[] + new Drawable?[] { // Participants column new GridContainer @@ -139,10 +138,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer null, new Drawable[] { - new MultiplayerPlaylist + new MultiplayerPlaylist(Room) { RelativeSizeAxes = Axes.Both, - RequestEdit = OpenSongSelection + RequestEdit = OpenSongSelection, + SelectedItem = SelectedItem } }, new[] @@ -220,7 +220,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer /// Opens the song selection screen to add or edit an item. /// /// An optional playlist item to edit. If null, a new item will be added instead. - internal void OpenSongSelection(PlaylistItem itemToEdit = null) + internal void OpenSongSelection(PlaylistItem? itemToEdit = null) { if (!this.IsCurrentScreen()) return; @@ -228,9 +228,15 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer this.Push(new MultiplayerMatchSongSelect(Room, itemToEdit)); } - protected override Drawable CreateFooter() => new MultiplayerMatchFooter(); + protected override Drawable CreateFooter() => new MultiplayerMatchFooter + { + SelectedItem = SelectedItem + }; - protected override RoomSettingsOverlay CreateRoomSettingsOverlay(Room room) => new MultiplayerMatchSettingsOverlay(room); + protected override RoomSettingsOverlay CreateRoomSettingsOverlay(Room room) => new MultiplayerMatchSettingsOverlay(room) + { + SelectedItem = SelectedItem + }; protected override void UpdateMods() { @@ -245,7 +251,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer } [Resolved(canBeNull: true)] - private IDialogOverlay dialogOverlay { get; set; } + private IDialogOverlay? dialogOverlay { get; set; } private bool exitConfirmed; @@ -275,8 +281,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer return base.OnExiting(e); } - private ModSettingChangeTracker modSettingChangeTracker; - private ScheduledDelegate debouncedModSettingsUpdate; + private ModSettingChangeTracker? modSettingChangeTracker; + private ScheduledDelegate? debouncedModSettingsUpdate; private void onUserModsChanged(ValueChangedEvent> mods) { @@ -352,7 +358,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer Activity.Value = new UserActivity.InLobby(Room); } - private bool localUserCanAddItem => client.IsHost || Room.QueueMode.Value != QueueMode.HostOnly; + private bool localUserCanAddItem => client.IsHost || Room.QueueMode != QueueMode.HostOnly; private void updateCurrentItem() { @@ -395,7 +401,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer StartPlay(); } - protected override Screen CreateGameplayScreen() + protected override Screen CreateGameplayScreen(PlaylistItem selectedItem) { Debug.Assert(client.LocalUser != null); Debug.Assert(client.Room != null); @@ -409,7 +415,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer return new MultiSpectatorScreen(Room, users.Take(PlayerGrid.MAX_PLAYERS).ToArray()); default: - return new MultiplayerPlayerLoader(() => new MultiplayerPlayer(Room, SelectedItem.Value, users)); + return new MultiplayerPlayerLoader(() => new MultiplayerPlayer(Room, selectedItem, users)); } } @@ -422,7 +428,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer return; // If there's only one playlist item and we are the host, assume we want to change it. Else add a new one. - PlaylistItem itemToEdit = client.IsHost && Room.Playlist.Count == 1 ? Room.Playlist.Single() : null; + PlaylistItem? itemToEdit = client.IsHost && Room.Playlist.Count == 1 ? Room.Playlist.Single() : null; OpenSongSelection(itemToEdit); @@ -434,7 +440,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { base.Dispose(isDisposing); - if (client != null) + if (client.IsNotNull()) { client.RoomUpdated -= onRoomUpdated; client.LoadRequested -= onLoadRequested; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs index e560c5ca5d..111b453adb 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs @@ -1,14 +1,13 @@ // 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 System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Logging; using osu.Framework.Screens; using osu.Game.Graphics.UserInterface; @@ -29,17 +28,15 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer protected override UserActivity InitialActivity => new UserActivity.InMultiplayerGame(Beatmap.Value.BeatmapInfo, Ruleset.Value); [Resolved] - private MultiplayerClient client { get; set; } + private MultiplayerClient client { get; set; } = null!; - private IBindable isConnected; + private IBindable isConnected = null!; private readonly TaskCompletionSource resultsReady = new TaskCompletionSource(); - private readonly MultiplayerRoomUser[] users; - private LoadingLayer loadingDisplay; - - private MultiplayerGameplayLeaderboard multiplayerLeaderboard; + private LoadingLayer loadingDisplay = null!; + private MultiplayerGameplayLeaderboard multiplayerLeaderboard = null!; /// /// Construct a multiplayer player. @@ -53,8 +50,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer AllowPause = false, AllowRestart = false, AllowFailAnimation = false, - AllowSkipping = room.AutoSkip.Value, - AutomaticallySkipIntro = room.AutoSkip.Value, + AllowSkipping = room.AutoSkip, + AutomaticallySkipIntro = room.AutoSkip, AlwaysShowLeaderboard = true, }) { @@ -153,12 +150,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer GameplayClockContainer.Reset(); } - private void failAndBail(string message = null) + private void failAndBail(string? message = null) { if (!string.IsNullOrEmpty(message)) Logger.Log(message, LoggingTarget.Runtime, LogLevel.Important); - Schedule(() => PerformExit(false)); + Schedule(() => PerformExit()); } private void onGameplayStarted() => Scheduler.Add(() => @@ -196,14 +193,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer protected override ResultsScreen CreateResults(ScoreInfo score) { - Debug.Assert(Room.RoomID.Value != null); + Debug.Assert(Room.RoomID != null); return multiplayerLeaderboard.TeamScores.Count == 2 - ? new MultiplayerTeamResultsScreen(score, Room.RoomID.Value.Value, PlaylistItem, multiplayerLeaderboard.TeamScores) + ? new MultiplayerTeamResultsScreen(score, Room.RoomID.Value, PlaylistItem, multiplayerLeaderboard.TeamScores) { ShowUserStatistics = true, } - : new MultiplayerResultsScreen(score, Room.RoomID.Value.Value, PlaylistItem) + : new MultiplayerResultsScreen(score, Room.RoomID.Value, PlaylistItem) { ShowUserStatistics = true }; @@ -213,7 +210,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { base.Dispose(isDisposing); - if (client != null) + if (client.IsNotNull()) { client.GameplayStarted -= onGameplayStarted; client.ResultsReady -= onResultsReady; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayerLoader.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayerLoader.cs index f682508319..7eb7f6610e 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayerLoader.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayerLoader.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.Threading.Tasks; using osu.Framework.Allocation; @@ -18,9 +16,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer public bool GameplayPassed => player?.GameplayState.HasPassed == true; [Resolved] - private MultiplayerClient multiplayerClient { get; set; } + private MultiplayerClient multiplayerClient { get; set; } = null!; - private Player player; + private Player? player; public MultiplayerPlayerLoader(Func createPlayer) : base(createPlayer) @@ -45,7 +43,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer .ContinueWith(task => failAndBail(task.Exception?.Message ?? "Server error"), TaskContinuationOptions.NotOnRanToCompletion); } - private void failAndBail(string message = null) + private void failAndBail(string? message = null) { if (!string.IsNullOrEmpty(message)) Logger.Log(message, LoggingTarget.Runtime, LogLevel.Important); 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/MultiplayerRoomManager.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs index 5f51ccc8d4..e16582a6e1 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.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.Diagnostics; using osu.Framework.Allocation; @@ -18,12 +16,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer public partial class MultiplayerRoomManager : RoomManager { [Resolved] - private MultiplayerClient multiplayerClient { get; set; } + private MultiplayerClient multiplayerClient { get; set; } = null!; - public override void CreateRoom(Room room, Action onSuccess = null, Action onError = null) - => base.CreateRoom(room, r => joinMultiplayerRoom(r, r.Password.Value, onSuccess, onError), onError); + public override void CreateRoom(Room room, Action? onSuccess = null, Action? onError = null) + => base.CreateRoom(room, r => joinMultiplayerRoom(r, r.Password, onSuccess, onError), onError); - public override void JoinRoom(Room room, string password = null, Action onSuccess = null, Action onError = null) + public override void JoinRoom(Room room, string? password = null, Action? onSuccess = null, Action? onError = null) { if (!multiplayerClient.IsConnected.Value) { @@ -33,7 +31,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer // this is done here as a pre-check to avoid clicking on already closed rooms in the lounge from triggering a server join. // should probably be done at a higher level, but due to the current structure of things this is the easiest place for now. - if (room.Status.Value is RoomStatusEnded) + if (room.Status is RoomStatusEnded) { onError?.Invoke("Cannot join an ended room."); return; @@ -51,9 +49,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer multiplayerClient.LeaveRoom(); } - private void joinMultiplayerRoom(Room room, string password, Action onSuccess = null, Action onError = null) + private void joinMultiplayerRoom(Room room, string? password, Action? onSuccess = null, Action? onError = null) { - Debug.Assert(room.RoomID.Value != null); + Debug.Assert(room.RoomID != null); multiplayerClient.JoinRoom(room, password).ContinueWith(t => { 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/OnlinePlay/OngoingOperationTracker.cs b/osu.Game/Screens/OnlinePlay/OngoingOperationTracker.cs index 7f73d6655f..0c761dba44 100644 --- a/osu.Game/Screens/OnlinePlay/OngoingOperationTracker.cs +++ b/osu.Game/Screens/OnlinePlay/OngoingOperationTracker.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 osu.Framework.Bindables; using osu.Framework.Graphics; @@ -22,7 +20,7 @@ namespace osu.Game.Screens.OnlinePlay private readonly Bindable inProgress = new BindableBool(); - private LeasedBindable leasedInProgress; + private LeasedBindable? leasedInProgress; public OngoingOperationTracker() { diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlayComposite.cs b/osu.Game/Screens/OnlinePlay/OnlinePlayComposite.cs deleted file mode 100644 index 5be5c4b4f4..0000000000 --- a/osu.Game/Screens/OnlinePlay/OnlinePlayComposite.cs +++ /dev/null @@ -1,115 +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 System; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics.Containers; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Online.Multiplayer; -using osu.Game.Online.Rooms; -using osu.Game.Screens.OnlinePlay.Match; - -namespace osu.Game.Screens.OnlinePlay -{ - /// - /// A that exposes bindables for properties. - /// - public partial class OnlinePlayComposite : CompositeDrawable - { - [Resolved(typeof(Room))] - protected Bindable RoomID { get; private set; } - - [Resolved(typeof(Room), nameof(Room.Name))] - protected Bindable RoomName { get; private set; } - - [Resolved(typeof(Room))] - protected Bindable Host { get; private set; } - - [Resolved(typeof(Room))] - protected Bindable Status { get; private set; } - - [Resolved(typeof(Room))] - protected Bindable Type { get; private set; } - - /// - /// The currently selected item in the , or the current item from - /// if this is not within a . - /// - [Resolved(typeof(Room))] - protected Bindable CurrentPlaylistItem { get; private set; } - - [Resolved(typeof(Room))] - protected Bindable PlaylistItemStats { get; private set; } - - [Resolved(typeof(Room))] - protected BindableList Playlist { get; private set; } - - [Resolved(typeof(Room))] - protected Bindable DifficultyRange { get; private set; } - - [Resolved(typeof(Room))] - protected Bindable Category { get; private set; } - - [Resolved(typeof(Room))] - protected BindableList RecentParticipants { get; private set; } - - [Resolved(typeof(Room))] - protected Bindable ParticipantCount { get; private set; } - - [Resolved(typeof(Room))] - protected Bindable MaxParticipants { get; private set; } - - [Resolved(typeof(Room))] - protected Bindable MaxAttempts { get; private set; } - - [Resolved(typeof(Room))] - public Bindable UserScore { get; private set; } - - [Resolved(typeof(Room))] - protected Bindable StartDate { get; private set; } - - [Resolved(typeof(Room))] - protected Bindable EndDate { get; private set; } - - [Resolved(typeof(Room))] - protected Bindable Availability { get; private set; } - - [Resolved(typeof(Room))] - public Bindable Password { get; private set; } - - [Resolved(typeof(Room))] - protected Bindable Duration { get; private set; } - - [Resolved(typeof(Room))] - protected Bindable QueueMode { get; private set; } - - [Resolved(typeof(Room))] - protected Bindable AutoStartDuration { get; private set; } - - [Resolved(typeof(Room))] - protected Bindable AutoSkip { get; private set; } - - [Resolved(CanBeNull = true)] - private IBindable subScreenSelectedItem { get; set; } - - protected override void LoadComplete() - { - base.LoadComplete(); - - subScreenSelectedItem?.BindValueChanged(_ => UpdateSelectedItem()); - Playlist.BindCollectionChanged((_, _) => UpdateSelectedItem(), true); - } - - protected void UpdateSelectedItem() - { - // null room ID means this is a room in the process of being created. - if (RoomID.Value == null) - CurrentPlaylistItem.Value = Playlist.GetCurrentItem(); - else if (subScreenSelectedItem != null) - CurrentPlaylistItem.Value = subScreenSelectedItem.Value; - } - } -} diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs index 1b7041c9bb..cc6a4e09e1 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.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.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -27,16 +25,16 @@ namespace osu.Game.Screens.OnlinePlay public IScreen CurrentSubScreen => screenStack.CurrentScreen; - public override bool CursorVisible => (screenStack?.CurrentScreen as IOnlinePlaySubScreen)?.CursorVisible ?? true; + public override bool CursorVisible => (screenStack.CurrentScreen as IOnlinePlaySubScreen)?.CursorVisible ?? true; // this is required due to PlayerLoader eventually being pushed to the main stack // while leases may be taken out by a subscreen. public override bool DisallowExternalBeatmapRulesetChanges => true; - protected LoungeSubScreen Lounge { get; private set; } + protected LoungeSubScreen Lounge { get; private set; } = null!; - private OnlinePlayScreenWaveContainer waves; - private ScreenStack screenStack; + private readonly ScreenStack screenStack = new OnlinePlaySubScreenStack { RelativeSizeAxes = Axes.Both }; + private OnlinePlayScreenWaveContainer waves = null!; [Cached(Type = typeof(IRoomManager))] protected RoomManager RoomManager { get; private set; } @@ -45,7 +43,7 @@ namespace osu.Game.Screens.OnlinePlay private readonly OngoingOperationTracker ongoingOperationTracker = new OngoingOperationTracker(); [Resolved] - protected IAPIProvider API { get; private set; } + protected IAPIProvider API { get; private set; } = null!; protected OnlinePlayScreen() { @@ -67,7 +65,7 @@ namespace osu.Game.Screens.OnlinePlay RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - screenStack = new OnlinePlaySubScreenStack { RelativeSizeAxes = Axes.Both }, + screenStack, new Header(ScreenTitle, screenStack), RoomManager, ongoingOperationTracker, diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs index a8dfece916..f6b6dfd3ab 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs @@ -32,9 +32,6 @@ namespace osu.Game.Screens.OnlinePlay public override bool AllowEditing => false; - [Resolved(typeof(Room), nameof(Room.Playlist))] - protected BindableList Playlist { get; private set; } = null!; - [Resolved] private RulesetStore rulesets { get; set; } = null!; diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsLoungeSubScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsLoungeSubScreen.cs index e1d747c3b0..d66b4f844c 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsLoungeSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsLoungeSubScreen.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.Collections.Generic; using System.ComponentModel; using System.Linq; @@ -22,9 +20,9 @@ namespace osu.Game.Screens.OnlinePlay.Playlists public partial class PlaylistsLoungeSubScreen : LoungeSubScreen { [Resolved] - private IAPIProvider api { get; set; } + private IAPIProvider api { get; set; } = null!; - private Dropdown categoryDropdown; + private Dropdown categoryDropdown = null!; protected override IEnumerable CreateFilterControls() { @@ -67,8 +65,8 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { return new Room { - Name = { Value = $"{api.LocalUser}'s awesome playlist" }, - Type = { Value = MatchType.Playlists } + Name = $"{api.LocalUser}'s awesome playlist", + Type = MatchType.Playlists }; } diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs index 4a2d8f8f6b..7ca09b5563 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.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.Diagnostics; using System.Linq; @@ -21,11 +19,11 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { public partial class PlaylistsPlayer : RoomSubmittingPlayer { - public Action Exited; + public Action? Exited; protected override UserActivity InitialActivity => new UserActivity.InPlaylistGame(Beatmap.Value.BeatmapInfo, Ruleset.Value); - public PlaylistsPlayer(Room room, PlaylistItem playlistItem, PlayerConfiguration configuration = null) + public PlaylistsPlayer(Room room, PlaylistItem playlistItem, PlayerConfiguration? configuration = null) : base(room, playlistItem, configuration) { } @@ -57,8 +55,8 @@ namespace osu.Game.Screens.OnlinePlay.Playlists protected override ResultsScreen CreateResults(ScoreInfo score) { - Debug.Assert(Room.RoomID.Value != null); - return new PlaylistItemUserResultsScreen(score, Room.RoomID.Value.Value, PlaylistItem) + Debug.Assert(Room.RoomID != null); + return new PlaylistItemUserResultsScreen(score, Room.RoomID.Value, PlaylistItem) { AllowRetry = true, ShowUserStatistics = true, diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsReadyButton.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsReadyButton.cs index 4b00678b01..a460779ea6 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsReadyButton.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsReadyButton.cs @@ -1,9 +1,8 @@ // 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.ComponentModel; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -17,20 +16,14 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { public partial class PlaylistsReadyButton : ReadyButton { - [Resolved(typeof(Room), nameof(Room.EndDate))] - private Bindable endDate { get; set; } - - [Resolved(typeof(Room), nameof(Room.MaxAttempts))] - private Bindable maxAttempts { get; set; } - - [Resolved(typeof(Room), nameof(Room.UserScore))] - private Bindable userScore { get; set; } - [Resolved] - private IBindable gameBeatmap { get; set; } + private IBindable gameBeatmap { get; set; } = null!; - public PlaylistsReadyButton() + private readonly Room room; + + public PlaylistsReadyButton(Room room) { + this.room = room; Text = "Start"; } @@ -46,15 +39,24 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { base.LoadComplete(); - userScore.BindValueChanged(aggregate => - { - if (maxAttempts.Value == null) - return; + room.PropertyChanged += onRoomPropertyChanged; + updateRoomUserScore(); + } - int remaining = maxAttempts.Value.Value - aggregate.NewValue.PlaylistItemAttempts.Sum(a => a.Attempts); + private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Room.UserScore)) + updateRoomUserScore(); + } - hasRemainingAttempts = remaining > 0; - }); + private void updateRoomUserScore() + { + if (room.MaxAttempts == null || room.UserScore == null) + return; + + int remaining = room.MaxAttempts.Value - room.UserScore.PlaylistItemAttempts.Sum(a => a.Attempts); + + hasRemainingAttempts = remaining > 0; } protected override void Update() @@ -80,6 +82,12 @@ namespace osu.Game.Screens.OnlinePlay.Playlists private bool enoughTimeLeft => // This should probably consider the length of the currently selected item, rather than a constant 30 seconds. - endDate.Value != null && DateTimeOffset.UtcNow.AddSeconds(30).AddMilliseconds(gameBeatmap.Value.Track.Length) < endDate.Value; + room.EndDate != null && DateTimeOffset.UtcNow.AddSeconds(30).AddMilliseconds(gameBeatmap.Value.Track.Length) < room.EndDate; + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + room.PropertyChanged -= onRoomPropertyChanged; + } } } diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomFooter.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomFooter.cs index 5161de5f64..0d837423a6 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomFooter.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomFooter.cs @@ -1,26 +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; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Online.Rooms; using osuTK; namespace osu.Game.Screens.OnlinePlay.Playlists { public partial class PlaylistsRoomFooter : CompositeDrawable { - public Action OnStart; + public Action? OnStart; - public PlaylistsRoomFooter() + public PlaylistsRoomFooter(Room room) { RelativeSizeAxes = Axes.Both; InternalChildren = new[] { - new PlaylistsReadyButton + new PlaylistsReadyButton(room) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs index 9166cac9de..88af161cc8 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Specialized; +using System.ComponentModel; using System.Linq; using Humanizer; using Humanizer.Localisation; @@ -25,6 +26,7 @@ using osu.Game.Screens.OnlinePlay.Match.Components; using osuTK; using osu.Game.Localisation; using osu.Game.Rulesets; +using Container = osu.Framework.Graphics.Containers.Container; namespace osu.Game.Screens.OnlinePlay.Playlists { @@ -45,14 +47,14 @@ namespace osu.Game.Screens.OnlinePlay.Playlists protected override void SelectBeatmap() => settings.SelectBeatmap(); - protected override OnlinePlayComposite CreateSettings(Room room) => settings = new MatchSettings(room) + protected override Drawable CreateSettings(Room room) => settings = new MatchSettings(room) { RelativeSizeAxes = Axes.Both, RelativePositionAxes = Axes.Y, EditPlaylist = () => EditPlaylist?.Invoke() }; - protected partial class MatchSettings : OnlinePlayComposite + protected partial class MatchSettings : CompositeDrawable { private const float disabled_alpha = 0.2f; @@ -142,7 +144,8 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { RelativeSizeAxes = Axes.X, TabbableContentContainer = this, - LengthLimit = 100 + LengthLimit = 100, + Text = room.Name }, }, new Section("Duration") @@ -313,12 +316,6 @@ namespace osu.Game.Screens.OnlinePlay.Playlists loadingLayer = new LoadingLayer(true) }; - RoomName.BindValueChanged(name => NameField.Text = name.NewValue, true); - Availability.BindValueChanged(availability => AvailabilityPicker.Current.Value = availability.NewValue, true); - MaxParticipants.BindValueChanged(count => MaxParticipantsField.Text = count.NewValue?.ToString(), true); - MaxAttempts.BindValueChanged(count => MaxAttemptsField.Text = count.NewValue?.ToString(), true); - Duration.BindValueChanged(duration => DurationField.Current.Value = duration.NewValue ?? TimeSpan.FromMinutes(30), true); - DurationField.Current.BindValueChanged(duration => { if (hasValidDuration) @@ -332,11 +329,72 @@ namespace osu.Game.Screens.OnlinePlay.Playlists localUser = api.LocalUser.GetBoundCopy(); localUser.BindValueChanged(populateDurations, true); - - playlist.Items.BindTo(Playlist); - Playlist.BindCollectionChanged(onPlaylistChanged, true); } + protected override void LoadComplete() + { + base.LoadComplete(); + + room.PropertyChanged += onRoomPropertyChanged; + + updateRoomName(); + updateRoomAvailability(); + updateRoomMaxParticipants(); + updateRoomDuration(); + updateRoomMaxAttempts(); + updateRoomPlaylist(); + + playlist.Items.BindCollectionChanged((_, __) => room.Playlist = playlist.Items.ToArray()); + } + + private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(Room.Name): + updateRoomName(); + break; + + case nameof(Room.Availability): + updateRoomAvailability(); + break; + + case nameof(Room.MaxParticipants): + updateRoomMaxParticipants(); + break; + + case nameof(Room.Duration): + updateRoomDuration(); + break; + + case nameof(Room.MaxAttempts): + updateRoomMaxAttempts(); + break; + + case nameof(Room.Playlist): + updateRoomPlaylist(); + break; + } + } + + private void updateRoomName() + => NameField.Text = room.Name; + + private void updateRoomAvailability() + => AvailabilityPicker.Current.Value = room.Availability; + + private void updateRoomMaxParticipants() + => MaxParticipantsField.Text = room.MaxParticipants?.ToString(); + + private void updateRoomDuration() + => DurationField.Current.Value = room.Duration ?? TimeSpan.FromMinutes(30); + + private void updateRoomMaxAttempts() + => MaxAttemptsField.Text = room.MaxAttempts?.ToString(); + + private void updateRoomPlaylist() + => playlist.Items.ReplaceRange(0, playlist.Items.Count, room.Playlist); + private void populateDurations(ValueChangedEvent user) { // roughly correct (see https://github.com/Humanizr/Humanizer/blob/18167e56c082449cc4fe805b8429e3127a7b7f93/readme.md?plain=1#L427) @@ -370,9 +428,9 @@ namespace osu.Game.Screens.OnlinePlay.Playlists public void SelectBeatmap() => editPlaylistButton.TriggerClick(); private void onPlaylistChanged(object? sender, NotifyCollectionChangedEventArgs e) => - playlistLength.Text = $"Length: {Playlist.GetTotalDuration(rulesets)}"; + playlistLength.Text = $"Length: {room.Playlist.GetTotalDuration(rulesets)}"; - private bool hasValidSettings => RoomID.Value == null && NameField.Text.Length > 0 && Playlist.Count > 0 + private bool hasValidSettings => room.RoomID == null && NameField.Text.Length > 0 && room.Playlist.Count > 0 && hasValidDuration; private bool hasValidDuration => DurationField.Current.Value <= TimeSpan.FromDays(14) || localUser.Value.IsSupporter; @@ -384,20 +442,11 @@ namespace osu.Game.Screens.OnlinePlay.Playlists hideError(); - RoomName.Value = NameField.Text; - Availability.Value = AvailabilityPicker.Current.Value; - - if (int.TryParse(MaxParticipantsField.Text, out int max)) - MaxParticipants.Value = max; - else - MaxParticipants.Value = null; - - if (int.TryParse(MaxAttemptsField.Text, out max)) - MaxAttempts.Value = max; - else - MaxAttempts.Value = null; - - Duration.Value = DurationField.Current.Value; + room.Name = NameField.Text; + room.Availability = AvailabilityPicker.Current.Value; + room.MaxParticipants = int.TryParse(MaxParticipantsField.Text, out int maxParticipants) ? maxParticipants : null; + room.MaxAttempts = int.TryParse(MaxAttemptsField.Text, out int maxAttempts) ? maxAttempts : null; + room.Duration = DurationField.Current.Value; loadingLayer.Show(); manager?.CreateRoom(room, onSuccess, onError); @@ -422,7 +471,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists .Select(int.Parse) .ToArray(); - foreach (var item in Playlist) + foreach (var item in room.Playlist) { if (invalidBeatmapIDs.Contains(item.Beatmap.OnlineID)) item.MarkInvalid(); @@ -436,6 +485,12 @@ namespace osu.Game.Screens.OnlinePlay.Playlists ErrorText.FadeIn(50); loadingLayer.Hide(); } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + room.PropertyChanged -= onRoomPropertyChanged; + } } public partial class CreateRoomButton : RoundedButton diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs index 3126bbf2eb..44d1841fb8 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs @@ -1,11 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - +using System.ComponentModel; using System.Diagnostics; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -22,6 +20,7 @@ using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; using osu.Game.Users; using osuTK; +using Container = osu.Framework.Graphics.Containers.Container; namespace osu.Game.Screens.OnlinePlay.Playlists { @@ -33,20 +32,23 @@ namespace osu.Game.Screens.OnlinePlay.Playlists private readonly IBindable isIdle = new BindableBool(); - private MatchLeaderboard leaderboard; - private SelectionPollingComponent selectionPollingComponent; + [Resolved(CanBeNull = true)] + private IdleTracker? idleTracker { get; set; } - private FillFlowContainer progressSection; + private MatchLeaderboard leaderboard = null!; + private SelectionPollingComponent selectionPollingComponent = null!; + private FillFlowContainer progressSection = null!; + private DrawableRoomPlaylist drawablePlaylist = null!; public PlaylistsRoomSubScreen(Room room) : base(room, false) // Editing is temporarily not allowed. { - Title = room.RoomID.Value == null ? "New playlist" : room.Name.Value; + Title = room.RoomID == null ? "New playlist" : room.Name; Activity.Value = new UserActivity.InLobby(room); } - [BackgroundDependencyLoader(true)] - private void load([CanBeNull] IdleTracker idleTracker) + [BackgroundDependencyLoader] + private void load() { if (idleTracker != null) isIdle.BindTo(idleTracker.IsIdle); @@ -59,19 +61,47 @@ namespace osu.Game.Screens.OnlinePlay.Playlists base.LoadComplete(); isIdle.BindValueChanged(_ => updatePollingRate(), true); - RoomId.BindValueChanged(id => - { - if (id.NewValue != null) - { - // Set the first playlist item. - // This is scheduled since updating the room and playlist may happen in an arbitrary order (via Room.CopyFrom()). - Schedule(() => SelectedItem.Value = Room.Playlist.FirstOrDefault()); - } - }, true); - Room.MaxAttempts.BindValueChanged(_ => progressSection.Alpha = Room.MaxAttempts.Value != null ? 1 : 0, true); + Room.PropertyChanged += onRoomPropertyChanged; + updateSetupState(); + updateRoomMaxAttempts(); + updateRoomPlaylist(); } + private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(Room.RoomID): + updateSetupState(); + break; + + case nameof(Room.MaxAttempts): + updateRoomMaxAttempts(); + break; + + case nameof(Room.Playlist): + updateRoomPlaylist(); + break; + } + } + + private void updateSetupState() + { + if (Room.RoomID != null) + { + // Set the first playlist item. + // This is scheduled since updating the room and playlist may happen in an arbitrary order (via Room.CopyFrom()). + Schedule(() => SelectedItem.Value = Room.Playlist.FirstOrDefault()); + } + } + + private void updateRoomMaxAttempts() + => progressSection.Alpha = Room.MaxAttempts != null ? 1 : 0; + + private void updateRoomPlaylist() + => drawablePlaylist.Items.ReplaceRange(0, drawablePlaylist.Items.Count, Room.Playlist); + protected override Drawable CreateMainContent() => new Container { RelativeSizeAxes = Axes.Both, @@ -92,7 +122,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists }, Content = new[] { - new Drawable[] + new Drawable?[] { // Playlist items column new GridContainer @@ -101,20 +131,19 @@ namespace osu.Game.Screens.OnlinePlay.Playlists Padding = new MarginPadding { Right = 5 }, Content = new[] { - new Drawable[] { new OverlinedPlaylistHeader(), }, + new Drawable[] { new OverlinedPlaylistHeader(Room), }, new Drawable[] { - new DrawableRoomPlaylist + drawablePlaylist = new DrawableRoomPlaylist { RelativeSizeAxes = Axes.Both, - Items = { BindTarget = Room.Playlist }, SelectedItem = { BindTarget = SelectedItem }, AllowSelection = true, AllowShowingResults = true, RequestResults = item => { - Debug.Assert(RoomId.Value != null); - ParentScreen?.Push(new PlaylistItemUserResultsScreen(null, RoomId.Value.Value, item)); + Debug.Assert(Room.RoomID != null); + ParentScreen?.Push(new PlaylistItemUserResultsScreen(null, Room.RoomID.Value, item)); } } }, @@ -183,7 +212,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists Children = new Drawable[] { new OverlinedHeader("Progress"), - new RoomLocalUserInfo(), + new RoomLocalUserInfo(Room), } }, }, @@ -191,7 +220,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { new OverlinedHeader("Leaderboard") }, - new Drawable[] { leaderboard = new MatchLeaderboard { RelativeSizeAxes = Axes.Both }, }, + new Drawable[] { leaderboard = new MatchLeaderboard(Room) { RelativeSizeAxes = Axes.Both }, }, }, RowDimensions = new[] { @@ -224,7 +253,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists } }; - protected override Drawable CreateFooter() => new PlaylistsRoomFooter + protected override Drawable CreateFooter() => new PlaylistsRoomFooter(Room) { OnStart = StartPlay }; @@ -244,9 +273,18 @@ namespace osu.Game.Screens.OnlinePlay.Playlists Logger.Log($"Polling adjusted (selection: {selectionPollingComponent.TimeBetweenPolls.Value})"); } - protected override Screen CreateGameplayScreen() => new PlayerLoader(() => new PlaylistsPlayer(Room, SelectedItem.Value) + protected override Screen CreateGameplayScreen(PlaylistItem selectedItem) { - Exited = () => leaderboard.RefetchScores() - }); + return new PlayerLoader(() => new PlaylistsPlayer(Room, selectedItem) + { + Exited = () => leaderboard.RefetchScores() + }); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + Room.PropertyChanged -= onRoomPropertyChanged; + } } } diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs index cedea4af70..23824b6a73 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs @@ -12,45 +12,34 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { public partial class PlaylistsSongSelect : OnlinePlaySongSelect { + private readonly Room room; + public PlaylistsSongSelect(Room room) : base(room) { + this.room = room; } - protected override BeatmapDetailArea CreateBeatmapDetailArea() => new MatchBeatmapDetailArea + protected override BeatmapDetailArea CreateBeatmapDetailArea() => new MatchBeatmapDetailArea(room) { - CreateNewItem = createNewItem + CreateNewItem = () => room.Playlist = room.Playlist.Append(createNewItem()).ToArray() }; protected override bool SelectItem(PlaylistItem item) { - switch (Playlist.Count) - { - case 0: - createNewItem(); - break; - - case 1: - Playlist.Clear(); - createNewItem(); - break; - } + if (room.Playlist.Count <= 1) + room.Playlist = [createNewItem()]; this.Exit(); return true; } - private void createNewItem() + private PlaylistItem createNewItem() => new PlaylistItem(Beatmap.Value.BeatmapInfo) { - PlaylistItem item = new PlaylistItem(Beatmap.Value.BeatmapInfo) - { - ID = Playlist.Count == 0 ? 0 : Playlist.Max(p => p.ID) + 1, - RulesetID = Ruleset.Value.OnlineID, - RequiredMods = Mods.Value.Select(m => new APIMod(m)).ToArray(), - AllowedMods = FreeMods.Value.Select(m => new APIMod(m)).ToArray() - }; - - Playlist.Add(item); - } + ID = room.Playlist.Count == 0 ? 0 : room.Playlist.Max(p => p.ID) + 1, + RulesetID = Ruleset.Value.OnlineID, + RequiredMods = Mods.Value.Select(m => new APIMod(m)).ToArray(), + AllowedMods = FreeMods.Value.Select(m => new APIMod(m)).ToArray() + }; } } 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/BreakOverlay.cs b/osu.Game/Screens/Play/BreakOverlay.cs index 1fdb9402bc..550d29965f 100644 --- a/osu.Game/Screens/Play/BreakOverlay.cs +++ b/osu.Game/Screens/Play/BreakOverlay.cs @@ -166,7 +166,7 @@ namespace osu.Game.Screens.Play return; float timeBoxTargetWidth = (float)Math.Max(0, (remainingTimeForCurrentPeriod - timingPoint.BeatLength / currentPeriod.Value.Value.Duration)); - remainingTimeBox.ResizeWidthTo(timeBoxTargetWidth, timingPoint.BeatLength * 2, Easing.OutQuint); + remainingTimeBox.ResizeWidthTo(timeBoxTargetWidth, timingPoint.BeatLength * 3.5, Easing.OutQuint); } private void updateDisplay(ValueChangedEvent period) diff --git a/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs b/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs index 71996718d9..44f021f93e 100644 --- a/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs +++ b/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs @@ -13,6 +13,7 @@ using osu.Framework.Layout; using osu.Framework.Threading; using osu.Framework.Utils; using osu.Game.Configuration; +using osu.Game.Localisation.SkinComponents; using osu.Game.Rulesets.Judgements; using osu.Game.Screens.Play.HUD.ArgonHealthDisplayParts; using osu.Game.Skinning; @@ -33,7 +34,7 @@ namespace osu.Game.Screens.Play.HUD Precision = 1 }; - [SettingSource("Use relative size")] + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.UseRelativeSize))] public BindableBool UseRelativeSize { get; } = new BindableBool(true); private ArgonHealthDisplayBar mainBar = null!; diff --git a/osu.Game/Screens/Play/HUD/ArgonSongProgress.cs b/osu.Game/Screens/Play/HUD/ArgonSongProgress.cs index 92ac863e98..8dc5d60352 100644 --- a/osu.Game/Screens/Play/HUD/ArgonSongProgress.cs +++ b/osu.Game/Screens/Play/HUD/ArgonSongProgress.cs @@ -30,6 +30,9 @@ namespace osu.Game.Screens.Play.HUD [SettingSource(typeof(SongProgressStrings), nameof(SongProgressStrings.ShowTime), nameof(SongProgressStrings.ShowTimeDescription))] public Bindable ShowTime { get; } = new BindableBool(true); + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.UseRelativeSize))] + public BindableBool UseRelativeSize { get; } = new BindableBool(true); + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Colour), nameof(SkinnableComponentStrings.ColourDescription))] public BindableColour4 AccentColour { get; } = new BindableColour4(Colour4.White); @@ -99,6 +102,11 @@ namespace osu.Game.Screens.Play.HUD ShowGraph.BindValueChanged(_ => updateGraphVisibility(), true); ShowTime.BindValueChanged(_ => info.FadeTo(ShowTime.Value ? 1 : 0, 200, Easing.In), true); AccentColour.BindValueChanged(_ => Colour = AccentColour.Value, true); + + // see comment in ArgonHealthDisplay.cs regarding RelativeSizeAxes + float previousWidth = Width; + UseRelativeSize.BindValueChanged(v => RelativeSizeAxes = v.NewValue ? Axes.X : Axes.None, true); + Width = previousWidth; } protected override void UpdateObjects(IEnumerable objects) diff --git a/osu.Game/Screens/Play/HUD/ArgonSongProgressBar.cs b/osu.Game/Screens/Play/HUD/ArgonSongProgressBar.cs index 7a7870a775..9ab2366b3e 100644 --- a/osu.Game/Screens/Play/HUD/ArgonSongProgressBar.cs +++ b/osu.Game/Screens/Play/HUD/ArgonSongProgressBar.cs @@ -6,15 +6,17 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Graphics; using osuTK; namespace osu.Game.Screens.Play.HUD { - public partial class ArgonSongProgressBar : SongProgressBar + public partial class ArgonSongProgressBar : SongProgressBar, IHasTooltip { // Parent will handle restricting the area of valid input. public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; @@ -33,6 +35,26 @@ namespace osu.Game.Screens.Play.HUD private double trackTime => (EndTime - StartTime) * Progress; + private float lastMouseX; + + public LocalisableString TooltipText + { + get + { + if (!Interactive) + return default; + + double progress = Math.Clamp(lastMouseX, 0, DrawWidth) / DrawWidth; + + TimeSpan currentSpan = TimeSpan.FromMilliseconds(Math.Round((EndTime - StartTime) * progress)); + + int seconds = currentSpan.Duration().Seconds; + int minutes = (int)Math.Floor(currentSpan.Duration().TotalMinutes); + + return $"{minutes}:{seconds:D2} ({progress:P0})"; + } + } + public ArgonSongProgressBar(float barHeight) { RelativeSizeAxes = Axes.X; @@ -84,6 +106,14 @@ namespace osu.Game.Screens.Play.HUD playfieldBar.TransformTo(nameof(playfieldBar.AccentColour), mainColour, 200, Easing.In); } + protected override bool OnMouseMove(MouseMoveEvent e) + { + base.OnMouseMove(e); + + lastMouseX = e.MousePosition.X; + return false; + } + protected override bool OnHover(HoverEvent e) { if (Interactive) diff --git a/osu.Game/Screens/Play/HUD/DefaultSongProgress.cs b/osu.Game/Screens/Play/HUD/DefaultSongProgress.cs index 4e41901ee3..672017750d 100644 --- a/osu.Game/Screens/Play/HUD/DefaultSongProgress.cs +++ b/osu.Game/Screens/Play/HUD/DefaultSongProgress.cs @@ -37,6 +37,9 @@ namespace osu.Game.Screens.Play.HUD [SettingSource(typeof(SongProgressStrings), nameof(SongProgressStrings.ShowTime), nameof(SongProgressStrings.ShowTimeDescription))] public Bindable ShowTime { get; } = new BindableBool(true); + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.UseRelativeSize))] + public BindableBool UseRelativeSize { get; } = new BindableBool(true); + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Colour), nameof(SkinnableComponentStrings.ColourDescription))] public BindableColour4 AccentColour { get; } = new BindableColour4(Colour4.White); @@ -83,6 +86,11 @@ namespace osu.Game.Screens.Play.HUD private void load(OsuColour colours) { graph.FillColour = bar.FillColour = colours.BlueLighter; + + // see comment in ArgonHealthDisplay.cs regarding RelativeSizeAxes + float previousWidth = Width; + UseRelativeSize.BindValueChanged(v => RelativeSizeAxes = v.NewValue ? Axes.X : Axes.None, true); + Width = previousWidth; } protected override void LoadComplete() 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/HUD/HoldForMenuButton.cs b/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs index 89d083eca9..96e937fda7 100644 --- a/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs +++ b/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs @@ -162,14 +162,18 @@ namespace osu.Game.Screens.Play.HUD private bool pendingAnimation; private ScheduledDelegate shakeOperation; + private Bindable alwaysRequireHold; + public HoldButton(bool isDangerousAction) : base(isDangerousAction) { } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OsuColour colours, OsuConfigManager config) { + alwaysRequireHold = config.GetBindable(OsuSetting.AlwaysRequireHoldingForPause); + Size = new Vector2(60); Child = new CircularContainer @@ -299,7 +303,13 @@ namespace osu.Game.Screens.Play.HUD { case GlobalAction.Back: if (!pendingAnimation) - BeginConfirm(); + { + if (IsDangerousAction || alwaysRequireHold.Value) + BeginConfirm(); + else + Confirm(); + } + return true; case GlobalAction.PauseGameplay: @@ -307,7 +317,13 @@ namespace osu.Game.Screens.Play.HUD if (ReplayLoaded.Value) return false; if (!pendingAnimation) - BeginConfirm(); + { + if (IsDangerousAction || alwaysRequireHold.Value) + BeginConfirm(); + else + Confirm(); + } + return true; } diff --git a/osu.Game/Screens/Play/HUD/InputTrigger.cs b/osu.Game/Screens/Play/HUD/InputTrigger.cs index edc61ec142..72dade25f6 100644 --- a/osu.Game/Screens/Play/HUD/InputTrigger.cs +++ b/osu.Game/Screens/Play/HUD/InputTrigger.cs @@ -34,6 +34,11 @@ namespace osu.Game.Screens.Play.HUD /// public IBindable ActivationCount => activationCount; + /// + /// Whether this is currently active. + /// + public bool IsActive { get; private set; } + /// /// Whether any activation or deactivation of this impacts its /// @@ -49,6 +54,7 @@ namespace osu.Game.Screens.Play.HUD if (forwardPlayback && isCounting.Value) activationCount.Value++; + IsActive = true; OnActivate?.Invoke(forwardPlayback); } @@ -57,6 +63,7 @@ namespace osu.Game.Screens.Play.HUD if (!forwardPlayback && isCounting.Value) activationCount.Value--; + IsActive = false; OnDeactivate?.Invoke(forwardPlayback); } } diff --git a/osu.Game/Screens/Play/HUD/KeyCounter.cs b/osu.Game/Screens/Play/HUD/KeyCounter.cs index 66f9dfd6f2..b506694044 100644 --- a/osu.Game/Screens/Play/HUD/KeyCounter.cs +++ b/osu.Game/Screens/Play/HUD/KeyCounter.cs @@ -36,6 +36,14 @@ namespace osu.Game.Screens.Play.HUD Trigger.OnDeactivate += Deactivate; } + protected override void LoadComplete() + { + base.LoadComplete(); + + if (Trigger.IsActive) + Activate(); + } + protected virtual void Activate(bool forwardPlayback = true) { isActive.Value = true; diff --git a/osu.Game/Screens/Play/HUDOverlay.cs b/osu.Game/Screens/Play/HUDOverlay.cs index ac1b9ce34f..a6c2405eb6 100644 --- a/osu.Game/Screens/Play/HUDOverlay.cs +++ b/osu.Game/Screens/Play/HUDOverlay.cs @@ -252,7 +252,7 @@ namespace osu.Game.Screens.Play PlayfieldSkinLayer.Position = ToLocalSpace(playfieldScreenSpaceDrawQuad.TopLeft); PlayfieldSkinLayer.Width = (ToLocalSpace(playfieldScreenSpaceDrawQuad.TopRight) - ToLocalSpace(playfieldScreenSpaceDrawQuad.TopLeft)).Length; PlayfieldSkinLayer.Height = (ToLocalSpace(playfieldScreenSpaceDrawQuad.BottomLeft) - ToLocalSpace(playfieldScreenSpaceDrawQuad.TopLeft)).Length; - PlayfieldSkinLayer.Rotation = drawableRuleset.Playfield.Rotation; + PlayfieldSkinLayer.Rotation = drawableRuleset.PlayfieldAdjustmentContainer.Rotation; } float? lowestTopScreenSpaceLeft = null; @@ -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/Play/ILocalUserPlayInfo.cs b/osu.Game/Screens/Play/ILocalUserPlayInfo.cs index 2d181a09d4..dd24549c55 100644 --- a/osu.Game/Screens/Play/ILocalUserPlayInfo.cs +++ b/osu.Game/Screens/Play/ILocalUserPlayInfo.cs @@ -10,8 +10,8 @@ namespace osu.Game.Screens.Play public interface ILocalUserPlayInfo { /// - /// Whether the local user is currently playing. + /// Whether the local user is currently interacting (playing) with the game in a way that should not be interrupted. /// - IBindable IsPlaying { get; } + IBindable PlayingState { get; } } } diff --git a/osu.Game/Screens/Play/LocalUserPlayingState.cs b/osu.Game/Screens/Play/LocalUserPlayingState.cs new file mode 100644 index 0000000000..9ae4130298 --- /dev/null +++ b/osu.Game/Screens/Play/LocalUserPlayingState.cs @@ -0,0 +1,23 @@ +// 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.Play +{ + public enum LocalUserPlayingState + { + /// + /// The local player is not current in gameplay. If watching a replay, gameplay always remains in this state. + /// + NotPlaying, + + /// + /// The local player is in a break, paused, or failed but still at the gameplay screen. + /// + Break, + + /// + /// The local user is in active gameplay. + /// + Playing, + } +} diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index 2a66c3d5d3..2d1f602832 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -86,6 +86,7 @@ namespace osu.Game.Screens.Play public Action RestartRequested; private bool isRestarting; + private bool skipExitTransition; private Bindable mouseWheelDisabled; @@ -94,6 +95,7 @@ namespace osu.Game.Screens.Play public IBindable LocalUserPlaying => localUserPlaying; private readonly Bindable localUserPlaying = new Bindable(); + private readonly Bindable playingState = new Bindable(); public int RestartCount; @@ -231,9 +233,6 @@ namespace osu.Game.Screens.Play if (game != null) gameActive.BindTo(game.IsActive); - if (game is OsuGame osuGame) - LocalUserPlaying.BindTo(osuGame.LocalUserPlaying); - DrawableRuleset = ruleset.CreateDrawableRulesetWith(playableBeatmap, gameplayMods); dependencies.CacheAs(DrawableRuleset); @@ -291,7 +290,7 @@ namespace osu.Game.Screens.Play { SaveReplay = async () => await prepareAndImportScoreAsync(true).ConfigureAwait(false), OnRetry = Configuration.AllowUserInteraction ? () => Restart() : null, - OnQuit = () => PerformExit(true), + OnQuit = () => PerformExitWithConfirmation(), }, new HotkeyExitOverlay { @@ -299,10 +298,7 @@ namespace osu.Game.Screens.Play { if (!this.IsCurrentScreen()) return; - if (PerformExit(false)) - // The hotkey overlay dims the screen. - // If the operation succeeds, we want to make sure we stay dimmed to keep continuity. - fadeOut(true); + PerformExit(skipTransition: true); }, }, }); @@ -320,10 +316,7 @@ namespace osu.Game.Screens.Play { if (!this.IsCurrentScreen()) return; - if (Restart(true)) - // The hotkey overlay dims the screen. - // If the operation succeeds, we want to make sure we stay dimmed to keep continuity. - fadeOut(true); + Restart(true); }, }, }); @@ -450,7 +443,7 @@ namespace osu.Game.Screens.Play { HoldToQuit = { - Action = () => PerformExit(true), + Action = () => PerformExitWithConfirmation(), IsPaused = { BindTarget = GameplayClockContainer.IsPaused }, ReplayLoaded = { BindTarget = DrawableRuleset.HasReplayLoaded }, }, @@ -487,7 +480,7 @@ namespace osu.Game.Screens.Play OnResume = Resume, Retries = RestartCount, OnRetry = () => Restart(), - OnQuit = () => PerformExit(true), + OnQuit = () => PerformExitWithConfirmation(), }, }, }; @@ -510,9 +503,16 @@ namespace osu.Game.Screens.Play private void updateGameplayState() { - bool inGameplay = !DrawableRuleset.HasReplayLoaded.Value && !DrawableRuleset.IsPaused.Value && !breakTracker.IsBreakTime.Value && !GameplayState.HasFailed; - OverlayActivationMode.Value = inGameplay ? OverlayActivation.Disabled : OverlayActivation.UserTriggered; - localUserPlaying.Value = inGameplay; + bool inGameplay = !DrawableRuleset.HasReplayLoaded.Value && !GameplayState.HasPassed && !GameplayState.HasFailed; + bool inBreak = breakTracker.IsBreakTime.Value || DrawableRuleset.IsPaused.Value; + + if (inGameplay) + playingState.Value = inBreak ? LocalUserPlayingState.Break : LocalUserPlayingState.Playing; + else + playingState.Value = LocalUserPlayingState.NotPlaying; + + localUserPlaying.Value = playingState.Value == LocalUserPlayingState.Playing; + OverlayActivationMode.Value = playingState.Value == LocalUserPlayingState.Playing ? OverlayActivation.Disabled : OverlayActivation.UserTriggered; } private void updateSampleDisabledState() @@ -557,11 +557,8 @@ namespace osu.Game.Screens.Play } catch (BeatmapInvalidForRulesetException) { - // A playable beatmap may not be creatable with the user's preferred ruleset, so try using the beatmap's default ruleset - rulesetInfo = Beatmap.Value.BeatmapInfo.Ruleset; - ruleset = rulesetInfo.CreateInstance(); - - playable = Beatmap.Value.GetPlayableBeatmap(rulesetInfo, gameplayMods, cancellationToken); + Logger.Log($"The current beatmap is not playable in {ruleset.RulesetInfo.Name}!", level: LogLevel.Important); + return null; } if (playable.HitObjects.Count == 0) @@ -586,25 +583,24 @@ namespace osu.Game.Screens.Play } /// - /// Attempts to complete a user request to exit gameplay. + /// Attempts to complete a user request to exit gameplay, with confirmation. /// /// /// /// This should only be called in response to a user interaction. Exiting is not guaranteed. /// This will interrupt any pending progression to the results screen, even if the transition has begun. /// + /// + /// This method will show the pause or fail dialog before performing an exit. + /// If a dialog is not yet displayed, the exit will be blocked and the relevant dialog will display instead. /// - /// - /// Whether the pause or fail dialog should be shown before performing an exit. - /// If and a dialog is not yet displayed, the exit will be blocked and the relevant dialog will display instead. - /// /// Whether this call resulted in a final exit. - protected bool PerformExit(bool showDialogFirst) + protected bool PerformExitWithConfirmation() { bool pauseOrFailDialogVisible = PauseOverlay.State.Value == Visibility.Visible || FailOverlay.State.Value == Visibility.Visible; - if (showDialogFirst && !pauseOrFailDialogVisible) + if (!pauseOrFailDialogVisible) { // if the fail animation is currently in progress, accelerate it (it will show the pause dialog on completion). if (ValidForResume && GameplayState.HasFailed) @@ -623,6 +619,22 @@ namespace osu.Game.Screens.Play } } + return PerformExit(); + } + + /// + /// Attempts to complete a user request to exit gameplay. + /// + /// + /// + /// This should only be called in response to a user interaction. Exiting is not guaranteed. + /// This will interrupt any pending progression to the results screen, even if the transition has begun. + /// + /// + /// Whether the exit should perform without a transition, because the screen had faded to black already. + /// Whether this call resulted in a final exit. + protected bool PerformExit(bool skipTransition = false) + { // Matching osu!stable behaviour, if the results screen is pending and the user requests an exit, // show the results instead. if (GameplayState.HasPassed && !isRestarting) @@ -637,6 +649,8 @@ namespace osu.Game.Screens.Play // Screen may not be current if a restart has been performed. if (this.IsCurrentScreen()) { + skipExitTransition = skipTransition; + // The actual exit is performed if // - the pause / fail dialog was not requested // - the pause / fail dialog was requested but is already displayed (user showing intention to exit). @@ -705,9 +719,14 @@ namespace osu.Game.Screens.Play // stopping here is to ensure music doesn't become audible after exiting back to PlayerLoader. musicController.Stop(); - RestartRequested?.Invoke(quickRestart); + if (RestartRequested != null) + { + skipExitTransition = quickRestart; + RestartRequested?.Invoke(quickRestart); + return true; + } - return PerformExit(false); + return PerformExit(quickRestart); } /// @@ -957,7 +976,9 @@ namespace osu.Game.Screens.Play if (PauseOverlay.State.Value == Visibility.Visible) PauseOverlay.Hide(); - failAnimationContainer.Start(); + bool restartOnFail = GameplayState.Mods.OfType().Any(m => m.RestartOnFail); + if (!restartOnFail) + failAnimationContainer.Start(); // Failures can be triggered either by a judgement, or by a mod. // @@ -971,7 +992,7 @@ namespace osu.Game.Screens.Play ScoreProcessor.FailScore(Score.ScoreInfo); OnFail(); - if (GameplayState.Mods.OfType().Any(m => m.RestartOnFail)) + if (restartOnFail) Restart(true); }); } @@ -1252,10 +1273,10 @@ namespace osu.Game.Screens.Play ShowUserStatistics = true, }; - private void fadeOut(bool instant = false) + private void fadeOut() { - float fadeOutDuration = instant ? 0 : 250; - this.FadeOut(fadeOutDuration); + if (!skipExitTransition) + this.FadeOut(250); if (this.IsCurrentScreen()) { @@ -1279,6 +1300,6 @@ namespace osu.Game.Screens.Play IBindable ISamplePlaybackDisabler.SamplePlaybackDisabled => samplePlaybackDisabled; - IBindable ILocalUserPlayInfo.IsPlaying => LocalUserPlaying; + public IBindable PlayingState => playingState; } } diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index 7682bba9a6..3e36c630db 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -10,6 +10,7 @@ using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Transforms; using osu.Framework.Input; @@ -51,6 +52,8 @@ namespace osu.Game.Screens.Play public override bool? AllowGlobalTrackControl => false; + public override float BackgroundParallaxAmount => quickRestart ? 0 : 1; + // Here because IsHovered will not update unless we do so. public override bool HandlePositionalInput => true; @@ -86,9 +89,13 @@ namespace osu.Game.Screens.Play private SkinnableSound sampleRestart = null!; + private Box? quickRestartBlackLayer; + [Cached] private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + private const double quick_restart_initial_delay = 500; + protected bool BackgroundBrightnessReduction { set @@ -115,7 +122,9 @@ namespace osu.Game.Screens.Play // not ready if the user is dragging a slider or otherwise. && (inputManager.DraggedDrawable == null || inputManager.DraggedDrawable is OsuLogo) // not ready if a focused overlay is visible, like settings. - && inputManager.FocusedDrawable == null; + && inputManager.FocusedDrawable is not OsuFocusedOverlayContainer + // or if a child of a focused overlay is focused, like settings' search textbox. + && inputManager.FocusedDrawable?.FindClosestParent() == null; private readonly Func createPlayer; @@ -224,7 +233,7 @@ namespace osu.Game.Screens.Play } }, }, - idleTracker = new IdleTracker(750), + idleTracker = new IdleTracker(1500), sampleRestart = new SkinnableSound(new SampleInfo(@"Gameplay/restart", @"pause-retry-click")) }; @@ -305,6 +314,9 @@ namespace osu.Game.Screens.Play { base.OnSuspending(e); + quickRestartBlackLayer?.FadeOut(500, Easing.OutQuint).Expire(); + quickRestartBlackLayer = null; + BackgroundBrightnessReduction = false; // we're moving to player, so a period of silence is upcoming. @@ -321,6 +333,9 @@ namespace osu.Game.Screens.Play cancelLoad(); ContentOut(); + quickRestartBlackLayer?.FadeOut(100, Easing.OutQuint).Expire(); + quickRestartBlackLayer = null; + // Ensure the screen doesn't expire until all the outwards fade operations have completed. this.Delay(CONTENT_OUT_DURATION).FadeOut(); @@ -348,7 +363,14 @@ namespace osu.Game.Screens.Play if (!resuming) logo.MoveTo(new Vector2(0.5f), duration, Easing.OutQuint); logo.ScaleTo(new Vector2(0.15f), duration, Easing.OutQuint); - logo.FadeIn(350); + + if (quickRestart) + { + logo.Delay(quick_restart_initial_delay) + .FadeIn(350); + } + else + logo.FadeIn(350); Scheduler.AddDelayed(() => { @@ -387,7 +409,7 @@ namespace osu.Game.Screens.Play // We need to perform this check here rather than in OnHover as any number of children of VisualSettings // may also be handling the hover events. - if (inputManager.HoveredDrawables.Contains(VisualSettings)) + if (inputManager.HoveredDrawables.Contains(VisualSettings) || quickRestart) { // Preview user-defined background dim and blur when hovered on the visual settings panel. ApplyToBackground(b => @@ -454,16 +476,45 @@ namespace osu.Game.Screens.Play { MetadataInfo.Loading = true; - content.FadeInFromZero(500, Easing.OutQuint); - content.ScaleTo(1, 650, Easing.OutQuint).Then().Schedule(prepareNewPlayer); - - using (BeginDelayedSequence(delayBeforeSideDisplays)) + if (quickRestart) { - settingsScroll.FadeInFromZero(500, Easing.Out) - .MoveToX(0, 500, Easing.OutQuint); + // A quick restart starts by triggering a fade to black + AddInternal(quickRestartBlackLayer = new Box + { + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both, + Depth = float.MaxValue + }); - disclaimers.FadeInFromZero(500, Easing.Out) - .MoveToX(0, 500, Easing.OutQuint); + quickRestartBlackLayer + .Delay(50) + .FadeOut(5000, Easing.OutQuint); + + prepareNewPlayer(); + + content + .Delay(quick_restart_initial_delay) + .ScaleTo(1) + .FadeInFromZero(500, Easing.OutQuint); + } + else + { + content.FadeInFromZero(500, Easing.OutQuint); + + content + .ScaleTo(0.7f) + .ScaleTo(1, 650, Easing.OutQuint) + .Then() + .Schedule(prepareNewPlayer); + + using (BeginDelayedSequence(delayBeforeSideDisplays)) + { + settingsScroll.FadeInFromZero(500, Easing.Out) + .MoveToX(0, 500, Easing.OutQuint); + + disclaimers.FadeInFromZero(500, Easing.Out) + .MoveToX(0, 500, Easing.OutQuint); + } } AddRangeInternal(new[] @@ -527,33 +578,36 @@ namespace osu.Game.Screens.Play highPerformanceSession ??= highPerformanceSessionManager?.BeginSession(); scheduledPushPlayer = Scheduler.AddDelayed(() => - { - // ensure that once we have reached this "point of no return", readyForPush will be false for all future checks (until a new player instance is prepared). - var consumedPlayer = consumePlayer(); - - ContentOut(); - - TransformSequence pushSequence = this.Delay(0); - - // This goes hand-in-hand with the restoration of low pass filter in contentOut(). - this.TransformBindableTo(volumeAdjustment, 0, CONTENT_OUT_DURATION, Easing.OutCubic); - - pushSequence.Schedule(() => { - if (!this.IsCurrentScreen()) return; + // ensure that once we have reached this "point of no return", readyForPush will be false for all future checks (until a new player instance is prepared). + var consumedPlayer = consumePlayer(); - LoadTask = null; + ContentOut(); - // By default, we want to load the player and never be returned to. - // Note that this may change if the player we load requested a re-run. - ValidForResume = false; + TransformSequence pushSequence = this.Delay(0); - if (consumedPlayer.LoadedBeatmapSuccessfully) - this.Push(consumedPlayer); - else - this.Exit(); - }); - }, 500); + // This goes hand-in-hand with the restoration of low pass filter in contentOut(). + this.TransformBindableTo(volumeAdjustment, 0, CONTENT_OUT_DURATION, Easing.OutCubic); + + pushSequence.Schedule(() => + { + if (!this.IsCurrentScreen()) return; + + LoadTask = null; + + // By default, we want to load the player and never be returned to. + // Note that this may change if the player we load requested a re-run. + ValidForResume = false; + + if (consumedPlayer.LoadedBeatmapSuccessfully) + this.Push(consumedPlayer); + else + this.Exit(); + }); + }, + // When a quick restart is activated, the metadata content will display some time later if it's taking too long. + // To avoid it appearing too briefly, if it begins to fade in let's induce a standard delay. + quickRestart && content.Alpha == 0 ? 0 : 500); } private void cancelLoad() diff --git a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs index f312fb0ec5..74b887481f 100644 --- a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs +++ b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs @@ -195,7 +195,10 @@ namespace osu.Game.Screens.Play.PlayerSettings }, }; - if (hitEvents.Count < 10) + // affecting unstable rate here is used as a substitute of determining if a hit event represents a *timed* hit event, + // i.e. an user input that the user had to *time to the track*, + // i.e. one that it *makes sense to use* when doing anything with timing and offsets. + if (hitEvents.Count(HitEventExtensions.AffectsUnstableRate) < 10) { referenceScoreContainer.AddRange(new Drawable[] { diff --git a/osu.Game/Screens/Play/RoomSubmittingPlayer.cs b/osu.Game/Screens/Play/RoomSubmittingPlayer.cs index 3f74f49384..74ee7e1868 100644 --- a/osu.Game/Screens/Play/RoomSubmittingPlayer.cs +++ b/osu.Game/Screens/Play/RoomSubmittingPlayer.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.Diagnostics; using osu.Game.Extensions; using osu.Game.Online.API; @@ -19,16 +17,16 @@ namespace osu.Game.Screens.Play protected readonly PlaylistItem PlaylistItem; protected readonly Room Room; - protected RoomSubmittingPlayer(Room room, PlaylistItem playlistItem, PlayerConfiguration configuration = null) + protected RoomSubmittingPlayer(Room room, PlaylistItem playlistItem, PlayerConfiguration? configuration = null) : base(configuration) { Room = room; PlaylistItem = playlistItem; } - protected override APIRequest CreateTokenRequest() + protected override APIRequest? CreateTokenRequest() { - if (!(Room.RoomID.Value is long roomId)) + if (Room.RoomID is not long roomId) return null; int beatmapId = Beatmap.Value.BeatmapInfo.OnlineID; @@ -45,8 +43,8 @@ namespace osu.Game.Screens.Play protected override APIRequest CreateSubmissionRequest(Score score, long token) { - Debug.Assert(Room.RoomID.Value != null); - return new SubmitRoomScoreRequest(score.ScoreInfo, token, Room.RoomID.Value.Value, PlaylistItem.ID); + Debug.Assert(Room.RoomID != null); + return new SubmitRoomScoreRequest(score.ScoreInfo, token, Room.RoomID.Value, PlaylistItem.ID); } } } diff --git a/osu.Game/Screens/Play/SkipOverlay.cs b/osu.Game/Screens/Play/SkipOverlay.cs index 362677ca5c..be8517d9a0 100644 --- a/osu.Game/Screens/Play/SkipOverlay.cs +++ b/osu.Game/Screens/Play/SkipOverlay.cs @@ -219,7 +219,7 @@ namespace osu.Game.Screens.Play float progress = (float)(gameplayClock.CurrentTime - displayTime) / (float)(fadeOutBeginTime - displayTime); float newWidth = 1 - Math.Clamp(progress, 0, 1); - remainingTimeBox.ResizeWidthTo(newWidth, timingPoint.BeatLength * 2, Easing.OutQuint); + remainingTimeBox.ResizeWidthTo(newWidth, timingPoint.BeatLength * 3.5, Easing.OutQuint); } public partial class FadeContainer : Container, IStateful diff --git a/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs b/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs index cebc54f490..319a87fdfc 100644 --- a/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs +++ b/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs @@ -298,7 +298,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy { foreach (var badge in badges) { - if (badge.Accuracy > score.Accuracy) + if (badge.Rank > score.Rank) continue; using (BeginDelayedSequence( diff --git a/osu.Game/Screens/Ranking/Expanded/Statistics/PerformanceStatistic.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/PerformanceStatistic.cs index 7ea3cbe917..7d155e32b0 100644 --- a/osu.Game/Screens/Ranking/Expanded/Statistics/PerformanceStatistic.cs +++ b/osu.Game/Screens/Ranking/Expanded/Statistics/PerformanceStatistic.cs @@ -53,10 +53,10 @@ namespace osu.Game.Screens.Ranking.Expanded.Statistics var performanceCalculator = score.Ruleset.CreateInstance().CreatePerformanceCalculator(); // Performance calculation requires the beatmap and ruleset to be locally available. If not, return a default value. - if (attributes?.Attributes == null || performanceCalculator == null) + if (attributes?.DifficultyAttributes == null || performanceCalculator == null) return; - var result = await performanceCalculator.CalculateAsync(score, attributes.Value.Attributes, cancellationToken ?? default).ConfigureAwait(false); + var result = await performanceCalculator.CalculateAsync(score, attributes.Value.DifficultyAttributes, cancellationToken ?? default).ConfigureAwait(false); Schedule(() => setPerformanceValue(score, result.Total)); }, cancellationToken ?? default); diff --git a/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs b/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs index 207e19a716..a9b93e0ffc 100644 --- a/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs +++ b/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs @@ -176,7 +176,7 @@ namespace osu.Game.Screens.Ranking.Statistics for (int i = 1; i <= axis_points; i++) { double axisValue = i * axisValueStep; - float position = (float)(axisValue / maxValue); + float position = maxValue == 0 ? 0 : (float)(axisValue / maxValue); float alpha = 1f - position * 0.8f; axisFlow.Add(new OsuSpriteText @@ -348,7 +348,7 @@ namespace osu.Game.Screens.Ranking.Statistics boxAdjustment.FadeTo(!hasAdjustment ? 0 : 1, duration, Easing.OutQuint); } - private float offsetForValue(float value) => (1 - minimum_height) * value / maxValue; + private float offsetForValue(float value) => maxValue == 0 ? 0 : (1 - minimum_height) * value / maxValue; private float heightForValue(float value) => minimum_height + offsetForValue(value); } diff --git a/osu.Game/Screens/Ranking/Statistics/PerformanceBreakdownChart.cs b/osu.Game/Screens/Ranking/Statistics/PerformanceBreakdownChart.cs index b5eed2d12a..f9c8c93dec 100644 --- a/osu.Game/Screens/Ranking/Statistics/PerformanceBreakdownChart.cs +++ b/osu.Game/Screens/Ranking/Statistics/PerformanceBreakdownChart.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading; +using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Extensions; using osu.Framework.Extensions.Color4Extensions; @@ -26,7 +27,6 @@ namespace osu.Game.Screens.Ranking.Statistics public partial class PerformanceBreakdownChart : Container { private readonly ScoreInfo score; - private readonly IBeatmap playableBeatmap; private Drawable spinner = null!; private Drawable content = null!; @@ -42,7 +42,6 @@ namespace osu.Game.Screens.Ranking.Statistics public PerformanceBreakdownChart(ScoreInfo score, IBeatmap playableBeatmap) { this.score = score; - this.playableBeatmap = playableBeatmap; } [BackgroundDependencyLoader] @@ -142,12 +141,33 @@ namespace osu.Game.Screens.Ranking.Statistics spinner.Show(); - new PerformanceBreakdownCalculator(playableBeatmap, difficultyCache) - .CalculateAsync(score, cancellationTokenSource.Token) - .ContinueWith(t => Schedule(() => setPerformanceValue(t.GetResultSafely()!))); + computePerformance(cancellationTokenSource.Token) + .ContinueWith(t => Schedule(() => + { + if (t.GetResultSafely() is PerformanceBreakdown breakdown) + setPerformance(breakdown); + }), TaskContinuationOptions.OnlyOnRanToCompletion); } - private void setPerformanceValue(PerformanceBreakdown breakdown) + private async Task computePerformance(CancellationToken token) + { + var performanceCalculator = score.Ruleset.CreateInstance().CreatePerformanceCalculator(); + if (performanceCalculator == null) + return null; + + var starsTask = difficultyCache.GetDifficultyAsync(score.BeatmapInfo!, score.Ruleset, score.Mods, token).ConfigureAwait(false); + if (await starsTask is not StarDifficulty stars) + return null; + + if (stars.DifficultyAttributes == null || stars.PerformanceAttributes == null) + return null; + + return new PerformanceBreakdown( + await performanceCalculator.CalculateAsync(score, stars.DifficultyAttributes, token).ConfigureAwait(false), + stars.PerformanceAttributes); + } + + private void setPerformance(PerformanceBreakdown breakdown) { spinner.Hide(); content.FadeIn(200); @@ -236,6 +256,8 @@ namespace osu.Game.Screens.Ranking.Statistics protected override void Dispose(bool isDisposing) { cancellationTokenSource.Cancel(); + cancellationTokenSource.Dispose(); + base.Dispose(isDisposing); } } diff --git a/osu.Game/Screens/Select/BeatmapCarousel.cs b/osu.Game/Screens/Select/BeatmapCarousel.cs index d9359cfec3..5e1e0ce615 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] @@ -251,8 +245,7 @@ namespace osu.Game.Screens.Select config.BindWith(OsuSetting.RandomSelectAlgorithm, RandomAlgorithm); config.BindWith(OsuSetting.SongSelectRightMouseScroll, RightClickScrollingEnabled); - RightClickScrollingEnabled.ValueChanged += enabled => Scroll.RightMouseScrollbar = enabled.NewValue; - RightClickScrollingEnabled.TriggerChange(); + RightClickScrollingEnabled.BindValueChanged(enabled => Scroll.RightMouseScrollbar = enabled.NewValue, true); if (detachedBeatmapStore != null && detachedBeatmapSets == null) { @@ -322,6 +315,11 @@ namespace osu.Game.Screens.Select { try { + // To handle the beatmap update flow, attempt to track selection changes across delete-insert transactions. + // When an update occurs, the previous beatmap set is either soft or hard deleted. + // Check if the current selection was potentially deleted by re-querying its validity. + bool selectedSetMarkedDeleted = SelectedBeatmapSet != null && fetchFromID(SelectedBeatmapSet.ID)?.DeletePending != false; + foreach (var set in setsRequiringRemoval) removeBeatmapSet(set.ID); foreach (var set in setsRequiringUpdate) updateBeatmapSet(set); @@ -331,11 +329,6 @@ namespace osu.Game.Screens.Select // If SelectedBeatmapInfo is non-null, the set should also be non-null. Debug.Assert(SelectedBeatmapSet != null); - // To handle the beatmap update flow, attempt to track selection changes across delete-insert transactions. - // When an update occurs, the previous beatmap set is either soft or hard deleted. - // Check if the current selection was potentially deleted by re-querying its validity. - bool selectedSetMarkedDeleted = fetchFromID(SelectedBeatmapSet.ID)?.DeletePending != false; - if (selectedSetMarkedDeleted && setsRequiringUpdate.Any()) { // If it is no longer valid, make the bold assumption that an updated version will be available in the modified/inserted indices. @@ -1116,11 +1109,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 +1259,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/BeatmapDeleteDialog.cs b/osu.Game/Screens/Select/BeatmapDeleteDialog.cs index e98af8cca2..16f0cbe65e 100644 --- a/osu.Game/Screens/Select/BeatmapDeleteDialog.cs +++ b/osu.Game/Screens/Select/BeatmapDeleteDialog.cs @@ -7,14 +7,14 @@ using osu.Game.Overlays.Dialog; namespace osu.Game.Screens.Select { - public partial class BeatmapDeleteDialog : DangerousActionDialog + public partial class BeatmapDeleteDialog : DeletionDialog { private readonly BeatmapSetInfo beatmapSet; public BeatmapDeleteDialog(BeatmapSetInfo beatmapSet) { this.beatmapSet = beatmapSet; - BodyText = $@"{beatmapSet.Metadata.Artist} - {beatmapSet.Metadata.Title}"; + BodyText = beatmapSet.Metadata.GetDisplayTitleRomanisable(false); } [BackgroundDependencyLoader] diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs index 8f38ae710c..c007fa29ed 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs @@ -66,6 +66,8 @@ namespace osu.Game.Screens.Select.Carousel match &= !criteria.OverallDifficulty.HasFilter || criteria.OverallDifficulty.IsInRange(BeatmapInfo.Difficulty.OverallDifficulty); match &= !criteria.Length.HasFilter || criteria.Length.IsInRange(BeatmapInfo.Length); match &= !criteria.LastPlayed.HasFilter || criteria.LastPlayed.IsInRange(BeatmapInfo.LastPlayed ?? DateTimeOffset.MinValue); + match &= !criteria.DateRanked.HasFilter || (BeatmapInfo.BeatmapSet?.DateRanked != null && criteria.DateRanked.IsInRange(BeatmapInfo.BeatmapSet.DateRanked.Value)); + match &= !criteria.DateSubmitted.HasFilter || (BeatmapInfo.BeatmapSet?.DateSubmitted != null && criteria.DateSubmitted.IsInRange(BeatmapInfo.BeatmapSet.DateSubmitted.Value)); match &= !criteria.BPM.HasFilter || criteria.BPM.IsInRange(BeatmapInfo.BPM); match &= !criteria.BeatDivisor.HasFilter || criteria.BeatDivisor.IsInRange(BeatmapInfo.BeatDivisor); @@ -79,6 +81,7 @@ namespace osu.Game.Screens.Select.Carousel match &= !criteria.Title.HasFilter || criteria.Title.Matches(BeatmapInfo.Metadata.Title) || criteria.Title.Matches(BeatmapInfo.Metadata.TitleUnicode); match &= !criteria.DifficultyName.HasFilter || criteria.DifficultyName.Matches(BeatmapInfo.DifficultyName); + match &= !criteria.Source.HasFilter || criteria.Source.Matches(BeatmapInfo.Metadata.Source); match &= !criteria.UserStarDifficulty.HasFilter || criteria.UserStarDifficulty.IsInRange(BeatmapInfo.StarRating); if (!match) return false; 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/Details/AdvancedStats.cs b/osu.Game/Screens/Select/Details/AdvancedStats.cs index 1da890100e..b7086d2416 100644 --- a/osu.Game/Screens/Select/Details/AdvancedStats.cs +++ b/osu.Game/Screens/Select/Details/AdvancedStats.cs @@ -3,6 +3,7 @@ #nullable disable +using System; using osuTK.Graphics; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; @@ -36,9 +37,6 @@ namespace osu.Game.Screens.Select.Details [Resolved] private BeatmapDifficultyCache difficultyCache { get; set; } - [Resolved] - private IBindable> mods { get; set; } - protected readonly StatisticRow FirstValue, HpDrain, Accuracy, ApproachRate; private readonly StatisticRow starDifficulty; @@ -69,6 +67,14 @@ namespace osu.Game.Screens.Select.Details /// public Bindable Ruleset { get; } = new Bindable(); + /// + /// Mods to be used for certain elements of display. + /// + /// + /// No checks are done as to whether the mods specified are valid for the current . + /// + public Bindable> Mods { get; } = new Bindable>(Array.Empty()); + public AdvancedStats(int columns = 1) { switch (columns) @@ -143,8 +149,7 @@ namespace osu.Game.Screens.Select.Details base.LoadComplete(); Ruleset.BindValueChanged(_ => updateStatistics()); - - mods.BindValueChanged(modsChanged, true); + Mods.BindValueChanged(modsChanged, true); } private ModSettingChangeTracker modSettingChangeTracker; @@ -173,14 +178,14 @@ namespace osu.Game.Screens.Select.Details { BeatmapDifficulty originalDifficulty = new BeatmapDifficulty(baseDifficulty); - foreach (var mod in mods.Value.OfType()) + foreach (var mod in Mods.Value.OfType()) mod.ApplyToDifficulty(originalDifficulty); adjustedDifficulty = originalDifficulty; if (Ruleset.Value != null) { - double rate = ModUtils.CalculateRateWithMods(mods.Value); + double rate = ModUtils.CalculateRateWithMods(Mods.Value); adjustedDifficulty = Ruleset.Value.CreateInstance().GetRateAdjustedDisplayDifficulty(originalDifficulty, rate); @@ -198,7 +203,7 @@ namespace osu.Game.Screens.Select.Details // For the time being, the key count is static no matter what, because: // a) The method doesn't have knowledge of the active keymods. Doing so may require considerations for filtering. // b) Using the difficulty adjustment mod to adjust OD doesn't have an effect on conversion. - int keyCount = baseDifficulty == null ? 0 : legacyRuleset.GetKeyCount(BeatmapInfo, mods.Value); + int keyCount = baseDifficulty == null ? 0 : legacyRuleset.GetKeyCount(BeatmapInfo, Mods.Value); FirstValue.Title = BeatmapsetsStrings.ShowStatsCsMania; FirstValue.Value = (keyCount, keyCount); @@ -236,7 +241,7 @@ namespace osu.Game.Screens.Select.Details starDifficultyCancellationSource = new CancellationTokenSource(); var normalStarDifficultyTask = difficultyCache.GetDifficultyAsync(BeatmapInfo, Ruleset.Value, null, starDifficultyCancellationSource.Token); - var moddedStarDifficultyTask = difficultyCache.GetDifficultyAsync(BeatmapInfo, Ruleset.Value, mods.Value, starDifficultyCancellationSource.Token); + var moddedStarDifficultyTask = difficultyCache.GetDifficultyAsync(BeatmapInfo, Ruleset.Value, Mods.Value, starDifficultyCancellationSource.Token); Task.WhenAll(normalStarDifficultyTask, moddedStarDifficultyTask).ContinueWith(_ => Schedule(() => { 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/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs index 190efd0fb0..76c0f769f0 100644 --- a/osu.Game/Screens/Select/FilterCriteria.cs +++ b/osu.Game/Screens/Select/FilterCriteria.cs @@ -37,10 +37,13 @@ namespace osu.Game.Screens.Select public OptionalRange BeatDivisor; public OptionalSet OnlineStatus = new OptionalSet(); public OptionalRange LastPlayed; + public OptionalRange DateRanked; + public OptionalRange DateSubmitted; public OptionalTextFilter Creator; public OptionalTextFilter Artist; public OptionalTextFilter Title; public OptionalTextFilter DifficultyName; + public OptionalTextFilter Source; public OptionalRange UserStarDifficulty = new OptionalRange { diff --git a/osu.Game/Screens/Select/FilterQueryParser.cs b/osu.Game/Screens/Select/FilterQueryParser.cs index 6c9a95a250..78f3bab114 100644 --- a/osu.Game/Screens/Select/FilterQueryParser.cs +++ b/osu.Game/Screens/Select/FilterQueryParser.cs @@ -65,6 +65,12 @@ namespace osu.Game.Screens.Select case "lastplayed": return tryUpdateDateAgoRange(ref criteria.LastPlayed, op, value); + case "ranked": + return tryUpdateRankedDateRange(ref criteria.DateRanked, op, value); + + case "submitted": + return tryUpdateRankedDateRange(ref criteria.DateSubmitted, op, value); + case "played": if (!tryParseBool(value, out bool played)) return false; @@ -107,6 +113,9 @@ namespace osu.Game.Screens.Select case "diff": return TryUpdateCriteriaText(ref criteria.DifficultyName, op, value); + case "source": + return TryUpdateCriteriaText(ref criteria.Source, op, value); + default: return criteria.RulesetCriteria?.TryParseCustomKeywordCriteria(key, op, value) ?? false; } @@ -592,5 +601,163 @@ namespace osu.Game.Screens.Select return tryUpdateCriteriaRange(ref dateRange, op, dateTimeOffset.Value); } + + /// + /// Helper function for building a UTC date from only the year, month and day. + /// UTC is used to keep consistent search results with osu!web. + /// + private static DateTimeOffset dateTimeOffsetFromDateOnly(int year, int month, int day) => + new DateTimeOffset(year, month, day, 0, 0, 0, TimeSpan.Zero); + + /// + /// Parses a string containing a ranked or submitted date filter. + /// Returns a boolean depending on whether parsing was successful or not. + /// Accepted dates are in the formats `yyyy`, `yyyy-mm` and `yyyy-mm-dd`. + /// Leading zeros are accepted. Numbers can be separated by `-`, `/`, or `.` + /// + /// The to store the parsed data into, if successful. + /// The operator of the filtering query + /// The string value to attempt parsing for. + private static bool tryUpdateRankedDateRange(ref FilterCriteria.OptionalRange dateRange, Operator op, string val) + { + GroupCollection? match = tryMatchRegex(val, @"^(?\d+)([-/.](?\d+)([-/.](?\d+))?)?$"); + + if (match == null) + return false; + + int? year = null; + int? month = null; + int? day = null; + + List keys = new List { @"year", @"month", @"day" }; + + foreach (string key in keys) + { + if (!match.TryGetValue(key, out var group) || !group.Success) + continue; + + if (group.Success) + { + if (!tryParseDoubleWithPoint(group.Value, out double value)) + return false; + + switch (key) + { + case @"year": + year = (int)value; + break; + + case @"month": + month = (int)value; + break; + + case @"day": + day = (int)value; + break; + } + } + } + + if (year == null) + { + return false; + } + + try + { + DateTimeOffset dateTimeOffset; + + switch (op) + { + case Operator.Less: + month ??= 1; + day ??= 1; + + dateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value); + return tryUpdateCriteriaRange(ref dateRange, op, dateTimeOffset); + + case Operator.LessOrEqual: + if (month == null) + { + month = 1; + day = 1; + dateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddYears(1); + return tryUpdateCriteriaRange(ref dateRange, Operator.Less, dateTimeOffset); + } + + if (day == null) + { + day = 1; + dateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddMonths(1); + return tryUpdateCriteriaRange(ref dateRange, Operator.Less, dateTimeOffset); + } + + dateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddDays(1); + return tryUpdateCriteriaRange(ref dateRange, Operator.Less, dateTimeOffset); + + case Operator.GreaterOrEqual: + month ??= 1; + day ??= 1; + + dateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value); + return tryUpdateCriteriaRange(ref dateRange, op, dateTimeOffset); + + case Operator.Greater: + if (month == null) + { + month = 1; + day = 1; + dateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddYears(1); + return tryUpdateCriteriaRange(ref dateRange, Operator.GreaterOrEqual, dateTimeOffset); + } + + if (day == null) + { + day = 1; + dateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddMonths(1); + return tryUpdateCriteriaRange(ref dateRange, Operator.GreaterOrEqual, dateTimeOffset); + } + + dateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddDays(1); + return tryUpdateCriteriaRange(ref dateRange, Operator.GreaterOrEqual, dateTimeOffset); + + case Operator.Equal: + + DateTimeOffset minDateTimeOffset; + DateTimeOffset maxDateTimeOffset; + + if (month == null) + { + month = 1; + day = 1; + minDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value); + maxDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddYears(1); + return tryUpdateCriteriaRange(ref dateRange, Operator.GreaterOrEqual, minDateTimeOffset) + && tryUpdateCriteriaRange(ref dateRange, Operator.Less, maxDateTimeOffset); + } + + if (day == null) + { + day = 1; + minDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value); + maxDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddMonths(1); + return tryUpdateCriteriaRange(ref dateRange, Operator.GreaterOrEqual, minDateTimeOffset) + && tryUpdateCriteriaRange(ref dateRange, Operator.Less, maxDateTimeOffset); + } + + minDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value); + maxDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddDays(1); + return tryUpdateCriteriaRange(ref dateRange, Operator.GreaterOrEqual, minDateTimeOffset) + && tryUpdateCriteriaRange(ref dateRange, Operator.Less, maxDateTimeOffset); + + default: + return false; + } + } + catch (ArgumentOutOfRangeException) + { + return false; + } + } } } 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/LocalScoreDeleteDialog.cs b/osu.Game/Screens/Select/LocalScoreDeleteDialog.cs index cd98872b65..ec2b8437e1 100644 --- a/osu.Game/Screens/Select/LocalScoreDeleteDialog.cs +++ b/osu.Game/Screens/Select/LocalScoreDeleteDialog.cs @@ -4,11 +4,10 @@ using osu.Framework.Allocation; using osu.Game.Overlays.Dialog; using osu.Game.Scoring; -using osu.Framework.Graphics.Sprites; namespace osu.Game.Screens.Select { - public partial class LocalScoreDeleteDialog : DangerousActionDialog + public partial class LocalScoreDeleteDialog : DeletionDialog { private readonly ScoreInfo score; @@ -21,8 +20,6 @@ namespace osu.Game.Screens.Select private void load(ScoreManager scoreManager) { BodyText = $"{score.User} ({score.DisplayAccuracy}, {score.Rank})"; - - Icon = FontAwesome.Regular.TrashAlt; DangerousAction = () => scoreManager.Delete(score); } } diff --git a/osu.Game/Screens/Select/ModSpeedHotkeyHandler.cs b/osu.Game/Screens/Select/ModSpeedHotkeyHandler.cs index af64002bcf..c4cd44705e 100644 --- a/osu.Game/Screens/Select/ModSpeedHotkeyHandler.cs +++ b/osu.Game/Screens/Select/ModSpeedHotkeyHandler.cs @@ -27,6 +27,7 @@ namespace osu.Game.Screens.Select private OnScreenDisplay? onScreenDisplay { get; set; } private ModRateAdjust? lastActiveRateAdjustMod; + private ModSettingChangeTracker? settingChangeTracker; protected override void LoadComplete() { @@ -34,10 +35,19 @@ namespace osu.Game.Screens.Select selectedMods.BindValueChanged(val => { - lastActiveRateAdjustMod = val.NewValue.OfType().SingleOrDefault() ?? lastActiveRateAdjustMod; + storeLastActiveRateAdjustMod(); + + settingChangeTracker?.Dispose(); + settingChangeTracker = new ModSettingChangeTracker(val.NewValue); + settingChangeTracker.SettingChanged += _ => storeLastActiveRateAdjustMod(); }, true); } + private void storeLastActiveRateAdjustMod() + { + lastActiveRateAdjustMod = (ModRateAdjust?)selectedMods.Value.OfType().SingleOrDefault()?.DeepClone() ?? lastActiveRateAdjustMod; + } + public bool ChangeSpeed(double delta, IEnumerable availableMods) { double targetSpeed = (selectedMods.Value.OfType().SingleOrDefault()?.SpeedChange.Value ?? 1) + delta; diff --git a/osu.Game/Screens/Select/SkinDeleteDialog.cs b/osu.Game/Screens/Select/SkinDeleteDialog.cs index 6612ae837a..cd14b5b6d2 100644 --- a/osu.Game/Screens/Select/SkinDeleteDialog.cs +++ b/osu.Game/Screens/Select/SkinDeleteDialog.cs @@ -7,7 +7,7 @@ using osu.Game.Overlays.Dialog; namespace osu.Game.Screens.Select { - public partial class SkinDeleteDialog : DangerousActionDialog + public partial class SkinDeleteDialog : DeletionDialog { private readonly Skin skin; diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs index 18608d61e9..9f7a2c02ff 100644 --- a/osu.Game/Screens/Select/SongSelect.cs +++ b/osu.Game/Screens/Select/SongSelect.cs @@ -610,11 +610,6 @@ namespace osu.Game.Screens.Select beatmapInfoPrevious = beatmap; } - // we can't run this in the debounced run due to the selected mods bindable not being debounced, - // since mods could be updated to the new ruleset instances while the decoupled bindable is held behind, - // therefore resulting in performing difficulty calculation with invalid states. - advancedStats.Ruleset.Value = ruleset; - void run() { // clear pending task immediately to track any potential nested debounce operation. @@ -716,12 +711,6 @@ namespace osu.Game.Screens.Select Carousel.AllowSelection = true; - if (pendingFilterApplication) - { - Carousel.Filter(FilterControl.CreateCriteria()); - pendingFilterApplication = false; - } - BeatmapDetails.Refresh(); beginLooping(); @@ -754,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). @@ -878,6 +878,8 @@ namespace osu.Game.Screens.Select ModSelect.Beatmap.Value = beatmap; advancedStats.BeatmapInfo = beatmap.BeatmapInfo; + advancedStats.Mods.Value = selectedMods.Value; + advancedStats.Ruleset.Value = Ruleset.Value; bool beatmapSelected = beatmap is not DummyWorkingBeatmap; @@ -990,6 +992,12 @@ namespace osu.Game.Screens.Select Beatmap.BindValueChanged(updateCarouselSelection); + selectedMods.BindValueChanged(_ => + { + if (decoupledRuleset.Value.Equals(rulesetNoDebounce)) + advancedStats.Mods.Value = selectedMods.Value; + }, true); + boundLocalBindables = true; } 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/Skinning/Components/BeatmapAttributeText.cs b/osu.Game/Skinning/Components/BeatmapAttributeText.cs index 6e1d655cef..f1c27434fa 100644 --- a/osu.Game/Skinning/Components/BeatmapAttributeText.cs +++ b/osu.Game/Skinning/Components/BeatmapAttributeText.cs @@ -3,8 +3,8 @@ using System; using System.Collections.Generic; -using System.Collections.Immutable; using System.Linq; +using System.Threading; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -20,6 +20,9 @@ using osu.Game.Graphics.Sprites; using osu.Game.Localisation; using osu.Game.Localisation.SkinComponents; using osu.Game.Resources.Localisation.Web; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Utils; namespace osu.Game.Skinning.Components { @@ -35,26 +38,20 @@ namespace osu.Game.Skinning.Components [Resolved] private IBindable beatmap { get; set; } = null!; - private readonly Dictionary valueDictionary = new Dictionary(); + [Resolved] + private IBindable> mods { get; set; } = null!; - private static readonly ImmutableDictionary label_dictionary = new Dictionary - { - [BeatmapAttribute.CircleSize] = BeatmapsetsStrings.ShowStatsCs, - [BeatmapAttribute.Accuracy] = BeatmapsetsStrings.ShowStatsAccuracy, - [BeatmapAttribute.HPDrain] = BeatmapsetsStrings.ShowStatsDrain, - [BeatmapAttribute.ApproachRate] = BeatmapsetsStrings.ShowStatsAr, - [BeatmapAttribute.StarRating] = BeatmapsetsStrings.ShowStatsStars, - [BeatmapAttribute.Title] = EditorSetupStrings.Title, - [BeatmapAttribute.Artist] = EditorSetupStrings.Artist, - [BeatmapAttribute.DifficultyName] = EditorSetupStrings.DifficultyHeader, - [BeatmapAttribute.Creator] = EditorSetupStrings.Creator, - [BeatmapAttribute.Source] = EditorSetupStrings.Source, - [BeatmapAttribute.Length] = ArtistStrings.TracklistLength.ToTitle(), - [BeatmapAttribute.RankedStatus] = BeatmapDiscussionsStrings.IndexFormBeatmapsetStatusDefault, - [BeatmapAttribute.BPM] = BeatmapsetsStrings.ShowStatsBpm, - }.ToImmutableDictionary(); + [Resolved] + private IBindable ruleset { get; set; } = null!; + + [Resolved] + private BeatmapDifficultyCache difficultyCache { get; set; } = null!; private readonly OsuSpriteText text; + private IBindable? difficultyBindable; + private CancellationTokenSource? difficultyCancellationSource; + private ModSettingChangeTracker? modSettingTracker; + private StarDifficulty? starDifficulty; public BeatmapAttributeText() { @@ -74,57 +71,201 @@ namespace osu.Game.Skinning.Components { base.LoadComplete(); - Attribute.BindValueChanged(_ => updateLabel()); - Template.BindValueChanged(_ => updateLabel()); + Attribute.BindValueChanged(_ => updateText()); + Template.BindValueChanged(_ => updateText()); + beatmap.BindValueChanged(b => { - updateBeatmapContent(b.NewValue); - updateLabel(); + difficultyCancellationSource?.Cancel(); + difficultyCancellationSource = new CancellationTokenSource(); + + difficultyBindable?.UnbindAll(); + difficultyBindable = difficultyCache.GetBindableDifficulty(b.NewValue.BeatmapInfo, difficultyCancellationSource.Token); + difficultyBindable.BindValueChanged(d => + { + starDifficulty = d.NewValue; + updateText(); + }); + + updateText(); }, true); + + mods.BindValueChanged(m => + { + modSettingTracker?.Dispose(); + modSettingTracker = new ModSettingChangeTracker(m.NewValue) + { + SettingChanged = _ => updateText() + }; + + updateText(); + }, true); + + ruleset.BindValueChanged(_ => updateText()); + + updateText(); } - private void updateBeatmapContent(WorkingBeatmap workingBeatmap) - { - valueDictionary[BeatmapAttribute.Title] = new RomanisableString(workingBeatmap.BeatmapInfo.Metadata.TitleUnicode, workingBeatmap.BeatmapInfo.Metadata.Title); - valueDictionary[BeatmapAttribute.Artist] = new RomanisableString(workingBeatmap.BeatmapInfo.Metadata.ArtistUnicode, workingBeatmap.BeatmapInfo.Metadata.Artist); - valueDictionary[BeatmapAttribute.DifficultyName] = workingBeatmap.BeatmapInfo.DifficultyName; - valueDictionary[BeatmapAttribute.Creator] = workingBeatmap.BeatmapInfo.Metadata.Author.Username; - valueDictionary[BeatmapAttribute.Source] = workingBeatmap.BeatmapInfo.Metadata.Source; - valueDictionary[BeatmapAttribute.Length] = TimeSpan.FromMilliseconds(workingBeatmap.BeatmapInfo.Length).ToFormattedDuration(); - valueDictionary[BeatmapAttribute.RankedStatus] = workingBeatmap.BeatmapInfo.Status.GetLocalisableDescription(); - valueDictionary[BeatmapAttribute.BPM] = workingBeatmap.BeatmapInfo.BPM.ToLocalisableString(@"F2"); - valueDictionary[BeatmapAttribute.CircleSize] = ((double)workingBeatmap.BeatmapInfo.Difficulty.CircleSize).ToLocalisableString(@"F2"); - valueDictionary[BeatmapAttribute.HPDrain] = ((double)workingBeatmap.BeatmapInfo.Difficulty.DrainRate).ToLocalisableString(@"F2"); - valueDictionary[BeatmapAttribute.Accuracy] = ((double)workingBeatmap.BeatmapInfo.Difficulty.OverallDifficulty).ToLocalisableString(@"F2"); - valueDictionary[BeatmapAttribute.ApproachRate] = ((double)workingBeatmap.BeatmapInfo.Difficulty.ApproachRate).ToLocalisableString(@"F2"); - valueDictionary[BeatmapAttribute.StarRating] = workingBeatmap.BeatmapInfo.StarRating.ToLocalisableString(@"F2"); - } - - private void updateLabel() + private void updateText() { string numberedTemplate = Template.Value .Replace("{", "{{") .Replace("}", "}}") .Replace(@"{{Label}}", "{0}") - .Replace(@"{{Value}}", $"{{{1 + (int)Attribute.Value}}}"); + .Replace(@"{{Value}}", "{1}"); - object?[] args = valueDictionary.OrderBy(pair => pair.Key) - .Select(pair => pair.Value) - .Prepend(label_dictionary[Attribute.Value]) - .Cast() - .ToArray(); + List values = new List + { + getLabelString(Attribute.Value), + getValueString(Attribute.Value) + }; foreach (var type in Enum.GetValues()) { - numberedTemplate = numberedTemplate.Replace($"{{{{{type}}}}}", $"{{{1 + (int)type}}}"); + string replaced = numberedTemplate.Replace($@"{{{{{type}}}}}", $@"{{{values.Count}}}"); + + if (numberedTemplate != replaced) + { + numberedTemplate = replaced; + values.Add(getValueString(type)); + } } - text.Text = LocalisableString.Format(numberedTemplate, args); + text.Text = LocalisableString.Format(numberedTemplate, values.ToArray()); + } + + private LocalisableString getLabelString(BeatmapAttribute attribute) + { + switch (attribute) + { + case BeatmapAttribute.CircleSize: + return BeatmapsetsStrings.ShowStatsCs; + + case BeatmapAttribute.Accuracy: + return BeatmapsetsStrings.ShowStatsAccuracy; + + case BeatmapAttribute.HPDrain: + return BeatmapsetsStrings.ShowStatsDrain; + + case BeatmapAttribute.ApproachRate: + return BeatmapsetsStrings.ShowStatsAr; + + case BeatmapAttribute.StarRating: + return BeatmapsetsStrings.ShowStatsStars; + + case BeatmapAttribute.Title: + return EditorSetupStrings.Title; + + case BeatmapAttribute.Artist: + return EditorSetupStrings.Artist; + + case BeatmapAttribute.DifficultyName: + return EditorSetupStrings.DifficultyHeader; + + case BeatmapAttribute.Creator: + return EditorSetupStrings.Creator; + + case BeatmapAttribute.Source: + return EditorSetupStrings.Source; + + case BeatmapAttribute.Length: + return ArtistStrings.TracklistLength.ToTitle(); + + case BeatmapAttribute.RankedStatus: + return BeatmapDiscussionsStrings.IndexFormBeatmapsetStatusDefault; + + case BeatmapAttribute.BPM: + return BeatmapsetsStrings.ShowStatsBpm; + + case BeatmapAttribute.MaxPP: + return BeatmapAttributeTextStrings.MaxPP; + + default: + return string.Empty; + } + } + + private LocalisableString getValueString(BeatmapAttribute attribute) + { + switch (attribute) + { + case BeatmapAttribute.Title: + return new RomanisableString(beatmap.Value.BeatmapInfo.Metadata.TitleUnicode, beatmap.Value.BeatmapInfo.Metadata.Title); + + case BeatmapAttribute.Artist: + return new RomanisableString(beatmap.Value.BeatmapInfo.Metadata.ArtistUnicode, beatmap.Value.BeatmapInfo.Metadata.Artist); + + case BeatmapAttribute.DifficultyName: + return beatmap.Value.BeatmapInfo.DifficultyName; + + case BeatmapAttribute.Creator: + return beatmap.Value.BeatmapInfo.Metadata.Author.Username; + + case BeatmapAttribute.Source: + return beatmap.Value.BeatmapInfo.Metadata.Source; + + case BeatmapAttribute.Length: + return Math.Round(beatmap.Value.BeatmapInfo.Length / ModUtils.CalculateRateWithMods(mods.Value)).ToFormattedDuration(); + + case BeatmapAttribute.RankedStatus: + return beatmap.Value.BeatmapInfo.Status.GetLocalisableDescription(); + + case BeatmapAttribute.BPM: + return FormatUtils.RoundBPM(beatmap.Value.BeatmapInfo.BPM, ModUtils.CalculateRateWithMods(mods.Value)).ToLocalisableString(@"0.##"); + + case BeatmapAttribute.CircleSize: + return computeDifficulty().CircleSize.ToLocalisableString(@"0.##"); + + case BeatmapAttribute.HPDrain: + return computeDifficulty().DrainRate.ToLocalisableString(@"0.##"); + + case BeatmapAttribute.Accuracy: + return computeDifficulty().OverallDifficulty.ToLocalisableString(@"0.##"); + + case BeatmapAttribute.ApproachRate: + return computeDifficulty().ApproachRate.ToLocalisableString(@"0.##"); + + case BeatmapAttribute.StarRating: + return (starDifficulty?.Stars ?? 0).ToLocalisableString(@"F2"); + + case BeatmapAttribute.MaxPP: + return Math.Round(starDifficulty?.PerformanceAttributes?.Total ?? 0, MidpointRounding.AwayFromZero).ToLocalisableString(); + + default: + return string.Empty; + } + + BeatmapDifficulty computeDifficulty() + { + BeatmapDifficulty difficulty = new BeatmapDifficulty(beatmap.Value.BeatmapInfo.Difficulty); + + foreach (var mod in mods.Value.OfType()) + mod.ApplyToDifficulty(difficulty); + + if (ruleset.Value is RulesetInfo rulesetInfo) + { + double rate = ModUtils.CalculateRateWithMods(mods.Value); + difficulty = rulesetInfo.CreateInstance().GetRateAdjustedDisplayDifficulty(difficulty, rate); + } + + return difficulty; + } } protected override void SetFont(FontUsage font) => text.Font = font.With(size: 40); protected override void SetTextColour(Colour4 textColour) => text.Colour = textColour; + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + difficultyCancellationSource?.Cancel(); + difficultyCancellationSource?.Dispose(); + difficultyCancellationSource = null; + + modSettingTracker?.Dispose(); + } } // WARNING: DO NOT ADD ANY VALUES TO THIS ENUM ANYWHERE ELSE THAN AT THE END. @@ -144,5 +285,6 @@ namespace osu.Game.Skinning.Components RankedStatus, BPM, Source, + MaxPP } } diff --git a/osu.Game/Skinning/SkinnableSound.cs b/osu.Game/Skinning/SkinnableSound.cs index f153f4f8d3..be9212da9e 100644 --- a/osu.Game/Skinning/SkinnableSound.cs +++ b/osu.Game/Skinning/SkinnableSound.cs @@ -154,6 +154,9 @@ namespace osu.Game.Skinning { bool wasPlaying = IsPlaying; + if (wasPlaying && Looping) + Stop(); + // Remove all pooled samples (return them to the pool), and dispose the rest. samplesContainer.RemoveAll(s => s.IsInPool, false); samplesContainer.Clear(); diff --git a/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs b/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs index c9f2b183e3..4a862750bc 100644 --- a/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs +++ b/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs @@ -13,7 +13,8 @@ namespace osu.Game.Tests.Visual.Metadata { public partial class TestMetadataClient : MetadataClient { - public override IBindable IsConnected => new BindableBool(true); + public override IBindable IsConnected => isConnected; + private readonly BindableBool isConnected = new BindableBool(true); public override IBindable IsWatchingUserPresence => isWatchingUserPresence; private readonly BindableBool isWatchingUserPresence = new BindableBool(); @@ -98,5 +99,16 @@ namespace osu.Game.Tests.Visual.Metadata } public override Task EndWatchingMultiplayerRoom(long id) => Task.CompletedTask; + + public void Disconnect() + { + isConnected.Value = false; + dailyChallengeInfo.Value = null; + } + + public void Reconnect() + { + isConnected.Value = true; + } } } diff --git a/osu.Game/Tests/Visual/ModFailConditionTestScene.cs b/osu.Game/Tests/Visual/ModFailConditionTestScene.cs index 8f0dff055d..72bdd54c51 100644 --- a/osu.Game/Tests/Visual/ModFailConditionTestScene.cs +++ b/osu.Game/Tests/Visual/ModFailConditionTestScene.cs @@ -20,7 +20,7 @@ namespace osu.Game.Tests.Visual protected void CreateHitObjectTest(HitObjectTestData testData, bool shouldMiss) => CreateModTest(new ModTestData { Mod = mod, - Beatmap = new Beatmap + CreateBeatmap = () => new Beatmap { BeatmapInfo = { Ruleset = CreatePlayerRuleset().RulesetInfo }, HitObjects = { testData.HitObject } diff --git a/osu.Game/Tests/Visual/ModTestScene.cs b/osu.Game/Tests/Visual/ModTestScene.cs index c2ebcdefac..eb61518d35 100644 --- a/osu.Game/Tests/Visual/ModTestScene.cs +++ b/osu.Game/Tests/Visual/ModTestScene.cs @@ -40,7 +40,7 @@ namespace osu.Game.Tests.Visual base.TearDownSteps(); } - protected sealed override IBeatmap CreateBeatmap(RulesetInfo ruleset) => CurrentTestData?.Beatmap ?? base.CreateBeatmap(ruleset); + protected sealed override IBeatmap CreateBeatmap(RulesetInfo ruleset) => CurrentTestData?.CreateBeatmap?.Invoke() ?? base.CreateBeatmap(ruleset); protected sealed override TestPlayer CreatePlayer(Ruleset ruleset) { @@ -93,7 +93,7 @@ namespace osu.Game.Tests.Visual protected class ModTestData { /// - /// Whether to use a replay to simulate an auto-play. True by default. + /// Whether to use a replay to simulate an autoplay. True by default. /// public bool Autoplay = true; @@ -104,10 +104,11 @@ namespace osu.Game.Tests.Visual public List ReplayFrames; /// - /// The beatmap for this test case. + /// A function which should create a new instance of a beatmap containing relevant + /// content to the test. /// [CanBeNull] - public IBeatmap Beatmap; + public Func CreateBeatmap; /// /// The conditions that cause this test case to pass. diff --git a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs index 80c69db8b1..42cf317829 100644 --- a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs +++ b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Game.Online.Rooms; using osu.Game.Tests.Beatmaps; using osu.Game.Tests.Visual.OnlinePlay; @@ -37,15 +35,15 @@ namespace osu.Game.Tests.Visual.Multiplayer { return new Room { - Name = { Value = "test name" }, - Type = { Value = MatchType.HeadToHead }, + Name = "test name", + Type = MatchType.HeadToHead, Playlist = - { + [ new PlaylistItem(new TestBeatmap(Ruleset.Value).BeatmapInfo) { RulesetID = Ruleset.Value.OnlineID } - } + ] }; } diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index efa9dc4990..4d812abf11 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -214,9 +214,9 @@ namespace osu.Game.Tests.Visual.Multiplayer roomId = clone(roomId); password = clone(password); - ServerAPIRoom = roomManager.ServerSideRooms.Single(r => r.RoomID.Value == roomId); + ServerAPIRoom = roomManager.ServerSideRooms.Single(r => r.RoomID == roomId); - if (password != ServerAPIRoom.Password.Value) + if (password != ServerAPIRoom.Password) throw new InvalidOperationException("Invalid password."); lastPlaylistItemId = ServerAPIRoom.Playlist.Max(item => item.ID); @@ -230,11 +230,11 @@ namespace osu.Game.Tests.Visual.Multiplayer { Settings = { - Name = ServerAPIRoom.Name.Value, - MatchType = ServerAPIRoom.Type.Value, - Password = password, - QueueMode = ServerAPIRoom.QueueMode.Value, - AutoStartDuration = ServerAPIRoom.AutoStartDuration.Value + Name = ServerAPIRoom.Name, + MatchType = ServerAPIRoom.Type, + Password = password ?? string.Empty, + QueueMode = ServerAPIRoom.QueueMode, + AutoStartDuration = ServerAPIRoom.AutoStartDuration }, Playlist = ServerAPIRoom.Playlist.Select(CreateMultiplayerPlaylistItem).ToList(), Users = { localUser }, @@ -447,7 +447,7 @@ namespace osu.Game.Tests.Visual.Multiplayer item.PlaylistOrder = existingItem.PlaylistOrder; ServerRoom.Playlist[ServerRoom.Playlist.IndexOf(existingItem)] = item; - ServerAPIRoom.Playlist[ServerAPIRoom.Playlist.IndexOf(ServerAPIRoom.Playlist.Single(i => i.ID == item.ID))] = new PlaylistItem(item); + ServerAPIRoom.Playlist = ServerAPIRoom.Playlist.Select((pi, i) => pi.ID == item.ID ? new PlaylistItem(item) : ServerAPIRoom.Playlist[i]).ToArray(); await ((IMultiplayerClient)this).PlaylistItemChanged(clone(item)).ConfigureAwait(false); } @@ -474,7 +474,7 @@ namespace osu.Game.Tests.Visual.Multiplayer throw new InvalidOperationException("Attempted to remove an item which has already been played."); ServerRoom.Playlist.Remove(item); - ServerAPIRoom.Playlist.RemoveAll(i => i.ID == item.ID); + ServerAPIRoom.Playlist = ServerAPIRoom.Playlist.Where(i => i.ID != item.ID).ToArray(); await ((IMultiplayerClient)this).PlaylistItemRemoved(clone(playlistItemId)).ConfigureAwait(false); await updateCurrentItem(ServerRoom).ConfigureAwait(false); @@ -569,7 +569,7 @@ namespace osu.Game.Tests.Visual.Multiplayer item.ID = ++lastPlaylistItemId; ServerRoom.Playlist.Add(item); - ServerAPIRoom.Playlist.Add(new PlaylistItem(item)); + ServerAPIRoom.Playlist = ServerAPIRoom.Playlist.Append(new PlaylistItem(item)).ToArray(); await ((IMultiplayerClient)this).PlaylistItemAdded(clone(item)).ConfigureAwait(false); await updatePlaylistOrder(ServerRoom).ConfigureAwait(false); diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs index 8d04c808fd..b998a638e5 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using osu.Game.Online.API.Requests.Responses; @@ -28,10 +26,10 @@ namespace osu.Game.Tests.Visual.Multiplayer public IReadOnlyList ServerSideRooms => requestsHandler.ServerSideRooms; - public override void CreateRoom(Room room, Action onSuccess = null, Action onError = null) + public override void CreateRoom(Room room, Action? onSuccess = null, Action? onError = null) => base.CreateRoom(room, r => onSuccess?.Invoke(r), onError); - public override void JoinRoom(Room room, string password = null, Action onSuccess = null, Action onError = null) + public override void JoinRoom(Room room, string? password = null, Action? onSuccess = null, Action? onError = null) => base.JoinRoom(room, password, r => onSuccess?.Invoke(r), onError); /// diff --git a/osu.Game/Tests/Visual/OnlinePlay/IOnlinePlayTestSceneDependencies.cs b/osu.Game/Tests/Visual/OnlinePlay/IOnlinePlayTestSceneDependencies.cs index 3509519113..8ddc5325db 100644 --- a/osu.Game/Tests/Visual/OnlinePlay/IOnlinePlayTestSceneDependencies.cs +++ b/osu.Game/Tests/Visual/OnlinePlay/IOnlinePlayTestSceneDependencies.cs @@ -16,7 +16,7 @@ namespace osu.Game.Tests.Visual.OnlinePlay /// /// The cached . /// - Bindable SelectedRoom { get; } + Bindable SelectedRoom { get; } /// /// The cached diff --git a/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs b/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs index eebc3503bc..3f6c175fbd 100644 --- a/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs +++ b/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs @@ -20,7 +20,7 @@ namespace osu.Game.Tests.Visual.OnlinePlay /// public abstract partial class OnlinePlayTestScene : ScreenTestScene, IOnlinePlayTestSceneDependencies { - public Bindable SelectedRoom => OnlinePlayDependencies.SelectedRoom; + public Bindable SelectedRoom => OnlinePlayDependencies.SelectedRoom; public IRoomManager RoomManager => OnlinePlayDependencies.RoomManager; public OngoingOperationTracker OngoingOperationTracker => OnlinePlayDependencies.OngoingOperationTracker; public OnlinePlayBeatmapAvailabilityTracker AvailabilityTracker => OnlinePlayDependencies.AvailabilityTracker; diff --git a/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestSceneDependencies.cs b/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestSceneDependencies.cs index 64bd27b871..e2670c9ad8 100644 --- a/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestSceneDependencies.cs +++ b/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestSceneDependencies.cs @@ -18,7 +18,7 @@ namespace osu.Game.Tests.Visual.OnlinePlay /// public class OnlinePlayTestSceneDependencies : IReadOnlyDependencyContainer, IOnlinePlayTestSceneDependencies { - public Bindable SelectedRoom { get; } + public Bindable SelectedRoom { get; } public IRoomManager RoomManager { get; } public OngoingOperationTracker OngoingOperationTracker { get; } public OnlinePlayBeatmapAvailabilityTracker AvailabilityTracker { get; } @@ -36,7 +36,7 @@ namespace osu.Game.Tests.Visual.OnlinePlay public OnlinePlayTestSceneDependencies() { - SelectedRoom = new Bindable(); + SelectedRoom = new Bindable(); RequestsHandler = new TestRoomRequestsHandler(); OngoingOperationTracker = new OngoingOperationTracker(); AvailabilityTracker = new OnlinePlayBeatmapAvailabilityTracker(); @@ -44,7 +44,7 @@ namespace osu.Game.Tests.Visual.OnlinePlay UserLookupCache = new TestUserLookupCache(); BeatmapLookupCache = new BeatmapLookupCache(); - dependencies = new DependencyContainer(new CachedModelDependencyContainer(null) { Model = { BindTarget = SelectedRoom } }); + dependencies = new DependencyContainer(); CacheAs(RequestsHandler); CacheAs(SelectedRoom); diff --git a/osu.Game/Tests/Visual/OnlinePlay/TestRoomManager.cs b/osu.Game/Tests/Visual/OnlinePlay/TestRoomManager.cs index e9980e822c..b1e3eafacc 100644 --- a/osu.Game/Tests/Visual/OnlinePlay/TestRoomManager.cs +++ b/osu.Game/Tests/Visual/OnlinePlay/TestRoomManager.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 osu.Game.Beatmaps; using osu.Game.Online.API.Requests.Responses; @@ -17,49 +15,42 @@ namespace osu.Game.Tests.Visual.OnlinePlay /// public partial class TestRoomManager : RoomManager { - public Action JoinRoomRequested; + public Action? JoinRoomRequested; private int currentRoomId; - public override void JoinRoom(Room room, string password = null, Action onSuccess = null, Action onError = null) + public override void JoinRoom(Room room, string? password = null, Action? onSuccess = null, Action? onError = null) { JoinRoomRequested?.Invoke(room, password); base.JoinRoom(room, password, onSuccess, onError); } - public void AddRooms(int count, RulesetInfo ruleset = null, bool withPassword = false, bool withSpotlightRooms = false) + public void AddRooms(int count, RulesetInfo? ruleset = null, bool withPassword = false, bool withSpotlightRooms = false) { for (int i = 0; i < count; i++) { - var room = new Room + AddRoom(new Room { - RoomID = { Value = -currentRoomId }, - Name = { Value = $@"Room {currentRoomId}" }, - Host = { Value = new APIUser { Username = @"Host" } }, - EndDate = { Value = DateTimeOffset.Now + TimeSpan.FromSeconds(10) }, - Category = { Value = withSpotlightRooms && i % 2 == 0 ? RoomCategory.Spotlight : RoomCategory.Normal }, - }; - - if (withPassword) - room.Password.Value = @"password"; - - if (ruleset != null) - { - room.PlaylistItemStats.Value = new Room.RoomPlaylistItemStats - { - RulesetIDs = new[] { ruleset.OnlineID }, - }; - - room.Playlist.Add(new PlaylistItem(new BeatmapInfo { Metadata = new BeatmapMetadata() }) - { - RulesetID = ruleset.OnlineID, - }); - } - - CreateRoom(room); - - currentRoomId++; + Name = $@"Room {currentRoomId}", + Host = new APIUser { Username = @"Host" }, + Duration = TimeSpan.FromSeconds(10), + Category = withSpotlightRooms && i % 2 == 0 ? RoomCategory.Spotlight : RoomCategory.Normal, + Password = withPassword ? @"password" : null, + PlaylistItemStats = ruleset == null + ? null + : new Room.RoomPlaylistItemStats { RulesetIDs = [ruleset.OnlineID] }, + Playlist = ruleset == null + ? Array.Empty() + : [new PlaylistItem(new BeatmapInfo { Metadata = new BeatmapMetadata() }) { RulesetID = ruleset.OnlineID }] + }); } } + + public void AddRoom(Room room) + { + room.RoomID = -currentRoomId; + CreateRoom(room); + currentRoomId++; + } } } diff --git a/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs b/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs index cb05180d17..c9149bda22 100644 --- a/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs +++ b/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Diagnostics; @@ -53,8 +51,7 @@ namespace osu.Game.Tests.Visual.OnlinePlay var apiRoom = cloneRoom(createRoomRequest.Room); // Passwords are explicitly not copied between rooms. - apiRoom.HasPassword.Value = !string.IsNullOrEmpty(createRoomRequest.Room.Password.Value); - apiRoom.Password.Value = createRoomRequest.Room.Password.Value; + apiRoom.Password = createRoomRequest.Room.Password; AddServerSideRoom(apiRoom, localUser); @@ -66,15 +63,15 @@ namespace osu.Game.Tests.Visual.OnlinePlay case JoinRoomRequest joinRoomRequest: { - var room = ServerSideRooms.Single(r => r.RoomID.Value == joinRoomRequest.Room.RoomID.Value); + var room = ServerSideRooms.Single(r => r.RoomID == joinRoomRequest.Room.RoomID); - if (joinRoomRequest.Password != room.Password.Value) + if (joinRoomRequest.Password != room.Password) { joinRoomRequest.TriggerFailure(new InvalidOperationException("Invalid password.")); return true; } - joinRoomRequest.TriggerSuccess(); + joinRoomRequest.TriggerSuccess(createResponseRoom(room, true)); return true; } @@ -164,7 +161,7 @@ namespace osu.Game.Tests.Visual.OnlinePlay return true; case GetRoomRequest getRoomRequest: - getRoomRequest.TriggerSuccess(createResponseRoom(ServerSideRooms.Single(r => r.RoomID.Value == getRoomRequest.RoomId), true)); + getRoomRequest.TriggerSuccess(createResponseRoom(ServerSideRooms.Single(r => r.RoomID == getRoomRequest.RoomId), true)); return true; case CreateRoomScoreRequest createRoomScoreRequest: @@ -188,7 +185,7 @@ namespace osu.Game.Tests.Visual.OnlinePlay case GetBeatmapRequest getBeatmapRequest: { - getBeatmapRequest.TriggerSuccess(createResponseBeatmaps(getBeatmapRequest.BeatmapInfo.OnlineID).Single()); + getBeatmapRequest.TriggerSuccess(createResponseBeatmaps(getBeatmapRequest.OnlineID).Single()); return true; } @@ -214,6 +211,22 @@ namespace osu.Game.Tests.Visual.OnlinePlay getBeatmapSetRequest.TriggerSuccess(OsuTestScene.CreateAPIBeatmapSet(baseBeatmap)); return true; } + + case GetUsersRequest getUsersRequest: + { + getUsersRequest.TriggerSuccess(new GetUsersResponse + { + Users = getUsersRequest.UserIds.Select(id => id == TestUserLookupCache.UNRESOLVED_USER_ID + ? null + : new APIUser + { + Id = id, + Username = $"User {id}" + }) + .Where(u => u != null).ToList(), + }); + return true; + } } List createResponseBeatmaps(params int[] beatmapIds) @@ -247,13 +260,13 @@ namespace osu.Game.Tests.Visual.OnlinePlay /// The room host. public void AddServerSideRoom(Room room, APIUser host) { - room.RoomID.Value ??= currentRoomId++; - room.Host.Value = host; + room.RoomID ??= currentRoomId++; + room.Host = host; for (int i = 0; i < room.Playlist.Count; i++) { room.Playlist[i].ID = currentPlaylistItemId++; - room.Playlist[i].OwnerID = room.Host.Value.OnlineID; + room.Playlist[i].OwnerID = room.Host.OnlineID; } serverSideRooms.Add(room); @@ -264,12 +277,10 @@ namespace osu.Game.Tests.Visual.OnlinePlay var responseRoom = cloneRoom(room); // Password is hidden from the response, and is only propagated via HasPassword. - bool hadPassword = responseRoom.HasPassword.Value; - responseRoom.Password.Value = null; - responseRoom.HasPassword.Value = hadPassword; + responseRoom.Password = responseRoom.HasPassword ? Guid.NewGuid().ToString() : null; if (!withParticipants) - responseRoom.RecentParticipants.Clear(); + responseRoom.RecentParticipants = []; return responseRoom; } @@ -279,18 +290,17 @@ namespace osu.Game.Tests.Visual.OnlinePlay var result = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(source)); Debug.Assert(result != null); - // Playlist item IDs and beatmaps aren't serialised. - if (source.CurrentPlaylistItem.Value != null) - { - result.CurrentPlaylistItem.Value = result.CurrentPlaylistItem.Value.With(new Optional(source.CurrentPlaylistItem.Value.Beatmap)); - result.CurrentPlaylistItem.Value.ID = source.CurrentPlaylistItem.Value.ID; - } + // When serialising, only beatmap IDs are sent to the server. + // When deserialising, full beatmaps and IDs are expected to arrive. - for (int i = 0; i < source.Playlist.Count; i++) - { - result.Playlist[i] = result.Playlist[i].With(new Optional(source.Playlist[i].Beatmap)); - result.Playlist[i].ID = source.Playlist[i].ID; - } + PlaylistItem? finalCurrentItem = result.CurrentPlaylistItem?.With(id: source.CurrentPlaylistItem!.ID, beatmap: new Optional(source.CurrentPlaylistItem.Beatmap)); + PlaylistItem[] finalPlaylist = result.Playlist.Select((pi, i) => pi.With(id: source.Playlist[i].ID, beatmap: new Optional(source.Playlist[i].Beatmap))).ToArray(); + + // When setting the properties, we do a clear-then-add, otherwise equality comparers (that only compare by ID) pass early and members don't get replaced. + result.CurrentPlaylistItem = null; + result.CurrentPlaylistItem = finalCurrentItem; + result.Playlist = []; + result.Playlist = finalPlaylist; return result; } diff --git a/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs b/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs index c8d9ef8fc8..aa8aff3adc 100644 --- a/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs +++ b/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs @@ -3,9 +3,11 @@ #nullable disable +using System; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; @@ -24,6 +26,10 @@ namespace osu.Game.Tests.Visual protected PlacementBlueprintTestScene() { base.Content.Add(HitObjectContainer = CreateHitObjectContainer().With(c => c.Clock = new FramedClock(new StopwatchClock()))); + base.Content.Add(new MouseMovementInterceptor + { + MouseMoved = updatePlacementTimeAndPosition, + }); } protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) @@ -53,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() @@ -84,9 +90,14 @@ namespace osu.Game.Tests.Visual { base.Update(); - CurrentBlueprint.UpdateTimeAndPosition(SnapForBlueprint(CurrentBlueprint)); + if (CurrentBlueprint.PlacementActive == PlacementBlueprint.PlacementState.Finished) + ResetPlacement(); + + updatePlacementTimeAndPosition(); } + private void updatePlacementTimeAndPosition() => CurrentBlueprint.UpdateTimeAndPosition(SnapForBlueprint(CurrentBlueprint)); + protected virtual SnapResult SnapForBlueprint(HitObjectPlacementBlueprint blueprint) => new SnapResult(InputManager.CurrentState.Mouse.Position, null); @@ -107,5 +118,22 @@ namespace osu.Game.Tests.Visual protected abstract DrawableHitObject CreateHitObject(HitObject hitObject); protected abstract HitObjectPlacementBlueprint CreateBlueprint(); + + private partial class MouseMovementInterceptor : Drawable + { + public Action MouseMoved; + + public MouseMovementInterceptor() + { + RelativeSizeAxes = Axes.Both; + Depth = float.MinValue; + } + + protected override bool OnMouseMove(MouseMoveEvent e) + { + MouseMoved?.Invoke(); + return base.OnMouseMove(e); + } + } } } diff --git a/osu.Game/Users/UserActivity.cs b/osu.Game/Users/UserActivity.cs index a431b204bc..93812e3f6b 100644 --- a/osu.Game/Users/UserActivity.cs +++ b/osu.Game/Users/UserActivity.cs @@ -248,8 +248,8 @@ namespace osu.Game.Users public InLobby(Room room) { - RoomID = room.RoomID.Value ?? -1; - RoomName = room.Name.Value; + RoomID = room.RoomID ?? -1; + RoomName = room.Name; } [SerializationConstructor] 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/Utils/SpecialFunctions.cs b/osu.Game/Utils/SpecialFunctions.cs new file mode 100644 index 0000000000..0b0f0598bb --- /dev/null +++ b/osu.Game/Utils/SpecialFunctions.cs @@ -0,0 +1,694 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +// All code is referenced from the following: +// https://github.com/mathnet/mathnet-numerics/blob/master/src/Numerics/SpecialFunctions/Erf.cs +// https://github.com/mathnet/mathnet-numerics/blob/master/src/Numerics/Optimization/NelderMeadSimplex.cs + +/* + Copyright (c) 2002-2022 Math.NET +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; + +namespace osu.Game.Utils +{ + public class SpecialFunctions + { + private const double sqrt2_pi = 2.5066282746310005024157652848110452530069867406099d; + + /// + /// ************************************** + /// COEFFICIENTS FOR METHOD ErfImp * + /// ************************************** + /// + /// Polynomial coefficients for a numerator of ErfImp + /// calculation for Erf(x) in the interval [1e-10, 0.5]. + /// + private static readonly double[] erf_imp_an = { 0.00337916709551257388990745, -0.00073695653048167948530905, -0.374732337392919607868241, 0.0817442448733587196071743, -0.0421089319936548595203468, 0.0070165709512095756344528, -0.00495091255982435110337458, 0.000871646599037922480317225 }; + + /// Polynomial coefficients for a denominator of ErfImp + /// calculation for Erf(x) in the interval [1e-10, 0.5]. + /// + private static readonly double[] erf_imp_ad = { 1, -0.218088218087924645390535, 0.412542972725442099083918, -0.0841891147873106755410271, 0.0655338856400241519690695, -0.0120019604454941768171266, 0.00408165558926174048329689, -0.000615900721557769691924509 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [0.5, 0.75]. + /// + private static readonly double[] erf_imp_bn = { -0.0361790390718262471360258, 0.292251883444882683221149, 0.281447041797604512774415, 0.125610208862766947294894, 0.0274135028268930549240776, 0.00250839672168065762786937 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [0.5, 0.75]. + /// + private static readonly double[] erf_imp_bd = { 1, 1.8545005897903486499845, 1.43575803037831418074962, 0.582827658753036572454135, 0.124810476932949746447682, 0.0113724176546353285778481 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [0.75, 1.25]. + /// + private static readonly double[] erf_imp_cn = { -0.0397876892611136856954425, 0.153165212467878293257683, 0.191260295600936245503129, 0.10276327061989304213645, 0.029637090615738836726027, 0.0046093486780275489468812, 0.000307607820348680180548455 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [0.75, 1.25]. + /// + private static readonly double[] erf_imp_cd = { 1, 1.95520072987627704987886, 1.64762317199384860109595, 0.768238607022126250082483, 0.209793185936509782784315, 0.0319569316899913392596356, 0.00213363160895785378615014 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [1.25, 2.25]. + /// + private static readonly double[] erf_imp_dn = { -0.0300838560557949717328341, 0.0538578829844454508530552, 0.0726211541651914182692959, 0.0367628469888049348429018, 0.00964629015572527529605267, 0.00133453480075291076745275, 0.778087599782504251917881e-4 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [1.25, 2.25]. + /// + private static readonly double[] erf_imp_dd = { 1, 1.75967098147167528287343, 1.32883571437961120556307, 0.552528596508757581287907, 0.133793056941332861912279, 0.0179509645176280768640766, 0.00104712440019937356634038, -0.106640381820357337177643e-7 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [2.25, 3.5]. + /// + private static readonly double[] erf_imp_en = { -0.0117907570137227847827732, 0.014262132090538809896674, 0.0202234435902960820020765, 0.00930668299990432009042239, 0.00213357802422065994322516, 0.00025022987386460102395382, 0.120534912219588189822126e-4 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [2.25, 3.5]. + /// + private static readonly double[] erf_imp_ed = { 1, 1.50376225203620482047419, 0.965397786204462896346934, 0.339265230476796681555511, 0.0689740649541569716897427, 0.00771060262491768307365526, 0.000371421101531069302990367 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [3.5, 5.25]. + /// + private static readonly double[] erf_imp_fn = { -0.00546954795538729307482955, 0.00404190278731707110245394, 0.0054963369553161170521356, 0.00212616472603945399437862, 0.000394984014495083900689956, 0.365565477064442377259271e-4, 0.135485897109932323253786e-5 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [3.5, 5.25]. + /// + private static readonly double[] erf_imp_fd = { 1, 1.21019697773630784832251, 0.620914668221143886601045, 0.173038430661142762569515, 0.0276550813773432047594539, 0.00240625974424309709745382, 0.891811817251336577241006e-4, -0.465528836283382684461025e-11 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [5.25, 8]. + /// + private static readonly double[] erf_imp_gn = { -0.00270722535905778347999196, 0.0013187563425029400461378, 0.00119925933261002333923989, 0.00027849619811344664248235, 0.267822988218331849989363e-4, 0.923043672315028197865066e-6 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [5.25, 8]. + /// + private static readonly double[] erf_imp_gd = { 1, 0.814632808543141591118279, 0.268901665856299542168425, 0.0449877216103041118694989, 0.00381759663320248459168994, 0.000131571897888596914350697, 0.404815359675764138445257e-11 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [8, 11.5]. + /// + private static readonly double[] erf_imp_hn = { -0.00109946720691742196814323, 0.000406425442750422675169153, 0.000274499489416900707787024, 0.465293770646659383436343e-4, 0.320955425395767463401993e-5, 0.778286018145020892261936e-7 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [8, 11.5]. + /// + private static readonly double[] erf_imp_hd = { 1, 0.588173710611846046373373, 0.139363331289409746077541, 0.0166329340417083678763028, 0.00100023921310234908642639, 0.24254837521587225125068e-4 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [11.5, 17]. + /// + private static readonly double[] erf_imp_in = { -0.00056907993601094962855594, 0.000169498540373762264416984, 0.518472354581100890120501e-4, 0.382819312231928859704678e-5, 0.824989931281894431781794e-7 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [11.5, 17]. + /// + private static readonly double[] erf_imp_id = { 1, 0.339637250051139347430323, 0.043472647870310663055044, 0.00248549335224637114641629, 0.535633305337152900549536e-4, -0.117490944405459578783846e-12 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [17, 24]. + /// + private static readonly double[] erf_imp_jn = { -0.000241313599483991337479091, 0.574224975202501512365975e-4, 0.115998962927383778460557e-4, 0.581762134402593739370875e-6, 0.853971555085673614607418e-8 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [17, 24]. + /// + private static readonly double[] erf_imp_jd = { 1, 0.233044138299687841018015, 0.0204186940546440312625597, 0.000797185647564398289151125, 0.117019281670172327758019e-4 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [24, 38]. + /// + private static readonly double[] erf_imp_kn = { -0.000146674699277760365803642, 0.162666552112280519955647e-4, 0.269116248509165239294897e-5, 0.979584479468091935086972e-7, 0.101994647625723465722285e-8 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [24, 38]. + /// + private static readonly double[] erf_imp_kd = { 1, 0.165907812944847226546036, 0.0103361716191505884359634, 0.000286593026373868366935721, 0.298401570840900340874568e-5 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [38, 60]. + /// + private static readonly double[] erf_imp_ln = { -0.583905797629771786720406e-4, 0.412510325105496173512992e-5, 0.431790922420250949096906e-6, 0.993365155590013193345569e-8, 0.653480510020104699270084e-10 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [38, 60]. + /// + private static readonly double[] erf_imp_ld = { 1, 0.105077086072039915406159, 0.00414278428675475620830226, 0.726338754644523769144108e-4, 0.477818471047398785369849e-6 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [60, 85]. + /// + private static readonly double[] erf_imp_mn = { -0.196457797609229579459841e-4, 0.157243887666800692441195e-5, 0.543902511192700878690335e-7, 0.317472492369117710852685e-9 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [60, 85]. + /// + private static readonly double[] erf_imp_md = { 1, 0.052803989240957632204885, 0.000926876069151753290378112, 0.541011723226630257077328e-5, 0.535093845803642394908747e-15 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [85, 110]. + /// + private static readonly double[] erf_imp_nn = { -0.789224703978722689089794e-5, 0.622088451660986955124162e-6, 0.145728445676882396797184e-7, 0.603715505542715364529243e-10 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [85, 110]. + /// + private static readonly double[] erf_imp_nd = { 1, 0.0375328846356293715248719, 0.000467919535974625308126054, 0.193847039275845656900547e-5 }; + + /// + /// ************************************** + /// COEFFICIENTS FOR METHOD ErfInvImp * + /// ************************************** + /// + /// Polynomial coefficients for a numerator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0, 0.5]. + /// + private static readonly double[] erv_inv_imp_an = { -0.000508781949658280665617, -0.00836874819741736770379, 0.0334806625409744615033, -0.0126926147662974029034, -0.0365637971411762664006, 0.0219878681111168899165, 0.00822687874676915743155, -0.00538772965071242932965 }; + + /// Polynomial coefficients for a denominator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0, 0.5]. + /// + private static readonly double[] erv_inv_imp_ad = { 1, -0.970005043303290640362, -1.56574558234175846809, 1.56221558398423026363, 0.662328840472002992063, -0.71228902341542847553, -0.0527396382340099713954, 0.0795283687341571680018, -0.00233393759374190016776, 0.000886216390456424707504 }; + + /// Polynomial coefficients for a numerator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0.5, 0.75]. + /// + private static readonly double[] erv_inv_imp_bn = { -0.202433508355938759655, 0.105264680699391713268, 8.37050328343119927838, 17.6447298408374015486, -18.8510648058714251895, -44.6382324441786960818, 17.445385985570866523, 21.1294655448340526258, -3.67192254707729348546 }; + + /// Polynomial coefficients for a denominator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0.5, 0.75]. + /// + private static readonly double[] erv_inv_imp_bd = { 1, 6.24264124854247537712, 3.9713437953343869095, -28.6608180499800029974, -20.1432634680485188801, 48.5609213108739935468, 10.8268667355460159008, -22.6436933413139721736, 1.72114765761200282724 }; + + /// Polynomial coefficients for a numerator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0.75, 1] with x less than 3. + /// + private static readonly double[] erv_inv_imp_cn = { -0.131102781679951906451, -0.163794047193317060787, 0.117030156341995252019, 0.387079738972604337464, 0.337785538912035898924, 0.142869534408157156766, 0.0290157910005329060432, 0.00214558995388805277169, -0.679465575181126350155e-6, 0.285225331782217055858e-7, -0.681149956853776992068e-9 }; + + /// Polynomial coefficients for a denominator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0.75, 1] with x less than 3. + /// + private static readonly double[] erv_inv_imp_cd = { 1, 3.46625407242567245975, 5.38168345707006855425, 4.77846592945843778382, 2.59301921623620271374, 0.848854343457902036425, 0.152264338295331783612, 0.01105924229346489121 }; + + /// Polynomial coefficients for a numerator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0.75, 1] with x between 3 and 6. + /// + private static readonly double[] erv_inv_imp_dn = { -0.0350353787183177984712, -0.00222426529213447927281, 0.0185573306514231072324, 0.00950804701325919603619, 0.00187123492819559223345, 0.000157544617424960554631, 0.460469890584317994083e-5, -0.230404776911882601748e-9, 0.266339227425782031962e-11 }; + + /// Polynomial coefficients for a denominator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0.75, 1] with x between 3 and 6. + /// + private static readonly double[] erv_inv_imp_dd = { 1, 1.3653349817554063097, 0.762059164553623404043, 0.220091105764131249824, 0.0341589143670947727934, 0.00263861676657015992959, 0.764675292302794483503e-4 }; + + /// Polynomial coefficients for a numerator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0.75, 1] with x between 6 and 18. + /// + private static readonly double[] erv_inv_imp_en = { -0.0167431005076633737133, -0.00112951438745580278863, 0.00105628862152492910091, 0.000209386317487588078668, 0.149624783758342370182e-4, 0.449696789927706453732e-6, 0.462596163522878599135e-8, -0.281128735628831791805e-13, 0.99055709973310326855e-16 }; + + /// Polynomial coefficients for a denominator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0.75, 1] with x between 6 and 18. + /// + private static readonly double[] erv_inv_imp_ed = { 1, 0.591429344886417493481, 0.138151865749083321638, 0.0160746087093676504695, 0.000964011807005165528527, 0.275335474764726041141e-4, 0.282243172016108031869e-6 }; + + /// Polynomial coefficients for a numerator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0.75, 1] with x between 18 and 44. + /// + private static readonly double[] erv_inv_imp_fn = { -0.0024978212791898131227, -0.779190719229053954292e-5, 0.254723037413027451751e-4, 0.162397777342510920873e-5, 0.396341011304801168516e-7, 0.411632831190944208473e-9, 0.145596286718675035587e-11, -0.116765012397184275695e-17 }; + + /// Polynomial coefficients for a denominator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0.75, 1] with x between 18 and 44. + /// + private static readonly double[] erv_inv_imp_fd = { 1, 0.207123112214422517181, 0.0169410838120975906478, 0.000690538265622684595676, 0.145007359818232637924e-4, 0.144437756628144157666e-6, 0.509761276599778486139e-9 }; + + /// Polynomial coefficients for a numerator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0.75, 1] with x greater than 44. + /// + private static readonly double[] erv_inv_imp_gn = { -0.000539042911019078575891, -0.28398759004727721098e-6, 0.899465114892291446442e-6, 0.229345859265920864296e-7, 0.225561444863500149219e-9, 0.947846627503022684216e-12, 0.135880130108924861008e-14, -0.348890393399948882918e-21 }; + + /// Polynomial coefficients for a denominator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0.75, 1] with x greater than 44. + /// + private static readonly double[] erv_inv_imp_gd = { 1, 0.0845746234001899436914, 0.00282092984726264681981, 0.468292921940894236786e-4, 0.399968812193862100054e-6, 0.161809290887904476097e-8, 0.231558608310259605225e-11 }; + + /// Calculates the error function. + /// The value to evaluate. + /// the error function evaluated at given value. + /// + /// + /// returns 1 if x == double.PositiveInfinity. + /// returns -1 if x == double.NegativeInfinity. + /// + /// + public static double Erf(double x) + { + if (x == 0) + { + return 0; + } + + if (double.IsPositiveInfinity(x)) + { + return 1; + } + + if (double.IsNegativeInfinity(x)) + { + return -1; + } + + if (double.IsNaN(x)) + { + return double.NaN; + } + + return erfImp(x, false); + } + + /// Calculates the complementary error function. + /// The value to evaluate. + /// the complementary error function evaluated at given value. + /// + /// + /// returns 0 if x == double.PositiveInfinity. + /// returns 2 if x == double.NegativeInfinity. + /// + /// + public static double Erfc(double x) + { + if (x == 0) + { + return 1; + } + + if (double.IsPositiveInfinity(x)) + { + return 0; + } + + if (double.IsNegativeInfinity(x)) + { + return 2; + } + + if (double.IsNaN(x)) + { + return double.NaN; + } + + return erfImp(x, true); + } + + /// Calculates the inverse error function evaluated at z. + /// The inverse error function evaluated at given value. + /// + /// + /// returns double.PositiveInfinity if z >= 1.0. + /// returns double.NegativeInfinity if z <= -1.0. + /// + /// + /// Calculates the inverse error function evaluated at z. + /// value to evaluate. + /// the inverse error function evaluated at Z. + public static double ErfInv(double z) + { + if (z == 0.0) + { + return 0.0; + } + + if (z >= 1.0) + { + return double.PositiveInfinity; + } + + if (z <= -1.0) + { + return double.NegativeInfinity; + } + + double p, q, s; + + if (z < 0) + { + p = -z; + q = 1 - p; + s = -1; + } + else + { + p = z; + q = 1 - z; + s = 1; + } + + return erfInvImpl(p, q, s); + } + + /// + /// Implementation of the error function. + /// + /// Where to evaluate the error function. + /// Whether to compute 1 - the error function. + /// the error function. + private static double erfImp(double z, bool invert) + { + if (z < 0) + { + if (!invert) + { + return -erfImp(-z, false); + } + + if (z < -0.5) + { + return 2 - erfImp(-z, true); + } + + return 1 + erfImp(-z, false); + } + + double result; + + // Big bunch of selection statements now to pick which + // implementation to use, try to put most likely options + // first: + if (z < 0.5) + { + // We're going to calculate erf: + if (z < 1e-10) + { + result = (z * 1.125) + (z * 0.003379167095512573896158903121545171688); + } + else + { + // Worst case absolute error found: 6.688618532e-21 + result = (z * 1.125) + (z * evaluatePolynomial(z, erf_imp_an) / evaluatePolynomial(z, erf_imp_ad)); + } + } + else if (z < 110) + { + // We'll be calculating erfc: + invert = !invert; + double r, b; + + if (z < 0.75) + { + // Worst case absolute error found: 5.582813374e-21 + r = evaluatePolynomial(z - 0.5, erf_imp_bn) / evaluatePolynomial(z - 0.5, erf_imp_bd); + b = 0.3440242112F; + } + else if (z < 1.25) + { + // Worst case absolute error found: 4.01854729e-21 + r = evaluatePolynomial(z - 0.75, erf_imp_cn) / evaluatePolynomial(z - 0.75, erf_imp_cd); + b = 0.419990927F; + } + else if (z < 2.25) + { + // Worst case absolute error found: 2.866005373e-21 + r = evaluatePolynomial(z - 1.25, erf_imp_dn) / evaluatePolynomial(z - 1.25, erf_imp_dd); + b = 0.4898625016F; + } + else if (z < 3.5) + { + // Worst case absolute error found: 1.045355789e-21 + r = evaluatePolynomial(z - 2.25, erf_imp_en) / evaluatePolynomial(z - 2.25, erf_imp_ed); + b = 0.5317370892F; + } + else if (z < 5.25) + { + // Worst case absolute error found: 8.300028706e-22 + r = evaluatePolynomial(z - 3.5, erf_imp_fn) / evaluatePolynomial(z - 3.5, erf_imp_fd); + b = 0.5489973426F; + } + else if (z < 8) + { + // Worst case absolute error found: 1.700157534e-21 + r = evaluatePolynomial(z - 5.25, erf_imp_gn) / evaluatePolynomial(z - 5.25, erf_imp_gd); + b = 0.5571740866F; + } + else if (z < 11.5) + { + // Worst case absolute error found: 3.002278011e-22 + r = evaluatePolynomial(z - 8, erf_imp_hn) / evaluatePolynomial(z - 8, erf_imp_hd); + b = 0.5609807968F; + } + else if (z < 17) + { + // Worst case absolute error found: 6.741114695e-21 + r = evaluatePolynomial(z - 11.5, erf_imp_in) / evaluatePolynomial(z - 11.5, erf_imp_id); + b = 0.5626493692F; + } + else if (z < 24) + { + // Worst case absolute error found: 7.802346984e-22 + r = evaluatePolynomial(z - 17, erf_imp_jn) / evaluatePolynomial(z - 17, erf_imp_jd); + b = 0.5634598136F; + } + else if (z < 38) + { + // Worst case absolute error found: 2.414228989e-22 + r = evaluatePolynomial(z - 24, erf_imp_kn) / evaluatePolynomial(z - 24, erf_imp_kd); + b = 0.5638477802F; + } + else if (z < 60) + { + // Worst case absolute error found: 5.896543869e-24 + r = evaluatePolynomial(z - 38, erf_imp_ln) / evaluatePolynomial(z - 38, erf_imp_ld); + b = 0.5640528202F; + } + else if (z < 85) + { + // Worst case absolute error found: 3.080612264e-21 + r = evaluatePolynomial(z - 60, erf_imp_mn) / evaluatePolynomial(z - 60, erf_imp_md); + b = 0.5641309023F; + } + else + { + // Worst case absolute error found: 8.094633491e-22 + r = evaluatePolynomial(z - 85, erf_imp_nn) / evaluatePolynomial(z - 85, erf_imp_nd); + b = 0.5641584396F; + } + + double g = Math.Exp(-z * z) / z; + result = (g * b) + (g * r); + } + else + { + // Any value of z larger than 28 will underflow to zero: + result = 0; + invert = !invert; + } + + if (invert) + { + result = 1 - result; + } + + return result; + } + + /// Calculates the complementary inverse error function evaluated at z. + /// The complementary inverse error function evaluated at given value. + /// We have tested this implementation against the arbitrary precision mpmath library + /// and found cases where we can only guarantee 9 significant figures correct. + /// + /// returns double.PositiveInfinity if z <= 0.0. + /// returns double.NegativeInfinity if z >= 2.0. + /// + /// + /// calculates the complementary inverse error function evaluated at z. + /// value to evaluate. + /// the complementary inverse error function evaluated at Z. + public static double ErfcInv(double z) + { + if (z <= 0.0) + { + return double.PositiveInfinity; + } + + if (z >= 2.0) + { + return double.NegativeInfinity; + } + + double p, q, s; + + if (z > 1) + { + q = 2 - z; + p = 1 - q; + s = -1; + } + else + { + p = 1 - z; + q = z; + s = 1; + } + + return erfInvImpl(p, q, s); + } + + /// + /// The implementation of the inverse error function. + /// + /// First intermediate parameter. + /// Second intermediate parameter. + /// Third intermediate parameter. + /// the inverse error function. + private static double erfInvImpl(double p, double q, double s) + { + double result; + + if (p <= 0.5) + { + // Evaluate inverse erf using the rational approximation: + // + // x = p(p+10)(Y+R(p)) + // + // Where Y is a constant, and R(p) is optimized for a low + // absolute error compared to |Y|. + // + // double: Max error found: 2.001849e-18 + // long double: Max error found: 1.017064e-20 + // Maximum Deviation Found (actual error term at infinite precision) 8.030e-21 + const float y = 0.0891314744949340820313f; + double g = p * (p + 10); + double r = evaluatePolynomial(p, erv_inv_imp_an) / evaluatePolynomial(p, erv_inv_imp_ad); + result = (g * y) + (g * r); + } + else if (q >= 0.25) + { + // Rational approximation for 0.5 > q >= 0.25 + // + // x = sqrt(-2*log(q)) / (Y + R(q)) + // + // Where Y is a constant, and R(q) is optimized for a low + // absolute error compared to Y. + // + // double : Max error found: 7.403372e-17 + // long double : Max error found: 6.084616e-20 + // Maximum Deviation Found (error term) 4.811e-20 + const float y = 2.249481201171875f; + double g = Math.Sqrt(-2 * Math.Log(q)); + double xs = q - 0.25; + double r = evaluatePolynomial(xs, erv_inv_imp_bn) / evaluatePolynomial(xs, erv_inv_imp_bd); + result = g / (y + r); + } + else + { + // For q < 0.25 we have a series of rational approximations all + // of the general form: + // + // let: x = sqrt(-log(q)) + // + // Then the result is given by: + // + // x(Y+R(x-B)) + // + // where Y is a constant, B is the lowest value of x for which + // the approximation is valid, and R(x-B) is optimized for a low + // absolute error compared to Y. + // + // Note that almost all code will really go through the first + // or maybe second approximation. After than we're dealing with very + // small input values indeed: 80 and 128 bit long double's go all the + // way down to ~ 1e-5000 so the "tail" is rather long... + double x = Math.Sqrt(-Math.Log(q)); + + if (x < 3) + { + // Max error found: 1.089051e-20 + const float y = 0.807220458984375f; + double xs = x - 1.125; + double r = evaluatePolynomial(xs, erv_inv_imp_cn) / evaluatePolynomial(xs, erv_inv_imp_cd); + result = (y * x) + (r * x); + } + else if (x < 6) + { + // Max error found: 8.389174e-21 + const float y = 0.93995571136474609375f; + double xs = x - 3; + double r = evaluatePolynomial(xs, erv_inv_imp_dn) / evaluatePolynomial(xs, erv_inv_imp_dd); + result = (y * x) + (r * x); + } + else if (x < 18) + { + // Max error found: 1.481312e-19 + const float y = 0.98362827301025390625f; + double xs = x - 6; + double r = evaluatePolynomial(xs, erv_inv_imp_en) / evaluatePolynomial(xs, erv_inv_imp_ed); + result = (y * x) + (r * x); + } + else if (x < 44) + { + // Max error found: 5.697761e-20 + const float y = 0.99714565277099609375f; + double xs = x - 18; + double r = evaluatePolynomial(xs, erv_inv_imp_fn) / evaluatePolynomial(xs, erv_inv_imp_fd); + result = (y * x) + (r * x); + } + else + { + // Max error found: 1.279746e-20 + const float y = 0.99941349029541015625f; + double xs = x - 44; + double r = evaluatePolynomial(xs, erv_inv_imp_gn) / evaluatePolynomial(xs, erv_inv_imp_gd); + result = (y * x) + (r * x); + } + } + + return s * result; + } + + /// + /// Evaluate a polynomial at point x. + /// Coefficients are ordered ascending by power with power k at index k. + /// Example: coefficients [3,-1,2] represent y=2x^2-x+3. + /// + /// The location where to evaluate the polynomial at. + /// The coefficients of the polynomial, coefficient for power k at index k. + /// + /// is a null reference. + /// + private static double evaluatePolynomial(double z, params double[] coefficients) + { + // 2020-10-07 jbialogrodzki #730 Since this is public API we should probably + // handle null arguments? It doesn't seem to have been done consistently in this class though. + if (coefficients == null) + { + throw new ArgumentNullException(nameof(coefficients)); + } + + // 2020-10-07 jbialogrodzki #730 Zero polynomials need explicit handling. + // Without this check, we attempted to peek coefficients at negative indices! + int n = coefficients.Length; + + if (n == 0) + { + return 0; + } + + double sum = coefficients[n - 1]; + + for (int i = n - 2; i >= 0; --i) + { + sum *= z; + sum += coefficients[i]; + } + + return sum; + } + } +} diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index f74d18f6e4..95eb30a2c7 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -18,16 +18,16 @@ - + - + - - - - - - + + + + + + @@ -35,13 +35,13 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + - + - + diff --git a/osu.iOS.props b/osu.iOS.props index 8acd1deff1..ccae4a15ee 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -17,6 +17,6 @@ -all - + diff --git a/osu.sln.DotSettings b/osu.sln.DotSettings index 4a2ef97520..ccd6db354b 100644 --- a/osu.sln.DotSettings +++ b/osu.sln.DotSettings @@ -69,7 +69,7 @@ DO_NOT_SHOW HINT WARNING - WARNING + HINT WARNING WARNING DO_NOT_SHOW