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