diff --git a/.github/workflows/_diffcalc_processor.yml b/.github/workflows/_diffcalc_processor.yml new file mode 100644 index 0000000000..4e221d0550 --- /dev/null +++ b/.github/workflows/_diffcalc_processor.yml @@ -0,0 +1,228 @@ +name: "🔒diffcalc (do not use)" + +on: + workflow_call: + inputs: + id: + type: string + head-sha: + type: string + pr-url: + type: string + pr-text: + type: string + dispatch-inputs: + type: string + outputs: + target: + description: The comparison target. + value: ${{ jobs.generator.outputs.target }} + sheet: + description: The comparison spreadsheet. + value: ${{ jobs.generator.outputs.sheet }} + secrets: + DIFFCALC_GOOGLE_CREDENTIALS: + required: true + +env: + GENERATOR_DIR: ${{ github.workspace }}/${{ inputs.id }} + GENERATOR_ENV: ${{ github.workspace }}/${{ inputs.id }}/.env + +defaults: + run: + shell: bash -euo pipefail {0} + +jobs: + generator: + name: Run + runs-on: self-hosted + timeout-minutes: 720 + + outputs: + target: ${{ steps.run.outputs.target }} + sheet: ${{ steps.run.outputs.sheet }} + + steps: + - name: Checkout diffcalc-sheet-generator + uses: actions/checkout@v4 + with: + path: ${{ inputs.id }} + repository: 'smoogipoo/diffcalc-sheet-generator' + + - name: Add base environment + env: + GOOGLE_CREDS_FILE: ${{ github.workspace }}/${{ inputs.id }}/google-credentials.json + VARS_JSON: ${{ (vars != null && toJSON(vars)) || '' }} + run: | + # Required by diffcalc-sheet-generator + cp '${{ env.GENERATOR_DIR }}/.env.sample' "${{ env.GENERATOR_ENV }}" + + # Add Google credentials + echo '${{ secrets.DIFFCALC_GOOGLE_CREDENTIALS }}' | base64 -d > "${{ env.GOOGLE_CREDS_FILE }}" + + # Add repository variables + echo "${VARS_JSON}" | jq -c '. | to_entries | .[]' | while read -r line; do + opt=$(jq -r '.key' <<< ${line}) + val=$(jq -r '.value' <<< ${line}) + + if [[ "${opt}" =~ ^DIFFCALC_ ]]; then + optNoPrefix=$(echo "${opt}" | cut -d '_' -f2-) + sed -i "s;^${optNoPrefix}=.*$;${optNoPrefix}=${val};" "${{ env.GENERATOR_ENV }}" + fi + done + + - name: Add HEAD environment + run: | + sed -i "s;^OSU_A=.*$;OSU_A=${{ inputs.head-sha }};" "${{ env.GENERATOR_ENV }}" + + - name: Add pull-request environment + if: ${{ inputs.pr-url != '' }} + run: | + sed -i "s;^OSU_B=.*$;OSU_B=${{ inputs.pr-url }};" "${{ env.GENERATOR_ENV }}" + + - name: Add comment environment + if: ${{ inputs.pr-text != '' }} + env: + PR_TEXT: ${{ inputs.pr-text }} + run: | + # Add comment environment + echo "${PR_TEXT}" | sed -r 's/\r$//' | { grep -E '^\w+=' || true; } | while read -r line; do + opt=$(echo "${line}" | cut -d '=' -f1) + sed -i "s;^${opt}=.*$;${line};" "${{ env.GENERATOR_ENV }}" + done + + - name: Add dispatch environment + if: ${{ inputs.dispatch-inputs != '' }} + env: + DISPATCH_INPUTS_JSON: ${{ inputs.dispatch-inputs }} + run: | + function get_input() { + echo "${DISPATCH_INPUTS_JSON}" | jq -r ".\"$1\"" + } + + osu_a=$(get_input 'osu-a') + osu_b=$(get_input 'osu-b') + ruleset=$(get_input 'ruleset') + generators=$(get_input 'generators') + difficulty_calculator_a=$(get_input 'difficulty-calculator-a') + difficulty_calculator_b=$(get_input 'difficulty-calculator-b') + score_processor_a=$(get_input 'score-processor-a') + score_processor_b=$(get_input 'score-processor-b') + converts=$(get_input 'converts') + ranked_only=$(get_input 'ranked-only') + + sed -i "s;^OSU_B=.*$;OSU_B=${osu_b};" "${{ env.GENERATOR_ENV }}" + sed -i "s/^RULESET=.*$/RULESET=${ruleset}/" "${{ env.GENERATOR_ENV }}" + sed -i "s/^GENERATORS=.*$/GENERATORS=${generators}/" "${{ env.GENERATOR_ENV }}" + + if [[ "${osu_a}" != 'latest' ]]; then + sed -i "s;^OSU_A=.*$;OSU_A=${osu_a};" "${{ env.GENERATOR_ENV }}" + fi + + if [[ "${difficulty_calculator_a}" != 'latest' ]]; then + sed -i "s;^DIFFICULTY_CALCULATOR_A=.*$;DIFFICULTY_CALCULATOR_A=${difficulty_calculator_a};" "${{ env.GENERATOR_ENV }}" + fi + + if [[ "${difficulty_calculator_b}" != 'latest' ]]; then + sed -i "s;^DIFFICULTY_CALCULATOR_B=.*$;DIFFICULTY_CALCULATOR_B=${difficulty_calculator_b};" "${{ env.GENERATOR_ENV }}" + fi + + if [[ "${score_processor_a}" != 'latest' ]]; then + sed -i "s;^SCORE_PROCESSOR_A=.*$;SCORE_PROCESSOR_A=${score_processor_a};" "${{ env.GENERATOR_ENV }}" + fi + + if [[ "${score_processor_b}" != 'latest' ]]; then + sed -i "s;^SCORE_PROCESSOR_B=.*$;SCORE_PROCESSOR_B=${score_processor_b};" "${{ env.GENERATOR_ENV }}" + fi + + if [[ "${converts}" == 'true' ]]; then + sed -i 's/^NO_CONVERTS=.*$/NO_CONVERTS=0/' "${{ env.GENERATOR_ENV }}" + else + sed -i 's/^NO_CONVERTS=.*$/NO_CONVERTS=1/' "${{ env.GENERATOR_ENV }}" + fi + + if [[ "${ranked_only}" == 'true' ]]; then + sed -i 's/^RANKED_ONLY=.*$/RANKED_ONLY=1/' "${{ env.GENERATOR_ENV }}" + else + sed -i 's/^RANKED_ONLY=.*$/RANKED_ONLY=0/' "${{ env.GENERATOR_ENV }}" + fi + + - name: Query latest scores + id: query-scores + run: | + ruleset=$(cat ${{ env.GENERATOR_ENV }} | grep -E '^RULESET=' | cut -d '=' -f2-) + performance_data_name=$(curl -s "https://data.ppy.sh/" | grep "performance_${ruleset}_top_1000\b" | tail -1 | awk -F "'" '{print $2}' | sed 's/\.tar\.bz2//g') + + echo "TARGET_DIR=${{ env.GENERATOR_DIR }}/sql/${ruleset}" >> "${GITHUB_OUTPUT}" + echo "DATA_NAME=${performance_data_name}" >> "${GITHUB_OUTPUT}" + echo "DATA_PKG=${performance_data_name}.tar.bz2" >> "${GITHUB_OUTPUT}" + + - name: Restore score cache + id: restore-score-cache + uses: maxnowack/local-cache@720e69c948191660a90aa1cf6a42fc4d2dacdf30 # v2 + with: + path: ${{ steps.query-scores.outputs.DATA_PKG }} + key: ${{ steps.query-scores.outputs.DATA_NAME }} + + - name: Download scores + if: steps.restore-score-cache.outputs.cache-hit != 'true' + run: | + wget -q -O "${{ steps.query-scores.outputs.DATA_PKG }}" "https://data.ppy.sh/${{ steps.query-scores.outputs.DATA_PKG }}" + + - name: Extract scores + run: | + tar -I lbzip2 -xf "${{ steps.query-scores.outputs.DATA_PKG }}" + rm -r "${{ steps.query-scores.outputs.TARGET_DIR }}" + mv "${{ steps.query-scores.outputs.DATA_NAME }}" "${{ steps.query-scores.outputs.TARGET_DIR }}" + + - name: Query latest beatmaps + id: query-beatmaps + run: | + beatmaps_data_name=$(curl -s "https://data.ppy.sh/" | grep "osu_files" | tail -1 | awk -F "'" '{print $2}' | sed 's/\.tar\.bz2//g') + + echo "TARGET_DIR=${{ env.GENERATOR_DIR }}/beatmaps" >> "${GITHUB_OUTPUT}" + echo "DATA_NAME=${beatmaps_data_name}" >> "${GITHUB_OUTPUT}" + echo "DATA_PKG=${beatmaps_data_name}.tar.bz2" >> "${GITHUB_OUTPUT}" + + - name: Restore beatmap cache + id: restore-beatmap-cache + uses: maxnowack/local-cache@720e69c948191660a90aa1cf6a42fc4d2dacdf30 # v2 + with: + path: ${{ steps.query-beatmaps.outputs.DATA_PKG }} + key: ${{ steps.query-beatmaps.outputs.DATA_NAME }} + + - name: Download beatmap + if: steps.restore-beatmap-cache.outputs.cache-hit != 'true' + run: | + wget -q -O "${{ steps.query-beatmaps.outputs.DATA_PKG }}" "https://data.ppy.sh/${{ steps.query-beatmaps.outputs.DATA_PKG }}" + + - name: Extract beatmap + run: | + tar -I lbzip2 -xf "${{ steps.query-beatmaps.outputs.DATA_PKG }}" + rm -r "${{ steps.query-beatmaps.outputs.TARGET_DIR }}" + mv "${{ steps.query-beatmaps.outputs.DATA_NAME }}" "${{ steps.query-beatmaps.outputs.TARGET_DIR }}" + + - name: Run + id: run + run: | + # Add the GitHub token. This needs to be done here because it's unique per-job. + sed -i 's/^GH_TOKEN=.*$/GH_TOKEN=${{ github.token }}/' "${{ env.GENERATOR_ENV }}" + + cd "${{ env.GENERATOR_DIR }}" + + docker compose up --build --detach + docker compose logs --follow & + docker compose wait generator + + link=$(docker compose logs --tail 10 generator | grep 'http' | sed -E 's/^.*(http.*)$/\1/') + target=$(cat "${{ env.GENERATOR_ENV }}" | grep -E '^OSU_B=' | cut -d '=' -f2-) + + echo "target=${target}" >> "${GITHUB_OUTPUT}" + echo "sheet=${link}" >> "${GITHUB_OUTPUT}" + + - name: Shutdown + if: ${{ always() }} + run: | + cd "${{ env.GENERATOR_DIR }}" + docker compose down --volumes + rm -rf "${{ env.GENERATOR_DIR }}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc6e231c4b..d8645d728e 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,10 @@ jobs: dotnet-version: "8.0.x" - name: Install .NET workloads - run: dotnet workload install maui-android + # since windows image 20241113.3.0, not specifying a version here + # installs the .NET 7 version of android workload for very unknown reasons. + # revisit once we upgrade to .NET 9, it's probably fixed there. + run: dotnet workload install android --version (dotnet --version) - name: Compile run: dotnet build -c Debug osu.Android.slnf diff --git a/.github/workflows/diffcalc.yml b/.github/workflows/diffcalc.yml index ebf22b8a0e..8461208a2e 100644 --- a/.github/workflows/diffcalc.yml +++ b/.github/workflows/diffcalc.yml @@ -103,26 +103,11 @@ permissions: env: EXECUTION_ID: execution-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }} +defaults: + run: + shell: bash -euo pipefail {0} + jobs: - master-environment: - name: Save master environment - runs-on: ubuntu-latest - outputs: - HEAD: ${{ steps.get-head.outputs.HEAD }} - steps: - - name: Checkout osu - uses: actions/checkout@v4 - with: - ref: master - sparse-checkout: | - README.md - - - name: Get HEAD ref - id: get-head - run: | - ref=$(git log -1 --format='%H') - echo "HEAD=https://github.com/${{ github.repository }}/commit/${ref}" >> "${GITHUB_OUTPUT}" - check-permissions: name: Check permissions runs-on: ubuntu-latest @@ -130,7 +115,7 @@ jobs: steps: - name: Check permissions run: | - ALLOWED_USERS=(smoogipoo peppy bdach frenzibyte) + ALLOWED_USERS=(smoogipoo peppy bdach frenzibyte tsunyoku stanriders) for i in "${ALLOWED_USERS[@]}"; do if [[ "${{ github.actor }}" == "$i" ]]; then exit 0 @@ -138,9 +123,23 @@ jobs: done exit 1 + run-diffcalc: + name: Run spreadsheet generator + needs: check-permissions + uses: ./.github/workflows/_diffcalc_processor.yml + with: + # Can't reference env... Why GitHub, WHY? + id: execution-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }} + head-sha: https://github.com/${{ github.repository }}/commit/${{ github.event.pull_request.head.sha || github.sha }} + pr-url: ${{ github.event.issue.pull_request.html_url || '' }} + pr-text: ${{ github.event.comment.body || '' }} + dispatch-inputs: ${{ (github.event.type == 'workflow_dispatch' && toJSON(inputs)) || '' }} + secrets: + DIFFCALC_GOOGLE_CREDENTIALS: ${{ secrets.DIFFCALC_GOOGLE_CREDENTIALS }} + create-comment: name: Create PR comment - needs: [ master-environment, check-permissions ] + needs: check-permissions runs-on: ubuntu-latest if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request }} steps: @@ -153,244 +152,34 @@ jobs: *This comment will update on completion* - directory: - name: Prepare directory - needs: check-permissions - runs-on: self-hosted - outputs: - GENERATOR_DIR: ${{ steps.set-outputs.outputs.GENERATOR_DIR }} - GENERATOR_ENV: ${{ steps.set-outputs.outputs.GENERATOR_ENV }} - GOOGLE_CREDS_FILE: ${{ steps.set-outputs.outputs.GOOGLE_CREDS_FILE }} - steps: - - name: Checkout diffcalc-sheet-generator - uses: actions/checkout@v4 - with: - path: ${{ env.EXECUTION_ID }} - repository: 'smoogipoo/diffcalc-sheet-generator' - - - name: Set outputs - id: set-outputs - run: | - echo "GENERATOR_DIR=${{ github.workspace }}/${{ env.EXECUTION_ID }}" >> "${GITHUB_OUTPUT}" - echo "GENERATOR_ENV=${{ github.workspace }}/${{ env.EXECUTION_ID }}/.env" >> "${GITHUB_OUTPUT}" - echo "GOOGLE_CREDS_FILE=${{ github.workspace }}/${{ env.EXECUTION_ID }}/google-credentials.json" >> "${GITHUB_OUTPUT}" - - environment: - name: Setup environment - needs: [ master-environment, directory ] - runs-on: self-hosted - env: - VARS_JSON: ${{ toJSON(vars) }} - steps: - - name: Add base environment - run: | - # Required by diffcalc-sheet-generator - cp '${{ needs.directory.outputs.GENERATOR_DIR }}/.env.sample' "${{ needs.directory.outputs.GENERATOR_ENV }}" - - # Add Google credentials - echo '${{ secrets.DIFFCALC_GOOGLE_CREDENTIALS }}' | base64 -d > "${{ needs.directory.outputs.GOOGLE_CREDS_FILE }}" - - # Add repository variables - echo "${VARS_JSON}" | jq -c '. | to_entries | .[]' | while read -r line; do - opt=$(jq -r '.key' <<< ${line}) - val=$(jq -r '.value' <<< ${line}) - - if [[ "${opt}" =~ ^DIFFCALC_ ]]; then - optNoPrefix=$(echo "${opt}" | cut -d '_' -f2-) - sed -i "s;^${optNoPrefix}=.*$;${optNoPrefix}=${val};" "${{ needs.directory.outputs.GENERATOR_ENV }}" - fi - done - - - name: Add master environment - run: | - sed -i "s;^OSU_A=.*$;OSU_A=${{ needs.master-environment.outputs.HEAD }};" "${{ needs.directory.outputs.GENERATOR_ENV }}" - - - name: Add pull-request environment - if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request }} - run: | - sed -i "s;^OSU_B=.*$;OSU_B=${{ github.event.issue.pull_request.html_url }};" "${{ needs.directory.outputs.GENERATOR_ENV }}" - - - name: Add comment environment - if: ${{ github.event_name == 'issue_comment' }} - env: - COMMENT_BODY: ${{ github.event.comment.body }} - run: | - # Add comment environment - echo "$COMMENT_BODY" | sed -r 's/\r$//' | grep -E '^\w+=' | while read -r line; do - opt=$(echo "${line}" | cut -d '=' -f1) - sed -i "s;^${opt}=.*$;${line};" "${{ needs.directory.outputs.GENERATOR_ENV }}" - done - - - name: Add dispatch environment - if: ${{ github.event_name == 'workflow_dispatch' }} - run: | - sed -i 's;^OSU_B=.*$;OSU_B=${{ inputs.osu-b }};' "${{ needs.directory.outputs.GENERATOR_ENV }}" - sed -i 's/^RULESET=.*$/RULESET=${{ inputs.ruleset }}/' "${{ needs.directory.outputs.GENERATOR_ENV }}" - sed -i 's/^GENERATORS=.*$/GENERATORS=${{ inputs.generators }}/' "${{ needs.directory.outputs.GENERATOR_ENV }}" - - if [[ '${{ inputs.osu-a }}' != 'latest' ]]; then - sed -i 's;^OSU_A=.*$;OSU_A=${{ inputs.osu-a }};' "${{ needs.directory.outputs.GENERATOR_ENV }}" - fi - - if [[ '${{ inputs.difficulty-calculator-a }}' != 'latest' ]]; then - sed -i 's;^DIFFICULTY_CALCULATOR_A=.*$;DIFFICULTY_CALCULATOR_A=${{ inputs.difficulty-calculator-a }};' "${{ needs.directory.outputs.GENERATOR_ENV }}" - fi - - if [[ '${{ inputs.difficulty-calculator-b }}' != 'latest' ]]; then - sed -i 's;^DIFFICULTY_CALCULATOR_B=.*$;DIFFICULTY_CALCULATOR_B=${{ inputs.difficulty-calculator-b }};' "${{ needs.directory.outputs.GENERATOR_ENV }}" - fi - - if [[ '${{ inputs.score-processor-a }}' != 'latest' ]]; then - sed -i 's;^SCORE_PROCESSOR_A=.*$;SCORE_PROCESSOR_A=${{ inputs.score-processor-a }};' "${{ needs.directory.outputs.GENERATOR_ENV }}" - fi - - if [[ '${{ inputs.score-processor-b }}' != 'latest' ]]; then - sed -i 's;^SCORE_PROCESSOR_B=.*$;SCORE_PROCESSOR_B=${{ inputs.score-processor-b }};' "${{ needs.directory.outputs.GENERATOR_ENV }}" - fi - - if [[ '${{ inputs.converts }}' == 'true' ]]; then - sed -i 's/^NO_CONVERTS=.*$/NO_CONVERTS=0/' "${{ needs.directory.outputs.GENERATOR_ENV }}" - else - sed -i 's/^NO_CONVERTS=.*$/NO_CONVERTS=1/' "${{ needs.directory.outputs.GENERATOR_ENV }}" - fi - - if [[ '${{ inputs.ranked-only }}' == 'true' ]]; then - sed -i 's/^RANKED_ONLY=.*$/RANKED_ONLY=1/' "${{ needs.directory.outputs.GENERATOR_ENV }}" - else - sed -i 's/^RANKED_ONLY=.*$/RANKED_ONLY=0/' "${{ needs.directory.outputs.GENERATOR_ENV }}" - fi - - scores: - name: Setup scores - needs: [ directory, environment ] - runs-on: self-hosted - steps: - - name: Query latest data - id: query - run: | - ruleset=$(cat ${{ needs.directory.outputs.GENERATOR_ENV }} | grep -E '^RULESET=' | cut -d '=' -f2-) - performance_data_name=$(curl -s "https://data.ppy.sh/" | grep "performance_${ruleset}_top_1000\b" | tail -1 | awk -F "'" '{print $2}' | sed 's/\.tar\.bz2//g') - - echo "TARGET_DIR=${{ needs.directory.outputs.GENERATOR_DIR }}/sql/${ruleset}" >> "${GITHUB_OUTPUT}" - echo "DATA_NAME=${performance_data_name}" >> "${GITHUB_OUTPUT}" - - - 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: 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 }} @@ -399,7 +188,7 @@ jobs: Difficulty calculation failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - name: Update comment on cancellation - if: ${{ needs.generator.result == 'cancelled' }} + if: ${{ needs.run-diffcalc.result == 'cancelled' }} uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0 with: comment_tag: ${{ env.EXECUTION_ID }} diff --git a/.github/workflows/report-nunit.yml b/.github/workflows/report-nunit.yml index c44f46d70a..14f0208fc8 100644 --- a/.github/workflows/report-nunit.yml +++ b/.github/workflows/report-nunit.yml @@ -5,33 +5,40 @@ name: Annotate CI run with test results on: workflow_run: - workflows: ["Continuous Integration"] + workflows: [ "Continuous Integration" ] types: - completed -permissions: {} + +permissions: + contents: read + actions: read + checks: write + jobs: annotate: - permissions: - checks: write # to create checks (dorny/test-reporter) - name: Annotate CI run with test results runs-on: ubuntu-latest if: ${{ github.event.workflow_run.conclusion != 'cancelled' }} - strategy: - fail-fast: false - matrix: - os: - - { prettyname: Windows } - - { prettyname: macOS } - - { prettyname: Linux } - threadingMode: ['SingleThread', 'MultiThreaded'] timeout-minutes: 5 steps: + - name: Checkout + uses: actions/checkout@v4 + with: + repository: ${{ github.event.workflow_run.repository.full_name }} + ref: ${{ github.event.workflow_run.head_sha }} + + - name: Download results + uses: actions/download-artifact@v4 + with: + pattern: osu-test-results-* + merge-multiple: true + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + - name: Annotate CI run with test results uses: dorny/test-reporter@v1.8.0 with: - artifact: osu-test-results-${{matrix.os.prettyname}}-${{matrix.threadingMode}} - name: Test Results (${{matrix.os.prettyname}}, ${{matrix.threadingMode}}) + name: Results path: "*.trx" reporter: dotnet-trx list-suites: 'failed' diff --git a/.globalconfig b/.globalconfig deleted file mode 100644 index a4d4707f9b..0000000000 --- a/.globalconfig +++ /dev/null @@ -1,57 +0,0 @@ -# .NET Code Style -# IDE styles reference: https://docs.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ - -# IDE0001: Simplify names -dotnet_diagnostic.IDE0001.severity = warning - -# IDE0002: Simplify member access -dotnet_diagnostic.IDE0002.severity = warning - -# IDE0003: Remove qualification -dotnet_diagnostic.IDE0003.severity = warning - -# IDE0004: Remove unnecessary cast -dotnet_diagnostic.IDE0004.severity = warning - -# IDE0005: Remove unnecessary imports -dotnet_diagnostic.IDE0005.severity = warning - -# IDE0034: Simplify default literal -dotnet_diagnostic.IDE0034.severity = warning - -# IDE0036: Sort modifiers -dotnet_diagnostic.IDE0036.severity = warning - -# IDE0040: Add accessibility modifier -dotnet_diagnostic.IDE0040.severity = warning - -# IDE0049: Use keyword for type name -dotnet_diagnostic.IDE0040.severity = warning - -# IDE0055: Fix formatting -dotnet_diagnostic.IDE0055.severity = warning - -# IDE0051: Private method is unused -dotnet_diagnostic.IDE0051.severity = silent - -# IDE0052: Private member is unused -dotnet_diagnostic.IDE0052.severity = silent - -# IDE0073: File header -dotnet_diagnostic.IDE0073.severity = warning - -# IDE0130: Namespace mismatch with folder -dotnet_diagnostic.IDE0130.severity = warning - -# IDE1006: Naming style -dotnet_diagnostic.IDE1006.severity = warning - -#Disable operator overloads requiring alternate named methods -dotnet_diagnostic.CA2225.severity = none - -# Banned APIs -dotnet_diagnostic.RS0030.severity = error - -# Temporarily disable analysing CanBeNull = true in NRT contexts due to mobile issues. -# See: https://github.com/ppy/osu/pull/19677 -dotnet_diagnostic.OSUF001.severity = none 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/CodeAnalysis/BannedSymbols.txt b/CodeAnalysis/BannedSymbols.txt index 3c60b28765..550f7c8e11 100644 --- a/CodeAnalysis/BannedSymbols.txt +++ b/CodeAnalysis/BannedSymbols.txt @@ -14,10 +14,6 @@ M:Realms.CollectionExtensions.SubscribeForNotifications`1(System.Collections.Gen M:System.Threading.Tasks.Task.Wait();Don't use Task.Wait. Use Task.WaitSafely() to ensure we avoid deadlocks. P:System.Threading.Tasks.Task`1.Result;Don't use Task.Result. Use Task.GetResultSafely() to ensure we avoid deadlocks. M:System.Threading.ManualResetEventSlim.Wait();Specify a timeout to avoid waiting forever. -M:System.Char.ToLower(System.Char);char.ToLower() changes behaviour depending on CultureInfo.CurrentCulture. Use char.ToLowerInvariant() instead. If wanting culture-sensitive behaviour, explicitly provide CultureInfo.CurrentCulture. -M:System.Char.ToUpper(System.Char);char.ToUpper() changes behaviour depending on CultureInfo.CurrentCulture. Use char.ToUpperInvariant() instead. If wanting culture-sensitive behaviour, explicitly provide CultureInfo.CurrentCulture. -M:System.String.ToLower();string.ToLower() changes behaviour depending on CultureInfo.CurrentCulture. Use string.ToLowerInvariant() instead. If wanting culture-sensitive behaviour, explicitly provide CultureInfo.CurrentCulture or use LocalisableString. -M:System.String.ToUpper();string.ToUpper() changes behaviour depending on CultureInfo.CurrentCulture. Use string.ToUpperInvariant() instead. If wanting culture-sensitive behaviour, explicitly provide CultureInfo.CurrentCulture or use LocalisableString. M:Humanizer.InflectorExtensions.Pascalize(System.String);Humanizer's .Pascalize() extension method changes behaviour depending on CultureInfo.CurrentCulture. Use StringDehumanizeExtensions.ToPascalCase() instead. M:Humanizer.InflectorExtensions.Camelize(System.String);Humanizer's .Camelize() extension method changes behaviour depending on CultureInfo.CurrentCulture. Use StringDehumanizeExtensions.ToCamelCase() instead. M:Humanizer.InflectorExtensions.Underscore(System.String);Humanizer's .Underscore() extension method changes behaviour depending on CultureInfo.CurrentCulture. Use StringDehumanizeExtensions.ToSnakeCase() instead. diff --git a/CodeAnalysis/osu.globalconfig b/CodeAnalysis/osu.globalconfig new file mode 100644 index 0000000000..247a825033 --- /dev/null +++ b/CodeAnalysis/osu.globalconfig @@ -0,0 +1,109 @@ +# .NET Code Style +# IDE styles reference: https://docs.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ +is_global = true + +# IDE0001: Simplify names +dotnet_diagnostic.IDE0001.severity = warning + +# IDE0002: Simplify member access +dotnet_diagnostic.IDE0002.severity = warning + +# IDE0003: Remove qualification +dotnet_diagnostic.IDE0003.severity = warning + +# IDE0004: Remove unnecessary cast +dotnet_diagnostic.IDE0004.severity = warning + +# IDE0005: Remove unnecessary imports +dotnet_diagnostic.IDE0005.severity = warning + +# IDE0034: Simplify default literal +dotnet_diagnostic.IDE0034.severity = warning + +# IDE0036: Sort modifiers +dotnet_diagnostic.IDE0036.severity = warning + +# IDE0040: Add accessibility modifier +dotnet_diagnostic.IDE0040.severity = warning + +# IDE0049: Use keyword for type name +dotnet_diagnostic.IDE0040.severity = warning + +# IDE0055: Fix formatting +dotnet_diagnostic.IDE0055.severity = warning + +# IDE0051: Private method is unused +dotnet_diagnostic.IDE0051.severity = silent + +# IDE0052: Private member is unused +dotnet_diagnostic.IDE0052.severity = silent + +# IDE0073: File header +dotnet_diagnostic.IDE0073.severity = warning + +# IDE0130: Namespace mismatch with folder +dotnet_diagnostic.IDE0130.severity = warning + +# IDE1006: Naming style +dotnet_diagnostic.IDE1006.severity = warning + +# CA1305: Specify IFormatProvider +# Too many noisy warnings for parsing/formatting numbers +dotnet_diagnostic.CA1305.severity = none + +# CA1507: Use nameof to express symbol names +# Flaggs serialization name attributes +dotnet_diagnostic.CA1507.severity = suggestion + +# CA1806: Do not ignore method results +# The usages for numeric parsing are explicitly optional +dotnet_diagnostic.CA1806.severity = suggestion + +# CA1822: Mark members as static +# Potential false positive around reflection/too much noise +dotnet_diagnostic.CA1822.severity = none + +# CA1826: Do not use Enumerable method on indexable collections +dotnet_diagnostic.CA1826.severity = suggestion + +# CA1859: Use concrete types when possible for improved performance +# Involves design considerations +dotnet_diagnostic.CA1859.severity = suggestion + +# CA1860: Avoid using 'Enumerable.Any()' extension method +dotnet_diagnostic.CA1860.severity = suggestion + +# CA1861: Avoid constant arrays as arguments +# Outdated with collection expressions +dotnet_diagnostic.CA1861.severity = suggestion + +# CA2007: Consider calling ConfigureAwait on the awaited task +dotnet_diagnostic.CA2007.severity = warning + +# CA2016: Forward the 'CancellationToken' parameter to methods +# Some overloads are having special handling for debugger +dotnet_diagnostic.CA2016.severity = suggestion + +# CA2021: Do not call Enumerable.Cast or Enumerable.OfType with incompatible types +# Causing a lot of false positives with generics +dotnet_diagnostic.CA2021.severity = none + +# CA2101: Specify marshaling for P/Invoke string arguments +# Reports warning for all non-UTF16 usages on DllImport; consider migrating to LibraryImport +dotnet_diagnostic.CA2101.severity = none + +# CA2201: Do not raise reserved exception types +dotnet_diagnostic.CA2201.severity = warning + +# CA2208: Instantiate argument exceptions correctly +dotnet_diagnostic.CA2208.severity = suggestion + +# CA2242: Test for NaN correctly +dotnet_diagnostic.CA2242.severity = warning + +# Banned APIs +dotnet_diagnostic.RS0030.severity = error + +# Temporarily disable analysing CanBeNull = true in NRT contexts due to mobile issues. +# See: https://github.com/ppy/osu/pull/19677 +dotnet_diagnostic.OSUF001.severity = none diff --git a/CodeAnalysis/osu.ruleset b/CodeAnalysis/osu.ruleset deleted file mode 100644 index 6a99e230d1..0000000000 --- a/CodeAnalysis/osu.ruleset +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props index 5ba12b845b..3acb86ee0c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -18,9 +18,21 @@ + + - $(MSBuildThisFileDirectory)CodeAnalysis\osu.ruleset + Default + Default + Recommended + Recommended + Recommended + Recommended + Default + Minimum + Recommended + Default + Default true 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 7844dcc1fe..632325725a 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 a23988362a..48e59877df 100644 --- a/osu.Game.Rulesets.Mania/Edit/Setup/ManiaDifficultySection.cs +++ b/osu.Game.Rulesets.Mania/Edit/Setup/ManiaDifficultySection.cs @@ -55,7 +55,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Setup { 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 } + Current = { Value = Beatmap.SpecialStyle } }, healthDrainSlider = new FormSliderBar { @@ -157,7 +157,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Setup // for now, update these on commit rather than making BeatmapMetadata bindables. // after switching database engines we can reconsider if switching to bindables is a good direction. Beatmap.Difficulty.CircleSize = keyCountSlider.Current.Value; - Beatmap.BeatmapInfo.SpecialStyle = specialStyle.Current.Value; + Beatmap.SpecialStyle = specialStyle.Current.Value; Beatmap.Difficulty.DrainRate = healthDrainSlider.Current.Value; Beatmap.Difficulty.OverallDifficulty = overallDifficultySlider.Current.Value; Beatmap.Difficulty.SliderMultiplier = baseVelocitySlider.Current.Value; 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/Argon/ManiaArgonSkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs index afccb2e568..c37c18081a 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs @@ -164,7 +164,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon case 1: return colour_cyan; - default: throw new ArgumentOutOfRangeException(); + default: throw new ArgumentOutOfRangeException(nameof(columnIndex)); } case 3: @@ -176,7 +176,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon case 2: return colour_cyan; - default: throw new ArgumentOutOfRangeException(); + default: throw new ArgumentOutOfRangeException(nameof(columnIndex)); } case 4: @@ -190,7 +190,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon case 3: return colour_purple; - default: throw new ArgumentOutOfRangeException(); + default: throw new ArgumentOutOfRangeException(nameof(columnIndex)); } case 5: @@ -206,7 +206,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon case 4: return colour_cyan; - default: throw new ArgumentOutOfRangeException(); + default: throw new ArgumentOutOfRangeException(nameof(columnIndex)); } case 6: @@ -224,7 +224,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon case 5: return colour_pink; - default: throw new ArgumentOutOfRangeException(); + default: throw new ArgumentOutOfRangeException(nameof(columnIndex)); } case 7: @@ -244,7 +244,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon case 6: return colour_pink; - default: throw new ArgumentOutOfRangeException(); + default: throw new ArgumentOutOfRangeException(nameof(columnIndex)); } case 8: @@ -266,7 +266,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon case 7: return colour_purple; - default: throw new ArgumentOutOfRangeException(); + default: throw new ArgumentOutOfRangeException(nameof(columnIndex)); } case 9: @@ -290,7 +290,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon case 8: return colour_purple; - default: throw new ArgumentOutOfRangeException(); + default: throw new ArgumentOutOfRangeException(nameof(columnIndex)); } case 10: @@ -316,7 +316,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon case 9: return colour_purple; - default: throw new ArgumentOutOfRangeException(); + default: throw new ArgumentOutOfRangeException(nameof(columnIndex)); } } @@ -339,7 +339,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon case 5: return colour_green; - default: throw new ArgumentOutOfRangeException(); + default: throw new ArgumentOutOfRangeException(nameof(columnIndex)); } } } diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageBackground.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageBackground.cs index 758c8dd347..71618a4bc3 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageBackground.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageBackground.cs @@ -54,7 +54,8 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy }, columnBackgrounds = new ColumnFlow(stageDefinition) { - RelativeSizeAxes = Axes.Y + RelativeSizeAxes = Axes.Y, + Masking = false, }, new HitTargetInsetContainer { @@ -126,8 +127,8 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy }, new Container { + X = isLastColumn ? -0.16f : 0, Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, RelativeSizeAxes = Axes.Y, Width = rightLineWidth, Scale = new Vector2(0.740f, 1), diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs index cb42b2b62a..8f425edc44 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs @@ -164,10 +164,10 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy private Drawable getResult(HitResult result) { - if (!hit_result_mapping.ContainsKey(result)) + if (!hit_result_mapping.TryGetValue(result, out var value)) return null; - string filename = this.GetManiaSkinConfig(hit_result_mapping[result])?.Value + string filename = this.GetManiaSkinConfig(value)?.Value ?? default_hit_result_skin_filenames[result]; var animation = this.GetAnimation(filename, true, true, frameLength: 1000 / 20d); 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/TestSceneOsuComposerSelection.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuComposerSelection.cs index b97fe5c5a8..345965b912 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuComposerSelection.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuComposerSelection.cs @@ -231,6 +231,36 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor AddAssert("slider still has 2 anchors", () => secondSlider.Path.ControlPoints.Count, () => Is.EqualTo(2)); } + [Test] + public void TestControlClickDoesNotDiscardExistingSelectionEvenIfNothingHit() + { + var firstSlider = new Slider + { + StartTime = 0, + Position = new Vector2(0, 0), + Path = new SliderPath + { + ControlPoints = + { + new PathControlPoint(), + new PathControlPoint(new Vector2(100)) + } + } + }; + + AddStep("add object", () => EditorBeatmap.AddRange([firstSlider])); + AddStep("select first slider", () => EditorBeatmap.SelectedHitObjects.AddRange([firstSlider])); + + AddStep("move mouse to middle of playfield", () => InputManager.MoveMouseTo(blueprintContainer.ScreenSpaceDrawQuad.Centre)); + AddStep("control-click left mouse", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Click(MouseButton.Left); + InputManager.ReleaseKey(Key.ControlLeft); + }); + AddAssert("selection preserved", () => EditorBeatmap.SelectedHitObjects.Count, () => Is.EqualTo(1)); + } + private ComposeBlueprintContainer blueprintContainer => Editor.ChildrenOfType().First(); diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs index b70ecfbba8..f257ed5987 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); @@ -188,6 +191,98 @@ 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); + && EditorBeatmap.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..33ae2c68e6 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 { @@ -83,10 +83,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods }) } }, - BeatmapInfo = - { - StackLeniency = 0, - } + StackLeniency = 0, }, ReplayFrames = new List { @@ -114,7 +111,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 +150,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..ece4b94b8a 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) @@ -74,12 +74,12 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods { BeatmapInfo = new BeatmapInfo { - StackLeniency = 0, Difficulty = new BeatmapDifficulty { ApproachRate = 8.5f } }, + StackLeniency = 0, ControlPointInfo = controlPointInfo }; 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/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/TestSceneSliderLateHitJudgement.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs index 1ba4a60b75..d089e924ca 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs @@ -465,7 +465,7 @@ namespace osu.Game.Rulesets.Osu.Tests private void performTest(List frames, Beatmap beatmap) { beatmap.BeatmapInfo.Ruleset = new OsuRuleset().RulesetInfo; - beatmap.BeatmapInfo.StackLeniency = 0; + beatmap.StackLeniency = 0; beatmap.BeatmapInfo.Difficulty = new BeatmapDifficulty { SliderMultiplier = 4, diff --git a/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj b/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj index ea54c8d313..5ea231e606 100644 --- a/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj +++ b/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj @@ -1,7 +1,7 @@  - + diff --git a/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapProcessor.cs b/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapProcessor.cs index 0e77553177..3cc5af3176 100644 --- a/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapProcessor.cs +++ b/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapProcessor.cs @@ -56,13 +56,13 @@ namespace osu.Game.Rulesets.Osu.Beatmaps h.StackHeight = 0; if (beatmap.BeatmapInfo.BeatmapVersion >= 6) - applyStacking(beatmap.BeatmapInfo, hitObjects, 0, hitObjects.Count - 1); + applyStacking(beatmap, hitObjects, 0, hitObjects.Count - 1); else - applyStackingOld(beatmap.BeatmapInfo, hitObjects); + applyStackingOld(beatmap, hitObjects); } } - private static void applyStacking(BeatmapInfo beatmapInfo, List hitObjects, int startIndex, int endIndex) + private static void applyStacking(IBeatmap beatmap, List hitObjects, int startIndex, int endIndex) { ArgumentOutOfRangeException.ThrowIfGreaterThan(startIndex, endIndex); ArgumentOutOfRangeException.ThrowIfNegative(startIndex); @@ -87,7 +87,7 @@ namespace osu.Game.Rulesets.Osu.Beatmaps continue; double endTime = stackBaseObject.GetEndTime(); - double stackThreshold = objectN.TimePreempt * beatmapInfo.StackLeniency; + double stackThreshold = objectN.TimePreempt * beatmap.StackLeniency; if (objectN.StartTime - endTime > stackThreshold) // We are no longer within stacking range of the next object. @@ -132,7 +132,7 @@ namespace osu.Game.Rulesets.Osu.Beatmaps OsuHitObject objectI = hitObjects[i]; if (objectI.StackHeight != 0 || objectI is Spinner) continue; - double stackThreshold = objectI.TimePreempt * beatmapInfo.StackLeniency; + double stackThreshold = objectI.TimePreempt * beatmap.StackLeniency; /* If this object is a hitcircle, then we enter this "special" case. * It either ends with a stack of hitcircles only, or a stack of hitcircles that are underneath a slider. @@ -214,7 +214,7 @@ namespace osu.Game.Rulesets.Osu.Beatmaps } } - private static void applyStackingOld(BeatmapInfo beatmapInfo, List hitObjects) + private static void applyStackingOld(IBeatmap beatmap, List hitObjects) { for (int i = 0; i < hitObjects.Count; i++) { @@ -228,7 +228,7 @@ namespace osu.Game.Rulesets.Osu.Beatmaps for (int j = i + 1; j < hitObjects.Count; j++) { - double stackThreshold = hitObjects[i].TimePreempt * beatmapInfo.StackLeniency; + double stackThreshold = hitObjects[i].TimePreempt * beatmap.StackLeniency; if (hitObjects[j].StartTime - stackThreshold > startTime) break; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs index 3d1939acac..9816f6d0a4 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs @@ -3,6 +3,7 @@ using System; using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Objects; @@ -33,6 +34,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators var osuLastObj = (OsuDifficultyHitObject)current.Previous(0); var osuLastLastObj = (OsuDifficultyHitObject)current.Previous(1); + const int radius = OsuDifficultyHitObject.NORMALISED_RADIUS; + const int diameter = OsuDifficultyHitObject.NORMALISED_DIAMETER; + // Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle. double currVelocity = osuCurrObj.LazyJumpDistance / osuCurrObj.StrainTime; @@ -77,14 +81,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators wideAngleBonus = calcWideAngleBonus(currAngle); acuteAngleBonus = calcAcuteAngleBonus(currAngle); - if (osuCurrObj.StrainTime > 100) // Only buff deltaTime exceeding 300 bpm 1/2. + if (DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.StrainTime, 2) < 300) // Only buff deltaTime exceeding 300 bpm 1/2. acuteAngleBonus = 0; else { acuteAngleBonus *= calcAcuteAngleBonus(lastAngle) // Multiply by previous angle, we don't want to buff unless this is a wiggle type pattern. - * Math.Min(angleBonus, 125 / osuCurrObj.StrainTime) // The maximum velocity we buff is equal to 125 / strainTime + * Math.Min(angleBonus, diameter * 1.25 / osuCurrObj.StrainTime) // The maximum velocity we buff is equal to 125 / strainTime * Math.Pow(Math.Sin(Math.PI / 2 * Math.Min(1, (100 - osuCurrObj.StrainTime) / 25)), 2) // scale buff from 150 bpm 1/4 to 200 bpm 1/4 - * Math.Pow(Math.Sin(Math.PI / 2 * (Math.Clamp(osuCurrObj.LazyJumpDistance, 50, 100) - 50) / 50), 2); // Buff distance exceeding 50 (radius) up to 100 (diameter). + * Math.Pow(Math.Sin(Math.PI / 2 * (Math.Clamp(osuCurrObj.LazyJumpDistance, radius, diameter) - radius) / radius), 2); // Buff distance exceeding radius up to diameter. } // Penalize wide angles if they're repeated, reducing the penalty as the lastAngle gets more acute. @@ -104,7 +108,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators double distRatio = Math.Pow(Math.Sin(Math.PI / 2 * Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity)), 2); // Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing. - double overlapVelocityBuff = Math.Min(125 / Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime), Math.Abs(prevVelocity - currVelocity)); + double overlapVelocityBuff = Math.Min(diameter * 1.25 / Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime), Math.Abs(prevVelocity - currVelocity)); velocityChangeBonus = overlapVelocityBuff * distRatio; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs index d10d2c5c05..d503dd2bcc 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Objects; @@ -120,7 +121,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators islandCount.Count++; // repeated island (ex: triplet -> triplet) - double power = logistic(island.Delta, 2.75, 0.24, 14); + double power = DifficultyCalculationUtils.Logistic(island.Delta, maxValue: 2.75, multiplier: 0.24, midpointOffset: 58.33); effectiveRatio *= Math.Min(3.0 / islandCount.Count, Math.Pow(1.0 / islandCount.Count, power)); islandCounts[countIndex] = (islandCount.Island, islandCount.Count); @@ -172,8 +173,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators return Math.Sqrt(4 + rhythmComplexitySum * rhythm_overall_multiplier) / 2.0; // produces multiplier that can be applied to strain. range [1, infinity) (not really though) } - private static double logistic(double x, double maxValue, double multiplier, double offset) => (maxValue / (1 + Math.Pow(Math.E, offset - (multiplier * x)))); - private class Island : IEquatable { private readonly double deltaDifferenceEpsilon; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs index c220352ee0..a5f6468f17 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs @@ -3,6 +3,7 @@ using System; using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Objects; @@ -10,8 +11,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators { public static class SpeedEvaluator { - private const double single_spacing_threshold = 125; // 1.25 circles distance between centers - private const double min_speed_bonus = 75; // ~200BPM + private const double single_spacing_threshold = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25; // 1.25 circles distance between centers + private const double min_speed_bonus = 200; // 200 BPM 1/4th private const double speed_balancing_factor = 40; private const double distance_multiplier = 0.94; @@ -43,8 +44,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators double speedBonus = 0.0; // Add additional scaling bonus for streams/bursts higher than 200bpm - if (strainTime < min_speed_bonus) - speedBonus = 0.75 * Math.Pow((min_speed_bonus - strainTime) / speed_balancing_factor, 2); + if (DifficultyCalculationUtils.MillisecondsToBPM(strainTime) > min_speed_bonus) + speedBonus = 0.75 * Math.Pow((DifficultyCalculationUtils.BPMToMilliseconds(min_speed_bonus) - strainTime) / speed_balancing_factor, 2); double travelDistance = osuPrevObj?.TravelDistance ?? 0; double distance = travelDistance + osuCurrObj.MinimumJumpDistance; diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index acf01b2a83..575e03051c 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -48,8 +48,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty double sliderFactor = aimRating > 0 ? aimRatingNoSliders / aimRating : 1; - double aimDifficultyStrainCount = ((OsuStrainSkill)skills[0]).CountDifficultStrains(); - double speedDifficultyStrainCount = ((OsuStrainSkill)skills[2]).CountDifficultStrains(); + double aimDifficultyStrainCount = ((OsuStrainSkill)skills[0]).CountTopWeightedStrains(); + double speedDifficultyStrainCount = ((OsuStrainSkill)skills[2]).CountTopWeightedStrains(); if (mods.Any(m => m is OsuModTouchDevice)) { diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index bca53dfa87..31b00dba2b 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -25,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() @@ -44,7 +57,38 @@ namespace osu.Game.Rulesets.Osu.Difficulty 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; @@ -124,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; } @@ -247,29 +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 3eaf500ad7..5e4c5c1ee9 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -20,6 +20,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing /// public const int NORMALISED_RADIUS = 50; // Change radius to 50 to make 100 the diameter. Easier for mental maths. + public const int NORMALISED_DIAMETER = NORMALISED_RADIUS * 2; + public const int MIN_DELTA_TIME = 25; private const float maximum_slider_radius = NORMALISED_RADIUS * 2.4f; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index b3d530e3af..faf91e4652 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -34,7 +34,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills { currentStrain *= strainDecay(current.DeltaTime); currentStrain += AimEvaluator.EvaluateDifficultyOf(current, withSliders) * skillMultiplier; - ObjectStrains.Add(currentStrain); return currentStrain; } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs index 96180c0aa1..6823512cef 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs @@ -23,9 +23,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills /// protected virtual double ReducedStrainBaseline => 0.75; - protected List ObjectStrains = new List(); - protected double Difficulty; - protected OsuStrainSkill(Mod[] mods) : base(mods) { @@ -33,7 +30,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills public override double DifficultyValue() { - Difficulty = 0; + double difficulty = 0; double weight = 1; // Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). @@ -53,25 +50,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills // We're sorting from highest to lowest strain. foreach (double strain in strains.OrderDescending()) { - Difficulty += strain * weight; + difficulty += strain * weight; weight *= DecayWeight; } - return Difficulty; - } - - /// - /// Returns the number of strains weighted against the top strain. - /// The result is scaled by clock rate as it affects the total number of strains. - /// - public double CountDifficultStrains() - { - if (Difficulty == 0) - return 0.0; - - double consistentTopStrain = Difficulty / 10; // What would the top strain be if all strain values were identical - // Use a weighted sum of all strains. Constants are arbitrary and give nice values - return ObjectStrains.Sum(s => 1.1 / (1 + Math.Exp(-10 * (s / consistentTopStrain - 0.88)))); + return difficulty; } public static double DifficultyToPerformance(double difficulty) => Math.Pow(5.0 * Math.Max(1.0, difficulty / 0.0675) - 4.0, 3.0) / 100000.0; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index e5aa25c1eb..d2c4bbb618 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -40,7 +40,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills currentRhythm = RhythmEvaluator.EvaluateDifficultyOf(current); double totalStrain = currentStrain * currentRhythm; - ObjectStrains.Add(totalStrain); return totalStrain; } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/GridPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/GridPlacementBlueprint.cs index b13663cb44..163b42bcfd 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/GridPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/GridPlacementBlueprint.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; using osu.Game.Rulesets.Edit; using osuTK; @@ -31,12 +32,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints public override void EndPlacement(bool commit) { if (!commit && PlacementActive != PlacementState.Finished) - { - gridToolboxGroup.StartPosition.Value = originalOrigin; - gridToolboxGroup.Spacing.Value = originalSpacing; - if (!gridToolboxGroup.GridLinesRotation.Disabled) - gridToolboxGroup.GridLinesRotation.Value = originalRotation; - } + resetGridState(); base.EndPlacement(commit); @@ -103,6 +99,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints public override void UpdateTimeAndPosition(SnapResult result) { + if (State.Value == Visibility.Hidden) + return; + var pos = ToLocalSpace(result.ScreenSpacePosition); if (PlacementActive != PlacementState.Active) @@ -122,5 +121,19 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints } } } + + protected override void PopOut() + { + base.PopOut(); + resetGridState(); + } + + private void resetGridState() + { + gridToolboxGroup.StartPosition.Value = originalOrigin; + gridToolboxGroup.Spacing.Value = originalSpacing; + if (!gridToolboxGroup.GridLinesRotation.Disabled) + gridToolboxGroup.GridLinesRotation.Value = originalRotation; + } } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs index 239bd1100a..f114516300 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs @@ -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) { @@ -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) @@ -392,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; @@ -473,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 diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs index cb57c8e6e0..4f2f6516a8 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs @@ -156,6 +156,13 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders return true; } + // this allows sliders to be drawn outside compose area (after starting from a point within the compose area). + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => base.ReceivePositionalInputAt(screenSpacePos) || PlacementActive == PlacementState.Active; + + // ReceivePositionalInputAtSubTree generally always returns true when masking is disabled, but we don't want that, + // otherwise a slider path tooltip will be displayed anywhere in the editor (outside compose area). + protected override bool ReceivePositionalInputAtSubTree(Vector2 screenSpacePos) => ReceivePositionalInputAt(screenSpacePos); + private void beginNewSegment(PathControlPoint lastPoint) { segmentStart = lastPoint; diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs index c72f547565..34de81f1ba 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs @@ -178,6 +178,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders { base.OnDeselected(); + if (placementControlPoint != null) + endControlPointPlacement(); + updateVisualDefinition(); BodyPiece.RecyclePath(); } @@ -377,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) 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 2b88860cc8..6220fa66b1 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuGridToolboxGroup.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuGridToolboxGroup.cs @@ -38,6 +38,7 @@ namespace osu.Game.Rulesets.Osu.Edit { MinValue = 0f, MaxValue = OsuPlayfield.BASE_SIZE.X, + Precision = 0.01f, }; /// @@ -47,6 +48,7 @@ namespace osu.Game.Rulesets.Osu.Edit { MinValue = 0f, MaxValue = OsuPlayfield.BASE_SIZE.Y, + Precision = 0.01f, }; /// @@ -56,6 +58,7 @@ namespace osu.Game.Rulesets.Osu.Edit { MinValue = 4f, MaxValue = 128f, + Precision = 0.01f, }; /// @@ -65,6 +68,7 @@ namespace osu.Game.Rulesets.Osu.Edit { MinValue = -180f, MaxValue = 180f, + Precision = 0.01f, }; /// @@ -166,7 +170,7 @@ namespace osu.Game.Rulesets.Osu.Edit }, }; - Spacing.Value = editorBeatmap.BeatmapInfo.GridSize; + Spacing.Value = editorBeatmap.GridSize; } protected override void LoadComplete() @@ -200,7 +204,7 @@ namespace osu.Game.Rulesets.Osu.Edit spacingSlider.ContractedLabelText = $"S: {spacing.NewValue:#,0.##}"; spacingSlider.ExpandedLabelText = $"Spacing: {spacing.NewValue:#,0.##}"; SpacingVector.Value = new Vector2(spacing.NewValue); - editorBeatmap.BeatmapInfo.GridSize = (int)spacing.NewValue; + editorBeatmap.GridSize = (int)spacing.NewValue; }, true); GridLinesRotation.BindValueChanged(rotation => @@ -213,6 +217,8 @@ namespace osu.Game.Rulesets.Osu.Edit { GridLinesRotation.Disabled = v.NewValue == PositionSnapGridType.Circle; + gridTypeButtons.Items[(int)v.NewValue].Select(); + switch (v.NewValue) { case PositionSnapGridType.Square: @@ -241,17 +247,16 @@ namespace osu.Game.Rulesets.Osu.Edit return ((rotation + 360 + period * 0.5f) % period) - period * 0.5f; } - private void nextGridSize() - { - Spacing.Value = Spacing.Value * 2 >= max_automatic_spacing ? Spacing.Value / 8 : Spacing.Value * 2; - } - 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/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 7a01646b35..45e3f3ac49 100644 --- a/osu.Game.Rulesets.Osu/Edit/Setup/OsuDifficultySection.cs +++ b/osu.Game.Rulesets.Osu/Edit/Setup/OsuDifficultySection.cs @@ -119,7 +119,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Setup { 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) + Current = new BindableFloat(Beatmap.StackLeniency) { Default = 0.7f, MinValue = 0, @@ -148,7 +148,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Setup Beatmap.Difficulty.OverallDifficulty = overallDifficultySlider.Current.Value; Beatmap.Difficulty.SliderMultiplier = baseVelocitySlider.Current.Value; Beatmap.Difficulty.SliderTickRate = tickRateSlider.Current.Value; - Beatmap.BeatmapInfo.StackLeniency = stackLeniency.Current.Value; + Beatmap.StackLeniency = stackLeniency.Current.Value; Beatmap.UpdateAllHitObjects(); Beatmap.SaveState(); 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/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index 1077113302..6321824c0f 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -214,7 +214,8 @@ namespace osu.Game.Rulesets.Osu new OsuModFreezeFrame(), new OsuModBubbles(), new OsuModSynesthesia(), - new OsuModDepth() + new OsuModDepth(), + new OsuModBloom() }; case ModType.System: diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs index ef616ae964..0dc0f065d4 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs @@ -57,11 +57,22 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy [BackgroundDependencyLoader] private void load() { + const string base_lookup = @"hitcircle"; + var drawableOsuObject = (DrawableOsuHitObject?)drawableObject; + // As a precondition, ensure that any prefix lookups are run against the skin which is providing "hitcircle". + // This is to correctly handle a case such as: + // + // - Beatmap provides `hitcircle` + // - User skin provides `sliderstartcircle` + // + // In such a case, the `hitcircle` should be used for slider start circles rather than the user's skin override. + var provider = skin.FindProvider(s => s.GetTexture(base_lookup) != null) ?? skin; + // if a base texture for the specified prefix exists, continue using it for subsequent lookups. // otherwise fall back to the default prefix "hitcircle". - string circleName = (priorityLookupPrefix != null && skin.GetTexture(priorityLookupPrefix) != null) ? priorityLookupPrefix : @"hitcircle"; + string circleName = (priorityLookupPrefix != null && provider.GetTexture(priorityLookupPrefix) != null) ? priorityLookupPrefix : base_lookup; Vector2 maxSize = OsuHitObject.OBJECT_DIMENSIONS * 2; @@ -70,7 +81,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy // expected behaviour in this scenario is not showing the overlay, rather than using hitcircleoverlay.png. InternalChildren = new[] { - CircleSprite = new LegacyKiaiFlashingDrawable(() => new Sprite { Texture = skin.GetTexture(circleName)?.WithMaximumSize(maxSize) }) + CircleSprite = new LegacyKiaiFlashingDrawable(() => new Sprite { Texture = provider.GetTexture(circleName)?.WithMaximumSize(maxSize) }) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -79,7 +90,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Child = OverlaySprite = new LegacyKiaiFlashingDrawable(() => new Sprite { Texture = skin.GetTexture(@$"{circleName}overlay")?.WithMaximumSize(maxSize) }) + Child = OverlaySprite = new LegacyKiaiFlashingDrawable(() => new Sprite { Texture = provider.GetTexture(@$"{circleName}overlay")?.WithMaximumSize(maxSize) }) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs index d4a0f243e4..5d09267c21 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs @@ -63,18 +63,18 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy Origin = Anchor.Centre, Texture = source.GetTexture("spinner-top"), }, - fixedMiddle = new Sprite - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Texture = source.GetTexture("spinner-middle"), - }, spinningMiddle = new Sprite { Anchor = Anchor.Centre, Origin = Anchor.Centre, Texture = source.GetTexture("spinner-middle2"), }, + fixedMiddle = new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Texture = source.GetTexture("spinner-middle"), + }, } }); 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.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 451aed183d..c8f0448767 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs @@ -16,6 +16,12 @@ namespace osu.Game.Rulesets.Taiko.Difficulty [JsonProperty("stamina_difficulty")] public double StaminaDifficulty { get; set; } + /// + /// The ratio of stamina difficulty from mono-color (single colour) streams to total stamina difficulty. + /// + [JsonProperty("mono_stamina_factor")] + public double MonoStaminaFactor { get; set; } + /// /// The difficulty corresponding to the rhythm skill. /// @@ -60,6 +66,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty yield return (ATTRIB_ID_DIFFICULTY, StarRating); yield return (ATTRIB_ID_GREAT_HIT_WINDOW, GreatHitWindow); yield return (ATTRIB_ID_OK_HIT_WINDOW, OkHitWindow); + yield return (ATTRIB_ID_MONO_STAMINA_FACTOR, MonoStaminaFactor); } public override void FromDatabaseAttributes(IReadOnlyDictionary values, IBeatmapOnlineInfo onlineInfo) @@ -69,6 +76,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty StarRating = values[ATTRIB_ID_DIFFICULTY]; GreatHitWindow = values[ATTRIB_ID_GREAT_HIT_WINDOW]; OkHitWindow = values[ATTRIB_ID_OK_HIT_WINDOW]; + MonoStaminaFactor = values[ATTRIB_ID_MONO_STAMINA_FACTOR]; } } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs index 18223e74fa..7f2558c406 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs @@ -38,7 +38,8 @@ namespace osu.Game.Rulesets.Taiko.Difficulty { new Rhythm(mods), new Colour(mods), - new Stamina(mods) + new Stamina(mods, false), + new Stamina(mods, true) }; } @@ -79,14 +80,26 @@ namespace osu.Game.Rulesets.Taiko.Difficulty Colour colour = (Colour)skills.First(x => x is Colour); Rhythm rhythm = (Rhythm)skills.First(x => x is Rhythm); Stamina stamina = (Stamina)skills.First(x => x is Stamina); + Stamina singleColourStamina = (Stamina)skills.Last(x => x is Stamina); double colourRating = colour.DifficultyValue() * colour_skill_multiplier; double rhythmRating = rhythm.DifficultyValue() * rhythm_skill_multiplier; double staminaRating = stamina.DifficultyValue() * stamina_skill_multiplier; + double monoStaminaRating = singleColourStamina.DifficultyValue() * stamina_skill_multiplier; + double monoStaminaFactor = staminaRating == 0 ? 1 : Math.Pow(monoStaminaRating / staminaRating, 5); double combinedRating = combinedDifficultyValue(rhythm, colour, stamina); double starRating = rescale(combinedRating * 1.4); + // TODO: This is temporary measure as we don't detect abuse of multiple-input playstyles of converts within the current system. + if (beatmap.BeatmapInfo.Ruleset.OnlineID == 0) + { + starRating *= 0.925; + // For maps with low colour variance and high stamina requirement, multiple inputs are more likely to be abused. + if (colourRating < 2 && staminaRating > 8) + starRating *= 0.80; + } + HitWindows hitWindows = new TaikoHitWindows(); hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); @@ -95,6 +108,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty StarRating = starRating, Mods = mods, StaminaDifficulty = staminaRating, + MonoStaminaFactor = monoStaminaFactor, RhythmDifficulty = rhythmRating, ColourDifficulty = colourRating, PeakDifficulty = combinedRating, diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoLegacyScoreSimulator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoLegacyScoreSimulator.cs index 9839d94277..416a11c2a8 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoLegacyScoreSimulator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoLegacyScoreSimulator.cs @@ -144,6 +144,13 @@ namespace osu.Game.Rulesets.Taiko.Difficulty foreach (var nested in hitObject.NestedHitObjects) simulateHit(nested, ref attributes); return; + + case StrongNestedHitObject: + // we never need to deal with these directly. + // the only thing strong hits do in terms of scoring is double their object's score increase, + // which is already handled at the parent object level via the `strongable.IsStrong` check lower down in this method. + // not handling these here can lead to them falsely being counted as combo-increasing when handling strong drum rolls! + return; } if (hitObject is DrumRollTick tick) diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs index e42b015176..c672b7a1d9 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs @@ -42,18 +42,18 @@ namespace osu.Game.Rulesets.Taiko.Difficulty if (totalSuccessfulHits > 0) effectiveMissCount = Math.Max(1.0, 1000.0 / totalSuccessfulHits) * countMiss; - // TODO: The detection of rulesets is temporary until the leftover old skills have been reworked. + // Converts are detected and omitted from mod-specific bonuses due to the scope of current difficulty calculation. bool isConvert = score.BeatmapInfo!.Ruleset.OnlineID != 1; double multiplier = 1.13; - if (score.Mods.Any(m => m is ModHidden)) + if (score.Mods.Any(m => m is ModHidden) && !isConvert) multiplier *= 1.075; if (score.Mods.Any(m => m is ModEasy)) - multiplier *= 0.975; + multiplier *= 0.950; - double difficultyValue = computeDifficultyValue(score, taikoAttributes, isConvert); + double difficultyValue = computeDifficultyValue(score, taikoAttributes); double accuracyValue = computeAccuracyValue(score, taikoAttributes, isConvert); double totalValue = Math.Pow( @@ -71,7 +71,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty }; } - private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool isConvert) + private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes attributes) { double difficultyValue = Math.Pow(5 * Math.Max(1.0, attributes.StarRating / 0.115) - 4.0, 2.25) / 1150.0; @@ -81,21 +81,25 @@ namespace osu.Game.Rulesets.Taiko.Difficulty difficultyValue *= Math.Pow(0.986, effectiveMissCount); if (score.Mods.Any(m => m is ModEasy)) - difficultyValue *= 0.985; + difficultyValue *= 0.90; - if (score.Mods.Any(m => m is ModHidden) && !isConvert) + if (score.Mods.Any(m => m is ModHidden)) difficultyValue *= 1.025; if (score.Mods.Any(m => m is ModHardRock)) difficultyValue *= 1.10; if (score.Mods.Any(m => m is ModFlashlight)) - difficultyValue *= 1.050 * lengthBonus; + difficultyValue *= Math.Max(1, 1.050 - Math.Min(attributes.MonoStaminaFactor / 50, 1) * lengthBonus); if (estimatedUnstableRate == null) return 0; - return difficultyValue * Math.Pow(SpecialFunctions.Erf(400 / (Math.Sqrt(2) * estimatedUnstableRate.Value)), 2.0); + // Scale accuracy more harshly on nearly-completely mono (single coloured) speed maps. + double accScalingExponent = 2 + attributes.MonoStaminaFactor; + double accScalingShift = 300 - 100 * attributes.MonoStaminaFactor; + + return difficultyValue * Math.Pow(SpecialFunctions.Erf(accScalingShift / (Math.Sqrt(2) * estimatedUnstableRate.Value)), accScalingExponent); } private double computeAccuracyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool isConvert) diff --git a/osu.Game.Rulesets.Taiko/Edit/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/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 b706e96bdb..be2a5ac144 100644 --- a/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs +++ b/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs @@ -54,17 +54,17 @@ namespace osu.Game.Rulesets.Taiko.Edit public void SetStrongState(bool state) { - if (SelectedItems.OfType().All(h => h.IsStrong == state)) + if (SelectedItems.OfType().All(h => h.IsStrong == state)) return; EditorBeatmap.PerformOnSelection(h => { - if (!(h is Hit taikoHit)) return; + if (h is not TaikoStrongableHitObject strongable) return; - if (taikoHit.IsStrong != state) + if (strongable.IsStrong != state) { - taikoHit.IsStrong = state; - EditorBeatmap.Update(taikoHit); + strongable.IsStrong = state; + EditorBeatmap.Update(strongable); } }); } diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs index 1af4719b02..547d0afe4a 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs @@ -31,6 +31,9 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables public override Quad ScreenSpaceDrawQuad => MainPiece.Drawable.ScreenSpaceDrawQuad; + // done strictly for editor purposes. + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => MainPiece.Drawable.ReceivePositionalInputAt(screenSpacePos); + /// /// Rolling number of tick hits. This increases for hits and decreases for misses. /// @@ -78,6 +81,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables { base.RecreatePieces(); updateColour(); + Height = HitObject.IsStrong ? TaikoStrongableHitObject.DEFAULT_STRONG_SIZE : TaikoHitObject.DEFAULT_SIZE; } protected override void OnFree() diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs index 0333fd71a9..64d2020edc 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs @@ -12,6 +12,7 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Taiko.Skinning.Default; using osu.Game.Skinning; +using osuTK; namespace osu.Game.Rulesets.Taiko.Objects.Drawables { @@ -44,6 +45,12 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables IsFirstTick.Value = HitObject.FirstTick; } + protected override void RecreatePieces() + { + base.RecreatePieces(); + Size = new Vector2(HitObject.IsStrong ? TaikoStrongableHitObject.DEFAULT_STRONG_SIZE : TaikoHitObject.DEFAULT_SIZE); + } + protected override void CheckForResult(bool userTriggered, double timeOffset) { if (!userTriggered) diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs index a5e63c373f..28831a6d2c 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs @@ -14,6 +14,7 @@ using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Skinning.Default; using osu.Game.Skinning; +using osuTK; namespace osu.Game.Rulesets.Taiko.Objects.Drawables { @@ -63,6 +64,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables { updateActionsFromType(); base.RecreatePieces(); + Size = new Vector2(HitObject.IsStrong ? TaikoStrongableHitObject.DEFAULT_STRONG_SIZE : TaikoHitObject.DEFAULT_SIZE); } protected override void OnFree() diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs index f2fcd185dd..28617b35f6 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs @@ -19,6 +19,7 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Taiko.Skinning.Default; using osu.Game.Screens.Play; using osu.Game.Skinning; +using osuTK; namespace osu.Game.Rulesets.Taiko.Objects.Drawables { @@ -34,6 +35,8 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables /// private const double ring_appear_offset = 100; + private Vector2 baseSize; + private readonly Container ticks; private readonly Container bodyContainer; private readonly CircularContainer targetRing; @@ -141,6 +144,12 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables Origin = Anchor.Centre, }); + protected override void RecreatePieces() + { + base.RecreatePieces(); + Size = baseSize = new Vector2(TaikoHitObject.DEFAULT_SIZE); + } + protected override void OnFree() { base.OnFree(); @@ -269,7 +278,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables { base.Update(); - Size = BaseSize * Parent!.RelativeChildSize; + Size = baseSize * Parent!.RelativeChildSize; // Make the swell stop at the hit target X = Math.Max(0, X); diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs index 3f4694d71d..0cf9651965 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs @@ -130,7 +130,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables public new TObject HitObject => (TObject)base.HitObject; - protected Vector2 BaseSize; protected SkinnableDrawable MainPiece; protected DrawableTaikoHitObject([CanBeNull] TObject hitObject) @@ -152,8 +151,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables protected virtual void RecreatePieces() { - Size = BaseSize = new Vector2(TaikoHitObject.DEFAULT_SIZE); - if (MainPiece != null) Content.Remove(MainPiece, true); diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoStrongableHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoStrongableHitObject.cs index 4d7cdf3243..7c3ff4f27e 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoStrongableHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoStrongableHitObject.cs @@ -8,7 +8,6 @@ using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; -using osuTK; namespace osu.Game.Rulesets.Taiko.Objects.Drawables { @@ -44,13 +43,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables isStrong.UnbindEvents(); } - protected override void RecreatePieces() - { - base.RecreatePieces(); - if (HitObject.IsStrong) - Size = BaseSize = new Vector2(TaikoStrongableHitObject.DEFAULT_STRONG_SIZE); - } - protected override void AddNestedHitObject(DrawableHitObject hitObject) { base.AddNestedHitObject(hitObject); diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs index 5543a31ec9..78be0ef643 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -9,6 +10,7 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Game.Graphics; using osu.Game.Skinning; +using osuTK; using osuTK.Graphics; namespace osu.Game.Rulesets.Taiko.Skinning.Legacy @@ -19,13 +21,22 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy { get { - var headDrawQuad = headCircle.ScreenSpaceDrawQuad; - var tailDrawQuad = tailCircle.ScreenSpaceDrawQuad; + // the reason why this calculation is so involved is that the head & tail sprites have different sizes/radii. + // therefore naively taking the SSDQs of them and making a quad out of them results in a trapezoid shape and not a box. + var headCentre = headCircle.ScreenSpaceDrawQuad.Centre; + var tailCentre = (tailCircle.ScreenSpaceDrawQuad.TopLeft + tailCircle.ScreenSpaceDrawQuad.BottomLeft) / 2; - return new Quad(headDrawQuad.TopLeft, tailDrawQuad.TopRight, headDrawQuad.BottomLeft, tailDrawQuad.BottomRight); + float headRadius = headCircle.ScreenSpaceDrawQuad.Height / 2; + float tailRadius = tailCircle.ScreenSpaceDrawQuad.Height / 2; + float radius = Math.Max(headRadius, tailRadius); + + var rectangle = new RectangleF(headCentre.X, headCentre.Y, tailCentre.X - headCentre.X, 0).Inflate(radius); + return new Quad(rectangle.TopLeft, rectangle.TopRight, rectangle.BottomLeft, rectangle.BottomRight); } } + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => ScreenSpaceDrawQuad.Contains(screenSpacePos); + private LegacyCirclePiece headCircle = null!; private Sprite body = null!; diff --git a/osu.Game.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..adb1755c11 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs @@ -80,16 +80,16 @@ namespace osu.Game.Tests.Beatmaps.Formats var metadata = beatmap.Metadata; Assert.AreEqual("03. Renatus - Soleily 192kbps.mp3", metadata.AudioFile); - Assert.AreEqual(0, beatmapInfo.AudioLeadIn); + Assert.AreEqual(0, beatmap.AudioLeadIn); Assert.AreEqual(164471, metadata.PreviewTime); - Assert.AreEqual(0.7f, beatmapInfo.StackLeniency); + Assert.AreEqual(0.7f, beatmap.StackLeniency); Assert.IsTrue(beatmapInfo.Ruleset.OnlineID == 0); - Assert.IsFalse(beatmapInfo.LetterboxInBreaks); - Assert.IsFalse(beatmapInfo.SpecialStyle); - Assert.IsFalse(beatmapInfo.WidescreenStoryboard); - Assert.IsFalse(beatmapInfo.SamplesMatchPlaybackRate); - Assert.AreEqual(CountdownType.None, beatmapInfo.Countdown); - Assert.AreEqual(0, beatmapInfo.CountdownOffset); + Assert.IsFalse(beatmap.LetterboxInBreaks); + Assert.IsFalse(beatmap.SpecialStyle); + Assert.IsFalse(beatmap.WidescreenStoryboard); + Assert.IsFalse(beatmap.SamplesMatchPlaybackRate); + Assert.AreEqual(CountdownType.None, beatmap.Countdown); + Assert.AreEqual(0, beatmap.CountdownOffset); } } @@ -101,7 +101,7 @@ namespace osu.Game.Tests.Beatmaps.Formats using (var resStream = TestResources.OpenResource("Soleily - Renatus (Gamu) [Insane].osu")) using (var stream = new LineBufferedReader(resStream)) { - var beatmapInfo = decoder.Decode(stream).BeatmapInfo; + var beatmap = decoder.Decode(stream); int[] expectedBookmarks = { @@ -109,13 +109,13 @@ namespace osu.Game.Tests.Beatmaps.Formats 95901, 106450, 116999, 119637, 130186, 140735, 151285, 161834, 164471, 175020, 185570, 196119, 206669, 209306 }; - Assert.AreEqual(expectedBookmarks.Length, beatmapInfo.Bookmarks.Length); + Assert.AreEqual(expectedBookmarks.Length, beatmap.Bookmarks.Length); for (int i = 0; i < expectedBookmarks.Length; i++) - Assert.AreEqual(expectedBookmarks[i], beatmapInfo.Bookmarks[i]); - Assert.AreEqual(1.8, beatmapInfo.DistanceSpacing); - Assert.AreEqual(4, beatmapInfo.BeatDivisor); - Assert.AreEqual(4, beatmapInfo.GridSize); - Assert.AreEqual(2, beatmapInfo.TimelineZoom); + Assert.AreEqual(expectedBookmarks[i], beatmap.Bookmarks[i]); + Assert.AreEqual(1.8, beatmap.DistanceSpacing); + Assert.AreEqual(4, beatmap.BeatmapInfo.BeatDivisor); + Assert.AreEqual(4, beatmap.GridSize); + Assert.AreEqual(2, beatmap.TimelineZoom); } } @@ -993,15 +993,15 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.Multiple(() => { - Assert.That(decoded.BeatmapInfo.AudioLeadIn, Is.EqualTo(0)); - Assert.That(decoded.BeatmapInfo.StackLeniency, Is.EqualTo(0.7f)); - Assert.That(decoded.BeatmapInfo.SpecialStyle, Is.False); - Assert.That(decoded.BeatmapInfo.LetterboxInBreaks, Is.False); - 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.CountdownOffset, Is.EqualTo(0)); + Assert.That(decoded.AudioLeadIn, Is.EqualTo(0)); + Assert.That(decoded.StackLeniency, Is.EqualTo(0.7f)); + Assert.That(decoded.SpecialStyle, Is.False); + Assert.That(decoded.LetterboxInBreaks, Is.False); + Assert.That(decoded.WidescreenStoryboard, Is.False); + Assert.That(decoded.EpilepsyWarning, Is.False); + Assert.That(decoded.SamplesMatchPlaybackRate, Is.False); + Assert.That(decoded.Countdown, Is.EqualTo(CountdownType.None)); + Assert.That(decoded.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/Formats/OsuJsonDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/OsuJsonDecoderTest.cs index 3764467047..c20cf7befd 100644 --- a/osu.Game.Tests/Beatmaps/Formats/OsuJsonDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/OsuJsonDecoderTest.cs @@ -51,14 +51,14 @@ namespace osu.Game.Tests.Beatmaps.Formats { var beatmap = decodeAsJson(normal); var beatmapInfo = beatmap.BeatmapInfo; - Assert.AreEqual(0, beatmapInfo.AudioLeadIn); - Assert.AreEqual(0.7f, beatmapInfo.StackLeniency); - Assert.AreEqual(false, beatmapInfo.SpecialStyle); + Assert.AreEqual(0, beatmap.AudioLeadIn); + Assert.AreEqual(0.7f, beatmap.StackLeniency); + Assert.AreEqual(false, beatmap.SpecialStyle); Assert.IsTrue(beatmapInfo.Ruleset.OnlineID == 0); - Assert.AreEqual(false, beatmapInfo.LetterboxInBreaks); - Assert.AreEqual(false, beatmapInfo.WidescreenStoryboard); - Assert.AreEqual(CountdownType.None, beatmapInfo.Countdown); - Assert.AreEqual(0, beatmapInfo.CountdownOffset); + Assert.AreEqual(false, beatmap.LetterboxInBreaks); + Assert.AreEqual(false, beatmap.WidescreenStoryboard); + Assert.AreEqual(CountdownType.None, beatmap.Countdown); + Assert.AreEqual(0, beatmap.CountdownOffset); } [Test] @@ -73,13 +73,13 @@ namespace osu.Game.Tests.Beatmaps.Formats 95901, 106450, 116999, 119637, 130186, 140735, 151285, 161834, 164471, 175020, 185570, 196119, 206669, 209306 }; - Assert.AreEqual(expectedBookmarks.Length, beatmapInfo.Bookmarks.Length); + Assert.AreEqual(expectedBookmarks.Length, beatmap.Bookmarks.Length); for (int i = 0; i < expectedBookmarks.Length; i++) - Assert.AreEqual(expectedBookmarks[i], beatmapInfo.Bookmarks[i]); - Assert.AreEqual(1.8, beatmapInfo.DistanceSpacing); + Assert.AreEqual(expectedBookmarks[i], beatmap.Bookmarks[i]); + Assert.AreEqual(1.8, beatmap.DistanceSpacing); Assert.AreEqual(4, beatmapInfo.BeatDivisor); - Assert.AreEqual(4, beatmapInfo.GridSize); - Assert.AreEqual(2, beatmapInfo.TimelineZoom); + Assert.AreEqual(4, beatmap.GridSize); + Assert.AreEqual(2, beatmap.TimelineZoom); } [Test] 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/CodeAnalysis.tests.globalconfig b/osu.Game.Tests/CodeAnalysis.tests.globalconfig new file mode 100644 index 0000000000..3f039736ec --- /dev/null +++ b/osu.Game.Tests/CodeAnalysis.tests.globalconfig @@ -0,0 +1,7 @@ +# Higher global_level has higher priority, the default global_level +# is 100 for root .globalconfig and 0 for others +# https://learn.microsoft.com/dotnet/fundamentals/code-analysis/configuration-files#precedence +is_global = true +global_level = 101 + +dotnet_diagnostic.CA2007.severity = none 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/Database/RealmSubscriptionRegistrationTests.cs b/osu.Game.Tests/Database/RealmSubscriptionRegistrationTests.cs index e5be4d665b..df83bc9b7b 100644 --- a/osu.Game.Tests/Database/RealmSubscriptionRegistrationTests.cs +++ b/osu.Game.Tests/Database/RealmSubscriptionRegistrationTests.cs @@ -41,7 +41,7 @@ namespace osu.Game.Tests.Database Assert.That(lastChanges?.ModifiedIndices, Is.Empty); Assert.That(lastChanges?.NewModifiedIndices, Is.Empty); - realm.Write(r => r.All().First().Beatmaps.First().CountdownOffset = 5); + realm.Write(r => r.All().First().Beatmaps.First().EditorTimestamp = 5); realm.Run(r => r.Refresh()); Assert.That(collectionChanges, Is.EqualTo(1)); 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/ImportTest.cs b/osu.Game.Tests/ImportTest.cs index 27b8d3f21e..b1e2730703 100644 --- a/osu.Game.Tests/ImportTest.cs +++ b/osu.Game.Tests/ImportTest.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using System.Threading; using System.Threading.Tasks; using NUnit.Framework; @@ -64,6 +65,10 @@ namespace osu.Game.Tests // Beatmap must be imported before the collection manager is loaded. if (withBeatmap) BeatmapManager.Import(TestResources.GetTestBeatmapForImport()).WaitSafely(); + + // the logic for setting the initial ruleset exists in OsuGame rather than OsuGameBase. + // the ruleset bindable is not meant to be nullable, so assign any ruleset in here. + Ruleset.Value = RulesetStore.AvailableRulesets.First(); } } } 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/NonVisual/Ranking/UnstableRateTest.cs b/osu.Game.Tests/NonVisual/Ranking/UnstableRateTest.cs index 5a416d05d7..03dc91b5d4 100644 --- a/osu.Game.Tests/NonVisual/Ranking/UnstableRateTest.cs +++ b/osu.Game.Tests/NonVisual/Ranking/UnstableRateTest.cs @@ -20,12 +20,53 @@ namespace osu.Game.Tests.NonVisual.Ranking public void TestDistributedHits() { var events = Enumerable.Range(-5, 11) - .Select(t => new HitEvent(t - 5, 1.0, HitResult.Great, new HitObject(), null, null)); + .Select(t => new HitEvent(t - 5, 1.0, HitResult.Great, new HitObject(), null, null)) + .ToList(); var unstableRate = new UnstableRate(events); Assert.IsNotNull(unstableRate.Value); - Assert.IsTrue(Precision.AlmostEquals(unstableRate.Value.Value, 10 * Math.Sqrt(10))); + Assert.AreEqual(unstableRate.Value.Value, 10 * Math.Sqrt(10), Precision.DOUBLE_EPSILON); + } + + [Test] + public void TestDistributedHitsIncrementalRewind() + { + var events = Enumerable.Range(-5, 11) + .Select(t => new HitEvent(t - 5, 1.0, HitResult.Great, new HitObject(), null, null)) + .ToList(); + + HitEventExtensions.UnstableRateCalculationResult result = null; + + for (int i = 0; i < events.Count; i++) + { + result = events.GetRange(0, i + 1) + .CalculateUnstableRate(result); + } + + result = events.GetRange(0, 2).CalculateUnstableRate(result); + + Assert.IsNotNull(result!.Result); + Assert.AreEqual(5, result.Result, Precision.DOUBLE_EPSILON); + } + + [Test] + public void TestDistributedHitsIncremental() + { + var events = Enumerable.Range(-5, 11) + .Select(t => new HitEvent(t - 5, 1.0, HitResult.Great, new HitObject(), null, null)) + .ToList(); + + HitEventExtensions.UnstableRateCalculationResult result = null; + + for (int i = 0; i < events.Count; i++) + { + result = events.GetRange(0, i + 1) + .CalculateUnstableRate(result); + } + + Assert.IsNotNull(result!.Result); + Assert.AreEqual(10 * Math.Sqrt(10), result.Result, Precision.DOUBLE_EPSILON); } [Test] 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/TestSceneDesignSection.cs b/osu.Game.Tests/Visual/Editing/TestSceneDesignSection.cs index 4dd27a7b6e..2858650e3c 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneDesignSection.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneDesignSection.cs @@ -56,7 +56,7 @@ namespace osu.Game.Tests.Visual.Editing { AddStep("turn countdown off", () => designSection.EnableCountdown.Current.Value = false); - AddAssert("beatmap has correct type", () => editorBeatmap.BeatmapInfo.Countdown == CountdownType.None); + AddAssert("beatmap has correct type", () => editorBeatmap.Countdown == CountdownType.None); AddUntilStep("other controls hidden", () => !designSection.CountdownSettings.IsPresent); } @@ -65,12 +65,12 @@ namespace osu.Game.Tests.Visual.Editing { AddStep("turn countdown on", () => designSection.EnableCountdown.Current.Value = true); - AddAssert("beatmap has correct type", () => editorBeatmap.BeatmapInfo.Countdown == CountdownType.Normal); + AddAssert("beatmap has correct type", () => editorBeatmap.Countdown == CountdownType.Normal); AddUntilStep("other controls shown", () => designSection.CountdownSettings.IsPresent); AddStep("change countdown speed", () => designSection.CountdownSpeed.Current.Value = CountdownType.DoubleSpeed); - AddAssert("beatmap has correct type", () => editorBeatmap.BeatmapInfo.Countdown == CountdownType.DoubleSpeed); + AddAssert("beatmap has correct type", () => editorBeatmap.Countdown == CountdownType.DoubleSpeed); AddUntilStep("other controls still shown", () => designSection.CountdownSettings.IsPresent); } @@ -79,7 +79,7 @@ namespace osu.Game.Tests.Visual.Editing { AddStep("turn countdown on", () => designSection.EnableCountdown.Current.Value = true); - AddAssert("beatmap has correct type", () => editorBeatmap.BeatmapInfo.Countdown == CountdownType.Normal); + AddAssert("beatmap has correct type", () => editorBeatmap.Countdown == CountdownType.Normal); checkOffsetAfter("1", 1); checkOffsetAfter(string.Empty, 0); @@ -99,7 +99,7 @@ namespace osu.Game.Tests.Visual.Editing AddStep("commit text", () => InputManager.Key(Key.Enter)); AddAssert($"displayed value is {expectedFinalValue}", () => designSection.CountdownOffset.Current.Value == expectedFinalValue.ToString(CultureInfo.InvariantCulture)); - AddAssert($"beatmap value is {expectedFinalValue}", () => editorBeatmap.BeatmapInfo.CountdownOffset == expectedFinalValue); + AddAssert($"beatmap value is {expectedFinalValue}", () => editorBeatmap.CountdownOffset == expectedFinalValue); } private partial class TestDesignSection : DesignSection diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs index db87987815..ddf6502899 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs @@ -19,6 +19,7 @@ using osu.Game.Overlays.Dialog; using osu.Game.Rulesets; using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.UI; @@ -154,6 +155,7 @@ namespace osu.Game.Tests.Visual.Editing AddStep("set unique difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = firstDifficultyName); AddStep("add timing point", () => EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 1000 })); + AddStep("add effect point", () => EditorBeatmap.ControlPointInfo.Add(500, new EffectControlPoint { KiaiMode = true })); AddStep("add hitobjects", () => EditorBeatmap.AddRange(new[] { new HitCircle @@ -200,6 +202,11 @@ namespace osu.Game.Tests.Visual.Editing var timingPoint = EditorBeatmap.ControlPointInfo.TimingPoints.Single(); return timingPoint.Time == 0 && timingPoint.BeatLength == 1000; }); + AddAssert("created difficulty has effect points", () => + { + var effectPoint = EditorBeatmap.ControlPointInfo.EffectPoints.Single(); + return effectPoint.Time == 500 && effectPoint.KiaiMode && effectPoint.ScrollSpeedBindable.IsDefault; + }); AddAssert("created difficulty has no objects", () => EditorBeatmap.HitObjects.Count == 0); AddAssert("status is modified", () => EditorBeatmap.BeatmapInfo.Status == BeatmapOnlineStatus.LocallyModified); @@ -219,6 +226,104 @@ namespace osu.Game.Tests.Visual.Editing }); } + [Test] + public void TestCreateNewDifficultyWithScrollSpeed_SameRuleset() + { + string firstDifficultyName = Guid.NewGuid().ToString(); + + AddStep("save beatmap", () => Editor.Save()); + AddStep("create new difficulty", () => Editor.CreateNewDifficulty(new ManiaRuleset().RulesetInfo)); + + AddStep("set unique difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = firstDifficultyName); + AddStep("add timing point", () => EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 1000 })); + AddStep("add effect points", () => + { + EditorBeatmap.ControlPointInfo.Add(250, new EffectControlPoint { KiaiMode = false, ScrollSpeed = 0.05 }); + EditorBeatmap.ControlPointInfo.Add(500, new EffectControlPoint { KiaiMode = true, ScrollSpeed = 0.1 }); + EditorBeatmap.ControlPointInfo.Add(750, new EffectControlPoint { KiaiMode = true, ScrollSpeed = 0.15 }); + EditorBeatmap.ControlPointInfo.Add(1000, new EffectControlPoint { KiaiMode = false, ScrollSpeed = 0.2 }); + EditorBeatmap.ControlPointInfo.Add(1500, new EffectControlPoint { KiaiMode = false, ScrollSpeed = 0.3 }); + }); + + AddStep("save beatmap", () => Editor.Save()); + + AddStep("create new difficulty", () => Editor.CreateNewDifficulty(new ManiaRuleset().RulesetInfo)); + + AddUntilStep("wait for dialog", () => DialogOverlay.CurrentDialog is CreateNewDifficultyDialog); + AddStep("confirm creation with no objects", () => DialogOverlay.CurrentDialog!.PerformOkAction()); + + AddUntilStep("wait for created", () => + { + string? difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName; + return difficultyName != null && difficultyName != firstDifficultyName; + }); + + AddAssert("created difficulty has timing point", () => + { + var timingPoint = EditorBeatmap.ControlPointInfo.TimingPoints.Single(); + return timingPoint.Time == 0 && timingPoint.BeatLength == 1000; + }); + + AddAssert("created difficulty has effect points", () => + { + return EditorBeatmap.ControlPointInfo.EffectPoints.SequenceEqual(new[] + { + new EffectControlPoint { Time = 250, KiaiMode = false, ScrollSpeed = 0.05 }, + new EffectControlPoint { Time = 500, KiaiMode = true, ScrollSpeed = 0.1 }, + new EffectControlPoint { Time = 750, KiaiMode = true, ScrollSpeed = 0.15 }, + new EffectControlPoint { Time = 1000, KiaiMode = false, ScrollSpeed = 0.2 }, + new EffectControlPoint { Time = 1500, KiaiMode = false, ScrollSpeed = 0.3 }, + }); + }); + } + + [Test] + public void TestCreateNewDifficultyWithScrollSpeed_DifferentRuleset() + { + string firstDifficultyName = Guid.NewGuid().ToString(); + + AddStep("save beatmap", () => Editor.Save()); + AddStep("create new difficulty", () => Editor.CreateNewDifficulty(new ManiaRuleset().RulesetInfo)); + + AddStep("set unique difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = firstDifficultyName); + AddStep("add timing point", () => EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 1000 })); + AddStep("add effect points", () => + { + EditorBeatmap.ControlPointInfo.Add(250, new EffectControlPoint { KiaiMode = false, ScrollSpeed = 0.05 }); + EditorBeatmap.ControlPointInfo.Add(500, new EffectControlPoint { KiaiMode = true, ScrollSpeed = 0.1 }); + EditorBeatmap.ControlPointInfo.Add(750, new EffectControlPoint { KiaiMode = true, ScrollSpeed = 0.15 }); + EditorBeatmap.ControlPointInfo.Add(1000, new EffectControlPoint { KiaiMode = false, ScrollSpeed = 0.2 }); + EditorBeatmap.ControlPointInfo.Add(1500, new EffectControlPoint { KiaiMode = false, ScrollSpeed = 0.3 }); + }); + + AddStep("save beatmap", () => Editor.Save()); + + AddStep("create new difficulty", () => Editor.CreateNewDifficulty(new TaikoRuleset().RulesetInfo)); + + AddUntilStep("wait for created", () => + { + string? difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName; + return difficultyName != null && difficultyName != firstDifficultyName; + }); + + AddAssert("created difficulty has timing point", () => + { + var timingPoint = EditorBeatmap.ControlPointInfo.TimingPoints.Single(); + return timingPoint.Time == 0 && timingPoint.BeatLength == 1000; + }); + + AddAssert("created difficulty has effect points", () => + { + // since this difficulty is on another ruleset, scroll speed specifications are completely reset, + // therefore discarding some effect points in the process due to being redundant. + return EditorBeatmap.ControlPointInfo.EffectPoints.SequenceEqual(new[] + { + new EffectControlPoint { Time = 500, KiaiMode = true }, + new EffectControlPoint { Time = 1000, KiaiMode = false }, + }); + }); + } + [Test] public void TestCopyDifficulty() { diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs index b487fa3cec..d1782da25f 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs @@ -70,7 +70,7 @@ namespace osu.Game.Tests.Visual.Editing AddStep("Set beat divisor", () => Editor.Dependencies.Get().Value = 16); AddStep("Set timeline zoom", () => { - originalTimelineZoom = EditorBeatmap.BeatmapInfo.TimelineZoom; + originalTimelineZoom = EditorBeatmap.TimelineZoom; var timeline = Editor.ChildrenOfType().Single(); InputManager.MoveMouseTo(timeline); @@ -81,19 +81,19 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("Ensure timeline zoom changed", () => { - changedTimelineZoom = EditorBeatmap.BeatmapInfo.TimelineZoom; + changedTimelineZoom = EditorBeatmap.TimelineZoom; return !Precision.AlmostEquals(changedTimelineZoom, originalTimelineZoom); }); SaveEditor(); AddAssert("Beatmap has correct beat divisor", () => EditorBeatmap.BeatmapInfo.BeatDivisor == 16); - AddAssert("Beatmap has correct timeline zoom", () => EditorBeatmap.BeatmapInfo.TimelineZoom == changedTimelineZoom); + AddAssert("Beatmap has correct timeline zoom", () => EditorBeatmap.TimelineZoom == changedTimelineZoom); ReloadEditorToSameBeatmap(); AddAssert("Beatmap still has correct beat divisor", () => EditorBeatmap.BeatmapInfo.BeatDivisor == 16); - AddAssert("Beatmap still has correct timeline zoom", () => EditorBeatmap.BeatmapInfo.TimelineZoom == changedTimelineZoom); + AddAssert("Beatmap still has correct timeline zoom", () => EditorBeatmap.TimelineZoom == changedTimelineZoom); } [Test] diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSummaryTimeline.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSummaryTimeline.cs index 677d3135ba..e584f1b9d7 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSummaryTimeline.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSummaryTimeline.cs @@ -26,7 +26,7 @@ namespace osu.Game.Tests.Visual.Editing beatmap.ControlPointInfo.Add(50000, new DifficultyControlPoint { SliderVelocity = 2 }); beatmap.ControlPointInfo.Add(80000, new EffectControlPoint { KiaiMode = true }); beatmap.ControlPointInfo.Add(110000, new EffectControlPoint { KiaiMode = false }); - beatmap.BeatmapInfo.Bookmarks = new[] { 75000, 125000 }; + beatmap.Bookmarks = new[] { 75000, 125000 }; beatmap.Breaks.Add(new ManualBreakPeriod(90000, 120000)); editorBeatmap = new EditorBeatmap(beatmap); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs index f392841ac7..440e002021 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() { @@ -165,7 +205,7 @@ namespace osu.Game.Tests.Visual.Editing { double originalSpacing = 0; - AddStep("retrieve original spacing", () => originalSpacing = editorBeatmap.BeatmapInfo.DistanceSpacing); + AddStep("retrieve original spacing", () => originalSpacing = editorBeatmap.DistanceSpacing); AddStep("hold ctrl", () => InputManager.PressKey(Key.LControl)); AddStep("hold alt", () => InputManager.PressKey(Key.LAlt)); @@ -175,7 +215,7 @@ namespace osu.Game.Tests.Visual.Editing AddStep("release alt", () => InputManager.ReleaseKey(Key.LAlt)); AddStep("release ctrl", () => InputManager.ReleaseKey(Key.LControl)); - AddAssert("distance spacing increased by 0.5", () => editorBeatmap.BeatmapInfo.DistanceSpacing == originalSpacing + 0.5); + AddAssert("distance spacing increased by 0.5", () => editorBeatmap.DistanceSpacing == originalSpacing + 0.5); } public partial class EditorBeatmapContainer : PopoverContainer diff --git a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSampleAdjustments.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSampleAdjustments.cs index 3c5277a4d9..765fe1ecf6 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,32 @@ 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 +676,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 +742,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); } @@ -654,15 +784,39 @@ namespace osu.Game.Tests.Visual.Editing setAdditionBankViaPopover(HitSampleInfo.BANK_SOFT); dismissPopover(); - hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_FINISH); - hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_NORMAL); - hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_SOFT); + assertNoChanges(); - AddStep("select first object", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects[0])); + AddStep("select first object", () => + { + EditorBeatmap.SelectedHitObjects.Clear(); + EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects[0]); + }); + assertNoChanges(); - hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_FINISH); - hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_NORMAL); - hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_SOFT); + AddStep("select second object", () => + { + EditorBeatmap.SelectedHitObjects.Clear(); + EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects[1]); + }); + assertNoChanges(); + + AddStep("select first object", () => + { + EditorBeatmap.SelectedHitObjects.Clear(); + EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects[0]); + }); + assertNoChanges(); + + void assertNoChanges() + { + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_FINISH); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_NORMAL); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_SOFT); + + hitObjectHasSamples(1, HitSampleInfo.HIT_NORMAL); + hitObjectHasSampleNormalBank(1, HitSampleInfo.BANK_SOFT); + hitObjectHasSampleAdditionBank(1, HitSampleInfo.BANK_SOFT); + } } private void clickSamplePiece(int objectIndex) => AddStep($"click {objectIndex.ToOrdinalWords()} sample piece", () => @@ -756,11 +910,12 @@ namespace osu.Game.Tests.Visual.Editing return h.Samples.All(o => o.Volume == volume); }); - private void hitObjectNodeHasSampleVolume(int objectIndex, int nodeIndex, int volume) => AddAssert($"{objectIndex.ToOrdinalWords()} object {nodeIndex.ToOrdinalWords()} node has volume {volume}", () => - { - var h = EditorBeatmap.HitObjects.ElementAt(objectIndex) as IHasRepeats; - return h is not null && h.NodeSamples[nodeIndex].All(o => o.Volume == volume); - }); + private void hitObjectNodeHasSampleVolume(int objectIndex, int nodeIndex, int volume) => AddAssert( + $"{objectIndex.ToOrdinalWords()} object {nodeIndex.ToOrdinalWords()} node has volume {volume}", () => + { + var h = EditorBeatmap.HitObjects.ElementAt(objectIndex) as IHasRepeats; + return h is not null && h.NodeSamples[nodeIndex].All(o => o.Volume == volume); + }); private void setBankViaPopover(string bank) => AddStep($"set bank {bank} via popover", () => { @@ -817,29 +972,33 @@ namespace osu.Game.Tests.Visual.Editing return h.Samples.Where(o => o.Name != HitSampleInfo.HIT_NORMAL).All(o => o.Bank == bank); }); - private void hitObjectNodeHasSamples(int objectIndex, int nodeIndex, params string[] samples) => AddAssert($"{objectIndex.ToOrdinalWords()} object {nodeIndex.ToOrdinalWords()} node has samples {string.Join(',', samples)}", () => - { - var h = EditorBeatmap.HitObjects.ElementAt(objectIndex) as IHasRepeats; - return h is not null && h.NodeSamples[nodeIndex].Select(s => s.Name).SequenceEqual(samples); - }); + private void hitObjectNodeHasSamples(int objectIndex, int nodeIndex, params string[] samples) => AddAssert( + $"{objectIndex.ToOrdinalWords()} object {nodeIndex.ToOrdinalWords()} node has samples {string.Join(',', samples)}", () => + { + var h = EditorBeatmap.HitObjects.ElementAt(objectIndex) as IHasRepeats; + return h is not null && h.NodeSamples[nodeIndex].Select(s => s.Name).SequenceEqual(samples); + }); - private void hitObjectNodeHasSampleBank(int objectIndex, int nodeIndex, string bank) => AddAssert($"{objectIndex.ToOrdinalWords()} object {nodeIndex.ToOrdinalWords()} node has bank {bank}", () => - { - var h = EditorBeatmap.HitObjects.ElementAt(objectIndex) as IHasRepeats; - return h is not null && h.NodeSamples[nodeIndex].All(o => o.Bank == bank); - }); + private void hitObjectNodeHasSampleBank(int objectIndex, int nodeIndex, string bank) => AddAssert($"{objectIndex.ToOrdinalWords()} object {nodeIndex.ToOrdinalWords()} node has bank {bank}", + () => + { + var h = EditorBeatmap.HitObjects.ElementAt(objectIndex) as IHasRepeats; + return h is not null && h.NodeSamples[nodeIndex].All(o => o.Bank == bank); + }); - private void hitObjectNodeHasSampleNormalBank(int objectIndex, int nodeIndex, string bank) => AddAssert($"{objectIndex.ToOrdinalWords()} object {nodeIndex.ToOrdinalWords()} node has normal bank {bank}", () => - { - var h = EditorBeatmap.HitObjects.ElementAt(objectIndex) as IHasRepeats; - return h is not null && h.NodeSamples[nodeIndex].Where(o => o.Name == HitSampleInfo.HIT_NORMAL).All(o => o.Bank == bank); - }); + private void hitObjectNodeHasSampleNormalBank(int objectIndex, int nodeIndex, string bank) => AddAssert( + $"{objectIndex.ToOrdinalWords()} object {nodeIndex.ToOrdinalWords()} node has normal bank {bank}", () => + { + var h = EditorBeatmap.HitObjects.ElementAt(objectIndex) as IHasRepeats; + return h is not null && h.NodeSamples[nodeIndex].Where(o => o.Name == HitSampleInfo.HIT_NORMAL).All(o => o.Bank == bank); + }); - private void hitObjectNodeHasSampleAdditionBank(int objectIndex, int nodeIndex, string bank) => AddAssert($"{objectIndex.ToOrdinalWords()} object {nodeIndex.ToOrdinalWords()} node has addition bank {bank}", () => - { - var h = EditorBeatmap.HitObjects.ElementAt(objectIndex) as IHasRepeats; - return h is not null && h.NodeSamples[nodeIndex].Where(o => o.Name != HitSampleInfo.HIT_NORMAL).All(o => o.Bank == bank); - }); + private void hitObjectNodeHasSampleAdditionBank(int objectIndex, int nodeIndex, string bank) => AddAssert( + $"{objectIndex.ToOrdinalWords()} object {nodeIndex.ToOrdinalWords()} node has addition bank {bank}", () => + { + var h = EditorBeatmap.HitObjects.ElementAt(objectIndex) as IHasRepeats; + return h is not null && h.NodeSamples[nodeIndex].Where(o => o.Name != HitSampleInfo.HIT_NORMAL).All(o => o.Bank == bank); + }); private void editorTimeIs(double time) => AddAssert($"editor time is {time}", () => Precision.AlmostEquals(EditorClock.CurrentTimeAccurate, time, 1)); } 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/TestSceneLeadIn.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs index 5af7540f6f..8cbd6249e0 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs @@ -32,7 +32,7 @@ namespace osu.Game.Tests.Visual.Gameplay { loadPlayerWithBeatmap(new TestBeatmap(new OsuRuleset().RulesetInfo) { - BeatmapInfo = { AudioLeadIn = leadIn } + AudioLeadIn = leadIn }); checkFirstFrameTime(expectedStartTime); @@ -133,7 +133,7 @@ namespace osu.Game.Tests.Visual.Gameplay { Text = $"GameplayStartTime: {DrawableRuleset.GameplayStartTime} " + $"FirstHitObjectTime: {FirstHitObjectTime} " - + $"LeadInTime: {Beatmap.Value.BeatmapInfo.AudioLeadIn} " + + $"LeadInTime: {Beatmap.Value.Beatmap.AudioLeadIn} " + $"FirstFrameClockTime: {FirstFrameClockTime}" }); } diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs index 1949808dfe..c8b7ccc3d0 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; @@ -135,10 +136,10 @@ namespace osu.Game.Tests.Visual.Gameplay var workingBeatmap = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); // Add intro time to test quick retry skipping (TestQuickRetry). - workingBeatmap.BeatmapInfo.AudioLeadIn = 60000; + workingBeatmap.Beatmap.AudioLeadIn = 60000; // Set up data for testing disclaimer display. - workingBeatmap.BeatmapInfo.EpilepsyWarning = epilepsyWarning ?? false; + workingBeatmap.Beatmap.EpilepsyWarning = epilepsyWarning ?? false; workingBeatmap.BeatmapInfo.Status = onlineStatus ?? BeatmapOnlineStatus.Ranked; Beatmap.Value = workingBeatmap; @@ -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/TestScenePlayerLocalScoreImport.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs index 1660f93384..046ae6d953 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs @@ -221,7 +221,7 @@ namespace osu.Game.Tests.Visual.Gameplay string? filePath = null; // Files starting with _ are temporary, created by CreateFileSafely call. - AddUntilStep("wait for export file", () => filePath = LocalStorage.GetFiles("exports").SingleOrDefault(f => !Path.GetFileName(f).StartsWith("_", StringComparison.Ordinal)), () => Is.Not.Null); + AddUntilStep("wait for export file", () => filePath = LocalStorage.GetFiles("exports").SingleOrDefault(f => !Path.GetFileName(f).StartsWith('_')), () => Is.Not.Null); AddUntilStep("filesize is non-zero", () => { try diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayPlayer.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayPlayer.cs index ae10207de0..81dd23661c 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayPlayer.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayPlayer.cs @@ -63,7 +63,7 @@ namespace osu.Game.Tests.Visual.Gameplay { loadPlayerWithBeatmap(new TestBeatmap(new OsuRuleset().RulesetInfo) { - BeatmapInfo = { AudioLeadIn = 60000 } + AudioLeadIn = 60000 }); AddUntilStep("wait for skip overlay", () => Player.ChildrenOfType().First().IsButtonVisible); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs index e918a93cbc..5da60966b2 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs @@ -53,7 +53,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("load storyboard with only video", () => { // LegacyStoryboardDecoder doesn't parse WidescreenStoryboard, so it is set manually - loadStoryboard("storyboard_only_video.osu", s => s.BeatmapInfo.WidescreenStoryboard = false); + loadStoryboard("storyboard_only_video.osu", s => s.Beatmap.WidescreenStoryboard = false); }); AddAssert("storyboard is correct width", () => Precision.AlmostEquals(storyboard?.Width ?? 0f, 480 * 16 / 9f)); 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/TestSceneLoginOverlay.cs b/osu.Game.Tests/Visual/Menus/TestSceneLoginOverlay.cs index 460d7814e0..609bc6e166 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneLoginOverlay.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneLoginOverlay.cs @@ -10,11 +10,13 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; +using osu.Game.Online; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Overlays; using osu.Game.Overlays.Login; using osu.Game.Overlays.Settings; +using osu.Game.Tests.Visual.Online; using osu.Game.Users; using osu.Game.Users.Drawables; using osuTK.Input; @@ -31,6 +33,9 @@ namespace osu.Game.Tests.Visual.Menus [Resolved] private OsuConfigManager configManager { get; set; } = null!; + [Cached(typeof(LocalUserStatisticsProvider))] + private readonly TestSceneUserPanel.TestUserStatisticsProvider statisticsProvider = new TestSceneUserPanel.TestUserStatisticsProvider(); + [BackgroundDependencyLoader] private void load() { @@ -170,6 +175,7 @@ namespace osu.Game.Tests.Visual.Menus AddStep("enter code", () => loginOverlay.ChildrenOfType().First().Text = "88800088"); assertAPIState(APIState.Online); + AddStep("feed statistics", () => statisticsProvider.UpdateStatistics(new UserStatistics(), Ruleset.Value)); AddStep("click on flag", () => { InputManager.MoveMouseTo(loginOverlay.ChildrenOfType().First()); 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 4454501a96..32009dc8c2 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneMusicActionHandling.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneMusicActionHandling.cs @@ -79,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/Menus/TestSceneStarFountain.cs b/osu.Game.Tests/Visual/Menus/TestSceneStarFountain.cs index 29fa7287d2..0d981014b8 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneStarFountain.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneStarFountain.cs @@ -3,8 +3,10 @@ using System.Linq; using NUnit.Framework; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Utils; +using osu.Game.Configuration; using osu.Game.Screens.Menu; using osu.Game.Screens.Play; @@ -73,5 +75,57 @@ namespace osu.Game.Tests.Visual.Menus ((StarFountain)Children[1]).Shoot(-1); }); } + + [Test] + public void TestGameplayStarFountainsSetting() + { + Bindable starFountainsEnabled = null!; + + AddStep("load configuration", () => + { + var config = new OsuConfigManager(LocalStorage); + starFountainsEnabled = config.GetBindable(OsuSetting.StarFountains); + }); + + AddStep("make fountains", () => + { + Children = new Drawable[] + { + new KiaiGameplayFountains.GameplayStarFountain + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + X = 75, + }, + new KiaiGameplayFountains.GameplayStarFountain + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + X = -75, + }, + }; + }); + + AddStep("enable KiaiStarEffects", () => starFountainsEnabled.Value = true); + AddRepeatStep("activate fountains (enabled)", () => + { + ((KiaiGameplayFountains.GameplayStarFountain)Children[0]).Shoot(1); + ((KiaiGameplayFountains.GameplayStarFountain)Children[1]).Shoot(-1); + }, 100); + + AddStep("disable KiaiStarEffects", () => starFountainsEnabled.Value = false); + AddRepeatStep("attempt to activate fountains (disabled)", () => + { + ((KiaiGameplayFountains.GameplayStarFountain)Children[0]).Shoot(1); + ((KiaiGameplayFountains.GameplayStarFountain)Children[1]).Shoot(-1); + }, 100); + + AddStep("re-enable KiaiStarEffects", () => starFountainsEnabled.Value = true); + AddRepeatStep("activate fountains (re-enabled)", () => + { + ((KiaiGameplayFountains.GameplayStarFountain)Children[0]).Shoot(1); + ((KiaiGameplayFountains.GameplayStarFountain)Children[1]).Shoot(-1); + }, 100); + } } } diff --git a/osu.Game.Tests/Visual/Menus/TestSceneToolbarUserButton.cs b/osu.Game.Tests/Visual/Menus/TestSceneToolbarUserButton.cs index 71a45e2398..1af4af8f6b 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneToolbarUserButton.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneToolbarUserButton.cs @@ -101,7 +101,7 @@ namespace osu.Game.Tests.Visual.Menus AddStep("Gain", () => { var transientUpdateDisplay = this.ChildrenOfType().Single(); - transientUpdateDisplay.LatestUpdate.Value = new UserStatisticsUpdate( + transientUpdateDisplay.LatestUpdate.Value = new ScoreBasedUserStatisticsUpdate( new ScoreInfo(), new UserStatistics { @@ -118,7 +118,7 @@ namespace osu.Game.Tests.Visual.Menus AddStep("Loss", () => { var transientUpdateDisplay = this.ChildrenOfType().Single(); - transientUpdateDisplay.LatestUpdate.Value = new UserStatisticsUpdate( + transientUpdateDisplay.LatestUpdate.Value = new ScoreBasedUserStatisticsUpdate( new ScoreInfo(), new UserStatistics { @@ -136,7 +136,7 @@ namespace osu.Game.Tests.Visual.Menus AddStep("Tiny increase in PP", () => { var transientUpdateDisplay = this.ChildrenOfType().Single(); - transientUpdateDisplay.LatestUpdate.Value = new UserStatisticsUpdate( + transientUpdateDisplay.LatestUpdate.Value = new ScoreBasedUserStatisticsUpdate( new ScoreInfo(), new UserStatistics { @@ -153,7 +153,7 @@ namespace osu.Game.Tests.Visual.Menus AddStep("No change 1", () => { var transientUpdateDisplay = this.ChildrenOfType().Single(); - transientUpdateDisplay.LatestUpdate.Value = new UserStatisticsUpdate( + transientUpdateDisplay.LatestUpdate.Value = new ScoreBasedUserStatisticsUpdate( new ScoreInfo(), new UserStatistics { @@ -170,7 +170,7 @@ namespace osu.Game.Tests.Visual.Menus AddStep("Was null", () => { var transientUpdateDisplay = this.ChildrenOfType().Single(); - transientUpdateDisplay.LatestUpdate.Value = new UserStatisticsUpdate( + transientUpdateDisplay.LatestUpdate.Value = new ScoreBasedUserStatisticsUpdate( new ScoreInfo(), new UserStatistics { @@ -187,7 +187,7 @@ namespace osu.Game.Tests.Visual.Menus AddStep("Became null", () => { var transientUpdateDisplay = this.ChildrenOfType().Single(); - transientUpdateDisplay.LatestUpdate.Value = new UserStatisticsUpdate( + transientUpdateDisplay.LatestUpdate.Value = new ScoreBasedUserStatisticsUpdate( new ScoreInfo(), new UserStatistics { 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/Mods/TestSceneModFailCondition.cs b/osu.Game.Tests/Visual/Mods/TestSceneModFailCondition.cs index f4732234a7..a7447a92cd 100644 --- a/osu.Game.Tests/Visual/Mods/TestSceneModFailCondition.cs +++ b/osu.Game.Tests/Visual/Mods/TestSceneModFailCondition.cs @@ -21,7 +21,7 @@ namespace osu.Game.Tests.Visual.Mods protected override TestPlayer CreateModPlayer(Ruleset ruleset) { var player = base.CreateModPlayer(ruleset); - player.RestartRequested = _ => restartRequested = true; + player.PrepareLoaderForRestart = _ => restartRequested = true; return player; } 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 6a500bbe55..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,7 +18,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { public partial class TestSceneGameplayChatDisplay : OsuManualInputManagerTestScene { - private GameplayChatDisplay chatDisplay; + private GameplayChatDisplay chatDisplay = null!; [Cached(typeof(ILocalUserPlayInfo))] private ILocalUserPlayInfo localUserInfo; 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..0a3d48828e 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs @@ -406,13 +406,13 @@ namespace osu.Game.Tests.Visual.Multiplayer } /// - /// Tests spectating with a beatmap that has a high value. + /// Tests spectating with a beatmap that has a high value. /// /// This test is not intended not to check the correct initial time value, but only to guard against /// gameplay potentially getting stuck in a stopped state due to lead in time being present. /// [Test] - public void TestAudioLeadIn() => testLeadIn(b => b.BeatmapInfo.AudioLeadIn = 2000); + public void TestAudioLeadIn() => testLeadIn(b => b.Beatmap.AudioLeadIn = 2000); /// /// Tests spectating with a beatmap that has a storyboard element with a negative start time (i.e. intro storyboard element). @@ -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/TestSceneMultiplayerParticipantsList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs index 95ae4c5e80..d88741ec0c 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs @@ -198,7 +198,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("make second user host", () => MultiplayerClient.TransferHost(3)); - AddUntilStep("kick buttons not visible", () => this.ChildrenOfType().Count(d => d.IsPresent) == 0); + AddUntilStep("kick buttons not visible", () => !this.ChildrenOfType().Any(d => d.IsPresent)); AddStep("make local user host again", () => MultiplayerClient.TransferHost(API.LocalUser.Value.Id)); 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/Navigation/TestSceneInterProcessCommunication.cs b/osu.Game.Tests/Visual/Navigation/TestSceneInterProcessCommunication.cs index 83430b5665..be9dc387f2 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneInterProcessCommunication.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneInterProcessCommunication.cs @@ -63,7 +63,7 @@ namespace osu.Game.Tests.Visual.Navigation }); AddStep("create IPC sender channels", () => { - ipcSenderHost = new HeadlessGameHost(gameHost.Name, new HostOptions { IPCPort = OsuGame.IPC_PORT }); + ipcSenderHost = new HeadlessGameHost(gameHost.Name, new HostOptions { IPCPipeName = OsuGame.IPC_PIPE_NAME }); osuSchemeLinkIPCSender = new OsuSchemeLinkIPCChannel(ipcSenderHost); archiveImportIPCSender = new ArchiveImportIPCChannel(ipcSenderHost); }); diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index eda7ce925a..5646649d33 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -354,6 +354,23 @@ namespace osu.Game.Tests.Visual.Navigation AddAssert("retry count is 1", () => player.RestartCount == 1); } + [Test] + public void TestLastScoreNullAfterExitingPlayer() + { + AddUntilStep("wait for last play null", getLastPlay, () => Is.Null); + + var getOriginalPlayer = playToCompletion(); + + AddStep("attempt to retry", () => getOriginalPlayer().ChildrenOfType().First().Action()); + AddUntilStep("wait for last play matches player", getLastPlay, () => Is.EqualTo(getOriginalPlayer().Score.ScoreInfo)); + + AddUntilStep("wait for player", () => Game.ScreenStack.CurrentScreen != getOriginalPlayer() && Game.ScreenStack.CurrentScreen is Player); + AddStep("exit player", () => (Game.ScreenStack.CurrentScreen as Player)?.Exit()); + AddUntilStep("wait for last play null", getLastPlay, () => Is.Null); + + ScoreInfo getLastPlay() => Game.Dependencies.Get().Get(Static.LastLocalUserScore); + } + [Test] public void TestRetryImmediatelyAfterCompletion() { diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs index b6445dec6b..ab9ee1d8cc 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs @@ -457,6 +457,61 @@ namespace osu.Game.Tests.Visual.Online waitForChannel1Visible(); } + [Test] + public void TestPublicChannelsSortedByName() + { + // Intentionally join back to front. + AddStep("Show overlay with channel 2", () => + { + channelManager.CurrentChannel.Value = channelManager.JoinChannel(testChannel2); + chatOverlay.Show(); + }); + AddUntilStep("second channel is at top of list", () => getFirstVisiblePublicChannel().Channel == testChannel2); + + AddStep("Join channel 1", () => channelManager.JoinChannel(testChannel1)); + AddUntilStep("first channel is at top of list", () => getFirstVisiblePublicChannel().Channel == testChannel1); + + AddStep("message in channel 2", () => + { + testChannel2.AddNewMessages(new Message(1) { Content = "hi!", Sender = new APIUser { Username = "person" } }); + }); + AddUntilStep("first channel still at top of list", () => getFirstVisiblePublicChannel().Channel == testChannel1); + + ChannelListItem getFirstVisiblePublicChannel() => + chatOverlay.ChildrenOfType().Single().PublicChannelGroup.ItemFlow.FlowingChildren.OfType().First(item => item.Channel.Type == ChannelType.Public); + } + + [Test] + public void TestPrivateChannelsSortedByRecent() + { + Channel pmChannel1 = createPrivateChannel(); + Channel pmChannel2 = createPrivateChannel(); + + joinChannel(pmChannel1); + joinChannel(pmChannel2); + + AddStep("Show overlay", () => chatOverlay.Show()); + + AddUntilStep("first channel is at top of list", () => getFirstVisiblePMChannel().Channel == pmChannel1); + + AddStep("message in channel 2", () => + { + pmChannel2.AddNewMessages(new Message(1) { Content = "hi!", Sender = new APIUser { Username = "person" } }); + }); + + AddUntilStep("wait for first channel raised to top of list", () => getFirstVisiblePMChannel().Channel == pmChannel2); + + AddStep("message in channel 1", () => + { + pmChannel1.AddNewMessages(new Message(1) { Content = "hi!", Sender = new APIUser { Username = "person" } }); + }); + + AddUntilStep("wait for first channel raised to top of list", () => getFirstVisiblePMChannel().Channel == pmChannel1); + + ChannelListItem getFirstVisiblePMChannel() => + chatOverlay.ChildrenOfType().Single().PrivateChannelGroup.ItemFlow.FlowingChildren.OfType().First(item => item.Channel.Type == ChannelType.PM); + } + [Test] public void TestKeyboardNewChannel() { @@ -648,6 +703,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/TestSceneLocalUserStatisticsProvider.cs b/osu.Game.Tests/Visual/Online/TestSceneLocalUserStatisticsProvider.cs new file mode 100644 index 0000000000..f24a9333c1 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneLocalUserStatisticsProvider.cs @@ -0,0 +1,179 @@ +// 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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Graphics.Containers; +using osu.Game.Online; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Catch; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Taiko; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.Online +{ + public partial class TestSceneLocalUserStatisticsProvider : OsuTestScene + { + private LocalUserStatisticsProvider statisticsProvider = null!; + + private readonly Dictionary<(int userId, string rulesetName), UserStatistics> serverSideStatistics = new Dictionary<(int userId, string rulesetName), UserStatistics>(); + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("clear statistics", () => serverSideStatistics.Clear()); + + setUser(1000); + + AddStep("setup provider", () => + { + OsuTextFlowContainer text; + + ((DummyAPIAccess)API).HandleRequest = r => + { + switch (r) + { + case GetUserRequest userRequest: + int userId = int.Parse(userRequest.Lookup); + string rulesetName = userRequest.Ruleset!.ShortName; + var response = new APIUser + { + Id = userId, + Statistics = tryGetStatistics(userId, rulesetName) + }; + + userRequest.TriggerSuccess(response); + return true; + + default: + return false; + } + }; + + Clear(); + Add(statisticsProvider = new LocalUserStatisticsProvider()); + Add(text = new OsuTextFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + + statisticsProvider.StatisticsUpdated += update => + { + text.Clear(); + + foreach (var ruleset in Dependencies.Get().AvailableRulesets) + { + text.AddText(statisticsProvider.GetStatisticsFor(ruleset) is UserStatistics statistics + ? $"{ruleset.Name} statistics: (total score: {statistics.TotalScore})" + : $"{ruleset.Name} statistics: (null)"); + text.NewLine(); + } + + text.AddText($"latest update: {update.Ruleset}" + + $" ({(update.OldStatistics?.TotalScore.ToString() ?? "null")} -> {update.NewStatistics.TotalScore})"); + }; + + Ruleset.Value = new OsuRuleset().RulesetInfo; + }); + } + + [Test] + public void TestInitialStatistics() + { + AddAssert("osu statistics populated", () => statisticsProvider.GetStatisticsFor(new OsuRuleset().RulesetInfo)!.TotalScore, () => Is.EqualTo(4_000_000)); + AddAssert("taiko statistics populated", () => statisticsProvider.GetStatisticsFor(new TaikoRuleset().RulesetInfo)!.TotalScore, () => Is.EqualTo(3_000_000)); + AddAssert("catch statistics populated", () => statisticsProvider.GetStatisticsFor(new CatchRuleset().RulesetInfo)!.TotalScore, () => Is.EqualTo(2_000_000)); + AddAssert("mania statistics populated", () => statisticsProvider.GetStatisticsFor(new ManiaRuleset().RulesetInfo)!.TotalScore, () => Is.EqualTo(1_000_000)); + } + + [Test] + public void TestUserChanges() + { + setUser(1001); + + AddStep("update statistics for user 1000", () => + { + serverSideStatistics[(1000, "osu")] = new UserStatistics { TotalScore = 5_000_000 }; + serverSideStatistics[(1000, "taiko")] = new UserStatistics { TotalScore = 6_000_000 }; + }); + + AddAssert("statistics matches user 1001 in osu", + () => statisticsProvider.GetStatisticsFor(new OsuRuleset().RulesetInfo)!.TotalScore, + () => Is.EqualTo(4_000_000)); + + AddAssert("statistics matches user 1001 in taiko", + () => statisticsProvider.GetStatisticsFor(new TaikoRuleset().RulesetInfo)!.TotalScore, + () => Is.EqualTo(3_000_000)); + + setUser(1000, false); + + AddAssert("statistics matches user 1000 in osu", + () => statisticsProvider.GetStatisticsFor(new OsuRuleset().RulesetInfo)!.TotalScore, + () => Is.EqualTo(5_000_000)); + + AddAssert("statistics matches user 1000 in taiko", + () => statisticsProvider.GetStatisticsFor(new TaikoRuleset().RulesetInfo)!.TotalScore, + () => Is.EqualTo(6_000_000)); + } + + [Test] + public void TestRefetchStatistics() + { + UserStatisticsUpdate? update = null; + + setUser(1001); + + AddStep("update statistics server side", + () => serverSideStatistics[(1001, "osu")] = new UserStatistics { TotalScore = 9_000_000 }); + + AddAssert("statistics match old score", + () => statisticsProvider.GetStatisticsFor(new OsuRuleset().RulesetInfo)!.TotalScore, + () => Is.EqualTo(4_000_000)); + + AddStep("setup event", () => + { + update = null; + statisticsProvider.StatisticsUpdated -= onStatisticsUpdated; + statisticsProvider.StatisticsUpdated += onStatisticsUpdated; + }); + + AddStep("request refetch", () => statisticsProvider.RefetchStatistics(new OsuRuleset().RulesetInfo)); + AddUntilStep("statistics update raised", + () => update?.NewStatistics.TotalScore, + () => Is.EqualTo(9_000_000)); + AddAssert("statistics match new score", + () => statisticsProvider.GetStatisticsFor(new OsuRuleset().RulesetInfo)!.TotalScore, + () => Is.EqualTo(9_000_000)); + + void onStatisticsUpdated(UserStatisticsUpdate u) => update = u; + } + + private UserStatistics tryGetStatistics(int userId, string rulesetName) + => serverSideStatistics.TryGetValue((userId, rulesetName), out var stats) ? stats : new UserStatistics(); + + private void setUser(int userId, bool generateStatistics = true) + { + AddStep($"set local user to {userId}", () => + { + if (generateStatistics) + { + serverSideStatistics[(userId, "osu")] = new UserStatistics { TotalScore = 4_000_000 }; + serverSideStatistics[(userId, "taiko")] = new UserStatistics { TotalScore = 3_000_000 }; + serverSideStatistics[(userId, "fruits")] = new UserStatistics { TotalScore = 2_000_000 }; + serverSideStatistics[(userId, "mania")] = new UserStatistics { TotalScore = 1_000_000 }; + } + + ((DummyAPIAccess)API).LocalUser.Value = new APIUser { Id = userId }; + }); + } + } +} 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..3f1d961588 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserPanel.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.Allocation; @@ -11,6 +9,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Online; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Rulesets; @@ -24,17 +23,20 @@ namespace osu.Game.Tests.Visual.Online [TestFixture] public partial class TestSceneUserPanel : OsuTestScene { - private readonly Bindable activity = new Bindable(); + private readonly Bindable activity = new Bindable(); private readonly Bindable status = new Bindable(); - private UserGridPanel boundPanel1; - private TestUserListPanel boundPanel2; + private UserGridPanel boundPanel1 = null!; + private TestUserListPanel boundPanel2 = null!; [Cached] - private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + [Cached(typeof(LocalUserStatisticsProvider))] + private readonly TestUserStatisticsProvider statisticsProvider = new TestUserStatisticsProvider(); [Resolved] - private IRulesetStore rulesetStore { get; set; } + private IRulesetStore rulesetStore { get; set; } = null!; [SetUp] public void SetUp() => Schedule(() => @@ -42,7 +44,11 @@ namespace osu.Game.Tests.Visual.Online activity.Value = null; status.Value = null; - Child = new FillFlowContainer + Remove(statisticsProvider, false); + Clear(); + Add(statisticsProvider); + + Add(new FillFlowContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -55,21 +61,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 +83,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 +101,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,11 +110,11 @@ 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 } } - }; + }); boundPanel1.Status.BindTo(status); boundPanel1.Activity.BindTo(activity); @@ -162,16 +168,21 @@ namespace osu.Game.Tests.Visual.Online { AddStep("update statistics", () => { - API.UpdateStatistics(new UserStatistics + statisticsProvider.UpdateStatistics(new UserStatistics { GlobalRank = RNG.Next(100000), CountryRank = RNG.Next(100000) - }); + }, Ruleset.Value); }); - AddStep("set statistics to empty", () => + AddStep("set statistics to something big", () => { - API.UpdateStatistics(new UserStatistics()); + statisticsProvider.UpdateStatistics(new UserStatistics + { + GlobalRank = RNG.Next(1_000_000, 100_000_000), + CountryRank = RNG.Next(1_000_000, 100_000_000) + }, Ruleset.Value); }); + AddStep("set statistics to empty", () => statisticsProvider.UpdateStatistics(new UserStatistics(), Ruleset.Value)); } private UserActivity soloGameStatusForRuleset(int rulesetId) => new UserActivity.InSoloGame(new BeatmapInfo(), rulesetStore.GetRuleset(rulesetId)!); @@ -193,5 +204,11 @@ namespace osu.Game.Tests.Visual.Online public new TextFlowContainer LastVisitMessage => base.LastVisitMessage; } + + public partial class TestUserStatisticsProvider : LocalUserStatisticsProvider + { + public new void UpdateStatistics(UserStatistics newStatistics, RulesetInfo ruleset, Action? callback = null) + => base.UpdateStatistics(newStatistics, ruleset, callback); + } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileDailyChallenge.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileDailyChallenge.cs index 9db30380f6..0477d39193 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileDailyChallenge.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileDailyChallenge.cs @@ -37,7 +37,7 @@ namespace osu.Game.Tests.Visual.Online AddSliderStep("weekly best", 0, 250, 1, v => update(s => s.WeeklyStreakBest = v)); AddSliderStep("top 10%", 0, 999, 0, v => update(s => s.Top10PercentPlacements = v)); AddSliderStep("top 50%", 0, 999, 0, v => update(s => s.Top50PercentPlacements = v)); - AddSliderStep("playcount", 0, 999, 0, v => update(s => s.PlayCount = v)); + AddSliderStep("playcount", 0, 1500, 1, v => update(s => s.PlayCount = v)); AddStep("create", () => { Clear(); @@ -66,8 +66,8 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestPlayCountRankingTier() { - AddAssert("1 before silver", () => DailyChallengeStatsTooltip.TierForPlayCount(30) == RankingTier.Bronze); - AddAssert("first silver", () => DailyChallengeStatsTooltip.TierForPlayCount(31) == RankingTier.Silver); + AddAssert("1 before silver", () => DailyChallengeStatsTooltip.TierForPlayCount(29) == RankingTier.Bronze); + AddAssert("first silver", () => DailyChallengeStatsTooltip.TierForPlayCount(30) == RankingTier.Silver); } } } 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/Online/TestSceneUserStatisticsWatcher.cs b/osu.Game.Tests/Visual/Online/TestSceneUserStatisticsWatcher.cs index e7ad07041c..d410b7f3a4 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserStatisticsWatcher.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserStatisticsWatcher.cs @@ -25,6 +25,7 @@ namespace osu.Game.Tests.Visual.Online { protected override bool UseOnlineAPI => false; + private LocalUserStatisticsProvider statisticsProvider = null!; private UserStatisticsWatcher watcher = null!; [Resolved] @@ -107,7 +108,9 @@ namespace osu.Game.Tests.Visual.Online AddStep("create watcher", () => { - Child = watcher = new UserStatisticsWatcher(); + Clear(); + Add(statisticsProvider = new LocalUserStatisticsProvider()); + Add(watcher = new UserStatisticsWatcher(statisticsProvider)); }); } @@ -123,7 +126,7 @@ namespace osu.Game.Tests.Visual.Online var ruleset = new OsuRuleset().RulesetInfo; - UserStatisticsUpdate? update = null; + ScoreBasedUserStatisticsUpdate? update = null; registerForUpdates(scoreId, ruleset, receivedUpdate => update = receivedUpdate); feignScoreProcessing(userId, ruleset, 5_000_000); @@ -146,7 +149,7 @@ namespace osu.Game.Tests.Visual.Online // note ordering - in this test processing completes *before* the registration is added. feignScoreProcessing(userId, ruleset, 5_000_000); - UserStatisticsUpdate? update = null; + ScoreBasedUserStatisticsUpdate? update = null; registerForUpdates(scoreId, ruleset, receivedUpdate => update = receivedUpdate); AddStep("signal score processed", () => ((ISpectatorClient)spectatorClient).UserScoreProcessed(userId, scoreId)); @@ -164,7 +167,7 @@ namespace osu.Game.Tests.Visual.Online long scoreId = getScoreId(); var ruleset = new OsuRuleset().RulesetInfo; - UserStatisticsUpdate? update = null; + ScoreBasedUserStatisticsUpdate? update = null; registerForUpdates(scoreId, ruleset, receivedUpdate => update = receivedUpdate); feignScoreProcessing(userId, ruleset, 5_000_000); @@ -191,7 +194,7 @@ namespace osu.Game.Tests.Visual.Online long scoreId = getScoreId(); var ruleset = new OsuRuleset().RulesetInfo; - UserStatisticsUpdate? update = null; + ScoreBasedUserStatisticsUpdate? update = null; registerForUpdates(scoreId, ruleset, receivedUpdate => update = receivedUpdate); feignScoreProcessing(userId, ruleset, 5_000_000); @@ -212,7 +215,7 @@ namespace osu.Game.Tests.Visual.Online long scoreId = getScoreId(); var ruleset = new OsuRuleset().RulesetInfo; - UserStatisticsUpdate? update = null; + ScoreBasedUserStatisticsUpdate? update = null; registerForUpdates(scoreId, ruleset, receivedUpdate => update = receivedUpdate); feignScoreProcessing(userId, ruleset, 5_000_000); @@ -241,7 +244,7 @@ namespace osu.Game.Tests.Visual.Online feignScoreProcessing(userId, ruleset, 6_000_000); - UserStatisticsUpdate? update = null; + ScoreBasedUserStatisticsUpdate? update = null; registerForUpdates(secondScoreId, ruleset, receivedUpdate => update = receivedUpdate); AddStep("signal score processed", () => ((ISpectatorClient)spectatorClient).UserScoreProcessed(userId, secondScoreId)); @@ -259,15 +262,14 @@ namespace osu.Game.Tests.Visual.Online var ruleset = new OsuRuleset().RulesetInfo; - UserStatisticsUpdate? update = null; + ScoreBasedUserStatisticsUpdate? update = null; registerForUpdates(scoreId, ruleset, receivedUpdate => update = receivedUpdate); feignScoreProcessing(userId, ruleset, 5_000_000); AddStep("signal score processed", () => ((ISpectatorClient)spectatorClient).UserScoreProcessed(userId, scoreId)); AddUntilStep("update received", () => update != null); - AddAssert("local user values are correct", () => dummyAPI.LocalUser.Value.Statistics.TotalScore, () => Is.EqualTo(5_000_000)); - AddAssert("statistics values are correct", () => dummyAPI.Statistics.Value!.TotalScore, () => Is.EqualTo(5_000_000)); + AddAssert("statistics values are correct", () => statisticsProvider.GetStatisticsFor(ruleset)!.TotalScore, () => Is.EqualTo(5_000_000)); } private int nextUserId = 2000; @@ -289,7 +291,7 @@ namespace osu.Game.Tests.Visual.Online }); } - private void registerForUpdates(long scoreId, RulesetInfo rulesetInfo, Action onUpdateReady) => + private void registerForUpdates(long scoreId, RulesetInfo rulesetInfo, Action onUpdateReady) => AddStep("register for updates", () => { watcher.RegisterForStatisticsUpdateAfter( 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..33bd573617 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs @@ -1,15 +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.Linq; using System.Net; -using JetBrains.Annotations; using Newtonsoft.Json.Linq; using NUnit.Framework; +using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Game.Graphics.Containers; @@ -34,14 +32,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 ResultsScreen 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() @@ -71,11 +69,11 @@ namespace osu.Game.Tests.Visual.Playlists } [Test] - public void TestShowWithUserScore() + public void TestShowUserScore() { AddStep("bind user score info handler", () => bindHandler(userScore: userScore)); - createResults(() => userScore); + createResultsWithScore(() => userScore); waitForDisplay(); AddAssert("user score selected", () => this.ChildrenOfType().Single(p => p.Score.OnlineID == userScore.OnlineID).State == PanelState.Expanded); @@ -84,11 +82,24 @@ namespace osu.Game.Tests.Visual.Playlists } [Test] - public void TestShowNullUserScore() + public void TestShowUserBest() + { + AddStep("bind user score info handler", () => bindHandler(userScore: userScore)); + + createUserBestResults(); + waitForDisplay(); + + AddAssert("user score selected", () => this.ChildrenOfType().Single(p => p.Score.UserID == userScore.UserID).State == PanelState.Expanded); + AddAssert($"score panel position is {real_user_position}", + () => this.ChildrenOfType().Single(p => p.Score.UserID == userScore.UserID).ScorePosition.Value == real_user_position); + } + + [Test] + public void TestShowNonUserScores() { AddStep("bind user score info handler", () => bindHandler()); - createResults(); + createUserBestResults(); waitForDisplay(); AddAssert("top score selected", () => this.ChildrenOfType().OrderByDescending(p => p.Score.TotalScore).First().State == PanelState.Expanded); @@ -99,7 +110,7 @@ namespace osu.Game.Tests.Visual.Playlists { AddStep("bind user score info handler", () => bindHandler(true, userScore)); - createResults(() => userScore); + createResultsWithScore(() => userScore); waitForDisplay(); AddAssert("more than 1 panel displayed", () => this.ChildrenOfType().Count() > 1); @@ -107,11 +118,11 @@ namespace osu.Game.Tests.Visual.Playlists } [Test] - public void TestShowNullUserScoreWithDelay() + public void TestShowNonUserScoresWithDelay() { AddStep("bind delayed handler", () => bindHandler(true)); - createResults(); + createUserBestResults(); waitForDisplay(); AddAssert("top score selected", () => this.ChildrenOfType().OrderByDescending(p => p.Score.TotalScore).First().State == PanelState.Expanded); @@ -122,7 +133,7 @@ namespace osu.Game.Tests.Visual.Playlists { AddStep("bind delayed handler", () => bindHandler(true)); - createResults(); + createUserBestResults(); waitForDisplay(); for (int i = 0; i < 2; i++) @@ -130,13 +141,16 @@ namespace osu.Game.Tests.Visual.Playlists int beforePanelCount = 0; AddStep("get panel count", () => beforePanelCount = this.ChildrenOfType().Count()); - AddStep("scroll to right", () => resultsScreen.ScorePanelList.ChildrenOfType().Single().ScrollToEnd(false)); + AddStep("scroll to right", () => resultsScreen.ChildrenOfType().Single().ChildrenOfType().Single().ScrollToEnd(false)); + + AddAssert("right loading spinner shown", () => + resultsScreen.ChildrenOfType().Single(l => l.Anchor == Anchor.CentreRight).State.Value == Visibility.Visible); - AddAssert("right loading spinner shown", () => resultsScreen.RightSpinner.State.Value == Visibility.Visible); waitForDisplay(); AddAssert($"count increased by {scores_per_result}", () => this.ChildrenOfType().Count() == beforePanelCount + scores_per_result); - AddAssert("right loading spinner hidden", () => resultsScreen.RightSpinner.State.Value == Visibility.Hidden); + AddAssert("right loading spinner hidden", () => + resultsScreen.ChildrenOfType().Single(l => l.Anchor == Anchor.CentreRight).State.Value == Visibility.Hidden); } } @@ -145,29 +159,36 @@ namespace osu.Game.Tests.Visual.Playlists { AddStep("bind delayed handler with scores", () => bindHandler(delayed: true)); - createResults(); + createUserBestResults(); waitForDisplay(); int beforePanelCount = 0; AddStep("get panel count", () => beforePanelCount = this.ChildrenOfType().Count()); - AddStep("scroll to right", () => resultsScreen.ScorePanelList.ChildrenOfType().Single().ScrollToEnd(false)); + AddStep("scroll to right", () => resultsScreen.ChildrenOfType().Single().ChildrenOfType().Single().ScrollToEnd(false)); + + AddAssert("right loading spinner shown", () => + resultsScreen.ChildrenOfType().Single(l => l.Anchor == Anchor.CentreRight).State.Value == Visibility.Visible); - AddAssert("right loading spinner shown", () => resultsScreen.RightSpinner.State.Value == Visibility.Visible); waitForDisplay(); AddAssert($"count increased by {scores_per_result}", () => this.ChildrenOfType().Count() == beforePanelCount + scores_per_result); - AddAssert("right loading spinner hidden", () => resultsScreen.RightSpinner.State.Value == Visibility.Hidden); + AddAssert("right loading spinner hidden", () => + resultsScreen.ChildrenOfType().Single(l => l.Anchor == Anchor.CentreRight).State.Value == Visibility.Hidden); AddStep("get panel count", () => beforePanelCount = this.ChildrenOfType().Count()); AddStep("bind delayed handler with no scores", () => bindHandler(delayed: true, noScores: true)); - AddStep("scroll to right", () => resultsScreen.ScorePanelList.ChildrenOfType().Single().ScrollToEnd(false)); + AddStep("scroll to right", () => resultsScreen.ChildrenOfType().Single().ChildrenOfType().Single().ScrollToEnd(false)); + + AddAssert("right loading spinner shown", () => + resultsScreen.ChildrenOfType().Single(l => l.Anchor == Anchor.CentreRight).State.Value == Visibility.Visible); - AddAssert("right loading spinner shown", () => resultsScreen.RightSpinner.State.Value == Visibility.Visible); waitForDisplay(); AddAssert("count not increased", () => this.ChildrenOfType().Count() == beforePanelCount); - AddAssert("right loading spinner hidden", () => resultsScreen.RightSpinner.State.Value == Visibility.Hidden); + AddAssert("right loading spinner hidden", () => + resultsScreen.ChildrenOfType().Single(l => l.Anchor == Anchor.CentreRight).State.Value == Visibility.Hidden); + AddAssert("no placeholders shown", () => this.ChildrenOfType().Count(), () => Is.Zero); } @@ -176,7 +197,7 @@ namespace osu.Game.Tests.Visual.Playlists { AddStep("bind user score info handler", () => bindHandler(userScore: userScore)); - createResults(() => userScore); + createResultsWithScore(() => userScore); waitForDisplay(); AddStep("bind delayed handler", () => bindHandler(true)); @@ -186,30 +207,36 @@ namespace osu.Game.Tests.Visual.Playlists int beforePanelCount = 0; AddStep("get panel count", () => beforePanelCount = this.ChildrenOfType().Count()); - AddStep("scroll to left", () => resultsScreen.ScorePanelList.ChildrenOfType().Single().ScrollToStart(false)); + AddStep("scroll to left", () => resultsScreen.ChildrenOfType().Single().ChildrenOfType().Single().ScrollToStart(false)); + + AddAssert("left loading spinner shown", () => + resultsScreen.ChildrenOfType().Single(l => l.Anchor == Anchor.CentreLeft).State.Value == Visibility.Visible); - AddAssert("left loading spinner shown", () => resultsScreen.LeftSpinner.State.Value == Visibility.Visible); waitForDisplay(); AddAssert($"count increased by {scores_per_result}", () => this.ChildrenOfType().Count() == beforePanelCount + scores_per_result); - AddAssert("left loading spinner hidden", () => resultsScreen.LeftSpinner.State.Value == Visibility.Hidden); + AddAssert("left loading spinner hidden", () => + resultsScreen.ChildrenOfType().Single(l => l.Anchor == Anchor.CentreLeft).State.Value == Visibility.Hidden); } } + /// + /// Shows the with no scores provided by the API. + /// [Test] - public void TestShowWithNoScores() + public void TestShowUserBestWithNoScoresPresent() { AddStep("bind user score info handler", () => bindHandler(noScores: true)); - createResults(); - AddAssert("no scores visible", () => !resultsScreen.ScorePanelList.GetScorePanels().Any()); + createUserBestResults(); + AddAssert("no scores visible", () => !resultsScreen.ChildrenOfType().Single().GetScorePanels().Any()); AddAssert("placeholder shown", () => this.ChildrenOfType().Count(), () => Is.EqualTo(1)); } - private void createResults(Func getScore = null) + private void createResultsWithScore(Func getScore) { AddStep("load results", () => { - LoadScreen(resultsScreen = new TestResultsScreen(getScore?.Invoke(), 1, new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) + LoadScreen(resultsScreen = new TestScoreResultsScreen(getScore(), 1, new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID })); @@ -218,22 +245,36 @@ namespace osu.Game.Tests.Visual.Playlists AddUntilStep("wait for screen to load", () => resultsScreen.IsLoaded); } + private void createUserBestResults() + { + AddStep("load results", () => + { + LoadScreen(resultsScreen = new TestUserBestResultsScreen(1, new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID + }, 2)); + }); + + AddUntilStep("wait for screen to load", () => resultsScreen.IsLoaded); + } + private void waitForDisplay() { AddUntilStep("wait for scores loaded", () => requestComplete // request handler may need to fire more than once to get scores. && totalCount > 0 - && resultsScreen.ScorePanelList.GetScorePanels().Count() == totalCount - && resultsScreen.ScorePanelList.AllPanelsVisible); + && resultsScreen.ChildrenOfType().Single().GetScorePanels().Count() == totalCount + && resultsScreen.ChildrenOfType().Single().AllPanelsVisible); 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) { + case ShowPlaylistScoreRequest: case ShowPlaylistUserScoreRequest: case IndexPlaylistScoresRequest: break; @@ -256,7 +297,7 @@ namespace osu.Game.Tests.Visual.Playlists switch (request) { - case ShowPlaylistUserScoreRequest s: + case ShowPlaylistScoreRequest s: if (userScore == null) triggerFail(s); else @@ -264,6 +305,14 @@ namespace osu.Game.Tests.Visual.Playlists break; + case ShowPlaylistUserScoreRequest u: + if (userScore == null) + triggerFail(u); + else + triggerSuccess(u, createUserResponse(userScore)); + + break; + case IndexPlaylistScoresRequest i: triggerSuccess(i, createIndexResponse(i, noScores)); break; @@ -286,7 +335,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 { @@ -317,7 +366,7 @@ namespace osu.Game.Tests.Visual.Playlists MaxCombo = userScore.MaxCombo, User = new APIUser { - Id = 2, + Id = 2 + i, Username = $"peppy{i}", CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", }, @@ -332,7 +381,7 @@ namespace osu.Game.Tests.Visual.Playlists MaxCombo = userScore.MaxCombo, User = new APIUser { - Id = 2, + Id = 2 + i, Username = $"peppy{i}", CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", }, @@ -366,7 +415,7 @@ namespace osu.Game.Tests.Visual.Playlists MaxCombo = 1000, User = new APIUser { - Id = 2, + Id = 2 + i, Username = $"peppy{i}", CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", }, @@ -413,18 +462,22 @@ namespace osu.Game.Tests.Visual.Playlists }; } - private partial class TestResultsScreen : PlaylistItemUserResultsScreen + private partial class TestScoreResultsScreen : PlaylistItemScoreResultsScreen { - public new LoadingSpinner LeftSpinner => base.LeftSpinner; - public new LoadingSpinner CentreSpinner => base.CentreSpinner; - public new LoadingSpinner RightSpinner => base.RightSpinner; - public new ScorePanelList ScorePanelList => base.ScorePanelList; - - public TestResultsScreen([CanBeNull] ScoreInfo score, int roomId, PlaylistItem playlistItem) + public TestScoreResultsScreen(ScoreInfo score, int roomId, PlaylistItem playlistItem) : base(score, roomId, playlistItem) { AllowRetry = true; } } + + private partial class TestUserBestResultsScreen : PlaylistItemUserBestResultsScreen + { + public TestUserBestResultsScreen(int roomId, PlaylistItem playlistItem, int userId) + : base(roomId, playlistItem, userId) + { + 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..de84ca680d --- /dev/null +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomSubScreen.cs @@ -0,0 +1,149 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Extensions; +using osu.Framework.Platform; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Rooms; +using osu.Game.Online.Rooms.RoomStatuses; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Scoring; +using osu.Game.Screens.OnlinePlay.Playlists; +using osu.Game.Tests.Resources; +using osu.Game.Tests.Visual.OnlinePlay; + +namespace osu.Game.Tests.Visual.Playlists +{ + public partial class TestScenePlaylistsRoomSubScreen : OnlinePlayTestScene + { + private const double track_length = 10000; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + protected new TestRoomManager RoomManager => (TestRoomManager)base.RoomManager; + + private BeatmapManager beatmaps = null!; + private RulesetStore rulesets = null!; + private BeatmapSetInfo? importedSet; + + [BackgroundDependencyLoader] + private void load(GameHost host, AudioManager audio) + { + Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); + Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(new ScoreManager(rulesets, () => beatmaps, LocalStorage, Realm, API)); + Dependencies.Cache(Realm); + + beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); + + Realm.Write(r => + { + foreach (var set in r.All()) + { + foreach (var b in set.Beatmaps) + { + // These will all have a virtual track length of 1000, see WorkingBeatmap.GetVirtualTrack(). + b.Length = track_length - 1000; + } + } + }); + + importedSet = beatmaps.GetAllUsableBeatmapSets().First(); + } + + [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); + } + + [Test] + public void TestCloseButtonGoesAwayAfterGracePeriod() + { + Room room = null!; + PlaylistsRoomSubScreen roomScreen = null!; + + AddStep("create room", () => + { + RoomManager.AddRoom(room = new Room + { + Name = @"Test Room", + Host = api.LocalUser.Value, + Category = RoomCategory.Normal, + StartDate = DateTimeOffset.Now.AddMinutes(-5).AddSeconds(3), + EndDate = DateTimeOffset.Now.AddMinutes(30) + }); + }); + + AddStep("push screen", () => LoadScreen(roomScreen = new PlaylistsRoomSubScreen(room))); + AddUntilStep("wait for screen load", () => roomScreen.IsCurrentScreen()); + AddAssert("close button present", () => roomScreen.ChildrenOfType().Any()); + AddUntilStep("wait for close button to disappear", () => !roomScreen.ChildrenOfType().Any()); + } + + [TestCase(120_000, true)] // Definitely enough time. + [TestCase(45_000, true)] // Enough time. + [TestCase(35_000, false)] // Not enough time to complete beatmap after lenience. + [TestCase(20_000, false)] // Not enough time. + [TestCase(5_000, false)] // Not enough time to complete beatmap before lenience. + [TestCase(37_500, true, 2)] // Enough time to complete beatmap after mods are applied. + public void TestReadyButtonEnablementPeriod(int offsetMs, bool enabled, double rate = 1) + { + Room room = null!; + PlaylistsRoomSubScreen roomScreen = null!; + + AddStep("create room", () => + { + RoomManager.AddRoom(room = new Room + { + Name = @"Test Room", + Host = api.LocalUser.Value, + Category = RoomCategory.Normal, + StartDate = DateTimeOffset.Now, + EndDate = DateTimeOffset.Now.AddMilliseconds(offsetMs), + Playlist = + [ + new PlaylistItem(importedSet!.Beatmaps[0]) + { + RequiredMods = rate == 1 + ? [] + : [new APIMod(new OsuModDoubleTime { SpeedChange = { Value = rate } })] + } + ] + }); + }); + + AddStep("push screen", () => LoadScreen(roomScreen = new PlaylistsRoomSubScreen(room))); + AddUntilStep("wait for screen load", () => roomScreen.IsCurrentScreen()); + AddUntilStep("ready button enabled", () => roomScreen.ChildrenOfType().SingleOrDefault()?.Enabled.Value, () => Is.EqualTo(enabled)); + } + } +} 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/Ranking/TestSceneOverallRanking.cs b/osu.Game.Tests/Visual/Ranking/TestSceneOverallRanking.cs index ffc7d88a34..b406ea369f 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneOverallRanking.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneOverallRanking.cs @@ -112,6 +112,6 @@ namespace osu.Game.Tests.Visual.Ranking }); private void displayUpdate(UserStatistics before, UserStatistics after) => - AddStep("display update", () => overallRanking.StatisticsUpdate.Value = new UserStatisticsUpdate(new ScoreInfo(), before, after)); + AddStep("display update", () => overallRanking.StatisticsUpdate.Value = new ScoreBasedUserStatisticsUpdate(new ScoreInfo(), before, after)); } } diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs b/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs index f46f76cbb8..c12b9d29bc 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs @@ -91,12 +91,12 @@ namespace osu.Game.Tests.Visual.Ranking UserStatisticsWatcher userStatisticsWatcher = null!; ScoreInfo score = null!; - AddStep("create user statistics watcher", () => Add(userStatisticsWatcher = new UserStatisticsWatcher())); + AddStep("create user statistics watcher", () => Add(userStatisticsWatcher = new UserStatisticsWatcher(new LocalUserStatisticsProvider()))); AddStep("set user statistics update", () => { score = TestResources.CreateTestScoreInfo(); score.OnlineID = 1234; - ((Bindable)userStatisticsWatcher.LatestUpdate).Value = new UserStatisticsUpdate(score, + ((Bindable)userStatisticsWatcher.LatestUpdate).Value = new ScoreBasedUserStatisticsUpdate(score, new UserStatistics { Level = new UserStatistics.LevelInfo @@ -157,7 +157,7 @@ namespace osu.Game.Tests.Visual.Ranking Score = { Value = score }, DisplayedUserStatisticsUpdate = { - Value = new UserStatisticsUpdate(score, new UserStatistics + Value = new ScoreBasedUserStatisticsUpdate(score, new UserStatistics { Level = new UserStatistics.LevelInfo { 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..bd5c43d242 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs @@ -8,11 +8,14 @@ using System.Collections.Generic; using System.Diagnostics; 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.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Mania; @@ -25,25 +28,31 @@ namespace osu.Game.Tests.Visual.SongSelect { public partial class TestSceneBeatmapRecommendations : OsuGameTestScene { - [Resolved] - private IRulesetStore rulesetStore { get; set; } - [SetUpSteps] public override void SetUpSteps() { AddStep("populate ruleset statistics", () => { - Dictionary rulesetStatistics = new Dictionary(); - - rulesetStore.AvailableRulesets.Where(ruleset => ruleset.IsLegacyRuleset()).ForEach(rulesetInfo => + ((DummyAPIAccess)API).HandleRequest = r => { - rulesetStatistics[rulesetInfo.ShortName] = new UserStatistics + switch (r) { - PP = getNecessaryPP(rulesetInfo.OnlineID) - }; - }); + case GetUserRequest userRequest: + userRequest.TriggerSuccess(new APIUser + { + Id = 99, + Statistics = new UserStatistics + { + PP = getNecessaryPP(userRequest.Ruleset?.OnlineID ?? 0) + } + }); - API.LocalUser.Value.RulesetsStatistics = rulesetStatistics; + return true; + + default: + return false; + } + }; }); decimal getNecessaryPP(int? rulesetID) @@ -191,8 +200,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/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/TestSceneContextMenu.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneContextMenu.cs index 2a2f267fc8..118e37dab4 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneContextMenu.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneContextMenu.cs @@ -101,7 +101,16 @@ namespace osu.Game.Tests.Visual.UserInterface } } } - } + }, + } + }, + new OsuMenuItem(@"Another nested option") + { + Items = new MenuItem[] + { + new OsuMenuItem(@"Sub-One"), + new OsuMenuItem(@"Sub-Two"), + new OsuMenuItem(@"Sub-Three"), } }, new OsuMenuItem(@"Choose me please"), diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs index e2fe10fa74..f7bdda6b57 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs @@ -151,7 +151,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("click delete option", () => { - InputManager.MoveMouseTo(contextMenuContainer.ChildrenOfType().First(i => i.Item.Text.Value.ToString().ToLowerInvariant() == "delete")); + InputManager.MoveMouseTo(contextMenuContainer.ChildrenOfType().First(i => string.Equals(i.Item.Text.Value.ToString(), "delete", System.StringComparison.OrdinalIgnoreCase))); InputManager.Click(MouseButton.Left); }); 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..01d2241650 100644 --- a/osu.Game.Tests/osu.Game.Tests.csproj +++ b/osu.Game.Tests/osu.Game.Tests.csproj @@ -1,8 +1,8 @@  + - @@ -12,9 +12,9 @@ WinExe net8.0 - - tests.ruleset - + + + diff --git a/osu.Game.Tests/tests.ruleset b/osu.Game.Tests/tests.ruleset deleted file mode 100644 index a0abb781d3..0000000000 --- a/osu.Game.Tests/tests.ruleset +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - 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/.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/Beatmap.cs b/osu.Game/Beatmaps/Beatmap.cs index 282f8fe794..8ea6fa1f51 100644 --- a/osu.Game/Beatmaps/Beatmap.cs +++ b/osu.Game/Beatmaps/Beatmap.cs @@ -115,6 +115,32 @@ namespace osu.Game.Beatmaps return mostCommon.beatLength; } + public double AudioLeadIn { get; set; } + + public float StackLeniency { get; set; } = 0.7f; + + public bool SpecialStyle { get; set; } + + public bool LetterboxInBreaks { get; set; } + + public bool WidescreenStoryboard { get; set; } = true; + + public bool EpilepsyWarning { get; set; } + + public bool SamplesMatchPlaybackRate { get; set; } + + public double DistanceSpacing { get; set; } = 1.0; + + public int GridSize { get; set; } + + public double TimelineZoom { get; set; } = 1.0; + + public CountdownType Countdown { get; set; } = CountdownType.None; + + public int CountdownOffset { get; set; } + + public int[] Bookmarks { get; set; } = Array.Empty(); + IBeatmap IBeatmap.Clone() => Clone(); public Beatmap Clone() => (Beatmap)MemberwiseClone(); diff --git a/osu.Game/Beatmaps/BeatmapConverter.cs b/osu.Game/Beatmaps/BeatmapConverter.cs index c43bd494e9..0cf10c659b 100644 --- a/osu.Game/Beatmaps/BeatmapConverter.cs +++ b/osu.Game/Beatmaps/BeatmapConverter.cs @@ -73,6 +73,19 @@ namespace osu.Game.Beatmaps beatmap.HitObjects = convertHitObjects(original.HitObjects, original, cancellationToken).OrderBy(s => s.StartTime).ToList(); beatmap.Breaks = original.Breaks; beatmap.UnhandledEventLines = original.UnhandledEventLines; + beatmap.AudioLeadIn = original.AudioLeadIn; + beatmap.StackLeniency = original.StackLeniency; + beatmap.SpecialStyle = original.SpecialStyle; + beatmap.LetterboxInBreaks = original.LetterboxInBreaks; + beatmap.WidescreenStoryboard = original.WidescreenStoryboard; + beatmap.EpilepsyWarning = original.EpilepsyWarning; + beatmap.SamplesMatchPlaybackRate = original.SamplesMatchPlaybackRate; + beatmap.DistanceSpacing = original.DistanceSpacing; + beatmap.GridSize = original.GridSize; + beatmap.TimelineZoom = original.TimelineZoom; + beatmap.Countdown = original.Countdown; + beatmap.CountdownOffset = original.CountdownOffset; + beatmap.Bookmarks = original.Bookmarks; return beatmap; } 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/BeatmapImporter.cs b/osu.Game/Beatmaps/BeatmapImporter.cs index 94144e4695..77aca5eecf 100644 --- a/osu.Game/Beatmaps/BeatmapImporter.cs +++ b/osu.Game/Beatmaps/BeatmapImporter.cs @@ -428,17 +428,7 @@ namespace osu.Game.Beatmaps Hash = hash, DifficultyName = decodedInfo.DifficultyName, OnlineID = decodedInfo.OnlineID, - AudioLeadIn = decodedInfo.AudioLeadIn, - StackLeniency = decodedInfo.StackLeniency, - SpecialStyle = decodedInfo.SpecialStyle, - LetterboxInBreaks = decodedInfo.LetterboxInBreaks, - WidescreenStoryboard = decodedInfo.WidescreenStoryboard, - EpilepsyWarning = decodedInfo.EpilepsyWarning, - SamplesMatchPlaybackRate = decodedInfo.SamplesMatchPlaybackRate, - DistanceSpacing = decodedInfo.DistanceSpacing, BeatDivisor = decodedInfo.BeatDivisor, - GridSize = decodedInfo.GridSize, - TimelineZoom = decodedInfo.TimelineZoom, MD5Hash = memoryStream.ComputeMD5Hash(), EndTimeObjectCount = decoded.HitObjects.Count(h => h is IHasDuration), TotalObjectCount = decoded.HitObjects.Count diff --git a/osu.Game/Beatmaps/BeatmapInfo.cs b/osu.Game/Beatmaps/BeatmapInfo.cs index 425fd98d27..333ec89eab 100644 --- a/osu.Game/Beatmaps/BeatmapInfo.cs +++ b/osu.Game/Beatmaps/BeatmapInfo.cs @@ -6,14 +6,12 @@ using System.Diagnostics; using System.Linq; using JetBrains.Annotations; using Newtonsoft.Json; -using osu.Game.Beatmaps.ControlPoints; using osu.Game.Collections; using osu.Game.Database; using osu.Game.Models; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.BeatmapSet.Scores; using osu.Game.Rulesets; -using osu.Game.Rulesets.Edit; using osu.Game.Scoring; using Realms; @@ -62,7 +60,7 @@ namespace osu.Game.Beatmaps } [UsedImplicitly] - private BeatmapInfo() + protected BeatmapInfo() { } @@ -136,60 +134,18 @@ namespace osu.Game.Beatmaps Status = BeatmapOnlineStatus.None; } - #region Properties we may not want persisted (but also maybe no harm?) - - public double AudioLeadIn { get; set; } - - public float StackLeniency { get; set; } = 0.7f; - - public bool SpecialStyle { get; set; } - - public bool LetterboxInBreaks { get; set; } - - public bool WidescreenStoryboard { get; set; } = true; - - public bool EpilepsyWarning { get; set; } - - public bool SamplesMatchPlaybackRate { get; set; } = true; - /// /// The time at which this beatmap was last played by the local user. /// public DateTimeOffset? LastPlayed { get; set; } - /// - /// The ratio of distance travelled per time unit. - /// Generally used to decouple the spacing between hit objects from the enforced "velocity" of the beatmap (see ). - /// - /// - /// The most common method of understanding is that at a default value of 1.0, the time-to-distance ratio will match the slider velocity of the beatmap - /// at the current point in time. Increasing this value will make hit objects more spaced apart when compared to the cursor movement required to track a slider. - /// - /// This is only a hint property, used by the editor in implementations. It does not directly affect the beatmap or gameplay. - /// - public double DistanceSpacing { get; set; } = 1.0; - public int BeatDivisor { get; set; } = 4; - public int GridSize { get; set; } - - public double TimelineZoom { get; set; } = 1.0; - /// /// The time in milliseconds when last exiting the editor with this beatmap loaded. /// public double? EditorTimestamp { get; set; } - [Ignored] - public CountdownType Countdown { get; set; } = CountdownType.Normal; - - /// - /// The number of beats to move the countdown backwards (compared to its default location). - /// - public int CountdownOffset { get; set; } - - #endregion - public bool Equals(BeatmapInfo? other) { if (ReferenceEquals(this, other)) return true; @@ -275,9 +231,6 @@ namespace osu.Game.Beatmaps [Obsolete("Use ScoreManager.GetMaximumAchievableComboAsync instead.")] public int? MaxCombo { get; set; } - [Ignored] - public int[] Bookmarks { get; set; } = Array.Empty(); - public int BeatmapVersion; public BeatmapInfo Clone() => (BeatmapInfo)this.Detach().MemberwiseClone(); diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index cd818941ff..148bd90f28 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -15,6 +15,7 @@ using osu.Framework.Audio.Track; using osu.Framework.Extensions; using osu.Framework.IO.Stores; using osu.Framework.Platform; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Formats; using osu.Game.Database; using osu.Game.Extensions; @@ -154,9 +155,20 @@ namespace osu.Game.Beatmaps DifficultyName = NamingUtils.GetNextBestName(targetBeatmapSet.Beatmaps.Select(b => b.DifficultyName), "New Difficulty") }; var newBeatmap = new Beatmap { BeatmapInfo = newBeatmapInfo }; + foreach (var timingPoint in referenceWorkingBeatmap.Beatmap.ControlPointInfo.TimingPoints) newBeatmap.ControlPointInfo.Add(timingPoint.Time, timingPoint.DeepClone()); + foreach (var effectPoint in referenceWorkingBeatmap.Beatmap.ControlPointInfo.EffectPoints) + { + var clonedEffectPoint = (EffectControlPoint)effectPoint.DeepClone(); + + if (!rulesetInfo.Equals(referenceWorkingBeatmap.BeatmapInfo.Ruleset)) + clonedEffectPoint.ScrollSpeedBindable.SetDefault(); + + newBeatmap.ControlPointInfo.Add(clonedEffectPoint.Time, clonedEffectPoint); + } + return addDifficultyToSet(targetBeatmapSet, newBeatmap, referenceWorkingBeatmap.Skin); } @@ -285,7 +297,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 +326,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 => @@ -390,7 +420,7 @@ namespace osu.Game.Beatmaps // user requested abort return; - var video = b.Files.FirstOrDefault(f => OsuGameBase.VIDEO_EXTENSIONS.Any(ex => f.Filename.EndsWith(ex, StringComparison.OrdinalIgnoreCase))); + var video = b.Files.FirstOrDefault(f => SupportedExtensions.VIDEO_EXTENSIONS.Any(ex => f.Filename.EndsWith(ex, StringComparison.OrdinalIgnoreCase))); if (video != null) { @@ -541,7 +571,11 @@ namespace osu.Game.Beatmaps // If we seem to be missing files, now is a good time to re-fetch. bool missingFiles = beatmapInfo.BeatmapSet?.Files.Count == 0; - if (refetch || beatmapInfo.IsManaged || missingFiles) + if (beatmapInfo.IsManaged) + { + beatmapInfo = beatmapInfo.Detach(); + } + else if (refetch || missingFiles) { Guid id = beatmapInfo.ID; beatmapInfo = Realm.Run(r => r.Find(id)?.Detach()) ?? beatmapInfo; 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/DifficultyRecommender.cs b/osu.Game/Beatmaps/DifficultyRecommender.cs index ec00756fd9..d132b86052 100644 --- a/osu.Game/Beatmaps/DifficultyRecommender.cs +++ b/osu.Game/Beatmaps/DifficultyRecommender.cs @@ -9,9 +9,11 @@ using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; -using osu.Game.Online.API; +using osu.Game.Online; using osu.Game.Rulesets; +using osu.Game.Users; namespace osu.Game.Beatmaps { @@ -21,18 +23,63 @@ namespace osu.Game.Beatmaps /// public partial class DifficultyRecommender : Component { - [Resolved] - private IAPIProvider api { get; set; } + private readonly LocalUserStatisticsProvider statisticsProvider; [Resolved] - private Bindable ruleset { get; set; } + private Bindable gameRuleset { get; set; } + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; private readonly Dictionary recommendedDifficultyMapping = new Dictionary(); + /// + /// Rulesets ordered descending by their respective recommended difficulties. + /// The currently selected ruleset will always be first. + /// + private IEnumerable orderedRulesets + { + get + { + if (LoadState < LoadState.Ready || gameRuleset.Value == null) + return Enumerable.Empty(); + + return recommendedDifficultyMapping + .OrderByDescending(pair => pair.Value) + .Select(pair => pair.Key) + .Where(r => !r.Equals(gameRuleset.Value.ShortName, StringComparison.Ordinal)) + .Prepend(gameRuleset.Value.ShortName); + } + } + + public DifficultyRecommender(LocalUserStatisticsProvider statisticsProvider) + { + this.statisticsProvider = statisticsProvider; + } + [BackgroundDependencyLoader] private void load() { - api.LocalUser.BindValueChanged(_ => populateValues(), true); + foreach (var ruleset in rulesets.AvailableRulesets) + { + if (statisticsProvider.GetStatisticsFor(ruleset) is UserStatistics statistics) + updateMapping(ruleset, statistics); + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + statisticsProvider.StatisticsUpdated += onStatisticsUpdated; + } + + private void onStatisticsUpdated(UserStatisticsUpdate update) => updateMapping(update.Ruleset, update.NewStatistics); + + private void updateMapping(RulesetInfo ruleset, UserStatistics statistics) + { + // algorithm taken from https://github.com/ppy/osu-web/blob/e6e2825516449e3d0f3f5e1852c6bdd3428c3437/app/Models/User.php#L1505 + recommendedDifficultyMapping[ruleset.ShortName] = Math.Pow((double)(statistics.PP ?? 0), 0.4) * 0.195; } /// @@ -64,35 +111,12 @@ namespace osu.Game.Beatmaps return null; } - private void populateValues() + protected override void Dispose(bool isDisposing) { - if (api.LocalUser.Value.RulesetsStatistics == null) - return; + if (statisticsProvider.IsNotNull()) + statisticsProvider.StatisticsUpdated -= onStatisticsUpdated; - foreach (var kvp in api.LocalUser.Value.RulesetsStatistics) - { - // algorithm taken from https://github.com/ppy/osu-web/blob/e6e2825516449e3d0f3f5e1852c6bdd3428c3437/app/Models/User.php#L1505 - recommendedDifficultyMapping[kvp.Key] = Math.Pow((double)(kvp.Value.PP ?? 0), 0.4) * 0.195; - } - } - - /// - /// Rulesets ordered descending by their respective recommended difficulties. - /// The currently selected ruleset will always be first. - /// - private IEnumerable orderedRulesets - { - get - { - if (LoadState < LoadState.Ready || ruleset.Value == null) - return Enumerable.Empty(); - - return recommendedDifficultyMapping - .OrderByDescending(pair => pair.Value) - .Select(pair => pair.Key) - .Where(r => !r.Equals(ruleset.Value.ShortName, StringComparison.Ordinal)) - .Prepend(ruleset.Value.ShortName); - } + base.Dispose(isDisposing); } } } 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..153db6d6b9 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs @@ -17,6 +17,7 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Legacy; using osu.Game.Rulesets.Objects.Types; using osu.Game.Screens.Edit; +using osu.Game.Utils; namespace osu.Game.Beatmaps.Formats { @@ -38,8 +39,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,8 +80,9 @@ namespace osu.Game.Beatmaps.Formats { this.beatmap = beatmap; this.beatmap.BeatmapInfo.BeatmapVersion = FormatVersion; + parser = new ConvertHitObjectParser(getOffsetTime(), FormatVersion); - applyLegacyDefaults(this.beatmap.BeatmapInfo); + ApplyLegacyDefaults(this.beatmap); base.ParseStreamInto(stream, beatmap); @@ -162,7 +163,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 +177,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(); } } @@ -187,10 +190,9 @@ namespace osu.Game.Beatmaps.Formats /// This method's intention is to restore those legacy defaults. /// See also: https://osu.ppy.sh/wiki/en/Client/File_formats/Osu_%28file_format%29 /// - private static void applyLegacyDefaults(BeatmapInfo beatmapInfo) + internal static void ApplyLegacyDefaults(Beatmap beatmap) { - beatmapInfo.WidescreenStoryboard = false; - beatmapInfo.SamplesMatchPlaybackRate = false; + beatmap.WidescreenStoryboard = false; } protected override void ParseLine(Beatmap beatmap, Section section, string line) @@ -242,7 +244,7 @@ namespace osu.Game.Beatmaps.Formats break; case @"AudioLeadIn": - beatmap.BeatmapInfo.AudioLeadIn = Parsing.ParseInt(pair.Value); + beatmap.AudioLeadIn = Parsing.ParseInt(pair.Value); break; case @"PreviewTime": @@ -259,61 +261,39 @@ namespace osu.Game.Beatmaps.Formats break; case @"StackLeniency": - beatmap.BeatmapInfo.StackLeniency = Parsing.ParseFloat(pair.Value); + beatmap.StackLeniency = Parsing.ParseFloat(pair.Value); 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": - beatmap.BeatmapInfo.LetterboxInBreaks = Parsing.ParseInt(pair.Value) == 1; + beatmap.LetterboxInBreaks = Parsing.ParseInt(pair.Value) == 1; break; case @"SpecialStyle": - beatmap.BeatmapInfo.SpecialStyle = Parsing.ParseInt(pair.Value) == 1; + beatmap.SpecialStyle = Parsing.ParseInt(pair.Value) == 1; break; case @"WidescreenStoryboard": - beatmap.BeatmapInfo.WidescreenStoryboard = Parsing.ParseInt(pair.Value) == 1; + beatmap.WidescreenStoryboard = Parsing.ParseInt(pair.Value) == 1; break; case @"EpilepsyWarning": - beatmap.BeatmapInfo.EpilepsyWarning = Parsing.ParseInt(pair.Value) == 1; + beatmap.EpilepsyWarning = Parsing.ParseInt(pair.Value) == 1; break; case @"SamplesMatchPlaybackRate": - beatmap.BeatmapInfo.SamplesMatchPlaybackRate = Parsing.ParseInt(pair.Value) == 1; + beatmap.SamplesMatchPlaybackRate = Parsing.ParseInt(pair.Value) == 1; break; case @"Countdown": - beatmap.BeatmapInfo.Countdown = Enum.Parse(pair.Value); + beatmap.Countdown = Enum.Parse(pair.Value); break; case @"CountdownOffset": - beatmap.BeatmapInfo.CountdownOffset = Parsing.ParseInt(pair.Value); + beatmap.CountdownOffset = Parsing.ParseInt(pair.Value); break; } } @@ -325,7 +305,7 @@ namespace osu.Game.Beatmaps.Formats switch (pair.Key) { case @"Bookmarks": - beatmap.BeatmapInfo.Bookmarks = pair.Value.Split(',').Select(v => + beatmap.Bookmarks = pair.Value.Split(',').Select(v => { bool result = int.TryParse(v, out int val); return new { result, val }; @@ -333,7 +313,7 @@ namespace osu.Game.Beatmaps.Formats break; case @"DistanceSpacing": - beatmap.BeatmapInfo.DistanceSpacing = Math.Max(0, Parsing.ParseDouble(pair.Value)); + beatmap.DistanceSpacing = Math.Max(0, Parsing.ParseDouble(pair.Value)); break; case @"BeatDivisor": @@ -341,11 +321,11 @@ namespace osu.Game.Beatmaps.Formats break; case @"GridSize": - beatmap.BeatmapInfo.GridSize = Parsing.ParseInt(pair.Value); + beatmap.GridSize = Parsing.ParseInt(pair.Value); break; case @"TimelineZoom": - beatmap.BeatmapInfo.TimelineZoom = Math.Max(0, Parsing.ParseDouble(pair.Value)); + beatmap.TimelineZoom = Math.Max(0, Parsing.ParseDouble(pair.Value)); break; } } @@ -467,7 +447,7 @@ namespace osu.Game.Beatmaps.Formats // Some very old beatmaps had incorrect type specifications for their backgrounds (ie. using 1 for VIDEO // instead of 0 for BACKGROUND). To handle this gracefully, check the file extension against known supported // video extensions and handle similar to a background if it doesn't match. - if (!OsuGameBase.VIDEO_EXTENSIONS.Contains(Path.GetExtension(filename).ToLowerInvariant())) + if (!SupportedExtensions.VIDEO_EXTENSIONS.Contains(Path.GetExtension(filename).ToLowerInvariant())) { beatmap.BeatmapInfo.Metadata.BackgroundFile = filename; lineSupportedByEncoder = true; @@ -617,17 +597,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..6c855e1346 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs @@ -79,14 +79,14 @@ namespace osu.Game.Beatmaps.Formats writer.WriteLine("[General]"); if (!string.IsNullOrEmpty(beatmap.Metadata.AudioFile)) writer.WriteLine(FormattableString.Invariant($"AudioFilename: {Path.GetFileName(beatmap.Metadata.AudioFile)}")); - writer.WriteLine(FormattableString.Invariant($"AudioLeadIn: {beatmap.BeatmapInfo.AudioLeadIn}")); + writer.WriteLine(FormattableString.Invariant($"AudioLeadIn: {beatmap.AudioLeadIn}")); writer.WriteLine(FormattableString.Invariant($"PreviewTime: {beatmap.Metadata.PreviewTime}")); - writer.WriteLine(FormattableString.Invariant($"Countdown: {(int)beatmap.BeatmapInfo.Countdown}")); + writer.WriteLine(FormattableString.Invariant($"Countdown: {(int)beatmap.Countdown}")); writer.WriteLine(FormattableString.Invariant( $"SampleSet: {toLegacySampleBank(((beatmap.ControlPointInfo as LegacyControlPointInfo)?.SamplePoints.FirstOrDefault() ?? SampleControlPoint.DEFAULT).SampleBank)}")); - writer.WriteLine(FormattableString.Invariant($"StackLeniency: {beatmap.BeatmapInfo.StackLeniency}")); + writer.WriteLine(FormattableString.Invariant($"StackLeniency: {beatmap.StackLeniency}")); writer.WriteLine(FormattableString.Invariant($"Mode: {onlineRulesetID}")); - writer.WriteLine(FormattableString.Invariant($"LetterboxInBreaks: {(beatmap.BeatmapInfo.LetterboxInBreaks ? '1' : '0')}")); + writer.WriteLine(FormattableString.Invariant($"LetterboxInBreaks: {(beatmap.LetterboxInBreaks ? '1' : '0')}")); // if (beatmap.BeatmapInfo.UseSkinSprites) // writer.WriteLine(@"UseSkinSprites: 1"); // if (b.AlwaysShowPlayfield) @@ -95,14 +95,14 @@ namespace osu.Game.Beatmaps.Formats // writer.WriteLine(@"OverlayPosition: " + b.OverlayPosition); // if (!string.IsNullOrEmpty(b.SkinPreference)) // writer.WriteLine(@"SkinPreference:" + b.SkinPreference); - if (beatmap.BeatmapInfo.EpilepsyWarning) + if (beatmap.EpilepsyWarning) writer.WriteLine(@"EpilepsyWarning: 1"); - if (beatmap.BeatmapInfo.CountdownOffset > 0) - writer.WriteLine(FormattableString.Invariant($@"CountdownOffset: {beatmap.BeatmapInfo.CountdownOffset}")); + if (beatmap.CountdownOffset > 0) + writer.WriteLine(FormattableString.Invariant($@"CountdownOffset: {beatmap.CountdownOffset}")); if (onlineRulesetID == 3) - writer.WriteLine(FormattableString.Invariant($"SpecialStyle: {(beatmap.BeatmapInfo.SpecialStyle ? '1' : '0')}")); - writer.WriteLine(FormattableString.Invariant($"WidescreenStoryboard: {(beatmap.BeatmapInfo.WidescreenStoryboard ? '1' : '0')}")); - if (beatmap.BeatmapInfo.SamplesMatchPlaybackRate) + writer.WriteLine(FormattableString.Invariant($"SpecialStyle: {(beatmap.SpecialStyle ? '1' : '0')}")); + writer.WriteLine(FormattableString.Invariant($"WidescreenStoryboard: {(beatmap.WidescreenStoryboard ? '1' : '0')}")); + if (beatmap.SamplesMatchPlaybackRate) writer.WriteLine(@"SamplesMatchPlaybackRate: 1"); } @@ -110,12 +110,12 @@ namespace osu.Game.Beatmaps.Formats { writer.WriteLine("[Editor]"); - if (beatmap.BeatmapInfo.Bookmarks.Length > 0) - writer.WriteLine(FormattableString.Invariant($"Bookmarks: {string.Join(',', beatmap.BeatmapInfo.Bookmarks)}")); - writer.WriteLine(FormattableString.Invariant($"DistanceSpacing: {beatmap.BeatmapInfo.DistanceSpacing}")); + if (beatmap.Bookmarks.Length > 0) + writer.WriteLine(FormattableString.Invariant($"Bookmarks: {string.Join(',', beatmap.Bookmarks)}")); + writer.WriteLine(FormattableString.Invariant($"DistanceSpacing: {beatmap.DistanceSpacing}")); writer.WriteLine(FormattableString.Invariant($"BeatDivisor: {beatmap.BeatmapInfo.BeatDivisor}")); - writer.WriteLine(FormattableString.Invariant($"GridSize: {beatmap.BeatmapInfo.GridSize}")); - writer.WriteLine(FormattableString.Invariant($"TimelineZoom: {beatmap.BeatmapInfo.TimelineZoom}")); + writer.WriteLine(FormattableString.Invariant($"GridSize: {beatmap.GridSize}")); + writer.WriteLine(FormattableString.Invariant($"TimelineZoom: {beatmap.TimelineZoom}")); } private void handleMetadata(TextWriter writer) @@ -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/Formats/LegacyStoryboardDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs index 2f9a256d31..7ee1010079 100644 --- a/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs @@ -10,6 +10,7 @@ using osu.Game.Beatmaps.Legacy; using osu.Game.IO; using osu.Game.Storyboards; using osu.Game.Storyboards.Commands; +using osu.Game.Utils; using osuTK; using osuTK.Graphics; @@ -37,6 +38,17 @@ namespace osu.Game.Beatmaps.Formats SetFallbackDecoder(() => new LegacyStoryboardDecoder()); } + protected override Storyboard CreateTemplateObject() + { + var sb = base.CreateTemplateObject(); + + var beatmap = new Beatmap(); + LegacyBeatmapDecoder.ApplyLegacyDefaults(beatmap); + sb.Beatmap = beatmap; + + return sb; + } + protected override void ParseStreamInto(LineBufferedReader stream, Storyboard storyboard) { this.storyboard = storyboard; @@ -72,6 +84,10 @@ namespace osu.Game.Beatmaps.Formats case "UseSkinSprites": storyboard.UseSkinSprites = pair.Value == "1"; break; + + case @"WidescreenStoryboard": + storyboard.Beatmap.WidescreenStoryboard = Parsing.ParseInt(pair.Value) == 1; + break; } } @@ -112,7 +128,7 @@ namespace osu.Game.Beatmaps.Formats // // This avoids potential weird crashes when ffmpeg attempts to parse an image file as a video // (see https://github.com/ppy/osu/issues/22829#issuecomment-1465552451). - if (!OsuGameBase.VIDEO_EXTENSIONS.Contains(Path.GetExtension(path).ToLowerInvariant())) + if (!SupportedExtensions.VIDEO_EXTENSIONS.Contains(Path.GetExtension(path).ToLowerInvariant())) break; storyboard.GetLayer("Video").Add(storyboardSprite = new StoryboardVideo(path, offset)); diff --git a/osu.Game/Beatmaps/IBeatmap.cs b/osu.Game/Beatmaps/IBeatmap.cs index 430a31769b..826d4e19a7 100644 --- a/osu.Game/Beatmaps/IBeatmap.cs +++ b/osu.Game/Beatmaps/IBeatmap.cs @@ -7,6 +7,7 @@ using System.Linq; using osu.Framework.Lists; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Timing; +using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; @@ -69,6 +70,45 @@ namespace osu.Game.Beatmaps /// double GetMostCommonBeatLength(); + double AudioLeadIn { get; internal set; } + + float StackLeniency { get; internal set; } + + bool SpecialStyle { get; internal set; } + + bool LetterboxInBreaks { get; internal set; } + + bool WidescreenStoryboard { get; internal set; } + + bool EpilepsyWarning { get; internal set; } + + bool SamplesMatchPlaybackRate { get; internal set; } + + /// + /// The ratio of distance travelled per time unit. + /// Generally used to decouple the spacing between hit objects from the enforced "velocity" of the beatmap (see ). + /// + /// + /// The most common method of understanding is that at a default value of 1.0, the time-to-distance ratio will match the slider velocity of the beatmap + /// at the current point in time. Increasing this value will make hit objects more spaced apart when compared to the cursor movement required to track a slider. + /// + /// This is only a hint property, used by the editor in implementations. It does not directly affect the beatmap or gameplay. + /// + double DistanceSpacing { get; internal set; } + + int GridSize { get; internal set; } + + double TimelineZoom { get; internal set; } + + CountdownType Countdown { get; internal set; } + + /// + /// The number of beats to move the countdown backwards (compared to its default location). + /// + int CountdownOffset { get; internal set; } + + int[] Bookmarks { get; internal set; } + /// /// Creates a shallow-clone of this beatmap and returns it. /// 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 07bf4c028a..890a969415 100644 --- a/osu.Game/Beatmaps/WorkingBeatmap.cs +++ b/osu.Game/Beatmaps/WorkingBeatmap.cs @@ -62,7 +62,12 @@ namespace osu.Game.Beatmaps #region Resource getters protected virtual Waveform GetWaveform() => new Waveform(null); - protected virtual Storyboard GetStoryboard() => new Storyboard { BeatmapInfo = BeatmapInfo }; + + protected virtual Storyboard GetStoryboard() => new Storyboard + { + BeatmapInfo = BeatmapInfo, + Beatmap = Beatmap, + }; protected abstract IBeatmap GetBeatmap(); public abstract Texture GetBackground(); 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..4f62db8cf7 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,12 +132,13 @@ 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); SetDefault(OsuSetting.HitLighting, true); + SetDefault(OsuSetting.StarFountains, true); SetDefault(OsuSetting.HUDVisibilityMode, HUDVisibilityMode.Always); SetDefault(OsuSetting.ShowHealthDisplayWhenCantFail, true); @@ -168,13 +170,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 +195,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 +209,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) @@ -406,6 +415,7 @@ namespace osu.Game.Configuration NotifyOnPrivateMessage, UIHoldActivationDelay, HitLighting, + StarFountains, MenuBackgroundSource, GameplayDisableWinKey, SeasonalBackgroundMode, @@ -431,6 +441,12 @@ namespace osu.Game.Configuration HideCountryFlags, EditorTimelineShowTimingChanges, EditorTimelineShowTicks, - AlwaysShowHoldForMenuButton + AlwaysShowHoldForMenuButton, + EditorContractSidebars, + EditorScaleOrigin, + EditorRotationOrigin, + EditorTimelineShowBreaks, + EditorAdjustExistingObjectsOnTimingChanges, + AlwaysRequireHoldingForPause } } diff --git a/osu.Game/Configuration/SessionStatics.cs b/osu.Game/Configuration/SessionStatics.cs index 225f209380..18631f5d00 100644 --- a/osu.Game/Configuration/SessionStatics.cs +++ b/osu.Game/Configuration/SessionStatics.cs @@ -10,6 +10,7 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.Mods; using osu.Game.Scoring; +using osu.Game.Screens.Play; namespace osu.Game.Configuration { @@ -77,7 +78,8 @@ namespace osu.Game.Configuration TouchInputActive, /// - /// Stores the local user's last score (can be completed or aborted). + /// Contains the local user's last score (can be completed or aborted) after exiting . + /// Will be cleared to null when leaving . /// LastLocalUserScore, diff --git a/osu.Game/Database/BackgroundDataStoreProcessor.cs b/osu.Game/Database/BackgroundDataStoreProcessor.cs index 3efd4da3aa..1512b6be93 100644 --- a/osu.Game/Database/BackgroundDataStoreProcessor.cs +++ b/osu.Game/Database/BackgroundDataStoreProcessor.cs @@ -46,7 +46,7 @@ namespace osu.Game.Database private RealmAccess realmAccess { get; set; } = null!; [Resolved] - private BeatmapUpdater beatmapUpdater { get; set; } = null!; + private IBeatmapUpdater beatmapUpdater { get; set; } = null!; [Resolved] private IBindable gameBeatmap { get; set; } = null!; diff --git a/osu.Game/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 ad0423191d..a520040ad1 100644 --- a/osu.Game/Database/RealmAccess.cs +++ b/osu.Game/Database/RealmAccess.cs @@ -93,8 +93,10 @@ 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. + /// 44 2024-11-22 Removed several properties from BeatmapInfo which did not need to be persisted to realm. /// - private const int schema_version = 42; + private const int schema_version = 44; /// /// Lock object which is held during sections, blocking realm retrieval during blocking periods. @@ -375,10 +377,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); } @@ -1192,6 +1190,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/RealmObjectExtensions.cs b/osu.Game/Database/RealmObjectExtensions.cs index 2fa3b8a880..df725505fc 100644 --- a/osu.Game/Database/RealmObjectExtensions.cs +++ b/osu.Game/Database/RealmObjectExtensions.cs @@ -248,29 +248,30 @@ namespace osu.Game.Database return new RealmLive(realmObject, realm); } +#pragma warning disable RS0030 // mentioning banned symbols in documentation /// - /// Register a callback to be invoked each time this changes. + /// Register a callback to be invoked each time this changes. /// /// /// /// This adds osu! specific thread and managed state safety checks on top of . /// /// - /// The first callback will be invoked with the initial after the asynchronous query completes, + /// The first callback will be invoked with the initial after the asynchronous query completes, /// and then called again after each write transaction which changes either any of the objects in the collection, or /// which objects are in the collection. The changes parameter will /// be null the first time the callback is invoked with the initial results. For each call after that, /// it will contain information about which rows in the results were added, removed or modified. /// /// - /// If a write transaction did not modify any objects in this , the callback is not invoked at all. + /// If a write transaction did not modify any objects in this , the callback is not invoked at all. /// If an error occurs the callback will be invoked with null for the sender parameter and a non-null error. - /// Currently the only errors that can occur are when opening the on the background worker thread. + /// Currently the only errors that can occur are when opening the on the background worker thread. /// /// - /// At the time when the block is called, the object will be fully evaluated + /// At the time when the block is called, the object will be fully evaluated /// and up-to-date, and as long as you do not perform a write transaction on the same thread - /// or explicitly call , accessing it will never perform blocking work. + /// or explicitly call , accessing it will never perform blocking work. /// /// /// Notifications are delivered via the standard event loop, and so can't be delivered while the event loop is blocked by other activity. @@ -279,13 +280,14 @@ namespace osu.Game.Database /// /// /// The to observe for changes. - /// The callback to be invoked with the updated . + /// The callback to be invoked with the updated . /// /// A subscription token. It must be kept alive for as long as you want to receive change notifications. - /// To stop receiving notifications, call . + /// To stop receiving notifications, call . /// - /// - /// + /// + /// +#pragma warning restore RS0030 public static IDisposable QueryAsyncWithNotifications(this IRealmCollection collection, NotificationCallbackDelegate callback) where T : RealmObjectBase { diff --git a/osu.Game/Database/StandardisedScoreMigrationTools.cs b/osu.Game/Database/StandardisedScoreMigrationTools.cs index db44731bed..15e3da3c19 100644 --- a/osu.Game/Database/StandardisedScoreMigrationTools.cs +++ b/osu.Game/Database/StandardisedScoreMigrationTools.cs @@ -245,8 +245,8 @@ namespace osu.Game.Database var scoreProcessor = ruleset.CreateScoreProcessor(); // warning: ordering is important here - both total score and ranks are dependent on accuracy! - score.Accuracy = computeAccuracy(score, scoreProcessor); - score.Rank = computeRank(score, scoreProcessor); + score.Accuracy = ComputeAccuracy(score, scoreProcessor); + score.Rank = ComputeRank(score, scoreProcessor); (score.TotalScoreWithoutMods, score.TotalScore) = convertFromLegacyTotalScore(score, ruleset, beatmap); } @@ -269,8 +269,8 @@ namespace osu.Game.Database var scoreProcessor = ruleset.CreateScoreProcessor(); // warning: ordering is important here - both total score and ranks are dependent on accuracy! - score.Accuracy = computeAccuracy(score, scoreProcessor); - score.Rank = computeRank(score, scoreProcessor); + score.Accuracy = ComputeAccuracy(score, scoreProcessor); + score.Rank = ComputeRank(score, scoreProcessor); (score.TotalScoreWithoutMods, score.TotalScore) = convertFromLegacyTotalScore(score, ruleset, difficulty, attributes); } @@ -313,7 +313,8 @@ namespace osu.Game.Database /// The beatmap difficulty. /// The legacy scoring attributes for the beatmap which the score was set on. /// The standardised total score. - private static (long withoutMods, long withMods) convertFromLegacyTotalScore(ScoreInfo score, Ruleset ruleset, LegacyBeatmapConversionDifficultyInfo difficulty, LegacyScoreAttributes attributes) + private static (long withoutMods, long withMods) convertFromLegacyTotalScore(ScoreInfo score, Ruleset ruleset, LegacyBeatmapConversionDifficultyInfo difficulty, + LegacyScoreAttributes attributes) { if (!score.IsLegacyScore) return (score.TotalScoreWithoutMods, score.TotalScore); @@ -620,24 +621,31 @@ namespace osu.Game.Database } } - private static double computeAccuracy(ScoreInfo scoreInfo, ScoreProcessor scoreProcessor) + public static double ComputeAccuracy(ScoreInfo scoreInfo, ScoreProcessor scoreProcessor) + => ComputeAccuracy(scoreInfo.Statistics, scoreInfo.MaximumStatistics, scoreProcessor); + + public static double ComputeAccuracy(IReadOnlyDictionary statistics, IReadOnlyDictionary maximumStatistics, ScoreProcessor scoreProcessor) { - int baseScore = scoreInfo.Statistics.Where(kvp => kvp.Key.AffectsAccuracy()) - .Sum(kvp => kvp.Value * scoreProcessor.GetBaseScoreForResult(kvp.Key)); - int maxBaseScore = scoreInfo.MaximumStatistics.Where(kvp => kvp.Key.AffectsAccuracy()) - .Sum(kvp => kvp.Value * scoreProcessor.GetBaseScoreForResult(kvp.Key)); + int baseScore = statistics.Where(kvp => kvp.Key.AffectsAccuracy()) + .Sum(kvp => kvp.Value * scoreProcessor.GetBaseScoreForResult(kvp.Key)); + int maxBaseScore = maximumStatistics.Where(kvp => kvp.Key.AffectsAccuracy()) + .Sum(kvp => kvp.Value * scoreProcessor.GetBaseScoreForResult(kvp.Key)); return maxBaseScore == 0 ? 1 : baseScore / (double)maxBaseScore; } - public static ScoreRank ComputeRank(ScoreInfo scoreInfo) => computeRank(scoreInfo, scoreInfo.Ruleset.CreateInstance().CreateScoreProcessor()); + public static ScoreRank ComputeRank(ScoreInfo scoreInfo) => + ComputeRank(scoreInfo.Accuracy, scoreInfo.Statistics, scoreInfo.Mods, scoreInfo.Ruleset.CreateInstance().CreateScoreProcessor()); - private static ScoreRank computeRank(ScoreInfo scoreInfo, ScoreProcessor scoreProcessor) + public static ScoreRank ComputeRank(ScoreInfo scoreInfo, ScoreProcessor processor) => + ComputeRank(scoreInfo.Accuracy, scoreInfo.Statistics, scoreInfo.Mods, processor); + + public static ScoreRank ComputeRank(double accuracy, IReadOnlyDictionary statistics, IList mods, ScoreProcessor scoreProcessor) { - var rank = scoreProcessor.RankFromScore(scoreInfo.Accuracy, scoreInfo.Statistics); + var rank = scoreProcessor.RankFromScore(accuracy, statistics); - foreach (var mod in scoreInfo.Mods.OfType()) - rank = mod.AdjustRank(rank, scoreInfo.Accuracy); + foreach (var mod in mods.OfType()) + rank = mod.AdjustRank(rank, accuracy); return rank; } 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/Extensions/StringDehumanizeExtensions.cs b/osu.Game/Extensions/StringDehumanizeExtensions.cs index 6f0d7622d3..5993f83b55 100644 --- a/osu.Game/Extensions/StringDehumanizeExtensions.cs +++ b/osu.Game/Extensions/StringDehumanizeExtensions.cs @@ -60,7 +60,7 @@ namespace osu.Game.Extensions public static string ToCamelCase(this string input) { string word = input.ToPascalCase(); - return word.Length > 0 ? word.Substring(0, 1).ToLowerInvariant() + word.Substring(1) : word; + return word.Length > 0 ? char.ToLowerInvariant(word[0]) + word.Substring(1) : word; } /// 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/Cursor/OsuContextMenuContainer.cs b/osu.Game/Graphics/Cursor/OsuContextMenuContainer.cs index 7b21a413f7..3180661b0c 100644 --- a/osu.Game/Graphics/Cursor/OsuContextMenuContainer.cs +++ b/osu.Game/Graphics/Cursor/OsuContextMenuContainer.cs @@ -11,16 +11,8 @@ namespace osu.Game.Graphics.Cursor [Cached(typeof(OsuContextMenuContainer))] public partial class OsuContextMenuContainer : ContextMenuContainer { - [Cached] - private OsuContextMenuSamples samples = new OsuContextMenuSamples(); - private OsuContextMenu menu = null!; - public OsuContextMenuContainer() - { - AddInternal(samples); - } - protected override Menu CreateMenu() => menu = new OsuContextMenu(true); public void CloseMenu() 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/HoverSampleSet.cs b/osu.Game/Graphics/UserInterface/HoverSampleSet.cs index 5b0fbc693e..62eb765cc8 100644 --- a/osu.Game/Graphics/UserInterface/HoverSampleSet.cs +++ b/osu.Game/Graphics/UserInterface/HoverSampleSet.cs @@ -23,6 +23,9 @@ namespace osu.Game.Graphics.UserInterface DialogCancel, [Description("dialog-ok")] - DialogOk + DialogOk, + + [Description("menu-open")] + MenuOpen, } } diff --git a/osu.Game/Graphics/UserInterface/OsuContextMenu.cs b/osu.Game/Graphics/UserInterface/OsuContextMenu.cs index 96797e5d01..433d37834f 100644 --- a/osu.Game/Graphics/UserInterface/OsuContextMenu.cs +++ b/osu.Game/Graphics/UserInterface/OsuContextMenu.cs @@ -15,7 +15,7 @@ namespace osu.Game.Graphics.UserInterface private const int fade_duration = 250; [Resolved] - private OsuContextMenuSamples samples { get; set; } = null!; + private OsuMenuSamples menuSamples { get; set; } = null!; // todo: this shouldn't be required after https://github.com/ppy/osu-framework/issues/4519 is fixed. private bool wasOpened; @@ -47,15 +47,14 @@ namespace osu.Game.Graphics.UserInterface protected override void AnimateOpen() { + wasOpened = true; this.FadeIn(fade_duration, Easing.OutQuint); - if (playClickSample) - samples.PlayClickSample(); + if (!playClickSample) + return; - if (!wasOpened) - samples.PlayOpenSample(); - - wasOpened = true; + menuSamples.PlayClickSample(); + menuSamples.PlayOpenSample(); } protected override void AnimateClose() @@ -63,7 +62,7 @@ namespace osu.Game.Graphics.UserInterface this.FadeOut(fade_duration, Easing.OutQuint); if (wasOpened) - samples.PlayCloseSample(); + menuSamples.PlayCloseSample(); wasOpened = false; } diff --git a/osu.Game/Graphics/UserInterface/OsuContextMenuSamples.cs b/osu.Game/Graphics/UserInterface/OsuContextMenuSamples.cs deleted file mode 100644 index 6d7543c472..0000000000 --- a/osu.Game/Graphics/UserInterface/OsuContextMenuSamples.cs +++ /dev/null @@ -1,37 +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.Audio; -using osu.Framework.Audio.Sample; -using osu.Framework.Extensions; -using osu.Framework.Graphics; - -namespace osu.Game.Graphics.UserInterface -{ - public partial class OsuContextMenuSamples : Component - { - private Sample sampleClick; - private Sample sampleOpen; - private Sample sampleClose; - - [BackgroundDependencyLoader] - private void load(AudioManager audio) - { - sampleClick = audio.Samples.Get($@"UI/{HoverSampleSet.Default.GetDescription()}-select"); - sampleOpen = audio.Samples.Get(@"UI/dropdown-open"); - sampleClose = audio.Samples.Get(@"UI/dropdown-close"); - } - - public void PlayClickSample() => Scheduler.AddOnce(playClickSample); - private void playClickSample() => sampleClick.Play(); - - public void PlayOpenSample() => Scheduler.AddOnce(playOpenSample); - private void playOpenSample() => sampleOpen.Play(); - - public void PlayCloseSample() => Scheduler.AddOnce(playCloseSample); - private void playCloseSample() => sampleClose.Play(); - } -} diff --git a/osu.Game/Graphics/UserInterface/OsuMenu.cs b/osu.Game/Graphics/UserInterface/OsuMenu.cs index 6e7dad2b5f..7cc1bab25f 100644 --- a/osu.Game/Graphics/UserInterface/OsuMenu.cs +++ b/osu.Game/Graphics/UserInterface/OsuMenu.cs @@ -4,8 +4,6 @@ #nullable disable using osu.Framework.Allocation; -using osu.Framework.Audio; -using osu.Framework.Audio.Sample; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -20,12 +18,12 @@ namespace osu.Game.Graphics.UserInterface { public partial class OsuMenu : Menu { - private Sample sampleOpen; - private Sample sampleClose; - // todo: this shouldn't be required after https://github.com/ppy/osu-framework/issues/4519 is fixed. private bool wasOpened; + [Resolved] + private OsuMenuSamples menuSamples { get; set; } = null!; + public OsuMenu(Direction direction, bool topLevelMenu = false) : base(direction, topLevelMenu) { @@ -33,13 +31,8 @@ namespace osu.Game.Graphics.UserInterface MaskingContainer.CornerRadius = 4; ItemsContainer.Padding = new MarginPadding(5); - } - [BackgroundDependencyLoader] - private void load(AudioManager audio) - { - sampleOpen = audio.Samples.Get(@"UI/dropdown-open"); - sampleClose = audio.Samples.Get(@"UI/dropdown-close"); + OnSubmenuOpen += _ => { menuSamples?.PlaySubOpenSample(); }; } protected override void Update() @@ -64,7 +57,7 @@ namespace osu.Game.Graphics.UserInterface protected override void AnimateOpen() { if (!TopLevelMenu && !wasOpened) - sampleOpen?.Play(); + menuSamples?.PlayOpenSample(); this.FadeIn(300, Easing.OutQuint); wasOpened = true; @@ -73,7 +66,7 @@ namespace osu.Game.Graphics.UserInterface protected override void AnimateClose() { if (!TopLevelMenu && wasOpened) - sampleClose?.Play(); + menuSamples?.PlayCloseSample(); this.FadeOut(300, Easing.OutQuint); wasOpened = false; diff --git a/osu.Game/Graphics/UserInterface/OsuMenuSamples.cs b/osu.Game/Graphics/UserInterface/OsuMenuSamples.cs new file mode 100644 index 0000000000..779671b6ad --- /dev/null +++ b/osu.Game/Graphics/UserInterface/OsuMenuSamples.cs @@ -0,0 +1,70 @@ +// 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; +using osu.Framework.Graphics; + +namespace osu.Game.Graphics.UserInterface +{ + public partial class OsuMenuSamples : Component + { + private Sample sampleClick; + private Sample sampleOpen; + private Sample sampleSubOpen; + private Sample sampleClose; + + private bool triggerOpen; + private bool triggerSubOpen; + private bool triggerClose; + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + sampleClick = audio.Samples.Get(@"UI/menu-open-select"); + sampleOpen = audio.Samples.Get(@"UI/menu-open"); + sampleSubOpen = audio.Samples.Get(@"UI/menu-sub-open"); + sampleClose = audio.Samples.Get(@"UI/menu-close"); + } + + public void PlayClickSample() + { + Scheduler.AddOnce(playClickSample); + } + + public void PlayOpenSample() + { + triggerOpen = true; + Scheduler.AddOnce(resolvePlayback); + } + + public void PlaySubOpenSample() + { + triggerSubOpen = true; + Scheduler.AddOnce(resolvePlayback); + } + + public void PlayCloseSample() + { + triggerClose = true; + Scheduler.AddOnce(resolvePlayback); + } + + private void playClickSample() => sampleClick.Play(); + + private void resolvePlayback() + { + if (triggerSubOpen) + sampleSubOpen?.Play(); + else if (triggerOpen) + sampleOpen?.Play(); + else if (triggerClose) + sampleClose?.Play(); + + triggerOpen = triggerSubOpen = triggerClose = false; + } + } +} 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/FormSliderBar.cs b/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs index da28437eee..532423876e 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs @@ -71,7 +71,7 @@ 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!; @@ -135,7 +135,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 }, TabbableContentContainer = tabbableContentContainer, }, - slider = new Slider + slider = new InnerSlider { Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, @@ -163,6 +163,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 textBox.Current.BindValueChanged(textChanged); slider.IsDragging.BindValueChanged(_ => updateState()); + slider.Focused.BindValueChanged(_ => updateState()); current.ValueChanged += e => currentNumberInstantaneous.Value = e.NewValue; current.MinValueChanged += v => currentNumberInstantaneous.MinValue = v; @@ -259,16 +260,18 @@ namespace osu.Game.Graphics.UserInterfaceV2 private void updateState() { + bool childHasFocus = slider.Focused.Value || textBox.Focused.Value; + textBox.Alpha = 1; 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); @@ -283,8 +286,10 @@ namespace osu.Game.Graphics.UserInterfaceV2 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; } @@ -344,7 +349,6 @@ namespace osu.Game.Graphics.UserInterfaceV2 protected override void LoadComplete() { base.LoadComplete(); - updateState(); } @@ -382,11 +386,25 @@ 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) diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs b/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs index c7b559d9ed..addea5c4a9 100644 --- a/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs +++ b/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs @@ -13,6 +13,7 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterfaceV2.FileSelection; using osu.Game.Overlays; +using osu.Game.Utils; namespace osu.Game.Graphics.UserInterfaceV2 { @@ -96,24 +97,18 @@ namespace osu.Game.Graphics.UserInterfaceV2 { get { - if (OsuGameBase.VIDEO_EXTENSIONS.Contains(File.Extension.ToLowerInvariant())) + string extension = File.Extension.ToLowerInvariant(); + + if (SupportedExtensions.VIDEO_EXTENSIONS.Contains(extension)) return FontAwesome.Regular.FileVideo; - switch (File.Extension) - { - case @".ogg": - case @".mp3": - case @".wav": - return FontAwesome.Regular.FileAudio; + if (SupportedExtensions.AUDIO_EXTENSIONS.Contains(extension)) + return FontAwesome.Regular.FileAudio; - case @".jpg": - case @".jpeg": - case @".png": - return FontAwesome.Regular.FileImage; + if (SupportedExtensions.IMAGE_EXTENSIONS.Contains(extension)) + return FontAwesome.Regular.FileImage; - default: - return FontAwesome.Regular.File; - } + return FontAwesome.Regular.File; } } diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index aca0984e0f..170d247023 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), @@ -151,6 +152,10 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.Control, InputKey.Right }, GlobalAction.EditorSeekToNextHitObject), new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.Left }, GlobalAction.EditorSeekToPreviousSamplePoint), new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.Right }, GlobalAction.EditorSeekToNextSamplePoint), + new KeyBinding(new[] { InputKey.Control, InputKey.B }, GlobalAction.EditorAddBookmark), + new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.B }, GlobalAction.EditorRemoveClosestBookmark), + new KeyBinding(new[] { InputKey.Alt, InputKey.Left }, GlobalAction.EditorSeekToPreviousBookmark), + new KeyBinding(new[] { InputKey.Alt, InputKey.Right }, GlobalAction.EditorSeekToNextBookmark), }; private static IEnumerable editorTestPlayKeyBindings => new[] @@ -368,8 +373,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 +477,21 @@ namespace osu.Game.Input.Bindings [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorSeekToNextSamplePoint))] EditorSeekToNextSamplePoint, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorCycleGridType))] + EditorCycleGridType, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorAddBookmark))] + EditorAddBookmark, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorRemoveClosestBookmark))] + EditorRemoveClosestBookmark, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorSeekToPreviousBookmark))] + EditorSeekToPreviousBookmark, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorSeekToNextBookmark))] + EditorSeekToNextBookmark, } public enum GlobalActionCategory 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/DebugSettingsStrings.cs b/osu.Game/Localisation/DebugSettingsStrings.cs index 066c07858c..bdb0348981 100644 --- a/osu.Game/Localisation/DebugSettingsStrings.cs +++ b/osu.Game/Localisation/DebugSettingsStrings.cs @@ -9,21 +9,6 @@ namespace osu.Game.Localisation { private const string prefix = @"osu.Game.Resources.Localisation.DebugSettings"; - /// - /// "Debug" - /// - public static LocalisableString DebugSectionHeader => new TranslatableString(getKey(@"debug_section_header"), @"Debug"); - - /// - /// "Show log overlay" - /// - public static LocalisableString ShowLogOverlay => new TranslatableString(getKey(@"show_log_overlay"), @"Show log overlay"); - - /// - /// "Bypass front-to-back render pass" - /// - public static LocalisableString BypassFrontToBackPass => new TranslatableString(getKey(@"bypass_front_to_back_pass"), @"Bypass front-to-back render pass"); - /// /// "Import files" /// @@ -34,16 +19,6 @@ namespace osu.Game.Localisation /// public static LocalisableString RunLatencyCertifier => new TranslatableString(getKey(@"run_latency_certifier"), @"Run latency certifier"); - /// - /// "Memory" - /// - public static LocalisableString MemoryHeader => new TranslatableString(getKey(@"memory_header"), @"Memory"); - - /// - /// "Clear all caches" - /// - public static LocalisableString ClearAllCaches => new TranslatableString(getKey(@"clear_all_caches"), @"Clear all caches"); - private static string getKey(string key) => $"{prefix}:{key}"; } } 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/EditorStrings.cs b/osu.Game/Localisation/EditorStrings.cs index bcffc18d4d..3b4026be11 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,11 +144,46 @@ 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" /// public static LocalisableString TimelineShowTicks => new TranslatableString(getKey(@"timeline_show_ticks"), @"Show ticks"); + /// + /// "Bookmarks" + /// + public static LocalisableString Bookmarks => new TranslatableString(getKey(@"bookmarks"), @"Bookmarks"); + + /// + /// "Add bookmark" + /// + public static LocalisableString AddBookmark => new TranslatableString(getKey(@"add_bookmark"), @"Add bookmark"); + + /// + /// "Remove closest bookmark" + /// + public static LocalisableString RemoveClosestBookmark => new TranslatableString(getKey(@"remove_closest_bookmark"), @"Remove closest bookmark"); + + /// + /// "Seek to previous bookmark" + /// + public static LocalisableString SeekToPreviousBookmark => new TranslatableString(getKey(@"seek_to_previous_bookmark"), @"Seek to previous bookmark"); + + /// + /// "Seek to next bookmark" + /// + public static LocalisableString SeekToNextBookmark => new TranslatableString(getKey(@"seek_to_next_bookmark"), @"Seek to next bookmark"); + + /// + /// "Reset bookmarks" + /// + public static LocalisableString ResetBookmarks => new TranslatableString(getKey(@"reset_bookmarks"), @"Reset bookmarks"); + private static string getKey(string key) => $@"{prefix}:{key}"; } -} +} \ No newline at end of file diff --git a/osu.Game/Localisation/GameplaySettingsStrings.cs b/osu.Game/Localisation/GameplaySettingsStrings.cs index 6de61f7ebe..2715f0b8cf 100644 --- a/osu.Game/Localisation/GameplaySettingsStrings.cs +++ b/osu.Game/Localisation/GameplaySettingsStrings.cs @@ -74,6 +74,11 @@ namespace osu.Game.Localisation /// public static LocalisableString FadePlayfieldWhenHealthLow => new TranslatableString(getKey(@"fade_playfield_when_health_low"), @"Fade playfield to red when health is low"); + /// + /// "Star fountains" + /// + public static LocalisableString StarFountains => new TranslatableString(getKey(@"star_fountains"), @"Star fountains"); + /// /// "Always show key overlay" /// @@ -89,6 +94,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/GeneralSettingsStrings.cs b/osu.Game/Localisation/GeneralSettingsStrings.cs index 42623f4632..83a3af574c 100644 --- a/osu.Game/Localisation/GeneralSettingsStrings.cs +++ b/osu.Game/Localisation/GeneralSettingsStrings.cs @@ -44,6 +44,11 @@ namespace osu.Game.Localisation /// public static LocalisableString CheckUpdate => new TranslatableString(getKey(@"check_update"), @"Check for updates"); + /// + /// "Checking for updates" + /// + public static LocalisableString CheckingForUpdates => new TranslatableString(getKey(@"checking_for_updates"), @"Checking for updates"); + /// /// "Open osu! folder" /// diff --git a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs index 206db1a166..f9db0461ce 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" @@ -424,6 +429,26 @@ namespace osu.Game.Localisation /// public static LocalisableString EditorSeekToNextSamplePoint => new TranslatableString(getKey(@"editor_seek_to_next_sample_point"), @"Seek to next sample point"); + /// + /// "Add bookmark" + /// + public static LocalisableString EditorAddBookmark => new TranslatableString(getKey(@"editor_add_bookmark"), @"Add bookmark"); + + /// + /// "Remove closest bookmark" + /// + public static LocalisableString EditorRemoveClosestBookmark => new TranslatableString(getKey(@"editor_remove_closest_bookmark"), @"Remove closest bookmark"); + + /// + /// "Seek to previous bookmark" + /// + public static LocalisableString EditorSeekToPreviousBookmark => new TranslatableString(getKey(@"editor_seek_to_previous_bookmark"), @"Seek to previous bookmark"); + + /// + /// "Seek to next bookmark" + /// + public static LocalisableString EditorSeekToNextBookmark => new TranslatableString(getKey(@"editor_seek_to_next_bookmark"), @"Seek to next bookmark"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } 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/MenuTipStrings.cs b/osu.Game/Localisation/MenuTipStrings.cs new file mode 100644 index 0000000000..f97ad5fa2c --- /dev/null +++ b/osu.Game/Localisation/MenuTipStrings.cs @@ -0,0 +1,154 @@ +// 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.Localisation; + +namespace osu.Game.Localisation +{ + public static class MenuTipStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.MenuTip"; + + /// + /// "Press Ctrl-T anywhere in the game to toggle the toolbar!" + /// + public static LocalisableString ToggleToolbarShortcut => new TranslatableString(getKey(@"toggle_toolbar_shortcut"), @"Press Ctrl-T anywhere in the game to toggle the toolbar!"); + + /// + /// "Press Ctrl-O anywhere in the game to access settings!" + /// + public static LocalisableString GameSettingsShortcut => new TranslatableString(getKey(@"game_settings_shortcut"), @"Press Ctrl-O anywhere in the game to access settings!"); + + /// + /// "All settings are dynamic and take effect in real-time. Try changing the skin while watching autoplay!" + /// + public static LocalisableString DynamicSettings => new TranslatableString(getKey(@"dynamic_settings"), @"All settings are dynamic and take effect in real-time. Try changing the skin while watching autoplay!"); + + /// + /// "New features are coming online every update. Make sure to stay up-to-date!" + /// + public static LocalisableString NewFeaturesAreComingOnline => new TranslatableString(getKey(@"new_features_are_coming_online"), @"New features are coming online every update. Make sure to stay up-to-date!"); + + /// + /// "If you find the UI too large or small, try adjusting UI scale in settings!" + /// + public static LocalisableString UIScalingSettings => new TranslatableString(getKey(@"ui_scaling_settings"), @"If you find the UI too large or small, try adjusting UI scale in settings!"); + + /// + /// "Try adjusting the "Screen Scaling" mode to change your gameplay or UI area, even in fullscreen!" + /// + public static LocalisableString ScreenScalingSettings => new TranslatableString(getKey(@"screen_scaling_settings"), @"Try adjusting the ""Screen Scaling"" mode to change your gameplay or UI area, even in fullscreen!"); + + /// + /// "What used to be "osu!direct" is available to all users just like on the website. You can access it anywhere using Ctrl-B!" + /// + public static LocalisableString FreeOsuDirect => new TranslatableString(getKey(@"free_osu_direct"), @"What used to be ""osu!direct"" is available to all users just like on the website. You can access it anywhere using Ctrl-B!"); + + /// + /// "Seeking in replays is available by dragging on the progress bar at the bottom of the screen or by using the left and right arrow keys!" + /// + public static LocalisableString ReplaySeeking => new TranslatableString(getKey(@"replay_seeking"), @"Seeking in replays is available by dragging on the progress bar at the bottom of the screen or by using the left and right arrow keys!"); + + /// + /// "Try scrolling right in mod select to find a bunch of new fun mods!" + /// + public static LocalisableString TryNewMods => new TranslatableString(getKey(@"try_new_mods"), @"Try scrolling right in mod select to find a bunch of new fun mods!"); + + /// + /// "Most of the web content (profiles, rankings, etc.) are available natively in-game from the icons on the toolbar!" + /// + public static LocalisableString EmbeddedWebContent => new TranslatableString(getKey(@"embedded_web_content"), @"Most of the web content (profiles, rankings, etc.) are available natively in-game from the icons on the toolbar!"); + + /// + /// "Get more details, hide or delete a beatmap by right-clicking on its panel at song select!" + /// + public static LocalisableString BeatmapRightClick => new TranslatableString(getKey(@"beatmap_right_click"), @"Get more details, hide or delete a beatmap by right-clicking on its panel at song select!"); + + /// + /// "Check out the "playlists" system, which lets users create their own custom and permanent leaderboards!" + /// + public static LocalisableString DiscoverPlaylists => new TranslatableString(getKey(@"discover_playlists"), @"Check out the ""playlists"" system, which lets users create their own custom and permanent leaderboards!"); + + /// + /// "Toggle advanced frame / thread statistics with Ctrl-F11!" + /// + public static LocalisableString ToggleAdvancedFPSCounter => new TranslatableString(getKey(@"toggle_advanced_fps_counter"), @"Toggle advanced frame / thread statistics with Ctrl-F11!"); + + /// + /// "You can pause during a replay by pressing Space!" + /// + public static LocalisableString ReplayPausing => new TranslatableString(getKey(@"replay_pausing"), @"You can pause during a replay by pressing Space!"); + + /// + /// "Most of the hotkeys in the game are configurable and can be changed to anything you want. Check the bindings panel under input settings!" + /// + public static LocalisableString ConfigurableHotkeys => new TranslatableString(getKey(@"configurable_hotkeys"), @"Most of the hotkeys in the game are configurable and can be changed to anything you want. Check the bindings panel under input settings!"); + + /// + /// "Your gameplay HUD can be customised by using the skin layout editor. Open it at any time via Ctrl-Shift-S!" + /// + public static LocalisableString SkinEditor => new TranslatableString(getKey(@"skin_editor"), @"Your gameplay HUD can be customised by using the skin layout editor. Open it at any time via Ctrl-Shift-S!"); + + /// + /// "You can create mod presets to make toggling your favourite mod combinations easier!" + /// + public static LocalisableString ModPresets => new TranslatableString(getKey(@"mod_presets"), @"You can create mod presets to make toggling your favourite mod combinations easier!"); + + /// + /// "Many mods have customisation settings that drastically change how they function. Click the Customise button in mod select to view settings!" + /// + public static LocalisableString ModCustomisationSettings => new TranslatableString(getKey(@"mod_customisation_settings"), @"Many mods have customisation settings that drastically change how they function. Click the Customise button in mod select to view settings!"); + + /// + /// "Press Ctrl-Shift-R to switch to a random skin!" + /// + public static LocalisableString RandomSkinShortcut => new TranslatableString(getKey(@"random_skin_shortcut"), @"Press Ctrl-Shift-R to switch to a random skin!"); + + /// + /// "While watching a replay, press Ctrl-H to toggle replay settings!" + /// + public static LocalisableString ToggleReplaySettingsShortcut => new TranslatableString(getKey(@"toggle_replay_settings_shortcut"), @"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!" + /// + public static LocalisableString CopyModsFromScore => new TranslatableString(getKey(@"copy_mods_from_score"), @"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!" + /// + public static LocalisableString AutoplayBeatmapShortcut => new TranslatableString(getKey(@"autoplay_beatmap_shortcut"), @"Ctrl-Enter at song select will start a beatmap in autoplay mode!"); + + /// + /// "Multithreading support means that even with low "FPS" your input and judgements will be accurate!" + /// + public static LocalisableString MultithreadingSupport => new TranslatableString(getKey(@"multithreading_support"), @"Multithreading support means that even with low ""FPS"" your input and judgements will be accurate!"); + + /// + /// "All delete operations are temporary until exiting. Restore accidentally deleted content from the maintenance settings!" + /// + public static LocalisableString TemporaryDeleteOperations => new TranslatableString(getKey(@"temporary_delete_operations"), @"All delete operations are temporary until exiting. Restore accidentally deleted content from the maintenance settings!"); + + /// + /// "Take a look under the hood at performance counters and enable verbose performance logging with Ctrl-F2!" + /// + public static LocalisableString GlobalStatisticsShortcut => new TranslatableString(getKey(@"global_statistics_shortcut"), @"Take a look under the hood at performance counters and enable verbose performance logging with Ctrl-F2!"); + + /// + /// "When your gameplay HUD is hidden, you can press and hold Ctrl to view it temporarily!" + /// + public static LocalisableString PeekHUDWhenHidden => new TranslatableString(getKey(@"peek_hud_when_hidden"), @"When your gameplay HUD is hidden, you can press and hold Ctrl to view it temporarily!"); + + /// + /// "Drag and drop any image into the skin editor to load it in quickly!" + /// + public static LocalisableString DragAndDropImageInSkinEditor => new TranslatableString(getKey(@"drag_and_drop_image_in_skin_editor"), @"Drag and drop any image into the skin editor to load it in quickly!"); + + /// + /// "a tip for you:" + /// + public static LocalisableString MenuTipTitle => new TranslatableString(getKey(@"menu_tip_title"), @"a tip for you:"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} 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..ec48fa2436 100644 --- a/osu.Game/Online/API/APIAccess.cs +++ b/osu.Game/Online/API/APIAccess.cs @@ -57,9 +57,8 @@ 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; public INotificationsClient NotificationsClient { get; } @@ -67,15 +66,13 @@ 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(); private Bindable configStatus { get; } = new Bindable(); private Bindable localUserStatus { get; } = new Bindable(); - private Bindable statistics { get; } = new Bindable(); - protected bool HasLogin => authentication.Token.Value != null || (!string.IsNullOrEmpty(ProvidedUsername) && !string.IsNullOrEmpty(password)); private readonly CancellationTokenSource cancellationToken = new CancellationTokenSource(); @@ -360,19 +357,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 @@ -616,21 +601,25 @@ namespace osu.Game.Online.API flushQueue(); } - public void UpdateStatistics(UserStatistics newStatistics) + public void UpdateLocalFriends() { - statistics.Value = newStatistics; + if (!IsLoggedIn) + return; - if (IsLoggedIn) - localUser.Value.Statistics = newStatistics; + 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(() => - { - localUser.Value = user; - statistics.Value = user.Statistics; - }, false); + private void setLocalUser(APIUser user) => Scheduler.Add(() => localUser.Value = user, false); protected override void Dispose(bool isDisposing) { diff --git a/osu.Game/Online/API/DummyAPIAccess.cs b/osu.Game/Online/API/DummyAPIAccess.cs index 7ac5c45fad..5d63c04925 100644 --- a/osu.Game/Online/API/DummyAPIAccess.cs +++ b/osu.Game/Online/API/DummyAPIAccess.cs @@ -26,12 +26,10 @@ 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(); - public Bindable Statistics { get; } = new Bindable(); - public DummyNotificationsClient NotificationsClient { get; } = new DummyNotificationsClient(); INotificationsClient IAPIProvider.NotificationsClient => NotificationsClient; @@ -178,11 +176,6 @@ namespace osu.Game.Online.API private void onSuccessfulLogin() { state.Value = APIState.Online; - Statistics.Value = new UserStatistics - { - GlobalRank = 1, - CountryRank = 1 - }; } public void Logout() @@ -193,12 +186,8 @@ namespace osu.Game.Online.API LocalUser.Value = new GuestUser(); } - public void UpdateStatistics(UserStatistics newStatistics) + public void UpdateLocalFriends() { - Statistics.Value = newStatistics; - - if (IsLoggedIn) - LocalUser.Value.Statistics = newStatistics; } public IHubClientConnector? GetHubConnector(string clientName, string endpoint, bool preferMessagePack) => null; @@ -214,9 +203,8 @@ 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; /// /// Skip 2FA requirement for next login. diff --git a/osu.Game/Online/API/IAPIProvider.cs b/osu.Game/Online/API/IAPIProvider.cs index eccfb36546..1c4b2da742 100644 --- a/osu.Game/Online/API/IAPIProvider.cs +++ b/osu.Game/Online/API/IAPIProvider.cs @@ -22,18 +22,13 @@ namespace osu.Game.Online.API /// /// The user's friends. /// - IBindableList Friends { get; } + IBindableList Friends { get; } /// /// The current user's activity. /// IBindable Activity { get; } - /// - /// The current user's online statistics. - /// - IBindable Statistics { get; } - /// /// The language supplied by this provider to API requests. /// @@ -130,9 +125,9 @@ namespace osu.Game.Online.API void Logout(); /// - /// Sets Statistics bindable. + /// Update the friends status of the current user. /// - void UpdateStatistics(UserStatistics newStatistics); + 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/ClosePlaylistRequest.cs b/osu.Game/Online/API/Requests/ClosePlaylistRequest.cs new file mode 100644 index 0000000000..545266491e --- /dev/null +++ b/osu.Game/Online/API/Requests/ClosePlaylistRequest.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 ClosePlaylistRequest : APIRequest + { + private readonly long roomId; + + public ClosePlaylistRequest(long roomId) + { + this.roomId = roomId; + } + + protected override WebRequest CreateWebRequest() + { + var request = base.CreateWebRequest(); + request.Method = HttpMethod.Delete; + return request; + } + + protected override string Target => $@"rooms/{roomId}"; + } +} 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..a829484506 100644 --- a/osu.Game/Online/API/Requests/Responses/APIUser.cs +++ b/osu.Game/Online/API/Requests/Responses/APIUser.cs @@ -223,8 +223,10 @@ namespace osu.Game.Online.API.Requests.Responses /// /// User statistics for the requested ruleset (in the case of a or response). - /// Otherwise empty. /// + /// + /// This returns null when accessed from . Use instead. + /// [JsonProperty(@"statistics")] public UserStatistics Statistics { @@ -261,7 +263,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/Chat/Channel.cs b/osu.Game/Online/Chat/Channel.cs index 15ce926039..9de77237b4 100644 --- a/osu.Game/Online/Chat/Channel.cs +++ b/osu.Game/Online/Chat/Channel.cs @@ -161,7 +161,7 @@ namespace osu.Game.Online.Chat Messages.AddRange(messages); long? maxMessageId = messages.Max(m => m.Id); - if (maxMessageId > LastMessageId) + if (LastMessageId == null || maxMessageId > LastMessageId) LastMessageId = maxMessageId; purgeOldMessages(); diff --git a/osu.Game/Online/Chat/ExternalLinkOpener.cs b/osu.Game/Online/Chat/ExternalLinkOpener.cs index 1c48a4fe6d..75b161d57b 100644 --- a/osu.Game/Online/Chat/ExternalLinkOpener.cs +++ b/osu.Game/Online/Chat/ExternalLinkOpener.cs @@ -11,7 +11,7 @@ using osu.Game.Configuration; using osu.Game.Localisation; using osu.Game.Overlays; using osu.Game.Overlays.Dialog; -using CommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; +using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; namespace osu.Game.Online.Chat { @@ -60,12 +60,12 @@ namespace osu.Game.Online.Chat }, new PopupDialogCancelButton { - Text = @"Copy link", + Text = CommonStrings.CopyLink, Action = copyExternalLinkAction }, new PopupDialogCancelButton { - Text = CommonStrings.ButtonsCancel, + Text = WebCommonStrings.ButtonsCancel, }, }; } diff --git a/osu.Game/Online/Leaderboards/Leaderboard.cs b/osu.Game/Online/Leaderboards/Leaderboard.cs index 0fd9597ac0..d76da54adf 100644 --- a/osu.Game/Online/Leaderboards/Leaderboard.cs +++ b/osu.Game/Online/Leaderboards/Leaderboard.cs @@ -363,7 +363,7 @@ namespace osu.Game.Online.Leaderboards return null; default: - throw new ArgumentOutOfRangeException(); + throw new ArgumentOutOfRangeException(nameof(state)); } } 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/LocalUserStatisticsProvider.cs b/osu.Game/Online/LocalUserStatisticsProvider.cs new file mode 100644 index 0000000000..22d5788c87 --- /dev/null +++ b/osu.Game/Online/LocalUserStatisticsProvider.cs @@ -0,0 +1,92 @@ +// 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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Extensions; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Rulesets; +using osu.Game.Users; + +namespace osu.Game.Online +{ + /// + /// A component that keeps track of the latest statistics for the local user. + /// + public partial class LocalUserStatisticsProvider : Component + { + /// + /// Invoked whenever a change occured to the statistics of any ruleset, + /// either due to change in local user (log out and log in) or as a result of score submission. + /// + /// + /// This does not guarantee the presence of the old statistics, + /// specifically in the case of initial population or change in local user. + /// + public event Action? StatisticsUpdated; + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + private readonly Dictionary statisticsCache = new Dictionary(); + + /// + /// Returns the currently available for the given ruleset. + /// This may return null if the requested statistics has not been fetched before yet. + /// + /// The ruleset to return the corresponding for. + public UserStatistics? GetStatisticsFor(RulesetInfo ruleset) => statisticsCache.GetValueOrDefault(ruleset.ShortName); + + protected override void LoadComplete() + { + base.LoadComplete(); + + api.LocalUser.BindValueChanged(_ => + { + // queuing up requests directly on user change is unsafe, as the API status may have not been updated yet. + // schedule a frame to allow the API to be in its correct state sending requests. + Schedule(initialiseStatistics); + }, true); + } + + private void initialiseStatistics() + { + statisticsCache.Clear(); + + if (api.LocalUser.Value == null || api.LocalUser.Value.Id <= 1) + return; + + foreach (var ruleset in rulesets.AvailableRulesets.Where(r => r.IsLegacyRuleset())) + RefetchStatistics(ruleset); + } + + public void RefetchStatistics(RulesetInfo ruleset, Action? callback = null) + { + if (!ruleset.IsLegacyRuleset()) + throw new InvalidOperationException($@"Retrieving statistics is not supported for ruleset {ruleset.ShortName}"); + + var request = new GetUserRequest(api.LocalUser.Value.Id, ruleset); + request.Success += u => UpdateStatistics(u.Statistics, ruleset, callback); + api.Queue(request); + } + + protected void UpdateStatistics(UserStatistics newStatistics, RulesetInfo ruleset, Action? callback = null) + { + var oldStatistics = statisticsCache.GetValueOrDefault(ruleset.ShortName); + statisticsCache[ruleset.ShortName] = newStatistics; + + var update = new UserStatisticsUpdate(ruleset, oldStatistics, newStatistics); + callback?.Invoke(update); + StatisticsUpdated?.Invoke(update); + } + } + + public record UserStatisticsUpdate(RulesetInfo Ruleset, UserStatistics? OldStatistics, UserStatistics NewStatistics); +} 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/OnlineViewContainer.cs b/osu.Game/Online/OnlineViewContainer.cs index 824da152b2..ce55b50d94 100644 --- a/osu.Game/Online/OnlineViewContainer.cs +++ b/osu.Game/Online/OnlineViewContainer.cs @@ -87,7 +87,7 @@ namespace osu.Game.Online break; default: - throw new ArgumentOutOfRangeException(); + throw new ArgumentOutOfRangeException(nameof(state.NewValue)); } }); 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..5a008bac13 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,28 @@ 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; + StartDate = other.StartDate; + 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 +398,7 @@ namespace osu.Game.Online.Rooms public int CountTotal; [JsonProperty("ruleset_ids")] - public int[] RulesetIDs; + public int[] RulesetIDs = []; } [JsonObject(MemberSerialization.OptIn)] @@ -252,5 +410,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/UserStatisticsUpdate.cs b/osu.Game/Online/ScoreBasedUserStatisticsUpdate.cs similarity index 84% rename from osu.Game/Online/UserStatisticsUpdate.cs rename to osu.Game/Online/ScoreBasedUserStatisticsUpdate.cs index f85b219ef0..dc55c57c68 100644 --- a/osu.Game/Online/UserStatisticsUpdate.cs +++ b/osu.Game/Online/ScoreBasedUserStatisticsUpdate.cs @@ -9,7 +9,7 @@ namespace osu.Game.Online /// /// Contains data about the change in a user's profile statistics after completing a score. /// - public class UserStatisticsUpdate + public class ScoreBasedUserStatisticsUpdate { /// /// The score set by the user that triggered the update. @@ -27,12 +27,12 @@ namespace osu.Game.Online public UserStatistics After { get; } /// - /// Creates a new . + /// Creates a new . /// /// The score set by the user that triggered the update. /// The user's profile statistics prior to the score being set. /// The user's profile statistics after the score was set. - public UserStatisticsUpdate(ScoreInfo score, UserStatistics before, UserStatistics after) + public ScoreBasedUserStatisticsUpdate(ScoreInfo score, UserStatistics before, UserStatistics after) { Score = score; Before = before; 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/Online/UserStatisticsWatcher.cs b/osu.Game/Online/UserStatisticsWatcher.cs index af32e86ae4..73ca3c9f53 100644 --- a/osu.Game/Online/UserStatisticsWatcher.cs +++ b/osu.Game/Online/UserStatisticsWatcher.cs @@ -2,18 +2,14 @@ // 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.Bindables; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Game.Extensions; using osu.Game.Online.API; -using osu.Game.Online.API.Requests; -using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Spectator; using osu.Game.Scoring; -using osu.Game.Users; namespace osu.Game.Online { @@ -22,8 +18,10 @@ namespace osu.Game.Online /// public partial class UserStatisticsWatcher : Component { - public IBindable LatestUpdate => latestUpdate; - private readonly Bindable latestUpdate = new Bindable(); + private readonly LocalUserStatisticsProvider statisticsProvider; + + public IBindable LatestUpdate => latestUpdate; + private readonly Bindable latestUpdate = new Bindable(); [Resolved] private SpectatorClient spectatorClient { get; set; } = null!; @@ -33,13 +31,15 @@ namespace osu.Game.Online private readonly Dictionary watchedScores = new Dictionary(); - private Dictionary? latestStatistics; + public UserStatisticsWatcher(LocalUserStatisticsProvider statisticsProvider) + { + this.statisticsProvider = statisticsProvider; + } protected override void LoadComplete() { base.LoadComplete(); - api.LocalUser.BindValueChanged(user => onUserChanged(user.NewValue), true); spectatorClient.OnUserScoreProcessed += userScoreProcessed; } @@ -61,35 +61,6 @@ namespace osu.Game.Online }); } - private void onUserChanged(APIUser? localUser) => Schedule(() => - { - latestStatistics = null; - - if (localUser == null || localUser.OnlineID <= 1) - return; - - var userRequest = new GetUsersRequest(new[] { localUser.OnlineID }); - userRequest.Success += initialiseUserStatistics; - api.Queue(userRequest); - }); - - private void initialiseUserStatistics(GetUsersResponse response) => Schedule(() => - { - var user = response.Users.SingleOrDefault(); - - // possible if the user is restricted or similar. - if (user == null) - return; - - latestStatistics = new Dictionary(); - - if (user.RulesetsStatistics != null) - { - foreach (var rulesetStats in user.RulesetsStatistics) - latestStatistics.Add(rulesetStats.Key, rulesetStats.Value); - } - }); - private void userScoreProcessed(int userId, long scoreId) { if (userId != api.LocalUser.Value?.OnlineID) @@ -98,30 +69,11 @@ namespace osu.Game.Online if (!watchedScores.Remove(scoreId, out var scoreInfo)) return; - requestStatisticsUpdate(userId, scoreInfo); - } - - private void requestStatisticsUpdate(int userId, ScoreInfo scoreInfo) - { - var request = new GetUserRequest(userId, scoreInfo.Ruleset); - request.Success += user => Schedule(() => dispatchStatisticsUpdate(scoreInfo, user.Statistics)); - api.Queue(request); - } - - private void dispatchStatisticsUpdate(ScoreInfo scoreInfo, UserStatistics updatedStatistics) - { - string rulesetName = scoreInfo.Ruleset.ShortName; - - api.UpdateStatistics(updatedStatistics); - - if (latestStatistics == null) - return; - - latestStatistics.TryGetValue(rulesetName, out UserStatistics? latestRulesetStatistics); - latestRulesetStatistics ??= new UserStatistics(); - - latestUpdate.Value = new UserStatisticsUpdate(scoreInfo, latestRulesetStatistics, updatedStatistics); - latestStatistics[rulesetName] = updatedStatistics; + statisticsProvider.RefetchStatistics(scoreInfo.Ruleset, u => Schedule(() => + { + if (u.OldStatistics != null) + latestUpdate.Value = new ScoreBasedUserStatisticsUpdate(scoreInfo, u.OldStatistics, u.NewStatistics); + })); } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index dce24c6ee7..d8145c8246 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -87,9 +87,9 @@ namespace osu.Game { #if DEBUG // Different port allows running release and debug builds alongside each other. - public const int IPC_PORT = 44824; + public const string IPC_PIPE_NAME = "osu-lazer-debug"; #else - public const int IPC_PORT = 44823; + public const string IPC_PIPE_NAME = "osu-lazer"; #endif /// @@ -148,8 +148,7 @@ namespace osu.Game [Resolved] private FrameworkConfigManager frameworkConfig { get; set; } - [Cached] - private readonly DifficultyRecommender difficultyRecommender = new DifficultyRecommender(); + private DifficultyRecommender difficultyRecommender; [Cached] private readonly LegacyImportManager legacyImportManager = new LegacyImportManager(); @@ -175,6 +174,11 @@ namespace osu.Game /// public readonly IBindable OverlayActivationMode = new Bindable(); + /// + /// Whether the back button is currently displayed. + /// + private readonly IBindable backButtonVisibility = new Bindable(); + IBindable ILocalUserPlayInfo.PlayingState => playingState; private readonly Bindable playingState = new Bindable(); @@ -196,7 +200,8 @@ namespace osu.Game private MainMenu menuScreen; - private VersionManager versionManager; + [CanBeNull] + private DevBuildBanner devBuildBanner; [CanBeNull] private IntroScreen introScreen; @@ -1019,7 +1024,7 @@ namespace osu.Game if (!(ScreenStack.CurrentScreen is IOsuScreen currentScreen)) return; - if (!((Drawable)currentScreen).IsLoaded || (currentScreen.AllowBackButton && !currentScreen.OnBackButton())) + if (!((Drawable)currentScreen).IsLoaded || (currentScreen.AllowUserExit && !currentScreen.OnBackButton())) ScreenStack.Exit(); } }, @@ -1056,10 +1061,7 @@ namespace osu.Game }, topMostOverlayContent.Add); if (!IsDeployedBuild) - { - dependencies.Cache(versionManager = new VersionManager()); - loadComponentSingleFile(versionManager, ScreenContainer.Add); - } + loadComponentSingleFile(devBuildBanner = new DevBuildBanner(), ScreenContainer.Add); loadComponentSingleFile(osuLogo, _ => { @@ -1069,7 +1071,11 @@ namespace osu.Game ScreenStack.Push(CreateLoader().With(l => l.RelativeSizeAxes = Axes.Both)); }); - loadComponentSingleFile(new UserStatisticsWatcher(), Add, true); + LocalUserStatisticsProvider statisticsProvider; + + loadComponentSingleFile(statisticsProvider = new LocalUserStatisticsProvider(), Add, true); + loadComponentSingleFile(difficultyRecommender = new DifficultyRecommender(statisticsProvider), Add, true); + loadComponentSingleFile(new UserStatisticsWatcher(statisticsProvider), Add, true); loadComponentSingleFile(Toolbar = new Toolbar { OnHome = delegate @@ -1139,7 +1145,6 @@ namespace osu.Game loadComponentSingleFile(new BackgroundDataStoreProcessor(), Add); loadComponentSingleFile(new DetachedBeatmapStore(), Add, true); - Add(difficultyRecommender); Add(externalLinkOpener = new ExternalLinkOpener()); Add(new MusicKeyBindingHandler()); Add(new OnlineStatusNotifier(() => ScreenStack.CurrentScreen)); @@ -1189,6 +1194,14 @@ namespace osu.Game if (mode.NewValue != OverlayActivation.All) CloseAllOverlays(); }; + backButtonVisibility.ValueChanged += visible => + { + if (visible.NewValue) + BackButton.Show(); + else + BackButton.Hide(); + }; + // Importantly, this should be run after binding PostNotification to the import handlers so they can present the import after game startup. handleStartupImport(); } @@ -1562,12 +1575,12 @@ namespace osu.Game { case IntroScreen intro: introScreen = intro; - versionManager?.Show(); + devBuildBanner?.Show(); break; case MainMenu menu: menuScreen = menu; - versionManager?.Show(); + devBuildBanner?.Show(); break; case Player player: @@ -1575,18 +1588,20 @@ namespace osu.Game break; default: - versionManager?.Hide(); + devBuildBanner?.Hide(); break; } if (current is IOsuScreen currentOsuScreen) { + backButtonVisibility.UnbindFrom(currentOsuScreen.BackButtonVisibility); OverlayActivationMode.UnbindFrom(currentOsuScreen.OverlayActivationMode); API.Activity.UnbindFrom(currentOsuScreen.Activity); } if (newScreen is IOsuScreen newOsuScreen) { + backButtonVisibility.BindTo(newOsuScreen.BackButtonVisibility); OverlayActivationMode.BindTo(newOsuScreen.OverlayActivationMode); API.Activity.BindTo(newOsuScreen.Activity); @@ -1597,11 +1612,6 @@ namespace osu.Game else Toolbar.Show(); - if (newOsuScreen.AllowBackButton) - BackButton.Show(); - else - BackButton.Hide(); - if (newOsuScreen.ShowFooter) { BackButton.Hide(); diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index d4704d1c72..8027b6bfbc 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -41,6 +41,7 @@ using osu.Game.Database; using osu.Game.Extensions; using osu.Game.Graphics; using osu.Game.Graphics.Cursor; +using osu.Game.Graphics.UserInterface; using osu.Game.Input; using osu.Game.Input.Bindings; using osu.Game.IO; @@ -73,8 +74,6 @@ namespace osu.Game [Cached(typeof(OsuGameBase))] public partial class OsuGameBase : Framework.Game, ICanAcceptFiles, IBeatSyncProvider { - public static readonly string[] VIDEO_EXTENSIONS = { ".mp4", ".mov", ".avi", ".flv", ".mpg", ".wmv", ".m4v" }; - #if DEBUG public const string GAME_NAME = "osu! (development)"; #else @@ -198,7 +197,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 +323,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)); @@ -385,6 +384,10 @@ namespace osu.Game GlobalActionContainer globalBindings; + OsuMenuSamples menuSamples; + dependencies.Cache(menuSamples = new OsuMenuSamples()); + base.Content.Add(menuSamples); + base.Content.Add(SafeAreaContainer = new SafeAreaContainer { SafeAreaOverrideEdges = SafeAreaOverrideEdges, @@ -563,6 +566,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/AccountCreationOverlay.cs b/osu.Game/Overlays/AccountCreationOverlay.cs index 82fc5508f1..55cba33153 100644 --- a/osu.Game/Overlays/AccountCreationOverlay.cs +++ b/osu.Game/Overlays/AccountCreationOverlay.cs @@ -126,7 +126,7 @@ namespace osu.Game.Overlays break; default: - throw new ArgumentOutOfRangeException(); + throw new ArgumentOutOfRangeException(nameof(state.NewValue)); } } } diff --git a/osu.Game/Overlays/BeatmapListingOverlay.cs b/osu.Game/Overlays/BeatmapListingOverlay.cs index b47e2b82c0..f83368fa41 100644 --- a/osu.Game/Overlays/BeatmapListingOverlay.cs +++ b/osu.Game/Overlays/BeatmapListingOverlay.cs @@ -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..8e342b49c9 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs @@ -12,7 +12,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics; @@ -35,7 +34,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores private readonly FontUsage smallFont = OsuFont.GetFont(size: 16); private readonly FontUsage largeFont = OsuFont.GetFont(size: 22, weight: FontWeight.Light); - private readonly TextColumn totalScoreColumn; + private readonly TotalScoreColumn totalScoreColumn; private readonly TextColumn accuracyColumn; private readonly TextColumn maxComboColumn; private readonly TextColumn ppColumn; @@ -67,7 +66,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores Spacing = new Vector2(margin, 0), Children = new Drawable[] { - totalScoreColumn = new TextColumn(BeatmapsetsStrings.ShowScoreboardHeadersScoreTotal, largeFont, top_columns_min_width), + totalScoreColumn = new TotalScoreColumn(BeatmapsetsStrings.ShowScoreboardHeadersScoreTotal, largeFont, top_columns_min_width), accuracyColumn = new TextColumn(BeatmapsetsStrings.ShowScoreboardHeadersAccuracy, largeFont, top_columns_min_width), maxComboColumn = new TextColumn(BeatmapsetsStrings.ShowScoreboardHeadersCombo, largeFont, top_columns_min_width) } @@ -96,10 +95,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; @@ -219,7 +225,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores } } - private partial class TextColumn : InfoColumn, IHasCurrentValue + private partial class TextColumn : InfoColumn { private readonly OsuTextFlowContainer text; @@ -228,6 +234,11 @@ namespace osu.Game.Overlays.BeatmapSet.Scores set => text.Text = value; } + public Colour4 TextColour + { + set => text.Colour = value; + } + public Drawable Drawable { set @@ -237,18 +248,6 @@ namespace osu.Game.Overlays.BeatmapSet.Scores } } - private Bindable current; - - public Bindable Current - { - get => current; - set - { - text.Clear(); - text.AddText(value.Value, t => t.Current = current = value); - } - } - public TextColumn(LocalisableString title, FontUsage font, float? minWidth = null) : this(title, new OsuTextFlowContainer(t => t.Font = font) { @@ -264,6 +263,28 @@ namespace osu.Game.Overlays.BeatmapSet.Scores } } + private partial class TotalScoreColumn : TextColumn + { + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + public TotalScoreColumn(LocalisableString title, FontUsage font, float? minWidth = null) + : base(title, font, minWidth) + { + } + + public Bindable Current + { + get => current; + set => current.Current = value; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Current.BindValueChanged(_ => Text = current.Value, true); + } + } + private partial class ModsInfoColumn : InfoColumn { private readonly FillFlowContainer modsContainer; 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..f027888962 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 { @@ -33,12 +37,15 @@ namespace osu.Game.Overlays.Chat.ChannelList private readonly Dictionary channelMap = new Dictionary(); + public ChannelGroup AnnounceChannelGroup { get; private set; } = null!; + public ChannelGroup PublicChannelGroup { get; private set; } = null!; + public ChannelGroup PrivateChannelGroup { get; private set; } = null!; + private OsuScrollContainer scroll = null!; - private FillFlowContainer groupFlow = null!; - private ChannelGroup announceChannelGroup = null!; - private ChannelGroup publicChannelGroup = null!; - private ChannelGroup privateChannelGroup = null!; + private SearchContainer groupFlow = null!; + private ChannelListItem selector = null!; + private TextBox searchTextBox = null!; [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) @@ -55,22 +62,45 @@ 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[] { - announceChannelGroup = new ChannelGroup(ChatStrings.ChannelsListTitleANNOUNCE.ToUpper()), - publicChannelGroup = new ChannelGroup(ChatStrings.ChannelsListTitlePUBLIC.ToUpper()), + 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(), false), + PublicChannelGroup = new ChannelGroup(ChatStrings.ChannelsListTitlePUBLIC.ToUpper(), false), selector = new ChannelListItem(ChannelListingChannel), - privateChannelGroup = new ChannelGroup(ChatStrings.ChannelsListTitlePM.ToUpper()), + PrivateChannelGroup = new ChannelGroup(ChatStrings.ChannelsListTitlePM.ToUpper(), true), }, }, }, }; + 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); } @@ -83,69 +113,70 @@ namespace osu.Game.Overlays.Chat.ChannelList item.OnRequestSelect += chan => OnRequestSelect?.Invoke(chan); item.OnRequestLeave += chan => OnRequestLeave?.Invoke(chan); - FillFlowContainer flow = getFlowForChannel(channel); + ChannelGroup group = getGroupFromChannel(channel); channelMap.Add(channel, item); - flow.Add(item); + group.AddChannel(item); updateVisibility(); } public void RemoveChannel(Channel channel) { - if (!channelMap.ContainsKey(channel)) + if (!channelMap.TryGetValue(channel, out var item)) return; - ChannelListItem item = channelMap[channel]; - FillFlowContainer flow = getFlowForChannel(channel); + ChannelGroup group = getGroupFromChannel(channel); channelMap.Remove(channel); - flow.Remove(item, true); + group.RemoveChannel(item); updateVisibility(); } public ChannelListItem GetItem(Channel channel) { - if (!channelMap.ContainsKey(channel)) + if (!channelMap.TryGetValue(channel, out var item)) throw new ArgumentOutOfRangeException(); - return channelMap[channel]; + return item; } public void ScrollChannelIntoView(Channel channel) => scroll.ScrollIntoView(GetItem(channel)); - private FillFlowContainer getFlowForChannel(Channel channel) + private ChannelGroup getGroupFromChannel(Channel channel) { switch (channel.Type) { case ChannelType.Public: - return publicChannelGroup.ItemFlow; + return PublicChannelGroup; case ChannelType.PM: - return privateChannelGroup.ItemFlow; + return PrivateChannelGroup; case ChannelType.Announce: - return announceChannelGroup.ItemFlow; + return AnnounceChannelGroup; default: - return publicChannelGroup.ItemFlow; + return PublicChannelGroup; } } private void updateVisibility() { - if (announceChannelGroup.ItemFlow.Children.Count == 0) - announceChannelGroup.Hide(); + if (AnnounceChannelGroup.ItemFlow.Children.Count == 0) + AnnounceChannelGroup.Hide(); else - announceChannelGroup.Show(); + AnnounceChannelGroup.Show(); } - private partial class ChannelGroup : FillFlowContainer + public partial class ChannelGroup : FillFlowContainer { - public readonly FillFlowContainer ItemFlow; + private readonly bool sortByRecent; + public readonly ChannelListItemFlow ItemFlow; - public ChannelGroup(LocalisableString label) + public ChannelGroup(LocalisableString label, bool sortByRecent) { + this.sortByRecent = sortByRecent; Direction = FillDirection.Vertical; RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; @@ -159,7 +190,7 @@ namespace osu.Game.Overlays.Chat.ChannelList Margin = new MarginPadding { Left = 18, Bottom = 5 }, Font = OsuFont.Torus.With(size: 12, weight: FontWeight.SemiBold), }, - ItemFlow = new FillFlowContainer + ItemFlow = new ChannelListItemFlow(sortByRecent) { Direction = FillDirection.Vertical, RelativeSizeAxes = Axes.X, @@ -167,6 +198,72 @@ namespace osu.Game.Overlays.Chat.ChannelList }, }; } + + public partial class ChannelListItemFlow : FillFlowContainer + { + private readonly bool sortByRecent; + + public ChannelListItemFlow(bool sortByRecent) + { + this.sortByRecent = sortByRecent; + } + + public void Reflow() => InvalidateLayout(); + + public override IEnumerable FlowingChildren => sortByRecent + ? base.FlowingChildren.OfType().OrderByDescending(i => i.Channel.LastMessageId ?? long.MinValue) + : base.FlowingChildren.OfType().OrderBy(i => i.Channel.Name); + } + + public void AddChannel(ChannelListItem item) + { + ItemFlow.Add(item); + + if (sortByRecent) + { + item.Channel.NewMessagesArrived += newMessagesArrived; + item.Channel.PendingMessageResolved += pendingMessageResolved; + } + + ItemFlow.Reflow(); + } + + public void RemoveChannel(ChannelListItem item) + { + if (sortByRecent) + { + item.Channel.NewMessagesArrived -= newMessagesArrived; + item.Channel.PendingMessageResolved -= pendingMessageResolved; + } + + ItemFlow.Remove(item, true); + } + + private void pendingMessageResolved(LocalEchoMessage _, Message __) => ItemFlow.Reflow(); + private void newMessagesArrived(IEnumerable _) => ItemFlow.Reflow(); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + foreach (var item in ItemFlow) + { + item.Channel.NewMessagesArrived -= newMessagesArrived; + item.Channel.PendingMessageResolved -= pendingMessageResolved; + } + } + } + + 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/ChatOverlay.cs b/osu.Game/Overlays/ChatOverlay.cs index b11483e678..c49afa3a66 100644 --- a/osu.Game/Overlays/ChatOverlay.cs +++ b/osu.Game/Overlays/ChatOverlay.cs @@ -386,10 +386,8 @@ namespace osu.Game.Overlays { channelList.RemoveChannel(channel); - if (loadedChannels.ContainsKey(channel)) + if (loadedChannels.Remove(channel, out var loaded)) { - DrawableChannel loaded = loadedChannels[channel]; - loadedChannels.Remove(channel); // DrawableChannel removed from cache must be manually disposed loaded.Dispose(); } diff --git a/osu.Game/Overlays/Comments/CommentsContainer.cs b/osu.Game/Overlays/Comments/CommentsContainer.cs index 921c1682f5..5e277357a9 100644 --- a/osu.Game/Overlays/Comments/CommentsContainer.cs +++ b/osu.Game/Overlays/Comments/CommentsContainer.cs @@ -244,7 +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()); + newCommentEditor.CommentableMeta.Value = response.CommentableMeta.SingleOrDefault(m => m.Id == id.Value && string.Equals(m.Type, type.Value.ToString().ToSnakeCase(), StringComparison.OrdinalIgnoreCase)); if (!response.Comments.Any()) { 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/DevBuildBanner.cs b/osu.Game/Overlays/DevBuildBanner.cs new file mode 100644 index 0000000000..f514483e76 --- /dev/null +++ b/osu.Game/Overlays/DevBuildBanner.cs @@ -0,0 +1,58 @@ +// 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.Framework.Graphics.Textures; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Overlays +{ + public partial class DevBuildBanner : VisibilityContainer + { + [BackgroundDependencyLoader] + private void load(OsuColour colours, TextureStore textures, OsuGameBase game) + { + AutoSizeAxes = Axes.Both; + + Anchor = Anchor.BottomCentre; + Origin = Anchor.BottomCentre; + + Alpha = 0; + + AddRange(new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Font = OsuFont.Numeric.With(weight: FontWeight.Bold, size: 12), + Colour = colours.YellowDark, + Text = @"DEVELOPER BUILD", + }, + new Sprite + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Texture = textures.Get(@"Menu/dev-build-footer"), + Scale = new Vector2(0.4f, 1), + Y = 2, + }, + }); + } + + protected override void PopIn() + { + this.FadeIn(1400, Easing.OutQuint); + } + + protected override void PopOut() + { + this.FadeOut(500, Easing.OutQuint); + } + } +} diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenBehaviour.cs b/osu.Game/Overlays/FirstRunSetup/ScreenBehaviour.cs index 31a56c9748..d31ce7ea18 100644 --- a/osu.Game/Overlays/FirstRunSetup/ScreenBehaviour.cs +++ b/osu.Game/Overlays/FirstRunSetup/ScreenBehaviour.cs @@ -5,6 +5,7 @@ using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Development; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Localisation; @@ -90,11 +91,13 @@ namespace osu.Game.Overlays.FirstRunSetup new GraphicsSection(), new OnlineSection(), new MaintenanceSection(), - new DebugSection(), }, SearchTerm = SettingsItem.CLASSIC_DEFAULT_SEARCH_TERM, } }; + + if (DebugUtils.IsDebugBuild) + searchContainer.Add(new DebugSection()); } private void applyClassic() 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 971503ca8b..87920fdf55 100644 --- a/osu.Game/Overlays/MusicController.cs +++ b/osu.Game/Overlays/MusicController.cs @@ -74,6 +74,7 @@ namespace osu.Game.Overlays private readonly Bindable randomSelectAlgorithm = new Bindable(); private readonly List> previousRandomSets = new List>(); private int randomHistoryDirection; + private int lastRandomTrackDirection; [BackgroundDependencyLoader] private void load(AudioManager audio, OsuConfigManager configManager) @@ -371,54 +372,80 @@ namespace osu.Game.Overlays private Live? getNextRandom(int direction, bool allowProtectedTracks) { - Live result; - - var possibleSets = getBeatmapSets().Where(s => !s.Value.Protected || allowProtectedTracks).ToArray(); - - if (possibleSets.Length == 0) - return null; - - // 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) + try { - Debug.Assert(Math.Abs(randomHistoryDirection) == previousRandomSets.Count); - result = previousRandomSets[^1]; - previousRandomSets.RemoveAt(previousRandomSets.Count - 1); - randomHistoryDirection += direction; - return result; - } + Live 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.Length)]; - break; + var possibleSets = getBeatmapSets().Where(s => !s.Value.Protected || allowProtectedTracks).ToList(); - case RandomSelectAlgorithm.RandomPermutation: - var notYetPlayedSets = possibleSets.Except(previousRandomSets).ToArray(); + if (possibleSets.Count == 0) + return null; - if (notYetPlayedSets.Length == 0) + // 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) { - notYetPlayedSets = possibleSets; - previousRandomSets.Clear(); - randomHistoryDirection = 0; + previousRandomSets.RemoveAt(previousRandomSets.Count - 1); + randomHistoryDirection += direction; } - result = notYetPlayedSets[RNG.Next(notYetPlayedSets.Length)]; - break; + if (previousRandomSets.Count > 0) + { + result = previousRandomSets[^1]; + previousRandomSets.RemoveAt(previousRandomSets.Count - 1); + return result; + } + } - default: - throw new ArgumentOutOfRangeException(nameof(randomSelectAlgorithm), randomSelectAlgorithm.Value, "Unsupported random select algorithm"); + // 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; } - - previousRandomSets.Add(result); - randomHistoryDirection += direction; - return result; } private void restartTrack() diff --git a/osu.Game/Overlays/Profile/Header/Components/DailyChallengeStatsTooltip.cs b/osu.Game/Overlays/Profile/Header/Components/DailyChallengeStatsTooltip.cs index 24e531bd87..826b40d70c 100644 --- a/osu.Game/Overlays/Profile/Header/Components/DailyChallengeStatsTooltip.cs +++ b/osu.Game/Overlays/Profile/Header/Components/DailyChallengeStatsTooltip.cs @@ -138,34 +138,31 @@ namespace osu.Game.Overlays.Profile.Header.Components topFifty.ValueColour = colourProvider.Content2; } - // reference: https://github.com/ppy/osu-web/blob/adf1e94754ba9625b85eba795f4a310caf169eec/resources/js/profile-page/daily-challenge.tsx#L13-L47 + // reference: https://github.com/ppy/osu-web/blob/a97f156014e00ea1aa315140da60542e798a9f06/resources/js/profile-page/daily-challenge.tsx#L13-L47 - // Rounding up is needed here to ensure the overlay shows the same colour as osu-web for the play count. - // This is because, for example, 31 / 3 > 10 in JavaScript because floats are used, while here it would - // get truncated to 10 with an integer division and show a lower tier. - public static RankingTier TierForPlayCount(int playCount) => TierForDaily((int)Math.Ceiling(playCount / 3.0d)); + public static RankingTier TierForPlayCount(int playCount) => TierForDaily((int)Math.Floor(playCount / 3.0d)); public static RankingTier TierForDaily(int daily) { - if (daily > 360) + if (daily >= 360) return RankingTier.Lustrous; - if (daily > 240) + if (daily >= 240) return RankingTier.Radiant; - if (daily > 120) + if (daily >= 120) return RankingTier.Rhodium; - if (daily > 60) + if (daily >= 60) return RankingTier.Platinum; - if (daily > 30) + if (daily >= 30) return RankingTier.Gold; - if (daily > 10) + if (daily >= 10) return RankingTier.Silver; - if (daily > 5) + if (daily >= 5) return RankingTier.Bronze; return RankingTier.Iron; 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/DebugSection.cs b/osu.Game/Overlays/Settings/Sections/DebugSection.cs index b84c441057..1d2129413c 100644 --- a/osu.Game/Overlays/Settings/Sections/DebugSection.cs +++ b/osu.Game/Overlays/Settings/Sections/DebugSection.cs @@ -1,19 +1,17 @@ // 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.Development; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Graphics; -using osu.Game.Localisation; using osu.Game.Overlays.Settings.Sections.DebugSettings; namespace osu.Game.Overlays.Settings.Sections { public partial class DebugSection : SettingsSection { - public override LocalisableString Header => DebugSettingsStrings.DebugSectionHeader; + public override LocalisableString Header => @"Debug"; public override Drawable CreateIcon() => new SpriteIcon { @@ -22,12 +20,12 @@ namespace osu.Game.Overlays.Settings.Sections public DebugSection() { - Add(new GeneralSettings()); - - if (DebugUtils.IsDebugBuild) - Add(new BatchImportSettings()); - - Add(new MemorySettings()); + Children = new Drawable[] + { + new GeneralSettings(), + new BatchImportSettings(), + new MemorySettings(), + }; } } } diff --git a/osu.Game/Overlays/Settings/Sections/DebugSettings/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/DebugSettings/GeneralSettings.cs index df46e38491..bd6ada4ca7 100644 --- a/osu.Game/Overlays/Settings/Sections/DebugSettings/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/DebugSettings/GeneralSettings.cs @@ -5,43 +5,28 @@ using osu.Framework.Allocation; using osu.Framework.Configuration; using osu.Framework.Graphics; using osu.Framework.Localisation; -using osu.Framework.Screens; -using osu.Game.Localisation; -using osu.Game.Screens; -using osu.Game.Screens.Import; -using osu.Game.Screens.Utility; namespace osu.Game.Overlays.Settings.Sections.DebugSettings { public partial class GeneralSettings : SettingsSubsection { - protected override LocalisableString Header => CommonStrings.General; + protected override LocalisableString Header => @"General"; [BackgroundDependencyLoader] - private void load(FrameworkDebugConfigManager config, FrameworkConfigManager frameworkConfig, IPerformFromScreenRunner? performer) + private void load(FrameworkDebugConfigManager config, FrameworkConfigManager frameworkConfig) { Children = new Drawable[] { new SettingsCheckbox { - LabelText = DebugSettingsStrings.ShowLogOverlay, + LabelText = @"Show log overlay", Current = frameworkConfig.GetBindable(FrameworkSetting.ShowLogOverlay) }, new SettingsCheckbox { - LabelText = DebugSettingsStrings.BypassFrontToBackPass, + LabelText = @"Bypass front-to-back render pass", Current = config.GetBindable(DebugSetting.BypassFrontToBackPass) }, - new SettingsButton - { - Text = DebugSettingsStrings.ImportFiles, - Action = () => performer?.PerformFromScreen(menu => menu.Push(new FileImportScreen())) - }, - new SettingsButton - { - Text = DebugSettingsStrings.RunLatencyCertifier, - Action = () => performer?.PerformFromScreen(menu => menu.Push(new LatencyCertifierScreen())) - } }; } } diff --git a/osu.Game/Overlays/Settings/Sections/DebugSettings/MemorySettings.cs b/osu.Game/Overlays/Settings/Sections/DebugSettings/MemorySettings.cs index d5de7ae2db..b693822838 100644 --- a/osu.Game/Overlays/Settings/Sections/DebugSettings/MemorySettings.cs +++ b/osu.Game/Overlays/Settings/Sections/DebugSettings/MemorySettings.cs @@ -11,13 +11,12 @@ using osu.Framework.Localisation; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Database; -using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.DebugSettings { public partial class MemorySettings : SettingsSubsection { - protected override LocalisableString Header => DebugSettingsStrings.MemoryHeader; + protected override LocalisableString Header => @"Memory"; [BackgroundDependencyLoader] private void load(GameHost host, RealmAccess realm) @@ -29,27 +28,27 @@ namespace osu.Game.Overlays.Settings.Sections.DebugSettings { new SettingsButton { - Text = DebugSettingsStrings.ClearAllCaches, + Text = @"Clear all caches", Action = host.Collect }, new SettingsButton { - Text = "Compact realm", + Text = @"Compact realm", Action = () => { // Blocking operations implicitly causes a Compact(). - using (realm.BlockAllOperations("compact")) + using (realm.BlockAllOperations(@"compact")) { } } }, blockAction = new SettingsButton { - Text = "Block realm", + Text = @"Block realm", }, unblockAction = new SettingsButton { - Text = "Unblock realm", + Text = @"Unblock realm", }, }; @@ -57,7 +56,7 @@ namespace osu.Game.Overlays.Settings.Sections.DebugSettings { try { - IDisposable? token = realm.BlockAllOperations("maintenance"); + IDisposable? token = realm.BlockAllOperations(@"maintenance"); blockAction.Enabled.Value = false; @@ -89,7 +88,7 @@ namespace osu.Game.Overlays.Settings.Sections.DebugSettings } catch (Exception e) { - Logger.Error(e, "Blocking realm failed"); + Logger.Error(e, @"Blocking realm failed"); } }; } diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs index 83e9140b33..779d5cdf00 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs @@ -31,6 +31,11 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay LabelText = GraphicsSettingsStrings.HitLighting, Current = config.GetBindable(OsuSetting.HitLighting) }, + new SettingsCheckbox + { + LabelText = GameplaySettingsStrings.StarFountains, + Current = config.GetBindable(OsuSetting.StarFountains) + }, }; } } 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/General/UpdateSettings.cs b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs index 82cc952e53..261103173e 100644 --- a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs @@ -4,7 +4,6 @@ using System.Threading.Tasks; using osu.Framework; using osu.Framework.Allocation; -using osu.Framework.Extensions; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Framework.Logging; @@ -13,6 +12,7 @@ using osu.Framework.Screens; using osu.Framework.Statistics; using osu.Game.Configuration; using osu.Game.Localisation; +using osu.Game.Online.Multiplayer; using osu.Game.Overlays.Notifications; using osu.Game.Overlays.Settings.Sections.Maintenance; using osu.Game.Updater; @@ -36,8 +36,11 @@ namespace osu.Game.Overlays.Settings.Sections.General [Resolved] private Storage storage { get; set; } = null!; + [Resolved] + private OsuGame? game { get; set; } + [BackgroundDependencyLoader] - private void load(OsuConfigManager config, OsuGame? game) + private void load(OsuConfigManager config) { Add(new SettingsEnumDropdown { @@ -50,23 +53,7 @@ namespace osu.Game.Overlays.Settings.Sections.General Add(checkForUpdatesButton = new SettingsButton { Text = GeneralSettingsStrings.CheckUpdate, - Action = () => - { - checkForUpdatesButton.Enabled.Value = false; - Task.Run(updateManager.CheckForUpdateAsync).ContinueWith(task => Schedule(() => - { - if (!task.GetResultSafely()) - { - notifications?.Post(new SimpleNotification - { - Text = GeneralSettingsStrings.RunningLatestRelease(game!.Version), - Icon = FontAwesome.Solid.CheckCircle, - }); - } - - checkForUpdatesButton.Enabled.Value = true; - })); - } + Action = () => checkForUpdates().FireAndForget() }); } @@ -94,6 +81,44 @@ namespace osu.Game.Overlays.Settings.Sections.General } } + private async Task checkForUpdates() + { + if (updateManager == null || game == null) + return; + + checkForUpdatesButton.Enabled.Value = false; + + var checkingNotification = new ProgressNotification + { + Text = GeneralSettingsStrings.CheckingForUpdates, + }; + notifications?.Post(checkingNotification); + + try + { + bool foundUpdate = await updateManager.CheckForUpdateAsync().ConfigureAwait(true); + + if (!foundUpdate) + { + notifications?.Post(new SimpleNotification + { + Text = GeneralSettingsStrings.RunningLatestRelease(game.Version), + Icon = FontAwesome.Solid.CheckCircle, + }); + } + } + catch + { + } + finally + { + // This sequence allows the notification to be immediately dismissed. + checkingNotification.State = ProgressNotificationState.Cancelled; + checkingNotification.Close(false); + checkForUpdatesButton.Enabled.Value = true; + } + } + private void exportLogs() { ProgressNotification notification = new ProgressNotification 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/BindingSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/BindingSettings.cs index a93e6c37af..704fa6e907 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/BindingSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/BindingSettings.cs @@ -13,7 +13,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input { protected override LocalisableString Header => BindingSettingsStrings.ShortcutAndGameplayBindings; - public override IEnumerable FilterTerms => base.FilterTerms.Concat(new LocalisableString[] { @"keybindings", @"controls", @"keyboard", @"keys" }); + public override IEnumerable FilterTerms => base.FilterTerms.Concat(new LocalisableString[] { @"keybindings", @"controls", @"keyboard", @"keys", @"buttons" }); public BindingSettings(KeyBindingPanel keyConfig) { 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/Input/TabletSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs index 4c9320c2a6..00ffbc1120 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs @@ -114,10 +114,10 @@ namespace osu.Game.Overlays.Settings.Sections.Input if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows || RuntimeInfo.OS == RuntimeInfo.Platform.Linux) { t.NewLine(); - var formattedSource = MessageFormatter.FormatText(localisation.GetLocalisedBindableString(TabletSettingsStrings.NoTabletDetectedDescription( + var formattedSource = MessageFormatter.FormatText(localisation.GetLocalisedString(TabletSettingsStrings.NoTabletDetectedDescription( RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? @"https://opentabletdriver.net/Wiki/FAQ/Windows" - : @"https://opentabletdriver.net/Wiki/FAQ/Linux")).Value); + : @"https://opentabletdriver.net/Wiki/FAQ/Linux"))); t.AddLinks(formattedSource.Text, formattedSource.Links); } }), 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/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs new file mode 100644 index 0000000000..f75fc2c8bc --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs @@ -0,0 +1,36 @@ +// 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.Localisation; +using osu.Framework.Screens; +using osu.Game.Localisation; +using osu.Game.Screens; +using osu.Game.Screens.Import; +using osu.Game.Screens.Utility; + +namespace osu.Game.Overlays.Settings.Sections.Maintenance +{ + public partial class GeneralSettings : SettingsSubsection + { + protected override LocalisableString Header => CommonStrings.General; + + [BackgroundDependencyLoader] + private void load(IPerformFromScreenRunner? performer) + { + Children = new[] + { + new SettingsButton + { + Text = DebugSettingsStrings.ImportFiles, + Action = () => performer?.PerformFromScreen(menu => menu.Push(new FileImportScreen())) + }, + new SettingsButton + { + Text = DebugSettingsStrings.RunLatencyCertifier, + Action = () => performer?.PerformFromScreen(menu => menu.Push(new LatencyCertifierScreen())) + } + }; + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs index 3bba480aaa..c0363851ef 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs @@ -26,7 +26,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance [Resolved(canBeNull: true)] private OsuGame game { get; set; } - public override bool AllowBackButton => false; + public override bool AllowUserExit => false; public override bool AllowExternalScreenChange => false; diff --git a/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs b/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs index bd90e4c35d..f1b1511df8 100644 --- a/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs +++ b/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs @@ -23,6 +23,7 @@ namespace osu.Game.Overlays.Settings.Sections { Children = new Drawable[] { + new GeneralSettings(), new BeatmapSettings(), new SkinSettings(), new CollectionsSettings(), diff --git a/osu.Game/Overlays/SettingsOverlay.cs b/osu.Game/Overlays/SettingsOverlay.cs index 9076dadf93..1157860e03 100644 --- a/osu.Game/Overlays/SettingsOverlay.cs +++ b/osu.Game/Overlays/SettingsOverlay.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Development; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; @@ -27,21 +28,28 @@ namespace osu.Game.Overlays public LocalisableString Title => SettingsStrings.HeaderTitle; public LocalisableString Description => SettingsStrings.HeaderDescription; - protected override IEnumerable CreateSections() => new SettingsSection[] + protected override IEnumerable CreateSections() { - // This list should be kept in sync with ScreenBehaviour. - new GeneralSection(), - new SkinSection(), - new InputSection(createSubPanel(new KeyBindingPanel())), - new UserInterfaceSection(), - new GameplaySection(), - new RulesetSection(), - new AudioSection(), - new GraphicsSection(), - new OnlineSection(), - new MaintenanceSection(), - new DebugSection(), - }; + var sections = new List + { + // This list should be kept in sync with ScreenBehaviour. + new GeneralSection(), + new SkinSection(), + new InputSection(createSubPanel(new KeyBindingPanel())), + new UserInterfaceSection(), + new GameplaySection(), + new RulesetSection(), + new AudioSection(), + new GraphicsSection(), + new OnlineSection(), + new MaintenanceSection(), + }; + + if (DebugUtils.IsDebugBuild) + sections.Add(new DebugSection()); + + return sections; + } private readonly List subPanels = new List(); diff --git a/osu.Game/Overlays/SettingsToolboxGroup.cs b/osu.Game/Overlays/SettingsToolboxGroup.cs index 53849fa53c..f8cf218564 100644 --- a/osu.Game/Overlays/SettingsToolboxGroup.cs +++ b/osu.Game/Overlays/SettingsToolboxGroup.cs @@ -184,7 +184,7 @@ namespace osu.Game.Overlays content.ResizeHeightTo(0, animate ? transition_duration : 0, Easing.OutQuint); } - headerContent.FadeColour(Expanded.Value ? Color4.White : OsuColour.Gray(0.5f), 200, Easing.OutQuint); + headerContent.FadeColour(Expanded.Value ? Color4.White : OsuColour.Gray(0.7f), 200, Easing.OutQuint); } private void updateFadeState() diff --git a/osu.Game/Overlays/SkinEditor/SkinEditor.cs b/osu.Game/Overlays/SkinEditor/SkinEditor.cs index 6f7781ee9c..18e01e2490 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditor.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditor.cs @@ -34,11 +34,13 @@ 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; +using osu.Game.Utils; 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 +157,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 +169,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 +315,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 +382,7 @@ namespace osu.Game.Overlays.SkinEditor componentsSidebar.Children = new[] { - new EditorSidebarSection("Current working layer") + new EditorSidebarSection(SkinEditorStrings.CurrentWorkingLayer) { Children = new Drawable[] { @@ -689,7 +710,7 @@ namespace osu.Game.Overlays.SkinEditor Task ICanAcceptFiles.Import(ImportTask[] tasks, ImportParameters parameters) => throw new NotImplementedException(); - public IEnumerable HandledExtensions => new[] { ".jpg", ".jpeg", ".png" }; + public IEnumerable HandledExtensions => SupportedExtensions.IMAGE_EXTENSIONS; #endregion 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/Overlays/Toolbar/ToolbarUserButton.cs b/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs index 96c0b15c44..787c525566 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs @@ -130,7 +130,7 @@ namespace osu.Game.Overlays.Toolbar break; default: - throw new ArgumentOutOfRangeException(); + throw new ArgumentOutOfRangeException(nameof(state.NewValue)); } }); } diff --git a/osu.Game/Overlays/Toolbar/TransientUserStatisticsUpdateDisplay.cs b/osu.Game/Overlays/Toolbar/TransientUserStatisticsUpdateDisplay.cs index 07c2e72774..d5891da936 100644 --- a/osu.Game/Overlays/Toolbar/TransientUserStatisticsUpdateDisplay.cs +++ b/osu.Game/Overlays/Toolbar/TransientUserStatisticsUpdateDisplay.cs @@ -21,7 +21,7 @@ namespace osu.Game.Overlays.Toolbar { public partial class TransientUserStatisticsUpdateDisplay : CompositeDrawable { - public Bindable LatestUpdate { get; } = new Bindable(); + public Bindable LatestUpdate { get; } = new Bindable(); private Statistic globalRank = null!; private Statistic pp = null!; @@ -48,7 +48,7 @@ namespace osu.Game.Overlays.Toolbar }; if (userStatisticsWatcher != null) - ((IBindable)LatestUpdate).BindTo(userStatisticsWatcher.LatestUpdate); + ((IBindable)LatestUpdate).BindTo(userStatisticsWatcher.LatestUpdate); } protected override void LoadComplete() diff --git a/osu.Game/Overlays/VersionManager.cs b/osu.Game/Overlays/VersionManager.cs deleted file mode 100644 index 71f8fc05aa..0000000000 --- a/osu.Game/Overlays/VersionManager.cs +++ /dev/null @@ -1,95 +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.Framework.Allocation; -using osu.Framework.Development; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.Textures; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Overlays -{ - public partial class VersionManager : VisibilityContainer - { - [BackgroundDependencyLoader] - private void load(OsuColour colours, TextureStore textures, OsuGameBase game) - { - AutoSizeAxes = Axes.Both; - Anchor = Anchor.BottomCentre; - Origin = Anchor.BottomCentre; - - Alpha = 0; - - FillFlowContainer mainFill; - - Children = new Drawable[] - { - mainFill = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(5), - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Children = new Drawable[] - { - new OsuSpriteText - { - Font = OsuFont.GetFont(weight: FontWeight.Bold), - Text = game.Name - }, - new OsuSpriteText - { - Colour = DebugUtils.IsDebugBuild ? colours.Red : Color4.White, - Text = game.Version - }, - } - }, - } - } - }; - - if (!game.IsDeployedBuild) - { - mainFill.AddRange(new Drawable[] - { - new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Font = OsuFont.Numeric.With(size: 12), - Colour = colours.Yellow, - Text = @"Development Build" - }, - new Sprite - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Texture = textures.Get(@"Menu/dev-build-footer"), - }, - }); - } - } - - protected override void PopIn() - { - this.FadeIn(1400, Easing.OutQuint); - } - - protected override void PopOut() - { - this.FadeOut(500, Easing.OutQuint); - } - } -} diff --git a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs index ae4239c148..7b6bc37a61 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs @@ -29,6 +29,7 @@ namespace osu.Game.Rulesets.Difficulty protected const int ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT = 23; protected const int ATTRIB_ID_AIM_DIFFICULT_STRAIN_COUNT = 25; protected const int ATTRIB_ID_OK_HIT_WINDOW = 27; + protected const int ATTRIB_ID_MONO_STAMINA_FACTOR = 29; /// /// The mods which were applied to the beatmap. diff --git a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs index 63b27243d0..14acc9b908 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs @@ -341,6 +341,84 @@ namespace osu.Game.Rulesets.Difficulty public double GetMostCommonBeatLength() => baseBeatmap.GetMostCommonBeatLength(); public IBeatmap Clone() => new ProgressiveCalculationBeatmap(baseBeatmap.Clone()); + public double AudioLeadIn + { + get => baseBeatmap.AudioLeadIn; + set => baseBeatmap.AudioLeadIn = value; + } + + public float StackLeniency + { + get => baseBeatmap.StackLeniency; + set => baseBeatmap.StackLeniency = value; + } + + public bool SpecialStyle + { + get => baseBeatmap.SpecialStyle; + set => baseBeatmap.SpecialStyle = value; + } + + public bool LetterboxInBreaks + { + get => baseBeatmap.LetterboxInBreaks; + set => baseBeatmap.LetterboxInBreaks = value; + } + + public bool WidescreenStoryboard + { + get => baseBeatmap.WidescreenStoryboard; + set => baseBeatmap.WidescreenStoryboard = value; + } + + public bool EpilepsyWarning + { + get => baseBeatmap.EpilepsyWarning; + set => baseBeatmap.EpilepsyWarning = value; + } + + public bool SamplesMatchPlaybackRate + { + get => baseBeatmap.SamplesMatchPlaybackRate; + set => baseBeatmap.SamplesMatchPlaybackRate = value; + } + + public double DistanceSpacing + { + get => baseBeatmap.DistanceSpacing; + set => baseBeatmap.DistanceSpacing = value; + } + + public int GridSize + { + get => baseBeatmap.GridSize; + set => baseBeatmap.GridSize = value; + } + + public double TimelineZoom + { + get => baseBeatmap.TimelineZoom; + set => baseBeatmap.TimelineZoom = value; + } + + public CountdownType Countdown + { + get => baseBeatmap.Countdown; + set => baseBeatmap.Countdown = value; + } + + public int CountdownOffset + { + get => baseBeatmap.CountdownOffset; + set => baseBeatmap.CountdownOffset = value; + } + + public int[] Bookmarks + { + get => baseBeatmap.Bookmarks; + set => baseBeatmap.Bookmarks = value; + } + #endregion } } 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/Checks/Components/AudioCheckUtils.cs b/osu.Game/Rulesets/Edit/Checks/Components/AudioCheckUtils.cs index b8cbe63c1e..8a35b84170 100644 --- a/osu.Game/Rulesets/Edit/Checks/Components/AudioCheckUtils.cs +++ b/osu.Game/Rulesets/Edit/Checks/Components/AudioCheckUtils.cs @@ -3,13 +3,12 @@ using System.IO; using System.Linq; +using osu.Game.Utils; namespace osu.Game.Rulesets.Edit.Checks.Components { public static class AudioCheckUtils { - public static readonly string[] AUDIO_EXTENSIONS = { "mp3", "ogg", "wav" }; - - public static bool HasAudioExtension(string filename) => AUDIO_EXTENSIONS.Any(Path.GetExtension(filename).ToLowerInvariant().EndsWith); + public static bool HasAudioExtension(string filename) => SupportedExtensions.AUDIO_EXTENSIONS.Contains(Path.GetExtension(filename).ToLowerInvariant()); } } diff --git a/osu.Game/Rulesets/Edit/ComposerDistanceSnapProvider.cs b/osu.Game/Rulesets/Edit/ComposerDistanceSnapProvider.cs index cf41c8e108..7337a75509 100644 --- a/osu.Game/Rulesets/Edit/ComposerDistanceSnapProvider.cs +++ b/osu.Game/Rulesets/Edit/ComposerDistanceSnapProvider.cs @@ -100,7 +100,7 @@ namespace osu.Game.Rulesets.Edit } }); - DistanceSpacingMultiplier.Value = editorBeatmap.BeatmapInfo.DistanceSpacing; + DistanceSpacingMultiplier.Value = editorBeatmap.DistanceSpacing; DistanceSpacingMultiplier.BindValueChanged(multiplier => { distanceSpacingSlider.ContractedLabelText = $"D. S. ({multiplier.NewValue:0.##x})"; @@ -109,7 +109,7 @@ namespace osu.Game.Rulesets.Edit if (multiplier.NewValue != multiplier.OldValue) onScreenDisplay?.Display(new DistanceSpacingToast(multiplier.NewValue.ToLocalisableString(@"0.##x"), multiplier)); - editorBeatmap.BeatmapInfo.DistanceSpacing = multiplier.NewValue; + editorBeatmap.DistanceSpacing = multiplier.NewValue; }, true); DistanceSpacingMultiplier.BindDisabledChanged(disabled => distanceSpacingSlider.Alpha = disabled ? 0 : 1, true); @@ -163,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() { 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 0499e10607..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; @@ -178,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), + }, + } + } + }, } }, } @@ -231,7 +272,7 @@ 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(); @@ -303,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) @@ -362,7 +403,7 @@ 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 (checkToolboxMappingFromKey(e.Key, out int leftIndex)) @@ -379,14 +420,26 @@ namespace osu.Game.Rulesets.Edit 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; + } } } @@ -484,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 17fae9e8b2..612e09d3ea 100644 --- a/osu.Game/Rulesets/Edit/IDistanceSnapProvider.cs +++ b/osu.Game/Rulesets/Edit/IDistanceSnapProvider.cs @@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Edit /// A multiplier which changes the ratio of distance travelled per time unit. /// Importantly, this is provided for manual usage, and not multiplied into any of the methods exposed by this interface. /// - /// + /// Bindable DistanceSpacingMultiplier { get; } /// diff --git a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs index a36de02433..52b8a5c796 100644 --- a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs +++ b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs @@ -7,7 +7,6 @@ using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Game.Input.Bindings; using osu.Game.Rulesets.Objects; -using osuTK; using osuTK.Input; namespace osu.Game.Rulesets.Edit @@ -15,7 +14,7 @@ namespace osu.Game.Rulesets.Edit /// /// A blueprint which governs the placement of something. /// - public abstract partial class PlacementBlueprint : CompositeDrawable, IKeyBindingHandler + public abstract partial class PlacementBlueprint : VisibilityContainer, IKeyBindingHandler { /// /// Whether the is currently mid-placement, but has not necessarily finished being placed. @@ -31,12 +30,17 @@ namespace osu.Game.Rulesets.Edit /// protected virtual bool IsValidForPlacement => true; + // the blueprint should still be considered for input even if it is hidden, + // especially when such input is the reason for making the blueprint become visible. + public override bool PropagatePositionalInputSubTree => true; + public override bool PropagateNonPositionalInputSubTree => true; + protected PlacementBlueprint() { RelativeSizeAxes = Axes.Both; - // This is required to allow the blueprint's position to be updated via OnMouseMove/Handle - // on the same frame it is made visible via a PlacementState change. + // the blueprint should still be considered for input even if it is hidden, + // especially when such input is the reason for making the blueprint become visible. AlwaysPresent = true; } @@ -104,8 +108,6 @@ namespace osu.Game.Rulesets.Edit { } - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; - protected override bool Handle(UIEvent e) { base.Handle(e); @@ -127,6 +129,9 @@ namespace osu.Game.Rulesets.Edit } } + protected override void PopIn() => this.FadeIn(); + protected override void PopOut() => this.FadeOut(); + public enum PlacementState { Waiting, diff --git a/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs b/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs index 19554b6504..83a48599ca 100644 --- a/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs +++ b/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs @@ -173,10 +173,10 @@ namespace osu.Game.Rulesets.Mods }; drawable.OnRevertResult += (_, result) => { - if (!ratesForRewinding.ContainsKey(result.HitObject)) return; + if (!ratesForRewinding.TryGetValue(result.HitObject, out double rate)) return; if (!shouldProcessResult(result)) return; - recentRates.Insert(0, ratesForRewinding[result.HitObject]); + recentRates.Insert(0, rate); ratesForRewinding.Remove(result.HitObject); recentRates.RemoveAt(recentRates.Count - 1); @@ -205,7 +205,7 @@ namespace osu.Game.Rulesets.Mods { foreach (var hitObject in hitObjects) { - if (!(hitObject.HitWindows is HitWindows.EmptyHitWindows)) + if (hitObject.HitWindows != HitWindows.Empty) yield return hitObject; foreach (HitObject nested in getAllApplicableHitObjects(hitObject.NestedHitObjects)) 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..0162c8017b 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; @@ -230,7 +243,7 @@ namespace osu.Game.Rulesets.Objects.Legacy return PathType.CATMULL; case 'B': - if (input.Length > 1 && int.TryParse(input.Substring(1), out int degree) && degree > 0) + if (input.Length > 1 && int.TryParse(input.AsSpan(1), out int degree) && degree > 0) return PathType.BSpline(degree); return PathType.BEZIER; @@ -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/Ruleset.cs b/osu.Game/Rulesets/Ruleset.cs index a5679af023..749ade9d15 100644 --- a/osu.Game/Rulesets/Ruleset.cs +++ b/osu.Game/Rulesets/Ruleset.cs @@ -101,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(); } /// diff --git a/osu.Game/Rulesets/Scoring/HitEventExtensions.cs b/osu.Game/Rulesets/Scoring/HitEventExtensions.cs index 6e2852676a..269342460f 100644 --- a/osu.Game/Rulesets/Scoring/HitEventExtensions.cs +++ b/osu.Game/Rulesets/Scoring/HitEventExtensions.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using osu.Game.Rulesets.Objects; namespace osu.Game.Rulesets.Scoring { @@ -20,32 +21,36 @@ namespace osu.Game.Rulesets.Scoring /// A non-null value if unstable rate could be calculated, /// and if unstable rate cannot be calculated due to being empty. /// - public static double? CalculateUnstableRate(this IEnumerable hitEvents) + public static UnstableRateCalculationResult? CalculateUnstableRate(this IReadOnlyList hitEvents, UnstableRateCalculationResult? result = null) { Debug.Assert(hitEvents.All(ev => ev.GameplayRate != null)); - int count = 0; - double mean = 0; - double sumOfSquares = 0; + result ??= new UnstableRateCalculationResult(); - foreach (var e in hitEvents) + // Handle rewinding in the simplest way possible. + if (hitEvents.Count < result.EventCount + 1) + result = new UnstableRateCalculationResult(); + + for (int i = result.EventCount; i < hitEvents.Count; i++) { - if (!affectsUnstableRate(e)) + HitEvent e = hitEvents[i]; + + if (!AffectsUnstableRate(e)) continue; - count++; + result.EventCount++; // Division by gameplay rate is to account for TimeOffset scaling with gameplay rate. double currentValue = e.TimeOffset / e.GameplayRate!.Value; - double nextMean = mean + (currentValue - mean) / count; - sumOfSquares += (currentValue - mean) * (currentValue - nextMean); - mean = nextMean; + double nextMean = result.Mean + (currentValue - result.Mean) / result.EventCount; + result.SumOfSquares += (currentValue - result.Mean) * (currentValue - nextMean); + result.Mean = nextMean; } - if (count == 0) + if (result.EventCount == 0) return null; - return 10.0 * Math.Sqrt(sumOfSquares / count); + return result; } /// @@ -57,7 +62,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 +70,39 @@ 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) => AffectsUnstableRate(e.HitObject, e.Result); + public static bool AffectsUnstableRate(HitObject hitObject, HitResult result) => hitObject.HitWindows != HitWindows.Empty && result.IsHit(); + + /// + /// Data type returned by which allows efficient incremental processing. + /// + /// + /// This should be passed back into future calls as a parameter. + /// + /// The optimisations used here rely on hit events being a consecutive sequence from a single gameplay session. + /// When a new gameplay session is started, any existing results should be disposed. + /// + public class UnstableRateCalculationResult + { + /// + /// Total events processed. For internal incremental calculation use. + /// + public int EventCount; + + /// + /// Last sum-of-squares value. For internal incremental calculation use. + /// + public double SumOfSquares; + + /// + /// Last mean value. For internal incremental calculation use. + /// + public double Mean; + + /// + /// The unstable rate. + /// + public double Result => EventCount == 0 ? 0 : 10.0 * Math.Sqrt(SumOfSquares / EventCount); + } } } diff --git a/osu.Game/Rulesets/Scoring/HitWindows.cs b/osu.Game/Rulesets/Scoring/HitWindows.cs index 2d008b58ba..a6a268fc78 100644 --- a/osu.Game/Rulesets/Scoring/HitWindows.cs +++ b/osu.Game/Rulesets/Scoring/HitWindows.cs @@ -36,7 +36,7 @@ namespace osu.Game.Rulesets.Scoring /// An empty with only and . /// No time values are provided (meaning instantaneous hit or miss). /// - public static HitWindows Empty => new EmptyHitWindows(); + public static HitWindows Empty { get; } = new EmptyHitWindows(); public HitWindows() { @@ -182,7 +182,7 @@ namespace osu.Game.Rulesets.Scoring /// protected virtual DifficultyRange[] GetRanges() => base_ranges; - public class EmptyHitWindows : HitWindows + private class EmptyHitWindows : HitWindows { private static readonly DifficultyRange[] ranges = { 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/Rulesets/UI/FrameStabilityContainer.cs b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs index c4feb249f4..92258f3fc9 100644 --- a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs +++ b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs @@ -3,19 +3,15 @@ using System; using System.Diagnostics; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Logging; -using osu.Framework.Testing; using osu.Framework.Timing; -using osu.Game.Beatmaps; using osu.Game.Input.Handlers; using osu.Game.Screens.Play; -using osu.Game.Utils; namespace osu.Game.Rulesets.UI { @@ -168,13 +164,7 @@ namespace osu.Game.Rulesets.UI if (lastBackwardsSeekLogTime == null || Math.Abs(Clock.CurrentTime - lastBackwardsSeekLogTime.Value) > 1000) { lastBackwardsSeekLogTime = Clock.CurrentTime; - - string loggableContent = $"Denying backwards seek during gameplay (reference: {referenceClock.CurrentTime:N2} stable: {proposedTime:N2})"; - - if (parentGameplayClock is GameplayClockContainer gcc) - loggableContent += $"\n{gcc.ChildrenOfType().Single().GetSnapshot()}"; - - Logger.Error(new SentryOnlyDiagnosticsException("backwards seek"), loggableContent); + Logger.Log($"Denying backwards seek during gameplay (reference: {referenceClock.CurrentTime:N2} stable: {proposedTime:N2})"); } state = PlaybackState.NotValid; 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/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs index e3601fe91e..3177873182 100644 --- a/osu.Game/Scoring/ScoreManager.cs +++ b/osu.Game/Scoring/ScoreManager.cs @@ -78,7 +78,7 @@ namespace osu.Game.Scoring /// Perform a lookup query on available s. /// /// The query. - /// The first result for the provided query, or null if no results were found. + /// The first result for the provided query in its detached form, or null if no results were found. public ScoreInfo? Query(Expression> query) { return Realm.Run(r => r.All().FirstOrDefault(query)?.Detach()); @@ -88,8 +88,14 @@ namespace osu.Game.Scoring { ScoreInfo? databasedScoreInfo = null; - if (originalScoreInfo is ScoreInfo scoreInfo && !string.IsNullOrEmpty(scoreInfo.Hash)) - databasedScoreInfo = Query(s => s.Hash == scoreInfo.Hash); + if (originalScoreInfo is ScoreInfo scoreInfo) + { + if (scoreInfo.IsManaged) + return scoreInfo.Detach(); + + if (!string.IsNullOrEmpty(scoreInfo.Hash)) + databasedScoreInfo = Query(s => s.Hash == scoreInfo.Hash); + } if (originalScoreInfo.OnlineID > 0) databasedScoreInfo ??= Query(s => s.OnlineID == originalScoreInfo.OnlineID); diff --git a/osu.Game/Screens/Edit/Components/Menus/EditorMenuBar.cs b/osu.Game/Screens/Edit/Components/Menus/EditorMenuBar.cs index 47a13dcfba..76b8811b89 100644 --- a/osu.Game/Screens/Edit/Components/Menus/EditorMenuBar.cs +++ b/osu.Game/Screens/Edit/Components/Menus/EditorMenuBar.cs @@ -99,7 +99,7 @@ namespace osu.Game.Screens.Edit.Components.Menus ForegroundColourHover = colourProvider.Content1; BackgroundColourHover = colourProvider.Background1; - AddInternal(hoverClickSounds = new HoverClickSounds()); + AddInternal(hoverClickSounds = new HoverClickSounds(HoverSampleSet.MenuOpen)); } protected override void LoadComplete() 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/Components/Timelines/Summary/Parts/BookmarkPart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BookmarkPart.cs index 189cb4ba4a..4b178dd831 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BookmarkPart.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BookmarkPart.cs @@ -2,7 +2,11 @@ // 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.Cursor; +using osu.Framework.Graphics.Pooling; +using osu.Framework.Graphics.Shapes; using osu.Framework.Localisation; using osu.Game.Extensions; using osu.Game.Graphics; @@ -15,24 +19,69 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts /// public partial class BookmarkPart : TimelinePart { + private readonly BindableList bookmarks = new BindableList(); + + private DrawablePool pool = null!; + + [BackgroundDependencyLoader] + private void load() + { + AddInternal(pool = new DrawablePool(10)); + } + protected override void LoadBeatmap(EditorBeatmap beatmap) { base.LoadBeatmap(beatmap); - foreach (int bookmark in beatmap.BeatmapInfo.Bookmarks) - Add(new BookmarkVisualisation(bookmark)); + + bookmarks.UnbindAll(); + bookmarks.BindTo(beatmap.Bookmarks); } - private partial class BookmarkVisualisation : PointVisualisation, IHasTooltip + protected override void LoadComplete() { - public BookmarkVisualisation(double startTime) - : base(startTime) + base.LoadComplete(); + bookmarks.BindCollectionChanged((_, _) => { + Clear(disposeChildren: false); + foreach (int bookmark in bookmarks) + Add(pool.Get(v => v.StartTime = bookmark)); + }, true); + } + + private partial class BookmarkVisualisation : PoolableDrawable, IHasTooltip + { + private int startTime; + + public int StartTime + { + get => startTime; + set + { + if (startTime == value) + return; + + startTime = value; + X = startTime; + } } [BackgroundDependencyLoader] - private void load(OsuColour colours) => Colour = colours.Blue; + private void load(OsuColour colours) + { + RelativePositionAxes = Axes.Both; + RelativeSizeAxes = Axes.Y; - public LocalisableString TooltipText => $"{StartTime.ToEditorFormattedString()} bookmark"; + Anchor = Anchor.CentreLeft; + Origin = Anchor.Centre; + + Width = PointVisualisation.MAX_WIDTH; + Height = 0.4f; + + Colour = colours.Blue; + InternalChild = new FastCircle { RelativeSizeAxes = Axes.Both }; + } + + public LocalisableString TooltipText => $"{((double)StartTime).ToEditorFormattedString()} bookmark"; } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs index 30c1258f93..4a321f4a81 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. @@ -428,7 +433,10 @@ namespace osu.Game.Screens.Edit.Compose.Components /// Finishes the current blueprint selection. /// /// The mouse event which triggered end of selection. - /// Whether a click selection was active. + /// + /// Whether the mouse event is considered to be fully handled. + /// If the return value is , the standard click / mouse up action will follow. + /// 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. @@ -438,14 +446,16 @@ namespace osu.Game.Screens.Edit.Compose.Components if (e.ControlPressed) { - // if a selection didn't occur, we may want to trigger a deselection. - // Iterate from the top of the input stack (blueprints closest to the front of the screen first). // Priority is given to already-selected blueprints. foreach (SelectionBlueprint blueprint in SelectionBlueprints.AliveChildren.Where(b => b.IsHovered).OrderByDescending(b => b.IsSelected)) return clickSelectionHandled = SelectionHandler.MouseUpSelectionRequested(blueprint, e); - return false; + // can only be reached if there are no hovered blueprints. + // in that case, we still want to suppress mouse up / click handling, because when control is pressed, + // it is presumed we want to add to existing selection, not remove from it + // (unless explicitly control-clicking a selected object, which is handled above). + return true; } if (selectedBlueprintAlreadySelectedOnMouseDown && SelectedItems.Count == 1) diff --git a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs index cbec8fc7a3..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,18 +291,41 @@ 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, CurrentPlacement.SnapType); @@ -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/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 dbbf767a7d..78cee2c1cf 100644 --- a/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Collections.Specialized; using System.Linq; using Humanizer; using osu.Framework.Allocation; @@ -10,7 +11,6 @@ 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; @@ -37,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); @@ -59,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) /// @@ -91,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; } @@ -128,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) { @@ -171,12 +256,26 @@ namespace osu.Game.Screens.Edit.Compose.Components }; } + private void resetTernaryStates() + { + if (SelectedItems.Count > 0) + return; + + 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(); @@ -187,18 +286,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; } } @@ -214,12 +329,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; @@ -233,12 +348,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); @@ -282,9 +432,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); } @@ -415,6 +565,12 @@ namespace osu.Game.Screens.Edit.Compose.Components 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() + }; } #endregion 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/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/Timeline.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs index aea8d02838..66621afa21 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs @@ -157,7 +157,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline Scheduler.AddOnce(applyVisualOffset, beatmap); }, true); - Zoom = (float)(defaultTimelineZoom * editorBeatmap.BeatmapInfo.TimelineZoom); + Zoom = (float)(defaultTimelineZoom * editorBeatmap.TimelineZoom); } private void applyVisualOffset(IBindable beatmap) @@ -212,7 +212,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline float minimumZoom = getZoomLevelForVisibleMilliseconds(10000); float maximumZoom = getZoomLevelForVisibleMilliseconds(500); - float initialZoom = (float)Math.Clamp(defaultTimelineZoom * (editorBeatmap.BeatmapInfo.TimelineZoom == 0 ? 1 : editorBeatmap.BeatmapInfo.TimelineZoom), minimumZoom, maximumZoom); + float initialZoom = (float)Math.Clamp(defaultTimelineZoom * (editorBeatmap.TimelineZoom == 0 ? 1 : editorBeatmap.TimelineZoom), minimumZoom, maximumZoom); SetupZoom(initialZoom, minimumZoom, maximumZoom); @@ -234,7 +234,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline protected override void OnZoomChanged() { base.OnZoomChanged(); - editorBeatmap.BeatmapInfo.TimelineZoom = Zoom / defaultTimelineZoom; + editorBeatmap.TimelineZoom = Zoom / defaultTimelineZoom; } protected override void UpdateAfterChildren() diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs index a6af83d268..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() @@ -287,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/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index e9bcd3050b..a022ca5435 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -80,8 +80,6 @@ namespace osu.Game.Screens.Edit public override float BackgroundParallaxAmount => 0.1f; - public override bool AllowBackButton => false; - public override bool HideOverlaysOnEnter => true; public override bool DisallowExternalBeatmapRulesetChanges => true; @@ -194,6 +192,8 @@ namespace osu.Game.Screens.Edit } } + protected override bool InitialBackButtonVisibility => false; + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); @@ -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 { @@ -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,41 @@ 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), + new EditorMenuItem(EditorStrings.Bookmarks) + { + Items = new MenuItem[] + { + new EditorMenuItem(EditorStrings.AddBookmark, MenuItemType.Standard, addBookmarkAtCurrentTime) + { + Hotkey = new Hotkey(GlobalAction.EditorAddBookmark), + }, + new EditorMenuItem(EditorStrings.RemoveClosestBookmark, MenuItemType.Destructive, removeBookmarksInProximityToCurrentTime) + { + Hotkey = new Hotkey(GlobalAction.EditorRemoveClosestBookmark) + }, + new EditorMenuItem(EditorStrings.SeekToPreviousBookmark, MenuItemType.Standard, () => seekBookmark(-1)) + { + Hotkey = new Hotkey(GlobalAction.EditorSeekToPreviousBookmark) + }, + new EditorMenuItem(EditorStrings.SeekToNextBookmark, MenuItemType.Standard, () => seekBookmark(1)) + { + Hotkey = new Hotkey(GlobalAction.EditorSeekToNextBookmark) + }, + new EditorMenuItem(EditorStrings.ResetBookmarks, MenuItemType.Destructive, () => editorBeatmap.Bookmarks.Clear()) + } + } } } } @@ -741,6 +776,14 @@ namespace osu.Game.Screens.Edit case GlobalAction.EditorSeekToNextSamplePoint: seekSamplePoint(1); return true; + + case GlobalAction.EditorSeekToPreviousBookmark: + seekBookmark(-1); + return true; + + case GlobalAction.EditorSeekToNextBookmark: + seekBookmark(1); + return true; } if (e.Repeat) @@ -748,9 +791,12 @@ namespace osu.Game.Screens.Edit switch (e.Action) { - case GlobalAction.Back: - // as we don't want to display the back button, manual handling of exit action is required. - this.Exit(); + case GlobalAction.EditorAddBookmark: + addBookmarkAtCurrentTime(); + return true; + + case GlobalAction.EditorRemoveClosestBookmark: + removeBookmarksInProximityToCurrentTime(); return true; case GlobalAction.EditorCloneSelection: @@ -785,6 +831,19 @@ namespace osu.Game.Screens.Edit return false; } + private void addBookmarkAtCurrentTime() + { + int bookmark = (int)clock.CurrentTimeAccurate; + int idx = editorBeatmap.Bookmarks.BinarySearch(bookmark); + if (idx < 0) + editorBeatmap.Bookmarks.Insert(~idx, bookmark); + } + + private void removeBookmarksInProximityToCurrentTime() + { + editorBeatmap.Bookmarks.RemoveAll(b => Math.Abs(b - clock.CurrentTimeAccurate) < 2000); + } + public void OnReleased(KeyBindingReleaseEvent e) { } @@ -1098,8 +1157,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) @@ -1116,6 +1179,16 @@ namespace osu.Game.Screens.Edit clock.SeekSmoothlyTo(found.StartTime); } + private void seekBookmark(int direction) + { + int? targetBookmark = direction < 1 + ? editorBeatmap.Bookmarks.Cast().LastOrDefault(b => b < clock.CurrentTimeAccurate) + : editorBeatmap.Bookmarks.Cast().FirstOrDefault(b => b > clock.CurrentTimeAccurate); + + if (targetBookmark != null) + clock.SeekSmoothlyTo(targetBookmark.Value); + } + private void seekSamplePoint(int direction) { double currentTime = clock.CurrentTimeAccurate; diff --git a/osu.Game/Screens/Edit/EditorBeatmap.cs b/osu.Game/Screens/Edit/EditorBeatmap.cs index ad31c2ccc3..44f9646889 100644 --- a/osu.Game/Screens/Edit/EditorBeatmap.cs +++ b/osu.Game/Screens/Edit/EditorBeatmap.cs @@ -118,6 +118,14 @@ namespace osu.Game.Screens.Edit playableBeatmap.Breaks.AddRange(Breaks); }); + Bookmarks = new BindableList(playableBeatmap.Bookmarks); + Bookmarks.BindCollectionChanged((_, _) => + { + BeginChange(); + playableBeatmap.Bookmarks = Bookmarks.OrderBy(x => x).Distinct().ToArray(); + EndChange(); + }); + PreviewTime = new BindableInt(BeatmapInfo.Metadata.PreviewTime); PreviewTime.BindValueChanged(s => { @@ -198,6 +206,86 @@ namespace osu.Game.Screens.Edit public double GetMostCommonBeatLength() => PlayableBeatmap.GetMostCommonBeatLength(); + public double AudioLeadIn + { + get => PlayableBeatmap.AudioLeadIn; + set => PlayableBeatmap.AudioLeadIn = value; + } + + public float StackLeniency + { + get => PlayableBeatmap.StackLeniency; + set => PlayableBeatmap.StackLeniency = value; + } + + public bool SpecialStyle + { + get => PlayableBeatmap.SpecialStyle; + set => PlayableBeatmap.SpecialStyle = value; + } + + public bool LetterboxInBreaks + { + get => PlayableBeatmap.LetterboxInBreaks; + set => PlayableBeatmap.LetterboxInBreaks = value; + } + + public bool WidescreenStoryboard + { + get => PlayableBeatmap.WidescreenStoryboard; + set => PlayableBeatmap.WidescreenStoryboard = value; + } + + public bool EpilepsyWarning + { + get => PlayableBeatmap.EpilepsyWarning; + set => PlayableBeatmap.EpilepsyWarning = value; + } + + public bool SamplesMatchPlaybackRate + { + get => PlayableBeatmap.SamplesMatchPlaybackRate; + set => PlayableBeatmap.SamplesMatchPlaybackRate = value; + } + + public double DistanceSpacing + { + get => PlayableBeatmap.DistanceSpacing; + set => PlayableBeatmap.DistanceSpacing = value; + } + + public int GridSize + { + get => PlayableBeatmap.GridSize; + set => PlayableBeatmap.GridSize = value; + } + + public double TimelineZoom + { + get => PlayableBeatmap.TimelineZoom; + set => PlayableBeatmap.TimelineZoom = value; + } + + public CountdownType Countdown + { + get => PlayableBeatmap.Countdown; + set => PlayableBeatmap.Countdown = value; + } + + public int CountdownOffset + { + get => PlayableBeatmap.CountdownOffset; + set => PlayableBeatmap.CountdownOffset = value; + } + + public readonly BindableList Bookmarks; + + int[] IBeatmap.Bookmarks + { + get => PlayableBeatmap.Bookmarks; + set => PlayableBeatmap.Bookmarks = value; + } + public IBeatmap Clone() => (EditorBeatmap)MemberwiseClone(); private IList mutableHitObjects => (IList)PlayableBeatmap.HitObjects; diff --git a/osu.Game/Screens/Edit/EditorLoader.cs b/osu.Game/Screens/Edit/EditorLoader.cs index 0e0fb9f795..7c6ee10840 100644 --- a/osu.Game/Screens/Edit/EditorLoader.cs +++ b/osu.Game/Screens/Edit/EditorLoader.cs @@ -36,7 +36,7 @@ namespace osu.Game.Screens.Edit public override float BackgroundParallaxAmount => 0.1f; - public override bool AllowBackButton => false; + public override bool AllowUserExit => false; public override bool HideOverlaysOnEnter => true; diff --git a/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs b/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs index a1ee41fc48..f3d58a3c3c 100644 --- a/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs +++ b/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs @@ -46,6 +46,7 @@ namespace osu.Game.Screens.Edit processHitObjects(result, () => newBeatmap ??= readBeatmap(newState)); processTimingPoints(() => newBeatmap ??= readBeatmap(newState)); processBreaks(() => newBeatmap ??= readBeatmap(newState)); + processBookmarks(() => newBeatmap ??= readBeatmap(newState)); processHitObjectLocalData(() => newBeatmap ??= readBeatmap(newState)); editorBeatmap.EndChange(); } @@ -97,6 +98,27 @@ namespace osu.Game.Screens.Edit } } + private void processBookmarks(Func getNewBeatmap) + { + var newBookmarks = getNewBeatmap().Bookmarks.ToHashSet(); + + foreach (int oldBookmark in editorBeatmap.Bookmarks.ToArray()) + { + if (newBookmarks.Contains(oldBookmark)) + continue; + + editorBeatmap.Bookmarks.Remove(oldBookmark); + } + + foreach (int newBookmark in newBookmarks) + { + if (editorBeatmap.Bookmarks.Contains(newBookmark)) + continue; + + editorBeatmap.Bookmarks.Add(newBookmark); + } + } + private void processHitObjects(DiffResult result, Func getNewBeatmap) { findChangedIndices(result, LegacyDecoder.Section.HitObjects, out var removedIndices, out var addedIndices); diff --git a/osu.Game/Screens/Edit/Setup/DesignSection.cs b/osu.Game/Screens/Edit/Setup/DesignSection.cs index 7def5394e6..f9c9835849 100644 --- a/osu.Game/Screens/Edit/Setup/DesignSection.cs +++ b/osu.Game/Screens/Edit/Setup/DesignSection.cs @@ -39,7 +39,7 @@ namespace osu.Game.Screens.Edit.Setup { Caption = EditorSetupStrings.EnableCountdown, HintText = EditorSetupStrings.CountdownDescription, - Current = { Value = Beatmap.BeatmapInfo.Countdown != CountdownType.None }, + Current = { Value = Beatmap.Countdown != CountdownType.None }, }, CountdownSettings = new FillFlowContainer { @@ -52,14 +52,14 @@ namespace osu.Game.Screens.Edit.Setup CountdownSpeed = new FormEnumDropdown { Caption = EditorSetupStrings.CountdownSpeed, - Current = { Value = Beatmap.BeatmapInfo.Countdown != CountdownType.None ? Beatmap.BeatmapInfo.Countdown : CountdownType.Normal }, + Current = { Value = Beatmap.Countdown != CountdownType.None ? Beatmap.Countdown : CountdownType.Normal }, Items = Enum.GetValues().Where(type => type != CountdownType.None) }, CountdownOffset = new FormNumberBox { Caption = EditorSetupStrings.CountdownOffset, HintText = EditorSetupStrings.CountdownOffsetDescription, - Current = { Value = Beatmap.BeatmapInfo.CountdownOffset.ToString() }, + Current = { Value = Beatmap.CountdownOffset.ToString() }, TabbableContentContainer = this, } } @@ -68,25 +68,25 @@ namespace osu.Game.Screens.Edit.Setup { Caption = EditorSetupStrings.WidescreenSupport, HintText = EditorSetupStrings.WidescreenSupportDescription, - Current = { Value = Beatmap.BeatmapInfo.WidescreenStoryboard } + Current = { Value = Beatmap.WidescreenStoryboard } }, epilepsyWarning = new FormCheckBox { Caption = EditorSetupStrings.EpilepsyWarning, HintText = EditorSetupStrings.EpilepsyWarningDescription, - Current = { Value = Beatmap.BeatmapInfo.EpilepsyWarning } + Current = { Value = Beatmap.EpilepsyWarning } }, letterboxDuringBreaks = new FormCheckBox { Caption = EditorSetupStrings.LetterboxDuringBreaks, HintText = EditorSetupStrings.LetterboxDuringBreaksDescription, - Current = { Value = Beatmap.BeatmapInfo.LetterboxInBreaks } + Current = { Value = Beatmap.LetterboxInBreaks } }, samplesMatchPlaybackRate = new FormCheckBox { Caption = EditorSetupStrings.SamplesMatchPlaybackRate, HintText = EditorSetupStrings.SamplesMatchPlaybackRateDescription, - Current = { Value = Beatmap.BeatmapInfo.SamplesMatchPlaybackRate } + Current = { Value = Beatmap.SamplesMatchPlaybackRate } } }; } @@ -113,18 +113,18 @@ namespace osu.Game.Screens.Edit.Setup { updateBeatmap(); // update displayed text to ensure parsed value matches display (i.e. if empty string was provided). - CountdownOffset.Current.Value = Beatmap.BeatmapInfo.CountdownOffset.ToString(CultureInfo.InvariantCulture); + CountdownOffset.Current.Value = Beatmap.CountdownOffset.ToString(CultureInfo.InvariantCulture); } private void updateBeatmap() { - Beatmap.BeatmapInfo.Countdown = EnableCountdown.Current.Value ? CountdownSpeed.Current.Value : CountdownType.None; - Beatmap.BeatmapInfo.CountdownOffset = int.TryParse(CountdownOffset.Current.Value, NumberStyles.None, CultureInfo.InvariantCulture, out int offset) ? offset : 0; + Beatmap.Countdown = EnableCountdown.Current.Value ? CountdownSpeed.Current.Value : CountdownType.None; + Beatmap.CountdownOffset = int.TryParse(CountdownOffset.Current.Value, NumberStyles.None, CultureInfo.InvariantCulture, out int offset) ? offset : 0; - Beatmap.BeatmapInfo.WidescreenStoryboard = widescreenSupport.Current.Value; - Beatmap.BeatmapInfo.EpilepsyWarning = epilepsyWarning.Current.Value; - Beatmap.BeatmapInfo.LetterboxInBreaks = letterboxDuringBreaks.Current.Value; - Beatmap.BeatmapInfo.SamplesMatchPlaybackRate = samplesMatchPlaybackRate.Current.Value; + Beatmap.WidescreenStoryboard = widescreenSupport.Current.Value; + Beatmap.EpilepsyWarning = epilepsyWarning.Current.Value; + Beatmap.LetterboxInBreaks = letterboxDuringBreaks.Current.Value; + Beatmap.SamplesMatchPlaybackRate = samplesMatchPlaybackRate.Current.Value; Beatmap.SaveState(); } diff --git a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs index 845c21b598..59a0520a52 100644 --- a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs +++ b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs @@ -10,6 +10,7 @@ using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Overlays; using osu.Game.Localisation; +using osu.Game.Utils; namespace osu.Game.Screens.Edit.Setup { @@ -48,12 +49,12 @@ namespace osu.Game.Screens.Edit.Setup Children = new Drawable[] { - backgroundChooser = new FormFileSelector(".jpg", ".jpeg", ".png") + backgroundChooser = new FormFileSelector(SupportedExtensions.IMAGE_EXTENSIONS) { Caption = GameplaySettingsStrings.BackgroundHeader, PlaceholderText = EditorSetupStrings.ClickToSelectBackground, }, - audioTrackChooser = new FormFileSelector(".mp3", ".ogg") + audioTrackChooser = new FormFileSelector(SupportedExtensions.AUDIO_EXTENSIONS) { Caption = EditorSetupStrings.AudioTrack, PlaceholderText = EditorSetupStrings.ClickToSelectTrack, 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/IOsuScreen.cs b/osu.Game/Screens/IOsuScreen.cs index b80c1f87a4..9e474ed0c6 100644 --- a/osu.Game/Screens/IOsuScreen.cs +++ b/osu.Game/Screens/IOsuScreen.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using osu.Framework.Bindables; using osu.Framework.Screens; using osu.Game.Beatmaps; +using osu.Game.Input.Bindings; using osu.Game.Overlays; using osu.Game.Rulesets; using osu.Game.Screens.Footer; @@ -21,15 +22,21 @@ namespace osu.Game.Screens bool DisallowExternalBeatmapRulesetChanges { get; } /// - /// Whether the user can exit this by pressing the back button. + /// Whether the user can exit this . /// - bool AllowBackButton { get; } + /// + /// When overriden to false, + /// the user is blocked from exiting the screen via the action, + /// and the back button is hidden from this screen by the initial state of being set to hidden. + /// + bool AllowUserExit { get; } /// /// Whether a footer (and a back button) should be displayed underneath the screen. /// /// - /// Temporarily, the back button is shown regardless of whether is true. + /// Temporarily, the footer's own back button is shown regardless of whether is set to hidden. + /// This will be corrected as the footer becomes used more commonly. /// bool ShowFooter { get; } @@ -59,6 +66,11 @@ namespace osu.Game.Screens /// IBindable OverlayActivationMode { get; } + /// + /// Whether the back button should be displayed in this screen. + /// + IBindable BackButtonVisibility { get; } + /// /// The current for this screen. /// 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/LogoVisualisation.cs b/osu.Game/Screens/Menu/LogoVisualisation.cs index 6d9d2f69b7..53d153ab31 100644 --- a/osu.Game/Screens/Menu/LogoVisualisation.cs +++ b/osu.Game/Screens/Menu/LogoVisualisation.cs @@ -106,9 +106,12 @@ namespace osu.Game.Screens.Menu foreach (var source in amplitudeSources) addAmplitudesFromSource(source); + float kiaiMultiplier = beatSyncProvider.CheckIsKiaiTime() ? 1 : 0.5f; + for (int i = 0; i < bars_per_visualiser; i++) { - float targetAmplitude = (temporalAmplitudes[(i + indexOffset) % bars_per_visualiser]) * (beatSyncProvider.CheckIsKiaiTime() ? 1 : 0.5f); + float targetAmplitude = (temporalAmplitudes[(i + indexOffset) % bars_per_visualiser]) * kiaiMultiplier; + if (targetAmplitude > frequencyAmplitudes[i]) frequencyAmplitudes[i] = targetAmplitude; } diff --git a/osu.Game/Screens/Menu/MainMenu.cs b/osu.Game/Screens/Menu/MainMenu.cs index 346bdcb751..0630b9612e 100644 --- a/osu.Game/Screens/Menu/MainMenu.cs +++ b/osu.Game/Screens/Menu/MainMenu.cs @@ -48,7 +48,7 @@ namespace osu.Game.Screens.Menu public override bool HideOverlaysOnEnter => Buttons == null || Buttons.State == ButtonSystemState.Initial; - public override bool AllowBackButton => false; + public override bool AllowUserExit => false; public override bool AllowExternalScreenChange => true; @@ -79,9 +79,6 @@ namespace osu.Game.Screens.Menu [Resolved(canBeNull: true)] private IDialogOverlay dialogOverlay { get; set; } - [Resolved(canBeNull: true)] - private VersionManager versionManager { get; set; } - protected override BackgroundScreen CreateBackground() => new BackgroundScreenDefault(); protected override bool PlayExitSound => false; @@ -153,9 +150,9 @@ namespace osu.Game.Screens.Menu else this.Push(new DailyChallengeIntro(room)); }, - OnExit = () => + OnExit = e => { - exitConfirmedViaHoldOrClick = true; + exitConfirmedViaHoldOrClick = e is MouseEvent; this.Exit(); } } @@ -294,16 +291,6 @@ namespace osu.Game.Screens.Menu } } - protected override void Update() - { - base.Update(); - - bottomElementsFlow.Margin = new MarginPadding - { - Bottom = (versionManager?.DrawHeight + 5) ?? 0 - }; - } - protected override void LogoSuspending(OsuLogo logo) { var seq = logo.FadeOut(300, Easing.InSine) 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..3fc5fe57fb 100644 --- a/osu.Game/Screens/Menu/MenuTip.cs +++ b/osu.Game/Screens/Menu/MenuTip.cs @@ -7,12 +7,14 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osuTK; using osuTK.Graphics; +using osu.Game.Localisation; namespace osu.Game.Screens.Menu { @@ -78,50 +80,49 @@ namespace osu.Game.Screens.Menu static void formatRegular(SpriteText t) => t.Font = OsuFont.GetFont(size: 16, weight: FontWeight.Regular); static void formatSemiBold(SpriteText t) => t.Font = OsuFont.GetFont(size: 16, weight: FontWeight.SemiBold); - string tip = getRandomTip(); + var tip = getRandomTip(); textFlow.Clear(); - textFlow.AddParagraph("a tip for you:", formatSemiBold); + textFlow.AddParagraph(MenuTipStrings.MenuTipTitle, formatSemiBold); textFlow.AddParagraph(tip, formatRegular); this.FadeInFromZero(200, Easing.OutQuint) - .Delay(1000 + 80 * tip.Length) + .Delay(1000 + 80 * tip.ToString().Length) .Then() .FadeOutFromOne(2000, Easing.OutQuint); } - private string getRandomTip() + private LocalisableString getRandomTip() { - string[] tips = + LocalisableString[] tips = { - "Press Ctrl-T anywhere in the game to toggle the toolbar!", - "Press Ctrl-O anywhere in the game to access options!", - "All settings are dynamic and take effect in real-time. Try changing the skin while watching autoplay!", - "New features are coming online every update. Make sure to stay up-to-date!", - "If you find the UI too large or small, try adjusting UI scale in settings!", - "Try adjusting the \"Screen Scaling\" mode to change your gameplay or UI area, even in fullscreen!", - "What used to be \"osu!direct\" is available to all users just like on the website. You can access it anywhere using Ctrl-B!", - "Seeking in replays is available by dragging on the progress bar at the bottom of the screen or by using the left and right arrow keys!", - "Multithreading support means that even with low \"FPS\" your input and judgements will be accurate!", - "Try scrolling right in mod select to find a bunch of new fun mods!", - "Most of the web content (profiles, rankings, etc.) are available natively in-game from the icons on the toolbar!", - "Get more details, hide or delete a beatmap by right-clicking on its panel at song select!", - "All delete operations are temporary until exiting. Restore accidentally deleted content from the maintenance settings!", - "Check out the \"playlists\" system, which lets users create their own custom and permanent leaderboards!", - "Toggle advanced frame / thread statistics with Ctrl-F11!", - "Take a look under the hood at performance counters and enable verbose performance logging with Ctrl-F2!", - "You can pause during a replay by pressing Space!", - "Most of the hotkeys in the game are configurable and can be changed to anything you want. Check the bindings panel under input settings!", - "When your gameplay HUD is hidden, you can press and hold Ctrl to view it temporarily!", - "Your gameplay HUD can be customized by using the skin layout editor. Open it at any time via Ctrl-Shift-S!", - "Drag and drop any image into the skin editor to load it in quickly!", - "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!" + MenuTipStrings.ToggleToolbarShortcut, + MenuTipStrings.GameSettingsShortcut, + MenuTipStrings.DynamicSettings, + MenuTipStrings.NewFeaturesAreComingOnline, + MenuTipStrings.UIScalingSettings, + MenuTipStrings.ScreenScalingSettings, + MenuTipStrings.FreeOsuDirect, + MenuTipStrings.ReplaySeeking, + MenuTipStrings.MultithreadingSupport, + MenuTipStrings.TryNewMods, + MenuTipStrings.EmbeddedWebContent, + MenuTipStrings.BeatmapRightClick, + MenuTipStrings.TemporaryDeleteOperations, + MenuTipStrings.DiscoverPlaylists, + MenuTipStrings.ToggleAdvancedFPSCounter, + MenuTipStrings.GlobalStatisticsShortcut, + MenuTipStrings.ReplayPausing, + MenuTipStrings.ConfigurableHotkeys, + MenuTipStrings.PeekHUDWhenHidden, + MenuTipStrings.SkinEditor, + MenuTipStrings.DragAndDropImageInSkinEditor, + MenuTipStrings.ModPresets, + MenuTipStrings.ModCustomisationSettings, + MenuTipStrings.RandomSkinShortcut, + MenuTipStrings.ToggleReplaySettingsShortcut, + MenuTipStrings.CopyModsFromScore, + MenuTipStrings.AutoplayBeatmapShortcut }; return tips[RNG.Next(0, tips.Length)]; 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..13a282dd52 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(id, room.RoomID!.Value, playlistItem)); } 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..6eda993f94 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs @@ -1,16 +1,16 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Collections.Specialized; using System.Diagnostics; +using System.Globalization; 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 +24,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 +33,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 +67,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,20 +80,35 @@ 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; - - if (!string.IsNullOrEmpty(criteria.SearchString)) - { - // Room name isn't translatable, so ToString() is used here for simplicity. - matchingFilter &= r.FilterTerms.Any(term => term.ToString().Contains(criteria.SearchString, StringComparison.InvariantCultureIgnoreCase)); - } - + matchingFilter &= criteria.Ruleset == null || r.Room.PlaylistItemStats?.RulesetIDs.Any(id => id == criteria.Ruleset.OnlineID) != false; matchingFilter &= matchPermissions(r, criteria.Permissions); + // Room name isn't translatable, so ToString() is used here for simplicity. + string[] filterTerms = r.FilterTerms.Select(t => t.ToString()).ToArray(); + string[] searchTerms = criteria.SearchString.Split(' ', StringSplitOptions.RemoveEmptyEntries); + matchingFilter &= searchTerms.All(searchTerm => filterTerms.Any(filterTerm => checkTerm(filterTerm, searchTerm))); + r.MatchingFilter = matchingFilter; } }); + // Lifted from SearchContainer. + static bool checkTerm(string haystack, string needle) + { + int index = 0; + + for (int i = 0; i < needle.Length; i++) + { + int found = CultureInfo.InvariantCulture.CompareInfo.IndexOf(haystack, needle[i], index, CompareOptions.OrdinalIgnoreCase); + if (found < 0) + return false; + + index = found + 1; + } + + return true; + } + static bool matchPermissions(DrawableLoungeRoom room, RoomPermissionsFilter accessType) { switch (accessType) @@ -102,10 +117,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 +128,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 +155,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 +185,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 +228,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 +251,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..7d36cec7ba 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.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; using System.Collections.Generic; +using System.ComponentModel; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -23,11 +23,17 @@ using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Input.Bindings; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; using osu.Game.Online.Rooms; +using osu.Game.Online.Rooms.RoomStatuses; +using osu.Game.Overlays; using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Lounge.Components; +using osu.Game.Screens.OnlinePlay.Playlists; using osuTK; using osuTK.Graphics; +using Container = osu.Framework.Graphics.Containers.Container; namespace osu.Game.Screens.OnlinePlay.Lounge { @@ -39,14 +45,25 @@ 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; + [Resolved] + private IDialogOverlay? dialogOverlay { get; set; } + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + private readonly BindableWithCurrent selectedRoom = new BindableWithCurrent(); + private Sample? sampleSelect; + private Sample? sampleJoin; + private Drawable selectionBox = null!; public DrawableLoungeRoom(Room room) : base(room) @@ -61,7 +78,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 +106,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 +133,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; @@ -127,20 +156,41 @@ namespace osu.Game.Screens.OnlinePlay.Lounge public Popover GetPopover() => new PasswordEntryPopover(Room); - public MenuItem[] ContextMenuItems => new MenuItem[] + public MenuItem[] ContextMenuItems { - new OsuMenuItem("Create copy", MenuItemType.Standard, () => + get { - lounge?.OpenCopy(Room); - }) - }; + var items = new List + { + new OsuMenuItem("Create copy", MenuItemType.Standard, () => + { + lounge?.OpenCopy(Room); + }) + }; + + if (Room.Type == MatchType.Playlists && Room.Host?.Id == api.LocalUser.Value.Id && Room.StartDate?.AddMinutes(5) >= DateTimeOffset.Now && Room.Status is not RoomStatusEnded) + { + items.Add(new OsuMenuItem("Close playlist", MenuItemType.Destructive, () => + { + dialogOverlay?.Push(new ClosePlaylistDialog(Room, () => + { + var request = new ClosePlaylistRequest(Room.RoomID!.Value); + request.Success += () => lounge?.RefreshRooms(); + api.Queue(request); + })); + })); + } + + return items.ToArray(); + } + } public bool OnPressed(KeyBindingPressEvent e) { if (e.Repeat) return false; - if (SelectedRoom.Value != Room) + if (selectedRoom.Value != Room) return false; switch (e.Action) @@ -157,18 +207,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 +229,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 +251,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..5d0983f09c 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()) @@ -382,6 +379,8 @@ namespace osu.Game.Screens.OnlinePlay.Lounge this.Push(CreateRoomSubScreen(room)); } + public void RefreshRooms() => ListingPollingComponent.PollImmediately(); + private void updateLoadingLayer() { if (operationInProgress.Value || !ListingPollingComponent.InitialRoomsReceived.Value) 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..4ef31c02c3 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!; + protected IAPIProvider API { get; private 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)] + protected IDialogOverlay? DialogOverlay { get; private 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; + 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,19 +359,19 @@ 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) + if (DialogOverlay == null || !hasUnsavedChanges) return true; // if the dialog is already displayed, block exiting until the user explicitly makes a decision. - if (dialogOverlay.CurrentDialog is ConfirmDiscardChangesDialog discardChangesDialog) + if (DialogOverlay.CurrentDialog is ConfirmDiscardChangesDialog discardChangesDialog) { discardChangesDialog.Flash(); return false; } - dialogOverlay.Push(new ConfirmDiscardChangesDialog(() => + DialogOverlay.Push(new ConfirmDiscardChangesDialog(() => { ExitConfirmed = true; settingsOverlay.Hide(); @@ -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 d4483044e0..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,8 +17,7 @@ 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(); 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/MultiplayerResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerResultsScreen.cs index c439df82a6..6b3e8fea46 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerResultsScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerResultsScreen.cs @@ -7,7 +7,7 @@ using osu.Game.Screens.OnlinePlay.Playlists; namespace osu.Game.Screens.OnlinePlay.Multiplayer { - public partial class MultiplayerResultsScreen : PlaylistItemUserResultsScreen + public partial class MultiplayerResultsScreen : PlaylistItemScoreResultsScreen { public MultiplayerResultsScreen(ScoreInfo score, long roomId, PlaylistItem playlistItem) : base(score, roomId, playlistItem) 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/Multiplayer/Spectate/MultiSpectatorScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs index eb0e35c880..91df739052 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs @@ -126,7 +126,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate syncManager = new SpectatorSyncManager(masterClockContainer) { ReadyToStart = performInitialSeek, - } + }, + new PlayerSettingsOverlay() }; for (int i = 0; i < Users.Count; i++) 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..17fb667e14 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, @@ -182,7 +180,7 @@ namespace osu.Game.Screens.OnlinePlay if (!(screenStack.CurrentScreen is IOnlinePlaySubScreen onlineSubScreen)) return false; - if (((Drawable)onlineSubScreen).IsLoaded && onlineSubScreen.AllowBackButton && onlineSubScreen.OnBackButton()) + if (((Drawable)onlineSubScreen).IsLoaded && onlineSubScreen.AllowUserExit && onlineSubScreen.OnBackButton()) return true; if (screenStack.CurrentScreen != null && !(screenStack.CurrentScreen is LoungeSubScreen)) 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/ClosePlaylistDialog.cs b/osu.Game/Screens/OnlinePlay/Playlists/ClosePlaylistDialog.cs new file mode 100644 index 0000000000..08fed037d3 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Playlists/ClosePlaylistDialog.cs @@ -0,0 +1,19 @@ +// 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.Rooms; +using osu.Game.Overlays.Dialog; + +namespace osu.Game.Screens.OnlinePlay.Playlists +{ + public partial class ClosePlaylistDialog : DeletionDialog + { + public ClosePlaylistDialog(Room room, Action closeAction) + { + HeaderText = "Are you sure you want to close the following playlist:"; + BodyText = room.Name; + DangerousAction = closeAction; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemResultsScreen.cs index dc06b88823..81ae51bd1b 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemResultsScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemResultsScreen.cs @@ -191,8 +191,8 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { var scoreInfos = scores.Select(s => s.CreateScoreInfo(ScoreManager, Rulesets, PlaylistItem, Beatmap.Value.BeatmapInfo)).OrderByTotalScore().ToArray(); - // Invoke callback to add the scores. - callback.Invoke(scoreInfos); + // Invoke callback to add the scores. Exclude the score provided to this screen since it's added already. + callback.Invoke(scoreInfos.Where(s => s.OnlineID != Score?.OnlineID)); return scoreInfos; } diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemScoreResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemScoreResultsScreen.cs index 32be7f21b0..05c03a4b28 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemScoreResultsScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemScoreResultsScreen.cs @@ -11,13 +11,19 @@ using osu.Game.Scoring; namespace osu.Game.Screens.OnlinePlay.Playlists { /// - /// Shows a selected arbitrary score for a playlist item, with scores around included. + /// Shows a given score in a playlist item, with scores around included. /// public partial class PlaylistItemScoreResultsScreen : PlaylistItemResultsScreen { private readonly long scoreId; - public PlaylistItemScoreResultsScreen(long roomId, PlaylistItem playlistItem, long scoreId) + public PlaylistItemScoreResultsScreen(ScoreInfo score, long roomId, PlaylistItem playlistItem) + : base(score, roomId, playlistItem) + { + scoreId = score.OnlineID; + } + + public PlaylistItemScoreResultsScreen(long scoreId, long roomId, PlaylistItem playlistItem) : base(null, roomId, playlistItem) { this.scoreId = scoreId; @@ -28,9 +34,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists protected override ScoreInfo[] PerformSuccessCallback(Action> callback, List scores, MultiplayerScores? pivot = null) { var scoreInfos = base.PerformSuccessCallback(callback, scores, pivot); - - Schedule(() => SelectedScore.Value ??= scoreInfos.SingleOrDefault(score => score.OnlineID == scoreId)); - + Schedule(() => SelectedScore.Value ??= scoreInfos.SingleOrDefault(s => s.OnlineID == scoreId)); return scoreInfos; } } diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemUserBestResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemUserBestResultsScreen.cs new file mode 100644 index 0000000000..5b20496dba --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemUserBestResultsScreen.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 System.Collections.Generic; +using System.Linq; +using osu.Game.Online.API; +using osu.Game.Online.Rooms; +using osu.Game.Scoring; + +namespace osu.Game.Screens.OnlinePlay.Playlists +{ + /// + /// Shows a user's best score in a playlist item, with scores around included. + /// + public partial class PlaylistItemUserBestResultsScreen : PlaylistItemResultsScreen + { + private readonly int userId; + + public PlaylistItemUserBestResultsScreen(long roomId, PlaylistItem playlistItem, int userId) + : base(null, roomId, playlistItem) + { + this.userId = userId; + } + + protected override APIRequest CreateScoreRequest() => new ShowPlaylistUserScoreRequest(RoomId, PlaylistItem.ID, userId); + + protected override ScoreInfo[] PerformSuccessCallback(Action> callback, List scores, MultiplayerScores? pivot = null) + { + var scoreInfos = base.PerformSuccessCallback(callback, scores, pivot); + + Schedule(() => + { + // Prefer selecting the local user's score, or otherwise default to the first visible score. + SelectedScore.Value ??= scoreInfos.FirstOrDefault(s => s.UserID == userId) ?? scoreInfos.FirstOrDefault(); + }); + + return scoreInfos; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemUserResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemUserResultsScreen.cs deleted file mode 100644 index e038cf3288..0000000000 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemUserResultsScreen.cs +++ /dev/null @@ -1,46 +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 System; -using System.Collections.Generic; -using System.Linq; -using osu.Game.Online.API; -using osu.Game.Online.Rooms; -using osu.Game.Scoring; - -namespace osu.Game.Screens.OnlinePlay.Playlists -{ - /// - /// Shows the user's best score for a given playlist item, with scores around included. - /// - public partial class PlaylistItemUserResultsScreen : PlaylistItemResultsScreen - { - public PlaylistItemUserResultsScreen(ScoreInfo? score, long roomId, PlaylistItem playlistItem) - : base(score, roomId, playlistItem) - { - } - - protected override APIRequest CreateScoreRequest() => new ShowPlaylistUserScoreRequest(RoomId, PlaylistItem.ID, API.LocalUser.Value.Id); - - protected override ScoreInfo[] PerformSuccessCallback(Action> callback, List scores, MultiplayerScores? pivot = null) - { - var scoreInfos = scores.Select(s => s.CreateScoreInfo(ScoreManager, Rulesets, PlaylistItem, Beatmap.Value.BeatmapInfo)).OrderByTotalScore().ToArray(); - - // Select a score if we don't already have one selected. - // Note: This is done before the callback so that the panel list centres on the selected score before panels are added (eliminating initial scroll). - if (SelectedScore.Value == null) - { - Schedule(() => - { - // Prefer selecting the local user's score, or otherwise default to the first visible score. - SelectedScore.Value = scoreInfos.FirstOrDefault(s => s.User.OnlineID == API.LocalUser.Value.Id) ?? scoreInfos.FirstOrDefault(); - }); - } - - // Invoke callback to add the scores. Exclude the user's current score which was added previously. - callback.Invoke(scoreInfos.Where(s => s.OnlineID != Score?.OnlineID)); - - return scoreInfos; - } - } -} 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..b82c2404ab 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 PlaylistItemScoreResultsScreen(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..e72f8be50a 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsReadyButton.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsReadyButton.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; +using System.Collections.Generic; +using System.ComponentModel; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -11,26 +11,25 @@ using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Online.Rooms; +using osu.Game.Rulesets.Mods; using osu.Game.Screens.OnlinePlay.Components; +using osu.Game.Utils; 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; } = null!; [Resolved] - private IBindable gameBeatmap { get; set; } + private IBindable> mods { get; set; } = null!; - public PlaylistsReadyButton() + private readonly Room room; + + public PlaylistsReadyButton(Room room) { + this.room = room; Text = "Start"; } @@ -46,29 +45,38 @@ 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() { base.Update(); - Enabled.Value = hasRemainingAttempts && enoughTimeLeft; + Enabled.Value = hasRemainingAttempts && enoughTimeLeft(); } public override LocalisableString TooltipText { get { - if (!enoughTimeLeft) + if (!enoughTimeLeft()) return "No time left!"; if (!hasRemainingAttempts) @@ -78,8 +86,22 @@ 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; + private bool enoughTimeLeft() + { + double rate = ModUtils.CalculateRateWithMods(mods.Value); + + // We want to avoid users not being able to submit scores if they chose to not skip, + // so track length is chosen over playable length. + double trackLength = Math.Round(gameBeatmap.Value.Track.Length / rate); + + // Additional 30 second delay added to account for load and/or submit time. + return room.EndDate != null && DateTimeOffset.UtcNow.AddSeconds(30).AddMilliseconds(trackLength) < 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..6089b4734e 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomFooter.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomFooter.cs @@ -1,34 +1,120 @@ // 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 osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Online.Rooms; +using osu.Game.Online.Rooms.RoomStatuses; using osuTK; namespace osu.Game.Screens.OnlinePlay.Playlists { public partial class PlaylistsRoomFooter : CompositeDrawable { - public Action OnStart; + public Action? OnStart; + public Action? OnClose; - public PlaylistsRoomFooter() + private readonly Room room; + private DangerousRoundedButton closeButton = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + public PlaylistsRoomFooter(Room room) + { + this.room = room; + } + + [BackgroundDependencyLoader] + private void load() { RelativeSizeAxes = Axes.Both; - InternalChildren = new[] + InternalChild = new FillFlowContainer { - new PlaylistsReadyButton + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10), + Children = new Drawable[] { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Y, - Size = new Vector2(600, 1), - Action = () => OnStart?.Invoke() + new PlaylistsReadyButton(room) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Y, + Size = new Vector2(600, 1), + Action = () => OnStart?.Invoke() + }, + closeButton = new DangerousRoundedButton + { + Text = "Close", + Action = () => OnClose?.Invoke(), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(120, 1), + Alpha = 0, + RelativeSizeAxes = Axes.Y, + } } }; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + room.PropertyChanged += onRoomChanged; + updateState(); + } + + private void hideCloseButton() + { + closeButton.ResizeWidthTo(0, 100, Easing.OutQuint) + .Then().FadeOut().Expire(); + } + + private void onRoomChanged(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(Room.Status): + case nameof(Room.Host): + case nameof(Room.StartDate): + updateState(); + break; + } + } + + private void updateState() + { + TimeSpan? deletionGracePeriodRemaining = room.StartDate?.AddMinutes(5) - DateTimeOffset.Now; + + if (room.Host?.Id == api.LocalUser.Value.Id) + { + if (deletionGracePeriodRemaining > TimeSpan.Zero && room.Status is not RoomStatusEnded) + { + closeButton.FadeIn(); + using (BeginDelayedSequence(deletionGracePeriodRemaining.Value.TotalMilliseconds)) + hideCloseButton(); + } + else if (closeButton.Alpha > 0) + hideCloseButton(); + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + room.PropertyChanged -= onRoomChanged; + } } } 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..9573155f5a 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs @@ -1,11 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - +using System; +using System.ComponentModel; using System.Diagnostics; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -14,7 +13,10 @@ using osu.Framework.Logging; using osu.Framework.Screens; using osu.Game.Graphics.Cursor; using osu.Game.Input; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; using osu.Game.Online.Rooms; +using osu.Game.Online.Rooms.RoomStatuses; using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Match; using osu.Game.Screens.OnlinePlay.Match.Components; @@ -22,6 +24,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 +36,26 @@ namespace osu.Game.Screens.OnlinePlay.Playlists private readonly IBindable isIdle = new BindableBool(); - private MatchLeaderboard leaderboard; - private SelectionPollingComponent selectionPollingComponent; + [Resolved] + private IAPIProvider api { get; set; } = null!; - private FillFlowContainer progressSection; + [Resolved(CanBeNull = true)] + private IdleTracker? idleTracker { get; set; } + + 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 +68,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 +129,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists }, Content = new[] { - new Drawable[] + new Drawable?[] { // Playlist items column new GridContainer @@ -101,20 +138,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 PlaylistItemUserBestResultsScreen(Room.RoomID.Value, item, api.LocalUser.Value.Id)); } } }, @@ -183,7 +219,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists Children = new Drawable[] { new OverlinedHeader("Progress"), - new RoomLocalUserInfo(), + new RoomLocalUserInfo(Room), } }, }, @@ -191,7 +227,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,9 +260,10 @@ namespace osu.Game.Screens.OnlinePlay.Playlists } }; - protected override Drawable CreateFooter() => new PlaylistsRoomFooter + protected override Drawable CreateFooter() => new PlaylistsRoomFooter(Room) { - OnStart = StartPlay + OnStart = StartPlay, + OnClose = closePlaylist, }; protected override RoomSettingsOverlay CreateRoomSettingsOverlay(Room room) => new PlaylistsRoomSettingsOverlay(room) @@ -244,9 +281,32 @@ 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) + private void closePlaylist() { - Exited = () => leaderboard.RefetchScores() - }); + DialogOverlay?.Push(new ClosePlaylistDialog(Room, () => + { + var request = new ClosePlaylistRequest(Room.RoomID!.Value); + request.Success += () => + { + Room.Status = new RoomStatusEnded(); + Room.EndDate = DateTimeOffset.UtcNow; + }; + API.Queue(request); + })); + } + + protected override Screen CreateGameplayScreen(PlaylistItem selectedItem) + { + 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/OsuScreen.cs b/osu.Game/Screens/OsuScreen.cs index 695a074907..ab66241a77 100644 --- a/osu.Game/Screens/OsuScreen.cs +++ b/osu.Game/Screens/OsuScreen.cs @@ -37,7 +37,7 @@ namespace osu.Game.Screens public string Description => Title; - public virtual bool AllowBackButton => true; + public virtual bool AllowUserExit => true; public virtual bool ShowFooter => false; @@ -56,6 +56,15 @@ namespace osu.Game.Screens IBindable IOsuScreen.OverlayActivationMode => OverlayActivationMode; + /// + /// The initial visibility state of the back button when this screen is entered for the first time. + /// + protected virtual bool InitialBackButtonVisibility => AllowUserExit; + + public readonly Bindable BackButtonVisibility; + + IBindable IOsuScreen.BackButtonVisibility => BackButtonVisibility; + public virtual bool CursorVisible => true; protected new OsuGameBase Game => base.Game as OsuGameBase; @@ -154,6 +163,7 @@ namespace osu.Game.Screens Origin = Anchor.Centre; OverlayActivationMode = new Bindable(InitialOverlayActivationMode); + BackButtonVisibility = new Bindable(InitialBackButtonVisibility); } [BackgroundDependencyLoader(true)] 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 ace21fa955..9ab2366b3e 100644 --- a/osu.Game/Screens/Play/HUD/ArgonSongProgressBar.cs +++ b/osu.Game/Screens/Play/HUD/ArgonSongProgressBar.cs @@ -41,6 +41,9 @@ namespace osu.Game.Screens.Play.HUD { get { + if (!Interactive) + return default; + double progress = Math.Clamp(lastMouseX, 0, DrawWidth) / DrawWidth; TimeSpan currentSpan = TimeSpan.FromMilliseconds(Math.Round((EndTime - StartTime) * progress)); 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/HUD/PlayerSettingsOverlay.cs b/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs index a2b49f6302..18d7f6a503 100644 --- a/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs +++ b/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs @@ -1,46 +1,102 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// 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.Extensions.Color4Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; -using osuTK; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input; +using osu.Framework.Input.Events; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; using osu.Game.Screens.Play.PlayerSettings; +using osuTK; +using osuTK.Graphics; namespace osu.Game.Screens.Play.HUD { - public partial class PlayerSettingsOverlay : VisibilityContainer + public partial class PlayerSettingsOverlay : ExpandingContainer { + public VisualSettings VisualSettings { get; private set; } + + private const float padding = 10; + + public const float EXPANDED_WIDTH = player_settings_width + padding * 2; + + private const float player_settings_width = 270; + private const int fade_duration = 200; - public readonly VisualSettings VisualSettings; + public override void Show() => this.FadeIn(fade_duration); + public override void Hide() => this.FadeOut(fade_duration); + + // we'll handle this ourselves because we have slightly custom logic. + protected override bool ExpandOnHover => false; protected override Container Content => content; private readonly FillFlowContainer content; - public PlayerSettingsOverlay() - { - Anchor = Anchor.TopRight; - Origin = Anchor.TopRight; - AutoSizeAxes = Axes.Both; + private readonly IconButton button; - InternalChild = content = new FillFlowContainer + private InputManager inputManager = null!; + + public PlayerSettingsOverlay() + : base(0, EXPANDED_WIDTH) + { + Origin = Anchor.TopRight; + Anchor = Anchor.TopRight; + + base.Content.Add(content = new FillFlowContainer { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, AutoSizeAxes = Axes.Both, Direction = FillDirection.Vertical, Spacing = new Vector2(0, 20), + Margin = new MarginPadding(padding), Children = new PlayerSettingsGroup[] { VisualSettings = new VisualSettings { Expanded = { Value = false } }, new AudioSettings { Expanded = { Value = false } } } - }; + }); + + AddInternal(button = new IconButton + { + Icon = FontAwesome.Solid.Cog, + Origin = Anchor.TopRight, + Anchor = Anchor.TopLeft, + Margin = new MarginPadding(5), + Action = () => Expanded.Toggle() + }); + + AddInternal(new Box + { + Colour = ColourInfo.GradientHorizontal(Color4.Black.Opacity(0), Color4.Black.Opacity(0.8f)), + Depth = float.MaxValue, + RelativeSizeAxes = Axes.Both, + }); } - protected override void PopIn() => this.FadeIn(fade_duration); - protected override void PopOut() => this.FadeOut(fade_duration); + protected override void LoadComplete() + { + base.LoadComplete(); + + inputManager = GetContainingInputManager()!; + } + + protected override void Update() + { + base.Update(); + + Expanded.Value = inputManager.CurrentState.Mouse.Position.X >= button.ScreenSpaceDrawQuad.TopLeft.X; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + // handle un-expanding manually because our children do weird hover blocking stuff. + } public void AddAtStart(PlayerSettingsGroup drawable) => content.Insert(-1, drawable); } diff --git a/osu.Game/Screens/Play/HUD/UnstableRateCounter.cs b/osu.Game/Screens/Play/HUD/UnstableRateCounter.cs index ab7ab6b3a0..a856a09388 100644 --- a/osu.Game/Screens/Play/HUD/UnstableRateCounter.cs +++ b/osu.Game/Screens/Play/HUD/UnstableRateCounter.cs @@ -28,6 +28,8 @@ namespace osu.Game.Screens.Play.HUD private const float alpha_when_invalid = 0.3f; private readonly Bindable valid = new Bindable(); + private HitEventExtensions.UnstableRateCalculationResult? unstableRateResult; + [Resolved] private ScoreProcessor scoreProcessor { get; set; } = null!; @@ -44,9 +46,6 @@ namespace osu.Game.Screens.Play.HUD DrawableCount.FadeTo(e.NewValue ? 1 : alpha_when_invalid, 1000, Easing.OutQuint)); } - private bool changesUnstableRate(JudgementResult judgement) - => !(judgement.HitObject.HitWindows is HitWindows.EmptyHitWindows) && judgement.IsHit; - protected override void LoadComplete() { base.LoadComplete(); @@ -56,13 +55,20 @@ namespace osu.Game.Screens.Play.HUD updateDisplay(); } - private void updateDisplay(JudgementResult _) => Scheduler.AddOnce(updateDisplay); + private void updateDisplay(JudgementResult result) + { + if (HitEventExtensions.AffectsUnstableRate(result.HitObject, result.Type)) + Scheduler.AddOnce(updateDisplay); + } private void updateDisplay() { - double? unstableRate = scoreProcessor.HitEvents.CalculateUnstableRate(); + unstableRateResult = scoreProcessor.HitEvents.CalculateUnstableRate(unstableRateResult); + + double? unstableRate = unstableRateResult?.Result; valid.Value = unstableRate != null; + if (unstableRate != null) Current.Value = (int)Math.Round(unstableRate.Value); } diff --git a/osu.Game/Screens/Play/HUDOverlay.cs b/osu.Game/Screens/Play/HUDOverlay.cs index ac1b9ce34f..fca871e42f 100644 --- a/osu.Game/Screens/Play/HUDOverlay.cs +++ b/osu.Game/Screens/Play/HUDOverlay.cs @@ -115,6 +115,8 @@ namespace osu.Game.Screens.Play public HUDOverlay([CanBeNull] DrawableRuleset drawableRuleset, IReadOnlyList mods, bool alwaysShowLeaderboard = true) { + Container rightSettings; + this.drawableRuleset = drawableRuleset; this.mods = mods; @@ -146,7 +148,6 @@ namespace osu.Game.Screens.Play Children = new Drawable[] { ModDisplay = CreateModsContainer(), - PlayerSettingsOverlay = CreatePlayerSettingsOverlay(), } }, bottomRightElements = new FillFlowContainer @@ -164,6 +165,14 @@ namespace osu.Game.Screens.Play HoldToQuit = CreateHoldForMenuButton(), } }, + rightSettings = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + PlayerSettingsOverlay = new PlayerSettingsOverlay(), + } + }, LeaderboardFlow = new FillFlowContainer { AutoSizeAxes = Axes.Both, @@ -173,7 +182,7 @@ namespace osu.Game.Screens.Play }, }; - hideTargets = new List { mainComponents, topRightElements }; + hideTargets = new List { mainComponents, topRightElements, rightSettings }; if (rulesetComponents != null) hideTargets.Add(rulesetComponents); @@ -252,7 +261,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 +309,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 +330,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; } @@ -387,8 +398,6 @@ namespace osu.Game.Screens.Play Origin = Anchor.TopRight, }; - protected PlayerSettingsOverlay CreatePlayerSettingsOverlay() => new PlayerSettingsOverlay(); - public bool OnPressed(KeyBindingPressEvent e) { if (e.Repeat) diff --git a/osu.Game/Screens/Play/KiaiGameplayFountains.cs b/osu.Game/Screens/Play/KiaiGameplayFountains.cs index 7659c61123..fd9596c838 100644 --- a/osu.Game/Screens/Play/KiaiGameplayFountains.cs +++ b/osu.Game/Screens/Play/KiaiGameplayFountains.cs @@ -5,8 +5,10 @@ using System; using osu.Framework.Allocation; using osu.Framework.Audio.Track; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Utils; +using osu.Game.Configuration; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics.Containers; using osu.Game.Screens.Menu; @@ -18,9 +20,13 @@ namespace osu.Game.Screens.Play private StarFountain leftFountain = null!; private StarFountain rightFountain = null!; + private Bindable kiaiStarFountains = null!; + [BackgroundDependencyLoader] - private void load() + private void load(OsuConfigManager config) { + kiaiStarFountains = config.GetBindable(OsuSetting.StarFountains); + RelativeSizeAxes = Axes.Both; Children = new[] @@ -48,6 +54,9 @@ namespace osu.Game.Screens.Play { base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes); + if (!kiaiStarFountains.Value) + return; + if (effectPoint.KiaiMode && !isTriggered) { bool isNearEffectPoint = Math.Abs(BeatSyncSource.Clock.CurrentTime - effectPoint.Time) < 500; diff --git a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs index b2f0ae5561..3851806788 100644 --- a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs +++ b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs @@ -95,8 +95,8 @@ namespace osu.Game.Screens.Play // some beatmaps specify a current lead-in time which should be used instead of the ruleset-provided value when available. // this is not available as an option in the live editor but can still be applied via .osu editing. double firstHitObjectTime = beatmap.Beatmap.HitObjects.First().StartTime; - if (beatmap.BeatmapInfo.AudioLeadIn > 0) - time = Math.Min(time, firstHitObjectTime - beatmap.BeatmapInfo.AudioLeadIn); + if (beatmap.Beatmap.AudioLeadIn > 0) + time = Math.Min(time, firstHitObjectTime - beatmap.Beatmap.AudioLeadIn); return time; } diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index 51f7ab6e4d..216a079085 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -57,7 +57,7 @@ namespace osu.Game.Screens.Play /// public event Action OnGameplayStarted; - public override bool AllowBackButton => false; // handled by HoldForMenuButton + public override bool AllowUserExit => false; // handled by HoldForMenuButton protected override bool PlayExitSound => !isRestarting; @@ -83,9 +83,10 @@ namespace osu.Game.Screens.Play /// protected virtual bool PauseOnFocusLost => true; - public Action RestartRequested; + public Action PrepareLoaderForRestart; private bool isRestarting; + private bool skipExitTransition; private Bindable mouseWheelDisabled; @@ -289,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 { @@ -297,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); }, }, }); @@ -318,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); }, }, }); @@ -448,7 +443,7 @@ namespace osu.Game.Screens.Play { HoldToQuit = { - Action = () => PerformExit(true), + Action = () => PerformExitWithConfirmation(), IsPaused = { BindTarget = GameplayClockContainer.IsPaused }, ReplayLoaded = { BindTarget = DrawableRuleset.HasReplayLoaded }, }, @@ -462,7 +457,7 @@ namespace osu.Game.Screens.Play Anchor = Anchor.Centre, Origin = Anchor.Centre }, - BreakOverlay = new BreakOverlay(working.Beatmap.BeatmapInfo.LetterboxInBreaks, ScoreProcessor) + BreakOverlay = new BreakOverlay(working.Beatmap.LetterboxInBreaks, ScoreProcessor) { Clock = DrawableRuleset.FrameStableClock, ProcessCustomClock = false, @@ -485,7 +480,7 @@ namespace osu.Game.Screens.Play OnResume = Resume, Retries = RestartCount, OnRetry = () => Restart(), - OnQuit = () => PerformExit(true), + OnQuit = () => PerformExitWithConfirmation(), }, }, }; @@ -562,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) @@ -591,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) @@ -628,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) @@ -639,15 +646,22 @@ namespace osu.Game.Screens.Play // import current score if possible. prepareAndImportScoreAsync(); - // 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). // - the pause / fail dialog was requested but couldn't be displayed due to the type or state of this Player instance. this.Exit(); } + else + { + // May be restarting from results screen. + if (this.GetChildScreen() != null) + this.MakeCurrent(); + } return true; } @@ -710,9 +724,10 @@ 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); + skipExitTransition = quickRestart; + PrepareLoaderForRestart?.Invoke(quickRestart); - return PerformExit(false); + return PerformExit(quickRestart); } /// @@ -962,7 +977,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. // @@ -976,7 +993,7 @@ namespace osu.Game.Screens.Play ScoreProcessor.FailScore(Score.ScoreInfo); OnFail(); - if (GameplayState.Mods.OfType().Any(m => m.RestartOnFail)) + if (restartOnFail) Restart(true); }); } @@ -1257,10 +1274,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()) { diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index 7682bba9a6..20985c20e0 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; @@ -27,6 +28,7 @@ using osu.Game.Localisation; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; using osu.Game.Performance; +using osu.Game.Scoring; using osu.Game.Screens.Menu; using osu.Game.Screens.Play.PlayerSettings; using osu.Game.Skinning; @@ -51,6 +53,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; @@ -75,6 +79,8 @@ namespace osu.Game.Screens.Play private FillFlowContainer disclaimers = null!; private OsuScrollContainer settingsScroll = null!; + private Bindable lastScore = null!; + private Bindable showStoryboards = null!; private bool backgroundBrightnessReduction; @@ -86,9 +92,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 +125,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; @@ -170,6 +182,8 @@ namespace osu.Game.Screens.Play { muteWarningShownOnce = sessionStatics.GetBindable(Static.MutedAudioNotificationShownOnce); batteryWarningShownOnce = sessionStatics.GetBindable(Static.LowBatteryNotificationShownOnce); + lastScore = sessionStatics.GetBindable(Static.LastLocalUserScore); + showStoryboards = config.GetBindable(OsuSetting.ShowStoryboard); const float padding = 25; @@ -224,11 +238,11 @@ namespace osu.Game.Screens.Play } }, }, - idleTracker = new IdleTracker(750), + idleTracker = new IdleTracker(1500), sampleRestart = new SkinnableSound(new SampleInfo(@"Gameplay/restart", @"pause-retry-click")) }; - if (Beatmap.Value.BeatmapInfo.EpilepsyWarning) + if (Beatmap.Value.Beatmap.EpilepsyWarning) { disclaimers.Add(epilepsyWarning = new PlayerLoaderDisclaimer(PlayerLoaderStrings.EpilepsyWarningTitle, PlayerLoaderStrings.EpilepsyWarningContent)); } @@ -305,6 +319,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 +338,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(); @@ -332,6 +352,8 @@ namespace osu.Game.Screens.Play highPerformanceSession?.Dispose(); highPerformanceSession = null; + lastScore.Value = null; + return base.OnExiting(e); } @@ -348,7 +370,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 +416,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 => @@ -428,7 +457,7 @@ namespace osu.Game.Screens.Play CurrentPlayer = createPlayer(); CurrentPlayer.Configuration.AutomaticallySkipIntro |= quickRestart; CurrentPlayer.RestartCount = restartCount++; - CurrentPlayer.RestartRequested = restartRequested; + CurrentPlayer.PrepareLoaderForRestart = prepareForRestart; LoadTask = LoadComponentAsync(CurrentPlayer, _ => { @@ -441,29 +470,60 @@ namespace osu.Game.Screens.Play { } - private void restartRequested(bool quickRestartRequested) + private void prepareForRestart(bool quickRestartRequested) { quickRestart = quickRestartRequested; hideOverlays = true; ValidForResume = true; - - this.MakeCurrent(); } private void contentIn(double delayBeforeSideDisplays = 0) { 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); + BackButtonVisibility.Value = false; - disclaimers.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 + }); + + quickRestartBlackLayer + .Delay(50) + .FadeOut(5000, Easing.OutQuint); + + prepareNewPlayer(); + + content + .Delay(quick_restart_initial_delay) + .ScaleTo(1) + .FadeInFromZero(500, Easing.OutQuint); + + this.Delay(quick_restart_initial_delay).Schedule(() => BackButtonVisibility.Value = true); + } + 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 +587,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/SaveFailedScoreButton.cs b/osu.Game/Screens/Play/SaveFailedScoreButton.cs index 4f665b87e8..e5c9e115d1 100644 --- a/osu.Game/Screens/Play/SaveFailedScoreButton.cs +++ b/osu.Game/Screens/Play/SaveFailedScoreButton.cs @@ -32,7 +32,7 @@ namespace osu.Game.Screens.Play private readonly Func>? importFailedScore; - private ScoreInfo? importedScore; + private Live? importedScore; private DownloadButton button = null!; @@ -55,7 +55,7 @@ namespace osu.Game.Screens.Play switch (state.Value) { case DownloadState.LocallyAvailable: - game?.PresentScore(importedScore, ScorePresentType.Gameplay); + game?.PresentScore(importedScore?.Value, ScorePresentType.Gameplay); break; case DownloadState.NotDownloaded: @@ -65,7 +65,7 @@ namespace osu.Game.Screens.Play { Task.Run(importFailedScore).ContinueWith(t => { - importedScore = realm.Run(r => r.Find(t.GetResultSafely().ID)?.Detach()); + importedScore = realm.Run?>(r => r.Find(t.GetResultSafely().ID)?.ToLive(realm)); Schedule(() => state.Value = importedScore != null ? DownloadState.LocallyAvailable : DownloadState.NotDownloaded); }).FireAndForget(); } @@ -77,7 +77,7 @@ namespace osu.Game.Screens.Play if (player != null) { - importedScore = realm.Run(r => r.Find(player.Score.ScoreInfo.ID)?.Detach()); + importedScore = realm.Run(r => r.Find(player.Score.ScoreInfo.ID)?.ToLive(realm)); state.Value = importedScore != null ? DownloadState.LocallyAvailable : DownloadState.NotDownloaded; } @@ -137,7 +137,7 @@ namespace osu.Game.Screens.Play { if (state.NewValue != DownloadState.LocallyAvailable) return; - if (importedScore != null) scoreManager.Export(importedScore); + if (importedScore != null) scoreManager.Export(importedScore.Value); this.state.ValueChanged -= exportWhenReady; } 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/ResultsScreen.cs b/osu.Game/Screens/Ranking/ResultsScreen.cs index 0209fbd39c..507d138d90 100644 --- a/osu.Game/Screens/Ranking/ResultsScreen.cs +++ b/osu.Game/Screens/Ranking/ResultsScreen.cs @@ -55,6 +55,8 @@ namespace osu.Game.Screens.Ranking [Resolved] private Player? player { get; set; } + private bool skipExitTransition; + [Resolved] private IAPIProvider api { get; set; } = null!; @@ -203,6 +205,7 @@ namespace osu.Game.Screens.Ranking { if (!this.IsCurrentScreen()) return; + skipExitTransition = true; player?.Restart(true); }, }); @@ -313,7 +316,8 @@ namespace osu.Game.Screens.Ranking // HitObject references from HitEvent. Score?.HitEvents.Clear(); - this.FadeOut(100); + if (!skipExitTransition) + this.FadeOut(100); return false; } diff --git a/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs b/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs index 207e19a716..a80aeaa5dd 100644 --- a/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs +++ b/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs @@ -58,7 +58,7 @@ namespace osu.Game.Screens.Ranking.Statistics /// The s to display the timing distribution of. public HitEventTimingDistributionGraph(IReadOnlyList hitEvents) { - this.hitEvents = hitEvents.Where(e => !(e.HitObject.HitWindows is HitWindows.EmptyHitWindows) && e.Result.IsBasic() && e.Result.IsHit()).ToList(); + this.hitEvents = hitEvents.Where(e => e.HitObject.HitWindows != HitWindows.Empty && e.Result.IsBasic() && e.Result.IsHit()).ToList(); bins = Enumerable.Range(0, total_timing_distribution_bins).Select(_ => new Dictionary()).ToArray>(); } @@ -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/Ranking/Statistics/UnstableRate.cs b/osu.Game/Screens/Ranking/Statistics/UnstableRate.cs index cc3535a426..d114bed156 100644 --- a/osu.Game/Screens/Ranking/Statistics/UnstableRate.cs +++ b/osu.Game/Screens/Ranking/Statistics/UnstableRate.cs @@ -15,10 +15,10 @@ namespace osu.Game.Screens.Ranking.Statistics /// Creates and computes an statistic. /// /// Sequence of s to calculate the unstable rate based on. - public UnstableRate(IEnumerable hitEvents) + public UnstableRate(IReadOnlyList hitEvents) : base("Unstable Rate") { - Value = hitEvents.CalculateUnstableRate(); + Value = hitEvents.CalculateUnstableRate()?.Result; } protected override string DisplayValue(double? value) => value == null ? "(not available)" : value.Value.ToString(@"N2"); diff --git a/osu.Game/Screens/Ranking/Statistics/User/OverallRanking.cs b/osu.Game/Screens/Ranking/Statistics/User/OverallRanking.cs index 1e60e09486..9f5afea6f0 100644 --- a/osu.Game/Screens/Ranking/Statistics/User/OverallRanking.cs +++ b/osu.Game/Screens/Ranking/Statistics/User/OverallRanking.cs @@ -14,7 +14,7 @@ namespace osu.Game.Screens.Ranking.Statistics.User { private const float transition_duration = 300; - public Bindable StatisticsUpdate { get; } = new Bindable(); + public Bindable StatisticsUpdate { get; } = new Bindable(); private LoadingLayer loadingLayer = null!; private GridContainer content = null!; @@ -59,14 +59,14 @@ namespace osu.Game.Screens.Ranking.Statistics.User new SimpleStatisticTable.Spacer(), new PerformancePointsChangeRow { StatisticsUpdate = { BindTarget = StatisticsUpdate } }, }, - new Drawable[] { }, + [], new Drawable[] { new MaximumComboChangeRow { StatisticsUpdate = { BindTarget = StatisticsUpdate } }, new SimpleStatisticTable.Spacer(), new AccuracyChangeRow { StatisticsUpdate = { BindTarget = StatisticsUpdate } }, }, - new Drawable[] { }, + [], new Drawable[] { new RankedScoreChangeRow { StatisticsUpdate = { BindTarget = StatisticsUpdate } }, @@ -86,7 +86,7 @@ namespace osu.Game.Screens.Ranking.Statistics.User FinishTransforms(true); } - private void onUpdateReceived(ValueChangedEvent update) + private void onUpdateReceived(ValueChangedEvent update) { if (update.NewValue == null) { diff --git a/osu.Game/Screens/Ranking/Statistics/User/RankingChangeRow.cs b/osu.Game/Screens/Ranking/Statistics/User/RankingChangeRow.cs index e5f07d9891..e6a6530345 100644 --- a/osu.Game/Screens/Ranking/Statistics/User/RankingChangeRow.cs +++ b/osu.Game/Screens/Ranking/Statistics/User/RankingChangeRow.cs @@ -19,7 +19,7 @@ namespace osu.Game.Screens.Ranking.Statistics.User { public abstract partial class RankingChangeRow : CompositeDrawable { - public Bindable StatisticsUpdate { get; } = new Bindable(); + public Bindable StatisticsUpdate { get; } = new Bindable(); private readonly Func accessor; @@ -113,7 +113,7 @@ namespace osu.Game.Screens.Ranking.Statistics.User StatisticsUpdate.BindValueChanged(onStatisticsUpdate, true); } - private void onStatisticsUpdate(ValueChangedEvent statisticsUpdate) + private void onStatisticsUpdate(ValueChangedEvent statisticsUpdate) { var update = statisticsUpdate.NewValue; diff --git a/osu.Game/Screens/Ranking/Statistics/UserStatisticsPanel.cs b/osu.Game/Screens/Ranking/Statistics/UserStatisticsPanel.cs index 4e9c07ab7b..86fed4a9bb 100644 --- a/osu.Game/Screens/Ranking/Statistics/UserStatisticsPanel.cs +++ b/osu.Game/Screens/Ranking/Statistics/UserStatisticsPanel.cs @@ -18,9 +18,9 @@ namespace osu.Game.Screens.Ranking.Statistics { private readonly ScoreInfo achievedScore; - internal readonly Bindable DisplayedUserStatisticsUpdate = new Bindable(); + internal readonly Bindable DisplayedUserStatisticsUpdate = new Bindable(); - private IBindable latestGlobalStatisticsUpdate = null!; + private IBindable latestGlobalStatisticsUpdate = null!; public UserStatisticsPanel(ScoreInfo achievedScore) { diff --git a/osu.Game/Screens/Select/BeatmapCarousel.cs b/osu.Game/Screens/Select/BeatmapCarousel.cs index d9359cfec3..fc7c7989e2 100644 --- a/osu.Game/Screens/Select/BeatmapCarousel.cs +++ b/osu.Game/Screens/Select/BeatmapCarousel.cs @@ -29,7 +29,6 @@ using osu.Game.Input.Bindings; using osu.Game.Screens.Select.Carousel; using osuTK; using osuTK.Input; -using Realms; namespace osu.Game.Screens.Select { @@ -207,8 +206,6 @@ namespace osu.Game.Screens.Select private CarouselRoot root; - private IDisposable? subscriptionBeatmaps; - private readonly DrawablePool setPool = new DrawablePool(100); private Sample? spinSample; @@ -216,18 +213,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 +230,7 @@ namespace osu.Game.Screens.Select } }; - activeCriteria = initialCriterial; + activeCriteria = initialCriteria; } [BackgroundDependencyLoader] @@ -251,8 +242,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) { @@ -265,13 +255,6 @@ namespace osu.Game.Screens.Select } } - protected override void LoadComplete() - { - base.LoadComplete(); - - subscriptionBeatmaps = realm.RegisterForNotifications(r => r.All().Where(b => !b.Hidden), beatmapsChanged); - } - private readonly HashSet setsRequiringUpdate = new HashSet(); private readonly HashSet setsRequiringRemoval = new HashSet(); @@ -322,6 +305,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 +319,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. @@ -373,35 +356,6 @@ namespace osu.Game.Screens.Select BeatmapSetInfo? fetchFromID(Guid id) => realm.Realm.Find(id); } - private void beatmapsChanged(IRealmCollection sender, ChangeSet? changes) - { - // we only care about actual changes in hidden status. - if (changes == null) - return; - - bool changed = false; - - foreach (int i in changes.InsertedIndices) - { - var beatmapInfo = sender[i]; - var beatmapSet = beatmapInfo.BeatmapSet; - - Debug.Assert(beatmapSet != null); - - // Only require to action here if the beatmap is missing. - // This avoids processing these events unnecessarily when new beatmaps are imported, for example. - if (root.BeatmapSetsByID.TryGetValue(beatmapSet.ID, out var existingSets) - && existingSets.SelectMany(s => s.Beatmaps).All(b => b.BeatmapInfo.ID != beatmapInfo.ID)) - { - updateBeatmapSet(beatmapSet.Detach()); - changed = true; - } - } - - if (changed) - invalidateAfterChange(); - } - public void RemoveBeatmapSet(BeatmapSetInfo beatmapSet) => Schedule(() => { removeBeatmapSet(beatmapSet.ID); @@ -1116,11 +1070,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,13 +1220,38 @@ namespace osu.Game.Screens.Select return base.OnDragStart(e); } - } - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); + protected override ScrollbarContainer CreateScrollbar(Direction direction) + { + return new PaddedScrollbar(); + } - subscriptionBeatmaps?.Dispose(); + 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))); + } } } } diff --git a/osu.Game/Screens/Select/BeatmapDeleteDialog.cs b/osu.Game/Screens/Select/BeatmapDeleteDialog.cs index 8bc40dbd9e..16f0cbe65e 100644 --- a/osu.Game/Screens/Select/BeatmapDeleteDialog.cs +++ b/osu.Game/Screens/Select/BeatmapDeleteDialog.cs @@ -14,7 +14,7 @@ namespace osu.Game.Screens.Select 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/BeatmapInfoWedge.cs b/osu.Game/Screens/Select/BeatmapInfoWedge.cs index 3b0fdc3e47..fd1c944689 100644 --- a/osu.Game/Screens/Select/BeatmapInfoWedge.cs +++ b/osu.Game/Screens/Select/BeatmapInfoWedge.cs @@ -401,7 +401,6 @@ namespace osu.Game.Screens.Select if (beatmap == null || bpmLabelContainer == null) return; - // this doesn't consider mods which apply variable rates, yet. double rate = ModUtils.CalculateRateWithMods(mods.Value); int bpmMax = FormatUtils.RoundBPM(beatmap.ControlPointInfo.BPMMaximum, rate); 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/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/Screens/StartupScreen.cs b/osu.Game/Screens/StartupScreen.cs index 9e04a238eb..0724327a9f 100644 --- a/osu.Game/Screens/StartupScreen.cs +++ b/osu.Game/Screens/StartupScreen.cs @@ -10,7 +10,7 @@ namespace osu.Game.Screens /// public abstract partial class StartupScreen : OsuScreen { - public override bool AllowBackButton => false; + public override bool AllowUserExit => false; public override bool HideOverlaysOnEnter => true; 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/SkinImporter.cs b/osu.Game/Skinning/SkinImporter.cs index 59c7f0ba26..70d3195ecd 100644 --- a/osu.Game/Skinning/SkinImporter.cs +++ b/osu.Game/Skinning/SkinImporter.cs @@ -37,7 +37,7 @@ namespace osu.Game.Skinning protected override string[] HashableFileTypes => new[] { ".ini", ".json" }; - protected override bool ShouldDeleteArchive(string path) => Path.GetExtension(path).ToLowerInvariant() == @".osk"; + protected override bool ShouldDeleteArchive(string path) => string.Equals(Path.GetExtension(path), @".osk", StringComparison.OrdinalIgnoreCase); protected override SkinInfo CreateModel(ArchiveReader archive, ImportParameters parameters) => new SkinInfo { Name = archive.Name ?? @"No name" }; 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/Skinning/SkinnableSprite.cs b/osu.Game/Skinning/SkinnableSprite.cs index 9effb483c4..47618f6296 100644 --- a/osu.Game/Skinning/SkinnableSprite.cs +++ b/osu.Game/Skinning/SkinnableSprite.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; using System.Collections.Generic; +using System.IO; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -14,6 +14,7 @@ using osu.Game.Configuration; using osu.Game.Graphics.Sprites; using osu.Game.Localisation.SkinComponents; using osu.Game.Overlays.Settings; +using osu.Game.Utils; using osuTK; namespace osu.Game.Skinning @@ -93,10 +94,10 @@ namespace osu.Game.Skinning // but that requires further thought. var highestPrioritySkin = getHighestPriorityUserSkin(((SkinnableSprite)SettingSourceObject).source.AllSources) as Skin; - string[]? availableFiles = highestPrioritySkin?.SkinInfo.PerformRead(s => s.Files - .Where(f => f.Filename.EndsWith(".png", StringComparison.Ordinal) - || f.Filename.EndsWith(".jpg", StringComparison.Ordinal)) - .Select(f => f.Filename).Distinct()).ToArray(); + string[]? availableFiles = highestPrioritySkin?.SkinInfo.PerformRead( + s => s.Files + .Where(f => SupportedExtensions.IMAGE_EXTENSIONS.Contains(Path.GetExtension(f.Filename).ToLowerInvariant())) + .Select(f => f.Filename).Distinct()).ToArray(); if (availableFiles?.Length > 0) Items = availableFiles; diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs index 8e7b3feacf..095bd95314 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs @@ -67,7 +67,7 @@ namespace osu.Game.Storyboards.Drawables bool onlyHasVideoElements = Storyboard.Layers.SelectMany(l => l.Elements).All(e => e is StoryboardVideo); - Width = Height * (storyboard.BeatmapInfo.WidescreenStoryboard || onlyHasVideoElements ? 16 / 9f : 4 / 3f); + Width = Height * (storyboard.Beatmap.WidescreenStoryboard || onlyHasVideoElements ? 16 / 9f : 4 / 3f); Anchor = Anchor.Centre; Origin = Anchor.Centre; diff --git a/osu.Game/Storyboards/Storyboard.cs b/osu.Game/Storyboards/Storyboard.cs index 8c43b99702..4cad1ba4ad 100644 --- a/osu.Game/Storyboards/Storyboard.cs +++ b/osu.Game/Storyboards/Storyboard.cs @@ -1,12 +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 System.Collections.Generic; using System.IO; using System.Linq; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; using osu.Game.Storyboards.Drawables; +using osu.Game.Utils; namespace osu.Game.Storyboards { @@ -16,6 +18,7 @@ namespace osu.Game.Storyboards public IEnumerable Layers => layers.Values; public BeatmapInfo BeatmapInfo = new BeatmapInfo(); + public IBeatmap Beatmap { get; set; } = new Beatmap(); /// /// Whether the storyboard should prefer textures from the current skin before using local storyboard textures. @@ -89,15 +92,13 @@ namespace osu.Game.Storyboards // Importantly, do this after the NullOrEmpty because EF may have stored the non-nullable value as null to the database, bypassing compile-time constraints. backgroundPath = backgroundPath.ToLowerInvariant(); - return GetLayer("Background").Elements.Any(e => e.Path.ToLowerInvariant() == backgroundPath); + return GetLayer("Background").Elements.Any(e => string.Equals(e.Path, backgroundPath, StringComparison.OrdinalIgnoreCase)); } } public virtual DrawableStoryboard CreateDrawable(IReadOnlyList? mods = null) => new DrawableStoryboard(this, mods); - private static readonly string[] image_extensions = { @".png", @".jpg" }; - public virtual string? GetStoragePathFromStoryboardPath(string path) { string? resolvedPath = null; @@ -109,7 +110,7 @@ namespace osu.Game.Storyboards else { // Some old storyboards don't include a file extension, so let's best guess at one. - foreach (string ext in image_extensions) + foreach (string ext in SupportedExtensions.IMAGE_EXTENSIONS) { if ((resolvedPath = BeatmapInfo.BeatmapSet?.GetPathForFile($"{path}{ext}")) != null) break; diff --git a/osu.Game/Tests/Beatmaps/TestBeatmap.cs b/osu.Game/Tests/Beatmaps/TestBeatmap.cs index 31ad2de62e..1f0c08714e 100644 --- a/osu.Game/Tests/Beatmaps/TestBeatmap.cs +++ b/osu.Game/Tests/Beatmaps/TestBeatmap.cs @@ -27,6 +27,17 @@ namespace osu.Game.Tests.Beatmaps BeatmapInfo = baseBeatmap.BeatmapInfo; ControlPointInfo = baseBeatmap.ControlPointInfo; UnhandledEventLines = baseBeatmap.UnhandledEventLines; + AudioLeadIn = baseBeatmap.AudioLeadIn; + StackLeniency = baseBeatmap.StackLeniency; + SpecialStyle = baseBeatmap.SpecialStyle; + LetterboxInBreaks = baseBeatmap.LetterboxInBreaks; + WidescreenStoryboard = baseBeatmap.WidescreenStoryboard; + EpilepsyWarning = baseBeatmap.EpilepsyWarning; + SamplesMatchPlaybackRate = baseBeatmap.SamplesMatchPlaybackRate; + DistanceSpacing = baseBeatmap.DistanceSpacing; + GridSize = baseBeatmap.GridSize; + TimelineZoom = baseBeatmap.TimelineZoom; + CountdownOffset = baseBeatmap.CountdownOffset; if (withHitObjects) { diff --git a/osu.Game/Tests/CleanRunHeadlessGameHost.cs b/osu.Game/Tests/CleanRunHeadlessGameHost.cs index 00e5b38b1a..df4a91c4e2 100644 --- a/osu.Game/Tests/CleanRunHeadlessGameHost.cs +++ b/osu.Game/Tests/CleanRunHeadlessGameHost.cs @@ -27,7 +27,7 @@ namespace osu.Game.Tests [CallerMemberName] string callingMethodName = @"") : base($"{callingMethodName}-{Guid.NewGuid()}", new HostOptions { - IPCPort = bindIPC ? OsuGame.IPC_PORT : null, + IPCPipeName = bindIPC ? OsuGame.IPC_PIPE_NAME : null, }, bypassCleanup: bypassCleanupOnDispose, realtime: realtime) { this.bypassCleanupOnSetup = bypassCleanupOnSetup; 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/Tests/Visual/Spectator/TestSpectatorClient.cs b/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs index c27e7f15ca..5d33afd288 100644 --- a/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs +++ b/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs @@ -78,12 +78,12 @@ namespace osu.Game.Tests.Visual.Spectator /// The spectator state to end play with. public void SendEndPlay(int userId, SpectatedUserState state = SpectatedUserState.Quit) { - if (!userBeatmapDictionary.ContainsKey(userId)) + if (!userBeatmapDictionary.TryGetValue(userId, out int beatmapId)) return; ((ISpectatorClient)this).UserFinishedPlaying(userId, new SpectatorState { - BeatmapID = userBeatmapDictionary[userId], + BeatmapID = beatmapId, RulesetID = 0, Mods = userModsDictionary[userId], State = state diff --git a/osu.Game/Users/UserActivity.cs b/osu.Game/Users/UserActivity.cs index a431b204bc..a8e0fc9030 100644 --- a/osu.Game/Users/UserActivity.cs +++ b/osu.Game/Users/UserActivity.cs @@ -41,6 +41,12 @@ namespace osu.Game.Users public virtual Color4 GetAppropriateColour(OsuColour colours) => colours.GreenDarker; + /// + /// Returns the ID of the beatmap involved in this activity, if applicable and/or available. + /// + /// + public virtual int? GetBeatmapID(bool hideIdentifiableInformation = false) => null; + [MessagePackObject] public class ChoosingBeatmap : UserActivity { @@ -76,6 +82,7 @@ namespace osu.Game.Users public override string GetStatus(bool hideIdentifiableInformation = false) => RulesetPlayingVerb; public override string GetDetails(bool hideIdentifiableInformation = false) => BeatmapDisplayTitle; + public override int? GetBeatmapID(bool hideIdentifiableInformation = false) => BeatmapID; } [MessagePackObject] @@ -156,6 +163,11 @@ namespace osu.Game.Users // For now let's assume that showing the beatmap a user is editing could reveal unwanted information. ? string.Empty : BeatmapDisplayTitle; + + public override int? GetBeatmapID(bool hideIdentifiableInformation = false) => hideIdentifiableInformation + // For now let's assume that showing the beatmap a user is editing could reveal unwanted information. + ? null + : BeatmapID; } [MessagePackObject] @@ -248,8 +260,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..5e3ae172be 100644 --- a/osu.Game/Users/UserRankPanel.cs +++ b/osu.Game/Users/UserRankPanel.cs @@ -4,13 +4,16 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; -using osu.Game.Online.API; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.Profile.Header.Components; using osu.Game.Resources.Localisation.Web; +using osu.Game.Rulesets; using osuTK; namespace osu.Game.Users @@ -24,13 +27,9 @@ namespace osu.Game.Users private const int padding = 10; private const int main_content_height = 80; - [Resolved] - private IAPIProvider api { get; set; } = null!; - private ProfileValueDisplay globalRankDisplay = null!; private ProfileValueDisplay countryRankDisplay = null!; - - private readonly IBindable statistics = new Bindable(); + private LoadingLayer loadingLayer = null!; public UserRankPanel(APIUser user) : base(user) @@ -43,13 +42,37 @@ namespace osu.Game.Users private void load() { BorderColour = ColourProvider?.Light1 ?? Colours.GreyVioletLighter; + } - statistics.BindTo(api.Statistics); - statistics.BindValueChanged(stats => - { - globalRankDisplay.Content = stats.NewValue?.GlobalRank?.ToLocalisableString("\\##,##0") ?? "-"; - countryRankDisplay.Content = stats.NewValue?.CountryRank?.ToLocalisableString("\\##,##0") ?? "-"; - }, true); + [Resolved] + private LocalUserStatisticsProvider? statisticsProvider { get; set; } + + [Resolved] + private IBindable ruleset { get; set; } = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (statisticsProvider != null) + statisticsProvider.StatisticsUpdated += onStatisticsUpdated; + + ruleset.BindValueChanged(_ => updateDisplay(), true); + } + + private void onStatisticsUpdated(UserStatisticsUpdate update) + { + if (update.Ruleset.Equals(ruleset.Value)) + updateDisplay(); + } + + private void updateDisplay() + { + var statistics = statisticsProvider?.GetStatisticsFor(ruleset.Value); + + loadingLayer.State.Value = statistics == null ? Visibility.Visible : Visibility.Hidden; + globalRankDisplay.Content = statistics?.GlobalRank?.ToLocalisableString("\\##,##0") ?? "-"; + countryRankDisplay.Content = statistics?.CountryRank?.ToLocalisableString("\\##,##0") ?? "-"; } protected override Drawable CreateLayout() @@ -152,7 +175,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(), @@ -176,7 +199,8 @@ namespace osu.Game.Users } } } - } + }, + loadingLayer = new LoadingLayer(true), } }; @@ -205,5 +229,13 @@ namespace osu.Game.Users } protected override Drawable? CreateBackground() => null; + + protected override void Dispose(bool isDisposing) + { + if (statisticsProvider.IsNotNull()) + statisticsProvider.StatisticsUpdated -= onStatisticsUpdated; + + base.Dispose(isDisposing); + } } } diff --git a/osu.Game/Utils/ModUtils.cs b/osu.Game/Utils/ModUtils.cs index f901f15388..15fc34b468 100644 --- a/osu.Game/Utils/ModUtils.cs +++ b/osu.Game/Utils/ModUtils.cs @@ -286,6 +286,7 @@ namespace osu.Game.Utils { double rate = 1; + // TODO: This doesn't consider mods which apply variable rates, yet. foreach (var mod in mods.OfType()) rate = mod.ApplyToRate(0, rate); diff --git a/osu.Game/Utils/SpecialFunctions.cs b/osu.Game/Utils/SpecialFunctions.cs index 0b0f0598bb..795a84a973 100644 --- a/osu.Game/Utils/SpecialFunctions.cs +++ b/osu.Game/Utils/SpecialFunctions.cs @@ -666,10 +666,7 @@ namespace osu.Game.Utils { // 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)); - } + ArgumentNullException.ThrowIfNull(coefficients); // 2020-10-07 jbialogrodzki #730 Zero polynomials need explicit handling. // Without this check, we attempted to peek coefficients at negative indices! diff --git a/osu.Game/Utils/SupportedExtensions.cs b/osu.Game/Utils/SupportedExtensions.cs new file mode 100644 index 0000000000..ec1538a041 --- /dev/null +++ b/osu.Game/Utils/SupportedExtensions.cs @@ -0,0 +1,19 @@ +// 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.Utils +{ + public static class SupportedExtensions + { + public static readonly string[] VIDEO_EXTENSIONS = [@".mp4", @".mov", @".avi", @".flv", @".mpg", @".wmv", @".m4v"]; + public static readonly string[] AUDIO_EXTENSIONS = [@".mp3", @".ogg", @".wav"]; + public static readonly string[] IMAGE_EXTENSIONS = [@".jpg", @".jpeg", @".png"]; + + public static readonly string[] ALL_EXTENSIONS = + [ + ..VIDEO_EXTENSIONS, + ..AUDIO_EXTENSIONS, + ..IMAGE_EXTENSIONS + ]; + } +} diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index f982b11ad5..847c209cc4 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 9fc7cdf453..62a65f291d 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -17,6 +17,6 @@ -all - + diff --git a/osu.iOS/Info.plist b/osu.iOS/Info.plist index 1330e29bc1..ae36d00910 100644 --- a/osu.iOS/Info.plist +++ b/osu.iOS/Info.plist @@ -68,6 +68,8 @@ sh.ppy.osu.items + UTTypeDescription + osu! replay UTTypeIdentifier sh.ppy.osu.osr UTTypeTagSpecification @@ -81,6 +83,8 @@ sh.ppy.osu.items + UTTypeDescription + osu! skin UTTypeIdentifier sh.ppy.osu.osk UTTypeTagSpecification @@ -94,6 +98,8 @@ sh.ppy.osu.items + UTTypeDescription + osu! beatmap UTTypeIdentifier sh.ppy.osu.osz UTTypeTagSpecification @@ -107,6 +113,8 @@ sh.ppy.osu.items + UTTypeDescription + osu! beatmap UTTypeIdentifier sh.ppy.osu.olz UTTypeTagSpecification diff --git a/osu.sln b/osu.sln index 829e43fc65..63da18c23e 100644 --- a/osu.sln +++ b/osu.sln @@ -56,11 +56,10 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{10DF8F12-50FD-45D8-8A38-17BA764BF54D}" ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig - .globalconfig = .globalconfig Directory.Build.props = Directory.Build.props osu.Android.props = osu.Android.props osu.iOS.props = osu.iOS.props - CodeAnalysis\osu.ruleset = CodeAnalysis\osu.ruleset + global.json = global.json osu.sln.DotSettings = osu.sln.DotSettings osu.TestProject.props = osu.TestProject.props EndProjectSection @@ -95,6 +94,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Pippidon" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Pippidon.Tests", "Templates\Rulesets\ruleset-scrolling-example\osu.Game.Rulesets.Pippidon.Tests\osu.Game.Rulesets.Pippidon.Tests.csproj", "{1743BF7C-E6AE-4A06-BAD9-166D62894303}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "CodeAnalysis", "CodeAnalysis", "{FB156649-D457-4D1A-969C-D3A23FD31513}" + ProjectSection(SolutionItems) = preProject + CodeAnalysis\BannedSymbols.txt = CodeAnalysis\BannedSymbols.txt + CodeAnalysis\osu.globalconfig = CodeAnalysis\osu.globalconfig + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU 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