diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 99906f0895..c4ba6e5143 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -21,7 +21,7 @@ ] }, "ppy.localisationanalyser.tools": { - "version": "2023.1117.0", + "version": "2024.802.0", "commands": [ "localisation" ] diff --git a/.editorconfig b/.editorconfig index c249e5e9b3..7aecde95ee 100644 --- a/.editorconfig +++ b/.editorconfig @@ -196,6 +196,9 @@ csharp_style_prefer_switch_expression = false:none csharp_style_namespace_declarations = block_scoped:warning +#Style - C# 12 features +csharp_style_prefer_primary_constructors = false + [*.{yaml,yml}] insert_final_newline = true indent_style = space diff --git a/.github/workflows/_diffcalc_processor.yml b/.github/workflows/_diffcalc_processor.yml new file mode 100644 index 0000000000..4e221d0550 --- /dev/null +++ b/.github/workflows/_diffcalc_processor.yml @@ -0,0 +1,228 @@ +name: "🔒diffcalc (do not use)" + +on: + workflow_call: + inputs: + id: + type: string + head-sha: + type: string + pr-url: + type: string + pr-text: + type: string + dispatch-inputs: + type: string + outputs: + target: + description: The comparison target. + value: ${{ jobs.generator.outputs.target }} + sheet: + description: The comparison spreadsheet. + value: ${{ jobs.generator.outputs.sheet }} + secrets: + DIFFCALC_GOOGLE_CREDENTIALS: + required: true + +env: + GENERATOR_DIR: ${{ github.workspace }}/${{ inputs.id }} + GENERATOR_ENV: ${{ github.workspace }}/${{ inputs.id }}/.env + +defaults: + run: + shell: bash -euo pipefail {0} + +jobs: + generator: + name: Run + runs-on: self-hosted + timeout-minutes: 720 + + outputs: + target: ${{ steps.run.outputs.target }} + sheet: ${{ steps.run.outputs.sheet }} + + steps: + - name: Checkout diffcalc-sheet-generator + uses: actions/checkout@v4 + with: + path: ${{ inputs.id }} + repository: 'smoogipoo/diffcalc-sheet-generator' + + - name: Add base environment + env: + GOOGLE_CREDS_FILE: ${{ github.workspace }}/${{ inputs.id }}/google-credentials.json + VARS_JSON: ${{ (vars != null && toJSON(vars)) || '' }} + run: | + # Required by diffcalc-sheet-generator + cp '${{ env.GENERATOR_DIR }}/.env.sample' "${{ env.GENERATOR_ENV }}" + + # Add Google credentials + echo '${{ secrets.DIFFCALC_GOOGLE_CREDENTIALS }}' | base64 -d > "${{ env.GOOGLE_CREDS_FILE }}" + + # Add repository variables + echo "${VARS_JSON}" | jq -c '. | to_entries | .[]' | while read -r line; do + opt=$(jq -r '.key' <<< ${line}) + val=$(jq -r '.value' <<< ${line}) + + if [[ "${opt}" =~ ^DIFFCALC_ ]]; then + optNoPrefix=$(echo "${opt}" | cut -d '_' -f2-) + sed -i "s;^${optNoPrefix}=.*$;${optNoPrefix}=${val};" "${{ env.GENERATOR_ENV }}" + fi + done + + - name: Add HEAD environment + run: | + sed -i "s;^OSU_A=.*$;OSU_A=${{ inputs.head-sha }};" "${{ env.GENERATOR_ENV }}" + + - name: Add pull-request environment + if: ${{ inputs.pr-url != '' }} + run: | + sed -i "s;^OSU_B=.*$;OSU_B=${{ inputs.pr-url }};" "${{ env.GENERATOR_ENV }}" + + - name: Add comment environment + if: ${{ inputs.pr-text != '' }} + env: + PR_TEXT: ${{ inputs.pr-text }} + run: | + # Add comment environment + echo "${PR_TEXT}" | sed -r 's/\r$//' | { grep -E '^\w+=' || true; } | while read -r line; do + opt=$(echo "${line}" | cut -d '=' -f1) + sed -i "s;^${opt}=.*$;${line};" "${{ env.GENERATOR_ENV }}" + done + + - name: Add dispatch environment + if: ${{ inputs.dispatch-inputs != '' }} + env: + DISPATCH_INPUTS_JSON: ${{ inputs.dispatch-inputs }} + run: | + function get_input() { + echo "${DISPATCH_INPUTS_JSON}" | jq -r ".\"$1\"" + } + + osu_a=$(get_input 'osu-a') + osu_b=$(get_input 'osu-b') + ruleset=$(get_input 'ruleset') + generators=$(get_input 'generators') + difficulty_calculator_a=$(get_input 'difficulty-calculator-a') + difficulty_calculator_b=$(get_input 'difficulty-calculator-b') + score_processor_a=$(get_input 'score-processor-a') + score_processor_b=$(get_input 'score-processor-b') + converts=$(get_input 'converts') + ranked_only=$(get_input 'ranked-only') + + sed -i "s;^OSU_B=.*$;OSU_B=${osu_b};" "${{ env.GENERATOR_ENV }}" + sed -i "s/^RULESET=.*$/RULESET=${ruleset}/" "${{ env.GENERATOR_ENV }}" + sed -i "s/^GENERATORS=.*$/GENERATORS=${generators}/" "${{ env.GENERATOR_ENV }}" + + if [[ "${osu_a}" != 'latest' ]]; then + sed -i "s;^OSU_A=.*$;OSU_A=${osu_a};" "${{ env.GENERATOR_ENV }}" + fi + + if [[ "${difficulty_calculator_a}" != 'latest' ]]; then + sed -i "s;^DIFFICULTY_CALCULATOR_A=.*$;DIFFICULTY_CALCULATOR_A=${difficulty_calculator_a};" "${{ env.GENERATOR_ENV }}" + fi + + if [[ "${difficulty_calculator_b}" != 'latest' ]]; then + sed -i "s;^DIFFICULTY_CALCULATOR_B=.*$;DIFFICULTY_CALCULATOR_B=${difficulty_calculator_b};" "${{ env.GENERATOR_ENV }}" + fi + + if [[ "${score_processor_a}" != 'latest' ]]; then + sed -i "s;^SCORE_PROCESSOR_A=.*$;SCORE_PROCESSOR_A=${score_processor_a};" "${{ env.GENERATOR_ENV }}" + fi + + if [[ "${score_processor_b}" != 'latest' ]]; then + sed -i "s;^SCORE_PROCESSOR_B=.*$;SCORE_PROCESSOR_B=${score_processor_b};" "${{ env.GENERATOR_ENV }}" + fi + + if [[ "${converts}" == 'true' ]]; then + sed -i 's/^NO_CONVERTS=.*$/NO_CONVERTS=0/' "${{ env.GENERATOR_ENV }}" + else + sed -i 's/^NO_CONVERTS=.*$/NO_CONVERTS=1/' "${{ env.GENERATOR_ENV }}" + fi + + if [[ "${ranked_only}" == 'true' ]]; then + sed -i 's/^RANKED_ONLY=.*$/RANKED_ONLY=1/' "${{ env.GENERATOR_ENV }}" + else + sed -i 's/^RANKED_ONLY=.*$/RANKED_ONLY=0/' "${{ env.GENERATOR_ENV }}" + fi + + - name: Query latest scores + id: query-scores + run: | + ruleset=$(cat ${{ env.GENERATOR_ENV }} | grep -E '^RULESET=' | cut -d '=' -f2-) + performance_data_name=$(curl -s "https://data.ppy.sh/" | grep "performance_${ruleset}_top_1000\b" | tail -1 | awk -F "'" '{print $2}' | sed 's/\.tar\.bz2//g') + + echo "TARGET_DIR=${{ env.GENERATOR_DIR }}/sql/${ruleset}" >> "${GITHUB_OUTPUT}" + echo "DATA_NAME=${performance_data_name}" >> "${GITHUB_OUTPUT}" + echo "DATA_PKG=${performance_data_name}.tar.bz2" >> "${GITHUB_OUTPUT}" + + - name: Restore score cache + id: restore-score-cache + uses: maxnowack/local-cache@720e69c948191660a90aa1cf6a42fc4d2dacdf30 # v2 + with: + path: ${{ steps.query-scores.outputs.DATA_PKG }} + key: ${{ steps.query-scores.outputs.DATA_NAME }} + + - name: Download scores + if: steps.restore-score-cache.outputs.cache-hit != 'true' + run: | + wget -q -O "${{ steps.query-scores.outputs.DATA_PKG }}" "https://data.ppy.sh/${{ steps.query-scores.outputs.DATA_PKG }}" + + - name: Extract scores + run: | + tar -I lbzip2 -xf "${{ steps.query-scores.outputs.DATA_PKG }}" + rm -r "${{ steps.query-scores.outputs.TARGET_DIR }}" + mv "${{ steps.query-scores.outputs.DATA_NAME }}" "${{ steps.query-scores.outputs.TARGET_DIR }}" + + - name: Query latest beatmaps + id: query-beatmaps + run: | + beatmaps_data_name=$(curl -s "https://data.ppy.sh/" | grep "osu_files" | tail -1 | awk -F "'" '{print $2}' | sed 's/\.tar\.bz2//g') + + echo "TARGET_DIR=${{ env.GENERATOR_DIR }}/beatmaps" >> "${GITHUB_OUTPUT}" + echo "DATA_NAME=${beatmaps_data_name}" >> "${GITHUB_OUTPUT}" + echo "DATA_PKG=${beatmaps_data_name}.tar.bz2" >> "${GITHUB_OUTPUT}" + + - name: Restore beatmap cache + id: restore-beatmap-cache + uses: maxnowack/local-cache@720e69c948191660a90aa1cf6a42fc4d2dacdf30 # v2 + with: + path: ${{ steps.query-beatmaps.outputs.DATA_PKG }} + key: ${{ steps.query-beatmaps.outputs.DATA_NAME }} + + - name: Download beatmap + if: steps.restore-beatmap-cache.outputs.cache-hit != 'true' + run: | + wget -q -O "${{ steps.query-beatmaps.outputs.DATA_PKG }}" "https://data.ppy.sh/${{ steps.query-beatmaps.outputs.DATA_PKG }}" + + - name: Extract beatmap + run: | + tar -I lbzip2 -xf "${{ steps.query-beatmaps.outputs.DATA_PKG }}" + rm -r "${{ steps.query-beatmaps.outputs.TARGET_DIR }}" + mv "${{ steps.query-beatmaps.outputs.DATA_NAME }}" "${{ steps.query-beatmaps.outputs.TARGET_DIR }}" + + - name: Run + id: run + run: | + # Add the GitHub token. This needs to be done here because it's unique per-job. + sed -i 's/^GH_TOKEN=.*$/GH_TOKEN=${{ github.token }}/' "${{ env.GENERATOR_ENV }}" + + cd "${{ env.GENERATOR_DIR }}" + + docker compose up --build --detach + docker compose logs --follow & + docker compose wait generator + + link=$(docker compose logs --tail 10 generator | grep 'http' | sed -E 's/^.*(http.*)$/\1/') + target=$(cat "${{ env.GENERATOR_ENV }}" | grep -E '^OSU_B=' | cut -d '=' -f2-) + + echo "target=${target}" >> "${GITHUB_OUTPUT}" + echo "sheet=${link}" >> "${GITHUB_OUTPUT}" + + - name: Shutdown + if: ${{ always() }} + run: | + cd "${{ env.GENERATOR_DIR }}" + docker compose down --volumes + rm -rf "${{ env.GENERATOR_DIR }}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ea4654563..cb45447ed5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,10 +64,11 @@ jobs: matrix: os: - { prettyname: Windows, fullname: windows-latest } - - { prettyname: macOS, fullname: macos-latest } + # macOS runner performance has gotten unbearably slow so let's turn them off temporarily. + # - { prettyname: macOS, fullname: macos-latest } - { prettyname: Linux, fullname: ubuntu-latest } threadingMode: ['SingleThread', 'MultiThreaded'] - timeout-minutes: 60 + timeout-minutes: 120 steps: - name: Checkout uses: actions/checkout@v4 @@ -87,7 +88,7 @@ jobs: # Attempt to upload results even if test fails. # https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#always - name: Upload Test Results - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: ${{ always() }} with: name: osu-test-results-${{matrix.os.prettyname}}-${{matrix.threadingMode}} @@ -120,9 +121,7 @@ jobs: build-only-ios: name: Build only (iOS) - # `macos-13` is required, because the newest Microsoft.iOS.Sdk versions require Xcode 14.3. - # TODO: can be changed to `macos-latest` once `macos-13` becomes latest (currently in beta: https://github.com/actions/runner-images/tree/main#available-images) - runs-on: macos-13 + runs-on: macos-latest timeout-minutes: 60 steps: - name: Checkout @@ -134,10 +133,7 @@ jobs: dotnet-version: "8.0.x" - name: Install .NET Workloads - run: dotnet workload install maui-ios - - - name: Select Xcode 15.2 - run: sudo xcode-select -s /Applications/Xcode_15.2.app/Contents/Developer + run: dotnet workload install ios --from-rollback-file https://raw.githubusercontent.com/ppy/osu-framework/refs/heads/master/workloads.json - name: Build run: dotnet build -c Debug osu.iOS diff --git a/.github/workflows/diffcalc.yml b/.github/workflows/diffcalc.yml index 7fd0f798cd..4297a88e89 100644 --- a/.github/workflows/diffcalc.yml +++ b/.github/workflows/diffcalc.yml @@ -103,6 +103,10 @@ permissions: env: EXECUTION_ID: execution-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }} +defaults: + run: + shell: bash -euo pipefail {0} + jobs: check-permissions: name: Check permissions @@ -111,7 +115,7 @@ jobs: steps: - name: Check permissions run: | - ALLOWED_USERS=(smoogipoo peppy bdach) + ALLOWED_USERS=(smoogipoo peppy bdach frenzibyte) for i in "${ALLOWED_USERS[@]}"; do if [[ "${{ github.actor }}" == "$i" ]]; then exit 0 @@ -119,6 +123,20 @@ jobs: done exit 1 + run-diffcalc: + name: Run spreadsheet generator + needs: check-permissions + uses: ./.github/workflows/_diffcalc_processor.yml + with: + # Can't reference env... Why GitHub, WHY? + id: execution-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }} + head-sha: https://github.com/${{ github.repository }}/commit/${{ github.event.pull_request.head.sha || github.sha }} + pr-url: ${{ github.event.issue.pull_request.html_url || '' }} + pr-text: ${{ github.event.comment.body || '' }} + dispatch-inputs: ${{ (github.event.type == 'workflow_dispatch' && toJSON(inputs)) || '' }} + secrets: + DIFFCALC_GOOGLE_CREDENTIALS: ${{ secrets.DIFFCALC_GOOGLE_CREDENTIALS }} + create-comment: name: Create PR comment needs: check-permissions @@ -134,251 +152,43 @@ jobs: *This comment will update on completion* - directory: - name: Prepare directory - needs: check-permissions - runs-on: self-hosted - outputs: - GENERATOR_DIR: ${{ steps.set-outputs.outputs.GENERATOR_DIR }} - GENERATOR_ENV: ${{ steps.set-outputs.outputs.GENERATOR_ENV }} - GOOGLE_CREDS_FILE: ${{ steps.set-outputs.outputs.GOOGLE_CREDS_FILE }} - steps: - - name: Checkout diffcalc-sheet-generator - uses: actions/checkout@v4 - with: - path: ${{ env.EXECUTION_ID }} - repository: 'smoogipoo/diffcalc-sheet-generator' - - - name: Set outputs - id: set-outputs - run: | - echo "GENERATOR_DIR=${{ github.workspace }}/${{ env.EXECUTION_ID }}" >> "${GITHUB_OUTPUT}" - echo "GENERATOR_ENV=${{ github.workspace }}/${{ env.EXECUTION_ID }}/.env" >> "${GITHUB_OUTPUT}" - echo "GOOGLE_CREDS_FILE=${{ github.workspace }}/${{ env.EXECUTION_ID }}/google-credentials.json" >> "${GITHUB_OUTPUT}" - - environment: - name: Setup environment - needs: directory - runs-on: self-hosted - env: - VARS_JSON: ${{ toJSON(vars) }} - steps: - - name: Add base environment - run: | - # Required by diffcalc-sheet-generator - cp '${{ needs.directory.outputs.GENERATOR_DIR }}/.env.sample' "${{ needs.directory.outputs.GENERATOR_ENV }}" - - # Add Google credentials - echo '${{ secrets.DIFFCALC_GOOGLE_CREDENTIALS }}' | base64 -d > "${{ needs.directory.outputs.GOOGLE_CREDS_FILE }}" - - # Add repository variables - echo "${VARS_JSON}" | jq -c '. | to_entries | .[]' | while read -r line; do - opt=$(jq -r '.key' <<< ${line}) - val=$(jq -r '.value' <<< ${line}) - - if [[ "${opt}" =~ ^DIFFCALC_ ]]; then - optNoPrefix=$(echo "${opt}" | cut -d '_' -f2-) - sed -i "s;^${optNoPrefix}=.*$;${optNoPrefix}=${val};" "${{ needs.directory.outputs.GENERATOR_ENV }}" - fi - done - - - name: Add pull-request environment - if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request }} - run: | - sed -i "s;^OSU_B=.*$;OSU_B=${{ github.event.issue.pull_request.html_url }};" "${{ needs.directory.outputs.GENERATOR_ENV }}" - - - name: Add comment environment - if: ${{ github.event_name == 'issue_comment' }} - env: - COMMENT_BODY: ${{ github.event.comment.body }} - run: | - # Add comment environment - echo "$COMMENT_BODY" | sed -r 's/\r$//' | grep -E '^\w+=' | while read -r line; do - opt=$(echo "${line}" | cut -d '=' -f1) - sed -i "s;^${opt}=.*$;${line};" "${{ needs.directory.outputs.GENERATOR_ENV }}" - done - - - name: Add dispatch environment - if: ${{ github.event_name == 'workflow_dispatch' }} - run: | - sed -i 's;^OSU_B=.*$;OSU_B=${{ inputs.osu-b }};' "${{ needs.directory.outputs.GENERATOR_ENV }}" - sed -i 's/^RULESET=.*$/RULESET=${{ inputs.ruleset }}/' "${{ needs.directory.outputs.GENERATOR_ENV }}" - sed -i 's/^GENERATORS=.*$/GENERATORS=${{ inputs.generators }}/' "${{ needs.directory.outputs.GENERATOR_ENV }}" - - if [[ '${{ inputs.osu-a }}' != 'latest' ]]; then - sed -i 's;^OSU_A=.*$;OSU_A=${{ inputs.osu-a }};' "${{ needs.directory.outputs.GENERATOR_ENV }}" - fi - - if [[ '${{ inputs.difficulty-calculator-a }}' != 'latest' ]]; then - sed -i 's;^DIFFICULTY_CALCULATOR_A=.*$;DIFFICULTY_CALCULATOR_A=${{ inputs.difficulty-calculator-a }};' "${{ needs.directory.outputs.GENERATOR_ENV }}" - fi - - if [[ '${{ inputs.difficulty-calculator-b }}' != 'latest' ]]; then - sed -i 's;^DIFFICULTY_CALCULATOR_B=.*$;DIFFICULTY_CALCULATOR_B=${{ inputs.difficulty-calculator-b }};' "${{ needs.directory.outputs.GENERATOR_ENV }}" - fi - - if [[ '${{ inputs.score-processor-a }}' != 'latest' ]]; then - sed -i 's;^SCORE_PROCESSOR_A=.*$;SCORE_PROCESSOR_A=${{ inputs.score-processor-a }};' "${{ needs.directory.outputs.GENERATOR_ENV }}" - fi - - if [[ '${{ inputs.score-processor-b }}' != 'latest' ]]; then - sed -i 's;^SCORE_PROCESSOR_B=.*$;SCORE_PROCESSOR_B=${{ inputs.score-processor-b }};' "${{ needs.directory.outputs.GENERATOR_ENV }}" - fi - - if [[ '${{ inputs.converts }}' == 'true' ]]; then - sed -i 's/^NO_CONVERTS=.*$/NO_CONVERTS=0/' "${{ needs.directory.outputs.GENERATOR_ENV }}" - else - sed -i 's/^NO_CONVERTS=.*$/NO_CONVERTS=1/' "${{ needs.directory.outputs.GENERATOR_ENV }}" - fi - - if [[ '${{ inputs.ranked-only }}' == 'true' ]]; then - sed -i 's/^RANKED_ONLY=.*$/RANKED_ONLY=1/' "${{ needs.directory.outputs.GENERATOR_ENV }}" - else - sed -i 's/^RANKED_ONLY=.*$/RANKED_ONLY=0/' "${{ needs.directory.outputs.GENERATOR_ENV }}" - fi - - scores: - name: Setup scores - needs: [ directory, environment ] - runs-on: self-hosted - steps: - - name: Query latest data - id: query - run: | - ruleset=$(cat ${{ needs.directory.outputs.GENERATOR_ENV }} | grep -E '^RULESET=' | cut -d '=' -f2-) - performance_data_name=$(curl -s "https://data.ppy.sh/" | grep "performance_${ruleset}_top_1000\b" | tail -1 | awk -F "'" '{print $2}' | sed 's/\.tar\.bz2//g') - - echo "TARGET_DIR=${{ needs.directory.outputs.GENERATOR_DIR }}/sql/${ruleset}" >> "${GITHUB_OUTPUT}" - echo "DATA_NAME=${performance_data_name}" >> "${GITHUB_OUTPUT}" - - - name: Restore cache - id: restore-cache - uses: maxnowack/local-cache@720e69c948191660a90aa1cf6a42fc4d2dacdf30 # v2 - with: - path: ${{ steps.query.outputs.DATA_NAME }}.tar.bz2 - key: ${{ steps.query.outputs.DATA_NAME }} - - - name: Download - if: steps.restore-cache.outputs.cache-hit != 'true' - run: | - wget -q -nc "https://data.ppy.sh/${{ steps.query.outputs.DATA_NAME }}.tar.bz2" - - - name: Extract - run: | - tar -I lbzip2 -xf "${{ steps.query.outputs.DATA_NAME }}.tar.bz2" - rm -r "${{ steps.query.outputs.TARGET_DIR }}" - mv "${{ steps.query.outputs.DATA_NAME }}" "${{ steps.query.outputs.TARGET_DIR }}" - - beatmaps: - name: Setup beatmaps - needs: directory - runs-on: self-hosted - steps: - - name: Query latest data - id: query - run: | - beatmaps_data_name=$(curl -s "https://data.ppy.sh/" | grep "osu_files" | tail -1 | awk -F "'" '{print $2}' | sed 's/\.tar\.bz2//g') - - echo "TARGET_DIR=${{ needs.directory.outputs.GENERATOR_DIR }}/beatmaps" >> "${GITHUB_OUTPUT}" - echo "DATA_NAME=${beatmaps_data_name}" >> "${GITHUB_OUTPUT}" - - - name: Restore cache - id: restore-cache - uses: maxnowack/local-cache@720e69c948191660a90aa1cf6a42fc4d2dacdf30 # v2 - with: - path: ${{ steps.query.outputs.DATA_NAME }}.tar.bz2 - key: ${{ steps.query.outputs.DATA_NAME }} - - - name: Download - if: steps.restore-cache.outputs.cache-hit != 'true' - run: | - wget -q -nc "https://data.ppy.sh/${{ steps.query.outputs.DATA_NAME }}.tar.bz2" - - - name: Extract - run: | - tar -I lbzip2 -xf "${{ steps.query.outputs.DATA_NAME }}.tar.bz2" - rm -r "${{ steps.query.outputs.TARGET_DIR }}" - mv "${{ steps.query.outputs.DATA_NAME }}" "${{ steps.query.outputs.TARGET_DIR }}" - - generator: - name: Run generator - needs: [ directory, environment, scores, beatmaps ] - runs-on: self-hosted - timeout-minutes: 720 - outputs: - TARGET: ${{ steps.run.outputs.TARGET }} - SPREADSHEET_LINK: ${{ steps.run.outputs.SPREADSHEET_LINK }} - steps: - - name: Run - id: run - run: | - # Add the GitHub token. This needs to be done here because it's unique per-job. - sed -i 's/^GH_TOKEN=.*$/GH_TOKEN=${{ github.token }}/' "${{ needs.directory.outputs.GENERATOR_ENV }}" - - cd "${{ needs.directory.outputs.GENERATOR_DIR }}" - docker-compose up --build generator - - link=$(docker-compose logs generator -n 10 | grep 'http' | sed -E 's/^.*(http.*)$/\1/') - target=$(cat "${{ needs.directory.outputs.GENERATOR_ENV }}" | grep -E '^OSU_B=' | cut -d '=' -f2-) - - echo "TARGET=${target}" >> "${GITHUB_OUTPUT}" - echo "SPREADSHEET_LINK=${link}" >> "${GITHUB_OUTPUT}" - - - name: Shutdown - if: ${{ always() }} - run: | - cd "${{ needs.directory.outputs.GENERATOR_DIR }}" - docker-compose down -v - output-cli: - name: Output info - needs: generator + name: Info + needs: run-diffcalc runs-on: ubuntu-latest steps: - name: Output info run: | - echo "Target: ${{ needs.generator.outputs.TARGET }}" - echo "Spreadsheet: ${{ needs.generator.outputs.SPREADSHEET_LINK }}" - - cleanup: - name: Cleanup - needs: [ directory, generator ] - if: ${{ always() && needs.directory.result == 'success' }} - runs-on: self-hosted - steps: - - name: Cleanup - run: | - rm -rf "${{ needs.directory.outputs.GENERATOR_DIR }}" + echo "Target: ${{ needs.run-diffcalc.outputs.target }}" + echo "Spreadsheet: ${{ needs.run-diffcalc.outputs.sheet }}" update-comment: name: Update PR comment - needs: [ create-comment, generator ] + needs: [ create-comment, run-diffcalc ] runs-on: ubuntu-latest if: ${{ always() && needs.create-comment.result == 'success' }} steps: - name: Update comment on success - if: ${{ needs.generator.result == 'success' }} + if: ${{ needs.run-diffcalc.result == 'success' }} uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0 with: comment_tag: ${{ env.EXECUTION_ID }} - mode: upsert - create_if_not_exists: false + mode: recreate message: | - Target: ${{ needs.generator.outputs.TARGET }} - Spreadsheet: ${{ needs.generator.outputs.SPREADSHEET_LINK }} + Target: ${{ needs.run-diffcalc.outputs.target }} + Spreadsheet: ${{ needs.run-diffcalc.outputs.sheet }} - name: Update comment on failure - if: ${{ needs.generator.result == 'failure' }} + if: ${{ needs.run-diffcalc.result == 'failure' }} uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0 with: comment_tag: ${{ env.EXECUTION_ID }} - mode: upsert - create_if_not_exists: false + mode: recreate message: | Difficulty calculation failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - name: Update comment on cancellation - if: ${{ needs.generator.result == 'cancelled' }} + if: ${{ needs.run-diffcalc.result == 'cancelled' }} uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0 with: comment_tag: ${{ env.EXECUTION_ID }} diff --git a/.github/workflows/report-nunit.yml b/.github/workflows/report-nunit.yml index c44f46d70a..14f0208fc8 100644 --- a/.github/workflows/report-nunit.yml +++ b/.github/workflows/report-nunit.yml @@ -5,33 +5,40 @@ name: Annotate CI run with test results on: workflow_run: - workflows: ["Continuous Integration"] + workflows: [ "Continuous Integration" ] types: - completed -permissions: {} + +permissions: + contents: read + actions: read + checks: write + jobs: annotate: - permissions: - checks: write # to create checks (dorny/test-reporter) - name: Annotate CI run with test results runs-on: ubuntu-latest if: ${{ github.event.workflow_run.conclusion != 'cancelled' }} - strategy: - fail-fast: false - matrix: - os: - - { prettyname: Windows } - - { prettyname: macOS } - - { prettyname: Linux } - threadingMode: ['SingleThread', 'MultiThreaded'] timeout-minutes: 5 steps: + - name: Checkout + uses: actions/checkout@v4 + with: + repository: ${{ github.event.workflow_run.repository.full_name }} + ref: ${{ github.event.workflow_run.head_sha }} + + - name: Download results + uses: actions/download-artifact@v4 + with: + pattern: osu-test-results-* + merge-multiple: true + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + - name: Annotate CI run with test results uses: dorny/test-reporter@v1.8.0 with: - artifact: osu-test-results-${{matrix.os.prettyname}}-${{matrix.threadingMode}} - name: Test Results (${{matrix.os.prettyname}}, ${{matrix.threadingMode}}) + name: Results path: "*.trx" reporter: dotnet-trx list-suites: 'failed' diff --git a/.gitignore b/.gitignore index 11fee27f28..1fec94d82b 100644 --- a/.gitignore +++ b/.gitignore @@ -265,6 +265,8 @@ __pycache__/ .idea/**/usage.statistics.xml .idea/**/dictionaries .idea/**/shelf +.idea/*/.idea/projectSettingsUpdater.xml +.idea/*/.idea/encodings.xml # Generated files .idea/**/contentModel.xml diff --git a/.idea/.idea.osu.Android/.idea/projectSettingsUpdater.xml b/.idea/.idea.osu.Android/.idea/projectSettingsUpdater.xml deleted file mode 100644 index 4bb9f4d2a0..0000000000 --- a/.idea/.idea.osu.Android/.idea/projectSettingsUpdater.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/.idea.osu.Desktop/.idea/encodings.xml b/.idea/.idea.osu.Desktop/.idea/encodings.xml deleted file mode 100644 index 15a15b218a..0000000000 --- a/.idea/.idea.osu.Desktop/.idea/encodings.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/.idea.osu.Desktop/.idea/projectSettingsUpdater.xml b/.idea/.idea.osu.Desktop/.idea/projectSettingsUpdater.xml deleted file mode 100644 index 4bb9f4d2a0..0000000000 --- a/.idea/.idea.osu.Desktop/.idea/projectSettingsUpdater.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/.idea.osu.iOS/.idea/projectSettingsUpdater.xml b/.idea/.idea.osu.iOS/.idea/projectSettingsUpdater.xml deleted file mode 100644 index 4bb9f4d2a0..0000000000 --- a/.idea/.idea.osu.iOS/.idea/projectSettingsUpdater.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/.idea.osu/.idea/projectSettingsUpdater.xml b/.idea/.idea.osu/.idea/projectSettingsUpdater.xml deleted file mode 100644 index 4bb9f4d2a0..0000000000 --- a/.idea/.idea.osu/.idea/projectSettingsUpdater.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 5b7a98f4ba..0793dcc76c 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,5 +1,6 @@ { "recommendations": [ - "ms-dotnettools.csharp" + "editorconfig.editorconfig", + "ms-dotnettools.csdevkit" ] } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0fe6b6fb4d..ebe1e08074 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,7 +55,7 @@ When in doubt, it's probably best to start with a discussion first. We will esca While pull requests from unaffiliated contributors are welcome, please note that due to significant community interest and limited review throughput, the core team's primary focus is on the issues which are currently [on the roadmap](https://github.com/orgs/ppy/projects/7/views/6). Reviewing PRs that fall outside of the scope of the roadmap is done on a best-effort basis, so please be aware that it may take a while before a core maintainer gets around to review your change. -The [issue tracker](https://github.com/ppy/osu/issues) should provide plenty of issues to start with. We also have a [`good-first-issue`](https://github.com/ppy/osu/issues?q=is%3Aissue+is%3Aopen+label%3Agood-first-issue) label, although from experience it is not used very often, as it is relatively rare that we can spot an issue that will definitively be a good first issue for a new contributor regardless of their programming experience. +The [issue tracker](https://github.com/ppy/osu/issues) should provide plenty of issues to start with. We also have a [`good first issue`](https://github.com/ppy/osu/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) label, although from experience it is not used very often, as it is relatively rare that we can spot an issue that will definitively be a good first issue for a new contributor regardless of their programming experience. In the case of simple issues, a direct PR is okay. However, if you decide to work on an existing issue which doesn't seem trivial, **please ask us first**. This way we can try to estimate if it is a good fit for you and provide the correct direction on how to address it. In addition, note that while we do not rule out external contributors from working on roadmapped issues, we will generally prefer to handle them ourselves unless they're not very time sensitive. diff --git a/CodeAnalysis/BannedSymbols.txt b/CodeAnalysis/BannedSymbols.txt index 03fd21829d..3c60b28765 100644 --- a/CodeAnalysis/BannedSymbols.txt +++ b/CodeAnalysis/BannedSymbols.txt @@ -7,7 +7,6 @@ T:SixLabors.ImageSharp.IDeepCloneable`1;Use osu.Game.Utils.IDeepCloneable ins M:osu.Framework.Graphics.Sprites.SpriteText.#ctor;Use OsuSpriteText. M:osu.Framework.Bindables.IBindableList`1.GetBoundCopy();Fails on iOS. Use manual ctor + BindTo instead. (see https://github.com/mono/mono/issues/19900) T:NuGet.Packaging.CollectionExtensions;Don't use internal extension methods. -M:System.Enum.HasFlag(System.Enum);Use osu.Framework.Extensions.EnumExtensions.HasFlagFast() instead. M:Realms.IRealmCollection`1.SubscribeForNotifications`1(Realms.NotificationCallbackDelegate{``0});Use osu.Game.Database.RealmObjectExtensions.QueryAsyncWithNotifications(IRealmCollection,NotificationCallbackDelegate) instead. M:System.Guid.#ctor;Probably meaning to use Guid.NewGuid() instead. If actually wanting empty, use Guid.Empty. M:Realms.CollectionExtensions.SubscribeForNotifications`1(System.Linq.IQueryable{``0},Realms.NotificationCallbackDelegate{``0});Use osu.Game.Database.RealmObjectExtensions.QueryAsyncWithNotifications(IQueryable,NotificationCallbackDelegate) instead. diff --git a/Directory.Build.props b/Directory.Build.props index 2d289d0f22..5ba12b845b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,7 +2,6 @@ 12.0 - true enable diff --git a/README.md b/README.md index cb722e5df3..6043497181 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Please make sure you have the following prerequisites: - A desktop platform with the [.NET 8.0 SDK](https://dotnet.microsoft.com/download) installed. -When working with the codebase, we recommend using an IDE with intelligent code completion and syntax highlighting, such as the latest version of [Visual Studio](https://visualstudio.microsoft.com/vs/), [JetBrains Rider](https://www.jetbrains.com/rider/), or [Visual Studio Code](https://code.visualstudio.com/) with the [EditorConfig](https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig) and [C#](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csharp) plugin installed. +When working with the codebase, we recommend using an IDE with intelligent code completion and syntax highlighting, such as the latest version of [Visual Studio](https://visualstudio.microsoft.com/vs/), [JetBrains Rider](https://www.jetbrains.com/rider/), or [Visual Studio Code](https://code.visualstudio.com/) with the [EditorConfig](https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig) and [C# Dev Kit](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csdevkit) plugin installed. ### Downloading the source code diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj index 7d43eb2b05..f77cda1533 100644 --- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj @@ -9,7 +9,7 @@ false - + diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj index 7dc8a1336b..47cabaddb1 100644 --- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj @@ -9,7 +9,7 @@ false - + diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj index 9c4c8217f0..a7d62291d0 100644 --- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj @@ -9,7 +9,7 @@ false - + diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj index 7dc8a1336b..47cabaddb1 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj @@ -9,7 +9,7 @@ false - + diff --git a/assets/lazer-nuget.png b/assets/lazer-nuget.png index fed2f45149..fabfcc223e 100644 Binary files a/assets/lazer-nuget.png and b/assets/lazer-nuget.png differ diff --git a/assets/lazer.png b/assets/lazer.png index 2ee44225bf..f564b93d6f 100644 Binary files a/assets/lazer.png and b/assets/lazer.png differ diff --git a/osu.Android.props b/osu.Android.props index c61977cfa3..0ebb6be7a1 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -10,7 +10,7 @@ true - + Release Difference / ms // release_threshold if (isOverlapping) - holdAddition = 1 / (1 + Math.Exp(0.27 * (release_threshold - closestEndTime))); + holdAddition = DifficultyCalculationUtils.Logistic(x: closestEndTime, multiplier: 0.27, midpointOffset: release_threshold); // Decay and increase individualStrains in own column individualStrains[column] = applyDecay(individualStrains[column], startTime - startTimes[column], individual_decay_base); diff --git a/osu.Game.Rulesets.Mania/DualStageVariantGenerator.cs b/osu.Game.Rulesets.Mania/DualStageVariantGenerator.cs index e9d26b4aa1..6a7634da01 100644 --- a/osu.Game.Rulesets.Mania/DualStageVariantGenerator.cs +++ b/osu.Game.Rulesets.Mania/DualStageVariantGenerator.cs @@ -45,18 +45,15 @@ namespace osu.Game.Rulesets.Mania LeftKeys = stage1LeftKeys, RightKeys = stage1RightKeys, SpecialKey = InputKey.V, - SpecialAction = ManiaAction.Special1, - NormalActionStart = ManiaAction.Key1 - }.GenerateKeyBindingsFor(singleStageVariant, out var nextNormal); + }.GenerateKeyBindingsFor(singleStageVariant); var stage2Bindings = new VariantMappingGenerator { LeftKeys = stage2LeftKeys, RightKeys = stage2RightKeys, SpecialKey = InputKey.B, - SpecialAction = ManiaAction.Special2, - NormalActionStart = nextNormal - }.GenerateKeyBindingsFor(singleStageVariant, out _); + ActionStart = (ManiaAction)singleStageVariant, + }.GenerateKeyBindingsFor(singleStageVariant); return stage1Bindings.Concat(stage2Bindings); } diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditBodyPiece.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditBodyPiece.cs index 6a12ec5088..5cfcf00b33 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditBodyPiece.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditBodyPiece.cs @@ -3,21 +3,39 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; -using osu.Game.Rulesets.Mania.Skinning.Default; namespace osu.Game.Rulesets.Mania.Edit.Blueprints.Components { - public partial class EditBodyPiece : DefaultBodyPiece + public partial class EditBodyPiece : CompositeDrawable { + private readonly Container border; + + public EditBodyPiece() + { + InternalChildren = new Drawable[] + { + border = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + BorderThickness = 3, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + }, + }, + }; + } + [BackgroundDependencyLoader] private void load(OsuColour colours) { - AccentColour.Value = colours.Yellow; - - Background.Alpha = 0.5f; + border.BorderColour = colours.YellowDarker; } - - protected override Drawable CreateForeground() => base.CreateForeground().With(d => d.Alpha = 0); } } diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditHoldNoteEndPiece.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditHoldNoteEndPiece.cs new file mode 100644 index 0000000000..d4b61b4661 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditHoldNoteEndPiece.cs @@ -0,0 +1,87 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Rulesets.Mania.Skinning.Default; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Edit.Blueprints.Components +{ + public partial class EditHoldNoteEndPiece : CompositeDrawable + { + public Action? DragStarted { get; init; } + public Action? Dragging { get; init; } + public Action? DragEnded { get; init; } + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + Height = DefaultNotePiece.NOTE_HEIGHT; + + InternalChild = new EditNotePiece + { + RelativeSizeAxes = Axes.Both, + Height = 1, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + updateState(); + } + + protected override bool OnHover(HoverEvent e) + { + updateState(); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateState(); + base.OnHoverLost(e); + } + + protected override bool OnDragStart(DragStartEvent e) + { + DragStarted?.Invoke(); + return true; + } + + protected override void OnDrag(DragEvent e) + { + base.OnDrag(e); + Dragging?.Invoke(e.ScreenSpaceMousePosition); + updateState(); + } + + protected override void OnDragEnd(DragEndEvent e) + { + base.OnDragEnd(e); + DragEnded?.Invoke(); + updateState(); + } + + private void updateState() + { + InternalChild.Colour = Colour4.White; + + var colour = colours.Yellow; + + if (IsHovered || IsDragged) + colour = colour.Lighten(1); + + Colour = colour; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditNotePiece.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditNotePiece.cs index 48dde29a9f..f68004db28 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditNotePiece.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditNotePiece.cs @@ -2,28 +2,63 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; -using osu.Game.Rulesets.Mania.Skinning.Default; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK; namespace osu.Game.Rulesets.Mania.Edit.Blueprints.Components { public partial class EditNotePiece : CompositeDrawable { + private readonly Container border; + private readonly Box box; + + [Resolved] + private Column? column { get; set; } + public EditNotePiece() { - Height = DefaultNotePiece.NOTE_HEIGHT; - - CornerRadius = 5; - Masking = true; - - InternalChild = new DefaultNotePiece(); + InternalChildren = new Drawable[] + { + border = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + BorderThickness = 3, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + }, + }, + box = new Box + { + RelativeSizeAxes = Axes.X, + Height = 3, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + }, + }; } [BackgroundDependencyLoader] private void load(OsuColour colours) { - Colour = colours.Yellow; + border.BorderColour = colours.YellowDark; + box.Colour = colours.YellowLight; + } + + protected override void Update() + { + base.Update(); + + if (column != null) + Scale = new Vector2(1, column.ScrollingInfo.Direction.Value == ScrollingDirection.Down ? 1 : -1); } } } diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs index 991b7f476c..13cfc5f691 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs @@ -4,8 +4,10 @@ using System; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Framework.Utils; +using osu.Game.Graphics; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Edit.Blueprints.Components; using osu.Game.Rulesets.Mania.Objects; @@ -17,9 +19,9 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints { public partial class HoldNotePlacementBlueprint : ManiaPlacementBlueprint { - private readonly EditBodyPiece bodyPiece; - private readonly EditNotePiece headPiece; - private readonly EditNotePiece tailPiece; + private EditBodyPiece bodyPiece = null!; + private Circle headPiece = null!; + private Circle tailPiece = null!; [Resolved] private IScrollingInfo scrollingInfo { get; set; } = null!; @@ -28,14 +30,29 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints public HoldNotePlacementBlueprint() : base(new HoldNote()) + { + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) { RelativeSizeAxes = Axes.Both; InternalChildren = new Drawable[] { bodyPiece = new EditBodyPiece { Origin = Anchor.TopCentre }, - headPiece = new EditNotePiece { Origin = Anchor.Centre }, - tailPiece = new EditNotePiece { Origin = Anchor.Centre } + headPiece = new Circle + { + Origin = Anchor.Centre, + Colour = colours.Yellow, + Height = 10 + }, + tailPiece = new Circle + { + Origin = Anchor.Centre, + Colour = colours.Yellow, + Height = 10 + }, }; } diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs index 8ec5213d5f..915706c044 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs @@ -1,16 +1,16 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; -using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; +using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Edit.Blueprints.Components; using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Screens.Edit; using osuTK; namespace osu.Game.Rulesets.Mania.Edit.Blueprints @@ -18,10 +18,19 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints public partial class HoldNoteSelectionBlueprint : ManiaSelectionBlueprint { [Resolved] - private OsuColour colours { get; set; } + private IEditorChangeHandler? changeHandler { get; set; } - private EditNotePiece head; - private EditNotePiece tail; + [Resolved] + private EditorBeatmap? editorBeatmap { get; set; } + + [Resolved] + private IPositionSnapProvider? positionSnapProvider { get; set; } + + private EditBodyPiece body = null!; + private EditHoldNoteEndPiece head = null!; + private EditHoldNoteEndPiece tail = null!; + + protected new DrawableHoldNote DrawableObject => (DrawableHoldNote)base.DrawableObject; public HoldNoteSelectionBlueprint(HoldNote hold) : base(hold) @@ -33,21 +42,53 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints { InternalChildren = new Drawable[] { - head = new EditNotePiece { RelativeSizeAxes = Axes.X }, - tail = new EditNotePiece { RelativeSizeAxes = Axes.X }, - new Container + body = new EditBodyPiece { RelativeSizeAxes = Axes.Both, - Masking = true, - BorderThickness = 1, - BorderColour = colours.Yellow, - Child = new Box + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + }, + head = new EditHoldNoteEndPiece + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + DragStarted = () => changeHandler?.BeginChange(), + Dragging = pos => { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - AlwaysPresent = true, - } - } + double endTimeBeforeDrag = HitObject.EndTime; + double proposedStartTime = positionSnapProvider?.FindSnappedPositionAndTime(pos).Time ?? HitObjectContainer.TimeAtScreenSpacePosition(pos); + double proposedEndTime = endTimeBeforeDrag; + + if (proposedStartTime >= proposedEndTime) + return; + + HitObject.StartTime = proposedStartTime; + HitObject.EndTime = proposedEndTime; + editorBeatmap?.Update(HitObject); + }, + DragEnded = () => changeHandler?.EndChange(), + }, + tail = new EditHoldNoteEndPiece + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + DragStarted = () => changeHandler?.BeginChange(), + Dragging = pos => + { + double proposedStartTime = HitObject.StartTime; + double proposedEndTime = positionSnapProvider?.FindSnappedPositionAndTime(pos).Time ?? HitObjectContainer.TimeAtScreenSpacePosition(pos); + + if (proposedStartTime >= proposedEndTime) + return; + + HitObject.StartTime = proposedStartTime; + HitObject.EndTime = proposedEndTime; + editorBeatmap?.Update(HitObject); + }, + DragEnded = () => changeHandler?.EndChange(), + }, }; } @@ -55,11 +96,23 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints { base.Update(); + head.Height = DrawableObject.Head.DrawHeight; head.Y = HitObjectContainer.PositionAtTime(HitObject.Head.StartTime, HitObject.StartTime); + tail.Height = DrawableObject.Tail.DrawHeight; tail.Y = HitObjectContainer.PositionAtTime(HitObject.Tail.StartTime, HitObject.StartTime); Height = HitObjectContainer.LengthAtTime(HitObject.StartTime, HitObject.EndTime) + tail.DrawHeight; } + protected override void OnDirectionChanged(ValueChangedEvent direction) + { + Origin = direction.NewValue == ScrollingDirection.Down ? Anchor.BottomCentre : Anchor.TopCentre; + + foreach (var child in InternalChildren) + child.Anchor = Origin; + + head.Scale = tail.Scale = body.Scale = new Vector2(1, direction.NewValue == ScrollingDirection.Down ? 1 : -1); + } + public override Quad SelectionQuad => ScreenSpaceDrawQuad; public override Vector2 ScreenSpaceSelectionPoint => head.ScreenSpaceDrawQuad.Centre; diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs index 5e0512b5dc..a68bd5d6d6 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs @@ -15,7 +15,7 @@ using osuTK.Input; namespace osu.Game.Rulesets.Mania.Edit.Blueprints { - public abstract partial class ManiaPlacementBlueprint : PlacementBlueprint + public abstract partial class ManiaPlacementBlueprint : HitObjectPlacementBlueprint where T : ManiaHitObject { protected new T HitObject => (T)base.HitObject; diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs index c645ddd98d..4bb9d5f5c1 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs @@ -37,16 +37,10 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints protected override void LoadComplete() { base.LoadComplete(); - directionBindable.BindValueChanged(onDirectionChanged, true); + directionBindable.BindValueChanged(OnDirectionChanged, true); } - private void onDirectionChanged(ValueChangedEvent direction) - { - var anchor = direction.NewValue == ScrollingDirection.Up ? Anchor.TopCentre : Anchor.BottomCentre; - Anchor = Origin = anchor; - foreach (var child in InternalChildren) - child.Anchor = child.Origin = anchor; - } + protected abstract void OnDirectionChanged(ValueChangedEvent direction); protected override void Update() { diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/NotePlacementBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/NotePlacementBlueprint.cs index b3ec3ef3e4..422215db57 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/NotePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/NotePlacementBlueprint.cs @@ -1,10 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; +using osu.Game.Graphics; using osu.Game.Rulesets.Edit; -using osu.Game.Rulesets.Mania.Edit.Blueprints.Components; using osu.Game.Rulesets.Mania.Objects; using osuTK.Input; @@ -12,14 +14,25 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints { public partial class NotePlacementBlueprint : ManiaPlacementBlueprint { - private readonly EditNotePiece piece; + private Circle piece = null!; public NotePlacementBlueprint() : base(new Note()) { - RelativeSizeAxes = Axes.Both; + } - InternalChild = piece = new EditNotePiece { Origin = Anchor.Centre }; + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + RelativeSizeAxes = Axes.Both; + Masking = true; + + InternalChild = piece = new Circle + { + Origin = Anchor.Centre, + Colour = colours.Yellow, + Height = 10 + }; } public override void UpdateTimeAndPosition(SnapResult result) diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/NoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/NoteSelectionBlueprint.cs index 01c7bd502a..3476f91568 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/NoteSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/NoteSelectionBlueprint.cs @@ -1,18 +1,42 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Rulesets.Mania.Edit.Blueprints.Components; using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK; namespace osu.Game.Rulesets.Mania.Edit.Blueprints { public partial class NoteSelectionBlueprint : ManiaSelectionBlueprint { + private readonly EditNotePiece notePiece; + public NoteSelectionBlueprint(Note note) : base(note) { - AddInternal(new EditNotePiece { RelativeSizeAxes = Axes.X }); + Anchor = Anchor.BottomCentre; + Origin = Anchor.BottomCentre; + AddInternal(notePiece = new EditNotePiece + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + }); + } + + protected override void Update() + { + base.Update(); + + notePiece.Height = DrawableObject.DrawHeight; + } + + protected override void OnDirectionChanged(ValueChangedEvent direction) + { + notePiece.Scale = new Vector2(1, direction.NewValue == ScrollingDirection.Down ? 1 : -1); } } } diff --git a/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditorRuleset.cs b/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditorRuleset.cs index 8d34373f82..4c4cf519ce 100644 --- a/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditorRuleset.cs +++ b/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditorRuleset.cs @@ -6,6 +6,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Rulesets.Mania.Configuration; using osu.Game.Rulesets.Mania.UI; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; @@ -18,6 +19,8 @@ namespace osu.Game.Rulesets.Mania.Edit { public BindableBool ShowSpeedChanges { get; } = new BindableBool(); + public double? TimelineTimeRange { get; set; } + public new IScrollingInfo ScrollingInfo => base.ScrollingInfo; public DrawableManiaEditorRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList? mods) @@ -38,5 +41,11 @@ namespace osu.Game.Rulesets.Mania.Edit Origin = Anchor.Centre, Size = Vector2.One }; + + protected override void Update() + { + TargetTimeRange = TimelineTimeRange == null || ShowSpeedChanges.Value ? ComputeScrollTime(Config.Get(ManiaRulesetSetting.ScrollSpeed)) : TimelineTimeRange.Value; + base.Update(); + } } } diff --git a/osu.Game.Rulesets.Mania/Edit/HoldNoteCompositionTool.cs b/osu.Game.Rulesets.Mania/Edit/HoldNoteCompositionTool.cs index 99e1ce04b1..592f8d9af7 100644 --- a/osu.Game.Rulesets.Mania/Edit/HoldNoteCompositionTool.cs +++ b/osu.Game.Rulesets.Mania/Edit/HoldNoteCompositionTool.cs @@ -9,7 +9,7 @@ using osu.Game.Rulesets.Mania.Edit.Blueprints; namespace osu.Game.Rulesets.Mania.Edit { - public class HoldNoteCompositionTool : HitObjectCompositionTool + public class HoldNoteCompositionTool : CompositionTool { public HoldNoteCompositionTool() : base("Hold") @@ -18,6 +18,6 @@ namespace osu.Game.Rulesets.Mania.Edit public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders); - public override PlacementBlueprint CreatePlacementBlueprint() => new HoldNotePlacementBlueprint(); + public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new HoldNotePlacementBlueprint(); } } diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs b/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs index 967cdb0e54..926a4b2736 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs @@ -1,11 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; +using osu.Framework.Allocation; using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Tools; @@ -14,6 +13,7 @@ using osu.Game.Rulesets.Mania.UI; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Compose.Components; using osuTK; @@ -21,14 +21,17 @@ namespace osu.Game.Rulesets.Mania.Edit { public partial class ManiaHitObjectComposer : ScrollingHitObjectComposer { - private DrawableManiaEditorRuleset drawableRuleset; + private DrawableManiaEditorRuleset drawableRuleset = null!; + + [Resolved] + private EditorScreenWithTimeline? screenWithTimeline { get; set; } public ManiaHitObjectComposer(Ruleset ruleset) : base(ruleset) { } - public new ManiaPlayfield Playfield => ((ManiaPlayfield)drawableRuleset.Playfield); + public new ManiaPlayfield Playfield => drawableRuleset.Playfield; public IScrollingInfo ScrollingInfo => drawableRuleset.ScrollingInfo; @@ -43,7 +46,7 @@ namespace osu.Game.Rulesets.Mania.Edit protected override BeatSnapGrid CreateBeatSnapGrid() => new ManiaBeatSnapGrid(); - protected override IReadOnlyList CompositionTools => new HitObjectCompositionTool[] + protected override IReadOnlyList CompositionTools => new CompositionTool[] { new NoteCompositionTool(), new HoldNoteCompositionTool() @@ -72,7 +75,7 @@ namespace osu.Game.Rulesets.Mania.Edit if (!double.TryParse(split[0], out double time) || !int.TryParse(split[1], out int column)) continue; - ManiaHitObject current = remainingHitObjects.FirstOrDefault(h => h.StartTime == time && h.Column == column); + ManiaHitObject? current = remainingHitObjects.FirstOrDefault(h => h.StartTime == time && h.Column == column); if (current == null) continue; @@ -83,5 +86,13 @@ namespace osu.Game.Rulesets.Mania.Edit remainingHitObjects = remainingHitObjects.Where(h => h != current && h.StartTime >= current.StartTime).ToList(); } } + + protected override void Update() + { + base.Update(); + + if (screenWithTimeline?.TimelineArea.Timeline != null) + drawableRuleset.TimelineTimeRange = EditorClock.TrackLength / screenWithTimeline.TimelineArea.Timeline.CurrentZoom.Value / 2; + } } } diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs b/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs index 8fdbada04f..74e616ac3f 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Graphics; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Objects; @@ -16,6 +17,16 @@ namespace osu.Game.Rulesets.Mania.Edit [Resolved] private HitObjectComposer composer { get; set; } = null!; + protected override void OnSelectionChanged() + { + base.OnSelectionChanged(); + + var selectedObjects = SelectedItems.OfType().ToArray(); + + SelectionBox.CanFlipX = canFlipX(selectedObjects); + SelectionBox.CanFlipY = canFlipY(selectedObjects); + } + public override bool HandleMovement(MoveSelectionEvent moveEvent) { var hitObjectBlueprint = (HitObjectSelectionBlueprint)moveEvent.Blueprint; @@ -26,6 +37,57 @@ namespace osu.Game.Rulesets.Mania.Edit return true; } + public override bool HandleFlip(Direction direction, bool flipOverOrigin) + { + var selectedObjects = SelectedItems.OfType().ToArray(); + var maniaPlayfield = ((ManiaHitObjectComposer)composer).Playfield; + + if (selectedObjects.Length == 0) + return false; + + switch (direction) + { + case Direction.Horizontal: + if (!canFlipX(selectedObjects)) + return false; + + int firstColumn = flipOverOrigin ? 0 : selectedObjects.Min(ho => ho.Column); + int lastColumn = flipOverOrigin ? (int)EditorBeatmap.BeatmapInfo.Difficulty.CircleSize - 1 : selectedObjects.Max(ho => ho.Column); + + performOnSelection(maniaObject => + { + maniaPlayfield.Remove(maniaObject); + maniaObject.Column = firstColumn + (lastColumn - maniaObject.Column); + maniaPlayfield.Add(maniaObject); + }); + + return true; + + case Direction.Vertical: + if (!canFlipY(selectedObjects)) + return false; + + double selectionStartTime = selectedObjects.Min(ho => ho.StartTime); + double selectionEndTime = selectedObjects.Max(ho => ho.GetEndTime()); + + performOnSelection(hitObject => + { + hitObject.StartTime = selectionStartTime + (selectionEndTime - hitObject.GetEndTime()); + }); + + return true; + + default: + throw new ArgumentOutOfRangeException(nameof(direction), direction, "Cannot flip over the supplied direction."); + } + } + + private static bool canFlipX(ManiaHitObject[] selectedObjects) + => selectedObjects.Select(ho => ho.Column).Distinct().Count() > 1; + + private static bool canFlipY(ManiaHitObject[] selectedObjects) + => selectedObjects.Length > 1 && selectedObjects.Min(ho => ho.StartTime) < selectedObjects.Max(ho => ho.GetEndTime()); + private void performColumnMovement(int lastColumn, MoveSelectionEvent moveEvent) { var maniaPlayfield = ((ManiaHitObjectComposer)composer).Playfield; @@ -41,8 +103,10 @@ namespace osu.Game.Rulesets.Mania.Edit int minColumn = int.MaxValue; int maxColumn = int.MinValue; + var selectedObjects = EditorBeatmap.SelectedHitObjects.OfType().ToArray(); + // find min/max in an initial pass before actually performing the movement. - foreach (var obj in EditorBeatmap.SelectedHitObjects.OfType()) + foreach (var obj in selectedObjects) { if (obj.Column < minColumn) minColumn = obj.Column; @@ -52,12 +116,26 @@ namespace osu.Game.Rulesets.Mania.Edit columnDelta = Math.Clamp(columnDelta, -minColumn, maniaPlayfield.TotalColumns - 1 - maxColumn); - EditorBeatmap.PerformOnSelection(h => + performOnSelection(h => { maniaPlayfield.Remove(h); - ((ManiaHitObject)h).Column += columnDelta; + h.Column += columnDelta; maniaPlayfield.Add(h); }); } + + private void performOnSelection(Action action) + { + var selectedObjects = EditorBeatmap.SelectedHitObjects.OfType().ToArray(); + + EditorBeatmap.PerformOnSelection(h => action.Invoke((ManiaHitObject)h)); + + // `HitObjectUsageEventBuffer`'s usage transferal flows and the playfield's `SetKeepAlive()` functionality do not combine well with mania's usage patterns, + // leading to selections being sometimes partially dropped if some of the objects being moved are off screen + // (check blame for detailed explanation). + // thus, ensure that selection is preserved manually. + EditorBeatmap.SelectedHitObjects.Clear(); + EditorBeatmap.SelectedHitObjects.AddRange(selectedObjects); + } } } diff --git a/osu.Game.Rulesets.Mania/Edit/NoteCompositionTool.cs b/osu.Game.Rulesets.Mania/Edit/NoteCompositionTool.cs index 08ee05ad3f..2e54d63525 100644 --- a/osu.Game.Rulesets.Mania/Edit/NoteCompositionTool.cs +++ b/osu.Game.Rulesets.Mania/Edit/NoteCompositionTool.cs @@ -10,7 +10,7 @@ using osu.Game.Rulesets.Mania.Objects; namespace osu.Game.Rulesets.Mania.Edit { - public class NoteCompositionTool : HitObjectCompositionTool + public class NoteCompositionTool : CompositionTool { public NoteCompositionTool() : base(nameof(Note)) @@ -19,6 +19,6 @@ namespace osu.Game.Rulesets.Mania.Edit public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles); - public override PlacementBlueprint CreatePlacementBlueprint() => new NotePlacementBlueprint(); + public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new NotePlacementBlueprint(); } } diff --git a/osu.Game.Rulesets.Mania/Edit/Setup/ManiaDifficultySection.cs b/osu.Game.Rulesets.Mania/Edit/Setup/ManiaDifficultySection.cs index 4f983debea..a23988362a 100644 --- a/osu.Game.Rulesets.Mania/Edit/Setup/ManiaDifficultySection.cs +++ b/osu.Game.Rulesets.Mania/Edit/Setup/ManiaDifficultySection.cs @@ -3,20 +3,168 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Localisation; using osu.Game.Resources.Localisation.Web; +using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Setup; namespace osu.Game.Rulesets.Mania.Edit.Setup { - public partial class ManiaDifficultySection : DifficultySection + public partial class ManiaDifficultySection : SetupSection { + public override LocalisableString Title => EditorSetupStrings.DifficultyHeader; + + private FormSliderBar keyCountSlider { get; set; } = null!; + private FormCheckBox specialStyle { get; set; } = null!; + private FormSliderBar healthDrainSlider { get; set; } = null!; + private FormSliderBar overallDifficultySlider { get; set; } = null!; + private FormSliderBar baseVelocitySlider { get; set; } = null!; + private FormSliderBar tickRateSlider { get; set; } = null!; + + [Resolved] + private Editor? editor { get; set; } + + [Resolved] + private IEditorChangeHandler? changeHandler { get; set; } + [BackgroundDependencyLoader] private void load() { - CircleSizeSlider.Label = BeatmapsetsStrings.ShowStatsCsMania; - CircleSizeSlider.Description = "The number of columns in the beatmap"; - if (CircleSizeSlider.Current is BindableNumber circleSizeFloat) - circleSizeFloat.Precision = 1; + Children = new Drawable[] + { + keyCountSlider = new FormSliderBar + { + Caption = BeatmapsetsStrings.ShowStatsCsMania, + HintText = "The number of columns in the beatmap", + Current = new BindableFloat(Beatmap.Difficulty.CircleSize) + { + Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, + MinValue = 0, + MaxValue = 10, + Precision = 1, + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, + }, + specialStyle = new FormCheckBox + { + Caption = "Use special (N+1) style", + HintText = "Changes one column to act as a classic \"scratch\" or \"special\" column, which can be moved around by the user's skin (to the left/right/centre). Generally used in 6K (5+1) or 8K (7+1) configurations.", + Current = { Value = Beatmap.BeatmapInfo.SpecialStyle } + }, + healthDrainSlider = new FormSliderBar + { + Caption = BeatmapsetsStrings.ShowStatsDrain, + HintText = EditorSetupStrings.DrainRateDescription, + Current = new BindableFloat(Beatmap.Difficulty.DrainRate) + { + Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, + MinValue = 0, + MaxValue = 10, + Precision = 0.1f, + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, + }, + overallDifficultySlider = new FormSliderBar + { + Caption = BeatmapsetsStrings.ShowStatsAccuracy, + HintText = EditorSetupStrings.OverallDifficultyDescription, + Current = new BindableFloat(Beatmap.Difficulty.OverallDifficulty) + { + Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, + MinValue = 0, + MaxValue = 10, + Precision = 0.1f, + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, + }, + baseVelocitySlider = new FormSliderBar + { + Caption = EditorSetupStrings.BaseVelocity, + HintText = EditorSetupStrings.BaseVelocityDescription, + Current = new BindableDouble(Beatmap.Difficulty.SliderMultiplier) + { + Default = 1.4, + MinValue = 0.4, + MaxValue = 3.6, + Precision = 0.01f, + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, + }, + tickRateSlider = new FormSliderBar + { + Caption = EditorSetupStrings.TickRate, + HintText = EditorSetupStrings.TickRateDescription, + Current = new BindableDouble(Beatmap.Difficulty.SliderTickRate) + { + Default = 1, + MinValue = 1, + MaxValue = 4, + Precision = 1, + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, + }, + }; + + keyCountSlider.Current.BindValueChanged(updateKeyCount); + healthDrainSlider.Current.BindValueChanged(_ => updateValues()); + overallDifficultySlider.Current.BindValueChanged(_ => updateValues()); + baseVelocitySlider.Current.BindValueChanged(_ => updateValues()); + tickRateSlider.Current.BindValueChanged(_ => updateValues()); + } + + private bool updatingKeyCount; + + private void updateKeyCount(ValueChangedEvent keyCount) + { + if (updatingKeyCount) return; + + updateValues(); + + if (editor == null) return; + + updatingKeyCount = true; + + editor.Reload().ContinueWith(t => + { + if (!t.GetResultSafely()) + { + Schedule(() => + { + changeHandler!.RestoreState(-1); + Beatmap.Difficulty.CircleSize = keyCountSlider.Current.Value = keyCount.OldValue; + updatingKeyCount = false; + }); + } + else + { + updatingKeyCount = false; + } + }); + } + + private void updateValues() + { + // for now, update these on commit rather than making BeatmapMetadata bindables. + // after switching database engines we can reconsider if switching to bindables is a good direction. + Beatmap.Difficulty.CircleSize = keyCountSlider.Current.Value; + Beatmap.BeatmapInfo.SpecialStyle = specialStyle.Current.Value; + Beatmap.Difficulty.DrainRate = healthDrainSlider.Current.Value; + Beatmap.Difficulty.OverallDifficulty = overallDifficultySlider.Current.Value; + Beatmap.Difficulty.SliderMultiplier = baseVelocitySlider.Current.Value; + Beatmap.Difficulty.SliderTickRate = tickRateSlider.Current.Value; + + Beatmap.UpdateAllHitObjects(); + Beatmap.SaveState(); } } } diff --git a/osu.Game.Rulesets.Mania/Edit/Setup/ManiaSetupSection.cs b/osu.Game.Rulesets.Mania/Edit/Setup/ManiaSetupSection.cs deleted file mode 100644 index d5a9a311bc..0000000000 --- a/osu.Game.Rulesets.Mania/Edit/Setup/ManiaSetupSection.cs +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Game.Graphics.UserInterfaceV2; -using osu.Game.Screens.Edit.Setup; - -namespace osu.Game.Rulesets.Mania.Edit.Setup -{ - public partial class ManiaSetupSection : RulesetSetupSection - { - private LabelledSwitchButton specialStyle; - - public ManiaSetupSection() - : base(new ManiaRuleset().RulesetInfo) - { - } - - [BackgroundDependencyLoader] - private void load() - { - Children = new Drawable[] - { - specialStyle = new LabelledSwitchButton - { - Label = "Use special (N+1) style", - Description = "Changes one column to act as a classic \"scratch\" or \"special\" column, which can be moved around by the user's skin (to the left/right/centre). Generally used in 6K (5+1) or 8K (7+1) configurations.", - Current = { Value = Beatmap.BeatmapInfo.SpecialStyle } - } - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - specialStyle.Current.BindValueChanged(_ => updateBeatmap()); - } - - private void updateBeatmap() - { - Beatmap.BeatmapInfo.SpecialStyle = specialStyle.Current.Value; - Beatmap.SaveState(); - } - } -} diff --git a/osu.Game.Rulesets.Mania/ManiaInputManager.cs b/osu.Game.Rulesets.Mania/ManiaInputManager.cs index a41e72660b..36ccf68d76 100644 --- a/osu.Game.Rulesets.Mania/ManiaInputManager.cs +++ b/osu.Game.Rulesets.Mania/ManiaInputManager.cs @@ -19,16 +19,8 @@ namespace osu.Game.Rulesets.Mania public enum ManiaAction { - [Description("Special 1")] - Special1 = 1, - - [Description("Special 2")] - Special2, - - // This offsets the start value of normal keys in-case we add more special keys - // above at a later time, without breaking replays/configs. [Description("Key 1")] - Key1 = 10, + Key1, [Description("Key 2")] Key2, diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs index b5614e2b56..cdc7b0a951 100644 --- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; @@ -89,79 +88,79 @@ namespace osu.Game.Rulesets.Mania public override IEnumerable ConvertFromLegacyMods(LegacyMods mods) { - if (mods.HasFlagFast(LegacyMods.Nightcore)) + if (mods.HasFlag(LegacyMods.Nightcore)) yield return new ManiaModNightcore(); - else if (mods.HasFlagFast(LegacyMods.DoubleTime)) + else if (mods.HasFlag(LegacyMods.DoubleTime)) yield return new ManiaModDoubleTime(); - if (mods.HasFlagFast(LegacyMods.Perfect)) + if (mods.HasFlag(LegacyMods.Perfect)) yield return new ManiaModPerfect(); - else if (mods.HasFlagFast(LegacyMods.SuddenDeath)) + else if (mods.HasFlag(LegacyMods.SuddenDeath)) yield return new ManiaModSuddenDeath(); - if (mods.HasFlagFast(LegacyMods.Cinema)) + if (mods.HasFlag(LegacyMods.Cinema)) yield return new ManiaModCinema(); - else if (mods.HasFlagFast(LegacyMods.Autoplay)) + else if (mods.HasFlag(LegacyMods.Autoplay)) yield return new ManiaModAutoplay(); - if (mods.HasFlagFast(LegacyMods.Easy)) + if (mods.HasFlag(LegacyMods.Easy)) yield return new ManiaModEasy(); - if (mods.HasFlagFast(LegacyMods.FadeIn)) + if (mods.HasFlag(LegacyMods.FadeIn)) yield return new ManiaModFadeIn(); - if (mods.HasFlagFast(LegacyMods.Flashlight)) + if (mods.HasFlag(LegacyMods.Flashlight)) yield return new ManiaModFlashlight(); - if (mods.HasFlagFast(LegacyMods.HalfTime)) + if (mods.HasFlag(LegacyMods.HalfTime)) yield return new ManiaModHalfTime(); - if (mods.HasFlagFast(LegacyMods.HardRock)) + if (mods.HasFlag(LegacyMods.HardRock)) yield return new ManiaModHardRock(); - if (mods.HasFlagFast(LegacyMods.Hidden)) + if (mods.HasFlag(LegacyMods.Hidden)) yield return new ManiaModHidden(); - if (mods.HasFlagFast(LegacyMods.Key1)) + if (mods.HasFlag(LegacyMods.Key1)) yield return new ManiaModKey1(); - if (mods.HasFlagFast(LegacyMods.Key2)) + if (mods.HasFlag(LegacyMods.Key2)) yield return new ManiaModKey2(); - if (mods.HasFlagFast(LegacyMods.Key3)) + if (mods.HasFlag(LegacyMods.Key3)) yield return new ManiaModKey3(); - if (mods.HasFlagFast(LegacyMods.Key4)) + if (mods.HasFlag(LegacyMods.Key4)) yield return new ManiaModKey4(); - if (mods.HasFlagFast(LegacyMods.Key5)) + if (mods.HasFlag(LegacyMods.Key5)) yield return new ManiaModKey5(); - if (mods.HasFlagFast(LegacyMods.Key6)) + if (mods.HasFlag(LegacyMods.Key6)) yield return new ManiaModKey6(); - if (mods.HasFlagFast(LegacyMods.Key7)) + if (mods.HasFlag(LegacyMods.Key7)) yield return new ManiaModKey7(); - if (mods.HasFlagFast(LegacyMods.Key8)) + if (mods.HasFlag(LegacyMods.Key8)) yield return new ManiaModKey8(); - if (mods.HasFlagFast(LegacyMods.Key9)) + if (mods.HasFlag(LegacyMods.Key9)) yield return new ManiaModKey9(); - if (mods.HasFlagFast(LegacyMods.KeyCoop)) + if (mods.HasFlag(LegacyMods.KeyCoop)) yield return new ManiaModDualStages(); - if (mods.HasFlagFast(LegacyMods.NoFail)) + if (mods.HasFlag(LegacyMods.NoFail)) yield return new ManiaModNoFail(); - if (mods.HasFlagFast(LegacyMods.Random)) + if (mods.HasFlag(LegacyMods.Random)) yield return new ManiaModRandom(); - if (mods.HasFlagFast(LegacyMods.Mirror)) + if (mods.HasFlag(LegacyMods.Mirror)) yield return new ManiaModMirror(); - if (mods.HasFlagFast(LegacyMods.ScoreV2)) + if (mods.HasFlag(LegacyMods.ScoreV2)) yield return new ModScoreV2(); } @@ -241,6 +240,7 @@ namespace osu.Game.Rulesets.Mania new ManiaModEasy(), new ManiaModNoFail(), new MultiMod(new ManiaModHalfTime(), new ManiaModDaycore()), + new ManiaModNoRelease(), }; case ModType.DifficultyIncrease: @@ -419,9 +419,13 @@ namespace osu.Game.Rulesets.Mania return new ManiaFilterCriteria(); } - public override RulesetSetupSection CreateEditorSetupSection() => new ManiaSetupSection(); - - public override DifficultySection CreateEditorDifficultySection() => new ManiaDifficultySection(); + public override IEnumerable CreateEditorSetupSections() => + [ + new MetadataSection(), + new ManiaDifficultySection(), + new ResourcesSection(), + new DesignSection(), + ]; public int GetKeyCount(IBeatmapInfo beatmapInfo, IReadOnlyList? mods = null) => ManiaBeatmapConverter.GetColumnCount(LegacyBeatmapConversionDifficultyInfo.FromBeatmapInfo(beatmapInfo), mods); diff --git a/osu.Game.Rulesets.Mania/ManiaSkinComponentLookup.cs b/osu.Game.Rulesets.Mania/ManiaSkinComponentLookup.cs index 046d1c5b34..f3613eff99 100644 --- a/osu.Game.Rulesets.Mania/ManiaSkinComponentLookup.cs +++ b/osu.Game.Rulesets.Mania/ManiaSkinComponentLookup.cs @@ -5,7 +5,7 @@ using osu.Game.Skinning; namespace osu.Game.Rulesets.Mania { - public class ManiaSkinComponentLookup : GameplaySkinComponentLookup + public class ManiaSkinComponentLookup : SkinComponentLookup { /// /// Creates a new . diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModHoldOff.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModHoldOff.cs index 4e6cc4f1d6..eba0b2effe 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModHoldOff.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModHoldOff.cs @@ -27,7 +27,7 @@ namespace osu.Game.Rulesets.Mania.Mods public override ModType Type => ModType.Conversion; - public override Type[] IncompatibleMods => new[] { typeof(ManiaModInvert) }; + public override Type[] IncompatibleMods => new[] { typeof(ManiaModInvert), typeof(ManiaModNoRelease) }; public void ApplyToBeatmap(IBeatmap beatmap) { diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModNoRelease.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModNoRelease.cs new file mode 100644 index 0000000000..b5490aa950 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModNoRelease.cs @@ -0,0 +1,110 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using System.Threading; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Mania.Mods +{ + public partial class ManiaModNoRelease : Mod, IApplicableAfterBeatmapConversion, IApplicableToDrawableRuleset + { + public override string Name => "No Release"; + + public override string Acronym => "NR"; + + public override LocalisableString Description => "No more timing the end of hold notes."; + + public override double ScoreMultiplier => 0.9; + + public override ModType Type => ModType.DifficultyReduction; + + public override Type[] IncompatibleMods => new[] { typeof(ManiaModHoldOff) }; + + public void ApplyToBeatmap(IBeatmap beatmap) + { + var maniaBeatmap = (ManiaBeatmap)beatmap; + var hitObjects = maniaBeatmap.HitObjects.Select(obj => + { + if (obj is HoldNote hold) + return new NoReleaseHoldNote(hold); + + return obj; + }).ToList(); + + maniaBeatmap.HitObjects = hitObjects; + } + + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + var maniaRuleset = (DrawableManiaRuleset)drawableRuleset; + + foreach (var stage in maniaRuleset.Playfield.Stages) + { + foreach (var column in stage.Columns) + { + column.RegisterPool(10, 50); + } + } + } + + private partial class NoReleaseDrawableHoldNoteTail : DrawableHoldNoteTail + { + protected override void CheckForResult(bool userTriggered, double timeOffset) + { + // apply perfect once the tail is reached + if (HoldNote.HoldStartTime != null && timeOffset >= 0) + ApplyResult(GetCappedResult(HitResult.Perfect)); + else + base.CheckForResult(userTriggered, timeOffset); + } + } + + private class NoReleaseTailNote : TailNote + { + } + + private class NoReleaseHoldNote : HoldNote + { + public NoReleaseHoldNote(HoldNote hold) + { + StartTime = hold.StartTime; + Duration = hold.Duration; + Column = hold.Column; + NodeSamples = hold.NodeSamples; + } + + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) + { + AddNested(Head = new HeadNote + { + StartTime = StartTime, + Column = Column, + Samples = GetNodeSamples(0), + }); + + AddNested(Tail = new NoReleaseTailNote + { + StartTime = EndTime, + Column = Column, + Samples = GetNodeSamples((NodeSamples?.Count - 1) ?? 1), + }); + + AddNested(Body = new HoldNoteBody + { + StartTime = StartTime, + Column = Column + }); + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs index 2b55e81788..9c56f0473c 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs @@ -268,11 +268,14 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables ApplyMaxResult(); else MissForcefully(); - } - // Make sure that the hold note is fully judged by giving the body a judgement. - if (Tail.AllJudged && !Body.AllJudged) - Body.TriggerResult(Tail.IsHit); + // Make sure that the hold note is fully judged by giving the body a judgement. + if (!Body.AllJudged) + Body.TriggerResult(Tail.IsHit); + + // Important that this is always called when a result is applied. + endHold(); + } } public override void MissForcefully() diff --git a/osu.Game.Rulesets.Mania/Objects/HoldNote.cs b/osu.Game.Rulesets.Mania/Objects/HoldNote.cs index 3f930a310b..98060dd226 100644 --- a/osu.Game.Rulesets.Mania/Objects/HoldNote.cs +++ b/osu.Game.Rulesets.Mania/Objects/HoldNote.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Threading; using osu.Game.Audio; using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Scoring; @@ -72,18 +73,18 @@ namespace osu.Game.Rulesets.Mania.Objects /// /// The head note of the hold. /// - public HeadNote Head { get; private set; } + public HeadNote Head { get; protected set; } /// /// The tail note of the hold. /// - public TailNote Tail { get; private set; } + public TailNote Tail { get; protected set; } /// /// The body of the hold. /// This is an invisible and silent object that tracks the holding state of the . /// - public HoldNoteBody Body { get; private set; } + public HoldNoteBody Body { get; protected set; } public override double MaximumJudgementOffset => Tail.MaximumJudgementOffset; @@ -91,6 +92,10 @@ namespace osu.Game.Rulesets.Mania.Objects { base.CreateNestedHitObjects(cancellationToken); + // Generally node samples will be populated by ManiaBeatmapConverter, but in a case like the editor they may not be. + // Ensure they are set to a sane default here. + NodeSamples ??= CreateDefaultNodeSamples(this); + AddNested(Head = new HeadNote { StartTime = StartTime, @@ -102,7 +107,7 @@ namespace osu.Game.Rulesets.Mania.Objects { StartTime = EndTime, Column = Column, - Samples = GetNodeSamples((NodeSamples?.Count - 1) ?? 1), + Samples = GetNodeSamples(NodeSamples.Count - 1), }); AddNested(Body = new HoldNoteBody @@ -116,7 +121,20 @@ namespace osu.Game.Rulesets.Mania.Objects protected override HitWindows CreateHitWindows() => HitWindows.Empty; - public IList GetNodeSamples(int nodeIndex) => - nodeIndex < NodeSamples?.Count ? NodeSamples[nodeIndex] : Samples; + public IList GetNodeSamples(int nodeIndex) => nodeIndex < NodeSamples?.Count ? NodeSamples[nodeIndex] : Samples; + + /// + /// Create the default note samples for a hold note, based off their main sample. + /// + /// + /// By default, osu!mania beatmaps in only play samples at the start of the hold note. + /// + /// The object to use as a basis for the head sample. + /// Defaults for assigning to . + public static List> CreateDefaultNodeSamples(HitObject obj) => new List> + { + obj.Samples, + new List(), + }; } } diff --git a/osu.Game.Rulesets.Mania/Replays/ManiaAutoGenerator.cs b/osu.Game.Rulesets.Mania/Replays/ManiaAutoGenerator.cs index dd3208bd89..5d4cebca30 100644 --- a/osu.Game.Rulesets.Mania/Replays/ManiaAutoGenerator.cs +++ b/osu.Game.Rulesets.Mania/Replays/ManiaAutoGenerator.cs @@ -17,28 +17,9 @@ namespace osu.Game.Rulesets.Mania.Replays public new ManiaBeatmap Beatmap => (ManiaBeatmap)base.Beatmap; - private readonly ManiaAction[] columnActions; - public ManiaAutoGenerator(ManiaBeatmap beatmap) : base(beatmap) { - columnActions = new ManiaAction[Beatmap.TotalColumns]; - - var normalAction = ManiaAction.Key1; - var specialAction = ManiaAction.Special1; - int totalCounter = 0; - - foreach (var stage in Beatmap.Stages) - { - for (int i = 0; i < stage.Columns; i++) - { - if (stage.IsSpecialColumn(i)) - columnActions[totalCounter] = specialAction++; - else - columnActions[totalCounter] = normalAction++; - totalCounter++; - } - } } protected override void GenerateFrames() @@ -57,11 +38,11 @@ namespace osu.Game.Rulesets.Mania.Replays switch (point) { case HitPoint: - actions.Add(columnActions[point.Column]); + actions.Add(ManiaAction.Key1 + point.Column); break; case ReleasePoint: - actions.Remove(columnActions[point.Column]); + actions.Remove(ManiaAction.Key1 + point.Column); break; } } diff --git a/osu.Game.Rulesets.Mania/Replays/ManiaReplayFrame.cs b/osu.Game.Rulesets.Mania/Replays/ManiaReplayFrame.cs index 29249ba474..f80c442025 100644 --- a/osu.Game.Rulesets.Mania/Replays/ManiaReplayFrame.cs +++ b/osu.Game.Rulesets.Mania/Replays/ManiaReplayFrame.cs @@ -1,11 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Collections.Generic; using osu.Game.Beatmaps; using osu.Game.Replays.Legacy; -using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Replays.Types; @@ -27,118 +25,27 @@ namespace osu.Game.Rulesets.Mania.Replays public void FromLegacy(LegacyReplayFrame legacyFrame, IBeatmap beatmap, ReplayFrame? lastFrame = null) { - var maniaBeatmap = (ManiaBeatmap)beatmap; - - var normalAction = ManiaAction.Key1; - var specialAction = ManiaAction.Special1; - + var action = ManiaAction.Key1; int activeColumns = (int)(legacyFrame.MouseX ?? 0); - int counter = 0; while (activeColumns > 0) { - bool isSpecial = isColumnAtIndexSpecial(maniaBeatmap, counter); - if ((activeColumns & 1) > 0) - Actions.Add(isSpecial ? specialAction : normalAction); + Actions.Add(action); - if (isSpecial) - specialAction++; - else - normalAction++; - - counter++; + action++; activeColumns >>= 1; } } public LegacyReplayFrame ToLegacy(IBeatmap beatmap) { - var maniaBeatmap = (ManiaBeatmap)beatmap; - int keys = 0; foreach (var action in Actions) - { - switch (action) - { - case ManiaAction.Special1: - keys |= 1 << getSpecialColumnIndex(maniaBeatmap, 0); - break; - - case ManiaAction.Special2: - keys |= 1 << getSpecialColumnIndex(maniaBeatmap, 1); - break; - - default: - // the index in lazer, which doesn't include special keys. - int nonSpecialKeyIndex = action - ManiaAction.Key1; - - // the index inclusive of special keys. - int overallIndex = 0; - - // iterate to find the index including special keys. - for (; overallIndex < maniaBeatmap.TotalColumns; overallIndex++) - { - // skip over special columns. - if (isColumnAtIndexSpecial(maniaBeatmap, overallIndex)) - continue; - // found a non-special column to use. - if (nonSpecialKeyIndex == 0) - break; - // found a non-special column but not ours. - nonSpecialKeyIndex--; - } - - keys |= 1 << overallIndex; - break; - } - } + keys |= 1 << (int)action; return new LegacyReplayFrame(Time, keys, null, ReplayButtonState.None); } - - /// - /// Find the overall index (across all stages) for a specified special key. - /// - /// The beatmap. - /// The special key offset (0 is S1). - /// The overall index for the special column. - private int getSpecialColumnIndex(ManiaBeatmap maniaBeatmap, int specialOffset) - { - for (int i = 0; i < maniaBeatmap.TotalColumns; i++) - { - if (isColumnAtIndexSpecial(maniaBeatmap, i)) - { - if (specialOffset == 0) - return i; - - specialOffset--; - } - } - - throw new ArgumentException("Special key index is too high.", nameof(specialOffset)); - } - - /// - /// Check whether the column at an overall index (across all stages) is a special column. - /// - /// The beatmap. - /// The overall index to check. - private bool isColumnAtIndexSpecial(ManiaBeatmap beatmap, int index) - { - foreach (var stage in beatmap.Stages) - { - if (index >= stage.Columns) - { - index -= stage.Columns; - continue; - } - - return stage.IsSpecialColumn(index); - } - - throw new ArgumentException("Column index is too high.", nameof(index)); - } } } diff --git a/osu.Game.Rulesets.Mania/SingleStageVariantGenerator.cs b/osu.Game.Rulesets.Mania/SingleStageVariantGenerator.cs index 44ffeb5ec2..c642da6dc4 100644 --- a/osu.Game.Rulesets.Mania/SingleStageVariantGenerator.cs +++ b/osu.Game.Rulesets.Mania/SingleStageVariantGenerator.cs @@ -34,8 +34,6 @@ namespace osu.Game.Rulesets.Mania LeftKeys = leftKeys, RightKeys = rightKeys, SpecialKey = InputKey.Space, - SpecialAction = ManiaAction.Special1, - NormalActionStart = ManiaAction.Key1, - }.GenerateKeyBindingsFor(variant, out _); + }.GenerateKeyBindingsFor(variant); } } diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonManiaComboCounter.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonManiaComboCounter.cs new file mode 100644 index 0000000000..6626e5f1c7 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonManiaComboCounter.cs @@ -0,0 +1,52 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Screens.Play.HUD; + +namespace osu.Game.Rulesets.Mania.Skinning.Argon +{ + public partial class ArgonManiaComboCounter : ArgonComboCounter + { + protected override bool DisplayXSymbol => false; + + [Resolved] + private IScrollingInfo scrollingInfo { get; set; } = null!; + + private IBindable direction = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + // the logic of flipping the position of the combo counter w.r.t. the direction does not work with "Closest" anchor, + // because it always forces the anchor to be top or bottom based on scrolling direction. + UsesFixedAnchor = true; + + direction = scrollingInfo.Direction.GetBoundCopy(); + direction.BindValueChanged(_ => updateAnchor()); + + // two schedules are required so that updateAnchor is executed in the next frame, + // which is when the combo counter receives its Y position by the default layout in ArgonManiaSkinTransformer. + Schedule(() => Schedule(updateAnchor)); + } + + private void updateAnchor() + { + // if the anchor isn't a vertical center, set top or bottom anchor based on scroll direction + if (Anchor.HasFlag(Anchor.y1)) + return; + + Anchor &= ~(Anchor.y0 | Anchor.y2); + Anchor |= direction.Value == ScrollingDirection.Up ? Anchor.y2 : Anchor.y0; + + // change the sign of the Y coordinate in line with the scrolling direction. + // i.e. if the user changes direction from down to up, the anchor is changed from top to bottom, and the Y is flipped from positive to negative here. + Y = Math.Abs(Y) * (direction.Value == ScrollingDirection.Up ? -1 : 1); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs index 7f6540e7b5..afccb2e568 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs @@ -2,8 +2,10 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Scoring; @@ -26,7 +28,34 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon { switch (lookup) { - case GameplaySkinComponentLookup resultComponent: + case GlobalSkinnableContainerLookup containerLookup: + // Only handle per ruleset defaults here. + if (containerLookup.Ruleset == null) + return base.GetDrawableComponent(lookup); + + switch (containerLookup.Lookup) + { + case GlobalSkinnableContainers.MainHUDComponents: + return new DefaultSkinComponentsContainer(container => + { + var combo = container.ChildrenOfType().FirstOrDefault(); + + if (combo != null) + { + combo.ShowLabel.Value = false; + combo.Anchor = Anchor.TopCentre; + combo.Origin = Anchor.Centre; + combo.Y = 200; + } + }) + { + new ArgonManiaComboCounter(), + }; + } + + return null; + + case SkinComponentLookup resultComponent: // This should eventually be moved to a skin setting, when supported. if (Skin is ArgonProSkin && resultComponent.Component >= HitResult.Great) return Drawable.Empty(); diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs index a8200e0144..6de0752671 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs @@ -65,11 +65,8 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy if (tmp is IFramedAnimation tmpAnimation && tmpAnimation.FrameCount > 0) frameLength = Math.Max(1000 / 60.0, 170.0 / tmpAnimation.FrameCount); - light = skin.GetAnimation(lightImage, true, true, frameLength: frameLength).With(d => + light = skin.GetAnimation(lightImage, true, true, frameLength: frameLength)?.With(d => { - if (d == null) - return; - d.Origin = Anchor.Centre; d.Blending = BlendingParameters.Additive; d.Scale = new Vector2(lightScale); @@ -91,11 +88,8 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy direction.BindTo(scrollingInfo.Direction); isHitting.BindTo(holdNote.IsHitting); - bodySprite = skin.GetAnimation(imageName, wrapMode, wrapMode, true, true, frameLength: 30).With(d => + bodySprite = skin.GetAnimation(imageName, wrapMode, wrapMode, true, true, frameLength: 30)?.With(d => { - if (d == null) - return; - if (d is TextureAnimation animation) animation.IsPlaying = false; @@ -140,10 +134,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy private void onIsHittingChanged(ValueChangedEvent isHitting) { if (bodySprite is TextureAnimation bodyAnimation) - { - bodyAnimation.GotoFrame(0); bodyAnimation.IsPlaying = isHitting.NewValue; - } if (lightContainer == null) return; @@ -219,6 +210,9 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy { base.Update(); + if (!isHitting.Value) + (bodySprite as TextureAnimation)?.GotoFrame(0); + if (holdNote.Body.HasHoldBreak) missFadeTime.Value = holdNote.Body.Result.TimeAbsolute; @@ -245,7 +239,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy // i dunno this looks about right?? // the guard against zero draw height is intended for zero-length hold notes. yes, such cases have been spotted in the wild. if (sprite.DrawHeight > 0) - bodySprite.Scale = new Vector2(1, scaleDirection * 32800 / sprite.DrawHeight); + bodySprite.Scale = new Vector2(1, scaleDirection * MathF.Max(1, 32800 / sprite.DrawHeight)); } break; diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyHitExplosion.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyHitExplosion.cs index 1ec218644c..95b00e32ea 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyHitExplosion.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyHitExplosion.cs @@ -43,11 +43,8 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy if (tmp is IFramedAnimation tmpAnimation && tmpAnimation.FrameCount > 0) frameLength = Math.Max(1000 / 60.0, 170.0 / tmpAnimation.FrameCount); - explosion = skin.GetAnimation(imageName, true, false, frameLength: frameLength).With(d => + explosion = skin.GetAnimation(imageName, true, false, frameLength: frameLength)?.With(d => { - if (d == null) - return; - d.Origin = Anchor.Centre; d.Blending = BlendingParameters.Additive; d.Scale = new Vector2(explosionScale); diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyManiaComboCounter.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyManiaComboCounter.cs new file mode 100644 index 0000000000..889e6326f7 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyManiaComboCounter.cs @@ -0,0 +1,194 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Globalization; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Legacy +{ + public partial class LegacyManiaComboCounter : CompositeDrawable, ISerialisableDrawable + { + public bool UsesFixedAnchor { get; set; } + + public Bindable Current { get; } = new BindableInt { MinValue = 0 }; + + /// + /// Value shown at the current moment. + /// + public virtual int DisplayedCount + { + get => displayedCount; + private set + { + if (displayedCount.Equals(value)) + return; + + displayedCountText.FadeTo(value == 0 ? 0 : 1); + displayedCountText.Text = value.ToString(CultureInfo.InvariantCulture); + counterContainer.Size = displayedCountText.Size; + + displayedCount = value; + } + } + + private int displayedCount; + + private int previousValue; + + private const double fade_out_duration = 100; + private const double rolling_duration = 20; + + private Container counterContainer = null!; + private LegacySpriteText popOutCountText = null!; + private LegacySpriteText displayedCountText = null!; + + [BackgroundDependencyLoader] + private void load(ISkinSource skin, ScoreProcessor scoreProcessor) + { + AutoSizeAxes = Axes.Both; + + InternalChildren = new[] + { + counterContainer = new Container + { + AlwaysPresent = true, + Children = new[] + { + popOutCountText = new LegacySpriteText(LegacyFont.Combo) + { + Alpha = 0, + Blending = BlendingParameters.Additive, + BypassAutoSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.ComboBreakColour)?.Value ?? Color4.Red, + }, + displayedCountText = new LegacySpriteText(LegacyFont.Combo) + { + Alpha = 0, + AlwaysPresent = true, + BypassAutoSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + } + } + }; + + Current.BindTo(scoreProcessor.Combo); + } + + [Resolved] + private IScrollingInfo scrollingInfo { get; set; } = null!; + + private IBindable direction = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + displayedCountText.Text = popOutCountText.Text = Current.Value.ToString(CultureInfo.InvariantCulture); + + Current.BindValueChanged(combo => updateCount(combo.NewValue == 0), true); + + counterContainer.Size = displayedCountText.Size; + + direction = scrollingInfo.Direction.GetBoundCopy(); + direction.BindValueChanged(_ => updateAnchor()); + + // two schedules are required so that updateAnchor is executed in the next frame, + // which is when the combo counter receives its Y position by the default layout in LegacyManiaSkinTransformer. + Schedule(() => Schedule(updateAnchor)); + } + + private void updateAnchor() + { + // if the anchor isn't a vertical center, set top or bottom anchor based on scroll direction + if (Anchor.HasFlag(Anchor.y1)) + return; + + Anchor &= ~(Anchor.y0 | Anchor.y2); + Anchor |= direction.Value == ScrollingDirection.Up ? Anchor.y2 : Anchor.y0; + + // change the sign of the Y coordinate in line with the scrolling direction. + // i.e. if the user changes direction from down to up, the anchor is changed from top to bottom, and the Y is flipped from positive to negative here. + Y = Math.Abs(Y) * (direction.Value == ScrollingDirection.Up ? -1 : 1); + } + + private void updateCount(bool rolling) + { + int prev = previousValue; + previousValue = Current.Value; + + if (!IsLoaded) + return; + + if (!rolling) + { + FinishTransforms(false, nameof(DisplayedCount)); + + if (prev + 1 == Current.Value) + onCountIncrement(); + else + onCountChange(); + } + else + onCountRolling(); + } + + private void onCountIncrement() + { + popOutCountText.Hide(); + + DisplayedCount = Current.Value; + displayedCountText.ScaleTo(new Vector2(1f, 1.4f)) + .ScaleTo(new Vector2(1f), 300, Easing.Out) + .FadeIn(120); + } + + private void onCountChange() + { + popOutCountText.Hide(); + + if (Current.Value == 0) + displayedCountText.FadeOut(); + + DisplayedCount = Current.Value; + + displayedCountText.ScaleTo(1f); + } + + private void onCountRolling() + { + if (DisplayedCount > 0) + { + popOutCountText.Text = DisplayedCount.ToString(CultureInfo.InvariantCulture); + popOutCountText.FadeTo(0.8f).FadeOut(200) + .ScaleTo(1f).ScaleTo(4f, 200); + + displayedCountText.FadeTo(0.5f, 300); + } + + // Hides displayed count if was increasing from 0 to 1 but didn't finish + if (DisplayedCount == 0 && Current.Value == 0) + displayedCountText.FadeOut(fade_out_duration); + + this.TransformTo(nameof(DisplayedCount), Current.Value, getProportionalDuration(DisplayedCount, Current.Value)); + } + + private double getProportionalDuration(int currentValue, int newValue) + { + double difference = currentValue > newValue ? currentValue - newValue : newValue - currentValue; + return difference * rolling_duration; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageBackground.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageBackground.cs index 758c8dd347..71618a4bc3 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageBackground.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageBackground.cs @@ -54,7 +54,8 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy }, columnBackgrounds = new ColumnFlow(stageDefinition) { - RelativeSizeAxes = Axes.Y + RelativeSizeAxes = Axes.Y, + Masking = false, }, new HitTargetInsetContainer { @@ -126,8 +127,8 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy }, new Container { + X = isLastColumn ? -0.16f : 0, Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, RelativeSizeAxes = Axes.Y, Width = rightLineWidth, Scale = new Vector2(0.740f, 1), diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageForeground.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageForeground.cs index 1a47fe5076..680198c1a6 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageForeground.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageForeground.cs @@ -28,13 +28,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy string bottomImage = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.BottomStageImage)?.Value ?? "mania-stage-bottom"; - sprite = skin.GetAnimation(bottomImage, true, true)?.With(d => - { - if (d == null) - return; - - d.Scale = new Vector2(1.6f); - }); + sprite = skin.GetAnimation(bottomImage, true, true)?.With(d => d.Scale = new Vector2(1.6f)); if (sprite != null) InternalChild = sprite; diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs index 73c521b2ed..cb42b2b62a 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs @@ -5,9 +5,11 @@ using System; using System.Collections.Generic; +using System.Linq; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Testing; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mania.Beatmaps; @@ -78,7 +80,37 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy { switch (lookup) { - case GameplaySkinComponentLookup resultComponent: + case GlobalSkinnableContainerLookup containerLookup: + // Modifications for global components. + if (containerLookup.Ruleset == null) + return base.GetDrawableComponent(lookup); + + // we don't have enough assets to display these components (this is especially the case on a "beatmap" skin). + if (!IsProvidingLegacyResources) + return null; + + switch (containerLookup.Lookup) + { + case GlobalSkinnableContainers.MainHUDComponents: + return new DefaultSkinComponentsContainer(container => + { + var combo = container.ChildrenOfType().FirstOrDefault(); + + if (combo != null) + { + combo.Anchor = Anchor.TopCentre; + combo.Origin = Anchor.Centre; + combo.Y = this.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.ComboPosition)?.Value ?? 0; + } + }) + { + new LegacyManiaComboCounter(), + }; + } + + return null; + + case SkinComponentLookup resultComponent: return getResult(resultComponent.Component); case ManiaSkinComponentLookup maniaComponent: diff --git a/osu.Game.Rulesets.Mania/UI/Column.cs b/osu.Game.Rulesets.Mania/UI/Column.cs index 6cd55bb099..c05a8f2a29 100644 --- a/osu.Game.Rulesets.Mania/UI/Column.cs +++ b/osu.Game.Rulesets.Mania/UI/Column.cs @@ -93,8 +93,7 @@ namespace osu.Game.Rulesets.Mania.UI // For input purposes, the background is added at the highest depth, but is then proxied back below all other elements externally // (see `Stage.columnBackgrounds`). BackgroundContainer, - TopLevelContainer, - new ColumnTouchInputArea(this) + TopLevelContainer }; var background = new SkinnableDrawable(new ManiaSkinComponentLookup(ManiaSkinComponents.ColumnBackground), _ => new DefaultColumnBackground()) @@ -181,38 +180,5 @@ namespace osu.Game.Rulesets.Mania.UI public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) // This probably shouldn't exist as is, but the columns in the stage are separated by a 1px border => DrawRectangle.Inflate(new Vector2(Stage.COLUMN_SPACING / 2, 0)).Contains(ToLocalSpace(screenSpacePos)); - - public partial class ColumnTouchInputArea : Drawable - { - private readonly Column column; - - [Resolved(canBeNull: true)] - private ManiaInputManager maniaInputManager { get; set; } - - private KeyBindingContainer keyBindingContainer; - - public ColumnTouchInputArea(Column column) - { - RelativeSizeAxes = Axes.Both; - - this.column = column; - } - - protected override void LoadComplete() - { - keyBindingContainer = maniaInputManager?.KeyBindingContainer; - } - - protected override bool OnTouchDown(TouchDownEvent e) - { - keyBindingContainer?.TriggerPressed(column.Action.Value); - return true; - } - - protected override void OnTouchUp(TouchUpEvent e) - { - keyBindingContainer?.TriggerReleased(column.Action.Value); - } - } } } diff --git a/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs b/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs index 1593e8e76f..5614a13a48 100644 --- a/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs +++ b/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs @@ -3,15 +3,12 @@ #nullable disable -using System; -using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Skinning; using osu.Game.Skinning; -using osuTK; namespace osu.Game.Rulesets.Mania.UI { @@ -31,6 +28,12 @@ namespace osu.Game.Rulesets.Mania.UI private readonly FillFlowContainer> columns; private readonly StageDefinition stageDefinition; + public new bool Masking + { + get => base.Masking; + set => base.Masking = value; + } + public ColumnFlow(StageDefinition stageDefinition) { this.stageDefinition = stageDefinition; @@ -62,12 +65,6 @@ namespace osu.Game.Rulesets.Mania.UI onSkinChanged(); } - protected override void LoadComplete() - { - base.LoadComplete(); - updateMobileSizing(); - } - private void onSkinChanged() { for (int i = 0; i < stageDefinition.Columns; i++) @@ -92,8 +89,6 @@ namespace osu.Game.Rulesets.Mania.UI columns[i].Width = width.Value; } - - updateMobileSizing(); } /// @@ -106,31 +101,6 @@ namespace osu.Game.Rulesets.Mania.UI Content[column] = columns[column].Child = content; } - private void updateMobileSizing() - { - if (!IsLoaded || !RuntimeInfo.IsMobile) - return; - - // GridContainer+CellContainer containing this stage (gets split up for dual stages). - Vector2? containingCell = this.FindClosestParent()?.Parent?.DrawSize; - - // Will be null in tests. - if (containingCell == null) - return; - - float aspectRatio = containingCell.Value.X / containingCell.Value.Y; - - // 2.83 is a mostly arbitrary scale-up (170 / 60, based on original implementation for argon) - float mobileAdjust = 2.83f * Math.Min(1, 7f / stageDefinition.Columns); - // 1.92 is a "reference" mobile screen aspect ratio for phones. - // We should scale it back for cases like tablets which aren't so extreme. - mobileAdjust *= aspectRatio / 1.92f; - - // Best effort until we have better mobile support. - for (int i = 0; i < stageDefinition.Columns; i++) - columns[i].Width *= mobileAdjust; - } - protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs b/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs index 896dfb2b23..9f25a44e21 100644 --- a/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs +++ b/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs @@ -5,22 +5,12 @@ using osu.Framework.Graphics; using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Mania.UI { public partial class DrawableManiaJudgement : DrawableJudgement { - public DrawableManiaJudgement(JudgementResult result, DrawableHitObject judgedObject) - : base(result, judgedObject) - { - } - - public DrawableManiaJudgement() - { - } - protected override Drawable CreateDefaultJudgement(HitResult result) => new DefaultManiaJudgementPiece(result); private partial class DefaultManiaJudgementPiece : DefaultJudgementPiece diff --git a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs index 275b1311de..aed53e157a 100644 --- a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs @@ -8,9 +8,10 @@ using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Extensions.ObjectExtensions; -using osu.Framework.Graphics; using osu.Framework.Input; +using osu.Framework.Platform; using osu.Framework.Threading; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Input.Handlers; @@ -31,6 +32,7 @@ using osu.Game.Skinning; namespace osu.Game.Rulesets.Mania.UI { + [Cached] public partial class DrawableManiaRuleset : DrawableScrollingRuleset { /// @@ -43,7 +45,7 @@ namespace osu.Game.Rulesets.Mania.UI /// public const double MAX_TIME_RANGE = 11485; - protected new ManiaPlayfield Playfield => (ManiaPlayfield)base.Playfield; + public new ManiaPlayfield Playfield => (ManiaPlayfield)base.Playfield; public new ManiaBeatmap Beatmap => (ManiaBeatmap)base.Beatmap; @@ -55,13 +57,18 @@ namespace osu.Game.Rulesets.Mania.UI private readonly Bindable configDirection = new Bindable(); private readonly BindableInt configScrollSpeed = new BindableInt(); - private double smoothTimeRange; + + private double currentTimeRange; + protected double TargetTimeRange; // Stores the current speed adjustment active in gameplay. private readonly Track speedAdjustmentTrack = new TrackVirtual(0); private ISkinSource currentSkin = null!; + [Resolved] + private GameHost gameHost { get; set; } = null!; + public DrawableManiaRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList? mods = null) : base(ruleset, beatmap, mods) { @@ -100,9 +107,11 @@ namespace osu.Game.Rulesets.Mania.UI configDirection.BindValueChanged(direction => Direction.Value = (ScrollingDirection)direction.NewValue, true); Config.BindWith(ManiaRulesetSetting.ScrollSpeed, configScrollSpeed); - configScrollSpeed.BindValueChanged(speed => this.TransformTo(nameof(smoothTimeRange), ComputeScrollTime(speed.NewValue), 200, Easing.OutQuint)); + configScrollSpeed.BindValueChanged(speed => TargetTimeRange = ComputeScrollTime(speed.NewValue)); - TimeRange.Value = smoothTimeRange = ComputeScrollTime(configScrollSpeed.Value); + TimeRange.Value = TargetTimeRange = currentTimeRange = ComputeScrollTime(configScrollSpeed.Value); + + KeyBindingInputManager.Add(new ManiaTouchInputArea()); } protected override void AdjustScrollSpeed(int amount) => configScrollSpeed.Value += amount; @@ -141,7 +150,9 @@ namespace osu.Game.Rulesets.Mania.UI // This scaling factor preserves the scroll speed as the scroll length varies from changes to the hit position. float scale = lengthToHitPosition / length_to_default_hit_position; - TimeRange.Value = smoothTimeRange * speedAdjustmentTrack.AggregateTempo.Value * speedAdjustmentTrack.AggregateFrequency.Value * scale; + // we're intentionally using the game host's update clock here to decouple the time range tween from the gameplay clock (which can be arbitrarily paused, or even rewinding) + currentTimeRange = Interpolation.DampContinuously(currentTimeRange, TargetTimeRange, 50, gameHost.UpdateThread.Clock.ElapsedFrameTime); + TimeRange.Value = currentTimeRange * speedAdjustmentTrack.AggregateTempo.Value * speedAdjustmentTrack.AggregateFrequency.Value * scale; } /// diff --git a/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs b/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs index b3420c49f3..1f388144bd 100644 --- a/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs +++ b/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs @@ -66,13 +66,12 @@ namespace osu.Game.Rulesets.Mania.UI Content = new[] { new Drawable[stageDefinitions.Count] } }); - var normalColumnAction = ManiaAction.Key1; - var specialColumnAction = ManiaAction.Special1; + var columnAction = ManiaAction.Key1; int firstColumnIndex = 0; for (int i = 0; i < stageDefinitions.Count; i++) { - var newStage = new Stage(firstColumnIndex, stageDefinitions[i], ref normalColumnAction, ref specialColumnAction); + var newStage = new Stage(firstColumnIndex, stageDefinitions[i], ref columnAction); playfieldGrid.Content[0][i] = newStage; diff --git a/osu.Game.Rulesets.Mania/UI/ManiaTouchInputArea.cs b/osu.Game.Rulesets.Mania/UI/ManiaTouchInputArea.cs new file mode 100644 index 0000000000..8c4a71cf24 --- /dev/null +++ b/osu.Game.Rulesets.Mania/UI/ManiaTouchInputArea.cs @@ -0,0 +1,199 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Game.Configuration; +using osuTK; + +namespace osu.Game.Rulesets.Mania.UI +{ + /// + /// An overlay that captures and displays osu!mania mouse and touch input. + /// + public partial class ManiaTouchInputArea : VisibilityContainer + { + // visibility state affects our child. we always want to handle input. + public override bool PropagatePositionalInputSubTree => true; + public override bool PropagateNonPositionalInputSubTree => true; + + [SettingSource("Spacing", "The spacing between receptors.")] + public BindableFloat Spacing { get; } = new BindableFloat(10) + { + Precision = 1, + MinValue = 0, + MaxValue = 100, + }; + + [SettingSource("Opacity", "The receptor opacity.")] + public BindableFloat Opacity { get; } = new BindableFloat(1) + { + Precision = 0.1f, + MinValue = 0, + MaxValue = 1 + }; + + [Resolved] + private DrawableManiaRuleset drawableRuleset { get; set; } = null!; + + private GridContainer gridContainer = null!; + + public ManiaTouchInputArea() + { + Anchor = Anchor.BottomCentre; + Origin = Anchor.BottomCentre; + + RelativeSizeAxes = Axes.Both; + Height = 0.5f; + } + + [BackgroundDependencyLoader] + private void load() + { + List receptorGridContent = new List(); + List receptorGridDimensions = new List(); + + bool first = true; + + foreach (var stage in drawableRuleset.Playfield.Stages) + { + foreach (var column in stage.Columns) + { + if (!first) + { + receptorGridContent.Add(new Gutter { Spacing = { BindTarget = Spacing } }); + receptorGridDimensions.Add(new Dimension(GridSizeMode.AutoSize)); + } + + receptorGridContent.Add(new ColumnInputReceptor { Action = { BindTarget = column.Action } }); + receptorGridDimensions.Add(new Dimension()); + + first = false; + } + } + + InternalChild = gridContainer = new GridContainer + { + RelativeSizeAxes = Axes.Both, + AlwaysPresent = true, + Content = new[] { receptorGridContent.ToArray() }, + ColumnDimensions = receptorGridDimensions.ToArray() + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Opacity.BindValueChanged(o => Alpha = o.NewValue, true); + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + // Hide whenever the keyboard is used. + Hide(); + return false; + } + + protected override bool OnTouchDown(TouchDownEvent e) + { + Show(); + return true; + } + + protected override void PopIn() + { + gridContainer.FadeIn(500, Easing.OutQuint); + } + + protected override void PopOut() + { + gridContainer.FadeOut(300); + } + + public partial class ColumnInputReceptor : CompositeDrawable + { + public readonly IBindable Action = new Bindable(); + + private readonly Box highlightOverlay; + + [Resolved] + private ManiaInputManager? inputManager { get; set; } + + private bool isPressed; + + public ColumnInputReceptor() + { + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 10, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0.15f, + }, + highlightOverlay = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Blending = BlendingParameters.Additive, + } + } + } + }; + } + + protected override bool OnTouchDown(TouchDownEvent e) + { + updateButton(true); + return false; // handled by parent container to show overlay. + } + + protected override void OnTouchUp(TouchUpEvent e) + { + updateButton(false); + } + + private void updateButton(bool press) + { + if (press == isPressed) + return; + + isPressed = press; + + if (press) + { + inputManager?.KeyBindingContainer.TriggerPressed(Action.Value); + highlightOverlay.FadeTo(0.1f, 80, Easing.OutQuint); + } + else + { + inputManager?.KeyBindingContainer.TriggerReleased(Action.Value); + highlightOverlay.FadeTo(0, 400, Easing.OutQuint); + } + } + } + + private partial class Gutter : Drawable + { + public readonly IBindable Spacing = new Bindable(); + + public Gutter() + { + Spacing.BindValueChanged(s => Size = new Vector2(s.NewValue)); + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/UI/Stage.cs b/osu.Game.Rulesets.Mania/UI/Stage.cs index a4a09c9a82..86f2243561 100644 --- a/osu.Game.Rulesets.Mania/UI/Stage.cs +++ b/osu.Game.Rulesets.Mania/UI/Stage.cs @@ -59,7 +59,7 @@ namespace osu.Game.Rulesets.Mania.UI private ISkinSource currentSkin = null!; - public Stage(int firstColumnIndex, StageDefinition definition, ref ManiaAction normalColumnStartAction, ref ManiaAction specialColumnStartAction) + public Stage(int firstColumnIndex, StageDefinition definition, ref ManiaAction columnStartAction) { this.firstColumnIndex = firstColumnIndex; Definition = definition; @@ -138,7 +138,7 @@ namespace osu.Game.Rulesets.Mania.UI { RelativeSizeAxes = Axes.Both, Width = 1, - Action = { Value = isSpecial ? specialColumnStartAction++ : normalColumnStartAction++ } + Action = { Value = columnStartAction++ } }; topLevelContainer.Add(column.TopLevelContainer.CreateProxy()); diff --git a/osu.Game.Rulesets.Mania/VariantMappingGenerator.cs b/osu.Game.Rulesets.Mania/VariantMappingGenerator.cs index 2742ee087b..2195c9e1b9 100644 --- a/osu.Game.Rulesets.Mania/VariantMappingGenerator.cs +++ b/osu.Game.Rulesets.Mania/VariantMappingGenerator.cs @@ -26,37 +26,30 @@ namespace osu.Game.Rulesets.Mania public InputKey SpecialKey; /// - /// The at which the normal columns should begin. + /// The at which the columns should begin. /// - public ManiaAction NormalActionStart; - - /// - /// The for the special column. - /// - public ManiaAction SpecialAction; + public ManiaAction ActionStart; /// /// Generates a list of s for a specific number of columns. /// /// The number of columns that need to be bound. - /// The next to use for normal columns. /// The keybindings. - public IEnumerable GenerateKeyBindingsFor(int columns, out ManiaAction nextNormalAction) + public IEnumerable GenerateKeyBindingsFor(int columns) { - ManiaAction currentNormalAction = NormalActionStart; + ManiaAction currentAction = ActionStart; var bindings = new List(); for (int i = LeftKeys.Length - columns / 2; i < LeftKeys.Length; i++) - bindings.Add(new KeyBinding(LeftKeys[i], currentNormalAction++)); + bindings.Add(new KeyBinding(LeftKeys[i], currentAction++)); if (columns % 2 == 1) - bindings.Add(new KeyBinding(SpecialKey, SpecialAction)); + bindings.Add(new KeyBinding(SpecialKey, currentAction++)); for (int i = 0; i < columns / 2; i++) - bindings.Add(new KeyBinding(RightKeys[i], currentNormalAction++)); + bindings.Add(new KeyBinding(RightKeys[i], currentAction++)); - nextNormalAction = currentNormalAction; return bindings; } } diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneHitCirclePlacementBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneHitCirclePlacementBlueprint.cs index a49afd82f3..a105d860bf 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneHitCirclePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneHitCirclePlacementBlueprint.cs @@ -14,6 +14,6 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor public partial class TestSceneHitCirclePlacementBlueprint : PlacementBlueprintTestScene { protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableHitCircle((HitCircle)hitObject); - protected override PlacementBlueprint CreateBlueprint() => new HitCirclePlacementBlueprint(); + protected override HitObjectPlacementBlueprint CreateBlueprint() => new HitCirclePlacementBlueprint(); } } diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectMerging.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectMerging.cs index dfe950c01e..fd711e543c 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectMerging.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectMerging.cs @@ -34,7 +34,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor }); moveMouseToHitObject(1); - AddAssert("merge option available", () => selectionHandler.ContextMenuItems?.Any(o => o.Text.Value == "Merge selection") == true); + AddAssert("merge option available", () => selectionHandler.ContextMenuItems.Any(o => o.Text.Value == "Merge selection")); mergeSelection(); @@ -198,7 +198,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor }); moveMouseToHitObject(1); - AddAssert("merge option not available", () => selectionHandler.ContextMenuItems?.Length > 0 && selectionHandler.ContextMenuItems.All(o => o.Text.Value != "Merge selection")); + AddAssert("merge option not available", () => selectionHandler.ContextMenuItems.Length > 0 && selectionHandler.ContextMenuItems.All(o => o.Text.Value != "Merge selection")); mergeSelection(); AddAssert("circles not merged", () => circle1 is not null && circle2 is not null && EditorBeatmap.HitObjects.Contains(circle1) && EditorBeatmap.HitObjects.Contains(circle2)); @@ -222,7 +222,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor }); moveMouseToHitObject(1); - AddAssert("merge option available", () => selectionHandler.ContextMenuItems?.Any(o => o.Text.Value == "Merge selection") == true); + AddAssert("merge option available", () => selectionHandler.ContextMenuItems.Any(o => o.Text.Value == "Merge selection")); mergeSelection(); diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs index d14e593587..fb109ba6f9 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs @@ -1,13 +1,18 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Linq; using NUnit.Framework; using osu.Framework.Testing; using osu.Framework.Utils; +using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Osu.Edit; using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Edit.Compose.Components; using osu.Game.Tests.Visual; +using osu.Game.Utils; using osuTK; using osuTK.Input; @@ -20,26 +25,26 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor [Test] public void TestGridToggles() { - AddStep("enable distance snap grid", () => InputManager.Key(Key.T)); + AddStep("enable distance snap grid", () => InputManager.Key(Key.Y)); AddStep("select second object", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.ElementAt(1))); AddUntilStep("distance snap grid visible", () => this.ChildrenOfType().Any()); - rectangularGridActive(false); + gridActive(false); - AddStep("enable rectangular grid", () => InputManager.Key(Key.Y)); + AddStep("enable rectangular grid", () => InputManager.Key(Key.T)); AddStep("select second object", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.ElementAt(1))); AddUntilStep("distance snap grid still visible", () => this.ChildrenOfType().Any()); - rectangularGridActive(true); + gridActive(true); - AddStep("disable distance snap grid", () => InputManager.Key(Key.T)); + AddStep("disable distance snap grid", () => InputManager.Key(Key.Y)); AddUntilStep("distance snap grid hidden", () => !this.ChildrenOfType().Any()); AddStep("select second object", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.ElementAt(1))); - rectangularGridActive(true); + gridActive(true); - AddStep("disable rectangular grid", () => InputManager.Key(Key.Y)); + AddStep("disable rectangular grid", () => InputManager.Key(Key.T)); AddUntilStep("distance snap grid still hidden", () => !this.ChildrenOfType().Any()); - rectangularGridActive(false); + gridActive(false); } [Test] @@ -52,38 +57,124 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor AddUntilStep("distance snap grid visible", () => this.ChildrenOfType().Any()); AddStep("release alt", () => InputManager.ReleaseKey(Key.AltLeft)); AddUntilStep("distance snap grid hidden", () => !this.ChildrenOfType().Any()); + + AddStep("enable distance snap grid", () => InputManager.Key(Key.Y)); + AddUntilStep("distance snap grid visible", () => this.ChildrenOfType().Any()); + AddStep("hold alt", () => InputManager.PressKey(Key.AltLeft)); + AddUntilStep("distance snap grid hidden", () => !this.ChildrenOfType().Any()); + AddStep("release alt", () => InputManager.ReleaseKey(Key.AltLeft)); + AddUntilStep("distance snap grid visible", () => this.ChildrenOfType().Any()); + } + + [Test] + public void TestDistanceSnapAdjustDoesNotHideTheGridIfStartingEnabled() + { + double distanceSnap = double.PositiveInfinity; + + AddStep("enable distance snap grid", () => InputManager.Key(Key.Y)); + + AddStep("select second object", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.ElementAt(1))); + AddUntilStep("distance snap grid visible", () => this.ChildrenOfType().Any()); + AddStep("store distance snap", () => distanceSnap = this.ChildrenOfType().First().DistanceSpacingMultiplier.Value); + + AddStep("increase distance", () => + { + InputManager.PressKey(Key.AltLeft); + InputManager.PressKey(Key.ControlLeft); + InputManager.ScrollVerticalBy(1); + InputManager.ReleaseKey(Key.ControlLeft); + InputManager.ReleaseKey(Key.AltLeft); + }); + + AddUntilStep("distance snap increased", () => this.ChildrenOfType().First().DistanceSpacingMultiplier.Value, () => Is.GreaterThan(distanceSnap)); + AddUntilStep("distance snap grid still visible", () => this.ChildrenOfType().Any()); + } + + [Test] + public void TestDistanceSnapAdjustShowsGridMomentarilyIfStartingDisabled() + { + double distanceSnap = double.PositiveInfinity; + + AddStep("select second object", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.ElementAt(1))); + AddUntilStep("distance snap grid hidden", () => !this.ChildrenOfType().Any()); + AddStep("store distance snap", () => distanceSnap = this.ChildrenOfType().First().DistanceSpacingMultiplier.Value); + + AddStep("start increasing distance", () => + { + InputManager.PressKey(Key.AltLeft); + InputManager.PressKey(Key.ControlLeft); + }); + + AddUntilStep("distance snap grid visible", () => this.ChildrenOfType().Any()); + + AddStep("finish increasing distance", () => + { + InputManager.ScrollVerticalBy(1); + InputManager.ReleaseKey(Key.ControlLeft); + InputManager.ReleaseKey(Key.AltLeft); + }); + + AddUntilStep("distance snap increased", () => this.ChildrenOfType().First().DistanceSpacingMultiplier.Value, () => Is.GreaterThan(distanceSnap)); + AddUntilStep("distance snap hidden in the end", () => !this.ChildrenOfType().Any()); } [Test] public void TestGridSnapMomentaryToggle() { - rectangularGridActive(false); + gridActive(false); AddStep("hold shift", () => InputManager.PressKey(Key.ShiftLeft)); - rectangularGridActive(true); + gridActive(true); AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft)); - rectangularGridActive(false); + gridActive(false); } - private void rectangularGridActive(bool active) + private void gridActive(bool active) where T : PositionSnapGrid { + AddAssert($"grid type is {typeof(T).Name}", () => this.ChildrenOfType().Any()); AddStep("choose placement tool", () => InputManager.Key(Key.Number2)); - AddStep("move cursor to (1, 1)", () => + AddStep("move cursor to spacing + (1, 1)", () => { - var composer = Editor.ChildrenOfType().Single(); - InputManager.MoveMouseTo(composer.ToScreenSpace(new Vector2(1, 1))); + var composer = Editor.ChildrenOfType().Single(); + InputManager.MoveMouseTo(composer.ToScreenSpace(uniqueSnappingPosition(composer) + new Vector2(1, 1))); }); if (active) - AddAssert("placement blueprint at (0, 0)", () => Precision.AlmostEquals(Editor.ChildrenOfType().Single().HitObject.Position, new Vector2(0, 0))); + { + AddAssert("placement blueprint at spacing + (0, 0)", () => + { + var composer = Editor.ChildrenOfType().Single(); + return Precision.AlmostEquals(Editor.ChildrenOfType().Single().HitObject.Position, + uniqueSnappingPosition(composer)); + }); + } else - AddAssert("placement blueprint at (1, 1)", () => Precision.AlmostEquals(Editor.ChildrenOfType().Single().HitObject.Position, new Vector2(1, 1))); + { + AddAssert("placement blueprint at spacing + (1, 1)", () => + { + var composer = Editor.ChildrenOfType().Single(); + return Precision.AlmostEquals(Editor.ChildrenOfType().Single().HitObject.Position, + uniqueSnappingPosition(composer) + new Vector2(1, 1)); + }); + } + } + + private Vector2 uniqueSnappingPosition(PositionSnapGrid grid) + { + return grid switch + { + RectangularPositionSnapGrid rectangular => rectangular.StartPosition.Value + GeometryUtils.RotateVector(rectangular.Spacing.Value, -rectangular.GridLineRotation.Value), + TriangularPositionSnapGrid triangular => triangular.StartPosition.Value + GeometryUtils.RotateVector( + new Vector2(triangular.Spacing.Value / 2, triangular.Spacing.Value / 2 * MathF.Sqrt(3)), -triangular.GridLineRotation.Value), + CircularPositionSnapGrid circular => circular.StartPosition.Value + GeometryUtils.RotateVector(new Vector2(circular.Spacing.Value, 0), -45), + _ => Vector2.Zero + }; } [Test] public void TestGridSizeToggling() { AddStep("enable rectangular grid", () => InputManager.Key(Key.Y)); - AddUntilStep("rectangular grid visible", () => this.ChildrenOfType().Any()); + AddUntilStep("rectangular grid visible", () => this.ChildrenOfType().Any()); gridSizeIs(4); nextGridSizeIs(8); @@ -99,7 +190,99 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor } private void gridSizeIs(int size) - => AddAssert($"grid size is {size}", () => this.ChildrenOfType().Single().Spacing == new Vector2(size) + => AddAssert($"grid size is {size}", () => this.ChildrenOfType().Single().Spacing.Value == new Vector2(size) && EditorBeatmap.BeatmapInfo.GridSize == size); + + [Test] + public void TestGridTypeToggling() + { + AddStep("enable rectangular grid", () => InputManager.Key(Key.T)); + AddUntilStep("rectangular grid visible", () => this.ChildrenOfType().Any()); + gridActive(true); + + nextGridTypeIs(); + nextGridTypeIs(); + nextGridTypeIs(); + } + + private void nextGridTypeIs() where T : PositionSnapGrid + { + AddStep("toggle to next grid type", () => + { + InputManager.PressKey(Key.ShiftLeft); + InputManager.Key(Key.G); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + gridActive(true); + } + + [Test] + public void TestGridPlacementTool() + { + AddStep("enable rectangular grid", () => InputManager.Key(Key.T)); + + AddStep("start grid placement", () => InputManager.Key(Key.Number5)); + AddStep("move cursor to slider head + (1, 1)", () => + { + var composer = Editor.ChildrenOfType().Single(); + InputManager.MoveMouseTo(composer.ToScreenSpace(((Slider)EditorBeatmap.HitObjects.First()).Position + new Vector2(1, 1))); + }); + AddStep("left click", () => InputManager.Click(MouseButton.Left)); + AddStep("move cursor to slider tail + (1, 1)", () => + { + var composer = Editor.ChildrenOfType().Single(); + InputManager.MoveMouseTo(composer.ToScreenSpace(((Slider)EditorBeatmap.HitObjects.First()).EndPosition + new Vector2(1, 1))); + }); + AddStep("left click", () => InputManager.Click(MouseButton.Left)); + + gridActive(true); + AddAssert("grid position at slider head", () => + { + var composer = Editor.ChildrenOfType().Single(); + return Precision.AlmostEquals(((Slider)EditorBeatmap.HitObjects.First()).Position, composer.StartPosition.Value); + }); + AddAssert("grid spacing is distance to slider tail", () => + { + var composer = Editor.ChildrenOfType().Single(); + return Precision.AlmostEquals(composer.Spacing.Value.X, 32.05, 0.01) + && Precision.AlmostEquals(composer.Spacing.Value.X, composer.Spacing.Value.Y); + }); + AddAssert("grid rotation points to slider tail", () => + { + var composer = Editor.ChildrenOfType().Single(); + return Precision.AlmostEquals(composer.GridLineRotation.Value, 0.09, 0.01); + }); + + AddStep("start grid placement", () => InputManager.Key(Key.Number5)); + AddStep("move cursor to slider tail + (1, 1)", () => + { + var composer = Editor.ChildrenOfType().Single(); + InputManager.MoveMouseTo(composer.ToScreenSpace(((Slider)EditorBeatmap.HitObjects.First()).EndPosition + new Vector2(1, 1))); + }); + AddStep("double click", () => + { + InputManager.Click(MouseButton.Left); + InputManager.Click(MouseButton.Left); + }); + AddStep("move cursor to (0, 0)", () => + { + var composer = Editor.ChildrenOfType().Single(); + InputManager.MoveMouseTo(composer.ToScreenSpace(Vector2.Zero)); + }); + + gridActive(true); + AddAssert("grid position at slider tail", () => + { + var composer = Editor.ChildrenOfType().Single(); + return Precision.AlmostEquals(((Slider)EditorBeatmap.HitObjects.First()).EndPosition, composer.StartPosition.Value); + }); + AddAssert("grid spacing and rotation unchanged", () => + { + var composer = Editor.ChildrenOfType().Single(); + return Precision.AlmostEquals(composer.Spacing.Value.X, 32.05, 0.01) + && Precision.AlmostEquals(composer.Spacing.Value.X, composer.Spacing.Value.Y) + && Precision.AlmostEquals(composer.GridLineRotation.Value, 0.09, 0.01); + }); + } } } diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.cs index 0ca30e00bc..93eb76aba6 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.cs @@ -15,6 +15,7 @@ using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Tests.Visual; using osuTK; +using osuTK.Input; namespace osu.Game.Rulesets.Osu.Tests.Editor { @@ -30,23 +31,6 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); }); - [Test] - public void TestAddOverlappingControlPoints() - { - createVisualiser(true); - - addControlPointStep(new Vector2(200)); - addControlPointStep(new Vector2(300)); - addControlPointStep(new Vector2(300)); - addControlPointStep(new Vector2(500, 300)); - - AddAssert("last connection displayed", () => - { - var lastConnection = visualiser.Connections.Last(c => c.ControlPoint.Position == new Vector2(300)); - return lastConnection.DrawWidth > 50; - }); - } - [Test] public void TestPerfectCurveTooManyPoints() { @@ -195,21 +179,76 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor } [Test] - public void TestStackingUpdatesConnectionPosition() + public void TestChangingControlPointTypeViaTab() { createVisualiser(true); - Vector2 connectionPosition; - addControlPointStep(connectionPosition = new Vector2(300)); - addControlPointStep(new Vector2(600)); + addControlPointStep(new Vector2(200), PathType.LINEAR); + addControlPointStep(new Vector2(300)); + addControlPointStep(new Vector2(500, 300)); + addControlPointStep(new Vector2(700, 200)); + addControlPointStep(new Vector2(500, 100)); - // Apply a big number in stacking so the person running the test can clearly see if it fails - AddStep("apply stacking", () => slider.StackHeightBindable.Value += 10); + AddStep("select first control point", () => visualiser.Pieces[0].IsSelected.Value = true); + AddStep("press tab", () => InputManager.Key(Key.Tab)); + assertControlPointPathType(0, PathType.BEZIER); - AddAssert($"Connection at {connectionPosition} changed", - () => visualiser.Connections[0].Position, - () => !Is.EqualTo(connectionPosition) - ); + AddStep("press shift-tab", () => + { + InputManager.PressKey(Key.LShift); + InputManager.Key(Key.Tab); + InputManager.ReleaseKey(Key.LShift); + }); + assertControlPointPathType(0, PathType.LINEAR); + + AddStep("press shift-tab", () => + { + InputManager.PressKey(Key.LShift); + InputManager.Key(Key.Tab); + InputManager.ReleaseKey(Key.LShift); + }); + assertControlPointPathType(0, PathType.BSpline(4)); + + AddStep("press shift-tab", () => + { + InputManager.PressKey(Key.LShift); + InputManager.Key(Key.Tab); + InputManager.ReleaseKey(Key.LShift); + }); + assertControlPointPathType(0, PathType.PERFECT_CURVE); + assertControlPointPathType(2, PathType.BSpline(4)); + + AddStep("select third last control point", () => + { + visualiser.Pieces[0].IsSelected.Value = false; + visualiser.Pieces[2].IsSelected.Value = true; + }); + + AddStep("press shift-tab", () => + { + InputManager.PressKey(Key.LShift); + InputManager.Key(Key.Tab); + InputManager.ReleaseKey(Key.LShift); + }); + assertControlPointPathType(2, PathType.PERFECT_CURVE); + + AddRepeatStep("press tab", () => InputManager.Key(Key.Tab), 2); + assertControlPointPathType(0, PathType.BEZIER); + assertControlPointPathType(2, null); + + AddStep("select first and third control points", () => + { + visualiser.Pieces[0].IsSelected.Value = true; + visualiser.Pieces[2].IsSelected.Value = true; + }); + AddStep("press alt-1", () => + { + InputManager.PressKey(Key.AltLeft); + InputManager.Key(Key.Number1); + InputManager.ReleaseKey(Key.AltLeft); + }); + assertControlPointPathType(0, PathType.LINEAR); + assertControlPointPathType(2, PathType.LINEAR); } private void addAssertPointPositionChanged(Vector2[] points, int index) diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePreciseRotation.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePreciseRotation.cs index 30e0dbbf2e..d14bd1fc87 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePreciseRotation.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePreciseRotation.cs @@ -116,7 +116,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor () => EditorBeatmap.HitObjects.OfType().ElementAt(1).Position, () => Is.EqualTo(OsuPlayfield.BASE_SIZE - new Vector2(200))); - AddStep("change rotation origin", () => getPopover().ChildrenOfType().ElementAt(1).TriggerClick()); + AddStep("change rotation origin", () => getPopover().ChildrenOfType().ElementAt(2).TriggerClick()); AddAssert("first object rotated 90deg around selection centre", () => EditorBeatmap.HitObjects.OfType().ElementAt(0).Position, () => Is.EqualTo(new Vector2(200, 200))); AddAssert("second object rotated 90deg around selection centre", diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderChangeStates.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderChangeStates.cs new file mode 100644 index 0000000000..0b8f2f7417 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderChangeStates.cs @@ -0,0 +1,47 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Tests.Beatmaps; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests.Editor +{ + public partial class TestSceneSliderChangeStates : TestSceneOsuEditor + { + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); + + [TestCase(SplineType.Catmull)] + [TestCase(SplineType.BSpline)] + [TestCase(SplineType.Linear)] + [TestCase(SplineType.PerfectCurve)] + public void TestSliderRetainsCurveTypes(SplineType splineType) + { + Slider? slider = null; + PathType pathType = new PathType(splineType); + + AddStep("add slider", () => EditorBeatmap.Add(slider = new Slider + { + StartTime = 500, + Path = new SliderPath(new[] + { + new PathControlPoint(Vector2.Zero, pathType), + new PathControlPoint(new Vector2(200, 0), pathType), + }) + })); + AddAssert("slider has correct spline type", () => ((Slider)EditorBeatmap.HitObjects[0]).Path.ControlPoints.All(p => p.Type == pathType)); + AddStep("remove object", () => EditorBeatmap.Remove(slider)); + AddAssert("slider removed", () => EditorBeatmap.HitObjects.Count == 0); + addUndoSteps(); + AddAssert("slider not removed", () => EditorBeatmap.HitObjects.Count == 1); + AddAssert("slider has correct spline type", () => ((Slider)EditorBeatmap.HitObjects[0]).Path.ControlPoints.All(p => p.Type == pathType)); + } + + private void addUndoSteps() => AddStep("undo", () => Editor.Undo()); + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderDrawing.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderDrawing.cs new file mode 100644 index 0000000000..0e36c1dc45 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderDrawing.cs @@ -0,0 +1,150 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.UI; +using osu.Game.Screens.Edit.Components.RadioButtons; +using osu.Game.Screens.Edit.Compose.Components; +using osu.Game.Tests.Beatmaps; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Osu.Tests.Editor +{ + [TestFixture] + public partial class TestSceneSliderDrawing : TestSceneOsuEditor + { + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); + + [Test] + public void TestTouchInputPlaceHitCircleDirectly() + { + AddStep("tap circle", () => tap(this.ChildrenOfType().Single(b => b.Button.Label == "HitCircle"))); + + AddStep("tap to place circle", () => tap(this.ChildrenOfType().Single())); + AddAssert("circle placed correctly", () => + { + var circle = (HitCircle)EditorBeatmap.HitObjects.Single(h => h.StartTime == EditorClock.CurrentTimeAccurate); + Assert.Multiple(() => + { + Assert.That(circle.Position.X, Is.EqualTo(256f).Within(0.01f)); + Assert.That(circle.Position.Y, Is.EqualTo(192f).Within(0.01f)); + }); + + return true; + }); + } + + [Test] + public void TestTouchInputPlaceCircleAfterTouchingComposeArea() + { + AddStep("tap circle", () => tap(this.ChildrenOfType().Single(b => b.Button.Label == "HitCircle"))); + + AddStep("tap playfield", () => tap(this.ChildrenOfType().Single())); + AddAssert("circle placed", () => EditorBeatmap.HitObjects.Single(h => h.StartTime == EditorClock.CurrentTimeAccurate) is HitCircle); + + AddStep("move forward", () => InputManager.Key(Key.Right)); + + AddStep("tap to place circle", () => tap(this.ChildrenOfType().Single())); + AddAssert("circle placed correctly", () => + { + var circle = (HitCircle)EditorBeatmap.HitObjects.Single(h => h.StartTime == EditorClock.CurrentTimeAccurate); + Assert.Multiple(() => + { + Assert.That(circle.Position.X, Is.EqualTo(256f).Within(0.01f)); + Assert.That(circle.Position.Y, Is.EqualTo(192f).Within(0.01f)); + }); + + return true; + }); + } + + [Test] + public void TestTouchInputPlaceSliderDirectly() + { + AddStep("tap slider", () => tap(this.ChildrenOfType().Single(b => b.Button.Label == "Slider"))); + + AddStep("hold to draw slider", () => InputManager.BeginTouch(new Touch(TouchSource.Touch1, this.ChildrenOfType().Single().ToScreenSpace(new Vector2(50, 20))))); + AddStep("drag to draw", () => InputManager.MoveTouchTo(new Touch(TouchSource.Touch1, this.ChildrenOfType().Single().ToScreenSpace(new Vector2(200, 50))))); + AddAssert("selection not initiated", () => this.ChildrenOfType().All(d => d.State == Visibility.Hidden)); + AddAssert("blueprint visible", () => this.ChildrenOfType().Single().Alpha > 0); + AddStep("end", () => InputManager.EndTouch(new Touch(TouchSource.Touch1, InputManager.CurrentState.Touch.GetTouchPosition(TouchSource.Touch1)!.Value))); + AddAssert("slider placed correctly", () => + { + var slider = (Slider)EditorBeatmap.HitObjects.Single(h => h.StartTime == EditorClock.CurrentTimeAccurate); + Assert.Multiple(() => + { + Assert.That(slider.Position.X, Is.EqualTo(50f).Within(0.01f)); + Assert.That(slider.Position.Y, Is.EqualTo(20f).Within(0.01f)); + Assert.That(slider.Path.ControlPoints.Count, Is.EqualTo(2)); + Assert.That(slider.Path.ControlPoints[0].Position, Is.EqualTo(Vector2.Zero)); + + // the final position may be slightly off from the mouse position when drawing, account for that. + Assert.That(slider.Path.ControlPoints[1].Position.X, Is.EqualTo(150).Within(5)); + Assert.That(slider.Path.ControlPoints[1].Position.Y, Is.EqualTo(30).Within(5)); + }); + + return true; + }); + } + + [Test] + public void TestTouchInputPlaceSliderAfterTouchingComposeArea() + { + AddStep("tap slider", () => tap(this.ChildrenOfType().Single(b => b.Button.Label == "Slider"))); + + AddStep("tap playfield", () => tap(this.ChildrenOfType().Single())); + AddStep("tap and hold another spot", () => hold(this.ChildrenOfType().Single(), new Vector2(50, 0))); + AddUntilStep("wait for slider placement", () => EditorBeatmap.HitObjects.SingleOrDefault(h => h.StartTime == EditorClock.CurrentTimeAccurate) is Slider); + AddStep("end", () => InputManager.EndTouch(new Touch(TouchSource.Touch1, InputManager.CurrentState.Touch.GetTouchPosition(TouchSource.Touch1)!.Value))); + + AddStep("move forward", () => InputManager.Key(Key.Right)); + + AddStep("hold to draw slider", () => InputManager.BeginTouch(new Touch(TouchSource.Touch1, this.ChildrenOfType().Single().ToScreenSpace(new Vector2(50, 20))))); + AddStep("drag to draw", () => InputManager.MoveTouchTo(new Touch(TouchSource.Touch1, this.ChildrenOfType().Single().ToScreenSpace(new Vector2(200, 50))))); + AddAssert("selection not initiated", () => this.ChildrenOfType().All(d => d.State == Visibility.Hidden)); + AddAssert("blueprint visible", () => this.ChildrenOfType().Single().IsPresent); + AddStep("end", () => InputManager.EndTouch(new Touch(TouchSource.Touch1, InputManager.CurrentState.Touch.GetTouchPosition(TouchSource.Touch1)!.Value))); + AddAssert("slider placed correctly", () => + { + var slider = (Slider)EditorBeatmap.HitObjects.Single(h => h.StartTime == EditorClock.CurrentTimeAccurate); + Assert.Multiple(() => + { + Assert.That(slider.Position.X, Is.EqualTo(50f).Within(0.01f)); + Assert.That(slider.Position.Y, Is.EqualTo(20f).Within(0.01f)); + Assert.That(slider.Path.ControlPoints.Count, Is.EqualTo(2)); + Assert.That(slider.Path.ControlPoints[0].Position, Is.EqualTo(Vector2.Zero)); + + // the final position may be slightly off from the mouse position when drawing, account for that. + Assert.That(slider.Path.ControlPoints[1].Position.X, Is.EqualTo(150).Within(5)); + Assert.That(slider.Path.ControlPoints[1].Position.Y, Is.EqualTo(30).Within(5)); + }); + + return true; + }); + } + + private void tap(Drawable drawable, Vector2 offset = default) => tap(drawable.ToScreenSpace(drawable.LayoutRectangle.Centre + offset)); + + private void tap(Vector2 position) + { + hold(position); + InputManager.EndTouch(new Touch(TouchSource.Touch1, position)); + } + + private void hold(Drawable drawable, Vector2 offset = default) => hold(drawable.ToScreenSpace(drawable.LayoutRectangle.Centre + offset)); + + private void hold(Vector2 position) + { + InputManager.BeginTouch(new Touch(TouchSource.Touch1, position)); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs index bbded55732..5831cc0a8a 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs @@ -2,13 +2,17 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using NUnit.Framework; +using osu.Framework.Input; +using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders; +using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; using osu.Game.Tests.Visual; @@ -57,7 +61,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor assertPlaced(true); assertLength(200); assertControlPointCount(2); - assertControlPointType(0, PathType.LINEAR); + assertFinalControlPointType(0, PathType.LINEAR); } [Test] @@ -71,7 +75,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor assertPlaced(true); assertControlPointCount(2); - assertControlPointType(0, PathType.LINEAR); + assertFinalControlPointType(0, PathType.LINEAR); } [Test] @@ -89,7 +93,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor assertPlaced(true); assertControlPointCount(3); assertControlPointPosition(1, new Vector2(100, 0)); - assertControlPointType(0, PathType.PERFECT_CURVE); + assertFinalControlPointType(0, PathType.PERFECT_CURVE); } [Test] @@ -111,7 +115,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor assertControlPointCount(4); assertControlPointPosition(1, new Vector2(100, 0)); assertControlPointPosition(2, new Vector2(100, 100)); - assertControlPointType(0, PathType.BEZIER); + assertFinalControlPointType(0, PathType.BEZIER); } [Test] @@ -130,8 +134,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor assertPlaced(true); assertControlPointCount(3); assertControlPointPosition(1, new Vector2(100, 0)); - assertControlPointType(0, PathType.LINEAR); - assertControlPointType(1, PathType.LINEAR); + assertFinalControlPointType(0, PathType.LINEAR); + assertFinalControlPointType(1, PathType.LINEAR); } [Test] @@ -149,7 +153,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor assertPlaced(true); assertControlPointCount(2); - assertControlPointType(0, PathType.LINEAR); + assertFinalControlPointType(0, PathType.LINEAR); assertLength(100); } @@ -171,7 +175,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor assertPlaced(true); assertControlPointCount(3); - assertControlPointType(0, PathType.PERFECT_CURVE); + assertFinalControlPointType(0, PathType.PERFECT_CURVE); } [Test] @@ -195,7 +199,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor assertPlaced(true); assertControlPointCount(4); - assertControlPointType(0, PathType.BEZIER); + assertFinalControlPointType(0, PathType.BEZIER); } [Test] @@ -215,8 +219,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor assertControlPointCount(3); assertControlPointPosition(1, new Vector2(100, 0)); assertControlPointPosition(2, new Vector2(100)); - assertControlPointType(0, PathType.LINEAR); - assertControlPointType(1, PathType.LINEAR); + assertFinalControlPointType(0, PathType.LINEAR); + assertFinalControlPointType(1, PathType.LINEAR); } [Test] @@ -239,8 +243,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor assertControlPointCount(4); assertControlPointPosition(1, new Vector2(100, 0)); assertControlPointPosition(2, new Vector2(100)); - assertControlPointType(0, PathType.LINEAR); - assertControlPointType(1, PathType.PERFECT_CURVE); + assertFinalControlPointType(0, PathType.LINEAR); + assertFinalControlPointType(1, PathType.PERFECT_CURVE); } [Test] @@ -268,8 +272,54 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor assertControlPointPosition(2, new Vector2(100)); assertControlPointPosition(3, new Vector2(200, 100)); assertControlPointPosition(4, new Vector2(200)); - assertControlPointType(0, PathType.PERFECT_CURVE); - assertControlPointType(2, PathType.PERFECT_CURVE); + assertFinalControlPointType(0, PathType.PERFECT_CURVE); + assertFinalControlPointType(2, PathType.PERFECT_CURVE); + } + + [Test] + public void TestManualPathTypeControlViaKeyboard() + { + addMovementStep(new Vector2(200)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300, 200)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300)); + + assertControlPointTypeDuringPlacement(0, PathType.PERFECT_CURVE); + + AddRepeatStep("press tab", () => InputManager.Key(Key.Tab), 2); + assertControlPointTypeDuringPlacement(0, PathType.LINEAR); + + AddStep("press shift-tab", () => + { + InputManager.PressKey(Key.ShiftLeft); + InputManager.Key(Key.Tab); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + assertControlPointTypeDuringPlacement(0, PathType.BSpline(4)); + + AddStep("press alt-2", () => + { + InputManager.PressKey(Key.AltLeft); + InputManager.Key(Key.Number2); + InputManager.ReleaseKey(Key.AltLeft); + }); + assertControlPointTypeDuringPlacement(0, PathType.BEZIER); + + AddStep("start new segment via S", () => InputManager.Key(Key.S)); + assertControlPointTypeDuringPlacement(2, PathType.LINEAR); + + addMovementStep(new Vector2(400, 300)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(400)); + addClickStep(MouseButton.Right); + + assertPlaced(true); + assertFinalControlPointType(0, PathType.BEZIER); + assertFinalControlPointType(2, PathType.PERFECT_CURVE); } [Test] @@ -293,7 +343,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor addClickStep(MouseButton.Right); assertPlaced(true); - assertControlPointType(0, PathType.BEZIER); + assertFinalControlPointType(0, PathType.BEZIER); } [Test] @@ -312,11 +362,11 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor assertPlaced(true); assertLength(808, tolerance: 10); assertControlPointCount(5); - assertControlPointType(0, PathType.BSpline(4)); - assertControlPointType(1, null); - assertControlPointType(2, null); - assertControlPointType(3, null); - assertControlPointType(4, null); + assertFinalControlPointType(0, PathType.BSpline(4)); + assertFinalControlPointType(1, null); + assertFinalControlPointType(2, null); + assertFinalControlPointType(3, null); + assertFinalControlPointType(4, null); } [Test] @@ -337,10 +387,33 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor assertPlaced(true); assertLength(600, tolerance: 10); assertControlPointCount(4); - assertControlPointType(0, PathType.BSpline(4)); - assertControlPointType(1, PathType.BSpline(4)); - assertControlPointType(2, PathType.BSpline(4)); - assertControlPointType(3, null); + assertFinalControlPointType(0, PathType.BSpline(4)); + assertFinalControlPointType(1, PathType.BSpline(4)); + assertFinalControlPointType(2, PathType.BSpline(4)); + assertFinalControlPointType(3, null); + } + + [Test] + public void TestSliderDrawingViaTouch() + { + Vector2 startPoint = new Vector2(200); + + AddStep("move mouse to a random point", () => InputManager.MoveMouseTo(InputManager.ToScreenSpace(Vector2.Zero))); + AddStep("begin touch at start point", () => InputManager.BeginTouch(new Touch(TouchSource.Touch1, InputManager.ToScreenSpace(startPoint)))); + + for (int i = 1; i < 20; i++) + addTouchMovementStep(startPoint + new Vector2(i * 40, MathF.Sin(i * MathF.PI / 5) * 50)); + + AddStep("release touch at end point", () => InputManager.EndTouch(new Touch(TouchSource.Touch1, InputManager.CurrentState.Touch.GetTouchPosition(TouchSource.Touch1)!.Value))); + + assertPlaced(true); + assertLength(808, tolerance: 10); + assertControlPointCount(5); + assertFinalControlPointType(0, PathType.BSpline(4)); + assertFinalControlPointType(1, null); + assertFinalControlPointType(2, null); + assertFinalControlPointType(3, null); + assertFinalControlPointType(4, null); } [Test] @@ -359,7 +432,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor assertPlaced(true); assertControlPointCount(3); - assertControlPointType(0, PathType.BEZIER); + assertFinalControlPointType(0, PathType.BEZIER); } [Test] @@ -379,7 +452,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor assertPlaced(true); assertControlPointCount(3); - assertControlPointType(0, PathType.PERFECT_CURVE); + assertFinalControlPointType(0, PathType.PERFECT_CURVE); } [Test] @@ -400,7 +473,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor assertPlaced(true); assertControlPointCount(3); - assertControlPointType(0, PathType.PERFECT_CURVE); + assertFinalControlPointType(0, PathType.PERFECT_CURVE); } [Test] @@ -421,7 +494,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor assertPlaced(true); assertControlPointCount(3); - assertControlPointType(0, PathType.BEZIER); + assertFinalControlPointType(0, PathType.BEZIER); } [Test] @@ -438,11 +511,13 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor assertPlaced(true); assertControlPointCount(3); - assertControlPointType(0, PathType.PERFECT_CURVE); + assertFinalControlPointType(0, PathType.PERFECT_CURVE); } private void addMovementStep(Vector2 position) => AddStep($"move mouse to {position}", () => InputManager.MoveMouseTo(InputManager.ToScreenSpace(position))); + private void addTouchMovementStep(Vector2 position) => AddStep($"move touch1 to {position}", () => InputManager.MoveTouchTo(new Touch(TouchSource.Touch1, InputManager.ToScreenSpace(position)))); + private void addClickStep(MouseButton button) { AddStep($"click {button}", () => InputManager.Click(button)); @@ -454,7 +529,10 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor private void assertControlPointCount(int expected) => AddAssert($"has {expected} control points", () => getSlider()!.Path.ControlPoints.Count, () => Is.EqualTo(expected)); - private void assertControlPointType(int index, PathType? type) => AddAssert($"control point {index} is {type?.ToString() ?? "inherit"}", () => getSlider()!.Path.ControlPoints[index].Type, () => Is.EqualTo(type)); + private void assertControlPointTypeDuringPlacement(int index, PathType? type) => AddAssert($"control point {index} is {type?.ToString() ?? "inherit"}", + () => this.ChildrenOfType>().ElementAt(index).ControlPoint.Type, () => Is.EqualTo(type)); + + private void assertFinalControlPointType(int index, PathType? type) => AddAssert($"control point {index} is {type?.ToString() ?? "inherit"}", () => getSlider()!.Path.ControlPoints[index].Type, () => Is.EqualTo(type)); private void assertControlPointPosition(int index, Vector2 position) => AddAssert($"control point {index} at {position}", () => Precision.AlmostEquals(position, getSlider()!.Path.ControlPoints[index].Position, 1)); @@ -462,6 +540,6 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor private Slider? getSlider() => HitObjectContainer.Count > 0 ? ((DrawableSlider)HitObjectContainer[0]).HitObject : null; protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableSlider((Slider)hitObject); - protected override PlacementBlueprint CreateBlueprint() => new SliderPlacementBlueprint(); + protected override HitObjectPlacementBlueprint CreateBlueprint() => new SliderPlacementBlueprint(); } } diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSelectionBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSelectionBlueprint.cs index d4d99e1019..f0f969b15b 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSelectionBlueprint.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using NUnit.Framework; using osu.Framework.Utils; using osu.Game.Beatmaps; @@ -22,9 +20,9 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor { public partial class TestSceneSliderSelectionBlueprint : SelectionBlueprintTestScene { - private Slider slider; - private DrawableSlider drawableObject; - private TestSliderBlueprint blueprint; + private Slider slider = null!; + private DrawableSlider drawableObject = null!; + private TestSliderBlueprint blueprint = null!; [SetUp] public void Setup() => Schedule(() => @@ -163,6 +161,44 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor checkControlPointSelected(1, false); } + [Test] + public void TestAdjustLength() + { + AddStep("move mouse to drag marker", () => + { + Vector2 position = slider.Position + slider.Path.PositionAt(1) + new Vector2(60, 0); + InputManager.MoveMouseTo(drawableObject.Parent!.ToScreenSpace(position)); + }); + AddStep("start drag", () => InputManager.PressButton(MouseButton.Left)); + AddStep("move mouse to control point 1", () => + { + Vector2 position = slider.Position + slider.Path.ControlPoints[1].Position + new Vector2(60, 0); + InputManager.MoveMouseTo(drawableObject.Parent!.ToScreenSpace(position)); + }); + AddStep("end adjust length", () => InputManager.ReleaseButton(MouseButton.Left)); + AddAssert("expected distance halved", + () => Precision.AlmostEquals(slider.Path.Distance, 172.2, 0.1)); + + AddStep("move mouse to drag marker", () => + { + Vector2 position = slider.Position + slider.Path.PositionAt(1) + new Vector2(60, 0); + InputManager.MoveMouseTo(drawableObject.Parent!.ToScreenSpace(position)); + }); + AddStep("start drag", () => InputManager.PressButton(MouseButton.Left)); + AddStep("move mouse beyond last control point", () => + { + Vector2 position = slider.Position + slider.Path.ControlPoints[2].Position + new Vector2(100, 0); + InputManager.MoveMouseTo(drawableObject.Parent!.ToScreenSpace(position)); + }); + AddStep("end adjust length", () => InputManager.ReleaseButton(MouseButton.Left)); + AddAssert("expected distance is calculated distance", + () => Precision.AlmostEquals(slider.Path.Distance, slider.Path.CalculatedDistance, 0.1)); + + moveMouseToControlPoint(1); + AddAssert("expected distance is unchanged", + () => Precision.AlmostEquals(slider.Path.Distance, slider.Path.CalculatedDistance, 0.1)); + } + private void moveHitObject() { AddStep("move hitobject", () => @@ -180,6 +216,9 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor AddAssert("tail positioned correctly", () => Precision.AlmostEquals(blueprint.TailOverlay.CirclePiece.ScreenSpaceDrawQuad.Centre, drawableObject.TailCircle.ScreenSpaceDrawQuad.Centre)); + + AddAssert("end drag marker positioned correctly", + () => Precision.AlmostEquals(blueprint.TailOverlay.EndDragMarker!.ToScreenSpace(blueprint.TailOverlay.EndDragMarker.OriginPosition), drawableObject.TailCircle.ScreenSpaceDrawQuad.Centre, 2)); } private void moveMouseToControlPoint(int index) @@ -192,14 +231,14 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor } private void checkControlPointSelected(int index, bool selected) - => AddAssert($"control point {index} {(selected ? "selected" : "not selected")}", () => blueprint.ControlPointVisualiser.Pieces[index].IsSelected.Value == selected); + => AddAssert($"control point {index} {(selected ? "selected" : "not selected")}", () => blueprint.ControlPointVisualiser!.Pieces[index].IsSelected.Value == selected); private partial class TestSliderBlueprint : SliderSelectionBlueprint { public new SliderBodyPiece BodyPiece => base.BodyPiece; public new TestSliderCircleOverlay HeadOverlay => (TestSliderCircleOverlay)base.HeadOverlay; public new TestSliderCircleOverlay TailOverlay => (TestSliderCircleOverlay)base.TailOverlay; - public new PathControlPointVisualiser ControlPointVisualiser => base.ControlPointVisualiser; + public new PathControlPointVisualiser? ControlPointVisualiser => base.ControlPointVisualiser; public TestSliderBlueprint(Slider slider) : base(slider) diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs index d68cbe6265..d5bacc25bc 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs @@ -180,7 +180,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor { if (slider == null) return; - sample = new HitSampleInfo("hitwhistle", HitSampleInfo.BANK_SOFT, volume: 70); + sample = new HitSampleInfo("hitwhistle", HitSampleInfo.BANK_SOFT, volume: 70, editorAutoBank: false); slider.Samples.Add(sample.With()); }); diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSpinnerPlacementBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSpinnerPlacementBlueprint.cs index 0e8673319e..d7b5cc73be 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSpinnerPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSpinnerPlacementBlueprint.cs @@ -15,6 +15,6 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor { protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableSpinner((Spinner)hitObject); - protected override PlacementBlueprint CreateBlueprint() => new SpinnerPlacementBlueprint(); + protected override HitObjectPlacementBlueprint CreateBlueprint() => new SpinnerPlacementBlueprint(); } } diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneToolSwitching.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneToolSwitching.cs new file mode 100644 index 0000000000..d5ab349a16 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneToolSwitching.cs @@ -0,0 +1,55 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Tests.Visual; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Osu.Tests.Editor +{ + public partial class TestSceneToolSwitching : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + [Test] + public void TestSliderAnchorMoveOperationEndsOnSwitchingTool() + { + var initialPosition = Vector2.Zero; + + AddStep("store original anchor position", () => initialPosition = EditorBeatmap.HitObjects.OfType().First().Path.ControlPoints.ElementAt(1).Position); + AddStep("select first slider", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.OfType().First())); + AddStep("move to second anchor", () => InputManager.MoveMouseTo(this.ChildrenOfType>().ElementAt(1))); + AddStep("start dragging", () => InputManager.PressButton(MouseButton.Left)); + AddStep("drag away", () => InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + new Vector2(0, -200))); + AddStep("switch tool", () => InputManager.PressButton(MouseButton.Button1)); + AddStep("undo", () => Editor.Undo()); + AddAssert("anchor back at original position", + () => EditorBeatmap.HitObjects.OfType().First().Path.ControlPoints.ElementAt(1).Position, + () => Is.EqualTo(initialPosition)); + } + + [Test] + public void TestSliderAnchorCreationOperationEndsOnSwitchingTool() + { + AddStep("select first slider", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.OfType().First())); + AddStep("move to second anchor", () => InputManager.MoveMouseTo(this.ChildrenOfType>().ElementAt(1), new Vector2(-50, 0))); + AddStep("quick-create anchor", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.PressButton(MouseButton.Left); + InputManager.ReleaseKey(Key.ControlLeft); + }); + AddStep("drag away", () => InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + new Vector2(0, -200))); + AddStep("switch tool", () => InputManager.PressKey(Key.Number3)); + AddStep("drag away further", () => InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + new Vector2(0, -200))); + AddStep("select first slider", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.OfType().First())); + AddStep("undo", () => Editor.Undo()); + AddAssert("slider has three anchors again", () => EditorBeatmap.HitObjects.OfType().First().Path.ControlPoints, () => Has.Count.EqualTo(3)); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSliderScaling.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSliderScaling.cs index 021fdba225..52a170b84e 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSliderScaling.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSliderScaling.cs @@ -3,9 +3,12 @@ #nullable disable +using System; using System.Linq; using NUnit.Framework; +using osu.Framework.Graphics.Primitives; using osu.Framework.Testing; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; @@ -71,4 +74,120 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor private void moveMouse(Vector2 pos) => AddStep($"move mouse to {pos}", () => InputManager.MoveMouseTo(playfield.ToScreenSpace(pos))); } + + [TestFixture] + public class TestSliderNearLinearScaling + { + private readonly Random rng = new Random(1337); + + [Test] + public void TestScalingSliderFlat() + { + SliderPath sliderPathPerfect = new SliderPath( + [ + new PathControlPoint(new Vector2(0), PathType.PERFECT_CURVE), + new PathControlPoint(new Vector2(50, 25)), + new PathControlPoint(new Vector2(25, 100)), + ]); + + SliderPath sliderPathBezier = new SliderPath( + [ + new PathControlPoint(new Vector2(0), PathType.BEZIER), + new PathControlPoint(new Vector2(50, 25)), + new PathControlPoint(new Vector2(25, 100)), + ]); + + scaleSlider(sliderPathPerfect, new Vector2(0.000001f, 1)); + scaleSlider(sliderPathBezier, new Vector2(0.000001f, 1)); + + for (int i = 0; i < 100; i++) + { + Assert.True(Precision.AlmostEquals(sliderPathPerfect.PositionAt(i / 100.0f), sliderPathBezier.PositionAt(i / 100.0f))); + } + } + + [Test] + public void TestPerfectCurveMatchesTheoretical() + { + for (int i = 0; i < 20000; i++) + { + //Only test points that are in the screen's bounds + float p1X = 640.0f * (float)rng.NextDouble(); + float p2X = 640.0f * (float)rng.NextDouble(); + + float p1Y = 480.0f * (float)rng.NextDouble(); + float p2Y = 480.0f * (float)rng.NextDouble(); + SliderPath sliderPathPerfect = new SliderPath( + [ + new PathControlPoint(new Vector2(0, 0), PathType.PERFECT_CURVE), + new PathControlPoint(new Vector2(p1X, p1Y)), + new PathControlPoint(new Vector2(p2X, p2Y)), + ]); + + assertMatchesPerfectCircle(sliderPathPerfect); + + scaleSlider(sliderPathPerfect, new Vector2(0.00001f, 1)); + + assertMatchesPerfectCircle(sliderPathPerfect); + } + } + + private void assertMatchesPerfectCircle(SliderPath path) + { + if (path.ControlPoints.Count != 3) + return; + + //Replication of PathApproximator.CircularArcToPiecewiseLinear + CircularArcProperties circularArcProperties = new CircularArcProperties(path.ControlPoints.Select(x => x.Position).ToArray()); + + if (!circularArcProperties.IsValid) + return; + + //Addresses cases where circularArcProperties.ThetaRange>0.5 + //Occurs in code in PathControlPointVisualiser.ensureValidPathType + RectangleF boundingBox = PathApproximator.CircularArcBoundingBox(path.ControlPoints.Select(x => x.Position).ToArray()); + if (boundingBox.Width >= 640 || boundingBox.Height >= 480) + return; + + int subpoints = (2f * circularArcProperties.Radius <= 0.1f) ? 2 : Math.Max(2, (int)Math.Ceiling(circularArcProperties.ThetaRange / (2.0 * Math.Acos(1f - (0.1f / circularArcProperties.Radius))))); + + //ignore cases where subpoints is int.MaxValue, result will be garbage + //as well, having this many subpoints will cause an out of memory error, so can't happen during normal useage + if (subpoints == int.MaxValue) + return; + + for (int i = 0; i < Math.Min(subpoints, 100); i++) + { + float progress = (float)rng.NextDouble(); + + //To avoid errors from interpolating points, ensure we check only positions that would be subpoints. + progress = (float)Math.Ceiling(progress * (subpoints - 1)) / (subpoints - 1); + + //Special case - if few subpoints, ensure checking every single one rather than randomly + if (subpoints < 100) + progress = i / (float)(subpoints - 1); + + //edge points cause issue with interpolation, so ignore the last two points and first + if (progress == 0.0f || progress >= (subpoints - 2) / (float)(subpoints - 1)) + continue; + + double theta = circularArcProperties.ThetaStart + (circularArcProperties.Direction * progress * circularArcProperties.ThetaRange); + Vector2 vector = new Vector2((float)Math.Cos(theta), (float)Math.Sin(theta)) * circularArcProperties.Radius; + + Assert.True(Precision.AlmostEquals(circularArcProperties.Centre + vector, path.PositionAt(progress), 0.01f), + "A perfect circle with points " + string.Join(", ", path.ControlPoints.Select(x => x.Position)) + " and radius" + circularArcProperties.Radius + "from SliderPath does not almost equal a theoretical perfect circle with " + subpoints + " subpoints" + + ": " + (circularArcProperties.Centre + vector) + " - " + path.PositionAt(progress) + + " = " + (circularArcProperties.Centre + vector - path.PositionAt(progress)) + ); + } + } + + private void scaleSlider(SliderPath path, Vector2 scale) + { + for (int i = 0; i < path.ControlPoints.Count; i++) + { + path.ControlPoints[i].Position *= scale; + } + } + } } diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAlternate.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAlternate.cs index 88c81c7a39..7375617aa8 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAlternate.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAlternate.cs @@ -133,7 +133,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods Autoplay = false, Beatmap = new Beatmap { - Breaks = new List + Breaks = { new BreakPeriod(500, 2000), }, diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMirror.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMirror.cs new file mode 100644 index 0000000000..0b3496ba68 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMirror.cs @@ -0,0 +1,64 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.UI; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests.Mods +{ + public partial class TestSceneOsuModMirror : OsuModTestScene + { + [Test] + public void TestCorrectReflections([Values] OsuModMirror.MirrorType type, [Values] bool withStrictTracking) => CreateModTest(new ModTestData + { + Autoplay = true, + Beatmap = new OsuBeatmap + { + HitObjects = + { + new Slider + { + Position = new Vector2(0), + Path = new SliderPath + { + ControlPoints = + { + new PathControlPoint(), + new PathControlPoint(new Vector2(100, 0)) + } + }, + TickDistanceMultiplier = 0.5, + RepeatCount = 1, + } + } + }, + Mods = withStrictTracking + ? [new OsuModMirror { Reflection = { Value = type } }, new OsuModStrictTracking()] + : [new OsuModMirror { Reflection = { Value = type } }], + PassCondition = () => + { + var slider = this.ChildrenOfType().SingleOrDefault(); + var playfield = this.ChildrenOfType().Single(); + + if (slider == null) + return false; + + return Precision.AlmostEquals(playfield.ToLocalSpace(slider.HeadCircle.ScreenSpaceDrawQuad.Centre), slider.HitObject.Position) + && Precision.AlmostEquals(playfield.ToLocalSpace(slider.TailCircle.ScreenSpaceDrawQuad.Centre), slider.HitObject.Position) + && Precision.AlmostEquals(playfield.ToLocalSpace(slider.NestedHitObjects.OfType().Single().ScreenSpaceDrawQuad.Centre), + slider.HitObject.Position + slider.HitObject.Path.PositionAt(1)) + && Precision.AlmostEquals(playfield.ToLocalSpace(slider.NestedHitObjects.OfType().First().ScreenSpaceDrawQuad.Centre), + slider.HitObject.Position + slider.HitObject.Path.PositionAt(0.7f)); + } + }); + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModNoScope.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModNoScope.cs index 9dfa76fc8e..d3996ebc3b 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModNoScope.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModNoScope.cs @@ -46,7 +46,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods StartTime = 5000, } }, - Breaks = new List + Breaks = { new BreakPeriod(2000, 4000), } diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSingleTap.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSingleTap.cs index 402c680b46..bd2b205ac8 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSingleTap.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSingleTap.cs @@ -132,7 +132,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods Autoplay = false, Beatmap = new Beatmap { - Breaks = new List + Breaks = { new BreakPeriod(500, 2000), }, diff --git a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs index e35cf10d95..efda3fa369 100644 --- a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs @@ -15,22 +15,22 @@ namespace osu.Game.Rulesets.Osu.Tests { protected override string ResourceAssembly => "osu.Game.Rulesets.Osu.Tests"; - [TestCase(6.710442985146793d, 239, "diffcalc-test")] - [TestCase(1.4386882251130073d, 54, "zero-length-sliders")] - [TestCase(0.42506480230838789d, 4, "very-fast-slider")] - [TestCase(0.14102693012101306d, 2, "nan-slider")] + [TestCase(6.7171144000821119d, 239, "diffcalc-test")] + [TestCase(1.4485749025771304d, 54, "zero-length-sliders")] + [TestCase(0.42630400627180914d, 4, "very-fast-slider")] + [TestCase(0.14143808967817237d, 2, "nan-slider")] public void Test(double expectedStarRating, int expectedMaxCombo, string name) => base.Test(expectedStarRating, expectedMaxCombo, name); - [TestCase(8.9742952703071666d, 239, "diffcalc-test")] - [TestCase(1.743180218215227d, 54, "zero-length-sliders")] - [TestCase(0.55071082800473514d, 4, "very-fast-slider")] + [TestCase(8.9825709931204205d, 239, "diffcalc-test")] + [TestCase(1.7550169162648608d, 54, "zero-length-sliders")] + [TestCase(0.55231632896800109d, 4, "very-fast-slider")] public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name) => Test(expectedStarRating, expectedMaxCombo, name, new OsuModDoubleTime()); - [TestCase(6.710442985146793d, 239, "diffcalc-test")] - [TestCase(1.4386882251130073d, 54, "zero-length-sliders")] - [TestCase(0.42506480230838789d, 4, "very-fast-slider")] + [TestCase(6.7171144000821119d, 239, "diffcalc-test")] + [TestCase(1.4485749025771304d, 54, "zero-length-sliders")] + [TestCase(0.42630400627180914d, 4, "very-fast-slider")] public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name) => Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic()); diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs index 4db66fde4b..17f365f820 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs @@ -88,6 +88,21 @@ namespace osu.Game.Rulesets.Osu.Tests AddAssert("trail is disjoint", () => this.ChildrenOfType().Single().DisjointTrail, () => Is.True); } + [Test] + public void TestClickExpand() + { + createTest(() => new Container + { + RelativeSizeAxes = Axes.Both, + Scale = new Vector2(10), + Child = new CursorTrail(), + }); + + AddStep("expand", () => this.ChildrenOfType().Single().NewPartScale = new Vector2(3)); + AddWaitStep("let the cursor trail draw a bit", 5); + AddStep("contract", () => this.ChildrenOfType().Single().NewPartScale = Vector2.One); + } + private void createTest(Func createContent) => AddStep("create trail", () => { Clear(); diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs index 5f5596cbb3..a239f671af 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs @@ -108,7 +108,7 @@ namespace osu.Game.Rulesets.Osu.Tests private partial class TestDrawableOsuJudgement : DrawableOsuJudgement { public new SkinnableSprite Lighting => base.Lighting; - public new SkinnableDrawable JudgementBody => base.JudgementBody; + public new SkinnableDrawable? JudgementBody => base.JudgementBody; } } } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs index e6696032ae..98113a6513 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs @@ -161,9 +161,9 @@ namespace osu.Game.Rulesets.Osu.Tests pressed = value; if (value) - OnPressed(new KeyBindingPressEvent(GetContainingInputManager().CurrentState, OsuAction.LeftButton)); + OnPressed(new KeyBindingPressEvent(GetContainingInputManager()!.CurrentState, OsuAction.LeftButton)); else - OnReleased(new KeyBindingReleaseEvent(GetContainingInputManager().CurrentState, OsuAction.LeftButton)); + OnReleased(new KeyBindingReleaseEvent(GetContainingInputManager()!.CurrentState, OsuAction.LeftButton)); } } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleArea.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleArea.cs index 71174e3295..5cac9843b8 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleArea.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleArea.cs @@ -100,7 +100,7 @@ namespace osu.Game.Rulesets.Osu.Tests private void scheduleHit() => AddStep("schedule action", () => { double delay = hitCircle.StartTime - hitCircle.HitWindows.WindowFor(HitResult.Great) - Time.Current; - Scheduler.AddDelayed(() => hitAreaReceptor.OnPressed(new KeyBindingPressEvent(GetContainingInputManager().CurrentState, OsuAction.LeftButton)), delay); + Scheduler.AddDelayed(() => hitAreaReceptor.OnPressed(new KeyBindingPressEvent(GetContainingInputManager()!.CurrentState, OsuAction.LeftButton)), delay); }); } } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuAnalysisContainer.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuAnalysisContainer.cs new file mode 100644 index 0000000000..184938ceda --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuAnalysisContainer.cs @@ -0,0 +1,133 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Replays; +using osu.Game.Rulesets.Osu.Configuration; +using osu.Game.Rulesets.Osu.Replays; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.Replays; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public partial class TestSceneOsuAnalysisContainer : OsuTestScene + { + private TestReplayAnalysisOverlay analysisContainer = null!; + private ReplayAnalysisSettings settings = null!; + + [Cached] + private OsuRulesetConfigManager config = new OsuRulesetConfigManager(null, new OsuRuleset().RulesetInfo); + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create analysis container", () => + { + Children = new Drawable[] + { + new OsuPlayfieldAdjustmentContainer + { + Child = analysisContainer = new TestReplayAnalysisOverlay(fabricateReplay()), + }, + settings = new ReplayAnalysisSettings(config), + }; + + settings.ShowClickMarkers.Value = false; + settings.ShowAimMarkers.Value = false; + settings.ShowCursorPath.Value = false; + }); + } + + [Test] + public void TestEverythingOn() + { + AddStep("enable everything", () => + { + settings.ShowClickMarkers.Value = true; + settings.ShowAimMarkers.Value = true; + settings.ShowCursorPath.Value = true; + }); + } + + [Test] + public void TestHitMarkers() + { + AddStep("enable hit markers", () => settings.ShowClickMarkers.Value = true); + AddUntilStep("hit markers visible", () => analysisContainer.HitMarkersVisible); + AddStep("disable hit markers", () => settings.ShowClickMarkers.Value = false); + AddUntilStep("hit markers not visible", () => !analysisContainer.HitMarkersVisible); + } + + [Test] + public void TestAimMarker() + { + AddStep("enable aim markers", () => settings.ShowAimMarkers.Value = true); + AddUntilStep("aim markers visible", () => analysisContainer.AimMarkersVisible); + AddStep("disable aim markers", () => settings.ShowAimMarkers.Value = false); + AddUntilStep("aim markers not visible", () => !analysisContainer.AimMarkersVisible); + } + + [Test] + public void TestAimLines() + { + AddStep("enable aim lines", () => settings.ShowCursorPath.Value = true); + AddUntilStep("aim lines visible", () => analysisContainer.AimLinesVisible); + AddStep("disable aim lines", () => settings.ShowCursorPath.Value = false); + AddUntilStep("aim lines not visible", () => !analysisContainer.AimLinesVisible); + } + + private Replay fabricateReplay() + { + var frames = new List(); + var random = new Random(); + int posX = 250; + int posY = 250; + + var actions = new HashSet(); + + for (int i = 0; i < 1000; i++) + { + posX = Math.Clamp(posX + random.Next(-20, 21), -100, 600); + posY = Math.Clamp(posY + random.Next(-20, 21), -100, 600); + + if (random.NextDouble() > (actions.Count == 0 ? 0.9 : 0.95)) + { + actions.Add(random.NextDouble() > 0.5 ? OsuAction.LeftButton : OsuAction.RightButton); + } + else if (random.NextDouble() > 0.7) + { + actions.Remove(random.NextDouble() > 0.5 ? OsuAction.LeftButton : OsuAction.RightButton); + } + + frames.Add(new OsuReplayFrame + { + Time = Time.Current + i * 15, + Position = new Vector2(posX, posY), + Actions = actions.ToList(), + }); + } + + return new Replay { Frames = frames }; + } + + private partial class TestReplayAnalysisOverlay : ReplayAnalysisOverlay + { + public TestReplayAnalysisOverlay(Replay replay) + : base(replay) + { + } + + public bool HitMarkersVisible => ClickMarkers?.Alpha > 0 && ClickMarkers.Entries.Any(); + public bool AimMarkersVisible => FrameMarkers?.Alpha > 0 && FrameMarkers.Entries.Any(); + public bool AimLinesVisible => CursorPath?.Alpha > 0 && CursorPath.Vertices.Count > 1; + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuTouchInput.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuTouchInput.cs index 5bf7c0326a..bf0ab8efa0 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuTouchInput.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuTouchInput.cs @@ -19,6 +19,7 @@ using osu.Game.Beatmaps.ControlPoints; using osu.Game.Configuration; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Osu.UI.Cursor; using osu.Game.Screens.Play.HUD; using osu.Game.Tests.Visual; @@ -578,6 +579,24 @@ namespace osu.Game.Rulesets.Osu.Tests assertKeyCounter(1, 1); } + [Test] + public void TestTouchJudgedCircle() + { + addHitCircleAt(TouchSource.Touch1); + addHitCircleAt(TouchSource.Touch2); + + beginTouch(TouchSource.Touch1); + endTouch(TouchSource.Touch1); + + // Hold the second touch (this becomes the primary touch). + beginTouch(TouchSource.Touch2); + + // Touch again on the first circle. + // Because it's been judged, the cursor should not move here. + beginTouch(TouchSource.Touch1); + checkPosition(TouchSource.Touch2); + } + private void addHitCircleAt(TouchSource source) { AddStep($"Add circle at {source}", () => @@ -590,6 +609,7 @@ namespace osu.Game.Rulesets.Osu.Tests { Clock = new FramedClock(new ManualClock()), Position = mainContent.ToLocalSpace(getSanePositionForSource(source)), + CheckHittable = (_, _, _) => ClickAction.Hit }); }); } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs index 4600db8174..b18c77e8ee 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs @@ -156,6 +156,7 @@ namespace osu.Game.Rulesets.Osu.Tests { slider = (DrawableSlider)createSlider(repeats: 1); Add(slider); + slider.HitObject.NodeSamples.Clear(); }); AddStep("change samples", () => slider.HitObject.Samples = new[] diff --git a/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj b/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj index ea54c8d313..5ea231e606 100644 --- a/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj +++ b/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj @@ -1,7 +1,7 @@  - + diff --git a/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapProcessor.cs b/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapProcessor.cs index 9cc0a8c414..0e77553177 100644 --- a/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapProcessor.cs +++ b/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapProcessor.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Linq; using osu.Framework.Graphics; using osu.Game.Beatmaps; @@ -41,22 +42,27 @@ namespace osu.Game.Rulesets.Osu.Beatmaps { base.PostProcess(); - var osuBeatmap = (Beatmap)Beatmap; + ApplyStacking(Beatmap); + } - if (osuBeatmap.HitObjects.Count > 0) + internal static void ApplyStacking(IBeatmap beatmap) + { + var hitObjects = beatmap.HitObjects as List ?? beatmap.HitObjects.OfType().ToList(); + + if (hitObjects.Count > 0) { // Reset stacking - foreach (var h in osuBeatmap.HitObjects) + foreach (var h in hitObjects) h.StackHeight = 0; - if (Beatmap.BeatmapInfo.BeatmapVersion >= 6) - applyStacking(osuBeatmap, 0, osuBeatmap.HitObjects.Count - 1); + if (beatmap.BeatmapInfo.BeatmapVersion >= 6) + applyStacking(beatmap.BeatmapInfo, hitObjects, 0, hitObjects.Count - 1); else - applyStackingOld(osuBeatmap); + applyStackingOld(beatmap.BeatmapInfo, hitObjects); } } - private void applyStacking(Beatmap beatmap, int startIndex, int endIndex) + private static void applyStacking(BeatmapInfo beatmapInfo, List hitObjects, int startIndex, int endIndex) { ArgumentOutOfRangeException.ThrowIfGreaterThan(startIndex, endIndex); ArgumentOutOfRangeException.ThrowIfNegative(startIndex); @@ -64,24 +70,24 @@ namespace osu.Game.Rulesets.Osu.Beatmaps int extendedEndIndex = endIndex; - if (endIndex < beatmap.HitObjects.Count - 1) + if (endIndex < hitObjects.Count - 1) { // Extend the end index to include objects they are stacked on for (int i = endIndex; i >= startIndex; i--) { int stackBaseIndex = i; - for (int n = stackBaseIndex + 1; n < beatmap.HitObjects.Count; n++) + for (int n = stackBaseIndex + 1; n < hitObjects.Count; n++) { - OsuHitObject stackBaseObject = beatmap.HitObjects[stackBaseIndex]; + OsuHitObject stackBaseObject = hitObjects[stackBaseIndex]; if (stackBaseObject is Spinner) break; - OsuHitObject objectN = beatmap.HitObjects[n]; + OsuHitObject objectN = hitObjects[n]; if (objectN is Spinner) continue; double endTime = stackBaseObject.GetEndTime(); - double stackThreshold = objectN.TimePreempt * beatmap.BeatmapInfo.StackLeniency; + double stackThreshold = objectN.TimePreempt * beatmapInfo.StackLeniency; if (objectN.StartTime - endTime > stackThreshold) // We are no longer within stacking range of the next object. @@ -100,7 +106,7 @@ namespace osu.Game.Rulesets.Osu.Beatmaps if (stackBaseIndex > extendedEndIndex) { extendedEndIndex = stackBaseIndex; - if (extendedEndIndex == beatmap.HitObjects.Count - 1) + if (extendedEndIndex == hitObjects.Count - 1) break; } } @@ -123,10 +129,10 @@ namespace osu.Game.Rulesets.Osu.Beatmaps * 2 and 1 will be ignored in the i loop because they already have a stack value. */ - OsuHitObject objectI = beatmap.HitObjects[i]; + OsuHitObject objectI = hitObjects[i]; if (objectI.StackHeight != 0 || objectI is Spinner) continue; - double stackThreshold = objectI.TimePreempt * beatmap.BeatmapInfo.StackLeniency; + double stackThreshold = objectI.TimePreempt * beatmapInfo.StackLeniency; /* If this object is a hitcircle, then we enter this "special" case. * It either ends with a stack of hitcircles only, or a stack of hitcircles that are underneath a slider. @@ -136,7 +142,7 @@ namespace osu.Game.Rulesets.Osu.Beatmaps { while (--n >= 0) { - OsuHitObject objectN = beatmap.HitObjects[n]; + OsuHitObject objectN = hitObjects[n]; if (objectN is Spinner) continue; double endTime = objectN.GetEndTime(); @@ -164,7 +170,7 @@ namespace osu.Game.Rulesets.Osu.Beatmaps for (int j = n + 1; j <= i; j++) { // For each object which was declared under this slider, we will offset it to appear *below* the slider end (rather than above). - OsuHitObject objectJ = beatmap.HitObjects[j]; + OsuHitObject objectJ = hitObjects[j]; if (Vector2Extensions.Distance(objectN.EndPosition, objectJ.Position) < stack_distance) objectJ.StackHeight -= offset; } @@ -191,7 +197,7 @@ namespace osu.Game.Rulesets.Osu.Beatmaps */ while (--n >= startIndex) { - OsuHitObject objectN = beatmap.HitObjects[n]; + OsuHitObject objectN = hitObjects[n]; if (objectN is Spinner) continue; if (objectI.StartTime - objectN.StartTime > stackThreshold) @@ -208,11 +214,11 @@ namespace osu.Game.Rulesets.Osu.Beatmaps } } - private void applyStackingOld(Beatmap beatmap) + private static void applyStackingOld(BeatmapInfo beatmapInfo, List hitObjects) { - for (int i = 0; i < beatmap.HitObjects.Count; i++) + for (int i = 0; i < hitObjects.Count; i++) { - OsuHitObject currHitObject = beatmap.HitObjects[i]; + OsuHitObject currHitObject = hitObjects[i]; if (currHitObject.StackHeight != 0 && !(currHitObject is Slider)) continue; @@ -220,11 +226,11 @@ namespace osu.Game.Rulesets.Osu.Beatmaps double startTime = currHitObject.GetEndTime(); int sliderStack = 0; - for (int j = i + 1; j < beatmap.HitObjects.Count; j++) + for (int j = i + 1; j < hitObjects.Count; j++) { - double stackThreshold = beatmap.HitObjects[i].TimePreempt * beatmap.BeatmapInfo.StackLeniency; + double stackThreshold = hitObjects[i].TimePreempt * beatmapInfo.StackLeniency; - if (beatmap.HitObjects[j].StartTime - stackThreshold > startTime) + if (hitObjects[j].StartTime - stackThreshold > startTime) break; // The start position of the hitobject, or the position at the end of the path if the hitobject is a slider @@ -239,17 +245,17 @@ namespace osu.Game.Rulesets.Osu.Beatmaps // Effects of this can be seen on https://osu.ppy.sh/beatmapsets/243#osu/1146 at sliders around 86647 ms, where // if we use `EndTime` here it would result in unexpected stacking. - if (Vector2Extensions.Distance(beatmap.HitObjects[j].Position, currHitObject.Position) < stack_distance) + if (Vector2Extensions.Distance(hitObjects[j].Position, currHitObject.Position) < stack_distance) { currHitObject.StackHeight++; - startTime = beatmap.HitObjects[j].StartTime; + startTime = hitObjects[j].StartTime; } - else if (Vector2Extensions.Distance(beatmap.HitObjects[j].Position, position2) < stack_distance) + else if (Vector2Extensions.Distance(hitObjects[j].Position, position2) < stack_distance) { // Case for sliders - bump notes down and right, rather than up and left. sliderStack++; - beatmap.HitObjects[j].StackHeight -= sliderStack; - startTime = beatmap.HitObjects[j].StartTime; + hitObjects[j].StackHeight -= sliderStack; + startTime = hitObjects[j].StartTime; } } } diff --git a/osu.Game.Rulesets.Osu/Configuration/OsuRulesetConfigManager.cs b/osu.Game.Rulesets.Osu/Configuration/OsuRulesetConfigManager.cs index 2056a50eda..580c7e6bd8 100644 --- a/osu.Game.Rulesets.Osu/Configuration/OsuRulesetConfigManager.cs +++ b/osu.Game.Rulesets.Osu/Configuration/OsuRulesetConfigManager.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Game.Configuration; using osu.Game.Rulesets.Configuration; using osu.Game.Rulesets.UI; @@ -11,7 +9,7 @@ namespace osu.Game.Rulesets.Osu.Configuration { public class OsuRulesetConfigManager : RulesetConfigManager { - public OsuRulesetConfigManager(SettingsStore settings, RulesetInfo ruleset, int? variant = null) + public OsuRulesetConfigManager(SettingsStore? settings, RulesetInfo ruleset, int? variant = null) : base(settings, ruleset, variant) { } @@ -24,6 +22,12 @@ namespace osu.Game.Rulesets.Osu.Configuration SetDefault(OsuRulesetSetting.ShowCursorTrail, true); SetDefault(OsuRulesetSetting.ShowCursorRipples, false); SetDefault(OsuRulesetSetting.PlayfieldBorderStyle, PlayfieldBorderStyle.None); + + SetDefault(OsuRulesetSetting.ReplayClickMarkersEnabled, false); + SetDefault(OsuRulesetSetting.ReplayFrameMarkersEnabled, false); + SetDefault(OsuRulesetSetting.ReplayCursorPathEnabled, false); + SetDefault(OsuRulesetSetting.ReplayCursorHideEnabled, false); + SetDefault(OsuRulesetSetting.ReplayAnalysisDisplayLength, 800); } } @@ -34,5 +38,12 @@ namespace osu.Game.Rulesets.Osu.Configuration ShowCursorTrail, ShowCursorRipples, PlayfieldBorderStyle, + + // Replay + ReplayClickMarkersEnabled, + ReplayFrameMarkersEnabled, + ReplayCursorPathEnabled, + ReplayCursorHideEnabled, + ReplayAnalysisDisplayLength, } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs index 3d1939acac..9816f6d0a4 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs @@ -3,6 +3,7 @@ using System; using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Objects; @@ -33,6 +34,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators var osuLastObj = (OsuDifficultyHitObject)current.Previous(0); var osuLastLastObj = (OsuDifficultyHitObject)current.Previous(1); + const int radius = OsuDifficultyHitObject.NORMALISED_RADIUS; + const int diameter = OsuDifficultyHitObject.NORMALISED_DIAMETER; + // Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle. double currVelocity = osuCurrObj.LazyJumpDistance / osuCurrObj.StrainTime; @@ -77,14 +81,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators wideAngleBonus = calcWideAngleBonus(currAngle); acuteAngleBonus = calcAcuteAngleBonus(currAngle); - if (osuCurrObj.StrainTime > 100) // Only buff deltaTime exceeding 300 bpm 1/2. + if (DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.StrainTime, 2) < 300) // Only buff deltaTime exceeding 300 bpm 1/2. acuteAngleBonus = 0; else { acuteAngleBonus *= calcAcuteAngleBonus(lastAngle) // Multiply by previous angle, we don't want to buff unless this is a wiggle type pattern. - * Math.Min(angleBonus, 125 / osuCurrObj.StrainTime) // The maximum velocity we buff is equal to 125 / strainTime + * Math.Min(angleBonus, diameter * 1.25 / osuCurrObj.StrainTime) // The maximum velocity we buff is equal to 125 / strainTime * Math.Pow(Math.Sin(Math.PI / 2 * Math.Min(1, (100 - osuCurrObj.StrainTime) / 25)), 2) // scale buff from 150 bpm 1/4 to 200 bpm 1/4 - * Math.Pow(Math.Sin(Math.PI / 2 * (Math.Clamp(osuCurrObj.LazyJumpDistance, 50, 100) - 50) / 50), 2); // Buff distance exceeding 50 (radius) up to 100 (diameter). + * Math.Pow(Math.Sin(Math.PI / 2 * (Math.Clamp(osuCurrObj.LazyJumpDistance, radius, diameter) - radius) / radius), 2); // Buff distance exceeding radius up to diameter. } // Penalize wide angles if they're repeated, reducing the penalty as the lastAngle gets more acute. @@ -104,7 +108,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators double distRatio = Math.Pow(Math.Sin(Math.PI / 2 * Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity)), 2); // Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing. - double overlapVelocityBuff = Math.Min(125 / Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime), Math.Abs(prevVelocity - currVelocity)); + double overlapVelocityBuff = Math.Min(diameter * 1.25 / Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime), Math.Abs(prevVelocity - currVelocity)); velocityChangeBonus = overlapVelocityBuff * distRatio; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs index ed7c60ccf6..e4cbdcd3a3 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs @@ -17,7 +17,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators private const double overlap_multiplier = 1; - public static double EvaluateDensityOf(DifficultyHitObject current, bool applyDistanceNerf = true) + private const double slider_body_length_multiplier = 1.3; + + public static double EvaluateDensityOf(DifficultyHitObject current, bool applyDistanceNerf = true, bool applySliderbodyDensity = true, double angleNerfMultiplier = 1.0) { var currObj = (OsuDifficultyHitObject)current; @@ -38,7 +40,28 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators double loopDifficulty = currObj.OpacityAt(loopObj.BaseObject.StartTime, false); // Small distances means objects may be cheesed, so it doesn't matter whether they are arranged confusingly. - if (applyDistanceNerf) loopDifficulty *= (logistic((loopObj.MinimumJumpDistance - 80) / 10) + 0.2) / 1.2; + if (applyDistanceNerf) loopDifficulty *= (logistic((loopObj.LazyJumpDistance - 80) / 10) + 0.2) / 1.2; + + // Additional buff for long sliderbodies. OVERBUFFED ON PURPOSE + if (applySliderbodyDensity && loopObj.BaseObject is Slider slider) + { + // In radiuses, with minimal of 1 + double sliderBodyLength = Math.Max(1, slider.Velocity * slider.SpanDuration / slider.Radius); + + // Bandaid to fix abuze + sliderBodyLength = Math.Min(sliderBodyLength, 1 + slider.LazyTravelDistance / 8); + + // The maximum is 3x buff + double sliderBodyBuff = Math.Log10(sliderBodyLength); + + // Limit the max buff to prevent abuse with very long sliders. + // With explicit coverage of cases like one very long slider on the map, or just very few objects visible before/after. + double maxBuff = 0.5; + if (i > 0) maxBuff += 1; + if (i < readingObjects.Count - 1) maxBuff += 1; + + loopDifficulty *= 1 + slider_body_length_multiplier * Math.Min(sliderBodyBuff, maxBuff); + } // Reduce density bonus for this object if they're too apart in time // Nerf starts on 1500ms and reaches maximum (*=0) on 3000ms @@ -63,12 +86,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators density += loopDifficulty; // Angles nerf - double currAngleNerf = (loopObj.AnglePredictability / 2) + 0.5; + // Why it's /2 + 0.5? + // Because there was a bug initially that made angle predictability to be from 0.5 to 1 + // And removing this bug caused balance to be destroyed + double angleNerf = (loopObj.AnglePredictability / 2) + 0.5; - // Apply the nerf only when it's repeated - double angleNerf = currAngleNerf; - - densityAnglesNerf += angleNerf * loopDifficulty; + densityAnglesNerf += angleNerf * loopDifficulty * angleNerfMultiplier; prevObj0 = loopObj; } @@ -109,6 +132,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators var sortedDifficulties = overlapDifficulties.OrderByDescending(d => d.Difficulty).ToList(); + // Nerf overlap values of easier notes that are in the same place as hard notes for (int i = 0; i < sortedDifficulties.Count; i++) { var harderObject = sortedDifficulties[i]; @@ -136,6 +160,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators const double threshold = 0.6; double weight = 1.0; + // Sum the overlap values to get difficulty foreach (var diffObject in sortedDifficulties.Where(d => d.Difficulty > threshold).OrderByDescending(d => d.Difficulty)) { // Add weighted difficulty @@ -150,7 +175,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators if (current.BaseObject is Spinner || current.Index == 0) return 0; - double difficulty = Math.Pow(4 * Math.Log(Math.Max(1, ((OsuDifficultyHitObject)current).Density)), 2.5); + double difficulty = Math.Pow(4 * Math.Log(Math.Max(1, EvaluateDensityOf(current, true, true))), 2.5); double overlapBonus = EvaluateOverlapDifficultyOf(current) * difficulty; difficulty += overlapBonus; @@ -160,14 +185,51 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators public static double EvaluateAimingDensityFactorOf(DifficultyHitObject current) { - double difficulty = ((OsuDifficultyHitObject)current).Density; + double difficulty = EvaluateDensityOf(current, true, false, 0.5); - return Math.Max(0, Math.Pow(difficulty, 1.5) - 1); + return Math.Max(0, Math.Pow(difficulty, 1.37) - 1); + } + + // Returns value from 0 to 1, where 0 is very predictable and 1 is very unpredictable + public static double EvaluateInpredictabilityOf(DifficultyHitObject current) + { + if (current.BaseObject is Spinner || current.Index == 0 || current.Previous(0).BaseObject is Spinner) + return 0; + + var osuCurrObj = (OsuDifficultyHitObject)current; + var osuLastObj = (OsuDifficultyHitObject)current.Previous(0); + + double currVelocity = osuCurrObj.LazyJumpDistance / osuCurrObj.StrainTime; + double prevVelocity = osuLastObj.LazyJumpDistance / osuLastObj.StrainTime; + + double velocityChangeFactor = 0; + + // https://www.desmos.com/calculator/kqxmqc8pkg + if (currVelocity > 0 || prevVelocity > 0) + { + double velocityChange = Math.Max(0, + Math.Min( + Math.Abs(prevVelocity - currVelocity) - 0.5 * Math.Min(currVelocity, prevVelocity), + Math.Max(((OsuHitObject)osuCurrObj.BaseObject).Radius / Math.Max(osuCurrObj.StrainTime, osuLastObj.StrainTime), Math.Min(currVelocity, prevVelocity)) + )); // Stealed from xexxar + velocityChangeFactor = velocityChange / Math.Max(currVelocity, prevVelocity); // maxiumum is 0.4 + velocityChangeFactor /= 0.4; + } + + // Rhythm difference punishment for velocity and angle bonuses + double rhythmSimilarity = 1 - getRhythmDifference(osuCurrObj.StrainTime, osuLastObj.StrainTime); + + // Make differentiation going from 1/4 to 1/2 and bigger difference + // To 1/3 to 1/2 and smaller difference + rhythmSimilarity = Math.Clamp(rhythmSimilarity, 0.5, 0.75); + rhythmSimilarity = 4 * (rhythmSimilarity - 0.5); + + return velocityChangeFactor * rhythmSimilarity; } private static double getTimeNerfFactor(double deltaTime) => Math.Clamp(2 - deltaTime / (reading_window_size / 2), 0, 1); private static double getRhythmDifference(double t1, double t2) => 1 - Math.Min(t1, t2) / Math.Max(t1, t2); - private static double logistic(double x) => 1 / (1 + Math.Exp(-x)); + private static double logistic(double x) => 1.0 / (1 + Math.Exp(-x)); // Finds the overlapness of the last object for which StartTime lower than target private static double boundBinarySearch(List arr, double target) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs index 05939bb3ab..d503dd2bcc 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs @@ -2,7 +2,10 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; +using System.Linq; using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Objects; @@ -10,8 +13,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators { public static class RhythmEvaluator { - private const int history_time_max = 5000; // 5 seconds of calculatingRhythmBonus max. - private const double rhythm_multiplier = 0.75; + private const int history_time_max = 5 * 1000; // 5 seconds + private const int history_objects_max = 32; + private const double rhythm_overall_multiplier = 0.95; + private const double rhythm_ratio_multiplier = 12.0; /// /// Calculates a rhythm multiplier for the difficulty of the tap associated with historic data of the current . @@ -21,88 +26,200 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators if (current.BaseObject is Spinner) return 0; - int previousIslandSize = 0; - double rhythmComplexitySum = 0; - int islandSize = 1; + + double deltaDifferenceEpsilon = ((OsuDifficultyHitObject)current).HitWindowGreat * 0.3; + + var island = new Island(deltaDifferenceEpsilon); + var previousIsland = new Island(deltaDifferenceEpsilon); + + // we can't use dictionary here because we need to compare island with a tolerance + // which is impossible to pass into the hash comparer + var islandCounts = new List<(Island Island, int Count)>(); + double startRatio = 0; // store the ratio of the current start of an island to buff for tighter rhythms bool firstDeltaSwitch = false; - int historicalNoteCount = Math.Min(current.Index, 32); + int historicalNoteCount = Math.Min(current.Index, history_objects_max); int rhythmStart = 0; while (rhythmStart < historicalNoteCount - 2 && current.StartTime - current.Previous(rhythmStart).StartTime < history_time_max) rhythmStart++; + OsuDifficultyHitObject prevObj = (OsuDifficultyHitObject)current.Previous(rhythmStart); + OsuDifficultyHitObject lastObj = (OsuDifficultyHitObject)current.Previous(rhythmStart + 1); + + // we go from the furthest object back to the current one for (int i = rhythmStart; i > 0; i--) { OsuDifficultyHitObject currObj = (OsuDifficultyHitObject)current.Previous(i - 1); - OsuDifficultyHitObject prevObj = (OsuDifficultyHitObject)current.Previous(i); - OsuDifficultyHitObject lastObj = (OsuDifficultyHitObject)current.Previous(i + 1); - double currHistoricalDecay = (history_time_max - (current.StartTime - currObj.StartTime)) / history_time_max; // scales note 0 to 1 from history to now + // scales note 0 to 1 from history to now + double timeDecay = (history_time_max - (current.StartTime - currObj.StartTime)) / history_time_max; + double noteDecay = (double)(historicalNoteCount - i) / historicalNoteCount; - currHistoricalDecay = Math.Min((double)(historicalNoteCount - i) / historicalNoteCount, currHistoricalDecay); // either we're limited by time or limited by object count. + double currHistoricalDecay = Math.Min(noteDecay, timeDecay); // either we're limited by time or limited by object count. double currDelta = currObj.StrainTime; double prevDelta = prevObj.StrainTime; double lastDelta = lastObj.StrainTime; - double currRatio = 1.0 + 6.0 * Math.Min(0.5, Math.Pow(Math.Sin(Math.PI / (Math.Min(prevDelta, currDelta) / Math.Max(prevDelta, currDelta))), 2)); // fancy function to calculate rhythmbonuses. - double windowPenalty = Math.Min(1, Math.Max(0, Math.Abs(prevDelta - currDelta) - currObj.HitWindowGreat * 0.3) / (currObj.HitWindowGreat * 0.3)); + // calculate how much current delta difference deserves a rhythm bonus + // this function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e 100 and 200) + double deltaDifferenceRatio = Math.Min(prevDelta, currDelta) / Math.Max(prevDelta, currDelta); + double currRatio = 1.0 + rhythm_ratio_multiplier * Math.Min(0.5, Math.Pow(Math.Sin(Math.PI / deltaDifferenceRatio), 2)); - windowPenalty = Math.Min(1, windowPenalty); + // reduce ratio bonus if delta difference is too big + double fraction = Math.Max(prevDelta / currDelta, currDelta / prevDelta); + double fractionMultiplier = Math.Clamp(2.0 - fraction / 8.0, 0.0, 1.0); - double effectiveRatio = windowPenalty * currRatio; + double windowPenalty = Math.Min(1, Math.Max(0, Math.Abs(prevDelta - currDelta) - deltaDifferenceEpsilon) / deltaDifferenceEpsilon); + + double effectiveRatio = windowPenalty * currRatio * fractionMultiplier; if (firstDeltaSwitch) { - if (!(prevDelta > 1.25 * currDelta || prevDelta * 1.25 < currDelta)) + if (Math.Abs(prevDelta - currDelta) < deltaDifferenceEpsilon) { - if (islandSize < 7) - islandSize++; // island is still progressing, count size. + // island is still progressing + island.AddDelta((int)currDelta); } else { - if (current.Previous(i - 1).BaseObject is Slider) // bpm change is into slider, this is easy acc window + // bpm change is into slider, this is easy acc window + if (currObj.BaseObject is Slider) effectiveRatio *= 0.125; - if (current.Previous(i).BaseObject is Slider) // bpm change was from a slider, this is easier typically than circle -> circle - effectiveRatio *= 0.25; + // bpm change was from a slider, this is easier typically than circle -> circle + // unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty than bursts without sliders + if (prevObj.BaseObject is Slider) + effectiveRatio *= 0.3; - if (previousIslandSize == islandSize) // repeated island size (ex: triplet -> triplet) - effectiveRatio *= 0.25; + // repeated island polarity (2 -> 4, 3 -> 5) + if (island.IsSimilarPolarity(previousIsland)) + effectiveRatio *= 0.5; - if (previousIslandSize % 2 == islandSize % 2) // repeated island polartiy (2 -> 4, 3 -> 5) - effectiveRatio *= 0.50; - - if (lastDelta > prevDelta + 10 && prevDelta > currDelta + 10) // previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this. + // previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this. + if (lastDelta > prevDelta + deltaDifferenceEpsilon && prevDelta > currDelta + deltaDifferenceEpsilon) effectiveRatio *= 0.125; - rhythmComplexitySum += Math.Sqrt(effectiveRatio * startRatio) * currHistoricalDecay * Math.Sqrt(4 + islandSize) / 2 * Math.Sqrt(4 + previousIslandSize) / 2; + // repeated island size (ex: triplet -> triplet) + // TODO: remove this nerf since its staying here only for balancing purposes because of the flawed ratio calculation + if (previousIsland.DeltaCount == island.DeltaCount) + effectiveRatio *= 0.5; + + var islandCount = islandCounts.FirstOrDefault(x => x.Island.Equals(island)); + + if (islandCount != default) + { + int countIndex = islandCounts.IndexOf(islandCount); + + // only add island to island counts if they're going one after another + if (previousIsland.Equals(island)) + islandCount.Count++; + + // repeated island (ex: triplet -> triplet) + double power = DifficultyCalculationUtils.Logistic(island.Delta, maxValue: 2.75, multiplier: 0.24, midpointOffset: 58.33); + effectiveRatio *= Math.Min(3.0 / islandCount.Count, Math.Pow(1.0 / islandCount.Count, power)); + + islandCounts[countIndex] = (islandCount.Island, islandCount.Count); + } + else + { + islandCounts.Add((island, 1)); + } + + // scale down the difficulty if the object is doubletappable + double doubletapness = prevObj.GetDoubletapness(currObj); + effectiveRatio *= 1 - doubletapness * 0.75; + + rhythmComplexitySum += Math.Sqrt(effectiveRatio * startRatio) * currHistoricalDecay; startRatio = effectiveRatio; - previousIslandSize = islandSize; // log the last island size. + previousIsland = island; - if (prevDelta * 1.25 < currDelta) // we're slowing down, stop counting - firstDeltaSwitch = false; // if we're speeding up, this stays true and we keep counting island size. + if (prevDelta + deltaDifferenceEpsilon < currDelta) // we're slowing down, stop counting + firstDeltaSwitch = false; // if we're speeding up, this stays true and we keep counting island size. - islandSize = 1; + island = new Island((int)currDelta, deltaDifferenceEpsilon); } } - else if (prevDelta > 1.25 * currDelta) // we want to be speeding up. + else if (prevDelta > currDelta + deltaDifferenceEpsilon) // we're speeding up { // Begin counting island until we change speed again. firstDeltaSwitch = true; + + // bpm change is into slider, this is easy acc window + if (currObj.BaseObject is Slider) + effectiveRatio *= 0.6; + + // bpm change was from a slider, this is easier typically than circle -> circle + // unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty than bursts without sliders + if (prevObj.BaseObject is Slider) + effectiveRatio *= 0.6; + startRatio = effectiveRatio; - islandSize = 1; + + island = new Island((int)currDelta, deltaDifferenceEpsilon); } + + lastObj = prevObj; + prevObj = currObj; } - return Math.Sqrt(4 + rhythmComplexitySum * rhythm_multiplier) / 2; //produces multiplier that can be applied to strain. range [1, infinity) (not really though) + return Math.Sqrt(4 + rhythmComplexitySum * rhythm_overall_multiplier) / 2.0; // produces multiplier that can be applied to strain. range [1, infinity) (not really though) + } + + private class Island : IEquatable + { + private readonly double deltaDifferenceEpsilon; + + public Island(double epsilon) + { + deltaDifferenceEpsilon = epsilon; + } + + public Island(int delta, double epsilon) + { + deltaDifferenceEpsilon = epsilon; + Delta = Math.Max(delta, OsuDifficultyHitObject.MIN_DELTA_TIME); + DeltaCount++; + } + + public int Delta { get; private set; } = int.MaxValue; + public int DeltaCount { get; private set; } + + public void AddDelta(int delta) + { + if (Delta == int.MaxValue) + Delta = Math.Max(delta, OsuDifficultyHitObject.MIN_DELTA_TIME); + + DeltaCount++; + } + + public bool IsSimilarPolarity(Island other) + { + // TODO: consider islands to be of similar polarity only if they're having the same average delta (we don't want to consider 3 singletaps similar to a triple) + // naively adding delta check here breaks _a lot_ of maps because of the flawed ratio calculation + return DeltaCount % 2 == other.DeltaCount % 2; + } + + public bool Equals(Island? other) + { + if (other == null) + return false; + + return Math.Abs(Delta - other.Delta) < deltaDifferenceEpsilon && + DeltaCount == other.DeltaCount; + } + + public override string ToString() + { + return $"{Delta}x{DeltaCount}"; + } } } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs index 2df383aaa8..c5a9675b3d 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs @@ -3,6 +3,7 @@ using System; using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Objects; @@ -10,9 +11,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators { public static class SpeedEvaluator { - private const double single_spacing_threshold = 125; - private const double min_speed_bonus = 75; // ~200BPM + private const double single_spacing_threshold = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25; // 1.25 circles distance between centers + private const double min_speed_bonus = 200; // 200 BPM 1/4th private const double speed_balancing_factor = 40; + private const double distance_multiplier = 0.85; /// /// Evaluates the difficulty of tapping the current object, based on: @@ -30,36 +32,35 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators // derive strainTime for calculation var osuCurrObj = (OsuDifficultyHitObject)current; var osuPrevObj = current.Index > 0 ? (OsuDifficultyHitObject)current.Previous(0) : null; - var osuNextObj = (OsuDifficultyHitObject?)current.Next(0); double strainTime = osuCurrObj.StrainTime; - double doubletapness = 1; - - // Nerf doubletappable doubles. - if (osuNextObj != null) - { - double currDeltaTime = Math.Max(1, osuCurrObj.DeltaTime); - double nextDeltaTime = Math.Max(1, osuNextObj.DeltaTime); - double deltaDifference = Math.Abs(nextDeltaTime - currDeltaTime); - double speedRatio = currDeltaTime / Math.Max(currDeltaTime, deltaDifference); - double windowRatio = Math.Pow(Math.Min(1, currDeltaTime / osuCurrObj.HitWindowGreat), 2); - doubletapness = Math.Pow(speedRatio, 1 - windowRatio); - } + double doubletapness = 1.0 - osuCurrObj.GetDoubletapness((OsuDifficultyHitObject?)osuCurrObj.Next(0)); // Cap deltatime to the OD 300 hitwindow. // 0.93 is derived from making sure 260bpm OD8 streams aren't nerfed harshly, whilst 0.92 limits the effect of the cap. strainTime /= Math.Clamp((strainTime / osuCurrObj.HitWindowGreat) / 0.93, 0.92, 1); - // derive speedBonus for calculation - double speedBonus = 1.0; + // speedBonus will be 0.0 for BPM < 200 + double speedBonus = 0.0; - if (strainTime < min_speed_bonus) - speedBonus = 1 + 0.75 * Math.Pow((min_speed_bonus - strainTime) / speed_balancing_factor, 2); + // Add additional scaling bonus for streams/bursts higher than 200bpm + if (DifficultyCalculationUtils.MillisecondsToBPM(strainTime) > min_speed_bonus) + speedBonus = 0.75 * Math.Pow((DifficultyCalculationUtils.BPMToMilliseconds(min_speed_bonus) - strainTime) / speed_balancing_factor, 2); double travelDistance = osuPrevObj?.TravelDistance ?? 0; - double distance = Math.Min(single_spacing_threshold, travelDistance + osuCurrObj.MinimumJumpDistance); + double distance = travelDistance + osuCurrObj.MinimumJumpDistance; - return (speedBonus + speedBonus * Math.Pow(distance / single_spacing_threshold, 3.5)) * doubletapness / strainTime; + // Cap distance at single_spacing_threshold + distance = Math.Min(distance, single_spacing_threshold); + + // Max distance bonus is 1 * `distance_multiplier` at single_spacing_threshold + double distanceBonus = Math.Pow(distance / single_spacing_threshold, 3.95) * distance_multiplier; + + // Base difficulty with all bonuses + double difficulty = (1 + speedBonus + distanceBonus) * 1000 / strainTime; + + // Apply penalty if there's doubletappable doubles + return difficulty * doubletapness; } } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs index 2a2a0e1dc2..12f4540924 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs @@ -44,12 +44,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty [JsonProperty("flashlight_difficulty")] public double FlashlightDifficulty { get; set; } - /// - /// The difficulty corresponding to the flashlight skill with HD (used in capping cognition performance). - /// - [JsonProperty("hidden_flashlight_difficulty")] - public double HiddenFlashlightDifficulty { get; set; } - /// /// Describes how much of is contributed to by hitcircles or sliders. /// A value closer to 1.0 indicates most of is contributed by hitcircles. @@ -58,6 +52,18 @@ namespace osu.Game.Rulesets.Osu.Difficulty [JsonProperty("slider_factor")] public double SliderFactor { get; set; } + [JsonProperty("aim_difficult_strain_count")] + public double AimDifficultStrainCount { get; set; } + + [JsonProperty("speed_difficult_strain_count")] + public double SpeedDifficultStrainCount { get; set; } + + [JsonProperty("low_ar_difficult_strain_count")] + public double LowArDifficultStrainCount { get; set; } + + [JsonProperty("hidden_difficult_strain_count")] + public double HiddenDifficultStrainCount { get; set; } + /// /// The perceived approach rate inclusive of rate-adjusting mods (DT/HT/etc). /// @@ -106,11 +112,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty yield return (ATTRIB_ID_OVERALL_DIFFICULTY, OverallDifficulty); yield return (ATTRIB_ID_APPROACH_RATE, ApproachRate); yield return (ATTRIB_ID_DIFFICULTY, StarRating); - - if (ShouldSerializeFlashlightDifficulty()) - yield return (ATTRIB_ID_FLASHLIGHT, FlashlightDifficulty); - + yield return (ATTRIB_ID_FLASHLIGHT, FlashlightDifficulty); yield return (ATTRIB_ID_SLIDER_FACTOR, SliderFactor); + + yield return (ATTRIB_ID_AIM_DIFFICULT_STRAIN_COUNT, AimDifficultStrainCount); + yield return (ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT, SpeedDifficultStrainCount); yield return (ATTRIB_ID_SPEED_NOTE_COUNT, SpeedNoteCount); } @@ -125,8 +131,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty StarRating = values[ATTRIB_ID_DIFFICULTY]; FlashlightDifficulty = values.GetValueOrDefault(ATTRIB_ID_FLASHLIGHT); SliderFactor = values[ATTRIB_ID_SLIDER_FACTOR]; + AimDifficultStrainCount = values[ATTRIB_ID_AIM_DIFFICULT_STRAIN_COUNT]; + SpeedDifficultStrainCount = values[ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT]; SpeedNoteCount = values[ATTRIB_ID_SPEED_NOTE_COUNT]; - DrainRate = onlineInfo.DrainRate; HitCircleCount = onlineInfo.CircleCount; SliderCount = onlineInfo.SliderCount; diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 1cc31cf2e4..4922002ec7 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -22,10 +22,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty { public class OsuDifficultyCalculator : DifficultyCalculator { - public const double DIFFICULTY_MULTIPLIER = 0.0668; + public const double DIFFICULTY_MULTIPLIER = 0.0675; public const double SUM_POWER = 1.1; public const double FL_SUM_POWER = 1.5; - public override int Version => 20220902; + public override int Version => 20241007; public OsuDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) : base(ruleset, beatmap) @@ -39,21 +39,17 @@ namespace osu.Game.Rulesets.Osu.Difficulty double aimRating = Math.Sqrt(skills[0].DifficultyValue()) * DIFFICULTY_MULTIPLIER; double aimRatingNoSliders = Math.Sqrt(skills[1].DifficultyValue()) * DIFFICULTY_MULTIPLIER; - double speedRating = Math.Sqrt(skills[2].DifficultyValue()) * DIFFICULTY_MULTIPLIER; - double speedNotes = ((Speed)skills[2]).RelevantNoteCount(); - double hiddenFlashlightRating = Math.Sqrt(skills[3].DifficultyValue()) * DIFFICULTY_MULTIPLIER; + double speedRating = Math.Sqrt(skills.OfType().First().DifficultyValue()) * DIFFICULTY_MULTIPLIER; + double speedNotes = skills.OfType().First().RelevantNoteCount(); - double readingLowARRating = Math.Sqrt(skills[4].DifficultyValue()) * DIFFICULTY_MULTIPLIER; + double flashlightRating = Math.Sqrt(skills.OfType().First().DifficultyValue()) * DIFFICULTY_MULTIPLIER; + double readingLowARRating = Math.Sqrt(skills.OfType().First().DifficultyValue()) * DIFFICULTY_MULTIPLIER; double sliderFactor = aimRating > 0 ? aimRatingNoSliders / aimRating : 1; - double flashlightRating = 0; - double baseFlashlightPerformance = 0.0; - if (mods.Any(h => h is OsuModFlashlight)) - { - flashlightRating = Math.Sqrt(skills[5].DifficultyValue()) * DIFFICULTY_MULTIPLIER; - baseFlashlightPerformance = Flashlight.DifficultyToPerformance(flashlightRating); - } + double aimDifficultyStrainCount = skills[0].CountTopWeightedStrains(); + double speedDifficultyStrainCount = skills.OfType().First().CountTopWeightedStrains(); + double lowArDifficultyStrainCount = skills.OfType().First().CountTopWeightedStrains(); if (mods.Any(m => m is OsuModTouchDevice)) { @@ -66,34 +62,36 @@ namespace osu.Game.Rulesets.Osu.Difficulty { aimRating *= 0.9; speedRating = 0.0; + readingLowARRating *= 0.95; flashlightRating *= 0.7; readingLowARRating *= 0.95; } - double baseAimPerformance = OsuStrainSkill.DifficultyToPerformance(aimRating); - double baseSpeedPerformance = OsuStrainSkill.DifficultyToPerformance(speedRating); + double aimPerformance = OsuStrainSkill.DifficultyToPerformance(aimRating); + double speedPerformance = OsuStrainSkill.DifficultyToPerformance(speedRating); // Cognition - double baseReadingLowARPerformance = ReadingLowAR.DifficultyToPerformance(readingLowARRating); - double baseReadingARPerformance = baseReadingLowARPerformance; + double readingLowARPerformance = ReadingLowAR.DifficultyToPerformance(readingLowARRating); + double readingARPerformance = readingLowARPerformance; - double baseFlashlightARPerformance = Math.Pow(Math.Pow(baseFlashlightPerformance, FL_SUM_POWER) + Math.Pow(baseReadingARPerformance, FL_SUM_POWER), 1.0 / FL_SUM_POWER); + double potentialFlashlightPerformance = Flashlight.DifficultyToPerformance(flashlightRating); + double flashlightPerformance = mods.Any(h => h is OsuModFlashlight) ? potentialFlashlightPerformance : 0; + + double flashlightARPerformance = Math.Pow(Math.Pow(flashlightPerformance, FL_SUM_POWER) + Math.Pow(readingARPerformance, FL_SUM_POWER), 1.0 / FL_SUM_POWER); double preempt = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.ApproachRate, 1800, 1200, 450) / clockRate; double drainRate = beatmap.Difficulty.DrainRate; - int maxCombo = beatmap.GetMaxCombo(); int hitCirclesCount = beatmap.HitObjects.Count(h => h is HitCircle); int sliderCount = beatmap.HitObjects.Count(h => h is Slider); int spinnerCount = beatmap.HitObjects.Count(h => h is Spinner); - double cognitionPerformance = baseFlashlightARPerformance; - double mechanicalPerformance = Math.Pow(Math.Pow(baseAimPerformance, SUM_POWER) + Math.Pow(baseSpeedPerformance, SUM_POWER), 1.0 / SUM_POWER); + double cognitionPerformance = flashlightARPerformance; + double mechanicalPerformance = Math.Pow(Math.Pow(aimPerformance, SUM_POWER) + Math.Pow(speedPerformance, SUM_POWER), 1.0 / SUM_POWER); - // Limit cognition by full memorisation difficulty - double maxHiddenFlashlightPerformance = OsuPerformanceCalculator.ComputePerfectFlashlightValue(hiddenFlashlightRating, hitCirclesCount + sliderCount); - cognitionPerformance = OsuPerformanceCalculator.AdjustCognitionPerformance(cognitionPerformance, mechanicalPerformance, maxHiddenFlashlightPerformance); + // Limit cognition by full memorisation difficulty, what is assumed to be mechanicalPerformance + flashlightPerformance + cognitionPerformance = OsuPerformanceCalculator.AdjustCognitionPerformance(cognitionPerformance, mechanicalPerformance, potentialFlashlightPerformance); double basePerformance = mechanicalPerformance + cognitionPerformance; @@ -115,15 +113,17 @@ namespace osu.Game.Rulesets.Osu.Difficulty SpeedNoteCount = speedNotes, ReadingDifficultyLowAR = readingLowARRating, FlashlightDifficulty = flashlightRating, - HiddenFlashlightDifficulty = hiddenFlashlightRating, SliderFactor = sliderFactor, + AimDifficultStrainCount = aimDifficultyStrainCount, + SpeedDifficultStrainCount = speedDifficultyStrainCount, + LowArDifficultStrainCount = lowArDifficultyStrainCount, ApproachRate = IBeatmapDifficultyInfo.InverseDifficultyRange(preempt, 1800, 1200, 450), OverallDifficulty = (80 - hitWindowGreat) / 6, DrainRate = drainRate, - MaxCombo = maxCombo, + MaxCombo = beatmap.GetMaxCombo(), HitCircleCount = hitCirclesCount, SliderCount = sliderCount, - SpinnerCount = spinnerCount, + SpinnerCount = spinnerCount }; return attributes; @@ -151,13 +151,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty new Aim(mods, true), new Aim(mods, false), new Speed(mods), - new HiddenFlashlight(mods), + new Flashlight(mods), new ReadingLowAR(mods), }; - if (mods.Any(h => h is OsuModFlashlight)) - skills.Add(new Flashlight(mods)); - return skills.ToArray(); } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs index 5616ae72e4..d3867837d4 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs @@ -18,8 +18,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty [JsonProperty("accuracy")] public double Accuracy { get; set; } - [JsonProperty("cognition")] - public double Cognition { get; set; } + [JsonProperty("flashlight")] + public double Flashlight { get; set; } + + [JsonProperty("reading")] + public double Reading { get; set; } [JsonProperty("effective_miss_count")] public double EffectiveMissCount { get; set; } @@ -32,7 +35,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty yield return new PerformanceDisplayAttribute(nameof(Aim), "Aim", Aim); yield return new PerformanceDisplayAttribute(nameof(Speed), "Speed", Speed); yield return new PerformanceDisplayAttribute(nameof(Accuracy), "Accuracy", Accuracy); - yield return new PerformanceDisplayAttribute(nameof(Cognition), "Cognition", Cognition); + yield return new PerformanceDisplayAttribute(nameof(Reading), "Reading", Reading); + yield return new PerformanceDisplayAttribute(nameof(Flashlight), "Flashlight Bonus", Flashlight); } } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index b708d35db6..697ab9310d 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -14,7 +14,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty { public class OsuPerformanceCalculator : PerformanceCalculator { - public const double PERFORMANCE_BASE_MULTIPLIER = 1.14; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things. + public const double PERFORMANCE_BASE_MULTIPLIER = 1.114; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things. + + private bool usingClassicSliderAccuracy; private double accuracy; private int scoreMaxCombo; @@ -23,6 +25,19 @@ namespace osu.Game.Rulesets.Osu.Difficulty private int countMeh; private int countMiss; + /// + /// Missed slider ticks that includes missed reverse arrows. Will only be correct on non-classic scores + /// + private int countSliderTickMiss; + + /// + /// Amount of missed slider tails that don't break combo. Will only be correct on non-classic scores + /// + private int countSliderEndsDropped; + + /// + /// Estimated total amount of combo breaks + /// private double effectiveMissCount; public OsuPerformanceCalculator() @@ -34,16 +49,48 @@ namespace osu.Game.Rulesets.Osu.Difficulty { var osuAttributes = (OsuDifficultyAttributes)attributes; + usingClassicSliderAccuracy = score.Mods.OfType().Any(m => m.NoSliderHeadAccuracy.Value); + accuracy = score.Accuracy; scoreMaxCombo = score.MaxCombo; countGreat = score.Statistics.GetValueOrDefault(HitResult.Great); countOk = score.Statistics.GetValueOrDefault(HitResult.Ok); countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh); countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss); - effectiveMissCount = calculateEffectiveMissCount(osuAttributes); + countSliderEndsDropped = osuAttributes.SliderCount - score.Statistics.GetValueOrDefault(HitResult.SliderTailHit); + countSliderTickMiss = score.Statistics.GetValueOrDefault(HitResult.LargeTickMiss); + effectiveMissCount = countMiss; + + if (osuAttributes.SliderCount > 0) + { + if (usingClassicSliderAccuracy) + { + // Consider that full combo is maximum combo minus dropped slider tails since they don't contribute to combo but also don't break it + // In classic scores we can't know the amount of dropped sliders so we estimate to 10% of all sliders on the map + double fullComboThreshold = attributes.MaxCombo - 0.1 * osuAttributes.SliderCount; + + if (scoreMaxCombo < fullComboThreshold) + effectiveMissCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo); + + // In classic scores there can't be more misses than a sum of all non-perfect judgements + effectiveMissCount = Math.Min(effectiveMissCount, totalImperfectHits); + } + else + { + double fullComboThreshold = attributes.MaxCombo - countSliderEndsDropped; + + if (scoreMaxCombo < fullComboThreshold) + effectiveMissCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo); + + // Combine regular misses with tick misses since tick misses break combo as well + effectiveMissCount = Math.Min(effectiveMissCount, countSliderTickMiss + countMiss); + } + } + + effectiveMissCount = Math.Max(countMiss, effectiveMissCount); + effectiveMissCount = Math.Min(totalHits, effectiveMissCount); double multiplier = PERFORMANCE_BASE_MULTIPLIER; - double power = OsuDifficultyCalculator.SUM_POWER; if (score.Mods.Any(m => m is OsuModNoFail)) multiplier *= Math.Max(0.90, 1.0 - 0.02 * effectiveMissCount); @@ -63,33 +110,27 @@ namespace osu.Game.Rulesets.Osu.Difficulty effectiveMissCount = Math.Min(effectiveMissCount + countOk * okMultiplier + countMeh * mehMultiplier, totalHits); } + double power = OsuDifficultyCalculator.SUM_POWER; + double aimValue = computeAimValue(score, osuAttributes); double speedValue = computeSpeedValue(score, osuAttributes); double mechanicalValue = Math.Pow(Math.Pow(aimValue, power) + Math.Pow(speedValue, power), 1.0 / power); // Cognition - // Get HDFL value for capping reading performance - // In theory stuff like AR13, AR13 +HD and AR-INF +HD should use this values - // While AR-INF without HD shoud use normal flashlight values - // Because in first case you're clicking air, while in AR-INF case you're see the notes - // But implementing it is pretty annoying, so I left it "as is" - double potentialHiddenFlashlightValue = computeFlashlightValue(score, osuAttributes, true); - double lowARValue = computeReadingLowARValue(score, osuAttributes); double readingARValue = lowARValue; - double flashlightValue = 0; - if (score.Mods.Any(h => h is OsuModFlashlight)) - flashlightValue = computeFlashlightValue(score, osuAttributes); + double flashlightValue = computeFlashlightValue(score, osuAttributes); // Reduce AR reading bonus if FL is present double flPower = OsuDifficultyCalculator.FL_SUM_POWER; - double flashlightARValue = Math.Pow(Math.Pow(flashlightValue, flPower) + Math.Pow(readingARValue, flPower), 1.0 / flPower); + double flashlightARValue = score.Mods.Any(h => h is OsuModFlashlight) ? + Math.Pow(Math.Pow(flashlightValue, flPower) + Math.Pow(readingARValue, flPower), 1.0 / flPower) : readingARValue; double cognitionValue = flashlightARValue; - cognitionValue = AdjustCognitionPerformance(cognitionValue, mechanicalValue, potentialHiddenFlashlightValue); + cognitionValue = AdjustCognitionPerformance(cognitionValue, mechanicalValue, flashlightValue); double accuracyValue = computeAccuracyValue(score, osuAttributes); @@ -98,12 +139,20 @@ namespace osu.Game.Rulesets.Osu.Difficulty (Math.Pow(Math.Pow(mechanicalValue, power) + Math.Pow(accuracyValue, power), 1.0 / power) + cognitionValue) * multiplier; + // Fancy stuff for better visual display of FL pp + + // Calculate reading difficulty as there was no FL in the first place + double visualCognitionValue = AdjustCognitionPerformance(readingARValue, mechanicalValue, flashlightValue); + + double visualFlashlightValue = cognitionValue - visualCognitionValue; + return new OsuPerformanceAttributes { Aim = aimValue, Speed = speedValue, Accuracy = accuracyValue, - Cognition = cognitionValue, + Flashlight = visualFlashlightValue, + Reading = visualCognitionValue, EffectiveMissCount = effectiveMissCount, Total = totalValue }; @@ -118,11 +167,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty double lengthBonus = CalculateDefaultLengthBonus(totalHits); aimValue *= lengthBonus; - // Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses. if (effectiveMissCount > 0) - aimValue *= 0.97 * Math.Pow(1 - Math.Pow(effectiveMissCount / totalHits, 0.775), effectiveMissCount); - - aimValue *= getComboScalingFactor(attributes); + aimValue *= calculateMissPenalty(effectiveMissCount, attributes.AimDifficultStrainCount); double approachRateFactor = 0.0; if (attributes.ApproachRate > 10.33) @@ -146,8 +192,22 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (attributes.SliderCount > 0) { - double estimateSliderEndsDropped = Math.Clamp(Math.Min(countOk + countMeh + countMiss, attributes.MaxCombo - scoreMaxCombo), 0, estimateDifficultSliders); - double sliderNerfFactor = (1 - attributes.SliderFactor) * Math.Pow(1 - estimateSliderEndsDropped / estimateDifficultSliders, 3) + attributes.SliderFactor; + double estimateImproperlyFollowedDifficultSliders; + + if (usingClassicSliderAccuracy) + { + // When the score is considered classic (regardless if it was made on old client or not) we consider all missing combo to be dropped difficult sliders + int maximumPossibleDroppedSliders = totalImperfectHits; + estimateImproperlyFollowedDifficultSliders = Math.Clamp(Math.Min(maximumPossibleDroppedSliders, attributes.MaxCombo - scoreMaxCombo), 0, estimateDifficultSliders); + } + else + { + // We add tick misses here since they too mean that the player didn't follow the slider properly + // We however aren't adding misses here because missing slider heads has a harsh penalty by itself and doesn't mean that the rest of the slider wasn't followed properly + estimateImproperlyFollowedDifficultSliders = Math.Clamp(countSliderEndsDropped + countSliderTickMiss, 0, estimateDifficultSliders); + } + + double sliderNerfFactor = (1 - attributes.SliderFactor) * Math.Pow(1 - estimateImproperlyFollowedDifficultSliders / estimateDifficultSliders, 3) + attributes.SliderFactor; aimValue *= sliderNerfFactor; } @@ -168,11 +228,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty double lengthBonus = CalculateDefaultLengthBonus(totalHits); speedValue *= lengthBonus; - // Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses. if (effectiveMissCount > 0) - speedValue *= 0.97 * Math.Pow(1 - Math.Pow(effectiveMissCount / totalHits, 0.775), Math.Pow(effectiveMissCount, .875)); - - speedValue *= getComboScalingFactor(attributes); + speedValue *= calculateMissPenalty(effectiveMissCount, attributes.SpeedDifficultStrainCount); double approachRateFactor = 0.0; if (attributes.ApproachRate > 10.33) @@ -199,7 +256,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty double relevantAccuracy = attributes.SpeedNoteCount == 0 ? 0 : (relevantCountGreat * 6.0 + relevantCountOk * 2.0 + relevantCountMeh) / (attributes.SpeedNoteCount * 6.0); // Scale the speed value with accuracy and OD. - speedValue *= (0.95 + Math.Pow(attributes.OverallDifficulty, 2) / 750) * Math.Pow((accuracy + relevantAccuracy) / 2.0, (14.5 - Math.Max(attributes.OverallDifficulty, 8)) / 2); + speedValue *= (0.95 + Math.Pow(attributes.OverallDifficulty, 2) / 750) * Math.Pow((accuracy + relevantAccuracy) / 2.0, (14.5 - attributes.OverallDifficulty) / 2); // Scale the speed value with # of 50s to punish doubletapping. speedValue *= Math.Pow(0.99, countMeh < totalHits / 500.0 ? 0 : countMeh - totalHits / 500.0); @@ -216,6 +273,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty double betterAccuracyPercentage; int amountHitObjectsWithAccuracy = attributes.HitCircleCount; + if (!usingClassicSliderAccuracy) + amountHitObjectsWithAccuracy += attributes.SliderCount; + if (amountHitObjectsWithAccuracy > 0) betterAccuracyPercentage = ((countGreat - (totalHits - amountHitObjectsWithAccuracy)) * 6 + countOk * 2 + countMeh) / (double)(amountHitObjectsWithAccuracy * 6); else @@ -227,7 +287,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty // Lots of arbitrary values from testing. // Considering to use derivation from perfect accuracy in a probabilistic manner - assume normal distribution. - double accuracyValue = Math.Pow(1.52163, attributes.OverallDifficulty) * Math.Pow(betterAccuracyPercentage, 24) * 2.83; + double accuracyValue = Math.Pow(1.52163, attributes.OverallDifficulty) * Math.Pow(betterAccuracyPercentage, 24) * 2.92; // Bonus for many hitcircles - it's harder to keep good accuracy up for longer. accuracyValue *= Math.Min(1.15, Math.Pow(amountHitObjectsWithAccuracy / 1000.0, 0.3)); @@ -250,14 +310,20 @@ namespace osu.Game.Rulesets.Osu.Difficulty // This one is goes from 0.0 on delta=0 to 1.0 somewhere around delta=3.4 double deltaBonus = (1 - Math.Pow(0.95, Math.Pow(ARODDelta, 4))); + // Nerf delta bonus on OD lower than 10 and 9 + if (attributes.OverallDifficulty < 10) + deltaBonus *= Math.Pow(attributes.OverallDifficulty / 10, 2); + if (attributes.OverallDifficulty < 9) + deltaBonus *= Math.Pow(attributes.OverallDifficulty / 9, 4); + accuracyValue *= 1 + visualBonus * (1 + 2 * deltaBonus); return accuracyValue; } - private double computeFlashlightValue(ScoreInfo score, OsuDifficultyAttributes attributes, bool alwaysUseHD = false) + private double computeFlashlightValue(ScoreInfo score, OsuDifficultyAttributes attributes) { - double flashlightValue = Math.Pow(alwaysUseHD ? attributes.HiddenFlashlightDifficulty : attributes.FlashlightDifficulty, 2.0) * 25.0; + double flashlightValue = Flashlight.DifficultyToPerformance(attributes.FlashlightDifficulty); // Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses. if (effectiveMissCount > 0) @@ -277,30 +343,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty return flashlightValue; } - public static double ComputePerfectFlashlightValue(double flashlightDifficulty, int objectsCount) - { - double flashlightValue = Flashlight.DifficultyToPerformance(flashlightDifficulty); - - flashlightValue *= 0.7 + 0.1 * Math.Min(1.0, objectsCount / 200.0) + - (objectsCount > 200 ? 0.2 * Math.Min(1.0, (objectsCount - 200) / 200.0) : 0.0); - - return flashlightValue; - } - private double computeReadingLowARValue(ScoreInfo score, OsuDifficultyAttributes attributes) { - double rawReading = attributes.ReadingDifficultyLowAR; - - if (score.Mods.Any(m => m is OsuModTouchDevice)) - rawReading = Math.Pow(rawReading, 0.8); - - double readingValue = ReadingLowAR.DifficultyToPerformance(rawReading); + double readingValue = ReadingLowAR.DifficultyToPerformance(attributes.ReadingDifficultyLowAR); // Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses. if (effectiveMissCount > 0) - readingValue *= 0.97 * Math.Pow(1 - Math.Pow(effectiveMissCount / totalHits, 0.775), Math.Pow(effectiveMissCount, .875)); - - readingValue *= getComboScalingFactor(attributes); + readingValue *= calculateMissPenalty(effectiveMissCount, attributes.LowArDifficultStrainCount); // Scale the reading value with accuracy _harshly_. Additional note: it would have it's own curve in Statistical Accuracy rework. readingValue *= accuracy * accuracy; @@ -309,31 +358,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty return readingValue; } - private double calculateEffectiveMissCount(OsuDifficultyAttributes attributes) + + // Limits reading difficulty by the difficulty of full-memorisation (assumed to be mechanicalPerformance + flashlightPerformance + 25) + // Desmos graph assuming that x = cognitionPerformance, while y = mechanicalPerformance + flaslightPerformance + // https://www.desmos.com/3d/vjygrxtkqs + public static double AdjustCognitionPerformance(double cognitionPerformance, double mechanicalPerformance, double flashlightPerformance) { - // Guess the number of misses + slider breaks from combo - double comboBasedMissCount = 0.0; - - if (attributes.SliderCount > 0) - { - double fullComboThreshold = attributes.MaxCombo - 0.1 * attributes.SliderCount; - if (scoreMaxCombo < fullComboThreshold) - comboBasedMissCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo); - } - - // Clamp miss count to maximum amount of possible breaks - comboBasedMissCount = Math.Min(comboBasedMissCount, countOk + countMeh + countMiss); - - return Math.Max(countMiss, comboBasedMissCount); - } - private double getComboScalingFactor(OsuDifficultyAttributes attributes) => attributes.MaxCombo <= 0 ? 1.0 : Math.Min(Math.Pow(scoreMaxCombo, 0.8) / Math.Pow(attributes.MaxCombo, 0.8), 1.0); - private int totalHits => countGreat + countOk + countMeh + countMiss; - - // Adjusts cognition performance accounting for full-memory - public static double AdjustCognitionPerformance(double cognitionPerformance, double mechanicalPerformance, double flaslightPerformance) - { - // Assuming that less than 25 mechanical pp is not worthy for memory - double capPerformance = mechanicalPerformance + flaslightPerformance + 25; + // Assuming that less than 25 pp is not worthy for memory + double capPerformance = mechanicalPerformance + flashlightPerformance + 25; double ratio = cognitionPerformance / capPerformance; if (ratio > 50) return capPerformance; @@ -342,7 +374,18 @@ namespace osu.Game.Rulesets.Osu.Difficulty return ratio * capPerformance; } + // Miss penalty assumes that a player will miss on the hardest parts of a map, + // so we use the amount of relatively difficult sections to adjust miss penalty + // to make it more punishing on maps with lower amount of hard sections. + private double calculateMissPenalty(double missCount, double difficultStrainCount) => 0.96 / ((missCount / (4 * Math.Pow(Math.Log(difficultStrainCount), 0.94))) + 1); + + private double getComboScalingFactor(OsuDifficultyAttributes attributes) => attributes.MaxCombo <= 0 ? 1.0 : Math.Min(Math.Pow(scoreMaxCombo, 0.8) / Math.Pow(attributes.MaxCombo, 0.8), 1.0); + private static double softmin(double a, double b, double power = Math.E) => a * b / Math.Log(Math.Pow(power, a) + Math.Pow(power, b), power); + private static double logistic(double x) => 1 / (1 + Math.Exp(-x)); + + private int totalHits => countGreat + countOk + countMeh + countMiss; + private int totalImperfectHits => countOk + countMeh + countMiss; } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index 2b17451829..249202bf1d 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -22,7 +22,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing /// public const int NORMALISED_RADIUS = 50; // Change radius to 50 to make 100 the diameter. Easier for mental maths. - private const int min_delta_time = 25; + public const int NORMALISED_DIAMETER = NORMALISED_RADIUS * 2; + + public const int MIN_DELTA_TIME = 25; + private const float maximum_slider_radius = NORMALISED_RADIUS * 2.4f; private const float assumed_slider_radius = NORMALISED_RADIUS * 1.8f; @@ -86,16 +89,18 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing /// public double? Angle { get; private set; } + /// + /// Signed version of the Angle. + /// Potentially should be used for more accurate angle bonuses + /// Ranges from -PI to PI + /// + public double? AngleSigned { get; private set; } + /// /// Retrieves the full hit window for a Great . /// public double HitWindowGreat { get; private set; } - /// - /// Density of the object for given preempt. Saved for optimization, density calculation is expensive. - /// - public double Density { get; private set; } - /// /// Predictabiliy of the angle. Gives high values only in exceptionally repetitive patterns. /// @@ -130,7 +135,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing Preempt = BaseObject.TimePreempt / clockRate; // Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects. - StrainTime = Math.Max(DeltaTime, min_delta_time); + StrainTime = Math.Max(DeltaTime, MIN_DELTA_TIME); if (BaseObject is Slider sliderObject) { @@ -146,8 +151,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing AnglePredictability = CalculateAnglePredictability(); (ReadingObjects, OverlapValues) = getReadingObjects(); - - Density = ReadingEvaluator.EvaluateDensityOf(this); } private (IList, IDictionary) getReadingObjects() @@ -165,7 +168,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing var readingObjects = new List(visibleObjects.Count); OverlapValues = new Dictionary(); - //foreach (var loopObj in visibleObjects) for (int loopIndex = 0; loopIndex < visibleObjects.Count; loopIndex++) { var loopObj = visibleObjects[loopIndex]; @@ -320,7 +322,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing var visibleObjects = new List(); - for (int i = 0; i < current.Count; i++) + for (int i = 0; i < current.Index; i++) { OsuDifficultyHitObject hitObject = (OsuDifficultyHitObject)current.Previous(i); @@ -389,6 +391,25 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing angleDifference -= prevAngleAdjust; + // Explicit nerf for same pattern repeating + OsuDifficultyHitObject? prevObj3 = (OsuDifficultyHitObject?)Previous(3); + OsuDifficultyHitObject? prevObj4 = (OsuDifficultyHitObject?)Previous(4); + OsuDifficultyHitObject? prevObj5 = (OsuDifficultyHitObject?)Previous(5); + + // 3-3 repeat + double similarity3_1 = getGeneralSimilarity(this, prevObj2); + double similarity3_2 = getGeneralSimilarity(prevObj0, prevObj3); + double similarity3_3 = getGeneralSimilarity(prevObj1, prevObj4); + + double similarity3_total = similarity3_1 * similarity3_2 * similarity3_3; + + // 4-4 repeat + double similarity4_1 = getGeneralSimilarity(this, prevObj3); + double similarity4_2 = getGeneralSimilarity(prevObj0, prevObj4); + double similarity4_3 = getGeneralSimilarity(prevObj1, prevObj5); + + double similarity4_total = similarity4_1 * similarity4_2 * similarity4_3; + // Bandaid to fix Rubik's Cube +EZ double wideness = 0; if (Angle!.Value > Math.PI * 0.5) @@ -405,12 +426,36 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing // Angle difference more than 15 degrees gets no penalty double adjustedAngleDifference = Math.Min(Math.PI / 12, angleDifference); - return rhythmFactor * Math.Cos(Math.Min(Math.PI / 2, 6 * adjustedAngleDifference)); + double predictability = Math.Cos(Math.Min(Math.PI / 2, 6 * adjustedAngleDifference)) * rhythmFactor; + + // Punish for big pattern similarity + return 1 - (1 - predictability) * (1 - Math.Max(similarity3_total, similarity4_total)); + } + + private double getGeneralSimilarity(OsuDifficultyHitObject? o1, OsuDifficultyHitObject? o2) + { + if (o1 == null || o2 == null) + return 1; + + if (o1.AngleSigned == null || o2.AngleSigned == null) + return o1.AngleSigned == o2.AngleSigned ? 1 : 0; + + + double timeSimilarity = 1 - getTimeDifference(o1.StrainTime, o2.StrainTime); + + double angleDelta = Math.Abs((double)o1.AngleSigned - (double)o2.AngleSigned); + angleDelta = Math.Clamp(angleDelta - 0.1, 0, 0.15); + double angleSimilarity = 1 - angleDelta / 0.15; + + double distanceDelta = Math.Abs(o1.LazyJumpDistance - o2.LazyJumpDistance) / NORMALISED_RADIUS; + double distanceSimilarity = 1 / Math.Max(1, distanceDelta); + + return timeSimilarity * angleSimilarity * distanceSimilarity; } public double OpacityAt(double time, bool hidden) { - var baseObject = BaseObject; // Optimization + var baseObject = BaseObject; if (time > baseObject.StartTime) { @@ -439,6 +484,24 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing return Math.Clamp((time - fadeInStartTime) / fadeInDuration, 0.0, 1.0); } + /// + /// Returns how possible is it to doubletap this object together with the next one and get perfect judgement in range from 0 to 1 + /// + public double GetDoubletapness(OsuDifficultyHitObject? osuNextObj) + { + if (osuNextObj != null) + { + double currDeltaTime = Math.Max(1, DeltaTime); + double nextDeltaTime = Math.Max(1, osuNextObj.DeltaTime); + double deltaDifference = Math.Abs(nextDeltaTime - currDeltaTime); + double speedRatio = currDeltaTime / Math.Max(currDeltaTime, deltaDifference); + double windowRatio = Math.Pow(Math.Min(1, currDeltaTime / HitWindowGreat), 2); + return 1.0 - Math.Pow(speedRatio, 1 - windowRatio); + } + + return 0; + } + private void setDistances(double clockRate) { if (BaseObject is Slider currentSlider) @@ -446,7 +509,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing computeSliderCursorPosition(currentSlider); // Bonus for repeat sliders until a better per nested object strain system can be achieved. TravelDistance = currentSlider.LazyTravelDistance * (float)Math.Pow(1 + currentSlider.RepeatCount / 2.5, 1.0 / 2.5); - TravelTime = Math.Max(currentSlider.LazyTravelTime / clockRate, min_delta_time); + TravelTime = Math.Max(currentSlider.LazyTravelTime / clockRate, MIN_DELTA_TIME); } // We don't need to calculate either angle or distance when one of the last->curr objects is a spinner @@ -470,8 +533,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing if (lastObject is Slider lastSlider) { - double lastTravelTime = Math.Max(lastSlider.LazyTravelTime / clockRate, min_delta_time); - MinimumJumpTime = Math.Max(StrainTime - lastTravelTime, min_delta_time); + double lastTravelTime = Math.Max(lastSlider.LazyTravelTime / clockRate, MIN_DELTA_TIME); + MinimumJumpTime = Math.Max(StrainTime - lastTravelTime, MIN_DELTA_TIME); // // There are two types of slider-to-object patterns to consider in order to better approximate the real movement a player will take to jump between the hitobjects. @@ -509,7 +572,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing float dot = Vector2.Dot(v1, v2); float det = v1.X * v2.Y - v1.Y * v2.X; - Angle = Math.Abs(Math.Atan2(det, dot)); + AngleSigned = Math.Atan2(det, dot); + Angle = Math.Abs(AngleSigned.Value); } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 3f6b22bbb1..e99efdf50e 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Evaluators; @@ -21,21 +20,17 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills private readonly bool withSliders; - private double currentStrain; + protected double CurrentStrain; + protected double SkillMultiplier => 25.5; - private double skillMultiplier => 23.55; - private double strainDecayBase => 0.15; - - private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); - - protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => currentStrain * strainDecay(time - current.Previous(0).StartTime); + protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => CurrentStrain * StrainDecay(time - current.Previous(0).StartTime); protected override double StrainValueAt(DifficultyHitObject current) { - currentStrain *= strainDecay(current.DeltaTime); - currentStrain += AimEvaluator.EvaluateDifficultyOf(current, withSliders) * skillMultiplier; + CurrentStrain *= StrainDecay(current.DeltaTime); + CurrentStrain += AimEvaluator.EvaluateDifficultyOf(current, withSliders) * SkillMultiplier; - return currentStrain; + return CurrentStrain; } } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs index 9fafeacb9c..affecbc3bf 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs @@ -17,7 +17,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills public class Flashlight : StrainSkill { private readonly bool hasHiddenMod; - protected virtual bool HasHiddenMod => hasHiddenMod; public Flashlight(Mod[] mods) : base(mods) @@ -25,7 +24,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills hasHiddenMod = mods.Any(m => m is OsuModHidden); } - private double skillMultiplier => 0.052; + private double skillMultiplier => 0.053; private double strainDecayBase => 0.15; private double currentStrain; @@ -37,23 +36,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills protected override double StrainValueAt(DifficultyHitObject current) { currentStrain *= strainDecay(current.DeltaTime); - currentStrain += FlashlightEvaluator.EvaluateDifficultyOf(current, HasHiddenMod) * skillMultiplier; + currentStrain += FlashlightEvaluator.EvaluateDifficultyOf(current, hasHiddenMod) * skillMultiplier; return currentStrain; } - public override double DifficultyValue() => GetCurrentStrainPeaks().Sum() * OsuStrainSkill.DEFAULT_DIFFICULTY_MULTIPLIER; + public override double DifficultyValue() => GetCurrentStrainPeaks().Sum(); - public static double DifficultyToPerformance(double difficulty) => Math.Pow(difficulty, 2) * 25.0; - } - - public class HiddenFlashlight : Flashlight - { - protected override bool HasHiddenMod => true; - - public HiddenFlashlight(Mod[] mods) - : base(mods) - { - } + public static double DifficultyToPerformance(double difficulty) => 25 * Math.Pow(difficulty, 2); } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs index 93c21d33ad..367f3bee18 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs @@ -12,12 +12,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills { public abstract class OsuStrainSkill : StrainSkill { - /// - /// The default multiplier applied by to the final difficulty value after all other calculations. - /// May be overridden via . - /// - public const double DEFAULT_DIFFICULTY_MULTIPLIER = 1.06; - /// /// The number of sections with the highest strains, which the peak strain reductions will apply to. /// This is done in order to decrease their impact on the overall difficulty of the map for this skill. @@ -29,10 +23,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills /// protected virtual double ReducedStrainBaseline => 0.75; - /// - /// The final multiplier to be applied to after all other calculations. - /// - protected virtual double DifficultyMultiplier => DEFAULT_DIFFICULTY_MULTIPLIER; + protected virtual double StrainDecayBase => 0.15; + + protected double StrainDecay(double ms) => Math.Pow(StrainDecayBase, ms / 1000); protected OsuStrainSkill(Mod[] mods) : base(mods) @@ -65,7 +58,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills weight *= DecayWeight; } - return difficulty * DifficultyMultiplier; + return difficulty; } /// diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs index 4835d5d817..3a60f5843c 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs @@ -9,14 +9,13 @@ using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Evaluators; +using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; namespace osu.Game.Rulesets.Osu.Difficulty.Skills { - - public class ReadingLowAR : Skill + public class ReadingLowAR : StrainSkill { - private readonly List difficulties = new List(); - private double skillMultiplier => 1.26; + private double skillMultiplier => 1.22; private double aimComponentMultiplier => 0.4; public ReadingLowAR(Mod[] mods) @@ -39,18 +38,28 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills double totalDensityDifficulty = (currentDensityAimStrain + densityReadingDifficulty) * skillMultiplier; - difficulties.Add(totalDensityDifficulty); + ObjectStrains.Add(totalDensityDifficulty); + + if (current.Index == 0) + CurrentSectionEnd = Math.Ceiling(current.StartTime / SectionLength) * SectionLength; + + while (current.StartTime > CurrentSectionEnd) + { + StrainPeaks.Add(CurrentSectionPeak); + CurrentSectionPeak = 0; + CurrentSectionEnd += SectionLength; + } + + CurrentSectionPeak = Math.Max(totalDensityDifficulty, CurrentSectionPeak); } private double reducedNoteCount => 5; private double reducedNoteBaseline => 0.7; public override double DifficultyValue() { - double difficulty = 0; - // Sections with 0 difficulty are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). // These sections will not contribute to the difficulty. - var peaks = difficulties.Where(p => p > 0); + var peaks = ObjectStrains.Where(p => p > 0); List values = peaks.OrderByDescending(d => d).ToList(); @@ -62,6 +71,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills values = values.OrderByDescending(d => d).ToList(); + double difficulty = 0; + // Difficulty is the weighted sum of the highest strains from every section. // We're sorting from highest to lowest strain. for (int i = 0; i < values.Count; i++) @@ -74,5 +85,15 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills public static double DifficultyToPerformance(double difficulty) => Math.Max( Math.Max(Math.Pow(difficulty, 1.5) * 20, Math.Pow(difficulty, 2) * 17.0), Math.Max(Math.Pow(difficulty, 3) * 10.5, Math.Pow(difficulty, 4) * 6.00)); + + protected override double StrainValueAt(DifficultyHitObject current) + { + throw new NotImplementedException(); + } + + protected override double CalculateInitialStrain(double time, DifficultyHitObject current) + { + throw new NotImplementedException(); + } } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index 40aac013ab..baccf8766d 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -6,7 +6,6 @@ using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Evaluators; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; -using System.Collections.Generic; using System.Linq; namespace osu.Game.Rulesets.Osu.Difficulty.Skills @@ -16,51 +15,44 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills /// public class Speed : OsuStrainSkill { - private double skillMultiplier => 1375; - private double strainDecayBase => 0.3; + protected double SkillMultiplier => 1.42; + protected override double StrainDecayBase => 0.3; - private double currentStrain; - private double currentRhythm; + protected double CurrentStrain; + protected double CurrentRhythm; protected override int ReducedSectionCount => 5; - protected override double DifficultyMultiplier => 1.04; - - private readonly List objectStrains = new List(); public Speed(Mod[] mods) : base(mods) { } - private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); - - protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => (currentStrain * currentRhythm) * strainDecay(time - current.Previous(0).StartTime); + protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => (CurrentStrain * CurrentRhythm) * StrainDecay(time - current.Previous(0).StartTime); protected override double StrainValueAt(DifficultyHitObject current) { - currentStrain *= strainDecay(((OsuDifficultyHitObject)current).StrainTime); - currentStrain += SpeedEvaluator.EvaluateDifficultyOf(current) * skillMultiplier; + OsuDifficultyHitObject currODHO = (OsuDifficultyHitObject)current; - currentRhythm = RhythmEvaluator.EvaluateDifficultyOf(current); + CurrentStrain *= StrainDecay(currODHO.StrainTime); + CurrentStrain += SpeedEvaluator.EvaluateDifficultyOf(current) * SkillMultiplier; - double totalStrain = currentStrain * currentRhythm; - - objectStrains.Add(totalStrain); + CurrentRhythm = RhythmEvaluator.EvaluateDifficultyOf(current); + double totalStrain = CurrentStrain * CurrentRhythm; return totalStrain; } public double RelevantNoteCount() { - if (objectStrains.Count == 0) + if (ObjectStrains.Count == 0) return 0; - double maxStrain = objectStrains.Max(); - + double maxStrain = ObjectStrains.Max(); if (maxStrain == 0) return 0; - return objectStrains.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxStrain * 12.0 - 6.0)))); + return ObjectStrains.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxStrain * 12.0 - 6.0)))); } } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/GridPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/GridPlacementBlueprint.cs new file mode 100644 index 0000000000..163b42bcfd --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/GridPlacementBlueprint.cs @@ -0,0 +1,139 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; +using osu.Game.Rulesets.Edit; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Osu.Edit.Blueprints +{ + public partial class GridPlacementBlueprint : PlacementBlueprint + { + [Resolved] + private HitObjectComposer? hitObjectComposer { get; set; } + + private OsuGridToolboxGroup gridToolboxGroup = null!; + private Vector2 originalOrigin; + private float originalSpacing; + private float originalRotation; + + [BackgroundDependencyLoader] + private void load(OsuGridToolboxGroup gridToolboxGroup) + { + this.gridToolboxGroup = gridToolboxGroup; + originalOrigin = gridToolboxGroup.StartPosition.Value; + originalSpacing = gridToolboxGroup.Spacing.Value; + originalRotation = gridToolboxGroup.GridLinesRotation.Value; + } + + public override void EndPlacement(bool commit) + { + if (!commit && PlacementActive != PlacementState.Finished) + resetGridState(); + + base.EndPlacement(commit); + + // You typically only place the grid once, so we switch back to the last tool after placement. + if (commit && hitObjectComposer is OsuHitObjectComposer osuHitObjectComposer) + osuHitObjectComposer.SetLastTool(); + } + + protected override bool OnClick(ClickEvent e) + { + if (e.Button == MouseButton.Left) + { + switch (PlacementActive) + { + case PlacementState.Waiting: + BeginPlacement(true); + return true; + + case PlacementState.Active: + EndPlacement(true); + return true; + } + } + + return base.OnClick(e); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (e.Button == MouseButton.Right) + { + // Reset the grid to the default values. + gridToolboxGroup.StartPosition.Value = gridToolboxGroup.StartPosition.Default; + gridToolboxGroup.Spacing.Value = gridToolboxGroup.Spacing.Default; + if (!gridToolboxGroup.GridLinesRotation.Disabled) + gridToolboxGroup.GridLinesRotation.Value = gridToolboxGroup.GridLinesRotation.Default; + EndPlacement(true); + return true; + } + + return base.OnMouseDown(e); + } + + protected override bool OnDragStart(DragStartEvent e) + { + if (e.Button == MouseButton.Left) + { + BeginPlacement(true); + return true; + } + + return base.OnDragStart(e); + } + + protected override void OnDragEnd(DragEndEvent e) + { + if (PlacementActive == PlacementState.Active) + EndPlacement(true); + + base.OnDragEnd(e); + } + + public override SnapType SnapType => ~SnapType.GlobalGrids; + + public override void UpdateTimeAndPosition(SnapResult result) + { + if (State.Value == Visibility.Hidden) + return; + + var pos = ToLocalSpace(result.ScreenSpacePosition); + + if (PlacementActive != PlacementState.Active) + gridToolboxGroup.StartPosition.Value = pos; + else + { + // Default to the original spacing and rotation if the distance is too small. + if (Vector2.Distance(gridToolboxGroup.StartPosition.Value, pos) < 2) + { + gridToolboxGroup.Spacing.Value = originalSpacing; + if (!gridToolboxGroup.GridLinesRotation.Disabled) + gridToolboxGroup.GridLinesRotation.Value = originalRotation; + } + else + { + gridToolboxGroup.SetGridFromPoints(gridToolboxGroup.StartPosition.Value, pos); + } + } + } + + protected override void PopOut() + { + base.PopOut(); + resetGridState(); + } + + private void resetGridState() + { + gridToolboxGroup.StartPosition.Value = originalOrigin; + gridToolboxGroup.Spacing.Value = originalSpacing; + if (!gridToolboxGroup.GridLinesRotation.Disabled) + gridToolboxGroup.GridLinesRotation.Value = originalRotation; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/Components/HitCircleOverlapMarker.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/Components/HitCircleOverlapMarker.cs index fe335a048d..8ed9d0476a 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/Components/HitCircleOverlapMarker.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/Components/HitCircleOverlapMarker.cs @@ -7,7 +7,6 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; using osu.Framework.Utils; using osu.Game.Configuration; using osu.Game.Rulesets.Objects.Types; @@ -16,7 +15,6 @@ using osu.Game.Rulesets.Osu.Skinning.Default; using osu.Game.Screens.Edit; using osu.Game.Skinning; using osuTK; -using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components { @@ -48,13 +46,6 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - new Circle - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Colour = Color4.White, - }, ring = new RingPiece { BorderThickness = 4, diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCirclePlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCirclePlacementBlueprint.cs index 20ad99baa2..78a0e36dc2 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCirclePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCirclePlacementBlueprint.cs @@ -9,7 +9,7 @@ using osuTK.Input; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles { - public partial class HitCirclePlacementBlueprint : PlacementBlueprint + public partial class HitCirclePlacementBlueprint : HitObjectPlacementBlueprint { public new HitCircle HitObject => (HitCircle)base.HitObject; diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCircleSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCircleSelectionBlueprint.cs index 0608f8c929..fd2bbe9916 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCircleSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCircleSelectionBlueprint.cs @@ -1,8 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Primitives; +using osu.Game.Configuration; using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; @@ -16,6 +19,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles protected readonly HitCirclePiece CirclePiece; private readonly HitCircleOverlapMarker marker; + private readonly Bindable showHitMarkers = new Bindable(); public HitCircleSelectionBlueprint(HitCircle circle) : base(circle) @@ -27,12 +31,32 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles }; } + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + config.BindWith(OsuSetting.EditorShowHitMarkers, showHitMarkers); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + showHitMarkers.BindValueChanged(_ => + { + if (!showHitMarkers.Value) + DrawableObject.RestoreHitAnimations(); + }); + } + protected override void Update() { base.Update(); CirclePiece.UpdateFrom(HitObject); marker.UpdateFrom(HitObject); + + if (showHitMarkers.Value) + DrawableObject.SuppressHitAnimations(); } public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => DrawableObject.HitArea.ReceivePositionalInputAt(screenSpacePos); diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointConnectionPiece.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointConnection.cs similarity index 51% rename from osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointConnectionPiece.cs rename to osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointConnection.cs index 9b3d8fc7a7..5706ed4baf 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointConnectionPiece.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointConnection.cs @@ -4,10 +4,7 @@ #nullable disable using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Lines; -using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Objects; using osuTK; @@ -15,36 +12,21 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components { /// - /// A visualisation of the line between two s. + /// A visualisation of the lines between s. /// - /// The type of which this visualises. - public partial class PathControlPointConnectionPiece : CompositeDrawable where T : OsuHitObject, IHasPath + /// The type of which this visualises. + public partial class PathControlPointConnection : SmoothPath where T : OsuHitObject, IHasPath { - public readonly PathControlPoint ControlPoint; - - private readonly Path path; private readonly T hitObject; - public int ControlPointIndex { get; set; } private IBindable hitObjectPosition; private IBindable pathVersion; private IBindable stackHeight; - public PathControlPointConnectionPiece(T hitObject, int controlPointIndex) + public PathControlPointConnection(T hitObject) { this.hitObject = hitObject; - ControlPointIndex = controlPointIndex; - - Origin = Anchor.Centre; - AutoSizeAxes = Axes.Both; - - ControlPoint = hitObject.Path.ControlPoints[controlPointIndex]; - - InternalChild = path = new SmoothPath - { - Anchor = Anchor.Centre, - PathRadius = 1 - }; + PathRadius = 1; } protected override void LoadComplete() @@ -68,18 +50,14 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components /// private void updateConnectingPath() { - Position = hitObject.StackedPosition + ControlPoint.Position; + Position = hitObject.StackedPosition; - path.ClearVertices(); + ClearVertices(); - int nextIndex = ControlPointIndex + 1; - if (nextIndex == 0 || nextIndex >= hitObject.Path.ControlPoints.Count) - return; + foreach (var controlPoint in hitObject.Path.ControlPoints) + AddVertex(controlPoint.Position); - path.AddVertex(Vector2.Zero); - path.AddVertex(hitObject.Path.ControlPoints[nextIndex].Position - ControlPoint.Position); - - path.OriginPosition = path.PositionInBoundingBox(Vector2.Zero); + OriginPosition = PositionInBoundingBox(Vector2.Zero); } } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs index c6e05d3ca3..3337e99215 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs @@ -8,9 +8,9 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Game.Graphics; @@ -40,7 +40,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components public readonly PathControlPoint ControlPoint; private readonly T hitObject; - private readonly Container marker; + private readonly FastCircle circle; private readonly Drawable markerRing; [Resolved] @@ -60,38 +60,22 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components Origin = Anchor.Centre; AutoSizeAxes = Axes.Both; - InternalChildren = new Drawable[] + InternalChildren = new[] { - marker = new Container + circle = new FastCircle { Anchor = Anchor.Centre, Origin = Anchor.Centre, - AutoSizeAxes = Axes.Both, - Children = new[] - { - new Circle - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(20), - }, - markerRing = new CircularContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(28), - Masking = true, - BorderThickness = 2, - BorderColour = Color4.White, - Alpha = 0, - Child = new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - AlwaysPresent = true - } - } - } + Size = new Vector2(20), + }, + markerRing = new CircularProgress + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(28), + Alpha = 0, + InnerRadius = 0.1f, + Progress = 1 } }; } @@ -115,7 +99,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components } // The connecting path is excluded from positional input - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => marker.ReceivePositionalInputAt(screenSpacePos); + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => circle.ReceivePositionalInputAt(screenSpacePos); protected override bool OnHover(HoverEvent e) { @@ -209,8 +193,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components if (IsHovered || IsSelected.Value) colour = colour.Lighten(1); - marker.Colour = colour; - marker.Scale = new Vector2(hitObject.Scale); + Colour = colour; + Scale = new Vector2(hitObject.Scale); } private Color4 getColourFromNodeType() diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs index b2d1709531..f114516300 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs @@ -34,10 +34,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components public partial class PathControlPointVisualiser : CompositeDrawable, IKeyBindingHandler, IHasContextMenu where T : OsuHitObject, IHasPath { - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; // allow context menu to appear outside of the playfield. + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; // allow context menu to appear outside the playfield. internal readonly Container> Pieces; - internal readonly Container> Connections; private readonly IBindableList controlPoints = new BindableList(); private readonly T hitObject; @@ -63,7 +62,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components InternalChildren = new Drawable[] { - Connections = new Container> { RelativeSizeAxes = Axes.Both }, + new PathControlPointConnection(hitObject), Pieces = new Container> { RelativeSizeAxes = Axes.Both } }; } @@ -78,6 +77,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components controlPoints.BindTo(hitObject.Path.ControlPoints); } + // Generally all the control points are within the visible area all the time. + public override bool UpdateSubTreeMasking() => true; + /// /// Handles correction of invalid path types. /// @@ -105,7 +107,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components if (segment.Count == 0) return; - var first = segment[0]; + PathControlPoint first = segment[0]; if (first.Type != PathType.PERFECT_CURVE) return; @@ -176,7 +178,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components private bool isSplittable(PathControlPointPiece p) => // A hit object can only be split on control points which connect two different path segments. - p.ControlPoint.Type.HasValue && p != Pieces.FirstOrDefault() && p != Pieces.LastOrDefault(); + p.ControlPoint.Type.HasValue && p.ControlPoint != controlPoints.FirstOrDefault() && p.ControlPoint != controlPoints.LastOrDefault(); private void onControlPointsChanged(object sender, NotifyCollectionChangedEventArgs e) { @@ -185,17 +187,6 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components case NotifyCollectionChangedAction.Add: Debug.Assert(e.NewItems != null); - // If inserting in the path (not appending), - // update indices of existing connections after insert location - if (e.NewStartingIndex < Pieces.Count) - { - foreach (var connection in Connections) - { - if (connection.ControlPointIndex >= e.NewStartingIndex) - connection.ControlPointIndex += e.NewItems.Count; - } - } - for (int i = 0; i < e.NewItems.Count; i++) { var point = (PathControlPoint)e.NewItems[i]; @@ -205,12 +196,11 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components if (allowSelection) d.RequestSelection = selectionRequested; + d.ControlPoint.Changed += controlPointChanged; d.DragStarted = DragStarted; d.DragInProgress = DragInProgress; d.DragEnded = DragEnded; })); - - Connections.Add(new PathControlPointConnectionPiece(hitObject, e.NewStartingIndex + i)); } break; @@ -220,27 +210,18 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components foreach (var point in e.OldItems.Cast()) { + point.Changed -= controlPointChanged; + foreach (var piece in Pieces.Where(p => p.ControlPoint == point).ToArray()) piece.RemoveAndDisposeImmediately(); - foreach (var connection in Connections.Where(c => c.ControlPoint == point).ToArray()) - connection.RemoveAndDisposeImmediately(); - } - - // If removing before the end of the path, - // update indices of connections after remove location - if (e.OldStartingIndex < Pieces.Count) - { - foreach (var connection in Connections) - { - if (connection.ControlPointIndex >= e.OldStartingIndex) - connection.ControlPointIndex -= e.OldItems.Count; - } } break; } } + private void controlPointChanged() => updateCurveMenuItems(); + protected override bool OnClick(ClickEvent e) { if (Pieces.Any(piece => piece.IsHovered)) @@ -269,6 +250,94 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components { } + // ReSharper disable once StaticMemberInGenericType + private static readonly PathType?[] path_types = + [ + PathType.LINEAR, + PathType.BEZIER, + PathType.PERFECT_CURVE, + PathType.BSpline(4), + null, + ]; + + protected override bool OnKeyDown(KeyDownEvent e) + { + if (e.Repeat) + return false; + + switch (e.Key) + { + case Key.Tab: + { + var selectedPieces = Pieces.Where(p => p.IsSelected.Value).ToArray(); + if (selectedPieces.Length != 1) + return false; + + PathControlPointPiece selectedPiece = selectedPieces.Single(); + PathControlPoint selectedPoint = selectedPiece.ControlPoint; + + PathType?[] validTypes = path_types; + + if (selectedPoint == controlPoints[0]) + validTypes = validTypes.Where(t => t != null).ToArray(); + + int currentTypeIndex = Array.IndexOf(validTypes, selectedPoint.Type); + + if (currentTypeIndex < 0 && e.ShiftPressed) + currentTypeIndex = 0; + + changeHandler?.BeginChange(); + + do + { + currentTypeIndex = (validTypes.Length + currentTypeIndex + (e.ShiftPressed ? -1 : 1)) % validTypes.Length; + + updatePathTypeOfSelectedPieces(validTypes[currentTypeIndex]); + } while (selectedPoint.Type != validTypes[currentTypeIndex]); + + changeHandler?.EndChange(); + + return true; + } + + case Key.Number1: + case Key.Number2: + case Key.Number3: + case Key.Number4: + case Key.Number5: + { + if (!e.AltPressed) + return false; + + // If no pieces are selected, we can't change the path type. + if (Pieces.All(p => !p.IsSelected.Value)) + return false; + + PathType? type = path_types[e.Key - Key.Number1]; + + // The first control point can never be inherit type + if (Pieces[0].IsSelected.Value && type == null) + return false; + + updatePathTypeOfSelectedPieces(type); + return true; + } + + default: + return false; + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + foreach (var p in Pieces) + p.ControlPoint.Changed -= controlPointChanged; + + if (draggedControlPointIndex >= 0) + DragEnded(); + } + private void selectionRequested(PathControlPointPiece piece, MouseButtonEvent e) { if (e.Button == MouseButton.Left && inputManager.CurrentState.Keyboard.ControlPressed) @@ -278,30 +347,45 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components } /// - /// Attempts to set the given control point piece to the given path type. - /// If that would fail, try to change the path such that it instead succeeds + /// Attempts to set all selected control point pieces to the given path type. + /// If that fails, try to change the path such that it instead succeeds /// in a UX-friendly way. /// - /// The control point piece that we want to change the path type of. /// The path type we want to assign to the given control point piece. - private void updatePathType(PathControlPointPiece piece, PathType? type) + private void updatePathTypeOfSelectedPieces(PathType? type) { - var pointsInSegment = hitObject.Path.PointsInSegment(piece.ControlPoint); - int indexInSegment = pointsInSegment.IndexOf(piece.ControlPoint); + changeHandler?.BeginChange(); - if (type?.Type == SplineType.PerfectCurve) + double originalDistance = hitObject.Path.Distance; + + foreach (var p in Pieces.Where(p => p.IsSelected.Value)) { - // Can't always create a circular arc out of 4 or more points, - // so we split the segment into one 3-point circular arc segment - // and one segment of the previous type. - int thirdPointIndex = indexInSegment + 2; + List pointsInSegment = hitObject.Path.PointsInSegment(p.ControlPoint); + int indexInSegment = pointsInSegment.IndexOf(p.ControlPoint); - if (pointsInSegment.Count > thirdPointIndex + 1) - pointsInSegment[thirdPointIndex].Type = pointsInSegment[0].Type; + if (type?.Type == SplineType.PerfectCurve) + { + // Can't always create a circular arc out of 4 or more points, + // so we split the segment into one 3-point circular arc segment + // and one segment of the previous type. + int thirdPointIndex = indexInSegment + 2; + + if (pointsInSegment.Count > thirdPointIndex + 1) + pointsInSegment[thirdPointIndex].Type = pointsInSegment[0].Type; + } + + hitObject.Path.ExpectedDistance.Value = null; + p.ControlPoint.Type = type; } - hitObject.Path.ExpectedDistance.Value = null; - piece.ControlPoint.Type = type; + EnsureValidPathTypes(); + + if (hitObject.Path.Distance < originalDistance) + hitObject.SnapTo(distanceSnapProvider); + else + hitObject.Path.ExpectedDistance.Value = originalDistance; + + changeHandler?.EndChange(); } [Resolved(CanBeNull = true)] @@ -311,9 +395,11 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components private Vector2[] dragStartPositions; private PathType?[] dragPathTypes; - private int draggedControlPointIndex; + private int draggedControlPointIndex = -1; private HashSet selectedControlPoints; + private List curveTypeItems; + public void DragStarted(PathControlPoint controlPoint) { dragStartPositions = hitObject.Path.ControlPoints.Select(point => point.Position).ToArray(); @@ -329,14 +415,14 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components public void DragInProgress(DragEvent e) { Vector2[] oldControlPoints = hitObject.Path.ControlPoints.Select(cp => cp.Position).ToArray(); - var oldPosition = hitObject.Position; + Vector2 oldPosition = hitObject.Position; double oldStartTime = hitObject.StartTime; if (selectedControlPoints.Contains(hitObject.Path.ControlPoints[0])) { // Special handling for selections containing head control point - the position of the hit object changes which means the snapped position and time have to be taken into account Vector2 newHeadPosition = Parent!.ToScreenSpace(e.MousePosition + (dragStartPositions[0] - dragStartPositions[draggedControlPointIndex])); - var result = positionSnapProvider?.FindSnappedPositionAndTime(newHeadPosition); + SnapResult result = positionSnapProvider?.FindSnappedPositionAndTime(newHeadPosition); Vector2 movementDelta = Parent!.ToLocalSpace(result?.ScreenSpacePosition ?? newHeadPosition) - hitObject.Position; @@ -345,7 +431,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components for (int i = 1; i < hitObject.Path.ControlPoints.Count; i++) { - var controlPoint = hitObject.Path.ControlPoints[i]; + PathControlPoint controlPoint = hitObject.Path.ControlPoints[i]; // Since control points are relative to the position of the hit object, all points that are _not_ selected // need to be offset _back_ by the delta corresponding to the movement of the head point. // All other selected control points (if any) will move together with the head point @@ -356,13 +442,13 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components } else { - var result = positionSnapProvider?.FindSnappedPositionAndTime(Parent!.ToScreenSpace(e.MousePosition), SnapType.GlobalGrids); + SnapResult result = positionSnapProvider?.FindSnappedPositionAndTime(Parent!.ToScreenSpace(e.MousePosition), SnapType.GlobalGrids); Vector2 movementDelta = Parent!.ToLocalSpace(result?.ScreenSpacePosition ?? Parent!.ToScreenSpace(e.MousePosition)) - dragStartPositions[draggedControlPointIndex] - hitObject.Position; for (int i = 0; i < controlPoints.Count; ++i) { - var controlPoint = controlPoints[i]; + PathControlPoint controlPoint = controlPoints[i]; if (selectedControlPoints.Contains(controlPoint)) controlPoint.Position = dragStartPositions[i] + movementDelta; } @@ -390,7 +476,11 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components EnsureValidPathTypes(); } - public void DragEnded() => changeHandler?.EndChange(); + public void DragEnded() + { + changeHandler?.EndChange(); + draggedControlPointIndex = -1; + } #endregion @@ -410,22 +500,29 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components var splittablePieces = selectedPieces.Where(isSplittable).ToList(); int splittableCount = splittablePieces.Count; - List curveTypeItems = new List(); + curveTypeItems = new List(); - if (!selectedPieces.Contains(Pieces[0])) + for (int i = 0; i < path_types.Length; ++i) { - curveTypeItems.Add(createMenuItemForPathType(null)); - curveTypeItems.Add(new OsuMenuItemSpacer()); + PathType? type = path_types[i]; + + // special inherit case + if (type == null) + { + if (selectedPieces.Contains(Pieces[0])) + continue; + + curveTypeItems.Add(new OsuMenuItemSpacer()); + } + + curveTypeItems.Add(createMenuItemForPathType(type, InputKey.Number1 + i)); } - // todo: hide/disable items which aren't valid for selected points - curveTypeItems.Add(createMenuItemForPathType(PathType.LINEAR)); - curveTypeItems.Add(createMenuItemForPathType(PathType.PERFECT_CURVE)); - curveTypeItems.Add(createMenuItemForPathType(PathType.BEZIER)); - curveTypeItems.Add(createMenuItemForPathType(PathType.BSpline(4))); - if (selectedPieces.Any(piece => piece.ControlPoint.Type?.Type == SplineType.Catmull)) + { + curveTypeItems.Add(new OsuMenuItemSpacer()); curveTypeItems.Add(createMenuItemForPathType(PathType.CATMULL)); + } var menuItems = new List { @@ -448,31 +545,50 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components () => DeleteSelected()) ); + updateCurveMenuItems(); + return menuItems.ToArray(); + + CurveTypeMenuItem createMenuItemForPathType(PathType? type, InputKey? key = null) + { + Hotkey hotkey = default; + + if (key != null) + hotkey = new Hotkey(new KeyCombination(InputKey.Alt, key.Value)); + + return new CurveTypeMenuItem(type, _ => updatePathTypeOfSelectedPieces(type)) { Hotkey = hotkey }; + } } } - private MenuItem createMenuItemForPathType(PathType? type) + private void updateCurveMenuItems() { - int totalCount = Pieces.Count(p => p.IsSelected.Value); - int countOfState = Pieces.Where(p => p.IsSelected.Value).Count(p => p.ControlPoint.Type == type); + if (curveTypeItems == null) + return; - var item = new TernaryStateRadioMenuItem(type?.Description ?? "Inherit", MenuItemType.Standard, _ => + foreach (var item in curveTypeItems.OfType()) { - foreach (var p in Pieces.Where(p => p.IsSelected.Value)) - updatePathType(p, type); + int totalCount = Pieces.Count(p => p.IsSelected.Value); + int countOfState = Pieces.Where(p => p.IsSelected.Value).Count(p => p.ControlPoint.Type == item.PathType); - EnsureValidPathTypes(); - }); + if (countOfState == totalCount) + item.State.Value = TernaryState.True; + else if (countOfState > 0) + item.State.Value = TernaryState.Indeterminate; + else + item.State.Value = TernaryState.False; + } + } - if (countOfState == totalCount) - item.State.Value = TernaryState.True; - else if (countOfState > 0) - item.State.Value = TernaryState.Indeterminate; - else - item.State.Value = TernaryState.False; + private class CurveTypeMenuItem : TernaryStateRadioMenuItem + { + public readonly PathType? PathType; - return item; + public CurveTypeMenuItem(PathType? pathType, Action action) + : base(pathType?.Description ?? "Inherit", MenuItemType.Standard, action) + { + PathType = pathType; + } } } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/SliderBodyPiece.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/SliderBodyPiece.cs index 075e9e6aa1..12626a77ed 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/SliderBodyPiece.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/SliderBodyPiece.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using osu.Framework.Allocation; +using osu.Framework.Graphics; using osu.Game.Graphics; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Skinning.Default; @@ -27,14 +28,16 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components public SliderBodyPiece() { - InternalChild = body = new ManualSliderBody - { - AccentColour = Color4.Transparent - }; + AutoSizeAxes = Axes.Both; // SliderSelectionBlueprint relies on calling ReceivePositionalInputAt on this drawable to determine whether selection should occur. // Without AlwaysPresent, a movement in a parent container (ie. the editor composer area resizing) could cause incorrect input handling. AlwaysPresent = true; + + InternalChild = body = new ManualSliderBody + { + AccentColour = Color4.Transparent + }; } [BackgroundDependencyLoader] @@ -61,7 +64,6 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components body.SetVertices(vertices); } - Size = body.Size; OriginPosition = body.PathOffset; } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderCircleOverlay.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderCircleOverlay.cs index d47cf6bf23..9c2998466a 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderCircleOverlay.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderCircleOverlay.cs @@ -1,8 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components; using osu.Game.Rulesets.Osu.Objects; @@ -10,42 +12,81 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders { public partial class SliderCircleOverlay : CompositeDrawable { + public SliderEndDragMarker? EndDragMarker { get; } + + public RectangleF VisibleQuad + { + get + { + var result = CirclePiece.ScreenSpaceDrawQuad.AABBFloat; + + if (endDragMarkerContainer == null) return result; + + var size = result.Size * 1.4f; + var location = result.TopLeft - result.Size * 0.2f; + return new RectangleF(location, size); + } + } + protected readonly HitCirclePiece CirclePiece; private readonly Slider slider; private readonly SliderPosition position; - private readonly HitCircleOverlapMarker marker; + private readonly HitCircleOverlapMarker? marker; + private readonly Container? endDragMarkerContainer; public SliderCircleOverlay(Slider slider, SliderPosition position) { this.slider = slider; this.position = position; - InternalChildren = new Drawable[] + if (position == SliderPosition.Start) + AddInternal(marker = new HitCircleOverlapMarker()); + + AddInternal(CirclePiece = new HitCirclePiece()); + + if (position == SliderPosition.End) { - marker = new HitCircleOverlapMarker(), - CirclePiece = new HitCirclePiece(), - }; + AddInternal(endDragMarkerContainer = new Container + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Padding = new MarginPadding(-2.5f), + Child = EndDragMarker = new SliderEndDragMarker() + }); + } } protected override void Update() { base.Update(); - var circle = position == SliderPosition.Start ? (HitCircle)slider.HeadCircle : slider.TailCircle; + var circle = position == SliderPosition.Start ? (HitCircle)slider.HeadCircle : + slider.RepeatCount % 2 == 0 ? slider.TailCircle : slider.LastRepeat!; CirclePiece.UpdateFrom(circle); - marker.UpdateFrom(circle); + marker?.UpdateFrom(circle); + + if (endDragMarkerContainer != null) + { + endDragMarkerContainer.Position = circle.Position + slider.StackOffset; + endDragMarkerContainer.Scale = CirclePiece.Scale * 1.2f; + var diff = slider.Path.PositionAt(1) - slider.Path.PositionAt(0.99f); + endDragMarkerContainer.Rotation = float.RadiansToDegrees(MathF.Atan2(diff.Y, diff.X)); + } } public override void Hide() { CirclePiece.Hide(); + endDragMarkerContainer?.Hide(); } public override void Show() { CirclePiece.Show(); + endDragMarkerContainer?.Show(); } } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderEndDragMarker.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderEndDragMarker.cs new file mode 100644 index 0000000000..37383544dc --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderEndDragMarker.cs @@ -0,0 +1,84 @@ +// 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.Lines; +using osu.Framework.Input.Events; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osu.Game.Rulesets.Osu.Objects; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders +{ + public partial class SliderEndDragMarker : SmoothPath + { + public Action? StartDrag { get; set; } + public Action? Drag { get; set; } + public Action? EndDrag { get; set; } + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + var path = PathApproximator.CircularArcToPiecewiseLinear([ + new Vector2(0, OsuHitObject.OBJECT_RADIUS), + new Vector2(OsuHitObject.OBJECT_RADIUS, 0), + new Vector2(0, -OsuHitObject.OBJECT_RADIUS) + ]); + + Anchor = Anchor.CentreLeft; + Origin = Anchor.CentreLeft; + PathRadius = 5; + Vertices = path; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + updateState(); + } + + protected override bool OnHover(HoverEvent e) + { + updateState(); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateState(); + base.OnHoverLost(e); + } + + protected override bool OnDragStart(DragStartEvent e) + { + updateState(); + StartDrag?.Invoke(e); + return true; + } + + protected override void OnDrag(DragEvent e) + { + updateState(); + base.OnDrag(e); + Drag?.Invoke(e); + } + + protected override void OnDragEnd(DragEndEvent e) + { + updateState(); + EndDrag?.Invoke(); + base.OnDragEnd(e); + } + + private void updateState() + { + Colour = IsHovered || IsDragged ? colours.Red : colours.Yellow; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs index 0fa84c91fc..4f2f6516a8 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs @@ -1,13 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Input; @@ -25,33 +21,33 @@ using osuTK.Input; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders { - public partial class SliderPlacementBlueprint : PlacementBlueprint + public partial class SliderPlacementBlueprint : HitObjectPlacementBlueprint { public new Slider HitObject => (Slider)base.HitObject; - private SliderBodyPiece bodyPiece; - private HitCirclePiece headCirclePiece; - private HitCirclePiece tailCirclePiece; - private PathControlPointVisualiser controlPointVisualiser; + private SliderBodyPiece bodyPiece = null!; + private HitCirclePiece headCirclePiece = null!; + private HitCirclePiece tailCirclePiece = null!; + private PathControlPointVisualiser controlPointVisualiser = null!; - private InputManager inputManager; + private InputManager inputManager = null!; + + private PathControlPoint? cursor; private SliderPlacementState state; private PathControlPoint segmentStart; - private PathControlPoint cursor; + private int currentSegmentLength; + private bool usingCustomSegmentType; - [Resolved(CanBeNull = true)] - [CanBeNull] - private IPositionSnapProvider positionSnapProvider { get; set; } + [Resolved] + private IPositionSnapProvider? positionSnapProvider { get; set; } - [Resolved(CanBeNull = true)] - [CanBeNull] - private IDistanceSnapProvider distanceSnapProvider { get; set; } + [Resolved] + private IDistanceSnapProvider? distanceSnapProvider { get; set; } - [Resolved(CanBeNull = true)] - [CanBeNull] - private FreehandSliderToolboxGroup freehandToolboxGroup { get; set; } + [Resolved] + private FreehandSliderToolboxGroup? freehandToolboxGroup { get; set; } private readonly IncrementalBSplineBuilder bSplineBuilder = new IncrementalBSplineBuilder { Degree = 4 }; @@ -83,7 +79,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders protected override void LoadComplete() { base.LoadComplete(); - inputManager = GetContainingInputManager(); + + inputManager = GetContainingInputManager()!; if (freehandToolboxGroup != null) { @@ -107,7 +104,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders } [Resolved] - private EditorBeatmap editorBeatmap { get; set; } + private EditorBeatmap editorBeatmap { get; set; } = null!; public override void UpdateTimeAndPosition(SnapResult result) { @@ -149,21 +146,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders case SliderPlacementState.ControlPoints: if (canPlaceNewControlPoint(out var lastPoint)) - { - // Place a new point by detatching the current cursor. - updateCursor(); - cursor = null; - } - else - { - // Transform the last point into a new segment. - Debug.Assert(lastPoint != null); - - segmentStart = lastPoint; - segmentStart.Type = PathType.LINEAR; - - currentSegmentLength = 1; - } + placeNewControlPoint(); + else if (lastPoint != null) + beginNewSegment(lastPoint); break; } @@ -171,6 +156,22 @@ 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; + segmentStart.Type = PathType.LINEAR; + + currentSegmentLength = 1; + usingCustomSegmentType = false; + } + protected override bool OnDragStart(DragStartEvent e) { if (e.Button != MouseButton.Left) @@ -223,6 +224,72 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders base.OnMouseUp(e); } + private static readonly PathType[] path_types = + [ + PathType.LINEAR, + PathType.BEZIER, + PathType.PERFECT_CURVE, + PathType.BSpline(4), + ]; + + protected override bool OnKeyDown(KeyDownEvent e) + { + if (e.Repeat) + return false; + + if (state != SliderPlacementState.ControlPoints) + return false; + + switch (e.Key) + { + case Key.S: + { + if (!canPlaceNewControlPoint(out _)) + return false; + + placeNewControlPoint(); + var last = HitObject.Path.ControlPoints.Last(p => p != cursor); + beginNewSegment(last); + return true; + } + + case Key.Number1: + case Key.Number2: + case Key.Number3: + case Key.Number4: + { + if (!e.AltPressed) + return false; + + usingCustomSegmentType = true; + segmentStart.Type = path_types[e.Key - Key.Number1]; + controlPointVisualiser.EnsureValidPathTypes(); + return true; + } + + case Key.Tab: + { + usingCustomSegmentType = true; + + int currentTypeIndex = segmentStart.Type.HasValue ? Array.IndexOf(path_types, segmentStart.Type.Value) : -1; + + if (currentTypeIndex < 0 && e.ShiftPressed) + currentTypeIndex = 0; + + do + { + currentTypeIndex = (path_types.Length + currentTypeIndex + (e.ShiftPressed ? -1 : 1)) % path_types.Length; + segmentStart.Type = path_types[currentTypeIndex]; + controlPointVisualiser.EnsureValidPathTypes(); + } while (segmentStart.Type != path_types[currentTypeIndex]); + + return true; + } + } + + return false; + } + protected override void Update() { base.Update(); @@ -246,6 +313,12 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders private void updatePathType() { + if (usingCustomSegmentType) + { + controlPointVisualiser.EnsureValidPathTypes(); + return; + } + if (state == SliderPlacementState.Drawing) { segmentStart.Type = PathType.BSpline(4); @@ -286,8 +359,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders } // Update the cursor position. - var result = positionSnapProvider?.FindSnappedPositionAndTime(inputManager.CurrentState.Mouse.Position, state == SliderPlacementState.ControlPoints ? SnapType.GlobalGrids : SnapType.All); - cursor.Position = ToLocalSpace(result?.ScreenSpacePosition ?? inputManager.CurrentState.Mouse.Position) - HitObject.Position; + cursor.Position = getCursorPosition(); } else if (cursor != null) { @@ -301,19 +373,34 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders } } + private Vector2 getCursorPosition() + { + var result = positionSnapProvider?.FindSnappedPositionAndTime(inputManager.CurrentState.Mouse.Position, state == SliderPlacementState.ControlPoints ? SnapType.GlobalGrids : SnapType.All); + return ToLocalSpace(result?.ScreenSpacePosition ?? inputManager.CurrentState.Mouse.Position) - HitObject.Position; + } + /// /// Whether a new control point can be placed at the current mouse position. /// /// The last-placed control point. May be null, but is not null if false is returned. /// Whether a new control point can be placed at the current position. - private bool canPlaceNewControlPoint([CanBeNull] out PathControlPoint lastPoint) + private bool canPlaceNewControlPoint(out PathControlPoint? lastPoint) { // We cannot rely on the ordering of drawable pieces, so find the respective drawable piece by searching for the last non-cursor control point. var last = HitObject.Path.ControlPoints.LastOrDefault(p => p != cursor); var lastPiece = controlPointVisualiser.Pieces.Single(p => p.ControlPoint == last); lastPoint = last; - return lastPiece.IsHovered != true; + // We may only place a new control point if the cursor is not overlapping with the last control point. + // If snapping is enabled, the cursor may not hover the last piece while still placing the control point at the same position. + return !lastPiece.IsHovered && (last is null || Vector2.DistanceSquared(last.Position, getCursorPosition()) > 1f); + } + + private void placeNewControlPoint() + { + // Place a new point by detatching the current cursor. + updateCursor(); + cursor = null; } private void updateSlider() @@ -321,7 +408,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders if (state == SliderPlacementState.Drawing) HitObject.Path.ExpectedDistance.Value = (float)HitObject.Path.CalculatedDistance; else - HitObject.Path.ExpectedDistance.Value = distanceSnapProvider?.FindSnappedDistance(HitObject, (float)HitObject.Path.CalculatedDistance) ?? (float)HitObject.Path.CalculatedDistance; + HitObject.Path.ExpectedDistance.Value = distanceSnapProvider?.FindSnappedDistance(HitObject, (float)HitObject.Path.CalculatedDistance, DistanceSnapTarget.Start) ?? (float)HitObject.Path.CalculatedDistance; bodyPiece.UpdateFrom(HitObject); headCirclePiece.UpdateFrom(HitObject.HeadCircle); @@ -349,7 +436,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders // Replace this segment with a circular arc if it is a reasonable substitute. var circleArcSegment = tryCircleArc(segment); - if (circleArcSegment is not null) + if (circleArcSegment != null) { HitObject.Path.ControlPoints.Add(new PathControlPoint(circleArcSegment[0], PathType.PERFECT_CURVE)); HitObject.Path.ControlPoints.Add(new PathControlPoint(circleArcSegment[1])); @@ -366,7 +453,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders } } - private Vector2[] tryCircleArc(List segment) + private Vector2[]? tryCircleArc(List segment) { if (segment.Count < 3 || freehandToolboxGroup?.CircleThreshold.Value == 0) return null; diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs index 2da462caf4..34de81f1ba 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs @@ -1,19 +1,21 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - +using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Caching; using osu.Framework.Graphics; using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Utils; using osu.Game.Audio; +using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; @@ -32,33 +34,57 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders { protected new DrawableSlider DrawableObject => (DrawableSlider)base.DrawableObject; - protected SliderBodyPiece BodyPiece { get; private set; } - protected SliderCircleOverlay HeadOverlay { get; private set; } - protected SliderCircleOverlay TailOverlay { get; private set; } + protected SliderBodyPiece BodyPiece { get; private set; } = null!; + protected SliderCircleOverlay HeadOverlay { get; private set; } = null!; + protected SliderCircleOverlay TailOverlay { get; private set; } = null!; - [CanBeNull] - protected PathControlPointVisualiser ControlPointVisualiser { get; private set; } + protected PathControlPointVisualiser? ControlPointVisualiser { get; private set; } - [Resolved(CanBeNull = true)] - private IDistanceSnapProvider distanceSnapProvider { get; set; } + [Resolved] + private IDistanceSnapProvider? distanceSnapProvider { get; set; } - [Resolved(CanBeNull = true)] - private IPlacementHandler placementHandler { get; set; } + [Resolved] + private IPlacementHandler? placementHandler { get; set; } - [Resolved(CanBeNull = true)] - private EditorBeatmap editorBeatmap { get; set; } + [Resolved] + private EditorBeatmap? editorBeatmap { get; set; } - [Resolved(CanBeNull = true)] - private IEditorChangeHandler changeHandler { get; set; } + [Resolved] + private IEditorChangeHandler? changeHandler { get; set; } - [Resolved(CanBeNull = true)] - private BindableBeatDivisor beatDivisor { get; set; } + [Resolved] + private BindableBeatDivisor? beatDivisor { get; set; } - public override Quad SelectionQuad => BodyPiece.ScreenSpaceDrawQuad; + private PathControlPoint? placementControlPoint; + + public override Quad SelectionQuad + { + get + { + var result = BodyPiece.ScreenSpaceDrawQuad.AABBFloat; + + result = RectangleF.Union(result, HeadOverlay.VisibleQuad); + result = RectangleF.Union(result, TailOverlay.VisibleQuad); + + if (ControlPointVisualiser != null) + { + foreach (var piece in ControlPointVisualiser.Pieces) + result = RectangleF.Union(result, piece.ScreenSpaceDrawQuad.AABBFloat); + } + + return result; + } + } private readonly BindableList controlPoints = new BindableList(); private readonly IBindable pathVersion = new Bindable(); private readonly BindableList selectedObjects = new BindableList(); + private readonly Bindable showHitMarkers = new Bindable(); + + // Cached slider path which ignored the expected distance value. + private readonly Cached fullPathCache = new Cached(); + + private Vector2 lastRightClickPosition; public SliderSelectionBlueprint(Slider slider) : base(slider) @@ -66,7 +92,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders } [BackgroundDependencyLoader] - private void load() + private void load(OsuConfigManager config) { InternalChildren = new Drawable[] { @@ -74,6 +100,15 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders HeadOverlay = CreateCircleOverlay(HitObject, SliderPosition.Start), TailOverlay = CreateCircleOverlay(HitObject, SliderPosition.End), }; + + // tail will always have a non-null end drag marker. + Debug.Assert(TailOverlay.EndDragMarker != null); + + TailOverlay.EndDragMarker.StartDrag += startAdjustingLength; + TailOverlay.EndDragMarker.Drag += adjustLength; + TailOverlay.EndDragMarker.EndDrag += endAdjustLength; + + config.BindWith(OsuSetting.EditorShowHitMarkers, showHitMarkers); } protected override void LoadComplete() @@ -81,6 +116,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders base.LoadComplete(); controlPoints.BindTo(HitObject.Path.ControlPoints); + controlPoints.CollectionChanged += (_, _) => fullPathCache.Invalidate(); pathVersion.BindTo(HitObject.Path.Version); pathVersion.BindValueChanged(_ => editorBeatmap?.Update(HitObject)); @@ -90,6 +126,11 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders if (editorBeatmap != null) selectedObjects.BindTo(editorBeatmap.SelectedHitObjects); selectedObjects.BindCollectionChanged((_, _) => updateVisualDefinition(), true); + showHitMarkers.BindValueChanged(_ => + { + if (!showHitMarkers.Value) + DrawableObject.RestoreHitAnimations(); + }); } public override bool HandleQuickDeletion() @@ -100,7 +141,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders return false; hoveredControlPoint.IsSelected.Value = true; - ControlPointVisualiser.DeleteSelected(); + ControlPointVisualiser?.DeleteSelected(); return true; } @@ -110,12 +151,14 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders if (IsSelected) BodyPiece.UpdateFrom(HitObject); + + if (showHitMarkers.Value) + DrawableObject.SuppressHitAnimations(); } protected override bool OnHover(HoverEvent e) { updateVisualDefinition(); - return base.OnHover(e); } @@ -135,6 +178,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders { base.OnDeselected(); + if (placementControlPoint != null) + endControlPointPlacement(); + updateVisualDefinition(); BodyPiece.RecyclePath(); } @@ -160,17 +206,16 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders } } - private Vector2 rightClickPosition; - protected override bool OnMouseDown(MouseDownEvent e) { switch (e.Button) { case MouseButton.Right: - rightClickPosition = e.MouseDownPosition; + lastRightClickPosition = e.MouseDownPosition; return false; // Allow right click to be handled by context menu case MouseButton.Left: + // If there's more than two objects selected, ctrl+click should deselect if (e.ControlPressed && IsSelected && selectedObjects.Count < 2) { @@ -186,8 +231,134 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders return false; } - [CanBeNull] - private PathControlPoint placementControlPoint; + #region Length Adjustment (independent of path nodes) + + private Vector2 lengthAdjustMouseOffset; + private double oldDuration; + private double oldVelocityMultiplier; + private double desiredDistance; + private bool isAdjustingLength; + private bool adjustVelocityMomentary; + + private void startAdjustingLength(DragStartEvent e) + { + isAdjustingLength = true; + adjustVelocityMomentary = e.ShiftPressed; + lengthAdjustMouseOffset = ToLocalSpace(e.ScreenSpaceMouseDownPosition) - HitObject.Position - HitObject.Path.PositionAt(1); + oldDuration = HitObject.Path.Distance / HitObject.SliderVelocityMultiplier; + oldVelocityMultiplier = HitObject.SliderVelocityMultiplier; + changeHandler?.BeginChange(); + } + + private void endAdjustLength() + { + trimExcessControlPoints(HitObject.Path); + changeHandler?.EndChange(); + isAdjustingLength = false; + } + + private void adjustLength(MouseEvent e) => adjustLength(findClosestPathDistance(e), e.ShiftPressed); + + private void adjustLength(double proposedDistance, bool adjustVelocity) + { + desiredDistance = proposedDistance; + double proposedVelocity = oldVelocityMultiplier; + + if (adjustVelocity) + { + proposedVelocity = proposedDistance / oldDuration; + proposedDistance = MathHelper.Clamp(proposedDistance, 0.1 * oldDuration, 10 * oldDuration); + } + else + { + double minDistance = distanceSnapProvider?.GetBeatSnapDistanceAt(HitObject, false) * oldVelocityMultiplier ?? 1; + // Add a small amount to the proposed distance to make it easier to snap to the full length of the slider. + proposedDistance = distanceSnapProvider?.FindSnappedDistance(HitObject, (float)proposedDistance + 1, DistanceSnapTarget.Start) ?? proposedDistance; + proposedDistance = MathHelper.Clamp(proposedDistance, minDistance, HitObject.Path.CalculatedDistance); + } + + if (Precision.AlmostEquals(proposedDistance, HitObject.Path.Distance) && Precision.AlmostEquals(proposedVelocity, HitObject.SliderVelocityMultiplier)) + return; + + HitObject.SliderVelocityMultiplier = proposedVelocity; + HitObject.Path.ExpectedDistance.Value = proposedDistance; + editorBeatmap?.Update(HitObject); + } + + /// + /// Trims control points from the end of the slider path which are not required to reach the expected end of the slider. + /// + /// The slider path to trim control points of. + private void trimExcessControlPoints(SliderPath sliderPath) + { + if (!sliderPath.ExpectedDistance.Value.HasValue) + return; + + double[] segmentEnds = sliderPath.GetSegmentEnds().ToArray(); + int segmentIndex = 0; + + for (int i = 1; i < sliderPath.ControlPoints.Count - 1; i++) + { + if (!sliderPath.ControlPoints[i].Type.HasValue) continue; + + if (Precision.AlmostBigger(segmentEnds[segmentIndex], 1, 1E-3)) + { + sliderPath.ControlPoints.RemoveRange(i + 1, sliderPath.ControlPoints.Count - i - 1); + sliderPath.ControlPoints[^1].Type = null; + break; + } + + segmentIndex++; + } + } + + /// + /// Finds the expected distance value for which the slider end is closest to the mouse position. + /// + private double findClosestPathDistance(MouseEvent e) + { + const double step1 = 10; + const double step2 = 0.1; + const double longer_distance_bias = 0.01; + + var desiredPosition = ToLocalSpace(e.ScreenSpaceMousePosition) - HitObject.Position - lengthAdjustMouseOffset; + + if (!fullPathCache.IsValid) + fullPathCache.Value = new SliderPath(HitObject.Path.ControlPoints.ToArray()); + + // Do a linear search to find the closest point on the path to the mouse position. + double bestValue = 0; + double minDistance = double.MaxValue; + + for (double d = 0; d <= fullPathCache.Value.CalculatedDistance; d += step1) + { + double t = d / fullPathCache.Value.CalculatedDistance; + double dist = Vector2.Distance(fullPathCache.Value.PositionAt(t), desiredPosition) - d * longer_distance_bias; + + if (dist >= minDistance) continue; + + minDistance = dist; + bestValue = d; + } + + // Do another linear search to fine-tune the result. + double maxValue = Math.Min(bestValue + step1, fullPathCache.Value.CalculatedDistance); + + for (double d = bestValue - step1; d <= maxValue; d += step2) + { + double t = d / fullPathCache.Value.CalculatedDistance; + double dist = Vector2.Distance(fullPathCache.Value.PositionAt(t), desiredPosition) - d * longer_distance_bias; + + if (dist >= minDistance) continue; + + minDistance = dist; + bestValue = d; + } + + return bestValue; + } + + #endregion protected override bool OnDragStart(DragStartEvent e) { @@ -209,13 +380,16 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders protected override void OnMouseUp(MouseUpEvent e) { if (placementControlPoint != null) - { - if (IsDragged) - ControlPointVisualiser?.DragEnded(); + endControlPointPlacement(); + } - placementControlPoint = null; - changeHandler?.EndChange(); - } + private void endControlPointPlacement() + { + if (IsDragged) + ControlPointVisualiser?.DragEnded(); + + placementControlPoint = null; + changeHandler?.EndChange(); } protected override bool OnKeyDown(KeyDownEvent e) @@ -229,9 +403,24 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders return true; } + if (isAdjustingLength && e.ShiftPressed != adjustVelocityMomentary) + { + adjustVelocityMomentary = e.ShiftPressed; + adjustLength(desiredDistance, adjustVelocityMomentary); + return true; + } + return false; } + protected override void OnKeyUp(KeyUpEvent e) + { + if (!IsSelected || !isAdjustingLength || e.ShiftPressed == adjustVelocityMomentary) return; + + adjustVelocityMomentary = e.ShiftPressed; + adjustLength(desiredDistance, adjustVelocityMomentary); + } + private PathControlPoint addControlPoint(Vector2 position) { position -= HitObject.Position; @@ -300,6 +489,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders private void splitControlPoints(List controlPointsToSplitAt) { + if (editorBeatmap == null) + return; + // Arbitrary gap in milliseconds to put between split slider pieces const double split_gap = 100; @@ -403,8 +595,19 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders public override MenuItem[] ContextMenuItems => new MenuItem[] { - new OsuMenuItem("Add control point", MenuItemType.Standard, () => addControlPoint(rightClickPosition)), - new OsuMenuItem("Convert to stream", MenuItemType.Destructive, convertToStream), + new OsuMenuItem("Add control point", MenuItemType.Standard, () => + { + changeHandler?.BeginChange(); + addControlPoint(lastRightClickPosition); + changeHandler?.EndChange(); + }) + { + Hotkey = new Hotkey(new KeyCombination(InputKey.Control, InputKey.MouseLeft)) + }, + new OsuMenuItem("Convert to stream", MenuItemType.Destructive, convertToStream) + { + Hotkey = new Hotkey(new KeyCombination(InputKey.Control, InputKey.Shift, InputKey.F)) + }, }; // Always refer to the drawable object's slider body so subsequent movement deltas are calculated with updated positions. diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerPlacementBlueprint.cs index f59be0e0e9..17d2dcd75c 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerPlacementBlueprint.cs @@ -13,7 +13,7 @@ using osuTK.Input; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners { - public partial class SpinnerPlacementBlueprint : PlacementBlueprint + public partial class SpinnerPlacementBlueprint : HitObjectPlacementBlueprint { public new Spinner HitObject => (Spinner)base.HitObject; diff --git a/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditorRuleset.cs b/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditorRuleset.cs index 68c565af4d..4dd718597a 100644 --- a/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditorRuleset.cs +++ b/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditorRuleset.cs @@ -2,10 +2,13 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.UI; +using osu.Game.Screens.Edit; using osuTK; namespace osu.Game.Rulesets.Osu.Edit @@ -23,12 +26,32 @@ namespace osu.Game.Rulesets.Osu.Edit private partial class OsuEditorPlayfield : OsuPlayfield { + [Resolved] + private EditorBeatmap editorBeatmap { get; set; } = null!; + protected override GameplayCursorContainer? CreateCursor() => null; public OsuEditorPlayfield() { HitPolicy = new AnyOrderHitPolicy(); } + + protected override void LoadComplete() + { + base.LoadComplete(); + + editorBeatmap.BeatmapReprocessed += onBeatmapReprocessed; + } + + private void onBeatmapReprocessed() => ApplyCircleSizeToPlayfieldBorder(editorBeatmap); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (editorBeatmap.IsNotNull()) + editorBeatmap.BeatmapReprocessed -= onBeatmapReprocessed; + } } } } diff --git a/osu.Game.Rulesets.Osu/Edit/GenerateToolboxGroup.cs b/osu.Game.Rulesets.Osu/Edit/GenerateToolboxGroup.cs new file mode 100644 index 0000000000..4e188a2b86 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/GenerateToolboxGroup.cs @@ -0,0 +1,54 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Game.Rulesets.Edit; +using osu.Game.Screens.Edit.Components; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Osu.Edit +{ + public partial class GenerateToolboxGroup : EditorToolboxGroup + { + private readonly EditorToolButton polygonButton; + + public GenerateToolboxGroup() + : base("Generate") + { + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(5), + Children = new Drawable[] + { + polygonButton = new EditorToolButton("Polygon", + () => new SpriteIcon { Icon = FontAwesome.Solid.Spinner }, + () => new PolygonGenerationPopover()), + } + }; + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + if (e.Repeat) return false; + + switch (e.Key) + { + case Key.D: + if (!e.ControlPressed || !e.ShiftPressed) + return false; + + polygonButton.TriggerClick(); + return true; + + default: + return false; + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/GridFromPointsTool.cs b/osu.Game.Rulesets.Osu/Edit/GridFromPointsTool.cs new file mode 100644 index 0000000000..626153a7fd --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/GridFromPointsTool.cs @@ -0,0 +1,29 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Tools; +using osu.Game.Rulesets.Osu.Edit.Blueprints; + +namespace osu.Game.Rulesets.Osu.Edit +{ + public partial class GridFromPointsTool : CompositionTool + { + public GridFromPointsTool() + : base("Grid") + { + TooltipText = """ + Left click to set the origin. + Left click again to set the spacing and rotation. + Right click to reset to default. + Click and drag to set the origin, spacing and rotation. + """; + } + + public override Drawable CreateIcon() => new SpriteIcon { Icon = FontAwesome.Solid.DraftingCompass }; + + public override PlacementBlueprint CreatePlacementBlueprint() => new GridPlacementBlueprint(); + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/HitCircleCompositionTool.cs b/osu.Game.Rulesets.Osu/Edit/HitCircleCompositionTool.cs index c41ae10b2e..d3116ede30 100644 --- a/osu.Game.Rulesets.Osu/Edit/HitCircleCompositionTool.cs +++ b/osu.Game.Rulesets.Osu/Edit/HitCircleCompositionTool.cs @@ -10,7 +10,7 @@ using osu.Game.Rulesets.Osu.Objects; namespace osu.Game.Rulesets.Osu.Edit { - public class HitCircleCompositionTool : HitObjectCompositionTool + public class HitCircleCompositionTool : CompositionTool { public HitCircleCompositionTool() : base(nameof(HitCircle)) @@ -19,6 +19,6 @@ namespace osu.Game.Rulesets.Osu.Edit public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles); - public override PlacementBlueprint CreatePlacementBlueprint() => new HitCirclePlacementBlueprint(); + public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new HitCirclePlacementBlueprint(); } } diff --git a/osu.Game.Rulesets.Osu/Edit/OsuGridToolboxGroup.cs b/osu.Game.Rulesets.Osu/Edit/OsuGridToolboxGroup.cs new file mode 100644 index 0000000000..768a764ad1 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/OsuGridToolboxGroup.cs @@ -0,0 +1,303 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.Components.RadioButtons; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Edit +{ + public partial class OsuGridToolboxGroup : EditorToolboxGroup, IKeyBindingHandler + { + [Resolved] + private EditorBeatmap editorBeatmap { get; set; } = null!; + + [Resolved] + private IExpandingContainer? expandingContainer { get; set; } + + /// + /// X position of the grid's origin. + /// + public BindableFloat StartPositionX { get; } = new BindableFloat(OsuPlayfield.BASE_SIZE.X / 2) + { + MinValue = 0f, + MaxValue = OsuPlayfield.BASE_SIZE.X, + }; + + /// + /// Y position of the grid's origin. + /// + public BindableFloat StartPositionY { get; } = new BindableFloat(OsuPlayfield.BASE_SIZE.Y / 2) + { + MinValue = 0f, + MaxValue = OsuPlayfield.BASE_SIZE.Y, + }; + + /// + /// The spacing between grid lines. + /// + public BindableFloat Spacing { get; } = new BindableFloat(4f) + { + MinValue = 4f, + MaxValue = 128f, + }; + + /// + /// Rotation of the grid lines in degrees. + /// + public BindableFloat GridLinesRotation { get; } = new BindableFloat(0f) + { + MinValue = -180f, + MaxValue = 180f, + }; + + /// + /// Read-only bindable representing the grid's origin. + /// Equivalent to new Vector2(StartPositionX, StartPositionY) + /// + public Bindable StartPosition { get; } = new Bindable(OsuPlayfield.BASE_SIZE / 2); + + /// + /// Read-only bindable representing the grid's spacing in both the X and Y dimension. + /// Equivalent to new Vector2(Spacing) + /// + public Bindable SpacingVector { get; } = new Bindable(); + + public Bindable GridType { get; } = new Bindable(); + + private ExpandableSlider startPositionXSlider = null!; + private ExpandableSlider startPositionYSlider = null!; + private ExpandableSlider spacingSlider = null!; + private ExpandableSlider gridLinesRotationSlider = null!; + private EditorRadioButtonCollection gridTypeButtons = null!; + + public OsuGridToolboxGroup() + : base("grid") + { + } + + private const float max_automatic_spacing = 64; + + public void SetGridFromPoints(Vector2 point1, Vector2 point2) + { + StartPositionX.Value = point1.X; + StartPositionY.Value = point1.Y; + + // Get the angle between the two points and normalize to the valid range. + if (!GridLinesRotation.Disabled) + { + float period = GridLinesRotation.MaxValue - GridLinesRotation.MinValue; + GridLinesRotation.Value = normalizeRotation(MathHelper.RadiansToDegrees(MathF.Atan2(point2.Y - point1.Y, point2.X - point1.X)), period); + } + + // Divide the distance so that there is a good density of grid lines. + // This matches the maximum grid size of the grid size cycling hotkey. + float dist = Vector2.Distance(point1, point2); + while (dist >= max_automatic_spacing) + dist /= 2; + Spacing.Value = dist; + } + + [BackgroundDependencyLoader] + private void load() + { + Children = new Drawable[] + { + startPositionXSlider = new ExpandableSlider + { + Current = StartPositionX, + KeyboardStep = 1, + }, + startPositionYSlider = new ExpandableSlider + { + Current = StartPositionY, + KeyboardStep = 1, + }, + spacingSlider = new ExpandableSlider + { + Current = Spacing, + KeyboardStep = 1, + }, + gridLinesRotationSlider = new ExpandableSlider + { + Current = GridLinesRotation, + KeyboardStep = 1, + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(0f, 10f), + Children = new Drawable[] + { + gridTypeButtons = new EditorRadioButtonCollection + { + RelativeSizeAxes = Axes.X, + Items = new[] + { + new RadioButton("Square", + () => GridType.Value = PositionSnapGridType.Square, + () => new SpriteIcon { Icon = FontAwesome.Regular.Square }), + new RadioButton("Triangle", + () => GridType.Value = PositionSnapGridType.Triangle, + () => new OutlineTriangle(true, 20)), + new RadioButton("Circle", + () => GridType.Value = PositionSnapGridType.Circle, + () => new SpriteIcon { Icon = FontAwesome.Regular.Circle }), + } + }, + } + }, + }; + + Spacing.Value = editorBeatmap.BeatmapInfo.GridSize; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + gridTypeButtons.Items.First().Select(); + + StartPositionX.BindValueChanged(x => + { + startPositionXSlider.ContractedLabelText = $"X: {x.NewValue:#,0.##}"; + startPositionXSlider.ExpandedLabelText = $"X Offset: {x.NewValue:#,0.##}"; + StartPosition.Value = new Vector2(x.NewValue, StartPosition.Value.Y); + }, true); + + StartPositionY.BindValueChanged(y => + { + startPositionYSlider.ContractedLabelText = $"Y: {y.NewValue:#,0.##}"; + startPositionYSlider.ExpandedLabelText = $"Y Offset: {y.NewValue:#,0.##}"; + StartPosition.Value = new Vector2(StartPosition.Value.X, y.NewValue); + }, true); + + StartPosition.BindValueChanged(pos => + { + StartPositionX.Value = pos.NewValue.X; + StartPositionY.Value = pos.NewValue.Y; + }); + + Spacing.BindValueChanged(spacing => + { + spacingSlider.ContractedLabelText = $"S: {spacing.NewValue:#,0.##}"; + spacingSlider.ExpandedLabelText = $"Spacing: {spacing.NewValue:#,0.##}"; + SpacingVector.Value = new Vector2(spacing.NewValue); + editorBeatmap.BeatmapInfo.GridSize = (int)spacing.NewValue; + }, true); + + GridLinesRotation.BindValueChanged(rotation => + { + gridLinesRotationSlider.ContractedLabelText = $"R: {rotation.NewValue:#,0.##}"; + gridLinesRotationSlider.ExpandedLabelText = $"Rotation: {rotation.NewValue:#,0.##}"; + }, true); + + GridType.BindValueChanged(v => + { + GridLinesRotation.Disabled = v.NewValue == PositionSnapGridType.Circle; + + gridTypeButtons.Items[(int)v.NewValue].Select(); + + switch (v.NewValue) + { + case PositionSnapGridType.Square: + GridLinesRotation.Value = normalizeRotation(GridLinesRotation.Value, 90); + GridLinesRotation.MinValue = -45; + GridLinesRotation.MaxValue = 45; + break; + + case PositionSnapGridType.Triangle: + GridLinesRotation.Value = normalizeRotation(GridLinesRotation.Value, 60); + GridLinesRotation.MinValue = -30; + GridLinesRotation.MaxValue = 30; + break; + } + }, true); + + expandingContainer?.Expanded.BindValueChanged(v => + { + gridTypeButtons.FadeTo(v.NewValue ? 1f : 0f, 500, Easing.OutQuint); + gridTypeButtons.BypassAutoSizeAxes = !v.NewValue ? Axes.Y : Axes.None; + }, true); + } + + private float normalizeRotation(float rotation, float period) + { + return ((rotation + 360 + period * 0.5f) % period) - period * 0.5f; + } + + public bool OnPressed(KeyBindingPressEvent e) + { + switch (e.Action) + { + case GlobalAction.EditorCycleGridSpacing: + Spacing.Value = Spacing.Value * 2 >= max_automatic_spacing ? Spacing.Value / 8 : Spacing.Value * 2; + return true; + + case GlobalAction.EditorCycleGridType: + GridType.Value = (PositionSnapGridType)(((int)GridType.Value + 1) % Enum.GetValues().Length); + return true; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + + public partial class OutlineTriangle : BufferedContainer + { + public OutlineTriangle(bool outlineOnly, float size) + : base(cachedFrameBuffer: true) + { + Size = new Vector2(size); + + InternalChildren = new Drawable[] + { + new EquilateralTriangle { RelativeSizeAxes = Axes.Both }, + }; + + if (outlineOnly) + { + AddInternal(new EquilateralTriangle + { + Anchor = Anchor.TopCentre, + Origin = Anchor.Centre, + RelativePositionAxes = Axes.Y, + Y = 0.48f, + Colour = Color4.Black, + Size = new Vector2(size - 7), + Blending = BlendingParameters.None, + }); + } + + Blending = BlendingParameters.Additive; + } + } + } + + public enum PositionSnapGridType + { + Square, + Triangle, + Circle, + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs index 3ead61f64a..7c50558b92 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs @@ -10,7 +10,6 @@ using System.Text.RegularExpressions; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Caching; -using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; @@ -24,6 +23,7 @@ using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.UI; using osu.Game.Screens.Edit.Components.TernaryButtons; using osu.Game.Screens.Edit.Compose.Components; @@ -41,32 +41,35 @@ namespace osu.Game.Rulesets.Osu.Edit protected override DrawableRuleset CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods) => new DrawableOsuEditorRuleset(ruleset, beatmap, mods); - protected override IReadOnlyList CompositionTools => new HitObjectCompositionTool[] + protected override IReadOnlyList CompositionTools => new CompositionTool[] { new HitCircleCompositionTool(), new SliderCompositionTool(), - new SpinnerCompositionTool() + new SpinnerCompositionTool(), + new GridFromPointsTool() }; private readonly Bindable rectangularGridSnapToggle = new Bindable(); + protected override Drawable CreateHitObjectInspector() => new OsuHitObjectInspector(); + protected override IEnumerable CreateTernaryButtons() => base.CreateTernaryButtons() - .Concat(DistanceSnapProvider.CreateTernaryButtons()) - .Concat(new[] - { - new TernaryButton(rectangularGridSnapToggle, "Grid Snap", () => new SpriteIcon { Icon = OsuIcon.EditorGridSnap }) - }); + .Append(new TernaryButton(rectangularGridSnapToggle, "Grid Snap", () => new SpriteIcon { Icon = OsuIcon.EditorGridSnap })) + .Concat(DistanceSnapProvider.CreateTernaryButtons()); private BindableList selectedHitObjects; private Bindable placementObject; [Cached(typeof(IDistanceSnapProvider))] - protected readonly OsuDistanceSnapProvider DistanceSnapProvider = new OsuDistanceSnapProvider(); + public readonly OsuDistanceSnapProvider DistanceSnapProvider = new OsuDistanceSnapProvider(); [Cached] - protected readonly FreehandSliderToolboxGroup FreehandlSliderToolboxGroup = new FreehandSliderToolboxGroup(); + protected readonly OsuGridToolboxGroup OsuGridToolboxGroup = new OsuGridToolboxGroup(); + + [Cached] + protected readonly FreehandSliderToolboxGroup FreehandSliderToolboxGroup = new FreehandSliderToolboxGroup(); [BackgroundDependencyLoader] private void load() @@ -77,17 +80,12 @@ namespace osu.Game.Rulesets.Osu.Edit // Give a bit of breathing room around the playfield content. PlayfieldContentContainer.Padding = new MarginPadding(10); - LayerBelowRuleset.AddRange(new Drawable[] - { + LayerBelowRuleset.Add( distanceSnapGridContainer = new Container - { - RelativeSizeAxes = Axes.Both - }, - rectangularPositionSnapGrid = new OsuRectangularPositionSnapGrid { RelativeSizeAxes = Axes.Both } - }); + ); selectedHitObjects = EditorBeatmap.SelectedHitObjects.GetBoundCopy(); selectedHitObjects.CollectionChanged += (_, _) => updateDistanceSnapGrid(); @@ -99,14 +97,67 @@ namespace osu.Game.Rulesets.Osu.Edit // we may be entering the screen with a selection already active updateDistanceSnapGrid(); - RightToolbox.AddRange(new EditorToolboxGroup[] + OsuGridToolboxGroup.GridType.BindValueChanged(updatePositionSnapGrid, true); + + RightToolbox.AddRange(new Drawable[] { - new TransformToolboxGroup { RotationHandler = BlueprintContainer.SelectionHandler.RotationHandler, }, - FreehandlSliderToolboxGroup + OsuGridToolboxGroup, + new TransformToolboxGroup + { + RotationHandler = BlueprintContainer.SelectionHandler.RotationHandler, + ScaleHandler = (OsuSelectionScaleHandler)BlueprintContainer.SelectionHandler.ScaleHandler, + GridToolbox = OsuGridToolboxGroup, + }, + new GenerateToolboxGroup(), + FreehandSliderToolboxGroup } ); } + private void updatePositionSnapGrid(ValueChangedEvent obj) + { + if (positionSnapGrid != null) + LayerBelowRuleset.Remove(positionSnapGrid, true); + + switch (obj.NewValue) + { + case PositionSnapGridType.Square: + var rectangularPositionSnapGrid = new RectangularPositionSnapGrid(); + + rectangularPositionSnapGrid.Spacing.BindTo(OsuGridToolboxGroup.SpacingVector); + rectangularPositionSnapGrid.GridLineRotation.BindTo(OsuGridToolboxGroup.GridLinesRotation); + + positionSnapGrid = rectangularPositionSnapGrid; + break; + + case PositionSnapGridType.Triangle: + var triangularPositionSnapGrid = new TriangularPositionSnapGrid(); + + triangularPositionSnapGrid.Spacing.BindTo(OsuGridToolboxGroup.Spacing); + triangularPositionSnapGrid.GridLineRotation.BindTo(OsuGridToolboxGroup.GridLinesRotation); + + positionSnapGrid = triangularPositionSnapGrid; + break; + + case PositionSnapGridType.Circle: + var circularPositionSnapGrid = new CircularPositionSnapGrid(); + + circularPositionSnapGrid.Spacing.BindTo(OsuGridToolboxGroup.Spacing); + + positionSnapGrid = circularPositionSnapGrid; + break; + + default: + throw new ArgumentOutOfRangeException(nameof(OsuGridToolboxGroup.GridType), OsuGridToolboxGroup.GridType, "Unsupported grid type."); + } + + // Bind the start position to the toolbox sliders. + positionSnapGrid.StartPosition.BindTo(OsuGridToolboxGroup.StartPosition); + + positionSnapGrid.RelativeSizeAxes = Axes.Both; + LayerBelowRuleset.Add(positionSnapGrid); + } + protected override ComposeBlueprintContainer CreateBlueprintContainer() => new OsuBlueprintContainer(this); @@ -147,7 +198,7 @@ namespace osu.Game.Rulesets.Osu.Edit private readonly Cached distanceSnapGridCache = new Cached(); private double? lastDistanceSnapGridTime; - private RectangularPositionSnapGrid rectangularPositionSnapGrid; + private PositionSnapGrid positionSnapGrid; protected override void Update() { @@ -168,7 +219,7 @@ namespace osu.Game.Rulesets.Osu.Edit public override SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All) { - if (snapType.HasFlagFast(SnapType.NearbyObjects) && snapToVisibleBlueprints(screenSpacePosition, out var snapResult)) + if (snapType.HasFlag(SnapType.NearbyObjects) && snapToVisibleBlueprints(screenSpacePosition, out var snapResult)) { // In the case of snapping to nearby objects, a time value is not provided. // This matches the stable editor (which also uses current time), but with the introduction of time-snapping distance snap @@ -178,7 +229,7 @@ namespace osu.Game.Rulesets.Osu.Edit // We want to ensure that in this particular case, the time-snapping component of distance snap is still applied. // The easiest way to ensure this is to attempt application of distance snap after a nearby object is found, and copy over // the time value if the proposed positions are roughly the same. - if (snapType.HasFlagFast(SnapType.RelativeGrids) && DistanceSnapProvider.DistanceSnapToggle.Value == TernaryState.True && distanceSnapGrid != null) + if (snapType.HasFlag(SnapType.RelativeGrids) && DistanceSnapProvider.DistanceSnapToggle.Value == TernaryState.True && distanceSnapGrid != null) { (Vector2 distanceSnappedPosition, double distanceSnappedTime) = distanceSnapGrid.GetSnappedPosition(distanceSnapGrid.ToLocalSpace(snapResult.ScreenSpacePosition)); if (Precision.AlmostEquals(distanceSnapGrid.ToScreenSpace(distanceSnappedPosition), snapResult.ScreenSpacePosition, 1)) @@ -190,7 +241,7 @@ namespace osu.Game.Rulesets.Osu.Edit SnapResult result = base.FindSnappedPositionAndTime(screenSpacePosition, snapType); - if (snapType.HasFlagFast(SnapType.RelativeGrids)) + if (snapType.HasFlag(SnapType.RelativeGrids)) { if (DistanceSnapProvider.DistanceSnapToggle.Value == TernaryState.True && distanceSnapGrid != null) { @@ -201,13 +252,17 @@ namespace osu.Game.Rulesets.Osu.Edit } } - if (snapType.HasFlagFast(SnapType.GlobalGrids)) + if (snapType.HasFlag(SnapType.GlobalGrids)) { if (rectangularGridSnapToggle.Value == TernaryState.True) { - Vector2 pos = rectangularPositionSnapGrid.GetSnappedPosition(rectangularPositionSnapGrid.ToLocalSpace(result.ScreenSpacePosition)); + Vector2 pos = positionSnapGrid.GetSnappedPosition(positionSnapGrid.ToLocalSpace(result.ScreenSpacePosition)); - result.ScreenSpacePosition = rectangularPositionSnapGrid.ToScreenSpace(pos); + // A grid which doesn't perfectly fit the playfield can produce a position that is outside of the playfield. + // We need to clamp the position to the playfield bounds to ensure that the snapped position is always in bounds. + pos = Vector2.Clamp(pos, Vector2.Zero, OsuPlayfield.BASE_SIZE); + + result.ScreenSpacePosition = positionSnapGrid.ToScreenSpace(pos); } } @@ -239,6 +294,12 @@ namespace osu.Game.Rulesets.Osu.Edit if (Vector2.Distance(closestSnapPosition, screenSpacePosition) < snapRadius) { + // if the snap target is a stacked object, snap to its unstacked position rather than its stacked position. + // this is intended to make working with stacks easier (because thanks to this, you can drag an object to any + // of the items on the stack to add an object to it, rather than having to drag to the position of the *first* object on it at all times). + if (b.Item is OsuHitObject osuObject && osuObject.StackOffset != Vector2.Zero) + closestSnapPosition = b.ToScreenSpace(b.ToLocalSpace(closestSnapPosition) - osuObject.StackOffset); + // only return distance portion, since time is not really valid snapResult = new SnapResult(closestSnapPosition, null, playfield); return true; @@ -308,6 +369,8 @@ namespace osu.Game.Rulesets.Osu.Edit gridSnapMomentary = shiftPressed; rectangularGridSnapToggle.Value = rectangularGridSnapToggle.Value == TernaryState.False ? TernaryState.True : TernaryState.False; } + + DistanceSnapProvider.HandleToggleViaKey(key); } private DistanceSnapGrid createDistanceSnapGrid(IEnumerable selectedHitObjects) diff --git a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectInspector.cs b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectInspector.cs new file mode 100644 index 0000000000..b31fe05995 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectInspector.cs @@ -0,0 +1,42 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics; +using System.Linq; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Edit.Compose.Components; + +namespace osu.Game.Rulesets.Osu.Edit +{ + public partial class OsuHitObjectInspector : HitObjectInspector + { + protected override void AddInspectorValues(HitObject[] objects) + { + base.AddInspectorValues(objects); + + if (objects.Length > 0) + { + var firstInSelection = (OsuHitObject)objects.MinBy(ho => ho.StartTime)!; + var lastInSelection = (OsuHitObject)objects.MaxBy(ho => ho.GetEndTime())!; + + Debug.Assert(firstInSelection != null && lastInSelection != null); + + var precedingObject = (OsuHitObject?)EditorBeatmap.HitObjects.LastOrDefault(ho => ho.GetEndTime() < firstInSelection.StartTime); + var nextObject = (OsuHitObject?)EditorBeatmap.HitObjects.FirstOrDefault(ho => ho.StartTime > lastInSelection.GetEndTime()); + + if (precedingObject != null && precedingObject is not Spinner) + { + AddHeader("To previous"); + AddValue($"{(firstInSelection.StackedPosition - precedingObject.StackedEndPosition).Length:#,0.##}px"); + } + + if (nextObject != null && nextObject is not Spinner) + { + AddHeader("To next"); + AddValue($"{(nextObject.StackedPosition - lastInSelection.StackedEndPosition).Length:#,0.##}px"); + } + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/OsuRectangularPositionSnapGrid.cs b/osu.Game.Rulesets.Osu/Edit/OsuRectangularPositionSnapGrid.cs deleted file mode 100644 index efc6668ebf..0000000000 --- a/osu.Game.Rulesets.Osu/Edit/OsuRectangularPositionSnapGrid.cs +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using osu.Framework.Allocation; -using osu.Framework.Input.Bindings; -using osu.Framework.Input.Events; -using osu.Game.Input.Bindings; -using osu.Game.Rulesets.Osu.UI; -using osu.Game.Screens.Edit; -using osu.Game.Screens.Edit.Compose.Components; -using osuTK; - -namespace osu.Game.Rulesets.Osu.Edit -{ - public partial class OsuRectangularPositionSnapGrid : RectangularPositionSnapGrid, IKeyBindingHandler - { - private static readonly int[] grid_sizes = { 4, 8, 16, 32 }; - - private int currentGridSizeIndex = grid_sizes.Length - 1; - - [Resolved] - private EditorBeatmap editorBeatmap { get; set; } = null!; - - public OsuRectangularPositionSnapGrid() - : base(OsuPlayfield.BASE_SIZE / 2) - { - } - - [BackgroundDependencyLoader] - private void load() - { - int gridSizeIndex = Array.IndexOf(grid_sizes, editorBeatmap.BeatmapInfo.GridSize); - if (gridSizeIndex >= 0) - currentGridSizeIndex = gridSizeIndex; - updateSpacing(); - } - - private void nextGridSize() - { - currentGridSizeIndex = (currentGridSizeIndex + 1) % grid_sizes.Length; - updateSpacing(); - } - - private void updateSpacing() - { - int gridSize = grid_sizes[currentGridSizeIndex]; - - editorBeatmap.BeatmapInfo.GridSize = gridSize; - Spacing = new Vector2(gridSize); - } - - public bool OnPressed(KeyBindingPressEvent e) - { - switch (e.Action) - { - case GlobalAction.EditorCycleGridDisplayMode: - nextGridSize(); - return true; - } - - return false; - } - - public void OnReleased(KeyBindingReleaseEvent e) - { - } - } -} diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs index b33272968b..bac0a5e273 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs @@ -14,6 +14,7 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.UI; using osu.Game.Screens.Edit.Compose.Components; @@ -25,14 +26,8 @@ namespace osu.Game.Rulesets.Osu.Edit { public partial class OsuSelectionHandler : EditorSelectionHandler { - [Resolved(CanBeNull = true)] - private IDistanceSnapProvider? snapProvider { get; set; } - - /// - /// During a transform, the initial path types of a single selected slider are stored so they - /// can be maintained throughout the operation. - /// - private List? referencePathTypes; + [Resolved] + private OsuGridToolboxGroup gridToolbox { get; set; } = null!; protected override void OnSelectionChanged() { @@ -40,18 +35,11 @@ namespace osu.Game.Rulesets.Osu.Edit Quad quad = selectedMovableObjects.Length > 0 ? GeometryUtils.GetSurroundingQuad(selectedMovableObjects) : new Quad(); - SelectionBox.CanFlipX = SelectionBox.CanScaleX = quad.Width > 0; - SelectionBox.CanFlipY = SelectionBox.CanScaleY = quad.Height > 0; - SelectionBox.CanScaleDiagonally = SelectionBox.CanScaleX && SelectionBox.CanScaleY; + SelectionBox.CanFlipX = quad.Width > 0; + SelectionBox.CanFlipY = quad.Height > 0; SelectionBox.CanReverse = EditorBeatmap.SelectedHitObjects.Count > 1 || EditorBeatmap.SelectedHitObjects.Any(s => s is Slider); } - protected override void OnOperationEnded() - { - base.OnOperationEnded(); - referencePathTypes = null; - } - protected override bool OnKeyDown(KeyDownEvent e) { if (e.Key == Key.M && e.ControlPressed && e.ShiftPressed) @@ -67,12 +55,33 @@ namespace osu.Game.Rulesets.Osu.Edit { var hitObjects = selectedMovableObjects; + var localDelta = this.ScreenSpaceDeltaToParentSpace(moveEvent.ScreenSpaceDelta); + + // this conditional is a rather ugly special case for stacks. + // as it turns out, adding the `EditorBeatmap.Update()` call at the end of this would cause stacked objects to jitter when moved around + // (they would stack and then unstack every frame). + // the reason for that is that the selection handling abstractions are not aware of the distinction between "displayed" and "actual" position + // which is unique to osu! due to stacking being applied as a post-processing step. + // therefore, the following loop would occur: + // - on frame 1 the blueprint is snapped to the stack's baseline position. `EditorBeatmap.Update()` applies stacking successfully, + // the blueprint moves up the stack from its original drag position. + // - on frame 2 the blueprint's position is now the *stacked* position, which is interpreted higher up as *manually performing an unstack* + // to the blueprint's unstacked position (as the machinery higher up only cares about differences in screen space position). + if (hitObjects.Any(h => Precision.AlmostEquals(localDelta, -h.StackOffset))) + return true; + // this will potentially move the selection out of bounds... foreach (var h in hitObjects) - h.Position += this.ScreenSpaceDeltaToParentSpace(moveEvent.ScreenSpaceDelta); + h.Position += localDelta; // but this will be corrected. moveSelectionInBounds(); + + // manually update stacking. + // this intentionally bypasses the editor `UpdateState()` / beatmap processor flow for performance reasons, + // as the entire flow is too expensive to run on every movement. + Scheduler.AddOnce(OsuBeatmapProcessor.ApplyStacking, EditorBeatmap); + return true; } @@ -118,13 +127,43 @@ namespace osu.Game.Rulesets.Osu.Edit { var hitObjects = selectedMovableObjects; - var flipQuad = flipOverOrigin ? new Quad(0, 0, OsuPlayfield.BASE_SIZE.X, OsuPlayfield.BASE_SIZE.Y) : GeometryUtils.GetSurroundingQuad(hitObjects); + // If we're flipping over the origin, we take the grid origin position from the grid toolbox. + var flipQuad = flipOverOrigin ? new Quad(gridToolbox.StartPositionX.Value, gridToolbox.StartPositionY.Value, 0, 0) : GeometryUtils.GetSurroundingQuad(hitObjects); + Vector2 flipAxis = direction == Direction.Vertical ? Vector2.UnitY : Vector2.UnitX; + + if (flipOverOrigin) + { + // If we're flipping over the origin, we take one of the axes of the grid. + // Take the axis closest to the direction we want to flip over. + switch (gridToolbox.GridType.Value) + { + case PositionSnapGridType.Square: + flipAxis = GeometryUtils.RotateVector(Vector2.UnitX, -((gridToolbox.GridLinesRotation.Value + 360 + 45) % 90 - 45)); + flipAxis = direction == Direction.Vertical ? flipAxis.PerpendicularLeft : flipAxis; + break; + + case PositionSnapGridType.Triangle: + // Hex grid has 3 axes, so you can not directly flip over one of the axes, + // however it's still possible to achieve that flip by combining multiple flips over the other axes. + // Angle degree range for vertical = (-120, -60] + // Angle degree range for horizontal = [-30, 30) + flipAxis = direction == Direction.Vertical + ? GeometryUtils.RotateVector(Vector2.UnitX, -((gridToolbox.GridLinesRotation.Value + 360 + 30) % 60 + 60)) + : GeometryUtils.RotateVector(Vector2.UnitX, -((gridToolbox.GridLinesRotation.Value + 360) % 60 - 30)); + break; + } + } + + var controlPointFlipQuad = new Quad(); bool didFlip = false; foreach (var h in hitObjects) { - var flippedPosition = GeometryUtils.GetFlippedPosition(direction, flipQuad, h.Position); + var flippedPosition = GeometryUtils.GetFlippedPosition(flipAxis, flipQuad, h.Position); + + // Clamp the flipped position inside the playfield bounds, because the flipped position might be outside the playfield bounds if the origin is not centered. + flippedPosition = Vector2.Clamp(flippedPosition, Vector2.Zero, OsuPlayfield.BASE_SIZE); if (!Precision.AlmostEquals(flippedPosition, h.Position)) { @@ -137,108 +176,16 @@ namespace osu.Game.Rulesets.Osu.Edit didFlip = true; foreach (var cp in slider.Path.ControlPoints) - { - cp.Position = new Vector2( - (direction == Direction.Horizontal ? -1 : 1) * cp.Position.X, - (direction == Direction.Vertical ? -1 : 1) * cp.Position.Y - ); - } + cp.Position = GeometryUtils.GetFlippedPosition(flipAxis, controlPointFlipQuad, cp.Position); } } return didFlip; } - public override bool HandleScale(Vector2 scale, Anchor reference) - { - adjustScaleFromAnchor(ref scale, reference); - - var hitObjects = selectedMovableObjects; - - // for the time being, allow resizing of slider paths only if the slider is - // the only hit object selected. with a group selection, it's likely the user - // is not looking to change the duration of the slider but expand the whole pattern. - if (hitObjects.Length == 1 && hitObjects.First() is Slider slider) - scaleSlider(slider, scale); - else - scaleHitObjects(hitObjects, reference, scale); - - moveSelectionInBounds(); - return true; - } - - private static void adjustScaleFromAnchor(ref Vector2 scale, Anchor reference) - { - // cancel out scale in axes we don't care about (based on which drag handle was used). - if ((reference & Anchor.x1) > 0) scale.X = 0; - if ((reference & Anchor.y1) > 0) scale.Y = 0; - - // reverse the scale direction if dragging from top or left. - if ((reference & Anchor.x0) > 0) scale.X = -scale.X; - if ((reference & Anchor.y0) > 0) scale.Y = -scale.Y; - } - public override SelectionRotationHandler CreateRotationHandler() => new OsuSelectionRotationHandler(); - private void scaleSlider(Slider slider, Vector2 scale) - { - referencePathTypes ??= slider.Path.ControlPoints.Select(p => p.Type).ToList(); - - Quad sliderQuad = GeometryUtils.GetSurroundingQuad(slider.Path.ControlPoints.Select(p => p.Position)); - - // Limit minimum distance between control points after scaling to almost 0. Less than 0 causes the slider to flip, exactly 0 causes a crash through division by 0. - scale = Vector2.ComponentMax(new Vector2(Precision.FLOAT_EPSILON), sliderQuad.Size + scale) - sliderQuad.Size; - - Vector2 pathRelativeDeltaScale = new Vector2( - sliderQuad.Width == 0 ? 0 : 1 + scale.X / sliderQuad.Width, - sliderQuad.Height == 0 ? 0 : 1 + scale.Y / sliderQuad.Height); - - Queue oldControlPoints = new Queue(); - - foreach (var point in slider.Path.ControlPoints) - { - oldControlPoints.Enqueue(point.Position); - point.Position *= pathRelativeDeltaScale; - } - - // Maintain the path types in case they were defaulted to bezier at some point during scaling - for (int i = 0; i < slider.Path.ControlPoints.Count; ++i) - slider.Path.ControlPoints[i].Type = referencePathTypes[i]; - - // Snap the slider's length to the current beat divisor - // to calculate the final resulting duration / bounding box before the final checks. - slider.SnapTo(snapProvider); - - //if sliderhead or sliderend end up outside playfield, revert scaling. - Quad scaledQuad = GeometryUtils.GetSurroundingQuad(new OsuHitObject[] { slider }); - (bool xInBounds, bool yInBounds) = isQuadInBounds(scaledQuad); - - if (xInBounds && yInBounds && slider.Path.HasValidLength) - return; - - foreach (var point in slider.Path.ControlPoints) - point.Position = oldControlPoints.Dequeue(); - - // Snap the slider's length again to undo the potentially-invalid length applied by the previous snap. - slider.SnapTo(snapProvider); - } - - private void scaleHitObjects(OsuHitObject[] hitObjects, Anchor reference, Vector2 scale) - { - scale = getClampedScale(hitObjects, reference, scale); - Quad selectionQuad = GeometryUtils.GetSurroundingQuad(hitObjects); - - foreach (var h in hitObjects) - h.Position = GeometryUtils.GetScaledPosition(reference, scale, selectionQuad, h.Position); - } - - private (bool X, bool Y) isQuadInBounds(Quad quad) - { - bool xInBounds = (quad.TopLeft.X >= 0) && (quad.BottomRight.X <= DrawWidth); - bool yInBounds = (quad.TopLeft.Y >= 0) && (quad.BottomRight.Y <= DrawHeight); - - return (xInBounds, yInBounds); - } + public override SelectionScaleHandler CreateScaleHandler() => new OsuSelectionScaleHandler(); private void moveSelectionInBounds() { @@ -262,43 +209,6 @@ namespace osu.Game.Rulesets.Osu.Edit h.Position += delta; } - /// - /// Clamp scale for multi-object-scaling where selection does not exceed playfield bounds or flip. - /// - /// The hitobjects to be scaled - /// The anchor from which the scale operation is performed - /// The scale to be clamped - /// The clamped scale vector - private Vector2 getClampedScale(OsuHitObject[] hitObjects, Anchor reference, Vector2 scale) - { - float xOffset = ((reference & Anchor.x0) > 0) ? -scale.X : 0; - float yOffset = ((reference & Anchor.y0) > 0) ? -scale.Y : 0; - - Quad selectionQuad = GeometryUtils.GetSurroundingQuad(hitObjects); - - //todo: this is not always correct for selections involving sliders. This approximation assumes each point is scaled independently, but sliderends move with the sliderhead. - Quad scaledQuad = new Quad(selectionQuad.TopLeft.X + xOffset, selectionQuad.TopLeft.Y + yOffset, selectionQuad.Width + scale.X, selectionQuad.Height + scale.Y); - - //max Size -> playfield bounds - if (scaledQuad.TopLeft.X < 0) - scale.X += scaledQuad.TopLeft.X; - if (scaledQuad.TopLeft.Y < 0) - scale.Y += scaledQuad.TopLeft.Y; - - if (scaledQuad.BottomRight.X > DrawWidth) - scale.X -= scaledQuad.BottomRight.X - DrawWidth; - if (scaledQuad.BottomRight.Y > DrawHeight) - scale.Y -= scaledQuad.BottomRight.Y - DrawHeight; - - //min Size -> almost 0. Less than 0 causes the quad to flip, exactly 0 causes scaling to get stuck at minimum scale. - Vector2 scaledSize = selectionQuad.Size + scale; - Vector2 minSize = new Vector2(Precision.FLOAT_EPSILON); - - scale = Vector2.ComponentMax(minSize, scaledSize) - selectionQuad.Size; - - return scale; - } - /// /// All osu! hitobjects which can be moved/rotated/scaled. /// diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionRotationHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionRotationHandler.cs index d48bc6a90b..44d1543ae4 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionRotationHandler.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionRotationHandler.cs @@ -41,25 +41,26 @@ namespace osu.Game.Rulesets.Osu.Edit private void updateState() { var quad = GeometryUtils.GetSurroundingQuad(selectedMovableObjects); - CanRotateSelectionOrigin.Value = quad.Width > 0 || quad.Height > 0; - CanRotatePlayfieldOrigin.Value = selectedMovableObjects.Any(); + CanRotateAroundSelectionOrigin.Value = quad.Width > 0 || quad.Height > 0; + CanRotateAroundPlayfieldOrigin.Value = selectedMovableObjects.Any(); } private OsuHitObject[]? objectsInRotation; - private Vector2? defaultOrigin; private Dictionary? originalPositions; private Dictionary? originalPathControlPointPositions; public override void Begin() { - if (objectsInRotation != null) + if (OperationInProgress.Value) throw new InvalidOperationException($"Cannot {nameof(Begin)} a rotate operation while another is in progress!"); + base.Begin(); + changeHandler?.BeginChange(); objectsInRotation = selectedMovableObjects.ToArray(); - defaultOrigin = GeometryUtils.GetSurroundingQuad(objectsInRotation).Centre; + DefaultOrigin = GeometryUtils.MinimumEnclosingCircle(objectsInRotation).Item1; originalPositions = objectsInRotation.ToDictionary(obj => obj, obj => obj.Position); originalPathControlPointPositions = objectsInRotation.OfType().ToDictionary( obj => obj, @@ -68,12 +69,12 @@ namespace osu.Game.Rulesets.Osu.Edit public override void Update(float rotation, Vector2? origin = null) { - if (objectsInRotation == null) + if (!OperationInProgress.Value) throw new InvalidOperationException($"Cannot {nameof(Update)} a rotate operation without calling {nameof(Begin)} first!"); - Debug.Assert(originalPositions != null && originalPathControlPointPositions != null && defaultOrigin != null); + Debug.Assert(objectsInRotation != null && originalPositions != null && originalPathControlPointPositions != null && DefaultOrigin != null); - Vector2 actualOrigin = origin ?? defaultOrigin.Value; + Vector2 actualOrigin = origin ?? DefaultOrigin.Value; foreach (var ho in objectsInRotation) { @@ -91,15 +92,17 @@ namespace osu.Game.Rulesets.Osu.Edit public override void Commit() { - if (objectsInRotation == null) + if (!OperationInProgress.Value) throw new InvalidOperationException($"Cannot {nameof(Commit)} a rotate operation without calling {nameof(Begin)} first!"); changeHandler?.EndChange(); + base.Commit(); + objectsInRotation = null; originalPositions = null; originalPathControlPointPositions = null; - defaultOrigin = null; + DefaultOrigin = null; } private IEnumerable selectedMovableObjects => selectedItems.Cast() diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs new file mode 100644 index 0000000000..e3ab95c402 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs @@ -0,0 +1,347 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Utils; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.Compose.Components; +using osu.Game.Utils; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Edit +{ + public partial class OsuSelectionScaleHandler : SelectionScaleHandler + { + /// + /// Whether scaling anchored by the center of the playfield can currently be performed. + /// + public Bindable CanScaleFromPlayfieldOrigin { get; private set; } = new BindableBool(); + + /// + /// Whether a single slider is currently selected, which results in a different scaling behaviour. + /// + public Bindable IsScalingSlider { get; private set; } = new BindableBool(); + + [Resolved] + private IEditorChangeHandler? changeHandler { get; set; } + + [Resolved(CanBeNull = true)] + private IDistanceSnapProvider? snapProvider { get; set; } + + private BindableList selectedItems { get; } = new BindableList(); + + [BackgroundDependencyLoader] + private void load(EditorBeatmap editorBeatmap) + { + selectedItems.BindTo(editorBeatmap.SelectedHitObjects); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + selectedItems.CollectionChanged += (_, __) => updateState(); + updateState(); + } + + private void updateState() + { + var quad = GeometryUtils.GetSurroundingQuad(selectedMovableObjects); + + CanScaleX.Value = quad.Width > 0; + CanScaleY.Value = quad.Height > 0; + CanScaleDiagonally.Value = CanScaleX.Value && CanScaleY.Value; + CanScaleFromPlayfieldOrigin.Value = selectedMovableObjects.Any(); + IsScalingSlider.Value = selectedMovableObjects.Count() == 1 && selectedMovableObjects.First() is Slider; + } + + private Dictionary? objectsInScale; + private Vector2? defaultOrigin; + private List? originalConvexHull; + + public override void Begin() + { + if (OperationInProgress.Value) + throw new InvalidOperationException($"Cannot {nameof(Begin)} a scale operation while another is in progress!"); + + base.Begin(); + + changeHandler?.BeginChange(); + + objectsInScale = selectedMovableObjects.ToDictionary(ho => ho, ho => new OriginalHitObjectState(ho)); + OriginalSurroundingQuad = objectsInScale.Count == 1 && objectsInScale.First().Key is Slider slider + ? GeometryUtils.GetSurroundingQuad(slider.Path.ControlPoints.Select(p => slider.Position + p.Position)) + : GeometryUtils.GetSurroundingQuad(objectsInScale.Keys); + originalConvexHull = objectsInScale.Count == 1 && objectsInScale.First().Key is Slider slider2 + ? GeometryUtils.GetConvexHull(slider2.Path.ControlPoints.Select(p => slider2.Position + p.Position)) + : GeometryUtils.GetConvexHull(objectsInScale.Keys); + defaultOrigin = GeometryUtils.MinimumEnclosingCircle(originalConvexHull).Item1; + } + + public override void Update(Vector2 scale, Vector2? origin = null, Axes adjustAxis = Axes.Both, float axisRotation = 0) + { + if (!OperationInProgress.Value) + throw new InvalidOperationException($"Cannot {nameof(Update)} a scale operation without calling {nameof(Begin)} first!"); + + Debug.Assert(objectsInScale != null && defaultOrigin != null && OriginalSurroundingQuad != null); + + Vector2 actualOrigin = origin ?? defaultOrigin.Value; + scale = clampScaleToAdjustAxis(scale, adjustAxis); + + // for the time being, allow resizing of slider paths only if the slider is + // the only hit object selected. with a group selection, it's likely the user + // is not looking to change the duration of the slider but expand the whole pattern. + if (objectsInScale.Count == 1 && objectsInScale.First().Key is Slider slider) + { + scaleSlider(slider, scale, actualOrigin, objectsInScale[slider], axisRotation); + } + else + { + scale = ClampScaleToPlayfieldBounds(scale, actualOrigin, adjustAxis, axisRotation); + + foreach (var (ho, originalState) in objectsInScale) + { + ho.Position = GeometryUtils.GetScaledPosition(scale, actualOrigin, originalState.Position, axisRotation); + } + } + + moveSelectionInBounds(); + } + + public override void Commit() + { + if (!OperationInProgress.Value) + throw new InvalidOperationException($"Cannot {nameof(Commit)} a rotate operation without calling {nameof(Begin)} first!"); + + changeHandler?.EndChange(); + + base.Commit(); + + objectsInScale = null; + OriginalSurroundingQuad = null; + defaultOrigin = null; + } + + private IEnumerable selectedMovableObjects => selectedItems.Cast() + .Where(h => h is not Spinner); + + private Vector2 clampScaleToAdjustAxis(Vector2 scale, Axes adjustAxis) + { + switch (adjustAxis) + { + case Axes.Y: + scale.X = 1; + break; + + case Axes.X: + scale.Y = 1; + break; + + case Axes.None: + scale = Vector2.One; + break; + } + + return scale; + } + + private void scaleSlider(Slider slider, Vector2 scale, Vector2 origin, OriginalHitObjectState originalInfo, float axisRotation = 0) + { + Debug.Assert(originalInfo.PathControlPointPositions != null && originalInfo.PathControlPointTypes != null); + + scale = Vector2.ComponentMax(scale, new Vector2(Precision.FLOAT_EPSILON)); + + // Maintain the path types in case they were defaulted to bezier at some point during scaling + for (int i = 0; i < slider.Path.ControlPoints.Count; i++) + { + slider.Path.ControlPoints[i].Position = GeometryUtils.GetScaledPosition(scale, Vector2.Zero, originalInfo.PathControlPointPositions[i], axisRotation); + slider.Path.ControlPoints[i].Type = originalInfo.PathControlPointTypes[i]; + } + + // Snap the slider's length to the current beat divisor + // to calculate the final resulting duration / bounding box before the final checks. + slider.SnapTo(snapProvider); + + slider.Position = GeometryUtils.GetScaledPosition(scale, origin, originalInfo.Position, axisRotation); + + //if sliderhead or sliderend end up outside playfield, revert scaling. + Quad scaledQuad = GeometryUtils.GetSurroundingQuad(new OsuHitObject[] { slider }); + (bool xInBounds, bool yInBounds) = isQuadInBounds(scaledQuad); + + if (xInBounds && yInBounds && slider.Path.HasValidLength) + return; + + for (int i = 0; i < slider.Path.ControlPoints.Count; i++) + slider.Path.ControlPoints[i].Position = originalInfo.PathControlPointPositions[i]; + + slider.Position = originalInfo.Position; + + // Snap the slider's length again to undo the potentially-invalid length applied by the previous snap. + slider.SnapTo(snapProvider); + } + + private (bool X, bool Y) isQuadInBounds(Quad quad) + { + bool xInBounds = (quad.TopLeft.X >= 0) && (quad.BottomRight.X <= OsuPlayfield.BASE_SIZE.X); + bool yInBounds = (quad.TopLeft.Y >= 0) && (quad.BottomRight.Y <= OsuPlayfield.BASE_SIZE.Y); + + return (xInBounds, yInBounds); + } + + /// + /// Clamp scale for multi-object-scaling where selection does not exceed playfield bounds or flip. + /// + /// The origin from which the scale operation is performed + /// The scale to be clamped + /// The axes to adjust the scale in. + /// The rotation of the axes in degrees + /// The clamped scale vector + public Vector2 ClampScaleToPlayfieldBounds(Vector2 scale, Vector2? origin = null, Axes adjustAxis = Axes.Both, float axisRotation = 0) + { + //todo: this is not always correct for selections involving sliders. This approximation assumes each point is scaled independently, but sliderends move with the sliderhead. + if (objectsInScale == null || adjustAxis == Axes.None) + return scale; + + Debug.Assert(defaultOrigin != null && OriginalSurroundingQuad != null); + + if (objectsInScale.Count == 1 && objectsInScale.First().Key is Slider slider) + origin = slider.Position; + + float cos = MathF.Cos(float.DegreesToRadians(-axisRotation)); + float sin = MathF.Sin(float.DegreesToRadians(-axisRotation)); + scale = clampScaleToAdjustAxis(scale, adjustAxis); + Vector2 actualOrigin = origin ?? defaultOrigin.Value; + IEnumerable points; + + if (axisRotation == 0) + { + var selectionQuad = OriginalSurroundingQuad.Value; + points = new[] + { + selectionQuad.TopLeft, + selectionQuad.TopRight, + selectionQuad.BottomLeft, + selectionQuad.BottomRight + }; + } + else + points = originalConvexHull!; + + foreach (var point in points) + scale = clampToBounds(scale, point, Vector2.Zero, OsuPlayfield.BASE_SIZE); + + return scale; + + // Clamps the scale vector s such that the point p scaled by s is within the rectangle defined by lowerBounds and upperBounds + Vector2 clampToBounds(Vector2 s, Vector2 p, Vector2 lowerBounds, Vector2 upperBounds) + { + p -= actualOrigin; + lowerBounds -= actualOrigin; + upperBounds -= actualOrigin; + // a.X is the rotated X component of p with respect to the X bounds + // a.Y is the rotated X component of p with respect to the Y bounds + // b.X is the rotated Y component of p with respect to the X bounds + // b.Y is the rotated Y component of p with respect to the Y bounds + var a = new Vector2(cos * cos * p.X - sin * cos * p.Y, -sin * cos * p.X + sin * sin * p.Y); + var b = new Vector2(sin * sin * p.X + sin * cos * p.Y, sin * cos * p.X + cos * cos * p.Y); + + float sLowerBound, sUpperBound; + + switch (adjustAxis) + { + case Axes.X: + (sLowerBound, sUpperBound) = computeBounds(lowerBounds - b, upperBounds - b, a); + s.X = MathHelper.Clamp(s.X, sLowerBound, sUpperBound); + break; + + case Axes.Y: + (sLowerBound, sUpperBound) = computeBounds(lowerBounds - a, upperBounds - a, b); + s.Y = MathHelper.Clamp(s.Y, sLowerBound, sUpperBound); + break; + + case Axes.Both: + // Here we compute the bounds for the magnitude multiplier of the scale vector + // Therefore the ratio s.X / s.Y will be maintained + (sLowerBound, sUpperBound) = computeBounds(lowerBounds, upperBounds, a * s.X + b * s.Y); + s.X = s.X < 0 + ? MathHelper.Clamp(s.X, s.X * sUpperBound, s.X * sLowerBound) + : MathHelper.Clamp(s.X, s.X * sLowerBound, s.X * sUpperBound); + s.Y = s.Y < 0 + ? MathHelper.Clamp(s.Y, s.Y * sUpperBound, s.Y * sLowerBound) + : MathHelper.Clamp(s.Y, s.Y * sLowerBound, s.Y * sUpperBound); + break; + } + + return s; + } + + // Computes the bounds for the magnitude of the scaled point p with respect to the bounds lowerBounds and upperBounds + (float, float) computeBounds(Vector2 lowerBounds, Vector2 upperBounds, Vector2 p) + { + var sLowerBounds = Vector2.Divide(lowerBounds, p); + var sUpperBounds = Vector2.Divide(upperBounds, p); + + // If the point is negative, then the bounds are flipped + if (p.X < 0) + (sLowerBounds.X, sUpperBounds.X) = (sUpperBounds.X, sLowerBounds.X); + if (p.Y < 0) + (sLowerBounds.Y, sUpperBounds.Y) = (sUpperBounds.Y, sLowerBounds.Y); + + // If the point is at zero, then any scale will have no effect on the point so the bounds are infinite + // The float division would already give us infinity for the bounds, but the sign is not consistent so we have to manually set it + if (Precision.AlmostEquals(p.X, 0)) + (sLowerBounds.X, sUpperBounds.X) = (float.NegativeInfinity, float.PositiveInfinity); + if (Precision.AlmostEquals(p.Y, 0)) + (sLowerBounds.Y, sUpperBounds.Y) = (float.NegativeInfinity, float.PositiveInfinity); + + return (MathF.Max(sLowerBounds.X, sLowerBounds.Y), MathF.Min(sUpperBounds.X, sUpperBounds.Y)); + } + } + + private void moveSelectionInBounds() + { + Quad quad = GeometryUtils.GetSurroundingQuad(objectsInScale!.Keys); + + Vector2 delta = Vector2.Zero; + + if (quad.TopLeft.X < 0) + delta.X -= quad.TopLeft.X; + if (quad.TopLeft.Y < 0) + delta.Y -= quad.TopLeft.Y; + + if (quad.BottomRight.X > OsuPlayfield.BASE_SIZE.X) + delta.X -= quad.BottomRight.X - OsuPlayfield.BASE_SIZE.X; + if (quad.BottomRight.Y > OsuPlayfield.BASE_SIZE.Y) + delta.Y -= quad.BottomRight.Y - OsuPlayfield.BASE_SIZE.Y; + + foreach (var (h, _) in objectsInScale!) + h.Position += delta; + } + + private struct OriginalHitObjectState + { + public Vector2 Position { get; } + public Vector2[]? PathControlPointPositions { get; } + public PathType?[]? PathControlPointTypes { get; } + + public OriginalHitObjectState(OsuHitObject hitObject) + { + Position = hitObject.Position; + PathControlPointPositions = (hitObject as IHasPath)?.Path.ControlPoints.Select(p => p.Position).ToArray(); + PathControlPointTypes = (hitObject as IHasPath)?.Path.ControlPoints.Select(p => p.Type).ToArray(); + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/PolygonGenerationPopover.cs b/osu.Game.Rulesets.Osu/Edit/PolygonGenerationPopover.cs new file mode 100644 index 0000000000..695ff516b1 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/PolygonGenerationPopover.cs @@ -0,0 +1,231 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Legacy; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.Compose.Components; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Edit +{ + public partial class PolygonGenerationPopover : OsuPopover + { + private SliderWithTextBoxInput distanceSnapInput = null!; + private SliderWithTextBoxInput offsetAngleInput = null!; + private SliderWithTextBoxInput repeatCountInput = null!; + private SliderWithTextBoxInput pointInput = null!; + private RoundedButton commitButton = null!; + + private readonly List insertedCircles = new List(); + private bool began; + private bool committed; + + [Resolved] + private IBeatSnapProvider beatSnapProvider { get; set; } = null!; + + [Resolved] + private EditorClock editorClock { get; set; } = null!; + + [Resolved] + private EditorBeatmap editorBeatmap { get; set; } = null!; + + [Resolved] + private IEditorChangeHandler? changeHandler { get; set; } + + [Resolved] + private HitObjectComposer composer { get; set; } = null!; + + private Bindable newComboState = null!; + + [BackgroundDependencyLoader] + private void load() + { + var selectionHandler = (EditorSelectionHandler)composer.BlueprintContainer.SelectionHandler; + newComboState = selectionHandler.SelectionNewComboState.GetBoundCopy(); + + AllowableAnchors = new[] { Anchor.CentreLeft, Anchor.CentreRight }; + + Child = new FillFlowContainer + { + Width = 220, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(20), + Children = new Drawable[] + { + distanceSnapInput = new SliderWithTextBoxInput("Distance snap:") + { + Current = new BindableNumber(1) + { + MinValue = 0.1, + MaxValue = 6, + Precision = 0.1, + Value = ((OsuHitObjectComposer)composer).DistanceSnapProvider.DistanceSpacingMultiplier.Value, + }, + Instantaneous = true + }, + offsetAngleInput = new SliderWithTextBoxInput("Offset angle:") + { + Current = new BindableNumber + { + MinValue = 0, + MaxValue = 180, + Precision = 1 + }, + Instantaneous = true + }, + repeatCountInput = new SliderWithTextBoxInput("Repeats:") + { + Current = new BindableNumber(1) + { + MinValue = 1, + MaxValue = 10, + Precision = 1 + }, + Instantaneous = true + }, + pointInput = new SliderWithTextBoxInput("Vertices:") + { + Current = new BindableNumber(3) + { + MinValue = 3, + MaxValue = 10, + Precision = 1, + }, + Instantaneous = true + }, + commitButton = new RoundedButton + { + RelativeSizeAxes = Axes.X, + Text = "Create", + Action = commit + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + changeHandler?.BeginChange(); + began = true; + + distanceSnapInput.Current.BindValueChanged(_ => Scheduler.AddOnce(tryCreatePolygon)); + offsetAngleInput.Current.BindValueChanged(_ => Scheduler.AddOnce(tryCreatePolygon)); + repeatCountInput.Current.BindValueChanged(_ => Scheduler.AddOnce(tryCreatePolygon)); + pointInput.Current.BindValueChanged(_ => Scheduler.AddOnce(tryCreatePolygon)); + newComboState.BindValueChanged(_ => Scheduler.AddOnce(tryCreatePolygon)); + tryCreatePolygon(); + } + + private void tryCreatePolygon() + { + double startTime = beatSnapProvider.SnapTime(editorClock.CurrentTime); + TimingControlPoint timingPoint = editorBeatmap.ControlPointInfo.TimingPointAt(startTime); + double timeSpacing = timingPoint.BeatLength / editorBeatmap.BeatDivisor; + IHasSliderVelocity lastWithSliderVelocity = editorBeatmap.HitObjects.Where(ho => ho.GetEndTime() <= startTime).OfType().LastOrDefault() ?? new Slider(); + double velocity = OsuHitObject.BASE_SCORING_DISTANCE * editorBeatmap.Difficulty.SliderMultiplier + / LegacyRulesetExtensions.GetPrecisionAdjustedBeatLength(lastWithSliderVelocity, timingPoint, OsuRuleset.SHORT_NAME); + double length = distanceSnapInput.Current.Value * velocity * timeSpacing; + float polygonRadius = (float)(length / (2 * Math.Sin(double.Pi / pointInput.Current.Value))); + + int totalPoints = pointInput.Current.Value * repeatCountInput.Current.Value; + + if (insertedCircles.Count > totalPoints) + { + editorBeatmap.RemoveRange(insertedCircles.GetRange(totalPoints, insertedCircles.Count - totalPoints)); + insertedCircles.RemoveRange(totalPoints, insertedCircles.Count - totalPoints); + } + + var newlyAdded = new List(); + + for (int i = 0; i < totalPoints; ++i) + { + float angle = float.DegreesToRadians(offsetAngleInput.Current.Value) + (i + 1) * (2 * float.Pi / pointInput.Current.Value); + var position = OsuPlayfield.BASE_SIZE / 2 + new Vector2(polygonRadius * float.Cos(angle), polygonRadius * float.Sin(angle)); + bool newCombo = i == 0 && newComboState.Value == TernaryState.True; + + HitCircle circle; + + if (i < insertedCircles.Count) + { + circle = insertedCircles[i]; + + circle.Position = position; + circle.StartTime = startTime; + circle.NewCombo = newCombo; + + editorBeatmap.Update(circle); + } + else + { + circle = new HitCircle + { + Position = position, + StartTime = startTime, + NewCombo = newCombo, + }; + + newlyAdded.Add(circle); + + // TODO: probably ensure samples also follow current ternary status (not trivial) + circle.Samples.Add(circle.CreateHitSampleInfo()); + } + + if (position.X < 0 || position.Y < 0 || position.X > OsuPlayfield.BASE_SIZE.X || position.Y > OsuPlayfield.BASE_SIZE.Y) + { + commitButton.Enabled.Value = false; + editorBeatmap.RemoveRange(insertedCircles); + insertedCircles.Clear(); + return; + } + + startTime = beatSnapProvider.SnapTime(startTime + timeSpacing); + } + + var previousNewComboState = newComboState.Value; + + insertedCircles.AddRange(newlyAdded); + editorBeatmap.AddRange(newlyAdded); + + // When adding new hitObjects, newCombo state will get reset to false when no objects are selected. + // Since this is the case when this popover is showing, we need to restore the previous newCombo state + newComboState.Value = previousNewComboState; + + commitButton.Enabled.Value = true; + } + + private void commit() + { + changeHandler?.EndChange(); + committed = true; + Hide(); + } + + protected override void PopOut() + { + base.PopOut(); + + if (began && !committed) + { + editorBeatmap.RemoveRange(insertedCircles); + changeHandler?.EndChange(); + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs b/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs index 88c3d7414b..477d3b4e57 100644 --- a/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs +++ b/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs @@ -1,13 +1,18 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Game.Configuration; using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Input.Bindings; using osu.Game.Rulesets.Osu.UI; using osu.Game.Screens.Edit.Components.RadioButtons; using osu.Game.Screens.Edit.Compose.Components; @@ -19,23 +24,32 @@ namespace osu.Game.Rulesets.Osu.Edit { private readonly SelectionRotationHandler rotationHandler; - private readonly Bindable rotationInfo = new Bindable(new PreciseRotationInfo(0, RotationOrigin.PlayfieldCentre)); + private readonly OsuGridToolboxGroup gridToolbox; + + 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!; - public PreciseRotationPopover(SelectionRotationHandler rotationHandler) + private Bindable configRotationOrigin = null!; + + public PreciseRotationPopover(SelectionRotationHandler rotationHandler, OsuGridToolboxGroup gridToolbox) { this.rotationHandler = rotationHandler; + this.gridToolbox = gridToolbox; AllowableAnchors = new[] { Anchor.CentreLeft, Anchor.CentreRight }; } [BackgroundDependencyLoader] - private void load() + private void load(OsuConfigManager config) { + configRotationOrigin = config.GetBindable(OsuSetting.EditorRotationOrigin); + Child = new FillFlowContainer { Width = 220, @@ -51,6 +65,7 @@ namespace osu.Game.Rulesets.Osu.Edit MaxValue = 360, Precision = 1 }, + KeyboardStep = 1f, Instantaneous = true }, rotationOrigin = new EditorRadioButtonCollection @@ -58,11 +73,14 @@ namespace osu.Game.Rulesets.Osu.Edit RelativeSizeAxes = Axes.X, Items = new[] { - new RadioButton("Playfield centre", - () => rotationInfo.Value = rotationInfo.Value with { Origin = RotationOrigin.PlayfieldCentre }, + gridCentreButton = new RadioButton("Grid centre", + () => rotationInfo.Value = rotationInfo.Value with { Origin = EditorOrigin.GridCentre }, + () => new SpriteIcon { Icon = FontAwesome.Regular.PlusSquare }), + 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 }) } } @@ -78,21 +96,84 @@ namespace osu.Game.Rulesets.Osu.Edit { base.LoadComplete(); - ScheduleAfterChildren(() => angleInput.TakeFocus()); + ScheduleAfterChildren(() => + { + angleInput.TakeFocus(); + angleInput.SelectAll(); + }); angleInput.Current.BindValueChanged(angle => rotationInfo.Value = rotationInfo.Value with { Degrees = angle.NewValue }); - rotationOrigin.Items.First().Select(); - rotationHandler.CanRotateSelectionOrigin.BindValueChanged(e => + 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, rotation.NewValue.Origin == RotationOrigin.PlayfieldCentre ? OsuPlayfield.BASE_SIZE / 2 : null); + rotationHandler.Update(rotation.NewValue.Degrees, getOriginPosition(rotation.NewValue)); }); } + private Vector2? getOriginPosition(PreciseRotationInfo rotation) => + rotation.Origin switch + { + EditorOrigin.GridCentre => gridToolbox.StartPosition.Value, + EditorOrigin.PlayfieldCentre => OsuPlayfield.BASE_SIZE / 2, + EditorOrigin.SelectionCentre => null, + _ => throw new ArgumentOutOfRangeException(nameof(rotation)) + }; + protected override void PopIn() { base.PopIn(); @@ -106,13 +187,18 @@ namespace osu.Game.Rulesets.Osu.Edit if (IsLoaded) rotationHandler.Commit(); } + + public override bool OnPressed(KeyBindingPressEvent e) + { + if (e.Action == GlobalAction.Select && !e.Repeat) + { + this.HidePopover(); + return true; + } + + return base.OnPressed(e); + } } - public enum RotationOrigin - { - 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 new file mode 100644 index 0000000000..e728290289 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs @@ -0,0 +1,358 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Game.Configuration; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Input.Bindings; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.Components.RadioButtons; +using osu.Game.Screens.Edit.Compose.Components; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Edit +{ + public partial class PreciseScalePopover : OsuPopover + { + private readonly OsuSelectionScaleHandler scaleHandler; + + private readonly OsuGridToolboxGroup gridToolbox; + + private readonly Bindable scaleInfo = new Bindable(new PreciseScaleInfo(1, EditorOrigin.GridCentre, true, true)); + + private SliderWithTextBoxInput scaleInput = null!; + private BindableNumber scaleInputBindable = null!; + private EditorRadioButtonCollection scaleOrigin = null!; + + private RadioButton gridCentreButton = null!; + private RadioButton playfieldCentreButton = null!; + private RadioButton selectionCentreButton = null!; + + private OsuCheckbox xCheckBox = null!; + private OsuCheckbox yCheckBox = null!; + + private Bindable configScaleOrigin = null!; + + private BindableList selectedItems { get; } = new BindableList(); + + public PreciseScalePopover(OsuSelectionScaleHandler scaleHandler, OsuGridToolboxGroup gridToolbox) + { + this.scaleHandler = scaleHandler; + this.gridToolbox = gridToolbox; + + AllowableAnchors = new[] { Anchor.CentreLeft, Anchor.CentreRight }; + } + + [BackgroundDependencyLoader] + private void load(EditorBeatmap editorBeatmap, OsuConfigManager config) + { + selectedItems.BindTo(editorBeatmap.SelectedHitObjects); + + configScaleOrigin = config.GetBindable(OsuSetting.EditorScaleOrigin); + + Child = new FillFlowContainer + { + Width = 220, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(20), + Children = new Drawable[] + { + scaleInput = new SliderWithTextBoxInput("Scale:") + { + Current = scaleInputBindable = new BindableNumber + { + MinValue = 0.05f, + MaxValue = 2, + Precision = 0.001f, + Value = 1, + Default = 1, + }, + KeyboardStep = 0.01f, + Instantaneous = true + }, + scaleOrigin = new EditorRadioButtonCollection + { + RelativeSizeAxes = Axes.X, + Items = new[] + { + gridCentreButton = new RadioButton("Grid centre", + () => setOrigin(EditorOrigin.GridCentre), + () => new SpriteIcon { Icon = FontAwesome.Regular.PlusSquare }), + playfieldCentreButton = new RadioButton("Playfield centre", + () => setOrigin(EditorOrigin.PlayfieldCentre), + () => new SpriteIcon { Icon = FontAwesome.Regular.Square }), + selectionCentreButton = new RadioButton("Selection centre", + () => setOrigin(EditorOrigin.SelectionCentre), + () => new SpriteIcon { Icon = FontAwesome.Solid.VectorSquare }) + } + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(4), + Children = new Drawable[] + { + xCheckBox = new OsuCheckbox(false) + { + RelativeSizeAxes = Axes.X, + LabelText = "X-axis", + Current = { Value = true }, + }, + yCheckBox = new OsuCheckbox(false) + { + RelativeSizeAxes = Axes.X, + LabelText = "Y-axis", + Current = { Value = true }, + }, + } + }, + } + }; + gridCentreButton.Selected.DisabledChanged += isDisabled => + { + gridCentreButton.TooltipText = isDisabled ? "The current selection cannot be scaled relative to grid centre." : string.Empty; + }; + playfieldCentreButton.Selected.DisabledChanged += isDisabled => + { + playfieldCentreButton.TooltipText = isDisabled ? "The current selection cannot be scaled relative to playfield centre." : string.Empty; + }; + selectionCentreButton.Selected.DisabledChanged += isDisabled => + { + selectionCentreButton.TooltipText = isDisabled ? "The current selection cannot be scaled relative to its centre." : string.Empty; + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + ScheduleAfterChildren(() => + { + scaleInput.TakeFocus(); + scaleInput.SelectAll(); + }); + scaleInput.Current.BindValueChanged(scale => scaleInfo.Value = scaleInfo.Value with { Scale = scale.NewValue }); + + xCheckBox.Current.BindValueChanged(_ => + { + if (!xCheckBox.Current.Value && !yCheckBox.Current.Value) + { + yCheckBox.Current.Value = true; + return; + } + + updateAxes(); + }); + yCheckBox.Current.BindValueChanged(_ => + { + if (!xCheckBox.Current.Value && !yCheckBox.Current.Value) + { + xCheckBox.Current.Value = true; + return; + } + + updateAxes(); + }); + + selectionCentreButton.Selected.Disabled = !(scaleHandler.CanScaleX.Value || scaleHandler.CanScaleY.Value); + playfieldCentreButton.Selected.Disabled = scaleHandler.IsScalingSlider.Value && !selectionCentreButton.Selected.Disabled; + gridCentreButton.Selected.Disabled = playfieldCentreButton.Selected.Disabled; + + 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 => + { + var newScale = new Vector2(scale.NewValue.Scale, scale.NewValue.Scale); + scaleHandler.Update(newScale, getOriginPosition(scale.NewValue), getAdjustAxis(scale.NewValue), getRotation(scale.NewValue)); + }); + } + + private void updateAxes() + { + scaleInfo.Value = scaleInfo.Value with { XAxis = xCheckBox.Current.Value, YAxis = yCheckBox.Current.Value }; + updateMinMaxScale(); + } + + private void updateAxisCheckBoxesEnabled() + { + if (scaleInfo.Value.Origin != EditorOrigin.SelectionCentre) + { + toggleAxisAvailable(xCheckBox.Current, true); + toggleAxisAvailable(yCheckBox.Current, true); + } + else + { + toggleAxisAvailable(xCheckBox.Current, scaleHandler.CanScaleX.Value); + toggleAxisAvailable(yCheckBox.Current, scaleHandler.CanScaleY.Value); + } + } + + private void toggleAxisAvailable(Bindable axisBindable, bool available) + { + // enable the bindable to allow setting the value + axisBindable.Disabled = false; + // restore the presumed default value given the axis's new availability state + axisBindable.Value = available; + axisBindable.Disabled = !available; + } + + private void updateMinMaxScale() + { + if (!scaleHandler.OriginalSurroundingQuad.HasValue) + return; + + const float min_scale = 0.05f; + const float max_scale = 10; + + var scale = scaleHandler.ClampScaleToPlayfieldBounds(new Vector2(max_scale), getOriginPosition(scaleInfo.Value), getAdjustAxis(scaleInfo.Value), getRotation(scaleInfo.Value)); + + if (!scaleInfo.Value.XAxis) + scale.X = max_scale; + if (!scaleInfo.Value.YAxis) + scale.Y = max_scale; + + scaleInputBindable.MaxValue = MathF.Max(1, MathF.Min(scale.X, scale.Y)); + + scale = scaleHandler.ClampScaleToPlayfieldBounds(new Vector2(min_scale), getOriginPosition(scaleInfo.Value), getAdjustAxis(scaleInfo.Value), getRotation(scaleInfo.Value)); + + if (!scaleInfo.Value.XAxis) + scale.X = min_scale; + if (!scaleInfo.Value.YAxis) + scale.Y = min_scale; + + scaleInputBindable.MinValue = MathF.Min(1, MathF.Max(scale.X, scale.Y)); + } + + private void setOrigin(EditorOrigin origin) + { + scaleInfo.Value = scaleInfo.Value with { Origin = origin }; + updateMinMaxScale(); + updateAxisCheckBoxesEnabled(); + } + + private Vector2? getOriginPosition(PreciseScaleInfo scale) + { + switch (scale.Origin) + { + case EditorOrigin.GridCentre: + return gridToolbox.StartPosition.Value; + + case EditorOrigin.PlayfieldCentre: + return OsuPlayfield.BASE_SIZE / 2; + + case EditorOrigin.SelectionCentre: + if (selectedItems.Count == 1 && selectedItems.First() is Slider slider) + return slider.Position; + + return null; + + default: + throw new ArgumentOutOfRangeException(nameof(scale)); + } + } + + private Axes getAdjustAxis(PreciseScaleInfo scale) + { + var result = Axes.None; + + if (scale.XAxis) + result |= Axes.X; + + if (scale.YAxis) + result |= Axes.Y; + + return result; + } + + private float getRotation(PreciseScaleInfo scale) => scale.Origin == EditorOrigin.GridCentre ? gridToolbox.GridLinesRotation.Value : 0; + + protected override void PopIn() + { + base.PopIn(); + scaleHandler.Begin(); + updateMinMaxScale(); + } + + protected override void PopOut() + { + base.PopOut(); + + if (IsLoaded) scaleHandler.Commit(); + } + + public override bool OnPressed(KeyBindingPressEvent e) + { + if (e.Action == GlobalAction.Select && !e.Repeat) + { + this.HidePopover(); + return true; + } + + return base.OnPressed(e); + } + } + + public record PreciseScaleInfo(float Scale, EditorOrigin Origin, bool XAxis, bool YAxis); +} diff --git a/osu.Game.Rulesets.Osu/Edit/Setup/OsuDifficultySection.cs b/osu.Game.Rulesets.Osu/Edit/Setup/OsuDifficultySection.cs new file mode 100644 index 0000000000..7a01646b35 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/Setup/OsuDifficultySection.cs @@ -0,0 +1,157 @@ +// 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.Graphics; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Localisation; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Screens.Edit.Setup; + +namespace osu.Game.Rulesets.Osu.Edit.Setup +{ + public partial class OsuDifficultySection : SetupSection + { + private FormSliderBar circleSizeSlider { get; set; } = null!; + private FormSliderBar healthDrainSlider { get; set; } = null!; + private FormSliderBar approachRateSlider { get; set; } = null!; + private FormSliderBar overallDifficultySlider { get; set; } = null!; + private FormSliderBar baseVelocitySlider { get; set; } = null!; + private FormSliderBar tickRateSlider { get; set; } = null!; + private FormSliderBar stackLeniency { get; set; } = null!; + + public override LocalisableString Title => EditorSetupStrings.DifficultyHeader; + + [BackgroundDependencyLoader] + private void load() + { + Children = new Drawable[] + { + circleSizeSlider = new FormSliderBar + { + Caption = BeatmapsetsStrings.ShowStatsCs, + HintText = EditorSetupStrings.CircleSizeDescription, + Current = new BindableFloat(Beatmap.Difficulty.CircleSize) + { + Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, + MinValue = 0, + MaxValue = 10, + Precision = 0.1f, + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, + }, + healthDrainSlider = new FormSliderBar + { + Caption = BeatmapsetsStrings.ShowStatsDrain, + HintText = EditorSetupStrings.DrainRateDescription, + Current = new BindableFloat(Beatmap.Difficulty.DrainRate) + { + Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, + MinValue = 0, + MaxValue = 10, + Precision = 0.1f, + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, + }, + approachRateSlider = new FormSliderBar + { + Caption = BeatmapsetsStrings.ShowStatsAr, + HintText = EditorSetupStrings.ApproachRateDescription, + Current = new BindableFloat(Beatmap.Difficulty.ApproachRate) + { + Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, + MinValue = 0, + MaxValue = 10, + Precision = 0.1f, + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, + }, + overallDifficultySlider = new FormSliderBar + { + Caption = BeatmapsetsStrings.ShowStatsAccuracy, + HintText = EditorSetupStrings.OverallDifficultyDescription, + Current = new BindableFloat(Beatmap.Difficulty.OverallDifficulty) + { + Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, + MinValue = 0, + MaxValue = 10, + Precision = 0.1f, + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, + }, + baseVelocitySlider = new FormSliderBar + { + Caption = EditorSetupStrings.BaseVelocity, + HintText = EditorSetupStrings.BaseVelocityDescription, + Current = new BindableDouble(Beatmap.Difficulty.SliderMultiplier) + { + Default = 1.4, + MinValue = 0.4, + MaxValue = 3.6, + Precision = 0.01f, + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, + }, + tickRateSlider = new FormSliderBar + { + Caption = EditorSetupStrings.TickRate, + HintText = EditorSetupStrings.TickRateDescription, + Current = new BindableDouble(Beatmap.Difficulty.SliderTickRate) + { + Default = 1, + MinValue = 1, + MaxValue = 4, + Precision = 1, + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, + }, + stackLeniency = new FormSliderBar + { + Caption = "Stack Leniency", + HintText = "In play mode, osu! automatically stacks notes which occur at the same location. Increasing this value means it is more likely to snap notes of further time-distance.", + Current = new BindableFloat(Beatmap.BeatmapInfo.StackLeniency) + { + Default = 0.7f, + MinValue = 0, + MaxValue = 1, + Precision = 0.1f + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, + }, + }; + + foreach (var item in Children.OfType>()) + item.Current.ValueChanged += _ => updateValues(); + + foreach (var item in Children.OfType>()) + item.Current.ValueChanged += _ => updateValues(); + } + + private void updateValues() + { + // for now, update these on commit rather than making BeatmapMetadata bindables. + // after switching database engines we can reconsider if switching to bindables is a good direction. + Beatmap.Difficulty.CircleSize = circleSizeSlider.Current.Value; + Beatmap.Difficulty.DrainRate = healthDrainSlider.Current.Value; + Beatmap.Difficulty.ApproachRate = approachRateSlider.Current.Value; + Beatmap.Difficulty.OverallDifficulty = overallDifficultySlider.Current.Value; + Beatmap.Difficulty.SliderMultiplier = baseVelocitySlider.Current.Value; + Beatmap.Difficulty.SliderTickRate = tickRateSlider.Current.Value; + Beatmap.BeatmapInfo.StackLeniency = stackLeniency.Current.Value; + + Beatmap.UpdateAllHitObjects(); + Beatmap.SaveState(); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/Setup/OsuSetupSection.cs b/osu.Game.Rulesets.Osu/Edit/Setup/OsuSetupSection.cs deleted file mode 100644 index ac567559b8..0000000000 --- a/osu.Game.Rulesets.Osu/Edit/Setup/OsuSetupSection.cs +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Game.Graphics.UserInterfaceV2; -using osu.Game.Screens.Edit.Setup; - -namespace osu.Game.Rulesets.Osu.Edit.Setup -{ - public partial class OsuSetupSection : RulesetSetupSection - { - private LabelledSliderBar stackLeniency; - - public OsuSetupSection() - : base(new OsuRuleset().RulesetInfo) - { - } - - [BackgroundDependencyLoader] - private void load() - { - Children = new[] - { - stackLeniency = new LabelledSliderBar - { - Label = "Stack Leniency", - Description = "In play mode, osu! automatically stacks notes which occur at the same location. Increasing this value means it is more likely to snap notes of further time-distance.", - Current = new BindableFloat(Beatmap.BeatmapInfo.StackLeniency) - { - Default = 0.7f, - MinValue = 0, - MaxValue = 1, - Precision = 0.1f - } - } - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - stackLeniency.Current.BindValueChanged(_ => updateBeatmap()); - } - - private void updateBeatmap() - { - Beatmap.BeatmapInfo.StackLeniency = stackLeniency.Current.Value; - Beatmap.SaveState(); - } - } -} diff --git a/osu.Game.Rulesets.Osu/Edit/SliderCompositionTool.cs b/osu.Game.Rulesets.Osu/Edit/SliderCompositionTool.cs index 676205c8d7..d697a2ebe6 100644 --- a/osu.Game.Rulesets.Osu/Edit/SliderCompositionTool.cs +++ b/osu.Game.Rulesets.Osu/Edit/SliderCompositionTool.cs @@ -10,15 +10,22 @@ using osu.Game.Rulesets.Osu.Objects; namespace osu.Game.Rulesets.Osu.Edit { - public class SliderCompositionTool : HitObjectCompositionTool + public class SliderCompositionTool : CompositionTool { public SliderCompositionTool() : base(nameof(Slider)) { + TooltipText = """ + Left click for new point. + Left click twice or S key for new segment. + Tab, Shift-Tab, or Alt-1~4 to change current segment type. + Right click to finish. + Click and drag for drawing mode. + """; } public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders); - public override PlacementBlueprint CreatePlacementBlueprint() => new SliderPlacementBlueprint(); + public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new SliderPlacementBlueprint(); } } diff --git a/osu.Game.Rulesets.Osu/Edit/SpinnerCompositionTool.cs b/osu.Game.Rulesets.Osu/Edit/SpinnerCompositionTool.cs index c8160617c9..de1506e4a9 100644 --- a/osu.Game.Rulesets.Osu/Edit/SpinnerCompositionTool.cs +++ b/osu.Game.Rulesets.Osu/Edit/SpinnerCompositionTool.cs @@ -10,7 +10,7 @@ using osu.Game.Rulesets.Osu.Objects; namespace osu.Game.Rulesets.Osu.Edit { - public class SpinnerCompositionTool : HitObjectCompositionTool + public class SpinnerCompositionTool : CompositionTool { public SpinnerCompositionTool() : base(nameof(Spinner)) @@ -19,6 +19,6 @@ namespace osu.Game.Rulesets.Osu.Edit public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Spinners); - public override PlacementBlueprint CreatePlacementBlueprint() => new SpinnerPlacementBlueprint(); + public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new SpinnerPlacementBlueprint(); } } diff --git a/osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs b/osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs index 9499bacade..a41412cbe3 100644 --- a/osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs +++ b/osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs @@ -18,14 +18,16 @@ namespace osu.Game.Rulesets.Osu.Edit { public partial class TransformToolboxGroup : EditorToolboxGroup, IKeyBindingHandler { - private readonly Bindable canRotate = new BindableBool(); + private readonly AggregateBindable canRotate = new AggregateBindable((x, y) => x || y); + private readonly AggregateBindable canScale = new AggregateBindable((x, y) => x || y); private EditorToolButton rotateButton = null!; - - private Bindable canRotatePlayfieldOrigin = null!; - private Bindable canRotateSelectionOrigin = null!; + private EditorToolButton scaleButton = null!; public SelectionRotationHandler RotationHandler { get; init; } = null!; + public OsuSelectionScaleHandler ScaleHandler { get; init; } = null!; + + public OsuGridToolboxGroup GridToolbox { get; init; } = null!; public TransformToolboxGroup() : base("transform") @@ -44,8 +46,10 @@ namespace osu.Game.Rulesets.Osu.Edit { rotateButton = new EditorToolButton("Rotate", () => new SpriteIcon { Icon = FontAwesome.Solid.Undo }, - () => new PreciseRotationPopover(RotationHandler)), - // TODO: scale + () => new PreciseRotationPopover(RotationHandler, GridToolbox)), + scaleButton = new EditorToolButton("Scale", + () => new SpriteIcon { Icon = FontAwesome.Solid.ArrowsAlt }, + () => new PreciseScalePopover(ScaleHandler, GridToolbox)) } }; } @@ -54,21 +58,17 @@ namespace osu.Game.Rulesets.Osu.Edit { base.LoadComplete(); - // aggregate two values into canRotate - canRotatePlayfieldOrigin = RotationHandler.CanRotatePlayfieldOrigin.GetBoundCopy(); - canRotatePlayfieldOrigin.BindValueChanged(_ => updateCanRotateAggregate()); + canRotate.AddSource(RotationHandler.CanRotateAroundPlayfieldOrigin); + canRotate.AddSource(RotationHandler.CanRotateAroundSelectionOrigin); - canRotateSelectionOrigin = RotationHandler.CanRotateSelectionOrigin.GetBoundCopy(); - canRotateSelectionOrigin.BindValueChanged(_ => updateCanRotateAggregate()); - - void updateCanRotateAggregate() - { - canRotate.Value = RotationHandler.CanRotatePlayfieldOrigin.Value || RotationHandler.CanRotateSelectionOrigin.Value; - } + canScale.AddSource(ScaleHandler.CanScaleX); + canScale.AddSource(ScaleHandler.CanScaleY); + canScale.AddSource(ScaleHandler.CanScaleFromPlayfieldOrigin); // bindings to `Enabled` on the buttons are decoupled on purpose // due to the weird `OsuButton` behaviour of resetting `Enabled` to `false` when `Action` is set. - canRotate.BindValueChanged(_ => rotateButton.Enabled.Value = canRotate.Value, true); + canRotate.Result.BindValueChanged(rotate => rotateButton.Enabled.Value = rotate.NewValue, true); + canScale.Result.BindValueChanged(scale => scaleButton.Enabled.Value = scale.NewValue, true); } public bool OnPressed(KeyBindingPressEvent e) @@ -79,7 +79,15 @@ namespace osu.Game.Rulesets.Osu.Edit { case GlobalAction.EditorToggleRotateControl: { - rotateButton.TriggerClick(); + if (!RotationHandler.OperationInProgress.Value || rotateButton.Selected.Value) + rotateButton.TriggerClick(); + return true; + } + + case GlobalAction.EditorToggleScaleControl: + { + if (!ScaleHandler.OperationInProgress.Value || scaleButton.Selected.Value) + scaleButton.TriggerClick(); return true; } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs b/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs index efcc728d55..b45b4fea13 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs @@ -67,8 +67,6 @@ namespace osu.Game.Rulesets.Osu.Mods // Generate the replay frames the cursor should follow replayFrames = new OsuAutoGenerator(drawableRuleset.Beatmap, drawableRuleset.Mods).Generate().Frames.Cast().ToList(); - - drawableRuleset.UseResumeOverlay = false; } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModBarrelRoll.cs b/osu.Game.Rulesets.Osu/Mods/OsuModBarrelRoll.cs index 2394cf92fc..8898faf7b8 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModBarrelRoll.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModBarrelRoll.cs @@ -2,10 +2,13 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Diagnostics; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Osu.Mods { @@ -25,5 +28,14 @@ namespace osu.Game.Rulesets.Osu.Mods } }; } + + public override void Update(Playfield playfield) + { + base.Update(playfield); + OsuPlayfield osuPlayfield = (OsuPlayfield)playfield; + Debug.Assert(osuPlayfield.Cursor != null); + + osuPlayfield.Cursor.ActiveCursor.Rotation = -CurrentRotation; + } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModBloom.cs b/osu.Game.Rulesets.Osu/Mods/OsuModBloom.cs new file mode 100644 index 0000000000..c674074dc6 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Mods/OsuModBloom.cs @@ -0,0 +1,85 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Bindables; +using osu.Framework.Localisation; +using osu.Framework.Utils; +using osu.Game.Configuration; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.UI.Cursor; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; +using osu.Game.Scoring; +using osu.Game.Screens.Play; + +namespace osu.Game.Rulesets.Osu.Mods +{ + public class OsuModBloom : Mod, IApplicableToScoreProcessor, IUpdatableByPlayfield, IApplicableToPlayer + { + public override string Name => "Bloom"; + public override string Acronym => "BM"; + public override ModType Type => ModType.Fun; + public override LocalisableString Description => "The cursor blooms into.. a larger cursor!"; + public override double ScoreMultiplier => 1; + protected const float MIN_SIZE = 1; + protected const float TRANSITION_DURATION = 100; + public override Type[] IncompatibleMods => new[] { typeof(OsuModFlashlight), typeof(OsuModNoScope), typeof(ModTouchDevice) }; + + protected readonly BindableNumber CurrentCombo = new BindableInt(); + protected readonly IBindable IsBreakTime = new Bindable(); + + private float currentSize; + + [SettingSource( + "Max size at combo", + "The combo count at which the cursor reaches its maximum size", + SettingControlType = typeof(SettingsSlider>) + )] + public BindableInt MaxSizeComboCount { get; } = new BindableInt(50) + { + MinValue = 5, + MaxValue = 100, + }; + + [SettingSource( + "Final size multiplier", + "The multiplier applied to cursor size when combo reaches maximum", + SettingControlType = typeof(SettingsSlider>) + )] + public BindableFloat MaxCursorSize { get; } = new BindableFloat(10f) + { + MinValue = 5f, + MaxValue = 15f, + Precision = 0.5f, + }; + + public ScoreRank AdjustRank(ScoreRank rank, double accuracy) => rank; + + public void ApplyToPlayer(Player player) + { + IsBreakTime.BindTo(player.IsBreakTime); + } + + public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) + { + CurrentCombo.BindTo(scoreProcessor.Combo); + CurrentCombo.BindValueChanged(combo => + { + currentSize = Math.Clamp(MaxCursorSize.Value * ((float)combo.NewValue / MaxSizeComboCount.Value), MIN_SIZE, MaxCursorSize.Value); + }, true); + } + + public void Update(Playfield playfield) + { + OsuCursor cursor = (OsuCursor)(playfield.Cursor!.ActiveCursor); + + if (IsBreakTime.Value) + cursor.ModScaleAdjust.Value = 1; + else + cursor.ModScaleAdjust.Value = (float)Interpolation.Lerp(cursor.ModScaleAdjust.Value, currentSize, Math.Clamp(cursor.Time.Elapsed / TRANSITION_DURATION, 0, 1)); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModDepth.cs b/osu.Game.Rulesets.Osu/Mods/OsuModDepth.cs index a9111eec1f..306dcee839 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModDepth.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModDepth.cs @@ -47,9 +47,8 @@ namespace osu.Game.Rulesets.Osu.Mods public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { - // Hide judgment displays and follow points as they won't make any sense. + // Hide follow points as they won't make any sense. // Judgements can potentially be turned on in a future where they display at a position relative to their drawable counterpart. - drawableRuleset.Playfield.DisplayJudgements.Value = false; (drawableRuleset.Playfield as OsuPlayfield)?.FollowPoints.Hide(); } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs b/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs index 5a6cc50082..3009530b50 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs @@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Osu.Mods public partial class OsuModFlashlight : ModFlashlight, IApplicableToDrawableHitObject { public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.12 : 1; - public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(OsuModBlinds)).ToArray(); + public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModBloom), typeof(OsuModBlinds) }).ToArray(); private const double default_follow_delay = 120; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs b/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs index b49fb931d1..b2553e295c 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs @@ -39,9 +39,8 @@ namespace osu.Game.Rulesets.Osu.Mods public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { - // Hide judgment displays and follow points as they won't make any sense. + // Hide follow points as they won't make any sense. // Judgements can potentially be turned on in a future where they display at a position relative to their drawable counterpart. - drawableRuleset.Playfield.DisplayJudgements.Value = false; (drawableRuleset.Playfield as OsuPlayfield)?.FollowPoints.Hide(); } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModNoScope.cs b/osu.Game.Rulesets.Osu/Mods/OsuModNoScope.cs index d1bbae8e1a..57d540a7d4 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModNoScope.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModNoScope.cs @@ -20,6 +20,8 @@ namespace osu.Game.Rulesets.Osu.Mods { public override LocalisableString Description => "Where's the cursor?"; + public override Type[] IncompatibleMods => new[] { typeof(OsuModBloom) }; + private PeriodTracker spinnerPeriods = null!; public override BindableInt HiddenComboCount { get; } = new BindableInt(10) diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRepel.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRepel.cs index ced98f0cd5..302e17432e 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModRepel.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModRepel.cs @@ -38,9 +38,8 @@ namespace osu.Game.Rulesets.Osu.Mods public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { - // Hide judgment displays and follow points as they won't make any sense. + // Hide follow points as they won't make any sense. // Judgements can potentially be turned on in a future where they display at a position relative to their drawable counterpart. - drawableRuleset.Playfield.DisplayJudgements.Value = false; (drawableRuleset.Playfield as OsuPlayfield)?.FollowPoints.Hide(); } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs b/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs index 2c9292c58b..7d2fd628f6 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs @@ -120,6 +120,7 @@ namespace osu.Game.Rulesets.Osu.Mods Position = Position + Path.PositionAt(e.PathProgress), StackHeight = StackHeight, Scale = Scale, + PathProgress = e.PathProgress, }); break; @@ -150,6 +151,7 @@ namespace osu.Game.Rulesets.Osu.Mods Position = Position + Path.PositionAt(e.PathProgress), StackHeight = StackHeight, Scale = Scale, + PathProgress = e.PathProgress, }); break; } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTouchDevice.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTouchDevice.cs index 917685cdad..a364190a00 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTouchDevice.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTouchDevice.cs @@ -9,7 +9,7 @@ namespace osu.Game.Rulesets.Osu.Mods { public class OsuModTouchDevice : ModTouchDevice { - public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAutopilot) }).ToArray(); + public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAutopilot), typeof(OsuModBloom) }).ToArray(); public override bool Ranked => UsesDefaultConfiguration; } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs index 9671f53bea..9091837034 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs @@ -6,7 +6,9 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; using osu.Game.Rulesets.Osu.Skinning.Default; @@ -19,9 +21,12 @@ namespace osu.Game.Rulesets.Osu.Mods public override ModType Type => ModType.Fun; public override LocalisableString Description => "Put your faith in the approach circles..."; public override double ScoreMultiplier => 1; + public override bool Ranked => true; public override Type[] IncompatibleMods => new[] { typeof(IHidesApproachCircles), typeof(OsuModDepth) }; + protected override bool IsFirstAdjustableObject(HitObject hitObject) => !(hitObject is Spinner || hitObject is SpinnerTick); + protected override void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state) { } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs index c3ce6acce9..101c34b725 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Framework.Utils; using osu.Game.Graphics.Containers; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Drawables; @@ -19,6 +20,7 @@ using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Scoring; using osu.Game.Skinning; using osuTK; +using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Objects.Drawables { @@ -319,5 +321,32 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { } } + + #region FOR EDITOR USE ONLY, DO NOT USE FOR ANY OTHER PURPOSE + + internal void SuppressHitAnimations() + { + UpdateState(ArmedState.Idle); + UpdateComboColour(); + + // This method is called every frame in editor contexts, thus the lack of need for transforms. + + if (Time.Current >= HitStateUpdateTime) + { + // More or less matches stable (see https://github.com/peppy/osu-stable-reference/blob/bb57924c1552adbed11ee3d96cdcde47cf96f2b6/osu!/GameplayElements/HitObjects/Osu/HitCircleOsu.cs#L336-L338) + AccentColour.Value = Color4.White; + Alpha = Interpolation.ValueAt(Time.Current, 1f, 0f, HitStateUpdateTime, HitStateUpdateTime + 700); + } + + LifetimeEnd = HitStateUpdateTime + 700; + } + + internal void RestoreHitAnimations() + { + UpdateState(ArmedState.Hit, force: true); + UpdateComboColour(); + } + + #endregion } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs index 5f5deca1ba..b3a68ec92d 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs @@ -91,20 +91,35 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables drawableObjectPiece.ApplyCustomUpdateState -= applyDimToDrawableHitObject; drawableObjectPiece.ApplyCustomUpdateState += applyDimToDrawableHitObject; } - else - applyDim(piece); - } - void applyDim(Drawable piece) - { - piece.FadeColour(new Color4(195, 195, 195, 255)); - using (piece.BeginDelayedSequence(InitialLifetimeOffset - OsuHitWindows.MISS_WINDOW)) - piece.FadeColour(Color4.White, 100); + // but at the end apply the transforms now regardless of whether this is a DHO or not. + // the above is just to ensure they don't get overwritten later. + applyDim(piece); } - - void applyDimToDrawableHitObject(DrawableHitObject dho, ArmedState _) => applyDim(dho); } + protected override void ClearNestedHitObjects() + { + base.ClearNestedHitObjects(); + + // any dimmable pieces that are DHOs will be pooled separately. + // `applyDimToDrawableHitObject` is a closure that implicitly captures `this`, + // and because of separate pooling of parent and child objects, there is no guarantee that the pieces will be associated with `this` again on re-use. + // therefore, clean up the subscription here to avoid crosstalk. + // not doing so can result in the callback attempting to read things from `this` when it is in a completely bogus state (not in use or similar). + foreach (var piece in DimmablePieces.OfType()) + piece.ApplyCustomUpdateState -= applyDimToDrawableHitObject; + } + + private void applyDim(Drawable piece) + { + piece.FadeColour(new Color4(195, 195, 195, 255)); + using (piece.BeginDelayedSequence(InitialLifetimeOffset - OsuHitWindows.MISS_WINDOW)) + piece.FadeColour(Color4.White, 100); + } + + private void applyDimToDrawableHitObject(DrawableHitObject dho, ArmedState _) => applyDim(dho); + protected sealed override double InitialLifetimeOffset => HitObject.TimePreempt; private OsuInputManager osuActionInputManager; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs index 76ae7340ff..8b3fcb23cd 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs @@ -1,23 +1,27 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Configuration; using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; using osuTK; +using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Objects.Drawables { public partial class DrawableOsuJudgement : DrawableJudgement { - internal SkinnableLighting Lighting { get; private set; } + internal Color4 AccentColour { get; private set; } + + internal SkinnableLighting Lighting { get; private set; } = null!; [Resolved] - private OsuConfigManager config { get; set; } + private OsuConfigManager config { get; set; } = null!; + + private Vector2 screenSpacePosition; [BackgroundDependencyLoader] private void load() @@ -32,18 +36,36 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables }); } + public override void Apply(JudgementResult result, DrawableHitObject? judgedObject) + { + base.Apply(result, judgedObject); + + if (judgedObject is not DrawableOsuHitObject osuObject) + return; + + AccentColour = osuObject.AccentColour.Value; + + switch (osuObject) + { + case DrawableSlider slider: + screenSpacePosition = slider.TailCircle.ToScreenSpace(slider.TailCircle.OriginPosition); + break; + + default: + screenSpacePosition = osuObject.ToScreenSpace(osuObject.OriginPosition); + break; + } + + Scale = new Vector2(osuObject.HitObject.Scale); + } + protected override void PrepareForUse() { base.PrepareForUse(); Lighting.ResetAnimation(); - Lighting.SetColourFrom(JudgedObject, Result); - - if (JudgedObject?.HitObject is OsuHitObject osuObject) - { - Position = osuObject.StackedEndPosition; - Scale = new Vector2(osuObject.Scale); - } + Lighting.SetColourFrom(this, Result); + Position = Parent!.ToLocalSpace(screenSpacePosition); } protected override void ApplyHitAnimations() diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index e519e51562..eacd2b3e75 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -370,5 +370,23 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables private partial class DefaultSliderBody : PlaySliderBody { } + + #region FOR EDITOR USE ONLY, DO NOT USE FOR ANY OTHER PURPOSE + + internal void SuppressHitAnimations() + { + UpdateState(ArmedState.Idle); + HeadCircle.SuppressHitAnimations(); + TailCircle.SuppressHitAnimations(); + } + + internal void RestoreHitAnimations() + { + UpdateState(ArmedState.Hit); + HeadCircle.RestoreHitAnimations(); + TailCircle.RestoreHitAnimations(); + } + + #endregion } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs index 46f0231981..24c0d0fcf0 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs @@ -10,9 +10,7 @@ using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Skinning.Default; -using osu.Game.Screens.Play; using osu.Game.Skinning; -using osuTK; namespace osu.Game.Rulesets.Osu.Objects.Drawables { @@ -63,22 +61,20 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables base.ApplyTransformsAt(time, false); } - private Vector2? lastPosition; - public void UpdateProgress(double completionProgress) { - Position = drawableSlider.HitObject.CurvePositionAt(completionProgress); + Slider slider = drawableSlider.HitObject; + Position = slider.CurvePositionAt(completionProgress); - var diff = lastPosition.HasValue ? lastPosition.Value - Position : Position - drawableSlider.HitObject.CurvePositionAt(completionProgress + 0.01f); - - bool rewinding = (Clock as IGameplayClock)?.IsRewinding == true; + //0.1 / slider.Path.Distance is the additional progress needed to ensure the diff length is 0.1 + var diff = slider.CurvePositionAt(completionProgress) - slider.CurvePositionAt(Math.Min(1, completionProgress + 0.1 / slider.Path.Distance)); // Ensure the value is substantially high enough to allow for Atan2 to get a valid angle. + // Needed for when near completion, or in case of a very short slider. if (diff.LengthFast < 0.01f) return; - ball.Rotation = -90 + (float)(-Math.Atan2(diff.X, diff.Y) * 180 / Math.PI) + (rewinding ? 180 : 0); - lastPosition = Position; + ball.Rotation = -90 + (float)(-Math.Atan2(diff.X, diff.Y) * 180 / Math.PI); } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs index c4731118a1..8bb1b0aebc 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs @@ -8,10 +8,12 @@ using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Utils; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; using osu.Game.Skinning; using osuTK; +using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Objects.Drawables { @@ -125,5 +127,32 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables if (Slider != null) Position = Slider.CurvePositionAt(HitObject.RepeatIndex % 2 == 0 ? 1 : 0); } + + #region FOR EDITOR USE ONLY, DO NOT USE FOR ANY OTHER PURPOSE + + internal void SuppressHitAnimations() + { + UpdateState(ArmedState.Idle); + UpdateComboColour(); + + // This method is called every frame in editor contexts, thus the lack of need for transforms. + + if (Time.Current >= HitStateUpdateTime) + { + // More or less matches stable (see https://github.com/peppy/osu-stable-reference/blob/bb57924c1552adbed11ee3d96cdcde47cf96f2b6/osu!/GameplayElements/HitObjects/Osu/HitCircleOsu.cs#L336-L338) + AccentColour.Value = Color4.White; + Alpha = Interpolation.ValueAt(Time.Current, 1f, 0f, HitStateUpdateTime, HitStateUpdateTime + 700); + } + + LifetimeEnd = HitStateUpdateTime + 700; + } + + internal void RestoreHitAnimations() + { + UpdateState(ArmedState.Hit); + UpdateComboColour(); + } + + #endregion } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/SkinnableLighting.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/SkinnableLighting.cs index b39b9c4c54..3776201626 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/SkinnableLighting.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/SkinnableLighting.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Skinning; @@ -12,8 +10,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { internal partial class SkinnableLighting : SkinnableSprite { - private DrawableHitObject targetObject; - private JudgementResult targetResult; + private DrawableOsuJudgement? targetJudgement; + private JudgementResult? targetResult; public SkinnableLighting() : base("lighting") @@ -29,11 +27,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables /// /// Updates the lighting colour from a given hitobject and result. /// - /// The that's been judged. - /// The that was judged with. - public void SetColourFrom(DrawableHitObject targetObject, JudgementResult targetResult) + /// The that's been judged. + /// The that was judged with. + public void SetColourFrom(DrawableOsuJudgement targetJudgement, JudgementResult? targetResult) { - this.targetObject = targetObject; + this.targetJudgement = targetJudgement; this.targetResult = targetResult; updateColour(); @@ -41,10 +39,10 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables private void updateColour() { - if (targetObject == null || targetResult == null) + if (targetJudgement == null || targetResult == null) Colour = Color4.White; else - Colour = targetResult.IsHit ? targetObject.AccentColour.Value : Color4.Transparent; + Colour = targetResult.IsHit ? targetJudgement.AccentColour : Color4.Transparent; } } } diff --git a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs index ad76fb466d..c3d2daab9a 100644 --- a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs +++ b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs @@ -14,7 +14,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Objects { - public abstract class OsuHitObject : HitObject, IHasComboInformation, IHasPosition + public abstract class OsuHitObject : HitObject, IHasComboInformation, IHasPosition, IHasTimePreempt { /// /// The radius of hit objects (ie. the radius of a ). @@ -46,7 +46,7 @@ namespace osu.Game.Rulesets.Osu.Objects /// public const double PREEMPT_MAX = 1800; - public double TimePreempt = 600; + public double TimePreempt { get; set; } = 600; public double TimeFadeIn = 400; public double TimeFadeInRaw = 400; diff --git a/osu.Game.Rulesets.Osu/Objects/Slider.cs b/osu.Game.Rulesets.Osu/Objects/Slider.cs index cc3ffd376e..e484efb408 100644 --- a/osu.Game.Rulesets.Osu/Objects/Slider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Slider.cs @@ -9,6 +9,7 @@ using System.Collections.Generic; using osu.Game.Rulesets.Objects; using System.Linq; using System.Threading; +using JetBrains.Annotations; using Newtonsoft.Json; using osu.Framework.Bindables; using osu.Framework.Caching; @@ -162,6 +163,10 @@ namespace osu.Game.Rulesets.Osu.Objects [JsonIgnore] public SliderTailCircle TailCircle { get; protected set; } + [JsonIgnore] + [CanBeNull] + public SliderRepeat LastRepeat { get; protected set; } + public Slider() { SamplesBindable.CollectionChanged += (_, _) => UpdateNestedSamples(); @@ -199,6 +204,7 @@ namespace osu.Game.Rulesets.Osu.Objects SpanStartTime = e.SpanStartTime, StartTime = e.Time, Position = Position + Path.PositionAt(e.PathProgress), + PathProgress = e.PathProgress, StackHeight = StackHeight, }); break; @@ -225,12 +231,13 @@ namespace osu.Game.Rulesets.Osu.Objects break; case SliderEventType.Repeat: - AddNested(new SliderRepeat(this) + AddNested(LastRepeat = new SliderRepeat(this) { RepeatIndex = e.SpanIndex, StartTime = StartTime + (e.SpanIndex + 1) * SpanDuration, Position = Position + Path.PositionAt(e.PathProgress), StackHeight = StackHeight, + PathProgress = e.PathProgress, }); break; } @@ -243,15 +250,33 @@ namespace osu.Game.Rulesets.Osu.Objects { endPositionCache.Invalidate(); - if (HeadCircle != null) - HeadCircle.Position = Position; + foreach (var nested in NestedHitObjects) + { + switch (nested) + { + case SliderHeadCircle headCircle: + headCircle.Position = Position; + break; - if (TailCircle != null) - TailCircle.Position = EndPosition; + case SliderTailCircle tailCircle: + tailCircle.Position = EndPosition; + break; + + case SliderRepeat repeat: + repeat.Position = Position + Path.PositionAt(repeat.PathProgress); + break; + + case SliderTick tick: + tick.Position = Position + Path.PositionAt(tick.PathProgress); + break; + } + } } protected void UpdateNestedSamples() { + this.PopulateNodeSamples(); + // TODO: remove this when guaranteed sort is present for samples (https://github.com/ppy/osu/issues/1933) HitSampleInfo tickSample = (Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL) ?? Samples.FirstOrDefault())?.With("slidertick"); diff --git a/osu.Game.Rulesets.Osu/Objects/SliderRepeat.cs b/osu.Game.Rulesets.Osu/Objects/SliderRepeat.cs index e95cfd369d..1bbd1e8070 100644 --- a/osu.Game.Rulesets.Osu/Objects/SliderRepeat.cs +++ b/osu.Game.Rulesets.Osu/Objects/SliderRepeat.cs @@ -5,6 +5,8 @@ namespace osu.Game.Rulesets.Osu.Objects { public class SliderRepeat : SliderEndCircle { + public double PathProgress { get; set; } + public SliderRepeat(Slider slider) : base(slider) { diff --git a/osu.Game.Rulesets.Osu/Objects/SliderTick.cs b/osu.Game.Rulesets.Osu/Objects/SliderTick.cs index 74ec4d6eb3..219c2be00b 100644 --- a/osu.Game.Rulesets.Osu/Objects/SliderTick.cs +++ b/osu.Game.Rulesets.Osu/Objects/SliderTick.cs @@ -13,6 +13,7 @@ namespace osu.Game.Rulesets.Osu.Objects { public int SpanIndex { get; set; } public double SpanStartTime { get; set; } + public double PathProgress { get; set; } protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty) { diff --git a/osu.Game.Rulesets.Osu/OsuInputManager.cs b/osu.Game.Rulesets.Osu/OsuInputManager.cs index ceac1989a6..65bd585e98 100644 --- a/osu.Game.Rulesets.Osu/OsuInputManager.cs +++ b/osu.Game.Rulesets.Osu/OsuInputManager.cs @@ -47,7 +47,7 @@ namespace osu.Game.Rulesets.Osu // // Based on user feedback of more nuanced scenarios (where touch doesn't behave as expected), // this can be expanded to a more complex implementation, but I'd still want to keep it as simple as we can. - NonPositionalInputQueue.OfType().Any(c => c.ReceivePositionalInputAt(screenSpacePosition)); + NonPositionalInputQueue.OfType().Any(c => c.CanBeHit() && c.ReceivePositionalInputAt(screenSpacePosition)); public OsuInputManager(RulesetInfo ruleset) : base(ruleset, 0, SimultaneousBindingMode.Unique) diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index 6752712be1..25b1dd9b12 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -4,8 +4,8 @@ using System; using System.Collections.Generic; using System.Linq; -using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; using osu.Framework.Localisation; @@ -40,6 +40,7 @@ using osu.Game.Scoring; using osu.Game.Screens.Edit.Setup; using osu.Game.Screens.Ranking.Statistics; using osu.Game.Skinning; +using osuTK; namespace osu.Game.Rulesets.Osu { @@ -70,55 +71,55 @@ namespace osu.Game.Rulesets.Osu public override IEnumerable ConvertFromLegacyMods(LegacyMods mods) { - if (mods.HasFlagFast(LegacyMods.Nightcore)) + if (mods.HasFlag(LegacyMods.Nightcore)) yield return new OsuModNightcore(); - else if (mods.HasFlagFast(LegacyMods.DoubleTime)) + else if (mods.HasFlag(LegacyMods.DoubleTime)) yield return new OsuModDoubleTime(); - if (mods.HasFlagFast(LegacyMods.Perfect)) + if (mods.HasFlag(LegacyMods.Perfect)) yield return new OsuModPerfect(); - else if (mods.HasFlagFast(LegacyMods.SuddenDeath)) + else if (mods.HasFlag(LegacyMods.SuddenDeath)) yield return new OsuModSuddenDeath(); - if (mods.HasFlagFast(LegacyMods.Autopilot)) + if (mods.HasFlag(LegacyMods.Autopilot)) yield return new OsuModAutopilot(); - if (mods.HasFlagFast(LegacyMods.Cinema)) + if (mods.HasFlag(LegacyMods.Cinema)) yield return new OsuModCinema(); - else if (mods.HasFlagFast(LegacyMods.Autoplay)) + else if (mods.HasFlag(LegacyMods.Autoplay)) yield return new OsuModAutoplay(); - if (mods.HasFlagFast(LegacyMods.Easy)) + if (mods.HasFlag(LegacyMods.Easy)) yield return new OsuModEasy(); - if (mods.HasFlagFast(LegacyMods.Flashlight)) + if (mods.HasFlag(LegacyMods.Flashlight)) yield return new OsuModFlashlight(); - if (mods.HasFlagFast(LegacyMods.HalfTime)) + if (mods.HasFlag(LegacyMods.HalfTime)) yield return new OsuModHalfTime(); - if (mods.HasFlagFast(LegacyMods.HardRock)) + if (mods.HasFlag(LegacyMods.HardRock)) yield return new OsuModHardRock(); - if (mods.HasFlagFast(LegacyMods.Hidden)) + if (mods.HasFlag(LegacyMods.Hidden)) yield return new OsuModHidden(); - if (mods.HasFlagFast(LegacyMods.NoFail)) + if (mods.HasFlag(LegacyMods.NoFail)) yield return new OsuModNoFail(); - if (mods.HasFlagFast(LegacyMods.Relax)) + if (mods.HasFlag(LegacyMods.Relax)) yield return new OsuModRelax(); - if (mods.HasFlagFast(LegacyMods.SpunOut)) + if (mods.HasFlag(LegacyMods.SpunOut)) yield return new OsuModSpunOut(); - if (mods.HasFlagFast(LegacyMods.Target)) + if (mods.HasFlag(LegacyMods.Target)) yield return new OsuModTargetPractice(); - if (mods.HasFlagFast(LegacyMods.TouchDevice)) + if (mods.HasFlag(LegacyMods.TouchDevice)) yield return new OsuModTouchDevice(); - if (mods.HasFlagFast(LegacyMods.ScoreV2)) + if (mods.HasFlag(LegacyMods.ScoreV2)) yield return new ModScoreV2(); } @@ -213,7 +214,8 @@ namespace osu.Game.Rulesets.Osu new OsuModFreezeFrame(), new OsuModBubbles(), new OsuModSynesthesia(), - new OsuModDepth() + new OsuModDepth(), + new OsuModBloom() }; case ModType.System: @@ -337,7 +339,29 @@ namespace osu.Game.Rulesets.Osu }; } - public override RulesetSetupSection CreateEditorSetupSection() => new OsuSetupSection(); + public override IEnumerable CreateEditorSetupSections() => + [ + new MetadataSection(), + new OsuDifficultySection(), + new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(SetupScreen.SPACING), + Children = new Drawable[] + { + new ResourcesSection + { + RelativeSizeAxes = Axes.X, + }, + new ColoursSection + { + RelativeSizeAxes = Axes.X, + } + } + }, + new DesignSection(), + ]; /// /// @@ -356,5 +380,7 @@ namespace osu.Game.Rulesets.Osu return adjustedDifficulty; } + + public override bool EditorShowScrollSpeed => false; } } diff --git a/osu.Game.Rulesets.Osu/OsuSkinComponentLookup.cs b/osu.Game.Rulesets.Osu/OsuSkinComponentLookup.cs index 3b3653e1ba..86a68c799f 100644 --- a/osu.Game.Rulesets.Osu/OsuSkinComponentLookup.cs +++ b/osu.Game.Rulesets.Osu/OsuSkinComponentLookup.cs @@ -5,7 +5,7 @@ using osu.Game.Skinning; namespace osu.Game.Rulesets.Osu { - public class OsuSkinComponentLookup : GameplaySkinComponentLookup + public class OsuSkinComponentLookup : SkinComponentLookup { public OsuSkinComponentLookup(OsuSkinComponents component) : base(component) diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/OsuArgonSkinTransformer.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/OsuArgonSkinTransformer.cs index ec63e1194d..9f6f65c206 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Argon/OsuArgonSkinTransformer.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/OsuArgonSkinTransformer.cs @@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon { switch (lookup) { - case GameplaySkinComponentLookup resultComponent: + case SkinComponentLookup resultComponent: HitResult result = resultComponent.Component; // This should eventually be moved to a skin setting, when supported. diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/ManualSliderBody.cs b/osu.Game.Rulesets.Osu/Skinning/Default/ManualSliderBody.cs index d171f56f40..127d13730a 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/ManualSliderBody.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/ManualSliderBody.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using osu.Framework.Graphics; using osuTK; namespace osu.Game.Rulesets.Osu.Skinning.Default @@ -11,10 +12,11 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default /// public partial class ManualSliderBody : SliderBody { - public new void SetVertices(IReadOnlyList vertices) + public ManualSliderBody() { - base.SetVertices(vertices); - Size = Path.Size; + AutoSizeAxes = Axes.Both; } + + public new void SetVertices(IReadOnlyList vertices) => base.SetVertices(vertices); } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/OsuTrianglesSkinTransformer.cs b/osu.Game.Rulesets.Osu/Skinning/Default/OsuTrianglesSkinTransformer.cs index 7a4c768aa2..ef8cb12286 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/OsuTrianglesSkinTransformer.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/OsuTrianglesSkinTransformer.cs @@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default { switch (lookup) { - case GameplaySkinComponentLookup resultComponent: + case SkinComponentLookup resultComponent: HitResult result = resultComponent.Component; switch (result) diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs index d2ebc68c52..636a9ecb21 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Rulesets.Osu.Objects; @@ -41,139 +42,187 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup) { - if (lookup is OsuSkinComponentLookup osuComponent) + switch (lookup) { - switch (osuComponent.Component) - { - case OsuSkinComponents.FollowPoint: - return this.GetAnimation("followpoint", true, true, true, startAtCurrentTime: false, maxSize: new Vector2(OsuHitObject.OBJECT_RADIUS * 2, OsuHitObject.OBJECT_RADIUS)); - - case OsuSkinComponents.SliderScorePoint: - return this.GetAnimation("sliderscorepoint", false, false, maxSize: OsuHitObject.OBJECT_DIMENSIONS); - - case OsuSkinComponents.SliderFollowCircle: - var followCircleContent = this.GetAnimation("sliderfollowcircle", true, true, true, maxSize: MAX_FOLLOW_CIRCLE_AREA_SIZE); - if (followCircleContent != null) - return new LegacyFollowCircle(followCircleContent); + case GlobalSkinnableContainerLookup containerLookup: + // Only handle per ruleset defaults here. + if (containerLookup.Ruleset == null) + return base.GetDrawableComponent(lookup); + // we don't have enough assets to display these components (this is especially the case on a "beatmap" skin). + if (!IsProvidingLegacyResources) return null; - case OsuSkinComponents.SliderBall: - if (GetTexture("sliderb") != null || GetTexture("sliderb0") != null) - return new LegacySliderBall(this); + // Our own ruleset components default. + switch (containerLookup.Lookup) + { + case GlobalSkinnableContainers.MainHUDComponents: + return new DefaultSkinComponentsContainer(container => + { + var keyCounter = container.OfType().FirstOrDefault(); - return null; + if (keyCounter != null) + { + // set the anchor to top right so that it won't squash to the return button to the top + keyCounter.Anchor = Anchor.CentreRight; + keyCounter.Origin = Anchor.TopRight; + keyCounter.Position = new Vector2(0, -40) * 1.6f; + } - case OsuSkinComponents.SliderBody: - if (hasHitCircle.Value) - return new LegacySliderBody(); + var combo = container.OfType().FirstOrDefault(); - return null; + if (combo != null) + { + combo.Anchor = Anchor.BottomLeft; + combo.Origin = Anchor.BottomLeft; + combo.Scale = new Vector2(1.28f); + } + }) + { + Children = new Drawable[] + { + new LegacyDefaultComboCounter(), + new LegacyKeyCounterDisplay(), + } + }; + } - case OsuSkinComponents.SliderTailHitCircle: - if (hasHitCircle.Value) - return new LegacyMainCirclePiece("sliderendcircle", false); + return null; - return null; + case OsuSkinComponentLookup osuComponent: + switch (osuComponent.Component) + { + case OsuSkinComponents.FollowPoint: + return this.GetAnimation("followpoint", true, true, true, startAtCurrentTime: false, + maxSize: new Vector2(OsuHitObject.OBJECT_RADIUS * 2, OsuHitObject.OBJECT_RADIUS)); - case OsuSkinComponents.SliderHeadHitCircle: - if (hasHitCircle.Value) - return new LegacySliderHeadHitCircle(); + case OsuSkinComponents.SliderScorePoint: + return this.GetAnimation("sliderscorepoint", false, false, maxSize: OsuHitObject.OBJECT_DIMENSIONS); - return null; + case OsuSkinComponents.SliderFollowCircle: + var followCircleContent = this.GetAnimation("sliderfollowcircle", true, true, true, maxSize: MAX_FOLLOW_CIRCLE_AREA_SIZE); + if (followCircleContent != null) + return new LegacyFollowCircle(followCircleContent); - case OsuSkinComponents.ReverseArrow: - if (hasHitCircle.Value) - return new LegacyReverseArrow(); - - return null; - - case OsuSkinComponents.HitCircle: - if (hasHitCircle.Value) - return new LegacyMainCirclePiece(); - - return null; - - case OsuSkinComponents.Cursor: - if (GetTexture("cursor") != null) - return new LegacyCursor(this); - - return null; - - case OsuSkinComponents.CursorTrail: - if (GetTexture("cursortrail") != null) - return new LegacyCursorTrail(this); - - return null; - - case OsuSkinComponents.CursorRipple: - if (GetTexture("cursor-ripple") != null) - { - var ripple = this.GetAnimation("cursor-ripple", false, false); - - // In stable this element was scaled down to 50% and opacity 20%, but this makes the elements WAY too big and inflexible. - // If anyone complains about these not being applied, this can be uncommented. - // - // But if no one complains I'd rather fix this in lazer. Wiki documentation doesn't mention size, - // so we might be okay. - // - // if (ripple != null) - // { - // ripple.Scale = new Vector2(0.5f); - // ripple.Alpha = 0.2f; - // } - - return ripple; - } - - return null; - - case OsuSkinComponents.CursorParticles: - if (GetTexture("star2") != null) - return new LegacyCursorParticles(); - - return null; - - case OsuSkinComponents.CursorSmoke: - if (GetTexture("cursor-smoke") != null) - return new LegacySmokeSegment(); - - return null; - - case OsuSkinComponents.HitCircleText: - if (!this.HasFont(LegacyFont.HitCircle)) return null; - const float hitcircle_text_scale = 0.8f; - return new LegacySpriteText(LegacyFont.HitCircle) - { - // stable applies a blanket 0.8x scale to hitcircle fonts - Scale = new Vector2(hitcircle_text_scale), - MaxSizePerGlyph = OsuHitObject.OBJECT_DIMENSIONS * 2 / hitcircle_text_scale, - }; + case OsuSkinComponents.SliderBall: + if (GetTexture("sliderb") != null || GetTexture("sliderb0") != null) + return new LegacySliderBall(this); - case OsuSkinComponents.SpinnerBody: - bool hasBackground = GetTexture("spinner-background") != null; + return null; - if (GetTexture("spinner-top") != null && !hasBackground) - return new LegacyNewStyleSpinner(); - else if (hasBackground) - return new LegacyOldStyleSpinner(); + case OsuSkinComponents.SliderBody: + if (hasHitCircle.Value) + return new LegacySliderBody(); - return null; + return null; - case OsuSkinComponents.ApproachCircle: - if (GetTexture(@"approachcircle") != null) - return new LegacyApproachCircle(); + case OsuSkinComponents.SliderTailHitCircle: + if (hasHitCircle.Value) + return new LegacyMainCirclePiece("sliderendcircle", false); - return null; + return null; - default: - throw new UnsupportedSkinComponentException(lookup); - } + case OsuSkinComponents.SliderHeadHitCircle: + if (hasHitCircle.Value) + return new LegacySliderHeadHitCircle(); + + return null; + + case OsuSkinComponents.ReverseArrow: + if (hasHitCircle.Value) + return new LegacyReverseArrow(); + + return null; + + case OsuSkinComponents.HitCircle: + if (hasHitCircle.Value) + return new LegacyMainCirclePiece(); + + return null; + + case OsuSkinComponents.Cursor: + if (GetTexture("cursor") != null) + return new LegacyCursor(this); + + return null; + + case OsuSkinComponents.CursorTrail: + if (GetTexture("cursortrail") != null) + return new LegacyCursorTrail(this); + + return null; + + case OsuSkinComponents.CursorRipple: + if (GetTexture("cursor-ripple") != null) + { + var ripple = this.GetAnimation("cursor-ripple", false, false); + + // In stable this element was scaled down to 50% and opacity 20%, but this makes the elements WAY too big and inflexible. + // If anyone complains about these not being applied, this can be uncommented. + // + // But if no one complains I'd rather fix this in lazer. Wiki documentation doesn't mention size, + // so we might be okay. + // + // if (ripple != null) + // { + // ripple.Scale = new Vector2(0.5f); + // ripple.Alpha = 0.2f; + // } + + return ripple; + } + + return null; + + case OsuSkinComponents.CursorParticles: + if (GetTexture("star2") != null) + return new LegacyCursorParticles(); + + return null; + + case OsuSkinComponents.CursorSmoke: + if (GetTexture("cursor-smoke") != null) + return new LegacySmokeSegment(); + + return null; + + case OsuSkinComponents.HitCircleText: + if (!this.HasFont(LegacyFont.HitCircle)) + return null; + + const float hitcircle_text_scale = 0.8f; + return new LegacySpriteText(LegacyFont.HitCircle) + { + // stable applies a blanket 0.8x scale to hitcircle fonts + Scale = new Vector2(hitcircle_text_scale), + MaxSizePerGlyph = OsuHitObject.OBJECT_DIMENSIONS * 2 / hitcircle_text_scale, + }; + + case OsuSkinComponents.SpinnerBody: + bool hasBackground = GetTexture("spinner-background") != null; + + if (GetTexture("spinner-top") != null && !hasBackground) + return new LegacyNewStyleSpinner(); + else if (hasBackground) + return new LegacyOldStyleSpinner(); + + return null; + + case OsuSkinComponents.ApproachCircle: + if (GetTexture(@"approachcircle") != null) + return new LegacyApproachCircle(); + + return null; + + default: + throw new UnsupportedSkinComponentException(lookup); + } + + default: + return base.GetDrawableComponent(lookup); } - - return base.GetDrawableComponent(lookup); } public override IBindable? GetConfig(TLookup lookup) diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs b/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs index 95a052dadb..5132dc2859 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs @@ -7,7 +7,6 @@ using System; using System.Diagnostics; using System.Runtime.InteropServices; using osu.Framework.Allocation; -using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Rendering; @@ -15,9 +14,9 @@ using osu.Framework.Graphics.Rendering.Vertices; using osu.Framework.Graphics.Shaders; using osu.Framework.Graphics.Shaders.Types; using osu.Framework.Graphics.Textures; +using osu.Framework.Graphics.Visualisation; using osu.Framework.Input; using osu.Framework.Input.Events; -using osu.Framework.Layout; using osu.Framework.Timing; using osuTK; using osuTK.Graphics; @@ -25,6 +24,7 @@ using osuTK.Graphics.ES30; namespace osu.Game.Rulesets.Osu.UI.Cursor { + [DrawVisualiserHidden] public partial class CursorTrail : Drawable, IRequireHighFrequencyMousePosition { private const int max_sprites = 2048; @@ -40,6 +40,11 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor private double timeOffset; private float time; + /// + /// The scale used on creation of a new trail part. + /// + public Vector2 NewPartScale = Vector2.One; + private Anchor trailOrigin = Anchor.Centre; protected Anchor TrailOrigin @@ -64,8 +69,6 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor // -1 signals that the part is unusable, and should not be drawn parts[i].InvalidationID = -1; } - - AddLayout(partSizeCache); } [BackgroundDependencyLoader] @@ -96,12 +99,6 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor } } - private readonly LayoutValue partSizeCache = new LayoutValue(Invalidation.DrawInfo | Invalidation.RequiredParentSizeToFit | Invalidation.Presence); - - private Vector2 partSize => partSizeCache.IsValid - ? partSizeCache.Value - : (partSizeCache.Value = new Vector2(Texture.DisplayWidth, Texture.DisplayHeight) * DrawInfo.Matrix.ExtractScale().Xy); - /// /// The amount of time to fade the cursor trail pieces. /// @@ -157,6 +154,8 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor protected void AddTrail(Vector2 position) { + position = ToLocalSpace(position); + if (InterpolateMovements) { if (!lastPosition.HasValue) @@ -175,7 +174,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor float distance = diff.Length; Vector2 direction = diff / distance; - float interval = partSize.X / 2.5f * IntervalMultiplier; + float interval = Texture.DisplayWidth / 2.5f * IntervalMultiplier; float stopAt = distance - (AvoidDrawingNearCursor ? interval : 0); for (float d = interval; d < stopAt; d += interval) @@ -192,10 +191,11 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor } } - private void addPart(Vector2 screenSpacePosition) + private void addPart(Vector2 localSpacePosition) { - parts[currentIndex].Position = screenSpacePosition; + parts[currentIndex].Position = localSpacePosition; parts[currentIndex].Time = time + 1; + parts[currentIndex].Scale = NewPartScale; ++parts[currentIndex].InvalidationID; currentIndex = (currentIndex + 1) % max_sprites; @@ -207,6 +207,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor { public Vector2 Position; public float Time; + public Vector2 Scale; public long InvalidationID; } @@ -221,7 +222,6 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor private float fadeExponent; private readonly TrailPart[] parts = new TrailPart[max_sprites]; - private Vector2 size; private Vector2 originPosition; private IVertexBatch vertexBatch; @@ -237,20 +237,19 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor shader = Source.shader; texture = Source.texture; - size = Source.partSize; time = Source.time; fadeExponent = Source.FadeExponent; originPosition = Vector2.Zero; - if (Source.TrailOrigin.HasFlagFast(Anchor.x1)) + if (Source.TrailOrigin.HasFlag(Anchor.x1)) originPosition.X = 0.5f; - else if (Source.TrailOrigin.HasFlagFast(Anchor.x2)) + else if (Source.TrailOrigin.HasFlag(Anchor.x2)) originPosition.X = 1f; - if (Source.TrailOrigin.HasFlagFast(Anchor.y1)) + if (Source.TrailOrigin.HasFlag(Anchor.y1)) originPosition.Y = 0.5f; - else if (Source.TrailOrigin.HasFlagFast(Anchor.y2)) + else if (Source.TrailOrigin.HasFlag(Anchor.y2)) originPosition.Y = 1f; Source.parts.CopyTo(parts, 0); @@ -278,6 +277,8 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor RectangleF textureRect = texture.GetTextureRect(); + renderer.PushLocalMatrix(DrawInfo.Matrix); + foreach (var part in parts) { if (part.InvalidationID == -1) @@ -288,7 +289,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor vertexBatch.Add(new TexturedTrailVertex { - Position = new Vector2(part.Position.X - size.X * originPosition.X, part.Position.Y + size.Y * (1 - originPosition.Y)), + Position = new Vector2(part.Position.X - texture.DisplayWidth * originPosition.X * part.Scale.X, part.Position.Y + texture.DisplayHeight * (1 - originPosition.Y) * part.Scale.Y), TexturePosition = textureRect.BottomLeft, TextureRect = new Vector4(0, 0, 1, 1), Colour = DrawColourInfo.Colour.BottomLeft.Linear, @@ -297,7 +298,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor vertexBatch.Add(new TexturedTrailVertex { - Position = new Vector2(part.Position.X + size.X * (1 - originPosition.X), part.Position.Y + size.Y * (1 - originPosition.Y)), + Position = new Vector2(part.Position.X + texture.DisplayWidth * (1 - originPosition.X) * part.Scale.X, part.Position.Y + texture.DisplayHeight * (1 - originPosition.Y) * part.Scale.Y), TexturePosition = textureRect.BottomRight, TextureRect = new Vector4(0, 0, 1, 1), Colour = DrawColourInfo.Colour.BottomRight.Linear, @@ -306,7 +307,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor vertexBatch.Add(new TexturedTrailVertex { - Position = new Vector2(part.Position.X + size.X * (1 - originPosition.X), part.Position.Y - size.Y * originPosition.Y), + Position = new Vector2(part.Position.X + texture.DisplayWidth * (1 - originPosition.X) * part.Scale.X, part.Position.Y - texture.DisplayHeight * originPosition.Y * part.Scale.Y), TexturePosition = textureRect.TopRight, TextureRect = new Vector4(0, 0, 1, 1), Colour = DrawColourInfo.Colour.TopRight.Linear, @@ -315,7 +316,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor vertexBatch.Add(new TexturedTrailVertex { - Position = new Vector2(part.Position.X - size.X * originPosition.X, part.Position.Y - size.Y * originPosition.Y), + Position = new Vector2(part.Position.X - texture.DisplayWidth * originPosition.X * part.Scale.X, part.Position.Y - texture.DisplayHeight * originPosition.Y * part.Scale.Y), TexturePosition = textureRect.TopLeft, TextureRect = new Vector4(0, 0, 1, 1), Colour = DrawColourInfo.Colour.TopLeft.Linear, @@ -323,6 +324,8 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor }); } + renderer.PopLocalMatrix(); + vertexBatch.Draw(); shader.Unbind(); } diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs index d8f50c1f5d..c2f7d84f5e 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs @@ -31,8 +31,18 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor private SkinnableCursor skinnableCursor => (SkinnableCursor)cursorSprite.Drawable; + /// + /// The current expanded scale of the cursor. + /// + public Vector2 CurrentExpandedScale => skinnableCursor.ExpandTarget?.Scale ?? Vector2.One; + public IBindable CursorScale => cursorScale; + /// + /// Mods which want to adjust cursor size should do so via this bindable. + /// + public readonly Bindable ModScaleAdjust = new Bindable(1); + private readonly Bindable cursorScale = new BindableFloat(1); private Bindable userCursorScale = null!; @@ -62,6 +72,8 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor autoCursorScale = config.GetBindable(OsuSetting.AutoCursorSize); autoCursorScale.ValueChanged += _ => cursorScale.Value = CalculateCursorScale(); + ModScaleAdjust.ValueChanged += _ => cursorScale.Value = CalculateCursorScale(); + cursorScale.BindValueChanged(e => cursorScaleContainer.Scale = new Vector2(e.NewValue), true); } @@ -85,7 +97,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor protected virtual float CalculateCursorScale() { - float scale = userCursorScale.Value; + float scale = userCursorScale.Value * ModScaleAdjust.Value; if (autoCursorScale.Value && state != null) { diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs index ba8a634ff7..8c0871d54f 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs @@ -23,14 +23,13 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor public new OsuCursor ActiveCursor => (OsuCursor)base.ActiveCursor; protected override Drawable CreateCursor() => new OsuCursor(); - protected override Container Content => fadeContainer; private readonly Container fadeContainer; private readonly Bindable showTrail = new Bindable(true); - private readonly Drawable cursorTrail; + private readonly SkinnableDrawable cursorTrail; private readonly CursorRippleVisualiser rippleVisualiser; @@ -39,7 +38,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor InternalChild = fadeContainer = new Container { RelativeSizeAxes = Axes.Both, - Children = new[] + Children = new CompositeDrawable[] { cursorTrail = new SkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.CursorTrail), _ => new DefaultCursorTrail(), confineMode: ConfineMode.NoScaling), rippleVisualiser = new CursorRippleVisualiser(), @@ -79,6 +78,14 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor ActiveCursor.Contract(); } + protected override void Update() + { + base.Update(); + + if (cursorTrail.Drawable is CursorTrail trail) + trail.NewPartScale = ActiveCursor.CurrentExpandedScale; + } + public bool OnPressed(KeyBindingPressEvent e) { switch (e.Action) diff --git a/osu.Game.Rulesets.Osu/UI/DrawableOsuRuleset.cs b/osu.Game.Rulesets.Osu/UI/DrawableOsuRuleset.cs index c3efd48053..ab69b67051 100644 --- a/osu.Game.Rulesets.Osu/UI/DrawableOsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/UI/DrawableOsuRuleset.cs @@ -1,11 +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.Collections.Generic; using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; using osu.Framework.Input; using osu.Game.Beatmaps; using osu.Game.Input.Handlers; @@ -13,6 +14,7 @@ using osu.Game.Replays; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Configuration; +using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Replays; using osu.Game.Rulesets.UI; @@ -24,18 +26,38 @@ namespace osu.Game.Rulesets.Osu.UI { public partial class DrawableOsuRuleset : DrawableRuleset { - protected new OsuRulesetConfigManager Config => (OsuRulesetConfigManager)base.Config; + private Bindable? cursorHideEnabled; public new OsuInputManager KeyBindingInputManager => (OsuInputManager)base.KeyBindingInputManager; public new OsuPlayfield Playfield => (OsuPlayfield)base.Playfield; - public DrawableOsuRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) + protected new OsuRulesetConfigManager Config => (OsuRulesetConfigManager)base.Config; + + public DrawableOsuRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList? mods = null) : base(ruleset, beatmap, mods) { } - public override DrawableHitObject CreateDrawableRepresentation(OsuHitObject h) => null; + [BackgroundDependencyLoader] + private void load(ReplayPlayer? replayPlayer) + { + if (replayPlayer != null) + { + ReplayAnalysisOverlay analysisOverlay; + PlayfieldAdjustmentContainer.Add(analysisOverlay = new ReplayAnalysisOverlay(replayPlayer.Score.Replay)); + Overlays.Add(analysisOverlay.CreateProxy().With(p => p.Depth = float.NegativeInfinity)); + replayPlayer.AddSettings(new ReplayAnalysisSettings(Config)); + + cursorHideEnabled = Config.GetBindable(OsuRulesetSetting.ReplayCursorHideEnabled); + + // I have little faith in this working (other things touch cursor visibility) but haven't broken it yet. + // Let's wait for someone to report an issue before spending too much time on it. + cursorHideEnabled.BindValueChanged(enabled => Playfield.Cursor.FadeTo(enabled.NewValue ? 0 : 1), true); + } + } + + public override DrawableHitObject? CreateDrawableRepresentation(OsuHitObject h) => null; public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; // always show the gameplay cursor @@ -45,7 +67,13 @@ namespace osu.Game.Rulesets.Osu.UI public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new OsuPlayfieldAdjustmentContainer { AlignWithStoryboard = true }; - protected override ResumeOverlay CreateResumeOverlay() => new OsuResumeOverlay(); + protected override ResumeOverlay CreateResumeOverlay() + { + if (Mods.Any(m => m is OsuModAutopilot)) + return new DelayedResumeOverlay { Scale = new Vector2(0.65f) }; + + return new OsuResumeOverlay(); + } protected override ReplayInputHandler CreateReplayInputHandler(Replay replay) => new OsuFramedReplayInputHandler(replay); diff --git a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs index 4933eb4041..7d9f5eb1a8 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs @@ -13,6 +13,7 @@ using osu.Game.Beatmaps; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Objects.Legacy; using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Configuration; using osu.Game.Rulesets.Osu.Objects; @@ -28,6 +29,7 @@ namespace osu.Game.Rulesets.Osu.UI [Cached] public partial class OsuPlayfield : Playfield { + private readonly Container borderContainer; private readonly PlayfieldBorder playfieldBorder; private readonly ProxyContainer approachCircles; private readonly ProxyContainer spinnerProxies; @@ -37,7 +39,7 @@ namespace osu.Game.Rulesets.Osu.UI // For osu! gameplay, everything is always on screen. // Skipping masking calculations improves performance in intense beatmaps (ie. https://osu.ppy.sh/beatmapsets/150945#osu/372245) - public override bool UpdateSubTreeMasking(Drawable source, RectangleF maskingBounds) => false; + public override bool UpdateSubTreeMasking() => false; public SmokeContainer Smoke { get; } public FollowPointRenderer FollowPoints { get; } @@ -46,6 +48,8 @@ namespace osu.Game.Rulesets.Osu.UI protected override GameplayCursorContainer? CreateCursor() => new OsuCursorContainer(); + public override Quad SkinnableComponentScreenSpaceDrawQuad => playfieldBorder.ScreenSpaceDrawQuad; + private readonly Container judgementAboveHitObjectLayer; public OsuPlayfield() @@ -55,7 +59,11 @@ namespace osu.Game.Rulesets.Osu.UI InternalChildren = new Drawable[] { - playfieldBorder = new PlayfieldBorder { RelativeSizeAxes = Axes.Both }, + borderContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Child = playfieldBorder = new PlayfieldBorder { RelativeSizeAxes = Axes.Both }, + }, Smoke = new SmokeContainer { RelativeSizeAxes = Axes.Both }, spinnerProxies = new ProxyContainer { RelativeSizeAxes = Axes.Both }, FollowPoints = new FollowPointRenderer { RelativeSizeAxes = Axes.Both }, @@ -152,6 +160,14 @@ namespace osu.Game.Rulesets.Osu.UI RegisterPool(2, 20); RegisterPool(10, 200); RegisterPool(10, 200); + + if (beatmap != null) + ApplyCircleSizeToPlayfieldBorder(beatmap); + } + + protected void ApplyCircleSizeToPlayfieldBorder(IBeatmap beatmap) + { + borderContainer.Padding = new MarginPadding(OsuHitObject.OBJECT_RADIUS * -LegacyRulesetExtensions.CalculateScaleFromCircleSize(beatmap.Difficulty.CircleSize, true)); } protected override HitObjectLifetimeEntry CreateLifetimeEntry(HitObject hitObject) => new OsuHitObjectLifetimeEntry(hitObject); @@ -190,6 +206,15 @@ namespace osu.Game.Rulesets.Osu.UI public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => HitObjectContainer.ReceivePositionalInputAt(screenSpacePos); + private OsuResumeOverlay.OsuResumeOverlayInputBlocker? resumeInputBlocker; + + public void AttachResumeOverlayInputBlocker(OsuResumeOverlay.OsuResumeOverlayInputBlocker resumeInputBlocker) + { + Debug.Assert(this.resumeInputBlocker == null); + this.resumeInputBlocker = resumeInputBlocker; + AddInternal(resumeInputBlocker); + } + private partial class ProxyContainer : LifetimeManagementContainer { public void Add(Drawable proxy) => AddInternal(proxy); diff --git a/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs b/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs index a04ea80640..b045b82960 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; @@ -33,9 +34,33 @@ namespace osu.Game.Rulesets.Osu.UI [BackgroundDependencyLoader] private void load() { + OsuResumeOverlayInputBlocker? inputBlocker = null; + + var drawableOsuRuleset = (DrawableOsuRuleset?)drawableRuleset; + + if (drawableOsuRuleset != null) + { + var osuPlayfield = drawableOsuRuleset.Playfield; + osuPlayfield.AttachResumeOverlayInputBlocker(inputBlocker = new OsuResumeOverlayInputBlocker()); + } + Add(cursorScaleContainer = new Container { - Child = clickToResumeCursor = new OsuClickToResumeCursor { ResumeRequested = Resume } + Child = clickToResumeCursor = new OsuClickToResumeCursor + { + ResumeRequested = action => + { + // since the user had to press a button to tap the resume cursor, + // block that press event from potentially reaching a hit circle that's behind the cursor. + // we cannot do this from OsuClickToResumeCursor directly since we're in a different input manager tree than the gameplay one, + // so we rely on a dedicated input blocking component that's implanted in there to do that for us. + // note this only matters when the user didn't pause while they were holding the same key that they are resuming with. + if (inputBlocker != null && !drawableOsuRuleset.AsNonNull().KeyBindingInputManager.PressedActions.Contains(action)) + inputBlocker.BlockNextPress = true; + + Resume(); + } + } }); } @@ -73,7 +98,7 @@ namespace osu.Game.Rulesets.Osu.UI { public override bool HandlePositionalInput => true; - public Action? ResumeRequested; + public Action? ResumeRequested; private Container scaleTransitionContainer = null!; public OsuClickToResumeCursor() @@ -115,8 +140,7 @@ namespace osu.Game.Rulesets.Osu.UI return false; scaleTransitionContainer.ScaleTo(2, TRANSITION_TIME, Easing.OutQuint); - - ResumeRequested?.Invoke(); + ResumeRequested?.Invoke(e.Action); return true; } @@ -141,5 +165,27 @@ namespace osu.Game.Rulesets.Osu.UI this.FadeColour(IsHovered ? Color4.White : Color4.Orange, 400, Easing.OutQuint); } } + + public partial class OsuResumeOverlayInputBlocker : Drawable, IKeyBindingHandler + { + public bool BlockNextPress; + + public OsuResumeOverlayInputBlocker() + { + RelativeSizeAxes = Axes.Both; + Depth = float.MinValue; + } + + public bool OnPressed(KeyBindingPressEvent e) + { + bool block = BlockNextPress; + BlockNextPress = false; + return block; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + } } } diff --git a/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/AnalysisFrameEntry.cs b/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/AnalysisFrameEntry.cs new file mode 100644 index 0000000000..116bccc747 --- /dev/null +++ b/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/AnalysisFrameEntry.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics.Performance; +using osuTK; + +namespace osu.Game.Rulesets.Osu.UI.ReplayAnalysis +{ + public partial class AnalysisFrameEntry : LifetimeEntry + { + public OsuAction[] Action { get; } + + public Vector2 Position { get; } + + public AnalysisFrameEntry(double time, double displayLength, Vector2 position, params OsuAction[] action) + { + LifetimeStart = time; + LifetimeEnd = time + displayLength; + Position = position; + Action = action; + } + } +} diff --git a/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/AnalysisMarker.cs b/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/AnalysisMarker.cs new file mode 100644 index 0000000000..187876d691 --- /dev/null +++ b/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/AnalysisMarker.cs @@ -0,0 +1,28 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Graphics; +using osu.Game.Rulesets.Objects.Pooling; + +namespace osu.Game.Rulesets.Osu.UI.ReplayAnalysis +{ + public abstract partial class AnalysisMarker : PoolableDrawableWithLifetime + { + [Resolved] + protected OsuColour Colours { get; private set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + Origin = Anchor.Centre; + } + + protected override void OnApply(AnalysisFrameEntry entry) + { + Position = entry.Position; + Depth = -(float)entry.LifetimeEnd; + } + } +} diff --git a/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/ClickMarker.cs b/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/ClickMarker.cs new file mode 100644 index 0000000000..9788ea1aa9 --- /dev/null +++ b/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/ClickMarker.cs @@ -0,0 +1,88 @@ +// 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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.UI.ReplayAnalysis +{ + /// + /// A marker which shows one click, with visuals focusing on the button which was clicked and the precise location of the click. + /// + public partial class ClickMarker : AnalysisMarker + { + private CircularProgress leftClickDisplay = null!; + private CircularProgress rightClickDisplay = null!; + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + new Circle + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(0.125f), + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + Colour = Colours.Gray5, + }, + new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Colour = Colours.Gray5, + Masking = true, + BorderThickness = 2.2f, + BorderColour = Color4.White, + Child = new Box + { + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both, + AlwaysPresent = true, + Alpha = 0, + }, + }, + leftClickDisplay = new CircularProgress + { + Colour = Colours.Yellow, + Size = new Vector2(0.95f), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreRight, + Rotation = 180, + Progress = 0.5f, + InnerRadius = 0.18f, + RelativeSizeAxes = Axes.Both, + }, + rightClickDisplay = new CircularProgress + { + Colour = Colours.Yellow, + Size = new Vector2(0.95f), + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Progress = 0.5f, + InnerRadius = 0.18f, + RelativeSizeAxes = Axes.Both, + }, + }; + + Size = new Vector2(16); + } + + protected override void OnApply(AnalysisFrameEntry entry) + { + base.OnApply(entry); + + leftClickDisplay.Alpha = entry.Action.Contains(OsuAction.LeftButton) ? 1 : 0; + rightClickDisplay.Alpha = entry.Action.Contains(OsuAction.RightButton) ? 1 : 0; + } + } +} diff --git a/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/ClickMarkerContainer.cs b/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/ClickMarkerContainer.cs new file mode 100644 index 0000000000..ff94449521 --- /dev/null +++ b/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/ClickMarkerContainer.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics.Pooling; +using osu.Game.Rulesets.Objects.Pooling; + +namespace osu.Game.Rulesets.Osu.UI.ReplayAnalysis +{ + public partial class ClickMarkerContainer : PooledDrawableWithLifetimeContainer + { + private readonly DrawablePool clickMarkerPool; + + public ClickMarkerContainer() + { + AddInternal(clickMarkerPool = new DrawablePool(30)); + } + + protected override AnalysisMarker GetDrawable(AnalysisFrameEntry entry) => clickMarkerPool.Get(d => d.Apply(entry)); + } +} diff --git a/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/CursorPathContainer.cs b/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/CursorPathContainer.cs new file mode 100644 index 0000000000..1951d467e2 --- /dev/null +++ b/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/CursorPathContainer.cs @@ -0,0 +1,86 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics.Lines; +using osu.Framework.Graphics.Performance; +using osu.Game.Graphics; +using osuTK; + +namespace osu.Game.Rulesets.Osu.UI.ReplayAnalysis +{ + public partial class CursorPathContainer : Path + { + private readonly LifetimeEntryManager lifetimeManager = new LifetimeEntryManager(); + private readonly SortedSet aliveEntries = new SortedSet(new AimLinePointComparator()); + + public CursorPathContainer() + { + lifetimeManager.EntryBecameAlive += entryBecameAlive; + lifetimeManager.EntryBecameDead += entryBecameDead; + + PathRadius = 0.5f; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Colour = colours.Pink2; + BackgroundColour = colours.Pink2.Opacity(0); + } + + protected override void Update() + { + base.Update(); + + lifetimeManager.Update(Time.Current); + } + + public void Add(AnalysisFrameEntry entry) => lifetimeManager.AddEntry(entry); + + private void entryBecameAlive(LifetimeEntry entry) + { + aliveEntries.Add((AnalysisFrameEntry)entry); + updateVertices(); + } + + private void entryBecameDead(LifetimeEntry entry) + { + aliveEntries.Remove((AnalysisFrameEntry)entry); + updateVertices(); + } + + private void updateVertices() + { + ClearVertices(); + + Vector2 min = Vector2.Zero; + + foreach (var entry in aliveEntries) + { + AddVertex(entry.Position); + if (entry.Position.X < min.X) + min.X = entry.Position.X; + + if (entry.Position.Y < min.Y) + min.Y = entry.Position.Y; + } + + Position = min; + } + + private sealed class AimLinePointComparator : IComparer + { + public int Compare(AnalysisFrameEntry? x, AnalysisFrameEntry? y) + { + ArgumentNullException.ThrowIfNull(x); + ArgumentNullException.ThrowIfNull(y); + + return x.LifetimeStart.CompareTo(y.LifetimeStart); + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/FrameMarker.cs b/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/FrameMarker.cs new file mode 100644 index 0000000000..35ee144568 --- /dev/null +++ b/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/FrameMarker.cs @@ -0,0 +1,69 @@ +// 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.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osuTK; + +namespace osu.Game.Rulesets.Osu.UI.ReplayAnalysis +{ + /// + /// A marker which shows one movement frame, include any buttons which are pressed. + /// + public partial class FrameMarker : AnalysisMarker + { + private CircularProgress leftClickDisplay = null!; + private CircularProgress rightClickDisplay = null!; + private Circle mainCircle = null!; + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + mainCircle = new Circle + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Colour = Colours.Pink2, + }, + leftClickDisplay = new CircularProgress + { + Colour = Colours.Yellow, + Size = new Vector2(0.8f), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreRight, + Rotation = 180, + Progress = 0.5f, + InnerRadius = 0.5f, + RelativeSizeAxes = Axes.Both, + }, + rightClickDisplay = new CircularProgress + { + Colour = Colours.Yellow, + Size = new Vector2(0.8f), + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Progress = 0.5f, + InnerRadius = 0.5f, + RelativeSizeAxes = Axes.Both, + }, + }; + } + + protected override void OnApply(AnalysisFrameEntry entry) + { + base.OnApply(entry); + Size = new Vector2(entry.Action.Any() ? 4 : 2.5f); + + mainCircle.Colour = entry.Action.Any() ? Colours.Gray4 : Colours.Pink2; + + leftClickDisplay.Alpha = entry.Action.Contains(OsuAction.LeftButton) ? 1 : 0; + rightClickDisplay.Alpha = entry.Action.Contains(OsuAction.RightButton) ? 1 : 0; + } + } +} diff --git a/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/FrameMarkerContainer.cs b/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/FrameMarkerContainer.cs new file mode 100644 index 0000000000..63aea259f7 --- /dev/null +++ b/osu.Game.Rulesets.Osu/UI/ReplayAnalysis/FrameMarkerContainer.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics.Pooling; +using osu.Game.Rulesets.Objects.Pooling; + +namespace osu.Game.Rulesets.Osu.UI.ReplayAnalysis +{ + public partial class FrameMarkerContainer : PooledDrawableWithLifetimeContainer + { + private readonly DrawablePool pool; + + public FrameMarkerContainer() + { + AddInternal(pool = new DrawablePool(80)); + } + + protected override AnalysisMarker GetDrawable(AnalysisFrameEntry entry) => pool.Get(d => d.Apply(entry)); + } +} diff --git a/osu.Game.Rulesets.Osu/UI/ReplayAnalysisOverlay.cs b/osu.Game.Rulesets.Osu/UI/ReplayAnalysisOverlay.cs new file mode 100644 index 0000000000..2b7f6c9fc9 --- /dev/null +++ b/osu.Game.Rulesets.Osu/UI/ReplayAnalysisOverlay.cs @@ -0,0 +1,128 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Caching; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Replays; +using osu.Game.Rulesets.Osu.Configuration; +using osu.Game.Rulesets.Osu.Replays; +using osu.Game.Rulesets.Osu.UI.ReplayAnalysis; + +namespace osu.Game.Rulesets.Osu.UI +{ + public partial class ReplayAnalysisOverlay : CompositeDrawable + { + private BindableBool showClickMarkers { get; } = new BindableBool(); + private BindableBool showFrameMarkers { get; } = new BindableBool(); + private BindableBool showCursorPath { get; } = new BindableBool(); + private BindableInt displayLength { get; } = new BindableInt(); + + protected ClickMarkerContainer? ClickMarkers; + protected FrameMarkerContainer? FrameMarkers; + protected CursorPathContainer? CursorPath; + + private readonly Replay replay; + + public ReplayAnalysisOverlay(Replay replay) + { + RelativeSizeAxes = Axes.Both; + + this.replay = replay; + } + + private bool requireDisplay => showClickMarkers.Value || showFrameMarkers.Value || showCursorPath.Value; + + [BackgroundDependencyLoader] + private void load(OsuRulesetConfigManager config) + { + config.BindWith(OsuRulesetSetting.ReplayClickMarkersEnabled, showClickMarkers); + config.BindWith(OsuRulesetSetting.ReplayFrameMarkersEnabled, showFrameMarkers); + config.BindWith(OsuRulesetSetting.ReplayCursorPathEnabled, showCursorPath); + config.BindWith(OsuRulesetSetting.ReplayAnalysisDisplayLength, displayLength); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + displayLength.BindValueChanged(_ => + { + // Need to fully reload to make this work. + loaded.Invalidate(); + }, true); + } + + private readonly Cached loaded = new Cached(); + + private CancellationTokenSource? generationCancellationSource; + + protected override void Update() + { + base.Update(); + + if (requireDisplay) + initialise(); + + if (ClickMarkers != null) ClickMarkers.Alpha = showClickMarkers.Value ? 1 : 0; + if (FrameMarkers != null) FrameMarkers.Alpha = showFrameMarkers.Value ? 1 : 0; + if (CursorPath != null) CursorPath.Alpha = showCursorPath.Value ? 1 : 0; + } + + private void initialise() + { + if (loaded.IsValid) + return; + + loaded.Validate(); + + generationCancellationSource?.Cancel(); + generationCancellationSource = new CancellationTokenSource(); + + // It's faster to reinitialise the whole drawable stack than use `Clear` on `PooledDrawableWithLifetimeContainer` + var newDrawables = new Drawable[] + { + CursorPath = new CursorPathContainer(), + ClickMarkers = new ClickMarkerContainer(), + FrameMarkers = new FrameMarkerContainer(), + }; + + bool leftHeld = false; + bool rightHeld = false; + + // This should probably be async as well, but it's a bit of a pain to debounce and everything. + // Let's address concerns when they are raised. + foreach (var frame in replay.Frames) + { + var osuFrame = (OsuReplayFrame)frame; + + bool leftButton = osuFrame.Actions.Contains(OsuAction.LeftButton); + bool rightButton = osuFrame.Actions.Contains(OsuAction.RightButton); + + if (leftHeld && !leftButton) + leftHeld = false; + else if (!leftHeld && leftButton) + { + leftHeld = true; + ClickMarkers.Add(new AnalysisFrameEntry(osuFrame.Time, displayLength.Value, osuFrame.Position, OsuAction.LeftButton)); + } + + if (rightHeld && !rightButton) + rightHeld = false; + else if (!rightHeld && rightButton) + { + rightHeld = true; + ClickMarkers.Add(new AnalysisFrameEntry(osuFrame.Time, displayLength.Value, osuFrame.Position, OsuAction.RightButton)); + } + + FrameMarkers.Add(new AnalysisFrameEntry(osuFrame.Time, displayLength.Value, osuFrame.Position, osuFrame.Actions.ToArray())); + CursorPath.Add(new AnalysisFrameEntry(osuFrame.Time, displayLength.Value, osuFrame.Position)); + } + + LoadComponentsAsync(newDrawables, drawables => InternalChildrenEnumerable = drawables, generationCancellationSource.Token); + } + } +} diff --git a/osu.Game.Rulesets.Osu/UI/ReplayAnalysisSettings.cs b/osu.Game.Rulesets.Osu/UI/ReplayAnalysisSettings.cs new file mode 100644 index 0000000000..dc4730d76a --- /dev/null +++ b/osu.Game.Rulesets.Osu/UI/ReplayAnalysisSettings.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 osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Game.Configuration; +using osu.Game.Rulesets.Osu.Configuration; +using osu.Game.Screens.Play.PlayerSettings; + +namespace osu.Game.Rulesets.Osu.UI +{ + public partial class ReplayAnalysisSettings : PlayerSettingsGroup + { + private readonly OsuRulesetConfigManager config; + + [SettingSource("Show click markers", SettingControlType = typeof(PlayerCheckbox))] + public BindableBool ShowClickMarkers { get; } = new BindableBool(); + + [SettingSource("Show frame markers", SettingControlType = typeof(PlayerCheckbox))] + public BindableBool ShowAimMarkers { get; } = new BindableBool(); + + [SettingSource("Show cursor path", SettingControlType = typeof(PlayerCheckbox))] + public BindableBool ShowCursorPath { get; } = new BindableBool(); + + [SettingSource("Hide gameplay cursor", SettingControlType = typeof(PlayerCheckbox))] + public BindableBool HideSkinCursor { get; } = new BindableBool(); + + [SettingSource("Display length", SettingControlType = typeof(PlayerSliderBar))] + public BindableInt DisplayLength { get; } = new BindableInt + { + MinValue = 200, + MaxValue = 2000, + Default = 800, + Precision = 200, + }; + + public ReplayAnalysisSettings(OsuRulesetConfigManager config) + : base("Analysis Settings") + { + this.config = config; + } + + [BackgroundDependencyLoader] + private void load() + { + AddRange(this.CreateSettingsControls()); + + config.BindWith(OsuRulesetSetting.ReplayClickMarkersEnabled, ShowClickMarkers); + config.BindWith(OsuRulesetSetting.ReplayFrameMarkersEnabled, ShowAimMarkers); + config.BindWith(OsuRulesetSetting.ReplayCursorPathEnabled, ShowCursorPath); + config.BindWith(OsuRulesetSetting.ReplayCursorHideEnabled, HideSkinCursor); + config.BindWith(OsuRulesetSetting.ReplayAnalysisDisplayLength, DisplayLength); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs index e936c24c08..f27624a633 100644 --- a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs +++ b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs @@ -3,7 +3,6 @@ using System; using System.Linq; -using osu.Framework.Extensions.IEnumerableExtensions; using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Beatmaps; @@ -117,10 +116,9 @@ namespace osu.Game.Rulesets.Osu.Utils if (osuObject is not Slider slider) return; - void reflectNestedObject(OsuHitObject nested) => nested.Position = new Vector2(OsuPlayfield.BASE_SIZE.X - nested.Position.X, nested.Position.Y); static void reflectControlPoint(PathControlPoint point) => point.Position = new Vector2(-point.Position.X, point.Position.Y); - modifySlider(slider, reflectNestedObject, reflectControlPoint); + modifySlider(slider, reflectControlPoint); } /// @@ -134,10 +132,9 @@ namespace osu.Game.Rulesets.Osu.Utils if (osuObject is not Slider slider) return; - void reflectNestedObject(OsuHitObject nested) => nested.Position = new Vector2(nested.Position.X, OsuPlayfield.BASE_SIZE.Y - nested.Position.Y); static void reflectControlPoint(PathControlPoint point) => point.Position = new Vector2(point.Position.X, -point.Position.Y); - modifySlider(slider, reflectNestedObject, reflectControlPoint); + modifySlider(slider, reflectControlPoint); } /// @@ -146,10 +143,9 @@ namespace osu.Game.Rulesets.Osu.Utils /// The slider to be flipped. public static void FlipSliderInPlaceHorizontally(Slider slider) { - void flipNestedObject(OsuHitObject nested) => nested.Position = new Vector2(slider.X - (nested.X - slider.X), nested.Y); static void flipControlPoint(PathControlPoint point) => point.Position = new Vector2(-point.Position.X, point.Position.Y); - modifySlider(slider, flipNestedObject, flipControlPoint); + modifySlider(slider, flipControlPoint); } /// @@ -159,18 +155,13 @@ namespace osu.Game.Rulesets.Osu.Utils /// The angle, measured in radians, to rotate the slider by. public static void RotateSlider(Slider slider, float rotation) { - void rotateNestedObject(OsuHitObject nested) => nested.Position = rotateVector(nested.Position - slider.Position, rotation) + slider.Position; void rotateControlPoint(PathControlPoint point) => point.Position = rotateVector(point.Position, rotation); - modifySlider(slider, rotateNestedObject, rotateControlPoint); + modifySlider(slider, rotateControlPoint); } - private static void modifySlider(Slider slider, Action modifyNestedObject, Action modifyControlPoint) + private static void modifySlider(Slider slider, Action modifyControlPoint) { - // No need to update the head and tail circles, since slider handles that when the new slider path is set - slider.NestedHitObjects.OfType().ForEach(modifyNestedObject); - slider.NestedHitObjects.OfType().ForEach(modifyNestedObject); - var controlPoints = slider.Path.ControlPoints.Select(p => new PathControlPoint(p.Position, p.Type)).ToArray(); foreach (var point in controlPoints) modifyControlPoint(point); diff --git a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs index a9ae313a31..7073abbc89 100644 --- a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs +++ b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs @@ -232,8 +232,6 @@ namespace osu.Game.Rulesets.Osu.Utils slider.Position = workingObject.PositionModified = new Vector2(newX, newY); workingObject.EndPositionModified = slider.EndPosition; - shiftNestedObjects(slider, workingObject.PositionModified - workingObject.PositionOriginal); - return workingObject.PositionModified - previousPosition; } @@ -307,22 +305,6 @@ namespace osu.Game.Rulesets.Osu.Utils return new RectangleF(left, top, right - left, bottom - top); } - /// - /// Shifts all nested s and s by the specified shift. - /// - /// whose nested s and s should be shifted - /// The the 's nested s and s should be shifted by - private static void shiftNestedObjects(Slider slider, Vector2 shift) - { - foreach (var hitObject in slider.NestedHitObjects.Where(o => o is SliderTick || o is SliderRepeat)) - { - if (!(hitObject is OsuHitObject osuHitObject)) - continue; - - osuHitObject.Position += shift; - } - } - /// /// Clamp a position to playfield, keeping a specified distance from the edges. /// @@ -431,7 +413,6 @@ namespace osu.Game.Rulesets.Osu.Utils private class WorkingObject { public float RotationOriginal { get; } - public Vector2 PositionOriginal { get; } public Vector2 PositionModified { get; set; } public Vector2 EndPositionModified { get; set; } @@ -442,7 +423,7 @@ namespace osu.Game.Rulesets.Osu.Utils { PositionInfo = positionInfo; RotationOriginal = HitObject is Slider slider ? getSliderRotation(slider) : 0; - PositionModified = PositionOriginal = HitObject.Position; + PositionModified = HitObject.Position; EndPositionModified = HitObject.EndPosition; } } diff --git a/osu.Game.Rulesets.Taiko.Tests.Android/osu.Game.Rulesets.Taiko.Tests.Android.csproj b/osu.Game.Rulesets.Taiko.Tests.Android/osu.Game.Rulesets.Taiko.Tests.Android.csproj index ee973e8544..88aa137797 100644 --- a/osu.Game.Rulesets.Taiko.Tests.Android/osu.Game.Rulesets.Taiko.Tests.Android.csproj +++ b/osu.Game.Rulesets.Taiko.Tests.Android/osu.Game.Rulesets.Taiko.Tests.Android.csproj @@ -19,6 +19,7 @@ + diff --git a/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneEditorPlacement.cs b/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneEditorPlacement.cs new file mode 100644 index 0000000000..c523652ae1 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneEditorPlacement.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Testing; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Objects.Drawables; +using osu.Game.Tests.Visual; +using osuTK.Input; + +namespace osu.Game.Rulesets.Taiko.Tests.Editor +{ + public partial class TestSceneEditorPlacement : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new TaikoRuleset(); + + [Test] + public void TestPlacementBlueprintDoesNotCauseCrashes() + { + AddStep("clear objects", () => EditorBeatmap.Clear()); + AddStep("add two objects", () => + { + EditorBeatmap.Add(new Hit { StartTime = 1818 }); + EditorBeatmap.Add(new Hit { StartTime = 1584 }); + }); + AddStep("seek back", () => EditorClock.Seek(1584)); + AddStep("choose hit placement tool", () => InputManager.Key(Key.Number2)); + AddStep("hover over first hit", () => InputManager.MoveMouseTo(Editor.ChildrenOfType().ElementAt(1))); + AddStep("hover over second hit", () => InputManager.MoveMouseTo(Editor.ChildrenOfType().ElementAt(0))); + AddStep("right click", () => InputManager.Click(MouseButton.Right)); + AddUntilStep("context menu open", () => Editor.ChildrenOfType().Any(menu => menu.State == MenuState.Open)); + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneTaikoEditorTestGameplay.cs b/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneTaikoEditorTestGameplay.cs new file mode 100644 index 0000000000..2422e62571 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneTaikoEditorTestGameplay.cs @@ -0,0 +1,70 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.Components.Timelines.Summary; +using osu.Game.Screens.Edit.GameplayTest; +using osu.Game.Storyboards; +using osu.Game.Tests.Beatmaps.IO; +using osu.Game.Tests.Visual; +using osuTK.Input; + +namespace osu.Game.Rulesets.Taiko.Tests.Editor +{ + public partial class TestSceneTaikoEditorTestGameplay : EditorTestScene + { + protected override bool IsolateSavingFromDatabase => false; + + protected override Ruleset CreateEditorRuleset() => new TaikoRuleset(); + + [Resolved] + private OsuGameBase game { get; set; } = null!; + + [Resolved] + private BeatmapManager beatmaps { get; set; } = null!; + + private BeatmapSetInfo importedBeatmapSet = null!; + + public override void SetUpSteps() + { + AddStep("import test beatmap", () => importedBeatmapSet = BeatmapImportHelper.LoadOszIntoOsu(game).GetResultSafely()); + base.SetUpSteps(); + } + + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard? storyboard = null) + => beatmaps.GetWorkingBeatmap(importedBeatmapSet.Beatmaps.First(b => b.Ruleset.OnlineID == 1)); + + [Test] + public void TestBasicGameplayTest() + { + AddStep("add objects", () => + { + EditorBeatmap.Clear(); + EditorBeatmap.Add(new Swell { StartTime = 500, EndTime = 1500 }); + EditorBeatmap.Add(new Hit { StartTime = 3000 }); + }); + AddStep("seek to 250", () => EditorClock.Seek(250)); + AddUntilStep("wait for seek", () => EditorClock.CurrentTime, () => Is.EqualTo(250)); + + AddStep("click test gameplay button", () => + { + var button = Editor.ChildrenOfType().Single(); + + InputManager.MoveMouseTo(button); + InputManager.Click(MouseButton.Left); + }); + AddUntilStep("save prompt shown", () => DialogOverlay.CurrentDialog is SaveRequiredPopupDialog); + + AddStep("save changes", () => DialogOverlay.CurrentDialog!.PerformOkAction()); + AddUntilStep("player pushed", () => Stack.CurrentScreen is EditorPlayer); + AddUntilStep("wait for return to editor", () => Stack.CurrentScreen is Screens.Edit.Editor); + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Judgements/JudgementTest.cs b/osu.Game.Rulesets.Taiko.Tests/Judgements/JudgementTest.cs index f3e37736b2..30ecec2366 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Judgements/JudgementTest.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Judgements/JudgementTest.cs @@ -43,6 +43,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Judgements AddStep("load player", () => { Beatmap.Value = CreateWorkingBeatmap(beatmap); + Ruleset.Value = new TaikoRuleset().RulesetInfo; SelectedMods.Value = mods ?? Array.Empty(); var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } }); diff --git a/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneSwellJudgements.cs b/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneSwellJudgements.cs index 6e42ae7eb5..04661fe2cf 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneSwellJudgements.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneSwellJudgements.cs @@ -85,6 +85,42 @@ namespace osu.Game.Rulesets.Taiko.Tests.Judgements AssertResult(0, HitResult.IgnoreMiss); } + [Test] + public void TestAlternatingIsRequired() + { + const double hit_time = 1000; + + Swell swell = new Swell + { + StartTime = hit_time, + Duration = 1000, + RequiredHits = 10 + }; + + List frames = new List + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(2001), + }; + + for (int i = 0; i < swell.RequiredHits; i++) + { + double frameTime = 1000 + i * 50; + frames.Add(new TaikoReplayFrame(frameTime, TaikoAction.LeftCentre)); + frames.Add(new TaikoReplayFrame(frameTime + 10)); + } + + PerformTest(frames, CreateBeatmap(swell)); + + AssertJudgementCount(11); + + AssertResult(0, HitResult.IgnoreHit); + for (int i = 1; i < swell.RequiredHits; i++) + AssertResult(i, HitResult.IgnoreMiss); + + AssertResult(0, HitResult.IgnoreMiss); + } + [Test] public void TestHitNoneSwell() { diff --git a/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModRelax.cs b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModRelax.cs new file mode 100644 index 0000000000..caf8aa8e76 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModRelax.cs @@ -0,0 +1,46 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Game.Rulesets.Taiko.Beatmaps; +using osu.Game.Rulesets.Taiko.Mods; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Replays; + +namespace osu.Game.Rulesets.Taiko.Tests.Mods +{ + public partial class TestSceneTaikoModRelax : TaikoModTestScene + { + [Test] + public void TestRelax() + { + var beatmap = new TaikoBeatmap + { + HitObjects = + { + new Hit { StartTime = 0, Type = HitType.Centre, }, + new Hit { StartTime = 250, Type = HitType.Rim, }, + new DrumRoll { StartTime = 500, Duration = 500, }, + new Swell { StartTime = 1250, Duration = 500 }, + } + }; + foreach (var ho in beatmap.HitObjects) + ho.ApplyDefaults(beatmap.ControlPointInfo, beatmap.Difficulty); + + var replay = new TaikoAutoGenerator(beatmap).Generate(); + + foreach (var frame in replay.Frames.OfType().Where(r => r.Actions.Any())) + frame.Actions = [TaikoAction.LeftCentre]; + + CreateModTest(new ModTestData + { + Mod = new TaikoModRelax(), + Beatmap = beatmap, + ReplayFrames = replay.Frames, + Autoplay = false, + PassCondition = () => Player.ScoreProcessor.HasCompleted.Value && Player.ScoreProcessor.Accuracy.Value == 1, + }); + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModSingleTap.cs b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModSingleTap.cs index 0cd3b85f8e..3a11a91f82 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModSingleTap.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModSingleTap.cs @@ -177,7 +177,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Mods Autoplay = false, Beatmap = new Beatmap { - Breaks = new List + Breaks = { new BreakPeriod(100, 1600), }, diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumSampleTriggerSource.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumSampleTriggerSource.cs index 6c925f566b..b47f02afa3 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumSampleTriggerSource.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumSampleTriggerSource.cs @@ -315,10 +315,7 @@ namespace osu.Game.Rulesets.Taiko.Tests hitObjectContainer.Add(drawableSwell); }); - // You might think that this should be a SwellTick since we're before the swell, but SwellTicks get no StartTime (ie. they are zero). - // This works fine in gameplay because they are judged whenever the user pressed, rather than being timed hits. - // But for sample playback purposes they can be ignored as noise. - AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + AddAssert("most valid object is swell tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf); checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); @@ -352,10 +349,7 @@ namespace osu.Game.Rulesets.Taiko.Tests hitObjectContainer.Add(drawableSwell); }); - // You might think that this should be a SwellTick since we're before the swell, but SwellTicks get no StartTime (ie. they are zero). - // This works fine in gameplay because they are judged whenever the user pressed, rather than being timed hits. - // But for sample playback purposes they can be ignored as noise. - AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + AddAssert("most valid object is swell tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf); checkSamples(HitType.Centre, false, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_DRUM); checkSamples(HitType.Rim, false, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_DRUM); diff --git a/osu.Game.Rulesets.Taiko.Tests/VolumeAwareHitSampleInfoTest.cs b/osu.Game.Rulesets.Taiko.Tests/VolumeAwareHitSampleInfoTest.cs new file mode 100644 index 0000000000..2b3a922067 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/VolumeAwareHitSampleInfoTest.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 NUnit.Framework; +using osu.Game.Audio; +using osu.Game.Rulesets.Taiko.Skinning.Argon; + +namespace osu.Game.Rulesets.Taiko.Tests +{ + [TestFixture] + public class VolumeAwareHitSampleInfoTest + { + [Test] + public void TestVolumeAwareHitSampleInfoIsNotEqualToItsUnderlyingSample( + [Values(HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_CLAP)] + string sample, + [Values(HitSampleInfo.BANK_NORMAL, HitSampleInfo.BANK_SOFT)] + string bank, + [Values(30, 70, 100)] int volume) + { + var underlyingSample = new HitSampleInfo(sample, bank, volume: volume); + var volumeAwareSample = new VolumeAwareHitSampleInfo(underlyingSample); + + Assert.That(underlyingSample, Is.Not.EqualTo(volumeAwareSample)); + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj b/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj index 26afd42445..2170009ae8 100644 --- a/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj +++ b/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj @@ -1,7 +1,7 @@  - + @@ -11,5 +11,6 @@ + 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/Peaks.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Peaks.cs deleted file mode 100644 index 91d8e93543..0000000000 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Peaks.cs +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.Linq; -using osu.Game.Rulesets.Difficulty.Preprocessing; -using osu.Game.Rulesets.Difficulty.Skills; -using osu.Game.Rulesets.Mods; - -namespace osu.Game.Rulesets.Taiko.Difficulty.Skills -{ - public class Peaks : Skill - { - private const double rhythm_skill_multiplier = 0.2 * final_multiplier; - private const double colour_skill_multiplier = 0.375 * final_multiplier; - private const double stamina_skill_multiplier = 0.375 * final_multiplier; - - private const double final_multiplier = 0.0625; - - private readonly Rhythm rhythm; - private readonly Colour colour; - private readonly Stamina stamina; - - public double ColourDifficultyValue => colour.DifficultyValue() * colour_skill_multiplier; - public double RhythmDifficultyValue => rhythm.DifficultyValue() * rhythm_skill_multiplier; - public double StaminaDifficultyValue => stamina.DifficultyValue() * stamina_skill_multiplier; - - public Peaks(Mod[] mods) - : base(mods) - { - rhythm = new Rhythm(mods); - colour = new Colour(mods); - stamina = new Stamina(mods); - } - - /// - /// Returns the p-norm of an n-dimensional vector. - /// - /// The value of p to calculate the norm for. - /// The coefficients of the vector. - private double norm(double p, params double[] values) => Math.Pow(values.Sum(x => Math.Pow(x, p)), 1 / p); - - public override void Process(DifficultyHitObject current) - { - rhythm.Process(current); - colour.Process(current); - stamina.Process(current); - } - - /// - /// Returns the combined star rating of the beatmap, calculated using peak strains from all sections of the map. - /// - /// - /// For each section, the peak strains of all separate skills are combined into a single peak strain for the section. - /// The resulting partial rating of the beatmap is a weighted sum of the combined peaks (higher peaks are weighted more). - /// - public override double DifficultyValue() - { - List peaks = new List(); - - var colourPeaks = colour.GetCurrentStrainPeaks().ToList(); - var rhythmPeaks = rhythm.GetCurrentStrainPeaks().ToList(); - var staminaPeaks = stamina.GetCurrentStrainPeaks().ToList(); - - for (int i = 0; i < colourPeaks.Count; i++) - { - double colourPeak = colourPeaks[i] * colour_skill_multiplier; - double rhythmPeak = rhythmPeaks[i] * rhythm_skill_multiplier; - double staminaPeak = staminaPeaks[i] * stamina_skill_multiplier; - - double peak = norm(1.5, colourPeak, staminaPeak); - peak = norm(2, peak, rhythmPeak); - - // Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). - // These sections will not contribute to the difficulty. - if (peak > 0) - peaks.Add(peak); - } - - double difficulty = 0; - double weight = 1; - - foreach (double strain in peaks.OrderDescending()) - { - difficulty += strain * weight; - weight *= 0.9; - } - - return difficulty; - } - } -} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs index e528c70699..f6914039f0 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs @@ -1,33 +1,55 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Taiko.Difficulty.Evaluators; +using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; namespace osu.Game.Rulesets.Taiko.Difficulty.Skills { /// /// Calculates the stamina coefficient of taiko difficulty. /// - public class Stamina : StrainDecaySkill + public class Stamina : StrainSkill { - protected override double SkillMultiplier => 1.1; - protected override double StrainDecayBase => 0.4; + private double skillMultiplier => 1.1; + private double strainDecayBase => 0.4; + + private readonly bool singleColourStamina; + + private double currentStrain; /// /// Creates a skill. /// /// Mods for use in skill calculations. - public Stamina(Mod[] mods) + /// Reads when Stamina is from a single coloured pattern. + public Stamina(Mod[] mods, bool singleColourStamina) : base(mods) { + this.singleColourStamina = singleColourStamina; } - protected override double StrainValueOf(DifficultyHitObject current) + private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); + + protected override double StrainValueAt(DifficultyHitObject current) { - return StaminaEvaluator.EvaluateDifficultyOf(current); + currentStrain *= strainDecay(current.DeltaTime); + currentStrain += StaminaEvaluator.EvaluateDifficultyOf(current) * skillMultiplier; + + // Safely prevents previous strains from shifting as new notes are added. + var currentObject = current as TaikoDifficultyHitObject; + int index = currentObject?.Colour.MonoStreak?.HitObjects.IndexOf(currentObject) ?? 0; + + if (singleColourStamina) + return currentStrain / (1 + Math.Exp(-(index - 10) / 2.0)); + + return currentStrain; } + + protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => singleColourStamina ? 0 : currentStrain * strainDecay(time - current.Previous(0).StartTime); } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs index 1664c941f8..c8f0448767 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs @@ -16,6 +16,12 @@ namespace osu.Game.Rulesets.Taiko.Difficulty [JsonProperty("stamina_difficulty")] public double StaminaDifficulty { get; set; } + /// + /// The ratio of stamina difficulty from mono-color (single colour) streams to total stamina difficulty. + /// + [JsonProperty("mono_stamina_factor")] + public double MonoStaminaFactor { get; set; } + /// /// The difficulty corresponding to the rhythm skill. /// @@ -43,6 +49,15 @@ namespace osu.Game.Rulesets.Taiko.Difficulty [JsonProperty("great_hit_window")] public double GreatHitWindow { get; set; } + /// + /// The perceived hit window for an OK hit inclusive of rate-adjusting mods (DT/HT/etc). + /// + /// + /// Rate-adjusting mods don't directly affect the hit window, but have a perceived effect as a result of adjusting audio timing. + /// + [JsonProperty("ok_hit_window")] + public double OkHitWindow { get; set; } + public override IEnumerable<(int attributeId, object value)> ToDatabaseAttributes() { foreach (var v in base.ToDatabaseAttributes()) @@ -50,6 +65,8 @@ namespace osu.Game.Rulesets.Taiko.Difficulty yield return (ATTRIB_ID_DIFFICULTY, StarRating); yield return (ATTRIB_ID_GREAT_HIT_WINDOW, GreatHitWindow); + yield return (ATTRIB_ID_OK_HIT_WINDOW, OkHitWindow); + yield return (ATTRIB_ID_MONO_STAMINA_FACTOR, MonoStaminaFactor); } public override void FromDatabaseAttributes(IReadOnlyDictionary values, IBeatmapOnlineInfo onlineInfo) @@ -58,6 +75,8 @@ namespace osu.Game.Rulesets.Taiko.Difficulty StarRating = values[ATTRIB_ID_DIFFICULTY]; GreatHitWindow = values[ATTRIB_ID_GREAT_HIT_WINDOW]; + OkHitWindow = values[ATTRIB_ID_OK_HIT_WINDOW]; + MonoStaminaFactor = values[ATTRIB_ID_MONO_STAMINA_FACTOR]; } } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs index b84c2d25ee..7f2558c406 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs @@ -14,16 +14,18 @@ using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour; using osu.Game.Rulesets.Taiko.Difficulty.Skills; using osu.Game.Rulesets.Taiko.Mods; -using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.Scoring; namespace osu.Game.Rulesets.Taiko.Difficulty { public class TaikoDifficultyCalculator : DifficultyCalculator { - private const double difficulty_multiplier = 1.35; + private const double difficulty_multiplier = 0.084375; + private const double rhythm_skill_multiplier = 0.2 * difficulty_multiplier; + private const double colour_skill_multiplier = 0.375 * difficulty_multiplier; + private const double stamina_skill_multiplier = 0.375 * difficulty_multiplier; - public override int Version => 20221107; + public override int Version => 20241007; public TaikoDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) : base(ruleset, beatmap) @@ -34,7 +36,10 @@ namespace osu.Game.Rulesets.Taiko.Difficulty { return new Skill[] { - new Peaks(mods) + new Rhythm(mods), + new Colour(mods), + new Stamina(mods, false), + new Stamina(mods, true) }; } @@ -72,15 +77,29 @@ namespace osu.Game.Rulesets.Taiko.Difficulty if (beatmap.HitObjects.Count == 0) return new TaikoDifficultyAttributes { Mods = mods }; - var combined = (Peaks)skills[0]; + 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 = combined.ColourDifficultyValue * difficulty_multiplier; - double rhythmRating = combined.RhythmDifficultyValue * difficulty_multiplier; - double staminaRating = combined.StaminaDifficultyValue * difficulty_multiplier; + 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 = combined.DifficultyValue() * difficulty_multiplier; + 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); @@ -89,11 +108,13 @@ namespace osu.Game.Rulesets.Taiko.Difficulty StarRating = starRating, Mods = mods, StaminaDifficulty = staminaRating, + MonoStaminaFactor = monoStaminaFactor, RhythmDifficulty = rhythmRating, ColourDifficulty = colourRating, PeakDifficulty = combinedRating, GreatHitWindow = hitWindows.WindowFor(HitResult.Great) / clockRate, - MaxCombo = beatmap.HitObjects.Count(h => h is Hit), + OkHitWindow = hitWindows.WindowFor(HitResult.Ok) / clockRate, + MaxCombo = beatmap.GetMaxCombo(), }; return attributes; @@ -109,5 +130,54 @@ namespace osu.Game.Rulesets.Taiko.Difficulty return 10.43 * Math.Log(sr / 8 + 1); } + + /// + /// Returns the combined star rating of the beatmap, calculated using peak strains from all sections of the map. + /// + /// + /// For each section, the peak strains of all separate skills are combined into a single peak strain for the section. + /// The resulting partial rating of the beatmap is a weighted sum of the combined peaks (higher peaks are weighted more). + /// + private double combinedDifficultyValue(Rhythm rhythm, Colour colour, Stamina stamina) + { + List peaks = new List(); + + var colourPeaks = colour.GetCurrentStrainPeaks().ToList(); + var rhythmPeaks = rhythm.GetCurrentStrainPeaks().ToList(); + var staminaPeaks = stamina.GetCurrentStrainPeaks().ToList(); + + for (int i = 0; i < colourPeaks.Count; i++) + { + double colourPeak = colourPeaks[i] * colour_skill_multiplier; + double rhythmPeak = rhythmPeaks[i] * rhythm_skill_multiplier; + double staminaPeak = staminaPeaks[i] * stamina_skill_multiplier; + + double peak = norm(1.5, colourPeak, staminaPeak); + peak = norm(2, peak, rhythmPeak); + + // Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). + // These sections will not contribute to the difficulty. + if (peak > 0) + peaks.Add(peak); + } + + double difficulty = 0; + double weight = 1; + + foreach (double strain in peaks.OrderDescending()) + { + difficulty += strain * weight; + weight *= 0.9; + } + + return difficulty; + } + + /// + /// Returns the p-norm of an n-dimensional vector. + /// + /// The value of p to calculate the norm for. + /// The coefficients of the vector. + private double norm(double p, params double[] values) => Math.Pow(values.Sum(x => Math.Pow(x, p)), 1 / p); } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceAttributes.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceAttributes.cs index b12c0ca29d..7c74e43db1 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceAttributes.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceAttributes.cs @@ -18,6 +18,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty [JsonProperty("effective_miss_count")] public double EffectiveMissCount { get; set; } + [JsonProperty("estimated_unstable_rate")] + public double? EstimatedUnstableRate { get; set; } + public override IEnumerable GetAttributesForDisplay() { foreach (var attribute in base.GetAttributesForDisplay()) diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs index ac4462c18b..c672b7a1d9 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs @@ -9,6 +9,7 @@ using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Scoring; +using osu.Game.Utils; namespace osu.Game.Rulesets.Taiko.Difficulty { @@ -18,7 +19,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty private int countOk; private int countMeh; private int countMiss; - private double accuracy; + private double? estimatedUnstableRate; private double effectiveMissCount; @@ -35,24 +36,24 @@ namespace osu.Game.Rulesets.Taiko.Difficulty countOk = score.Statistics.GetValueOrDefault(HitResult.Ok); countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh); countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss); - accuracy = customAccuracy; + estimatedUnstableRate = computeDeviationUpperBound(taikoAttributes) * 10; // The effectiveMissCount is calculated by gaining a ratio for totalSuccessfulHits and increasing the miss penalty for shorter object counts lower than 1000. if (totalSuccessfulHits > 0) effectiveMissCount = Math.Max(1.0, 1000.0 / totalSuccessfulHits) * countMiss; - // TODO: The detection of rulesets is temporary until the leftover old skills have been reworked. + // Converts are detected and omitted from mod-specific bonuses due to the scope of current difficulty calculation. bool isConvert = score.BeatmapInfo!.Ruleset.OnlineID != 1; double multiplier = 1.13; - if (score.Mods.Any(m => m is ModHidden)) + if (score.Mods.Any(m => m is ModHidden) && !isConvert) multiplier *= 1.075; if (score.Mods.Any(m => m is ModEasy)) - multiplier *= 0.975; + multiplier *= 0.950; - double difficultyValue = computeDifficultyValue(score, taikoAttributes, isConvert); + double difficultyValue = computeDifficultyValue(score, taikoAttributes); double accuracyValue = computeAccuracyValue(score, taikoAttributes, isConvert); double totalValue = Math.Pow( @@ -65,11 +66,12 @@ namespace osu.Game.Rulesets.Taiko.Difficulty Difficulty = difficultyValue, Accuracy = accuracyValue, EffectiveMissCount = effectiveMissCount, + EstimatedUnstableRate = estimatedUnstableRate, Total = totalValue }; } - private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool isConvert) + private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes attributes) { double difficultyValue = Math.Pow(5 * Math.Max(1.0, attributes.StarRating / 0.115) - 4.0, 2.25) / 1150.0; @@ -79,41 +81,104 @@ namespace osu.Game.Rulesets.Taiko.Difficulty difficultyValue *= Math.Pow(0.986, effectiveMissCount); if (score.Mods.Any(m => m is ModEasy)) - difficultyValue *= 0.985; + difficultyValue *= 0.90; - if (score.Mods.Any(m => m is ModHidden) && !isConvert) + if (score.Mods.Any(m => m is ModHidden)) difficultyValue *= 1.025; if (score.Mods.Any(m => m is ModHardRock)) - difficultyValue *= 1.050; + difficultyValue *= 1.10; if (score.Mods.Any(m => m is ModFlashlight)) - difficultyValue *= 1.050 * lengthBonus; + difficultyValue *= Math.Max(1, 1.050 - Math.Min(attributes.MonoStaminaFactor / 50, 1) * lengthBonus); - return difficultyValue * Math.Pow(accuracy, 2.0); + if (estimatedUnstableRate == null) + return 0; + + // Scale accuracy more harshly on nearly-completely mono (single coloured) speed maps. + double accScalingExponent = 2 + attributes.MonoStaminaFactor; + double accScalingShift = 300 - 100 * attributes.MonoStaminaFactor; + + return difficultyValue * Math.Pow(SpecialFunctions.Erf(accScalingShift / (Math.Sqrt(2) * estimatedUnstableRate.Value)), accScalingExponent); } private double computeAccuracyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool isConvert) { - if (attributes.GreatHitWindow <= 0) + if (attributes.GreatHitWindow <= 0 || estimatedUnstableRate == null) return 0; - double accuracyValue = Math.Pow(60.0 / attributes.GreatHitWindow, 1.1) * Math.Pow(accuracy, 8.0) * Math.Pow(attributes.StarRating, 0.4) * 27.0; + double accuracyValue = Math.Pow(70 / estimatedUnstableRate.Value, 1.1) * Math.Pow(attributes.StarRating, 0.4) * 100.0; double lengthBonus = Math.Min(1.15, Math.Pow(totalHits / 1500.0, 0.3)); - accuracyValue *= lengthBonus; // Slight HDFL Bonus for accuracy. A clamp is used to prevent against negative values. if (score.Mods.Any(m => m is ModFlashlight) && score.Mods.Any(m => m is ModHidden) && !isConvert) - accuracyValue *= Math.Max(1.0, 1.1 * lengthBonus); + accuracyValue *= Math.Max(1.0, 1.05 * lengthBonus); return accuracyValue; } + /// + /// Computes an upper bound on the player's tap deviation based on the OD, number of circles and sliders, + /// and the hit judgements, assuming the player's mean hit error is 0. The estimation is consistent in that + /// two SS scores on the same map with the same settings will always return the same deviation. + /// + private double? computeDeviationUpperBound(TaikoDifficultyAttributes attributes) + { + if (totalSuccessfulHits == 0 || attributes.GreatHitWindow <= 0) + return null; + + double h300 = attributes.GreatHitWindow; + double h100 = attributes.OkHitWindow; + + const double z = 2.32634787404; // 99% critical value for the normal distribution (one-tailed). + + // The upper bound on deviation, calculated with the ratio of 300s to objects, and the great hit window. + double? calcDeviationGreatWindow() + { + if (countGreat == 0) return null; + + double n = totalHits; + + // Proportion of greats hit. + double p = countGreat / n; + + // We can be 99% confident that p is at least this value. + double pLowerBound = (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4); + + // We can be 99% confident that the deviation is not higher than: + return h300 / (Math.Sqrt(2) * SpecialFunctions.ErfInv(pLowerBound)); + } + + // The upper bound on deviation, calculated with the ratio of 300s + 100s to objects, and the good hit window. + // This will return a lower value than the first method when the number of 100s is high, but the miss count is low. + double? calcDeviationGoodWindow() + { + if (totalSuccessfulHits == 0) return null; + + double n = totalHits; + + // Proportion of greats + goods hit. + double p = totalSuccessfulHits / n; + + // We can be 99% confident that p is at least this value. + double pLowerBound = (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4); + + // We can be 99% confident that the deviation is not higher than: + return h100 / (Math.Sqrt(2) * SpecialFunctions.ErfInv(pLowerBound)); + } + + double? deviationGreatWindow = calcDeviationGreatWindow(); + double? deviationGoodWindow = calcDeviationGoodWindow(); + + if (deviationGreatWindow is null) + return deviationGoodWindow; + + return Math.Min(deviationGreatWindow.Value, deviationGoodWindow!.Value); + } + private int totalHits => countGreat + countOk + countMeh + countMiss; private int totalSuccessfulHits => countGreat + countOk + countMeh; - - private double customAccuracy => totalHits > 0 ? (countGreat * 300 + countOk * 150) / (totalHits * 300.0) : 0; } } diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs index 329fff5b42..7f45123bd6 100644 --- a/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs @@ -10,7 +10,7 @@ using osuTK.Input; namespace osu.Game.Rulesets.Taiko.Edit.Blueprints { - public partial class HitPlacementBlueprint : PlacementBlueprint + public partial class HitPlacementBlueprint : HitObjectPlacementBlueprint { private readonly HitPiece piece; diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs index cd52398086..de3a4d96eb 100644 --- a/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs @@ -17,7 +17,7 @@ using osuTK.Input; namespace osu.Game.Rulesets.Taiko.Edit.Blueprints { - public partial class TaikoSpanPlacementBlueprint : PlacementBlueprint + public partial class TaikoSpanPlacementBlueprint : HitObjectPlacementBlueprint { private readonly HitPiece headPiece; private readonly HitPiece tailPiece; diff --git a/osu.Game.Rulesets.Taiko/Edit/DrawableTaikoEditorRuleset.cs b/osu.Game.Rulesets.Taiko/Edit/DrawableTaikoEditorRuleset.cs index 3c7a97c864..147ceb3ba1 100644 --- a/osu.Game.Rulesets.Taiko/Edit/DrawableTaikoEditorRuleset.cs +++ b/osu.Game.Rulesets.Taiko/Edit/DrawableTaikoEditorRuleset.cs @@ -7,6 +7,7 @@ using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Taiko.UI; +using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; namespace osu.Game.Rulesets.Taiko.Edit @@ -20,18 +21,13 @@ namespace osu.Game.Rulesets.Taiko.Edit { } + protected override Playfield CreatePlayfield() => new TaikoEditorPlayfield(); + protected override void LoadComplete() { base.LoadComplete(); ShowSpeedChanges.BindValueChanged(showChanges => VisualisationMethod = showChanges.NewValue ? ScrollVisualisationMethod.Overlapping : ScrollVisualisationMethod.Constant, true); } - - protected override double ComputeTimeRange() - { - // Adjust when we're using constant algorithm to not be sluggish. - double multiplier = ShowSpeedChanges.Value ? 1 : 4; - return base.ComputeTimeRange() / multiplier; - } } } diff --git a/osu.Game.Rulesets.Taiko/Edit/DrumRollCompositionTool.cs b/osu.Game.Rulesets.Taiko/Edit/DrumRollCompositionTool.cs index f332441875..ba0fda6771 100644 --- a/osu.Game.Rulesets.Taiko/Edit/DrumRollCompositionTool.cs +++ b/osu.Game.Rulesets.Taiko/Edit/DrumRollCompositionTool.cs @@ -10,7 +10,7 @@ using osu.Game.Rulesets.Taiko.Objects; namespace osu.Game.Rulesets.Taiko.Edit { - public class DrumRollCompositionTool : HitObjectCompositionTool + public class DrumRollCompositionTool : CompositionTool { public DrumRollCompositionTool() : base(nameof(DrumRoll)) @@ -19,6 +19,6 @@ namespace osu.Game.Rulesets.Taiko.Edit public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders); - public override PlacementBlueprint CreatePlacementBlueprint() => new DrumRollPlacementBlueprint(); + public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new DrumRollPlacementBlueprint(); } } diff --git a/osu.Game.Rulesets.Taiko/Edit/HitCompositionTool.cs b/osu.Game.Rulesets.Taiko/Edit/HitCompositionTool.cs index fa50841893..f58defba83 100644 --- a/osu.Game.Rulesets.Taiko/Edit/HitCompositionTool.cs +++ b/osu.Game.Rulesets.Taiko/Edit/HitCompositionTool.cs @@ -10,7 +10,7 @@ using osu.Game.Rulesets.Taiko.Objects; namespace osu.Game.Rulesets.Taiko.Edit { - public class HitCompositionTool : HitObjectCompositionTool + public class HitCompositionTool : CompositionTool { public HitCompositionTool() : base(nameof(Hit)) @@ -19,6 +19,6 @@ namespace osu.Game.Rulesets.Taiko.Edit public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles); - public override PlacementBlueprint CreatePlacementBlueprint() => new HitPlacementBlueprint(); + public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new HitPlacementBlueprint(); } } diff --git a/osu.Game.Rulesets.Taiko/Edit/Setup/TaikoDifficultySection.cs b/osu.Game.Rulesets.Taiko/Edit/Setup/TaikoDifficultySection.cs new file mode 100644 index 0000000000..52f7176b3f --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Edit/Setup/TaikoDifficultySection.cs @@ -0,0 +1,109 @@ +// 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.Graphics; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Localisation; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Screens.Edit.Setup; + +namespace osu.Game.Rulesets.Taiko.Edit.Setup +{ + public partial class TaikoDifficultySection : SetupSection + { + private FormSliderBar healthDrainSlider { get; set; } = null!; + private FormSliderBar overallDifficultySlider { get; set; } = null!; + private FormSliderBar baseVelocitySlider { get; set; } = null!; + private FormSliderBar tickRateSlider { get; set; } = null!; + + public override LocalisableString Title => EditorSetupStrings.DifficultyHeader; + + [BackgroundDependencyLoader] + private void load() + { + Children = new Drawable[] + { + healthDrainSlider = new FormSliderBar + { + Caption = BeatmapsetsStrings.ShowStatsDrain, + HintText = EditorSetupStrings.DrainRateDescription, + Current = new BindableFloat(Beatmap.Difficulty.DrainRate) + { + Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, + MinValue = 0, + MaxValue = 10, + Precision = 0.1f, + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, + }, + overallDifficultySlider = new FormSliderBar + { + Caption = BeatmapsetsStrings.ShowStatsAccuracy, + HintText = EditorSetupStrings.OverallDifficultyDescription, + Current = new BindableFloat(Beatmap.Difficulty.OverallDifficulty) + { + Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, + MinValue = 0, + MaxValue = 10, + Precision = 0.1f, + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, + }, + baseVelocitySlider = new FormSliderBar + { + Caption = EditorSetupStrings.BaseVelocity, + HintText = EditorSetupStrings.BaseVelocityDescription, + Current = new BindableDouble(Beatmap.Difficulty.SliderMultiplier) + { + Default = 1.4, + MinValue = 0.4, + MaxValue = 3.6, + Precision = 0.01f, + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, + }, + tickRateSlider = new FormSliderBar + { + Caption = EditorSetupStrings.TickRate, + HintText = EditorSetupStrings.TickRateDescription, + Current = new BindableDouble(Beatmap.Difficulty.SliderTickRate) + { + Default = 1, + MinValue = 1, + MaxValue = 4, + Precision = 1, + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, + }, + }; + + foreach (var item in Children.OfType>()) + item.Current.ValueChanged += _ => updateValues(); + + foreach (var item in Children.OfType>()) + item.Current.ValueChanged += _ => updateValues(); + } + + private void updateValues() + { + // for now, update these on commit rather than making BeatmapMetadata bindables. + // after switching database engines we can reconsider if switching to bindables is a good direction. + Beatmap.Difficulty.DrainRate = healthDrainSlider.Current.Value; + Beatmap.Difficulty.OverallDifficulty = overallDifficultySlider.Current.Value; + Beatmap.Difficulty.SliderMultiplier = baseVelocitySlider.Current.Value; + Beatmap.Difficulty.SliderTickRate = tickRateSlider.Current.Value; + + Beatmap.UpdateAllHitObjects(); + Beatmap.SaveState(); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Edit/SwellCompositionTool.cs b/osu.Game.Rulesets.Taiko/Edit/SwellCompositionTool.cs index 4d4ee8effe..4ec623e29e 100644 --- a/osu.Game.Rulesets.Taiko/Edit/SwellCompositionTool.cs +++ b/osu.Game.Rulesets.Taiko/Edit/SwellCompositionTool.cs @@ -10,7 +10,7 @@ using osu.Game.Rulesets.Taiko.Objects; namespace osu.Game.Rulesets.Taiko.Edit { - public class SwellCompositionTool : HitObjectCompositionTool + public class SwellCompositionTool : CompositionTool { public SwellCompositionTool() : base(nameof(Swell)) @@ -19,6 +19,6 @@ namespace osu.Game.Rulesets.Taiko.Edit public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Spinners); - public override PlacementBlueprint CreatePlacementBlueprint() => new SwellPlacementBlueprint(); + public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new SwellPlacementBlueprint(); } } diff --git a/osu.Game.Rulesets.Taiko/Edit/TaikoEditorPlayfield.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoEditorPlayfield.cs new file mode 100644 index 0000000000..760ed71662 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Edit/TaikoEditorPlayfield.cs @@ -0,0 +1,25 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Rulesets.Taiko.UI; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Taiko.Edit +{ + public partial class TaikoEditorPlayfield : TaikoPlayfield + { + [BackgroundDependencyLoader] + private void load() + { + // This is the simplest way to extend the taiko playfield beyond the left of the drum area. + // Required in the editor to not look weird underneath left toolbox area. + AddInternal(new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.PlayfieldBackgroundRight), _ => new PlayfieldBackgroundRight()) + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopRight, + }); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Edit/TaikoHitObjectComposer.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoHitObjectComposer.cs index 6020f6e04c..d97a854ff7 100644 --- a/osu.Game.Rulesets.Taiko/Edit/TaikoHitObjectComposer.cs +++ b/osu.Game.Rulesets.Taiko/Edit/TaikoHitObjectComposer.cs @@ -21,7 +21,7 @@ namespace osu.Game.Rulesets.Taiko.Edit { } - protected override IReadOnlyList CompositionTools => new HitObjectCompositionTool[] + protected override IReadOnlyList CompositionTools => new CompositionTool[] { new HitCompositionTool(), new DrumRollCompositionTool(), diff --git a/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs index 7ab8a54b02..be2a5ac144 100644 --- a/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs +++ b/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs @@ -6,6 +6,7 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Bindings; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; @@ -53,20 +54,26 @@ namespace osu.Game.Rulesets.Taiko.Edit public void SetStrongState(bool 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); } }); } public void SetRimState(bool state) { + if (SelectedItems.OfType().All(h => h.Type == (state ? HitType.Rim : HitType.Centre))) + return; + EditorBeatmap.PerformOnSelection(h => { if (h is Hit taikoHit) @@ -80,10 +87,22 @@ namespace osu.Game.Rulesets.Taiko.Edit protected override IEnumerable GetContextMenuItemsForSelection(IEnumerable> selection) { if (selection.All(s => s.Item is Hit)) - yield return new TernaryStateToggleMenuItem("Rim") { State = { BindTarget = selectionRimState } }; + { + yield return new TernaryStateToggleMenuItem("Rim") + { + State = { BindTarget = selectionRimState }, + Hotkey = new Hotkey(new KeyCombination(InputKey.W), new KeyCombination(InputKey.R)), + }; + } if (selection.All(s => s.Item is TaikoHitObject)) - yield return new TernaryStateToggleMenuItem("Strong") { State = { BindTarget = selectionStrongState } }; + { + yield return new TernaryStateToggleMenuItem("Strong") + { + State = { BindTarget = selectionStrongState }, + Hotkey = new Hotkey(new KeyCombination(InputKey.E)), + }; + } foreach (var item in base.GetContextMenuItemsForSelection(selection)) yield return item; diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModConstantSpeed.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModConstantSpeed.cs new file mode 100644 index 0000000000..81973e65cc --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModConstantSpeed.cs @@ -0,0 +1,29 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Configuration; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.UI; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Taiko.Mods +{ + public class TaikoModConstantSpeed : Mod, IApplicableToDrawableRuleset + { + public override string Name => "Constant Speed"; + public override string Acronym => "CS"; + public override double ScoreMultiplier => 0.9; + public override LocalisableString Description => "No more tricky speed changes!"; + public override IconUsage? Icon => FontAwesome.Solid.Equals; + public override ModType Type => ModType.Conversion; + + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + var taikoRuleset = (DrawableTaikoRuleset)drawableRuleset; + taikoRuleset.VisualisationMethod = ScrollVisualisationMethod.Constant; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModRelax.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModRelax.cs index e90ab589fc..ed09a85ebb 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModRelax.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModRelax.cs @@ -5,13 +5,34 @@ using System; using System.Linq; using osu.Framework.Localisation; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Taiko.Objects.Drawables; namespace osu.Game.Rulesets.Taiko.Mods { - public class TaikoModRelax : ModRelax + public class TaikoModRelax : ModRelax, IApplicableToDrawableHitObject { - public override LocalisableString Description => @"No ninja-like spinners, demanding drumrolls or unexpected katus."; + public override LocalisableString Description => @"No need to remember which key is correct anymore!"; public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(TaikoModSingleTap) }).ToArray(); + + public void ApplyToDrawableHitObject(DrawableHitObject drawable) + { + var allActions = Enum.GetValues(); + + drawable.HitObjectApplied += dho => + { + switch (dho) + { + case DrawableHit hit: + hit.HitActions = allActions; + break; + + case DrawableSwell swell: + swell.MustAlternate = false; + break; + } + }; + } } } 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 4fb69056da..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 { @@ -22,7 +23,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables /// /// A list of keys which can result in hits for this HitObject. /// - public TaikoAction[] HitActions { get; private set; } + public TaikoAction[] HitActions { get; internal set; } /// /// The action that caused this to be hit. @@ -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 e1fc28fe16..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; @@ -43,6 +46,11 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables public override bool DisplayResult => false; + /// + /// Whether the player must alternate centre and rim hits. + /// + public bool MustAlternate { get; internal set; } = true; + public DrawableSwell() : this(null) { @@ -136,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(); @@ -264,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); @@ -292,7 +306,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables bool isCentre = e.Action == TaikoAction.LeftCentre || e.Action == TaikoAction.RightCentre; // Ensure alternating centre and rim hits - if (lastWasCentre == isCentre) + if (lastWasCentre == isCentre && MustAlternate) return false; // If we've already successfully judged a tick this frame, do not judge more. 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/Objects/Swell.cs b/osu.Game.Rulesets.Taiko/Objects/Swell.cs index a8db8df021..d9e8c77ea7 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Swell.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Swell.cs @@ -33,6 +33,7 @@ namespace osu.Game.Rulesets.Taiko.Objects cancellationToken.ThrowIfCancellationRequested(); AddNested(new SwellTick { + StartTime = StartTime, Samples = Samples }); } diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/TaikoArgonSkinTransformer.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/TaikoArgonSkinTransformer.cs index 9fcecd2b1a..bfc9e8648d 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Argon/TaikoArgonSkinTransformer.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/TaikoArgonSkinTransformer.cs @@ -14,11 +14,11 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Argon { } - public override Drawable? GetDrawableComponent(ISkinComponentLookup component) + public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup) { - switch (component) + switch (lookup) { - case GameplaySkinComponentLookup resultComponent: + case SkinComponentLookup resultComponent: // This should eventually be moved to a skin setting, when supported. if (Skin is ArgonProSkin && resultComponent.Component >= HitResult.Great) return Drawable.Empty(); @@ -75,7 +75,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Argon break; } - return base.GetDrawableComponent(component); + return base.GetDrawableComponent(lookup); } } } diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/VolumeAwareHitSampleInfo.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/VolumeAwareHitSampleInfo.cs index 3ca4b5a3c7..288ffde052 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Argon/VolumeAwareHitSampleInfo.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/VolumeAwareHitSampleInfo.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using osu.Game.Audio; @@ -48,5 +49,24 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Argon return originalBank; } } + + public override bool Equals(HitSampleInfo? other) => other is VolumeAwareHitSampleInfo && base.Equals(other); + + /// + /// + /// This override attempts to match the override above, but in theory it is not strictly necessary. + /// Recall that must meet the following requirements: + /// + /// + /// "If two objects compare as equal, the method for each object must return the same value. + /// However, if two objects do not compare as equal, methods for the two objects do not have to return different values." + /// + /// + /// Making this override combine the value generated by the base implementation with a constant means + /// that and instances which have the same values of their members + /// will not have equal hash codes, which is slightly more efficient when these objects are used as dictionary keys. + /// + /// + public override int GetHashCode() => HashCode.Combine(base.GetHashCode(), 1); } } diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs index 5543a31ec9..78be0ef643 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -9,6 +10,7 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Game.Graphics; using osu.Game.Skinning; +using osuTK; using osuTK.Graphics; namespace osu.Game.Rulesets.Taiko.Skinning.Legacy @@ -19,13 +21,22 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy { get { - var headDrawQuad = headCircle.ScreenSpaceDrawQuad; - var tailDrawQuad = tailCircle.ScreenSpaceDrawQuad; + // the reason why this calculation is so involved is that the head & tail sprites have different sizes/radii. + // therefore naively taking the SSDQs of them and making a quad out of them results in a trapezoid shape and not a box. + var headCentre = headCircle.ScreenSpaceDrawQuad.Centre; + var tailCentre = (tailCircle.ScreenSpaceDrawQuad.TopLeft + tailCircle.ScreenSpaceDrawQuad.BottomLeft) / 2; - return new Quad(headDrawQuad.TopLeft, tailDrawQuad.TopRight, headDrawQuad.BottomLeft, tailDrawQuad.BottomRight); + float headRadius = headCircle.ScreenSpaceDrawQuad.Height / 2; + float tailRadius = tailCircle.ScreenSpaceDrawQuad.Height / 2; + float radius = Math.Max(headRadius, tailRadius); + + var rectangle = new RectangleF(headCentre.X, headCentre.Y, tailCentre.X - headCentre.X, 0).Inflate(radius); + return new Quad(rectangle.TopLeft, rectangle.TopRight, rectangle.BottomLeft, rectangle.BottomRight); } } + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => ScreenSpaceDrawQuad.Contains(screenSpacePos); + private LegacyCirclePiece headCircle = null!; private Sprite body = null!; diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyKiaiGlow.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyKiaiGlow.cs index 623243e9e1..9877efa127 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyKiaiGlow.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyKiaiGlow.cs @@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy Origin = Anchor.Centre, Anchor = Anchor.Centre, Alpha = 0, - Scale = new Vector2(0.7f), + Scale = new Vector2(TaikoLegacyHitTarget.SCALE), Colour = new Colour4(255, 228, 0, 255), }; @@ -58,8 +58,8 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy if (!result.IsHit || !isKiaiActive) return; - sprite.ScaleTo(0.85f).Then() - .ScaleTo(0.7f, 80, Easing.OutQuad); + sprite.ScaleTo(TaikoLegacyHitTarget.SCALE + 0.15f).Then() + .ScaleTo(TaikoLegacyHitTarget.SCALE, 80, Easing.OutQuad); } } } diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacyHitTarget.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacyHitTarget.cs index 0b43f1c845..2a008d81d9 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacyHitTarget.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacyHitTarget.cs @@ -12,6 +12,12 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy { public partial class TaikoLegacyHitTarget : CompositeDrawable { + /// + /// In stable this is 0.7f (see https://github.com/peppy/osu-stable-reference/blob/7519cafd1823f1879c0d9c991ba0e5c7fd3bfa02/osu!/GameModes/Play/Rulesets/Taiko/RulesetTaiko.cs#L592) + /// but for whatever reason this doesn't match visually. + /// + public const float SCALE = 0.8f; + [BackgroundDependencyLoader] private void load(ISkinSource skin) { @@ -22,7 +28,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy new Sprite { Texture = skin.GetTexture("approachcircle"), - Scale = new Vector2(0.83f), + Scale = new Vector2(SCALE + 0.03f), Alpha = 0.47f, // eyeballed to match stable Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -30,7 +36,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy new Sprite { Texture = skin.GetTexture("taikobigcircle"), - Scale = new Vector2(0.8f), + Scale = new Vector2(SCALE), Alpha = 0.22f, // eyeballed to match stable Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs index 894b91e9ce..5bdb824f1c 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs @@ -29,7 +29,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup) { - if (lookup is GameplaySkinComponentLookup) + if (lookup is SkinComponentLookup) { // if a taiko skin is providing explosion sprites, hide the judgements completely if (hasExplosion.Value) diff --git a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs index d7184bce60..70e429a344 100644 --- a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; @@ -36,6 +35,8 @@ using osu.Game.Rulesets.Configuration; using osu.Game.Configuration; using osu.Game.Rulesets.Scoring.Legacy; using osu.Game.Rulesets.Taiko.Configuration; +using osu.Game.Rulesets.Taiko.Edit.Setup; +using osu.Game.Screens.Edit.Setup; namespace osu.Game.Rulesets.Taiko { @@ -79,43 +80,43 @@ namespace osu.Game.Rulesets.Taiko public override IEnumerable ConvertFromLegacyMods(LegacyMods mods) { - if (mods.HasFlagFast(LegacyMods.Nightcore)) + if (mods.HasFlag(LegacyMods.Nightcore)) yield return new TaikoModNightcore(); - else if (mods.HasFlagFast(LegacyMods.DoubleTime)) + else if (mods.HasFlag(LegacyMods.DoubleTime)) yield return new TaikoModDoubleTime(); - if (mods.HasFlagFast(LegacyMods.Perfect)) + if (mods.HasFlag(LegacyMods.Perfect)) yield return new TaikoModPerfect(); - else if (mods.HasFlagFast(LegacyMods.SuddenDeath)) + else if (mods.HasFlag(LegacyMods.SuddenDeath)) yield return new TaikoModSuddenDeath(); - if (mods.HasFlagFast(LegacyMods.Cinema)) + if (mods.HasFlag(LegacyMods.Cinema)) yield return new TaikoModCinema(); - else if (mods.HasFlagFast(LegacyMods.Autoplay)) + else if (mods.HasFlag(LegacyMods.Autoplay)) yield return new TaikoModAutoplay(); - if (mods.HasFlagFast(LegacyMods.Easy)) + if (mods.HasFlag(LegacyMods.Easy)) yield return new TaikoModEasy(); - if (mods.HasFlagFast(LegacyMods.Flashlight)) + if (mods.HasFlag(LegacyMods.Flashlight)) yield return new TaikoModFlashlight(); - if (mods.HasFlagFast(LegacyMods.HalfTime)) + if (mods.HasFlag(LegacyMods.HalfTime)) yield return new TaikoModHalfTime(); - if (mods.HasFlagFast(LegacyMods.HardRock)) + if (mods.HasFlag(LegacyMods.HardRock)) yield return new TaikoModHardRock(); - if (mods.HasFlagFast(LegacyMods.Hidden)) + if (mods.HasFlag(LegacyMods.Hidden)) yield return new TaikoModHidden(); - if (mods.HasFlagFast(LegacyMods.NoFail)) + if (mods.HasFlag(LegacyMods.NoFail)) yield return new TaikoModNoFail(); - if (mods.HasFlagFast(LegacyMods.Relax)) + if (mods.HasFlag(LegacyMods.Relax)) yield return new TaikoModRelax(); - if (mods.HasFlagFast(LegacyMods.ScoreV2)) + if (mods.HasFlag(LegacyMods.ScoreV2)) yield return new ModScoreV2(); } @@ -150,6 +151,7 @@ namespace osu.Game.Rulesets.Taiko new TaikoModClassic(), new TaikoModSwap(), new TaikoModSingleTap(), + new TaikoModConstantSpeed(), }; case ModType.Automation: @@ -188,6 +190,14 @@ namespace osu.Game.Rulesets.Taiko public override HitObjectComposer CreateHitObjectComposer() => new TaikoHitObjectComposer(this); + public override IEnumerable CreateEditorSetupSections() => + [ + new MetadataSection(), + new TaikoDifficultySection(), + new ResourcesSection(), + new DesignSection(), + ]; + public override IBeatmapVerifier CreateBeatmapVerifier() => new TaikoBeatmapVerifier(); public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => new TaikoDifficultyCalculator(RulesetInfo, beatmap); diff --git a/osu.Game.Rulesets.Taiko/TaikoSkinComponentLookup.cs b/osu.Game.Rulesets.Taiko/TaikoSkinComponentLookup.cs index 8841c3d3ca..2fa4d3c9cb 100644 --- a/osu.Game.Rulesets.Taiko/TaikoSkinComponentLookup.cs +++ b/osu.Game.Rulesets.Taiko/TaikoSkinComponentLookup.cs @@ -5,7 +5,7 @@ using osu.Game.Skinning; namespace osu.Game.Rulesets.Taiko { - public class TaikoSkinComponentLookup : GameplaySkinComponentLookup + public class TaikoSkinComponentLookup : SkinComponentLookup { public TaikoSkinComponentLookup(TaikoSkinComponents component) : base(component) diff --git a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs index a235c08b84..4185b67f4c 100644 --- a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs @@ -82,7 +82,15 @@ namespace osu.Game.Rulesets.Taiko.UI TimeRange.Value = ComputeTimeRange(); } - protected virtual double ComputeTimeRange() => PlayfieldAdjustmentContainer.ComputeTimeRange(); + protected virtual double ComputeTimeRange() + { + // Using the constant algorithm results in a sluggish scroll speed that's equal to 60 BPM. + // We need to adjust it to the expected default scroll speed (BPM * base SV multiplier). + double multiplier = VisualisationMethod == ScrollVisualisationMethod.Constant + ? (Beatmap.BeatmapInfo.BPM * Beatmap.Difficulty.SliderMultiplier) / 60 + : 1; + return PlayfieldAdjustmentContainer.ComputeTimeRange() / multiplier; + } protected override void UpdateAfterChildren() { diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs index 0510f08068..bdcb341fb4 100644 --- a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs +++ b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs @@ -7,7 +7,6 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Primitives; using osu.Game.Graphics; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Judgements; @@ -345,7 +344,7 @@ namespace osu.Game.Rulesets.Taiko.UI { public void Add(Drawable proxy) => AddInternal(proxy); - public override bool UpdateSubTreeMasking(Drawable source, RectangleF maskingBounds) + public override bool UpdateSubTreeMasking() { // DrawableHitObject disables masking. // Hitobject content is proxied and unproxied based on hit status and the IsMaskedAway value could get stuck because of this. diff --git a/osu.Game.Tests/Beatmaps/BeatmapUpdaterMetadataLookupTest.cs b/osu.Game.Tests/Beatmaps/BeatmapUpdaterMetadataLookupTest.cs index 11c4c54ea6..82e54875ef 100644 --- a/osu.Game.Tests/Beatmaps/BeatmapUpdaterMetadataLookupTest.cs +++ b/osu.Game.Tests/Beatmaps/BeatmapUpdaterMetadataLookupTest.cs @@ -241,8 +241,8 @@ namespace osu.Game.Tests.Beatmaps metadataLookup.Update(beatmapSet, preferOnlineFetch); - Assert.That(beatmap.Status, Is.EqualTo(BeatmapOnlineStatus.None)); - Assert.That(beatmap.OnlineID, Is.EqualTo(-1)); + Assert.That(beatmap.Status, Is.EqualTo(BeatmapOnlineStatus.Ranked)); + Assert.That(beatmap.OnlineID, Is.EqualTo(654321)); } [Test] @@ -273,34 +273,6 @@ namespace osu.Game.Tests.Beatmaps Assert.That(beatmap.OnlineID, Is.EqualTo(654321)); } - [Test] - public void TestMetadataLookupForBeatmapWithoutPopulatedIDAndIncorrectHash([Values] bool preferOnlineFetch) - { - var lookupResult = new OnlineBeatmapMetadata - { - BeatmapID = 654321, - BeatmapStatus = BeatmapOnlineStatus.Ranked, - MD5Hash = @"cafebabe", - }; - - var targetMock = preferOnlineFetch ? apiMetadataSourceMock : localCachedMetadataSourceMock; - targetMock.Setup(src => src.Available).Returns(true); - targetMock.Setup(src => src.TryLookup(It.IsAny(), out lookupResult)) - .Returns(true); - - var beatmap = new BeatmapInfo - { - MD5Hash = @"deadbeef" - }; - var beatmapSet = new BeatmapSetInfo(beatmap.Yield()); - beatmap.BeatmapSet = beatmapSet; - - metadataLookup.Update(beatmapSet, preferOnlineFetch); - - Assert.That(beatmap.Status, Is.EqualTo(BeatmapOnlineStatus.None)); - Assert.That(beatmap.OnlineID, Is.EqualTo(-1)); - } - [Test] public void TestReturnedMetadataHasDifferentHash([Values] bool preferOnlineFetch) { @@ -383,58 +355,5 @@ namespace osu.Game.Tests.Beatmaps Assert.That(beatmapSet.Status, Is.EqualTo(BeatmapOnlineStatus.None)); } - - [Test] - public void TestPartiallyMaliciousSet([Values] bool preferOnlineFetch) - { - var firstResult = new OnlineBeatmapMetadata - { - BeatmapID = 654321, - BeatmapStatus = BeatmapOnlineStatus.Ranked, - BeatmapSetStatus = BeatmapOnlineStatus.Ranked, - MD5Hash = @"cafebabe" - }; - var secondResult = new OnlineBeatmapMetadata - { - BeatmapStatus = BeatmapOnlineStatus.Ranked, - BeatmapSetStatus = BeatmapOnlineStatus.Ranked, - MD5Hash = @"dededede" - }; - - var targetMock = preferOnlineFetch ? apiMetadataSourceMock : localCachedMetadataSourceMock; - targetMock.Setup(src => src.Available).Returns(true); - targetMock.Setup(src => src.TryLookup(It.Is(bi => bi.OnlineID == 654321), out firstResult)) - .Returns(true); - targetMock.Setup(src => src.TryLookup(It.Is(bi => bi.OnlineID == 666666), out secondResult)) - .Returns(true); - - var firstBeatmap = new BeatmapInfo - { - OnlineID = 654321, - MD5Hash = @"cafebabe", - }; - var secondBeatmap = new BeatmapInfo - { - OnlineID = 666666, - MD5Hash = @"deadbeef" - }; - var beatmapSet = new BeatmapSetInfo(new[] - { - firstBeatmap, - secondBeatmap - }); - firstBeatmap.BeatmapSet = beatmapSet; - secondBeatmap.BeatmapSet = beatmapSet; - - metadataLookup.Update(beatmapSet, preferOnlineFetch); - - Assert.That(firstBeatmap.Status, Is.EqualTo(BeatmapOnlineStatus.Ranked)); - Assert.That(firstBeatmap.OnlineID, Is.EqualTo(654321)); - - Assert.That(secondBeatmap.Status, Is.EqualTo(BeatmapOnlineStatus.None)); - Assert.That(secondBeatmap.OnlineID, Is.EqualTo(-1)); - - Assert.That(beatmapSet.Status, Is.EqualTo(BeatmapOnlineStatus.None)); - } } } diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs index 02432a1935..54ebebeb7b 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs @@ -468,6 +468,40 @@ namespace osu.Game.Tests.Beatmaps.Formats } } + [Test] + public void TestDecodeBeatmapHitObjectCoordinatesLegacy() + { + var decoder = new LegacyBeatmapDecoder(); + + using (var resStream = TestResources.OpenResource("hitobject-coordinates-legacy.osu")) + using (var stream = new LineBufferedReader(resStream)) + { + var hitObjects = decoder.Decode(stream).HitObjects; + + var positionData = hitObjects[0] as IHasPosition; + + Assert.IsNotNull(positionData); + Assert.AreEqual(new Vector2(256, 256), positionData!.Position); + } + } + + [Test] + public void TestDecodeBeatmapHitObjectCoordinatesLazer() + { + var decoder = new LegacyBeatmapDecoder(LegacyBeatmapEncoder.FIRST_LAZER_VERSION); + + using (var resStream = TestResources.OpenResource("hitobject-coordinates-lazer.osu")) + using (var stream = new LineBufferedReader(resStream)) + { + var hitObjects = decoder.Decode(stream).HitObjects; + + var positionData = hitObjects[0] as IHasPosition; + + Assert.IsNotNull(positionData); + Assert.AreEqual(new Vector2(256.99853f, 256.001f), positionData!.Position); + } + } + [Test] public void TestDecodeBeatmapHitObjects() { @@ -528,8 +562,17 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.AreEqual("Gameplay/normal-hitnormal2", getTestableSampleInfo(hitObjects[2]).LookupNames.First()); Assert.AreEqual("Gameplay/normal-hitnormal", getTestableSampleInfo(hitObjects[3]).LookupNames.First()); - // The control point at the end time of the slider should be applied - Assert.AreEqual("Gameplay/soft-hitnormal8", getTestableSampleInfo(hitObjects[4]).LookupNames.First()); + // The fourth object is a slider. + // `Samples` of a slider are presumed to control the volume of sounds that last the entire duration of the slider + // (such as ticks, slider slide sounds, etc.) + // Thus, the point of query of control points used for `Samples` is just beyond the start time of the slider. + Assert.AreEqual("Gameplay/soft-hitnormal11", getTestableSampleInfo(hitObjects[4]).LookupNames.First()); + + // That said, the `NodeSamples` of the slider are responsible for the sounds of the slider's head / tail / repeats / large ticks etc. + // Therefore, they should be read at the time instant correspondent to the given node. + // This means that the tail should use bank 8 rather than 11. + Assert.AreEqual("Gameplay/soft-hitnormal11", ((ConvertSlider)hitObjects[4]).NodeSamples[0][0].LookupNames.First()); + Assert.AreEqual("Gameplay/soft-hitnormal8", ((ConvertSlider)hitObjects[4]).NodeSamples[1][0].LookupNames.First()); } static HitSampleInfo getTestableSampleInfo(HitObject hitObject) => hitObject.Samples[0]; @@ -1188,5 +1231,36 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.That(beatmap.HitObjects[0].GetEndTime(), Is.EqualTo(3153)); } } + + [Test] + public void TestBeatmapDifficultyIsClamped() + { + var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false }; + + using (var resStream = TestResources.OpenResource("out-of-range-difficulties.osu")) + using (var stream = new LineBufferedReader(resStream)) + { + var decoded = decoder.Decode(stream).Difficulty; + Assert.That(decoded.DrainRate, Is.EqualTo(10)); + Assert.That(decoded.CircleSize, Is.EqualTo(10)); + Assert.That(decoded.OverallDifficulty, Is.EqualTo(10)); + Assert.That(decoded.ApproachRate, Is.EqualTo(10)); + Assert.That(decoded.SliderMultiplier, Is.EqualTo(3.6)); + Assert.That(decoded.SliderTickRate, Is.EqualTo(8)); + } + } + + [Test] + public void TestManiaBeatmapDifficultyCircleSizeClamp() + { + var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false }; + + using (var resStream = TestResources.OpenResource("out-of-range-difficulties-mania.osu")) + using (var stream = new LineBufferedReader(resStream)) + { + var decoded = decoder.Decode(stream).Difficulty; + Assert.That(decoded.CircleSize, Is.EqualTo(14)); + } + } } } diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs index e847b61fbe..c8a09786ec 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs @@ -25,6 +25,7 @@ using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Taiko; using osu.Game.Skinning; +using osu.Game.Storyboards; using osu.Game.Tests.Resources; using osuTK; @@ -37,6 +38,22 @@ namespace osu.Game.Tests.Beatmaps.Formats private static IEnumerable allBeatmaps = beatmaps_resource_store.GetAvailableResources().Where(res => res.EndsWith(".osu", StringComparison.Ordinal)); + [Test] + public void TestUnsupportedStoryboardEvents() + { + const string name = "Resources/storyboard_only_video.osu"; + + var decoded = decodeFromLegacy(beatmaps_resource_store.GetStream(name), name); + Assert.That(decoded.beatmap.UnhandledEventLines.Count, Is.EqualTo(1)); + Assert.That(decoded.beatmap.UnhandledEventLines.Single(), Is.EqualTo("Video,0,\"video.avi\"")); + + var memoryStream = encodeToLegacy(decoded); + + var storyboard = new LegacyStoryboardDecoder().Decode(new LineBufferedReader(memoryStream)); + StoryboardLayer video = storyboard.Layers.Single(l => l.Name == "Video"); + Assert.That(video.Elements.Count, Is.EqualTo(1)); + } + [TestCaseSource(nameof(allBeatmaps))] public void TestEncodeDecodeStability(string name) { @@ -103,11 +120,11 @@ namespace osu.Game.Tests.Beatmaps.Formats private void compareBeatmaps((IBeatmap beatmap, TestLegacySkin skin) expected, (IBeatmap beatmap, TestLegacySkin skin) actual) { // Check all control points that are still considered to be at a global level. - Assert.That(expected.beatmap.ControlPointInfo.TimingPoints.Serialize(), Is.EqualTo(actual.beatmap.ControlPointInfo.TimingPoints.Serialize())); - Assert.That(expected.beatmap.ControlPointInfo.EffectPoints.Serialize(), Is.EqualTo(actual.beatmap.ControlPointInfo.EffectPoints.Serialize())); + Assert.That(actual.beatmap.ControlPointInfo.TimingPoints.Serialize(), Is.EqualTo(expected.beatmap.ControlPointInfo.TimingPoints.Serialize())); + Assert.That(actual.beatmap.ControlPointInfo.EffectPoints.Serialize(), Is.EqualTo(expected.beatmap.ControlPointInfo.EffectPoints.Serialize())); // Check all hitobjects. - Assert.That(expected.beatmap.HitObjects.Serialize(), Is.EqualTo(actual.beatmap.HitObjects.Serialize())); + Assert.That(actual.beatmap.HitObjects.Serialize(), Is.EqualTo(expected.beatmap.HitObjects.Serialize())); // Check skin. Assert.IsTrue(areComboColoursEqual(expected.skin.Configuration, actual.skin.Configuration)); diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs index 43e471320e..713f2f3fb1 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs @@ -14,11 +14,12 @@ using osu.Game.Beatmaps; using osu.Game.Beatmaps.Formats; using osu.Game.Beatmaps.Legacy; using osu.Game.IO.Legacy; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Replays; using osu.Game.Rulesets; using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Mania; -using osu.Game.Rulesets.Mania.Mods; +using osu.Game.Rulesets.Mania.Replays; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; @@ -31,6 +32,8 @@ using osu.Game.Rulesets.Taiko; using osu.Game.Scoring; using osu.Game.Scoring.Legacy; using osu.Game.Tests.Resources; +using osu.Game.Users; +using osuTK; namespace osu.Game.Tests.Beatmaps.Formats { @@ -62,14 +65,13 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.AreEqual(829_931, score.ScoreInfo.LegacyTotalScore); Assert.AreEqual(3, score.ScoreInfo.MaxCombo); - Assert.IsTrue(score.ScoreInfo.Mods.Any(m => m is ManiaModClassic)); - Assert.IsTrue(score.ScoreInfo.APIMods.Any(m => m.Acronym == "CL")); - Assert.IsTrue(score.ScoreInfo.ModsJson.Contains("CL")); + Assert.That(score.ScoreInfo.APIMods.Select(m => m.Acronym), Is.EquivalentTo(new[] { "CL", "9K", "DS" })); Assert.That((2 * 300d + 1 * 200) / (3 * 305d), Is.EqualTo(score.ScoreInfo.Accuracy).Within(0.0001)); Assert.AreEqual(ScoreRank.B, score.ScoreInfo.Rank); - Assert.That(score.Replay.Frames, Is.Not.Empty); + Assert.That(score.Replay.Frames, Has.One.Matches(frame => + frame.Time == 414 && frame.Actions.SequenceEqual(new[] { ManiaAction.Key1, ManiaAction.Key18 }))); } } @@ -124,17 +126,20 @@ namespace osu.Game.Tests.Beatmaps.Formats [TestCase(LegacyBeatmapDecoder.LATEST_VERSION, false)] public void TestLegacyBeatmapReplayOffsetsDecode(int beatmapVersion, bool offsetApplied) { - const double first_frame_time = 48; - const double second_frame_time = 65; + const double first_frame_time = 31; + const double second_frame_time = 48; + const double third_frame_time = 65; var decoder = new TestLegacyScoreDecoder(beatmapVersion); using (var resourceStream = TestResources.OpenResource("Replays/mania-replay.osr")) { var score = decoder.Parse(resourceStream); + int offset = offsetApplied ? LegacyBeatmapDecoder.EARLY_VERSION_TIMING_OFFSET : 0; - Assert.That(score.Replay.Frames[0].Time, Is.EqualTo(first_frame_time + (offsetApplied ? LegacyBeatmapDecoder.EARLY_VERSION_TIMING_OFFSET : 0))); - Assert.That(score.Replay.Frames[1].Time, Is.EqualTo(second_frame_time + (offsetApplied ? LegacyBeatmapDecoder.EARLY_VERSION_TIMING_OFFSET : 0))); + Assert.That(score.Replay.Frames[0].Time, Is.EqualTo(first_frame_time + offset)); + Assert.That(score.Replay.Frames[1].Time, Is.EqualTo(second_frame_time + offset)); + Assert.That(score.Replay.Frames[2].Time, Is.EqualTo(third_frame_time + offset)); } } @@ -175,6 +180,94 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.That(decodedAfterEncode.Replay.Frames[1].Time, Is.EqualTo(second_frame_time)); } + [Test] + public void TestNegativeFrameSkipped() + { + var ruleset = new OsuRuleset().RulesetInfo; + var scoreInfo = TestResources.CreateTestScoreInfo(ruleset); + var beatmap = new TestBeatmap(ruleset); + + var score = new Score + { + ScoreInfo = scoreInfo, + Replay = new Replay + { + Frames = new List + { + new OsuReplayFrame(0, new Vector2()), + new OsuReplayFrame(1000, OsuPlayfield.BASE_SIZE), + new OsuReplayFrame(500, OsuPlayfield.BASE_SIZE / 2), + new OsuReplayFrame(2000, OsuPlayfield.BASE_SIZE), + } + } + }; + + var decodedAfterEncode = encodeThenDecode(LegacyScoreEncoder.LATEST_VERSION, score, beatmap); + + Assert.That(decodedAfterEncode.Replay.Frames, Has.Count.EqualTo(3)); + Assert.That(decodedAfterEncode.Replay.Frames[0].Time, Is.EqualTo(0)); + Assert.That(decodedAfterEncode.Replay.Frames[1].Time, Is.EqualTo(1000)); + Assert.That(decodedAfterEncode.Replay.Frames[2].Time, Is.EqualTo(2000)); + } + + [Test] + public void FirstTwoFramesSwappedIfInWrongOrder() + { + var ruleset = new OsuRuleset().RulesetInfo; + var scoreInfo = TestResources.CreateTestScoreInfo(ruleset); + var beatmap = new TestBeatmap(ruleset); + + var score = new Score + { + ScoreInfo = scoreInfo, + Replay = new Replay + { + Frames = new List + { + new OsuReplayFrame(100, new Vector2()), + new OsuReplayFrame(50, OsuPlayfield.BASE_SIZE / 2), + new OsuReplayFrame(1000, OsuPlayfield.BASE_SIZE), + } + } + }; + + var decodedAfterEncode = encodeThenDecode(LegacyScoreEncoder.LATEST_VERSION, score, beatmap); + + Assert.That(decodedAfterEncode.Replay.Frames, Has.Count.EqualTo(3)); + Assert.That(decodedAfterEncode.Replay.Frames[0].Time, Is.EqualTo(0)); + Assert.That(decodedAfterEncode.Replay.Frames[1].Time, Is.EqualTo(100)); + Assert.That(decodedAfterEncode.Replay.Frames[2].Time, Is.EqualTo(1000)); + } + + [Test] + public void FirstTwoFramesPulledTowardThirdIfTheyAreAfterIt() + { + var ruleset = new OsuRuleset().RulesetInfo; + var scoreInfo = TestResources.CreateTestScoreInfo(ruleset); + var beatmap = new TestBeatmap(ruleset); + + var score = new Score + { + ScoreInfo = scoreInfo, + Replay = new Replay + { + Frames = new List + { + new OsuReplayFrame(0, new Vector2()), + new OsuReplayFrame(500, OsuPlayfield.BASE_SIZE / 2), + new OsuReplayFrame(-1500, OsuPlayfield.BASE_SIZE), + } + } + }; + + var decodedAfterEncode = encodeThenDecode(LegacyScoreEncoder.LATEST_VERSION, score, beatmap); + + Assert.That(decodedAfterEncode.Replay.Frames, Has.Count.EqualTo(3)); + Assert.That(decodedAfterEncode.Replay.Frames[0].Time, Is.EqualTo(-1500)); + Assert.That(decodedAfterEncode.Replay.Frames[1].Time, Is.EqualTo(-1500)); + Assert.That(decodedAfterEncode.Replay.Frames[2].Time, Is.EqualTo(-1500)); + } + [Test] public void TestCultureInvariance() { @@ -224,6 +317,12 @@ namespace osu.Game.Tests.Beatmaps.Formats new OsuModDoubleTime { SpeedChange = { Value = 1.1 } } }; scoreInfo.OnlineID = 123123; + scoreInfo.User = new APIUser + { + Username = "spaceman_atlas", + Id = 3035836, + CountryCode = CountryCode.PL + }; scoreInfo.ClientVersion = "2023.1221.0"; var beatmap = new TestBeatmap(ruleset); @@ -248,6 +347,7 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.That(decodedAfterEncode.ScoreInfo.MaximumStatistics, Is.EqualTo(scoreInfo.MaximumStatistics)); Assert.That(decodedAfterEncode.ScoreInfo.Mods, Is.EqualTo(scoreInfo.Mods)); Assert.That(decodedAfterEncode.ScoreInfo.ClientVersion, Is.EqualTo("2023.1221.0")); + Assert.That(decodedAfterEncode.ScoreInfo.RealmUser.OnlineID, Is.EqualTo(3035836)); }); } @@ -352,6 +452,7 @@ namespace osu.Game.Tests.Beatmaps.Formats [HitResult.Great] = 200, [HitResult.LargeTickHit] = 1, }; + scoreInfo.Rank = ScoreRank.A; var beatmap = new TestBeatmap(ruleset); var score = new Score @@ -412,6 +513,80 @@ namespace osu.Game.Tests.Beatmaps.Formats }); } + [Test] + public void TestTotalScoreWithoutModsReadIfPresent() + { + var ruleset = new OsuRuleset().RulesetInfo; + + var scoreInfo = TestResources.CreateTestScoreInfo(ruleset); + scoreInfo.Mods = new Mod[] + { + new OsuModDoubleTime { SpeedChange = { Value = 1.1 } } + }; + scoreInfo.OnlineID = 123123; + scoreInfo.ClientVersion = "2023.1221.0"; + scoreInfo.TotalScoreWithoutMods = 1_000_000; + scoreInfo.TotalScore = 1_020_000; + + var beatmap = new TestBeatmap(ruleset); + var score = new Score + { + ScoreInfo = scoreInfo, + Replay = new Replay + { + Frames = new List + { + new OsuReplayFrame(2000, OsuPlayfield.BASE_SIZE / 2, OsuAction.LeftButton) + } + } + }; + + var decodedAfterEncode = encodeThenDecode(LegacyBeatmapDecoder.LATEST_VERSION, score, beatmap); + + Assert.Multiple(() => + { + Assert.That(decodedAfterEncode.ScoreInfo.TotalScoreWithoutMods, Is.EqualTo(1_000_000)); + Assert.That(decodedAfterEncode.ScoreInfo.TotalScore, Is.EqualTo(1_020_000)); + }); + } + + [Test] + public void TestTotalScoreWithoutModsBackwardsPopulatedIfMissing() + { + var ruleset = new OsuRuleset().RulesetInfo; + + var scoreInfo = TestResources.CreateTestScoreInfo(ruleset); + scoreInfo.Mods = new Mod[] + { + new OsuModDoubleTime { SpeedChange = { Value = 1.1 } } + }; + scoreInfo.OnlineID = 123123; + scoreInfo.ClientVersion = "2023.1221.0"; + scoreInfo.TotalScoreWithoutMods = 0; + scoreInfo.TotalScore = 1_020_000; + + var beatmap = new TestBeatmap(ruleset); + var score = new Score + { + ScoreInfo = scoreInfo, + Replay = new Replay + { + Frames = new List + { + new OsuReplayFrame(2000, OsuPlayfield.BASE_SIZE / 2, OsuAction.LeftButton) + } + } + }; + + var decodedAfterEncode = encodeThenDecode(LegacyBeatmapDecoder.LATEST_VERSION, score, beatmap); + + Assert.Multiple(() => + { + Assert.That(decodedAfterEncode.ScoreInfo.TotalScoreWithoutMods, Is.EqualTo(1_000_000)); + Assert.That(decodedAfterEncode.ScoreInfo.TotalScore, Is.EqualTo(1_020_000)); + }); + } + private static Score encodeThenDecode(int beatmapVersion, Score score, TestBeatmap beatmap) { var encodeStream = new MemoryStream(); diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyScoreEncoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyScoreEncoderTest.cs index 806f538249..1e57bd76cf 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyScoreEncoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyScoreEncoderTest.cs @@ -20,7 +20,7 @@ namespace osu.Game.Tests.Beatmaps.Formats [TestCase(1, 3)] [TestCase(1, 0)] [TestCase(0, 3)] - public void CatchMergesFruitAndDropletMisses(int missCount, int largeTickMissCount) + public void TestCatchMergesFruitAndDropletMisses(int missCount, int largeTickMissCount) { var ruleset = new CatchRuleset().RulesetInfo; var scoreInfo = TestResources.CreateTestScoreInfo(ruleset); @@ -41,7 +41,22 @@ namespace osu.Game.Tests.Beatmaps.Formats } [Test] - public void ScoreWithMissIsNotPerfect() + public void TestFailPreserved() + { + var ruleset = new OsuRuleset().RulesetInfo; + var scoreInfo = TestResources.CreateTestScoreInfo(); + var beatmap = new TestBeatmap(ruleset); + + scoreInfo.Rank = ScoreRank.F; + + var score = new Score { ScoreInfo = scoreInfo }; + var decodedAfterEncode = encodeThenDecode(LegacyBeatmapDecoder.LATEST_VERSION, score, beatmap); + + Assert.That(decodedAfterEncode.ScoreInfo.Rank, Is.EqualTo(ScoreRank.F)); + } + + [Test] + public void TestScoreWithMissIsNotPerfect() { var ruleset = new OsuRuleset().RulesetInfo; var scoreInfo = TestResources.CreateTestScoreInfo(ruleset); diff --git a/osu.Game.Tests/Beatmaps/IO/LegacyBeatmapExporterTest.cs b/osu.Game.Tests/Beatmaps/IO/LegacyBeatmapExporterTest.cs new file mode 100644 index 0000000000..9947def06d --- /dev/null +++ b/osu.Game.Tests/Beatmaps/IO/LegacyBeatmapExporterTest.cs @@ -0,0 +1,63 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.IO; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Tests.Resources; +using osu.Game.Tests.Visual; +using MemoryStream = System.IO.MemoryStream; + +namespace osu.Game.Tests.Beatmaps.IO +{ + [HeadlessTest] + public partial class LegacyBeatmapExporterTest : OsuTestScene + { + [Resolved] + private BeatmapManager beatmaps { get; set; } = null!; + + [Test] + public void TestObjectsSnappedAfterTruncatingExport() + { + IWorkingBeatmap beatmap = null!; + MemoryStream outStream = null!; + + // Ensure importer encoding is correct + AddStep("import beatmap", () => beatmap = importBeatmapFromArchives(@"decimal-timing-beatmap.olz")); + AddAssert("timing point has decimal offset", () => beatmap.Beatmap.ControlPointInfo.TimingPoints[0].Time, () => Is.EqualTo(284.725).Within(0.001)); + AddAssert("kiai has decimal offset", () => beatmap.Beatmap.ControlPointInfo.EffectPoints[0].Time, () => Is.EqualTo(28520.019).Within(0.001)); + AddAssert("hit object has decimal offset", () => beatmap.Beatmap.HitObjects[0].StartTime, () => Is.EqualTo(28520.019).Within(0.001)); + + // Ensure exporter legacy conversion is correct + AddStep("export", () => + { + outStream = new MemoryStream(); + + new LegacyBeatmapExporter(LocalStorage) + .ExportToStream((BeatmapSetInfo)beatmap.BeatmapInfo.BeatmapSet!, outStream, null); + }); + + AddStep("import beatmap again", () => beatmap = importBeatmapFromStream(outStream)); + AddAssert("timing point has truncated offset", () => beatmap.Beatmap.ControlPointInfo.TimingPoints[0].Time, () => Is.EqualTo(284).Within(0.001)); + AddAssert("kiai is snapped", () => beatmap.Beatmap.ControlPointInfo.EffectPoints[0].Time, () => Is.EqualTo(28519).Within(0.001)); + AddAssert("hit object is snapped", () => beatmap.Beatmap.HitObjects[0].StartTime, () => Is.EqualTo(28519).Within(0.001)); + } + + private IWorkingBeatmap importBeatmapFromStream(Stream stream) + { + var imported = beatmaps.Import(new ImportTask(stream, "filename.osz")).GetResultSafely(); + return imported.AsNonNull().PerformRead(s => beatmaps.GetWorkingBeatmap(s.Beatmaps[0])); + } + + private IWorkingBeatmap importBeatmapFromArchives(string filename) + { + var imported = beatmaps.Import(new ImportTask(TestResources.OpenResource($@"Archives/{filename}"), filename)).GetResultSafely(); + return imported.AsNonNull().PerformRead(s => beatmaps.GetWorkingBeatmap(s.Beatmaps[0])); + } + } +} diff --git a/osu.Game.Tests/Chat/TestSceneChannelManager.cs b/osu.Game.Tests/Chat/TestSceneChannelManager.cs index 95fd2669e5..ef4d4f683a 100644 --- a/osu.Game.Tests/Chat/TestSceneChannelManager.cs +++ b/osu.Game.Tests/Chat/TestSceneChannelManager.cs @@ -59,7 +59,7 @@ namespace osu.Game.Tests.Chat return true; case ChatAckRequest ack: - ack.TriggerSuccess(new ChatAckResponse { Silences = silencedUserIds.Select(u => new ChatSilence { UserId = u }).ToList() }); + ack.TriggerSuccess(new ChatAckResponse { Silences = silencedUserIds.Select(u => new ChatSilence { UserId = u }).ToArray() }); silencedUserIds.Clear(); return true; diff --git a/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs b/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs index e960995c45..c40624a3a0 100644 --- a/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs +++ b/osu.Game.Tests/Database/BackgroundDataStoreProcessorTests.cs @@ -22,9 +22,9 @@ namespace osu.Game.Tests.Database [HeadlessTest] public partial class BackgroundDataStoreProcessorTests : OsuTestScene, ILocalUserPlayInfo { - public IBindable IsPlaying => isPlaying; + public IBindable PlayingState => isPlaying; - private readonly Bindable isPlaying = new Bindable(); + private readonly Bindable isPlaying = new Bindable(); private BeatmapSetInfo importedSet = null!; @@ -37,7 +37,7 @@ namespace osu.Game.Tests.Database [SetUpSteps] public void SetUpSteps() { - AddStep("Set not playing", () => isPlaying.Value = false); + AddStep("Set not playing", () => isPlaying.Value = LocalUserPlayingState.NotPlaying); } [Test] @@ -89,7 +89,7 @@ namespace osu.Game.Tests.Database }); }); - AddStep("Set playing", () => isPlaying.Value = true); + AddStep("Set playing", () => isPlaying.Value = LocalUserPlayingState.Playing); AddStep("Reset difficulty", () => { @@ -117,7 +117,7 @@ namespace osu.Game.Tests.Database }); }); - AddStep("Set not playing", () => isPlaying.Value = false); + AddStep("Set not playing", () => isPlaying.Value = LocalUserPlayingState.NotPlaying); AddUntilStep("wait for difficulties repopulated", () => { @@ -157,8 +157,9 @@ namespace osu.Game.Tests.Database AddAssert("Score not marked as failed", () => Realm.Run(r => r.Find(scoreInfo.ID)!.BackgroundReprocessingFailed), () => Is.False); } - [Test] - public void TestScoreUpgradeFailed() + [TestCase(30000002)] + [TestCase(30000013)] + public void TestScoreUpgradeFailed(int scoreVersion) { ScoreInfo scoreInfo = null!; @@ -172,16 +173,18 @@ namespace osu.Game.Tests.Database Ruleset = r.All().First(), }) { - TotalScoreVersion = 30000002, + TotalScoreVersion = scoreVersion, IsLegacyScore = true, }); }); }); - AddStep("Run background processor", () => Add(new TestBackgroundDataStoreProcessor())); + TestBackgroundDataStoreProcessor processor = null!; + AddStep("Run background processor", () => Add(processor = new TestBackgroundDataStoreProcessor())); + AddUntilStep("Wait for completion", () => processor.Completed); AddUntilStep("Score marked as failed", () => Realm.Run(r => r.Find(scoreInfo.ID)!.BackgroundReprocessingFailed), () => Is.True); - AddAssert("Score version not upgraded", () => Realm.Run(r => r.Find(scoreInfo.ID)!.TotalScoreVersion), () => Is.EqualTo(30000002)); + AddAssert("Score version not upgraded", () => Realm.Run(r => r.Find(scoreInfo.ID)!.TotalScoreVersion), () => Is.EqualTo(scoreVersion)); } [Test] diff --git a/osu.Game.Tests/Database/BeatmapImporterTests.cs b/osu.Game.Tests/Database/BeatmapImporterTests.cs index 0eac70f9c8..38746f2567 100644 --- a/osu.Game.Tests/Database/BeatmapImporterTests.cs +++ b/osu.Game.Tests/Database/BeatmapImporterTests.cs @@ -716,7 +716,7 @@ namespace osu.Game.Tests.Database { foreach (var entry in zip.Entries.ToArray()) { - if (entry.Key.EndsWith(".osu", StringComparison.InvariantCulture)) + if (entry.Key!.EndsWith(".osu", StringComparison.InvariantCulture)) zip.RemoveEntry(entry); } diff --git a/osu.Game.Tests/Database/BeatmapImporterUpdateTests.cs b/osu.Game.Tests/Database/BeatmapImporterUpdateTests.cs index d30b3c089e..3f1bc58147 100644 --- a/osu.Game.Tests/Database/BeatmapImporterUpdateTests.cs +++ b/osu.Game.Tests/Database/BeatmapImporterUpdateTests.cs @@ -168,12 +168,12 @@ namespace osu.Game.Tests.Database Assert.That(importAfterUpdate, Is.Not.Null); Debug.Assert(importAfterUpdate != null); + realm.Run(r => r.Refresh()); + // should only contain the modified beatmap (others purged). Assert.That(importBeforeUpdate.Value.Beatmaps, Has.Count.EqualTo(1)); Assert.That(importAfterUpdate.Value.Beatmaps, Has.Count.EqualTo(count_beatmaps)); - realm.Run(r => r.Refresh()); - checkCount(realm, count_beatmaps + 1); checkCount(realm, count_beatmaps + 1); @@ -259,6 +259,44 @@ namespace osu.Game.Tests.Database }); } + [Test] + public void TestNoChangesAfterDelete() + { + RunTestWithRealmAsync(async (realm, storage) => + { + var importer = new BeatmapImporter(storage, realm); + using var rulesets = new RealmRulesetStore(realm, storage); + + using var __ = getBeatmapArchive(out string pathOriginal); + using var _ = getBeatmapArchive(out string pathOriginalSecond); + + var importBeforeUpdate = await importer.Import(new ImportTask(pathOriginal)); + + importBeforeUpdate!.PerformWrite(s => s.DeletePending = true); + + var dateBefore = importBeforeUpdate.Value.DateAdded; + + Assert.That(importBeforeUpdate, Is.Not.Null); + Debug.Assert(importBeforeUpdate != null); + + var importAfterUpdate = await importer.ImportAsUpdate(new ProgressNotification(), new ImportTask(pathOriginalSecond), importBeforeUpdate.Value); + + realm.Run(r => r.Refresh()); + + Assert.That(importAfterUpdate, Is.Not.Null); + Debug.Assert(importAfterUpdate != null); + + checkCount(realm, 1); + checkCount(realm, count_beatmaps); + checkCount(realm, count_beatmaps); + + Assert.That(importBeforeUpdate.Value.Beatmaps.First().OnlineID, Is.GreaterThan(-1)); + Assert.That(importBeforeUpdate.Value.DateAdded, Is.EqualTo(dateBefore)); + Assert.That(importAfterUpdate.Value.DateAdded, Is.EqualTo(dateBefore)); + Assert.That(importBeforeUpdate.ID, Is.EqualTo(importAfterUpdate.ID)); + }); + } + [Test] public void TestNoChanges() { @@ -272,21 +310,25 @@ namespace osu.Game.Tests.Database var importBeforeUpdate = await importer.Import(new ImportTask(pathOriginal)); + var dateBefore = importBeforeUpdate!.Value.DateAdded; + Assert.That(importBeforeUpdate, Is.Not.Null); Debug.Assert(importBeforeUpdate != null); var importAfterUpdate = await importer.ImportAsUpdate(new ProgressNotification(), new ImportTask(pathOriginalSecond), importBeforeUpdate.Value); + realm.Run(r => r.Refresh()); + Assert.That(importAfterUpdate, Is.Not.Null); Debug.Assert(importAfterUpdate != null); - realm.Run(r => r.Refresh()); - checkCount(realm, 1); checkCount(realm, count_beatmaps); checkCount(realm, count_beatmaps); Assert.That(importBeforeUpdate.Value.Beatmaps.First().OnlineID, Is.GreaterThan(-1)); + Assert.That(importBeforeUpdate.Value.DateAdded, Is.EqualTo(dateBefore)); + Assert.That(importAfterUpdate.Value.DateAdded, Is.EqualTo(dateBefore)); Assert.That(importBeforeUpdate.ID, Is.EqualTo(importAfterUpdate.ID)); }); } @@ -479,6 +521,7 @@ namespace osu.Game.Tests.Database using var rulesets = new RealmRulesetStore(realm, storage); using var __ = getBeatmapArchive(out string pathOriginal); + using var _ = getBeatmapArchiveWithModifications(out string pathMissingOneBeatmap, directory => { // arbitrary beatmap removal @@ -496,7 +539,7 @@ namespace osu.Game.Tests.Database Debug.Assert(importAfterUpdate != null); Assert.That(importBeforeUpdate.ID, Is.Not.EqualTo(importAfterUpdate.ID)); - Assert.That(importBeforeUpdate.Value.DateAdded, Is.EqualTo(importAfterUpdate.Value.DateAdded)); + Assert.That(importBeforeUpdate.Value.DateAdded, Is.EqualTo(importAfterUpdate.Value.DateAdded).Within(TimeSpan.FromSeconds(1))); }); } diff --git a/osu.Game.Tests/Database/RealmSubscriptionRegistrationTests.cs b/osu.Game.Tests/Database/RealmSubscriptionRegistrationTests.cs index 45842a952a..e5be4d665b 100644 --- a/osu.Game.Tests/Database/RealmSubscriptionRegistrationTests.cs +++ b/osu.Game.Tests/Database/RealmSubscriptionRegistrationTests.cs @@ -71,6 +71,35 @@ namespace osu.Game.Tests.Database } } + [Test] + public void TestSubscriptionInitialChangeSetNull() + { + ChangeSet? firstChanges = null; + int receivedChangesCount = 0; + + RunTestWithRealm((realm, _) => + { + var registration = realm.RegisterForNotifications(r => r.All(), onChanged); + + realm.WriteAsync(r => r.Add(TestResources.CreateTestBeatmapSetInfo())).WaitSafely(); + + realm.Run(r => r.Refresh()); + + Assert.That(receivedChangesCount, Is.EqualTo(1)); + Assert.That(firstChanges, Is.Null); + + registration.Dispose(); + }); + + void onChanged(IRealmCollection sender, ChangeSet? changes) + { + if (receivedChangesCount == 0) + firstChanges = changes; + + receivedChangesCount++; + } + } + [Test] public void TestSubscriptionWithAsyncWrite() { diff --git a/osu.Game.Tests/Editing/Checks/CheckBreaksTest.cs b/osu.Game.Tests/Editing/Checks/CheckBreaksTest.cs index 28556566ba..f53dd9a62a 100644 --- a/osu.Game.Tests/Editing/Checks/CheckBreaksTest.cs +++ b/osu.Game.Tests/Editing/Checks/CheckBreaksTest.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.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Game.Beatmaps; @@ -29,7 +28,7 @@ namespace osu.Game.Tests.Editing.Checks { var beatmap = new Beatmap { - Breaks = new List + Breaks = { new BreakPeriod(0, 649) } @@ -52,7 +51,7 @@ namespace osu.Game.Tests.Editing.Checks new HitCircle { StartTime = 0 }, new HitCircle { StartTime = 1_200 } }, - Breaks = new List + Breaks = { new BreakPeriod(100, 751) } @@ -75,7 +74,7 @@ namespace osu.Game.Tests.Editing.Checks new HitCircle { StartTime = 0 }, new HitCircle { StartTime = 1_298 } }, - Breaks = new List + Breaks = { new BreakPeriod(200, 850) } @@ -98,7 +97,7 @@ namespace osu.Game.Tests.Editing.Checks new HitCircle { StartTime = 0 }, new HitCircle { StartTime = 1200 } }, - Breaks = new List + Breaks = { new BreakPeriod(1398, 2300) } @@ -121,7 +120,7 @@ namespace osu.Game.Tests.Editing.Checks new HitCircle { StartTime = 1100 }, new HitCircle { StartTime = 1500 } }, - Breaks = new List + Breaks = { new BreakPeriod(0, 652) } @@ -145,7 +144,7 @@ namespace osu.Game.Tests.Editing.Checks new HitCircle { StartTime = 1_297 }, new HitCircle { StartTime = 1_298 } }, - Breaks = new List + Breaks = { new BreakPeriod(200, 850) } @@ -168,7 +167,7 @@ namespace osu.Game.Tests.Editing.Checks new HitCircle { StartTime = 0 }, new HitCircle { StartTime = 1_300 } }, - Breaks = new List + Breaks = { new BreakPeriod(200, 850) } diff --git a/osu.Game.Tests/Editing/Checks/CheckDrainLengthTest.cs b/osu.Game.Tests/Editing/Checks/CheckDrainLengthTest.cs index 1b5c5c398f..be9aa711cb 100644 --- a/osu.Game.Tests/Editing/Checks/CheckDrainLengthTest.cs +++ b/osu.Game.Tests/Editing/Checks/CheckDrainLengthTest.cs @@ -53,7 +53,7 @@ namespace osu.Game.Tests.Editing.Checks new HitCircle { StartTime = 0 }, new HitCircle { StartTime = 40_000 } }, - Breaks = new List + Breaks = { new BreakPeriod(10_000, 21_000) } diff --git a/osu.Game.Tests/Editing/Checks/CheckTitleMarkersTest.cs b/osu.Game.Tests/Editing/Checks/CheckTitleMarkersTest.cs new file mode 100644 index 0000000000..a8f86a6d45 --- /dev/null +++ b/osu.Game.Tests/Editing/Checks/CheckTitleMarkersTest.cs @@ -0,0 +1,235 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks; +using osu.Game.Rulesets.Objects; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Tests.Editing.Checks +{ + [TestFixture] + public class CheckTitleMarkersTest + { + private CheckTitleMarkers check = null!; + + private IBeatmap beatmap = null!; + + [SetUp] + public void Setup() + { + check = new CheckTitleMarkers(); + + beatmap = new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + Metadata = new BeatmapMetadata + { + Title = "Egao no Kanata", + TitleUnicode = "エガオノカナタ" + } + } + }; + } + + [Test] + public void TestNoTitleMarkers() + { + var issues = check.Run(getContext(beatmap)).ToList(); + Assert.That(issues, Has.Count.EqualTo(0)); + } + + [Test] + public void TestTvSizeMarker() + { + beatmap.BeatmapInfo.Metadata.Title += " (TV Size)"; + beatmap.BeatmapInfo.Metadata.TitleUnicode += " (TV Size)"; + + var issues = check.Run(getContext(beatmap)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(0)); + } + + [Test] + public void TestMalformedTvSizeMarker() + { + beatmap.BeatmapInfo.Metadata.Title += " (tv size)"; + beatmap.BeatmapInfo.Metadata.TitleUnicode += " (tv size)"; + + var issues = check.Run(getContext(beatmap)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(2)); + Assert.That(issues.Any(issue => issue.Template is CheckTitleMarkers.IssueTemplateIncorrectMarker)); + } + + [Test] + public void TestGameVerMarker() + { + beatmap.BeatmapInfo.Metadata.Title += " (Game Ver.)"; + beatmap.BeatmapInfo.Metadata.TitleUnicode += " (Game Ver.)"; + + var issues = check.Run(getContext(beatmap)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(0)); + } + + [Test] + public void TestMalformedGameVerMarker() + { + beatmap.BeatmapInfo.Metadata.Title += " (game ver.)"; + beatmap.BeatmapInfo.Metadata.TitleUnicode += " (game ver.)"; + + var issues = check.Run(getContext(beatmap)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(2)); + Assert.That(issues.Any(issue => issue.Template is CheckTitleMarkers.IssueTemplateIncorrectMarker)); + } + + [Test] + public void TestShortVerMarker() + { + beatmap.BeatmapInfo.Metadata.Title += " (Short Ver.)"; + beatmap.BeatmapInfo.Metadata.TitleUnicode += " (Short Ver.)"; + + var issues = check.Run(getContext(beatmap)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(0)); + } + + [Test] + public void TestMalformedShortVerMarker() + { + beatmap.BeatmapInfo.Metadata.Title += " (short ver.)"; + beatmap.BeatmapInfo.Metadata.TitleUnicode += " (short ver.)"; + + var issues = check.Run(getContext(beatmap)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(2)); + Assert.That(issues.Any(issue => issue.Template is CheckTitleMarkers.IssueTemplateIncorrectMarker)); + } + + [Test] + public void TestCutVerMarker() + { + beatmap.BeatmapInfo.Metadata.Title += " (Cut Ver.)"; + beatmap.BeatmapInfo.Metadata.TitleUnicode += " (Cut Ver.)"; + + var issues = check.Run(getContext(beatmap)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(0)); + } + + [Test] + public void TestMalformedCutVerMarker() + { + beatmap.BeatmapInfo.Metadata.Title += " (cut ver.)"; + beatmap.BeatmapInfo.Metadata.TitleUnicode += " (cut ver.)"; + + var issues = check.Run(getContext(beatmap)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(2)); + Assert.That(issues.Any(issue => issue.Template is CheckTitleMarkers.IssueTemplateIncorrectMarker)); + } + + [Test] + public void TestSpedUpVerMarker() + { + beatmap.BeatmapInfo.Metadata.Title += " (Sped Up Ver.)"; + beatmap.BeatmapInfo.Metadata.TitleUnicode += " (Sped Up Ver.)"; + + var issues = check.Run(getContext(beatmap)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(0)); + } + + [Test] + public void TestMalformedSpedUpVerMarker() + { + beatmap.BeatmapInfo.Metadata.Title += " (sped up ver.)"; + beatmap.BeatmapInfo.Metadata.TitleUnicode += " (sped up ver.)"; + + var issues = check.Run(getContext(beatmap)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(2)); + Assert.That(issues.Any(issue => issue.Template is CheckTitleMarkers.IssueTemplateIncorrectMarker)); + } + + [Test] + public void TestNightcoreMixMarker() + { + beatmap.BeatmapInfo.Metadata.Title += " (Nightcore Mix)"; + beatmap.BeatmapInfo.Metadata.TitleUnicode += " (Nightcore Mix)"; + + var issues = check.Run(getContext(beatmap)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(0)); + } + + [Test] + public void TestMalformedNightcoreMixMarker() + { + beatmap.BeatmapInfo.Metadata.Title += " (nightcore mix)"; + beatmap.BeatmapInfo.Metadata.TitleUnicode += " (nightcore mix)"; + + var issues = check.Run(getContext(beatmap)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(2)); + Assert.That(issues.Any(issue => issue.Template is CheckTitleMarkers.IssueTemplateIncorrectMarker)); + } + + [Test] + public void TestSpedUpCutVerMarker() + { + beatmap.BeatmapInfo.Metadata.Title += " (Sped Up & Cut Ver.)"; + beatmap.BeatmapInfo.Metadata.TitleUnicode += " (Sped Up & Cut Ver.)"; + + var issues = check.Run(getContext(beatmap)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(0)); + } + + [Test] + public void TestMalformedSpedUpCutVerMarker() + { + beatmap.BeatmapInfo.Metadata.Title += " (sped up & cut ver.)"; + beatmap.BeatmapInfo.Metadata.TitleUnicode += " (sped up & cut ver.)"; + + var issues = check.Run(getContext(beatmap)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(2)); + Assert.That(issues.Any(issue => issue.Template is CheckTitleMarkers.IssueTemplateIncorrectMarker)); + } + + [Test] + public void TestNightcoreCutVerMarker() + { + beatmap.BeatmapInfo.Metadata.Title += " (Nightcore & Cut Ver.)"; + beatmap.BeatmapInfo.Metadata.TitleUnicode += " (Nightcore & Cut Ver.)"; + + var issues = check.Run(getContext(beatmap)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(0)); + } + + [Test] + public void TestMalformedNightcoreCutVerMarker() + { + beatmap.BeatmapInfo.Metadata.Title += " (nightcore & cut ver.)"; + beatmap.BeatmapInfo.Metadata.TitleUnicode += " (nightcore & cut ver.)"; + + var issues = check.Run(getContext(beatmap)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(2)); + Assert.That(issues.Any(issue => issue.Template is CheckTitleMarkers.IssueTemplateIncorrectMarker)); + } + + private BeatmapVerifierContext getContext(IBeatmap beatmap) + { + return new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap)); + } + } +} \ No newline at end of file diff --git a/osu.Game.Tests/Editing/EditorTimestampParserTest.cs b/osu.Game.Tests/Editing/EditorTimestampParserTest.cs new file mode 100644 index 0000000000..49154f1cbb --- /dev/null +++ b/osu.Game.Tests/Editing/EditorTimestampParserTest.cs @@ -0,0 +1,46 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using NUnit.Framework; +using osu.Game.Rulesets.Edit; + +namespace osu.Game.Tests.Editing +{ + [TestFixture] + public class EditorTimestampParserTest + { + private static readonly object?[][] test_cases = + { + new object?[] { ":", false, null, null }, + new object?[] { "1", true, TimeSpan.FromMilliseconds(1), null }, + new object?[] { "99", true, TimeSpan.FromMilliseconds(99), null }, + new object?[] { "320000", true, TimeSpan.FromMilliseconds(320000), null }, + new object?[] { "1:2", true, new TimeSpan(0, 0, 1, 2), null }, + new object?[] { "1:02", true, new TimeSpan(0, 0, 1, 2), null }, + new object?[] { "1:92", false, null, null }, + new object?[] { "1:002", false, null, null }, + new object?[] { "1:02:3", true, new TimeSpan(0, 0, 1, 2, 3), null }, + new object?[] { "1:02:300", true, new TimeSpan(0, 0, 1, 2, 300), null }, + new object?[] { "1:02:3000", false, null, null }, + new object?[] { "1:02:300 ()", false, null, null }, + new object?[] { "1:02:300 (1,2,3)", true, new TimeSpan(0, 0, 1, 2, 300), "1,2,3" }, + new object?[] { "1:02:300 (1,2,3) - ", true, new TimeSpan(0, 0, 1, 2, 300), "1,2,3" }, + new object?[] { "1:02:300 (1,2,3) - following mod", true, new TimeSpan(0, 0, 1, 2, 300), "1,2,3" }, + new object?[] { "1:02:300 (1,2,3) - following mod\nwith newlines", true, new TimeSpan(0, 0, 1, 2, 300), "1,2,3" }, + }; + + [TestCaseSource(nameof(test_cases))] + public void TestTryParse(string timestamp, bool expectedSuccess, TimeSpan? expectedParsedTime, string? expectedSelection) + { + bool actualSuccess = EditorTimestampParser.TryParse(timestamp, out var actualParsedTime, out string? actualSelection); + + Assert.Multiple(() => + { + Assert.That(actualSuccess, Is.EqualTo(expectedSuccess)); + Assert.That(actualParsedTime, Is.EqualTo(expectedParsedTime)); + Assert.That(actualSelection, Is.EqualTo(expectedSelection)); + }); + } + } +} diff --git a/osu.Game.Tests/Editing/TestSceneEditorBeatmapProcessor.cs b/osu.Game.Tests/Editing/TestSceneEditorBeatmapProcessor.cs new file mode 100644 index 0000000000..bbcf6aac2c --- /dev/null +++ b/osu.Game.Tests/Editing/TestSceneEditorBeatmapProcessor.cs @@ -0,0 +1,543 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Timing; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Edit; + +namespace osu.Game.Tests.Editing +{ + [TestFixture] + public class TestSceneEditorBeatmapProcessor + { + [Test] + public void TestEmptyBeatmap() + { + var controlPoints = new ControlPointInfo(); + controlPoints.Add(0, new TimingControlPoint { BeatLength = 500 }); + var beatmap = new EditorBeatmap(new Beatmap + { + ControlPointInfo = controlPoints, + BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo }, + }); + + var beatmapProcessor = new EditorBeatmapProcessor(beatmap, new OsuRuleset()); + beatmapProcessor.PreProcess(); + beatmapProcessor.PostProcess(); + + Assert.That(beatmap.Breaks, Is.Empty); + } + + [Test] + public void TestSingleObjectBeatmap() + { + var controlPoints = new ControlPointInfo(); + controlPoints.Add(0, new TimingControlPoint { BeatLength = 500 }); + var beatmap = new EditorBeatmap(new Beatmap + { + ControlPointInfo = controlPoints, + BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo }, + HitObjects = + { + new Note { StartTime = 1000 }, + } + }); + + var beatmapProcessor = new EditorBeatmapProcessor(beatmap, new OsuRuleset()); + beatmapProcessor.PreProcess(); + beatmapProcessor.PostProcess(); + + Assert.That(beatmap.Breaks, Is.Empty); + } + + [Test] + public void TestTwoObjectsCloseTogether() + { + var controlPoints = new ControlPointInfo(); + controlPoints.Add(0, new TimingControlPoint { BeatLength = 500 }); + var beatmap = new EditorBeatmap(new Beatmap + { + ControlPointInfo = controlPoints, + BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo }, + HitObjects = + { + new Note { StartTime = 1000 }, + new Note { StartTime = 2000 }, + } + }); + + var beatmapProcessor = new EditorBeatmapProcessor(beatmap, new OsuRuleset()); + beatmapProcessor.PreProcess(); + beatmapProcessor.PostProcess(); + + Assert.That(beatmap.Breaks, Is.Empty); + } + + [Test] + public void TestHoldNote() + { + var controlPoints = new ControlPointInfo(); + controlPoints.Add(0, new TimingControlPoint { BeatLength = 500 }); + var beatmap = new EditorBeatmap(new Beatmap + { + ControlPointInfo = controlPoints, + BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo }, + HitObjects = + { + new HoldNote { StartTime = 1000, Duration = 10000 }, + } + }); + + var beatmapProcessor = new EditorBeatmapProcessor(beatmap, new ManiaRuleset()); + beatmapProcessor.PreProcess(); + beatmapProcessor.PostProcess(); + + Assert.That(beatmap.Breaks, Has.Count.EqualTo(0)); + } + + [Test] + public void TestHoldNoteWithOverlappingNote() + { + var controlPoints = new ControlPointInfo(); + controlPoints.Add(0, new TimingControlPoint { BeatLength = 500 }); + var beatmap = new EditorBeatmap(new Beatmap + { + ControlPointInfo = controlPoints, + BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo }, + HitObjects = + { + new HoldNote { StartTime = 1000, Duration = 10000 }, + new Note { StartTime = 2000 }, + new Note { StartTime = 12000 }, + } + }); + + var beatmapProcessor = new EditorBeatmapProcessor(beatmap, new ManiaRuleset()); + beatmapProcessor.PreProcess(); + beatmapProcessor.PostProcess(); + + Assert.That(beatmap.Breaks, Has.Count.EqualTo(0)); + } + + [Test] + public void TestTwoObjectsFarApart() + { + var controlPoints = new ControlPointInfo(); + controlPoints.Add(0, new TimingControlPoint { BeatLength = 500 }); + var beatmap = new EditorBeatmap(new Beatmap + { + ControlPointInfo = controlPoints, + BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo }, + HitObjects = + { + new Note { StartTime = 1000 }, + new Note { StartTime = 5000 }, + } + }); + + var beatmapProcessor = new EditorBeatmapProcessor(beatmap, new OsuRuleset()); + beatmapProcessor.PreProcess(); + beatmapProcessor.PostProcess(); + + Assert.Multiple(() => + { + Assert.That(beatmap.Breaks, Has.Count.EqualTo(1)); + Assert.That(beatmap.Breaks[0].StartTime, Is.EqualTo(1200)); + Assert.That(beatmap.Breaks[0].EndTime, Is.EqualTo(4000)); + }); + } + + [Test] + public void TestBreaksAreFused() + { + var controlPoints = new ControlPointInfo(); + controlPoints.Add(0, new TimingControlPoint { BeatLength = 500 }); + var beatmap = new EditorBeatmap(new Beatmap + { + ControlPointInfo = controlPoints, + BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo }, + HitObjects = + { + new Note { StartTime = 1000 }, + new Note { StartTime = 9000 }, + }, + Breaks = + { + new BreakPeriod(1200, 4000), + new BreakPeriod(5200, 8000), + } + }); + + var beatmapProcessor = new EditorBeatmapProcessor(beatmap, new OsuRuleset()); + beatmapProcessor.PreProcess(); + beatmapProcessor.PostProcess(); + + Assert.Multiple(() => + { + Assert.That(beatmap.Breaks, Has.Count.EqualTo(1)); + Assert.That(beatmap.Breaks[0].StartTime, Is.EqualTo(1200)); + Assert.That(beatmap.Breaks[0].EndTime, Is.EqualTo(8000)); + }); + } + + [Test] + public void TestBreaksAreSplit() + { + var controlPoints = new ControlPointInfo(); + controlPoints.Add(0, new TimingControlPoint { BeatLength = 500 }); + var beatmap = new EditorBeatmap(new Beatmap + { + ControlPointInfo = controlPoints, + BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo }, + HitObjects = + { + new Note { StartTime = 1000 }, + new Note { StartTime = 5000 }, + new Note { StartTime = 9000 }, + }, + Breaks = + { + new BreakPeriod(1200, 8000), + } + }); + + var beatmapProcessor = new EditorBeatmapProcessor(beatmap, new OsuRuleset()); + beatmapProcessor.PreProcess(); + beatmapProcessor.PostProcess(); + + Assert.Multiple(() => + { + Assert.That(beatmap.Breaks, Has.Count.EqualTo(2)); + Assert.That(beatmap.Breaks[0].StartTime, Is.EqualTo(1200)); + Assert.That(beatmap.Breaks[0].EndTime, Is.EqualTo(4000)); + Assert.That(beatmap.Breaks[1].StartTime, Is.EqualTo(5200)); + Assert.That(beatmap.Breaks[1].EndTime, Is.EqualTo(8000)); + }); + } + + [Test] + public void TestBreaksAreNudged() + { + var controlPoints = new ControlPointInfo(); + controlPoints.Add(0, new TimingControlPoint { BeatLength = 500 }); + var beatmap = new EditorBeatmap(new Beatmap + { + ControlPointInfo = controlPoints, + BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo }, + HitObjects = + { + new Note { StartTime = 1100 }, + new Note { StartTime = 9000 }, + }, + Breaks = + { + new BreakPeriod(1200, 8000), + } + }); + + var beatmapProcessor = new EditorBeatmapProcessor(beatmap, new OsuRuleset()); + beatmapProcessor.PreProcess(); + beatmapProcessor.PostProcess(); + + Assert.Multiple(() => + { + Assert.That(beatmap.Breaks, Has.Count.EqualTo(1)); + Assert.That(beatmap.Breaks[0].StartTime, Is.EqualTo(1300)); + Assert.That(beatmap.Breaks[0].EndTime, Is.EqualTo(8000)); + }); + } + + [Test] + public void TestManualBreaksAreNotFused() + { + var controlPoints = new ControlPointInfo(); + controlPoints.Add(0, new TimingControlPoint { BeatLength = 500 }); + var beatmap = new EditorBeatmap(new Beatmap + { + ControlPointInfo = controlPoints, + BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo }, + HitObjects = + { + new Note { StartTime = 1000 }, + new Note { StartTime = 9000 }, + }, + Breaks = + { + new ManualBreakPeriod(1200, 4000), + new ManualBreakPeriod(5200, 8000), + } + }); + + var beatmapProcessor = new EditorBeatmapProcessor(beatmap, new OsuRuleset()); + beatmapProcessor.PreProcess(); + beatmapProcessor.PostProcess(); + + Assert.Multiple(() => + { + Assert.That(beatmap.Breaks, Has.Count.EqualTo(2)); + Assert.That(beatmap.Breaks[0].StartTime, Is.EqualTo(1200)); + Assert.That(beatmap.Breaks[0].EndTime, Is.EqualTo(4000)); + Assert.That(beatmap.Breaks[1].StartTime, Is.EqualTo(5200)); + Assert.That(beatmap.Breaks[1].EndTime, Is.EqualTo(8000)); + }); + } + + [Test] + public void TestManualBreaksAreSplit() + { + var controlPoints = new ControlPointInfo(); + controlPoints.Add(0, new TimingControlPoint { BeatLength = 500 }); + var beatmap = new EditorBeatmap(new Beatmap + { + ControlPointInfo = controlPoints, + BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo }, + HitObjects = + { + new Note { StartTime = 1000 }, + new Note { StartTime = 5000 }, + new Note { StartTime = 9000 }, + }, + Breaks = + { + new ManualBreakPeriod(1200, 8000), + } + }); + + var beatmapProcessor = new EditorBeatmapProcessor(beatmap, new OsuRuleset()); + beatmapProcessor.PreProcess(); + beatmapProcessor.PostProcess(); + + Assert.Multiple(() => + { + Assert.That(beatmap.Breaks, Has.Count.EqualTo(2)); + Assert.That(beatmap.Breaks[0].StartTime, Is.EqualTo(1200)); + Assert.That(beatmap.Breaks[0].EndTime, Is.EqualTo(4000)); + Assert.That(beatmap.Breaks[1].StartTime, Is.EqualTo(5200)); + Assert.That(beatmap.Breaks[1].EndTime, Is.EqualTo(8000)); + }); + } + + [Test] + public void TestManualBreaksAreNotNudged() + { + var controlPoints = new ControlPointInfo(); + controlPoints.Add(0, new TimingControlPoint { BeatLength = 500 }); + var beatmap = new EditorBeatmap(new Beatmap + { + ControlPointInfo = controlPoints, + BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo }, + HitObjects = + { + new Note { StartTime = 1000 }, + new Note { StartTime = 9000 }, + }, + Breaks = + { + new ManualBreakPeriod(1200, 8800), + } + }); + + var beatmapProcessor = new EditorBeatmapProcessor(beatmap, new OsuRuleset()); + beatmapProcessor.PreProcess(); + beatmapProcessor.PostProcess(); + + Assert.Multiple(() => + { + Assert.That(beatmap.Breaks, Has.Count.EqualTo(1)); + Assert.That(beatmap.Breaks[0].StartTime, Is.EqualTo(1200)); + Assert.That(beatmap.Breaks[0].EndTime, Is.EqualTo(8800)); + }); + } + + [Test] + public void TestBreaksAtEndOfBeatmapAreRemoved() + { + var controlPoints = new ControlPointInfo(); + controlPoints.Add(0, new TimingControlPoint { BeatLength = 500 }); + var beatmap = new EditorBeatmap(new Beatmap + { + ControlPointInfo = controlPoints, + BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo }, + HitObjects = + { + new Note { StartTime = 1000 }, + new Note { StartTime = 2000 }, + }, + Breaks = + { + new BreakPeriod(10000, 15000), + } + }); + + var beatmapProcessor = new EditorBeatmapProcessor(beatmap, new OsuRuleset()); + beatmapProcessor.PreProcess(); + beatmapProcessor.PostProcess(); + + Assert.That(beatmap.Breaks, Is.Empty); + } + + [Test] + public void TestManualBreaksAtEndOfBeatmapAreRemoved() + { + var controlPoints = new ControlPointInfo(); + controlPoints.Add(0, new TimingControlPoint { BeatLength = 500 }); + var beatmap = new EditorBeatmap(new Beatmap + { + ControlPointInfo = controlPoints, + BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo }, + HitObjects = + { + new Note { StartTime = 1000 }, + new Note { StartTime = 2000 }, + }, + Breaks = + { + new ManualBreakPeriod(10000, 15000), + } + }); + + var beatmapProcessor = new EditorBeatmapProcessor(beatmap, new OsuRuleset()); + beatmapProcessor.PreProcess(); + beatmapProcessor.PostProcess(); + + Assert.That(beatmap.Breaks, Is.Empty); + } + + [Test] + public void TestManualBreaksAtEndOfBeatmapAreRemovedCorrectlyEvenWithConcurrentObjects() + { + var controlPoints = new ControlPointInfo(); + controlPoints.Add(0, new TimingControlPoint { BeatLength = 500 }); + var beatmap = new EditorBeatmap(new Beatmap + { + ControlPointInfo = controlPoints, + BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo }, + HitObjects = + { + new HoldNote { StartTime = 1000, EndTime = 20000 }, + new HoldNote { StartTime = 2000, EndTime = 3000 }, + }, + Breaks = + { + new ManualBreakPeriod(10000, 15000), + } + }); + + var beatmapProcessor = new EditorBeatmapProcessor(beatmap, new OsuRuleset()); + beatmapProcessor.PreProcess(); + beatmapProcessor.PostProcess(); + + Assert.That(beatmap.Breaks, Is.Empty); + } + + [Test] + public void TestBreaksAtStartOfBeatmapAreRemoved() + { + var controlPoints = new ControlPointInfo(); + controlPoints.Add(0, new TimingControlPoint { BeatLength = 500 }); + var beatmap = new EditorBeatmap(new Beatmap + { + ControlPointInfo = controlPoints, + BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo }, + HitObjects = + { + new Note { StartTime = 10000 }, + new Note { StartTime = 11000 }, + }, + Breaks = + { + new BreakPeriod(0, 9000), + } + }); + + var beatmapProcessor = new EditorBeatmapProcessor(beatmap, new OsuRuleset()); + beatmapProcessor.PreProcess(); + beatmapProcessor.PostProcess(); + + Assert.That(beatmap.Breaks, Is.Empty); + } + + [Test] + public void TestManualBreaksAtStartOfBeatmapAreRemoved() + { + var controlPoints = new ControlPointInfo(); + controlPoints.Add(0, new TimingControlPoint { BeatLength = 500 }); + var beatmap = new EditorBeatmap(new Beatmap + { + ControlPointInfo = controlPoints, + BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo }, + HitObjects = + { + new Note { StartTime = 10000 }, + new Note { StartTime = 11000 }, + }, + Breaks = + { + new ManualBreakPeriod(0, 9000), + } + }); + + var beatmapProcessor = new EditorBeatmapProcessor(beatmap, new OsuRuleset()); + beatmapProcessor.PreProcess(); + beatmapProcessor.PostProcess(); + + Assert.That(beatmap.Breaks, Is.Empty); + } + + [Test] + public void TestTimePreemptIsRespected() + { + var controlPoints = new ControlPointInfo(); + controlPoints.Add(0, new TimingControlPoint { BeatLength = 500 }); + var beatmap = new EditorBeatmap(new Beatmap + { + ControlPointInfo = controlPoints, + BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo }, + Difficulty = + { + ApproachRate = 10, + }, + HitObjects = + { + new HitCircle { StartTime = 1000 }, + new HitCircle { StartTime = 5000 }, + } + }); + + foreach (var ho in beatmap.HitObjects) + ho.ApplyDefaults(beatmap.ControlPointInfo, beatmap.Difficulty); + + var beatmapProcessor = new EditorBeatmapProcessor(beatmap, new OsuRuleset()); + beatmapProcessor.PreProcess(); + beatmapProcessor.PostProcess(); + + Assert.Multiple(() => + { + Assert.That(beatmap.Breaks, Has.Count.EqualTo(1)); + Assert.That(beatmap.Breaks[0].StartTime, Is.EqualTo(1200)); + Assert.That(beatmap.Breaks[0].EndTime, Is.EqualTo(5000 - OsuHitObject.PREEMPT_MIN)); + }); + + beatmap.Difficulty.ApproachRate = 0; + + foreach (var ho in beatmap.HitObjects) + ho.ApplyDefaults(beatmap.ControlPointInfo, beatmap.Difficulty); + + beatmapProcessor.PreProcess(); + beatmapProcessor.PostProcess(); + + Assert.Multiple(() => + { + Assert.That(beatmap.Breaks, Has.Count.EqualTo(1)); + Assert.That(beatmap.Breaks[0].StartTime, Is.EqualTo(1200)); + Assert.That(beatmap.Breaks[0].EndTime, Is.EqualTo(5000 - OsuHitObject.PREEMPT_MAX)); + }); + } + } +} diff --git a/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs b/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs index 12b7dbbf12..0f8583253b 100644 --- a/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs +++ b/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs @@ -52,10 +52,7 @@ namespace osu.Game.Tests.Editing [SetUp] public void Setup() => Schedule(() => { - Children = new Drawable[] - { - composer = new TestHitObjectComposer() - }; + Child = composer = new TestHitObjectComposer(); BeatDivisor.Value = 1; @@ -115,6 +112,7 @@ namespace osu.Game.Tests.Editing { SliderVelocityMultiplier = slider_velocity }; + AddStep("add to beatmap", () => composer.EditorBeatmap.Add(referenceObject)); assertSnapDistance(base_distance * slider_velocity, referenceObject, true); assertSnappedDistance(base_distance * slider_velocity + 10, base_distance * slider_velocity, referenceObject); @@ -230,26 +228,65 @@ namespace osu.Game.Tests.Editing assertSnappedDistance(400, 400); } + [Test] + public void TestUnsnappedObject() + { + var slider = new Slider + { + StartTime = 0, + Path = new SliderPath + { + ControlPoints = + { + new PathControlPoint(), + // simulate object snapped to 1/3rds + // this object's end time will be 2000 / 3 = 666.66... ms + new PathControlPoint(new Vector2(200 / 3f, 0)), + } + } + }; + + AddStep("add slider", () => composer.EditorBeatmap.Add(slider)); + AddStep("set snap to 1/4", () => BeatDivisor.Value = 4); + + // with default beat length of 1000ms and snap at 1/4, the valid snap times are 500ms, 750ms, and 1000ms + // with default settings, the snapped distance will be a tenth of the difference of the time delta + + // (500 - 666.66...) / 10 = -16.66... = -100 / 6 + assertSnappedDistance(0, -100 / 6f, slider); + assertSnappedDistance(7, -100 / 6f, slider); + + // (750 - 666.66...) / 10 = 8.33... = 100 / 12 + assertSnappedDistance(9, 100 / 12f, slider); + assertSnappedDistance(33, 100 / 12f, slider); + + // (1000 - 666.66...) / 10 = 33.33... = 100 / 3 + assertSnappedDistance(34, 100 / 3f, slider); + } + [Test] public void TestUseCurrentSnap() { + ExpandableButton getCurrentSnapButton() => composer.ChildrenOfType().Single(g => g.Name == "snapping") + .ChildrenOfType().Single(); + AddStep("add objects to beatmap", () => { editorBeatmap.Add(new HitCircle { StartTime = 1000 }); editorBeatmap.Add(new HitCircle { Position = new Vector2(100), StartTime = 2000 }); }); - AddStep("hover use current snap button", () => InputManager.MoveMouseTo(composer.ChildrenOfType().Single())); - AddUntilStep("use current snap expanded", () => composer.ChildrenOfType().Single().Expanded.Value, () => Is.True); + AddStep("hover use current snap button", () => InputManager.MoveMouseTo(getCurrentSnapButton())); + AddUntilStep("use current snap expanded", () => getCurrentSnapButton().Expanded.Value, () => Is.True); AddStep("seek before first object", () => EditorClock.Seek(0)); - AddUntilStep("use current snap not available", () => composer.ChildrenOfType().Single().Enabled.Value, () => Is.False); + AddUntilStep("use current snap not available", () => getCurrentSnapButton().Enabled.Value, () => Is.False); AddStep("seek to between objects", () => EditorClock.Seek(1500)); - AddUntilStep("use current snap available", () => composer.ChildrenOfType().Single().Enabled.Value, () => Is.True); + AddUntilStep("use current snap available", () => getCurrentSnapButton().Enabled.Value, () => Is.True); AddStep("seek after last object", () => EditorClock.Seek(2500)); - AddUntilStep("use current snap not available", () => composer.ChildrenOfType().Single().Enabled.Value, () => Is.False); + AddUntilStep("use current snap not available", () => getCurrentSnapButton().Enabled.Value, () => Is.False); } private void assertSnapDistance(float expectedDistance, HitObject? referenceObject, bool includeSliderVelocity) @@ -265,7 +302,7 @@ namespace osu.Game.Tests.Editing => AddAssert($"distance = {distance} -> duration = {expectedDuration} (snapped)", () => composer.DistanceSnapProvider.FindSnappedDuration(referenceObject ?? new HitObject(), distance), () => Is.EqualTo(expectedDuration).Within(Precision.FLOAT_EPSILON)); private void assertSnappedDistance(float distance, float expectedDistance, HitObject? referenceObject = null) - => AddAssert($"distance = {distance} -> distance = {expectedDistance} (snapped)", () => composer.DistanceSnapProvider.FindSnappedDistance(referenceObject ?? new HitObject(), distance), () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON)); + => AddAssert($"distance = {distance} -> distance = {expectedDistance} (snapped)", () => composer.DistanceSnapProvider.FindSnappedDistance(referenceObject ?? new HitObject(), distance, DistanceSnapTarget.End), () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON)); private partial class TestHitObjectComposer : OsuHitObjectComposer { diff --git a/osu.Game.Tests/Editing/TimingSectionAdjustmentsTest.cs b/osu.Game.Tests/Editing/TimingSectionAdjustmentsTest.cs new file mode 100644 index 0000000000..5f5a1760ea --- /dev/null +++ b/osu.Game.Tests/Editing/TimingSectionAdjustmentsTest.cs @@ -0,0 +1,161 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Edit.Timing; + +namespace osu.Game.Tests.Editing +{ + [TestFixture] + public class TimingSectionAdjustmentsTest + { + [Test] + public void TestOffsetAdjustment() + { + var controlPoints = new ControlPointInfo(); + + controlPoints.Add(100, new TimingControlPoint { BeatLength = 100 }); + controlPoints.Add(50_000, new TimingControlPoint { BeatLength = 200 }); + controlPoints.Add(100_000, new TimingControlPoint { BeatLength = 50 }); + + var beatmap = new Beatmap + { + ControlPointInfo = controlPoints, + HitObjects = new List + { + new HitCircle { StartTime = 0 }, + new HitCircle { StartTime = 200 }, + new HitCircle { StartTime = 49_900 }, + new HitCircle { StartTime = 50_000 }, + new HitCircle { StartTime = 50_200 }, + new HitCircle { StartTime = 99_800 }, + new HitCircle { StartTime = 100_000 }, + new HitCircle { StartTime = 100_050 }, + new HitCircle { StartTime = 100_550 }, + } + }; + + moveTimingPoint(beatmap, 100, -50); + + Assert.Multiple(() => + { + Assert.That(beatmap.HitObjects[0].StartTime, Is.EqualTo(-50)); + Assert.That(beatmap.HitObjects[1].StartTime, Is.EqualTo(150)); + Assert.That(beatmap.HitObjects[2].StartTime, Is.EqualTo(49_850)); + Assert.That(beatmap.HitObjects[3].StartTime, Is.EqualTo(50_000)); + }); + + moveTimingPoint(beatmap, 50_000, 1_000); + + Assert.Multiple(() => + { + Assert.That(beatmap.HitObjects[2].StartTime, Is.EqualTo(49_850)); + Assert.That(beatmap.HitObjects[3].StartTime, Is.EqualTo(51_000)); + Assert.That(beatmap.HitObjects[4].StartTime, Is.EqualTo(51_200)); + Assert.That(beatmap.HitObjects[5].StartTime, Is.EqualTo(100_800)); + Assert.That(beatmap.HitObjects[6].StartTime, Is.EqualTo(100_000)); + }); + + moveTimingPoint(beatmap, 100_000, 10_000); + + Assert.Multiple(() => + { + Assert.That(beatmap.HitObjects[4].StartTime, Is.EqualTo(51_200)); + Assert.That(beatmap.HitObjects[5].StartTime, Is.EqualTo(110_800)); + Assert.That(beatmap.HitObjects[6].StartTime, Is.EqualTo(110_000)); + Assert.That(beatmap.HitObjects[7].StartTime, Is.EqualTo(110_050)); + Assert.That(beatmap.HitObjects[8].StartTime, Is.EqualTo(110_550)); + }); + } + + [Test] + public void TestBPMAdjustment() + { + var controlPoints = new ControlPointInfo(); + + controlPoints.Add(100, new TimingControlPoint { BeatLength = 100 }); + controlPoints.Add(50_000, new TimingControlPoint { BeatLength = 200 }); + controlPoints.Add(100_000, new TimingControlPoint { BeatLength = 50 }); + + var beatmap = new Beatmap + { + ControlPointInfo = controlPoints, + HitObjects = new List + { + new HitCircle { StartTime = 0 }, + new HitCircle { StartTime = 200 }, + new Spinner { StartTime = 500, EndTime = 1000 }, + new HitCircle { StartTime = 49_900 }, + new HitCircle { StartTime = 50_000 }, + new HitCircle { StartTime = 50_200 }, + new HitCircle { StartTime = 99_800 }, + new HitCircle { StartTime = 100_000 }, + new HitCircle { StartTime = 100_050 }, + new HitCircle { StartTime = 100_550 }, + } + }; + + adjustBeatLength(beatmap, 100, 50); + + Assert.Multiple(() => + { + Assert.That(beatmap.HitObjects[0].StartTime, Is.EqualTo(50)); + Assert.That(beatmap.HitObjects[1].StartTime, Is.EqualTo(150)); + Assert.That(beatmap.HitObjects[2].StartTime, Is.EqualTo(300)); + Assert.That(beatmap.HitObjects[2].GetEndTime(), Is.EqualTo(550)); + Assert.That(beatmap.HitObjects[3].StartTime, Is.EqualTo(25_000)); + Assert.That(beatmap.HitObjects[4].StartTime, Is.EqualTo(50_000)); + }); + + adjustBeatLength(beatmap, 50_000, 400); + + Assert.Multiple(() => + { + Assert.That(beatmap.HitObjects[2].StartTime, Is.EqualTo(300)); + Assert.That(beatmap.HitObjects[2].GetEndTime(), Is.EqualTo(550)); + Assert.That(beatmap.HitObjects[3].StartTime, Is.EqualTo(25_000)); + Assert.That(beatmap.HitObjects[4].StartTime, Is.EqualTo(50_000)); + Assert.That(beatmap.HitObjects[5].StartTime, Is.EqualTo(50_400)); + Assert.That(beatmap.HitObjects[6].StartTime, Is.EqualTo(149_600)); + Assert.That(beatmap.HitObjects[7].StartTime, Is.EqualTo(100_000)); + }); + + adjustBeatLength(beatmap, 100_000, 100); + + Assert.Multiple(() => + { + Assert.That(beatmap.HitObjects[5].StartTime, Is.EqualTo(50_400)); + Assert.That(beatmap.HitObjects[6].StartTime, Is.EqualTo(199_200)); + Assert.That(beatmap.HitObjects[7].StartTime, Is.EqualTo(100_000)); + Assert.That(beatmap.HitObjects[8].StartTime, Is.EqualTo(100_100)); + Assert.That(beatmap.HitObjects[9].StartTime, Is.EqualTo(101_100)); + }); + } + + private static void moveTimingPoint(IBeatmap beatmap, double originalTime, double adjustment) + { + var controlPoints = beatmap.ControlPointInfo; + var controlPointGroup = controlPoints.GroupAt(originalTime); + var timingPoint = controlPointGroup.ControlPoints.OfType().Single(); + controlPoints.RemoveGroup(controlPointGroup); + TimingSectionAdjustments.AdjustHitObjectOffset(beatmap, timingPoint, adjustment); + controlPoints.Add(originalTime - adjustment, timingPoint); + } + + private static void adjustBeatLength(IBeatmap beatmap, double groupTime, double newBeatLength) + { + var controlPoints = beatmap.ControlPointInfo; + var controlPointGroup = controlPoints.GroupAt(groupTime); + var timingPoint = controlPointGroup.ControlPoints.OfType().Single(); + double oldBeatLength = timingPoint.BeatLength; + timingPoint.BeatLength = newBeatLength; + TimingSectionAdjustments.SetHitObjectBPM(beatmap, timingPoint, oldBeatLength); + } + } +} diff --git a/osu.Game.Tests/Input/ConfineMouseTrackerTest.cs b/osu.Game.Tests/Input/ConfineMouseTrackerTest.cs index 6b43ab83c5..42f50efdbf 100644 --- a/osu.Game.Tests/Input/ConfineMouseTrackerTest.cs +++ b/osu.Game.Tests/Input/ConfineMouseTrackerTest.cs @@ -3,11 +3,13 @@ using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Configuration; using osu.Framework.Input; using osu.Framework.Testing; using osu.Game.Configuration; using osu.Game.Input; +using osu.Game.Screens.Play; using osu.Game.Tests.Visual; namespace osu.Game.Tests.Input @@ -15,9 +17,20 @@ namespace osu.Game.Tests.Input [HeadlessTest] public partial class ConfineMouseTrackerTest : OsuGameTestScene { + private readonly Bindable playingState = new Bindable(); + [Resolved] private FrameworkConfigManager frameworkConfigManager { get; set; } = null!; + [SetUpSteps] + public override void SetUpSteps() + { + base.SetUpSteps(); + + // a bit dodgy. + AddStep("bind playing state", () => ((IBindable)playingState).BindTo(((ILocalUserPlayInfo)Game).PlayingState)); + } + [TestCase(WindowMode.Windowed)] [TestCase(WindowMode.Borderless)] public void TestDisableConfining(WindowMode windowMode) @@ -88,7 +101,7 @@ namespace osu.Game.Tests.Input => AddStep($"set {mode} game-side", () => Game.LocalConfig.SetValue(OsuSetting.ConfineMouseMode, mode)); private void setLocalUserPlayingTo(bool playing) - => AddStep($"local user {(playing ? "playing" : "not playing")}", () => Game.LocalUserPlaying.Value = playing); + => AddStep($"local user {(playing ? "playing" : "not playing")}", () => playingState.Value = playing ? LocalUserPlayingState.Playing : LocalUserPlayingState.NotPlaying); private void gameSideModeIs(OsuConfineMouseMode mode) => AddAssert($"mode is {mode} game-side", () => Game.LocalConfig.Get(OsuSetting.ConfineMouseMode) == mode); diff --git a/osu.Game.Tests/Mods/ModDifficultyAdjustTest.cs b/osu.Game.Tests/Mods/ModDifficultyAdjustTest.cs index 4101652c49..e31a3dbdf0 100644 --- a/osu.Game.Tests/Mods/ModDifficultyAdjustTest.cs +++ b/osu.Game.Tests/Mods/ModDifficultyAdjustTest.cs @@ -8,6 +8,8 @@ using osu.Game.Online.API; using osu.Game.Rulesets; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.UI; namespace osu.Game.Tests.Mods @@ -105,9 +107,6 @@ namespace osu.Game.Tests.Mods testMod.ResetSettingsToDefaults(); Assert.That(testMod.DrainRate.Value, Is.Null); - - // ReSharper disable once HeuristicUnreachableCode - // see https://youtrack.jetbrains.com/issue/RIDER-70159. Assert.That(testMod.OverallDifficulty.Value, Is.Null); var applied = applyDifficulty(new BeatmapDifficulty @@ -119,6 +118,48 @@ namespace osu.Game.Tests.Mods Assert.That(applied.OverallDifficulty, Is.EqualTo(10)); } + [Test] + public void TestDeserializeIncorrectRange() + { + var apiMod = new APIMod + { + Acronym = @"DA", + Settings = new Dictionary + { + [@"circle_size"] = -727, + [@"approach_rate"] = -727, + } + }; + var ruleset = new OsuRuleset(); + + var mod = (OsuModDifficultyAdjust)apiMod.ToMod(ruleset); + + Assert.Multiple(() => + { + Assert.That(mod.CircleSize.Value, Is.GreaterThanOrEqualTo(0).And.LessThanOrEqualTo(11)); + Assert.That(mod.ApproachRate.Value, Is.GreaterThanOrEqualTo(-10).And.LessThanOrEqualTo(11)); + }); + } + + [Test] + public void TestDeserializeNegativeApproachRate() + { + var apiMod = new APIMod + { + Acronym = @"DA", + Settings = new Dictionary + { + [@"approach_rate"] = -9, + } + }; + var ruleset = new OsuRuleset(); + + var mod = (OsuModDifficultyAdjust)apiMod.ToMod(ruleset); + + Assert.That(mod.ApproachRate.Value, Is.GreaterThanOrEqualTo(-10).And.LessThanOrEqualTo(11)); + Assert.That(mod.ApproachRate.Value, Is.EqualTo(-9)); + } + /// /// Applies a to the mod and returns a new /// representing the result if the mod were applied to a fresh instance. diff --git a/osu.Game.Tests/NonVisual/ControlPointInfoTest.cs b/osu.Game.Tests/NonVisual/ControlPointInfoTest.cs index 2d5d425ee8..d7df3d318d 100644 --- a/osu.Game.Tests/NonVisual/ControlPointInfoTest.cs +++ b/osu.Game.Tests/NonVisual/ControlPointInfoTest.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Linq; using NUnit.Framework; using osu.Game.Beatmaps.ControlPoints; @@ -286,5 +287,62 @@ namespace osu.Game.Tests.NonVisual Assert.That(cpi.TimingPoints[0].BeatLength, Is.Not.EqualTo(cpiCopy.TimingPoints[0].BeatLength)); } + + [Test] + public void TestBinarySearchEmptyList() + { + Assert.That(ControlPointInfo.BinarySearch(Array.Empty(), 0, EqualitySelection.FirstFound), Is.EqualTo(-1)); + Assert.That(ControlPointInfo.BinarySearch(Array.Empty(), 0, EqualitySelection.Leftmost), Is.EqualTo(-1)); + Assert.That(ControlPointInfo.BinarySearch(Array.Empty(), 0, EqualitySelection.Rightmost), Is.EqualTo(-1)); + } + + [TestCase(new[] { 1 }, 0, -1)] + [TestCase(new[] { 1 }, 1, 0)] + [TestCase(new[] { 1 }, 2, -2)] + [TestCase(new[] { 1, 3 }, 0, -1)] + [TestCase(new[] { 1, 3 }, 1, 0)] + [TestCase(new[] { 1, 3 }, 2, -2)] + [TestCase(new[] { 1, 3 }, 3, 1)] + [TestCase(new[] { 1, 3 }, 4, -3)] + public void TestBinarySearchUniqueScenarios(int[] values, int search, int expectedIndex) + { + var items = values.Select(t => new TimingControlPoint { Time = t }).ToArray(); + Assert.That(ControlPointInfo.BinarySearch(items, search, EqualitySelection.FirstFound), Is.EqualTo(expectedIndex)); + Assert.That(ControlPointInfo.BinarySearch(items, search, EqualitySelection.Leftmost), Is.EqualTo(expectedIndex)); + Assert.That(ControlPointInfo.BinarySearch(items, search, EqualitySelection.Rightmost), Is.EqualTo(expectedIndex)); + } + + [TestCase(new[] { 1, 1 }, 1, 0)] + [TestCase(new[] { 1, 2, 2 }, 2, 1)] + [TestCase(new[] { 1, 2, 2, 2 }, 2, 1)] + [TestCase(new[] { 1, 2, 2, 2, 3 }, 2, 2)] + [TestCase(new[] { 1, 2, 2, 3 }, 2, 1)] + public void TestBinarySearchFirstFoundDuplicateScenarios(int[] values, int search, int expectedIndex) + { + var items = values.Select(t => new TimingControlPoint { Time = t }).ToArray(); + Assert.That(ControlPointInfo.BinarySearch(items, search, EqualitySelection.FirstFound), Is.EqualTo(expectedIndex)); + } + + [TestCase(new[] { 1, 1 }, 1, 0)] + [TestCase(new[] { 1, 2, 2 }, 2, 1)] + [TestCase(new[] { 1, 2, 2, 2 }, 2, 1)] + [TestCase(new[] { 1, 2, 2, 2, 3 }, 2, 1)] + [TestCase(new[] { 1, 2, 2, 3 }, 2, 1)] + public void TestBinarySearchLeftMostDuplicateScenarios(int[] values, int search, int expectedIndex) + { + var items = values.Select(t => new TimingControlPoint { Time = t }).ToArray(); + Assert.That(ControlPointInfo.BinarySearch(items, search, EqualitySelection.Leftmost), Is.EqualTo(expectedIndex)); + } + + [TestCase(new[] { 1, 1 }, 1, 1)] + [TestCase(new[] { 1, 2, 2 }, 2, 2)] + [TestCase(new[] { 1, 2, 2, 2 }, 2, 3)] + [TestCase(new[] { 1, 2, 2, 2, 3 }, 2, 3)] + [TestCase(new[] { 1, 2, 2, 3 }, 2, 2)] + public void TestBinarySearchRightMostDuplicateScenarios(int[] values, int search, int expectedIndex) + { + var items = values.Select(t => new TimingControlPoint { Time = t }).ToArray(); + Assert.That(ControlPointInfo.BinarySearch(items, search, EqualitySelection.Rightmost), Is.EqualTo(expectedIndex)); + } } } diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs index 7897b3d8c0..c8f063719d 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs @@ -537,7 +537,7 @@ namespace osu.Game.Tests.NonVisual.Filtering [TestCaseSource(nameof(correct_date_query_examples))] public void TestValidDateQueries(string dateQuery) { - string query = $"played<{dateQuery} time"; + string query = $"lastplayed<{dateQuery} time"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); Assert.AreEqual(true, filterCriteria.LastPlayed.HasFilter); @@ -571,7 +571,7 @@ namespace osu.Game.Tests.NonVisual.Filtering [Test] public void TestGreaterDateQuery() { - const string query = "played>50"; + const string query = "lastplayed>50"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); Assert.That(filterCriteria.LastPlayed.Max, Is.Not.Null); @@ -584,7 +584,7 @@ namespace osu.Game.Tests.NonVisual.Filtering [Test] public void TestLowerDateQuery() { - const string query = "played<50"; + const string query = "lastplayed<50"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); Assert.That(filterCriteria.LastPlayed.Max, Is.Null); @@ -597,7 +597,7 @@ namespace osu.Game.Tests.NonVisual.Filtering [Test] public void TestBothSidesDateQuery() { - const string query = "played>3M played<1y6M"; + const string query = "lastplayed>3M lastplayed<1y6M"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); Assert.That(filterCriteria.LastPlayed.Min, Is.Not.Null); @@ -611,7 +611,7 @@ namespace osu.Game.Tests.NonVisual.Filtering [Test] public void TestEqualDateQuery() { - const string query = "played=50"; + const string query = "lastplayed=50"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); Assert.AreEqual(false, filterCriteria.LastPlayed.HasFilter); @@ -620,11 +620,119 @@ namespace osu.Game.Tests.NonVisual.Filtering [Test] public void TestOutOfRangeDateQuery() { - const string query = "played<10000y"; + const string query = "lastplayed<10000y"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); Assert.AreEqual(true, filterCriteria.LastPlayed.HasFilter); Assert.AreEqual(DateTimeOffset.MinValue.AddMilliseconds(1), filterCriteria.LastPlayed.Min); } + + private static DateTimeOffset dateTimeOffsetFromDateOnly(int year, int month, int day) => + new DateTimeOffset(year, month, day, 0, 0, 0, TimeSpan.Zero); + + private static readonly object[] ranked_date_valid_test_cases = + { + new object[] { "ranked<2012", dateTimeOffsetFromDateOnly(2012, 1, 1), (FilterCriteria x) => x.DateRanked.Max }, + new object[] { "ranked<2012.03", dateTimeOffsetFromDateOnly(2012, 3, 1), (FilterCriteria x) => x.DateRanked.Max }, + new object[] { "ranked<2012/03/05", dateTimeOffsetFromDateOnly(2012, 3, 5), (FilterCriteria x) => x.DateRanked.Max }, + new object[] { "ranked<2012-3-5", dateTimeOffsetFromDateOnly(2012, 3, 5), (FilterCriteria x) => x.DateRanked.Max }, + + new object[] { "ranked<=2012", dateTimeOffsetFromDateOnly(2013, 1, 1), (FilterCriteria x) => x.DateRanked.Max }, + new object[] { "ranked<=2012.03", dateTimeOffsetFromDateOnly(2012, 4, 1), (FilterCriteria x) => x.DateRanked.Max }, + new object[] { "ranked<=2012/03/05", dateTimeOffsetFromDateOnly(2012, 3, 6), (FilterCriteria x) => x.DateRanked.Max }, + new object[] { "ranked<=2012-3-5", dateTimeOffsetFromDateOnly(2012, 3, 6), (FilterCriteria x) => x.DateRanked.Max }, + + new object[] { "ranked>2012", dateTimeOffsetFromDateOnly(2013, 1, 1), (FilterCriteria x) => x.DateRanked.Min }, + new object[] { "ranked>2012.03", dateTimeOffsetFromDateOnly(2012, 4, 1), (FilterCriteria x) => x.DateRanked.Min }, + new object[] { "ranked>2012/03/05", dateTimeOffsetFromDateOnly(2012, 3, 6), (FilterCriteria x) => x.DateRanked.Min }, + new object[] { "ranked>2012-3-5", dateTimeOffsetFromDateOnly(2012, 3, 6), (FilterCriteria x) => x.DateRanked.Min }, + + new object[] { "ranked>=2012", dateTimeOffsetFromDateOnly(2012, 1, 1), (FilterCriteria x) => x.DateRanked.Min }, + new object[] { "ranked>=2012.03", dateTimeOffsetFromDateOnly(2012, 3, 1), (FilterCriteria x) => x.DateRanked.Min }, + new object[] { "ranked>=2012/03/05", dateTimeOffsetFromDateOnly(2012, 3, 5), (FilterCriteria x) => x.DateRanked.Min }, + new object[] { "ranked>=2012-3-5", dateTimeOffsetFromDateOnly(2012, 3, 5), (FilterCriteria x) => x.DateRanked.Min }, + + new object[] { "ranked=2012", dateTimeOffsetFromDateOnly(2012, 1, 1), (FilterCriteria x) => x.DateRanked.Min }, + new object[] { "ranked=2012", dateTimeOffsetFromDateOnly(2012, 1, 1), (FilterCriteria x) => x.DateRanked.Min }, + new object[] { "ranked=2012-03", dateTimeOffsetFromDateOnly(2012, 3, 1), (FilterCriteria x) => x.DateRanked.Min }, + new object[] { "ranked=2012-03", dateTimeOffsetFromDateOnly(2012, 4, 1), (FilterCriteria x) => x.DateRanked.Max }, + new object[] { "ranked=2012-03-05", dateTimeOffsetFromDateOnly(2012, 3, 5), (FilterCriteria x) => x.DateRanked.Min }, + new object[] { "ranked=2012-03-05", dateTimeOffsetFromDateOnly(2012, 3, 6), (FilterCriteria x) => x.DateRanked.Max }, + }; + + [Test] + [TestCaseSource(nameof(ranked_date_valid_test_cases))] + public void TestValidRankedDateQueries(string query, DateTimeOffset expected, Func f) + { + var filterCriteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(filterCriteria, query); + Assert.AreEqual(true, filterCriteria.DateRanked.HasFilter); + Assert.AreEqual(expected, f(filterCriteria)); + } + + private static readonly object[] ranked_date_invalid_test_cases = + { + new object[] { "ranked<0" }, + new object[] { "ranked=99999" }, + new object[] { "ranked>=2012-03-05-04" }, + }; + + [Test] + [TestCaseSource(nameof(ranked_date_invalid_test_cases))] + public void TestInvalidRankedDateQueries(string query) + { + var filterCriteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(filterCriteria, query); + Assert.AreEqual(false, filterCriteria.DateRanked.HasFilter); + } + + private static readonly object[] submitted_date_test_cases = + { + new object[] { "submitted<2012", true }, + new object[] { "submitted<2012.03", true }, + new object[] { "submitted<2012/03/05", true }, + new object[] { "submitted<2012-3-5", true }, + + new object[] { "submitted<0", false }, + new object[] { "submitted=99999", false }, + new object[] { "submitted>=2012-03-05-04", false }, + new object[] { "submitted>=2012/03.05-04", false }, + }; + + [Test] + [TestCaseSource(nameof(submitted_date_test_cases))] + public void TestInvalidRankedDateQueries(string query, bool expected) + { + var filterCriteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(filterCriteria, query); + Assert.AreEqual(expected, filterCriteria.DateSubmitted.HasFilter); + } + + private static readonly object[] played_query_tests = + { + new object[] { "0", DateTimeOffset.MinValue, true }, + new object[] { "0", DateTimeOffset.Now, false }, + new object[] { "false", DateTimeOffset.MinValue, true }, + new object[] { "false", DateTimeOffset.Now, false }, + new object[] { "no", DateTimeOffset.MinValue, true }, + new object[] { "no", DateTimeOffset.Now, false }, + + new object[] { "1", DateTimeOffset.MinValue, false }, + new object[] { "1", DateTimeOffset.Now, true }, + new object[] { "true", DateTimeOffset.MinValue, false }, + new object[] { "true", DateTimeOffset.Now, true }, + new object[] { "yes", DateTimeOffset.MinValue, false }, + new object[] { "yes", DateTimeOffset.Now, true }, + }; + + [Test] + [TestCaseSource(nameof(played_query_tests))] + public void TestPlayedQuery(string query, DateTimeOffset reference, bool matched) + { + var filterCriteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(filterCriteria, $"played={query}"); + Assert.AreEqual(true, filterCriteria.LastPlayed.HasFilter); + Assert.AreEqual(matched, filterCriteria.LastPlayed.IsInRange(reference)); + } } } diff --git a/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs b/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs index d4b69c1be2..07d6d68e82 100644 --- a/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs +++ b/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs @@ -96,6 +96,7 @@ namespace osu.Game.Tests.NonVisual public override IAdjustableAudioComponent Audio { get; } public override Playfield Playfield { get; } + public override PlayfieldAdjustmentContainer PlayfieldAdjustmentContainer { get; } public override Container Overlays { get; } public override Container FrameStableComponents { get; } public override IFrameStableClock FrameStableClock { get; } diff --git a/osu.Game.Tests/NonVisual/TestSceneTimedDifficultyCalculation.cs b/osu.Game.Tests/NonVisual/TestSceneTimedDifficultyCalculation.cs index 1a75f735ef..f860cd097a 100644 --- a/osu.Game.Tests/NonVisual/TestSceneTimedDifficultyCalculation.cs +++ b/osu.Game.Tests/NonVisual/TestSceneTimedDifficultyCalculation.cs @@ -22,7 +22,7 @@ namespace osu.Game.Tests.NonVisual public class TestSceneTimedDifficultyCalculation { [Test] - public void TestAttributesGeneratedForAllNonSkippedObjects() + public void TestAttributesGeneratedForEachObjectOnce() { var beatmap = new Beatmap { @@ -40,15 +40,14 @@ namespace osu.Game.Tests.NonVisual List attribs = new TestDifficultyCalculator(new TestWorkingBeatmap(beatmap)).CalculateTimed(); - Assert.That(attribs.Count, Is.EqualTo(4)); + Assert.That(attribs.Count, Is.EqualTo(3)); assertEquals(attribs[0], beatmap.HitObjects[0]); assertEquals(attribs[1], beatmap.HitObjects[0], beatmap.HitObjects[1]); - assertEquals(attribs[2], beatmap.HitObjects[0], beatmap.HitObjects[1]); // From the nested object. - assertEquals(attribs[3], beatmap.HitObjects[0], beatmap.HitObjects[1], beatmap.HitObjects[2]); + assertEquals(attribs[2], beatmap.HitObjects[0], beatmap.HitObjects[1], beatmap.HitObjects[2]); } [Test] - public void TestAttributesNotGeneratedForSkippedObjects() + public void TestAttributesGeneratedForSkippedObjects() { var beatmap = new Beatmap { @@ -72,35 +71,14 @@ namespace osu.Game.Tests.NonVisual List attribs = new TestDifficultyCalculator(new TestWorkingBeatmap(beatmap)).CalculateTimed(); - Assert.That(attribs.Count, Is.EqualTo(1)); - assertEquals(attribs[0], beatmap.HitObjects[0], beatmap.HitObjects[1], beatmap.HitObjects[2]); - } - - [Test] - public void TestNestedObjectOnlyAddsParentOnce() - { - var beatmap = new Beatmap - { - HitObjects = - { - new TestHitObject - { - StartTime = 1, - Skip = true, - Nested = 2 - }, - } - }; - - List attribs = new TestDifficultyCalculator(new TestWorkingBeatmap(beatmap)).CalculateTimed(); - - Assert.That(attribs.Count, Is.EqualTo(2)); + Assert.That(attribs.Count, Is.EqualTo(3)); assertEquals(attribs[0], beatmap.HitObjects[0]); - assertEquals(attribs[1], beatmap.HitObjects[0]); + assertEquals(attribs[1], beatmap.HitObjects[0], beatmap.HitObjects[1]); + assertEquals(attribs[2], beatmap.HitObjects[0], beatmap.HitObjects[1], beatmap.HitObjects[2]); } [Test] - public void TestSkippedLastObjectAddedInLastIteration() + public void TestAttributesGeneratedOnceForSkippedObjects() { var beatmap = new Beatmap { @@ -110,6 +88,7 @@ namespace osu.Game.Tests.NonVisual new TestHitObject { StartTime = 2, + Nested = 5, Skip = true }, new TestHitObject @@ -122,8 +101,10 @@ namespace osu.Game.Tests.NonVisual List attribs = new TestDifficultyCalculator(new TestWorkingBeatmap(beatmap)).CalculateTimed(); - Assert.That(attribs.Count, Is.EqualTo(1)); - assertEquals(attribs[0], beatmap.HitObjects[0], beatmap.HitObjects[1], beatmap.HitObjects[2]); + Assert.That(attribs.Count, Is.EqualTo(3)); + assertEquals(attribs[0], beatmap.HitObjects[0]); + assertEquals(attribs[1], beatmap.HitObjects[0], beatmap.HitObjects[1]); + assertEquals(attribs[2], beatmap.HitObjects[0], beatmap.HitObjects[1], beatmap.HitObjects[2]); } private void assertEquals(TimedDifficultyAttributes attribs, params HitObject[] expected) diff --git a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs index 585fd516bd..ae3451c3e0 100644 --- a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs +++ b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs @@ -257,7 +257,7 @@ namespace osu.Game.Tests.Online { } - protected override string Target => null; + protected override string Target => string.Empty; } } } diff --git a/osu.Game.Tests/Resources/Archives/argon-invalid-drawable.osk b/osu.Game.Tests/Resources/Archives/argon-invalid-drawable.osk new file mode 100644 index 0000000000..23c318149c Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/argon-invalid-drawable.osk differ diff --git a/osu.Game.Tests/Resources/Archives/argon-layout-version-0.osk b/osu.Game.Tests/Resources/Archives/argon-layout-version-0.osk new file mode 100644 index 0000000000..f767033eb1 Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/argon-layout-version-0.osk differ diff --git a/osu.Game.Tests/Resources/Archives/classic-layout-version-0.osk b/osu.Game.Tests/Resources/Archives/classic-layout-version-0.osk new file mode 100644 index 0000000000..8240510f7c Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/classic-layout-version-0.osk differ diff --git a/osu.Game.Tests/Resources/Archives/decimal-timing-beatmap.olz b/osu.Game.Tests/Resources/Archives/decimal-timing-beatmap.olz new file mode 100644 index 0000000000..38dedc35d1 Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/decimal-timing-beatmap.olz differ diff --git a/osu.Game.Tests/Resources/Archives/japanese-filename.osz b/osu.Game.Tests/Resources/Archives/japanese-filename.osz new file mode 100644 index 0000000000..4825c88179 Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/japanese-filename.osz differ diff --git a/osu.Game.Tests/Resources/Archives/modified-classic-20230809.osk b/osu.Game.Tests/Resources/Archives/modified-classic-20230809.osk new file mode 100644 index 0000000000..b200ab1261 Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-classic-20230809.osk differ diff --git a/osu.Game.Tests/Resources/Archives/modified-classic-20240724.osk b/osu.Game.Tests/Resources/Archives/modified-classic-20240724.osk new file mode 100644 index 0000000000..29a06abf1d Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-classic-20240724.osk differ diff --git a/osu.Game.Tests/Resources/Archives/modified-default-20230809.osk b/osu.Game.Tests/Resources/Archives/modified-default-20230809.osk new file mode 100644 index 0000000000..a46c20d2b8 Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-default-20230809.osk differ diff --git a/osu.Game.Tests/Resources/Archives/triangles-layout-version-0.osk b/osu.Game.Tests/Resources/Archives/triangles-layout-version-0.osk new file mode 100644 index 0000000000..5601eb279b Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/triangles-layout-version-0.osk differ diff --git a/osu.Game.Tests/Resources/Replays/mania-replay.osr b/osu.Game.Tests/Resources/Replays/mania-replay.osr index da1a7bdd28..ad55a5a318 100644 Binary files a/osu.Game.Tests/Resources/Replays/mania-replay.osr and b/osu.Game.Tests/Resources/Replays/mania-replay.osr differ diff --git a/osu.Game.Tests/Resources/TestResources.cs b/osu.Game.Tests/Resources/TestResources.cs index a77dc8d49b..e0572e604c 100644 --- a/osu.Game.Tests/Resources/TestResources.cs +++ b/osu.Game.Tests/Resources/TestResources.cs @@ -73,7 +73,12 @@ namespace osu.Game.Tests.Resources private static string getTempFilename() => temp_storage.GetFullPath(Guid.NewGuid() + ".osz"); - private static int importId; + private static int testId = 1; + + /// + /// Get a unique int value which is incremented each call. + /// + public static int GetNextTestID() => Interlocked.Increment(ref testId); /// /// Create a test beatmap set model. @@ -88,7 +93,7 @@ namespace osu.Game.Tests.Resources RulesetInfo getRuleset() => rulesets?[j++ % rulesets.Length]; - int setId = Interlocked.Increment(ref importId); + int setId = GetNextTestID(); var metadata = new BeatmapMetadata { diff --git a/osu.Game.Tests/Resources/hitobject-coordinates-lazer.osu b/osu.Game.Tests/Resources/hitobject-coordinates-lazer.osu new file mode 100644 index 0000000000..bb898a1521 --- /dev/null +++ b/osu.Game.Tests/Resources/hitobject-coordinates-lazer.osu @@ -0,0 +1,6 @@ +osu file format v128 + +[HitObjects] +// Coordinates should be preserves in lazer beatmaps. + +256.99853,256.001,1000,49,0,0:0:0:0: diff --git a/osu.Game.Tests/Resources/hitobject-coordinates-legacy.osu b/osu.Game.Tests/Resources/hitobject-coordinates-legacy.osu new file mode 100644 index 0000000000..e914c2fb36 --- /dev/null +++ b/osu.Game.Tests/Resources/hitobject-coordinates-legacy.osu @@ -0,0 +1,5 @@ +osu file format v14 + +[HitObjects] +// Coordinates should be truncated to int values in legacy beatmaps. +256.99853,256.001,1000,49,0,0:0:0:0: 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/Resources/out-of-range-difficulties-mania.osu b/osu.Game.Tests/Resources/out-of-range-difficulties-mania.osu new file mode 100644 index 0000000000..7dc2e51ad9 --- /dev/null +++ b/osu.Game.Tests/Resources/out-of-range-difficulties-mania.osu @@ -0,0 +1,5 @@ +[General] +Mode: 3 + +[Difficulty] +CircleSize:14 \ No newline at end of file diff --git a/osu.Game.Tests/Resources/out-of-range-difficulties.osu b/osu.Game.Tests/Resources/out-of-range-difficulties.osu new file mode 100644 index 0000000000..5029395614 --- /dev/null +++ b/osu.Game.Tests/Resources/out-of-range-difficulties.osu @@ -0,0 +1,10 @@ +[General] +Mode: 0 + +[Difficulty] +HPDrainRate:25 +CircleSize:25 +OverallDifficulty:25 +ApproachRate:30 +SliderMultiplier:30 +SliderTickRate:30 \ No newline at end of file diff --git a/osu.Game.Tests/Resources/per-slider-node-sample-settings.osu b/osu.Game.Tests/Resources/per-slider-node-sample-settings.osu new file mode 100644 index 0000000000..8b10f21f52 --- /dev/null +++ b/osu.Game.Tests/Resources/per-slider-node-sample-settings.osu @@ -0,0 +1,22 @@ +osu file format v128 + +[General] +SampleSet: Normal + +[TimingPoints] +15,1000,4,1,0,100,1,0 +2271,-100,4,1,0,5,0,0 +6021,-100,4,1,0,100,0,0 +8515,-100,4,1,0,5,0,0 +12765,-100,4,1,0,100,0,0 +14764,-100,4,1,0,5,0,0 +14770,-100,4,1,0,50,0,0 +17264,-100,4,1,0,5,0,0 +17270,-100,4,1,0,50,0,0 +22264,-100,4,1,0,100,0,0 + +[HitObjects] +113,54,2265,6,0,L|422:55,1,300,0|0,1:0|1:0,1:0:0:0: +82,206,6015,2,0,L|457:204,1,350,0|0,2:0|2:0,2:0:0:0: +75,310,10265,2,0,L|435:312,1,350,0|0,3:0|3:0,3:0:0:0: +75,310,14764,2,0,L|435:312,3,350,0|0|0|0,3:0|3:0|3:0|3:0,3:0:0:0: diff --git a/osu.Game.Tests/Scores/IO/ImportScoreTest.cs b/osu.Game.Tests/Scores/IO/ImportScoreTest.cs index ebbc329b9d..9c72804a6b 100644 --- a/osu.Game.Tests/Scores/IO/ImportScoreTest.cs +++ b/osu.Game.Tests/Scores/IO/ImportScoreTest.cs @@ -15,6 +15,7 @@ using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.IO.Archives; using osu.Game.Online.API; +using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; @@ -23,6 +24,7 @@ using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Tests.Beatmaps.IO; using osu.Game.Tests.Resources; +using osu.Game.Users; namespace osu.Game.Tests.Scores.IO { @@ -284,6 +286,272 @@ namespace osu.Game.Tests.Scores.IO } } + [Test] + public void TestUserLookedUpByUsernameForOnlineScoreIfUserIDMissing() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) + { + try + { + var osu = LoadOsuIntoHost(host, true); + + var api = (DummyAPIAccess)osu.API; + api.HandleRequest = req => + { + switch (req) + { + case GetUserRequest userRequest: + if (userRequest.Lookup != "Test user") + return false; + + userRequest.TriggerSuccess(new APIUser + { + Username = "Test user", + CountryCode = CountryCode.JP, + Id = 1234 + }); + return true; + + default: + return false; + } + }; + + var beatmap = BeatmapImportHelper.LoadOszIntoOsu(osu, TestResources.GetQuickTestBeatmapForImport()).GetResultSafely(); + + var toImport = new ScoreInfo + { + Rank = ScoreRank.B, + TotalScore = 987654, + Accuracy = 0.8, + MaxCombo = 500, + Combo = 250, + User = new APIUser { Username = "Test user" }, + Date = DateTimeOffset.Now, + OnlineID = 12345, + Ruleset = new OsuRuleset().RulesetInfo, + BeatmapInfo = beatmap.Beatmaps.First() + }; + + var imported = LoadScoreIntoOsu(osu, toImport); + + Assert.AreEqual(toImport.Rank, imported.Rank); + Assert.AreEqual(toImport.TotalScore, imported.TotalScore); + Assert.AreEqual(toImport.Accuracy, imported.Accuracy); + Assert.AreEqual(toImport.MaxCombo, imported.MaxCombo); + Assert.AreEqual(toImport.User.Username, imported.User.Username); + Assert.AreEqual(toImport.Date, imported.Date); + Assert.AreEqual(toImport.OnlineID, imported.OnlineID); + Assert.AreEqual(toImport.User.Username, imported.RealmUser.Username); + Assert.AreEqual(1234, imported.RealmUser.OnlineID); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public void TestUserLookedUpByUsernameForLegacyOnlineScore() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) + { + try + { + var osu = LoadOsuIntoHost(host, true); + + var api = (DummyAPIAccess)osu.API; + api.HandleRequest = req => + { + switch (req) + { + case GetUserRequest userRequest: + if (userRequest.Lookup != "Test user") + return false; + + userRequest.TriggerSuccess(new APIUser + { + Username = "Test user", + CountryCode = CountryCode.JP, + Id = 1234 + }); + return true; + + default: + return false; + } + }; + + var beatmap = BeatmapImportHelper.LoadOszIntoOsu(osu, TestResources.GetQuickTestBeatmapForImport()).GetResultSafely(); + + var toImport = new ScoreInfo + { + Rank = ScoreRank.B, + TotalScore = 987654, + Accuracy = 0.8, + MaxCombo = 500, + Combo = 250, + User = new APIUser { Username = "Test user" }, + Date = DateTimeOffset.Now, + LegacyOnlineID = 12345, + Ruleset = new OsuRuleset().RulesetInfo, + BeatmapInfo = beatmap.Beatmaps.First() + }; + + var imported = LoadScoreIntoOsu(osu, toImport); + + Assert.AreEqual(toImport.Rank, imported.Rank); + Assert.AreEqual(toImport.TotalScore, imported.TotalScore); + Assert.AreEqual(toImport.Accuracy, imported.Accuracy); + Assert.AreEqual(toImport.MaxCombo, imported.MaxCombo); + Assert.AreEqual(toImport.User.Username, imported.User.Username); + Assert.AreEqual(toImport.Date, imported.Date); + Assert.AreEqual(toImport.OnlineID, imported.OnlineID); + Assert.AreEqual(toImport.User.Username, imported.RealmUser.Username); + Assert.AreEqual(1234, imported.RealmUser.OnlineID); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public void TestUserNotLookedUpForOfflineScoreIfUserIDMissing() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) + { + try + { + var osu = LoadOsuIntoHost(host, true); + + var api = (DummyAPIAccess)osu.API; + api.HandleRequest = req => + { + switch (req) + { + case GetUserRequest userRequest: + if (userRequest.Lookup != "Test user") + return false; + + userRequest.TriggerSuccess(new APIUser + { + Username = "Test user", + CountryCode = CountryCode.JP, + Id = 1234 + }); + return true; + + default: + return false; + } + }; + + var beatmap = BeatmapImportHelper.LoadOszIntoOsu(osu, TestResources.GetQuickTestBeatmapForImport()).GetResultSafely(); + + var toImport = new ScoreInfo + { + Rank = ScoreRank.B, + TotalScore = 987654, + Accuracy = 0.8, + MaxCombo = 500, + Combo = 250, + User = new APIUser { Username = "Test user" }, + Date = DateTimeOffset.Now, + OnlineID = -1, + LegacyOnlineID = -1, + Ruleset = new OsuRuleset().RulesetInfo, + BeatmapInfo = beatmap.Beatmaps.First() + }; + + var imported = LoadScoreIntoOsu(osu, toImport); + + Assert.AreEqual(toImport.Rank, imported.Rank); + Assert.AreEqual(toImport.TotalScore, imported.TotalScore); + Assert.AreEqual(toImport.Accuracy, imported.Accuracy); + Assert.AreEqual(toImport.MaxCombo, imported.MaxCombo); + Assert.AreEqual(toImport.User.Username, imported.User.Username); + Assert.AreEqual(toImport.Date, imported.Date); + Assert.AreEqual(toImport.OnlineID, imported.OnlineID); + Assert.AreEqual(toImport.User.Username, imported.RealmUser.Username); + Assert.That(imported.RealmUser.OnlineID, Is.LessThanOrEqualTo(1)); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public void TestUserLookedUpByOnlineIDIfPresent([Values] bool isOnlineScore) + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) + { + try + { + var osu = LoadOsuIntoHost(host, true); + + var api = (DummyAPIAccess)osu.API; + api.HandleRequest = req => + { + switch (req) + { + case GetUserRequest userRequest: + if (userRequest.Lookup != "5555") + return false; + + userRequest.TriggerSuccess(new APIUser + { + Username = "Some other guy", + CountryCode = CountryCode.DE, + Id = 5555 + }); + return true; + + default: + return false; + } + }; + + var beatmap = BeatmapImportHelper.LoadOszIntoOsu(osu, TestResources.GetQuickTestBeatmapForImport()).GetResultSafely(); + + var toImport = new ScoreInfo + { + Rank = ScoreRank.B, + TotalScore = 987654, + Accuracy = 0.8, + MaxCombo = 500, + Combo = 250, + User = new APIUser { Id = 5555 }, + Date = DateTimeOffset.Now, + Ruleset = new OsuRuleset().RulesetInfo, + BeatmapInfo = beatmap.Beatmaps.First() + }; + if (isOnlineScore) + toImport.OnlineID = 12345; + + var imported = LoadScoreIntoOsu(osu, toImport); + + Assert.AreEqual(toImport.Rank, imported.Rank); + Assert.AreEqual(toImport.TotalScore, imported.TotalScore); + Assert.AreEqual(toImport.Accuracy, imported.Accuracy); + Assert.AreEqual(toImport.MaxCombo, imported.MaxCombo); + Assert.AreEqual(toImport.Date, imported.Date); + Assert.AreEqual(toImport.OnlineID, imported.OnlineID); + Assert.AreEqual("Some other guy", imported.RealmUser.Username); + Assert.AreEqual(5555, imported.RealmUser.OnlineID); + Assert.AreEqual(CountryCode.DE, imported.RealmUser.CountryCode); + } + finally + { + host.Exit(); + } + } + } + public static ScoreInfo LoadScoreIntoOsu(OsuGameBase osu, ScoreInfo score, ArchiveReader archive = null) { // clone to avoid attaching the input score to realm. diff --git a/osu.Game.Tests/Skins/SkinDeserialisationTest.cs b/osu.Game.Tests/Skins/SkinDeserialisationTest.cs index d979bdab93..7372557161 100644 --- a/osu.Game.Tests/Skins/SkinDeserialisationTest.cs +++ b/osu.Game.Tests/Skins/SkinDeserialisationTest.cs @@ -12,6 +12,7 @@ using osu.Framework.IO.Stores; using osu.Game.Audio; using osu.Game.IO; using osu.Game.IO.Archives; +using osu.Game.Screens.Menu; using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.HUD.HitErrorMeters; using osu.Game.Skinning; @@ -62,6 +63,12 @@ namespace osu.Game.Tests.Skins "Archives/modified-argon-20231108.osk", // Covers "Argon" performance points counter "Archives/modified-argon-20240305.osk", + // Covers default rank display + "Archives/modified-default-20230809.osk", + // Covers legacy rank display + "Archives/modified-classic-20230809.osk", + // Covers legacy key counter + "Archives/modified-classic-20240724.osk" }; /// @@ -101,7 +108,7 @@ namespace osu.Game.Tests.Skins var skin = new TestSkin(new SkinInfo(), null, storage); Assert.That(skin.LayoutInfos, Has.Count.EqualTo(2)); - Assert.That(skin.LayoutInfos[SkinComponentsContainerLookup.TargetArea.MainHUDComponents].AllDrawables.ToArray(), Has.Length.EqualTo(9)); + Assert.That(skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].AllDrawables.ToArray(), Has.Length.EqualTo(9)); } } @@ -114,8 +121,20 @@ namespace osu.Game.Tests.Skins var skin = new TestSkin(new SkinInfo(), null, storage); Assert.That(skin.LayoutInfos, Has.Count.EqualTo(2)); - Assert.That(skin.LayoutInfos[SkinComponentsContainerLookup.TargetArea.MainHUDComponents].AllDrawables.ToArray(), Has.Length.EqualTo(10)); - Assert.That(skin.LayoutInfos[SkinComponentsContainerLookup.TargetArea.MainHUDComponents].AllDrawables.Select(i => i.Type), Contains.Item(typeof(PlayerName))); + Assert.That(skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].AllDrawables.ToArray(), Has.Length.EqualTo(10)); + Assert.That(skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].AllDrawables.Select(i => i.Type), Contains.Item(typeof(PlayerName))); + } + } + + [Test] + public void TestDeserialiseInvalidDrawables() + { + using (var stream = TestResources.OpenResource("Archives/argon-invalid-drawable.osk")) + using (var storage = new ZipArchiveReader(stream)) + { + var skin = new TestSkin(new SkinInfo(), null, storage); + + Assert.That(skin.LayoutInfos.Any(kvp => kvp.Value.AllDrawables.Any(d => d.Type == typeof(StarFountain))), Is.False); } } @@ -128,10 +147,10 @@ namespace osu.Game.Tests.Skins var skin = new TestSkin(new SkinInfo(), null, storage); Assert.That(skin.LayoutInfos, Has.Count.EqualTo(2)); - Assert.That(skin.LayoutInfos[SkinComponentsContainerLookup.TargetArea.MainHUDComponents].AllDrawables.ToArray(), Has.Length.EqualTo(6)); - Assert.That(skin.LayoutInfos[SkinComponentsContainerLookup.TargetArea.SongSelect].AllDrawables.ToArray(), Has.Length.EqualTo(1)); + Assert.That(skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].AllDrawables.ToArray(), Has.Length.EqualTo(6)); + Assert.That(skin.LayoutInfos[GlobalSkinnableContainers.SongSelect].AllDrawables.ToArray(), Has.Length.EqualTo(1)); - var skinnableInfo = skin.LayoutInfos[SkinComponentsContainerLookup.TargetArea.SongSelect].AllDrawables.First(); + var skinnableInfo = skin.LayoutInfos[GlobalSkinnableContainers.SongSelect].AllDrawables.First(); Assert.That(skinnableInfo.Type, Is.EqualTo(typeof(SkinnableSprite))); Assert.That(skinnableInfo.Settings.First().Key, Is.EqualTo("sprite_name")); @@ -142,10 +161,10 @@ namespace osu.Game.Tests.Skins using (var storage = new ZipArchiveReader(stream)) { var skin = new TestSkin(new SkinInfo(), null, storage); - Assert.That(skin.LayoutInfos[SkinComponentsContainerLookup.TargetArea.MainHUDComponents].AllDrawables.ToArray(), Has.Length.EqualTo(8)); - Assert.That(skin.LayoutInfos[SkinComponentsContainerLookup.TargetArea.MainHUDComponents].AllDrawables.Select(i => i.Type), Contains.Item(typeof(UnstableRateCounter))); - Assert.That(skin.LayoutInfos[SkinComponentsContainerLookup.TargetArea.MainHUDComponents].AllDrawables.Select(i => i.Type), Contains.Item(typeof(ColourHitErrorMeter))); - Assert.That(skin.LayoutInfos[SkinComponentsContainerLookup.TargetArea.MainHUDComponents].AllDrawables.Select(i => i.Type), Contains.Item(typeof(LegacySongProgress))); + Assert.That(skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].AllDrawables.ToArray(), Has.Length.EqualTo(8)); + Assert.That(skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].AllDrawables.Select(i => i.Type), Contains.Item(typeof(UnstableRateCounter))); + Assert.That(skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].AllDrawables.Select(i => i.Type), Contains.Item(typeof(ColourHitErrorMeter))); + Assert.That(skin.LayoutInfos[GlobalSkinnableContainers.MainHUDComponents].AllDrawables.Select(i => i.Type), Contains.Item(typeof(LegacySongProgress))); } } diff --git a/osu.Game.Tests/Skins/TestSceneBeatmapSkinResources.cs b/osu.Game.Tests/Skins/TestSceneBeatmapSkinResources.cs index d9212386c3..5086b64433 100644 --- a/osu.Game.Tests/Skins/TestSceneBeatmapSkinResources.cs +++ b/osu.Game.Tests/Skins/TestSceneBeatmapSkinResources.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.IO; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio.Track; @@ -12,6 +13,7 @@ using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Tests.Resources; using osu.Game.Tests.Visual; +using MemoryStream = System.IO.MemoryStream; namespace osu.Game.Tests.Skins { @@ -21,6 +23,52 @@ namespace osu.Game.Tests.Skins [Resolved] private BeatmapManager beatmaps { get; set; } = null!; + [Test] + public void TestRetrieveAndLegacyExportJapaneseFilename() + { + IWorkingBeatmap beatmap = null!; + MemoryStream outStream = null!; + + // Ensure importer encoding is correct + AddStep("import beatmap", () => beatmap = importBeatmapFromArchives(@"japanese-filename.osz")); + AddAssert("sample is non-null", () => beatmap.Skin.GetSample(new SampleInfo(@"見本")) != null); + + // Ensure exporter encoding is correct (round trip) + AddStep("export", () => + { + outStream = new MemoryStream(); + + new LegacyBeatmapExporter(LocalStorage) + .ExportToStream((BeatmapSetInfo)beatmap.BeatmapInfo.BeatmapSet!, outStream, null); + }); + + AddStep("import beatmap again", () => beatmap = importBeatmapFromStream(outStream)); + AddAssert("sample is non-null", () => beatmap.Skin.GetSample(new SampleInfo(@"見本")) != null); + } + + [Test] + public void TestRetrieveAndNonLegacyExportJapaneseFilename() + { + IWorkingBeatmap beatmap = null!; + MemoryStream outStream = null!; + + // Ensure importer encoding is correct + AddStep("import beatmap", () => beatmap = importBeatmapFromArchives(@"japanese-filename.osz")); + AddAssert("sample is non-null", () => beatmap.Skin.GetSample(new SampleInfo(@"見本")) != null); + + // Ensure exporter encoding is correct (round trip) + AddStep("export", () => + { + outStream = new MemoryStream(); + + new BeatmapExporter(LocalStorage) + .ExportToStream((BeatmapSetInfo)beatmap.BeatmapInfo.BeatmapSet!, outStream, null); + }); + + AddStep("import beatmap again", () => beatmap = importBeatmapFromStream(outStream)); + AddAssert("sample is non-null", () => beatmap.Skin.GetSample(new SampleInfo(@"見本")) != null); + } + [Test] public void TestRetrieveOggAudio() { @@ -45,6 +93,12 @@ namespace osu.Game.Tests.Skins AddAssert("sample is non-null", () => beatmap.Skin.GetSample(new SampleInfo(@"spinner-osu")) != null); } + private IWorkingBeatmap importBeatmapFromStream(Stream stream) + { + var imported = beatmaps.Import(new ImportTask(stream, "filename.osz")).GetResultSafely(); + return imported.AsNonNull().PerformRead(s => beatmaps.GetWorkingBeatmap(s.Beatmaps[0])); + } + private IWorkingBeatmap importBeatmapFromArchives(string filename) { var imported = beatmaps.Import(new ImportTask(TestResources.OpenResource($@"Archives/{filename}"), filename)).GetResultSafely(); diff --git a/osu.Game.Tests/Utils/BindableValueAccessorTest.cs b/osu.Game.Tests/Utils/BindableValueAccessorTest.cs new file mode 100644 index 0000000000..f09623dbfc --- /dev/null +++ b/osu.Game.Tests/Utils/BindableValueAccessorTest.cs @@ -0,0 +1,52 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Bindables; +using osu.Game.Utils; + +namespace osu.Game.Tests.Utils +{ + [TestFixture] + public class BindableValueAccessorTest + { + [Test] + public void GetValue() + { + const int value = 1337; + + BindableInt bindable = new BindableInt(value); + Assert.That(BindableValueAccessor.GetValue(bindable), Is.EqualTo(value)); + } + + [Test] + public void SetValue() + { + const int value = 1337; + + BindableInt bindable = new BindableInt(); + BindableValueAccessor.SetValue(bindable, value); + + Assert.That(bindable.Value, Is.EqualTo(value)); + } + + [Test] + public void GetInvalidBindable() + { + BindableList list = new BindableList(); + Assert.That(BindableValueAccessor.GetValue(list), Is.EqualTo(list)); + } + + [Test] + public void SetInvalidBindable() + { + const int value = 1337; + + BindableList list = new BindableList { value }; + BindableValueAccessor.SetValue(list, 2); + + Assert.That(list, Has.Exactly(1).Items); + Assert.That(list[0], Is.EqualTo(value)); + } + } +} diff --git a/osu.Game.Tests/Utils/GeometryUtilsTest.cs b/osu.Game.Tests/Utils/GeometryUtilsTest.cs new file mode 100644 index 0000000000..f73175bb5b --- /dev/null +++ b/osu.Game.Tests/Utils/GeometryUtilsTest.cs @@ -0,0 +1,51 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Utils; +using osuTK; + +namespace osu.Game.Tests.Utils +{ + [TestFixture] + public class GeometryUtilsTest + { + [TestCase(new int[] { }, new int[] { })] + [TestCase(new[] { 0, 0 }, new[] { 0, 0 })] + [TestCase(new[] { 0, 0, 1, 1, 1, -1, 2, 0 }, new[] { 0, 0, 1, 1, 2, 0, 1, -1 })] + [TestCase(new[] { 0, 0, 1, 1, 1, -1, 2, 0, 1, 0 }, new[] { 0, 0, 1, 1, 2, 0, 1, -1 })] + [TestCase(new[] { 0, 0, 1, 1, 2, -1, 2, 0, 1, 0, 4, 10 }, new[] { 0, 0, 4, 10, 2, -1 })] + public void TestConvexHull(int[] values, int[] expected) + { + var points = new Vector2[values.Length / 2]; + for (int i = 0; i < values.Length; i += 2) + points[i / 2] = new Vector2(values[i], values[i + 1]); + + var expectedPoints = new Vector2[expected.Length / 2]; + for (int i = 0; i < expected.Length; i += 2) + expectedPoints[i / 2] = new Vector2(expected[i], expected[i + 1]); + + var hull = GeometryUtils.GetConvexHull(points); + + Assert.That(hull, Is.EquivalentTo(expectedPoints)); + } + + [TestCase(new int[] { }, 0, 0, 0)] + [TestCase(new[] { 0, 0 }, 0, 0, 0)] + [TestCase(new[] { 0, 0, 1, 1, 1, -1, 2, 0 }, 1, 0, 1)] + [TestCase(new[] { 0, 0, 1, 1, 1, -1, 2, 0, 1, 0 }, 1, 0, 1)] + [TestCase(new[] { 0, 0, 1, 1, 2, -1, 2, 0, 1, 0, 4, 10 }, 3, 4.5f, 5.5901699f)] + public void TestMinimumEnclosingCircle(int[] values, float x, float y, float r) + { + var points = new Vector2[values.Length / 2]; + for (int i = 0; i < values.Length; i += 2) + points[i / 2] = new Vector2(values[i], values[i + 1]); + + (var centre, float radius) = GeometryUtils.MinimumEnclosingCircle(points); + + Assert.That(centre.X, Is.EqualTo(x).Within(0.0001)); + Assert.That(centre.Y, Is.EqualTo(y).Within(0.0001)); + Assert.That(radius, Is.EqualTo(r).Within(0.0001)); + } + } +} diff --git a/osu.Game.Tests/Visual/Background/TestSceneBackgroundScreenDefault.cs b/osu.Game.Tests/Visual/Background/TestSceneBackgroundScreenDefault.cs index 37f2ee0b3f..7865d8fef7 100644 --- a/osu.Game.Tests/Visual/Background/TestSceneBackgroundScreenDefault.cs +++ b/osu.Game.Tests/Visual/Background/TestSceneBackgroundScreenDefault.cs @@ -304,11 +304,6 @@ namespace osu.Game.Tests.Visual.Background { private bool? lastLoadTriggerCausedChange; - public TestBackgroundScreenDefault() - : base(false) - { - } - public override bool Next() { bool didChange = base.Next(); diff --git a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs index aac7689b1b..d8be57382f 100644 --- a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs +++ b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs @@ -18,6 +18,7 @@ using osu.Framework.Screens; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -48,13 +49,18 @@ namespace osu.Game.Tests.Visual.Background [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { + DetachedBeatmapStore detachedBeatmapStore; + Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(new OsuConfigManager(LocalStorage)); + Dependencies.Cache(detachedBeatmapStore = new DetachedBeatmapStore()); Dependencies.Cache(Realm); manager.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); + Add(detachedBeatmapStore); + Beatmap.SetDefault(); } diff --git a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardThumbnail.cs b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardThumbnail.cs index f44fe2b90c..f5f9d121cc 100644 --- a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardThumbnail.cs +++ b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardThumbnail.cs @@ -34,7 +34,7 @@ namespace osu.Game.Tests.Visual.Beatmaps var beatmapSet = CreateAPIBeatmapSet(Ruleset.Value); beatmapSet.OnlineID = 241526; // ID hardcoded to ensure that the preview track exists online. - Child = thumbnail = new BeatmapCardThumbnail(beatmapSet) + Child = thumbnail = new BeatmapCardThumbnail(beatmapSet, beatmapSet) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs b/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs index 747cf73baf..56e7b4d39f 100644 --- a/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs +++ b/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs @@ -205,7 +205,9 @@ namespace osu.Game.Tests.Visual.Collections AddStep("click first delete button", () => { - InputManager.MoveMouseTo(dialog.ChildrenOfType().First(), new Vector2(5, 0)); + InputManager.MoveMouseTo(dialog + .ChildrenOfType().Single(i => i.Model.Value.Name == "1") + .ChildrenOfType().Single(), new Vector2(5, 0)); InputManager.Click(MouseButton.Left); }); @@ -213,9 +215,11 @@ namespace osu.Game.Tests.Visual.Collections assertCollectionCount(1); assertCollectionName(0, "2"); - AddStep("click first delete button", () => + AddStep("click second delete button", () => { - InputManager.MoveMouseTo(dialog.ChildrenOfType().First(), new Vector2(5, 0)); + InputManager.MoveMouseTo(dialog + .ChildrenOfType().Single(i => i.Model.Value.Name == "2") + .ChildrenOfType().Single(), new Vector2(5, 0)); InputManager.Click(MouseButton.Left); }); @@ -261,6 +265,7 @@ namespace osu.Game.Tests.Visual.Collections } [Test] + [Solo] public void TestCollectionRenamedExternal() { BeatmapCollection first = null!; @@ -310,7 +315,7 @@ namespace osu.Game.Tests.Visual.Collections AddStep("focus first collection", () => { - InputManager.MoveMouseTo(firstItem = dialog.ChildrenOfType().First()); + InputManager.MoveMouseTo(firstItem = dialog.ChildrenOfType().Single(i => i.Model.Value.Name == "1")); InputManager.Click(MouseButton.Left); }); @@ -337,6 +342,6 @@ namespace osu.Game.Tests.Visual.Collections => AddUntilStep($"{count} collections shown", () => dialog.ChildrenOfType().Count() == count + 1); // +1 for placeholder private void assertCollectionName(int index, string name) - => AddUntilStep($"item {index + 1} has correct name", () => dialog.ChildrenOfType().ElementAt(index).ChildrenOfType().First().Text == name); + => AddUntilStep($"item {index + 1} has correct name", () => dialog.ChildrenOfType().Single().OrderedItems.ElementAt(index).ChildrenOfType().First().Text == name); } } diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallenge.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallenge.cs new file mode 100644 index 0000000000..2791563954 --- /dev/null +++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallenge.cs @@ -0,0 +1,120 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Screens; +using osu.Game.Localisation; +using osu.Game.Online.API; +using osu.Game.Online.Metadata; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Tests.Resources; +using osu.Game.Tests.Visual.Metadata; +using osu.Game.Tests.Visual.OnlinePlay; + +namespace osu.Game.Tests.Visual.DailyChallenge +{ + public partial class TestSceneDailyChallenge : OnlinePlayTestScene + { + [Cached(typeof(MetadataClient))] + private TestMetadataClient metadataClient = new TestMetadataClient(); + + [Cached(typeof(INotificationOverlay))] + private NotificationOverlay notificationOverlay = new NotificationOverlay(); + + [BackgroundDependencyLoader] + private void load() + { + base.Content.Add(notificationOverlay); + base.Content.Add(metadataClient); + } + + [Test] + public void TestDailyChallenge() + { + var room = new Room + { + RoomID = { Value = 1234 }, + Name = { Value = "Daily Challenge: June 4, 2024" }, + Playlist = + { + new PlaylistItem(TestResources.CreateTestBeatmapSetInfo().Beatmaps.First()) + { + RequiredMods = [new APIMod(new OsuModTraceable())], + AllowedMods = [new APIMod(new OsuModDoubleTime())] + } + }, + EndDate = { Value = DateTimeOffset.Now.AddHours(12) }, + Category = { Value = RoomCategory.DailyChallenge } + }; + + AddStep("add room", () => API.Perform(new CreateRoomRequest(room))); + AddStep("push screen", () => LoadScreen(new Screens.OnlinePlay.DailyChallenge.DailyChallenge(room))); + } + + [Test] + public void TestNotifications() + { + var room = new Room + { + RoomID = { Value = 1234 }, + Name = { Value = "Daily Challenge: June 4, 2024" }, + Playlist = + { + new PlaylistItem(TestResources.CreateTestBeatmapSetInfo().Beatmaps.First()) + { + RequiredMods = [new APIMod(new OsuModTraceable())], + AllowedMods = [new APIMod(new OsuModDoubleTime())] + } + }, + EndDate = { Value = DateTimeOffset.Now.AddHours(12) }, + Category = { Value = RoomCategory.DailyChallenge } + }; + + AddStep("add room", () => API.Perform(new CreateRoomRequest(room))); + AddStep("set daily challenge info", () => metadataClient.DailyChallengeInfo.Value = new DailyChallengeInfo { RoomID = 1234 }); + + Screens.OnlinePlay.DailyChallenge.DailyChallenge screen = null!; + AddStep("push screen", () => LoadScreen(screen = new Screens.OnlinePlay.DailyChallenge.DailyChallenge(room))); + AddUntilStep("wait for screen", () => screen.IsCurrentScreen()); + AddStep("daily challenge ended", () => metadataClient.DailyChallengeInfo.Value = null); + AddAssert("notification posted", () => notificationOverlay.AllNotifications.OfType().Any(n => n.Text == DailyChallengeStrings.ChallengeEndedNotification)); + } + + [Test] + public void TestConclusionNotificationDoesNotFireOnDisconnect() + { + var room = new Room + { + RoomID = { Value = 1234 }, + Name = { Value = "Daily Challenge: June 4, 2024" }, + Playlist = + { + new PlaylistItem(TestResources.CreateTestBeatmapSetInfo().Beatmaps.First()) + { + RequiredMods = [new APIMod(new OsuModTraceable())], + AllowedMods = [new APIMod(new OsuModDoubleTime())] + } + }, + EndDate = { Value = DateTimeOffset.Now.AddHours(12) }, + Category = { Value = RoomCategory.DailyChallenge } + }; + + AddStep("add room", () => API.Perform(new CreateRoomRequest(room))); + AddStep("set daily challenge info", () => metadataClient.DailyChallengeInfo.Value = new DailyChallengeInfo { RoomID = 1234 }); + + Screens.OnlinePlay.DailyChallenge.DailyChallenge screen = null!; + AddStep("push screen", () => LoadScreen(screen = new Screens.OnlinePlay.DailyChallenge.DailyChallenge(room))); + AddUntilStep("wait for screen", () => screen.IsCurrentScreen()); + AddStep("disconnect from metadata server", () => metadataClient.Disconnect()); + AddUntilStep("wait for disconnection", () => metadataClient.DailyChallengeInfo.Value, () => Is.Null); + AddAssert("no notification posted", () => notificationOverlay.AllNotifications, () => Is.Empty); + AddStep("reconnect to metadata server", () => metadataClient.Reconnect()); + } + } +} diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeCarousel.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeCarousel.cs new file mode 100644 index 0000000000..d53e386ad4 --- /dev/null +++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeCarousel.cs @@ -0,0 +1,185 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using NUnit.Framework; +using osu.Framework.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.Framework.Utils; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Screens.OnlinePlay.DailyChallenge; +using osu.Game.Screens.OnlinePlay.DailyChallenge.Events; + +namespace osu.Game.Tests.Visual.DailyChallenge +{ + public partial class TestSceneDailyChallengeCarousel : OsuTestScene + { + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Plum); + + private readonly Bindable room = new Bindable(new Room()); + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => new CachedModelDependencyContainer(base.CreateChildDependencies(parent)) + { + Model = { BindTarget = room } + }; + + [Test] + public void TestBasicAppearance() + { + DailyChallengeCarousel carousel = null!; + + AddStep("create content", () => Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4, + }, + carousel = new DailyChallengeCarousel + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }); + AddSliderStep("adjust width", 0.1f, 1, 1, width => + { + if (carousel.IsNotNull()) + carousel.Width = width; + }); + AddSliderStep("adjust height", 0.1f, 1, 1, height => + { + if (carousel.IsNotNull()) + carousel.Height = height; + }); + AddRepeatStep("add content", () => carousel.Add(new FakeContent()), 3); + } + + [Test] + public void TestIntegration() + { + GridContainer grid = null!; + DailyChallengeEventFeed feed = null!; + DailyChallengeScoreBreakdown breakdown = null!; + + AddStep("create content", () => Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4, + }, + grid = new GridContainer + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RowDimensions = + [ + new Dimension(), + new Dimension() + ], + Content = new[] + { + new Drawable[] + { + new DailyChallengeCarousel + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + new DailyChallengeTimeRemainingRing(), + breakdown = new DailyChallengeScoreBreakdown(), + } + } + }, + [ + feed = new DailyChallengeEventFeed + { + RelativeSizeAxes = Axes.Both, + } + ], + } + }, + }); + AddSliderStep("adjust width", 0.1f, 1, 1, width => + { + if (grid.IsNotNull()) + grid.Width = width; + }); + AddSliderStep("adjust height", 0.1f, 1, 1, height => + { + if (grid.IsNotNull()) + grid.Height = height; + }); + AddSliderStep("update time remaining", 0f, 1f, 0f, progress => + { + var startedTimeAgo = TimeSpan.FromHours(24) * progress; + room.Value.StartDate.Value = DateTimeOffset.Now - startedTimeAgo; + room.Value.EndDate.Value = room.Value.StartDate.Value.Value.AddDays(1); + }); + AddStep("add normal score", () => + { + var ev = new NewScoreEvent(1, new APIUser + { + Id = 2, + Username = "peppy", + CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + }, RNG.Next(1_000_000), null); + + feed.AddNewScore(ev); + breakdown.AddNewScore(ev); + }); + AddStep("add new user best", () => + { + var ev = new NewScoreEvent(1, new APIUser + { + Id = 2, + Username = "peppy", + CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + }, RNG.Next(1_000_000), RNG.Next(1, 1000)); + + feed.AddNewScore(ev); + breakdown.AddNewScore(ev); + }); + } + + private partial class FakeContent : CompositeDrawable + { + private OsuSpriteText text = null!; + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = new Colour4(RNG.NextSingle(), RNG.NextSingle(), RNG.NextSingle(), 1), + }, + text = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Fake Content " + (char)('A' + RNG.Next(26)), + }, + }; + + text.FadeOut(500, Easing.OutQuint) + .Then().FadeIn(500, Easing.OutQuint) + .Loop(); + } + } + } +} diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeEventFeed.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeEventFeed.cs new file mode 100644 index 0000000000..4b784f661d --- /dev/null +++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeEventFeed.cs @@ -0,0 +1,119 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osu.Game.Screens.OnlinePlay.DailyChallenge; +using osu.Game.Screens.OnlinePlay.DailyChallenge.Events; +using osu.Game.Tests.Resources; + +namespace osu.Game.Tests.Visual.DailyChallenge +{ + public partial class TestSceneDailyChallengeEventFeed : OsuTestScene + { + private DailyChallengeEventFeed feed = null!; + + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Plum); + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create content", () => Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4, + }, + feed = new DailyChallengeEventFeed + { + RelativeSizeAxes = Axes.Both, + Height = 0.3f, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }); + + AddSliderStep("adjust width", 0.1f, 1, 1, width => + { + if (feed.IsNotNull()) + feed.Width = width; + }); + AddSliderStep("adjust height", 0.1f, 1, 0.3f, height => + { + if (feed.IsNotNull()) + feed.Height = height; + }); + } + + [Test] + public void TestBasicAppearance() + { + AddRepeatStep("add normal score", () => + { + var ev = new NewScoreEvent(1, new APIUser + { + Id = 2, + Username = "peppy", + CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + }, RNG.Next(1_000_000), null); + + feed.AddNewScore(ev); + }, 50); + + AddRepeatStep("add new user best", () => + { + var ev = new NewScoreEvent(1, new APIUser + { + Id = 2, + Username = "peppy", + CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + }, RNG.Next(1_000_000), RNG.Next(11, 1000)); + + var testScore = TestResources.CreateTestScoreInfo(); + testScore.TotalScore = RNG.Next(1_000_000); + + feed.AddNewScore(ev); + }, 50); + + AddRepeatStep("add top 10 score", () => + { + var ev = new NewScoreEvent(1, new APIUser + { + Id = 2, + Username = "peppy", + CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + }, RNG.Next(1_000_000), RNG.Next(1, 10)); + + feed.AddNewScore(ev); + }, 50); + } + + [Test] + public void TestMassAdd() + { + AddStep("add 1000 scores at once", () => + { + for (int i = 0; i < 1000; i++) + { + var ev = new NewScoreEvent(1, new APIUser + { + Id = 2, + Username = "peppy", + CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + }, RNG.Next(1_000_000), null); + + feed.AddNewScore(ev); + } + }); + } + } +} diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeIntro.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeIntro.cs new file mode 100644 index 0000000000..7619328e68 --- /dev/null +++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeIntro.cs @@ -0,0 +1,89 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Game.Configuration; +using osu.Game.Online.API; +using osu.Game.Online.Metadata; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Screens.Menu; +using osu.Game.Screens.OnlinePlay.DailyChallenge; +using osu.Game.Tests.Visual.Metadata; +using osu.Game.Tests.Visual.OnlinePlay; +using osuTK.Graphics; +using osuTK.Input; +using CreateRoomRequest = osu.Game.Online.Rooms.CreateRoomRequest; + +namespace osu.Game.Tests.Visual.DailyChallenge +{ + public partial class TestSceneDailyChallengeIntro : OnlinePlayTestScene + { + [Cached(typeof(MetadataClient))] + private TestMetadataClient metadataClient = new TestMetadataClient(); + + [Cached(typeof(INotificationOverlay))] + private NotificationOverlay notificationOverlay = new NotificationOverlay(); + + private Room room = null!; + + [BackgroundDependencyLoader] + private void load() + { + Add(notificationOverlay); + Add(metadataClient); + + // add button to observe for daily challenge changes and perform its logic. + Add(new DailyChallengeButton(@"button-default-select", new Color4(102, 68, 204, 255), (_, _) => { }, 0, Key.D)); + } + + [Test] + public void TestDailyChallenge() + { + startChallenge(1234); + AddStep("push screen", () => LoadScreen(new DailyChallengeIntro(room))); + } + + [Test] + public void TestPlayIntroOnceFlag() + { + startChallenge(1234); + AddStep("set intro played flag", () => Dependencies.Get().SetValue(Static.DailyChallengeIntroPlayed, true)); + + startChallenge(1235); + + AddAssert("intro played flag reset", () => Dependencies.Get().Get(Static.DailyChallengeIntroPlayed), () => Is.False); + + AddStep("push screen", () => LoadScreen(new DailyChallengeIntro(room))); + AddUntilStep("intro played flag set", () => Dependencies.Get().Get(Static.DailyChallengeIntroPlayed), () => Is.True); + } + + private void startChallenge(int roomId) + { + AddStep("add room", () => + { + API.Perform(new CreateRoomRequest(room = new Room + { + RoomID = { Value = roomId }, + Name = { Value = "Daily Challenge: June 4, 2024" }, + Playlist = + { + new PlaylistItem(CreateAPIBeatmap(new OsuRuleset().RulesetInfo)) + { + RequiredMods = [new APIMod(new OsuModTraceable())], + AllowedMods = [new APIMod(new OsuModDoubleTime())] + } + }, + StartDate = { Value = DateTimeOffset.Now }, + EndDate = { Value = DateTimeOffset.Now.AddHours(24) }, + Category = { Value = RoomCategory.DailyChallenge } + })); + }); + AddStep("signal client", () => metadataClient.DailyChallengeUpdated(new DailyChallengeInfo { RoomID = roomId })); + } + } +} diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeLeaderboard.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeLeaderboard.cs new file mode 100644 index 0000000000..5fff6bb010 --- /dev/null +++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeLeaderboard.cs @@ -0,0 +1,142 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Utils; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Screens.OnlinePlay.DailyChallenge; +using osuTK; + +namespace osu.Game.Tests.Visual.DailyChallenge +{ + public partial class TestSceneDailyChallengeLeaderboard : OsuTestScene + { + private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; + + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Plum); + + [Test] + public void TestBasicBehaviour() + { + DailyChallengeLeaderboard leaderboard = null!; + + AddStep("set up response without user best", () => + { + dummyAPI.HandleRequest = req => + { + if (req is IndexPlaylistScoresRequest indexRequest) + { + indexRequest.TriggerSuccess(createResponse(50, false)); + return true; + } + + return false; + }; + }); + AddStep("create leaderboard", () => Child = leaderboard = new DailyChallengeLeaderboard(new Room { RoomID = { Value = 1 } }, new PlaylistItem(Beatmap.Value.BeatmapInfo)) + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(0.8f), + }); + + AddStep("set up response with user best", () => + { + dummyAPI.HandleRequest = req => + { + if (req is IndexPlaylistScoresRequest indexRequest) + { + indexRequest.TriggerSuccess(createResponse(50, true)); + return true; + } + + return false; + }; + }); + AddStep("force refetch", () => leaderboard.RefetchScores()); + } + + [Test] + public void TestLoadingBehaviour() + { + IndexPlaylistScoresRequest pendingRequest = null!; + DailyChallengeLeaderboard leaderboard = null!; + + AddStep("set up requests handler", () => + { + dummyAPI.HandleRequest = req => + { + if (req is IndexPlaylistScoresRequest indexRequest) + { + pendingRequest = indexRequest; + return true; + } + + return false; + }; + }); + AddStep("create leaderboard", () => Child = leaderboard = new DailyChallengeLeaderboard(new Room { RoomID = { Value = 1 } }, new PlaylistItem(Beatmap.Value.BeatmapInfo)) + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(0.8f), + }); + AddStep("complete load", () => pendingRequest.TriggerSuccess(createResponse(3, true))); + AddStep("force refetch", () => leaderboard.RefetchScores()); + AddStep("complete load", () => pendingRequest.TriggerSuccess(createResponse(4, true))); + } + + private IndexedMultiplayerScores createResponse(int scoreCount, bool returnUserBest) + { + var result = new IndexedMultiplayerScores(); + + for (int i = 0; i < scoreCount; ++i) + { + result.Scores.Add(new MultiplayerScore + { + ID = i, + Accuracy = 1 - (float)i / (2 * scoreCount), + Position = i + 1, + EndedAt = DateTimeOffset.Now, + Passed = true, + Rank = (ScoreRank)RNG.Next((int)ScoreRank.D, (int)ScoreRank.XH), + MaxCombo = 1000 - i, + TotalScore = (long)(1_000_000 * (1 - (float)i / (2 * scoreCount))), + User = new APIUser { Username = $"user {i}" }, + Statistics = new Dictionary() + }); + } + + if (returnUserBest) + { + result.UserScore = new MultiplayerScore + { + ID = 99999, + Accuracy = 0.91, + Position = 4, + EndedAt = DateTimeOffset.Now, + Passed = true, + Rank = ScoreRank.A, + MaxCombo = 100, + TotalScore = 800000, + User = dummyAPI.LocalUser.Value, + Statistics = new Dictionary() + }; + } + + return result; + } + } +} diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeScoreBreakdown.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeScoreBreakdown.cs new file mode 100644 index 0000000000..b04696aded --- /dev/null +++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeScoreBreakdown.cs @@ -0,0 +1,96 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Screens.OnlinePlay.DailyChallenge; +using osu.Game.Screens.OnlinePlay.DailyChallenge.Events; + +namespace osu.Game.Tests.Visual.DailyChallenge +{ + public partial class TestSceneDailyChallengeScoreBreakdown : OsuTestScene + { + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Plum); + + private DailyChallengeScoreBreakdown breakdown = null!; + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create content", () => Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4, + }, + breakdown = new DailyChallengeScoreBreakdown + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }); + AddSliderStep("adjust width", 0.1f, 1, 1, width => + { + if (breakdown.IsNotNull()) + breakdown.Width = width; + }); + AddSliderStep("adjust height", 0.1f, 1, 1, height => + { + if (breakdown.IsNotNull()) + breakdown.Height = height; + }); + + AddToggleStep("toggle visible", v => breakdown.Alpha = v ? 1 : 0); + + AddStep("set initial data", () => breakdown.SetInitialCounts([1, 4, 9, 16, 25, 36, 49, 36, 25, 16, 9, 4, 1])); + } + + [Test] + public void TestBasicAppearance() + { + AddStep("add new score", () => + { + var ev = new NewScoreEvent(1, new APIUser + { + Id = 2, + Username = "peppy", + CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + }, RNG.Next(1_000_000), null); + + breakdown.AddNewScore(ev); + }); + AddStep("set user score", () => breakdown.UserBestScore.Value = new MultiplayerScore { TotalScore = RNG.Next(1_000_000) }); + AddStep("unset user score", () => breakdown.UserBestScore.Value = null); + } + + [Test] + public void TestMassAdd() + { + AddStep("add 1000 scores at once", () => + { + for (int i = 0; i < 1000; i++) + { + var ev = new NewScoreEvent(1, new APIUser + { + Id = 2, + Username = "peppy", + CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + }, RNG.Next(1_000_000), null); + + breakdown.AddNewScore(ev); + } + }); + } + } +} diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeTimeRemainingRing.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeTimeRemainingRing.cs new file mode 100644 index 0000000000..baa1eb8318 --- /dev/null +++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeTimeRemainingRing.cs @@ -0,0 +1,88 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Screens.OnlinePlay.DailyChallenge; + +namespace osu.Game.Tests.Visual.DailyChallenge +{ + public partial class TestSceneDailyChallengeTimeRemainingRing : OsuTestScene + { + private readonly Bindable room = new Bindable(new Room()); + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => new CachedModelDependencyContainer(base.CreateChildDependencies(parent)) + { + Model = { BindTarget = room } + }; + + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Plum); + + [Test] + public void TestBasicAppearance() + { + DailyChallengeTimeRemainingRing ring = null!; + + AddStep("create content", () => Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4, + }, + ring = new DailyChallengeTimeRemainingRing + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }); + AddSliderStep("adjust width", 0.1f, 1, 1, width => + { + if (ring.IsNotNull()) + ring.Width = width; + }); + AddSliderStep("adjust height", 0.1f, 1, 1, height => + { + if (ring.IsNotNull()) + ring.Height = height; + }); + AddToggleStep("toggle visible", v => ring.Alpha = v ? 1 : 0); + + AddStep("just started", () => + { + room.Value.StartDate.Value = DateTimeOffset.Now.AddMinutes(-1); + room.Value.EndDate.Value = room.Value.StartDate.Value.Value.AddDays(1); + }); + AddStep("midway through", () => + { + room.Value.StartDate.Value = DateTimeOffset.Now.AddHours(-12); + room.Value.EndDate.Value = room.Value.StartDate.Value.Value.AddDays(1); + }); + AddStep("nearing end", () => + { + room.Value.StartDate.Value = DateTimeOffset.Now.AddDays(-1).AddMinutes(8); + room.Value.EndDate.Value = room.Value.StartDate.Value.Value.AddDays(1); + }); + AddStep("already ended", () => + { + room.Value.StartDate.Value = DateTimeOffset.Now.AddDays(-2); + room.Value.EndDate.Value = room.Value.StartDate.Value.Value.AddDays(1); + }); + AddSliderStep("manual progress", 0f, 1f, 0f, progress => + { + var startedTimeAgo = TimeSpan.FromHours(24) * progress; + room.Value.StartDate.Value = DateTimeOffset.Now - startedTimeAgo; + room.Value.EndDate.Value = room.Value.StartDate.Value.Value.AddDays(1); + }); + } + } +} diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeTotalsDisplay.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeTotalsDisplay.cs new file mode 100644 index 0000000000..ae212f5212 --- /dev/null +++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeTotalsDisplay.cs @@ -0,0 +1,87 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Utils; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osu.Game.Screens.OnlinePlay.DailyChallenge; +using osu.Game.Screens.OnlinePlay.DailyChallenge.Events; +using osu.Game.Tests.Resources; + +namespace osu.Game.Tests.Visual.DailyChallenge +{ + public partial class TestSceneDailyChallengeTotalsDisplay : OsuTestScene + { + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Plum); + + [Test] + public void TestBasicAppearance() + { + DailyChallengeTotalsDisplay totals = null!; + + AddStep("create content", () => Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4, + }, + totals = new DailyChallengeTotalsDisplay + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }); + AddSliderStep("adjust width", 0.1f, 1, 1, width => + { + if (totals.IsNotNull()) + totals.Width = width; + }); + AddSliderStep("adjust height", 0.1f, 1, 1, height => + { + if (totals.IsNotNull()) + totals.Height = height; + }); + AddToggleStep("toggle visible", v => totals.Alpha = v ? 1 : 0); + + AddStep("set counts", () => totals.SetInitialCounts(totalPassCount: 9650, cumulativeTotalScore: 10_000_000_000)); + + AddStep("add normal score", () => + { + var ev = new NewScoreEvent(1, new APIUser + { + Id = 2, + Username = "peppy", + CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + }, RNG.Next(1_000_000), null); + + totals.AddNewScore(ev); + }); + + AddStep("spam scores", () => + { + for (int i = 0; i < 1000; ++i) + { + var ev = new NewScoreEvent(1, new APIUser + { + Id = 2, + Username = "peppy", + CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + }, RNG.Next(1_000_000), RNG.Next(11, 1000)); + + var testScore = TestResources.CreateTestScoreInfo(); + testScore.TotalScore = RNG.Next(1_000_000); + + totals.AddNewScore(ev); + } + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneColoursSection.cs b/osu.Game.Tests/Visual/Editing/TestSceneColoursSection.cs new file mode 100644 index 0000000000..5a3329bbc9 --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneColoursSection.cs @@ -0,0 +1,123 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.Setup; +using osu.Game.Skinning; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.Editing +{ + [HeadlessTest] + public partial class TestSceneColoursSection : OsuManualInputManagerTestScene + { + [Test] + public void TestNoBeatmapSkinColours() + { + LegacyBeatmapSkin skin = null!; + ColoursSection coloursSection = null!; + + AddStep("create beatmap skin", () => skin = new LegacyBeatmapSkin(new BeatmapInfo(), null)); + AddStep("create colours section", () => Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = + [ + (typeof(EditorBeatmap), new EditorBeatmap(new Beatmap + { + BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo } + }, skin)), + (typeof(OverlayColourProvider), new OverlayColourProvider(OverlayColourScheme.Aquamarine)) + ], + Child = coloursSection = new ColoursSection + { + RelativeSizeAxes = Axes.X, + } + }); + AddAssert("beatmap skin has no colours", () => skin.Configuration.CustomComboColours, () => Is.Empty); + AddAssert("section displays default combo colours", + () => coloursSection.ChildrenOfType().Single().Colours, + () => Is.EquivalentTo(new Colour4[] + { + SkinConfiguration.DefaultComboColours[1], + SkinConfiguration.DefaultComboColours[2], + SkinConfiguration.DefaultComboColours[3], + SkinConfiguration.DefaultComboColours[0], + })); + + AddStep("add a colour", () => coloursSection.ChildrenOfType().Single().Colours.Add(Colour4.Aqua)); + AddAssert("beatmap skin has colours", + () => skin.Configuration.CustomComboColours, + () => Is.EquivalentTo(new[] + { + SkinConfiguration.DefaultComboColours[1], + SkinConfiguration.DefaultComboColours[2], + SkinConfiguration.DefaultComboColours[3], + Color4.Aqua, + SkinConfiguration.DefaultComboColours[0], + })); + } + + [Test] + public void TestExistingColours() + { + LegacyBeatmapSkin skin = null!; + ColoursSection coloursSection = null!; + + AddStep("create beatmap skin", () => + { + skin = new LegacyBeatmapSkin(new BeatmapInfo(), null); + skin.Configuration.CustomComboColours = new List + { + Color4.Azure, + Color4.Beige, + Color4.Chartreuse + }; + }); + AddStep("create colours section", () => Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = + [ + (typeof(EditorBeatmap), new EditorBeatmap(new Beatmap + { + BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo } + }, skin)), + (typeof(OverlayColourProvider), new OverlayColourProvider(OverlayColourScheme.Aquamarine)) + ], + Child = coloursSection = new ColoursSection + { + RelativeSizeAxes = Axes.X, + } + }); + AddAssert("section displays combo colours", + () => coloursSection.ChildrenOfType().Single().Colours, + () => Is.EquivalentTo(new[] + { + Colour4.Beige, + Colour4.Chartreuse, + Colour4.Azure, + })); + + AddStep("add a colour", () => coloursSection.ChildrenOfType().Single().Colours.Add(Colour4.Aqua)); + AddAssert("beatmap skin has colours", + () => skin.Configuration.CustomComboColours, + () => Is.EquivalentTo(new[] + { + Color4.Azure, + Color4.Beige, + Color4.Aqua, + Color4.Chartreuse + })); + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs b/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs index 8e4f4a1cfd..2bf07d8e27 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs @@ -10,9 +10,11 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; using osu.Framework.Testing; using osu.Framework.Threading; using osu.Game.Screens.Edit.Compose.Components; +using osu.Game.Utils; using osuTK; using osuTK.Input; @@ -26,9 +28,13 @@ namespace osu.Game.Tests.Visual.Editing [Cached(typeof(SelectionRotationHandler))] private TestSelectionRotationHandler rotationHandler; + [Cached(typeof(SelectionScaleHandler))] + private TestSelectionScaleHandler scaleHandler; + public TestSceneComposeSelectBox() { rotationHandler = new TestSelectionRotationHandler(() => selectionArea); + scaleHandler = new TestSelectionScaleHandler(() => selectionArea); } [SetUp] @@ -45,13 +51,8 @@ namespace osu.Game.Tests.Visual.Editing { RelativeSizeAxes = Axes.Both, - CanScaleX = true, - CanScaleY = true, - CanScaleDiagonally = true, CanFlipX = true, CanFlipY = true, - - OnScale = handleScale } } }; @@ -60,27 +61,6 @@ namespace osu.Game.Tests.Visual.Editing InputManager.ReleaseButton(MouseButton.Left); }); - private bool handleScale(Vector2 amount, Anchor reference) - { - if ((reference & Anchor.y1) == 0) - { - int directionY = (reference & Anchor.y0) > 0 ? -1 : 1; - if (directionY < 0) - selectionArea.Y += amount.Y; - selectionArea.Height += directionY * amount.Y; - } - - if ((reference & Anchor.x1) == 0) - { - int directionX = (reference & Anchor.x0) > 0 ? -1 : 1; - if (directionX < 0) - selectionArea.X += amount.X; - selectionArea.Width += directionX * amount.X; - } - - return true; - } - private partial class TestSelectionRotationHandler : SelectionRotationHandler { private readonly Func getTargetContainer; @@ -89,7 +69,7 @@ namespace osu.Game.Tests.Visual.Editing { this.getTargetContainer = getTargetContainer; - CanRotateSelectionOrigin.Value = true; + CanRotateAroundSelectionOrigin.Value = true; } [CanBeNull] @@ -104,6 +84,9 @@ namespace osu.Game.Tests.Visual.Editing targetContainer = getTargetContainer(); initialRotation = targetContainer!.Rotation; + DefaultOrigin = ToLocalSpace(targetContainer.ToScreenSpace(Vector2.Zero)); + + base.Begin(); } public override void Update(float rotation, Vector2? origin = null) @@ -122,6 +105,53 @@ namespace osu.Game.Tests.Visual.Editing targetContainer = null; initialRotation = null; + + base.Commit(); + } + } + + private partial class TestSelectionScaleHandler : SelectionScaleHandler + { + private readonly Func getTargetContainer; + + public TestSelectionScaleHandler(Func getTargetContainer) + { + this.getTargetContainer = getTargetContainer; + + CanScaleX.Value = true; + CanScaleY.Value = true; + CanScaleDiagonally.Value = true; + } + + [CanBeNull] + private Container targetContainer; + + public override void Begin() + { + if (targetContainer != null) + throw new InvalidOperationException($"Cannot {nameof(Begin)} a scale operation while another is in progress!"); + + targetContainer = getTargetContainer(); + OriginalSurroundingQuad = new Quad(targetContainer!.X, targetContainer.Y, targetContainer.Width, targetContainer.Height); + } + + public override void Update(Vector2 scale, Vector2? origin = null, Axes adjustAxis = Axes.Both, float axisRotation = 0) + { + if (targetContainer == null) + throw new InvalidOperationException($"Cannot {nameof(Update)} a scale operation without calling {nameof(Begin)} first!"); + + Vector2 actualOrigin = origin ?? Vector2.Zero; + + targetContainer.Position = GeometryUtils.GetScaledPosition(scale, actualOrigin, OriginalSurroundingQuad!.Value.TopLeft); + targetContainer.Size = OriginalSurroundingQuad!.Value.Size * scale; + } + + public override void Commit() + { + if (targetContainer == null) + throw new InvalidOperationException($"Cannot {nameof(Commit)} a scale operation without calling {nameof(Begin)} first!"); + + targetContainer = null; } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs b/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs index 3884a3108f..fd3431c08b 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs @@ -4,9 +4,11 @@ using System; using System.Linq; using NUnit.Framework; +using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.UserInterface; using osu.Framework.Testing; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets; @@ -36,6 +38,9 @@ namespace osu.Game.Tests.Visual.Editing private ContextMenuContainer contextMenuContainer => Editor.ChildrenOfType().First(); + private SelectionBoxScaleHandle getScaleHandle(Anchor anchor) + => Editor.ChildrenOfType().First(it => it.Anchor == anchor); + private void moveMouseToObject(Func targetFunc) { AddStep("move mouse to object", () => @@ -78,7 +83,7 @@ namespace osu.Game.Tests.Visual.Editing } [Test] - public void TestNudgeSelection() + public void TestNudgeSelectionTime() { HitCircle[] addedObjects = null!; @@ -99,6 +104,51 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("objects reverted to original position", () => addedObjects[0].StartTime == 100); } + [Test] + public void TestNudgeSelectionPosition() + { + HitCircle addedObject = null!; + + AddStep("add hitobjects", () => EditorBeatmap.AddRange(new[] + { + addedObject = new HitCircle { StartTime = 200, Position = new Vector2(100) }, + })); + + AddStep("select object", () => EditorBeatmap.SelectedHitObjects.Add(addedObject)); + + AddStep("nudge up", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Key(Key.Up); + InputManager.ReleaseKey(Key.ControlLeft); + }); + AddAssert("object position moved up", () => addedObject.Position.Y, () => Is.EqualTo(99).Within(Precision.FLOAT_EPSILON)); + + AddStep("nudge down", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Key(Key.Down); + InputManager.ReleaseKey(Key.ControlLeft); + }); + AddAssert("object position moved down", () => addedObject.Position.Y, () => Is.EqualTo(100).Within(Precision.FLOAT_EPSILON)); + + AddStep("nudge left", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Key(Key.Left); + InputManager.ReleaseKey(Key.ControlLeft); + }); + AddAssert("object position moved left", () => addedObject.Position.X, () => Is.EqualTo(99).Within(Precision.FLOAT_EPSILON)); + + AddStep("nudge right", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Key(Key.Right); + InputManager.ReleaseKey(Key.ControlLeft); + }); + AddAssert("object position moved right", () => addedObject.Position.X, () => Is.EqualTo(100).Within(Precision.FLOAT_EPSILON)); + } + [Test] public void TestRotateHotkeys() { @@ -215,6 +265,51 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("2 hitobjects selected", () => EditorBeatmap.SelectedHitObjects.Count == 2 && !EditorBeatmap.SelectedHitObjects.Contains(addedObjects[1])); } + [Test] + public void TestMultiSelectWithDragBox() + { + var addedObjects = new[] + { + new HitCircle { StartTime = 100 }, + new HitCircle { StartTime = 200, Position = new Vector2(100) }, + new HitCircle { StartTime = 300, Position = new Vector2(512, 0) }, + new HitCircle { StartTime = 400, Position = new Vector2(412, 100) }, + }; + AddStep("add hitobjects", () => EditorBeatmap.AddRange(addedObjects)); + + AddStep("start dragging", () => + { + InputManager.MoveMouseTo(blueprintContainer.ScreenSpaceDrawQuad.Centre); + InputManager.PressButton(MouseButton.Left); + }); + AddStep("drag to left corner", () => InputManager.MoveMouseTo(blueprintContainer.ScreenSpaceDrawQuad.TopLeft - new Vector2(5))); + AddStep("end dragging", () => InputManager.ReleaseButton(MouseButton.Left)); + + AddAssert("2 hitobjects selected", () => EditorBeatmap.SelectedHitObjects, () => Has.Count.EqualTo(2)); + + AddStep("start dragging with control", () => + { + InputManager.MoveMouseTo(blueprintContainer.ScreenSpaceDrawQuad.Centre); + InputManager.PressButton(MouseButton.Left); + InputManager.PressKey(Key.ControlLeft); + }); + AddStep("drag to left corner", () => InputManager.MoveMouseTo(blueprintContainer.ScreenSpaceDrawQuad.TopRight + new Vector2(5, -5))); + AddStep("end dragging", () => InputManager.ReleaseButton(MouseButton.Left)); + AddStep("release control", () => InputManager.ReleaseKey(Key.ControlLeft)); + + AddAssert("4 hitobjects selected", () => EditorBeatmap.SelectedHitObjects, () => Has.Count.EqualTo(4)); + + AddStep("start dragging without control", () => + { + InputManager.MoveMouseTo(blueprintContainer.ScreenSpaceDrawQuad.Centre); + InputManager.PressButton(MouseButton.Left); + }); + AddStep("drag to left corner", () => InputManager.MoveMouseTo(blueprintContainer.ScreenSpaceDrawQuad.TopRight + new Vector2(5, -5))); + AddStep("end dragging", () => InputManager.ReleaseButton(MouseButton.Left)); + + AddAssert("2 hitobjects selected", () => EditorBeatmap.SelectedHitObjects, () => Has.Count.EqualTo(2)); + } + [Test] public void TestNearestSelection() { @@ -519,5 +614,137 @@ namespace osu.Game.Tests.Visual.Editing AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft)); } + + [Test] + public void TestShiftModifierMaintainsAspectRatio() + { + HitCircle[] addedObjects = null!; + + float aspectRatioBeforeDrag = 0; + + float getAspectRatio() => (addedObjects[1].X - addedObjects[0].X) / (addedObjects[1].Y - addedObjects[0].Y); + + AddStep("add hitobjects", () => + { + EditorBeatmap.AddRange(addedObjects = new[] + { + new HitCircle { StartTime = 100, Position = new Vector2(150, 150) }, + new HitCircle { StartTime = 200, Position = new Vector2(250, 200) }, + }); + + aspectRatioBeforeDrag = getAspectRatio(); + }); + + AddStep("select objects", () => EditorBeatmap.SelectedHitObjects.AddRange(addedObjects)); + + AddStep("move mouse to handle", () => InputManager.MoveMouseTo(getScaleHandle(Anchor.BottomRight).ScreenSpaceDrawQuad.Centre)); + + AddStep("begin drag", () => InputManager.PressButton(MouseButton.Left)); + + AddStep("move mouse", () => InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + new Vector2(50, 0))); + + AddStep("aspect ratio does not equal", () => Assert.AreNotEqual(aspectRatioBeforeDrag, getAspectRatio())); + + AddStep("press shift", () => InputManager.PressKey(Key.ShiftLeft)); + + AddStep("aspect ratio does equal", () => Assert.AreEqual(aspectRatioBeforeDrag, getAspectRatio())); + + AddStep("end drag", () => InputManager.ReleaseButton(MouseButton.Left)); + + AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft)); + } + + [Test] + public void TestAltModifierScalesAroundCenter() + { + HitCircle[] addedObjects = null!; + + Vector2 centerBeforeDrag = Vector2.Zero; + + Vector2 getCenter() => (addedObjects[0].Position + addedObjects[1].Position) / 2; + + AddStep("add hitobjects", () => + { + EditorBeatmap.AddRange(addedObjects = new[] + { + new HitCircle { StartTime = 100, Position = new Vector2(150, 150) }, + new HitCircle { StartTime = 200, Position = new Vector2(250, 200) }, + }); + + centerBeforeDrag = getCenter(); + }); + + AddStep("select objects", () => EditorBeatmap.SelectedHitObjects.AddRange(addedObjects)); + + AddStep("move mouse to handle", () => InputManager.MoveMouseTo(getScaleHandle(Anchor.BottomRight).ScreenSpaceDrawQuad.Centre)); + + AddStep("begin drag", () => InputManager.PressButton(MouseButton.Left)); + + AddStep("move mouse", () => InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + new Vector2(50, 0))); + + AddStep("center does not equal", () => Assert.AreNotEqual(centerBeforeDrag, getCenter())); + + AddStep("press alt", () => InputManager.PressKey(Key.AltLeft)); + + AddStep("center does equal", () => Assert.AreEqual(centerBeforeDrag, getCenter())); + + AddStep("end drag", () => InputManager.ReleaseButton(MouseButton.Left)); + + AddStep("release alt", () => InputManager.ReleaseKey(Key.AltLeft)); + } + + [Test] + public void TestShiftAndAltModifierKeys() + { + HitCircle[] addedObjects = null!; + + float aspectRatioBeforeDrag = 0; + + Vector2 centerBeforeDrag = Vector2.Zero; + + float getAspectRatio() => (addedObjects[1].X - addedObjects[0].X) / (addedObjects[1].Y - addedObjects[0].Y); + + Vector2 getCenter() => (addedObjects[0].Position + addedObjects[1].Position) / 2; + + AddStep("add hitobjects", () => + { + EditorBeatmap.AddRange(addedObjects = new[] + { + new HitCircle { StartTime = 100, Position = new Vector2(150, 150) }, + new HitCircle { StartTime = 200, Position = new Vector2(250, 200) }, + }); + + aspectRatioBeforeDrag = getAspectRatio(); + centerBeforeDrag = getCenter(); + }); + + AddStep("select objects", () => EditorBeatmap.SelectedHitObjects.AddRange(addedObjects)); + + AddStep("move mouse to handle", () => InputManager.MoveMouseTo(getScaleHandle(Anchor.BottomRight).ScreenSpaceDrawQuad.Centre)); + + AddStep("begin drag", () => InputManager.PressButton(MouseButton.Left)); + + AddStep("move mouse", () => InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + new Vector2(50, 0))); + + AddStep("aspect ratio does not equal", () => Assert.AreNotEqual(aspectRatioBeforeDrag, getAspectRatio())); + + AddStep("center does not equal", () => Assert.AreNotEqual(centerBeforeDrag, getCenter())); + + AddStep("press shift", () => InputManager.PressKey(Key.ShiftLeft)); + + AddStep("aspect ratio does equal", () => Assert.AreEqual(aspectRatioBeforeDrag, getAspectRatio())); + + AddStep("center does not equal", () => Assert.AreNotEqual(centerBeforeDrag, getCenter())); + + AddStep("press alt", () => InputManager.PressKey(Key.AltLeft)); + + AddStep("center does equal", () => Assert.AreEqual(centerBeforeDrag, getCenter())); + + AddStep("end drag", () => InputManager.ReleaseButton(MouseButton.Left)); + + AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft)); + + AddStep("release alt", () => InputManager.ReleaseKey(Key.AltLeft)); + } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneDesignSection.cs b/osu.Game.Tests/Visual/Editing/TestSceneDesignSection.cs index 9a66e1676d..4dd27a7b6e 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneDesignSection.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneDesignSection.cs @@ -7,12 +7,14 @@ using System; using System.Globalization; using System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; using osu.Game.Rulesets.Osu; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Setup; @@ -25,6 +27,9 @@ namespace osu.Game.Tests.Visual.Editing private TestDesignSection designSection; private EditorBeatmap editorBeatmap { get; set; } + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + [SetUpSteps] public void SetUp() { @@ -42,7 +47,7 @@ namespace osu.Game.Tests.Visual.Editing { (typeof(EditorBeatmap), editorBeatmap) }, - Child = designSection = new TestDesignSection() + Child = designSection = new TestDesignSection { RelativeSizeAxes = Axes.X } }); } @@ -99,11 +104,11 @@ namespace osu.Game.Tests.Visual.Editing private partial class TestDesignSection : DesignSection { - public new LabelledSwitchButton EnableCountdown => base.EnableCountdown; + public new FormCheckBox EnableCountdown => base.EnableCountdown; public new FillFlowContainer CountdownSettings => base.CountdownSettings; - public new LabelledEnumDropdown CountdownSpeed => base.CountdownSpeed; - public new LabelledNumberBox CountdownOffset => base.CountdownOffset; + public new FormEnumDropdown CountdownSpeed => base.CountdownSpeed; + public new FormTextBox CountdownOffset => base.CountdownOffset; } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneDifficultyDelete.cs b/osu.Game.Tests/Visual/Editing/TestSceneDifficultyDelete.cs index d4bd77642c..62ff59c6b3 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneDifficultyDelete.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneDifficultyDelete.cs @@ -13,6 +13,7 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.Components.Menus; using osu.Game.Storyboards; using osu.Game.Tests.Beatmaps.IO; using osuTK.Input; @@ -60,7 +61,7 @@ namespace osu.Game.Tests.Visual.Editing beatmapSetHashBefore = Beatmap.Value.BeatmapSetInfo.Hash; }); - AddStep("click File", () => this.ChildrenOfType().First().TriggerClick()); + AddStep("click File", () => this.ChildrenOfType().First().TriggerClick()); if (i == 11) { @@ -107,7 +108,7 @@ namespace osu.Game.Tests.Visual.Editing EditorBeatmap.EndChange(); }); - AddStep("click File", () => this.ChildrenOfType().First().TriggerClick()); + AddStep("click File", () => this.ChildrenOfType().First().TriggerClick()); AddStep("click delete", () => getDeleteMenuItem().TriggerClick()); AddUntilStep("wait for dialog", () => DialogOverlay.CurrentDialog != null); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneDifficultySwitching.cs b/osu.Game.Tests/Visual/Editing/TestSceneDifficultySwitching.cs index 76ed5063b0..457d4cee34 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneDifficultySwitching.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneDifficultySwitching.cs @@ -12,7 +12,9 @@ using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Overlays.Dialog; using osu.Game.Rulesets; +using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Taiko; using osu.Game.Rulesets.UI; using osu.Game.Screens.Edit; using osu.Game.Storyboards; @@ -169,6 +171,24 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("stack empty", () => Stack.CurrentScreen == null); } + [Test] + public void TestSwitchToDifficultyOfAnotherRuleset() + { + BeatmapInfo targetDifficulty = null; + + AddAssert("ruleset is catch", () => Ruleset.Value.CreateInstance() is CatchRuleset); + + AddStep("set taiko difficulty", () => targetDifficulty = importedBeatmapSet.Beatmaps.First(b => b.Ruleset.OnlineID == 1)); + switchToDifficulty(() => targetDifficulty); + confirmEditingBeatmap(() => targetDifficulty); + + AddAssert("ruleset switched to taiko", () => Ruleset.Value.CreateInstance() is TaikoRuleset); + + AddStep("exit editor forcefully", () => Stack.Exit()); + // ensure editor loader didn't resume. + AddAssert("stack empty", () => Stack.CurrentScreen == null); + } + private void switchToDifficulty(Func difficulty) => AddStep("switch to difficulty", () => Editor.SwitchToDifficulty(difficulty.Invoke())); private void confirmEditingBeatmap(Func targetDifficulty) diff --git a/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs b/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs index f2a015402a..c1a788cd22 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs @@ -199,7 +199,7 @@ namespace osu.Game.Tests.Visual.Editing public double FindSnappedDuration(HitObject referenceObject, float distance) => 0; - public float FindSnappedDistance(HitObject referenceObject, float distance) => 0; + public float FindSnappedDistance(HitObject referenceObject, float distance, DistanceSnapTarget target) => 0; } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorChangeStates.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorChangeStates.cs index 278b6e9626..7827347b1f 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorChangeStates.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorChangeStates.cs @@ -4,10 +4,12 @@ #nullable disable using NUnit.Framework; +using osu.Game.Beatmaps; using osu.Game.Rulesets; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Tests.Beatmaps; namespace osu.Game.Tests.Visual.Editing { @@ -15,6 +17,8 @@ namespace osu.Game.Tests.Visual.Editing { protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); + [Test] public void TestSelectedObjects() { diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorClock.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorClock.cs index ed58c59ff0..bfc8af7283 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorClock.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorClock.cs @@ -1,10 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Osu; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Components; @@ -19,9 +22,10 @@ namespace osu.Game.Tests.Visual.Editing [Cached] private EditorBeatmap editorBeatmap = new EditorBeatmap(new TestBeatmap(new OsuRuleset().RulesetInfo)); - public TestSceneEditorClock() + [SetUpSteps] + public void SetUpSteps() { - Add(new FillFlowContainer + AddStep("create content", () => Add(new FillFlowContainer { RelativeSizeAxes = Axes.Both, Children = new Drawable[] @@ -39,19 +43,17 @@ namespace osu.Game.Tests.Visual.Editing Size = new Vector2(200, 100) } } + })); + AddStep("set working beatmap", () => + { + Beatmap.Disabled = false; + Beatmap.Value = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); + // ensure that music controller does not change this beatmap due to it + // completing naturally as part of the test. + Beatmap.Disabled = true; }); } - protected override void LoadComplete() - { - base.LoadComplete(); - - Beatmap.Value = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); - // ensure that music controller does not change this beatmap due to it - // completing naturally as part of the test. - Beatmap.Disabled = true; - } - [Test] public void TestStopAtTrackEnd() { @@ -102,6 +104,29 @@ namespace osu.Game.Tests.Visual.Editing AddUntilStep("time is clamped to track length", () => EditorClock.CurrentTime, () => Is.EqualTo(EditorClock.TrackLength)); } + [Test] + public void TestCurrentTimeDoubleTransform() + { + AddAssert("seek smoothly twice and current time is accurate", () => + { + EditorClock.SeekSmoothlyTo(1000); + EditorClock.SeekSmoothlyTo(2000); + return 2000 == EditorClock.CurrentTimeAccurate; + }); + } + + [Test] + public void TestAdjustmentsRemovedOnDisposal() + { + AddStep("reset clock", () => EditorClock.Seek(0)); + + AddStep("set 0.25x speed", () => this.ChildrenOfType>().First().Current.Value = 0.25); + AddAssert("track has 0.25x tempo", () => Beatmap.Value.Track.AggregateTempo.Value, () => Is.EqualTo(0.25)); + + AddStep("dispose playback control", () => Clear(disposeChildren: true)); + AddAssert("track has 1x tempo", () => Beatmap.Value.Track.AggregateTempo.Value, () => Is.EqualTo(1)); + } + protected override void Dispose(bool isDisposing) { Beatmap.Disabled = false; diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs index 64c48e74cf..b487fa3cec 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs @@ -193,5 +193,20 @@ namespace osu.Game.Tests.Visual.Editing AddUntilStep("Wait for song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect); AddAssert("Tags reverted correctly", () => Game.Beatmap.Value.BeatmapInfo.Metadata.Tags == tags_to_save); } + + [Test] + public void TestBeatDivisor() + { + AddStep("Set custom beat divisor", () => Editor.Dependencies.Get().SetArbitraryDivisor(7)); + + SaveEditor(); + AddAssert("Hash updated", () => !string.IsNullOrEmpty(EditorBeatmap.BeatmapInfo.BeatmapSet?.Hash)); + AddAssert("Beatmap has correct beat divisor", () => EditorBeatmap.BeatmapInfo.BeatDivisor, () => Is.EqualTo(7)); + + ReloadEditorToSameBeatmap(); + + AddAssert("Beatmap still has correct beat divisor", () => EditorBeatmap.BeatmapInfo.BeatDivisor, () => Is.EqualTo(7)); + AddAssert("Correct beat divisor actually active", () => Editor.BeatDivisor, () => Is.EqualTo(7)); + } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSeeking.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSeeking.cs index da4f159cae..06facc546d 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSeeking.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSeeking.cs @@ -1,11 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Objects; using osuTK.Input; namespace osu.Game.Tests.Visual.Editing @@ -135,9 +137,42 @@ namespace osu.Game.Tests.Visual.Editing pressAndCheckTime(Key.Up, 0); } - private void pressAndCheckTime(Key key, double expectedTime) + [Test] + public void TestSeekBetweenObjects() { - AddStep($"press {key}", () => InputManager.Key(key)); + AddStep("add objects", () => + { + EditorBeatmap.Clear(); + EditorBeatmap.AddRange(new[] + { + new HitCircle { StartTime = 1000, }, + new HitCircle { StartTime = 2250, }, + new HitCircle { StartTime = 3600, }, + }); + }); + AddStep("seek to 0", () => EditorClock.Seek(0)); + + pressAndCheckTime(Key.Right, 1000, Key.ControlLeft); + pressAndCheckTime(Key.Right, 2250, Key.ControlLeft); + pressAndCheckTime(Key.Right, 3600, Key.ControlLeft); + pressAndCheckTime(Key.Right, 3600, Key.ControlLeft); + pressAndCheckTime(Key.Left, 2250, Key.ControlLeft); + pressAndCheckTime(Key.Left, 1000, Key.ControlLeft); + pressAndCheckTime(Key.Left, 1000, Key.ControlLeft); + } + + private void pressAndCheckTime(Key key, double expectedTime, params Key[] modifiers) + { + AddStep($"press {key} with {(modifiers.Any() ? string.Join(',', modifiers) : "no modifiers")}", () => + { + foreach (var modifier in modifiers) + InputManager.PressKey(modifier); + + InputManager.Key(key); + + foreach (var modifier in modifiers) + InputManager.ReleaseKey(modifier); + }); AddUntilStep($"time is {expectedTime}", () => EditorClock.CurrentTime, () => Is.EqualTo(expectedTime).Within(1)); } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSummaryTimeline.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSummaryTimeline.cs index ddca2f8553..677d3135ba 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSummaryTimeline.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSummaryTimeline.cs @@ -24,7 +24,10 @@ namespace osu.Game.Tests.Visual.Editing beatmap.ControlPointInfo.Add(100000, new TimingControlPoint { BeatLength = 100 }); beatmap.ControlPointInfo.Add(50000, new DifficultyControlPoint { SliderVelocity = 2 }); + beatmap.ControlPointInfo.Add(80000, new EffectControlPoint { KiaiMode = true }); + beatmap.ControlPointInfo.Add(110000, new EffectControlPoint { KiaiMode = false }); beatmap.BeatmapInfo.Bookmarks = new[] { 75000, 125000 }; + beatmap.Breaks.Add(new ManualBreakPeriod(90000, 120000)); editorBeatmap = new EditorBeatmap(beatmap); } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs index ca5e89c8ed..23efb40d3f 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs @@ -16,6 +16,7 @@ using osu.Game.Configuration; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.UI; using osu.Game.Screens.Backgrounds; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Components.Timelines.Summary; @@ -126,6 +127,24 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("sample playback re-enabled", () => !Editor.SamplePlaybackDisabled.Value); } + [TestCase(2000)] // chosen to be after last object in the map + [TestCase(22000)] // chosen to be in the middle of the last spinner + public void TestGameplayTestAtEndOfBeatmap(int offsetFromEnd) + { + AddStep($"seek to end minus {offsetFromEnd}ms", () => EditorClock.Seek(importedBeatmapSet.MaxLength - offsetFromEnd)); + AddStep("click test gameplay button", () => + { + var button = Editor.ChildrenOfType().Single(); + + InputManager.MoveMouseTo(button); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("player pushed", () => Stack.CurrentScreen is EditorPlayer); + + AddUntilStep("current screen is editor", () => Stack.CurrentScreen is Editor); + } + [Test] public void TestCancelGameplayTestWithUnsavedChanges() { @@ -206,6 +225,116 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("time reverted to 00:01:00", () => EditorClock.CurrentTime, () => Is.EqualTo(60_000)); } + [Test] + public void TestAutoplayToggle() + { + AddStep("click test gameplay button", () => + { + var button = Editor.ChildrenOfType().Single(); + + InputManager.MoveMouseTo(button); + InputManager.Click(MouseButton.Left); + }); + + EditorPlayer editorPlayer = null; + AddUntilStep("player pushed", () => (editorPlayer = Stack.CurrentScreen as EditorPlayer) != null); + AddUntilStep("no replay active", () => editorPlayer.ChildrenOfType().Single().ReplayScore, () => Is.Null); + AddStep("press Tab", () => InputManager.Key(Key.Tab)); + AddUntilStep("replay active", () => editorPlayer.ChildrenOfType().Single().ReplayScore, () => Is.Not.Null); + AddStep("press Tab", () => InputManager.Key(Key.Tab)); + AddUntilStep("no replay active", () => editorPlayer.ChildrenOfType().Single().ReplayScore, () => Is.Null); + AddStep("exit player", () => editorPlayer.Exit()); + AddUntilStep("current screen is editor", () => Stack.CurrentScreen is Editor); + } + + [Test] + public void TestQuickPause() + { + AddStep("click test gameplay button", () => + { + var button = Editor.ChildrenOfType().Single(); + + InputManager.MoveMouseTo(button); + InputManager.Click(MouseButton.Left); + }); + + EditorPlayer editorPlayer = null; + AddUntilStep("player pushed", () => (editorPlayer = Stack.CurrentScreen as EditorPlayer) != null); + AddUntilStep("clock running", () => editorPlayer.ChildrenOfType().Single().IsPaused.Value, () => Is.False); + AddStep("press Ctrl-P", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Key(Key.P); + InputManager.ReleaseKey(Key.ControlLeft); + }); + AddUntilStep("clock not running", () => editorPlayer.ChildrenOfType().Single().IsPaused.Value, () => Is.True); + AddStep("press Ctrl-P", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Key(Key.P); + InputManager.ReleaseKey(Key.ControlLeft); + }); + AddUntilStep("clock running", () => editorPlayer.ChildrenOfType().Single().IsPaused.Value, () => Is.False); + AddStep("exit player", () => editorPlayer.Exit()); + AddUntilStep("current screen is editor", () => Stack.CurrentScreen is Editor); + } + + [Test] + public void TestQuickExitAtInitialPosition() + { + AddStep("seek to 00:01:00", () => EditorClock.Seek(60_000)); + AddStep("click test gameplay button", () => + { + var button = Editor.ChildrenOfType().Single(); + + InputManager.MoveMouseTo(button); + InputManager.Click(MouseButton.Left); + }); + + EditorPlayer editorPlayer = null; + AddUntilStep("player pushed", () => (editorPlayer = Stack.CurrentScreen as EditorPlayer) != null); + + GameplayClockContainer gameplayClockContainer = null; + AddStep("fetch gameplay clock", () => gameplayClockContainer = editorPlayer.ChildrenOfType().First()); + AddUntilStep("gameplay clock running", () => gameplayClockContainer.IsRunning); + // when the gameplay test is entered, the clock is expected to continue from where it was in the main editor... + AddAssert("gameplay time past 00:01:00", () => gameplayClockContainer.CurrentTime >= 60_000); + + AddWaitStep("wait some", 5); + + AddStep("exit player", () => InputManager.PressKey(Key.F1)); + AddUntilStep("current screen is editor", () => Stack.CurrentScreen is Editor); + AddAssert("time reverted to 00:01:00", () => EditorClock.CurrentTime, () => Is.EqualTo(60_000)); + } + + [Test] + public void TestQuickExitAtCurrentPosition() + { + AddStep("seek to 00:01:00", () => EditorClock.Seek(60_000)); + AddStep("click test gameplay button", () => + { + var button = Editor.ChildrenOfType().Single(); + + InputManager.MoveMouseTo(button); + InputManager.Click(MouseButton.Left); + }); + + EditorPlayer editorPlayer = null; + AddUntilStep("player pushed", () => (editorPlayer = Stack.CurrentScreen as EditorPlayer) != null); + + GameplayClockContainer gameplayClockContainer = null; + AddStep("fetch gameplay clock", () => gameplayClockContainer = editorPlayer.ChildrenOfType().First()); + AddUntilStep("gameplay clock running", () => gameplayClockContainer.IsRunning); + // when the gameplay test is entered, the clock is expected to continue from where it was in the main editor... + AddAssert("gameplay time past 00:01:00", () => gameplayClockContainer.CurrentTime >= 60_000); + + AddWaitStep("wait some", 5); + + AddStep("exit player", () => InputManager.PressKey(Key.F2)); + AddUntilStep("current screen is editor", () => Stack.CurrentScreen is Editor); + AddAssert("time moved forward", () => EditorClock.CurrentTime, () => Is.GreaterThan(60_000)); + } + public override void TearDownSteps() { base.TearDownSteps(); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs index f392841ac7..d7c92a64b1 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs @@ -19,6 +19,7 @@ using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Edit; using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.UI; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Components.RadioButtons; using osu.Game.Screens.Edit.Components.TernaryButtons; @@ -82,6 +83,45 @@ namespace osu.Game.Tests.Visual.Editing }); } + [Test] + public void TestPlacementOutsideComposeScreen() + { + AddStep("clear all control points and hitobjects", () => + { + editorBeatmap.ControlPointInfo.Clear(); + editorBeatmap.Clear(); + }); + + AddStep("Add timing point", () => editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint())); + AddStep("select circle", () => hitObjectComposer.ChildrenOfType().First(d => d.Button.Label == "HitCircle").TriggerClick()); + AddStep("move mouse to compose", () => InputManager.MoveMouseTo(hitObjectComposer.ChildrenOfType().Single())); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + AddAssert("circle placed", () => editorBeatmap.HitObjects.Count == 1); + + AddStep("move mouse outside compose", () => InputManager.MoveMouseTo(hitObjectComposer.ChildrenOfType().Single().ScreenSpaceDrawQuad.TopLeft - new Vector2(0f, 20f))); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + AddAssert("no circle placed", () => editorBeatmap.HitObjects.Count == 1); + } + + [Test] + public void TestDragSliderOutsideComposeScreen() + { + AddStep("clear all control points and hitobjects", () => + { + editorBeatmap.ControlPointInfo.Clear(); + editorBeatmap.Clear(); + }); + + AddStep("Add timing point", () => editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint())); + AddStep("select slider", () => hitObjectComposer.ChildrenOfType().First(d => d.Button.Label == "Slider").TriggerClick()); + + AddStep("move mouse to compose", () => InputManager.MoveMouseTo(hitObjectComposer.ChildrenOfType().Single())); + AddStep("hold", () => InputManager.PressButton(MouseButton.Left)); + AddStep("move mouse outside compose", () => InputManager.MoveMouseTo(hitObjectComposer.ChildrenOfType().Single().ScreenSpaceDrawQuad.TopLeft - new Vector2(0f, 80f))); + AddStep("release", () => InputManager.ReleaseButton(MouseButton.Left)); + AddAssert("slider placed", () => editorBeatmap.HitObjects.Count == 1); + } + [Test] public void TestPlacementOnlyWorksWithTiming() { diff --git a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSampleAdjustments.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSampleAdjustments.cs index 1415ff4b0f..5cc1e64197 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSampleAdjustments.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSampleAdjustments.cs @@ -5,16 +5,21 @@ using System.Linq; using System.Collections.Generic; using Humanizer; using NUnit.Framework; +using osu.Framework.Input; using osu.Framework.Testing; +using osu.Framework.Utils; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Rulesets; using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.UI; +using osu.Game.Screens.Edit.Components.TernaryButtons; using osu.Game.Screens.Edit.Compose.Components.Timeline; using osu.Game.Screens.Edit.Timing; using osu.Game.Tests.Beatmaps; @@ -78,10 +83,10 @@ namespace osu.Game.Tests.Visual.Editing } [Test] - public void TestPopoverHasFocus() + public void TestPopoverHasNoFocus() { clickSamplePiece(0); - samplePopoverHasFocus(); + samplePopoverHasNoFocus(); } [Test] @@ -225,6 +230,124 @@ namespace osu.Game.Tests.Visual.Editing samplePopoverHasSingleBank(HitSampleInfo.BANK_NORMAL); } + [Test] + public void TestPopoverAddSampleAddition() + { + clickSamplePiece(0); + + setBankViaPopover(HitSampleInfo.BANK_SOFT); + hitObjectHasSampleBank(0, HitSampleInfo.BANK_SOFT); + + toggleAdditionViaPopover(0); + + hitObjectHasSampleBank(0, HitSampleInfo.BANK_SOFT); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_WHISTLE); + + setAdditionBankViaPopover(HitSampleInfo.BANK_DRUM); + + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_SOFT); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_DRUM); + + toggleAdditionViaPopover(0); + + hitObjectHasSampleBank(0, HitSampleInfo.BANK_SOFT); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL); + } + + [Test] + public void TestNodeSamplePopover() + { + AddStep("add slider", () => + { + EditorBeatmap.Clear(); + EditorBeatmap.Add(new Slider + { + Position = new Vector2(256, 256), + StartTime = 0, + Path = new SliderPath(new[] { new PathControlPoint(Vector2.Zero), new PathControlPoint(new Vector2(250, 0)) }), + Samples = + { + new HitSampleInfo(HitSampleInfo.HIT_NORMAL) + }, + NodeSamples = + { + new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }, + new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }, + } + }); + }); + + clickNodeSamplePiece(0, 1); + + setBankViaPopover(HitSampleInfo.BANK_SOFT); + hitObjectNodeHasSampleBank(0, 0, HitSampleInfo.BANK_NORMAL); + hitObjectNodeHasSampleBank(0, 1, HitSampleInfo.BANK_SOFT); + + toggleAdditionViaPopover(0); + + hitObjectNodeHasSampleBank(0, 0, HitSampleInfo.BANK_NORMAL); + hitObjectNodeHasSampleBank(0, 1, HitSampleInfo.BANK_SOFT); + hitObjectNodeHasSamples(0, 0, HitSampleInfo.HIT_NORMAL); + hitObjectNodeHasSamples(0, 1, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_WHISTLE); + + setAdditionBankViaPopover(HitSampleInfo.BANK_DRUM); + + hitObjectNodeHasSampleBank(0, 0, HitSampleInfo.BANK_NORMAL); + hitObjectNodeHasSampleNormalBank(0, 1, HitSampleInfo.BANK_SOFT); + hitObjectNodeHasSampleAdditionBank(0, 1, HitSampleInfo.BANK_DRUM); + + toggleAdditionViaPopover(0); + + hitObjectNodeHasSampleBank(0, 1, HitSampleInfo.BANK_SOFT); + hitObjectNodeHasSamples(0, 0, HitSampleInfo.HIT_NORMAL); + hitObjectNodeHasSamples(0, 1, HitSampleInfo.HIT_NORMAL); + + setVolumeViaPopover(10); + + hitObjectNodeHasSampleVolume(0, 0, 100); + hitObjectNodeHasSampleVolume(0, 1, 10); + } + + [Test] + public void TestSamplePointSeek() + { + AddStep("add slider", () => + { + EditorBeatmap.Clear(); + EditorBeatmap.Add(new Slider + { + Position = new Vector2(256, 256), + StartTime = 0, + Path = new SliderPath(new[] { new PathControlPoint(Vector2.Zero), new PathControlPoint(new Vector2(250, 0)) }), + Samples = + { + new HitSampleInfo(HitSampleInfo.HIT_NORMAL) + }, + NodeSamples = + { + new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }, + new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }, + }, + RepeatCount = 1 + }); + }); + + seekSamplePiece(-1); + editorTimeIs(0); + samplePopoverIsOpen(); + seekSamplePiece(-1); + editorTimeIs(0); + samplePopoverIsOpen(); + seekSamplePiece(1); + editorTimeIs(406); + seekSamplePiece(1); + editorTimeIs(813); + seekSamplePiece(1); + editorTimeIs(1627); + seekSamplePiece(1); + editorTimeIs(1627); + } + [Test] public void TestHotkeysMultipleSelectionWithSameSampleBank() { @@ -239,6 +362,12 @@ namespace osu.Game.Tests.Visual.Editing } }); + AddStep("add whistle addition", () => + { + foreach (var h in EditorBeatmap.HitObjects) + h.Samples.Add(new HitSampleInfo(HitSampleInfo.HIT_WHISTLE, HitSampleInfo.BANK_SOFT)); + }); + AddStep("select both objects", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects)); hitObjectHasSampleBank(0, HitSampleInfo.BANK_SOFT); @@ -251,8 +380,10 @@ namespace osu.Game.Tests.Visual.Editing InputManager.ReleaseKey(Key.ShiftLeft); }); - hitObjectHasSampleBank(0, HitSampleInfo.BANK_NORMAL); - hitObjectHasSampleBank(1, HitSampleInfo.BANK_NORMAL); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_NORMAL); + hitObjectHasSampleNormalBank(1, HitSampleInfo.BANK_NORMAL); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_SOFT); + hitObjectHasSampleAdditionBank(1, HitSampleInfo.BANK_SOFT); AddStep("Press drum bank shortcut", () => { @@ -261,8 +392,10 @@ namespace osu.Game.Tests.Visual.Editing InputManager.ReleaseKey(Key.ShiftLeft); }); - hitObjectHasSampleBank(0, HitSampleInfo.BANK_DRUM); - hitObjectHasSampleBank(1, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleNormalBank(1, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_SOFT); + hitObjectHasSampleAdditionBank(1, HitSampleInfo.BANK_SOFT); AddStep("Press auto bank shortcut", () => { @@ -272,8 +405,47 @@ namespace osu.Game.Tests.Visual.Editing }); // Should be a noop. - hitObjectHasSampleBank(0, HitSampleInfo.BANK_DRUM); - hitObjectHasSampleBank(1, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleNormalBank(1, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_SOFT); + hitObjectHasSampleAdditionBank(1, HitSampleInfo.BANK_SOFT); + + AddStep("Press addition normal bank shortcut", () => + { + InputManager.PressKey(Key.AltLeft); + InputManager.Key(Key.W); + InputManager.ReleaseKey(Key.AltLeft); + }); + + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleNormalBank(1, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_NORMAL); + hitObjectHasSampleAdditionBank(1, HitSampleInfo.BANK_NORMAL); + + AddStep("Press addition drum bank shortcut", () => + { + InputManager.PressKey(Key.AltLeft); + InputManager.Key(Key.R); + InputManager.ReleaseKey(Key.AltLeft); + }); + + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleNormalBank(1, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleAdditionBank(1, HitSampleInfo.BANK_DRUM); + + AddStep("Press auto bank shortcut", () => + { + InputManager.PressKey(Key.AltLeft); + InputManager.Key(Key.Q); + InputManager.ReleaseKey(Key.AltLeft); + }); + + // Should be a noop. + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleNormalBank(1, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleAdditionBank(1, HitSampleInfo.BANK_DRUM); } [Test] @@ -291,7 +463,21 @@ namespace osu.Game.Tests.Visual.Editing InputManager.ReleaseKey(Key.ShiftLeft); }); - checkPlacementSample(HitSampleInfo.BANK_NORMAL); + AddStep("Press soft addition bank shortcut", () => + { + InputManager.PressKey(Key.AltLeft); + InputManager.Key(Key.E); + InputManager.ReleaseKey(Key.AltLeft); + }); + + checkPlacementSampleBank(HitSampleInfo.BANK_NORMAL); + + AddStep("Press finish sample shortcut", () => + { + InputManager.Key(Key.E); + }); + + checkPlacementSampleAdditionBank(HitSampleInfo.BANK_SOFT); AddStep("Press drum bank shortcut", () => { @@ -300,7 +486,18 @@ namespace osu.Game.Tests.Visual.Editing InputManager.ReleaseKey(Key.ShiftLeft); }); - checkPlacementSample(HitSampleInfo.BANK_DRUM); + checkPlacementSampleBank(HitSampleInfo.BANK_DRUM); + checkPlacementSampleAdditionBank(HitSampleInfo.BANK_SOFT); + + AddStep("Press drum addition bank shortcut", () => + { + InputManager.PressKey(Key.AltLeft); + InputManager.Key(Key.R); + InputManager.ReleaseKey(Key.AltLeft); + }); + + checkPlacementSampleBank(HitSampleInfo.BANK_DRUM); + checkPlacementSampleAdditionBank(HitSampleInfo.BANK_DRUM); AddStep("Press auto bank shortcut", () => { @@ -309,32 +506,330 @@ namespace osu.Game.Tests.Visual.Editing InputManager.ReleaseKey(Key.ShiftLeft); }); - checkPlacementSample(HitSampleInfo.BANK_NORMAL); + checkPlacementSampleBank(HitSampleInfo.BANK_NORMAL); + checkPlacementSampleAdditionBank(HitSampleInfo.BANK_DRUM); + + AddStep("Press auto addition bank shortcut", () => + { + InputManager.PressKey(Key.AltLeft); + InputManager.Key(Key.Q); + InputManager.ReleaseKey(Key.AltLeft); + }); + + checkPlacementSampleBank(HitSampleInfo.BANK_NORMAL); + checkPlacementSampleAdditionBank(HitSampleInfo.BANK_NORMAL); AddStep("Move after second object", () => EditorClock.Seek(750)); - checkPlacementSample(HitSampleInfo.BANK_SOFT); + checkPlacementSampleBank(HitSampleInfo.BANK_SOFT); + checkPlacementSampleAdditionBank(HitSampleInfo.BANK_SOFT); AddStep("Move to first object", () => EditorClock.Seek(0)); - checkPlacementSample(HitSampleInfo.BANK_NORMAL); + checkPlacementSampleBank(HitSampleInfo.BANK_NORMAL); + checkPlacementSampleAdditionBank(HitSampleInfo.BANK_NORMAL); - void checkPlacementSample(string expected) => AddAssert($"Placement sample is {expected}", () => EditorBeatmap.PlacementObject.Value.Samples.First().Bank, () => Is.EqualTo(expected)); + void checkPlacementSampleBank(string expected) => AddAssert($"Placement sample is {expected}", () => EditorBeatmap.PlacementObject.Value.Samples.First(s => s.Name == HitSampleInfo.HIT_NORMAL).Bank, () => Is.EqualTo(expected)); + void checkPlacementSampleAdditionBank(string expected) => AddAssert($"Placement sample addition is {expected}", () => EditorBeatmap.PlacementObject.Value.Samples.First(s => s.Name != HitSampleInfo.HIT_NORMAL).Bank, () => Is.EqualTo(expected)); + } + + [Test] + public void PopoverForMultipleSelectionChangesAllSamples() + { + AddStep("add slider", () => + { + EditorBeatmap.Add(new Slider + { + Position = new Vector2(256, 256), + StartTime = 1000, + Path = new SliderPath(new[] { new PathControlPoint(Vector2.Zero), new PathControlPoint(new Vector2(250, 0)) }), + Samples = + { + new HitSampleInfo(HitSampleInfo.HIT_NORMAL) + }, + NodeSamples = new List> + { + new List + { + new HitSampleInfo(HitSampleInfo.HIT_NORMAL, bank: HitSampleInfo.BANK_DRUM), + new HitSampleInfo(HitSampleInfo.HIT_CLAP, bank: HitSampleInfo.BANK_DRUM), + }, + new List + { + new HitSampleInfo(HitSampleInfo.HIT_NORMAL, bank: HitSampleInfo.BANK_SOFT), + new HitSampleInfo(HitSampleInfo.HIT_WHISTLE, bank: HitSampleInfo.BANK_SOFT), + }, + } + }); + }); + AddStep("select everything", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects)); + + clickSamplePiece(0); + + setBankViaPopover(HitSampleInfo.BANK_DRUM); + samplePopoverHasSingleBank(HitSampleInfo.BANK_DRUM); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleNormalBank(1, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleNormalBank(2, HitSampleInfo.BANK_DRUM); + hitObjectNodeHasSampleNormalBank(2, 0, HitSampleInfo.BANK_DRUM); + hitObjectNodeHasSampleNormalBank(2, 1, HitSampleInfo.BANK_DRUM); + + setVolumeViaPopover(30); + samplePopoverHasSingleVolume(30); + hitObjectHasSampleVolume(0, 30); + hitObjectHasSampleVolume(1, 30); + hitObjectHasSampleVolume(2, 30); + hitObjectNodeHasSampleVolume(2, 0, 30); + hitObjectNodeHasSampleVolume(2, 1, 30); + + toggleAdditionViaPopover(0); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_WHISTLE); + hitObjectHasSamples(1, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_WHISTLE); + hitObjectHasSamples(2, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_WHISTLE); + hitObjectNodeHasSamples(2, 0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_CLAP, HitSampleInfo.HIT_WHISTLE); + hitObjectNodeHasSamples(2, 1, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_WHISTLE); + + setAdditionBankViaPopover(HitSampleInfo.BANK_SOFT); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_SOFT); + hitObjectHasSampleAdditionBank(1, HitSampleInfo.BANK_SOFT); + hitObjectHasSampleAdditionBank(2, HitSampleInfo.BANK_SOFT); + hitObjectNodeHasSampleAdditionBank(2, 0, HitSampleInfo.BANK_SOFT); + hitObjectNodeHasSampleAdditionBank(2, 1, HitSampleInfo.BANK_SOFT); + } + + [Test] + public void TestHotkeysAffectNodeSamples() + { + AddStep("add slider", () => + { + EditorBeatmap.Add(new Slider + { + Position = new Vector2(256, 256), + StartTime = 1000, + Path = new SliderPath(new[] { new PathControlPoint(Vector2.Zero), new PathControlPoint(new Vector2(250, 0)) }), + Samples = + { + new HitSampleInfo(HitSampleInfo.HIT_NORMAL) + }, + NodeSamples = new List> + { + new List + { + new HitSampleInfo(HitSampleInfo.HIT_NORMAL, bank: HitSampleInfo.BANK_DRUM), + new HitSampleInfo(HitSampleInfo.HIT_CLAP, bank: HitSampleInfo.BANK_DRUM), + }, + new List + { + new HitSampleInfo(HitSampleInfo.HIT_NORMAL, bank: HitSampleInfo.BANK_SOFT), + new HitSampleInfo(HitSampleInfo.HIT_WHISTLE, bank: HitSampleInfo.BANK_SOFT), + }, + } + }); + }); + AddStep("select everything", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects)); + + AddStep("add clap addition", () => InputManager.Key(Key.R)); + + hitObjectHasSampleBank(0, HitSampleInfo.BANK_NORMAL); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_CLAP); + + hitObjectHasSampleBank(1, HitSampleInfo.BANK_SOFT); + hitObjectHasSamples(1, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_CLAP); + + hitObjectHasSampleBank(2, HitSampleInfo.BANK_NORMAL); + hitObjectHasSamples(2, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_CLAP); + hitObjectNodeHasSampleBank(2, 0, HitSampleInfo.BANK_DRUM); + hitObjectNodeHasSamples(2, 0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_CLAP); + hitObjectNodeHasSampleBank(2, 1, HitSampleInfo.BANK_SOFT); + hitObjectNodeHasSamples(2, 1, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_WHISTLE, HitSampleInfo.HIT_CLAP); + + AddStep("remove clap addition", () => InputManager.Key(Key.R)); + + hitObjectHasSampleBank(0, HitSampleInfo.BANK_NORMAL); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL); + + hitObjectHasSampleBank(1, HitSampleInfo.BANK_SOFT); + hitObjectHasSamples(1, HitSampleInfo.HIT_NORMAL); + + hitObjectHasSampleBank(2, HitSampleInfo.BANK_NORMAL); + hitObjectHasSamples(2, HitSampleInfo.HIT_NORMAL); + hitObjectNodeHasSampleBank(2, 0, HitSampleInfo.BANK_DRUM); + hitObjectNodeHasSamples(2, 0, HitSampleInfo.HIT_NORMAL); + hitObjectNodeHasSampleBank(2, 1, HitSampleInfo.BANK_SOFT); + hitObjectNodeHasSamples(2, 1, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_WHISTLE); + + AddStep("set drum bank", () => + { + InputManager.PressKey(Key.LShift); + InputManager.Key(Key.R); + InputManager.ReleaseKey(Key.LShift); + }); + + hitObjectHasSampleBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL); + + hitObjectHasSampleBank(1, HitSampleInfo.BANK_DRUM); + hitObjectHasSamples(1, HitSampleInfo.HIT_NORMAL); + + hitObjectHasSampleBank(2, HitSampleInfo.BANK_DRUM); + hitObjectHasSamples(2, HitSampleInfo.HIT_NORMAL); + hitObjectNodeHasSampleBank(2, 0, HitSampleInfo.BANK_DRUM); + hitObjectNodeHasSamples(2, 0, HitSampleInfo.HIT_NORMAL); + hitObjectNodeHasSampleNormalBank(2, 1, HitSampleInfo.BANK_DRUM); + hitObjectNodeHasSampleAdditionBank(2, 1, HitSampleInfo.BANK_SOFT); + hitObjectNodeHasSamples(2, 1, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_WHISTLE); + + AddStep("set normal addition bank", () => + { + InputManager.PressKey(Key.LAlt); + InputManager.Key(Key.W); + InputManager.ReleaseKey(Key.LAlt); + }); + + hitObjectHasSampleBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL); + + hitObjectHasSampleBank(1, HitSampleInfo.BANK_DRUM); + hitObjectHasSamples(1, HitSampleInfo.HIT_NORMAL); + + hitObjectHasSampleBank(2, HitSampleInfo.BANK_DRUM); + hitObjectHasSamples(2, HitSampleInfo.HIT_NORMAL); + hitObjectNodeHasSampleBank(2, 0, HitSampleInfo.BANK_DRUM); + hitObjectNodeHasSamples(2, 0, HitSampleInfo.HIT_NORMAL); + hitObjectNodeHasSampleNormalBank(2, 1, HitSampleInfo.BANK_DRUM); + hitObjectNodeHasSampleAdditionBank(2, 1, HitSampleInfo.BANK_NORMAL); + hitObjectNodeHasSamples(2, 1, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_WHISTLE); + } + + [Test] + public void TestHotkeysUnifySliderSamplesAndNodeSamples() + { + AddStep("add slider", () => + { + EditorBeatmap.Clear(); + EditorBeatmap.Add(new Slider + { + Position = new Vector2(256, 256), + StartTime = 1000, + Path = new SliderPath(new[] { new PathControlPoint(Vector2.Zero), new PathControlPoint(new Vector2(250, 0)) }), + Samples = + { + new HitSampleInfo(HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT), + new HitSampleInfo(HitSampleInfo.HIT_WHISTLE, bank: HitSampleInfo.BANK_DRUM), + }, + NodeSamples = new List> + { + new List + { + new HitSampleInfo(HitSampleInfo.HIT_NORMAL, bank: HitSampleInfo.BANK_DRUM), + new HitSampleInfo(HitSampleInfo.HIT_CLAP, bank: HitSampleInfo.BANK_DRUM), + }, + new List + { + new HitSampleInfo(HitSampleInfo.HIT_NORMAL, bank: HitSampleInfo.BANK_SOFT), + new HitSampleInfo(HitSampleInfo.HIT_WHISTLE, bank: HitSampleInfo.BANK_SOFT), + }, + } + }); + }); + AddStep("select everything", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects)); + + AddStep("set soft bank", () => + { + InputManager.PressKey(Key.LShift); + InputManager.Key(Key.E); + InputManager.ReleaseKey(Key.LShift); + }); + + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_SOFT); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_WHISTLE); + hitObjectNodeHasSampleNormalBank(0, 0, HitSampleInfo.BANK_SOFT); + hitObjectNodeHasSamples(0, 0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_CLAP); + hitObjectNodeHasSampleNormalBank(0, 1, HitSampleInfo.BANK_SOFT); + hitObjectNodeHasSamples(0, 1, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_WHISTLE); + + AddStep("unify whistle addition", () => InputManager.Key(Key.W)); + + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_SOFT); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_WHISTLE); + hitObjectNodeHasSampleNormalBank(0, 0, HitSampleInfo.BANK_SOFT); + hitObjectNodeHasSamples(0, 0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_CLAP, HitSampleInfo.HIT_WHISTLE); + hitObjectNodeHasSampleNormalBank(0, 1, HitSampleInfo.BANK_SOFT); + hitObjectNodeHasSamples(0, 1, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_WHISTLE); + + AddStep("set drum addition bank", () => + { + InputManager.PressKey(Key.LAlt); + InputManager.Key(Key.R); + InputManager.ReleaseKey(Key.LAlt); + }); + + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_SOFT); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_WHISTLE); + hitObjectNodeHasSampleNormalBank(0, 0, HitSampleInfo.BANK_SOFT); + hitObjectNodeHasSampleAdditionBank(0, 0, HitSampleInfo.BANK_DRUM); + hitObjectNodeHasSamples(0, 0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_CLAP, HitSampleInfo.HIT_WHISTLE); + hitObjectNodeHasSampleNormalBank(0, 1, HitSampleInfo.BANK_SOFT); + hitObjectNodeHasSampleAdditionBank(0, 1, HitSampleInfo.BANK_DRUM); + hitObjectNodeHasSamples(0, 1, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_WHISTLE); + } + + [Test] + public void TestSelectingObjectDoesNotMutateSamples() + { + clickSamplePiece(0); + toggleAdditionViaPopover(1); + setAdditionBankViaPopover(HitSampleInfo.BANK_SOFT); + dismissPopover(); + + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_FINISH); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_NORMAL); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_SOFT); + + AddStep("select first object", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects[0])); + + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_FINISH); + hitObjectHasSampleNormalBank(0, HitSampleInfo.BANK_NORMAL); + hitObjectHasSampleAdditionBank(0, HitSampleInfo.BANK_SOFT); } private void clickSamplePiece(int objectIndex) => AddStep($"click {objectIndex.ToOrdinalWords()} sample piece", () => { - var samplePiece = this.ChildrenOfType().Single(piece => piece.HitObject == EditorBeatmap.HitObjects.ElementAt(objectIndex)); + var samplePiece = this.ChildrenOfType().Single(piece => piece is not NodeSamplePointPiece && piece.HitObject == EditorBeatmap.HitObjects.ElementAt(objectIndex)); InputManager.MoveMouseTo(samplePiece); InputManager.Click(MouseButton.Left); }); - private void samplePopoverHasFocus() => AddUntilStep("sample popover textbox focused", () => + private void clickNodeSamplePiece(int objectIndex, int nodeIndex) => AddStep($"click {objectIndex.ToOrdinalWords()} object {nodeIndex.ToOrdinalWords()} node sample piece", () => + { + var samplePiece = this.ChildrenOfType().Where(piece => piece.HitObject == EditorBeatmap.HitObjects.ElementAt(objectIndex)).ToArray()[nodeIndex]; + + InputManager.MoveMouseTo(samplePiece); + InputManager.Click(MouseButton.Left); + }); + + private void seekSamplePiece(int direction) => AddStep($"seek sample piece {direction}", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.PressKey(Key.ShiftLeft); + InputManager.Key(direction < 1 ? Key.Left : Key.Right); + InputManager.ReleaseKey(Key.ShiftLeft); + InputManager.ReleaseKey(Key.ControlLeft); + }); + + private void samplePopoverIsOpen() => AddUntilStep("sample popover is open", () => + { + var popover = this.ChildrenOfType().SingleOrDefault(o => o.IsPresent); + return popover != null; + }); + + private void samplePopoverHasNoFocus() => AddUntilStep("sample popover textbox not focused", () => { var popover = this.ChildrenOfType().SingleOrDefault(); var slider = popover?.ChildrenOfType>().Single(); var textbox = slider?.ChildrenOfType().Single(); - return textbox?.HasFocus == true; + return textbox?.HasFocus == false; }); private void samplePopoverHasSingleVolume(int volume) => AddUntilStep($"sample popover has volume {volume}", () => @@ -371,7 +866,6 @@ namespace osu.Game.Tests.Visual.Editing private void dismissPopover() { - AddStep("unfocus textbox", () => InputManager.Key(Key.Escape)); AddStep("dismiss popover", () => InputManager.Key(Key.Escape)); AddUntilStep("wait for dismiss", () => !this.ChildrenOfType().Any(popover => popover.IsPresent)); } @@ -389,6 +883,12 @@ namespace osu.Game.Tests.Visual.Editing return h.Samples.All(o => o.Volume == volume); }); + private void hitObjectNodeHasSampleVolume(int objectIndex, int nodeIndex, int volume) => AddAssert($"{objectIndex.ToOrdinalWords()} object {nodeIndex.ToOrdinalWords()} node has volume {volume}", () => + { + var h = EditorBeatmap.HitObjects.ElementAt(objectIndex) as IHasRepeats; + return h is not null && h.NodeSamples[nodeIndex].All(o => o.Volume == volume); + }); + private void setBankViaPopover(string bank) => AddStep($"set bank {bank} via popover", () => { var popover = this.ChildrenOfType().Single(); @@ -396,10 +896,30 @@ namespace osu.Game.Tests.Visual.Editing textBox.Current.Value = bank; // force a commit via keyboard. // this is needed when testing attempting to set empty bank - which should revert to the previous value, but only on commit. - InputManager.ChangeFocus(textBox); + ((IFocusManager)InputManager).ChangeFocus(textBox); InputManager.Key(Key.Enter); }); + private void setAdditionBankViaPopover(string bank) => AddStep($"set addition bank {bank} via popover", () => + { + var popover = this.ChildrenOfType().Single(); + var textBox = popover.ChildrenOfType().ToArray()[1]; + textBox.Current.Value = bank; + // force a commit via keyboard. + // this is needed when testing attempting to set empty bank - which should revert to the previous value, but only on commit. + ((IFocusManager)InputManager).ChangeFocus(textBox); + InputManager.Key(Key.Enter); + }); + + private void toggleAdditionViaPopover(int index) => AddStep($"toggle addition {index} via popover", () => + { + var popover = this.ChildrenOfType().First(); + var ternaryButton = popover.ChildrenOfType().ToArray()[index]; + InputManager.MoveMouseTo(ternaryButton); + InputManager.PressButton(MouseButton.Left); + InputManager.ReleaseButton(MouseButton.Left); + }); + private void hitObjectHasSamples(int objectIndex, params string[] samples) => AddAssert($"{objectIndex.ToOrdinalWords()} has samples {string.Join(',', samples)}", () => { var h = EditorBeatmap.HitObjects.ElementAt(objectIndex); @@ -411,5 +931,43 @@ namespace osu.Game.Tests.Visual.Editing var h = EditorBeatmap.HitObjects.ElementAt(objectIndex); return h.Samples.All(o => o.Bank == bank); }); + + private void hitObjectHasSampleNormalBank(int objectIndex, string bank) => AddAssert($"{objectIndex.ToOrdinalWords()} has normal bank {bank}", () => + { + var h = EditorBeatmap.HitObjects.ElementAt(objectIndex); + return h.Samples.Where(o => o.Name == HitSampleInfo.HIT_NORMAL).All(o => o.Bank == bank); + }); + + private void hitObjectHasSampleAdditionBank(int objectIndex, string bank) => AddAssert($"{objectIndex.ToOrdinalWords()} has addition bank {bank}", () => + { + var h = EditorBeatmap.HitObjects.ElementAt(objectIndex); + return h.Samples.Where(o => o.Name != HitSampleInfo.HIT_NORMAL).All(o => o.Bank == bank); + }); + + private void hitObjectNodeHasSamples(int objectIndex, int nodeIndex, params string[] samples) => AddAssert($"{objectIndex.ToOrdinalWords()} object {nodeIndex.ToOrdinalWords()} node has samples {string.Join(',', samples)}", () => + { + var h = EditorBeatmap.HitObjects.ElementAt(objectIndex) as IHasRepeats; + return h is not null && h.NodeSamples[nodeIndex].Select(s => s.Name).SequenceEqual(samples); + }); + + private void hitObjectNodeHasSampleBank(int objectIndex, int nodeIndex, string bank) => AddAssert($"{objectIndex.ToOrdinalWords()} object {nodeIndex.ToOrdinalWords()} node has bank {bank}", () => + { + var h = EditorBeatmap.HitObjects.ElementAt(objectIndex) as IHasRepeats; + return h is not null && h.NodeSamples[nodeIndex].All(o => o.Bank == bank); + }); + + private void hitObjectNodeHasSampleNormalBank(int objectIndex, int nodeIndex, string bank) => AddAssert($"{objectIndex.ToOrdinalWords()} object {nodeIndex.ToOrdinalWords()} node has normal bank {bank}", () => + { + var h = EditorBeatmap.HitObjects.ElementAt(objectIndex) as IHasRepeats; + return h is not null && h.NodeSamples[nodeIndex].Where(o => o.Name == HitSampleInfo.HIT_NORMAL).All(o => o.Bank == bank); + }); + + private void hitObjectNodeHasSampleAdditionBank(int objectIndex, int nodeIndex, string bank) => AddAssert($"{objectIndex.ToOrdinalWords()} object {nodeIndex.ToOrdinalWords()} node has addition bank {bank}", () => + { + var h = EditorBeatmap.HitObjects.ElementAt(objectIndex) as IHasRepeats; + return h is not null && h.NodeSamples[nodeIndex].Where(o => o.Name != HitSampleInfo.HIT_NORMAL).All(o => o.Bank == bank); + }); + + private void editorTimeIs(double time) => AddAssert($"editor time is {time}", () => Precision.AlmostEquals(EditorClock.CurrentTimeAccurate, time, 1)); } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneLabelledTimeSignature.cs b/osu.Game.Tests/Visual/Editing/TestSceneLabelledTimeSignature.cs index e91596b872..3d7d0797d4 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneLabelledTimeSignature.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneLabelledTimeSignature.cs @@ -6,6 +6,7 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; +using osu.Framework.Input; using osu.Framework.Testing; using osu.Game.Beatmaps.Timing; using osu.Game.Graphics.UserInterface; @@ -62,12 +63,12 @@ namespace osu.Game.Tests.Visual.Editing createLabelledTimeSignature(TimeSignature.SimpleQuadruple); AddAssert("current is 4/4", () => timeSignature.Current.Value.Equals(TimeSignature.SimpleQuadruple)); - AddStep("focus text box", () => InputManager.ChangeFocus(numeratorTextBox)); + AddStep("focus text box", () => ((IFocusManager)InputManager).ChangeFocus(numeratorTextBox)); AddStep("set numerator to 7", () => numeratorTextBox.Current.Value = "7"); AddAssert("current is 4/4", () => timeSignature.Current.Value.Equals(TimeSignature.SimpleQuadruple)); - AddStep("drop focus", () => InputManager.ChangeFocus(null)); + AddStep("drop focus", () => ((IFocusManager)InputManager).ChangeFocus(null)); AddAssert("current is 7/4", () => timeSignature.Current.Value.Equals(new TimeSignature(7))); } @@ -77,12 +78,12 @@ namespace osu.Game.Tests.Visual.Editing createLabelledTimeSignature(TimeSignature.SimpleQuadruple); AddAssert("current is 4/4", () => timeSignature.Current.Value.Equals(TimeSignature.SimpleQuadruple)); - AddStep("focus text box", () => InputManager.ChangeFocus(numeratorTextBox)); + AddStep("focus text box", () => ((IFocusManager)InputManager).ChangeFocus(numeratorTextBox)); AddStep("set numerator to 0", () => numeratorTextBox.Current.Value = "0"); AddAssert("current is 4/4", () => timeSignature.Current.Value.Equals(TimeSignature.SimpleQuadruple)); - AddStep("drop focus", () => InputManager.ChangeFocus(null)); + AddStep("drop focus", () => ((IFocusManager)InputManager).ChangeFocus(null)); AddAssert("current is 4/4", () => timeSignature.Current.Value.Equals(TimeSignature.SimpleQuadruple)); AddAssert("numerator is 4", () => numeratorTextBox.Current.Value == "4"); } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneMetadataSection.cs b/osu.Game.Tests/Visual/Editing/TestSceneMetadataSection.cs index a9f8e19e30..743529d40c 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneMetadataSection.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneMetadataSection.cs @@ -3,18 +3,28 @@ #nullable disable +using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input; +using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; using osu.Game.Rulesets.Osu; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Setup; +using osuTK.Input; namespace osu.Game.Tests.Visual.Editing { - public partial class TestSceneMetadataSection : OsuTestScene + public partial class TestSceneMetadataSection : OsuManualInputManagerTestScene { + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + [Cached] private EditorBeatmap editorBeatmap = new EditorBeatmap(new Beatmap { @@ -26,6 +36,81 @@ namespace osu.Game.Tests.Visual.Editing private TestMetadataSection metadataSection; + [Test] + public void TestUpdateViaTextBoxOnFocusLoss() + { + AddStep("set metadata", () => + { + editorBeatmap.Metadata.Artist = "Example Artist"; + editorBeatmap.Metadata.ArtistUnicode = string.Empty; + }); + + createSection(); + + TextBox textbox; + + AddStep("focus first textbox", () => + { + textbox = metadataSection.ChildrenOfType().First(); + InputManager.MoveMouseTo(textbox); + InputManager.Click(MouseButton.Left); + }); + + AddStep("simulate changing textbox", () => + { + // Can't simulate text input but this should work. + InputManager.Keys(PlatformAction.SelectAll); + InputManager.Keys(PlatformAction.Copy); + InputManager.Keys(PlatformAction.Paste); + InputManager.Keys(PlatformAction.Paste); + }); + + assertArtistMetadata("Example Artist"); + + // It's important values are committed immediately on focus loss so the editor exit sequence detects them. + AddAssert("value immediately changed on focus loss", () => + { + ((IFocusManager)InputManager).TriggerFocusContention(metadataSection); + return editorBeatmap.Metadata.Artist; + }, () => Is.EqualTo("Example ArtistExample Artist")); + } + + [Test] + public void TestUpdateViaTextBoxOnCommit() + { + AddStep("set metadata", () => + { + editorBeatmap.Metadata.Artist = "Example Artist"; + editorBeatmap.Metadata.ArtistUnicode = string.Empty; + }); + + createSection(); + + TextBox textbox; + + AddStep("focus first textbox", () => + { + textbox = metadataSection.ChildrenOfType().First(); + InputManager.MoveMouseTo(textbox); + InputManager.Click(MouseButton.Left); + }); + + AddStep("simulate changing textbox", () => + { + // Can't simulate text input but this should work. + InputManager.Keys(PlatformAction.SelectAll); + InputManager.Keys(PlatformAction.Copy); + InputManager.Keys(PlatformAction.Paste); + InputManager.Keys(PlatformAction.Paste); + }); + + assertArtistMetadata("Example Artist"); + + AddStep("commit", () => InputManager.Key(Key.Enter)); + + assertArtistMetadata("Example ArtistExample Artist"); + } + [Test] public void TestMinimalMetadata() { @@ -40,7 +125,7 @@ namespace osu.Game.Tests.Visual.Editing createSection(); - assertArtist("Example Artist"); + assertArtistTextBox("Example Artist"); assertRomanisedArtist("Example Artist", false); assertTitle("Example Title"); @@ -61,7 +146,7 @@ namespace osu.Game.Tests.Visual.Editing createSection(); - assertArtist("*なみりん"); + assertArtistTextBox("*なみりん"); assertRomanisedArtist(string.Empty, true); assertTitle("コイシテイク・プラネット"); @@ -82,7 +167,7 @@ namespace osu.Game.Tests.Visual.Editing createSection(); - assertArtist("*なみりん"); + assertArtistTextBox("*なみりん"); assertRomanisedArtist("*namirin", true); assertTitle("コイシテイク・プラネット"); @@ -104,11 +189,11 @@ namespace osu.Game.Tests.Visual.Editing createSection(); AddStep("set romanised artist name", () => metadataSection.ArtistTextBox.Current.Value = "*namirin"); - assertArtist("*namirin"); + assertArtistTextBox("*namirin"); assertRomanisedArtist("*namirin", false); AddStep("set native artist name", () => metadataSection.ArtistTextBox.Current.Value = "*なみりん"); - assertArtist("*なみりん"); + assertArtistTextBox("*なみりん"); assertRomanisedArtist("*namirin", true); AddStep("set romanised title", () => metadataSection.TitleTextBox.Current.Value = "Hitokoto no kyori"); @@ -121,33 +206,36 @@ namespace osu.Game.Tests.Visual.Editing } private void createSection() - => AddStep("create metadata section", () => Child = metadataSection = new TestMetadataSection()); + => AddStep("create metadata section", () => Child = metadataSection = new TestMetadataSection { RelativeSizeAxes = Axes.X }); - private void assertArtist(string expected) - => AddAssert($"artist is {expected}", () => metadataSection.ArtistTextBox.Current.Value == expected); + private void assertArtistMetadata(string expected) + => AddAssert($"artist metadata is {expected}", () => editorBeatmap.Metadata.Artist, () => Is.EqualTo(expected)); + + private void assertArtistTextBox(string expected) + => AddAssert($"artist textbox is {expected}", () => metadataSection.ArtistTextBox.Current.Value, () => Is.EqualTo(expected)); private void assertRomanisedArtist(string expected, bool editable) { - AddAssert($"romanised artist is {expected}", () => metadataSection.RomanisedArtistTextBox.Current.Value == expected); + AddAssert($"romanised artist is {expected}", () => metadataSection.RomanisedArtistTextBox.Current.Value, () => Is.EqualTo(expected)); AddAssert($"romanised artist is {(editable ? "" : "not ")}editable", () => metadataSection.RomanisedArtistTextBox.ReadOnly == !editable); } private void assertTitle(string expected) - => AddAssert($"title is {expected}", () => metadataSection.TitleTextBox.Current.Value == expected); + => AddAssert($"title is {expected}", () => metadataSection.TitleTextBox.Current.Value, () => Is.EqualTo(expected)); private void assertRomanisedTitle(string expected, bool editable) { - AddAssert($"romanised title is {expected}", () => metadataSection.RomanisedTitleTextBox.Current.Value == expected); + AddAssert($"romanised title is {expected}", () => metadataSection.RomanisedTitleTextBox.Current.Value, () => Is.EqualTo(expected)); AddAssert($"romanised title is {(editable ? "" : "not ")}editable", () => metadataSection.RomanisedTitleTextBox.ReadOnly == !editable); } private partial class TestMetadataSection : MetadataSection { - public new LabelledTextBox ArtistTextBox => base.ArtistTextBox; - public new LabelledTextBox RomanisedArtistTextBox => base.RomanisedArtistTextBox; + public new FormTextBox ArtistTextBox => base.ArtistTextBox; + public new FormTextBox RomanisedArtistTextBox => base.RomanisedArtistTextBox; - public new LabelledTextBox TitleTextBox => base.TitleTextBox; - public new LabelledTextBox RomanisedTitleTextBox => base.RomanisedTitleTextBox; + public new FormTextBox TitleTextBox => base.TitleTextBox; + public new FormTextBox RomanisedTitleTextBox => base.RomanisedTitleTextBox; } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneOpenEditorTimestamp.cs b/osu.Game.Tests/Visual/Editing/TestSceneOpenEditorTimestamp.cs index 1f46a08831..955ded97af 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneOpenEditorTimestamp.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneOpenEditorTimestamp.cs @@ -36,7 +36,7 @@ namespace osu.Game.Tests.Visual.Editing () => Is.EqualTo(1)); AddStep("enter song select", () => Game.ChildrenOfType().Single().OnSolo?.Invoke()); - AddUntilStep("entered song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect); + AddUntilStep("entered song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect && songSelect.BeatmapSetsLoaded); addStepClickLink("00:00:000 (1)", waitForSeek: false); AddUntilStep("received 'must be in edit'", @@ -100,6 +100,20 @@ namespace osu.Game.Tests.Visual.Editing assertOnScreenAt(EditorScreenMode.Compose, 0); } + [Test] + public void TestUrlDecodingOfArgs() + { + setUpEditor(new OsuRuleset().RulesetInfo); + AddAssert("is osu! ruleset", () => editorBeatmap.BeatmapInfo.Ruleset.Equals(new OsuRuleset().RulesetInfo)); + + AddStep("jump to encoded link", () => Game.HandleLink("osu://edit/00:14:142%20(1)")); + + AddUntilStep("wait for seek", () => editorClock.SeekingOrStopped.Value); + + AddAssert("time is correct", () => editorClock.CurrentTime, () => Is.EqualTo(14_142)); + AddAssert("selected object is correct", () => editorBeatmap.SelectedHitObjects.Single().StartTime, () => Is.EqualTo(14_142)); + } + private void addStepClickLink(string timestamp, string step = "", bool waitForSeek = true) { AddStep($"{step} {timestamp}", () => @@ -138,7 +152,7 @@ namespace osu.Game.Tests.Visual.Editing AddUntilStep("Wait for song select", () => Game.Beatmap.Value.BeatmapSetInfo.Equals(beatmapSet) && Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect - && songSelect.IsLoaded + && songSelect.BeatmapSetsLoaded ); AddStep("Switch ruleset", () => Game.Ruleset.Value = ruleset); AddStep("Open editor for ruleset", () => diff --git a/osu.Game.Tests/Visual/Editing/TestScenePlacementBlueprint.cs b/osu.Game.Tests/Visual/Editing/TestScenePlacementBlueprint.cs index a5681bea4a..966e6513bb 100644 --- a/osu.Game.Tests/Visual/Editing/TestScenePlacementBlueprint.cs +++ b/osu.Game.Tests/Visual/Editing/TestScenePlacementBlueprint.cs @@ -5,11 +5,14 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Screens; using osu.Framework.Testing; +using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Input.Bindings; using osu.Game.Rulesets; using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.UI; using osu.Game.Screens.Edit.Compose.Components; using osu.Game.Tests.Beatmaps; @@ -26,7 +29,53 @@ namespace osu.Game.Tests.Visual.Editing private GlobalActionContainer globalActionContainer => this.ChildrenOfType().Single(); [Test] - public void TestCommitPlacementViaGlobalAction() + public void TestDeleteUsingMiddleMouse() + { + AddStep("select circle placement tool", () => InputManager.Key(Key.Number2)); + AddStep("move mouse to center of playfield", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); + AddStep("place circle", () => InputManager.Click(MouseButton.Left)); + + AddAssert("one circle added", () => EditorBeatmap.HitObjects, () => Has.One.Items); + AddStep("delete with middle mouse", () => InputManager.Click(MouseButton.Middle)); + AddAssert("circle removed", () => EditorBeatmap.HitObjects, () => Is.Empty); + } + + [Test] + public void TestDeleteUsingShiftRightClick() + { + AddStep("select circle placement tool", () => InputManager.Key(Key.Number2)); + AddStep("move mouse to center of playfield", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); + AddStep("place circle", () => InputManager.Click(MouseButton.Left)); + + AddAssert("one circle added", () => EditorBeatmap.HitObjects, () => Has.One.Items); + AddStep("delete with right mouse", () => + { + InputManager.PressKey(Key.ShiftLeft); + InputManager.Click(MouseButton.Right); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + AddAssert("circle removed", () => EditorBeatmap.HitObjects, () => Is.Empty); + } + + [Test] + public void TestContextMenu() + { + AddStep("select circle placement tool", () => InputManager.Key(Key.Number2)); + AddStep("move mouse to center of playfield", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); + AddStep("place circle", () => InputManager.Click(MouseButton.Left)); + + AddAssert("one circle added", () => EditorBeatmap.HitObjects, () => Has.One.Items); + AddStep("delete with right mouse", () => + { + InputManager.Click(MouseButton.Right); + }); + AddAssert("circle not removed", () => EditorBeatmap.HitObjects, () => Has.One.Items); + AddAssert("circle selected", () => EditorBeatmap.SelectedHitObjects, () => Has.One.Items); + } + + [Test] + [Solo] + public void TestCommitPlacementViaRightClick() { Playfield playfield = null!; @@ -43,11 +92,7 @@ namespace osu.Game.Tests.Visual.Editing var location = (playfield.ScreenSpaceDrawQuad.TopLeft + 3 * playfield.ScreenSpaceDrawQuad.BottomRight) / 4; InputManager.MoveMouseTo(location); }); - AddStep("confirm via global action", () => - { - globalActionContainer.TriggerPressed(GlobalAction.Select); - globalActionContainer.TriggerReleased(GlobalAction.Select); - }); + AddStep("confirm via right click", () => InputManager.Click(MouseButton.Right)); AddAssert("slider placed", () => EditorBeatmap.HitObjects.Count, () => Is.EqualTo(1)); } @@ -102,5 +147,120 @@ namespace osu.Game.Tests.Visual.Editing AddStep("change tool to circle", () => InputManager.Key(Key.Number2)); AddAssert("slider placed", () => EditorBeatmap.HitObjects.Count, () => Is.EqualTo(1)); } + + [Test] + public void TestAutomaticBankAssignment() + { + AddStep("add object with soft bank", () => EditorBeatmap.Add(new HitCircle + { + StartTime = 0, + Samples = + { + new HitSampleInfo(name: HitSampleInfo.HIT_NORMAL, bank: HitSampleInfo.BANK_SOFT, volume: 70), + new HitSampleInfo(name: HitSampleInfo.HIT_WHISTLE, bank: HitSampleInfo.BANK_DRUM, volume: 70), + } + })); + + AddStep("seek to 500", () => EditorClock.Seek(500)); // previous object is the one at time 0 + AddStep("enable automatic bank assignment", () => + { + InputManager.PressKey(Key.LShift); + InputManager.PressKey(Key.LAlt); + InputManager.Key(Key.Q); + InputManager.ReleaseKey(Key.LAlt); + InputManager.ReleaseKey(Key.LShift); + }); + AddStep("select circle placement tool", () => InputManager.Key(Key.Number2)); + AddStep("move mouse to center of playfield", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); + AddStep("place circle", () => InputManager.Click(MouseButton.Left)); + AddAssert("circle has soft bank", () => EditorBeatmap.HitObjects[1].Samples.Single().Bank, () => Is.EqualTo(HitSampleInfo.BANK_SOFT)); + AddAssert("circle inherited volume", () => EditorBeatmap.HitObjects[1].Samples.All(s => s.Volume == 70)); + + AddStep("seek to 250", () => EditorClock.Seek(250)); // previous object is the one at time 0 + AddStep("enable clap addition", () => InputManager.Key(Key.R)); + AddStep("select circle placement tool", () => InputManager.Key(Key.Number2)); + AddStep("move mouse to center of playfield", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); + AddStep("place circle", () => InputManager.Click(MouseButton.Left)); + AddAssert("circle has 2 samples", () => EditorBeatmap.HitObjects[1].Samples, () => Has.Count.EqualTo(2)); + AddAssert("normal sample has soft bank", () => EditorBeatmap.HitObjects[1].Samples.Single(s => s.Name == HitSampleInfo.HIT_NORMAL).Bank, + () => Is.EqualTo(HitSampleInfo.BANK_SOFT)); + AddAssert("clap sample has drum bank", () => EditorBeatmap.HitObjects[1].Samples.Single(s => s.Name == HitSampleInfo.HIT_CLAP).Bank, + () => Is.EqualTo(HitSampleInfo.BANK_DRUM)); + AddAssert("circle inherited volume", () => EditorBeatmap.HitObjects[1].Samples.All(s => s.Volume == 70)); + + AddStep("seek to 1000", () => EditorClock.Seek(1000)); // previous object is the one at time 500, which has no additions + AddStep("select circle placement tool", () => InputManager.Key(Key.Number2)); + AddStep("move mouse to center of playfield", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); + AddStep("place circle", () => InputManager.Click(MouseButton.Left)); + AddAssert("circle has 2 samples", () => EditorBeatmap.HitObjects[3].Samples, () => Has.Count.EqualTo(2)); + AddAssert("all samples have soft bank", () => EditorBeatmap.HitObjects[3].Samples.All(s => s.Bank == HitSampleInfo.BANK_SOFT)); + AddAssert("circle inherited volume", () => EditorBeatmap.HitObjects[3].Samples.All(s => s.Volume == 70)); + } + + [Test] + public void TestVolumeIsInheritedFromLastObject() + { + AddStep("add object with soft bank", () => EditorBeatmap.Add(new HitCircle + { + StartTime = 0, + Samples = + { + new HitSampleInfo(name: HitSampleInfo.HIT_NORMAL, bank: HitSampleInfo.BANK_SOFT, volume: 70), + } + })); + AddStep("seek to 500", () => EditorClock.Seek(500)); + AddStep("select drum bank", () => + { + InputManager.PressKey(Key.LShift); + InputManager.Key(Key.R); + InputManager.ReleaseKey(Key.LShift); + }); + AddStep("select circle placement tool", () => InputManager.Key(Key.Number2)); + AddStep("move mouse to center of playfield", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); + AddStep("place circle", () => InputManager.Click(MouseButton.Left)); + AddAssert("circle has drum bank", () => EditorBeatmap.HitObjects[1].Samples.All(s => s.Bank == HitSampleInfo.BANK_DRUM)); + AddAssert("circle inherited volume", () => EditorBeatmap.HitObjects[1].Samples.All(s => s.Volume == 70)); + } + + [Test] + public void TestNodeSamplesAndSamplesAreSame() + { + Playfield playfield = null!; + + AddStep("select drum bank", () => + { + InputManager.PressKey(Key.LShift); + InputManager.PressKey(Key.LAlt); + InputManager.Key(Key.R); + InputManager.ReleaseKey(Key.LAlt); + InputManager.ReleaseKey(Key.LShift); + }); + AddStep("enable clap addition", () => InputManager.Key(Key.R)); + + AddStep("select slider placement tool", () => InputManager.Key(Key.Number3)); + AddStep("move mouse to top left of playfield", () => + { + playfield = this.ChildrenOfType().Single(); + var location = (3 * playfield.ScreenSpaceDrawQuad.TopLeft + playfield.ScreenSpaceDrawQuad.BottomRight) / 4; + InputManager.MoveMouseTo(location); + }); + AddStep("begin placement", () => InputManager.Click(MouseButton.Left)); + AddStep("move mouse to bottom right of playfield", () => + { + var location = (playfield.ScreenSpaceDrawQuad.TopLeft + 3 * playfield.ScreenSpaceDrawQuad.BottomRight) / 4; + InputManager.MoveMouseTo(location); + }); + AddStep("confirm via right click", () => InputManager.Click(MouseButton.Right)); + AddAssert("slider placed", () => EditorBeatmap.HitObjects.Count, () => Is.EqualTo(1)); + + AddAssert("slider samples have drum bank", () => EditorBeatmap.HitObjects[0].Samples.All(s => s.Bank == HitSampleInfo.BANK_DRUM)); + AddAssert("slider node samples have drum bank", + () => ((IHasRepeats)EditorBeatmap.HitObjects[0]).NodeSamples.SelectMany(s => s).All(s => s.Bank == HitSampleInfo.BANK_DRUM)); + + AddAssert("slider samples have clap addition", + () => EditorBeatmap.HitObjects[0].Samples.Select(s => s.Name), () => Does.Contain(HitSampleInfo.HIT_CLAP)); + AddAssert("slider node samples have clap addition", + () => ((IHasRepeats)EditorBeatmap.HitObjects[0]).NodeSamples.All(samples => samples.Any(s => s.Name == HitSampleInfo.HIT_CLAP))); + } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneRectangularPositionSnapGrid.cs b/osu.Game.Tests/Visual/Editing/TestScenePositionSnapGrid.cs similarity index 56% rename from osu.Game.Tests/Visual/Editing/TestSceneRectangularPositionSnapGrid.cs rename to osu.Game.Tests/Visual/Editing/TestScenePositionSnapGrid.cs index e73a45e154..72a11a526e 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneRectangularPositionSnapGrid.cs +++ b/osu.Game.Tests/Visual/Editing/TestScenePositionSnapGrid.cs @@ -16,7 +16,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.Editing { - public partial class TestSceneRectangularPositionSnapGrid : OsuManualInputManagerTestScene + public partial class TestScenePositionSnapGrid : OsuManualInputManagerTestScene { private Container content; protected override Container Content => content; @@ -33,28 +33,79 @@ namespace osu.Game.Tests.Visual.Editing }, content = new Container { - RelativeSizeAxes = Axes.Both + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(10), } }); } private static readonly object[][] test_cases = { - new object[] { new Vector2(0, 0), new Vector2(10, 10) }, - new object[] { new Vector2(240, 180), new Vector2(10, 15) }, - new object[] { new Vector2(160, 120), new Vector2(30, 20) }, - new object[] { new Vector2(480, 360), new Vector2(100, 100) }, + new object[] { new Vector2(0, 0), new Vector2(10, 10), 0f }, + new object[] { new Vector2(240, 180), new Vector2(10, 15), 10f }, + new object[] { new Vector2(160, 120), new Vector2(30, 20), -10f }, + new object[] { new Vector2(480, 360), new Vector2(100, 100), 0f }, }; [TestCaseSource(nameof(test_cases))] - public void TestRectangularGrid(Vector2 position, Vector2 spacing) + public void TestRectangularGrid(Vector2 position, Vector2 spacing, float rotation) { RectangularPositionSnapGrid grid = null; - AddStep("create grid", () => Child = grid = new RectangularPositionSnapGrid(position) + AddStep("create grid", () => + { + Child = grid = new RectangularPositionSnapGrid + { + RelativeSizeAxes = Axes.Both, + }; + grid.StartPosition.Value = position; + grid.Spacing.Value = spacing; + grid.GridLineRotation.Value = rotation; + }); + + AddStep("add snapping cursor", () => Add(new SnappingCursorContainer { RelativeSizeAxes = Axes.Both, - Spacing = spacing + GetSnapPosition = pos => grid.GetSnappedPosition(grid.ToLocalSpace(pos)) + })); + } + + [TestCaseSource(nameof(test_cases))] + public void TestTriangularGrid(Vector2 position, Vector2 spacing, float rotation) + { + TriangularPositionSnapGrid grid = null; + + AddStep("create grid", () => + { + Child = grid = new TriangularPositionSnapGrid + { + RelativeSizeAxes = Axes.Both, + }; + grid.StartPosition.Value = position; + grid.Spacing.Value = spacing.X; + grid.GridLineRotation.Value = rotation; + }); + + AddStep("add snapping cursor", () => Add(new SnappingCursorContainer + { + RelativeSizeAxes = Axes.Both, + GetSnapPosition = pos => grid.GetSnappedPosition(grid.ToLocalSpace(pos)) + })); + } + + [TestCaseSource(nameof(test_cases))] + public void TestCircularGrid(Vector2 position, Vector2 spacing, float rotation) + { + CircularPositionSnapGrid grid = null; + + AddStep("create grid", () => + { + Child = grid = new CircularPositionSnapGrid + { + RelativeSizeAxes = Axes.Both, + }; + grid.StartPosition.Value = position; + grid.Spacing.Value = spacing.X; }); AddStep("add snapping cursor", () => Add(new SnappingCursorContainer @@ -86,7 +137,7 @@ namespace osu.Game.Tests.Visual.Editing { base.LoadComplete(); - updatePosition(GetContainingInputManager().CurrentState.Mouse.Position); + updatePosition(GetContainingInputManager()!.CurrentState.Mouse.Position); } protected override bool OnMouseMove(MouseMoveEvent e) diff --git a/osu.Game.Tests/Visual/Editing/TestScenePreviewTime.cs b/osu.Game.Tests/Visual/Editing/TestScenePreviewTime.cs index 3319788c8a..ad8c29d180 100644 --- a/osu.Game.Tests/Visual/Editing/TestScenePreviewTime.cs +++ b/osu.Game.Tests/Visual/Editing/TestScenePreviewTime.cs @@ -29,7 +29,7 @@ namespace osu.Game.Tests.Visual.Editing AddStep("set preview time to -1", () => EditorBeatmap.PreviewTime.Value = -1); AddAssert("preview time line should not show", () => !Editor.ChildrenOfType().Single().Children.Any()); AddStep("set preview time to 1000", () => EditorBeatmap.PreviewTime.Value = 1000); - AddAssert("preview time line should show", () => Editor.ChildrenOfType().Single().Children.Single().Alpha == 1); + AddAssert("preview time line should show", () => Editor.ChildrenOfType().Single().Children.Single().Alpha, () => Is.GreaterThan(0)); } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTimelineSelection.cs b/osu.Game.Tests/Visual/Editing/TestSceneTimelineSelection.cs index d8219ff36e..229cb995d8 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneTimelineSelection.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneTimelineSelection.cs @@ -14,6 +14,7 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Edit; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Screens.Edit.Compose.Components.Timeline; using osu.Game.Tests.Beatmaps; @@ -357,6 +358,73 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("all blueprints are present", () => blueprintContainer.SelectionBlueprints.Count == EditorBeatmap.SelectedHitObjects.Count); } + [Test] + public void TestDragSelectionDuringPlacement() + { + var addedObjects = new[] + { + new Slider + { + StartTime = 300, + Path = new SliderPath([ + new PathControlPoint(), + new PathControlPoint(new Vector2(200)), + ]) + }, + }; + AddStep("add hitobjects", () => EditorBeatmap.AddRange(addedObjects)); + + AddStep("seek to 700", () => EditorClock.Seek(700)); + AddStep("select spinner placement tool", () => + { + InputManager.Key(Key.Number4); + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + }); + AddStep("begin spinner placement", () => InputManager.Click(MouseButton.Left)); + AddStep("seek to 1500", () => EditorClock.Seek(1500)); + + AddStep("start dragging", () => + { + var blueprintQuad = blueprintContainer.SelectionBlueprints[1].ScreenSpaceDrawQuad; + var dragStartPos = (blueprintQuad.TopLeft + blueprintQuad.BottomLeft) / 2 - new Vector2(30, 0); + InputManager.MoveMouseTo(dragStartPos); + InputManager.PressButton(MouseButton.Left); + }); + + AddStep("select entire object", () => + { + var blueprintQuad = blueprintContainer.SelectionBlueprints[1].ScreenSpaceDrawQuad; + var dragStartPos = (blueprintQuad.TopRight + blueprintQuad.BottomRight) / 2 + new Vector2(30, 0); + InputManager.MoveMouseTo(dragStartPos); + }); + AddStep("end drag", () => InputManager.ReleaseButton(MouseButton.Left)); + + AddUntilStep("hitobject selected", () => EditorBeatmap.SelectedHitObjects, () => NUnit.Framework.Contains.Item(addedObjects[0])); + AddAssert("placement committed", () => EditorBeatmap.HitObjects, () => Has.Count.EqualTo(2)); + } + + [Test] + public void TestBreakRemoval() + { + var addedObjects = new[] + { + new HitCircle { StartTime = 0 }, + new HitCircle { StartTime = 5000 }, + }; + + AddStep("add hitobjects", () => EditorBeatmap.AddRange(addedObjects)); + AddAssert("beatmap has one break", () => EditorBeatmap.Breaks, () => Has.Count.EqualTo(1)); + + AddStep("move mouse to break", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); + AddStep("right click", () => InputManager.Click(MouseButton.Right)); + + AddStep("move mouse to delete menu item", () => InputManager.MoveMouseTo(this.ChildrenOfType().First().ChildrenOfType().First())); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + + AddAssert("beatmap has no breaks", () => EditorBeatmap.Breaks, () => Is.Empty); + AddAssert("break piece went away", () => this.ChildrenOfType().Count(), () => Is.Zero); + } + private void assertSelectionIs(IEnumerable hitObjects) => AddAssert("correct hitobjects selected", () => EditorBeatmap.SelectedHitObjects.OrderBy(h => h.StartTime).SequenceEqual(hitObjects)); } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs b/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs index 6181024230..cf07ce2431 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs @@ -114,40 +114,6 @@ namespace osu.Game.Tests.Visual.Editing }); } - [Test] - public void TestTrackingCurrentTimeWhileRunning() - { - AddStep("Select first effect point", () => - { - InputManager.MoveMouseTo(Child.ChildrenOfType().First()); - InputManager.Click(MouseButton.Left); - }); - - AddUntilStep("Selection changed", () => timingScreen.SelectedGroup.Value.Time == 54670); - AddUntilStep("Ensure seeked to correct time", () => EditorClock.CurrentTimeAccurate == 54670); - - AddStep("Seek to just before next point", () => EditorClock.Seek(69000)); - AddStep("Start clock", () => EditorClock.Start()); - - AddUntilStep("Selection changed", () => timingScreen.SelectedGroup.Value.Time == 69670); - } - - [Test] - public void TestTrackingCurrentTimeWhilePaused() - { - AddStep("Select first effect point", () => - { - InputManager.MoveMouseTo(Child.ChildrenOfType().First()); - InputManager.Click(MouseButton.Left); - }); - - AddUntilStep("Selection changed", () => timingScreen.SelectedGroup.Value.Time == 54670); - AddUntilStep("Ensure seeked to correct time", () => EditorClock.CurrentTimeAccurate == 54670); - - AddStep("Seek to later", () => EditorClock.Seek(80000)); - AddUntilStep("Selection changed", () => timingScreen.SelectedGroup.Value.Time == 69670); - } - [Test] public void TestScrollControlGroupIntoView() { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneArgonHealthDisplay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneArgonHealthDisplay.cs index 5d2921107e..319efee1a7 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneArgonHealthDisplay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneArgonHealthDisplay.cs @@ -99,6 +99,7 @@ namespace osu.Game.Tests.Visual.Gameplay Scheduler.AddDelayed(applyMiss, 500 + 30); }); + AddUntilStep("wait for sequence", () => !Scheduler.HasPendingTasks); } [Test] @@ -120,6 +121,7 @@ namespace osu.Game.Tests.Visual.Gameplay } } }); + AddUntilStep("wait for sequence", () => !Scheduler.HasPendingTasks); } [Test] diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs index 83fc5c2013..0f47c3cd27 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs @@ -1,15 +1,18 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - +using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.Play.PlayerSettings; using osu.Game.Tests.Resources; @@ -19,7 +22,7 @@ namespace osu.Game.Tests.Visual.Gameplay { public partial class TestSceneBeatmapOffsetControl : OsuTestScene { - private BeatmapOffsetControl offsetControl; + private BeatmapOffsetControl offsetControl = null!; [SetUpSteps] public void SetUpSteps() @@ -53,6 +56,39 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("No calibration button", () => !offsetControl.ChildrenOfType().Any()); } + [Test] + public void TestNotEnoughTimedHitEvents() + { + AddStep("Set short reference score", () => + { + List hitEvents = + [ + // 10 events total. one of them (head circle) being timed / having hitwindows, rest having no hitwindows + new HitEvent(30, 1, HitResult.LargeTickHit, new SliderHeadCircle { ClassicSliderBehaviour = true }, null, null), + new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), + new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), + new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), + new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), + new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), + new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), + new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), + new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), + new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), + ]; + + foreach (var ev in hitEvents) + ev.HitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + + offsetControl.ReferenceScore.Value = new ScoreInfo + { + HitEvents = hitEvents, + BeatmapInfo = Beatmap.Value.BeatmapInfo, + }; + }); + + AddAssert("No calibration button", () => !offsetControl.ChildrenOfType().Any()); + } + [Test] public void TestScoreFromDifferentBeatmap() { @@ -137,5 +173,83 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("Remove reference score", () => offsetControl.ReferenceScore.Value = null); AddAssert("No calibration button", () => !offsetControl.ChildrenOfType().Any()); } + + [Test] + public void TestCalibrationFromNonZeroWithImmediateReferenceScore() + { + const double average_error = -4.5; + const double initial_offset = -2; + + AddStep("Set beatmap offset non-neutral", () => Realm.Write(r => + { + r.Add(new BeatmapInfo + { + ID = Beatmap.Value.BeatmapInfo.ID, + Ruleset = Beatmap.Value.BeatmapInfo.Ruleset, + UserSettings = + { + Offset = initial_offset, + } + }); + })); + + AddStep("Create control with preloaded reference score", () => + { + Child = new PlayerSettingsGroup("Some settings") + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + offsetControl = new BeatmapOffsetControl + { + ReferenceScore = + { + Value = new ScoreInfo + { + HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents(average_error), + BeatmapInfo = Beatmap.Value.BeatmapInfo, + } + } + } + } + }; + }); + + AddUntilStep("Has calibration button", () => offsetControl.ChildrenOfType().Any()); + AddStep("Press button", () => offsetControl.ChildrenOfType().Single().TriggerClick()); + AddAssert("Offset is adjusted", () => offsetControl.Current.Value, () => Is.EqualTo(initial_offset - average_error)); + + AddUntilStep("Button is disabled", () => !offsetControl.ChildrenOfType().Single().Enabled.Value); + AddStep("Remove reference score", () => offsetControl.ReferenceScore.Value = null); + AddAssert("No calibration button", () => !offsetControl.ChildrenOfType().Any()); + + AddStep("Clean up beatmap", () => Realm.Write(r => r.RemoveAll())); + } + + [Test] + public void TestCalibrationNoChange() + { + const double average_error = 0; + + AddAssert("Offset is neutral", () => offsetControl.Current.Value == 0); + AddAssert("No calibration button", () => !offsetControl.ChildrenOfType().Any()); + AddStep("Set reference score", () => + { + offsetControl.ReferenceScore.Value = new ScoreInfo + { + HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents(average_error), + BeatmapInfo = Beatmap.Value.BeatmapInfo, + }; + }); + + AddUntilStep("Has calibration button", () => offsetControl.ChildrenOfType().Any()); + AddStep("Press button", () => offsetControl.ChildrenOfType().Single().TriggerClick()); + AddAssert("Offset is adjusted", () => offsetControl.Current.Value == -average_error); + + AddUntilStep("Button is disabled", () => !offsetControl.ChildrenOfType().Single().Enabled.Value); + AddStep("Remove reference score", () => offsetControl.ReferenceScore.Value = null); + AddAssert("No calibration button", () => !offsetControl.ChildrenOfType().Any()); + } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs index a2ce62105e..5ec32f318c 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs @@ -37,8 +37,8 @@ namespace osu.Game.Tests.Visual.Gameplay public void TestEmptyLegacyBeatmapSkinFallsBack() { CreateSkinTest(TrianglesSkin.CreateInfo(), () => new LegacyBeatmapSkin(new BeatmapInfo(), null)); - AddUntilStep("wait for hud load", () => Player.ChildrenOfType().All(c => c.ComponentsLoaded)); - AddAssert("hud from default skin", () => AssertComponentsFromExpectedSource(SkinComponentsContainerLookup.TargetArea.MainHUDComponents, skinManager.CurrentSkin.Value)); + AddUntilStep("wait for hud load", () => Player.ChildrenOfType().All(c => c.ComponentsLoaded)); + AddAssert("hud from default skin", () => AssertComponentsFromExpectedSource(GlobalSkinnableContainers.MainHUDComponents, skinManager.CurrentSkin.Value)); } protected void CreateSkinTest(SkinInfo gameCurrentSkin, Func getBeatmapSkin) @@ -53,9 +53,9 @@ namespace osu.Game.Tests.Visual.Gameplay }); } - protected bool AssertComponentsFromExpectedSource(SkinComponentsContainerLookup.TargetArea target, ISkin expectedSource) + protected bool AssertComponentsFromExpectedSource(GlobalSkinnableContainers target, ISkin expectedSource) { - var targetContainer = Player.ChildrenOfType().First(s => s.Lookup.Target == target); + var targetContainer = Player.ChildrenOfType().First(s => s.Lookup.Lookup == target); var actualComponentsContainer = targetContainer.ChildrenOfType().SingleOrDefault(c => c.Parent == targetContainer); if (actualComponentsContainer == null) @@ -63,7 +63,7 @@ namespace osu.Game.Tests.Visual.Gameplay var actualInfo = actualComponentsContainer.CreateSerialisedInfo(); - var expectedComponentsContainer = expectedSource.GetDrawableComponent(new SkinComponentsContainerLookup(target)) as Container; + var expectedComponentsContainer = expectedSource.GetDrawableComponent(new GlobalSkinnableContainerLookup(target)) as Container; if (expectedComponentsContainer == null) return false; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneBreakTracker.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneBreakTracker.cs index c010b2c809..21b6495865 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneBreakTracker.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneBreakTracker.cs @@ -7,9 +7,13 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; using osu.Framework.Timing; using osu.Game.Beatmaps.Timing; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play; +using osuTK.Graphics; namespace osu.Game.Tests.Visual.Gameplay { @@ -28,14 +32,20 @@ namespace osu.Game.Tests.Visual.Gameplay public TestSceneBreakTracker() { - AddRange(new Drawable[] + Children = new Drawable[] { + new Box + { + Colour = Color4.White, + RelativeSizeAxes = Axes.Both, + }, breakTracker = new TestBreakTracker(), - breakOverlay = new BreakOverlay(true, null) + breakOverlay = new BreakOverlay(true, new ScoreProcessor(new OsuRuleset())) { ProcessCustomClock = false, + BreakTracker = breakTracker, } - }); + }; } protected override void Update() @@ -48,9 +58,6 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestShowBreaks() { - setClock(false); - - addShowBreakStep(2); addShowBreakStep(5); addShowBreakStep(15); } @@ -115,7 +122,7 @@ namespace osu.Game.Tests.Visual.Gameplay { AddStep($"show '{seconds}s' break", () => { - breakOverlay.Breaks = breakTracker.Breaks = new List + breakTracker.Breaks = new List { new BreakPeriod(Clock.CurrentTime, Clock.CurrentTime + seconds * 1000) }; @@ -129,7 +136,7 @@ namespace osu.Game.Tests.Visual.Gameplay private void loadBreaksStep(string breakDescription, IReadOnlyList breaks) { - AddStep($"load {breakDescription}", () => breakOverlay.Breaks = breakTracker.Breaks = breaks); + AddStep($"load {breakDescription}", () => breakTracker.Breaks = breaks); seekAndAssertBreak("seek back to 0", 0, false); } @@ -175,6 +182,7 @@ namespace osu.Game.Tests.Visual.Gameplay } public TestBreakTracker() + : base(0, new ScoreProcessor(new OsuRuleset())) { FramedManualClock = new FramedClock(manualClock = new ManualClock()); ProcessCustomClock = false; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs index 8fa2c9922e..800857c973 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs @@ -85,8 +85,8 @@ namespace osu.Game.Tests.Visual.Gameplay if (scaleTransformProvided) { - sprite.TimelineGroup.Scale.Add(Easing.None, Time.Current, Time.Current + 1000, 1, 2); - sprite.TimelineGroup.Scale.Add(Easing.None, Time.Current + 1000, Time.Current + 2000, 2, 1); + sprite.Commands.AddScale(Easing.None, Time.Current, Time.Current + 1000, 1, 2); + sprite.Commands.AddScale(Easing.None, Time.Current + 1000, Time.Current + 2000, 2, 1); } layer.Elements.Clear(); @@ -211,7 +211,8 @@ namespace osu.Game.Tests.Visual.Gameplay var layer = storyboard.GetLayer("Background"); var sprite = new StoryboardSprite(lookupName, origin, initialPosition); - sprite.AddLoop(Time.Current, 100).Alpha.Add(Easing.None, 0, 10000, 1, 1); + var loop = sprite.AddLoopingGroup(Time.Current, 100); + loop.AddAlpha(Easing.None, 0, 10000, 1, 1); layer.Elements.Clear(); layer.Add(sprite); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs index 193e8b2571..1787230117 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs @@ -155,7 +155,13 @@ namespace osu.Game.Tests.Visual.Gameplay var api = (DummyAPIAccess)API; api.Friends.Clear(); - api.Friends.Add(friend); + api.Friends.Add(new APIRelation + { + Mutual = true, + RelationType = RelationType.Friend, + TargetID = friend.OnlineID, + TargetUser = friend + }); }); int playerNumber = 1; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs index ad3fe7cb7e..21c83d521c 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs @@ -48,7 +48,7 @@ namespace osu.Game.Tests.Visual.Gameplay return true; }); - AddAssert("sample playback disabled", () => sampleDisabler.SamplePlaybackDisabled.Value); + AddUntilStep("sample playback disabled", () => sampleDisabler.SamplePlaybackDisabled.Value); // because we are in frame stable context, it's quite likely that not all samples are "played" at this point. // the important thing is that at least one started, and that sample has since stopped. diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs index f8226eb21d..d51c9b3f88 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs @@ -9,7 +9,6 @@ using osu.Framework.Allocation; using osu.Framework.Audio.Track; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Game.Configuration; using osu.Game.Graphics.Containers; @@ -44,8 +43,8 @@ namespace osu.Game.Tests.Visual.Gameplay private readonly IGameplayClock gameplayClock = new GameplayClockContainer(new TrackVirtual(60000), false, false); // best way to check without exposing. - private Drawable hideTarget => hudOverlay.ChildrenOfType().First(); - private Drawable keyCounterFlow => hudOverlay.ChildrenOfType().First().ChildrenOfType>().Single(); + private Drawable hideTarget => hudOverlay.ChildrenOfType().First(); + private Drawable keyCounterContent => hudOverlay.ChildrenOfType().First().ChildrenOfType().Skip(1).First(); public TestSceneHUDOverlay() { @@ -79,7 +78,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("showhud is set", () => hudOverlay.ShowHud.Value); AddAssert("hidetarget is visible", () => hideTarget.Alpha, () => Is.GreaterThan(0)); - AddAssert("key counter flow is visible", () => keyCounterFlow.IsPresent); + AddAssert("key counter flow is visible", () => keyCounterContent.IsPresent); AddAssert("pause button is visible", () => hudOverlay.HoldToQuit.IsPresent); } @@ -104,7 +103,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("pause button is still visible", () => hudOverlay.HoldToQuit.IsPresent); // Key counter flow container should not be affected by this, only the key counter display will be hidden as checked above. - AddAssert("key counter flow not affected", () => keyCounterFlow.IsPresent); + AddAssert("key counter flow not affected", () => keyCounterContent.IsPresent); } [Test] @@ -150,11 +149,11 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("set showhud false", () => hudOverlay.ShowHud.Value = false); AddUntilStep("hidetarget is hidden", () => hideTarget.Alpha, () => Is.LessThanOrEqualTo(0)); - AddUntilStep("key counters hidden", () => !keyCounterFlow.IsPresent); + AddUntilStep("key counters hidden", () => !keyCounterContent.IsPresent); AddStep("set showhud true", () => hudOverlay.ShowHud.Value = true); AddUntilStep("hidetarget is visible", () => hideTarget.Alpha, () => Is.GreaterThan(0)); - AddUntilStep("key counters still hidden", () => !keyCounterFlow.IsPresent); + AddUntilStep("key counters still hidden", () => !keyCounterContent.IsPresent); } [Test] @@ -175,6 +174,7 @@ namespace osu.Game.Tests.Visual.Gameplay holdForMenu.Action += () => activated = true; }); + AddStep("set hold button always visible", () => localConfig.SetValue(OsuSetting.AlwaysShowHoldForMenuButton, true)); AddStep("set showhud false", () => hudOverlay.ShowHud.Value = false); AddUntilStep("hidetarget is hidden", () => hideTarget.Alpha, () => Is.LessThanOrEqualTo(0)); @@ -215,6 +215,7 @@ namespace osu.Game.Tests.Visual.Gameplay progress.ChildrenOfType().Single().OnSeek += _ => seeked = true; }); + AddStep("set hold button always visible", () => localConfig.SetValue(OsuSetting.AlwaysShowHoldForMenuButton, true)); AddStep("set showhud false", () => hudOverlay.ShowHud.Value = false); AddUntilStep("hidetarget is hidden", () => hideTarget.Alpha, () => Is.LessThanOrEqualTo(0)); @@ -241,8 +242,8 @@ namespace osu.Game.Tests.Visual.Gameplay createNew(); - AddUntilStep("wait for components to be hidden", () => hudOverlay.ChildrenOfType().Single().Alpha == 0); - AddUntilStep("wait for hud load", () => hudOverlay.ChildrenOfType().All(c => c.ComponentsLoaded)); + AddUntilStep("wait for components to be hidden", () => hudOverlay.ChildrenOfType().Single().Alpha == 0); + AddUntilStep("wait for hud load", () => hudOverlay.ChildrenOfType().All(c => c.ComponentsLoaded)); AddStep("bind on update", () => { @@ -259,10 +260,10 @@ namespace osu.Game.Tests.Visual.Gameplay createNew(); - AddUntilStep("wait for components to be hidden", () => hudOverlay.ChildrenOfType().Single().Alpha == 0); + AddUntilStep("wait for components to be hidden", () => hudOverlay.ChildrenOfType().Single().Alpha == 0); - AddStep("reload components", () => hudOverlay.ChildrenOfType().Single().Reload()); - AddUntilStep("skinnable components loaded", () => hudOverlay.ChildrenOfType().Single().ComponentsLoaded); + AddStep("reload components", () => hudOverlay.ChildrenOfType().Single().Reload()); + AddUntilStep("skinnable components loaded", () => hudOverlay.ChildrenOfType().Single().ComponentsLoaded); } private void createNew(Action? action = null) diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs index e57177498d..2e646f2850 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs @@ -284,6 +284,7 @@ namespace osu.Game.Tests.Visual.Gameplay public override IAdjustableAudioComponent Audio { get; } public override Playfield Playfield { get; } + public override PlayfieldAdjustmentContainer PlayfieldAdjustmentContainer { get; } public override Container Overlays { get; } public override Container FrameStableComponents { get; } public override IFrameStableClock FrameStableClock { get; } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHoldForMenuButton.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHoldForMenuButton.cs index 3c225d60e0..cd1334165b 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneHoldForMenuButton.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHoldForMenuButton.cs @@ -1,13 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Testing; +using osu.Game.Configuration; using osu.Game.Screens.Play.HUD; using osuTK; using osuTK.Input; @@ -21,11 +21,19 @@ namespace osu.Game.Tests.Visual.Gameplay protected override double TimePerAction => 100; // required for the early exit test, since hold-to-confirm delay is 200ms - private HoldForMenuButton holdForMenuButton; + private HoldForMenuButton holdForMenuButton = null!; + + [Resolved] + private OsuConfigManager config { get; set; } = null!; [SetUpSteps] public void SetUpSteps() { + AddStep("set button always on", () => + { + config.SetValue(OsuSetting.AlwaysShowHoldForMenuButton, true); + }); + AddStep("create button", () => { exitAction = false; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneJudgementContainer.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneJudgementContainer.cs new file mode 100644 index 0000000000..0ba67c0bb0 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneJudgementContainer.cs @@ -0,0 +1,98 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Judgements; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public partial class TestSceneJudgementContainer : OsuTestScene + { + private JudgementContainer judgementContainer = null!; + + [SetUpSteps] + public void SetUp() + { + AddStep("create judgement container", () => Child = judgementContainer = new JudgementContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }); + } + + [Test] + public void TestJudgementFromSameHitObjectIsRemoved() + { + DrawableHitCircle drawableHitCircle1 = null!; + DrawableHitCircle drawableHitCircle2 = null!; + + AddStep("create hit circles", () => + { + Add(drawableHitCircle1 = new DrawableHitCircle(createHitCircle())); + Add(drawableHitCircle2 = new DrawableHitCircle(createHitCircle())); + }); + + int judgementCount = 0; + + AddStep("judge the same hitobject twice via different drawables", () => + { + addDrawableJudgement(drawableHitCircle1); + drawableHitCircle2.Apply(drawableHitCircle1.HitObject); + addDrawableJudgement(drawableHitCircle2); + judgementCount = judgementContainer.Count; + }); + + AddAssert("one judgement in container", () => judgementCount, () => Is.EqualTo(1)); + } + + [Test] + public void TestJudgementFromDifferentHitObjectIsNotRemoved() + { + DrawableHitCircle drawableHitCircle = null!; + + AddStep("create hit circle", () => Add(drawableHitCircle = new DrawableHitCircle(createHitCircle()))); + + int judgementCount = 0; + + AddStep("judge two hitobjects via the same drawable", () => + { + addDrawableJudgement(drawableHitCircle); + drawableHitCircle.Apply(createHitCircle()); + addDrawableJudgement(drawableHitCircle); + judgementCount = judgementContainer.Count; + }); + + AddAssert("two judgements in container", () => judgementCount, () => Is.EqualTo(2)); + } + + private void addDrawableJudgement(DrawableHitObject drawableHitObject) + { + var judgement = new DrawableOsuJudgement(); + + judgement.Apply(new JudgementResult(drawableHitObject.HitObject, new OsuJudgement()) + { + Type = HitResult.Great, + TimeOffset = Time.Current + }, drawableHitObject); + + judgementContainer.Add(judgement); + } + + private HitCircle createHitCircle() + { + var circle = new HitCircle(); + circle.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + return circle; + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneKeyCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneKeyCounter.cs index 2d2b6c3bed..57bfb5fddf 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneKeyCounter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneKeyCounter.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Utils; using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; +using osu.Game.Skinning; using osuTK; using osuTK.Input; @@ -56,6 +57,11 @@ namespace osu.Game.Tests.Visual.Gameplay Anchor = Anchor.Centre, Scale = new Vector2(1, -1) }, + new LegacyKeyCounterDisplay + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + }, new FillFlowContainer { AutoSizeAxes = Axes.Both, @@ -89,6 +95,12 @@ namespace osu.Game.Tests.Visual.Gameplay Anchor = Anchor.Centre, Rotation = 90, }, + new LegacyKeyCounterDisplay + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Rotation = 90, + }, } }, } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs index dadf3ca65f..5af7540f6f 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs @@ -27,6 +27,7 @@ namespace osu.Game.Tests.Visual.Gameplay [TestCase(2000, 0)] [TestCase(3000, first_hit_object - 3000)] [TestCase(10000, first_hit_object - 10000)] + [FlakyTest] public void TestLeadInProducesCorrectStartTime(double leadIn, double expectedStartTime) { loadPlayerWithBeatmap(new TestBeatmap(new OsuRuleset().RulesetInfo) @@ -41,13 +42,14 @@ namespace osu.Game.Tests.Visual.Gameplay [TestCase(0, 0)] [TestCase(-1000, -1000)] [TestCase(-10000, -10000)] + [FlakyTest] public void TestStoryboardProducesCorrectStartTimeSimpleAlpha(double firstStoryboardEvent, double expectedStartTime) { var storyboard = new Storyboard(); var sprite = new StoryboardSprite("unknown", Anchor.TopLeft, Vector2.Zero); - sprite.TimelineGroup.Alpha.Add(Easing.None, firstStoryboardEvent, firstStoryboardEvent + 500, 0, 1); + sprite.Commands.AddAlpha(Easing.None, firstStoryboardEvent, firstStoryboardEvent + 500, 0, 1); storyboard.GetLayer("Background").Add(sprite); @@ -64,6 +66,7 @@ namespace osu.Game.Tests.Visual.Gameplay [TestCase(0, 0, true)] [TestCase(-1000, -1000, true)] [TestCase(-10000, -10000, true)] + [FlakyTest] public void TestStoryboardProducesCorrectStartTimeFadeInAfterOtherEvents(double firstStoryboardEvent, double expectedStartTime, bool addEventToLoop) { const double loop_start_time = -20000; @@ -73,17 +76,17 @@ namespace osu.Game.Tests.Visual.Gameplay var sprite = new StoryboardSprite("unknown", Anchor.TopLeft, Vector2.Zero); // these should be ignored as we have an alpha visibility blocker proceeding this command. - sprite.TimelineGroup.Scale.Add(Easing.None, loop_start_time, -18000, 0, 1); - var loopGroup = sprite.AddLoop(loop_start_time, 50); - loopGroup.Scale.Add(Easing.None, loop_start_time, -18000, 0, 1); + sprite.Commands.AddScale(Easing.None, loop_start_time, -18000, 0, 1); + var loopGroup = sprite.AddLoopingGroup(loop_start_time, 50); + loopGroup.AddScale(Easing.None, loop_start_time, -18000, 0, 1); - var target = addEventToLoop ? loopGroup : sprite.TimelineGroup; + var target = addEventToLoop ? loopGroup : sprite.Commands; double loopRelativeOffset = addEventToLoop ? -loop_start_time : 0; - target.Alpha.Add(Easing.None, loopRelativeOffset + firstStoryboardEvent, loopRelativeOffset + firstStoryboardEvent + 500, 0, 1); + target.AddAlpha(Easing.None, loopRelativeOffset + firstStoryboardEvent, loopRelativeOffset + firstStoryboardEvent + 500, 0, 1); // these should be ignored due to being in the future. - sprite.TimelineGroup.Alpha.Add(Easing.None, 18000, 20000, 0, 1); - loopGroup.Alpha.Add(Easing.None, 38000, 40000, 0, 1); + sprite.Commands.AddAlpha(Easing.None, 18000, 20000, 0, 1); + loopGroup.AddAlpha(Easing.None, 38000, 40000, 0, 1); storyboard.GetLayer("Background").Add(sprite); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneOverlayActivation.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneOverlayActivation.cs index 269d104fa3..751405b1d6 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneOverlayActivation.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneOverlayActivation.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System.Linq; using NUnit.Framework; using osu.Game.Overlays; @@ -12,7 +10,7 @@ namespace osu.Game.Tests.Visual.Gameplay { public partial class TestSceneOverlayActivation : OsuPlayerTestScene { - protected new OverlayTestPlayer Player => base.Player as OverlayTestPlayer; + protected new OverlayTestPlayer Player => (OverlayTestPlayer)base.Player; public override void SetUpSteps() { diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs index 030f2592ed..6aa2c4e40d 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs @@ -320,6 +320,8 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestExitViaHoldToExit() { + AddStep("set hold button always visible", () => LocalConfig.SetValue(OsuSetting.AlwaysShowHoldForMenuButton, true)); + AddStep("exit", () => { InputManager.MoveMouseTo(Player.HUDOverlay.HoldToQuit.First(c => c is HoldToConfirmContainer)); diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePauseInputHandling.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePauseInputHandling.cs new file mode 100644 index 0000000000..2c0d7d6744 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePauseInputHandling.cs @@ -0,0 +1,388 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Framework.Timing; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Screens.Play.HUD; +using osu.Game.Skinning; +using osu.Game.Storyboards; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public partial class TestScenePauseInputHandling : PlayerTestScene + { + private Ruleset currentRuleset = new OsuRuleset(); + + protected override Ruleset CreatePlayerRuleset() => currentRuleset; + + protected override bool HasCustomSteps => true; + + [Resolved] + private AudioManager audioManager { get; set; } = null!; + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new Beatmap + { + HitObjects = + { + new HitCircle + { + Position = OsuPlayfield.BASE_SIZE / 2, + StartTime = 0, + }, + new HitCircle + { + Position = OsuPlayfield.BASE_SIZE / 2, + StartTime = 5000, + }, + new HitCircle + { + Position = OsuPlayfield.BASE_SIZE / 2, + StartTime = 10000, + }, + new HitCircle + { + Position = OsuPlayfield.BASE_SIZE / 2, + StartTime = 15000, + } + } + }; + + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard? storyboard = null) => + new ClockBackedTestWorkingBeatmap(beatmap, storyboard, new FramedClock(new ManualClock { Rate = 1 }), audioManager); + + [SetUp] + public void SetUp() => Schedule(() => + { + foreach (var key in InputManager.CurrentState.Keyboard.Keys) + InputManager.ReleaseKey(key); + + InputManager.MoveMouseTo(Content); + LocalConfig.SetValue(OsuSetting.KeyOverlay, true); + }); + + [Test] + public void TestOsuInputNotReceivedWhilePaused() + { + KeyCounter counter = null!; + + loadPlayer(() => new OsuRuleset()); + AddStep("get key counter", () => counter = this.ChildrenOfType().Single(k => k.Trigger is KeyCounterActionTrigger actionTrigger && actionTrigger.Action == OsuAction.LeftButton)); + checkKey(() => counter, 0, false); + + AddStep("press Z", () => InputManager.PressKey(Key.Z)); + checkKey(() => counter, 1, true); + + AddStep("release Z", () => InputManager.ReleaseKey(Key.Z)); + checkKey(() => counter, 1, false); + + AddStep("pause", () => Player.Pause()); + AddStep("press Z", () => InputManager.PressKey(Key.Z)); + checkKey(() => counter, 1, false); + + AddStep("release Z", () => InputManager.ReleaseKey(Key.Z)); + checkKey(() => counter, 1, false); + + AddStep("resume", () => Player.Resume()); + AddStep("go to resume cursor", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); + AddStep("press Z to resume", () => InputManager.PressKey(Key.Z)); + checkKey(() => counter, 2, true); + + AddStep("release Z", () => InputManager.ReleaseKey(Key.Z)); + checkKey(() => counter, 2, false); + + AddStep("press Z", () => InputManager.PressKey(Key.Z)); + checkKey(() => counter, 3, true); + + AddStep("release Z", () => InputManager.ReleaseKey(Key.Z)); + checkKey(() => counter, 3, false); + } + + [Test] + public void TestManiaInputNotReceivedWhilePaused() + { + KeyCounter counter = null!; + + loadPlayer(() => new ManiaRuleset()); + AddStep("get key counter", () => counter = this.ChildrenOfType().Single(k => k.Trigger is KeyCounterActionTrigger actionTrigger && actionTrigger.Action == ManiaAction.Key4)); + checkKey(() => counter, 0, false); + + AddStep("press space", () => InputManager.PressKey(Key.Space)); + checkKey(() => counter, 1, true); + + AddStep("release space", () => InputManager.ReleaseKey(Key.Space)); + checkKey(() => counter, 1, false); + + AddStep("pause", () => Player.Pause()); + AddStep("press space", () => InputManager.PressKey(Key.Space)); + checkKey(() => counter, 1, false); + + AddStep("release space", () => InputManager.ReleaseKey(Key.Space)); + checkKey(() => counter, 1, false); + + AddStep("resume", () => Player.Resume()); + AddUntilStep("wait for resume", () => Player.GameplayClockContainer.IsRunning); + + AddStep("press space", () => InputManager.PressKey(Key.Space)); + checkKey(() => counter, 2, true); + + AddStep("release space", () => InputManager.ReleaseKey(Key.Space)); + checkKey(() => counter, 2, false); + } + + [Test] + public void TestOsuPreviouslyHeldInputReleaseOnResume() + { + KeyCounter counterZ = null!; + KeyCounter counterX = null!; + + loadPlayer(() => new OsuRuleset()); + AddStep("get key counter Z", () => counterZ = this.ChildrenOfType().Single(k => k.Trigger is KeyCounterActionTrigger actionTrigger && actionTrigger.Action == OsuAction.LeftButton)); + AddStep("get key counter X", () => counterX = this.ChildrenOfType().Single(k => k.Trigger is KeyCounterActionTrigger actionTrigger && actionTrigger.Action == OsuAction.RightButton)); + + AddStep("press Z", () => InputManager.PressKey(Key.Z)); + AddStep("pause", () => Player.Pause()); + + AddStep("release Z", () => InputManager.ReleaseKey(Key.Z)); + + AddStep("resume", () => Player.Resume()); + AddStep("go to resume cursor", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); + AddStep("press and release Z", () => InputManager.Key(Key.Z)); + checkKey(() => counterZ, 1, false); + + AddStep("press X", () => InputManager.PressKey(Key.X)); + AddStep("pause", () => Player.Pause()); + AddStep("release X", () => InputManager.ReleaseKey(Key.X)); + checkKey(() => counterX, 1, true); + + AddStep("resume", () => Player.Resume()); + AddStep("go to resume cursor", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); + AddStep("press Z to resume", () => InputManager.PressKey(Key.Z)); + checkKey(() => counterZ, 2, true); + checkKey(() => counterX, 1, false); + + AddStep("release Z", () => InputManager.ReleaseKey(Key.Z)); + checkKey(() => counterZ, 2, false); + } + + [Test] + public void TestManiaPreviouslyHeldInputReleaseOnResume() + { + KeyCounter counter = null!; + + loadPlayer(() => new ManiaRuleset()); + AddStep("get key counter", () => counter = this.ChildrenOfType().Single(k => k.Trigger is KeyCounterActionTrigger actionTrigger && actionTrigger.Action == ManiaAction.Key4)); + + AddStep("press space", () => InputManager.PressKey(Key.Space)); + AddStep("pause", () => Player.Pause()); + + AddStep("release space", () => InputManager.ReleaseKey(Key.Space)); + checkKey(() => counter, 1, true); + + AddStep("resume", () => Player.Resume()); + AddUntilStep("wait for resume", () => Player.GameplayClockContainer.IsRunning); + checkKey(() => counter, 1, false); + } + + [Test] + public void TestOsuHeldInputRemainHeldAfterResume() + { + KeyCounter counterZ = null!; + KeyCounter counterX = null!; + + loadPlayer(() => new OsuRuleset()); + AddStep("get key counter Z", () => counterZ = this.ChildrenOfType().Single(k => k.Trigger is KeyCounterActionTrigger actionTrigger && actionTrigger.Action == OsuAction.LeftButton)); + AddStep("get key counter X", () => counterX = this.ChildrenOfType().Single(k => k.Trigger is KeyCounterActionTrigger actionTrigger && actionTrigger.Action == OsuAction.RightButton)); + + AddStep("press Z", () => InputManager.PressKey(Key.Z)); + AddStep("pause", () => Player.Pause()); + + AddStep("release Z", () => InputManager.ReleaseKey(Key.Z)); + + AddStep("resume", () => Player.Resume()); + AddStep("go to resume cursor", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); + AddStep("press Z to resume", () => InputManager.PressKey(Key.Z)); + checkKey(() => counterZ, 1, true); + + AddStep("release Z", () => InputManager.ReleaseKey(Key.Z)); + checkKey(() => counterZ, 1, false); + + AddStep("press X", () => InputManager.PressKey(Key.X)); + checkKey(() => counterX, 1, true); + + AddStep("pause", () => Player.Pause()); + + AddStep("release X", () => InputManager.ReleaseKey(Key.X)); + AddStep("press X", () => InputManager.PressKey(Key.X)); + + AddStep("resume", () => Player.Resume()); + AddStep("go to resume cursor", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); + AddStep("press Z to resume", () => InputManager.PressKey(Key.Z)); + checkKey(() => counterZ, 2, true); + checkKey(() => counterX, 1, true); + + AddStep("release X", () => InputManager.ReleaseKey(Key.X)); + checkKey(() => counterX, 1, false); + + AddStep("release Z", () => InputManager.ReleaseKey(Key.Z)); + checkKey(() => counterZ, 2, false); + } + + [Test] + public void TestManiaHeldInputRemainHeldAfterResume() + { + KeyCounter counter = null!; + + loadPlayer(() => new ManiaRuleset()); + AddStep("get key counter", () => counter = this.ChildrenOfType().Single(k => k.Trigger is KeyCounterActionTrigger actionTrigger && actionTrigger.Action == ManiaAction.Key4)); + + AddStep("press space", () => InputManager.PressKey(Key.Space)); + checkKey(() => counter, 1, true); + + AddStep("pause", () => Player.Pause()); + + AddStep("release space", () => InputManager.ReleaseKey(Key.Space)); + AddStep("press space", () => InputManager.PressKey(Key.Space)); + + AddStep("resume", () => Player.Resume()); + AddUntilStep("wait for resume", () => Player.GameplayClockContainer.IsRunning); + checkKey(() => counter, 1, true); + + AddStep("release space", () => InputManager.ReleaseKey(Key.Space)); + checkKey(() => counter, 1, false); + } + + [Test] + public void TestOsuHitCircleNotReceivingInputOnResume() + { + KeyCounter counter = null!; + + loadPlayer(() => new OsuRuleset()); + AddStep("get key counter", () => counter = this.ChildrenOfType().Single(k => k.Trigger is KeyCounterActionTrigger actionTrigger && actionTrigger.Action == OsuAction.LeftButton)); + + AddStep("pause", () => Player.Pause()); + AddStep("resume", () => Player.Resume()); + AddStep("go to resume cursor", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); + AddStep("press Z to resume", () => InputManager.PressKey(Key.Z)); + + // ensure the input manager receives the Z button press... + checkKey(() => counter, 1, true); + AddAssert("button is pressed in kbc", () => Player.DrawableRuleset.Playfield.FindClosestParent()!.PressedActions.Single() == OsuAction.LeftButton); + + // ...but also ensure the hit circle in front of the cursor isn't hit by checking max combo. + AddAssert("circle not hit", () => Player.ScoreProcessor.HighestCombo.Value, () => Is.EqualTo(0)); + + AddStep("release Z", () => InputManager.ReleaseKey(Key.Z)); + + checkKey(() => counter, 1, false); + AddAssert("button is released in kbc", () => !Player.DrawableRuleset.Playfield.FindClosestParent()!.PressedActions.Any()); + } + + [Test] + public void TestOsuHitCircleNotReceivingInputOnResume_PauseWhileHoldingSameKey() + { + KeyCounter counter = null!; + + loadPlayer(() => new OsuRuleset()); + AddStep("get key counter", () => counter = this.ChildrenOfType().Single(k => k.Trigger is KeyCounterActionTrigger actionTrigger && actionTrigger.Action == OsuAction.LeftButton)); + + AddStep("press Z", () => InputManager.PressKey(Key.Z)); + AddAssert("circle hit", () => Player.ScoreProcessor.HighestCombo.Value, () => Is.EqualTo(1)); + + AddStep("pause", () => Player.Pause()); + AddStep("release Z", () => InputManager.ReleaseKey(Key.Z)); + + AddStep("resume", () => Player.Resume()); + AddStep("go to resume cursor", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); + AddStep("press Z to resume", () => InputManager.PressKey(Key.Z)); + AddStep("release Z", () => InputManager.ReleaseKey(Key.Z)); + + checkKey(() => counter, 1, false); + + seekTo(5000); + + AddStep("press Z", () => InputManager.PressKey(Key.Z)); + + checkKey(() => counter, 2, true); + AddAssert("circle hit", () => Player.ScoreProcessor.HighestCombo.Value, () => Is.EqualTo(2)); + + AddStep("release Z", () => InputManager.ReleaseKey(Key.Z)); + checkKey(() => counter, 2, false); + } + + [Test] + public void TestOsuHitCircleNotReceivingInputOnResume_PauseWhileHoldingOtherKey() + { + loadPlayer(() => new OsuRuleset()); + + AddStep("press X", () => InputManager.PressKey(Key.X)); + AddAssert("circle hit", () => Player.ScoreProcessor.HighestCombo.Value, () => Is.EqualTo(1)); + + seekTo(5000); + + AddStep("pause", () => Player.Pause()); + AddStep("release X", () => InputManager.ReleaseKey(Key.X)); + + AddStep("resume", () => Player.Resume()); + AddStep("go to resume cursor", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); + AddStep("press Z to resume", () => InputManager.PressKey(Key.Z)); + AddStep("release Z", () => InputManager.ReleaseKey(Key.Z)); + + AddAssert("circle not hit", () => Player.ScoreProcessor.HighestCombo.Value, () => Is.EqualTo(1)); + + AddStep("press X", () => InputManager.PressKey(Key.X)); + AddStep("release X", () => InputManager.ReleaseKey(Key.X)); + + AddAssert("circle hit", () => Player.ScoreProcessor.HighestCombo.Value, () => Is.EqualTo(2)); + } + + private void loadPlayer(Func createRuleset) + { + AddStep("set ruleset", () => currentRuleset = createRuleset()); + AddStep("load player", LoadPlayer); + AddUntilStep("player loaded", () => Player.IsLoaded && Player.Alpha == 1); + AddUntilStep("wait for hud", () => Player.HUDOverlay.ChildrenOfType().All(s => s.ComponentsLoaded)); + + seekTo(0); + AddAssert("not in break", () => !Player.IsBreakTime.Value); + AddStep("move cursor to center", () => InputManager.MoveMouseTo(Player.DrawableRuleset.Playfield)); + } + + private void seekTo(double time) + { + AddStep($"seek to {time}ms", () => Player.GameplayClockContainer.Seek(time)); + AddUntilStep("wait for seek to finish", () => Player.DrawableRuleset.FrameStableClock.CurrentTime, () => Is.EqualTo(time).Within(500)); + } + + private void checkKey(Func counter, int count, bool active) + { + AddAssert($"key count = {count}", () => counter().CountPresses.Value, () => Is.EqualTo(count)); + AddAssert($"key active = {active}", () => counter().IsActive.Value, () => Is.EqualTo(active)); + } + + protected override TestPlayer CreatePlayer(Ruleset ruleset) => new PausePlayer(); + + private partial class PausePlayer : TestPlayer + { + protected override double PauseCooldownDuration => 0; + + public PausePlayer() + : base(allowPause: true, showResults: false) + { + } + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs index 1949808dfe..cf813cfd51 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs @@ -523,7 +523,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("restart completed", () => getCurrentPlayer() != null && getCurrentPlayer() != previousPlayer); AddStep("release quick retry key", () => InputManager.ReleaseKey(Key.Tilde)); - AddUntilStep("wait for player", () => getCurrentPlayer()?.LoadState == LoadState.Ready); + AddUntilStep("wait for player", () => getCurrentPlayer()?.LoadState >= LoadState.Ready); AddUntilStep("time reached zero", () => getCurrentPlayer()?.GameplayClockContainer.CurrentTime > 0); AddUntilStep("skip button not visible", () => !checkSkipButtonVisible()); diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs index 5e22e47572..c382f0828b 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs @@ -8,7 +8,9 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using NUnit.Framework; +using osu.Framework.Graphics.Containers; using osu.Framework.Screens; +using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Online.API; using osu.Game.Online.Rooms; @@ -26,6 +28,7 @@ using osu.Game.Scoring; using osu.Game.Screens.Play; using osu.Game.Screens.Ranking; using osu.Game.Tests.Beatmaps; +using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { @@ -177,6 +180,30 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("ensure no submission", () => Player.SubmittedScore == null); } + [Test] + public void TestEmptyFailStillImports() + { + prepareTestAPI(true); + + createPlayerTest(true); + + AddUntilStep("wait for token request", () => Player.TokenCreationRequested); + + AddUntilStep("wait for fail", () => Player.GameplayState.HasFailed); + AddUntilStep("wait for fail overlay", () => Player.FailOverlay.State.Value, () => Is.EqualTo(Visibility.Visible)); + + AddStep("attempt import", () => + { + InputManager.MoveMouseTo(Player.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + AddUntilStep("wait for import to start", () => Player.ScoreImportStarted); + AddStep("allow import", () => Player.AllowImportCompletion.Release()); + + AddUntilStep("import completed", () => Player.ImportedScore, () => Is.Not.Null); + AddAssert("ensure no submission", () => Player.SubmittedScore, () => Is.Null); + } + [Test] public void TestSubmissionOnFail() { @@ -378,6 +405,8 @@ namespace osu.Game.Tests.Visual.Gameplay public SemaphoreSlim AllowImportCompletion { get; } public Score ImportedScore { get; private set; } + public new FailOverlay FailOverlay => base.FailOverlay; + public FakeImportingPlayer(bool allowPause = true, bool showResults = true, bool pauseOnFocusLost = false) : base(allowPause, showResults, pauseOnFocusLost) { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs index 5b32f380b9..061e8ea7e1 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs @@ -117,6 +117,9 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("state entered downloading", () => downloadStarted); AddUntilStep("state left downloading", () => downloadFinished); + + AddStep("change score to null", () => downloadButton.Score.Value = null); + AddUntilStep("state changed to unknown", () => downloadButton.State.Value, () => Is.EqualTo(DownloadState.Unknown)); } [Test] diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs index 3c97700fb0..91188f5bac 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs @@ -7,21 +7,25 @@ using System.Linq; using System.Text; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Extensions; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input; using osu.Framework.Testing; +using osu.Game.Database; using osu.Game.Overlays; using osu.Game.Overlays.Settings; using osu.Game.Overlays.SkinEditor; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; using osu.Game.Screens.Edit; +using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.HUD.HitErrorMeters; using osu.Game.Skinning; using osu.Game.Skinning.Components; +using osu.Game.Tests.Resources; using osuTK; using osuTK.Input; @@ -39,13 +43,17 @@ namespace osu.Game.Tests.Visual.Gameplay [Cached] public readonly EditorClipboard Clipboard = new EditorClipboard(); - private SkinComponentsContainer targetContainer => Player.ChildrenOfType().First(); + [Resolved] + private SkinManager skins { get; set; } = null!; + + private SkinnableContainer targetContainer => Player.ChildrenOfType().First(); [SetUpSteps] public override void SetUpSteps() { base.SetUpSteps(); + AddStep("reset skin", () => skins.CurrentSkinInfo.SetDefault()); AddUntilStep("wait for hud load", () => targetContainer.ComponentsLoaded); AddStep("reload skin editor", () => @@ -67,7 +75,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("Add big black boxes", () => { - var target = Player.ChildrenOfType().First(); + var target = Player.ChildrenOfType().First(); target.Add(box1 = new BigBlackBox { Position = new Vector2(-90), @@ -192,14 +200,14 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestUndoEditHistory() { - SkinComponentsContainer firstTarget = null!; + SkinnableContainer firstTarget = null!; TestSkinEditorChangeHandler changeHandler = null!; byte[] defaultState = null!; IEnumerable testComponents = null!; AddStep("Load necessary things", () => { - firstTarget = Player.ChildrenOfType().First(); + firstTarget = Player.ChildrenOfType().First(); changeHandler = new TestSkinEditorChangeHandler(firstTarget); changeHandler.SaveState(); @@ -369,6 +377,93 @@ namespace osu.Game.Tests.Visual.Gameplay () => Is.EqualTo(3)); } + private SkinnableContainer globalHUDTarget => Player.ChildrenOfType() + .Single(c => c.Lookup.Lookup == GlobalSkinnableContainers.MainHUDComponents && c.Lookup.Ruleset == null); + + private SkinnableContainer rulesetHUDTarget => Player.ChildrenOfType() + .Single(c => c.Lookup.Lookup == GlobalSkinnableContainers.MainHUDComponents && c.Lookup.Ruleset != null); + + [Test] + public void TestMigrationArgon() + { + Live importedSkin = null!; + + AddStep("import old argon skin", () => skins.CurrentSkinInfo.Value = importedSkin = importSkinFromArchives(@"argon-layout-version-0.osk").SkinInfo); + AddUntilStep("wait for load", () => globalHUDTarget.ComponentsLoaded && rulesetHUDTarget.ComponentsLoaded); + AddAssert("no combo in global target", () => !globalHUDTarget.Components.OfType().Any()); + AddAssert("combo placed in ruleset target", () => rulesetHUDTarget.Components.OfType().Count() == 1); + + AddStep("add combo to global target", () => globalHUDTarget.Add(new ArgonComboCounter + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(2f), + })); + AddStep("save skin", () => skins.Save(skins.CurrentSkin.Value)); + + AddStep("select another skin", () => skins.CurrentSkinInfo.SetDefault()); + AddStep("select skin again", () => skins.CurrentSkinInfo.Value = importedSkin); + AddUntilStep("wait for load", () => globalHUDTarget.ComponentsLoaded && rulesetHUDTarget.ComponentsLoaded); + AddAssert("combo placed in global target", () => globalHUDTarget.Components.OfType().Count() == 1); + AddAssert("combo placed in ruleset target", () => rulesetHUDTarget.Components.OfType().Count() == 1); + } + + [Test] + public void TestMigrationTriangles() + { + Live importedSkin = null!; + + AddStep("import old triangles skin", () => skins.CurrentSkinInfo.Value = importedSkin = importSkinFromArchives(@"triangles-layout-version-0.osk").SkinInfo); + AddUntilStep("wait for load", () => globalHUDTarget.ComponentsLoaded && rulesetHUDTarget.ComponentsLoaded); + AddAssert("no combo in global target", () => !globalHUDTarget.Components.OfType().Any()); + AddAssert("combo placed in ruleset target", () => rulesetHUDTarget.Components.OfType().Count() == 1); + + AddStep("add combo to global target", () => globalHUDTarget.Add(new DefaultComboCounter + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(2f), + })); + AddStep("save skin", () => skins.Save(skins.CurrentSkin.Value)); + + AddStep("select another skin", () => skins.CurrentSkinInfo.SetDefault()); + AddStep("select skin again", () => skins.CurrentSkinInfo.Value = importedSkin); + AddUntilStep("wait for load", () => globalHUDTarget.ComponentsLoaded && rulesetHUDTarget.ComponentsLoaded); + AddAssert("combo placed in global target", () => globalHUDTarget.Components.OfType().Count() == 1); + AddAssert("combo placed in ruleset target", () => rulesetHUDTarget.Components.OfType().Count() == 1); + } + + [Test] + public void TestMigrationLegacy() + { + Live importedSkin = null!; + + AddStep("import old classic skin", () => skins.CurrentSkinInfo.Value = importedSkin = importSkinFromArchives(@"classic-layout-version-0.osk").SkinInfo); + AddUntilStep("wait for load", () => globalHUDTarget.ComponentsLoaded && rulesetHUDTarget.ComponentsLoaded); + AddAssert("no combo in global target", () => !globalHUDTarget.Components.OfType().Any()); + AddAssert("combo placed in ruleset target", () => rulesetHUDTarget.Components.OfType().Count() == 1); + + AddStep("add combo to global target", () => globalHUDTarget.Add(new LegacyDefaultComboCounter + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(2f), + })); + AddStep("save skin", () => skins.Save(skins.CurrentSkin.Value)); + + AddStep("select another skin", () => skins.CurrentSkinInfo.SetDefault()); + AddStep("select skin again", () => skins.CurrentSkinInfo.Value = importedSkin); + AddUntilStep("wait for load", () => globalHUDTarget.ComponentsLoaded && rulesetHUDTarget.ComponentsLoaded); + AddAssert("combo placed in global target", () => globalHUDTarget.Components.OfType().Count() == 1); + AddAssert("combo placed in ruleset target", () => rulesetHUDTarget.Components.OfType().Count() == 1); + } + + private Skin importSkinFromArchives(string filename) + { + var imported = skins.Import(new ImportTask(TestResources.OpenResource($@"Archives/{filename}"), filename)).GetResultSafely(); + return imported.PerformRead(skinInfo => skins.GetSkin(skinInfo)); + } + protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); private partial class TestSkinEditorChangeHandler : SkinEditorChangeHandler diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorComponentsList.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorComponentsList.cs index b7b2a6c175..b5fe6633b6 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorComponentsList.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorComponentsList.cs @@ -20,7 +20,7 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestToggleEditor() { - var skinComponentsContainer = new SkinComponentsContainer(new SkinComponentsContainerLookup(SkinComponentsContainerLookup.TargetArea.SongSelect)); + var skinComponentsContainer = new SkinnableContainer(new GlobalSkinnableContainerLookup(GlobalSkinnableContainers.SongSelect)); AddStep("show available components", () => SetContents(_ => new SkinComponentToolbox(skinComponentsContainer, null) { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableComboCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableComboCounter.cs index 72f40d9c6f..a6196a8ca0 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableComboCounter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableComboCounter.cs @@ -4,7 +4,6 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Testing; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play.HUD; @@ -19,7 +18,7 @@ namespace osu.Game.Tests.Visual.Gameplay protected override Drawable CreateArgonImplementation() => new ArgonComboCounter(); protected override Drawable CreateDefaultImplementation() => new DefaultComboCounter(); - protected override Drawable CreateLegacyImplementation() => new LegacyComboCounter(); + protected override Drawable CreateLegacyImplementation() => new LegacyDefaultComboCounter(); [Test] public void TestComboCounterIncrementing() @@ -28,17 +27,5 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("reset combo", () => scoreProcessor.Combo.Value = 0); } - - [Test] - public void TestLegacyComboCounterHiddenByRulesetImplementation() - { - AddToggleStep("toggle legacy hidden by ruleset", visible => - { - foreach (var legacyCounter in this.ChildrenOfType()) - legacyCounter.HiddenByRulesetImplementation = visible; - }); - - AddRepeatStep("increase combo", () => scoreProcessor.Combo.Value++, 10); - } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs index 4cb0d5c0ff..fcaa2996e1 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs @@ -16,12 +16,13 @@ using osu.Framework.Testing; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; using osu.Game.Skinning; using osu.Game.Tests.Gameplay; -using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { @@ -44,7 +45,7 @@ namespace osu.Game.Tests.Visual.Gameplay private IEnumerable hudOverlays => CreatedDrawables.OfType(); // best way to check without exposing. - private Drawable hideTarget => hudOverlay.ChildrenOfType().First(); + private Drawable hideTarget => hudOverlay.ChildrenOfType().First(); private Drawable keyCounterFlow => hudOverlay.ChildrenOfType().First().ChildrenOfType>().Single(); public TestSceneSkinnableHUDOverlay() @@ -91,10 +92,7 @@ namespace osu.Game.Tests.Visual.Gameplay { SetContents(_ => { - hudOverlay = new HUDOverlay(null, Array.Empty()); - - // Add any key just to display the key counter visually. - hudOverlay.InputCountController.Add(new KeyCounterKeyboardTrigger(Key.Space)); + hudOverlay = new HUDOverlay(new DrawableOsuRuleset(new OsuRuleset(), new OsuBeatmap()), Array.Empty()); action?.Invoke(hudOverlay); @@ -103,7 +101,7 @@ namespace osu.Game.Tests.Visual.Gameplay }); AddUntilStep("HUD overlay loaded", () => hudOverlay.IsAlive); AddUntilStep("components container loaded", - () => hudOverlay.ChildrenOfType().Any(scc => scc.ComponentsLoaded)); + () => hudOverlay.ChildrenOfType().Any(scc => scc.ComponentsLoaded)); } protected override Ruleset CreateRulesetForSkinProvider() => new OsuRuleset(); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableKeyCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableKeyCounter.cs new file mode 100644 index 0000000000..098f8e3246 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableKeyCounter.cs @@ -0,0 +1,42 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD; +using osu.Game.Skinning; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public partial class TestSceneSkinnableKeyCounter : SkinnableHUDComponentTestScene + { + [Cached] + private readonly InputCountController controller = new InputCountController(); + + public override void SetUpSteps() + { + AddStep("create dependencies", () => + { + Add(controller); + controller.Add(new KeyCounterKeyboardTrigger(Key.Z)); + controller.Add(new KeyCounterKeyboardTrigger(Key.X)); + controller.Add(new KeyCounterKeyboardTrigger(Key.C)); + controller.Add(new KeyCounterKeyboardTrigger(Key.V)); + + foreach (var trigger in controller.Triggers) + Add(trigger); + }); + base.SetUpSteps(); + } + + protected override Ruleset CreateRulesetForSkinProvider() => new OsuRuleset(); + + protected override Drawable CreateDefaultImplementation() => new ArgonKeyCounterDisplay(); + + protected override Drawable CreateLegacyImplementation() => new LegacyKeyCounterDisplay(); + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableRankDisplay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableRankDisplay.cs new file mode 100644 index 0000000000..d442e69c61 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableRankDisplay.cs @@ -0,0 +1,41 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Screens.Play.HUD; +using osu.Game.Skinning; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public partial class TestSceneSkinnableRankDisplay : SkinnableHUDComponentTestScene + { + [Cached] + private ScoreProcessor scoreProcessor = new ScoreProcessor(new OsuRuleset()); + + private Bindable rank => (Bindable)scoreProcessor.Rank; + + protected override Drawable CreateDefaultImplementation() => new DefaultRankDisplay(); + + protected override Drawable CreateLegacyImplementation() => new LegacyRankDisplay(); + + [Test] + public void TestChangingRank() + { + AddStep("Set rank to SS Hidden", () => rank.Value = ScoreRank.XH); + AddStep("Set rank to SS", () => rank.Value = ScoreRank.X); + AddStep("Set rank to S Hidden", () => rank.Value = ScoreRank.SH); + AddStep("Set rank to S", () => rank.Value = ScoreRank.S); + AddStep("Set rank to A", () => rank.Value = ScoreRank.A); + AddStep("Set rank to B", () => rank.Value = ScoreRank.B); + AddStep("Set rank to C", () => rank.Value = ScoreRank.C); + AddStep("Set rank to D", () => rank.Value = ScoreRank.D); + AddStep("Set rank to F", () => rank.Value = ScoreRank.F); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs index 0de2b6a980..d8817e563c 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs @@ -19,6 +19,7 @@ using osu.Game.Rulesets.UI; using osu.Game.Scoring; using osu.Game.Screens; using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD.JudgementCounter; using osu.Game.Tests.Beatmaps.IO; using osu.Game.Tests.Gameplay; using osu.Game.Tests.Visual.Multiplayer; @@ -167,14 +168,16 @@ namespace osu.Game.Tests.Visual.Gameplay public void TestSpectatingDuringGameplay() { start(); - sendFrames(300); + sendFrames(300, initialResultCount: 100); loadSpectatingScreen(); waitForPlayerCurrent(); - sendFrames(300); + sendFrames(300, initialResultCount: 100); AddUntilStep("playing from correct point in time", () => player.ChildrenOfType().First().FrameStableClock.CurrentTime, () => Is.GreaterThan(30000)); + AddAssert("check judgement counts are correct", () => player.ChildrenOfType().Single().Counters.Sum(c => c.ResultCount.Value), + () => Is.GreaterThanOrEqualTo(100)); } [Test] @@ -405,9 +408,9 @@ namespace osu.Game.Tests.Visual.Gameplay private void checkPaused(bool state) => AddUntilStep($"game is {(state ? "paused" : "playing")}", () => player.ChildrenOfType().First().IsPaused.Value == state); - private void sendFrames(int count = 10, double startTime = 0) + private void sendFrames(int count = 10, double startTime = 0, int initialResultCount = 0) { - AddStep("send frames", () => spectatorClient.SendFramesFromUser(streamingUser.Id, count, startTime)); + AddStep("send frames", () => spectatorClient.SendFramesFromUser(streamingUser.Id, count, startTime, initialResultCount)); } private void loadSpectatingScreen() diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs index 893b9f11f4..e918a93cbc 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs @@ -14,8 +14,11 @@ using osu.Game.Beatmaps; using osu.Game.Beatmaps.Formats; using osu.Game.IO; using osu.Game.Overlays; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Play; using osu.Game.Storyboards; using osu.Game.Storyboards.Drawables; +using osu.Game.Tests.Gameplay; using osu.Game.Tests.Resources; using osuTK.Graphics; @@ -28,14 +31,14 @@ namespace osu.Game.Tests.Visual.Gameplay private DrawableStoryboard? storyboard; + [Cached] + private GameplayState testGameplayState = TestGameplayState.Create(new OsuRuleset()); + [Test] public void TestStoryboard() { AddStep("Restart", restart); - AddToggleStep("Passing", passing => - { - if (storyboard != null) storyboard.Passing = passing; - }); + AddToggleStep("Toggle passing state", passing => testGameplayState.HealthProcessor.Health.Value = passing ? 1 : 0); } [Test] @@ -109,7 +112,6 @@ namespace osu.Game.Tests.Visual.Gameplay storyboardContainer.Clock = new FramedClock(Beatmap.Value.Track); storyboard = toLoad.CreateDrawable(SelectedMods.Value); - storyboard.Passing = false; storyboardContainer.Add(storyboard); } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardCommands.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardCommands.cs new file mode 100644 index 0000000000..4af3d23463 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardCommands.cs @@ -0,0 +1,264 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.IO.Stores; +using osu.Framework.Testing; +using osu.Framework.Timing; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Mods; +using osu.Game.Storyboards; +using osu.Game.Storyboards.Drawables; +using osu.Game.Tests.Resources; +using osuTK; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public partial class TestSceneStoryboardCommands : OsuTestScene + { + [Cached(typeof(Storyboard))] + private TestStoryboard storyboard { get; set; } = new TestStoryboard + { + UseSkinSprites = false, + AlwaysProvideTexture = true, + }; + + private readonly ManualClock manualClock = new ManualClock { Rate = 1, IsRunning = true }; + private int clockDirection; + + private const string lookup_name = "hitcircleoverlay"; + private const double clock_limit = 2500; + + protected override Container Content => content; + + private Container content = null!; + private SpriteText timelineText = null!; + private Box timelineMarker = null!; + + [BackgroundDependencyLoader] + private void load() + { + base.Content.Children = new Drawable[] + { + content = new Container + { + RelativeSizeAxes = Axes.Both, + }, + timelineText = new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Margin = new MarginPadding { Bottom = 60 }, + }, + timelineMarker = new Box + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomCentre, + RelativePositionAxes = Axes.X, + Size = new Vector2(2, 50), + }, + }; + } + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("start clock", () => clockDirection = 1); + AddStep("pause clock", () => clockDirection = 0); + AddStep("set clock = 0", () => manualClock.CurrentTime = 0); + } + + [Test] + public void TestNormalCommandPlayback() + { + AddStep("create storyboard", () => Child = createStoryboard(s => + { + s.Commands.AddY(Easing.OutBounce, 500, 900, 100, 240); + s.Commands.AddY(Easing.OutQuint, 1100, 1500, 240, 100); + })); + + assert(0, 100); + assert(500, 100); + assert(1000, 240); + assert(1500, 100); + assert(clock_limit, 100); + assert(1500, 100); + assert(1000, 240); + assert(500, 100); + assert(0, 100); + + void assert(double time, double y) + { + AddStep($"set clock = {time}", () => manualClock.CurrentTime = time); + AddAssert($"sprite y = {y} at t = {time}", () => this.ChildrenOfType().Single().Y == y); + } + } + + [Test] + public void TestLoopingCommandsPlayback() + { + AddStep("create storyboard", () => Child = createStoryboard(s => + { + var loop = s.AddLoopingGroup(250, 1); + loop.AddY(Easing.OutBounce, 0, 400, 100, 240); + loop.AddY(Easing.OutQuint, 600, 1000, 240, 100); + })); + + assert(0, 100); + assert(250, 100); + assert(850, 240); + assert(1250, 100); + assert(1850, 240); + assert(2250, 100); + assert(clock_limit, 100); + assert(2250, 100); + assert(1850, 240); + assert(1250, 100); + assert(850, 240); + assert(250, 100); + assert(0, 100); + + void assert(double time, double y) + { + AddStep($"set clock = {time}", () => manualClock.CurrentTime = time); + AddAssert($"sprite y = {y} at t = {time}", () => this.ChildrenOfType().Single().Y == y); + } + } + + [Test] + public void TestLoopManyTimes() + { + AddStep("create storyboard", () => Child = createStoryboard(s => + { + var loop = s.AddLoopingGroup(500, 10000); + loop.AddY(Easing.OutBounce, 0, 60, 100, 240); + loop.AddY(Easing.OutQuint, 80, 120, 240, 100); + })); + } + + [Test] + public void TestParameterTemporaryEffect() + { + AddStep("create storyboard", () => Child = createStoryboard(s => + { + s.Commands.AddFlipV(Easing.None, 1000, 1500, true, false); + })); + + AddAssert("sprite not flipped at t = 0", () => !this.ChildrenOfType().Single().FlipV); + + AddStep("set clock = 1250", () => manualClock.CurrentTime = 1250); + AddAssert("sprite flipped at t = 1250", () => this.ChildrenOfType().Single().FlipV); + + AddStep("set clock = 2000", () => manualClock.CurrentTime = 2000); + AddAssert("sprite not flipped at t = 2000", () => !this.ChildrenOfType().Single().FlipV); + + AddStep("resume clock", () => clockDirection = 1); + } + + [Test] + public void TestParameterPermanentEffect() + { + AddStep("create storyboard", () => Child = createStoryboard(s => + { + s.Commands.AddFlipV(Easing.None, 1000, 1000, true, true); + })); + + AddAssert("sprite flipped at t = 0", () => this.ChildrenOfType().Single().FlipV); + + AddStep("set clock = 1250", () => manualClock.CurrentTime = 1250); + AddAssert("sprite flipped at t = 1250", () => this.ChildrenOfType().Single().FlipV); + + AddStep("set clock = 2000", () => manualClock.CurrentTime = 2000); + AddAssert("sprite flipped at t = 2000", () => this.ChildrenOfType().Single().FlipV); + + AddStep("resume clock", () => clockDirection = 1); + } + + protected override void Update() + { + base.Update(); + + if (manualClock.CurrentTime > clock_limit || manualClock.CurrentTime < 0) + clockDirection = -clockDirection; + + manualClock.CurrentTime += Time.Elapsed * clockDirection; + timelineText.Text = $"Time: {manualClock.CurrentTime:0}ms"; + timelineMarker.X = (float)(manualClock.CurrentTime / clock_limit); + } + + private DrawableStoryboard createStoryboard(Action? addCommands = null) + { + var layer = storyboard.GetLayer("Background"); + + var sprite = new StoryboardSprite(lookup_name, Anchor.Centre, new Vector2(320, 240)); + sprite.Commands.AddScale(Easing.None, 0, clock_limit, 0.5f, 0.5f); + sprite.Commands.AddAlpha(Easing.None, 0, clock_limit, 1, 1); + addCommands?.Invoke(sprite); + + layer.Elements.Clear(); + layer.Add(sprite); + + return storyboard.CreateDrawable().With(c => c.Clock = new FramedClock(manualClock)); + } + + private partial class TestStoryboard : Storyboard + { + public override DrawableStoryboard CreateDrawable(IReadOnlyList? mods = null) + { + return new TestDrawableStoryboard(this, mods); + } + + public bool AlwaysProvideTexture { get; set; } + + public override string GetStoragePathFromStoryboardPath(string path) => AlwaysProvideTexture ? path : string.Empty; + + private partial class TestDrawableStoryboard : DrawableStoryboard + { + private readonly bool alwaysProvideTexture; + + public TestDrawableStoryboard(TestStoryboard storyboard, IReadOnlyList? mods) + : base(storyboard, mods) + { + alwaysProvideTexture = storyboard.AlwaysProvideTexture; + } + + protected override IResourceStore CreateResourceLookupStore() => alwaysProvideTexture + ? new AlwaysReturnsTextureStore() + : new ResourceStore(); + + internal class AlwaysReturnsTextureStore : IResourceStore + { + private const string test_image = "Resources/Textures/test-image.png"; + + private readonly DllResourceStore store; + + public AlwaysReturnsTextureStore() + { + store = TestResources.GetStore(); + } + + public void Dispose() => store.Dispose(); + + public byte[] Get(string name) => store.Get(test_image); + + public Task GetAsync(string name, CancellationToken cancellationToken = new CancellationToken()) => store.GetAsync(test_image, cancellationToken); + + public Stream GetStream(string name) => store.GetStream(test_image); + + public IEnumerable GetAvailableResources() => store.GetAvailableResources(); + } + } + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithIntro.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithIntro.cs index 502a0de616..ee6a6938a9 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithIntro.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithIntro.cs @@ -39,7 +39,7 @@ namespace osu.Game.Tests.Visual.Gameplay { var storyboard = new Storyboard(); var sprite = new StoryboardSprite("unknown", Anchor.TopLeft, Vector2.Zero); - sprite.TimelineGroup.Alpha.Add(Easing.None, startTime, 0, 0, 1); + sprite.Commands.AddAlpha(Easing.None, startTime, 0, 0, 1); storyboard.GetLayer("Background").Add(sprite); return storyboard; } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs index f532921d63..4f1a63341a 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs @@ -216,14 +216,14 @@ namespace osu.Game.Tests.Visual.Gameplay { var storyboard = new Storyboard(); var sprite = new StoryboardSprite("unknown", Anchor.TopLeft, Vector2.Zero); - sprite.TimelineGroup.Alpha.Add(Easing.None, 0, duration, 1, 0); + sprite.Commands.AddAlpha(Easing.None, 0, duration, 1, 0); storyboard.GetLayer("Background").Add(sprite); return storyboard; } protected partial class OutroPlayer : TestPlayer { - public void ExitViaPause() => PerformExit(true); + public void ExitViaPause() => PerformExitWithConfirmation(); public new FailOverlay FailOverlay => base.FailOverlay; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneUnorderedBreaks.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneUnorderedBreaks.cs new file mode 100644 index 0000000000..04265ccc03 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneUnorderedBreaks.cs @@ -0,0 +1,70 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Testing; +using osu.Framework.Timing; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Timing; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Play; +using osu.Game.Storyboards; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public partial class TestSceneUnorderedBreaks : OsuPlayerTestScene + { + [Resolved] + private AudioManager audioManager { get; set; } = null!; + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) + { + var beatmap = new OsuBeatmap(); + beatmap.HitObjects.Add(new HitCircle { StartTime = 0 }); + beatmap.HitObjects.Add(new HitCircle { StartTime = 5000 }); + beatmap.HitObjects.Add(new HitCircle { StartTime = 10000 }); + beatmap.Breaks.Add(new BreakPeriod(6000, 9000)); + beatmap.Breaks.Add(new BreakPeriod(1000, 4000)); + return beatmap; + } + + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard? storyboard = null) => + new ClockBackedTestWorkingBeatmap(beatmap, storyboard, new FramedClock(new ManualClock { Rate = 1 }), audioManager); + + [SetUpSteps] + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning); + } + + [Test] + public void TestBreakOverlayVisibility() + { + AddAssert("break overlay hidden", () => !this.ChildrenOfType().Single().Child.IsPresent); + addSeekStep(2000); + AddUntilStep("break overlay visible", () => this.ChildrenOfType().Single().Child.IsPresent); + addSeekStep(5000); + AddAssert("break overlay hidden", () => !this.ChildrenOfType().Single().Child.IsPresent); + addSeekStep(7000); + AddUntilStep("break overlay visible", () => this.ChildrenOfType().Single().Child.IsPresent); + addSeekStep(10000); + AddAssert("break overlay hidden", () => !this.ChildrenOfType().Single().Child.IsPresent); + } + + private void addSeekStep(double time) + { + AddStep($"seek to {time}", () => Beatmap.Value.Track.Seek(time)); + + // Allow a few frames of lenience + AddUntilStep("wait for seek to finish", () => Precision.AlmostEquals(time, Player.DrawableRuleset.FrameStableClock.CurrentTime, 100)); + } + } +} diff --git a/osu.Game.Tests/Visual/Menus/TestSceneAudioDucking.cs b/osu.Game.Tests/Visual/Menus/TestSceneAudioDucking.cs new file mode 100644 index 0000000000..8d20d8e0d5 --- /dev/null +++ b/osu.Game.Tests/Visual/Menus/TestSceneAudioDucking.cs @@ -0,0 +1,180 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using NUnit.Framework; +using osu.Game.Audio.Effects; +using osu.Game.Overlays; + +namespace osu.Game.Tests.Visual.Menus +{ + public partial class TestSceneAudioDucking : OsuGameTestScene + { + [Test] + public void TestMomentaryDuck() + { + AddStep("duck momentarily", () => Game.MusicController.DuckMomentarily(1000)); + } + + [Test] + public void TestMultipleDucks() + { + IDisposable duckOp1 = null!; + IDisposable duckOp2 = null!; + + double normalVolume = 1; + + AddStep("get initial volume", () => + { + normalVolume = Game.Audio.Tracks.AggregateVolume.Value; + }); + + AddStep("duck one", () => + { + duckOp1 = Game.MusicController.Duck(new DuckParameters + { + DuckVolumeTo = 0.5, + }); + }); + + AddUntilStep("wait for duck to complete", () => Game.Audio.Tracks.AggregateVolume.Value, () => Is.EqualTo(normalVolume * 0.5f).Within(0.01)); + + AddStep("duck two", () => + { + duckOp2 = Game.MusicController.Duck(new DuckParameters + { + DuckVolumeTo = 0.2, + }); + }); + + AddUntilStep("wait for duck to complete", () => Game.Audio.Tracks.AggregateVolume.Value, () => Is.EqualTo(normalVolume * 0.2f).Within(0.01)); + + AddStep("restore two", () => duckOp2.Dispose()); + AddUntilStep("wait for restore to complete", () => Game.Audio.Tracks.AggregateVolume.Value, () => Is.EqualTo(normalVolume * 0.5f).Within(0.01)); + + AddStep("restore one", () => duckOp1.Dispose()); + AddUntilStep("wait for restore to complete", () => Game.Audio.Tracks.AggregateVolume.Value, () => Is.EqualTo(normalVolume).Within(0.01)); + } + + [Test] + public void TestMultipleDucksSameParameters() + { + var duckParameters = new DuckParameters + { + DuckVolumeTo = 0.5, + }; + + IDisposable duckOp1 = null!; + IDisposable duckOp2 = null!; + + double normalVolume = 1; + + AddStep("get initial volume", () => + { + normalVolume = Game.Audio.Tracks.AggregateVolume.Value; + }); + + AddStep("duck one", () => + { + duckOp1 = Game.MusicController.Duck(duckParameters); + }); + + AddUntilStep("wait for duck to complete", () => Game.Audio.Tracks.AggregateVolume.Value, () => Is.EqualTo(normalVolume * 0.5f).Within(0.01)); + + AddStep("duck two", () => + { + duckOp2 = Game.MusicController.Duck(duckParameters); + }); + + AddUntilStep("wait for duck to complete", () => Game.Audio.Tracks.AggregateVolume.Value, () => Is.EqualTo(normalVolume * 0.5f).Within(0.01)); + + AddStep("restore two", () => duckOp2.Dispose()); + AddUntilStep("wait for restore to complete", () => Game.Audio.Tracks.AggregateVolume.Value, () => Is.EqualTo(normalVolume * 0.5f).Within(0.01)); + + AddStep("restore one", () => duckOp1.Dispose()); + AddUntilStep("wait for restore to complete", () => Game.Audio.Tracks.AggregateVolume.Value, () => Is.EqualTo(normalVolume).Within(0.01)); + } + + [Test] + public void TestMultipleDucksReverseOrder() + { + IDisposable duckOp1 = null!; + IDisposable duckOp2 = null!; + + double normalVolume = 1; + + AddStep("get initial volume", () => + { + normalVolume = Game.Audio.Tracks.AggregateVolume.Value; + }); + + AddStep("duck one", () => + { + duckOp1 = Game.MusicController.Duck(new DuckParameters + { + DuckVolumeTo = 0.5, + }); + }); + + AddUntilStep("wait for duck to complete", () => Game.Audio.Tracks.AggregateVolume.Value, () => Is.EqualTo(normalVolume * 0.5f).Within(0.01)); + + AddStep("duck two", () => + { + duckOp2 = Game.MusicController.Duck(new DuckParameters + { + DuckVolumeTo = 0.2, + }); + }); + + AddUntilStep("wait for duck to complete", () => Game.Audio.Tracks.AggregateVolume.Value, () => Is.EqualTo(normalVolume * 0.2f).Within(0.01)); + + AddStep("restore one", () => duckOp1.Dispose()); + + // reverse order, less extreme duck removed so won't change + AddUntilStep("wait for restore to complete", () => Game.Audio.Tracks.AggregateVolume.Value, () => Is.EqualTo(normalVolume * 0.2f).Within(0.01)); + + AddStep("restore two", () => duckOp2.Dispose()); + AddUntilStep("wait for restore to complete", () => Game.Audio.Tracks.AggregateVolume.Value, () => Is.EqualTo(normalVolume).Within(0.01)); + } + + [Test] + public void TestMultipleDisposalIsNoop() + { + IDisposable duckOp1 = null!; + + AddStep("duck", () => duckOp1 = Game.MusicController.Duck()); + AddStep("restore", () => duckOp1.Dispose()); + AddStep("restore", () => duckOp1.Dispose()); + } + + [Test] + public void TestMultipleDucksDifferentPieces() + { + IDisposable duckOp1 = null!; + IDisposable duckOp2 = null!; + + AddStep("duck volume", () => + { + duckOp1 = Game.MusicController.Duck(new DuckParameters + { + DuckVolumeTo = 0.2, + DuckCutoffTo = AudioFilter.MAX_LOWPASS_CUTOFF, + DuckDuration = 500, + }); + }); + + AddStep("duck lowpass", () => + { + duckOp2 = Game.MusicController.Duck(new DuckParameters + { + DuckVolumeTo = 1, + DuckCutoffTo = 300, + DuckDuration = 500, + }); + }); + + AddStep("restore lowpass", () => duckOp2.Dispose()); + AddStep("restore volume", () => duckOp1.Dispose()); + } + } +} diff --git a/osu.Game.Tests/Visual/Menus/TestSceneMainMenu.cs b/osu.Game.Tests/Visual/Menus/TestSceneMainMenu.cs index e2a841d79a..aab3716463 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneMainMenu.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneMainMenu.cs @@ -1,11 +1,16 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Linq; using NUnit.Framework; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; +using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Metadata; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; using osu.Game.Screens.Menu; using osuTK.Input; @@ -15,8 +20,58 @@ namespace osu.Game.Tests.Visual.Menus { private OnlineMenuBanner onlineMenuBanner => Game.ChildrenOfType().Single(); + public override void SetUpSteps() + { + base.SetUpSteps(); + AddStep("don't fetch online content", () => onlineMenuBanner.FetchOnlineContent = false); + AddStep("disable return to top on idle", () => Game.ChildrenOfType().Single().ReturnToTopOnIdle = false); + } + [Test] - public void TestOnlineMenuBanner() + public void TestDailyChallenge() + { + AddStep("set up API", () => ((DummyAPIAccess)API).HandleRequest = req => + { + switch (req) + { + case GetRoomRequest getRoomRequest: + if (getRoomRequest.RoomId != 1234) + return false; + + var beatmap = CreateAPIBeatmap(); + beatmap.OnlineID = 1001; + getRoomRequest.TriggerSuccess(new Room + { + RoomID = { Value = 1234 }, + Name = { Value = "Aug 8, 2024" }, + Playlist = + { + new PlaylistItem(beatmap) + }, + StartDate = { Value = DateTimeOffset.Now.AddMinutes(-30) }, + EndDate = { Value = DateTimeOffset.Now.AddSeconds(60) } + }); + return true; + + default: + return false; + } + }); + + AddStep("beatmap of the day active", () => Game.ChildrenOfType().Single().DailyChallengeUpdated(new DailyChallengeInfo + { + RoomID = 1234, + })); + + AddStep("enter menu", () => InputManager.Key(Key.P)); + AddStep("enter submenu", () => InputManager.Key(Key.P)); + AddStep("enter daily challenge", () => InputManager.Key(Key.D)); + + AddUntilStep("wait for daily challenge screen", () => Game.ScreenStack.CurrentScreen, Is.TypeOf); + } + + [Test] + public void TestOnlineMenuBannerTrusted() { AddStep("set online content", () => onlineMenuBanner.Current.Value = new APIMenuContent { @@ -25,13 +80,51 @@ namespace osu.Game.Tests.Visual.Menus new APIMenuImage { Image = @"https://assets.ppy.sh/main-menu/project-loved-2@2x.png", - Url = @"https://osu.ppy.sh/home/news/2023-12-21-project-loved-december-2023", + Url = $@"{API.WebsiteRootUrl}/home/news/2023-12-21-project-loved-december-2023", } } }); AddAssert("system title not visible", () => onlineMenuBanner.State.Value, () => Is.EqualTo(Visibility.Hidden)); AddStep("enter menu", () => InputManager.Key(Key.Enter)); AddUntilStep("system title visible", () => onlineMenuBanner.State.Value, () => Is.EqualTo(Visibility.Visible)); + AddUntilStep("image loaded", () => onlineMenuBanner.ChildrenOfType().FirstOrDefault()?.IsLoaded, () => Is.True); + + AddStep("click banner", () => + { + InputManager.MoveMouseTo(onlineMenuBanner); + InputManager.Click(MouseButton.Left); + }); + + // Might not catch every occurrence due to async nature, but works in manual testing and saves annoying test setup. + AddAssert("no dialog", () => Game.ChildrenOfType().SingleOrDefault()?.CurrentDialog == null); + } + + [Test] + public void TestOnlineMenuBannerUntrustedDomain() + { + AddStep("set online content", () => onlineMenuBanner.Current.Value = new APIMenuContent + { + Images = new[] + { + new APIMenuImage + { + Image = @"https://assets.ppy.sh/main-menu/project-loved-2@2x.png", + Url = @"https://google.com", + } + } + }); + AddAssert("system title not visible", () => onlineMenuBanner.State.Value, () => Is.EqualTo(Visibility.Hidden)); + AddStep("enter menu", () => InputManager.Key(Key.Enter)); + AddUntilStep("system title visible", () => onlineMenuBanner.State.Value, () => Is.EqualTo(Visibility.Visible)); + AddUntilStep("image loaded", () => onlineMenuBanner.ChildrenOfType().FirstOrDefault()?.IsLoaded, () => Is.True); + + AddStep("click banner", () => + { + InputManager.MoveMouseTo(onlineMenuBanner); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("wait for dialog", () => Game.ChildrenOfType().SingleOrDefault()?.CurrentDialog != null); } } } diff --git a/osu.Game.Tests/Visual/Menus/TestSceneMusicActionHandling.cs b/osu.Game.Tests/Visual/Menus/TestSceneMusicActionHandling.cs index f17433244b..32009dc8c2 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneMusicActionHandling.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneMusicActionHandling.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -34,7 +32,9 @@ namespace osu.Game.Tests.Visual.Menus [Test] public void TestMusicNavigationActions() { - Queue<(IWorkingBeatmap working, TrackChangeDirection changeDirection)> trackChangeQueue = null; + Queue<(IWorkingBeatmap working, TrackChangeDirection changeDirection)> trackChangeQueue = null!; + + AddStep("disable shuffle", () => Game.MusicController.Shuffle.Value = false); // ensure we have at least two beatmaps available to identify the direction the music controller navigated to. AddRepeatStep("import beatmap", () => Game.BeatmapManager.Import(TestResources.CreateTestBeatmapSetInfo()), 5); @@ -62,14 +62,131 @@ namespace osu.Game.Tests.Visual.Menus AddUntilStep("track restarted", () => Game.MusicController.CurrentTrack.CurrentTime < 5000); AddStep("press previous", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPrev)); - AddAssert("track changed to previous", () => + AddUntilStep("track changed to previous", () => trackChangeQueue.Count == 1 && trackChangeQueue.Dequeue().changeDirection == TrackChangeDirection.Prev); AddStep("press next", () => globalActionContainer.TriggerPressed(GlobalAction.MusicNext)); - AddAssert("track changed to next", () => + AddUntilStep("track changed to next", () => trackChangeQueue.Count == 1 && - trackChangeQueue.Dequeue().changeDirection == TrackChangeDirection.Next); + trackChangeQueue.Peek().changeDirection == TrackChangeDirection.Next); + + AddUntilStep("wait until track switches", () => trackChangeQueue.Count == 2); + + AddStep("press next", () => globalActionContainer.TriggerPressed(GlobalAction.MusicNext)); + AddUntilStep("track changed to next", () => + trackChangeQueue.Count == 3 && + trackChangeQueue.Peek().changeDirection == TrackChangeDirection.Next); + AddAssert("track actually changed", () => !trackChangeQueue.First().working.BeatmapInfo.Equals(trackChangeQueue.Last().working.BeatmapInfo)); + } + + [Test] + public void TestShuffleBackwards() + { + Queue<(IWorkingBeatmap working, TrackChangeDirection changeDirection)> trackChangeQueue = null!; + + AddStep("enable shuffle", () => Game.MusicController.Shuffle.Value = true); + + // ensure we have at least two beatmaps available to identify the direction the music controller navigated to. + AddRepeatStep("import beatmap", () => Game.BeatmapManager.Import(TestResources.CreateTestBeatmapSetInfo()), 5); + AddStep("ensure nonzero track duration", () => Game.Realm.Write(r => + { + // this was already supposed to be non-zero (see innards of `TestResources.CreateTestBeatmapSetInfo()`), + // but the non-zero value is being overwritten *to* zero by `BeatmapUpdater`. + // do a bit of a hack to change it back again - otherwise tracks are going to switch instantly and we won't be able to assert anything sane anymore. + foreach (var beatmap in r.All().Where(b => b.Length == 0)) + beatmap.Length = 60_000; + })); + + AddStep("bind to track change", () => + { + trackChangeQueue = new Queue<(IWorkingBeatmap, TrackChangeDirection)>(); + Game.MusicController.TrackChanged += (working, changeDirection) => trackChangeQueue.Enqueue((working, changeDirection)); + }); + + AddStep("seek track to 6 second", () => Game.MusicController.SeekTo(6000)); + AddUntilStep("wait for current time to update", () => Game.MusicController.CurrentTrack.CurrentTime > 5000); + + AddStep("press previous", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPrev)); + AddAssert("no track change", () => trackChangeQueue.Count == 0); + AddUntilStep("track restarted", () => Game.MusicController.CurrentTrack.CurrentTime < 5000); + + AddStep("press previous", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPrev)); + AddUntilStep("track changed", () => trackChangeQueue.Count == 1); + + AddStep("press previous", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPrev)); + AddUntilStep("track changed", () => trackChangeQueue.Count == 2); + + AddStep("press next", () => globalActionContainer.TriggerPressed(GlobalAction.MusicNext)); + AddUntilStep("track changed", () => + trackChangeQueue.Count == 3 && !trackChangeQueue.ElementAt(1).working.BeatmapInfo.Equals(trackChangeQueue.Last().working.BeatmapInfo)); + } + + [Test] + public void TestShuffleForwards() + { + Queue<(IWorkingBeatmap working, TrackChangeDirection changeDirection)> trackChangeQueue = null!; + + AddStep("enable shuffle", () => Game.MusicController.Shuffle.Value = true); + + // ensure we have at least two beatmaps available to identify the direction the music controller navigated to. + AddRepeatStep("import beatmap", () => Game.BeatmapManager.Import(TestResources.CreateTestBeatmapSetInfo()), 5); + AddStep("ensure nonzero track duration", () => Game.Realm.Write(r => + { + // this was already supposed to be non-zero (see innards of `TestResources.CreateTestBeatmapSetInfo()`), + // but the non-zero value is being overwritten *to* zero by `BeatmapUpdater`. + // do a bit of a hack to change it back again - otherwise tracks are going to switch instantly and we won't be able to assert anything sane anymore. + foreach (var beatmap in r.All().Where(b => b.Length == 0)) + beatmap.Length = 60_000; + })); + + AddStep("bind to track change", () => + { + trackChangeQueue = new Queue<(IWorkingBeatmap, TrackChangeDirection)>(); + Game.MusicController.TrackChanged += (working, changeDirection) => trackChangeQueue.Enqueue((working, changeDirection)); + }); + + AddStep("press next", () => globalActionContainer.TriggerPressed(GlobalAction.MusicNext)); + AddUntilStep("track changed", () => trackChangeQueue.Count == 1); + + AddStep("press next", () => globalActionContainer.TriggerPressed(GlobalAction.MusicNext)); + AddUntilStep("track changed", () => trackChangeQueue.Count == 2); + + AddStep("press previous", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPrev)); + AddUntilStep("track changed", () => + trackChangeQueue.Count == 3 && !trackChangeQueue.ElementAt(1).working.BeatmapInfo.Equals(trackChangeQueue.Last().working.BeatmapInfo)); + } + + [Test] + public void TestShuffleBackAndForth() + { + Queue<(IWorkingBeatmap working, TrackChangeDirection changeDirection)> trackChangeQueue = null!; + + AddStep("enable shuffle", () => Game.MusicController.Shuffle.Value = true); + + // ensure we have at least two beatmaps available to identify the direction the music controller navigated to. + AddRepeatStep("import beatmap", () => Game.BeatmapManager.Import(TestResources.CreateTestBeatmapSetInfo()), 5); + AddStep("ensure nonzero track duration", () => Game.Realm.Write(r => + { + // this was already supposed to be non-zero (see innards of `TestResources.CreateTestBeatmapSetInfo()`), + // but the non-zero value is being overwritten *to* zero by `BeatmapUpdater`. + // do a bit of a hack to change it back again - otherwise tracks are going to switch instantly and we won't be able to assert anything sane anymore. + foreach (var beatmap in r.All().Where(b => b.Length == 0)) + beatmap.Length = 60_000; + })); + + AddStep("bind to track change", () => + { + trackChangeQueue = new Queue<(IWorkingBeatmap, TrackChangeDirection)>(); + Game.MusicController.TrackChanged += (working, changeDirection) => trackChangeQueue.Enqueue((working, changeDirection)); + }); + + AddStep("press next", () => globalActionContainer.TriggerPressed(GlobalAction.MusicNext)); + AddUntilStep("track changed", () => trackChangeQueue.Count == 1); + + AddStep("press previous", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPrev)); + AddUntilStep("track changed", () => + trackChangeQueue.Count == 2 && !trackChangeQueue.First().working.BeatmapInfo.Equals(trackChangeQueue.Last().working.BeatmapInfo)); } } } diff --git a/osu.Game.Tests/Visual/Menus/TestSceneStarFountain.cs b/osu.Game.Tests/Visual/Menus/TestSceneStarFountain.cs index bb327e5962..29fa7287d2 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneStarFountain.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneStarFountain.cs @@ -4,17 +4,17 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; -using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Screens.Menu; +using osu.Game.Screens.Play; namespace osu.Game.Tests.Visual.Menus { [TestFixture] public partial class TestSceneStarFountain : OsuTestScene { - [SetUpSteps] - public void SetUpSteps() + [Test] + public void TestMenu() { AddStep("make fountains", () => { @@ -34,11 +34,7 @@ namespace osu.Game.Tests.Visual.Menus }, }; }); - } - [Test] - public void TestPew() - { AddRepeatStep("activate fountains sometimes", () => { foreach (var fountain in Children.OfType()) @@ -48,5 +44,34 @@ namespace osu.Game.Tests.Visual.Menus } }, 150); } + + [Test] + public void TestGameplay() + { + AddStep("make fountains", () => + { + Children = new[] + { + new KiaiGameplayFountains.GameplayStarFountain + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + X = 75, + }, + new KiaiGameplayFountains.GameplayStarFountain + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + X = -75, + }, + }; + }); + + AddStep("activate fountains", () => + { + ((StarFountain)Children[0]).Shoot(1); + ((StarFountain)Children[1]).Shoot(-1); + }); + } } } diff --git a/osu.Game.Tests/Visual/Menus/TestSceneToolbarUserButton.cs b/osu.Game.Tests/Visual/Menus/TestSceneToolbarUserButton.cs index 1a4ca65975..71a45e2398 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneToolbarUserButton.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneToolbarUserButton.cs @@ -97,6 +97,7 @@ namespace osu.Game.Tests.Visual.Menus public void TestTransientUserStatisticsDisplay() { AddStep("Log in", () => dummyAPI.Login("wang", "jang")); + AddStep("Gain", () => { var transientUpdateDisplay = this.ChildrenOfType().Single(); @@ -113,6 +114,7 @@ namespace osu.Game.Tests.Visual.Menus PP = 1357 }); }); + AddStep("Loss", () => { var transientUpdateDisplay = this.ChildrenOfType().Single(); @@ -129,7 +131,9 @@ namespace osu.Game.Tests.Visual.Menus PP = 1234 }); }); - AddStep("No change", () => + + // Tests flooring logic works as expected. + AddStep("Tiny increase in PP", () => { var transientUpdateDisplay = this.ChildrenOfType().Single(); transientUpdateDisplay.LatestUpdate.Value = new UserStatisticsUpdate( @@ -137,14 +141,32 @@ namespace osu.Game.Tests.Visual.Menus new UserStatistics { GlobalRank = 111_111, - PP = 1357 + PP = 1357.6m }, new UserStatistics { GlobalRank = 111_111, - PP = 1357 + PP = 1358.1m }); }); + + AddStep("No change 1", () => + { + var transientUpdateDisplay = this.ChildrenOfType().Single(); + transientUpdateDisplay.LatestUpdate.Value = new UserStatisticsUpdate( + new ScoreInfo(), + new UserStatistics + { + GlobalRank = 111_111, + PP = 1357m + }, + new UserStatistics + { + GlobalRank = 111_111, + PP = 1357.1m + }); + }); + AddStep("Was null", () => { var transientUpdateDisplay = this.ChildrenOfType().Single(); @@ -161,6 +183,7 @@ namespace osu.Game.Tests.Visual.Menus PP = 1357 }); }); + AddStep("Became null", () => { var transientUpdateDisplay = this.ChildrenOfType().Single(); diff --git a/osu.Game.Tests/Visual/Multiplayer/QueueModeTestScene.cs b/osu.Game.Tests/Visual/Multiplayer/QueueModeTestScene.cs index 0f1ba9ba75..8bcd5aab1c 100644 --- a/osu.Game.Tests/Visual/Multiplayer/QueueModeTestScene.cs +++ b/osu.Game.Tests/Visual/Multiplayer/QueueModeTestScene.cs @@ -12,6 +12,7 @@ using osu.Framework.Platform; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Database; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; using osu.Game.Rulesets; @@ -45,9 +46,14 @@ namespace osu.Game.Tests.Visual.Multiplayer [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { + DetachedBeatmapStore detachedBeatmapStore; + Dependencies.Cache(new RealmRulesetStore(Realm)); Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(detachedBeatmapStore = new DetachedBeatmapStore()); Dependencies.Cache(Realm); + + Add(detachedBeatmapStore); } public override void SetUpSteps() diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs index 4ffccdbf0e..98242e2d92 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs @@ -69,8 +69,9 @@ namespace osu.Game.Tests.Visual.Multiplayer }), createLoungeRoom(new Room { - Name = { Value = "Multiplayer room" }, - Status = { Value = new RoomStatusOpen() }, + Name = { Value = "Private room" }, + Status = { Value = new RoomStatusOpenPrivate() }, + HasPassword = { Value = true }, EndDate = { Value = DateTimeOffset.Now.AddDays(1) }, Type = { Value = MatchType.HeadToHead }, Playlist = diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs index 6446ebd35f..2ef56bd54e 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs @@ -12,10 +12,12 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; +using osu.Game.Beatmaps.Drawables.Cards; using osu.Game.Database; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; @@ -317,17 +319,18 @@ namespace osu.Game.Tests.Visual.Multiplayer p.RequestResults = _ => resultsRequested = true; }); - AddStep("move mouse to first item title", () => - { - var drawQuad = playlist.ChildrenOfType().First().ScreenSpaceDrawQuad; - var location = (drawQuad.TopLeft + drawQuad.BottomLeft) / 2 + new Vector2(drawQuad.Width * 0.2f, 0); - InputManager.MoveMouseTo(location); - }); - AddUntilStep("wait for text load", () => playlist.ChildrenOfType().Any()); + AddUntilStep("wait for load", () => playlist.ChildrenOfType().Any() && playlist.ChildrenOfType().First().DrawWidth > 0); + + AddStep("move mouse to first item title", () => InputManager.MoveMouseTo(playlist.ChildrenOfType().First().ChildrenOfType().First())); AddAssert("first item title not hovered", () => playlist.ChildrenOfType().First().IsHovered, () => Is.False); - AddStep("click left mouse", () => InputManager.Click(MouseButton.Left)); + + AddStep("click title", () => + { + InputManager.MoveMouseTo(playlist.ChildrenOfType().First().ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + AddUntilStep("first item selected", () => playlist.ChildrenOfType().First().IsSelectedItem, () => Is.True); - // implies being clickable. AddUntilStep("first item title hovered", () => playlist.ChildrenOfType().First().IsHovered, () => Is.True); AddStep("move mouse to second item results button", () => InputManager.MoveMouseTo(playlist.ChildrenOfType().ElementAt(5))); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs index a4feffddfb..4316653dde 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs @@ -3,6 +3,7 @@ #nullable disable +using System; using System.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -16,6 +17,7 @@ using osu.Framework.Testing; using osu.Game.Overlays.Mods; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Footer; using osu.Game.Screens.OnlinePlay; using osu.Game.Utils; using osuTK.Input; @@ -26,6 +28,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { private FreeModSelectOverlay freeModSelectOverlay; private FooterButtonFreeMods footerButtonFreeMods; + private ScreenFooter footer; private readonly Bindable>> availableMods = new Bindable>>(); [BackgroundDependencyLoader] @@ -58,7 +61,9 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("select difficulty adjust", () => freeModSelectOverlay.SelectedMods.Value = new[] { new OsuModDifficultyAdjust() }); AddWaitStep("wait some", 3); - AddAssert("customisation area not expanded", () => this.ChildrenOfType().Single().Height == 0); + AddAssert("customisation area not expanded", + () => this.ChildrenOfType().Single().ExpandedState.Value, + () => Is.EqualTo(ModCustomisationPanel.ModCustomisationPanelState.Collapsed)); } [Test] @@ -127,7 +132,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { createFreeModSelect(); - AddAssert("overlay select all button enabled", () => freeModSelectOverlay.ChildrenOfType().Single().Enabled.Value); + AddAssert("overlay select all button enabled", () => this.ChildrenOfType().Single().Enabled.Value); AddAssert("footer button displays off", () => footerButtonFreeMods.ChildrenOfType().Any(t => t.Text == "off")); AddStep("click footer select all button", () => @@ -150,19 +155,27 @@ namespace osu.Game.Tests.Visual.Multiplayer private void createFreeModSelect() { - AddStep("create free mod select screen", () => Children = new Drawable[] + AddStep("create free mod select screen", () => Child = new DependencyProvidingContainer { - freeModSelectOverlay = new FreeModSelectOverlay + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] { - State = { Value = Visibility.Visible } - }, - footerButtonFreeMods = new FooterButtonFreeMods(freeModSelectOverlay) - { - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - Current = { BindTarget = freeModSelectOverlay.SelectedMods }, + freeModSelectOverlay = new FreeModSelectOverlay + { + State = { Value = Visibility.Visible } + }, + footerButtonFreeMods = new FooterButtonFreeMods(freeModSelectOverlay) + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Y = -ScreenFooter.HEIGHT, + Current = { BindTarget = freeModSelectOverlay.SelectedMods }, + }, + footer = new ScreenFooter(), }, + CachedDependencies = new (Type, object)[] { (typeof(ScreenFooter), footer) }, }); + AddUntilStep("all column content loaded", () => freeModSelectOverlay.ChildrenOfType().Any() && freeModSelectOverlay.ChildrenOfType().All(column => column.IsLoaded && column.ItemsLoaded)); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneGameplayChatDisplay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneGameplayChatDisplay.cs index d1a914300f..6a500bbe55 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneGameplayChatDisplay.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneGameplayChatDisplay.cs @@ -25,14 +25,14 @@ namespace osu.Game.Tests.Visual.Multiplayer [Cached(typeof(ILocalUserPlayInfo))] private ILocalUserPlayInfo localUserInfo; - private readonly Bindable localUserPlaying = new Bindable(); + private readonly Bindable playingState = new Bindable(); private TextBox textBox => chatDisplay.ChildrenOfType().First(); public TestSceneGameplayChatDisplay() { var mockLocalUserInfo = new Mock(); - mockLocalUserInfo.SetupGet(i => i.IsPlaying).Returns(localUserPlaying); + mockLocalUserInfo.SetupGet(i => i.PlayingState).Returns(playingState); localUserInfo = mockLocalUserInfo.Object; } @@ -124,6 +124,6 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert($"chat {(isFocused ? "focused" : "not focused")}", () => textBox.HasFocus == isFocused); private void setLocalUserPlaying(bool playing) => - AddStep($"local user {(playing ? "playing" : "not playing")}", () => localUserPlaying.Value = playing); + AddStep($"local user {(playing ? "playing" : "not playing")}", () => playingState.Value = playing ? LocalUserPlayingState.Playing : LocalUserPlayingState.NotPlaying); } } diff --git a/osu.Game.Tests/Visual/Multiplayer/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/TestSceneMultiSpectatorScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs index cebc75f90c..2b17f91e68 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs @@ -424,7 +424,7 @@ namespace osu.Game.Tests.Visual.Multiplayer public void TestIntroStoryboardElement() => testLeadIn(b => { var sprite = new StoryboardSprite("unknown", Anchor.TopLeft, Vector2.Zero); - sprite.TimelineGroup.Alpha.Add(Easing.None, -2000, 0, 0, 1); + sprite.Commands.AddAlpha(Easing.None, -2000, 0, 0, 1); b.Storyboard.GetLayer("Background").Add(sprite); }); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs index 8c7576ff52..df2021dbaf 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs @@ -19,6 +19,7 @@ using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Database; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; @@ -65,9 +66,14 @@ namespace osu.Game.Tests.Visual.Multiplayer [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { + DetachedBeatmapStore detachedBeatmapStore; + Dependencies.Cache(new RealmRulesetStore(Realm)); Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, API, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(detachedBeatmapStore = new DetachedBeatmapStore()); Dependencies.Cache(Realm); + + Add(detachedBeatmapStore); } public override void SetUpSteps() @@ -139,8 +145,8 @@ namespace osu.Game.Tests.Visual.Multiplayer private void addRandomPlayer() { - int randomUser = RNG.Next(200000, 500000); - multiplayerClient.AddUser(new APIUser { Id = randomUser, Username = $"user {randomUser}" }); + int id = TestResources.GetNextTestID(); + multiplayerClient.AddUser(new APIUser { Id = id, Username = $"user {id}" }); } private void removeLastUser() @@ -644,7 +650,7 @@ namespace osu.Game.Tests.Visual.Multiplayer } }); - AddStep("open mod overlay", () => this.ChildrenOfType().Single().TriggerClick()); + AddStep("open mod overlay", () => this.ChildrenOfType().Single().TriggerClick()); AddStep("invoke on back button", () => multiplayerComponents.OnBackButton()); 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 8dc41cd707..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,20 +44,26 @@ namespace osu.Game.Tests.Visual.Multiplayer private Live importedBeatmapSet; + [Resolved] + private OsuConfigManager configManager { get; set; } + [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { + DetachedBeatmapStore detachedBeatmapStore; + Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(detachedBeatmapStore = new DetachedBeatmapStore()); Dependencies.Cache(Realm); importedBeatmapSet = manager.Import(TestResources.CreateTestBeatmapSetInfo(8, rulesets.AvailableRulesets.ToArray())); + + Add(detachedBeatmapStore); } - public override void SetUpSteps() + private void setUp() { - base.SetUpSteps(); - AddStep("reset", () => { Ruleset.Value = new OsuRuleset().RulesetInfo; @@ -70,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()); @@ -80,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))); @@ -102,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) }); @@ -115,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", @@ -133,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/TestSceneMultiplayerMatchSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs index bdfe01ba09..9bf29c7bf8 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs @@ -165,11 +165,9 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for room join", () => RoomJoined); - AddStep("join other user (ready)", () => - { - MultiplayerClient.AddUser(new APIUser { Id = PLAYER_1_ID }); - MultiplayerClient.ChangeUserState(PLAYER_1_ID, MultiplayerUserState.Ready); - }); + AddStep("join other user", void () => MultiplayerClient.AddUser(new APIUser { Id = PLAYER_1_ID })); + AddUntilStep("wait for user populated", () => MultiplayerClient.ClientRoom!.Users.Single(u => u.UserID == PLAYER_1_ID).User, () => Is.Not.Null); + AddStep("other user ready", () => MultiplayerClient.ChangeUserState(PLAYER_1_ID, MultiplayerUserState.Ready)); ClickButtonWhenEnabled(); @@ -197,7 +195,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for join", () => RoomJoined); - ClickButtonWhenEnabled(); + ClickButtonWhenEnabled(); AddUntilStep("mod select contents loaded", () => this.ChildrenOfType().Any() && this.ChildrenOfType().All(col => col.IsLoaded && col.ItemsLoaded)); @@ -311,15 +309,15 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for join", () => RoomJoined); - ClickButtonWhenEnabled(); - AddAssert("mod select shows unranked", () => screen.UserModsSelectOverlay.ChildrenOfType().Single().Ranked.Value == false); - AddAssert("score multiplier = 1.20", () => screen.UserModsSelectOverlay.ChildrenOfType().Single().ModMultiplier.Value, () => Is.EqualTo(1.2).Within(0.01)); + ClickButtonWhenEnabled(); + AddAssert("mod select shows unranked", () => this.ChildrenOfType().Single().Ranked.Value == false); + AddAssert("score multiplier = 1.20", () => this.ChildrenOfType().Single().ModMultiplier.Value, () => Is.EqualTo(1.2).Within(0.01)); AddStep("select flashlight", () => screen.UserModsSelectOverlay.ChildrenOfType().Single(m => m.Mod is ModFlashlight).TriggerClick()); - AddAssert("score multiplier = 1.35", () => screen.UserModsSelectOverlay.ChildrenOfType().Single().ModMultiplier.Value, () => Is.EqualTo(1.35).Within(0.01)); + AddAssert("score multiplier = 1.35", () => this.ChildrenOfType().Single().ModMultiplier.Value, () => Is.EqualTo(1.35).Within(0.01)); AddStep("change flashlight setting", () => ((OsuModFlashlight)screen.UserModsSelectOverlay.SelectedMods.Value.Single()).FollowDelay.Value = 1200); - AddAssert("score multiplier = 1.20", () => screen.UserModsSelectOverlay.ChildrenOfType().Single().ModMultiplier.Value, () => Is.EqualTo(1.2).Within(0.01)); + AddAssert("score multiplier = 1.20", () => this.ChildrenOfType().Single().ModMultiplier.Value, () => Is.EqualTo(1.2).Within(0.01)); } private partial class TestMultiplayerMatchSubScreen : MultiplayerMatchSubScreen 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/Multiplayer/TestScenePlaylistsSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs index b0b753fc22..cc78bed5de 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs @@ -12,6 +12,7 @@ using osu.Framework.Platform; using osu.Framework.Screens; using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Database; using osu.Game.Online.Rooms; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; @@ -33,13 +34,18 @@ namespace osu.Game.Tests.Visual.Multiplayer [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { + DetachedBeatmapStore detachedBeatmapStore; + Dependencies.Cache(new RealmRulesetStore(Realm)); Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(detachedBeatmapStore = new DetachedBeatmapStore()); Dependencies.Cache(Realm); var beatmapSet = TestResources.CreateTestBeatmapSetInfo(); manager.Import(beatmapSet); + + Add(detachedBeatmapStore); } public override void SetUpSteps() diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneBeatmapEditorNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneBeatmapEditorNavigation.cs index 9930349b1b..d76e0290ef 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneBeatmapEditorNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneBeatmapEditorNavigation.cs @@ -1,22 +1,30 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using System.IO; using System.Linq; +using System.Threading; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Extensions; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Database; +using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Osu; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.GameplayTest; +using osu.Game.Screens.Edit.Setup; using osu.Game.Screens.Menu; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; @@ -27,25 +35,149 @@ namespace osu.Game.Tests.Visual.Navigation { public partial class TestSceneBeatmapEditorNavigation : OsuGameTestScene { + private BeatmapSetInfo beatmapSet = null!; + [Test] - public void TestEditorGameplayTestAlwaysUsesOriginalRuleset() + public void TestExternalEditingNoChange() { - BeatmapSetInfo beatmapSet = null!; + string difficultyName = null!; - AddStep("import test beatmap", () => Game.BeatmapManager.Import(TestResources.GetTestBeatmapForImport()).WaitSafely()); - AddStep("retrieve beatmap", () => beatmapSet = Game.BeatmapManager.QueryBeatmapSet(set => !set.Protected).AsNonNull().Value.Detach()); + prepareBeatmap(); + openEditor(); - AddStep("present beatmap", () => Game.PresentBeatmap(beatmapSet)); - AddUntilStep("wait for song select", - () => Game.Beatmap.Value.BeatmapSetInfo.Equals(beatmapSet) - && Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect - && songSelect.IsLoaded); + AddStep("store difficulty name", () => difficultyName = getEditor().Beatmap.Value.BeatmapInfo.DifficultyName); + + AddStep("open file menu", () => getEditor().ChildrenOfType().Single(m => m.Item.Text.Value.ToString() == "File").TriggerClick()); + AddStep("click external edit", () => getEditor().ChildrenOfType().Single(m => m.Item.Text.Value.ToString() == "Edit externally").TriggerClick()); + + AddUntilStep("wait for external edit screen", () => Game.ScreenStack.CurrentScreen is ExternalEditScreen externalEditScreen && externalEditScreen.IsLoaded); + + AddUntilStep("wait for button ready", () => ((ExternalEditScreen)Game.ScreenStack.CurrentScreen).ChildrenOfType().FirstOrDefault()?.Enabled.Value == true); + + AddStep("finish external edit", () => ((ExternalEditScreen)Game.ScreenStack.CurrentScreen).ChildrenOfType().First().TriggerClick()); + + AddUntilStep("wait for editor", () => Game.ScreenStack.CurrentScreen is Editor editor && editor.ReadyForUse); + + AddAssert("beatmapset didn't change", () => getEditor().Beatmap.Value.BeatmapSetInfo, () => Is.EqualTo(beatmapSet)); + AddAssert("difficulty didn't change", () => getEditor().Beatmap.Value.BeatmapInfo.DifficultyName, () => Is.EqualTo(difficultyName)); + AddAssert("old beatmapset not deleted", () => Game.BeatmapManager.QueryBeatmapSet(s => s.ID == beatmapSet.ID), () => Is.Not.Null); + } + + [Test] + public void TestExternalEditingWithChange() + { + string difficultyName = null!; + + prepareBeatmap(); + openEditor(); + + AddStep("store difficulty name", () => difficultyName = getEditor().Beatmap.Value.BeatmapInfo.DifficultyName); + + AddStep("open file menu", () => getEditor().ChildrenOfType().Single(m => m.Item.Text.Value.ToString() == "File").TriggerClick()); + AddStep("click external edit", () => getEditor().ChildrenOfType().Single(m => m.Item.Text.Value.ToString() == "Edit externally").TriggerClick()); + + AddUntilStep("wait for external edit screen", () => Game.ScreenStack.CurrentScreen is ExternalEditScreen externalEditScreen && externalEditScreen.IsLoaded); + + AddUntilStep("wait for button ready", () => ((ExternalEditScreen)Game.ScreenStack.CurrentScreen).ChildrenOfType().FirstOrDefault()?.Enabled.Value == true); + + AddStep("add file externally", () => + { + var op = ((ExternalEditScreen)Game.ScreenStack.CurrentScreen).EditOperation!; + File.WriteAllText(Path.Combine(op.MountedPath, "test.txt"), "test"); + }); + + AddStep("finish external edit", () => ((ExternalEditScreen)Game.ScreenStack.CurrentScreen).ChildrenOfType().First().TriggerClick()); + + AddUntilStep("wait for editor", () => Game.ScreenStack.CurrentScreen is Editor editor && editor.ReadyForUse); + + AddAssert("beatmapset changed", () => getEditor().Beatmap.Value.BeatmapSetInfo, () => Is.Not.EqualTo(beatmapSet)); + AddAssert("beatmapset is locally modified", () => getEditor().Beatmap.Value.BeatmapSetInfo.Status, () => Is.EqualTo(BeatmapOnlineStatus.LocallyModified)); + AddAssert("all difficulties are locally modified", () => getEditor().Beatmap.Value.BeatmapSetInfo.Beatmaps.All(b => b.Status == BeatmapOnlineStatus.LocallyModified)); + AddAssert("difficulty didn't change", () => getEditor().Beatmap.Value.BeatmapInfo.DifficultyName, () => Is.EqualTo(difficultyName)); + AddAssert("old beatmapset deleted", () => Game.BeatmapManager.QueryBeatmapSet(s => s.ID == beatmapSet.ID), () => Is.Null); + } + + [Test] + public void TestSaveThenDeleteActuallyDeletesAtSongSelect() + { + prepareBeatmap(); + openEditor(); + makeMetadataChange(); + + AddAssert("save", () => getEditor().Save()); + + AddStep("delete beatmap", () => Game.BeatmapManager.Delete(beatmapSet)); + + AddStep("exit", () => getEditor().Exit()); + + AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect + && songSelect.Beatmap.Value is DummyWorkingBeatmap); + } + + [Test] + public void TestChangeMetadataExitWhileTextboxFocusedPromptsSave() + { AddStep("switch ruleset", () => Game.Ruleset.Value = new ManiaRuleset().RulesetInfo); - AddStep("open editor", () => ((PlaySongSelect)Game.ScreenStack.CurrentScreen).Edit(beatmapSet.Beatmaps.First(beatmap => beatmap.Ruleset.OnlineID == 0))); - AddUntilStep("wait for editor open", () => Game.ScreenStack.CurrentScreen is Editor editor && editor.ReadyForUse); - AddStep("test gameplay", () => getEditor().TestGameplay()); + prepareBeatmap(); + openEditor(); + makeMetadataChange(commit: false); + + AddStep("exit", () => getEditor().Exit()); + + AddUntilStep("save dialog displayed", () => Game.ChildrenOfType().SingleOrDefault()?.CurrentDialog is PromptForSaveDialog); + } + + private void makeMetadataChange(bool commit = true) + { + AddStep("change to song setup", () => InputManager.Key(Key.F4)); + + TextBox textbox = null!; + + AddUntilStep("wait for metadata section", () => + { + var t = Game.ChildrenOfType().SingleOrDefault().ChildrenOfType().FirstOrDefault(); + + if (t == null) + return false; + + textbox = t; + return true; + }); + + AddStep("focus textbox", () => + { + InputManager.MoveMouseTo(textbox); + InputManager.Click(MouseButton.Left); + }); + + AddStep("simulate changing textbox", () => + { + // Can't simulate text input but this should work. + InputManager.Keys(PlatformAction.SelectAll); + InputManager.Keys(PlatformAction.Copy); + InputManager.Keys(PlatformAction.Paste); + InputManager.Keys(PlatformAction.Paste); + }); + + if (commit) AddStep("commit", () => InputManager.Key(Key.Enter)); + } + + [Test] + [Solo] + public void TestEditorGameplayTestAlwaysUsesOriginalRuleset() + { + prepareBeatmap(); + + AddStep("switch ruleset at song select", () => Game.Ruleset.Value = new ManiaRuleset().RulesetInfo); + + AddStep("open editor", () => ((PlaySongSelect)Game.ScreenStack.CurrentScreen).Edit(beatmapSet.Beatmaps.First(beatmap => beatmap.Ruleset.OnlineID == 0))); + + AddUntilStep("wait for editor open", () => Game.ScreenStack.CurrentScreen is Editor editor && editor.ReadyForUse); + AddAssert("editor ruleset is osu!", () => Game.Ruleset.Value, () => Is.EqualTo(new OsuRuleset().RulesetInfo)); + + AddStep("test gameplay", () => getEditor().TestGameplay()); AddUntilStep("wait for player", () => { // notifications may fire at almost any inopportune time and cause annoying test failures. @@ -54,8 +186,7 @@ namespace osu.Game.Tests.Visual.Navigation Game.CloseAllOverlays(); return Game.ScreenStack.CurrentScreen is EditorPlayer editorPlayer && editorPlayer.IsLoaded; }); - - AddAssert("current ruleset is osu!", () => Game.Ruleset.Value.Equals(new OsuRuleset().RulesetInfo)); + AddAssert("gameplay ruleset is osu!", () => Game.Ruleset.Value, () => Is.EqualTo(new OsuRuleset().RulesetInfo)); AddStep("exit to song select", () => Game.PerformFromScreen(_ => { }, typeof(PlaySongSelect).Yield())); AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect); @@ -79,12 +210,16 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("Set current beatmap to default", () => Game.Beatmap.SetDefault()); - AddStep("Push editor loader", () => Game.ScreenStack.Push(new EditorLoader())); + DelayedLoadEditorLoader loader = null!; + AddStep("Push editor loader", () => Game.ScreenStack.Push(loader = new DelayedLoadEditorLoader())); AddUntilStep("Wait for loader current", () => Game.ScreenStack.CurrentScreen is EditorLoader); + AddUntilStep("wait for editor load start", () => loader.Editor != null); AddStep("Close editor while loading", () => Game.ScreenStack.CurrentScreen.Exit()); + AddStep("allow editor load", () => loader.AllowLoad.Set()); + AddUntilStep("wait for editor ready", () => loader.Editor!.LoadState >= LoadState.Ready); AddUntilStep("Wait for menu", () => Game.ScreenStack.CurrentScreen is MainMenu); - AddAssert("Check no new beatmaps were made", () => allBeatmapSets().SequenceEqual(beatmapSets)); + AddAssert("Check no new beatmaps were made", allBeatmapSets, () => Is.EquivalentTo(beatmapSets)); BeatmapSetInfo[] allBeatmapSets() => Game.Realm.Run(realm => realm.All().Where(x => !x.DeletePending).ToArray()); } @@ -92,19 +227,8 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestExitEditorWithoutSelection() { - BeatmapSetInfo beatmapSet = null!; - - AddStep("import test beatmap", () => Game.BeatmapManager.Import(TestResources.GetTestBeatmapForImport()).WaitSafely()); - AddStep("retrieve beatmap", () => beatmapSet = Game.BeatmapManager.QueryBeatmapSet(set => !set.Protected).AsNonNull().Value.Detach()); - - AddStep("present beatmap", () => Game.PresentBeatmap(beatmapSet)); - AddUntilStep("wait for song select", - () => Game.Beatmap.Value.BeatmapSetInfo.Equals(beatmapSet) - && Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect - && songSelect.IsLoaded); - - AddStep("open editor", () => ((PlaySongSelect)Game.ScreenStack.CurrentScreen).Edit(beatmapSet.Beatmaps.First(beatmap => beatmap.Ruleset.OnlineID == 0))); - AddUntilStep("wait for editor open", () => Game.ScreenStack.CurrentScreen is Editor editor && editor.ReadyForUse); + prepareBeatmap(); + openEditor(); AddStep("escape once", () => InputManager.Key(Key.Escape)); @@ -114,19 +238,8 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestExitEditorWithSelection() { - BeatmapSetInfo beatmapSet = null!; - - AddStep("import test beatmap", () => Game.BeatmapManager.Import(TestResources.GetTestBeatmapForImport()).WaitSafely()); - AddStep("retrieve beatmap", () => beatmapSet = Game.BeatmapManager.QueryBeatmapSet(set => !set.Protected).AsNonNull().Value.Detach()); - - AddStep("present beatmap", () => Game.PresentBeatmap(beatmapSet)); - AddUntilStep("wait for song select", - () => Game.Beatmap.Value.BeatmapSetInfo.Equals(beatmapSet) - && Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect - && songSelect.IsLoaded); - - AddStep("open editor", () => ((PlaySongSelect)Game.ScreenStack.CurrentScreen).Edit(beatmapSet.Beatmaps.First(beatmap => beatmap.Ruleset.OnlineID == 0))); - AddUntilStep("wait for editor open", () => Game.ScreenStack.CurrentScreen is Editor editor && editor.ReadyForUse); + prepareBeatmap(); + openEditor(); AddStep("make selection", () => { @@ -148,19 +261,8 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestLastTimestampRememberedOnExit() { - BeatmapSetInfo beatmapSet = null!; - - AddStep("import test beatmap", () => Game.BeatmapManager.Import(TestResources.GetTestBeatmapForImport()).WaitSafely()); - AddStep("retrieve beatmap", () => beatmapSet = Game.BeatmapManager.QueryBeatmapSet(set => !set.Protected).AsNonNull().Value.Detach()); - - AddStep("present beatmap", () => Game.PresentBeatmap(beatmapSet)); - AddUntilStep("wait for song select", - () => Game.Beatmap.Value.BeatmapSetInfo.Equals(beatmapSet) - && Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect - && songSelect.IsLoaded); - - AddStep("open editor", () => ((PlaySongSelect)Game.ScreenStack.CurrentScreen).Edit(beatmapSet.Beatmaps.First(beatmap => beatmap.Ruleset.OnlineID == 0))); - AddUntilStep("wait for editor open", () => Game.ScreenStack.CurrentScreen is Editor editor && editor.ReadyForUse); + prepareBeatmap(); + openEditor(); AddStep("seek to arbitrary time", () => getEditor().ChildrenOfType().First().Seek(1234)); AddUntilStep("time is correct", () => getEditor().ChildrenOfType().First().CurrentTime, () => Is.EqualTo(1234)); @@ -168,32 +270,21 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("exit editor", () => InputManager.Key(Key.Escape)); AddUntilStep("wait for editor exit", () => Game.ScreenStack.CurrentScreen is not Editor); - AddStep("open editor", () => ((PlaySongSelect)Game.ScreenStack.CurrentScreen).Edit()); + openEditor(); - AddUntilStep("wait for editor open", () => Game.ScreenStack.CurrentScreen is Editor editor && editor.ReadyForUse); AddUntilStep("time is correct", () => getEditor().ChildrenOfType().First().CurrentTime, () => Is.EqualTo(1234)); } [Test] public void TestAttemptGlobalMusicOperationFromEditor() { - BeatmapSetInfo beatmapSet = null!; - - AddStep("import test beatmap", () => Game.BeatmapManager.Import(TestResources.GetTestBeatmapForImport()).WaitSafely()); - AddStep("retrieve beatmap", () => beatmapSet = Game.BeatmapManager.QueryBeatmapSet(set => !set.Protected).AsNonNull().Value.Detach()); - - AddStep("present beatmap", () => Game.PresentBeatmap(beatmapSet)); - AddUntilStep("wait for song select", - () => Game.Beatmap.Value.BeatmapSetInfo.Equals(beatmapSet) - && Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect - && songSelect.IsLoaded); + prepareBeatmap(); AddUntilStep("wait for music playing", () => Game.MusicController.IsPlaying); AddStep("user request stop", () => Game.MusicController.Stop(requestedByUser: true)); AddUntilStep("wait for music stopped", () => !Game.MusicController.IsPlaying); - AddStep("open editor", () => ((PlaySongSelect)Game.ScreenStack.CurrentScreen).Edit(beatmapSet.Beatmaps.First(beatmap => beatmap.Ruleset.OnlineID == 0))); - AddUntilStep("wait for editor open", () => Game.ScreenStack.CurrentScreen is Editor editor && editor.ReadyForUse); + openEditor(); AddUntilStep("music still stopped", () => !Game.MusicController.IsPlaying); AddStep("user request play", () => Game.MusicController.Play(requestedByUser: true)); @@ -211,20 +302,10 @@ namespace osu.Game.Tests.Visual.Navigation [TestCase(SortMode.Difficulty)] public void TestSelectionRetainedOnExit(SortMode sortMode) { - BeatmapSetInfo beatmapSet = null!; - - AddStep("import test beatmap", () => Game.BeatmapManager.Import(TestResources.GetTestBeatmapForImport()).WaitSafely()); - AddStep("retrieve beatmap", () => beatmapSet = Game.BeatmapManager.QueryBeatmapSet(set => !set.Protected).AsNonNull().Value.Detach()); - AddStep($"set sort mode to {sortMode}", () => Game.LocalConfig.SetValue(OsuSetting.SongSelectSortingMode, sortMode)); - AddStep("present beatmap", () => Game.PresentBeatmap(beatmapSet)); - AddUntilStep("wait for song select", - () => Game.Beatmap.Value.BeatmapSetInfo.Equals(beatmapSet) - && Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect - && songSelect.IsLoaded); - AddStep("open editor", () => ((PlaySongSelect)Game.ScreenStack.CurrentScreen).Edit(beatmapSet.Beatmaps.First(beatmap => beatmap.Ruleset.OnlineID == 0))); - AddUntilStep("wait for editor open", () => Game.ScreenStack.CurrentScreen is Editor editor && editor.ReadyForUse); + prepareBeatmap(); + openEditor(); AddStep("exit editor", () => InputManager.Key(Key.Escape)); AddUntilStep("wait for editor exit", () => Game.ScreenStack.CurrentScreen is not Editor); @@ -241,6 +322,7 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("open editor", () => Game.ChildrenOfType().Single().OnEditBeatmap?.Invoke()); AddUntilStep("wait for editor", () => Game.ScreenStack.CurrentScreen is Editor editor && editor.IsLoaded); + AddStep("click on file", () => { var item = getEditor().ChildrenOfType().Single(i => i.Item.Text.Value.ToString() == "File"); @@ -263,8 +345,54 @@ namespace osu.Game.Tests.Visual.Navigation AddAssert("editor beatmap uses catch ruleset", () => getEditorBeatmap().BeatmapInfo.Ruleset.ShortName == "fruits"); } + private void prepareBeatmap() + { + AddStep("import test beatmap", () => Game.BeatmapManager.Import(TestResources.GetTestBeatmapForImport()).WaitSafely()); + AddStep("retrieve beatmap", () => beatmapSet = Game.BeatmapManager.QueryBeatmapSet(set => !set.Protected).AsNonNull().Value.Detach()); + + AddStep("present beatmap", () => Game.PresentBeatmap(beatmapSet)); + AddUntilStep("wait for song select", + () => Game.Beatmap.Value.BeatmapSetInfo.Equals(beatmapSet) + && Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect + && songSelect.BeatmapSetsLoaded); + } + + private void openEditor() + { + AddStep("open editor", () => ((PlaySongSelect)Game.ScreenStack.CurrentScreen).Edit(beatmapSet.Beatmaps.First(beatmap => beatmap.Ruleset.OnlineID == 0))); + AddUntilStep("wait for editor open", () => Game.ScreenStack.CurrentScreen is Editor editor && editor.ReadyForUse); + } + private EditorBeatmap getEditorBeatmap() => getEditor().ChildrenOfType().Single(); private Editor getEditor() => (Editor)Game.ScreenStack.CurrentScreen; + + private partial class DelayedLoadEditorLoader : EditorLoader + { + public readonly ManualResetEventSlim AllowLoad = new ManualResetEventSlim(); + public Editor? Editor { get; private set; } + + protected override Editor CreateEditor() => Editor = new DelayedLoadEditor(this); + } + + private partial class DelayedLoadEditor : Editor + { + private readonly DelayedLoadEditorLoader loader; + + public DelayedLoadEditor(DelayedLoadEditorLoader loader) + : base(loader) + { + this.loader = loader; + } + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + // Importantly, this occurs before base.load(). + if (!loader.AllowLoad.Wait(TimeSpan.FromSeconds(10))) + throw new TimeoutException(); + + return base.CreateChildDependencies(parent); + } + } } } diff --git a/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs b/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs index c054792168..f036b4b3ef 100644 --- a/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs +++ b/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs @@ -176,6 +176,12 @@ namespace osu.Game.Tests.Visual.Navigation private void confirmBeatmapInSongSelect(Func getImport) { + AddUntilStep("wait for carousel loaded", () => + { + var songSelect = (Screens.Select.SongSelect)Game.ScreenStack.CurrentScreen; + return songSelect.ChildrenOfType().SingleOrDefault()?.IsLoaded == true; + }); + AddUntilStep("beatmap in song select", () => { var songSelect = (Screens.Select.SongSelect)Game.ScreenStack.CurrentScreen; @@ -187,7 +193,7 @@ namespace osu.Game.Tests.Visual.Navigation { AddStep("present beatmap", () => Game.PresentBeatmap(getImport())); - AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is Screens.Select.SongSelect songSelect && songSelect.IsLoaded); + AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is Screens.Select.SongSelect songSelect && songSelect.BeatmapSetsLoaded); AddUntilStep("correct beatmap displayed", () => Game.Beatmap.Value.BeatmapSetInfo.OnlineID, () => Is.EqualTo(getImport().OnlineID)); AddAssert("correct ruleset selected", () => Game.Ruleset.Value, () => Is.EqualTo(getImport().Beatmaps.First().Ruleset)); } @@ -197,7 +203,7 @@ namespace osu.Game.Tests.Visual.Navigation Predicate pred = b => b.OnlineID == importedID * 1024 + 2; AddStep("present difficulty", () => Game.PresentBeatmap(getImport(), pred)); - AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is Screens.Select.SongSelect songSelect && songSelect.IsLoaded); + AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is Screens.Select.SongSelect songSelect && songSelect.BeatmapSetsLoaded); AddUntilStep("correct beatmap displayed", () => Game.Beatmap.Value.BeatmapInfo.OnlineID, () => Is.EqualTo(importedID * 1024 + 2)); AddAssert("correct ruleset selected", () => Game.Ruleset.Value.OnlineID, () => Is.EqualTo(expectedRulesetOnlineID ?? getImport().Beatmaps.First().Ruleset.OnlineID)); } diff --git a/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs b/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs index 004d1de116..2c2335de13 100644 --- a/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs +++ b/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Linq; using NUnit.Framework; @@ -26,7 +24,7 @@ namespace osu.Game.Tests.Visual.Navigation { public partial class TestScenePresentScore : OsuGameTestScene { - private BeatmapSetInfo beatmap; + private BeatmapSetInfo beatmap = null!; [SetUpSteps] public new void SetUpSteps() @@ -64,7 +62,7 @@ namespace osu.Game.Tests.Visual.Navigation Ruleset = new OsuRuleset().RulesetInfo }, } - })?.Value; + })!.Value; }); } @@ -145,6 +143,40 @@ namespace osu.Game.Tests.Visual.Navigation presentAndConfirm(secondImport, type); } + [Test] + public void TestPresentTwoImportsWithSameOnlineIDButDifferentHashes([Values] ScorePresentType type) + { + AddStep("enter song select", () => Game.ChildrenOfType().Single().OnSolo?.Invoke()); + AddUntilStep("song select is current", () => Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect && songSelect.BeatmapSetsLoaded); + + var firstImport = importScore(1); + presentAndConfirm(firstImport, type); + + var secondImport = importScore(1); + presentAndConfirm(secondImport, type); + } + + [Test] + public void TestScoreRefetchIgnoresEmptyHash() + { + AddStep("enter song select", () => Game.ChildrenOfType().Single().OnSolo?.Invoke()); + AddUntilStep("song select is current", () => Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect && songSelect.BeatmapSetsLoaded); + + importScore(-1, hash: string.Empty); + importScore(3, hash: @"deadbeef"); + + // oftentimes a `PresentScore()` call will be given a `ScoreInfo` which is converted from an online score, + // in which cases the hash will generally not be available. + AddStep("present score", () => Game.PresentScore(new ScoreInfo { OnlineID = 3, Hash = string.Empty })); + + AddUntilStep("wait for results", () => lastWaitedScreen != Game.ScreenStack.CurrentScreen && Game.ScreenStack.CurrentScreen is ResultsScreen); + AddUntilStep("correct score displayed", () => + { + var score = ((ResultsScreen)Game.ScreenStack.CurrentScreen).Score!; + return score.OnlineID == 3 && score.Hash == "deadbeef"; + }); + } + private void returnToMenu() { // if we don't pause, there's a chance the track may change at the main menu out of our control (due to reaching the end of the track). @@ -158,14 +190,14 @@ namespace osu.Game.Tests.Visual.Navigation AddUntilStep("wait for menu", () => Game.ScreenStack.CurrentScreen is MainMenu); } - private Func importScore(int i, RulesetInfo ruleset = null) + private Func importScore(int i, RulesetInfo? ruleset = null, string? hash = null) { - ScoreInfo imported = null; + ScoreInfo? imported = null; AddStep($"import score {i}", () => { imported = Game.ScoreManager.Import(new ScoreInfo { - Hash = Guid.NewGuid().ToString(), + Hash = hash ?? Guid.NewGuid().ToString(), OnlineID = i, BeatmapInfo = beatmap.Beatmaps.First(), Ruleset = ruleset ?? new OsuRuleset().RulesetInfo, @@ -175,14 +207,14 @@ namespace osu.Game.Tests.Visual.Navigation AddAssert($"import {i} succeeded", () => imported != null); - return () => imported; + return () => imported!; } /// /// Some tests test waiting for a particular screen twice in a row, but expect a new instance each time. /// There's a case where they may succeed incorrectly if we don't compare against the previous instance. /// - private IScreen lastWaitedScreen; + private IScreen lastWaitedScreen = null!; private void presentAndConfirm(Func getImport, ScorePresentType type) { diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index 0fa2fd4b0b..eda7ce925a 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -47,8 +47,10 @@ using osu.Game.Screens.Select.Carousel; using osu.Game.Screens.Select.Leaderboards; using osu.Game.Screens.Select.Options; using osu.Game.Tests.Beatmaps.IO; +using osu.Game.Utils; using osuTK; using osuTK.Input; +using SharpCompress; namespace osu.Game.Tests.Visual.Navigation { @@ -142,6 +144,28 @@ namespace osu.Game.Tests.Visual.Navigation exitViaEscapeAndConfirm(); } + [Test] + public void TestEnterGameplayWhileFilteringToNoSelection() + { + TestPlaySongSelect songSelect = null; + + PushAndConfirm(() => songSelect = new TestPlaySongSelect()); + AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); + AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); + AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); + + AddStep("force selection", () => + { + songSelect.FinaliseSelection(); + songSelect.FilterControl.CurrentTextSearch.Value = "test"; + }); + + AddUntilStep("wait for player", () => !songSelect.IsCurrentScreen()); + AddStep("return to song select", () => songSelect.MakeCurrent()); + + AddUntilStep("wait for selection lost", () => songSelect.Beatmap.IsDefault); + } + [Test] public void TestSongSelectBackActionHandling() { @@ -239,11 +263,14 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("change beatmap files", () => { - foreach (var file in Game.Beatmap.Value.BeatmapSetInfo.Files.Where(f => Path.GetExtension(f.Filename) == ".osu")) + FileUtils.AttemptOperation(() => { - using (var stream = Game.Storage.GetStream(Path.Combine("files", file.File.GetStoragePath()), FileAccess.ReadWrite)) - stream.WriteByte(0); - } + foreach (var file in Game.Beatmap.Value.BeatmapSetInfo.Files.Where(f => Path.GetExtension(f.Filename) == ".osu")) + { + using (var stream = Game.Storage.GetStream(Path.Combine("files", file.File.GetStoragePath()), FileAccess.ReadWrite)) + stream.WriteByte(0); + } + }); }); AddStep("invalidate cache", () => @@ -271,8 +298,11 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("delete beatmap files", () => { - foreach (var file in Game.Beatmap.Value.BeatmapSetInfo.Files.Where(f => Path.GetExtension(f.Filename) == ".osu")) - Game.Storage.Delete(Path.Combine("files", file.File.GetStoragePath())); + FileUtils.AttemptOperation(() => + { + foreach (var file in Game.Beatmap.Value.BeatmapSetInfo.Files.Where(f => Path.GetExtension(f.Filename) == ".osu")) + Game.Storage.Delete(Path.Combine("files", file.File.GetStoragePath())); + }); }); AddStep("invalidate cache", () => @@ -837,21 +867,25 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestExitWithOperationInProgress() { - AddUntilStep("wait for dialog overlay", () => Game.ChildrenOfType().SingleOrDefault() != null); + int x = 0; - ProgressNotification progressNotification = null!; - - AddStep("start ongoing operation", () => + AddUntilStep("wait for dialog overlay", () => { - progressNotification = new ProgressNotification - { - Text = "Something is still running", - Progress = 0.5f, - State = ProgressNotificationState.Active, - }; - Game.Notifications.Post(progressNotification); + x = 0; + return Game.ChildrenOfType().SingleOrDefault() != null; }); + AddRepeatStep("start ongoing operation", () => + { + Game.Notifications.Post(new ProgressNotification + { + Text = $"Something is still running #{++x}", + Progress = 0.5f, + State = ProgressNotificationState.Active, + }); + }, 15); + + AddAssert("all notifications = 15", () => Game.Notifications.AllNotifications.Count(), () => Is.EqualTo(15)); AddStep("Hold escape", () => InputManager.PressKey(Key.Escape)); AddUntilStep("confirmation dialog shown", () => Game.ChildrenOfType().Single().CurrentDialog is ConfirmExitDialog); AddStep("Release escape", () => InputManager.ReleaseKey(Key.Escape)); @@ -861,8 +895,11 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("complete operation", () => { - progressNotification.Progress = 100; - progressNotification.State = ProgressNotificationState.Completed; + this.ChildrenOfType().ForEach(n => + { + n.Progress = 100; + n.State = ProgressNotificationState.Completed; + }); }); AddStep("Hold escape", () => InputManager.PressKey(Key.Escape)); @@ -878,7 +915,7 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("set hold delay to 0", () => Game.LocalConfig.SetValue(OsuSetting.UIHoldActivationDelay, 0.0)); AddUntilStep("wait for dialog overlay", () => Game.ChildrenOfType().SingleOrDefault() != null); - AddStep("start ongoing operation", () => + AddRepeatStep("start ongoing operation", () => { Game.Notifications.Post(new ProgressNotification { @@ -886,7 +923,7 @@ namespace osu.Game.Tests.Visual.Navigation Progress = 0.5f, State = ProgressNotificationState.Active, }); - }); + }, 15); AddRepeatStep("attempt force exit", () => Game.ScreenStack.CurrentScreen.Exit(), 2); AddUntilStep("stopped at exit confirm", () => Game.ChildrenOfType().Single().CurrentDialog is ConfirmExitDialog); @@ -944,6 +981,8 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestTouchScreenDetectionAtSongSelect() { + AddUntilStep("wait for settings", () => Game.Settings.IsLoaded); + AddStep("touch logo", () => { var button = Game.ChildrenOfType().Single(); @@ -1018,9 +1057,11 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestTouchScreenDetectionInGame() { + BeatmapSetInfo beatmapSet = null; + PushAndConfirm(() => new TestPlaySongSelect()); - AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); - AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); + AddStep("import beatmap", () => beatmapSet = BeatmapImportHelper.LoadQuickOszIntoOsu(Game).GetResultSafely()); + AddUntilStep("wait for selected", () => Game.Beatmap.Value.BeatmapSetInfo.Equals(beatmapSet)); AddStep("select", () => InputManager.Key(Key.Enter)); Player player = null; diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs index 9c180d43da..5267a57a05 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs @@ -3,6 +3,7 @@ #nullable disable +using System; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -321,6 +322,30 @@ namespace osu.Game.Tests.Visual.Navigation AddUntilStep("nested input disabled", () => ((Player)Game.ScreenStack.CurrentScreen).ChildrenOfType().All(manager => !manager.UseParentInput)); } + [Test] + public void TestSkinSavesOnChange() + { + advanceToSongSelect(); + openSkinEditor(); + + Guid editedSkinId = Guid.Empty; + AddStep("save skin id", () => editedSkinId = Game.Dependencies.Get().CurrentSkinInfo.Value.ID); + AddStep("add skinnable component", () => + { + skinEditor.ChildrenOfType().First().TriggerClick(); + }); + + AddStep("change to triangles skin", () => Game.Dependencies.Get().SetSkinFromConfiguration(SkinInfo.TRIANGLES_SKIN.ToString())); + AddUntilStep("components loaded", () => Game.ChildrenOfType().All(c => c.ComponentsLoaded)); + // sort of implicitly relies on song select not being skinnable. + // TODO: revisit if the above ever changes + AddUntilStep("skin changed", () => !skinEditor.ChildrenOfType().Any()); + + AddStep("change back to modified skin", () => Game.Dependencies.Get().SetSkinFromConfiguration(editedSkinId.ToString())); + AddUntilStep("components loaded", () => Game.ChildrenOfType().All(c => c.ComponentsLoaded)); + AddUntilStep("changes saved", () => skinEditor.ChildrenOfType().Any()); + } + private void advanceToSongSelect() { PushAndConfirm(() => songSelect = new TestPlaySongSelect()); diff --git a/osu.Game.Tests/Visual/Online/TestSceneChannelList.cs b/osu.Game.Tests/Visual/Online/TestSceneChannelList.cs index a0cca5f53d..5f77e084da 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChannelList.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChannelList.cs @@ -9,13 +9,13 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; -using osu.Framework.Utils; using osu.Game.Graphics.Sprites; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Chat; using osu.Game.Overlays; using osu.Game.Overlays.Chat.ChannelList; using osu.Game.Overlays.Chat.Listing; +using osu.Game.Tests.Resources; namespace osu.Game.Tests.Visual.Online { @@ -160,7 +160,7 @@ namespace osu.Game.Tests.Visual.Online private Channel createRandomPublicChannel() { - int id = RNG.Next(0, 10000); + int id = TestResources.GetNextTestID(); return new Channel { Name = $"#channel-{id}", @@ -171,7 +171,7 @@ namespace osu.Game.Tests.Visual.Online private Channel createRandomPrivateChannel() { - int id = RNG.Next(0, 10000); + int id = TestResources.GetNextTestID(); return new Channel(new APIUser { Id = id, @@ -181,7 +181,7 @@ namespace osu.Game.Tests.Visual.Online private Channel createRandomAnnounceChannel() { - int id = RNG.Next(0, 10000); + int id = TestResources.GetNextTestID(); return new Channel { Name = $"Announce {id}", diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs index 58feab4ebb..3d6fe50d34 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs @@ -19,7 +19,6 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Input; using osu.Framework.Logging; using osu.Framework.Testing; -using osu.Framework.Utils; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; @@ -33,6 +32,7 @@ using osu.Game.Overlays.Chat.ChannelList; using osuTK; using osuTK.Input; using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Tests.Resources; namespace osu.Game.Tests.Visual.Online { @@ -122,7 +122,7 @@ namespace osu.Game.Tests.Visual.Online return true; case PostMessageRequest postMessage: - postMessage.TriggerSuccess(new Message(RNG.Next(0, 10000000)) + postMessage.TriggerSuccess(new Message(TestResources.GetNextTestID()) { Content = postMessage.Message.Content, ChannelId = postMessage.Message.ChannelId, @@ -446,7 +446,7 @@ namespace osu.Game.Tests.Visual.Online { AddStep("Show overlay with channel 1", () => { - channelManager.JoinChannel(testChannel1); + channelManager.CurrentChannel.Value = channelManager.JoinChannel(testChannel1); chatOverlay.Show(); }); waitForChannel1Visible(); @@ -462,7 +462,7 @@ namespace osu.Game.Tests.Visual.Online { AddStep("Show overlay with channel 1", () => { - channelManager.JoinChannel(testChannel1); + channelManager.CurrentChannel.Value = channelManager.JoinChannel(testChannel1); chatOverlay.Show(); }); waitForChannel1Visible(); @@ -648,6 +648,34 @@ namespace osu.Game.Tests.Visual.Online AddUntilStep("Info message displayed", () => channelManager.CurrentChannel.Value.Messages.Last(), () => Is.InstanceOf(typeof(InfoMessage))); } + [Test] + public void TestFiltering() + { + AddStep("Show overlay", () => chatOverlay.Show()); + joinTestChannel(1); + joinTestChannel(3); + joinTestChannel(5); + joinChannel(new Channel(new APIUser { Id = 2001, Username = "alice" })); + joinChannel(new Channel(new APIUser { Id = 2002, Username = "bob" })); + joinChannel(new Channel(new APIUser { Id = 2003, Username = "charley the plant" })); + + AddStep("filter to \"c\"", () => chatOverlay.ChildrenOfType().Single().Text = "c"); + AddUntilStep("bob filtered out", () => chatOverlay.ChildrenOfType().Count(i => i.Alpha > 0), () => Is.EqualTo(5)); + + AddStep("filter to \"channel\"", () => chatOverlay.ChildrenOfType().Single().Text = "channel"); + AddUntilStep("only public channels left", () => chatOverlay.ChildrenOfType().Count(i => i.Alpha > 0), () => Is.EqualTo(3)); + + AddStep("commit textbox", () => + { + chatOverlay.ChildrenOfType().Single().TakeFocus(); + Schedule(() => InputManager.PressKey(Key.Enter)); + }); + AddUntilStep("#channel-2 active", () => channelManager.CurrentChannel.Value.Name, () => Is.EqualTo("#channel-2")); + + AddStep("filter to \"channel-3\"", () => chatOverlay.ChildrenOfType().Single().Text = "channel-3"); + AddUntilStep("no channels left", () => chatOverlay.ChildrenOfType().Count(i => i.Alpha > 0), () => Is.EqualTo(0)); + } + private void joinTestChannel(int i) { AddStep($"Join test channel {i}", () => channelManager.JoinChannel(testChannels[i])); @@ -719,7 +747,8 @@ namespace osu.Game.Tests.Visual.Online private Channel createPrivateChannel() { - int id = RNG.Next(0, DummyAPIAccess.DUMMY_USER_ID - 1); + int id = TestResources.GetNextTestID(); + return new Channel(new APIUser { Id = id, diff --git a/osu.Game.Tests/Visual/Online/TestSceneCommentsContainer.cs b/osu.Game.Tests/Visual/Online/TestSceneCommentsContainer.cs index fd3552f675..eb805b27cb 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneCommentsContainer.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneCommentsContainer.cs @@ -17,6 +17,7 @@ using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.Comments; +using osu.Game.Overlays.Comments.Buttons; namespace osu.Game.Tests.Visual.Online { @@ -58,6 +59,11 @@ namespace osu.Game.Tests.Visual.Online AddStep("show comments", () => commentsContainer.ShowComments(CommentableType.Beatmapset, 123)); AddUntilStep("show more button hidden", () => commentsContainer.ChildrenOfType().Single().Alpha == 0); + + if (withPinned) + AddAssert("pinned comment replies collapsed", () => commentsContainer.ChildrenOfType().First().Expanded.Value, () => Is.False); + else + AddAssert("first comment replies expanded", () => commentsContainer.ChildrenOfType().First().Expanded.Value, () => Is.True); } [TestCase(false)] @@ -157,6 +163,7 @@ namespace osu.Game.Tests.Visual.Online { setUpCommentsResponse(getExampleComments()); AddStep("show comments", () => commentsContainer.ShowComments(CommentableType.Beatmapset, 123)); + AddUntilStep("comments shown", () => commentsContainer.ChildrenOfType().Any()); setUpPostResponse(); AddStep("enter text", () => editorTextBox.Current.Value = "comm"); @@ -175,6 +182,7 @@ namespace osu.Game.Tests.Visual.Online { setUpCommentsResponse(getExampleComments()); AddStep("show comments", () => commentsContainer.ShowComments(CommentableType.Beatmapset, 123)); + AddUntilStep("comments shown", () => commentsContainer.ChildrenOfType().Any()); setUpPostResponse(true); AddStep("enter text", () => editorTextBox.Current.Value = "comm"); @@ -300,7 +308,7 @@ namespace osu.Game.Tests.Visual.Online bundle.Comments.Add(new Comment { Id = 20, - Message = "Reply to pinned comment", + Message = "Reply to pinned comment initially hidden", LegacyName = "AbandonedUser", CreatedAt = DateTimeOffset.Now, VotesCount = 0, 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/TestSceneDrawableChannel.cs b/osu.Game.Tests/Visual/Online/TestSceneDrawableChannel.cs index 4830c7b856..6a077708e3 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneDrawableChannel.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneDrawableChannel.cs @@ -6,6 +6,7 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Testing; +using osu.Framework.Utils; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Chat; using osu.Game.Overlays.Chat; @@ -32,6 +33,34 @@ namespace osu.Game.Tests.Visual.Online }); } + [Test] + public void TestMention() + { + AddStep("add normal message", () => channel.AddNewMessages( + new Message(1) + { + Sender = new APIUser + { + Id = 2, + Username = "TestUser2" + }, + Content = "Hello how are you today?", + Timestamp = new DateTimeOffset(2021, 12, 11, 13, 33, 24, TimeSpan.Zero) + })); + + AddStep("add mention", () => channel.AddNewMessages( + new Message(2) + { + Sender = new APIUser + { + Id = 2, + Username = "TestUser2" + }, + Content = $"Hello {API.LocalUser.Value.Username} how are you today?", + Timestamp = new DateTimeOffset(2021, 12, 11, 13, 33, 25, TimeSpan.Zero) + })); + } + [Test] public void TestDaySeparators() { @@ -40,6 +69,7 @@ namespace osu.Game.Tests.Visual.Online Id = 3, Username = "LocalUser" }; + string uuid = Guid.NewGuid().ToString(); AddStep("add local echo message", () => channel.AddLocalEcho(new LocalEchoMessage { @@ -83,5 +113,38 @@ namespace osu.Game.Tests.Visual.Online AddUntilStep("three day separators present", () => drawableChannel.ChildrenOfType().Count() == 3); AddAssert("last day separator is from correct day", () => drawableChannel.ChildrenOfType().Last().Date.Date == new DateTime(2022, 11, 22)); } + + [Test] + public void TestBackgroundAlternating() + { + int messageCount = 1; + + AddRepeatStep("add messages", () => + { + channel.AddNewMessages(new Message(messageCount) + { + Sender = new APIUser + { + Id = 3, + Username = "LocalUser " + RNG.Next(0, int.MaxValue - 100).ToString("N") + }, + Content = "Hi there all!", + Timestamp = new DateTimeOffset(2022, 11, 21, 20, messageCount, 13, TimeSpan.Zero), + Uuid = Guid.NewGuid().ToString(), + }); + messageCount++; + }, 10); + + AddUntilStep("10 message present", () => drawableChannel.ChildrenOfType().Count() == 10); + + int checkCount = 0; + + AddRepeatStep("check background", () => + { + // +1 because the day separator take one index + Assert.AreEqual((checkCount + 1) % 2 == 0, drawableChannel.ChildrenOfType().ToList()[checkCount].AlternatingBackground); + checkCount++; + }, 10); + } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneScoresContainer.cs b/osu.Game.Tests/Visual/Online/TestSceneScoresContainer.cs index 33f4d577bd..ad0c5f9247 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneScoresContainer.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneScoresContainer.cs @@ -8,11 +8,13 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Testing; using osu.Framework.Utils; +using osu.Game.Graphics.Sprites; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; @@ -90,6 +92,48 @@ namespace osu.Game.Tests.Visual.Online AddAssert("no best score displayed", () => scoresContainer.ChildrenOfType().Count() == 1); } + [Test] + public void TestHitResultsWithSameNameAreGrouped() + { + AddStep("Load scores without user best", () => + { + var allScores = createScores(); + allScores.UserScore = null; + scoresContainer.Scores = allScores; + }); + + AddUntilStep("wait for scores displayed", () => scoresContainer.ChildrenOfType().Any()); + AddAssert("only one column for slider end", () => + { + ScoreTable scoreTable = scoresContainer.ChildrenOfType().First(); + return scoreTable.Columns.Count(c => c.Header.Equals("slider end")) == 1; + }); + + AddAssert("all rows show non-zero slider ends", () => + { + ScoreTable scoreTable = scoresContainer.ChildrenOfType().First(); + int sliderEndColumnIndex = Array.FindIndex(scoreTable.Columns, c => c != null && c.Header.Equals("slider end")); + bool sliderEndFilledInEachRow = true; + + for (int i = 0; i < scoreTable.Content?.GetLength(0); i++) + { + switch (scoreTable.Content[i, sliderEndColumnIndex]) + { + case OsuSpriteText text: + if (text.Text.Equals(0.0d.ToLocalisableString(@"N0"))) + sliderEndFilledInEachRow = false; + break; + + default: + sliderEndFilledInEachRow = false; + break; + } + } + + return sliderEndFilledInEachRow; + }); + } + [Test] public void TestUserBest() { @@ -103,6 +147,18 @@ namespace osu.Game.Tests.Visual.Online AddUntilStep("wait for scores displayed", () => scoresContainer.ChildrenOfType().Any()); AddAssert("best score displayed", () => scoresContainer.ChildrenOfType().Count() == 2); + AddStep("Load scores with personal best FC", () => + { + var allScores = createScores(); + allScores.UserScore = createUserBest(); + allScores.UserScore.Score.Accuracy = 1; + scoresContainer.Beatmap.Value.MaxCombo = allScores.UserScore.Score.MaxCombo = 1337; + scoresContainer.Scores = allScores; + }); + + AddUntilStep("wait for scores displayed", () => scoresContainer.ChildrenOfType().Any()); + AddAssert("best score displayed", () => scoresContainer.ChildrenOfType().Count() == 2); + AddStep("Load scores with personal best (null position)", () => { var allScores = createScores(); @@ -287,13 +343,17 @@ namespace osu.Game.Tests.Visual.Online const int initial_great_count = 2000; const int initial_tick_count = 100; + const int initial_slider_end_count = 500; int greatCount = initial_great_count; int tickCount = initial_tick_count; + int sliderEndCount = initial_slider_end_count; - foreach (var s in scores.Scores) + foreach (var (score, index) in scores.Scores.Select((s, i) => (s, i))) { - s.Statistics = new Dictionary + HitResult sliderEndResult = index % 2 == 0 ? HitResult.SliderTailHit : HitResult.SmallTickHit; + + score.Statistics = new Dictionary { { HitResult.Great, greatCount }, { HitResult.LargeTickHit, tickCount }, @@ -301,10 +361,19 @@ namespace osu.Game.Tests.Visual.Online { HitResult.Meh, RNG.Next(100) }, { HitResult.Miss, initial_great_count - greatCount }, { HitResult.LargeTickMiss, initial_tick_count - tickCount }, + { sliderEndResult, sliderEndCount }, + }; + + // Some hit results, including SliderTailHit and SmallTickHit, are only displayed + // when the maximum number is known + score.MaximumStatistics = new Dictionary + { + { sliderEndResult, initial_slider_end_count }, }; greatCount -= 100; tickCount -= RNG.Next(1, 5); + sliderEndCount -= 20; } return scores; diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs b/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs index 91942c391a..ab3d5907ef 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs @@ -55,21 +55,21 @@ namespace osu.Game.Tests.Visual.Online { Username = @"flyte", Id = 3103765, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c6.jpg" + CoverUrl = @"https://assets.ppy.sh/user-cover-presets/1/df28696b58541a9e67f6755918951d542d93bdf1da41720fcca2fd2c1ea8cf51.jpeg", }), new UserBrickPanel(new APIUser { Username = @"peppy", Id = 2, Colour = "99EB47", - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg", }), new UserGridPanel(new APIUser { Username = @"flyte", Id = 3103765, CountryCode = CountryCode.JP, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c6.jpg", + CoverUrl = @"https://assets.ppy.sh/user-cover-presets/1/df28696b58541a9e67f6755918951d542d93bdf1da41720fcca2fd2c1ea8cf51.jpeg", Status = { Value = UserStatus.Online } }) { Width = 300 }, boundPanel1 = new UserGridPanel(new APIUser @@ -77,7 +77,7 @@ namespace osu.Game.Tests.Visual.Online Username = @"peppy", Id = 2, CountryCode = CountryCode.AU, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg", IsSupporter = true, SupportLevel = 3, }) { Width = 300 }, @@ -95,7 +95,7 @@ namespace osu.Game.Tests.Visual.Online Username = @"flyte", Id = 3103765, CountryCode = CountryCode.JP, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c6.jpg", + CoverUrl = @"https://assets.ppy.sh/user-cover-presets/1/df28696b58541a9e67f6755918951d542d93bdf1da41720fcca2fd2c1ea8cf51.jpeg", Statistics = new UserStatistics { GlobalRank = 12345, CountryRank = 1234 } }) { Width = 300 }, new UserRankPanel(new APIUser @@ -104,7 +104,7 @@ namespace osu.Game.Tests.Visual.Online Id = 2, Colour = "99EB47", CountryCode = CountryCode.AU, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg", Statistics = new UserStatistics { GlobalRank = null, CountryRank = null } }) { Width = 300 } } @@ -168,6 +168,14 @@ namespace osu.Game.Tests.Visual.Online CountryRank = RNG.Next(100000) }); }); + AddStep("set statistics to something big", () => + { + API.UpdateStatistics(new UserStatistics + { + GlobalRank = RNG.Next(1_000_000, 100_000_000), + CountryRank = RNG.Next(1_000_000, 100_000_000) + }); + }); AddStep("set statistics to empty", () => { API.UpdateStatistics(new UserStatistics()); diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileDailyChallenge.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileDailyChallenge.cs new file mode 100644 index 0000000000..9db30380f6 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileDailyChallenge.cs @@ -0,0 +1,73 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +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.Scoring; +using osuTK; + +namespace osu.Game.Tests.Visual.Online +{ + public partial class TestSceneUserProfileDailyChallenge : OsuManualInputManagerTestScene + { + [Cached] + public readonly Bindable User = new Bindable(new UserProfileData(new APIUser(), new OsuRuleset().RulesetInfo)); + + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink); + + protected override void LoadComplete() + { + base.LoadComplete(); + + DailyChallengeStatsDisplay display = null!; + + AddSliderStep("daily", 0, 999, 2, v => update(s => s.DailyStreakCurrent = v)); + AddSliderStep("daily best", 0, 999, 2, v => update(s => s.DailyStreakBest = v)); + AddSliderStep("weekly", 0, 250, 1, v => update(s => s.WeeklyStreakCurrent = v)); + AddSliderStep("weekly best", 0, 250, 1, v => update(s => s.WeeklyStreakBest = v)); + AddSliderStep("top 10%", 0, 999, 0, v => update(s => s.Top10PercentPlacements = v)); + AddSliderStep("top 50%", 0, 999, 0, v => update(s => s.Top50PercentPlacements = v)); + AddSliderStep("playcount", 0, 999, 0, v => update(s => s.PlayCount = v)); + AddStep("create", () => + { + Clear(); + Add(new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background2, + }); + Add(display = new DailyChallengeStatsDisplay + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(1f), + User = { BindTarget = User }, + }); + }); + AddStep("hover", () => InputManager.MoveMouseTo(display)); + } + + private void update(Action change) + { + change.Invoke(User.Value!.User.DailyChallengeStatistics); + User.Value = new UserProfileData(User.Value.User, User.Value.Ruleset); + } + + [Test] + public void TestPlayCountRankingTier() + { + AddAssert("1 before silver", () => DailyChallengeStatsTooltip.TierForPlayCount(30) == RankingTier.Bronze); + AddAssert("first silver", () => DailyChallengeStatsTooltip.TierForPlayCount(31) == RankingTier.Silver); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs index c9e5a3315c..6167d1f760 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs @@ -3,15 +3,20 @@ using System; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Testing; using osu.Game.Configuration; using osu.Game.Graphics.Containers; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.Profile; +using osu.Game.Overlays.Profile.Header.Components; using osu.Game.Rulesets.Osu; using osu.Game.Users; @@ -22,6 +27,10 @@ namespace osu.Game.Tests.Visual.Online [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); + private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; + + private readonly ManualResetEventSlim requestLock = new ManualResetEventSlim(); + [Resolved] private OsuConfigManager configManager { get; set; } = null!; @@ -400,5 +409,97 @@ namespace osu.Game.Tests.Visual.Online } }, new OsuRuleset().RulesetInfo)); } + + private APIUser nonFriend => new APIUser + { + Id = 727, + Username = "Whatever", + }; + + [Test] + public void TestAddFriend() + { + AddStep("Setup request", () => + { + requestLock.Reset(); + + dummyAPI.HandleRequest = request => + { + if (request is not AddFriendRequest req) + return false; + + if (req.TargetId != nonFriend.OnlineID) + return false; + + var apiRelation = new APIRelation + { + TargetID = nonFriend.OnlineID, + Mutual = true, + RelationType = RelationType.Friend, + TargetUser = nonFriend + }; + + Task.Run(() => + { + requestLock.Wait(3000); + dummyAPI.Friends.Add(apiRelation); + req.TriggerSuccess(new AddFriendResponse + { + UserRelation = apiRelation + }); + }); + + return true; + }; + }); + AddStep("clear friend list", () => dummyAPI.Friends.Clear()); + AddStep("Show non-friend user", () => header.User.Value = new UserProfileData(nonFriend, new OsuRuleset().RulesetInfo)); + AddStep("Click followers button", () => this.ChildrenOfType().First().TriggerClick()); + AddStep("Complete request", () => requestLock.Set()); + AddUntilStep("Friend added", () => API.Friends.Any(f => f.TargetID == nonFriend.OnlineID)); + } + + [Test] + public void TestAddFriendNonMutual() + { + AddStep("Setup request", () => + { + requestLock.Reset(); + + dummyAPI.HandleRequest = request => + { + if (request is not AddFriendRequest req) + return false; + + if (req.TargetId != nonFriend.OnlineID) + return false; + + var apiRelation = new APIRelation + { + TargetID = nonFriend.OnlineID, + Mutual = false, + RelationType = RelationType.Friend, + TargetUser = nonFriend + }; + + Task.Run(() => + { + requestLock.Wait(3000); + dummyAPI.Friends.Add(apiRelation); + req.TriggerSuccess(new AddFriendResponse + { + UserRelation = apiRelation + }); + }); + + return true; + }; + }); + AddStep("clear friend list", () => dummyAPI.Friends.Clear()); + AddStep("Show non-friend user", () => header.User.Value = new UserProfileData(nonFriend, new OsuRuleset().RulesetInfo)); + AddStep("Click followers button", () => this.ChildrenOfType().First().TriggerClick()); + AddStep("Complete request", () => requestLock.Set()); + AddUntilStep("Friend added", () => API.Friends.Any(f => f.TargetID == nonFriend.OnlineID)); + } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs index fa68c931d8..006610dccd 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs @@ -4,12 +4,14 @@ using System; using System.Linq; using NUnit.Framework; +using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; 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.Rulesets.Taiko; using osu.Game.Users; namespace osu.Game.Tests.Visual.Online @@ -24,7 +26,17 @@ namespace osu.Game.Tests.Visual.Online [SetUpSteps] public void SetUp() { - AddStep("create profile overlay", () => Child = profile = new UserProfileOverlay()); + AddStep("create profile overlay", () => + { + profile = new UserProfileOverlay(); + + Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = new (Type, object)[] { (typeof(UserProfileOverlay), profile) }, + Child = profile, + }; + }); } [Test] @@ -111,6 +123,103 @@ namespace osu.Game.Tests.Visual.Online AddStep("complete request", () => pendingRequest.TriggerSuccess(TEST_USER)); } + [Test] + public void TestCustomColourScheme() + { + int hue = 0; + + AddSliderStep("hue", 0, 360, 222, h => hue = h); + + AddStep("set up request handling", () => + { + dummyAPI.HandleRequest = req => + { + if (req is GetUserRequest getUserRequest) + { + getUserRequest.TriggerSuccess(new APIUser + { + Username = $"Colorful #{hue}", + Id = 1, + CountryCode = CountryCode.JP, + CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c2.jpg", + ProfileHue = hue, + PlayMode = "osu", + }); + return true; + } + + return false; + }; + }); + + AddStep("show user", () => profile.ShowUser(new APIUser { Id = 1 })); + } + + [Test] + public void TestCustomColourSchemeWithReload() + { + int hue = 0; + GetUserRequest pendingRequest = null!; + + AddSliderStep("hue", 0, 360, 222, h => hue = h); + + AddStep("set up request handling", () => + { + dummyAPI.HandleRequest = req => + { + if (req is GetUserRequest getUserRequest) + { + pendingRequest = getUserRequest; + return true; + } + + return false; + }; + }); + + AddStep("show user", () => profile.ShowUser(new APIUser { Id = 1 })); + + AddWaitStep("wait some", 3); + AddStep("complete request", () => pendingRequest.TriggerSuccess(new APIUser + { + Username = $"Colorful #{hue}", + Id = 1, + CountryCode = CountryCode.JP, + CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c2.jpg", + ProfileHue = hue, + PlayMode = "osu", + })); + + int hue2 = 0; + + AddSliderStep("hue 2", 0, 360, 50, h => hue2 = h); + AddStep("show user", () => profile.ShowUser(new APIUser { Id = 2 })); + AddWaitStep("wait some", 3); + + AddStep("complete request", () => pendingRequest.TriggerSuccess(new APIUser + { + Username = $"Colorful #{hue2}", + Id = 2, + CountryCode = CountryCode.JP, + CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c2.jpg", + ProfileHue = hue2, + PlayMode = "osu", + })); + + AddStep("show user different ruleset", () => profile.ShowUser(new APIUser { Id = 2 }, new TaikoRuleset().RulesetInfo)); + AddWaitStep("wait some", 3); + + AddStep("complete request", () => pendingRequest.TriggerSuccess(new APIUser + { + Username = $"Colorful #{hue2}", + Id = 2, + CountryCode = CountryCode.JP, + CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c2.jpg", + ProfileHue = hue2, + PlayMode = "osu", + })); + } + public static readonly APIUser TEST_USER = new APIUser { Username = @"Somebody", @@ -201,6 +310,15 @@ namespace osu.Game.Tests.Visual.Online ImageUrlLowRes = "https://assets.ppy.sh/profile-badges/contributor.png", }, }, + DailyChallengeStatistics = new APIUserDailyChallengeStatistics + { + DailyStreakCurrent = 231, + WeeklyStreakCurrent = 18, + DailyStreakBest = 370, + WeeklyStreakBest = 51, + Top10PercentPlacements = 345, + Top50PercentPlacements = 427, + }, Title = "osu!volunteer", Colour = "ff0000", Achievements = Array.Empty(), diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs index fca965052f..7527647b9c 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs @@ -135,7 +135,7 @@ namespace osu.Game.Tests.Visual.Playlists AddAssert("right loading spinner shown", () => resultsScreen.RightSpinner.State.Value == Visibility.Visible); waitForDisplay(); - AddAssert($"count increased by {scores_per_result}", () => this.ChildrenOfType().Count() >= beforePanelCount + scores_per_result); + AddAssert($"count increased by {scores_per_result}", () => this.ChildrenOfType().Count() == beforePanelCount + scores_per_result); AddAssert("right loading spinner hidden", () => resultsScreen.RightSpinner.State.Value == Visibility.Hidden); } } @@ -156,7 +156,7 @@ namespace osu.Game.Tests.Visual.Playlists AddAssert("right loading spinner shown", () => resultsScreen.RightSpinner.State.Value == Visibility.Visible); waitForDisplay(); - AddAssert($"count increased by {scores_per_result}", () => this.ChildrenOfType().Count() >= beforePanelCount + scores_per_result); + AddAssert($"count increased by {scores_per_result}", () => this.ChildrenOfType().Count() == beforePanelCount + scores_per_result); AddAssert("right loading spinner hidden", () => resultsScreen.RightSpinner.State.Value == Visibility.Hidden); AddStep("get panel count", () => beforePanelCount = this.ChildrenOfType().Count()); @@ -191,7 +191,7 @@ namespace osu.Game.Tests.Visual.Playlists AddAssert("left loading spinner shown", () => resultsScreen.LeftSpinner.State.Value == Visibility.Visible); waitForDisplay(); - AddAssert($"count increased by {scores_per_result}", () => this.ChildrenOfType().Count() >= beforePanelCount + scores_per_result); + AddAssert($"count increased by {scores_per_result}", () => this.ChildrenOfType().Count() == beforePanelCount + scores_per_result); AddAssert("left loading spinner hidden", () => resultsScreen.LeftSpinner.State.Value == Visibility.Hidden); } } @@ -413,7 +413,7 @@ namespace osu.Game.Tests.Visual.Playlists }; } - private partial class TestResultsScreen : PlaylistsResultsScreen + private partial class TestResultsScreen : PlaylistItemUserResultsScreen { public new LoadingSpinner LeftSpinner => base.LeftSpinner; public new LoadingSpinner CentreSpinner => base.CentreSpinner; diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneCollectionButton.cs b/osu.Game.Tests/Visual/Ranking/TestSceneCollectionButton.cs new file mode 100644 index 0000000000..5b6721bc0f --- /dev/null +++ b/osu.Game.Tests/Visual/Ranking/TestSceneCollectionButton.cs @@ -0,0 +1,67 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Screens.Ranking; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Ranking +{ + public partial class TestSceneCollectionButton : OsuManualInputManagerTestScene + { + private CollectionButton? collectionButton; + private readonly BeatmapInfo beatmapInfo = new BeatmapInfo { OnlineID = 88 }; + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create button", () => Child = new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Child = collectionButton = new CollectionButton(beatmapInfo) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + }); + } + + [Test] + public void TestCollectionButton() + { + AddStep("click collection button", () => + { + InputManager.MoveMouseTo(collectionButton!); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("collection popover is visible", () => this.ChildrenOfType().Single().State.Value == Visibility.Visible); + + AddStep("click outside popover", () => + { + InputManager.MoveMouseTo(ScreenSpaceDrawQuad.TopLeft); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("collection popover is hidden", () => this.ChildrenOfType().Single().State.Value == Visibility.Hidden); + + AddStep("click collection button", () => + { + InputManager.MoveMouseTo(collectionButton!); + InputManager.Click(MouseButton.Left); + }); + + AddStep("press escape", () => InputManager.Key(Key.Escape)); + + AddAssert("collection popover is hidden", () => this.ChildrenOfType().Single().State.Value == Visibility.Hidden); + } + } +} diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneFavouriteButton.cs b/osu.Game.Tests/Visual/Ranking/TestSceneFavouriteButton.cs new file mode 100644 index 0000000000..77a63a3995 --- /dev/null +++ b/osu.Game.Tests/Visual/Ranking/TestSceneFavouriteButton.cs @@ -0,0 +1,82 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Screens.Ranking; + +namespace osu.Game.Tests.Visual.Ranking +{ + public partial class TestSceneFavouriteButton : OsuTestScene + { + private FavouriteButton? favourite; + + private readonly BeatmapSetInfo beatmapSetInfo = new BeatmapSetInfo { OnlineID = 88 }; + private readonly BeatmapSetInfo invalidBeatmapSetInfo = new BeatmapSetInfo(); + + private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create button", () => Child = favourite = new FavouriteButton(beatmapSetInfo) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + + AddStep("register request handling", () => dummyAPI.HandleRequest = request => + { + if (!(request is GetBeatmapSetRequest beatmapSetRequest)) return false; + + beatmapSetRequest.TriggerSuccess(new APIBeatmapSet + { + OnlineID = beatmapSetRequest.ID, + HasFavourited = false, + FavouriteCount = 0, + }); + + return true; + }); + } + + [Test] + public void TestLoggedOutIn() + { + AddStep("log out", () => API.Logout()); + checkEnabled(false); + AddStep("log in", () => + { + API.Login("test", "test"); + ((DummyAPIAccess)API).AuthenticateSecondFactor("abcdefgh"); + }); + checkEnabled(true); + } + + [Test] + public void TestInvalidBeatmap() + { + AddStep("make beatmap invalid", () => Child = favourite = new FavouriteButton(invalidBeatmapSetInfo) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + AddStep("log in", () => + { + API.Login("test", "test"); + ((DummyAPIAccess)API).AuthenticateSecondFactor("abcdefgh"); + }); + checkEnabled(false); + } + + private void checkEnabled(bool expected) + { + AddAssert("is " + (expected ? "enabled" : "disabled"), () => favourite!.Enabled.Value == expected); + } + } +} diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs b/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs index 325a535731..760210c370 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs @@ -40,6 +40,13 @@ namespace osu.Game.Tests.Visual.Ranking AddSliderStep("height", 0.0f, 1000.0f, height.Value, height.Set); } + [Test] + public void TestZeroEvents() + { + createTest(new List()); + AddStep("update offset", () => graph.UpdateOffset(10)); + } + [Test] public void TestManyDistributedEventsOffset() { @@ -82,6 +89,14 @@ namespace osu.Game.Tests.Visual.Ranking }).ToList()); } + [Test] + public void TestNonBasicHitResultsAreIgnored() + { + createTest(CreateDistributedHitEvents(0, 50) + .Select(h => new HitEvent(h.TimeOffset, 1.0, h.TimeOffset > 0 ? HitResult.Ok : HitResult.LargeTickHit, placeholder_object, placeholder_object, null)) + .ToList()); + } + [Test] public void TestMultipleWindowsOfHitResult() { diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs b/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs index acfa519c81..f46f76cbb8 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs @@ -5,14 +5,18 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using NUnit.Framework; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; using osu.Game.Online; using osu.Game.Rulesets; using osu.Game.Rulesets.Difficulty; @@ -23,6 +27,7 @@ using osu.Game.Screens.Ranking.Statistics; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; +using osu.Game.Screens.Ranking.Statistics.User; using osu.Game.Tests.Resources; using osu.Game.Users; using osuTK; @@ -80,6 +85,69 @@ namespace osu.Game.Tests.Visual.Ranking loadPanel(null); } + [Test] + public void TestStatisticsShownCorrectlyIfUpdateDeliveredBeforeLoad() + { + UserStatisticsWatcher userStatisticsWatcher = null!; + ScoreInfo score = null!; + + AddStep("create user statistics watcher", () => Add(userStatisticsWatcher = new UserStatisticsWatcher())); + AddStep("set user statistics update", () => + { + score = TestResources.CreateTestScoreInfo(); + score.OnlineID = 1234; + ((Bindable)userStatisticsWatcher.LatestUpdate).Value = new UserStatisticsUpdate(score, + new UserStatistics + { + Level = new UserStatistics.LevelInfo + { + Current = 5, + Progress = 20, + }, + GlobalRank = 38000, + CountryRank = 12006, + PP = 2134, + RankedScore = 21123849, + Accuracy = 0.985, + PlayCount = 13375, + PlayTime = 354490, + TotalScore = 128749597, + TotalHits = 0, + MaxCombo = 1233, + }, new UserStatistics + { + Level = new UserStatistics.LevelInfo + { + Current = 5, + Progress = 30, + }, + GlobalRank = 36000, + CountryRank = 12000, + PP = (decimal)2134.5, + RankedScore = 23897015, + Accuracy = 0.984, + PlayCount = 13376, + PlayTime = 35789, + TotalScore = 132218497, + TotalHits = 0, + MaxCombo = 1233, + }); + }); + AddStep("load user statistics panel", () => Child = new DependencyProvidingContainer + { + CachedDependencies = [(typeof(UserStatisticsWatcher), userStatisticsWatcher)], + RelativeSizeAxes = Axes.Both, + Child = new UserStatisticsPanel(score) + { + RelativeSizeAxes = Axes.Both, + State = { Value = Visibility.Visible }, + Score = { Value = score, } + } + }); + AddUntilStep("overall ranking present", () => this.ChildrenOfType().Any()); + AddUntilStep("loading spinner not visible", () => this.ChildrenOfType().All(l => l.State.Value == Visibility.Hidden)); + } + private void loadPanel(ScoreInfo score) => AddStep("load panel", () => { Child = new UserStatisticsPanel(score) diff --git a/osu.Game.Tests/Visual/Settings/TestSceneDirectorySelector.cs b/osu.Game.Tests/Visual/Settings/TestSceneDirectorySelector.cs index 3ef0ffc13a..03ecd4af61 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneDirectorySelector.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneDirectorySelector.cs @@ -9,6 +9,11 @@ namespace osu.Game.Tests.Visual.Settings { public partial class TestSceneDirectorySelector : ThemeComparisonTestScene { + public TestSceneDirectorySelector() + : base(false) + { + } + protected override Drawable CreateContent() => new OsuDirectorySelector { RelativeSizeAxes = Axes.Both diff --git a/osu.Game.Tests/Visual/Settings/TestSceneFileSelector.cs b/osu.Game.Tests/Visual/Settings/TestSceneFileSelector.cs index e8f74a2f1b..cf8a589152 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneFileSelector.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneFileSelector.cs @@ -1,37 +1,49 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using NUnit.Framework; -using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; using osu.Game.Tests.Visual.UserInterface; namespace osu.Game.Tests.Visual.Settings { public partial class TestSceneFileSelector : ThemeComparisonTestScene { - [Resolved] - private OsuColour colours { get; set; } = null!; + public TestSceneFileSelector() + : base(false) + { + } [Test] public void TestJpgFilesOnly() { AddStep("create", () => { - Cell(0, 0).Children = new Drawable[] + var colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + + ContentContainer.Child = new DependencyProvidingContainer { - new Box + RelativeSizeAxes = Axes.Both, + CachedDependencies = new (Type, object)[] { - RelativeSizeAxes = Axes.Both, - Colour = colours.GreySeaFoam + (typeof(OverlayColourProvider), colourProvider) }, - new OsuFileSelector(validFileExtensions: new[] { ".jpg" }) + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - }, + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background3 + }, + new OsuFileSelector(validFileExtensions: new[] { ".jpg" }) + { + RelativeSizeAxes = Axes.Both, + }, + } }; }); } 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/Settings/TestSceneSettingsSource.cs b/osu.Game.Tests/Visual/Settings/TestSceneSettingsSource.cs index 309438e51c..9544f77940 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneSettingsSource.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneSettingsSource.cs @@ -5,6 +5,7 @@ using NUnit.Framework; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Overlays.Settings; @@ -19,16 +20,20 @@ namespace osu.Game.Tests.Visual.Settings { Children = new Drawable[] { - new FillFlowContainer + new PopoverContainer { RelativeSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Spacing = new Vector2(20), - Width = 0.5f, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Padding = new MarginPadding(50), - ChildrenEnumerable = new TestTargetClass().CreateSettingsControls() + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(20), + Width = 0.5f, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Padding = new MarginPadding(50), + ChildrenEnumerable = new TestTargetClass().CreateSettingsControls() + }, }, }; } @@ -66,6 +71,13 @@ namespace osu.Game.Tests.Visual.Settings [SettingSource("Sample number textbox", "Textbox number entry", SettingControlType = typeof(SettingsNumberBox))] public Bindable IntTextBoxBindable { get; } = new Bindable(); + + [SettingSource("Sample colour", "Change the colour", SettingControlType = typeof(SettingsColour))] + public BindableColour4 ColourBindable { get; } = new BindableColour4 + { + Default = Colour4.White, + Value = Colour4.Red + }; } private enum TestEnum diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneAdvancedStats.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneAdvancedStats.cs index 3b89c70a63..ca6c4998d1 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneAdvancedStats.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneAdvancedStats.cs @@ -84,7 +84,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("select EZ mod", () => { var ruleset = advancedStats.BeatmapInfo.Ruleset.CreateInstance().AsNonNull(); - SelectedMods.Value = new[] { ruleset.CreateMod() }; + advancedStats.Mods.Value = new[] { ruleset.CreateMod() }; }); AddAssert("circle size bar is blue", () => barIsBlue(advancedStats.FirstValue)); @@ -101,7 +101,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("select HR mod", () => { var ruleset = advancedStats.BeatmapInfo.Ruleset.CreateInstance().AsNonNull(); - SelectedMods.Value = new[] { ruleset.CreateMod() }; + advancedStats.Mods.Value = new[] { ruleset.CreateMod() }; }); AddAssert("circle size bar is red", () => barIsRed(advancedStats.FirstValue)); @@ -120,7 +120,7 @@ namespace osu.Game.Tests.Visual.SongSelect var ruleset = advancedStats.BeatmapInfo.Ruleset.CreateInstance().AsNonNull(); var difficultyAdjustMod = ruleset.CreateMod().AsNonNull(); difficultyAdjustMod.ReadFromDifficulty(advancedStats.BeatmapInfo.Difficulty); - SelectedMods.Value = new[] { difficultyAdjustMod }; + advancedStats.Mods.Value = new[] { difficultyAdjustMod }; }); AddAssert("circle size bar is white", () => barIsWhite(advancedStats.FirstValue)); @@ -143,7 +143,7 @@ namespace osu.Game.Tests.Visual.SongSelect difficultyAdjustMod.ReadFromDifficulty(originalDifficulty); difficultyAdjustMod.DrainRate.Value = originalDifficulty.DrainRate - 0.5f; difficultyAdjustMod.ApproachRate.Value = originalDifficulty.ApproachRate + 2.2f; - SelectedMods.Value = new[] { difficultyAdjustMod }; + advancedStats.Mods.Value = new[] { difficultyAdjustMod }; }); AddAssert("circle size bar is white", () => barIsWhite(advancedStats.FirstValue)); diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs index c0102b238c..97c46a11fc 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.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.Extensions.IEnumerableExtensions; @@ -52,11 +53,11 @@ namespace osu.Game.Tests.Visual.SongSelect { createCarousel(new List()); - AddStep("filter to ruleset 0", () => carousel.Filter(new FilterCriteria + AddStep("filter to ruleset 0", () => carousel.FilterImmediately(new FilterCriteria { Ruleset = rulesets.AvailableRulesets.ElementAt(0), AllowConvertedBeatmaps = true, - }, false)); + })); AddStep("add mixed ruleset beatmapset", () => { @@ -78,11 +79,11 @@ namespace osu.Game.Tests.Visual.SongSelect && visibleBeatmapPanels.Count(p => ((CarouselBeatmap)p.Item)!.BeatmapInfo.Ruleset.OnlineID == 0) == 1; }); - AddStep("filter to ruleset 1", () => carousel.Filter(new FilterCriteria + AddStep("filter to ruleset 1", () => carousel.FilterImmediately(new FilterCriteria { Ruleset = rulesets.AvailableRulesets.ElementAt(1), AllowConvertedBeatmaps = true, - }, false)); + })); AddUntilStep("wait for filtered difficulties", () => { @@ -93,11 +94,11 @@ namespace osu.Game.Tests.Visual.SongSelect && visibleBeatmapPanels.Count(p => ((CarouselBeatmap)p.Item)!.BeatmapInfo.Ruleset.OnlineID == 1) == 1; }); - AddStep("filter to ruleset 2", () => carousel.Filter(new FilterCriteria + AddStep("filter to ruleset 2", () => carousel.FilterImmediately(new FilterCriteria { Ruleset = rulesets.AvailableRulesets.ElementAt(2), AllowConvertedBeatmaps = true, - }, false)); + })); AddUntilStep("wait for filtered difficulties", () => { @@ -344,7 +345,7 @@ namespace osu.Game.Tests.Visual.SongSelect // basic filtering setSelected(1, 1); - AddStep("Filter", () => carousel.Filter(new FilterCriteria { SearchText = carousel.BeatmapSets.ElementAt(2).Metadata.Title }, false)); + AddStep("Filter", () => carousel.FilterImmediately(new FilterCriteria { SearchText = carousel.BeatmapSets.ElementAt(2).Metadata.Title })); checkVisibleItemCount(diff: false, count: 1); checkVisibleItemCount(diff: true, count: 3); waitForSelection(3, 1); @@ -360,13 +361,13 @@ namespace osu.Game.Tests.Visual.SongSelect // test filtering some difficulties (and keeping current beatmap set selected). setSelected(1, 2); - AddStep("Filter some difficulties", () => carousel.Filter(new FilterCriteria { SearchText = "Normal" }, false)); + AddStep("Filter some difficulties", () => carousel.FilterImmediately(new FilterCriteria { SearchText = "Normal" })); waitForSelection(1, 1); - AddStep("Un-filter", () => carousel.Filter(new FilterCriteria(), false)); + AddStep("Un-filter", () => carousel.FilterImmediately(new FilterCriteria())); waitForSelection(1, 1); - AddStep("Filter all", () => carousel.Filter(new FilterCriteria { SearchText = "Dingo" }, false)); + AddStep("Filter all", () => carousel.FilterImmediately(new FilterCriteria { SearchText = "Dingo" })); checkVisibleItemCount(false, 0); checkVisibleItemCount(true, 0); @@ -378,7 +379,7 @@ namespace osu.Game.Tests.Visual.SongSelect advanceSelection(false); AddAssert("Selection is null", () => currentSelection == null); - AddStep("Un-filter", () => carousel.Filter(new FilterCriteria(), false)); + AddStep("Un-filter", () => carousel.FilterImmediately(new FilterCriteria())); AddAssert("Selection is non-null", () => currentSelection != null); @@ -399,7 +400,7 @@ namespace osu.Game.Tests.Visual.SongSelect setSelected(1, 3); - AddStep("Apply a range filter", () => carousel.Filter(new FilterCriteria + AddStep("Apply a range filter", () => carousel.FilterImmediately(new FilterCriteria { SearchText = searchText, StarDifficulty = new FilterCriteria.OptionalRange @@ -408,7 +409,7 @@ namespace osu.Game.Tests.Visual.SongSelect Max = 5.5, IsLowerInclusive = true } - }, false)); + })); // should reselect the buffered selection. waitForSelection(3, 2); @@ -445,13 +446,13 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert("ensure repeat", () => selectedSets.Contains(carousel.SelectedBeatmapSet)); AddStep("Add set with 100 difficulties", () => carousel.UpdateBeatmapSet(TestResources.CreateTestBeatmapSetInfo(100, rulesets.AvailableRulesets.ToArray()))); - AddStep("Filter Extra", () => carousel.Filter(new FilterCriteria { SearchText = "Extra 10" }, false)); + AddStep("Filter Extra", () => carousel.FilterImmediately(new FilterCriteria { SearchText = "Extra 10" })); checkInvisibleDifficultiesUnselectable(); checkInvisibleDifficultiesUnselectable(); checkInvisibleDifficultiesUnselectable(); checkInvisibleDifficultiesUnselectable(); checkInvisibleDifficultiesUnselectable(); - AddStep("Un-filter", () => carousel.Filter(new FilterCriteria(), false)); + AddStep("Un-filter", () => carousel.FilterImmediately(new FilterCriteria())); } [Test] @@ -519,6 +520,17 @@ namespace osu.Game.Tests.Visual.SongSelect waitForSelection(set_count); } + [Test] + public void TestDifficultiesSplitOutOnLoad() + { + loadBeatmaps(new List { TestResources.CreateTestBeatmapSetInfo(diff_count) }, () => new FilterCriteria + { + Sort = SortMode.Difficulty, + }); + + checkVisibleItemCount(false, 3); + } + [Test] public void TestAddRemoveDifficultySort() { @@ -527,7 +539,7 @@ namespace osu.Game.Tests.Visual.SongSelect loadBeatmaps(setCount: local_set_count, diffCount: local_diff_count); - AddStep("Sort by difficulty", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Difficulty }, false)); + AddStep("Sort by difficulty", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.Difficulty })); checkVisibleItemCount(false, local_set_count * local_diff_count); @@ -566,7 +578,7 @@ namespace osu.Game.Tests.Visual.SongSelect loadBeatmaps(sets, () => new FilterCriteria { Ruleset = rulesets.AvailableRulesets.ElementAt(0) }); AddStep("Set non-empty mode filter", () => - carousel.Filter(new FilterCriteria { Ruleset = rulesets.AvailableRulesets.ElementAt(1) }, false)); + carousel.FilterImmediately(new FilterCriteria { Ruleset = rulesets.AvailableRulesets.ElementAt(1) })); AddAssert("Something is selected", () => carousel.SelectedBeatmapInfo != null); } @@ -601,7 +613,7 @@ namespace osu.Game.Tests.Visual.SongSelect loadBeatmaps(sets); - AddStep("Sort by date submitted", () => carousel.Filter(new FilterCriteria { Sort = SortMode.DateSubmitted }, false)); + AddStep("Sort by date submitted", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.DateSubmitted })); checkVisibleItemCount(diff: false, count: 10); checkVisibleItemCount(diff: true, count: 5); @@ -610,11 +622,11 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert("rest are at start", () => carousel.Items.OfType().TakeWhile(i => i.Item is CarouselBeatmapSet s && s.BeatmapSet.DateSubmitted != null).Count(), () => Is.EqualTo(6)); - AddStep("Sort by date submitted and string", () => carousel.Filter(new FilterCriteria + AddStep("Sort by date submitted and string", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.DateSubmitted, SearchText = zzz_string - }, false)); + })); checkVisibleItemCount(diff: false, count: 5); checkVisibleItemCount(diff: true, count: 5); @@ -658,10 +670,10 @@ namespace osu.Game.Tests.Visual.SongSelect loadBeatmaps(sets); - AddStep("Sort by author", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Author }, false)); + AddStep("Sort by author", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.Author })); AddAssert($"Check {zzz_uppercase} is last", () => carousel.BeatmapSets.Last().Metadata.Author.Username == zzz_uppercase); AddAssert($"Check {zzz_lowercase} is second last", () => carousel.BeatmapSets.SkipLast(1).Last().Metadata.Author.Username == zzz_lowercase); - AddStep("Sort by artist", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Artist }, false)); + AddStep("Sort by artist", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.Artist })); AddAssert($"Check {zzz_uppercase} is last", () => carousel.BeatmapSets.Last().Metadata.Artist == zzz_uppercase); AddAssert($"Check {zzz_lowercase} is second last", () => carousel.BeatmapSets.SkipLast(1).Last().Metadata.Artist == zzz_lowercase); } @@ -703,7 +715,7 @@ namespace osu.Game.Tests.Visual.SongSelect loadBeatmaps(sets); - AddStep("Sort by artist", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Artist }, false)); + AddStep("Sort by artist", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.Artist })); AddAssert("Check last item", () => { var lastItem = carousel.BeatmapSets.Last(); @@ -746,10 +758,10 @@ namespace osu.Game.Tests.Visual.SongSelect loadBeatmaps(sets); - AddStep("Sort by title", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Title }, false)); + AddStep("Sort by title", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.Title })); AddAssert("Items remain in descending added order", () => carousel.BeatmapSets.Select(s => s.DateAdded), () => Is.Ordered.Descending); - AddStep("Sort by artist", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Artist }, false)); + AddStep("Sort by artist", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.Artist })); AddAssert("Items remain in descending added order", () => carousel.BeatmapSets.Select(s => s.DateAdded), () => Is.Ordered.Descending); } @@ -786,7 +798,7 @@ namespace osu.Game.Tests.Visual.SongSelect loadBeatmaps(sets); - AddStep("Sort by artist", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Artist }, false)); + AddStep("Sort by artist", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.Artist })); AddAssert("Items in descending added order", () => carousel.BeatmapSets.Select(s => s.DateAdded), () => Is.Ordered.Descending); AddStep("Save order", () => originalOrder = carousel.BeatmapSets.Select(s => s.ID).ToArray()); @@ -796,7 +808,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert("Order didn't change", () => carousel.BeatmapSets.Select(s => s.ID), () => Is.EqualTo(originalOrder)); - AddStep("Sort by title", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Title }, false)); + AddStep("Sort by title", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.Title })); AddAssert("Order didn't change", () => carousel.BeatmapSets.Select(s => s.ID), () => Is.EqualTo(originalOrder)); } @@ -833,7 +845,7 @@ namespace osu.Game.Tests.Visual.SongSelect loadBeatmaps(sets); - AddStep("Sort by artist", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Artist }, false)); + AddStep("Sort by artist", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.Artist })); AddAssert("Items in descending added order", () => carousel.BeatmapSets.Select(s => s.DateAdded), () => Is.Ordered.Descending); AddStep("Save order", () => originalOrder = carousel.BeatmapSets.Select(s => s.ID).ToArray()); @@ -858,7 +870,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert("Order didn't change", () => carousel.BeatmapSets.Select(s => s.ID), () => Is.EqualTo(originalOrder)); - AddStep("Sort by title", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Title }, false)); + AddStep("Sort by title", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.Title })); AddAssert("Order didn't change", () => carousel.BeatmapSets.Select(s => s.ID), () => Is.EqualTo(originalOrder)); } @@ -885,12 +897,12 @@ namespace osu.Game.Tests.Visual.SongSelect loadBeatmaps(sets); - AddStep("Sort by difficulty", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Difficulty }, false)); + AddStep("Sort by difficulty", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.Difficulty })); checkVisibleItemCount(false, local_set_count * local_diff_count); checkVisibleItemCount(true, 1); - AddStep("Filter to normal", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Difficulty, SearchText = "Normal" }, false)); + AddStep("Filter to normal", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.Difficulty, SearchText = "Normal" })); checkVisibleItemCount(false, local_set_count); checkVisibleItemCount(true, 1); @@ -901,7 +913,7 @@ namespace osu.Game.Tests.Visual.SongSelect .Count(p => ((CarouselBeatmapSet)p.Item)!.Beatmaps.Single().BeatmapInfo.DifficultyName.StartsWith("Normal", StringComparison.Ordinal)) == local_set_count; }); - AddStep("Filter to insane", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Difficulty, SearchText = "Insane" }, false)); + AddStep("Filter to insane", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.Difficulty, SearchText = "Insane" })); checkVisibleItemCount(false, local_set_count); checkVisibleItemCount(true, 1); @@ -1022,7 +1034,7 @@ namespace osu.Game.Tests.Visual.SongSelect carousel.UpdateBeatmapSet(testMixed); }); AddStep("filter to ruleset 0", () => - carousel.Filter(new FilterCriteria { Ruleset = rulesets.AvailableRulesets.ElementAt(0) }, false)); + carousel.FilterImmediately(new FilterCriteria { Ruleset = rulesets.AvailableRulesets.ElementAt(0) })); AddStep("select filtered map skipping filtered", () => carousel.SelectBeatmap(testMixed.Beatmaps[1], false)); AddAssert("unfiltered beatmap not selected", () => carousel.SelectedBeatmapInfo?.Ruleset.OnlineID == 0); @@ -1068,12 +1080,12 @@ namespace osu.Game.Tests.Visual.SongSelect { AddStep("Toggle non-matching filter", () => { - carousel.Filter(new FilterCriteria { SearchText = Guid.NewGuid().ToString() }, false); + carousel.FilterImmediately(new FilterCriteria { SearchText = Guid.NewGuid().ToString() }); }); AddStep("Restore no filter", () => { - carousel.Filter(new FilterCriteria(), false); + carousel.FilterImmediately(new FilterCriteria()); eagerSelectedIDs.Add(carousel.SelectedBeatmapSet!.ID); }); } @@ -1097,7 +1109,7 @@ namespace osu.Game.Tests.Visual.SongSelect loadBeatmaps(manySets); - AddStep("Sort by difficulty", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Difficulty }, false)); + AddStep("Sort by difficulty", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.Difficulty })); advanceSelection(direction: 1, diff: false); @@ -1105,12 +1117,12 @@ namespace osu.Game.Tests.Visual.SongSelect { AddStep("Toggle non-matching filter", () => { - carousel.Filter(new FilterCriteria { SearchText = Guid.NewGuid().ToString() }, false); + carousel.FilterImmediately(new FilterCriteria { SearchText = Guid.NewGuid().ToString() }); }); AddStep("Restore no filter", () => { - carousel.Filter(new FilterCriteria(), false); + carousel.FilterImmediately(new FilterCriteria()); eagerSelectedIDs.Add(carousel.SelectedBeatmapSet!.ID); }); } @@ -1119,6 +1131,32 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert("Selection was remembered", () => eagerSelectedIDs.Count == 1); } + [Test] + public void TestCarouselRetainsSelectionFromDifficultySort() + { + List manySets = new List(); + + AddStep("Populate beatmap sets", () => + { + manySets.Clear(); + + for (int i = 1; i <= 50; i++) + manySets.Add(TestResources.CreateTestBeatmapSetInfo(diff_count)); + }); + + loadBeatmaps(manySets); + + BeatmapInfo chosenBeatmap = null!; + AddStep("select given beatmap", () => carousel.SelectBeatmap(chosenBeatmap = manySets[20].Beatmaps[0])); + AddUntilStep("selection changed", () => carousel.SelectedBeatmapInfo, () => Is.EqualTo(chosenBeatmap)); + + AddStep("sort by difficulty", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.Difficulty })); + AddAssert("selection retained", () => carousel.SelectedBeatmapInfo, () => Is.EqualTo(chosenBeatmap)); + + AddStep("sort by title", () => carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.Title })); + AddAssert("selection retained", () => carousel.SelectedBeatmapInfo, () => Is.EqualTo(chosenBeatmap)); + } + [Test] public void TestFilteringByUserStarDifficulty() { @@ -1185,7 +1223,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep($"Set ruleset to {rulesetInfo.ShortName}", () => { - carousel.Filter(new FilterCriteria { Ruleset = rulesetInfo, Sort = SortMode.Title }, false); + carousel.FilterImmediately(new FilterCriteria { Ruleset = rulesetInfo, Sort = SortMode.Title }); }); waitForSelection(i + 1, 1); } @@ -1223,12 +1261,12 @@ namespace osu.Game.Tests.Visual.SongSelect setSelected(i, 1); AddStep("Set ruleset to taiko", () => { - carousel.Filter(new FilterCriteria { Ruleset = rulesets.AvailableRulesets.ElementAt(1), Sort = SortMode.Title }, false); + carousel.FilterImmediately(new FilterCriteria { Ruleset = rulesets.AvailableRulesets.ElementAt(1), Sort = SortMode.Title }); }); waitForSelection(i - 1, 1); AddStep("Remove ruleset filter", () => { - carousel.Filter(new FilterCriteria { Sort = SortMode.Title }, false); + carousel.FilterImmediately(new FilterCriteria { Sort = SortMode.Title }); }); } @@ -1268,26 +1306,23 @@ namespace osu.Game.Tests.Visual.SongSelect } } - createCarousel(beatmapSets, c => + createCarousel(beatmapSets, initialCriteria, c => { - carouselAdjust?.Invoke(c); - - carousel.Filter(initialCriteria?.Invoke() ?? new FilterCriteria()); carousel.BeatmapSetsChanged = () => changed = true; - carousel.BeatmapSets = beatmapSets; + carouselAdjust?.Invoke(c); }); AddUntilStep("Wait for load", () => changed); } - private void createCarousel(List beatmapSets, Action carouselAdjust = null, Container target = null) + private void createCarousel(List beatmapSets, [CanBeNull] Func initialCriteria = null, Action carouselAdjust = null, Container target = null) { AddStep("Create carousel", () => { selectedSets.Clear(); eagerSelectedIDs.Clear(); - carousel = new TestBeatmapCarousel + carousel = new TestBeatmapCarousel(initialCriteria?.Invoke() ?? new FilterCriteria()) { RelativeSizeAxes = Axes.Both, }; @@ -1389,6 +1424,11 @@ namespace osu.Game.Tests.Visual.SongSelect private partial class TestBeatmapCarousel : BeatmapCarousel { + public TestBeatmapCarousel(FilterCriteria criteria) + : base(criteria) + { + } + public bool PendingFilterTask => PendingFilter != null; public IEnumerable Items @@ -1410,6 +1450,12 @@ namespace osu.Game.Tests.Visual.SongSelect } } } + + public void FilterImmediately(FilterCriteria newCriteria) + { + Filter(newCriteria); + FlushPendingFilterOperations(); + } } } } diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs index fd102da026..d8573b2d03 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs @@ -1,12 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Linq; -using JetBrains.Annotations; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -23,7 +20,6 @@ using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Legacy; -using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Taiko; @@ -35,15 +31,11 @@ namespace osu.Game.Tests.Visual.SongSelect [TestFixture] public partial class TestSceneBeatmapInfoWedge : OsuTestScene { - private RulesetStore rulesets; - private TestBeatmapInfoWedge infoWedge; - private readonly List beatmaps = new List(); + [Resolved] + private RulesetStore rulesets { get; set; } = null!; - [BackgroundDependencyLoader] - private void load(RulesetStore rulesets) - { - this.rulesets = rulesets; - } + private TestBeatmapInfoWedge infoWedge = null!; + private readonly List beatmaps = new List(); protected override void LoadComplete() { @@ -156,7 +148,7 @@ namespace osu.Game.Tests.Visual.SongSelect IBeatmap beatmap = createTestBeatmap(new OsuRuleset().RulesetInfo); beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 60 * 1000 / bpm }); - OsuModDoubleTime doubleTime = null; + OsuModDoubleTime doubleTime = null!; selectBeatmap(beatmap); checkDisplayedBPM($"{bpm}"); @@ -173,7 +165,7 @@ namespace osu.Game.Tests.Visual.SongSelect [TestCase(120, 120.4, null, "120")] [TestCase(120, 120.6, "DT", "180-182 (mostly 180)")] [TestCase(120, 120.4, "DT", "180")] - public void TestVaryingBPM(double commonBpm, double otherBpm, string mod, string expectedDisplay) + public void TestVaryingBPM(double commonBpm, double otherBpm, string? mod, string expectedDisplay) { IBeatmap beatmap = createTestBeatmap(new OsuRuleset().RulesetInfo); beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 60 * 1000 / commonBpm }); @@ -203,7 +195,7 @@ namespace osu.Game.Tests.Visual.SongSelect double drain = beatmap.CalculateDrainLength(); beatmap.BeatmapInfo.Length = drain; - OsuModDoubleTime doubleTime = null; + OsuModDoubleTime doubleTime = null!; selectBeatmap(beatmap); checkDisplayedLength(drain); @@ -221,14 +213,15 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep($"check map drain ({displayedLength})", () => { - var label = infoWedge.DisplayedContent.ChildrenOfType().Single(l => l.Statistic.Name == BeatmapsetsStrings.ShowStatsTotalLength(displayedLength)); + var label = infoWedge.DisplayedContent.ChildrenOfType() + .Single(l => l.Statistic.Name == BeatmapsetsStrings.ShowStatsTotalLength(displayedLength)); return label.Statistic.Content == displayedLength.ToString(); }); } private void setRuleset(RulesetInfo rulesetInfo) { - Container containerBefore = null; + Container? containerBefore = null; AddStep("set ruleset", () => { @@ -242,9 +235,9 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep("wait for async load", () => infoWedge.DisplayedContent != containerBefore); } - private void selectBeatmap([CanBeNull] IBeatmap b) + private void selectBeatmap(IBeatmap? b) { - Container containerBefore = null; + Container? containerBefore = null; AddStep($"select {b?.Metadata.Title ?? "null"} beatmap", () => { @@ -307,11 +300,6 @@ namespace osu.Game.Tests.Visual.SongSelect public new WedgeInfoText Info => base.Info; } - private class TestHitObject : ConvertHitObject, IHasPosition - { - public float X => 0; - public float Y => 0; - public Vector2 Position { get; } = Vector2.Zero; - } + private class TestHitObject : ConvertHitObject; } } diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs index a368e901f5..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; @@ -31,8 +34,6 @@ namespace osu.Game.Tests.Visual.SongSelect [SetUpSteps] public override void SetUpSteps() { - base.SetUpSteps(); - AddStep("populate ruleset statistics", () => { Dictionary rulesetStatistics = new Dictionary(); @@ -68,6 +69,8 @@ namespace osu.Game.Tests.Visual.SongSelect return 0; } } + + base.SetUpSteps(); } [Test] @@ -191,8 +194,39 @@ namespace osu.Game.Tests.Visual.SongSelect { AddStep("present beatmap", () => Game.PresentBeatmap(getImport())); - AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is Screens.Select.SongSelect); + AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is Screens.Select.SongSelect select && select.BeatmapSetsLoaded); AddUntilStep("recommended beatmap displayed", () => Game.Beatmap.Value.BeatmapInfo.MatchesOnlineID(getImport().Beatmaps[expectedDiff - 1])); } + + protected override TestOsuGame CreateTestGame() => new NoBeatmapUpdateGame(LocalStorage, API); + + private partial class NoBeatmapUpdateGame : TestOsuGame + { + public NoBeatmapUpdateGame(Storage storage, IAPIProvider api, string[] args = null) + : base(storage, api, args) + { + } + + protected override IBeatmapUpdater CreateBeatmapUpdater() => new TestBeatmapUpdater(); + + private class TestBeatmapUpdater : IBeatmapUpdater + { + public void Queue(Live beatmapSet, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst) + { + } + + public void Process(BeatmapSetInfo beatmapSet, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst) + { + } + + public void ProcessObjectCounts(BeatmapInfo beatmapInfo, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst) + { + } + + public void Dispose() + { + } + } + } } } diff --git a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs index e03ffd48f1..3a95aca6b9 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs @@ -56,16 +56,20 @@ namespace osu.Game.Tests.Visual.SongSelect [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { + DetachedBeatmapStore detachedBeatmapStore; + // These DI caches are required to ensure for interactive runs this test scene doesn't nuke all user beatmaps in the local install. // At a point we have isolated interactive test runs enough, this can likely be removed. Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); Dependencies.Cache(Realm); Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, defaultBeatmap = Beatmap.Default)); + Dependencies.Cache(detachedBeatmapStore = new DetachedBeatmapStore()); Dependencies.Cache(music = new MusicController()); // required to get bindables attached Add(music); + Add(detachedBeatmapStore); Dependencies.Cache(config = new OsuConfigManager(LocalStorage)); } @@ -87,6 +91,128 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("delete all beatmaps", () => manager.Delete()); } + [Test] + public void TestSpeedChange() + { + createSongSelect(); + changeMods(); + + decreaseModSpeed(); + AddAssert("half time activated at 0.95x", () => songSelect!.Mods.Value.OfType().Single().SpeedChange.Value, () => Is.EqualTo(0.95).Within(0.005)); + + decreaseModSpeed(); + AddAssert("half time speed changed to 0.9x", () => songSelect!.Mods.Value.OfType().Single().SpeedChange.Value, () => Is.EqualTo(0.9).Within(0.005)); + + increaseModSpeed(); + AddAssert("half time speed changed to 0.95x", () => songSelect!.Mods.Value.OfType().Single().SpeedChange.Value, () => Is.EqualTo(0.95).Within(0.005)); + + increaseModSpeed(); + AddAssert("no mods selected", () => songSelect!.Mods.Value.Count == 0); + + increaseModSpeed(); + AddAssert("double time activated at 1.05x", () => songSelect!.Mods.Value.OfType().Single().SpeedChange.Value, () => Is.EqualTo(1.05).Within(0.005)); + + increaseModSpeed(); + AddAssert("double time speed changed to 1.1x", () => songSelect!.Mods.Value.OfType().Single().SpeedChange.Value, () => Is.EqualTo(1.1).Within(0.005)); + + decreaseModSpeed(); + AddAssert("double time speed changed to 1.05x", () => songSelect!.Mods.Value.OfType().Single().SpeedChange.Value, () => Is.EqualTo(1.05).Within(0.005)); + + OsuModNightcore nc = new OsuModNightcore + { + SpeedChange = { Value = 1.05 } + }; + changeMods(nc); + + increaseModSpeed(); + AddAssert("nightcore speed changed to 1.1x", () => songSelect!.Mods.Value.OfType().Single().SpeedChange.Value, () => Is.EqualTo(1.1).Within(0.005)); + + decreaseModSpeed(); + AddAssert("nightcore speed changed to 1.05x", () => songSelect!.Mods.Value.OfType().Single().SpeedChange.Value, () => Is.EqualTo(1.05).Within(0.005)); + + decreaseModSpeed(); + AddAssert("no mods selected", () => songSelect!.Mods.Value.Count == 0); + + decreaseModSpeed(); + AddAssert("daycore activated at 0.95x", () => songSelect!.Mods.Value.OfType().Single().SpeedChange.Value, () => Is.EqualTo(0.95).Within(0.005)); + + decreaseModSpeed(); + AddAssert("daycore activated at 0.95x", () => songSelect!.Mods.Value.OfType().Single().SpeedChange.Value, () => Is.EqualTo(0.9).Within(0.005)); + + increaseModSpeed(); + AddAssert("daycore activated at 0.95x", () => songSelect!.Mods.Value.OfType().Single().SpeedChange.Value, () => Is.EqualTo(0.95).Within(0.005)); + + OsuModDoubleTime dt = new OsuModDoubleTime + { + SpeedChange = { Value = 1.02 }, + AdjustPitch = { Value = true }, + }; + changeMods(dt); + + decreaseModSpeed(); + AddAssert("half time activated at 0.97x", () => songSelect!.Mods.Value.OfType().Single().SpeedChange.Value, () => Is.EqualTo(0.97).Within(0.005)); + AddAssert("adjust pitch preserved", () => songSelect!.Mods.Value.OfType().Single().AdjustPitch.Value, () => Is.True); + + OsuModHalfTime ht = new OsuModHalfTime + { + SpeedChange = { Value = 0.97 }, + AdjustPitch = { Value = true }, + }; + Mod[] modlist = { ht, new OsuModHardRock(), new OsuModHidden() }; + changeMods(modlist); + + increaseModSpeed(); + AddAssert("double time activated at 1.02x", () => songSelect!.Mods.Value.OfType().Single().SpeedChange.Value, () => Is.EqualTo(1.02).Within(0.005)); + AddAssert("double time activated at 1.02x", () => songSelect!.Mods.Value.OfType().Single().AdjustPitch.Value, () => Is.True); + AddAssert("HD still enabled", () => songSelect!.Mods.Value.OfType().SingleOrDefault(), () => Is.Not.Null); + AddAssert("HR still enabled", () => songSelect!.Mods.Value.OfType().SingleOrDefault(), () => Is.Not.Null); + + changeMods(new ModWindUp()); + increaseModSpeed(); + AddAssert("windup still active", () => songSelect!.Mods.Value.First() is ModWindUp); + + changeMods(new ModAdaptiveSpeed()); + increaseModSpeed(); + AddAssert("adaptive speed still active", () => songSelect!.Mods.Value.First() is ModAdaptiveSpeed); + + OsuModDoubleTime dtWithAdjustPitch = new OsuModDoubleTime + { + SpeedChange = { Value = 1.05 }, + AdjustPitch = { Value = true }, + }; + changeMods(dtWithAdjustPitch); + + decreaseModSpeed(); + AddAssert("no mods selected", () => songSelect!.Mods.Value.Count == 0); + + decreaseModSpeed(); + AddAssert("half time activated at 0.95x", () => songSelect!.Mods.Value.OfType().Single().SpeedChange.Value, () => Is.EqualTo(0.95).Within(0.005)); + AddAssert("half time has adjust pitch active", () => songSelect!.Mods.Value.OfType().Single().AdjustPitch.Value, () => Is.True); + + AddStep("turn off adjust pitch", () => songSelect!.Mods.Value.OfType().Single().AdjustPitch.Value = false); + + increaseModSpeed(); + AddAssert("no mods selected", () => songSelect!.Mods.Value.Count == 0); + + increaseModSpeed(); + AddAssert("double time activated at 1.05x", () => songSelect!.Mods.Value.OfType().Single().SpeedChange.Value, () => Is.EqualTo(1.05).Within(0.005)); + AddAssert("double time has adjust pitch inactive", () => songSelect!.Mods.Value.OfType().Single().AdjustPitch.Value, () => Is.False); + + void increaseModSpeed() => AddStep("increase mod speed", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Key(Key.Up); + InputManager.ReleaseKey(Key.ControlLeft); + }); + + void decreaseModSpeed() => AddStep("decrease mod speed", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Key(Key.Down); + InputManager.ReleaseKey(Key.ControlLeft); + }); + } + [Test] public void TestPlaceholderBeatmapPresence() { @@ -143,7 +269,7 @@ namespace osu.Game.Tests.Visual.SongSelect createSongSelect(); - AddAssert("filter count is 1", () => songSelect?.FilterCount == 1); + AddAssert("filter count is 0", () => songSelect?.FilterCount, () => Is.EqualTo(0)); } [Test] @@ -263,7 +389,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("return", () => songSelect!.MakeCurrent()); AddUntilStep("wait for current", () => songSelect!.IsCurrentScreen()); - AddAssert("filter count is 1", () => songSelect!.FilterCount == 1); + AddAssert("filter count is 0", () => songSelect!.FilterCount, () => Is.EqualTo(0)); } [Test] @@ -283,7 +409,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("return", () => songSelect!.MakeCurrent()); AddUntilStep("wait for current", () => songSelect!.IsCurrentScreen()); - AddAssert("filter count is 2", () => songSelect!.FilterCount == 2); + AddAssert("filter count is 1", () => songSelect!.FilterCount, () => Is.EqualTo(1)); } [Test] @@ -1112,6 +1238,20 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert("0 matching shown", () => songSelect.ChildrenOfType().Single().InformationalText == "0 matches"); } + [Test] + [Solo] + public void TestHardDeleteHandledCorrectly() + { + createSongSelect(); + + addRulesetImportStep(0); + AddAssert("3 matching shown", () => songSelect.ChildrenOfType().Single().InformationalText == "3 matches"); + + AddStep("hard delete beatmap", () => Realm.Write(r => r.RemoveRange(r.All().Where(s => !s.Protected)))); + + AddUntilStep("0 matching shown", () => songSelect.ChildrenOfType().Single().InformationalText == "0 matches"); + } + [Test] public void TestDeleteHotkey() { @@ -1157,11 +1297,11 @@ namespace osu.Game.Tests.Visual.SongSelect // Mod that is guaranteed to never re-filter. AddStep("add non-filterable mod", () => SelectedMods.Value = new Mod[] { new OsuModCinema() }); - AddAssert("filter count is 1", () => songSelect!.FilterCount, () => Is.EqualTo(1)); + AddAssert("filter count is 0", () => songSelect!.FilterCount, () => Is.EqualTo(0)); // Removing the mod should still not re-filter. AddStep("remove non-filterable mod", () => SelectedMods.Value = Array.Empty()); - AddAssert("filter count is 1", () => songSelect!.FilterCount, () => Is.EqualTo(1)); + AddAssert("filter count is 0", () => songSelect!.FilterCount, () => Is.EqualTo(0)); } [Test] @@ -1173,35 +1313,35 @@ namespace osu.Game.Tests.Visual.SongSelect // Change to mania ruleset. AddStep("filter to mania ruleset", () => Ruleset.Value = rulesets.AvailableRulesets.First(r => r.OnlineID == 3)); - AddAssert("filter count is 2", () => songSelect!.FilterCount, () => Is.EqualTo(2)); + AddAssert("filter count is 2", () => songSelect!.FilterCount, () => Is.EqualTo(1)); // Apply a mod, but this should NOT re-filter because there's no search text. AddStep("add filterable mod", () => SelectedMods.Value = new Mod[] { new ManiaModKey3() }); - AddAssert("filter count is 2", () => songSelect!.FilterCount, () => Is.EqualTo(2)); + AddAssert("filter count is 1", () => songSelect!.FilterCount, () => Is.EqualTo(1)); // Set search text. Should re-filter. AddStep("set search text to match mods", () => songSelect!.FilterControl.CurrentTextSearch.Value = "keys=3"); - AddAssert("filter count is 3", () => songSelect!.FilterCount, () => Is.EqualTo(3)); + AddAssert("filter count is 2", () => songSelect!.FilterCount, () => Is.EqualTo(2)); // Change filterable mod. Should re-filter. AddStep("change new filterable mod", () => SelectedMods.Value = new Mod[] { new ManiaModKey5() }); - AddAssert("filter count is 4", () => songSelect!.FilterCount, () => Is.EqualTo(4)); + AddAssert("filter count is 3", () => songSelect!.FilterCount, () => Is.EqualTo(3)); // Add non-filterable mod. Should NOT re-filter. AddStep("apply non-filterable mod", () => SelectedMods.Value = new Mod[] { new ManiaModNoFail(), new ManiaModKey5() }); - AddAssert("filter count is 4", () => songSelect!.FilterCount, () => Is.EqualTo(4)); + AddAssert("filter count is 3", () => songSelect!.FilterCount, () => Is.EqualTo(3)); // Remove filterable mod. Should re-filter. AddStep("remove filterable mod", () => SelectedMods.Value = new Mod[] { new ManiaModNoFail() }); - AddAssert("filter count is 5", () => songSelect!.FilterCount, () => Is.EqualTo(5)); + AddAssert("filter count is 4", () => songSelect!.FilterCount, () => Is.EqualTo(4)); // Remove non-filterable mod. Should NOT re-filter. AddStep("remove filterable mod", () => SelectedMods.Value = Array.Empty()); - AddAssert("filter count is 5", () => songSelect!.FilterCount, () => Is.EqualTo(5)); + AddAssert("filter count is 4", () => songSelect!.FilterCount, () => Is.EqualTo(4)); // Add filterable mod. Should re-filter. AddStep("add filterable mod", () => SelectedMods.Value = new Mod[] { new ManiaModKey3() }); - AddAssert("filter count is 6", () => songSelect!.FilterCount, () => Is.EqualTo(6)); + AddAssert("filter count is 5", () => songSelect!.FilterCount, () => Is.EqualTo(5)); } private void waitForInitialSelection() @@ -1284,8 +1424,6 @@ namespace osu.Game.Tests.Visual.SongSelect { public Action? StartRequested; - public new Bindable Ruleset => base.Ruleset; - public new FilterControl FilterControl => base.FilterControl; public WorkingBeatmap CurrentBeatmap => Beatmap.Value; @@ -1295,18 +1433,18 @@ namespace osu.Game.Tests.Visual.SongSelect public new void PresentScore(ScoreInfo score) => base.PresentScore(score); + public int FilterCount; + protected override bool OnStart() { StartRequested?.Invoke(); return base.OnStart(); } - public int FilterCount; - - protected override void ApplyFilterToCarousel(FilterCriteria criteria) + [BackgroundDependencyLoader] + private void load() { - FilterCount++; - base.ApplyFilterToCarousel(criteria); + FilterControl.FilterChanged += _ => FilterCount++; } } } diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneSongSelectFooterV2.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneSongSelectFooterV2.cs deleted file mode 100644 index 013bad55bc..0000000000 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneSongSelectFooterV2.cs +++ /dev/null @@ -1,166 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Linq; -using NUnit.Framework; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Cursor; -using osu.Framework.Testing; -using osu.Game.Overlays; -using osu.Game.Overlays.Mods; -using osu.Game.Rulesets.Osu; -using osu.Game.Screens.Select.FooterV2; -using osuTK.Input; - -namespace osu.Game.Tests.Visual.SongSelect -{ - public partial class TestSceneSongSelectFooterV2 : OsuManualInputManagerTestScene - { - private FooterButtonRandomV2 randomButton = null!; - private FooterButtonModsV2 modsButton = null!; - - private bool nextRandomCalled; - private bool previousRandomCalled; - - private DummyOverlay overlay = null!; - - [Cached] - private OverlayColourProvider colourProvider { get; set; } = new OverlayColourProvider(OverlayColourScheme.Aquamarine); - - [SetUp] - public void SetUp() => Schedule(() => - { - nextRandomCalled = false; - previousRandomCalled = false; - - FooterV2 footer; - - Children = new Drawable[] - { - new PopoverContainer - { - RelativeSizeAxes = Axes.Both, - Child = footer = new FooterV2(), - }, - overlay = new DummyOverlay() - }; - - footer.AddButton(modsButton = new FooterButtonModsV2(), overlay); - footer.AddButton(randomButton = new FooterButtonRandomV2 - { - NextRandom = () => nextRandomCalled = true, - PreviousRandom = () => previousRandomCalled = true - }); - footer.AddButton(new FooterButtonOptionsV2()); - - overlay.Hide(); - }); - - [SetUpSteps] - public void SetUpSteps() - { - AddStep("set beatmap", () => Beatmap.Value = CreateWorkingBeatmap(CreateBeatmap(new OsuRuleset().RulesetInfo))); - } - - [Test] - public void TestShowOptions() - { - AddStep("enable options", () => - { - var optionsButton = this.ChildrenOfType().Last(); - - optionsButton.Enabled.Value = true; - optionsButton.TriggerClick(); - }); - } - - [Test] - public void TestState() - { - AddToggleStep("set options enabled state", state => this.ChildrenOfType().Last().Enabled.Value = state); - } - - [Test] - public void TestFooterRandom() - { - AddStep("press F2", () => InputManager.Key(Key.F2)); - AddAssert("next random invoked", () => nextRandomCalled && !previousRandomCalled); - } - - [Test] - public void TestFooterRandomViaMouse() - { - AddStep("click button", () => - { - InputManager.MoveMouseTo(randomButton); - InputManager.Click(MouseButton.Left); - }); - AddAssert("next random invoked", () => nextRandomCalled && !previousRandomCalled); - } - - [Test] - public void TestFooterRewind() - { - AddStep("press Shift+F2", () => - { - InputManager.PressKey(Key.LShift); - InputManager.PressKey(Key.F2); - InputManager.ReleaseKey(Key.F2); - InputManager.ReleaseKey(Key.LShift); - }); - AddAssert("previous random invoked", () => previousRandomCalled && !nextRandomCalled); - } - - [Test] - public void TestFooterRewindViaShiftMouseLeft() - { - AddStep("shift + click button", () => - { - InputManager.PressKey(Key.LShift); - InputManager.MoveMouseTo(randomButton); - InputManager.Click(MouseButton.Left); - InputManager.ReleaseKey(Key.LShift); - }); - AddAssert("previous random invoked", () => previousRandomCalled && !nextRandomCalled); - } - - [Test] - public void TestFooterRewindViaMouseRight() - { - AddStep("right click button", () => - { - InputManager.MoveMouseTo(randomButton); - InputManager.Click(MouseButton.Right); - }); - AddAssert("previous random invoked", () => previousRandomCalled && !nextRandomCalled); - } - - [Test] - public void TestOverlayPresent() - { - AddStep("Press F1", () => - { - InputManager.MoveMouseTo(modsButton); - InputManager.Click(MouseButton.Left); - }); - AddAssert("Overlay visible", () => overlay.State.Value == Visibility.Visible); - AddStep("Hide", () => overlay.Hide()); - } - - private partial class DummyOverlay : ShearedOverlayContainer - { - public DummyOverlay() - : base(OverlayColourScheme.Green) - { - } - - [BackgroundDependencyLoader] - private void load() - { - Header.Title = "An overlay"; - } - } - } -} diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneUpdateBeatmapSetButton.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneUpdateBeatmapSetButton.cs index 6d97be730b..0b0cd0317a 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneUpdateBeatmapSetButton.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneUpdateBeatmapSetButton.cs @@ -246,7 +246,7 @@ namespace osu.Game.Tests.Visual.SongSelect private BeatmapCarousel createCarousel() { - return carousel = new BeatmapCarousel + return carousel = new BeatmapCarousel(new FilterCriteria()) { RelativeSizeAxes = Axes.Both, BeatmapSets = new List diff --git a/osu.Game.Tests/Visual/SongSelectV2/SongSelectComponentsTestScene.cs b/osu.Game.Tests/Visual/SongSelectV2/SongSelectComponentsTestScene.cs new file mode 100644 index 0000000000..b7b0101a7c --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelectV2/SongSelectComponentsTestScene.cs @@ -0,0 +1,67 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Testing; +using osu.Game.Overlays; + +namespace osu.Game.Tests.Visual.SongSelectV2 +{ + public abstract partial class SongSelectComponentsTestScene : OsuTestScene + { + [Cached] + protected readonly OverlayColourProvider ColourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + + protected override Container Content { get; } = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(10), + }; + + private Container? resizeContainer; + private float relativeWidth; + + [BackgroundDependencyLoader] + private void load() + { + base.Content.Child = resizeContainer = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(10), + Width = relativeWidth, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourProvider.Background5, + }, + Content + } + }; + + AddSliderStep("change relative width", 0, 1f, 1f, v => + { + if (resizeContainer != null) + resizeContainer.Width = v; + + relativeWidth = v; + }); + } + + [SetUpSteps] + public virtual void SetUpSteps() + { + AddStep("reset dependencies", () => + { + Beatmap.SetDefault(); + SelectedMods.SetDefault(); + }); + } + } +} diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedgeV2.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapInfoWedge.cs similarity index 94% rename from osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedgeV2.cs rename to osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapInfoWedge.cs index 2a3269ea0a..fbbab3a604 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedgeV2.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapInfoWedge.cs @@ -14,14 +14,11 @@ using osu.Game.Beatmaps; using osu.Game.Rulesets; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Legacy; -using osu.Game.Rulesets.Objects.Types; using osu.Game.Screens.Select; -using osuTK; -namespace osu.Game.Tests.Visual.SongSelect +namespace osu.Game.Tests.Visual.SongSelectV2 { - [TestFixture] - public partial class TestSceneBeatmapInfoWedgeV2 : OsuTestScene + public partial class TestSceneBeatmapInfoWedge : SongSelectComponentsTestScene { private RulesetStore rulesets = null!; private TestBeatmapInfoWedgeV2 infoWedge = null!; @@ -33,6 +30,13 @@ namespace osu.Game.Tests.Visual.SongSelect this.rulesets = rulesets; } + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("reset mods", () => SelectedMods.SetDefault()); + } + protected override void LoadComplete() { base.LoadComplete(); @@ -107,12 +111,6 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert("check artist", () => infoWedge.Info!.ArtistLabel.Current.Value == $"{ruleset.ShortName}Artist"); } - [SetUpSteps] - public void SetUpSteps() - { - AddStep("reset mods", () => SelectedMods.SetDefault()); - } - [Test] public void TestTruncation() { @@ -209,11 +207,6 @@ namespace osu.Game.Tests.Visual.SongSelect public new WedgeInfoText? Info => base.Info; } - private class TestHitObject : ConvertHitObject, IHasPosition - { - public float X => 0; - public float Y => 0; - public Vector2 Position { get; } = Vector2.Zero; - } + private class TestHitObject : ConvertHitObject; } } diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneDifficultyNameContent.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneDifficultyNameContent.cs new file mode 100644 index 0000000000..49e7e2bc1a --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneDifficultyNameContent.cs @@ -0,0 +1,44 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Localisation; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Screens.SelectV2.Wedge; + +namespace osu.Game.Tests.Visual.SongSelectV2 +{ + public partial class TestSceneDifficultyNameContent : SongSelectComponentsTestScene + { + private DifficultyNameContent? difficultyNameContent; + + [Test] + public void TestLocalBeatmap() + { + AddStep("set component", () => Child = difficultyNameContent = new LocalDifficultyNameContent()); + + AddAssert("difficulty name is not set", () => LocalisableString.IsNullOrEmpty(difficultyNameContent.ChildrenOfType().Single().Text)); + AddAssert("author is not set", () => LocalisableString.IsNullOrEmpty(difficultyNameContent.ChildrenOfType().Single().ChildrenOfType().Single().Text)); + + AddStep("set beatmap", () => Beatmap.Value = CreateWorkingBeatmap(new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + DifficultyName = "really long difficulty name that gets truncated", + Metadata = new BeatmapMetadata + { + Author = { Username = "really long username that is autosized" }, + }, + OnlineID = 1, + } + })); + + AddAssert("difficulty name is set", () => !LocalisableString.IsNullOrEmpty(difficultyNameContent.ChildrenOfType().Single().Text)); + AddAssert("author is set", () => !LocalisableString.IsNullOrEmpty(difficultyNameContent.ChildrenOfType().Single().ChildrenOfType().Single().Text)); + } + } +} diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneLeaderboardScore.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneLeaderboardScore.cs new file mode 100644 index 0000000000..a7d0d70c03 --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneLeaderboardScore.cs @@ -0,0 +1,205 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Utils; +using osu.Game.Configuration; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Screens.SelectV2.Leaderboards; +using osu.Game.Tests.Resources; +using osu.Game.Users; +using osuTK; + +namespace osu.Game.Tests.Visual.SongSelectV2 +{ + public partial class TestSceneLeaderboardScore : SongSelectComponentsTestScene + { + [Cached] + private OverlayColourProvider colourProvider { get; set; } = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + + [Resolved] + private OsuConfigManager config { get; set; } = null!; + + private FillFlowContainer? fillFlow; + private OsuSpriteText? drawWidthText; + + [Test] + public void TestSheared() + { + AddStep("create content", () => + { + Children = new Drawable[] + { + fillFlow = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(0f, 2f), + Shear = new Vector2(OsuGame.SHEAR, 0) + }, + drawWidthText = new OsuSpriteText(), + }; + + foreach (var scoreInfo in getTestScores()) + { + fillFlow.Add(new LeaderboardScoreV2(scoreInfo) + { + Rank = scoreInfo.Position, + IsPersonalBest = scoreInfo.User.Id == 2, + Shear = Vector2.Zero, + }); + } + + foreach (var score in fillFlow.Children) + score.Show(); + }); + } + + [Test] + public void TestNonSheared() + { + AddStep("create content", () => + { + Children = new Drawable[] + { + fillFlow = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(0f, 2f), + }, + drawWidthText = new OsuSpriteText(), + }; + + foreach (var scoreInfo in getTestScores()) + { + fillFlow.Add(new LeaderboardScoreV2(scoreInfo) + { + Rank = scoreInfo.Position, + IsPersonalBest = scoreInfo.User.Id == 2, + }); + } + + foreach (var score in fillFlow.Children) + score.Show(); + }); + } + + public override void SetUpSteps() + { + AddToggleStep("toggle scoring mode", v => config.SetValue(OsuSetting.ScoreDisplayMode, v ? ScoringMode.Classic : ScoringMode.Standardised)); + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + if (drawWidthText != null) drawWidthText.Text = $"DrawWidth: {fillFlow?.DrawWidth}"; + } + + private static ScoreInfo[] getTestScores() + { + var scores = new[] + { + new ScoreInfo + { + Position = 999, + Rank = ScoreRank.X, + Accuracy = 1, + MaxCombo = 244, + TotalScore = RNG.Next(1_800_000, 2_000_000), + MaximumStatistics = { { HitResult.Great, 3000 } }, + Ruleset = new OsuRuleset().RulesetInfo, + User = new APIUser + { + Id = 6602580, + Username = @"waaiiru", + CountryCode = CountryCode.ES, + CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c1.jpg", + }, + Date = DateTimeOffset.Now.AddYears(-2), + }, + new ScoreInfo + { + Position = 22333, + Rank = ScoreRank.S, + Accuracy = 0.1f, + MaxCombo = 32040, + TotalScore = RNG.Next(1_200_000, 1_500_000), + MaximumStatistics = { { HitResult.Great, 3000 } }, + Ruleset = new OsuRuleset().RulesetInfo, + User = new APIUser + { + Id = 1541390, + Username = @"Toukai", + CountryCode = CountryCode.CA, + CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c2.jpg", + }, + Date = DateTimeOffset.Now.AddMonths(-6), + }, + TestResources.CreateTestScoreInfo(), + new ScoreInfo + { + Position = 110000, + Rank = ScoreRank.B, + Accuracy = 1, + MaxCombo = 244, + TotalScore = RNG.Next(1_000_000, 1_200_000), + MaximumStatistics = { { HitResult.Great, 3000 } }, + Ruleset = new ManiaRuleset().RulesetInfo, + User = new APIUser + { + Username = @"No cover", + CountryCode = CountryCode.BR, + }, + Date = DateTimeOffset.Now, + }, + new ScoreInfo + { + Position = 110000, + Rank = ScoreRank.D, + Accuracy = 1, + MaxCombo = 244, + TotalScore = RNG.Next(500_000, 1_000_000), + MaximumStatistics = { { HitResult.Great, 3000 } }, + Ruleset = new ManiaRuleset().RulesetInfo, + User = new APIUser + { + Id = 226597, + Username = @"WWWWWWWWWWWWWWWWWWWW", + CountryCode = CountryCode.US, + }, + Date = DateTimeOffset.Now, + }, + }; + + scores[2].Rank = ScoreRank.A; + scores[2].TotalScore = RNG.Next(120_000, 400_000); + scores[2].MaximumStatistics[HitResult.Great] = 3000; + + scores[1].Mods = new Mod[] { new OsuModHidden(), new OsuModDoubleTime(), new OsuModHardRock(), new OsuModFlashlight() }; + scores[2].Mods = new Mod[] { new OsuModHidden(), new OsuModDoubleTime(), new OsuModHardRock(), new OsuModFlashlight(), new OsuModClassic() }; + scores[3].Mods = new Mod[] { new OsuModHidden(), new OsuModDoubleTime(), new OsuModHardRock(), new OsuModFlashlight(), new OsuModClassic(), new OsuModDifficultyAdjust() }; + scores[4].Mods = new ManiaRuleset().CreateAllMods().ToArray(); + + return scores; + } + } +} diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelect.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelect.cs new file mode 100644 index 0000000000..d43026c960 --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelect.cs @@ -0,0 +1,197 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Overlays.Mods; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Screens; +using osu.Game.Screens.Footer; +using osu.Game.Screens.Menu; +using osu.Game.Screens.SelectV2.Footer; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.SongSelectV2 +{ + public partial class TestSceneSongSelect : ScreenTestScene + { + [Cached] + private readonly ScreenFooter screenScreenFooter; + + [Cached] + private readonly OsuLogo logo; + + public TestSceneSongSelect() + { + Children = new Drawable[] + { + new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Child = screenScreenFooter = new ScreenFooter + { + OnBack = () => Stack.CurrentScreen.Exit(), + }, + }, + logo = new OsuLogo + { + Alpha = 0f, + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Stack.ScreenPushed += updateFooter; + Stack.ScreenExited += updateFooter; + } + + [SetUpSteps] + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("load screen", () => Stack.Push(new Screens.SelectV2.SongSelectV2())); + AddUntilStep("wait for load", () => Stack.CurrentScreen is Screens.SelectV2.SongSelectV2 songSelect && songSelect.IsLoaded); + } + + #region Footer + + [Test] + public void TestMods() + { + AddStep("one mod", () => SelectedMods.Value = new List { new OsuModHidden() }); + AddStep("two mods", () => SelectedMods.Value = new List { new OsuModHidden(), new OsuModHardRock() }); + AddStep("three mods", () => SelectedMods.Value = new List { new OsuModHidden(), new OsuModHardRock(), new OsuModDoubleTime() }); + AddStep("four mods", () => SelectedMods.Value = new List { new OsuModHidden(), new OsuModHardRock(), new OsuModDoubleTime(), new OsuModClassic() }); + AddStep("five mods", () => SelectedMods.Value = new List { new OsuModHidden(), new OsuModHardRock(), new OsuModDoubleTime(), new OsuModClassic(), new OsuModDifficultyAdjust() }); + + AddStep("modified", () => SelectedMods.Value = new List { new OsuModDoubleTime { SpeedChange = { Value = 1.2 } } }); + AddStep("modified + one", () => SelectedMods.Value = new List { new OsuModHidden(), new OsuModDoubleTime { SpeedChange = { Value = 1.2 } } }); + AddStep("modified + two", () => SelectedMods.Value = new List { new OsuModHidden(), new OsuModHardRock(), new OsuModDoubleTime { SpeedChange = { Value = 1.2 } } }); + AddStep("modified + three", () => SelectedMods.Value = new List { new OsuModHidden(), new OsuModHardRock(), new OsuModClassic(), new OsuModDoubleTime { SpeedChange = { Value = 1.2 } } }); + AddStep("modified + four", () => SelectedMods.Value = new List { new OsuModHidden(), new OsuModHardRock(), new OsuModClassic(), new OsuModDifficultyAdjust(), new OsuModDoubleTime { SpeedChange = { Value = 1.2 } } }); + + AddStep("clear mods", () => SelectedMods.Value = Array.Empty()); + AddWaitStep("wait", 3); + AddStep("one mod", () => SelectedMods.Value = new List { new OsuModHidden() }); + + AddStep("clear mods", () => SelectedMods.Value = Array.Empty()); + AddWaitStep("wait", 3); + AddStep("five mods", () => SelectedMods.Value = new List { new OsuModHidden(), new OsuModHardRock(), new OsuModDoubleTime(), new OsuModClassic(), new OsuModDifficultyAdjust() }); + } + + [Test] + public void TestShowOptions() + { + AddStep("enable options", () => + { + var optionsButton = this.ChildrenOfType().Last(); + + optionsButton.Enabled.Value = true; + optionsButton.TriggerClick(); + }); + } + + [Test] + public void TestState() + { + AddToggleStep("set options enabled state", state => this.ChildrenOfType().Last().Enabled.Value = state); + } + + // add these test cases when functionality is implemented. + // [Test] + // public void TestFooterRandom() + // { + // AddStep("press F2", () => InputManager.Key(Key.F2)); + // AddAssert("next random invoked", () => nextRandomCalled && !previousRandomCalled); + // } + // + // [Test] + // public void TestFooterRandomViaMouse() + // { + // AddStep("click button", () => + // { + // InputManager.MoveMouseTo(randomButton); + // InputManager.Click(MouseButton.Left); + // }); + // AddAssert("next random invoked", () => nextRandomCalled && !previousRandomCalled); + // } + // + // [Test] + // public void TestFooterRewind() + // { + // AddStep("press Shift+F2", () => + // { + // InputManager.PressKey(Key.LShift); + // InputManager.PressKey(Key.F2); + // InputManager.ReleaseKey(Key.F2); + // InputManager.ReleaseKey(Key.LShift); + // }); + // AddAssert("previous random invoked", () => previousRandomCalled && !nextRandomCalled); + // } + // + // [Test] + // public void TestFooterRewindViaShiftMouseLeft() + // { + // AddStep("shift + click button", () => + // { + // InputManager.PressKey(Key.LShift); + // InputManager.MoveMouseTo(randomButton); + // InputManager.Click(MouseButton.Left); + // InputManager.ReleaseKey(Key.LShift); + // }); + // AddAssert("previous random invoked", () => previousRandomCalled && !nextRandomCalled); + // } + // + // [Test] + // public void TestFooterRewindViaMouseRight() + // { + // AddStep("right click button", () => + // { + // InputManager.MoveMouseTo(randomButton); + // InputManager.Click(MouseButton.Right); + // }); + // AddAssert("previous random invoked", () => previousRandomCalled && !nextRandomCalled); + // } + + [Test] + public void TestOverlayPresent() + { + AddStep("Press F1", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + AddAssert("Overlay visible", () => this.ChildrenOfType().Single().State.Value == Visibility.Visible); + AddStep("Hide", () => this.ChildrenOfType().Single().Hide()); + } + + #endregion + + private void updateFooter(IScreen? _, IScreen? newScreen) + { + if (newScreen is IOsuScreen osuScreen && osuScreen.ShowFooter) + { + screenScreenFooter.Show(); + screenScreenFooter.SetButtons(osuScreen.CreateFooterButtons()); + } + else + { + screenScreenFooter.Hide(); + screenScreenFooter.SetButtons(Array.Empty()); + } + } + } +} diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectNavigation.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectNavigation.cs new file mode 100644 index 0000000000..5173cb5673 --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectNavigation.cs @@ -0,0 +1,32 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Screens.Menu; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.SongSelectV2 +{ + public partial class TestSceneSongSelectNavigation : OsuGameTestScene + { + public override void SetUpSteps() + { + base.SetUpSteps(); + AddStep("press enter", () => InputManager.Key(Key.Enter)); + AddWaitStep("wait", 5); + PushAndConfirm(() => new Screens.SelectV2.SongSelectV2()); + } + + [Test] + public void TestClickLogo() + { + AddStep("click", () => + { + InputManager.MoveMouseTo(Game.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBackButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBackButton.cs index 494268b158..7aaf616767 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneBackButton.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBackButton.cs @@ -5,6 +5,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics.UserInterface; +using osu.Game.Screens.Footer; using osuTK; using osuTK.Graphics; @@ -15,7 +16,7 @@ namespace osu.Game.Tests.Visual.UserInterface public TestSceneBackButton() { BackButton button; - BackButton.Receptor receptor = new BackButton.Receptor(); + ScreenFooter.BackReceptor receptor = new ScreenFooter.BackReceptor(); Child = new Container { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapAttributeText.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapAttributeText.cs new file mode 100644 index 0000000000..e3a6fca319 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapAttributeText.cs @@ -0,0 +1,251 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Models; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.UI; +using osu.Game.Scoring; +using osu.Game.Skinning.Components; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public partial class TestSceneBeatmapAttributeText : OsuTestScene + { + private readonly BeatmapAttributeText text; + + public TestSceneBeatmapAttributeText() + { + Child = text = new BeatmapAttributeText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }; + } + + [SetUp] + public void Setup() => Schedule(() => + { + SelectedMods.SetDefault(); + Ruleset.Value = new OsuRuleset().RulesetInfo; + Beatmap.Value = CreateWorkingBeatmap(new TestBeatmap(new OsuRuleset().RulesetInfo) + { + BeatmapInfo = + { + BPM = 100, + DifficultyName = "_Difficulty", + Status = BeatmapOnlineStatus.Loved, + Metadata = + { + Title = "_Title", + TitleUnicode = "_Title", + Artist = "_Artist", + ArtistUnicode = "_Artist", + Author = new RealmUser { Username = "_Creator" }, + Source = "_Source", + }, + Difficulty = + { + CircleSize = 1, + DrainRate = 2, + OverallDifficulty = 3, + ApproachRate = 4, + } + } + }); + }); + + [TestCase(BeatmapAttribute.CircleSize, "Circle Size: 1.00")] + [TestCase(BeatmapAttribute.HPDrain, "HP Drain: 2.00")] + [TestCase(BeatmapAttribute.Accuracy, "Accuracy: 3.00")] + [TestCase(BeatmapAttribute.ApproachRate, "Approach Rate: 4.00")] + [TestCase(BeatmapAttribute.Title, "Title: _Title")] + [TestCase(BeatmapAttribute.Artist, "Artist: _Artist")] + [TestCase(BeatmapAttribute.Creator, "Creator: _Creator")] + [TestCase(BeatmapAttribute.DifficultyName, "Difficulty: _Difficulty")] + [TestCase(BeatmapAttribute.Source, "Source: _Source")] + [TestCase(BeatmapAttribute.RankedStatus, "Beatmap Status: Loved")] + public void TestAttributeDisplay(BeatmapAttribute attribute, string expectedText) + { + AddStep($"set attribute: {attribute}", () => text.Attribute.Value = attribute); + AddAssert("check correct text", getText, () => Is.EqualTo(expectedText)); + } + + [Test] + public void TestChangeBeatmap() + { + AddStep("set title attribute", () => text.Attribute.Value = BeatmapAttribute.Title); + AddAssert("check initial title", getText, () => Is.EqualTo("Title: _Title")); + + AddStep("change to beatmap with another title", () => Beatmap.Value = CreateWorkingBeatmap(new TestBeatmap(new OsuRuleset().RulesetInfo) + { + BeatmapInfo = + { + Metadata = + { + Title = "Another" + } + } + })); + + AddAssert("check new title", getText, () => Is.EqualTo("Title: Another")); + } + + [Test] + public void TestWithMods() + { + AddStep("set beatmap", () => Beatmap.Value = CreateWorkingBeatmap(new TestBeatmap(new OsuRuleset().RulesetInfo) + { + BeatmapInfo = + { + BPM = 100, + Length = 30000, + Difficulty = + { + ApproachRate = 10, + CircleSize = 9 + } + } + })); + + test(BeatmapAttribute.BPM, new OsuModDoubleTime(), "BPM: 100.00", "BPM: 150.00"); + test(BeatmapAttribute.Length, new OsuModDoubleTime(), "Length: 00:30", "Length: 00:20"); + test(BeatmapAttribute.ApproachRate, new OsuModDoubleTime(), "Approach Rate: 10.00", "Approach Rate: 11.00"); + test(BeatmapAttribute.CircleSize, new OsuModHardRock(), "Circle Size: 9.00", "Circle Size: 10.00"); + + void test(BeatmapAttribute attribute, Mod mod, string before, string after) + { + AddStep($"set attribute: {attribute}", () => text.Attribute.Value = attribute); + AddAssert("check text is correct", getText, () => Is.EqualTo(before)); + + AddStep("add DT mod", () => SelectedMods.Value = new[] { mod }); + AddAssert("check text is correct", getText, () => Is.EqualTo(after)); + AddStep("clear mods", () => SelectedMods.SetDefault()); + } + } + + [Test] + public void TestStarRating() + { + AddStep("set test ruleset", () => Ruleset.Value = new TestRuleset().RulesetInfo); + AddStep("set star rating attribute", () => text.Attribute.Value = BeatmapAttribute.StarRating); + AddAssert("check star rating is 0", getText, () => Is.EqualTo("Star Rating: 0.00")); + + // Adding mod + TestMod mod = null!; + AddStep("add mod with difficulty 1", () => SelectedMods.Value = new[] { mod = new TestMod { Difficulty = { Value = 1 } } }); + AddUntilStep("check star rating is 1", getText, () => Is.EqualTo("Star Rating: 1.00")); + + // Changing mod setting + AddStep("change mod difficulty to 2", () => mod.Difficulty.Value = 2); + AddUntilStep("check star rating is 2", getText, () => Is.EqualTo("Star Rating: 2.00")); + } + + [Test] + public void TestMaxPp() + { + AddStep("set test ruleset", () => Ruleset.Value = new TestRuleset().RulesetInfo); + AddStep("set max pp attribute", () => text.Attribute.Value = BeatmapAttribute.MaxPP); + AddAssert("check max pp is 0", getText, () => Is.EqualTo("Max PP: 0")); + + // Adding mod + TestMod mod = null!; + AddStep("add mod with pp 1", () => SelectedMods.Value = new[] { mod = new TestMod { Performance = { Value = 1 } } }); + AddUntilStep("check max pp is 1", getText, () => Is.EqualTo("Max PP: 1")); + + // Changing mod setting + AddStep("change mod pp to 2", () => mod.Performance.Value = 2); + AddUntilStep("check max pp is 2", getText, () => Is.EqualTo("Max PP: 2")); + } + + private string getText() => text.ChildrenOfType().Single().Text.ToString(); + + private class TestRuleset : Ruleset + { + public override IEnumerable GetModsFor(ModType type) => new[] + { + new TestMod() + }; + + public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) + => new OsuRuleset().CreateBeatmapConverter(beatmap); + + public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) + => new TestDifficultyCalculator(new TestRuleset().RulesetInfo, beatmap); + + public override PerformanceCalculator CreatePerformanceCalculator() + => new TestPerformanceCalculator(new TestRuleset()); + + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList? mods = null) + => null!; + + public override string Description => string.Empty; + public override string ShortName => string.Empty; + } + + private class TestDifficultyCalculator : DifficultyCalculator + { + public TestDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) + : base(ruleset, beatmap) + { + } + + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + => new DifficultyAttributes(mods, mods.OfType().SingleOrDefault()?.Difficulty.Value ?? 0); + + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) + => Array.Empty(); + + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) + => Array.Empty(); + } + + private class TestPerformanceCalculator : PerformanceCalculator + { + public TestPerformanceCalculator(Ruleset ruleset) + : base(ruleset) + { + } + + protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo score, DifficultyAttributes attributes) + => new PerformanceAttributes { Total = score.Mods.OfType().SingleOrDefault()?.Performance.Value ?? 0 }; + } + + private class TestMod : Mod + { + [SettingSource("difficulty")] + public BindableDouble Difficulty { get; } = new BindableDouble(0); + + [SettingSource("performance")] + public BindableDouble Performance { get; } = new BindableDouble(0); + + [JsonConstructor] + public TestMod() + { + } + + public override string Name => string.Empty; + public override LocalisableString Description => string.Empty; + public override double ScoreMultiplier => 1.0; + public override string Acronym => "Test"; + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneButtonSystem.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneButtonSystem.cs index 8f72be37df..d8baca6d23 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneButtonSystem.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneButtonSystem.cs @@ -110,7 +110,7 @@ namespace osu.Game.Tests.Visual.UserInterface break; case Key.Q: - buttons.OnExit = action; + buttons.OnExit = _ => action(); break; case Key.O: diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneCommentEditor.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneCommentEditor.cs index e1d40882be..721e231577 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneCommentEditor.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneCommentEditor.cs @@ -12,6 +12,7 @@ using osu.Framework.Localisation; using osu.Framework.Testing; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.Comments; using osuTK; @@ -28,9 +29,10 @@ namespace osu.Game.Tests.Visual.UserInterface private TestCancellableCommentEditor cancellableCommentEditor = null!; private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; - [SetUp] - public void SetUp() => Schedule(() => - Add(new FillFlowContainer + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create content", () => Child = new FillFlowContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -43,7 +45,8 @@ namespace osu.Game.Tests.Visual.UserInterface commentEditor = new TestCommentEditor(), cancellableCommentEditor = new TestCancellableCommentEditor() } - })); + }); + } [Test] public void TestCommitViaKeyboard() @@ -133,6 +136,34 @@ namespace osu.Game.Tests.Visual.UserInterface assertLoggedInState(); } + [Test] + public void TestCommentsDisabled() + { + AddStep("no reason for disable", () => commentEditor.CommentableMeta.Value = new CommentableMeta + { + CurrentUserAttributes = new CommentableMeta.CommentableCurrentUserAttributes(), + }); + AddAssert("textbox enabled", () => commentEditor.ChildrenOfType().Single().ReadOnly, () => Is.False); + + AddStep("specific reason for disable", () => commentEditor.CommentableMeta.Value = new CommentableMeta + { + CurrentUserAttributes = new CommentableMeta.CommentableCurrentUserAttributes + { + CanNewCommentReason = "This comment section is disabled. For reasons.", + } + }); + AddAssert("textbox disabled", () => commentEditor.ChildrenOfType().Single().ReadOnly, () => Is.True); + + AddStep("entire commentable meta missing", () => commentEditor.CommentableMeta.Value = null); + AddAssert("textbox enabled", () => commentEditor.ChildrenOfType().Single().ReadOnly, () => Is.False); + + AddStep("current user attributes missing", () => commentEditor.CommentableMeta.Value = new CommentableMeta + { + CurrentUserAttributes = null, + }); + AddAssert("textbox enabled", () => commentEditor.ChildrenOfType().Single().ReadOnly, () => Is.True); + } + [Test] public void TestCancelAction() { @@ -167,8 +198,7 @@ namespace osu.Game.Tests.Visual.UserInterface protected override LocalisableString GetButtonText(bool isLoggedIn) => isLoggedIn ? @"Commit" : "You're logged out!"; - protected override LocalisableString GetPlaceholderText(bool isLoggedIn) => - isLoggedIn ? @"This text box is empty" : "Still empty, but now you can't type in it."; + protected override LocalisableString GetPlaceholderText() => @"This text box is empty"; } private partial class TestCancellableCommentEditor : CancellableCommentEditor @@ -189,7 +219,7 @@ namespace osu.Game.Tests.Visual.UserInterface } protected override LocalisableString GetButtonText(bool isLoggedIn) => @"Save"; - protected override LocalisableString GetPlaceholderText(bool isLoggedIn) => @"Multiline textboxes soon"; + protected override LocalisableString GetPlaceholderText() => @"Multiline textboxes soon"; } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneCommentRepliesButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneCommentRepliesButton.cs index eaaf40fb36..0aef56bc2e 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneCommentRepliesButton.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneCommentRepliesButton.cs @@ -47,9 +47,9 @@ namespace osu.Game.Tests.Visual.UserInterface public void TestArrowDirection() { AddStep("Set upwards", () => button.SetIconDirection(true)); - AddAssert("Icon facing upwards", () => button.Icon.Scale.Y == -1); + AddUntilStep("Icon facing upwards", () => button.Icon.Scale.Y == -1); AddStep("Set downwards", () => button.SetIconDirection(false)); - AddAssert("Icon facing downwards", () => button.Icon.Scale.Y == 1); + AddUntilStep("Icon facing downwards", () => button.Icon.Scale.Y == 1); } private partial class TestButton : CommentRepliesButton diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunSetupOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunSetupOverlay.cs index 51da4d8755..2ca06bf2f4 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunSetupOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunSetupOverlay.cs @@ -11,6 +11,8 @@ using Moq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Screens; using osu.Framework.Testing; @@ -20,6 +22,7 @@ using osu.Game.Overlays; using osu.Game.Overlays.FirstRunSetup; using osu.Game.Overlays.Notifications; using osu.Game.Screens; +using osu.Game.Screens.Footer; using osuTK; using osuTK.Input; @@ -28,6 +31,7 @@ namespace osu.Game.Tests.Visual.UserInterface public partial class TestSceneFirstRunSetupOverlay : OsuManualInputManagerTestScene { private FirstRunSetupOverlay overlay; + private ScreenFooter footer; private readonly Mock performer = new Mock(); @@ -60,19 +64,16 @@ namespace osu.Game.Tests.Visual.UserInterface .Callback((Notification n) => lastNotification = n); }); - AddStep("add overlay", () => - { - Child = overlay = new FirstRunSetupOverlay - { - State = { Value = Visibility.Visible } - }; - }); + createOverlay(); + + AddStep("show overlay", () => overlay.Show()); } [Test] public void TestBasic() { AddAssert("overlay visible", () => overlay.State.Value == Visibility.Visible); + AddAssert("footer visible", () => footer.State.Value == Visibility.Visible); } [Test] @@ -82,16 +83,13 @@ namespace osu.Game.Tests.Visual.UserInterface AddUntilStep("step through", () => { - if (overlay.CurrentScreen?.IsLoaded != false) overlay.NextButton.TriggerClick(); + if (overlay.CurrentScreen?.IsLoaded != false) overlay.NextButton.AsNonNull().TriggerClick(); return overlay.State.Value == Visibility.Hidden; }); AddAssert("first run false", () => !LocalConfig.Get(OsuSetting.ShowFirstRunSetup)); - AddStep("add overlay", () => - { - Child = overlay = new FirstRunSetupOverlay(); - }); + createOverlay(); AddWaitStep("wait some", 5); @@ -109,7 +107,7 @@ namespace osu.Game.Tests.Visual.UserInterface if (keyboard) InputManager.Key(Key.Enter); else - overlay.NextButton.TriggerClick(); + overlay.NextButton.AsNonNull().TriggerClick(); } return overlay.State.Value == Visibility.Hidden; @@ -128,11 +126,9 @@ namespace osu.Game.Tests.Visual.UserInterface [TestCase(true)] public void TestBackButton(bool keyboard) { - AddAssert("back button disabled", () => !overlay.BackButton.Enabled.Value); - AddUntilStep("step to last", () => { - var nextButton = overlay.NextButton; + var nextButton = overlay.NextButton.AsNonNull(); if (overlay.CurrentScreen?.IsLoaded != false) nextButton.TriggerClick(); @@ -142,24 +138,29 @@ namespace osu.Game.Tests.Visual.UserInterface AddUntilStep("step back to start", () => { - if (overlay.CurrentScreen?.IsLoaded != false) + if (overlay.CurrentScreen?.IsLoaded != false && !(overlay.CurrentScreen is ScreenWelcome)) { if (keyboard) InputManager.Key(Key.Escape); else - overlay.BackButton.TriggerClick(); + footer.BackButton.TriggerClick(); } return overlay.CurrentScreen is ScreenWelcome; }); - AddAssert("back button disabled", () => !overlay.BackButton.Enabled.Value); + AddAssert("overlay not dismissed", () => overlay.State.Value == Visibility.Visible); if (keyboard) { AddStep("exit via keyboard", () => InputManager.Key(Key.Escape)); AddAssert("overlay dismissed", () => overlay.State.Value == Visibility.Hidden); } + else + { + AddStep("press back button", () => footer.BackButton.TriggerClick()); + AddAssert("overlay dismissed", () => overlay.State.Value == Visibility.Hidden); + } } [Test] @@ -185,7 +186,7 @@ namespace osu.Game.Tests.Visual.UserInterface [Test] public void TestResumeViaNotification() { - AddStep("step to next", () => overlay.NextButton.TriggerClick()); + AddStep("step to next", () => overlay.NextButton.AsNonNull().TriggerClick()); AddAssert("is at known screen", () => overlay.CurrentScreen is ScreenUIScale); @@ -200,6 +201,27 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("is resumed", () => overlay.CurrentScreen is ScreenUIScale); } + private void createOverlay() + { + AddStep("add overlay", () => + { + var receptor = new ScreenFooter.BackReceptor(); + footer = new ScreenFooter(receptor); + + Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = new[] { (typeof(ScreenFooter), (object)footer) }, + Children = new Drawable[] + { + receptor, + overlay = new FirstRunSetupOverlay(), + footer, + } + }; + }); + } + // interface mocks break hot reload, mocking this stub implementation instead works around it. // see: https://github.com/moq/moq4/issues/1252 [UsedImplicitly] diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFormControls.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFormControls.cs new file mode 100644 index 0000000000..c6fd65b973 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFormControls.cs @@ -0,0 +1,111 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Cursor; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Localisation; +using osuTK; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public partial class TestSceneFormControls : ThemeComparisonTestScene + { + public TestSceneFormControls() + : base(false) + { + } + + protected override Drawable CreateContent() => new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Child = new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 400, + Direction = FillDirection.Vertical, + Spacing = new Vector2(5), + Padding = new MarginPadding(10), + Children = new Drawable[] + { + new FormTextBox + { + Caption = "Artist", + HintText = "Poot artist here!", + PlaceholderText = "Here is an artist", + TabbableContentContainer = this, + }, + new FormTextBox + { + Caption = "Artist", + HintText = "Poot artist here!", + PlaceholderText = "Here is an artist", + Current = { Disabled = true }, + TabbableContentContainer = this, + }, + new FormNumberBox + { + Caption = "Number", + HintText = "Insert your favourite number", + PlaceholderText = "Mine is 42!", + TabbableContentContainer = this, + }, + new FormCheckBox + { + Caption = EditorSetupStrings.LetterboxDuringBreaks, + HintText = EditorSetupStrings.LetterboxDuringBreaksDescription, + }, + new FormCheckBox + { + Caption = EditorSetupStrings.LetterboxDuringBreaks, + HintText = EditorSetupStrings.LetterboxDuringBreaksDescription, + Current = { Disabled = true }, + }, + new FormSliderBar + { + Caption = "Slider", + Current = new BindableFloat + { + MinValue = 0, + MaxValue = 10, + Value = 5, + Precision = 0.1f, + }, + TabbableContentContainer = this, + }, + new FormEnumDropdown + { + Caption = EditorSetupStrings.EnableCountdown, + HintText = EditorSetupStrings.CountdownDescription, + }, + new FormFileSelector + { + Caption = "Audio file", + PlaceholderText = "Select an audio file", + }, + new FormColourPalette + { + Caption = "Combo colours", + Colours = + { + Colour4.Red, + Colour4.Green, + Colour4.Blue, + Colour4.Yellow, + } + }, + }, + }, + } + }; + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFormSliderBar.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFormSliderBar.cs new file mode 100644 index 0000000000..97835a993d --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFormSliderBar.cs @@ -0,0 +1,63 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public partial class TestSceneFormSliderBar : OsuTestScene + { + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + + [Test] + public void TestTransferValueOnCommit() + { + OsuSpriteText text; + FormSliderBar slider = null!; + + AddStep("create content", () => + { + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 0.5f, + Direction = FillDirection.Vertical, + Spacing = new Vector2(10), + Children = new Drawable[] + { + text = new OsuSpriteText(), + slider = new FormSliderBar + { + Caption = "Slider", + Current = new BindableFloat + { + MinValue = 0, + MaxValue = 10, + Precision = 0.1f, + Default = 5f, + } + }, + } + }; + slider.Current.BindValueChanged(_ => text.Text = $"Current value is: {slider.Current.Value}", true); + }); + AddToggleStep("toggle transfer value on commit", b => + { + if (slider.IsNotNull()) + slider.TransferValueOnCommit = b; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneHotkeyDisplay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneHotkeyDisplay.cs new file mode 100644 index 0000000000..1c2c94dbf1 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneHotkeyDisplay.cs @@ -0,0 +1,28 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input; +using osu.Framework.Input.Bindings; +using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public partial class TestSceneHotkeyDisplay : ThemeComparisonTestScene + { + protected override Drawable CreateContent() => new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Vertical, + Children = new[] + { + new HotkeyDisplay { Hotkey = new Hotkey(new KeyCombination(InputKey.MouseLeft)) }, + new HotkeyDisplay { Hotkey = new Hotkey(GlobalAction.EditorDecreaseDistanceSpacing) }, + new HotkeyDisplay { Hotkey = new Hotkey(PlatformAction.Save) }, + } + }; + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneMainMenuButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneMainMenuButton.cs new file mode 100644 index 0000000000..4925facd8a --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneMainMenuButton.cs @@ -0,0 +1,181 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Graphics; +using osu.Game.Localisation; +using osu.Game.Online.API; +using osu.Game.Online.Metadata; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Screens.Menu; +using osuTK.Input; +using Color4 = osuTK.Graphics.Color4; + +namespace osu.Game.Tests.Visual.UserInterface +{ + [TestFixture] + public partial class TestSceneMainMenuButton : OsuTestScene + { + [Resolved] + private MetadataClient metadataClient { get; set; } = null!; + + private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; + + [Test] + public void TestStandardButton() + { + AddStep("add button", () => Child = new MainMenuButton( + ButtonSystemStrings.Solo, @"button-default-select", OsuIcon.Player, new Color4(102, 68, 204, 255), (_, _) => { }, 0, Key.P) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + ButtonSystemState = ButtonSystemState.TopLevel, + }); + } + + [Test] + public void TestDailyChallengeButton() + { + AddStep("set up API", () => dummyAPI.HandleRequest = req => + { + switch (req) + { + case GetRoomRequest getRoomRequest: + if (getRoomRequest.RoomId != 1234) + return false; + + var beatmap = CreateAPIBeatmap(); + beatmap.OnlineID = 1001; + getRoomRequest.TriggerSuccess(new Room + { + RoomID = { Value = 1234 }, + Playlist = + { + new PlaylistItem(beatmap) + }, + StartDate = { Value = DateTimeOffset.Now.AddMinutes(-5) }, + EndDate = { Value = DateTimeOffset.Now.AddSeconds(30) } + }); + return true; + + default: + return false; + } + }); + + NotificationOverlay notificationOverlay = null!; + DependencyProvidingContainer buttonContainer = null!; + + AddStep("beatmap of the day active", () => metadataClient.DailyChallengeUpdated(new DailyChallengeInfo + { + RoomID = 1234, + })); + AddStep("add content", () => + { + notificationOverlay = new NotificationOverlay(); + Children = new Drawable[] + { + notificationOverlay, + buttonContainer = new DependencyProvidingContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + CachedDependencies = [(typeof(INotificationOverlay), notificationOverlay)], + Child = new DailyChallengeButton(@"button-default-select", new Color4(102, 68, 204, 255), (_, _) => { }, 0, Key.D) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + ButtonSystemState = ButtonSystemState.TopLevel, + }, + }, + }; + }); + AddAssert("notification posted", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1)); + + AddStep("clear notifications", () => + { + foreach (var notification in notificationOverlay.AllNotifications) + notification.Close(runFlingAnimation: false); + }); + + AddStep("beatmap of the day not active", () => metadataClient.DailyChallengeUpdated(null)); + AddAssert("no notification posted", () => notificationOverlay.AllNotifications.Count(), () => Is.Zero); + + AddStep("hide button's parent", () => buttonContainer.Hide()); + + AddStep("beatmap of the day active", () => metadataClient.DailyChallengeUpdated(new DailyChallengeInfo + { + RoomID = 1234, + })); + AddAssert("no notification posted", () => notificationOverlay.AllNotifications.Count(), () => Is.Zero); + } + + [Test] + public void TestDailyChallengeButtonOldChallenge() + { + AddStep("set up API", () => dummyAPI.HandleRequest = req => + { + switch (req) + { + case GetRoomRequest getRoomRequest: + if (getRoomRequest.RoomId != 1234) + return false; + + var beatmap = CreateAPIBeatmap(); + beatmap.OnlineID = 1001; + getRoomRequest.TriggerSuccess(new Room + { + RoomID = { Value = 1234 }, + Playlist = + { + new PlaylistItem(beatmap) + }, + StartDate = { Value = DateTimeOffset.Now.AddMinutes(-50) }, + EndDate = { Value = DateTimeOffset.Now.AddSeconds(30) } + }); + return true; + + default: + return false; + } + }); + + NotificationOverlay notificationOverlay = null!; + + AddStep("beatmap of the day not active", () => metadataClient.DailyChallengeUpdated(null)); + AddStep("add content", () => + { + notificationOverlay = new NotificationOverlay(); + Children = new Drawable[] + { + notificationOverlay, + new DependencyProvidingContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + CachedDependencies = [(typeof(INotificationOverlay), notificationOverlay)], + Child = new DailyChallengeButton(@"button-default-select", new Color4(102, 68, 204, 255), (_, _) => { }, 0, Key.D) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + ButtonSystemState = ButtonSystemState.TopLevel, + }, + }, + }; + }); + + AddStep("beatmap of the day active", () => metadataClient.DailyChallengeUpdated(new DailyChallengeInfo + { + RoomID = 1234 + })); + AddAssert("no notification posted", () => notificationOverlay.AllNotifications.Count(), () => Is.Zero); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModCustomisationPanel.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModCustomisationPanel.cs new file mode 100644 index 0000000000..04cb129630 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModCustomisationPanel.cs @@ -0,0 +1,176 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input; +using osu.Framework.Testing; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osu.Game.Overlays.Mods; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public partial class TestSceneModCustomisationPanel : OsuManualInputManagerTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + + private ModCustomisationPanel panel = null!; + private ModCustomisationHeader header = null!; + private Container content = null!; + + [SetUp] + public void SetUp() => Schedule(() => + { + SelectedMods.Value = Array.Empty(); + InputManager.MoveMouseTo(Vector2.One); + + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(20f), + Child = panel = new ModCustomisationPanel + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Width = 400f, + State = { Value = Visibility.Visible }, + SelectedMods = { BindTarget = SelectedMods }, + } + }; + + header = panel.Children.OfType().First(); + content = panel.Children.OfType().First(); + }); + + [Test] + public void TestDisplay() + { + AddStep("set DT", () => + { + SelectedMods.Value = new[] { new OsuModDoubleTime() }; + panel.Enabled.Value = true; + panel.ExpandedState.Value = ModCustomisationPanel.ModCustomisationPanelState.ExpandedByMod; + }); + AddStep("set DA", () => + { + SelectedMods.Value = new Mod[] { new OsuModDifficultyAdjust() }; + panel.Enabled.Value = true; + panel.ExpandedState.Value = ModCustomisationPanel.ModCustomisationPanelState.ExpandedByMod; + }); + AddStep("set FL+WU+DA+AD", () => + { + SelectedMods.Value = new Mod[] { new OsuModFlashlight(), new ModWindUp(), new OsuModDifficultyAdjust(), new OsuModApproachDifferent() }; + panel.Enabled.Value = true; + panel.ExpandedState.Value = ModCustomisationPanel.ModCustomisationPanelState.ExpandedByMod; + }); + AddStep("set empty", () => + { + SelectedMods.Value = Array.Empty(); + panel.Enabled.Value = false; + panel.ExpandedState.Value = ModCustomisationPanel.ModCustomisationPanelState.Collapsed; + }); + } + + [Test] + public void TestHoverDoesNotExpandWhenNoCustomisableMods() + { + AddStep("hover header", () => InputManager.MoveMouseTo(header)); + + checkExpanded(false); + + AddStep("hover content", () => InputManager.MoveMouseTo(content)); + + checkExpanded(false); + + AddStep("left from content", () => InputManager.MoveMouseTo(Vector2.One)); + } + + [Test] + public void TestHoverExpandsWithCustomisableMods() + { + AddStep("add customisable mod", () => + { + SelectedMods.Value = new[] { new OsuModDoubleTime() }; + panel.Enabled.Value = true; + }); + + AddStep("hover header", () => InputManager.MoveMouseTo(header)); + checkExpanded(true); + + AddStep("move to content", () => InputManager.MoveMouseTo(content)); + checkExpanded(true); + + AddStep("move away", () => InputManager.MoveMouseTo(Vector2.One)); + checkExpanded(false); + + AddStep("hover header", () => InputManager.MoveMouseTo(header)); + checkExpanded(true); + + AddStep("move away", () => InputManager.MoveMouseTo(Vector2.One)); + checkExpanded(false); + } + + [Test] + public void TestHoverExpandsAndCollapsesWhenHeaderTouched() + { + AddStep("add customisable mod", () => + { + SelectedMods.Value = new[] { new OsuModDoubleTime() }; + panel.Enabled.Value = true; + }); + + AddStep("touch header", () => + { + var touch = new Touch(TouchSource.Touch1, header.ScreenSpaceDrawQuad.Centre); + InputManager.BeginTouch(touch); + Schedule(() => InputManager.EndTouch(touch)); + }); + checkExpanded(true); + + AddStep("touch away from header", () => + { + var touch = new Touch(TouchSource.Touch1, header.ScreenSpaceDrawQuad.TopLeft - new Vector2(10)); + InputManager.BeginTouch(touch); + Schedule(() => InputManager.EndTouch(touch)); + }); + checkExpanded(false); + } + + [Test] + public void TestDraggingKeepsPanelExpanded() + { + AddStep("add customisable mod", () => + { + SelectedMods.Value = new[] { new OsuModDoubleTime() }; + panel.Enabled.Value = true; + }); + + AddStep("hover header", () => InputManager.MoveMouseTo(header)); + checkExpanded(true); + + AddStep("hover slider bar nub", () => InputManager.MoveMouseTo(panel.ChildrenOfType>().First().ChildrenOfType().Single())); + AddStep("hold", () => InputManager.PressButton(MouseButton.Left)); + AddStep("drag outside", () => InputManager.MoveMouseTo(Vector2.Zero)); + checkExpanded(true); + + AddStep("release", () => InputManager.ReleaseButton(MouseButton.Left)); + checkExpanded(false); + } + + private void checkExpanded(bool expanded) + { + AddUntilStep(expanded ? "is expanded" : "not expanded", () => panel.ExpandedState.Value, + () => expanded ? Is.Not.EqualTo(ModCustomisationPanel.ModCustomisationPanelState.Collapsed) : Is.EqualTo(ModCustomisationPanel.ModCustomisationPanelState.Collapsed)); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModDifficultyAdjustSettings.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModDifficultyAdjustSettings.cs index 307f436f84..b40d0b10d2 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModDifficultyAdjustSettings.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModDifficultyAdjustSettings.cs @@ -70,7 +70,7 @@ namespace osu.Game.Tests.Visual.UserInterface } [Test] - public void TestOutOfRangeValueStillApplied() + public void TestValueAboveRangeStillApplied() { AddStep("set override cs to 11", () => modDifficultyAdjust.CircleSize.Value = 11); @@ -91,6 +91,28 @@ namespace osu.Game.Tests.Visual.UserInterface checkBindableAtValue("Circle Size", 11); } + [Test] + public void TestValueBelowRangeStillApplied() + { + AddStep("set override cs to -5", () => modDifficultyAdjust.ApproachRate.Value = -5); + + checkSliderAtValue("Approach Rate", -5); + checkBindableAtValue("Approach Rate", -5); + + // this is a no-op, just showing that it won't reset the value during deserialisation. + setExtendedLimits(false); + + checkSliderAtValue("Approach Rate", -5); + checkBindableAtValue("Approach Rate", -5); + + // setting extended limits will reset the serialisation exception. + // this should be fine as the goal is to allow, at most, the value of extended limits. + setExtendedLimits(true); + + checkSliderAtValue("Approach Rate", -5); + checkBindableAtValue("Approach Rate", -5); + } + [Test] public void TestExtendedLimits() { @@ -109,6 +131,11 @@ namespace osu.Game.Tests.Visual.UserInterface checkSliderAtValue("Circle Size", 11); checkBindableAtValue("Circle Size", 11); + setSliderValue("Approach Rate", -5); + + checkSliderAtValue("Approach Rate", -5); + checkBindableAtValue("Approach Rate", -5); + setExtendedLimits(false); checkSliderAtValue("Circle Size", 10); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetPanel.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetPanel.cs index c79cbd3691..f87d8e0d2b 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetPanel.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetPanel.cs @@ -123,6 +123,43 @@ namespace osu.Game.Tests.Visual.UserInterface assertSelectedModsEquivalentTo(new Mod[] { new OsuModTouchDevice(), new OsuModHardRock(), new OsuModDoubleTime { SpeedChange = { Value = 1.5 } } }); } + [Test] + public void TestSystemModsNotPreservedIfIncompatibleWithPresetMods() + { + ModPresetPanel? panel = null; + + AddStep("create panel", () => Child = panel = new ModPresetPanel(new ModPreset + { + Name = "Autopilot included", + Description = "no way", + Mods = new Mod[] + { + new OsuModAutopilot() + }, + Ruleset = new OsuRuleset().RulesetInfo + }.ToLiveUnmanaged()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 0.5f + }); + + AddStep("Add touch device to selected mods", () => SelectedMods.Value = new Mod[] { new OsuModTouchDevice() }); + AddStep("activate panel", () => panel.AsNonNull().TriggerClick()); + + // touch device should be removed due to incompatibility with autopilot. + assertSelectedModsEquivalentTo(new Mod[] { new OsuModAutopilot() }); + + AddStep("deactivate panel", () => panel.AsNonNull().TriggerClick()); + assertSelectedModsEquivalentTo(Array.Empty()); + + // just for test purposes, can't/shouldn't happen in reality + AddStep("Add score v2 to selected mod", () => SelectedMods.Value = new Mod[] { new ModScoreV2() }); + AddStep("activate panel", () => panel.AsNonNull().TriggerClick()); + + assertSelectedModsEquivalentTo(new Mod[] { new OsuModAutopilot(), new ModScoreV2() }); + } + private void assertSelectedModsEquivalentTo(IEnumerable mods) => AddAssert("selected mods changed correctly", () => new HashSet(SelectedMods.Value).SetEquals(mods)); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs index 8ddbd84890..280497e861 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs @@ -7,9 +7,9 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Input; using osu.Framework.Localisation; using osu.Framework.Testing; using osu.Framework.Utils; @@ -24,6 +24,7 @@ using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Taiko.Mods; +using osu.Game.Screens.Footer; using osu.Game.Tests.Mods; using osuTK; using osuTK.Input; @@ -55,6 +56,8 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("clear contents", Clear); AddStep("reset ruleset", () => Ruleset.Value = rulesetStore.GetRuleset(0)); AddStep("reset mods", () => SelectedMods.SetDefault()); + AddStep("reset config", () => configManager.SetValue(OsuSetting.ModSelectTextSearchStartsActive, true)); + AddStep("reset mouse", () => InputManager.MoveMouseTo(Vector2.One)); AddStep("set beatmap", () => Beatmap.Value = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo)); AddStep("set up presets", () => { @@ -93,12 +96,28 @@ namespace osu.Game.Tests.Visual.UserInterface private void createScreen() { - AddStep("create screen", () => Child = modSelectOverlay = new TestModSelectOverlay + AddStep("create screen", () => { - RelativeSizeAxes = Axes.Both, - State = { Value = Visibility.Visible }, - Beatmap = Beatmap.Value, - SelectedMods = { BindTarget = SelectedMods } + var receptor = new ScreenFooter.BackReceptor(); + var footer = new ScreenFooter(receptor); + + Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = new[] { (typeof(ScreenFooter), (object)footer) }, + Children = new Drawable[] + { + receptor, + modSelectOverlay = new TestModSelectOverlay + { + RelativeSizeAxes = Axes.Both, + State = { Value = Visibility.Visible }, + Beatmap = { Value = Beatmap.Value }, + SelectedMods = { BindTarget = SelectedMods }, + }, + footer, + } + }; }); waitForColumnLoad(); } @@ -119,7 +138,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("mod multiplier correct", () => { double multiplier = SelectedMods.Value.Aggregate(1d, (m, mod) => m * mod.ScoreMultiplier); - return Precision.AlmostEquals(multiplier, modSelectOverlay.ChildrenOfType().Single().ModMultiplier.Value); + return Precision.AlmostEquals(multiplier, this.ChildrenOfType().Single().ModMultiplier.Value); }); assertCustomisationToggleState(disabled: false, active: false); AddAssert("setting items created", () => modSelectOverlay.ChildrenOfType().Any()); @@ -134,7 +153,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("mod multiplier correct", () => { double multiplier = SelectedMods.Value.Aggregate(1d, (m, mod) => m * mod.ScoreMultiplier); - return Precision.AlmostEquals(multiplier, modSelectOverlay.ChildrenOfType().Single().ModMultiplier.Value); + return Precision.AlmostEquals(multiplier, this.ChildrenOfType().Single().ModMultiplier.Value); }); assertCustomisationToggleState(disabled: false, active: false); AddAssert("setting items created", () => modSelectOverlay.ChildrenOfType().Any()); @@ -222,12 +241,8 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("select difficulty adjust via panel", () => getPanelForMod(typeof(OsuModDifficultyAdjust)).TriggerClick()); assertCustomisationToggleState(disabled: false, active: true); - AddStep("dismiss mod customisation via toggle", () => - { - InputManager.MoveMouseTo(modSelectOverlay.CustomisationButton.AsNonNull()); - InputManager.Click(MouseButton.Left); - }); - assertCustomisationToggleState(disabled: false, active: false); + AddStep("move mouse away", () => InputManager.MoveMouseTo(Vector2.Zero)); + assertCustomisationToggleState(disabled: false, active: true); AddStep("reset mods", () => SelectedMods.SetDefault()); AddStep("select difficulty adjust via panel", () => getPanelForMod(typeof(OsuModDifficultyAdjust)).TriggerClick()); @@ -257,7 +272,7 @@ namespace osu.Game.Tests.Visual.UserInterface } [Test] - public void TestDismissCustomisationViaDimmedArea() + public void TestDismissCustomisationViaClickingAway() { createScreen(); assertCustomisationToggleState(disabled: true, active: false); @@ -265,18 +280,23 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("select difficulty adjust via panel", () => getPanelForMod(typeof(OsuModDifficultyAdjust)).TriggerClick()); assertCustomisationToggleState(disabled: false, active: true); - AddStep("move mouse to settings area", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); - AddStep("move mouse to dimmed area", () => - { - InputManager.MoveMouseTo(new Vector2( - modSelectOverlay.ScreenSpaceDrawQuad.TopLeft.X, - (modSelectOverlay.ScreenSpaceDrawQuad.TopLeft.Y + modSelectOverlay.ScreenSpaceDrawQuad.BottomLeft.Y) / 2)); - }); + AddStep("move mouse to search bar", () => InputManager.MoveMouseTo(modSelectOverlay.ChildrenOfType().Single())); AddStep("click", () => InputManager.Click(MouseButton.Left)); assertCustomisationToggleState(disabled: false, active: false); + } - AddStep("move mouse to first mod panel", () => InputManager.MoveMouseTo(modSelectOverlay.ChildrenOfType().First())); - AddAssert("first mod panel is hovered", () => modSelectOverlay.ChildrenOfType().First().IsHovered); + [Test] + public void TestDismissCustomisationWhenHidingOverlay() + { + createScreen(); + assertCustomisationToggleState(disabled: true, active: false); + + AddStep("select difficulty adjust via panel", () => getPanelForMod(typeof(OsuModDifficultyAdjust)).TriggerClick()); + assertCustomisationToggleState(disabled: false, active: true); + + AddStep("hide overlay", () => modSelectOverlay.Hide()); + AddStep("show overlay again", () => modSelectOverlay.Show()); + assertCustomisationToggleState(disabled: false, active: false); } /// @@ -338,7 +358,7 @@ namespace osu.Game.Tests.Visual.UserInterface createScreen(); changeRuleset(0); - AddStep("Select all fun mods", () => + AddStep("Select all difficulty-increase mods", () => { modSelectOverlay.ChildrenOfType() .Single(c => c.ModType == ModType.DifficultyIncrease) @@ -623,7 +643,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("press tab", () => InputManager.Key(Key.Tab)); AddAssert("search text box focused", () => modSelectOverlay.SearchTextBox.HasFocus); - AddStep("unfocus search text box externally", () => InputManager.ChangeFocus(null)); + AddStep("unfocus search text box externally", () => ((IFocusManager)InputManager).ChangeFocus(null)); AddStep("press tab", () => InputManager.Key(Key.Tab)); AddAssert("search text box focused", () => modSelectOverlay.SearchTextBox.HasFocus); @@ -640,13 +660,15 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("select DT", () => SelectedMods.Value = new Mod[] { new OsuModDoubleTime() }); AddAssert("DT selected", () => modSelectOverlay.ChildrenOfType().Count(panel => panel.Active.Value), () => Is.EqualTo(1)); - AddStep("open customisation area", () => modSelectOverlay.CustomisationButton!.TriggerClick()); - assertCustomisationToggleState(false, true); + AddStep("open customisation area", () => InputManager.MoveMouseTo(modSelectOverlay.ChildrenOfType().Single())); + assertCustomisationToggleState(disabled: false, active: true); + AddStep("hover over mod settings slider", () => { - var slider = modSelectOverlay.ChildrenOfType().Single().ChildrenOfType>().First(); + var slider = modSelectOverlay.ChildrenOfType().Single().ChildrenOfType>().First(); InputManager.MoveMouseTo(slider); }); + AddStep("press right arrow", () => InputManager.PressKey(Key.Right)); AddAssert("DT speed changed", () => !SelectedMods.Value.OfType().Single().SpeedChange.IsDefault); @@ -743,17 +765,31 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("select difficulty adjust via panel", () => getPanelForMod(typeof(OsuModDifficultyAdjust)).TriggerClick()); assertCustomisationToggleState(disabled: false, active: true); - AddAssert("back button disabled", () => !modSelectOverlay.BackButton.Enabled.Value); AddStep("dismiss customisation area", () => InputManager.Key(Key.Escape)); + AddAssert("mod select still visible", () => modSelectOverlay.State.Value == Visibility.Visible); + AddStep("click back button", () => { - InputManager.MoveMouseTo(modSelectOverlay.BackButton); + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); InputManager.Click(MouseButton.Left); }); AddAssert("mod select hidden", () => modSelectOverlay.State.Value == Visibility.Hidden); } + [Test] + public void TestCloseViaToggleModSelectionBinding() + { + createScreen(); + changeRuleset(0); + + AddStep("select difficulty adjust via panel", () => getPanelForMod(typeof(OsuModDifficultyAdjust)).TriggerClick()); + assertCustomisationToggleState(disabled: false, active: true); + + AddStep("press F1", () => InputManager.Key(Key.F1)); + AddAssert("mod select hidden", () => modSelectOverlay.State.Value == Visibility.Hidden); + } + /// /// Covers columns hiding/unhiding on changes of . /// @@ -863,17 +899,17 @@ namespace osu.Game.Tests.Visual.UserInterface InputManager.Click(MouseButton.Left); }); AddAssert("difficulty multiplier display shows correct value", - () => modSelectOverlay.ChildrenOfType().Single().ModMultiplier.Value, () => Is.EqualTo(0.1).Within(Precision.DOUBLE_EPSILON)); + () => this.ChildrenOfType().Single().ModMultiplier.Value, () => Is.EqualTo(0.1).Within(Precision.DOUBLE_EPSILON)); // this is highly unorthodox in a test, but because the `ModSettingChangeTracker` machinery heavily leans on events and object disposal and re-creation, // it is instrumental in the reproduction of the failure scenario that this test is supposed to cover. AddStep("force collection", GC.Collect); - AddStep("open customisation area", () => modSelectOverlay.CustomisationButton!.TriggerClick()); - AddStep("reset half time speed to default", () => modSelectOverlay.ChildrenOfType().Single() + AddStep("open customisation area", () => modSelectOverlay.ChildrenOfType().Single().TriggerClick()); + AddStep("reset half time speed to default", () => modSelectOverlay.ChildrenOfType().Single() .ChildrenOfType>().Single().TriggerClick()); AddUntilStep("difficulty multiplier display shows correct value", - () => modSelectOverlay.ChildrenOfType().Single().ModMultiplier.Value, () => Is.EqualTo(0.3).Within(Precision.DOUBLE_EPSILON)); + () => this.ChildrenOfType().Single().ModMultiplier.Value, () => Is.EqualTo(0.3).Within(Precision.DOUBLE_EPSILON)); } [Test] @@ -882,24 +918,91 @@ namespace osu.Game.Tests.Visual.UserInterface createScreen(); AddStep("select DT + HD + DF", () => SelectedMods.Value = new Mod[] { new OsuModDoubleTime(), new OsuModHidden(), new OsuModDeflate() }); + AddStep("open customisation panel", () => this.ChildrenOfType().Single().TriggerClick()); AddAssert("mod settings order: DT, HD, DF", () => { - var columns = this.ChildrenOfType().Single().ChildrenOfType(); + var columns = this.ChildrenOfType(); return columns.ElementAt(0).Mod is OsuModDoubleTime && columns.ElementAt(1).Mod is OsuModHidden && columns.ElementAt(2).Mod is OsuModDeflate; }); - AddStep("replace DT with NC", () => SelectedMods.Value = SelectedMods.Value.Where(m => m is not ModDoubleTime).Append(new OsuModNightcore()).ToList()); + AddStep("replace DT with NC", () => + { + SelectedMods.Value = SelectedMods.Value.Where(m => m is not ModDoubleTime).Append(new OsuModNightcore()).ToList(); + this.ChildrenOfType().Single().TriggerClick(); + }); AddAssert("mod settings order: NC, HD, DF", () => { - var columns = this.ChildrenOfType().Single().ChildrenOfType(); + var columns = this.ChildrenOfType(); return columns.ElementAt(0).Mod is OsuModNightcore && columns.ElementAt(1).Mod is OsuModHidden && columns.ElementAt(2).Mod is OsuModDeflate; }); } + [Test] + public void TestOpeningCustomisationHidesPresetPopover() + { + createScreen(); + + AddStep("select DT", () => SelectedMods.Value = new Mod[] { new OsuModDoubleTime() }); + AddStep("click new preset", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("preset popover shown", () => this.ChildrenOfType().SingleOrDefault()?.IsPresent, () => Is.True); + + AddStep("click customisation header", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("preset popover hidden", () => this.ChildrenOfType().SingleOrDefault()?.IsPresent, () => Is.Not.True); + AddAssert("customisation panel shown", () => this.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Visible)); + } + + [Test] + public void TestCustomisationPanelAbsorbsInput([Values] bool textSearchStartsActive) + { + AddStep($"text search starts active = {textSearchStartsActive}", () => configManager.SetValue(OsuSetting.ModSelectTextSearchStartsActive, textSearchStartsActive)); + createScreen(); + + AddStep("select DT", () => SelectedMods.Value = new Mod[] { new OsuModDoubleTime() }); + AddStep("open customisation panel", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); + AddAssert("search lost focus", () => !this.ChildrenOfType().Single().HasFocus); + + AddStep("press tab", () => InputManager.Key(Key.Tab)); + AddAssert("search still not focused", () => !this.ChildrenOfType().Single().HasFocus); + + AddStep("press q", () => InputManager.Key(Key.Q)); + AddAssert("easy not selected", () => SelectedMods.Value.Single() is OsuModDoubleTime); + + // the "deselect all mods" action is intentionally disabled when customisation panel is open to not conflict with pressing backspace to delete characters in a textbox. + // this is supposed to be handled by the textbox itself especially since it's focused and thus prioritised in input queue, + // but it's not for some reason, and figuring out why is probably not going to be a pleasant experience (read TextBox.OnKeyDown for a head start). + AddStep("press backspace", () => InputManager.Key(Key.BackSpace)); + AddAssert("mods not deselected", () => SelectedMods.Value.Single() is OsuModDoubleTime); + + AddStep("move mouse to customisation panel", () => InputManager.MoveMouseTo(modSelectOverlay.ChildrenOfType().First())); + + AddStep("scroll down", () => InputManager.ScrollVerticalBy(-10f)); + AddAssert("column not scrolled", () => modSelectOverlay.ChildrenOfType().Single().IsScrolledToStart()); + + AddStep("move mouse away", () => InputManager.MoveMouseTo(Vector2.Zero)); + AddAssert("customisation panel closed", + () => this.ChildrenOfType().Single().ExpandedState.Value, + () => Is.EqualTo(ModCustomisationPanel.ModCustomisationPanelState.Collapsed)); + + if (textSearchStartsActive) + AddAssert("search focused", () => this.ChildrenOfType().Single().HasFocus); + else + AddAssert("search still not focused", () => !this.ChildrenOfType().Single().HasFocus); + } + private void waitForColumnLoad() => AddUntilStep("all column content loaded", () => modSelectOverlay.ChildrenOfType().Any() && modSelectOverlay.ChildrenOfType().All(column => column.IsLoaded && column.ItemsLoaded) @@ -914,8 +1017,10 @@ namespace osu.Game.Tests.Visual.UserInterface private void assertCustomisationToggleState(bool disabled, bool active) { - AddAssert($"customisation toggle is {(disabled ? "" : "not ")}disabled", () => modSelectOverlay.CustomisationButton.AsNonNull().Active.Disabled == disabled); - AddAssert($"customisation toggle is {(active ? "" : "not ")}active", () => modSelectOverlay.CustomisationButton.AsNonNull().Active.Value == active); + AddUntilStep($"customisation panel is {(disabled ? "" : "not ")}disabled", () => modSelectOverlay.ChildrenOfType().Single().Enabled.Value == !disabled); + AddAssert($"customisation panel is {(active ? "" : "not ")}active", + () => modSelectOverlay.ChildrenOfType().Single().ExpandedState.Value, + () => active ? Is.Not.EqualTo(ModCustomisationPanel.ModCustomisationPanelState.Collapsed) : Is.EqualTo(ModCustomisationPanel.ModCustomisationPanelState.Collapsed)); } private T getSelectedMod() where T : Mod => SelectedMods.Value.OfType().Single(); @@ -926,9 +1031,6 @@ namespace osu.Game.Tests.Visual.UserInterface private partial class TestModSelectOverlay : UserModSelectOverlay { protected override bool ShowPresets => true; - - public new ShearedButton BackButton => base.BackButton; - public new ShearedToggleButton? CustomisationButton => base.CustomisationButton; } private class TestUnimplementedMod : Mod diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSettingsArea.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSettingsArea.cs deleted file mode 100644 index dac1f94c28..0000000000 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSettingsArea.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using System; -using NUnit.Framework; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Overlays; -using osu.Game.Overlays.Mods; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Osu.Mods; - -namespace osu.Game.Tests.Visual.UserInterface -{ - [TestFixture] - public partial class TestSceneModSettingsArea : OsuTestScene - { - [Cached] - private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); - - [Test] - public void TestModToggleArea() - { - ModSettingsArea modSettingsArea = null; - - AddStep("create content", () => Child = new Container - { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Child = modSettingsArea = new ModSettingsArea() - }); - AddStep("set DT", () => modSettingsArea.SelectedMods.Value = new[] { new OsuModDoubleTime() }); - AddStep("set DA", () => modSettingsArea.SelectedMods.Value = new Mod[] { new OsuModDifficultyAdjust() }); - AddStep("set FL+WU+DA+AD", () => modSettingsArea.SelectedMods.Value = new Mod[] { new OsuModFlashlight(), new ModWindUp(), new OsuModDifficultyAdjust(), new OsuModApproachDifferent() }); - AddStep("set empty", () => modSettingsArea.SelectedMods.Value = Array.Empty()); - } - } -} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneNowPlayingOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneNowPlayingOverlay.cs index d07b90025f..d84089fb6f 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneNowPlayingOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneNowPlayingOverlay.cs @@ -5,7 +5,9 @@ using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Configuration; using osu.Framework.Graphics; +using osu.Game.Beatmaps; using osu.Game.Overlays; using osu.Game.Rulesets.Osu; @@ -20,9 +22,9 @@ namespace osu.Game.Tests.Visual.UserInterface private NowPlayingOverlay nowPlayingOverlay; [BackgroundDependencyLoader] - private void load() + private void load(FrameworkConfigManager frameworkConfig) { - Beatmap.Value = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); + AddToggleStep("toggle unicode", v => frameworkConfig.SetValue(FrameworkSetting.ShowUnicode, v)); nowPlayingOverlay = new NowPlayingOverlay { @@ -37,9 +39,38 @@ namespace osu.Game.Tests.Visual.UserInterface [Test] public void TestShowHideDisable() { + AddStep(@"set beatmap", () => Beatmap.Value = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo)); AddStep(@"show", () => nowPlayingOverlay.Show()); AddToggleStep(@"toggle beatmap lock", state => Beatmap.Disabled = state); AddStep(@"hide", () => nowPlayingOverlay.Hide()); } + + [Test] + public void TestLongMetadata() + { + AddStep(@"set metadata within tolerance", () => Beatmap.Value = CreateWorkingBeatmap(new Beatmap + { + Metadata = + { + Artist = "very very very very very very very very very very verry long artist", + ArtistUnicode = "very very very very very very very very very very verry long artist unicode", + Title = "very very very very very verry long title", + TitleUnicode = "very very very very very verry long title unicode", + } + })); + + AddStep(@"set metadata outside bounds", () => Beatmap.Value = CreateWorkingBeatmap(new Beatmap + { + Metadata = + { + Artist = "very very very very very very very very very very verrry long artist", + ArtistUnicode = "not very long artist unicode", + Title = "very very very very very verrry long title", + TitleUnicode = "not very long title unicode", + } + })); + + AddStep(@"show", () => nowPlayingOverlay.Show()); + } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuDropdown.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuDropdown.cs index 63f7a2f2cc..2f855c8744 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuDropdown.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuDropdown.cs @@ -6,23 +6,51 @@ using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; -using osu.Framework.Input.Events; -using osu.Framework.Input.States; using osu.Framework.Testing; using osu.Game.Graphics.UserInterface; -using osu.Game.Input.Bindings; +using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { public partial class TestSceneOsuDropdown : ThemeComparisonTestScene { - protected override Drawable CreateContent() => - new OsuEnumDropdown - { - Anchor = Anchor.Centre, - Origin = Anchor.TopCentre, - Width = 150 - }; + protected override Drawable CreateContent() => new OsuEnumDropdown + { + Anchor = Anchor.Centre, + Origin = Anchor.TopCentre, + Width = 150 + }; + + [Test] + public void TestBackAction() + { + AddStep("open", () => dropdownMenu.Open()); + AddStep("press back", () => InputManager.Key(Key.Escape)); + AddAssert("closed", () => dropdownMenu.State == MenuState.Closed); + + AddStep("open", () => dropdownMenu.Open()); + AddStep("type something", () => dropdownSearchBar.SearchTerm.Value = "something"); + AddAssert("search bar visible", () => dropdownSearchBar.State.Value == Visibility.Visible); + AddStep("press back", () => InputManager.Key(Key.Escape)); + AddAssert("text clear", () => dropdownSearchBar.SearchTerm.Value == string.Empty); + AddAssert("search bar hidden", () => dropdownSearchBar.State.Value == Visibility.Hidden); + AddAssert("still open", () => dropdownMenu.State == MenuState.Open); + AddStep("press back", () => InputManager.Key(Key.Escape)); + AddAssert("closed", () => dropdownMenu.State == MenuState.Closed); + } + + [Test] + public void TestSelectAction() + { + AddStep("open", () => dropdownMenu.Open()); + AddStep("press down", () => InputManager.Key(Key.Down)); + AddStep("press enter", () => InputManager.Key(Key.Enter)); + AddAssert("second selected", () => dropdown.Current.Value == TestEnum.ReallyLongOption); + } + + private OsuEnumDropdown dropdown => this.ChildrenOfType>().Last(); + private Menu dropdownMenu => dropdown.ChildrenOfType().Single(); + private DropdownSearchBar dropdownSearchBar => dropdown.ChildrenOfType().Single(); private enum TestEnum { @@ -32,26 +60,5 @@ namespace osu.Game.Tests.Visual.UserInterface [System.ComponentModel.Description("Really lonnnnnnng option")] ReallyLongOption, } - - [Test] - // todo: this can be written much better if ThemeComparisonTestScene has a manual input manager - public void TestBackAction() - { - AddStep("open", () => dropdown().ChildrenOfType().Single().Open()); - AddStep("press back", () => dropdown().OnPressed(new KeyBindingPressEvent(new InputState(), GlobalAction.Back))); - AddAssert("closed", () => dropdown().ChildrenOfType().Single().State == MenuState.Closed); - - AddStep("open", () => dropdown().ChildrenOfType().Single().Open()); - AddStep("type something", () => dropdown().ChildrenOfType().Single().SearchTerm.Value = "something"); - AddAssert("search bar visible", () => dropdown().ChildrenOfType().Single().State.Value == Visibility.Visible); - AddStep("press back", () => dropdown().OnPressed(new KeyBindingPressEvent(new InputState(), GlobalAction.Back))); - AddAssert("text clear", () => dropdown().ChildrenOfType().Single().SearchTerm.Value == string.Empty); - AddAssert("search bar hidden", () => dropdown().ChildrenOfType().Single().State.Value == Visibility.Hidden); - AddAssert("still open", () => dropdown().ChildrenOfType().Single().State == MenuState.Open); - AddStep("press back", () => dropdown().OnPressed(new KeyBindingPressEvent(new InputState(), GlobalAction.Back))); - AddAssert("closed", () => dropdown().ChildrenOfType().Single().State == MenuState.Closed); - - OsuEnumDropdown dropdown() => this.ChildrenOfType>().First(); - } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuTextBox.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuTextBox.cs index 69fe8ad105..abad7e775c 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuTextBox.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuTextBox.cs @@ -11,6 +11,7 @@ using osu.Framework.Testing; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; using osuTK; +using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { @@ -61,6 +62,40 @@ namespace osu.Game.Tests.Visual.UserInterface clearTextboxes(numberBoxes); } + [Test] + public void TestSelectAllOnFocus() + { + AddStep("create themed content", () => CreateThemedContent(OverlayColourScheme.Red)); + + AddStep("enter numbers", () => numberBoxes.ForEach(numberBox => numberBox.Text = "987654321")); + + AddAssert("nothing selected", () => string.IsNullOrEmpty(numberBoxes.First().SelectedText)); + AddStep("click on a number box", () => + { + InputManager.MoveMouseTo(numberBoxes.First()); + InputManager.Click(MouseButton.Left); + }); + AddAssert("text selected", () => numberBoxes.First().SelectedText == "987654321"); + + AddStep("click away", () => + { + InputManager.MoveMouseTo(Vector2.Zero); + InputManager.Click(MouseButton.Left); + }); + + Drawable textContainer = null!; + + AddStep("move mouse to end of text", () => + { + textContainer = numberBoxes.First().ChildrenOfType().ElementAt(1); + InputManager.MoveMouseTo(textContainer.ScreenSpaceDrawQuad.TopRight); + }); + AddStep("hold mouse", () => InputManager.PressButton(MouseButton.Left)); + AddStep("drag to half", () => InputManager.MoveMouseTo(textContainer.ScreenSpaceDrawQuad.BottomRight - new Vector2(textContainer.ScreenSpaceDrawQuad.Width / 2 + 1f, 0))); + AddStep("release mouse", () => InputManager.ReleaseButton(MouseButton.Left)); + AddAssert("half text selected", () => numberBoxes.First().SelectedText == "54321"); + } + private void clearTextboxes(IEnumerable textBoxes) => AddStep("clear textbox", () => textBoxes.ForEach(textBox => textBox.Text = null)); private void expectedValue(IEnumerable textBoxes, string value) => AddAssert("expected textbox value", () => textBoxes.All(textBox => textBox.Text == value)); } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuTooltip.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuTooltip.cs new file mode 100644 index 0000000000..a2e88bfbc9 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuTooltip.cs @@ -0,0 +1,86 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; +using osu.Framework.Testing; +using osu.Game.Graphics; +using osu.Game.Graphics.Cursor; +using osu.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Tests.Visual.UserInterface +{ + [TestFixture] + public partial class TestSceneOsuTooltip : OsuManualInputManagerTestScene + { + private TestTooltipContainer container = null!; + + private static readonly string[] test_case_tooltip_string = + [ + "Hello!!", + string.Concat(Enumerable.Repeat("Hello ", 100)), + + //TODO: o!f issue: https://github.com/ppy/osu-framework/issues/5007 + //Enable after o!f fixed + // $"H{new string('e', 500)}llo", + ]; + + [SetUp] + public void SetUp() => Schedule(() => + { + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(100), + Children = new Drawable[] + { + new Box + { + Colour = Colour4.Red.Opacity(0.5f), + RelativeSizeAxes = Axes.Both, + }, + container = new TestTooltipContainer + { + RelativeSizeAxes = Axes.Both, + Child = new OsuSpriteText + { + Text = "Hover me!", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.GetFont(size: 50) + } + }, + }, + }; + }); + + [TestCaseSource(nameof(test_case_tooltip_string))] + public void TestTooltipBasic(string text) + { + AddStep("Set tooltip content", () => container.TooltipText = text); + + AddStep("Move mouse to container", () => InputManager.MoveMouseTo(new Vector2(InputManager.ScreenSpaceDrawQuad.Centre.X, InputManager.ScreenSpaceDrawQuad.Centre.Y))); + + OsuTooltipContainer.OsuTooltip? tooltip = null!; + + AddUntilStep("Wait for the tooltip shown", () => + { + tooltip = container.FindClosestParent().ChildrenOfType().FirstOrDefault(); + return tooltip != null && tooltip.Alpha == 1; + }); + + AddAssert("Check tooltip is under width limit", () => tooltip != null && tooltip.Width <= 500); + } + + internal sealed partial class TestTooltipContainer : Container, IHasTooltip + { + public LocalisableString TooltipText { get; set; } + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneRoundedButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneRoundedButton.cs index 8c2651f71d..2d5c2c6d57 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneRoundedButton.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneRoundedButton.cs @@ -53,8 +53,8 @@ namespace osu.Game.Tests.Visual.UserInterface public void TestBackgroundColour() { AddStep("set red scheme", () => CreateThemedContent(OverlayColourScheme.Red)); - AddAssert("rounded button has correct colour", () => Cell(0, 1).ChildrenOfType().First().BackgroundColour == new OverlayColourProvider(OverlayColourScheme.Red).Colour3); - AddAssert("settings button has correct colour", () => Cell(0, 1).ChildrenOfType().First().BackgroundColour == new OverlayColourProvider(OverlayColourScheme.Red).Colour3); + AddAssert("rounded button has correct colour", () => ContentContainer.ChildrenOfType().First().BackgroundColour == new OverlayColourProvider(OverlayColourScheme.Red).Colour3); + AddAssert("settings button has correct colour", () => ContentContainer.ChildrenOfType().First().BackgroundColour == new OverlayColourProvider(OverlayColourScheme.Red).Colour3); } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneScreenFooter.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneScreenFooter.cs new file mode 100644 index 0000000000..a4cf8a276f --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneScreenFooter.cs @@ -0,0 +1,265 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Testing; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osu.Game.Overlays.Mods; +using osu.Game.Screens.Footer; +using osu.Game.Screens.SelectV2.Footer; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public partial class TestSceneScreenFooter : OsuManualInputManagerTestScene + { + private DependencyProvidingContainer contentContainer = null!; + private ScreenFooter screenFooter = null!; + private TestModSelectOverlay modOverlay = null!; + + [SetUp] + public void SetUp() => Schedule(() => + { + screenFooter = new ScreenFooter(); + + Child = contentContainer = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = new (Type, object)[] + { + (typeof(ScreenFooter), screenFooter) + }, + Children = new Drawable[] + { + modOverlay = new TestModSelectOverlay(), + new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Depth = float.MinValue, + Child = screenFooter, + }, + }, + }; + + screenFooter.SetButtons(new ScreenFooterButton[] + { + new ScreenFooterButtonMods(modOverlay) { Current = SelectedMods }, + new ScreenFooterButtonRandom(), + new ScreenFooterButtonOptions(), + }); + }); + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("show footer", () => screenFooter.Show()); + } + + /// + /// Transition when moving from a screen with no buttons to a screen with buttons. + /// + [Test] + public void TestButtonsIn() + { + } + + /// + /// Transition when moving from a screen with buttons to a screen with no buttons. + /// + [Test] + public void TestButtonsOut() + { + AddStep("clear buttons", () => screenFooter.SetButtons(Array.Empty())); + } + + /// + /// Transition when moving from a screen with buttons to a screen with buttons. + /// + [Test] + public void TestReplaceButtons() + { + AddStep("replace buttons", () => screenFooter.SetButtons(new[] + { + new ScreenFooterButton { Text = "One", Action = () => { } }, + new ScreenFooterButton { Text = "Two", Action = () => { } }, + new ScreenFooterButton { Text = "Three", Action = () => { } }, + })); + } + + [Test] + public void TestExternalOverlayContent() + { + TestShearedOverlayContainer externalOverlay = null!; + + AddStep("add overlay", () => contentContainer.Add(externalOverlay = new TestShearedOverlayContainer())); + AddStep("set buttons", () => screenFooter.SetButtons(new[] + { + new ScreenFooterButton(externalOverlay) + { + AccentColour = Dependencies.Get().Orange1, + Icon = FontAwesome.Solid.Toolbox, + Text = "One", + }, + new ScreenFooterButton { Text = "Two", Action = () => { } }, + new ScreenFooterButton { Text = "Three", Action = () => { } }, + })); + AddWaitStep("wait for transition", 3); + + AddStep("show overlay", () => externalOverlay.Show()); + AddAssert("content displayed in footer", () => screenFooter.ChildrenOfType().Single().IsPresent); + AddUntilStep("other buttons hidden", () => screenFooter.ChildrenOfType().Skip(1).All(b => b.Child.Parent!.Y > 0)); + + AddStep("hide overlay", () => externalOverlay.Hide()); + AddUntilStep("content hidden from footer", () => screenFooter.ChildrenOfType().SingleOrDefault()?.IsPresent != true); + AddUntilStep("other buttons returned", () => screenFooter.ChildrenOfType().Skip(1).All(b => b.ChildrenOfType().First().Y == 0)); + } + + [Test] + public void TestTemporarilyShowFooter() + { + TestShearedOverlayContainer externalOverlay = null!; + + AddStep("hide footer", () => screenFooter.Hide()); + AddStep("remove buttons", () => screenFooter.SetButtons(Array.Empty())); + + AddStep("add external overlay", () => contentContainer.Add(externalOverlay = new TestShearedOverlayContainer())); + AddStep("show external overlay", () => externalOverlay.Show()); + AddAssert("footer shown", () => screenFooter.State.Value == Visibility.Visible); + AddAssert("content displayed in footer", () => screenFooter.ChildrenOfType().Single().IsPresent); + + AddStep("hide external overlay", () => externalOverlay.Hide()); + AddAssert("footer hidden", () => screenFooter.State.Value == Visibility.Hidden); + AddUntilStep("content hidden from footer", () => screenFooter.ChildrenOfType().SingleOrDefault()?.IsPresent != true); + + AddStep("show footer", () => screenFooter.Show()); + AddAssert("content still hidden from footer", () => screenFooter.ChildrenOfType().SingleOrDefault()?.IsPresent != true); + + AddStep("show external overlay", () => externalOverlay.Show()); + AddAssert("footer still visible", () => screenFooter.State.Value == Visibility.Visible); + + AddStep("hide external overlay", () => externalOverlay.Hide()); + AddAssert("footer still visible", () => screenFooter.State.Value == Visibility.Visible); + + AddStep("hide footer", () => screenFooter.Hide()); + AddStep("show external overlay", () => externalOverlay.Show()); + } + + [Test] + public void TestBackButton() + { + TestShearedOverlayContainer externalOverlay = null!; + + AddStep("hide footer", () => screenFooter.Hide()); + AddStep("remove buttons", () => screenFooter.SetButtons(Array.Empty())); + + AddStep("add external overlay", () => contentContainer.Add(externalOverlay = new TestShearedOverlayContainer())); + AddStep("show external overlay", () => externalOverlay.Show()); + AddAssert("footer shown", () => screenFooter.State.Value == Visibility.Visible); + + AddStep("press back", () => this.ChildrenOfType().Single().TriggerClick()); + AddAssert("overlay hidden", () => externalOverlay.State.Value == Visibility.Hidden); + AddAssert("footer hidden", () => screenFooter.State.Value == Visibility.Hidden); + + AddStep("show external overlay", () => externalOverlay.Show()); + AddStep("set block count", () => externalOverlay.BackButtonCount = 1); + AddStep("press back", () => this.ChildrenOfType().Single().TriggerClick()); + AddAssert("overlay still visible", () => externalOverlay.State.Value == Visibility.Visible); + AddAssert("footer still shown", () => screenFooter.State.Value == Visibility.Visible); + AddStep("press back again", () => this.ChildrenOfType().Single().TriggerClick()); + AddAssert("overlay hidden", () => externalOverlay.State.Value == Visibility.Hidden); + AddAssert("footer hidden", () => screenFooter.State.Value == Visibility.Hidden); + } + + [Test] + public void TestLoadOverlayAfterFooterIsDisplayed() + { + TestShearedOverlayContainer externalOverlay = null!; + + AddStep("show mod overlay", () => modOverlay.Show()); + AddUntilStep("mod footer content shown", () => this.ChildrenOfType().SingleOrDefault()?.IsPresent, () => Is.True); + + AddStep("add external overlay", () => contentContainer.Add(externalOverlay = new TestShearedOverlayContainer())); + AddUntilStep("wait for load", () => externalOverlay.IsLoaded); + AddAssert("mod footer content still shown", () => this.ChildrenOfType().SingleOrDefault()?.IsPresent, () => Is.True); + AddAssert("external overlay content not shown", () => this.ChildrenOfType().SingleOrDefault()?.IsPresent, () => Is.Not.True); + + AddStep("hide mod overlay", () => modOverlay.Hide()); + AddUntilStep("mod footer content hidden", () => this.ChildrenOfType().SingleOrDefault()?.IsPresent, () => Is.Not.True); + AddAssert("external overlay content still not shown", () => this.ChildrenOfType().SingleOrDefault()?.IsPresent, () => Is.Not.True); + } + + private partial class TestModSelectOverlay : UserModSelectOverlay + { + protected override bool ShowPresets => true; + } + + private partial class TestShearedOverlayContainer : ShearedOverlayContainer + { + public TestShearedOverlayContainer() + : base(OverlayColourScheme.Orange) + { + } + + [BackgroundDependencyLoader] + private void load() + { + Header.Title = "Test overlay"; + Header.Description = "An overlay that is made purely for testing purposes."; + } + + public int BackButtonCount; + + public override bool OnBackButton() + { + if (BackButtonCount > 0) + { + BackButtonCount--; + return true; + } + + return false; + } + + public override VisibilityContainer CreateFooterContent() => new TestFooterContent(); + + public partial class TestFooterContent : VisibilityContainer + { + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Both; + + InternalChild = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Children = new[] + { + new ShearedButton(200) { Text = "Action #1", Action = () => { } }, + new ShearedButton(140) { Text = "Action #2", Action = () => { } }, + } + }; + } + + protected override void PopIn() + { + this.MoveToY(0, 400, Easing.OutQuint) + .FadeIn(400, Easing.OutQuint); + } + + protected override void PopOut() + { + this.MoveToY(-20f, 200, Easing.OutQuint) + .FadeOut(200, Easing.OutQuint); + } + } + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneScreenFooterButtonMods.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneScreenFooterButtonMods.cs new file mode 100644 index 0000000000..ba53eb83c4 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneScreenFooterButtonMods.cs @@ -0,0 +1,136 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osu.Game.Overlays.Mods; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Screens.SelectV2.Footer; +using osu.Game.Utils; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public partial class TestSceneScreenFooterButtonMods : OsuTestScene + { + private readonly TestScreenFooterButtonMods footerButtonMods; + + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + + public TestSceneScreenFooterButtonMods() + { + Add(footerButtonMods = new TestScreenFooterButtonMods(new TestModSelectOverlay()) + { + Anchor = Anchor.Centre, + Origin = Anchor.CentreLeft, + Action = () => { }, + X = -100, + }); + } + + [Test] + public void TestDisplay() + { + AddStep("one mod", () => changeMods(new List { new OsuModHidden() })); + AddStep("two mods", () => changeMods(new List { new OsuModHidden(), new OsuModHardRock() })); + AddStep("three mods", () => changeMods(new List { new OsuModHidden(), new OsuModHardRock(), new OsuModDoubleTime() })); + AddStep("four mods", () => changeMods(new List { new OsuModHidden(), new OsuModHardRock(), new OsuModDoubleTime(), new OsuModClassic() })); + AddStep("five mods", () => changeMods(new List { new OsuModHidden(), new OsuModHardRock(), new OsuModDoubleTime(), new OsuModClassic(), new OsuModDifficultyAdjust() })); + + AddStep("modified", () => changeMods(new List { new OsuModDoubleTime { SpeedChange = { Value = 1.2 } } })); + AddStep("modified + one", () => changeMods(new List { new OsuModHidden(), new OsuModDoubleTime { SpeedChange = { Value = 1.2 } } })); + AddStep("modified + two", () => changeMods(new List { new OsuModHidden(), new OsuModHardRock(), new OsuModDoubleTime { SpeedChange = { Value = 1.2 } } })); + + AddStep("clear mods", () => changeMods(Array.Empty())); + AddWaitStep("wait", 3); + AddStep("one mod", () => changeMods(new List { new OsuModHidden() })); + + AddStep("clear mods", () => changeMods(Array.Empty())); + AddWaitStep("wait", 3); + AddStep("five mods", () => changeMods(new List { new OsuModHidden(), new OsuModHardRock(), new OsuModDoubleTime(), new OsuModClassic(), new OsuModDifficultyAdjust() })); + } + + [Test] + public void TestIncrementMultiplier() + { + var hiddenMod = new Mod[] { new OsuModHidden() }; + AddStep(@"Add Hidden", () => changeMods(hiddenMod)); + AddAssert(@"Check Hidden multiplier", () => assertModsMultiplier(hiddenMod)); + + var hardRockMod = new Mod[] { new OsuModHardRock() }; + AddStep(@"Add HardRock", () => changeMods(hardRockMod)); + AddAssert(@"Check HardRock multiplier", () => assertModsMultiplier(hardRockMod)); + + var doubleTimeMod = new Mod[] { new OsuModDoubleTime() }; + AddStep(@"Add DoubleTime", () => changeMods(doubleTimeMod)); + AddAssert(@"Check DoubleTime multiplier", () => assertModsMultiplier(doubleTimeMod)); + + var multipleIncrementMods = new Mod[] { new OsuModDoubleTime(), new OsuModHidden(), new OsuModHardRock() }; + AddStep(@"Add multiple Mods", () => changeMods(multipleIncrementMods)); + AddAssert(@"Check multiple mod multiplier", () => assertModsMultiplier(multipleIncrementMods)); + } + + [Test] + public void TestDecrementMultiplier() + { + var easyMod = new Mod[] { new OsuModEasy() }; + AddStep(@"Add Easy", () => changeMods(easyMod)); + AddAssert(@"Check Easy multiplier", () => assertModsMultiplier(easyMod)); + + var noFailMod = new Mod[] { new OsuModNoFail() }; + AddStep(@"Add NoFail", () => changeMods(noFailMod)); + AddAssert(@"Check NoFail multiplier", () => assertModsMultiplier(noFailMod)); + + var multipleDecrementMods = new Mod[] { new OsuModEasy(), new OsuModNoFail() }; + AddStep(@"Add Multiple Mods", () => changeMods(multipleDecrementMods)); + AddAssert(@"Check multiple mod multiplier", () => assertModsMultiplier(multipleDecrementMods)); + } + + [Test] + public void TestUnrankedBadge() + { + AddStep(@"Add unranked mod", () => changeMods(new[] { new OsuModDeflate() })); + AddUntilStep("Unranked badge shown", () => footerButtonMods.ChildrenOfType().Single().Alpha == 1); + AddStep(@"Clear selected mod", () => changeMods(Array.Empty())); + AddUntilStep("Unranked badge not shown", () => footerButtonMods.ChildrenOfType().Single().Alpha == 0); + } + + private void changeMods(IReadOnlyList mods) => footerButtonMods.Current.Value = mods; + + private bool assertModsMultiplier(IEnumerable mods) + { + double multiplier = mods.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier); + string expectedValue = ModUtils.FormatScoreMultiplier(multiplier).ToString(); + + return expectedValue == footerButtonMods.MultiplierText.Current.Value; + } + + private partial class TestModSelectOverlay : UserModSelectOverlay + { + protected override bool ShowPresets => true; + + public TestModSelectOverlay() + : base(OverlayColourScheme.Aquamarine) + { + } + } + + private partial class TestScreenFooterButtonMods : ScreenFooterButtonMods + { + public new OsuSpriteText MultiplierText => base.MultiplierText; + + public TestScreenFooterButtonMods(ModSelectOverlay overlay) + : base(overlay) + { + } + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneSettingsColour.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneSettingsColour.cs new file mode 100644 index 0000000000..8d28116950 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneSettingsColour.cs @@ -0,0 +1,75 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays.Settings; +using osuTK.Graphics; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public partial class TestSceneSettingsColour : OsuManualInputManagerTestScene + { + private SettingsColour? component; + + [Test] + public void TestColour() + { + createContent(); + + AddRepeatStep("set random colour", () => component!.Current.Value = randomColour(), 4); + } + + [Test] + public void TestUserInteractions() + { + createContent(); + + AddStep("click colour", () => + { + InputManager.MoveMouseTo(component!); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("colour picker spawned", () => this.ChildrenOfType().Any()); + } + + private void createContent() + { + AddStep("create component", () => + { + Child = new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Child = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 500, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + component = new SettingsColour + { + LabelText = "a sample component", + }, + }, + }, + }; + }); + } + + private Colour4 randomColour() => new Color4( + RNG.NextSingle(), + RNG.NextSingle(), + RNG.NextSingle(), + 1); + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedButtons.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedButtons.cs index 118d32ee70..8db22f2d65 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedButtons.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedButtons.cs @@ -7,11 +7,13 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; +using osuTK; using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface @@ -35,7 +37,7 @@ namespace osu.Game.Tests.Visual.UserInterface if (bigButton) { - Child = button = new ShearedButton(400) + Child = button = new ShearedButton(400, 80) { LighterColour = Colour4.FromHex("#FFFFFF"), DarkerColour = Colour4.FromHex("#FFCC22"), @@ -44,13 +46,12 @@ namespace osu.Game.Tests.Visual.UserInterface Anchor = Anchor.Centre, Origin = Anchor.Centre, Text = "Let's GO!", - Height = 80, Action = () => actionFired = true, }; } else { - Child = button = new ShearedButton(200) + Child = button = new ShearedButton(200, 80) { LighterColour = Colour4.FromHex("#FF86DD"), DarkerColour = Colour4.FromHex("#DE31AE"), @@ -58,7 +59,6 @@ namespace osu.Game.Tests.Visual.UserInterface Anchor = Anchor.Centre, Origin = Anchor.Centre, Text = "Press me", - Height = 80, Action = () => actionFired = true, }; } @@ -171,5 +171,48 @@ namespace osu.Game.Tests.Visual.UserInterface void setToggleDisabledState(bool disabled) => AddStep($"{(disabled ? "disable" : "enable")} toggle", () => button.Active.Disabled = disabled); } + + [Test] + public void TestButtons() + { + AddStep("create buttons", () => Children = new[] + { + new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Both, + Scale = new Vector2(2.5f), + Children = new Drawable[] + { + new ShearedButton(120) + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Text = "Test", + Action = () => { }, + Padding = new MarginPadding(), + }, + new ShearedButton(120, 40) + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Text = "Test", + Action = () => { }, + Padding = new MarginPadding { Left = -1f }, + }, + new ShearedButton(120, 70) + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Text = "Test", + Action = () => { }, + Padding = new MarginPadding { Left = 3f }, + }, + } + } + }); + } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneSliderWithTextBoxInput.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneSliderWithTextBoxInput.cs index d23fcebae3..06b9623508 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneSliderWithTextBoxInput.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneSliderWithTextBoxInput.cs @@ -5,6 +5,7 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Input; using osu.Framework.Testing; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; @@ -42,7 +43,7 @@ namespace osu.Game.Tests.Visual.UserInterface { AddStep("set instantaneous to false", () => sliderWithTextBoxInput.Instantaneous = false); - AddStep("focus textbox", () => InputManager.ChangeFocus(textBox)); + AddStep("focus textbox", () => ((IFocusManager)InputManager).ChangeFocus(textBox)); AddStep("change text", () => textBox.Text = "3"); AddAssert("slider not moved", () => slider.Current.Value, () => Is.Zero); AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.Zero); @@ -61,7 +62,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("textbox changed", () => textBox.Current.Value, () => Is.EqualTo("-5")); AddAssert("current changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); - AddStep("focus textbox", () => InputManager.ChangeFocus(textBox)); + AddStep("focus textbox", () => ((IFocusManager)InputManager).ChangeFocus(textBox)); AddStep("set text to invalid", () => textBox.Text = "garbage"); AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5)); AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); @@ -71,12 +72,12 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5)); AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); - AddStep("focus textbox", () => InputManager.ChangeFocus(textBox)); + AddStep("focus textbox", () => ((IFocusManager)InputManager).ChangeFocus(textBox)); AddStep("set text to invalid", () => textBox.Text = "garbage"); AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5)); AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); - AddStep("lose focus", () => InputManager.ChangeFocus(null)); + AddStep("lose focus", () => ((IFocusManager)InputManager).ChangeFocus(null)); AddAssert("text restored", () => textBox.Text, () => Is.EqualTo("-5")); AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5)); AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); @@ -87,7 +88,7 @@ namespace osu.Game.Tests.Visual.UserInterface { AddStep("set instantaneous to true", () => sliderWithTextBoxInput.Instantaneous = true); - AddStep("focus textbox", () => InputManager.ChangeFocus(textBox)); + AddStep("focus textbox", () => ((IFocusManager)InputManager).ChangeFocus(textBox)); AddStep("change text", () => textBox.Text = "3"); AddAssert("slider moved", () => slider.Current.Value, () => Is.EqualTo(3)); AddAssert("current changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(3)); @@ -106,7 +107,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("textbox not changed", () => textBox.Current.Value, () => Is.EqualTo("-5")); AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); - AddStep("focus textbox", () => InputManager.ChangeFocus(textBox)); + AddStep("focus textbox", () => ((IFocusManager)InputManager).ChangeFocus(textBox)); AddStep("set text to invalid", () => textBox.Text = "garbage"); AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5)); AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); @@ -116,12 +117,12 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5)); AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); - AddStep("focus textbox", () => InputManager.ChangeFocus(textBox)); + AddStep("focus textbox", () => ((IFocusManager)InputManager).ChangeFocus(textBox)); AddStep("set text to invalid", () => textBox.Text = "garbage"); AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5)); AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); - AddStep("lose focus", () => InputManager.ChangeFocus(null)); + AddStep("lose focus", () => ((IFocusManager)InputManager).ChangeFocus(null)); AddAssert("text restored", () => textBox.Text, () => Is.EqualTo("-5")); AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5)); AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneStatefulMenuItem.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneStatefulMenuItem.cs index 88187f1808..63497040db 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneStatefulMenuItem.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneStatefulMenuItem.cs @@ -114,6 +114,51 @@ namespace osu.Game.Tests.Visual.UserInterface => AddAssert($"state is {expected}", () => state.Value == expected); } + [Test] + public void TestItemRespondsToRightClick() + { + OsuMenu menu = null; + + Bindable state = new Bindable(TernaryState.Indeterminate); + + AddStep("create menu", () => + { + state.Value = TernaryState.Indeterminate; + + Child = menu = new OsuMenu(Direction.Vertical, true) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Items = new[] + { + new TernaryStateToggleMenuItem("First"), + new TernaryStateToggleMenuItem("Second") { State = { BindTarget = state } }, + new TernaryStateToggleMenuItem("Third") { State = { Value = TernaryState.True } }, + } + }; + }); + + checkState(TernaryState.Indeterminate); + + click(); + checkState(TernaryState.True); + + click(); + checkState(TernaryState.False); + + AddStep("change state via bindable", () => state.Value = TernaryState.True); + + void click() => + AddStep("click", () => + { + InputManager.MoveMouseTo(menu.ScreenSpaceDrawQuad.Centre); + InputManager.Click(MouseButton.Right); + }); + + void checkState(TernaryState expected) + => AddAssert($"state is {expected}", () => state.Value == expected); + } + [Test] public void TestCustomState() { diff --git a/osu.Game.Tests/Visual/UserInterface/ThemeComparisonTestScene.cs b/osu.Game.Tests/Visual/UserInterface/ThemeComparisonTestScene.cs index 3177695f44..44133d89f8 100644 --- a/osu.Game.Tests/Visual/UserInterface/ThemeComparisonTestScene.cs +++ b/osu.Game.Tests/Visual/UserInterface/ThemeComparisonTestScene.cs @@ -6,18 +6,21 @@ using System.Linq; using NUnit.Framework; 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.Overlays; +using osuTK; namespace osu.Game.Tests.Visual.UserInterface { - public abstract partial class ThemeComparisonTestScene : OsuGridTestScene + public abstract partial class ThemeComparisonTestScene : OsuManualInputManagerTestScene { private readonly bool showWithoutColourProvider; + public Container ContentContainer { get; private set; } = null!; + protected ThemeComparisonTestScene(bool showWithoutColourProvider = true) - : base(1, showWithoutColourProvider ? 2 : 1) { this.showWithoutColourProvider = showWithoutColourProvider; } @@ -25,16 +28,32 @@ namespace osu.Game.Tests.Visual.UserInterface [BackgroundDependencyLoader] private void load(OsuColour colours) { + Child = ContentContainer = new Container + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.Both, + }; + if (showWithoutColourProvider) { - Cell(0, 0).AddRange(new[] + ContentContainer.Size = new Vector2(0.5f, 1f); + + Add(new Container { - new Box + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.5f, 1f), + Children = new[] { - RelativeSizeAxes = Axes.Both, - Colour = colours.GreySeaFoam - }, - CreateContent() + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.GreySeaFoam + }, + CreateContent() + } }); } } @@ -43,10 +62,8 @@ namespace osu.Game.Tests.Visual.UserInterface { var colourProvider = new OverlayColourProvider(colourScheme); - int col = showWithoutColourProvider ? 1 : 0; - - Cell(0, col).Clear(); - Cell(0, col).Add(new DependencyProvidingContainer + ContentContainer.Clear(); + ContentContainer.Add(new DependencyProvidingContainer { RelativeSizeAxes = Axes.Both, CachedDependencies = new (Type, object)[] @@ -58,7 +75,7 @@ namespace osu.Game.Tests.Visual.UserInterface new Box { RelativeSizeAxes = Axes.Both, - Colour = colourProvider.Background4 + Colour = colourProvider.Background3 }, CreateContent() } diff --git a/osu.Game.Tests/osu.Game.Tests.csproj b/osu.Game.Tests/osu.Game.Tests.csproj index c0bbdfb4ed..28a1d4d021 100644 --- a/osu.Game.Tests/osu.Game.Tests.csproj +++ b/osu.Game.Tests/osu.Game.Tests.csproj @@ -1,8 +1,8 @@  + - diff --git a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj index 8f1d7114b1..04683cd83b 100644 --- a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj +++ b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj @@ -4,7 +4,7 @@ osu.Game.Tournament.Tests.TournamentTestRunner - + diff --git a/osu.Game.Tournament/Screens/Editors/Components/DeleteRoundDialog.cs b/osu.Game.Tournament/Screens/Editors/Components/DeleteRoundDialog.cs index 769412bf94..6fff5111bd 100644 --- a/osu.Game.Tournament/Screens/Editors/Components/DeleteRoundDialog.cs +++ b/osu.Game.Tournament/Screens/Editors/Components/DeleteRoundDialog.cs @@ -2,18 +2,16 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osu.Framework.Graphics.Sprites; using osu.Game.Overlays.Dialog; using osu.Game.Tournament.Models; namespace osu.Game.Tournament.Screens.Editors.Components { - public partial class DeleteRoundDialog : DangerousActionDialog + public partial class DeleteRoundDialog : DeletionDialog { public DeleteRoundDialog(TournamentRound round, Action action) { HeaderText = round.Name.Value.Length > 0 ? $@"Delete round ""{round.Name.Value}""?" : @"Delete unnamed round?"; - Icon = FontAwesome.Solid.Trash; DangerousAction = action; } } diff --git a/osu.Game.Tournament/Screens/Editors/Components/DeleteTeamDialog.cs b/osu.Game.Tournament/Screens/Editors/Components/DeleteTeamDialog.cs index 65fb47cf94..cf1dffba0c 100644 --- a/osu.Game.Tournament/Screens/Editors/Components/DeleteTeamDialog.cs +++ b/osu.Game.Tournament/Screens/Editors/Components/DeleteTeamDialog.cs @@ -2,20 +2,18 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osu.Framework.Graphics.Sprites; using osu.Game.Overlays.Dialog; using osu.Game.Tournament.Models; namespace osu.Game.Tournament.Screens.Editors.Components { - public partial class DeleteTeamDialog : DangerousActionDialog + public partial class DeleteTeamDialog : DeletionDialog { public DeleteTeamDialog(TournamentTeam team, Action action) { HeaderText = team.FullName.Value.Length > 0 ? $@"Delete team ""{team.FullName.Value}""?" : team.Acronym.Value.Length > 0 ? $@"Delete team ""{team.Acronym.Value}""?" : @"Delete unnamed team?"; - Icon = FontAwesome.Solid.Trash; DangerousAction = action; } } diff --git a/osu.Game.Tournament/Screens/Editors/LadderEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/LadderEditorScreen.cs index 4074e681f9..a7f0a52003 100644 --- a/osu.Game.Tournament/Screens/Editors/LadderEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/LadderEditorScreen.cs @@ -51,15 +51,16 @@ namespace osu.Game.Tournament.Screens.Editors AddInternal(rightClickMessage = new WarningBox("Right click to place and link matches")); - ScrollContent.Add(grid = new RectangularPositionSnapGrid(Vector2.Zero) + ScrollContent.Add(grid = new RectangularPositionSnapGrid { - Spacing = new Vector2(GRID_SPACING), Anchor = Anchor.Centre, Origin = Anchor.Centre, BypassAutoSizeAxes = Axes.Both, Depth = float.MaxValue }); + grid.Spacing.Value = new Vector2(GRID_SPACING); + LadderInfo.Matches.CollectionChanged += (_, _) => updateMessage(); updateMessage(); } diff --git a/osu.Game.Tournament/Screens/Ladder/Components/LadderEditorSettings.cs b/osu.Game.Tournament/Screens/Ladder/Components/LadderEditorSettings.cs index 9f0fa19915..775fd4fdf2 100644 --- a/osu.Game.Tournament/Screens/Ladder/Components/LadderEditorSettings.cs +++ b/osu.Game.Tournament/Screens/Ladder/Components/LadderEditorSettings.cs @@ -58,7 +58,7 @@ namespace osu.Game.Tournament.Screens.Ladder.Components editorInfo.Selected.ValueChanged += selection => { // ensure any ongoing edits are committed out to the *current* selection before changing to a new one. - GetContainingInputManager().TriggerFocusContention(null); + GetContainingFocusManager()?.TriggerFocusContention(null); // Required to avoid cyclic failure in BindableWithCurrent (TriggerChange called during the Current_Set process). // Arguable a framework issue but since we haven't hit it anywhere else a local workaround seems best. diff --git a/osu.Game.Tournament/Screens/Ladder/LadderDragContainer.cs b/osu.Game.Tournament/Screens/Ladder/LadderDragContainer.cs index 3a2db4fc71..111dede815 100644 --- a/osu.Game.Tournament/Screens/Ladder/LadderDragContainer.cs +++ b/osu.Game.Tournament/Screens/Ladder/LadderDragContainer.cs @@ -22,7 +22,7 @@ namespace osu.Game.Tournament.Screens.Ladder protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) => false; - public override bool UpdateSubTreeMasking(Drawable source, RectangleF maskingBounds) => false; + public override bool UpdateSubTreeMasking() => false; protected override void OnDrag(DragEvent e) { diff --git a/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs b/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs index 665d3c131a..e8b6bdad9f 100644 --- a/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs +++ b/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs @@ -123,7 +123,12 @@ namespace osu.Game.Tournament.Screens.MapPool private void beatmapChanged(ValueChangedEvent beatmap) { - if (CurrentMatch.Value == null || CurrentMatch.Value.PicksBans.Count(p => p.Type == ChoiceType.Ban) < 2) + if (CurrentMatch.Value?.Round.Value == null) + return; + + int totalBansRequired = CurrentMatch.Value.Round.Value.BanCount.Value * 2; + + if (CurrentMatch.Value.PicksBans.Count(p => p.Type == ChoiceType.Ban) < totalBansRequired) return; // if bans have already been placed, beatmap changes result in a selection being made automatically diff --git a/osu.Game.Tournament/Screens/Setup/StablePathSelectScreen.cs b/osu.Game.Tournament/Screens/Setup/StablePathSelectScreen.cs index 74404e06f8..91b03ed085 100644 --- a/osu.Game.Tournament/Screens/Setup/StablePathSelectScreen.cs +++ b/osu.Game.Tournament/Screens/Setup/StablePathSelectScreen.cs @@ -27,6 +27,9 @@ namespace osu.Game.Tournament.Screens.Setup [Resolved] private MatchIPCInfo ipc { get; set; } = null!; + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + private OsuDirectorySelector directorySelector = null!; private DialogOverlay? overlay; diff --git a/osu.Game/Audio/Effects/AudioFilter.cs b/osu.Game/Audio/Effects/AudioFilter.cs index bfa9b31242..8db457ae67 100644 --- a/osu.Game/Audio/Effects/AudioFilter.cs +++ b/osu.Game/Audio/Effects/AudioFilter.cs @@ -1,10 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Diagnostics; using ManagedBass.Fx; using osu.Framework.Audio.Mixing; -using osu.Framework.Caching; using osu.Framework.Graphics; namespace osu.Game.Audio.Effects @@ -26,8 +24,6 @@ namespace osu.Game.Audio.Effects private readonly BQFParameters filter; private readonly BQFType type; - private readonly Cached filterApplication = new Cached(); - private int cutoff; /// @@ -42,7 +38,7 @@ namespace osu.Game.Audio.Effects return; cutoff = value; - filterApplication.Invalidate(); + updateFilter(); } } @@ -64,18 +60,9 @@ namespace osu.Game.Audio.Effects fQ = 0.7f }; - Cutoff = getInitialCutoff(type); - } + cutoff = getInitialCutoff(type); - protected override void Update() - { - base.Update(); - - if (!filterApplication.IsValid) - { - updateFilter(cutoff); - filterApplication.Validate(); - } + updateFilter(); } private int getInitialCutoff(BQFType type) @@ -93,13 +80,13 @@ namespace osu.Game.Audio.Effects } } - private void updateFilter(int newValue) + private void updateFilter() { switch (type) { case BQFType.LowPass: // Workaround for weird behaviour when rapidly setting fCenter of a low-pass filter to nyquist - 1hz. - if (newValue >= MAX_LOWPASS_CUTOFF) + if (Cutoff >= MAX_LOWPASS_CUTOFF) { ensureDetached(); return; @@ -109,7 +96,7 @@ namespace osu.Game.Audio.Effects // Workaround for weird behaviour when rapidly setting fCenter of a high-pass filter to 1hz. case BQFType.HighPass: - if (newValue <= 1) + if (Cutoff <= 1) { ensureDetached(); return; @@ -120,17 +107,8 @@ namespace osu.Game.Audio.Effects ensureAttached(); - int filterIndex = mixer.Effects.IndexOf(filter); - - if (filterIndex < 0) return; - - if (mixer.Effects[filterIndex] is BQFParameters existingFilter) - { - existingFilter.fCenter = newValue; - - // required to update effect with new parameters. - mixer.Effects[filterIndex] = existingFilter; - } + filter.fCenter = Cutoff; + mixer.UpdateEffect(filter); } private void ensureAttached() @@ -138,8 +116,7 @@ namespace osu.Game.Audio.Effects if (IsAttached) return; - Debug.Assert(!mixer.Effects.Contains(filter)); - mixer.Effects.Add(filter); + mixer.AddEffect(filter); IsAttached = true; } @@ -148,8 +125,7 @@ namespace osu.Game.Audio.Effects if (!IsAttached) return; - Debug.Assert(mixer.Effects.Contains(filter)); - mixer.Effects.Remove(filter); + mixer.RemoveEffect(filter); IsAttached = false; } diff --git a/osu.Game/Audio/HitSampleInfo.cs b/osu.Game/Audio/HitSampleInfo.cs index f9c93d72ff..19273e3714 100644 --- a/osu.Game/Audio/HitSampleInfo.cs +++ b/osu.Game/Audio/HitSampleInfo.cs @@ -60,12 +60,18 @@ namespace osu.Game.Audio /// public int Volume { get; } - public HitSampleInfo(string name, string bank = SampleControlPoint.DEFAULT_BANK, string? suffix = null, int volume = 100) + /// + /// Whether this sample should automatically assign the bank of the normal sample whenever it is set in the editor. + /// + public bool EditorAutoBank { get; } + + public HitSampleInfo(string name, string bank = SampleControlPoint.DEFAULT_BANK, string? suffix = null, int volume = 100, bool editorAutoBank = true) { Name = name; Bank = bank; Suffix = suffix; Volume = volume; + EditorAutoBank = editorAutoBank; } /// @@ -92,11 +98,12 @@ namespace osu.Game.Audio /// An optional new sample bank. /// An optional new lookup suffix. /// An optional new volume. + /// An optional new editor auto bank flag. /// The new . - public virtual HitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newSuffix = default, Optional newVolume = default) - => new HitSampleInfo(newName.GetOr(Name), newBank.GetOr(Bank), newSuffix.GetOr(Suffix), newVolume.GetOr(Volume)); + public virtual HitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newSuffix = default, Optional newVolume = default, Optional newEditorAutoBank = default) + => new HitSampleInfo(newName.GetOr(Name), newBank.GetOr(Bank), newSuffix.GetOr(Suffix), newVolume.GetOr(Volume), newEditorAutoBank.GetOr(EditorAutoBank)); - public bool Equals(HitSampleInfo? other) + public virtual bool Equals(HitSampleInfo? other) => other != null && Name == other.Name && Bank == other.Bank && Suffix == other.Suffix; public override bool Equals(object? obj) diff --git a/osu.Game/Beatmaps/APIBeatmapMetadataSource.cs b/osu.Game/Beatmaps/APIBeatmapMetadataSource.cs index a2eebe6161..34eedfb474 100644 --- a/osu.Game/Beatmaps/APIBeatmapMetadataSource.cs +++ b/osu.Game/Beatmaps/APIBeatmapMetadataSource.cs @@ -33,7 +33,7 @@ namespace osu.Game.Beatmaps Debug.Assert(beatmapInfo.BeatmapSet != null); - var req = new GetBeatmapRequest(beatmapInfo); + var req = new GetBeatmapRequest(md5Hash: beatmapInfo.MD5Hash, filename: beatmapInfo.Path); try { diff --git a/osu.Game/Beatmaps/Beatmap.cs b/osu.Game/Beatmaps/Beatmap.cs index 6db9febf36..282f8fe794 100644 --- a/osu.Game/Beatmaps/Beatmap.cs +++ b/osu.Game/Beatmaps/Beatmap.cs @@ -8,6 +8,7 @@ using System.Collections.Generic; using System.Linq; using osu.Game.Beatmaps.ControlPoints; using Newtonsoft.Json; +using osu.Framework.Lists; using osu.Game.IO.Serialization.Converters; namespace osu.Game.Beatmaps @@ -61,7 +62,9 @@ namespace osu.Game.Beatmaps public ControlPointInfo ControlPointInfo { get; set; } = new ControlPointInfo(); - public List Breaks { get; set; } = new List(); + public SortedList Breaks { get; set; } = new SortedList(Comparer.Default); + + public List UnhandledEventLines { get; set; } = new List(); [JsonIgnore] public double TotalBreakTime => Breaks.Sum(b => b.Duration); diff --git a/osu.Game/Beatmaps/BeatmapConverter.cs b/osu.Game/Beatmaps/BeatmapConverter.cs index c7c244bf0e..c43bd494e9 100644 --- a/osu.Game/Beatmaps/BeatmapConverter.cs +++ b/osu.Game/Beatmaps/BeatmapConverter.cs @@ -7,6 +7,8 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading; +using osu.Framework.Lists; +using osu.Game.Beatmaps.Timing; using osu.Game.Rulesets; using osu.Game.Rulesets.Objects; @@ -49,6 +51,10 @@ namespace osu.Game.Beatmaps original.BeatmapInfo = original.BeatmapInfo.Clone(); original.ControlPointInfo = original.ControlPointInfo.DeepClone(); + // Used in osu!mania conversion. + original.Breaks = new SortedList(Comparer.Default); + original.Breaks.AddRange(Beatmap.Breaks); + return ConvertBeatmap(original, cancellationToken); } @@ -66,6 +72,7 @@ namespace osu.Game.Beatmaps beatmap.ControlPointInfo = original.ControlPointInfo; beatmap.HitObjects = convertHitObjects(original.HitObjects, original, cancellationToken).OrderBy(s => s.StartTime).ToList(); beatmap.Breaks = original.Breaks; + beatmap.UnhandledEventLines = original.UnhandledEventLines; return beatmap; } diff --git a/osu.Game/Beatmaps/BeatmapDifficultyCache.cs b/osu.Game/Beatmaps/BeatmapDifficultyCache.cs index 871faf5906..fc4175415c 100644 --- a/osu.Game/Beatmaps/BeatmapDifficultyCache.cs +++ b/osu.Game/Beatmaps/BeatmapDifficultyCache.cs @@ -4,12 +4,15 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using osu.Framework.Allocation; +using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Extensions; +using osu.Framework.Graphics.Textures; using osu.Framework.Lists; using osu.Framework.Logging; using osu.Framework.Threading; @@ -18,7 +21,11 @@ using osu.Game.Database; using osu.Game.Rulesets; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; +using osu.Game.Scoring; +using osu.Game.Skinning; +using osu.Game.Storyboards; namespace osu.Game.Beatmaps { @@ -237,10 +244,37 @@ namespace osu.Game.Beatmaps var ruleset = rulesetInfo.CreateInstance(); Debug.Assert(ruleset != null); - var calculator = ruleset.CreateDifficultyCalculator(beatmapManager.GetWorkingBeatmap(key.BeatmapInfo)); - var attributes = calculator.Calculate(key.OrderedMods, cancellationToken); + PlayableCachedWorkingBeatmap workingBeatmap = new PlayableCachedWorkingBeatmap(beatmapManager.GetWorkingBeatmap(key.BeatmapInfo)); + IBeatmap playableBeatmap = workingBeatmap.GetPlayableBeatmap(ruleset.RulesetInfo, key.OrderedMods, cancellationToken); - return new StarDifficulty(attributes); + var difficulty = ruleset.CreateDifficultyCalculator(workingBeatmap).Calculate(key.OrderedMods, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + + var performanceCalculator = ruleset.CreatePerformanceCalculator(); + if (performanceCalculator == null) + return new StarDifficulty(difficulty, new PerformanceAttributes()); + + ScoreProcessor scoreProcessor = ruleset.CreateScoreProcessor(); + scoreProcessor.Mods.Value = key.OrderedMods; + scoreProcessor.ApplyBeatmap(playableBeatmap); + cancellationToken.ThrowIfCancellationRequested(); + + ScoreInfo perfectScore = new ScoreInfo(key.BeatmapInfo, ruleset.RulesetInfo) + { + Passed = true, + Accuracy = 1, + Mods = key.OrderedMods, + MaxCombo = scoreProcessor.MaximumCombo, + Combo = scoreProcessor.MaximumCombo, + TotalScore = scoreProcessor.MaximumTotalScore, + Statistics = scoreProcessor.MaximumStatistics, + MaximumStatistics = scoreProcessor.MaximumStatistics + }; + + var performance = performanceCalculator.Calculate(perfectScore, difficulty); + cancellationToken.ThrowIfCancellationRequested(); + + return new StarDifficulty(difficulty, performance); } catch (OperationCanceledException) { @@ -276,7 +310,6 @@ namespace osu.Game.Beatmaps { public readonly BeatmapInfo BeatmapInfo; public readonly RulesetInfo Ruleset; - public readonly Mod[] OrderedMods; public DifficultyCacheLookup(BeatmapInfo beatmapInfo, RulesetInfo? ruleset, IEnumerable? mods) @@ -317,5 +350,42 @@ namespace osu.Game.Beatmaps CancellationToken = cancellationToken; } } + + /// + /// A working beatmap that caches its playable representation. + /// This is intended as single-use for when it is guaranteed that the playable beatmap can be reused. + /// + private class PlayableCachedWorkingBeatmap : IWorkingBeatmap + { + private readonly IWorkingBeatmap working; + private IBeatmap? playable; + + public PlayableCachedWorkingBeatmap(IWorkingBeatmap working) + { + this.working = working; + } + + public IBeatmap GetPlayableBeatmap(IRulesetInfo ruleset, IReadOnlyList mods) + => playable ??= working.GetPlayableBeatmap(ruleset, mods); + + public IBeatmap GetPlayableBeatmap(IRulesetInfo ruleset, IReadOnlyList mods, CancellationToken cancellationToken) + => playable ??= working.GetPlayableBeatmap(ruleset, mods, cancellationToken); + + IBeatmapInfo IWorkingBeatmap.BeatmapInfo => working.BeatmapInfo; + bool IWorkingBeatmap.BeatmapLoaded => working.BeatmapLoaded; + bool IWorkingBeatmap.TrackLoaded => working.TrackLoaded; + IBeatmap IWorkingBeatmap.Beatmap => working.Beatmap; + Texture IWorkingBeatmap.GetBackground() => working.GetBackground(); + Texture IWorkingBeatmap.GetPanelBackground() => working.GetPanelBackground(); + Waveform IWorkingBeatmap.Waveform => working.Waveform; + Storyboard IWorkingBeatmap.Storyboard => working.Storyboard; + ISkin IWorkingBeatmap.Skin => working.Skin; + Track IWorkingBeatmap.Track => working.Track; + Track IWorkingBeatmap.LoadTrack() => working.LoadTrack(); + Stream IWorkingBeatmap.GetStream(string storagePath) => working.GetStream(storagePath); + void IWorkingBeatmap.BeginAsyncLoad() => working.BeginAsyncLoad(); + void IWorkingBeatmap.CancelAsyncLoad() => working.CancelAsyncLoad(); + void IWorkingBeatmap.PrepareTrackForPreview(bool looping, double offsetFromPreviewPoint) => working.PrepareTrackForPreview(looping, offsetFromPreviewPoint); + } } } diff --git a/osu.Game/Beatmaps/BeatmapImporter.cs b/osu.Game/Beatmaps/BeatmapImporter.cs index 2137f33e77..94144e4695 100644 --- a/osu.Game/Beatmaps/BeatmapImporter.cs +++ b/osu.Game/Beatmaps/BeatmapImporter.cs @@ -43,7 +43,11 @@ namespace osu.Game.Beatmaps public override async Task?> ImportAsUpdate(ProgressNotification notification, ImportTask importTask, BeatmapSetInfo original) { - var imported = await Import(notification, new[] { importTask }).ConfigureAwait(true); + var originalDateAdded = original.DateAdded; + + Guid originalId = original.ID; + + var imported = await Import(notification, new[] { importTask }).ConfigureAwait(false); if (!imported.Any()) return null; @@ -53,10 +57,13 @@ namespace osu.Game.Beatmaps var first = imported.First(); // If there were no changes, ensure we don't accidentally nuke ourselves. - if (first.ID == original.ID) + if (first.ID == originalId) { - first.PerformRead(s => + first.PerformWrite(s => { + // Transfer local values which should be persisted across a beatmap update. + s.DateAdded = originalDateAdded; + // Re-run processing even in this case. We might have outdated metadata. ProcessBeatmap?.Invoke(s, MetadataLookupScope.OnlineFirst); }); @@ -69,14 +76,15 @@ namespace osu.Game.Beatmaps Logger.Log($"Beatmap \"{updated}\" update completed successfully", LoggingTarget.Database); - original = realm!.Find(original.ID)!; + // Re-fetch as we are likely on a different thread. + original = realm!.Find(originalId)!; // Generally the import process will do this for us if the OnlineIDs match, // but that isn't a guarantee (ie. if the .osu file doesn't have OnlineIDs populated). original.DeletePending = true; // Transfer local values which should be persisted across a beatmap update. - updated.DateAdded = original.DateAdded; + updated.DateAdded = originalDateAdded; transferCollectionReferences(realm, original, updated); @@ -190,8 +198,11 @@ namespace osu.Game.Beatmaps if (beatmapSet.OnlineID > 0) { + // Required local for iOS. Will cause runtime crash if inlined. + int onlineId = beatmapSet.OnlineID; + // OnlineID should really be unique, but to avoid catastrophic failure let's iterate just to be sure. - foreach (var existingSetWithSameOnlineID in realm.All().Where(b => b.OnlineID == beatmapSet.OnlineID)) + foreach (var existingSetWithSameOnlineID in realm.All().Where(b => b.OnlineID == onlineId)) { existingSetWithSameOnlineID.DeletePending = true; existingSetWithSameOnlineID.OnlineID = -1; @@ -275,6 +286,9 @@ namespace osu.Game.Beatmaps protected override void UndeleteForReuse(BeatmapSetInfo existing) { + if (!existing.DeletePending) + return; + base.UndeleteForReuse(existing); existing.DateAdded = DateTimeOffset.UtcNow; } diff --git a/osu.Game/Beatmaps/BeatmapInfo.cs b/osu.Game/Beatmaps/BeatmapInfo.cs index 425fd98d27..f1463eb632 100644 --- a/osu.Game/Beatmaps/BeatmapInfo.cs +++ b/osu.Game/Beatmaps/BeatmapInfo.cs @@ -62,7 +62,7 @@ namespace osu.Game.Beatmaps } [UsedImplicitly] - private BeatmapInfo() + protected BeatmapInfo() { } diff --git a/osu.Game/Beatmaps/BeatmapInfoExtensions.cs b/osu.Game/Beatmaps/BeatmapInfoExtensions.cs index b00d0ba316..a82a288239 100644 --- a/osu.Game/Beatmaps/BeatmapInfoExtensions.cs +++ b/osu.Game/Beatmaps/BeatmapInfoExtensions.cs @@ -2,6 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Localisation; +using osu.Game.Online.API; +using osu.Game.Rulesets; using osu.Game.Screens.Select; namespace osu.Game.Beatmaps @@ -48,5 +50,16 @@ namespace osu.Game.Beatmaps } private static string getVersionString(IBeatmapInfo beatmapInfo) => string.IsNullOrEmpty(beatmapInfo.DifficultyName) ? string.Empty : $"[{beatmapInfo.DifficultyName}]"; + + /// + /// Get the beatmap info page URL, or null if unavailable. + /// + public static string? GetOnlineURL(this IBeatmapInfo beatmapInfo, IAPIProvider api, IRulesetInfo? ruleset = null) + { + if (beatmapInfo.OnlineID <= 0 || beatmapInfo.BeatmapSet == null) + return null; + + return $@"{api.WebsiteRootUrl}/beatmapsets/{beatmapInfo.BeatmapSet.OnlineID}#{ruleset?.ShortName ?? beatmapInfo.Ruleset.ShortName}/{beatmapInfo.OnlineID}"; + } } } diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index 0610f7f6fb..4191771116 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -285,7 +285,8 @@ namespace osu.Game.Beatmaps /// /// The query. /// The first result for the provided query, or null if no results were found. - public BeatmapInfo? QueryBeatmap(Expression> query) => Realm.Run(r => r.All().Filter($"{nameof(BeatmapInfo.BeatmapSet)}.{nameof(BeatmapSetInfo.DeletePending)} == false").FirstOrDefault(query)?.Detach()); + public BeatmapInfo? QueryBeatmap(Expression> query) => Realm.Run(r => + r.All().Filter($"{nameof(BeatmapInfo.BeatmapSet)}.{nameof(BeatmapSetInfo.DeletePending)} == false").FirstOrDefault(query)?.Detach()); /// /// A default representation of a WorkingBeatmap to use when no beatmap is available. @@ -313,6 +314,23 @@ namespace osu.Game.Beatmaps }); } + public void ResetAllOffsets() + { + const string reset_complete_message = "All offsets have been reset!"; + Realm.Write(r => + { + var items = r.All(); + + foreach (var beatmap in items) + { + if (beatmap.UserSettings.Offset != 0) + beatmap.UserSettings.Offset = 0; + } + + PostNotification?.Invoke(new ProgressCompletionNotification { Text = reset_complete_message }); + }); + } + public void Delete(Expression>? filter = null, bool silent = false) { Realm.Run(r => @@ -415,6 +433,9 @@ namespace osu.Game.Beatmaps public Task?> ImportAsUpdate(ProgressNotification notification, ImportTask importTask, BeatmapSetInfo original) => beatmapImporter.ImportAsUpdate(notification, importTask, original); + public Task> BeginExternalEditing(BeatmapSetInfo model) => + beatmapImporter.BeginExternalEditing(model); + public Task Export(BeatmapSetInfo beatmap) => beatmapExporter.ExportAsync(beatmap.ToLive(Realm)); public Task ExportLegacy(BeatmapSetInfo beatmap) => legacyBeatmapExporter.ExportAsync(beatmap.ToLive(Realm)); @@ -560,7 +581,7 @@ namespace osu.Game.Beatmaps remove => workingBeatmapCache.OnInvalidated -= value; } - public override bool IsAvailableLocally(BeatmapSetInfo model) => Realm.Run(realm => realm.All().Any(s => s.OnlineID == model.OnlineID)); + public override bool IsAvailableLocally(BeatmapSetInfo model) => Realm.Run(realm => realm.All().Any(s => s.OnlineID == model.OnlineID && !s.DeletePending)); #endregion 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/BeatmapSetInfoExtensions.cs b/osu.Game/Beatmaps/BeatmapSetInfoExtensions.cs index 965544da40..8a107ed486 100644 --- a/osu.Game/Beatmaps/BeatmapSetInfoExtensions.cs +++ b/osu.Game/Beatmaps/BeatmapSetInfoExtensions.cs @@ -6,6 +6,8 @@ using System.Linq; using osu.Game.Database; using osu.Game.Extensions; using osu.Game.Models; +using osu.Game.Online.API; +using osu.Game.Rulesets; namespace osu.Game.Beatmaps { @@ -29,5 +31,19 @@ namespace osu.Game.Beatmaps /// The name of the file to get the storage path of. public static RealmNamedFileUsage? GetFile(this IHasRealmFiles model, string filename) => model.Files.SingleOrDefault(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase)); + + /// + /// Get the beatmapset info page URL, or null if unavailable. + /// + public static string? GetOnlineURL(this IBeatmapSetInfo beatmapSetInfo, IAPIProvider api, IRulesetInfo? ruleset = null) + { + if (beatmapSetInfo.OnlineID <= 0) + return null; + + if (ruleset != null) + return $@"{api.WebsiteRootUrl}/beatmapsets/{beatmapSetInfo.OnlineID}#{ruleset.ShortName}"; + + return $@"{api.WebsiteRootUrl}/beatmapsets/{beatmapSetInfo.OnlineID}"; + } } } diff --git a/osu.Game/Beatmaps/BeatmapUpdater.cs b/osu.Game/Beatmaps/BeatmapUpdater.cs index e897d28916..efb432b84e 100644 --- a/osu.Game/Beatmaps/BeatmapUpdater.cs +++ b/osu.Game/Beatmaps/BeatmapUpdater.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Diagnostics; using System.Linq; using System.Threading.Tasks; @@ -15,10 +14,7 @@ using osu.Game.Rulesets.Objects.Types; namespace osu.Game.Beatmaps { - /// - /// Handles all processing required to ensure a local beatmap is in a consistent state with any changes. - /// - public class BeatmapUpdater : IDisposable + public class BeatmapUpdater : IBeatmapUpdater { private readonly IWorkingBeatmapCache workingBeatmapCache; @@ -38,11 +34,6 @@ namespace osu.Game.Beatmaps metadataLookup = new BeatmapUpdaterMetadataLookup(api, storage); } - /// - /// Queue a beatmap for background processing. - /// - /// The managed beatmap set to update. A transaction will be opened to apply changes. - /// The preferred scope to use for metadata lookup. public void Queue(Live beatmapSet, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst) { Logger.Log($"Queueing change for local beatmap {beatmapSet}"); @@ -50,55 +41,56 @@ namespace osu.Game.Beatmaps updateScheduler); } - /// - /// Run all processing on a beatmap immediately. - /// - /// The managed beatmap set to update. A transaction will be opened to apply changes. - /// The preferred scope to use for metadata lookup. - public void Process(BeatmapSetInfo beatmapSet, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst) => beatmapSet.Realm!.Write(_ => + public void Process(BeatmapSetInfo beatmapSet, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst) { - // Before we use below, we want to invalidate. - workingBeatmapCache.Invalidate(beatmapSet); - - if (lookupScope != MetadataLookupScope.None) - metadataLookup.Update(beatmapSet, lookupScope == MetadataLookupScope.OnlineFirst); - - foreach (var beatmap in beatmapSet.Beatmaps) + beatmapSet.Realm!.Write(_ => { - difficultyCache.Invalidate(beatmap); + // Before we use below, we want to invalidate. + workingBeatmapCache.Invalidate(beatmapSet); - var working = workingBeatmapCache.GetWorkingBeatmap(beatmap); - var ruleset = working.BeatmapInfo.Ruleset.CreateInstance(); + if (lookupScope != MetadataLookupScope.None) + metadataLookup.Update(beatmapSet, lookupScope == MetadataLookupScope.OnlineFirst); - Debug.Assert(ruleset != null); + foreach (var beatmap in beatmapSet.Beatmaps) + { + difficultyCache.Invalidate(beatmap); - var calculator = ruleset.CreateDifficultyCalculator(working); + var working = workingBeatmapCache.GetWorkingBeatmap(beatmap); + var ruleset = working.BeatmapInfo.Ruleset.CreateInstance(); - beatmap.StarRating = calculator.Calculate().StarRating; - beatmap.Length = working.Beatmap.CalculatePlayableLength(); - beatmap.BPM = 60000 / working.Beatmap.GetMostCommonBeatLength(); - beatmap.EndTimeObjectCount = working.Beatmap.HitObjects.Count(h => h is IHasDuration); - beatmap.TotalObjectCount = working.Beatmap.HitObjects.Count; - } + Debug.Assert(ruleset != null); - // And invalidate again afterwards as re-fetching the most up-to-date database metadata will be required. - workingBeatmapCache.Invalidate(beatmapSet); - }); + var calculator = ruleset.CreateDifficultyCalculator(working); - public void ProcessObjectCounts(BeatmapInfo beatmapInfo, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst) => beatmapInfo.Realm!.Write(_ => + beatmap.StarRating = calculator.Calculate().StarRating; + beatmap.Length = working.Beatmap.CalculatePlayableLength(); + beatmap.BPM = 60000 / working.Beatmap.GetMostCommonBeatLength(); + beatmap.EndTimeObjectCount = working.Beatmap.HitObjects.Count(h => h is IHasDuration); + beatmap.TotalObjectCount = working.Beatmap.HitObjects.Count; + } + + // And invalidate again afterwards as re-fetching the most up-to-date database metadata will be required. + workingBeatmapCache.Invalidate(beatmapSet); + }); + } + + public void ProcessObjectCounts(BeatmapInfo beatmapInfo, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst) { - // Before we use below, we want to invalidate. - workingBeatmapCache.Invalidate(beatmapInfo); + beatmapInfo.Realm!.Write(_ => + { + // Before we use below, we want to invalidate. + workingBeatmapCache.Invalidate(beatmapInfo); - var working = workingBeatmapCache.GetWorkingBeatmap(beatmapInfo); - var beatmap = working.Beatmap; + var working = workingBeatmapCache.GetWorkingBeatmap(beatmapInfo); + var beatmap = working.Beatmap; - beatmapInfo.EndTimeObjectCount = beatmap.HitObjects.Count(h => h is IHasDuration); - beatmapInfo.TotalObjectCount = beatmap.HitObjects.Count; + beatmapInfo.EndTimeObjectCount = beatmap.HitObjects.Count(h => h is IHasDuration); + beatmapInfo.TotalObjectCount = beatmap.HitObjects.Count; - // And invalidate again afterwards as re-fetching the most up-to-date database metadata will be required. - workingBeatmapCache.Invalidate(beatmapInfo); - }); + // And invalidate again afterwards as re-fetching the most up-to-date database metadata will be required. + workingBeatmapCache.Invalidate(beatmapInfo); + }); + } #region Implementation of IDisposable diff --git a/osu.Game/Beatmaps/BeatmapUpdaterMetadataLookup.cs b/osu.Game/Beatmaps/BeatmapUpdaterMetadataLookup.cs index 034ec31ee4..364a0f9b4b 100644 --- a/osu.Game/Beatmaps/BeatmapUpdaterMetadataLookup.cs +++ b/osu.Game/Beatmaps/BeatmapUpdaterMetadataLookup.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; -using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Online.API; @@ -44,10 +43,19 @@ namespace osu.Game.Beatmaps foreach (var beatmapInfo in beatmapSet.Beatmaps) { + // note that these lookups DO NOT ACTUALLY FULLY GUARANTEE that the beatmap is what it claims it is, + // i.e. the correctness of this lookup should be treated as APPROXIMATE AT WORST. + // this is because the beatmap filename is used as a fallback in some scenarios where the MD5 of the beatmap may mismatch. + // this is considered to be an acceptable casualty so that things can continue to work as expected for users in some rare scenarios + // (stale beatmap files in beatmap packs, beatmap mirror desyncs). + // however, all this means that other places such as score submission ARE EXPECTED TO VERIFY THE MD5 OF THE BEATMAP AGAINST THE ONLINE ONE EXPLICITLY AGAIN. + // + // additionally note that the online ID stored to the map is EXPLICITLY NOT USED because some users in a silly attempt to "fix" things for themselves on stable + // would reuse online IDs of already submitted beatmaps, which means that information is pretty much expected to be bogus in a nonzero number of beatmapsets. if (!tryLookup(beatmapInfo, preferOnlineFetch, out var res)) continue; - if (res == null || shouldDiscardLookupResult(res, beatmapInfo)) + if (res == null) { beatmapInfo.ResetOnlineInfo(); lookupResults.Add(null); // mark lookup failure @@ -83,23 +91,6 @@ namespace osu.Game.Beatmaps } } - private bool shouldDiscardLookupResult(OnlineBeatmapMetadata result, BeatmapInfo beatmapInfo) - { - if (beatmapInfo.OnlineID > 0 && result.BeatmapID != beatmapInfo.OnlineID) - { - Logger.Log($"Discarding metadata lookup result due to mismatching online ID (expected: {beatmapInfo.OnlineID} actual: {result.BeatmapID})", LoggingTarget.Database); - return true; - } - - if (beatmapInfo.OnlineID == -1 && result.MD5Hash != beatmapInfo.MD5Hash) - { - Logger.Log($"Discarding metadata lookup result due to mismatching hash (expected: {beatmapInfo.MD5Hash} actual: {result.MD5Hash})", LoggingTarget.Database); - return true; - } - - return false; - } - /// /// Attempts to retrieve the for the given . /// diff --git a/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs index f46e4af332..416ad0a590 100644 --- a/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Diagnostics.CodeAnalysis; using Newtonsoft.Json; using osu.Game.Graphics; using osu.Game.Utils; @@ -9,10 +10,31 @@ using osuTK.Graphics; namespace osu.Game.Beatmaps.ControlPoints { + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] public abstract class ControlPoint : IComparable, IDeepCloneable, IEquatable, IControlPoint { + /// + /// Invoked when any of this 's properties have changed. + /// + public event Action? Changed; + + protected void RaiseChanged() => Changed?.Invoke(this); + + private double time; + [JsonIgnore] - public double Time { get; set; } + public double Time + { + get => time; + set + { + if (time == value) + return; + + time = value; + RaiseChanged(); + } + } public void AttachGroup(ControlPointGroup pointGroup) => Time = pointGroup.Time; diff --git a/osu.Game/Beatmaps/ControlPoints/ControlPointGroup.cs b/osu.Game/Beatmaps/ControlPoints/ControlPointGroup.cs index 1f34f3777d..c9c87dc85d 100644 --- a/osu.Game/Beatmaps/ControlPoints/ControlPointGroup.cs +++ b/osu.Game/Beatmaps/ControlPoints/ControlPointGroup.cs @@ -10,8 +10,11 @@ namespace osu.Game.Beatmaps.ControlPoints public class ControlPointGroup : IComparable, IEquatable { public event Action? ItemAdded; + public event Action? ItemChanged; public event Action? ItemRemoved; + private void raiseItemChanged(ControlPoint controlPoint) => ItemChanged?.Invoke(controlPoint); + /// /// The time at which the control point takes effect. /// @@ -39,12 +42,14 @@ namespace osu.Game.Beatmaps.ControlPoints controlPoints.Add(point); ItemAdded?.Invoke(point); + point.Changed += raiseItemChanged; } public void Remove(ControlPoint point) { controlPoints.Remove(point); ItemRemoved?.Invoke(point); + point.Changed -= raiseItemChanged; } public sealed override bool Equals(object? obj) diff --git a/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs b/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs index 1a15db98e4..8666f01129 100644 --- a/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs +++ b/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs @@ -17,8 +17,17 @@ using osu.Game.Utils; namespace osu.Game.Beatmaps.ControlPoints { [Serializable] + [System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] public class ControlPointInfo : IDeepCloneable { + /// + /// Invoked on any change to the set of control points. + /// + [CanBeNull] + public event Action ControlPointsChanged; + + private void raiseControlPointsChanged([CanBeNull] ControlPoint _ = null) => ControlPointsChanged?.Invoke(); + /// /// All control points grouped by time. /// @@ -65,6 +74,19 @@ namespace osu.Game.Beatmaps.ControlPoints [NotNull] public TimingControlPoint TimingPointAt(double time) => BinarySearchWithFallback(TimingPoints, time, TimingPoints.Count > 0 ? TimingPoints[0] : TimingControlPoint.DEFAULT); + /// + /// Finds the first timing point that is active strictly after , or null if no such point exists. + /// + /// The time after which to find the timing control point. + /// The timing control point. + [CanBeNull] + public TimingControlPoint TimingPointAfter(double time) + { + int index = BinarySearch(TimingPoints, time, EqualitySelection.Rightmost); + index = index < 0 ? ~index : index + 1; + return index < TimingPoints.Count ? TimingPoints[index] : null; + } + /// /// Finds the maximum BPM represented by any timing control point. /// @@ -116,6 +138,7 @@ namespace osu.Game.Beatmaps.ControlPoints if (addIfNotExisting) { newGroup.ItemAdded += GroupItemAdded; + newGroup.ItemChanged += raiseControlPointsChanged; newGroup.ItemRemoved += GroupItemRemoved; groups.Insert(~i, newGroup); @@ -131,6 +154,7 @@ namespace osu.Game.Beatmaps.ControlPoints group.Remove(item); group.ItemAdded -= GroupItemAdded; + group.ItemChanged -= raiseControlPointsChanged; group.ItemRemoved -= GroupItemRemoved; groups.Remove(group); @@ -145,7 +169,14 @@ namespace osu.Game.Beatmaps.ControlPoints public double GetClosestSnappedTime(double time, int beatDivisor, double? referenceTime = null) { var timingPoint = TimingPointAt(referenceTime ?? time); - return getClosestSnappedTime(timingPoint, time, beatDivisor); + double snappedTime = getClosestSnappedTime(timingPoint, time, beatDivisor); + + if (referenceTime.HasValue) + return snappedTime; + + // If there is a timing point right after the given time, we should check if it is closer than the snapped time and snap to it. + var timingPointAfter = TimingPointAfter(time); + return timingPointAfter is null || Math.Abs(time - snappedTime) < Math.Abs(time - timingPointAfter.Time) ? snappedTime : timingPointAfter.Time; } /// @@ -219,17 +250,40 @@ namespace osu.Game.Beatmaps.ControlPoints { ArgumentNullException.ThrowIfNull(list); - if (list.Count == 0) - return null; + int index = BinarySearch(list, time, EqualitySelection.Rightmost); + + if (index < 0) + index = ~index - 1; + + return index >= 0 ? list[index] : null; + } + + /// + /// Binary searches one of the control point lists to find the active control point at . + /// + /// The list to search. + /// The time to find the control point at. + /// Determines which index to return if there are multiple exact matches. + /// The index of the control point at . Will return the complement of the index of the control point after if no exact match is found. + public static int BinarySearch(IReadOnlyList list, double time, EqualitySelection equalitySelection) + where T : class, IControlPoint + { + ArgumentNullException.ThrowIfNull(list); + + int n = list.Count; + + if (n == 0) + return -1; if (time < list[0].Time) - return null; + return -1; - if (time >= list[^1].Time) - return list[^1]; + if (time > list[^1].Time) + return ~n; int l = 0; - int r = list.Count - 2; + int r = n - 1; + bool equalityFound = false; while (l <= r) { @@ -240,11 +294,37 @@ namespace osu.Game.Beatmaps.ControlPoints else if (list[pivot].Time > time) r = pivot - 1; else - return list[pivot]; + { + equalityFound = true; + + switch (equalitySelection) + { + case EqualitySelection.Leftmost: + r = pivot - 1; + break; + + case EqualitySelection.Rightmost: + l = pivot + 1; + break; + + default: + case EqualitySelection.FirstFound: + return pivot; + } + } } - // l will be the first control point with Time > time, but we want the one before it - return list[l - 1]; + if (!equalityFound) return ~l; + + switch (equalitySelection) + { + case EqualitySelection.Leftmost: + return l; + + default: + case EqualitySelection.Rightmost: + return l - 1; + } } /// @@ -287,6 +367,8 @@ namespace osu.Game.Beatmaps.ControlPoints default: throw new ArgumentException($"A control point of unexpected type {controlPoint.GetType()} was added to this {nameof(ControlPointInfo)}"); } + + raiseControlPointsChanged(); } protected virtual void GroupItemRemoved(ControlPoint controlPoint) @@ -301,6 +383,8 @@ namespace osu.Game.Beatmaps.ControlPoints effectPoints.Remove(typed); break; } + + raiseControlPointsChanged(); } public ControlPointInfo DeepClone() @@ -313,4 +397,11 @@ namespace osu.Game.Beatmaps.ControlPoints return controlPointInfo; } } + + public enum EqualitySelection + { + FirstFound, + Leftmost, + Rightmost + } } diff --git a/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs index 05230c85f4..9f8ed1b396 100644 --- a/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs @@ -44,6 +44,11 @@ namespace osu.Game.Beatmaps.ControlPoints set => SliderVelocityBindable.Value = value; } + public DifficultyControlPoint() + { + SliderVelocityBindable.BindValueChanged(_ => RaiseChanged()); + } + public override bool IsRedundant(ControlPoint? existing) => existing is DifficultyControlPoint existingDifficulty && GenerateTicks == existingDifficulty.GenerateTicks diff --git a/osu.Game/Beatmaps/ControlPoints/EffectControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/EffectControlPoint.cs index 0138ac7569..4b73994dcf 100644 --- a/osu.Game/Beatmaps/ControlPoints/EffectControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/EffectControlPoint.cs @@ -34,7 +34,7 @@ namespace osu.Game.Beatmaps.ControlPoints set => ScrollSpeedBindable.Value = value; } - public override Color4 GetRepresentingColour(OsuColour colours) => colours.Purple; + public override Color4 GetRepresentingColour(OsuColour colours) => colours.Orange1; /// /// Whether this control point enables Kiai mode. @@ -50,6 +50,12 @@ namespace osu.Game.Beatmaps.ControlPoints set => KiaiModeBindable.Value = value; } + public EffectControlPoint() + { + KiaiModeBindable.BindValueChanged(_ => RaiseChanged()); + ScrollSpeedBindable.BindValueChanged(_ => RaiseChanged()); + } + public override bool IsRedundant(ControlPoint? existing) => existing is EffectControlPoint existingEffect && KiaiMode == existingEffect.KiaiMode diff --git a/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs index ae4bdafd6f..800d9f9abc 100644 --- a/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs @@ -56,6 +56,12 @@ namespace osu.Game.Beatmaps.ControlPoints set => SampleVolumeBindable.Value = value; } + public SampleControlPoint() + { + SampleBankBindable.BindValueChanged(_ => RaiseChanged()); + SampleVolumeBindable.BindValueChanged(_ => RaiseChanged()); + } + /// /// Create a SampleInfo based on the sample settings in this control point. /// diff --git a/osu.Game/Beatmaps/ControlPoints/TimingControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/TimingControlPoint.cs index 4e69486e2d..db1d440f18 100644 --- a/osu.Game/Beatmaps/ControlPoints/TimingControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/TimingControlPoint.cs @@ -26,7 +26,7 @@ namespace osu.Game.Beatmaps.ControlPoints /// private const double default_beat_length = 60000.0 / 60.0; - public override Color4 GetRepresentingColour(OsuColour colours) => colours.Orange1; + public override Color4 GetRepresentingColour(OsuColour colours) => colours.Red2; public static readonly TimingControlPoint DEFAULT = new TimingControlPoint { @@ -82,6 +82,13 @@ namespace osu.Game.Beatmaps.ControlPoints /// public double BPM => 60000 / BeatLength; + public TimingControlPoint() + { + TimeSignatureBindable.BindValueChanged(_ => RaiseChanged()); + OmitFirstBarLineBindable.BindValueChanged(_ => RaiseChanged()); + BeatLengthBindable.BindValueChanged(_ => RaiseChanged()); + } + // Timing points are never redundant as they can change the time signature. public override bool IsRedundant(ControlPoint? existing) => false; diff --git a/osu.Game/Beatmaps/Drawables/BundledBeatmapDownloader.cs b/osu.Game/Beatmaps/Drawables/BundledBeatmapDownloader.cs index 21ab1b78ea..3aa34a5580 100644 --- a/osu.Game/Beatmaps/Drawables/BundledBeatmapDownloader.cs +++ b/osu.Game/Beatmaps/Drawables/BundledBeatmapDownloader.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text.RegularExpressions; using osu.Framework.Allocation; @@ -21,6 +22,8 @@ using osu.Game.Utils; namespace osu.Game.Beatmaps.Drawables { + [SuppressMessage("ReSharper", "StringLiteralTypo")] + [SuppressMessage("ReSharper", "CommentTypo")] public partial class BundledBeatmapDownloader : CompositeDrawable { private readonly bool shouldPostNotifications; @@ -50,7 +53,7 @@ namespace osu.Game.Beatmaps.Drawables { queueDownloads(always_bundled_beatmaps); - queueDownloads(bundled_osu, 8); + queueDownloads(bundled_osu, 6); queueDownloads(bundled_taiko, 3); queueDownloads(bundled_catch, 3); queueDownloads(bundled_mania, 3); @@ -128,6 +131,26 @@ namespace osu.Game.Beatmaps.Drawables } } + /* + * criteria for bundled maps (managed by pishifat) + * + * auto: + * - licensed song + * - includes ENHI diffs + * - between 60s and 240s + * + * manual: + * - bg is explicitly permitted as okay to use. lots of artists say some variation of "it's ok for personal use/non-commercial use/with credit" + * (which is prob fine when maps are presented as user-generated content), but for a new osu! player, it's easy to assume bundled maps are + * commercial content like other rhythm games, so it's best to be cautious about using not-explicitly-permitted artwork. + * + * - no ai/thirst bgs + * - no controversial/explicit song content or titles + * - no repeating bundled songs (within each mode) + * - no songs that are relatively low production value + * - no songs with limited accessibility (annoying high pitch vocals, noise rock, etc) + */ + private const string tutorial_filename = "1011011 nekodex - new beginnings.osz"; /// @@ -135,215 +158,312 @@ namespace osu.Game.Beatmaps.Drawables /// private static readonly string[] always_bundled_beatmaps = { - // This thing is 40mb, I'm not sure we want it here... + // winner of https://osu.ppy.sh/home/news/2013-09-06-osu-monthly-beatmapping-contest-1 + @"123593 Rostik - Liquid (Paul Rosenthal Remix).osz", + // winner of https://osu.ppy.sh/home/news/2013-10-28-monthly-beatmapping-contest-2-submissions-open + @"140662 cYsmix feat. Emmy - Tear Rain.osz", + // winner of https://osu.ppy.sh/home/news/2013-12-15-monthly-beatmapping-contest-3-submissions-open + @"151878 Chasers - Lost.osz", + // winner of https://osu.ppy.sh/home/news/2014-02-14-monthly-beatmapping-contest-4-submissions-now + @"163112 Kuba Oms - My Love.osz", + // winner of https://osu.ppy.sh/home/news/2014-05-07-monthly-beatmapping-contest-5-submissions-now + @"190390 Rameses B - Flaklypa.osz", + // winner of https://osu.ppy.sh/home/news/2014-09-24-monthly-beatmapping-contest-7 + @"241526 Soleily - Renatus.osz", + // winner of https://osu.ppy.sh/home/news/2015-02-11-monthly-beatmapping-contest-8 + @"299224 raja - the light.osz", + // winner of https://osu.ppy.sh/home/news/2015-04-13-monthly-beatmapping-contest-9-taiko-only + @"319473 Furries in a Blender - Storm World.osz", + // winner of https://osu.ppy.sh/home/news/2015-06-15-monthly-beatmapping-contest-10-ctb-only + @"342751 Hylian Lemon - Foresight Is for Losers.osz", + // winner of https://osu.ppy.sh/home/news/2015-08-22-monthly-beatmapping-contest-11-mania-only + @"385056 Toni Leys - Dragon Valley (Toni Leys Remix feat. Esteban Bellucci).osz", + // winner of https://osu.ppy.sh/home/news/2016-03-04-beatmapping-contest-12-osu + @"456054 IAHN - Candy Luv (Short Ver.).osz", + // winner of https://osu.ppy.sh/home/news/2020-11-30-a-labour-of-love + // (this thing is 40mb, I'm not sure if we want it here...) @"1388906 Raphlesia & BilliumMoto - My Love.osz", - // Winner of Triangles mapping competition: https://osu.ppy.sh/home/news/2022-10-06-results-triangles + // winner of https://osu.ppy.sh/home/news/2022-05-31-triangles @"1841885 cYsmix - triangles.osz", + // winner of https://osu.ppy.sh/home/news/2023-02-01-twin-trials-contest-beatmapping-phase + @"1971987 James Landino - Aresene's Bazaar.osz", }; private static readonly string[] bundled_osu = { - "682286 Yuyoyuppe - Emerald Galaxy.osz", - "682287 baker - For a Dead Girl+.osz", - "682289 Hige Driver - I Wanna Feel Your Love (feat. shully).osz", - "682290 Hige Driver - Miracle Sugite Yabai (feat. shully).osz", - "682416 Hige Driver - Palette.osz", - "682595 baker - Kimi ga Kimi ga -vocanico remix-.osz", - "716211 yuki. - Spring Signal.osz", - "716213 dark cat - BUBBLE TEA (feat. juu & cinders).osz", - "716215 LukHash - CLONED.osz", - "716219 IAHN - Snowdrop.osz", - "716249 *namirin - Senaka Awase no Kuukyo (with Kakichoco).osz", - "716390 sakuraburst - SHA.osz", - "716441 Fractal Dreamers - Paradigm Shift.osz", - "729808 Thaehan - Leprechaun.osz", - "751771 Cranky - Hanaarashi.osz", - "751772 Cranky - Ran.osz", - "751773 Cranky - Feline, the White....osz", - "751774 Function Phantom - Variable.osz", - "751779 Rin - Daishibyo set 14 ~ Sado no Futatsuiwa.osz", - "751782 Fractal Dreamers - Fata Morgana.osz", - "751785 Cranky - Chandelier - King.osz", - "751846 Fractal Dreamers - Celestial Horizon.osz", - "751866 Rin - Moriya set 08 ReEdit ~ Youkai no Yama.osz", - "751894 Fractal Dreamers - Blue Haven.osz", - "751896 Cranky - Rave 2 Rave.osz", - "751932 Cranky - La fuite des jours.osz", - "751972 Cranky - CHASER.osz", - "779173 Thaehan - Superpower.osz", - "780932 VINXIS - A Centralized View.osz", - "785572 S3RL - I'll See You Again (feat. Chi Chi).osz", - "785650 yuki. feat. setsunan - Hello! World.osz", - "785677 Dictate - Militant.osz", - "785731 S3RL - Catchit (Radio Edit).osz", - "785774 LukHash - GLITCH.osz", - "786498 Trial & Error - Tokoyami no keiyaku KEGARETA-SHOUJO feat. GUMI.osz", - "789374 Pulse - LP.osz", - "789528 James Portland - Sky.osz", - "789529 Lexurus - Gravity.osz", - "789544 Andromedik - Invasion.osz", - "789905 Gourski x Himmes - Silence.osz", - "791667 cYsmix - Babaroque (Short Ver.).osz", - "791798 cYsmix - Behind the Walls.osz", - "791845 cYsmix - Little Knight.osz", - "792241 cYsmix - Eden.osz", - "792396 cYsmix - The Ballad of a Mindless Girl.osz", - "795432 Phonetic - Journey.osz", - "831322 DJ'TEKINA//SOMETHING - Hidamari no Uta.osz", - "847764 Cranky - Crocus.osz", - "847776 Culprate & Joe Ford - Gaucho.osz", - "847812 J. Pachelbel - Canon (Cranky Remix).osz", - "847900 Cranky - Time Alter.osz", - "847930 LukHash - 8BIT FAIRY TALE.osz", - "848003 Culprate - Aurora.osz", - "848068 nanobii - popsicle beach.osz", - "848090 Trial & Error - DAI*TAN SENSATION feat. Nanahira, Mii, Aitsuki Nakuru (Short Ver.).osz", - "848259 Culprate & Skorpion - Jester.osz", - "848976 Dictate - Treason.osz", - "851543 Culprate - Florn.osz", - "864748 Thaehan - Angry Birds Epic (Remix).osz", - "873667 OISHII - ONIGIRI FREEWAY.osz", - "876227 Culprate, Keota & Sophie Meiers - Mechanic Heartbeat.osz", - "880487 cYsmix - Peer Gynt.osz", - "883088 Wisp X - Somewhere I'd Rather Be.osz", - "891333 HyuN - White Aura.osz", - "891334 HyuN - Wild Card.osz", - "891337 HyuN feat. LyuU - Cross Over.osz", - "891338 HyuN & Ritoru - Apocalypse in Love.osz", - "891339 HyuN feat. Ato - Asu wa Ame ga Yamukara.osz", - "891345 HyuN - Infinity Heaven.osz", - "891348 HyuN - Guitian.osz", - "891356 HyuN - Legend of Genesis.osz", - "891366 HyuN - Illusion of Inflict.osz", - "891417 HyuN feat. Yu-A - My life is for you.osz", - "891441 HyuN - You'Re aRleAdY dEAd.osz", - "891632 HyuN feat. YURI - Disorder.osz", - "891712 HyuN - Tokyo's Starlight.osz", - "901091 *namirin - Ciel etoile.osz", - "916990 *namirin - Koishiteiku Planet.osz", - "929284 tieff - Sense of Nostalgia.osz", - "933940 Ben Briggs - Yes (Maybe).osz", - "934415 Ben Briggs - Fearless Living.osz", - "934627 Ben Briggs - New Game Plus.osz", - "934666 Ben Briggs - Wave Island.osz", - "936126 siromaru + cranky - conflict.osz", - "940377 onumi - ARROGANCE.osz", - "940597 tieff - Take Your Swimsuit.osz", - "941085 tieff - Our Story.osz", - "949297 tieff - Sunflower.osz", - "952380 Ben Briggs - Why Are We Yelling.osz", - "954272 *namirin - Kanzen Shouri*Esper Girl.osz", - "955866 KIRA & Heartbreaker - B.B.F (feat. Hatsune Miku & Kagamine Rin).osz", - "961320 Kuba Oms - All In All.osz", - "964553 The Flashbulb - You Take the World's Weight Away.osz", - "965651 Fractal Dreamers - Ad Astra.osz", - "966225 The Flashbulb - Passage D.osz", - "966324 DJ'TEKINA//SOMETHING - Hidamari no Uta.osz", - "972810 James Landino & Kabuki - Birdsong.osz", - "972932 James Landino - Hide And Seek.osz", - "977276 The Flashbulb - Mellann.osz", - "981616 *namirin - Mizutamari Tobikoete (with Nanahira).osz", - "985788 Loki - Wizard's Tower.osz", - "996628 OISHII - ONIGIRI FREEWAY.osz", - "996898 HyuN - White Aura.osz", - "1003554 yuki. - Nadeshiko Sensation.osz", - "1014936 Thaehan - Bwa !.osz", - "1019827 UNDEAD CORPORATION - Sad Dream.osz", - "1020213 Creo - Idolize.osz", - "1021450 Thaehan - Chiptune & Baroque.osz", + @"682286 Yuyoyuppe - Emerald Galaxy.osz", + @"682287 baker - For a Dead Girl+.osz", + @"682595 baker - Kimi ga Kimi ga -vocanico remix-.osz", + @"1048705 Thaehan - Never Give Up.osz", + @"1050185 Carpool Tunnel - Hooked Again.osz", + @"1052846 Carpool Tunnel - Impressions.osz", + @"1062477 Ricky Montgomery - Line Without a Hook.osz", + @"1081119 Celldweller - Pulsar.osz", + @"1086289 Frums - 24eeev0-$.osz", + @"1133317 PUP - Free At Last.osz", + @"1171188 PUP - Full Blown Meltdown.osz", + @"1177043 PUP - My Life Is Over And I Couldn't Be Happier.osz", + @"1250387 Circle of Dust - Humanarchy (Cut Ver.).osz", + @"1255411 Wisp X - Somewhere I'd Rather Be.osz", + @"1320298 nekodex - Little Drummer Girl.osz", + @"1323877 Masahiro ""Godspeed"" Aoki - Blaze.osz", + @"1342280 Minagu feat. Aitsuki Nakuru - Theater Endroll.osz", + @"1356447 SECONDWALL - Boku wa Boku de shika Nakute.osz", + @"1368054 SECONDWALL - Shooting Star.osz", + @"1398580 La priere - Senjou no Utahime.osz", + @"1403962 m108 - Sunflower.osz", + @"1405913 fiend - FEVER DREAM (feat. yzzyx).osz", + @"1409184 Omoi - Hey William (New Translation).osz", + @"1413418 URBANGARDE - KAMING OUT (Cut Ver.).osz", + @"1417793 P4koo (NONE) - Sogaikan Utopia.osz", + @"1428384 DUAL ALTER WORLD - Veracila.osz", + @"1442963 PUP - DVP.osz", + @"1460370 Sound Souler - Empty Stars.osz", + @"1485184 Koven - Love Wins Again.osz", + @"1496811 T & Sugah - Wicked Days (Cut Ver.).osz", + @"1501511 Masahiro ""Godspeed"" Aoki - Frostbite (Cut Ver.).osz", + @"1511518 T & Sugah X Zazu - Lost On My Own (Cut Ver.).osz", + @"1516617 wotoha - Digital Life Hacker.osz", + @"1524273 Michael Cera Palin - Admiral.osz", + @"1564234 P4koo - Fly High (feat. rerone).osz", + @"1572918 Lexurus - Take Me Away (Cut Ver.).osz", + @"1577313 Kurubukko - The 84th Flight.osz", + @"1587839 Amidst - Droplet.osz", + @"1595193 BlackY - Sakura Ranman Cleopatra.osz", + @"1667560 xi - FREEDOM DiVE.osz", + @"1668789 City Girl - L2M (feat. Kelsey Kuan).osz", + @"1672934 xi - Parousia.osz", + @"1673457 Boom Kitty - Any Other Way (feat. Ivy Marie).osz", + @"1685122 xi - Time files.osz", + @"1689372 NIWASHI - Y.osz", + @"1729551 JOYLESS - Dream.osz", + @"1742868 Ritorikal - Synergy.osz", + @"1757511 KINEMA106 - KARASU.osz", + @"1778169 Ricky Montgomery - Cabo.osz", + @"1848184 FRASER EDWARDS - Ruination.osz", + @"1862574 Pegboard Nerds - Try This (Cut Ver.).osz", + @"1873680 happy30 - You spin my world.osz", + @"1890055 A.SAKA - Mutsuki Akari no Yuki.osz", + @"1911933 Marmalade butcher - Waltz for Chroma (feat. Natsushiro Takaaki).osz", + @"1940007 Mili - Ga1ahad and Scientific Witchery.osz", + @"1948970 Shadren - You're Here Forever.osz", + @"1967856 Annabel - alpine blue.osz", + @"1969316 Silentroom - NULCTRL.osz", + @"1978614 Krimek - Idyllic World.osz", + @"1991315 Feint - Tower Of Heaven (You Are Slaves) (Cut Ver.).osz", + @"1997470 tephe - Genjitsu Escape.osz", + @"1999116 soowamisu - .vaporcore.osz", + @"2010589 Junk - Yellow Smile (bms edit).osz", + @"2022054 Yokomin - STINGER.osz", + @"2025686 Aice room - For U.osz", + @"2035357 C-Show feat. Ishizawa Yukari - Border Line.osz", + @"2039403 SECONDWALL - Freedom.osz", + @"2046487 Rameses B - Against the Grain (feat. Veela).osz", + @"2052201 ColBreakz & Vizzen - Remember.osz", + @"2055535 Sephid - Thunderstrike 1988.osz", + @"2057584 SAMString - Ataraxia.osz", + @"2067270 Blue Stahli - The Fall.osz", + @"2075039 garlagan - Skyless.osz", + @"2079089 Hamu feat. yuiko - Innocent Letter.osz", + @"2082895 FATE GEAR - Heart's Grave.osz", + @"2085974 HoneyComeBear - Twilight.osz", + @"2094934 F.O.O.L & Laura Brehm - Waking Up.osz", + @"2097481 Mameyudoufu - Wave feat. Aitsuki Nakuru.osz", + @"2106075 MYUKKE. - The 89's Momentum.osz", + @"2117392 t+pazolite & Komiya Mao - Elustametat.osz", + @"2123533 LeaF - Calamity Fortune.osz", + @"2143876 Alkome - Your Voice.osz", + @"2145826 Sephid - Cross-D Skyline.osz", + @"2153172 Emiru no Aishita Tsukiyo ni Dai San Gensou Kyoku wo - Eternal Bliss.osz", }; private static readonly string[] bundled_taiko = { - "707824 Fractal Dreamers - Fortuna Redux.osz", - "789553 Cranky - Ran.osz", - "827822 Function Phantom - Neuronecia.osz", - "847323 Nakanojojo - Bittersweet (feat. Kuishinboakachan a.k.a Kiato).osz", - "847433 Trial & Error - Tokoyami no keiyaku KEGARETA-SHOUJO feat. GUMI.osz", - "847576 dark cat - hot chocolate.osz", - "847957 Wisp X - Final Moments.osz", - "876282 VINXIS - Greetings.osz", - "876648 Thaehan - Angry Birds Epic (Remix).osz", - "877069 IAHN - Transform (Original Mix).osz", - "877496 Thaehan - Leprechaun.osz", - "877935 Thaehan - Overpowered.osz", - "878344 yuki. - Be Your Light.osz", - "918446 VINXIS - Facade.osz", - "918903 LukHash - Ghosts.osz", - "919251 *namirin - Hitokoto no Kyori.osz", - "919704 S3RL - I Will Pick You Up (feat. Tamika).osz", - "921535 SOOOO - Raven Haven.osz", - "927206 *namirin - Kanzen Shouri*Esper Girl.osz", - "927544 Camellia feat. Nanahira - Kansoku Eisei.osz", - "930806 Nakanojojo - Pararara (feat. Amekoya).osz", - "931741 Camellia - Quaoar.osz", - "935699 Rin - Mythic set ~ Heart-Stirring Urban Legends.osz", - "935732 Thaehan - Yuujou.osz", - "941145 Function Phantom - Euclid.osz", - "942334 Dictate - Cauldron.osz", - "946540 nanobii - astral blast.osz", - "948844 Rin - Kishinjou set 01 ~ Mist Lake.osz", - "949122 Wisp X - Petal.osz", - "951618 Rin - Kishinjou set 02 ~ Mermaid from the Uncharted Land.osz", - "957412 Rin - Lunatic set 16 ~ The Space Shrine Maiden Returns Home.osz", - "961335 Thaehan - Insert Coin.osz", - "965178 The Flashbulb - DIDJ PVC.osz", - "966087 The Flashbulb - Creep.osz", - "966277 The Flashbulb - Amen Iraq.osz", - "966407 LukHash - ROOM 12.osz", - "966451 The Flashbulb - Six Acid Strings.osz", - "972301 BilliumMoto - four veiled stars.osz", - "973173 nanobii - popsicle beach.osz", - "973954 BilliumMoto - Rocky Buinne (Short Ver.).osz", - "975435 BilliumMoto - life flashes before weeb eyes.osz", - "978759 L. V. Beethoven - Moonlight Sonata (Cranky Remix).osz", - "982559 BilliumMoto - HDHR.osz", - "984361 The Flashbulb - Ninedump.osz", - "1023681 Inferi - The Ruin of Mankind.osz", - "1034358 ALEPH - The Evil Spirit.osz", - "1037567 ALEPH - Scintillations.osz", + "1048153 Chroma - [@__@].osz", + "1229307 Venetian Snares - Shaky Sometimes.osz", + "1236083 meganeko - Sirius A (osu! edit).osz", + "1248594 Noisia - Anomaly.osz", + "1272851 siqlo - One Way Street.osz", + "1290736 Kola Kid - good old times.osz", + "1318825 SECONDWALL - Light.osz", + "1320872 MYUKKE. - The 89's Momentum.osz", + "1337389 cute girls doing cute things - Main Heroine.osz", + "1397782 Reku Mochizuki - Yorixiro.osz", + "1407228 II-L - VANGUARD-1.osz", + "1422686 II-L - VANGUARD-2.osz", + "1429217 Street - Phi.osz", + "1442235 2ToneDisco x Cosmicosmo - Shoelaces (feat. Puniden).osz", + "1447478 Cres. - End Time.osz", + "1449942 m108 - Crescent Sakura.osz", + "1463778 MuryokuP - A tree without a branch.osz", + "1465152 fiend - Fever Dream (feat. yzzyx).osz", + "1472397 MYUKKE. - Boudica.osz", + "1488148 Aoi vs. siqlo - Hacktivism.osz", + "1522733 wotoha - Digital Life Hacker.osz", + "1540010 Marmalade butcher - Floccinaucinihilipilification.osz", + "1584690 MYUKKE. - AKKERA-COUNTRY-BOY.osz", + "1608857 BLOOD STAIN CHILD - S.O.P.H.I.A.osz", + "1609365 Reku Mochizuki - Faith of Eastward.osz", + "1622545 METAROOM - I - DINKI THE STARGUIDE.osz", + "1629336 METAROOM - PINK ORIGINS.osz", + "1644680 Neko Hacker - Pictures feat. 4s4ki.osz", + "1650835 RiraN - Ready For The Madness.osz", + "1661508 PTB10 - Starfall.osz", + "1671987 xi - World Fragments II.osz", + "1703065 tokiwa - wasurena feat. Sennzai.osz", + "1703527 tokiwa feat. Nakamura Sanso - Kotodama Refrain.osz", + "1704340 A-One feat. Shihori - Magic Girl !!.osz", + "1712783 xi - Parousia.osz", + "1718774 Harumaki Gohan - Suisei ni Nareta nara.osz", + "1719687 EmoCosine - Love Kills U.osz", + "1733940 WHITEFISTS feat. Sennzai - Paralyzed Ash.osz", + "1734692 EmoCosine - Cutter.osz", + "1739529 luvlxckdown - tbh i dont like being social.osz", + "1756970 Kurubukko vs. yukitani - Minamichita EVOLVED.osz", + "1762209 Marmalade butcher - Immortality Math Club.osz", + "1765720 ZxNX - FORTALiCE.osz", + "1786165 NILFRUITS - Arandano.osz", + "1787258 SAMString - Night Fighter.osz", + "1791462 ZxNX - Schadenfreude.osz", + "1793821 Kobaryo - The Lightning Sword.osz", + "1796440 kuru x miraie - re:start.osz", + "1799285 Origami Angel - 666 Flags.osz", + "1812415 nanobii - Rainbow Road.osz", + "1814682 NIWASHI - Y.osz", + "1818361 meganeko - Feral (osu! edit).osz", + "1818924 fiend - Disconnect.osz", + "1838730 Pegboard Nerds - Disconnected.osz", + "1854710 Blaster & Extra Terra - Spacecraft (Cut Ver.).osz", + "1859322 Hino Isuka - Delightness Brightness.osz", + "1884102 Maduk - Go (feat. Lachi) (Cut Ver.).osz", + "1884578 Neko Hacker - People People feat. Nanahira.osz", + "1897902 uma vs. Morimori Atsushi - Re: End of a Dream.osz", + "1905582 KINEMA106 - Fly Away (Cut Ver.).osz", + "1934686 ARForest - Rainbow Magic!!.osz", + "1963076 METAROOM - S.N.U.F.F.Y.osz", + "1968973 Stars Hollow - Out the Sunroof..osz", + "1971951 James Landino - Shiba Paradise.osz", + "1972518 Toromaru - Sleight of Hand.osz", + "1982302 KINEMA106 - INVITE.osz", + "1983475 KNOWER - The Government Knows.osz", + "2010165 Junk - Yellow Smile (bms edit).osz", + "2022737 Andora - Euphoria (feat. WaMi).osz", + "2025023 tephe - Genjitsu Escape.osz", + "2052754 P4koo - 8th:Planet ~Re:search~.osz", + "2054122 Raimukun - Myths Orbis.osz", + "2121470 Raimukun - Nyarlathotep's Dreamland.osz", + "2122284 Agressor Bunx - Tornado (Cut Ver.).osz", + "2125034 Agressor Bunx - Acid Mirage (Cut Ver.).osz", + "2136263 Se-U-Ra - Cris Fortress.osz", }; private static readonly string[] bundled_catch = { - "554256 Helblinde - When Time Sleeps.osz", - "693123 yuki. - Nadeshiko Sensation.osz", - "767009 OISHII - PIZZA PLAZA.osz", - "767346 Thaehan - Bwa !.osz", - "815162 VINXIS - Greetings.osz", - "840964 cYsmix - Breeze.osz", - "932657 Wisp X - Eventide.osz", - "933700 onumi - CONFUSION PART ONE.osz", - "933984 onumi - PERSONALITY.osz", - "934785 onumi - FAKE.osz", - "936545 onumi - REGRET PART ONE.osz", - "943803 Fractal Dreamers - Everything for a Dream.osz", - "943876 S3RL - I Will Pick You Up (feat. Tamika).osz", - "946773 Trial & Error - DREAMING COLOR (Short Ver.).osz", - "955808 Trial & Error - Tokoyami no keiyaku KEGARETA-SHOUJO feat. GUMI (Short Ver.).osz", - "957808 Fractal Dreamers - Module_410.osz", - "957842 antiPLUR - One Life Left to Live.osz", - "965730 The Flashbulb - Lawn Wake IV (Black).osz", - "966240 Creo - Challenger.osz", - "968232 Rin - Lunatic set 15 ~ The Moon as Seen from the Shrine.osz", - "972302 VINXIS - A Centralized View.osz", - "972887 HyuN - Illusion of Inflict.osz", - "1008600 LukHash - WHEN AN ANGEL DIES.osz", - "1032103 LukHash - H8 U.osz", + @"693123 yuki. - Nadeshiko Sensation.osz", + @"833719 FOLiACETATE - Heterochromia Iridis.osz", + @"981762 siromaru + cranky - conflict.osz", + @"1008600 LukHash - WHEN AN ANGEL DIES.osz", + @"1071294 dark cat - pursuit of happiness.osz", + @"1102115 meganeko - Nova.osz", + @"1115500 Chopin - Etude Op. 25, No. 12 (meganeko Remix).osz", + @"1128274 LeaF - Wizdomiot.osz", + @"1141049 HyuN feat. JeeE - Fallen Angel.osz", + @"1148215 Zekk - Fluctuation.osz", + @"1151833 ginkiha - nightfall.osz", + @"1158124 PUP - Dark Days.osz", + @"1184890 IAHN - Transform (Original Mix).osz", + @"1195922 Disasterpeace - Home.osz", + @"1197461 MIMI - Nanimo nai Youna.osz", + @"1197924 Camellia feat. Nanahira - Looking For A New Adventure.osz", + @"1203594 ginkiha - Anemoi.osz", + @"1211572 MIMI - Lapis Lazuli.osz", + @"1231601 Lime - Harmony.osz", + @"1240162 P4koo - 8th:Planet ~Re:search~.osz", + @"1246000 Zekk - Calling.osz", + @"1249928 Thaehan - Yuujou.osz", + @"1258751 Umeboshi Chazuke - ICHIBANBOSHI*ROCKET.osz", + @"1264818 Umeboshi Chazuke - Panic! Pop'n! Picnic! (2019 REMASTER).osz", + @"1280183 IAHN - Mad Halloween.osz", + @"1303201 Umeboshi Chazuke - Run*2 Run To You!!.osz", + @"1328918 Kobaryo - Theme for Psychopath Justice.osz", + @"1338215 Lime - Renai Syndrome.osz", + @"1338796 uma vs. Morimori Atsushi - Re:End of a Dream.osz", + @"1340492 MYUKKE. - The 89's Momentum.osz", + @"1393933 Mastermind (xi+nora2r) - Dreadnought.osz", + @"1400205 m108 - XIII Charlotte.osz", + @"1471328 Lime - Chronomia.osz", + @"1503591 Origami Angel - The Title Track.osz", + @"1524173 litmus* as Ester - Krave.osz", + @"1541235 Getty vs. DJ DiA - Grayed Out -Antifront-.osz", + @"1554250 Shawn Wasabi - Otter Pop (feat. Hollis).osz", + @"1583461 Sound Souler - Absent Color.osz", + @"1638487 tokiwa - wasurena feat. Sennzai.osz", + @"1698949 ZxNX - Schadenfreude.osz", + @"1704324 xi - Time files.osz", + @"1756405 Fractal Dreamers - Kingdom of Silence.osz", + @"1769575 cYsmix - Peer Gynt.osz", + @"1770054 Ardolf - Split.osz", + @"1772648 in love with a ghost - interdimensional portal leading to a cute place feat. snail's house.osz", + @"1776379 in love with a ghost - i thought we were lovers w/ basil.osz", + @"1779476 URBANGARDE - KIMI WA OKUMAGASO.osz", + @"1789435 xi - Parousia.osz", + @"1794190 Se-U-Ra - The Endless for Traveler.osz", + @"1799889 Waterflame - Ricochet Love.osz", + @"1816401 Gram vs. Yooh - Apocalypse.osz", + @"1826327 -45 - Total Eclipse of The Sun.osz", + @"1830796 xi - Halcyon.osz", + @"1924231 Mili - Nine Point Eight.osz", + @"1952903 Cres. - End Time.osz", + @"1970946 Good Kid - Slingshot.osz", + @"1982063 linear ring - enchanted love.osz", + @"2000438 Toromaru - Erinyes.osz", + @"2124277 II-L - VANGUARD-3.osz", + @"2147529 Nashimoto Ui - AaAaAaAAaAaAAa (Cut Ver.).osz", }; private static readonly string[] bundled_mania = { - "943516 antiPLUR - Clockwork Spooks.osz", - "946394 VINXIS - Three Times The Original Charm.osz", - "966408 antiPLUR - One Life Left to Live.osz", - "971561 antiPLUR - Runengon.osz", - "983864 James Landino - Shiba Island.osz", - "989512 BilliumMoto - 1xMISS.osz", - "994104 James Landino - Reaction feat. Slyleaf.osz", - "1003217 nekodex - circles!.osz", - "1009907 James Landino & Kabuki - Birdsong.osz", - "1015169 Thaehan - Insert Coin.osz", + @"1008419 BilliumMoto - Four Veiled Stars.osz", + @"1025170 Frums - We Want To Run.osz", + @"1092856 F-777 - Viking Arena.osz", + @"1139247 O2i3 - Heart Function.osz", + @"1154007 LeaF - ATHAZA.osz", + @"1170054 Zekk - Fallen.osz", + @"1212132 Street - Koiyamai (TV Size).osz", + @"1226466 Se-U-Ra - Elif to Shiro Kura no Yoru -Called-.osz", + @"1247210 Frums - Credits.osz", + @"1254196 ARForest - Regret.osz", + @"1258829 Umeboshi Chazuke - Cineraria.osz", + @"1300398 ARForest - The Last Page.osz", + @"1305627 Frums - Star of the COME ON!!.osz", + @"1348806 Se-U-Ra - LOA2.osz", + @"1375449 yuki. - Nadeshiko Sensation.osz", + @"1448292 Cres. - End Time.osz", + @"1479741 Reku Mochizuki - FORViDDEN ENERZY -Fataldoze-.osz", + @"1494747 Fractal Dreamers - Whispers from a Distant Star.osz", + @"1505336 litmus* - Rush-More.osz", + @"1508963 ARForest - Rainbow Magic!!.osz", + @"1727126 Chroma - Strange Inventor.osz", + @"1737101 ZxNX - FORTALiCE.osz", + @"1740952 Sobrem x Silentroom - Random.osz", + @"1756251 Plum - Mad Piano Party.osz", + @"1909163 Frums - theyaremanycolors.osz", + @"1916285 siromaru + cranky - conflict.osz", + @"1948972 Ardolf - Split.osz", + @"1957138 GLORYHAMMER - Rise Of The Chaos Wizards.osz", + @"1972411 James Landino - Shiba Paradise.osz", + @"1978179 Andora - Flicker (feat. RANASOL).osz", + @"1987180 cygnus - The Evolution of War.osz", + @"1994458 tephe - Genjitsu Escape.osz", + @"1999339 Aice room - Nyan Nyan Dive (EmoCosine Remix).osz", + @"2015361 HoneyComeBear - Rainy Girl.osz", + @"2028108 HyuN - Infinity Heaven.osz", + @"2055329 miraie & blackwinterwells - facade.osz", + @"2069877 Sephid - Thunderstrike 1988.osz", + @"2119716 Aethoro - Snowy.osz", + @"2120379 Synthion - VIVIDVELOCITY.osz", + @"2124805 Frums (unknown ""lambda"") - 19ZZ.osz", + @"2127811 Wiklund - Joy of Living (Cut Ver.).osz", }; } } diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardDownloadProgressBar.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardDownloadProgressBar.cs index 5ea42fe4b1..d21e8e7c76 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardDownloadProgressBar.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardDownloadProgressBar.cs @@ -22,8 +22,6 @@ namespace osu.Game.Beatmaps.Drawables.Cards public override bool IsPresent => true; - private readonly CircularContainer foreground; - private readonly Box backgroundFill; private readonly Box foregroundFill; @@ -35,22 +33,17 @@ namespace osu.Game.Beatmaps.Drawables.Cards public BeatmapCardDownloadProgressBar() { - InternalChildren = new Drawable[] + InternalChild = new CircularContainer { - new CircularContainer + RelativeSizeAxes = Axes.Both, + Masking = true, + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Masking = true, - Child = backgroundFill = new Box + backgroundFill = new Box { RelativeSizeAxes = Axes.Both, - } - }, - foreground = new CircularContainer - { - RelativeSizeAxes = Axes.Both, - Masking = true, - Child = foregroundFill = new Box + }, + foregroundFill = new Box { RelativeSizeAxes = Axes.Both, } @@ -89,7 +82,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards private void progressChanged() { - foreground.ResizeWidthTo((float)progress.Value, progress.Value > 0 ? BeatmapCard.TRANSITION_DURATION : 0, Easing.OutQuint); + foregroundFill.ResizeWidthTo((float)progress.Value, progress.Value > 0 ? BeatmapCard.TRANSITION_DURATION : 0, Easing.OutQuint); } } } diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtra.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtra.cs index 175c15ea7b..2c2761ff0c 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtra.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtra.cs @@ -61,7 +61,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - thumbnail = new BeatmapCardThumbnail(BeatmapSet) + thumbnail = new BeatmapCardThumbnail(BeatmapSet, BeatmapSet) { Name = @"Left (icon) area", Size = new Vector2(height), diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNormal.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNormal.cs index 18e1584a98..46ab7ec5f6 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNormal.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNormal.cs @@ -23,7 +23,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards protected override Drawable IdleContent => idleBottomContent; protected override Drawable DownloadInProgressContent => downloadProgressBar; - private const float height = 100; + public const float HEIGHT = 100; [Cached] private readonly BeatmapCardContent content; @@ -42,14 +42,14 @@ namespace osu.Game.Beatmaps.Drawables.Cards public BeatmapCardNormal(APIBeatmapSet beatmapSet, bool allowExpansion = true) : base(beatmapSet, allowExpansion) { - content = new BeatmapCardContent(height); + content = new BeatmapCardContent(HEIGHT); } [BackgroundDependencyLoader] private void load() { Width = WIDTH; - Height = height; + Height = HEIGHT; FillFlowContainer leftIconArea = null!; FillFlowContainer titleBadgeArea = null!; @@ -62,10 +62,10 @@ namespace osu.Game.Beatmaps.Drawables.Cards RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - thumbnail = new BeatmapCardThumbnail(BeatmapSet) + thumbnail = new BeatmapCardThumbnail(BeatmapSet, BeatmapSet) { Name = @"Left (icon) area", - Size = new Vector2(height), + Size = new Vector2(HEIGHT), Padding = new MarginPadding { Right = CORNER_RADIUS }, Child = leftIconArea = new FillFlowContainer { @@ -77,8 +77,8 @@ namespace osu.Game.Beatmaps.Drawables.Cards }, buttonContainer = new CollapsibleButtonContainer(BeatmapSet) { - X = height - CORNER_RADIUS, - Width = WIDTH - height + CORNER_RADIUS, + X = HEIGHT - CORNER_RADIUS, + Width = WIDTH - HEIGHT + CORNER_RADIUS, FavouriteState = { BindTarget = FavouriteState }, ButtonsCollapsedWidth = CORNER_RADIUS, ButtonsExpandedWidth = 30, diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardThumbnail.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardThumbnail.cs index cd498c474a..1f6f638618 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardThumbnail.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardThumbnail.cs @@ -8,7 +8,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Beatmaps.Drawables.Cards.Buttons; -using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Framework.Graphics.UserInterface; using osuTK; @@ -36,14 +35,14 @@ namespace osu.Game.Beatmaps.Drawables.Cards [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; - public BeatmapCardThumbnail(APIBeatmapSet beatmapSetInfo) + public BeatmapCardThumbnail(IBeatmapSetInfo beatmapSetInfo, IBeatmapSetOnlineInfo onlineInfo) { InternalChildren = new Drawable[] { new UpdateableOnlineBeatmapSetCover(BeatmapSetCoverType.List) { RelativeSizeAxes = Axes.Both, - OnlineInfo = beatmapSetInfo + OnlineInfo = onlineInfo }, background = new Box { @@ -62,7 +61,6 @@ namespace osu.Game.Beatmaps.Drawables.Cards { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Size = new Vector2(50), InnerRadius = 0.2f }, content = new Container @@ -92,7 +90,9 @@ namespace osu.Game.Beatmaps.Drawables.Cards protected override void Update() { base.Update(); + progress.Progress = playButton.Progress.Value; + progress.Size = new Vector2(50 * playButton.DrawWidth / (BeatmapCardNormal.HEIGHT - BeatmapCard.CORNER_RADIUS)); } private void updateState() @@ -100,7 +100,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards bool shouldDim = Dimmed.Value || playButton.Playing.Value; playButton.FadeTo(shouldDim ? 1 : 0, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint); - background.FadeColour(colourProvider.Background6.Opacity(shouldDim ? 0.8f : 0f), BeatmapCard.TRANSITION_DURATION, Easing.OutQuint); + background.FadeColour(colourProvider.Background6.Opacity(shouldDim ? 0.6f : 0f), BeatmapCard.TRANSITION_DURATION, Easing.OutQuint); } } } diff --git a/osu.Game/Beatmaps/Drawables/Cards/Buttons/PlayButton.cs b/osu.Game/Beatmaps/Drawables/Cards/Buttons/PlayButton.cs index f808fd21b7..f6caf4815d 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/Buttons/PlayButton.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/Buttons/PlayButton.cs @@ -79,6 +79,8 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Buttons { base.Update(); + icon.Scale = new Vector2(DrawWidth / (BeatmapCardNormal.HEIGHT - BeatmapCard.CORNER_RADIUS)); + if (Playing.Value && previewTrack != null && previewTrack.TrackLoaded) progress.Value = previewTrack.CurrentTime / previewTrack.Length; else diff --git a/osu.Game/Beatmaps/Drawables/DifficultyIconTooltip.cs b/osu.Game/Beatmaps/Drawables/DifficultyIconTooltip.cs index 1f3dcfee8c..8182fe24b2 100644 --- a/osu.Game/Beatmaps/Drawables/DifficultyIconTooltip.cs +++ b/osu.Game/Beatmaps/Drawables/DifficultyIconTooltip.cs @@ -9,10 +9,12 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; +using osu.Game.Extensions; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; +using osu.Game.Utils; using osuTK; namespace osu.Game.Beatmaps.Drawables @@ -124,12 +126,8 @@ namespace osu.Game.Beatmaps.Drawables miscFillFlowContainer.Show(); double rate = 1; - if (displayedContent.Mods != null) - { - foreach (var mod in displayedContent.Mods.OfType()) - rate = mod.ApplyToRate(0, rate); - } + rate = ModUtils.CalculateRateWithMods(displayedContent.Mods); double bpmAdjusted = displayedContent.BeatmapInfo.BPM * rate; @@ -149,7 +147,8 @@ namespace osu.Game.Beatmaps.Drawables approachRate.Text = @" AR: " + adjustedDifficulty.ApproachRate.ToString(@"0.##"); overallDifficulty.Text = @" OD: " + adjustedDifficulty.OverallDifficulty.ToString(@"0.##"); - length.Text = "Length: " + TimeSpan.FromMilliseconds(displayedContent.BeatmapInfo.Length / rate).ToString(@"mm\:ss"); + TimeSpan lengthTimeSpan = TimeSpan.FromMilliseconds(displayedContent.BeatmapInfo.Length / rate); + length.Text = "Length: " + lengthTimeSpan.ToFormattedDuration(); bpm.Text = " BPM: " + Math.Round(bpmAdjusted, 0); } diff --git a/osu.Game/Beatmaps/Drawables/UpdateableBeatmapBackgroundSprite.cs b/osu.Game/Beatmaps/Drawables/UpdateableBeatmapBackgroundSprite.cs index 0bb60847e5..6f71fa90b8 100644 --- a/osu.Game/Beatmaps/Drawables/UpdateableBeatmapBackgroundSprite.cs +++ b/osu.Game/Beatmaps/Drawables/UpdateableBeatmapBackgroundSprite.cs @@ -52,8 +52,11 @@ namespace osu.Game.Beatmaps.Drawables private Drawable getDrawableForModel(IBeatmapInfo? model) { + if (model == null) + return Empty(); + // prefer online cover where available. - if (model?.BeatmapSet is IBeatmapSetOnlineInfo online) + if (model.BeatmapSet is IBeatmapSetOnlineInfo online) return new OnlineBeatmapSetCover(online, beatmapSetCoverType); if (model is BeatmapInfo localModel) diff --git a/osu.Game/Beatmaps/Drawables/UpdateableOnlineBeatmapSetCover.cs b/osu.Game/Beatmaps/Drawables/UpdateableOnlineBeatmapSetCover.cs index 93b0dd5c15..5bce472613 100644 --- a/osu.Game/Beatmaps/Drawables/UpdateableOnlineBeatmapSetCover.cs +++ b/osu.Game/Beatmaps/Drawables/UpdateableOnlineBeatmapSetCover.cs @@ -27,8 +27,17 @@ namespace osu.Game.Beatmaps.Drawables set => base.Masking = value; } - public UpdateableOnlineBeatmapSetCover(BeatmapSetCoverType coverType = BeatmapSetCoverType.Cover) + protected override double LoadDelay { get; } + + private readonly double timeBeforeUnload; + + protected override double TransformDuration => 400; + + public UpdateableOnlineBeatmapSetCover(BeatmapSetCoverType coverType = BeatmapSetCoverType.Cover, double timeBeforeLoad = 500, double timeBeforeUnload = 1000) { + LoadDelay = timeBeforeLoad; + this.timeBeforeUnload = timeBeforeUnload; + this.coverType = coverType; InternalChild = new Box @@ -38,12 +47,12 @@ namespace osu.Game.Beatmaps.Drawables }; } - protected override double LoadDelay => 500; - - protected override double TransformDuration => 400; - protected override DelayedLoadWrapper CreateDelayedLoadWrapper(Func createContentFunc, double timeBeforeLoad) - => new DelayedLoadUnloadWrapper(createContentFunc, timeBeforeLoad); + => new DelayedLoadUnloadWrapper(createContentFunc, timeBeforeLoad, timeBeforeUnload) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; protected override Drawable CreateDrawable(IBeatmapSetOnlineInfo model) { diff --git a/osu.Game/Beatmaps/DummyWorkingBeatmap.cs b/osu.Game/Beatmaps/DummyWorkingBeatmap.cs index d254945a51..35067f4055 100644 --- a/osu.Game/Beatmaps/DummyWorkingBeatmap.cs +++ b/osu.Game/Beatmaps/DummyWorkingBeatmap.cs @@ -53,7 +53,7 @@ namespace osu.Game.Beatmaps protected override IBeatmap GetBeatmap() => new Beatmap(); - public override Texture GetBackground() => textures?.Get(@"Backgrounds/bg4"); + public override Texture GetBackground() => textures?.Get(@"Backgrounds/bg2"); protected override Track GetBeatmapTrack() => GetVirtualTrack(); diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs index 386dada328..3d8c8a6e7a 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; using osu.Framework.Extensions; -using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Logging; using osu.Game.Audio; using osu.Game.Beatmaps.ControlPoints; @@ -17,6 +16,7 @@ using osu.Game.Rulesets; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Legacy; using osu.Game.Rulesets.Objects.Types; +using osu.Game.Screens.Edit; namespace osu.Game.Beatmaps.Formats { @@ -33,13 +33,12 @@ namespace osu.Game.Beatmaps.Formats /// /// Compare: https://github.com/peppy/osu-stable-reference/blob/master/osu!/GameplayElements/HitObjects/HitObject.cs#L319 /// - private const double control_point_leniency = 5; + public const double CONTROL_POINT_LENIENCY = 5; internal static RulesetStore? RulesetStore; private Beatmap beatmap = null!; - - private ConvertHitObjectParser? parser; + private ConvertHitObjectParser parser = null!; private LegacySampleBank defaultSampleBank; private int defaultSampleVolume = 100; @@ -80,11 +79,14 @@ namespace osu.Game.Beatmaps.Formats { this.beatmap = beatmap; this.beatmap.BeatmapInfo.BeatmapVersion = FormatVersion; + parser = new ConvertHitObjectParser(getOffsetTime(), FormatVersion); applyLegacyDefaults(this.beatmap.BeatmapInfo); base.ParseStreamInto(stream, beatmap); + applyDifficultyRestrictions(beatmap.Difficulty, beatmap); + flushPendingPoints(); // Objects may be out of order *only* if a user has manually edited an .osu file. @@ -102,10 +104,30 @@ namespace osu.Game.Beatmaps.Formats } } + /// + /// Ensures that all settings are within the allowed ranges. + /// See also: https://github.com/peppy/osu-stable-reference/blob/0e425c0d525ef21353c8293c235cc0621d28338b/osu!/GameplayElements/Beatmaps/Beatmap.cs#L567-L614 + /// + private static void applyDifficultyRestrictions(BeatmapDifficulty difficulty, Beatmap beatmap) + { + difficulty.DrainRate = Math.Clamp(difficulty.DrainRate, 0, 10); + + // mania uses "circle size" for key count, thus different allowable range + difficulty.CircleSize = beatmap.BeatmapInfo.Ruleset.OnlineID != 3 + ? Math.Clamp(difficulty.CircleSize, 0, 10) + : Math.Clamp(difficulty.CircleSize, 1, 18); + + difficulty.OverallDifficulty = Math.Clamp(difficulty.OverallDifficulty, 0, 10); + difficulty.ApproachRate = Math.Clamp(difficulty.ApproachRate, 0, 10); + + difficulty.SliderMultiplier = Math.Clamp(difficulty.SliderMultiplier, 0.4, 3.6); + difficulty.SliderTickRate = Math.Clamp(difficulty.SliderTickRate, 0.5, 8); + } + /// /// Processes the beatmap such that a new combo is started the first hitobject following each break. /// - private void postProcessBreaks(Beatmap beatmap) + private static void postProcessBreaks(Beatmap beatmap) { int currentBreak = 0; bool forceNewCombo = false; @@ -138,20 +160,26 @@ namespace osu.Game.Beatmaps.Formats private void applySamples(HitObject hitObject) { - SampleControlPoint sampleControlPoint = (beatmap.ControlPointInfo as LegacyControlPointInfo)?.SamplePointAt(hitObject.GetEndTime() + control_point_leniency) ?? SampleControlPoint.DEFAULT; - - hitObject.Samples = hitObject.Samples.Select(o => sampleControlPoint.ApplyTo(o)).ToList(); - if (hitObject is IHasRepeats hasRepeats) { + SampleControlPoint sampleControlPoint = (beatmap.ControlPointInfo as LegacyControlPointInfo)?.SamplePointAt(hitObject.StartTime + CONTROL_POINT_LENIENCY + 1) + ?? SampleControlPoint.DEFAULT; + hitObject.Samples = hitObject.Samples.Select(o => sampleControlPoint.ApplyTo(o)).ToList(); + for (int i = 0; i < hasRepeats.NodeSamples.Count; i++) { - double time = hitObject.StartTime + i * hasRepeats.Duration / hasRepeats.SpanCount() + control_point_leniency; + double time = hitObject.StartTime + i * hasRepeats.Duration / hasRepeats.SpanCount() + CONTROL_POINT_LENIENCY; var nodeSamplePoint = (beatmap.ControlPointInfo as LegacyControlPointInfo)?.SamplePointAt(time) ?? SampleControlPoint.DEFAULT; hasRepeats.NodeSamples[i] = hasRepeats.NodeSamples[i].Select(o => nodeSamplePoint.ApplyTo(o)).ToList(); } } + else + { + SampleControlPoint sampleControlPoint = (beatmap.ControlPointInfo as LegacyControlPointInfo)?.SamplePointAt(hitObject.GetEndTime() + CONTROL_POINT_LENIENCY) + ?? SampleControlPoint.DEFAULT; + hitObject.Samples = hitObject.Samples.Select(o => sampleControlPoint.ApplyTo(o)).ToList(); + } } /// @@ -161,14 +189,12 @@ namespace osu.Game.Beatmaps.Formats /// This method's intention is to restore those legacy defaults. /// See also: https://osu.ppy.sh/wiki/en/Client/File_formats/Osu_%28file_format%29 /// - private void applyLegacyDefaults(BeatmapInfo beatmapInfo) + private static void applyLegacyDefaults(BeatmapInfo beatmapInfo) { beatmapInfo.WidescreenStoryboard = false; beatmapInfo.SamplesMatchPlaybackRate = false; } - protected override bool ShouldSkipLine(string line) => base.ShouldSkipLine(line) || line.StartsWith(' ') || line.StartsWith('_'); - protected override void ParseLine(Beatmap beatmap, Section section, string line) { switch (section) @@ -239,29 +265,7 @@ namespace osu.Game.Beatmaps.Formats break; case @"Mode": - int rulesetID = Parsing.ParseInt(pair.Value); - - beatmap.BeatmapInfo.Ruleset = RulesetStore?.GetRuleset(rulesetID) ?? throw new ArgumentException("Ruleset is not available locally."); - - switch (rulesetID) - { - case 0: - parser = new Rulesets.Objects.Legacy.Osu.ConvertHitObjectParser(getOffsetTime(), FormatVersion); - break; - - case 1: - parser = new Rulesets.Objects.Legacy.Taiko.ConvertHitObjectParser(getOffsetTime(), FormatVersion); - break; - - case 2: - parser = new Rulesets.Objects.Legacy.Catch.ConvertHitObjectParser(getOffsetTime(), FormatVersion); - break; - - case 3: - parser = new Rulesets.Objects.Legacy.Mania.ConvertHitObjectParser(getOffsetTime(), FormatVersion); - break; - } - + beatmap.BeatmapInfo.Ruleset = RulesetStore?.GetRuleset(Parsing.ParseInt(pair.Value)) ?? throw new ArgumentException("Ruleset is not available locally."); break; case @"LetterboxInBreaks": @@ -313,7 +317,7 @@ namespace osu.Game.Beatmaps.Formats break; case @"BeatDivisor": - beatmap.BeatmapInfo.BeatDivisor = Parsing.ParseInt(pair.Value); + beatmap.BeatmapInfo.BeatDivisor = Math.Clamp(Parsing.ParseInt(pair.Value), BindableBeatDivisor.MINIMUM_DIVISOR, BindableBeatDivisor.MAXIMUM_DIVISOR); break; case @"GridSize": @@ -404,11 +408,11 @@ namespace osu.Game.Beatmaps.Formats break; case @"SliderMultiplier": - difficulty.SliderMultiplier = Math.Clamp(Parsing.ParseDouble(pair.Value), 0.4, 3.6); + difficulty.SliderMultiplier = Parsing.ParseDouble(pair.Value); break; case @"SliderTickRate": - difficulty.SliderTickRate = Math.Clamp(Parsing.ParseDouble(pair.Value), 0.5, 8); + difficulty.SliderTickRate = Parsing.ParseDouble(pair.Value); break; } } @@ -417,43 +421,57 @@ namespace osu.Game.Beatmaps.Formats { string[] split = line.Split(','); - if (!Enum.TryParse(split[0], out LegacyEventType type)) - throw new InvalidDataException($@"Unknown event type: {split[0]}"); + // Until we have full storyboard encoder coverage, let's track any lines which aren't handled + // and store them to a temporary location such that they aren't lost on editor save / export. + bool lineSupportedByEncoder = false; - switch (type) + if (Enum.TryParse(split[0], out LegacyEventType type)) { - case LegacyEventType.Sprite: - // Generally, the background is the first thing defined in a beatmap file. - // In some older beatmaps, it is not present and replaced by a storyboard-level background instead. - // Allow the first sprite (by file order) to act as the background in such cases. - if (string.IsNullOrEmpty(beatmap.BeatmapInfo.Metadata.BackgroundFile)) - beatmap.BeatmapInfo.Metadata.BackgroundFile = CleanFilename(split[3]); - break; + switch (type) + { + case LegacyEventType.Sprite: + // Generally, the background is the first thing defined in a beatmap file. + // In some older beatmaps, it is not present and replaced by a storyboard-level background instead. + // Allow the first sprite (by file order) to act as the background in such cases. + if (string.IsNullOrEmpty(beatmap.BeatmapInfo.Metadata.BackgroundFile)) + { + beatmap.BeatmapInfo.Metadata.BackgroundFile = CleanFilename(split[3]); + lineSupportedByEncoder = true; + } - case LegacyEventType.Video: - string filename = CleanFilename(split[2]); + break; - // Some very old beatmaps had incorrect type specifications for their backgrounds (ie. using 1 for VIDEO - // instead of 0 for BACKGROUND). To handle this gracefully, check the file extension against known supported - // video extensions and handle similar to a background if it doesn't match. - if (!OsuGameBase.VIDEO_EXTENSIONS.Contains(Path.GetExtension(filename).ToLowerInvariant())) - { - beatmap.BeatmapInfo.Metadata.BackgroundFile = filename; - } + case LegacyEventType.Video: + string filename = CleanFilename(split[2]); - break; + // Some very old beatmaps had incorrect type specifications for their backgrounds (ie. using 1 for VIDEO + // instead of 0 for BACKGROUND). To handle this gracefully, check the file extension against known supported + // video extensions and handle similar to a background if it doesn't match. + if (!OsuGameBase.VIDEO_EXTENSIONS.Contains(Path.GetExtension(filename).ToLowerInvariant())) + { + beatmap.BeatmapInfo.Metadata.BackgroundFile = filename; + lineSupportedByEncoder = true; + } - case LegacyEventType.Background: - beatmap.BeatmapInfo.Metadata.BackgroundFile = CleanFilename(split[2]); - break; + break; - case LegacyEventType.Break: - double start = getOffsetTime(Parsing.ParseDouble(split[1])); - double end = Math.Max(start, getOffsetTime(Parsing.ParseDouble(split[2]))); + case LegacyEventType.Background: + beatmap.BeatmapInfo.Metadata.BackgroundFile = CleanFilename(split[2]); + lineSupportedByEncoder = true; + break; - beatmap.Breaks.Add(new BreakPeriod(start, end)); - break; + case LegacyEventType.Break: + double start = getOffsetTime(Parsing.ParseDouble(split[1])); + double end = Math.Max(start, getOffsetTime(Parsing.ParseDouble(split[2]))); + + beatmap.Breaks.Add(new BreakPeriod(start, end)); + lineSupportedByEncoder = true; + break; + } } + + if (!lineSupportedByEncoder) + beatmap.UnhandledEventLines.Add(line); } private void handleTimingPoint(string line) @@ -494,8 +512,8 @@ namespace osu.Game.Beatmaps.Formats if (split.Length >= 8) { LegacyEffectFlags effectFlags = (LegacyEffectFlags)Parsing.ParseInt(split[7]); - kiaiMode = effectFlags.HasFlagFast(LegacyEffectFlags.Kiai); - omitFirstBarSignature = effectFlags.HasFlagFast(LegacyEffectFlags.OmitFirstBarLine); + kiaiMode = effectFlags.HasFlag(LegacyEffectFlags.Kiai); + omitFirstBarSignature = effectFlags.HasFlag(LegacyEffectFlags.OmitFirstBarLine); } string stringSampleSet = sampleSet.ToString().ToLowerInvariant(); @@ -579,17 +597,10 @@ namespace osu.Game.Beatmaps.Formats private void handleHitObject(string line) { - // If the ruleset wasn't specified, assume the osu!standard ruleset. - parser ??= new Rulesets.Objects.Legacy.Osu.ConvertHitObjectParser(getOffsetTime(), FormatVersion); - var obj = parser.Parse(line); + obj.ApplyDefaults(beatmap.ControlPointInfo, beatmap.Difficulty); - if (obj != null) - { - obj.ApplyDefaults(beatmap.ControlPointInfo, beatmap.Difficulty); - - beatmap.HitObjects.Add(obj); - } + beatmap.HitObjects.Add(obj); } private int getOffsetTime(int time) => time + (ApplyOffsets ? offset : 0); diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs index 290d29090a..956d004602 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs @@ -156,6 +156,9 @@ namespace osu.Game.Beatmaps.Formats foreach (var b in beatmap.Breaks) writer.WriteLine(FormattableString.Invariant($"{(int)LegacyEventType.Break},{b.StartTime},{b.EndTime}")); + + foreach (string l in beatmap.UnhandledEventLines) + writer.WriteLine(l); } private void handleControlPoints(TextWriter writer) @@ -180,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(); @@ -279,19 +292,39 @@ namespace osu.Game.Beatmaps.Formats { foreach (var hitObject in hitObjects) { - if (hitObject.Samples.Count > 0) + if (hitObject is IHasRepeats hasNodeSamples) { - int volume = hitObject.Samples.Max(o => o.Volume); - int customIndex = hitObject.Samples.Any(o => o is ConvertHitObjectParser.LegacyHitSampleInfo) - ? hitObject.Samples.OfType().Max(o => o.CustomSampleBank) - : -1; + double spanDuration = hasNodeSamples.Duration / hasNodeSamples.SpanCount(); - yield return new LegacyBeatmapDecoder.LegacySampleControlPoint { Time = hitObject.GetEndTime(), SampleVolume = volume, CustomSampleBank = customIndex }; + for (int i = 0; i < hasNodeSamples.NodeSamples.Count; ++i) + { + double nodeTime = hitObject.StartTime + i * spanDuration; + + if (hasNodeSamples.NodeSamples[i].Count > 0) + yield return createSampleControlPointFor(nodeTime, hasNodeSamples.NodeSamples[i]); + + if (spanDuration > LegacyBeatmapDecoder.CONTROL_POINT_LENIENCY + 1 && hitObject.Samples.Count > 0 && i < hasNodeSamples.NodeSamples.Count - 1) + yield return createSampleControlPointFor(nodeTime + LegacyBeatmapDecoder.CONTROL_POINT_LENIENCY + 1, hitObject.Samples); + } + } + else if (hitObject.Samples.Count > 0) + { + yield return createSampleControlPointFor(hitObject.GetEndTime(), hitObject.Samples); } foreach (var nested in collectSampleControlPoints(hitObject.NestedHitObjects)) yield return nested; } + + SampleControlPoint createSampleControlPointFor(double time, IList samples) + { + int volume = samples.Max(o => o.Volume); + int customIndex = samples.Any(o => o is ConvertHitObjectParser.LegacyHitSampleInfo) + ? samples.OfType().Max(o => o.CustomSampleBank) + : -1; + + return new LegacyBeatmapDecoder.LegacySampleControlPoint { Time = time, SampleVolume = volume, CustomSampleBank = customIndex }; + } } void extractSampleControlPoints(IEnumerable hitObject) @@ -427,7 +460,7 @@ namespace osu.Game.Beatmaps.Formats // Explicit segments have a new format in which the type is injected into the middle of the control point string. // To preserve compatibility with osu-stable as much as possible, explicit segments with the same type are converted to use implicit segments by duplicating the control point. // One exception are consecutive perfect curves, which aren't supported in osu!stable and can lead to decoding issues if encoded as implicit segments - bool needsExplicitSegment = point.Type != lastType || point.Type == PathType.PERFECT_CURVE; + bool needsExplicitSegment = point.Type != lastType || point.Type == PathType.PERFECT_CURVE || i == pathData.Path.ControlPoints.Count - 1; // Another exception to this is when the last two control points of the last segment were duplicated. This is not a scenario supported by osu!stable. // Lazer does not add implicit segments for the last two control points of _any_ explicit segment, so an explicit segment is forced in order to maintain consistency with the decoder. @@ -516,7 +549,7 @@ namespace osu.Game.Beatmaps.Formats private string getSampleBank(IList samples, bool banksOnly = false) { LegacySampleBank normalBank = toLegacySampleBank(samples.SingleOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL)?.Bank); - LegacySampleBank addBank = toLegacySampleBank(samples.FirstOrDefault(s => !string.IsNullOrEmpty(s.Name) && s.Name != HitSampleInfo.HIT_NORMAL)?.Bank); + LegacySampleBank addBank = toLegacySampleBank(samples.FirstOrDefault(s => !string.IsNullOrEmpty(s.Name) && s.Name != HitSampleInfo.HIT_NORMAL && !s.EditorAutoBank)?.Bank); StringBuilder sb = new StringBuilder(); diff --git a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs index 93af9cf41c..ca4fadf458 100644 --- a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs @@ -18,6 +18,12 @@ namespace osu.Game.Beatmaps.Formats { public const int LATEST_VERSION = 14; + /// + /// The .osu format (beatmap) version. + /// + /// osu!stable's versions end at . + /// osu!lazer's versions starts at . + /// protected readonly int FormatVersion; protected LegacyDecoder(int version) @@ -93,14 +99,8 @@ namespace osu.Game.Beatmaps.Formats return line; } - protected void HandleColours(TModel output, string line, bool allowAlpha) + private Color4 convertSettingStringToColor4(string[] split, bool allowAlpha, KeyValuePair pair) { - var pair = SplitKeyVal(line); - - bool isCombo = pair.Key.StartsWith(@"Combo", StringComparison.Ordinal); - - string[] split = pair.Value.Split(','); - if (split.Length != 3 && split.Length != 4) throw new InvalidOperationException($@"Color specified in incorrect format (should be R,G,B or R,G,B,A): {pair.Value}"); @@ -116,6 +116,18 @@ namespace osu.Game.Beatmaps.Formats throw new InvalidOperationException(@"Color must be specified with 8-bit integer components"); } + return colour; + } + + protected void HandleColours(TModel output, string line, bool allowAlpha) + { + var pair = SplitKeyVal(line); + + string[] split = pair.Value.Split(','); + Color4 colour = convertSettingStringToColor4(split, allowAlpha, pair); + + bool isCombo = pair.Key.StartsWith(@"Combo", StringComparison.Ordinal); + if (isCombo) { if (!(output is IHasComboColours tHasComboColours)) return; diff --git a/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs index b5d9ad1194..2f9a256d31 100644 --- a/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics; using osu.Game.Beatmaps.Legacy; using osu.Game.IO; using osu.Game.Storyboards; +using osu.Game.Storyboards.Commands; using osuTK; using osuTK.Graphics; @@ -17,7 +18,7 @@ namespace osu.Game.Beatmaps.Formats public class LegacyStoryboardDecoder : LegacyDecoder { private StoryboardSprite? storyboardSprite; - private CommandTimelineGroup? timelineGroup; + private StoryboardCommandGroup? currentCommandsGroup; private Storyboard storyboard = null!; @@ -164,7 +165,7 @@ namespace osu.Game.Beatmaps.Formats else { if (depth < 2) - timelineGroup = storyboardSprite?.TimelineGroup; + currentCommandsGroup = storyboardSprite?.Commands; string commandType = split[0]; @@ -176,7 +177,7 @@ namespace osu.Game.Beatmaps.Formats double startTime = split.Length > 2 ? Parsing.ParseDouble(split[2]) : double.MinValue; double endTime = split.Length > 3 ? Parsing.ParseDouble(split[3]) : double.MaxValue; int groupNumber = split.Length > 4 ? Parsing.ParseInt(split[4]) : 0; - timelineGroup = storyboardSprite?.AddTrigger(triggerName, startTime, endTime, groupNumber); + currentCommandsGroup = storyboardSprite?.AddTriggerGroup(triggerName, startTime, endTime, groupNumber); break; } @@ -184,7 +185,7 @@ namespace osu.Game.Beatmaps.Formats { double startTime = Parsing.ParseDouble(split[1]); int repeatCount = Parsing.ParseInt(split[2]); - timelineGroup = storyboardSprite?.AddLoop(startTime, Math.Max(0, repeatCount - 1)); + currentCommandsGroup = storyboardSprite?.AddLoopingGroup(startTime, Math.Max(0, repeatCount - 1)); break; } @@ -203,7 +204,7 @@ namespace osu.Game.Beatmaps.Formats { float startValue = Parsing.ParseFloat(split[4]); float endValue = split.Length > 5 ? Parsing.ParseFloat(split[5]) : startValue; - timelineGroup?.Alpha.Add(easing, startTime, endTime, startValue, endValue); + currentCommandsGroup?.AddAlpha(easing, startTime, endTime, startValue, endValue); break; } @@ -211,7 +212,7 @@ namespace osu.Game.Beatmaps.Formats { float startValue = Parsing.ParseFloat(split[4]); float endValue = split.Length > 5 ? Parsing.ParseFloat(split[5]) : startValue; - timelineGroup?.Scale.Add(easing, startTime, endTime, startValue, endValue); + currentCommandsGroup?.AddScale(easing, startTime, endTime, startValue, endValue); break; } @@ -221,7 +222,7 @@ namespace osu.Game.Beatmaps.Formats float startY = Parsing.ParseFloat(split[5]); float endX = split.Length > 6 ? Parsing.ParseFloat(split[6]) : startX; float endY = split.Length > 7 ? Parsing.ParseFloat(split[7]) : startY; - timelineGroup?.VectorScale.Add(easing, startTime, endTime, new Vector2(startX, startY), new Vector2(endX, endY)); + currentCommandsGroup?.AddVectorScale(easing, startTime, endTime, new Vector2(startX, startY), new Vector2(endX, endY)); break; } @@ -229,7 +230,7 @@ namespace osu.Game.Beatmaps.Formats { float startValue = Parsing.ParseFloat(split[4]); float endValue = split.Length > 5 ? Parsing.ParseFloat(split[5]) : startValue; - timelineGroup?.Rotation.Add(easing, startTime, endTime, float.RadiansToDegrees(startValue), float.RadiansToDegrees(endValue)); + currentCommandsGroup?.AddRotation(easing, startTime, endTime, float.RadiansToDegrees(startValue), float.RadiansToDegrees(endValue)); break; } @@ -239,8 +240,8 @@ namespace osu.Game.Beatmaps.Formats float startY = Parsing.ParseFloat(split[5]); float endX = split.Length > 6 ? Parsing.ParseFloat(split[6]) : startX; float endY = split.Length > 7 ? Parsing.ParseFloat(split[7]) : startY; - timelineGroup?.X.Add(easing, startTime, endTime, startX, endX); - timelineGroup?.Y.Add(easing, startTime, endTime, startY, endY); + currentCommandsGroup?.AddX(easing, startTime, endTime, startX, endX); + currentCommandsGroup?.AddY(easing, startTime, endTime, startY, endY); break; } @@ -248,7 +249,7 @@ namespace osu.Game.Beatmaps.Formats { float startValue = Parsing.ParseFloat(split[4]); float endValue = split.Length > 5 ? Parsing.ParseFloat(split[5]) : startValue; - timelineGroup?.X.Add(easing, startTime, endTime, startValue, endValue); + currentCommandsGroup?.AddX(easing, startTime, endTime, startValue, endValue); break; } @@ -256,7 +257,7 @@ namespace osu.Game.Beatmaps.Formats { float startValue = Parsing.ParseFloat(split[4]); float endValue = split.Length > 5 ? Parsing.ParseFloat(split[5]) : startValue; - timelineGroup?.Y.Add(easing, startTime, endTime, startValue, endValue); + currentCommandsGroup?.AddY(easing, startTime, endTime, startValue, endValue); break; } @@ -268,7 +269,7 @@ namespace osu.Game.Beatmaps.Formats float endRed = split.Length > 7 ? Parsing.ParseFloat(split[7]) : startRed; float endGreen = split.Length > 8 ? Parsing.ParseFloat(split[8]) : startGreen; float endBlue = split.Length > 9 ? Parsing.ParseFloat(split[9]) : startBlue; - timelineGroup?.Colour.Add(easing, startTime, endTime, + currentCommandsGroup?.AddColour(easing, startTime, endTime, new Color4(startRed / 255f, startGreen / 255f, startBlue / 255f, 1), new Color4(endRed / 255f, endGreen / 255f, endBlue / 255f, 1)); break; @@ -281,16 +282,16 @@ namespace osu.Game.Beatmaps.Formats switch (type) { case "A": - timelineGroup?.BlendingParameters.Add(easing, startTime, endTime, BlendingParameters.Additive, + currentCommandsGroup?.AddBlendingParameters(easing, startTime, endTime, BlendingParameters.Additive, startTime == endTime ? BlendingParameters.Additive : BlendingParameters.Inherit); break; case "H": - timelineGroup?.FlipH.Add(easing, startTime, endTime, true, startTime == endTime); + currentCommandsGroup?.AddFlipH(easing, startTime, endTime, true, startTime == endTime); break; case "V": - timelineGroup?.FlipV.Add(easing, startTime, endTime, true, startTime == endTime); + currentCommandsGroup?.AddFlipV(easing, startTime, endTime, true, startTime == endTime); break; } diff --git a/osu.Game/Beatmaps/IBeatmap.cs b/osu.Game/Beatmaps/IBeatmap.cs index 6fe494ca0f..430a31769b 100644 --- a/osu.Game/Beatmaps/IBeatmap.cs +++ b/osu.Game/Beatmaps/IBeatmap.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using osu.Framework.Lists; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Timing; using osu.Game.Rulesets.Objects; @@ -40,7 +41,13 @@ namespace osu.Game.Beatmaps /// /// The breaks in this beatmap. /// - List Breaks { get; } + SortedList Breaks { get; set; } + + /// + /// All lines from the [Events] section which aren't handled in the encoding process yet. + /// These lines should be written out to the beatmap file on save or export. + /// + List UnhandledEventLines { get; } /// /// Total amount of break time in the beatmap. diff --git a/osu.Game/Beatmaps/IBeatmapUpdater.cs b/osu.Game/Beatmaps/IBeatmapUpdater.cs new file mode 100644 index 0000000000..062984adf0 --- /dev/null +++ b/osu.Game/Beatmaps/IBeatmapUpdater.cs @@ -0,0 +1,35 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Database; + +namespace osu.Game.Beatmaps +{ + /// + /// Handles all processing required to ensure a local beatmap is in a consistent state with any changes. + /// + public interface IBeatmapUpdater : IDisposable + { + /// + /// Queue a beatmap for background processing. + /// + /// The managed beatmap set to update. A transaction will be opened to apply changes. + /// The preferred scope to use for metadata lookup. + void Queue(Live beatmapSet, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst); + + /// + /// Run all processing on a beatmap immediately. + /// + /// The managed beatmap set to update. A transaction will be opened to apply changes. + /// The preferred scope to use for metadata lookup. + void Process(BeatmapSetInfo beatmapSet, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst); + + /// + /// Runs a subset of processing focused on updating any cached beatmap object counts. + /// + /// The managed beatmap to update. A transaction will be opened to apply changes. + /// The preferred scope to use for metadata lookup. + void ProcessObjectCounts(BeatmapInfo beatmapInfo, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst); + } +} diff --git a/osu.Game/Beatmaps/Legacy/LegacyHitObjectType.cs b/osu.Game/Beatmaps/Legacy/LegacyHitObjectType.cs index 07f170f996..6fab66bf70 100644 --- a/osu.Game/Beatmaps/Legacy/LegacyHitObjectType.cs +++ b/osu.Game/Beatmaps/Legacy/LegacyHitObjectType.cs @@ -6,7 +6,7 @@ using System; namespace osu.Game.Beatmaps.Legacy { [Flags] - internal enum LegacyHitObjectType + public enum LegacyHitObjectType { Circle = 1, Slider = 1 << 1, diff --git a/osu.Game/Beatmaps/LocalCachedBeatmapMetadataSource.cs b/osu.Game/Beatmaps/LocalCachedBeatmapMetadataSource.cs index 3f93c32283..66fad6c8d8 100644 --- a/osu.Game/Beatmaps/LocalCachedBeatmapMetadataSource.cs +++ b/osu.Game/Beatmaps/LocalCachedBeatmapMetadataSource.cs @@ -3,6 +3,7 @@ using System; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Threading.Tasks; using Microsoft.Data.Sqlite; @@ -44,19 +45,44 @@ namespace osu.Game.Beatmaps this.storage = storage; - // avoid downloading / using cache for unit tests. - if (!DebugUtils.IsNUnitRunning && !storage.Exists(cache_database_name)) + if (shouldFetchCache()) prepareLocalCache(); } + private bool shouldFetchCache() + { + // avoid downloading / using cache for unit tests. + if (DebugUtils.IsNUnitRunning) + return false; + + if (!storage.Exists(cache_database_name)) + { + log(@"Fetching local cache because it does not exist."); + return true; + } + + // periodically update the cache to include newer beatmaps. + var fileInfo = new FileInfo(storage.GetFullPath(cache_database_name)); + + if (fileInfo.LastWriteTime < DateTime.Now.AddMonths(-1)) + { + log($@"Refetching local cache because it was last written to on {fileInfo.LastWriteTime}."); + return true; + } + + return false; + } + public bool Available => // no download in progress. cacheDownloadRequest == null // cached database exists on disk. && storage.Exists(cache_database_name); - public bool TryLookup(BeatmapInfo beatmapInfo, out OnlineBeatmapMetadata? onlineMetadata) + public bool TryLookup(BeatmapInfo beatmapInfo, [NotNullWhen(true)] out OnlineBeatmapMetadata? onlineMetadata) { + Debug.Assert(beatmapInfo.BeatmapSet != null); + if (!Available) { onlineMetadata = null; @@ -64,50 +90,27 @@ namespace osu.Game.Beatmaps } if (string.IsNullOrEmpty(beatmapInfo.MD5Hash) - && string.IsNullOrEmpty(beatmapInfo.Path) - && beatmapInfo.OnlineID <= 0) + && string.IsNullOrEmpty(beatmapInfo.Path)) { onlineMetadata = null; return false; } - Debug.Assert(beatmapInfo.BeatmapSet != null); - try { - using (var db = new SqliteConnection(string.Concat(@"Data Source=", storage.GetFullPath(@"online.db", true)))) + using (var db = getConnection()) { db.Open(); - using (var cmd = db.CreateCommand()) + switch (getCacheVersion(db)) { - cmd.CommandText = - @"SELECT beatmapset_id, beatmap_id, approved, user_id, checksum, last_update FROM osu_beatmaps WHERE checksum = @MD5Hash OR beatmap_id = @OnlineID OR filename = @Path"; + case 1: + // will eventually become irrelevant due to the monthly recycling of local caches + // can be removed 20250221 + return queryCacheVersion1(db, beatmapInfo, out onlineMetadata); - cmd.Parameters.Add(new SqliteParameter(@"@MD5Hash", beatmapInfo.MD5Hash)); - cmd.Parameters.Add(new SqliteParameter(@"@OnlineID", beatmapInfo.OnlineID)); - cmd.Parameters.Add(new SqliteParameter(@"@Path", beatmapInfo.Path)); - - using (var reader = cmd.ExecuteReader()) - { - if (reader.Read()) - { - logForModel(beatmapInfo.BeatmapSet, $@"Cached local retrieval for {beatmapInfo}."); - - onlineMetadata = new OnlineBeatmapMetadata - { - BeatmapSetID = reader.GetInt32(0), - BeatmapID = reader.GetInt32(1), - BeatmapStatus = (BeatmapOnlineStatus)reader.GetByte(2), - BeatmapSetStatus = (BeatmapOnlineStatus)reader.GetByte(2), - AuthorID = reader.GetInt32(3), - MD5Hash = reader.GetString(4), - LastUpdated = reader.GetDateTimeOffset(5), - // TODO: DateSubmitted and DateRanked are not provided by local cache. - }; - return true; - } - } + case 2: + return queryCacheVersion2(db, beatmapInfo, out onlineMetadata); } } } @@ -122,8 +125,13 @@ namespace osu.Game.Beatmaps return false; } + private SqliteConnection getConnection() => + new SqliteConnection(string.Concat(@"Data Source=", storage.GetFullPath(@"online.db", true))); + private void prepareLocalCache() { + bool isRefetch = storage.Exists(cache_database_name); + string cacheFilePath = storage.GetFullPath(cache_database_name); string compressedCacheFilePath = $@"{cacheFilePath}.bz2"; @@ -132,9 +140,15 @@ namespace osu.Game.Beatmaps cacheDownloadRequest.Failed += ex => { File.Delete(compressedCacheFilePath); - File.Delete(cacheFilePath); - Logger.Log($@"{nameof(BeatmapUpdaterMetadataLookup)}'s online cache download failed: {ex}", LoggingTarget.Database); + // don't clobber the cache when refetching if the download didn't succeed. seems excessive. + // consequently, also null the download request to allow the existing cache to be used (see `Available`). + if (isRefetch) + cacheDownloadRequest = null; + else + File.Delete(cacheFilePath); + + log($@"Online cache download failed: {ex}"); }; cacheDownloadRequest.Finished += () => @@ -143,15 +157,22 @@ namespace osu.Game.Beatmaps { using (var stream = File.OpenRead(cacheDownloadRequest.Filename)) using (var outStream = File.OpenWrite(cacheFilePath)) - using (var bz2 = new BZip2Stream(stream, CompressionMode.Decompress, false)) - bz2.CopyTo(outStream); + { + // ensure to clobber any and all existing data to avoid accidental corruption. + outStream.SetLength(0); + + using (var bz2 = new BZip2Stream(stream, CompressionMode.Decompress, false)) + bz2.CopyTo(outStream); + } // set to null on completion to allow lookups to begin using the new source cacheDownloadRequest = null; + log(@"Local cache fetch completed successfully."); } catch (Exception ex) { - Logger.Log($@"{nameof(LocalCachedBeatmapMetadataSource)}'s online cache extraction failed: {ex}", LoggingTarget.Database); + log($@"Online cache extraction failed: {ex}"); + // at this point clobber the cache regardless of whether we're refetching, because by this point who knows what state the cache file is in. File.Delete(cacheFilePath); } finally @@ -173,6 +194,125 @@ namespace osu.Game.Beatmaps }); } + public int GetCacheVersion() + { + using (var connection = getConnection()) + { + connection.Open(); + return getCacheVersion(connection); + } + } + + private int getCacheVersion(SqliteConnection connection) + { + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = @"SELECT COUNT(1) FROM `sqlite_master` WHERE `type` = 'table' AND `name` = 'schema_version'"; + + using var reader = cmd.ExecuteReader(); + + if (!reader.Read()) + throw new InvalidOperationException("Error when attempting to check for existence of `schema_version` table."); + + // No versioning table means that this is the very first version of the schema. + if (reader.GetInt32(0) == 0) + return 1; + } + + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = @"SELECT `number` FROM `schema_version`"; + + using var reader = cmd.ExecuteReader(); + + if (!reader.Read()) + throw new InvalidOperationException("Error when attempting to query schema version."); + + return reader.GetInt32(0); + } + } + + private bool queryCacheVersion1(SqliteConnection db, BeatmapInfo beatmapInfo, out OnlineBeatmapMetadata? onlineMetadata) + { + Debug.Assert(beatmapInfo.BeatmapSet != null); + + using var cmd = db.CreateCommand(); + + cmd.CommandText = + @"SELECT beatmapset_id, beatmap_id, approved, user_id, checksum, last_update FROM osu_beatmaps WHERE checksum = @MD5Hash OR filename = @Path"; + + cmd.Parameters.Add(new SqliteParameter(@"@MD5Hash", beatmapInfo.MD5Hash)); + cmd.Parameters.Add(new SqliteParameter(@"@Path", beatmapInfo.Path)); + + using var reader = cmd.ExecuteReader(); + + if (reader.Read()) + { + logForModel(beatmapInfo.BeatmapSet, $@"Cached local retrieval for {beatmapInfo} (cache version 1)."); + + onlineMetadata = new OnlineBeatmapMetadata + { + BeatmapSetID = reader.GetInt32(0), + BeatmapID = reader.GetInt32(1), + BeatmapStatus = (BeatmapOnlineStatus)reader.GetByte(2), + BeatmapSetStatus = (BeatmapOnlineStatus)reader.GetByte(2), + AuthorID = reader.GetInt32(3), + MD5Hash = reader.GetString(4), + LastUpdated = reader.GetDateTimeOffset(5), + // TODO: DateSubmitted and DateRanked are not provided by local cache in this version. + }; + return true; + } + + onlineMetadata = null; + return false; + } + + private bool queryCacheVersion2(SqliteConnection db, BeatmapInfo beatmapInfo, out OnlineBeatmapMetadata? onlineMetadata) + { + Debug.Assert(beatmapInfo.BeatmapSet != null); + + using var cmd = db.CreateCommand(); + + cmd.CommandText = + """ + SELECT `b`.`beatmapset_id`, `b`.`beatmap_id`, `b`.`approved`, `b`.`user_id`, `b`.`checksum`, `b`.`last_update`, `s`.`submit_date`, `s`.`approved_date` + FROM `osu_beatmaps` AS `b` + JOIN `osu_beatmapsets` AS `s` ON `s`.`beatmapset_id` = `b`.`beatmapset_id` + WHERE `b`.`checksum` = @MD5Hash OR `b`.`filename` = @Path + """; + + cmd.Parameters.Add(new SqliteParameter(@"@MD5Hash", beatmapInfo.MD5Hash)); + cmd.Parameters.Add(new SqliteParameter(@"@Path", beatmapInfo.Path)); + + using var reader = cmd.ExecuteReader(); + + if (reader.Read()) + { + logForModel(beatmapInfo.BeatmapSet, $@"Cached local retrieval for {beatmapInfo} (cache version 2)."); + + onlineMetadata = new OnlineBeatmapMetadata + { + BeatmapSetID = reader.GetInt32(0), + BeatmapID = reader.GetInt32(1), + BeatmapStatus = (BeatmapOnlineStatus)reader.GetByte(2), + BeatmapSetStatus = (BeatmapOnlineStatus)reader.GetByte(2), + AuthorID = reader.GetInt32(3), + MD5Hash = reader.GetString(4), + LastUpdated = reader.GetDateTimeOffset(5), + DateSubmitted = reader.GetDateTimeOffset(6), + DateRanked = reader.GetDateTimeOffset(7), + }; + return true; + } + + onlineMetadata = null; + return false; + } + + private static void log(string message) + => Logger.Log($@"[{nameof(LocalCachedBeatmapMetadataSource)}] {message}", LoggingTarget.Database); + private void logForModel(BeatmapSetInfo set, string message) => RealmArchiveModelImporter.LogForModel(set, $@"[{nameof(LocalCachedBeatmapMetadataSource)}] {message}"); diff --git a/osu.Game/Beatmaps/StarDifficulty.cs b/osu.Game/Beatmaps/StarDifficulty.cs index 6aac275a6a..9f7a92fe46 100644 --- a/osu.Game/Beatmaps/StarDifficulty.cs +++ b/osu.Game/Beatmaps/StarDifficulty.cs @@ -1,9 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - -using JetBrains.Annotations; using osu.Framework.Utils; using osu.Game.Rulesets.Difficulty; @@ -25,30 +22,34 @@ namespace osu.Game.Beatmaps /// The difficulty attributes computed for the given beatmap. /// Might not be available if the star difficulty is associated with a beatmap that's not locally available. /// - [CanBeNull] - public readonly DifficultyAttributes Attributes; + public readonly DifficultyAttributes? DifficultyAttributes; /// - /// Creates a structure based on computed - /// by a . + /// The performance attributes computed for a perfect score on the given beatmap. + /// Might not be available if the star difficulty is associated with a beatmap that's not locally available. /// - public StarDifficulty([NotNull] DifficultyAttributes attributes) + public readonly PerformanceAttributes? PerformanceAttributes; + + /// + /// Creates a structure. + /// + public StarDifficulty(DifficultyAttributes difficulty, PerformanceAttributes performance) { - Stars = double.IsFinite(attributes.StarRating) ? attributes.StarRating : 0; - MaxCombo = attributes.MaxCombo; - Attributes = attributes; + Stars = double.IsFinite(difficulty.StarRating) ? difficulty.StarRating : 0; + MaxCombo = difficulty.MaxCombo; + DifficultyAttributes = difficulty; + PerformanceAttributes = performance; // Todo: Add more members (BeatmapInfo.DifficultyRating? Attributes? Etc...) } /// /// Creates a structure with a pre-populated star difficulty and max combo - /// in scenarios where computing is not feasible (i.e. when working with online sources). + /// in scenarios where computing is not feasible (i.e. when working with online sources). /// public StarDifficulty(double starDifficulty, int maxCombo) { Stars = double.IsFinite(starDifficulty) ? starDifficulty : 0; MaxCombo = maxCombo; - Attributes = null; } public DifficultyRating DifficultyRating => GetDifficultyRating(Stars); diff --git a/osu.Game/Beatmaps/Timing/BreakPeriod.cs b/osu.Game/Beatmaps/Timing/BreakPeriod.cs index 4c90b16745..921cfe9c51 100644 --- a/osu.Game/Beatmaps/Timing/BreakPeriod.cs +++ b/osu.Game/Beatmaps/Timing/BreakPeriod.cs @@ -1,26 +1,44 @@ // 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.Screens.Play; namespace osu.Game.Beatmaps.Timing { - public class BreakPeriod + public class BreakPeriod : IEquatable, IComparable { + /// + /// The minimum gap between the start of the break and the previous object. + /// + public const double GAP_BEFORE_BREAK = 200; + + /// + /// The minimum gap between the end of the break and the next object. + /// Based on osu! preempt time at AR=10. + /// See also: https://github.com/ppy/osu/issues/14330#issuecomment-1002158551 + /// + public const double GAP_AFTER_BREAK = 450; + /// /// The minimum duration required for a break to have any effect. /// public const double MIN_BREAK_DURATION = 650; + /// + /// The minimum required duration of a gap between two objects such that a break can be placed between them. + /// + public const double MIN_GAP_DURATION = GAP_BEFORE_BREAK + MIN_BREAK_DURATION + GAP_AFTER_BREAK; + /// /// The break start time. /// - public double StartTime; + public double StartTime { get; } /// /// The break end time. /// - public double EndTime; + public double EndTime { get; } /// /// The break duration. @@ -49,5 +67,26 @@ namespace osu.Game.Beatmaps.Timing /// The time to check in milliseconds. /// Whether the time falls within this . public bool Contains(double time) => time >= StartTime && time <= EndTime - BreakOverlay.BREAK_FADE_DURATION; + + public bool Intersects(BreakPeriod other) => StartTime <= other.EndTime && EndTime >= other.StartTime; + + public virtual bool Equals(BreakPeriod? other) => + other != null + && StartTime == other.StartTime + && EndTime == other.EndTime; + + public override int GetHashCode() => HashCode.Combine(StartTime, EndTime); + + public int CompareTo(BreakPeriod? other) + { + if (ReferenceEquals(this, other)) return 0; + if (ReferenceEquals(null, other)) return 1; + + int result = StartTime.CompareTo(other.StartTime); + if (result != 0) + return result; + + return EndTime.CompareTo(other.EndTime); + } } } diff --git a/osu.Game/Beatmaps/WorkingBeatmap.cs b/osu.Game/Beatmaps/WorkingBeatmap.cs index 25159996f3..07bf4c028a 100644 --- a/osu.Game/Beatmaps/WorkingBeatmap.cs +++ b/osu.Game/Beatmaps/WorkingBeatmap.cs @@ -183,7 +183,14 @@ namespace osu.Game.Beatmaps #region Beatmap - public virtual bool BeatmapLoaded => beatmapLoadTask?.IsCompleted ?? false; + public virtual bool BeatmapLoaded + { + get + { + lock (beatmapFetchLock) + return beatmapLoadTask?.IsCompleted ?? false; + } + } public IBeatmap Beatmap { diff --git a/osu.Game/Collections/CollectionDropdown.cs b/osu.Game/Collections/CollectionDropdown.cs index 15dd644073..1e47aff3ec 100644 --- a/osu.Game/Collections/CollectionDropdown.cs +++ b/osu.Game/Collections/CollectionDropdown.cs @@ -163,8 +163,8 @@ namespace osu.Game.Collections public CollectionDropdownHeader() { Height = 25; - Icon.Size = new Vector2(16); - Foreground.Padding = new MarginPadding { Top = 4, Bottom = 4, Left = 8, Right = 4 }; + Chevron.Size = new Vector2(12); + Foreground.Padding = new MarginPadding { Top = 4, Bottom = 4, Left = 8, Right = 8 }; } } @@ -202,7 +202,7 @@ namespace osu.Game.Collections [BackgroundDependencyLoader] private void load() { - AddInternal(addOrRemoveButton = new IconButton + AddInternal(addOrRemoveButton = new NoFocusChangeIconButton { Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, @@ -271,6 +271,11 @@ namespace osu.Game.Collections } protected override Drawable CreateContent() => (Content)base.CreateContent(); + + private partial class NoFocusChangeIconButton : IconButton + { + public override bool ChangeFocusOnClick => false; + } } } } diff --git a/osu.Game/Collections/DeleteCollectionDialog.cs b/osu.Game/Collections/DeleteCollectionDialog.cs index 9edc213077..80a7fa4bc0 100644 --- a/osu.Game/Collections/DeleteCollectionDialog.cs +++ b/osu.Game/Collections/DeleteCollectionDialog.cs @@ -8,7 +8,7 @@ using osu.Game.Overlays.Dialog; namespace osu.Game.Collections { - public partial class DeleteCollectionDialog : DangerousActionDialog + public partial class DeleteCollectionDialog : DeletionDialog { public DeleteCollectionDialog(Live collection, Action deleteAction) { diff --git a/osu.Game/Collections/DrawableCollectionList.cs b/osu.Game/Collections/DrawableCollectionList.cs index 6fe38a3229..164ec558a4 100644 --- a/osu.Game/Collections/DrawableCollectionList.cs +++ b/osu.Game/Collections/DrawableCollectionList.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; @@ -29,7 +30,11 @@ namespace osu.Game.Collections private IDisposable? realmSubscription; - protected override FillFlowContainer>> CreateListFillFlowContainer() => new Flow + private Flow flow = null!; + + public IEnumerable OrderedItems => flow.FlowingChildren; + + protected override FillFlowContainer>> CreateListFillFlowContainer() => flow = new Flow { DragActive = { BindTarget = DragActive } }; @@ -43,8 +48,25 @@ namespace osu.Game.Collections private void collectionsChanged(IRealmCollection collections, ChangeSet? changes) { - Items.Clear(); - Items.AddRange(collections.AsEnumerable().Select(c => c.ToLive(realm))); + if (changes == null) + { + Items.AddRange(collections.AsEnumerable().Select(c => c.ToLive(realm))); + return; + } + + foreach (int i in changes.DeletedIndices.OrderDescending()) + Items.RemoveAt(i); + + foreach (int i in changes.InsertedIndices) + Items.Insert(i, collections[i].ToLive(realm)); + + foreach (int i in changes.NewModifiedIndices) + { + var updatedItem = collections[i]; + + Items.RemoveAt(i); + Items.Insert(i, updatedItem.ToLive(realm)); + } } protected override OsuRearrangeableListItem> CreateOsuDrawable(Live item) @@ -123,12 +145,37 @@ namespace osu.Game.Collections var previous = PlaceholderItem; placeholderContainer.Clear(false); - placeholderContainer.Add(PlaceholderItem = new DrawableCollectionListItem(new BeatmapCollection().ToLiveUnmanaged(), false)); + placeholderContainer.Add(PlaceholderItem = new NewCollectionEntryItem()); return previous; } } + private partial class NewCollectionEntryItem : DrawableCollectionListItem + { + [Resolved] + private RealmAccess realm { get; set; } = null!; + + public NewCollectionEntryItem() + : base(new BeatmapCollection().ToLiveUnmanaged(), false) + { + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + TextBox.OnCommit += (sender, newText) => + { + if (string.IsNullOrEmpty(TextBox.Text)) + return; + + realm.Write(r => r.Add(new BeatmapCollection(TextBox.Text))); + TextBox.Text = string.Empty; + }; + } + } + /// /// The flow of . Disables layout easing unless a drag is in progress. /// diff --git a/osu.Game/Collections/DrawableCollectionListItem.cs b/osu.Game/Collections/DrawableCollectionListItem.cs index 596bb5d673..f07ec87353 100644 --- a/osu.Game/Collections/DrawableCollectionListItem.cs +++ b/osu.Game/Collections/DrawableCollectionListItem.cs @@ -28,6 +28,10 @@ namespace osu.Game.Collections private const float item_height = 35; private const float button_width = item_height * 0.75f; + protected TextBox TextBox => content.TextBox; + + private ItemContent content = null!; + /// /// Creates a new . /// @@ -43,21 +47,21 @@ namespace osu.Game.Collections // // if we want to support user sorting (but changes will need to be made to realm to persist). ShowDragHandle.Value = false; + + Masking = true; + CornerRadius = item_height / 2; } - protected override Drawable CreateContent() => new ItemContent(Model); + protected override Drawable CreateContent() => content = new ItemContent(Model); /// /// The main content of the . /// - private partial class ItemContent : CircularContainer + private partial class ItemContent : CompositeDrawable { private readonly Live collection; - private ItemTextBox textBox = null!; - - [Resolved] - private RealmAccess realm { get; set; } = null!; + public ItemTextBox TextBox { get; private set; } = null!; public ItemContent(Live collection) { @@ -65,20 +69,19 @@ namespace osu.Game.Collections RelativeSizeAxes = Axes.X; Height = item_height; - Masking = true; } [BackgroundDependencyLoader] private void load() { - Children = new[] + InternalChildren = new[] { collection.IsManaged ? new DeleteButton(collection) { Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, - IsTextBoxHovered = v => textBox.ReceivePositionalInputAt(v) + IsTextBoxHovered = v => TextBox.ReceivePositionalInputAt(v) } : Empty(), new Container @@ -87,7 +90,7 @@ namespace osu.Game.Collections Padding = new MarginPadding { Right = collection.IsManaged ? button_width : 0 }, Children = new Drawable[] { - textBox = new ItemTextBox + TextBox = new ItemTextBox { RelativeSizeAxes = Axes.Both, Size = Vector2.One, @@ -105,18 +108,14 @@ namespace osu.Game.Collections base.LoadComplete(); // Bind late, as the collection name may change externally while still loading. - textBox.Current.Value = collection.PerformRead(c => c.IsValid ? c.Name : string.Empty); - textBox.OnCommit += onCommit; + TextBox.Current.Value = collection.PerformRead(c => c.IsValid ? c.Name : string.Empty); + TextBox.OnCommit += onCommit; } private void onCommit(TextBox sender, bool newText) { - if (collection.IsManaged) - collection.PerformWrite(c => c.Name = textBox.Current.Value); - else if (!string.IsNullOrEmpty(textBox.Current.Value)) - realm.Write(r => r.Add(new BeatmapCollection(textBox.Current.Value))); - - textBox.Text = string.Empty; + if (collection.IsManaged && collection.Value.Name != TextBox.Current.Value) + collection.PerformWrite(c => c.Name = TextBox.Current.Value); } } @@ -132,7 +131,7 @@ namespace osu.Game.Collections } } - public partial class DeleteButton : CompositeDrawable + public partial class DeleteButton : OsuClickableContainer { public Func IsTextBoxHovered = null!; @@ -155,7 +154,7 @@ namespace osu.Game.Collections [BackgroundDependencyLoader] private void load(OsuColour colours) { - InternalChild = fadeContainer = new Container + Child = fadeContainer = new Container { RelativeSizeAxes = Axes.Both, Alpha = 0.1f, @@ -176,6 +175,14 @@ namespace osu.Game.Collections } } }; + + Action = () => + { + if (collection.PerformRead(c => c.BeatmapMD5Hashes.Count) == 0) + deleteCollection(); + else + dialogOverlay?.Push(new DeleteCollectionDialog(collection, deleteCollection)); + }; } public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => base.ReceivePositionalInputAt(screenSpacePos) && !IsTextBoxHovered(screenSpacePos); @@ -195,12 +202,7 @@ namespace osu.Game.Collections { background.FlashColour(Color4.White, 150); - if (collection.PerformRead(c => c.BeatmapMD5Hashes.Count) == 0) - deleteCollection(); - else - dialogOverlay?.Push(new DeleteCollectionDialog(collection, deleteCollection)); - - return true; + return base.OnClick(e); } private void deleteCollection() => collection.PerformWrite(c => c.Realm!.Remove(c)); diff --git a/osu.Game/Collections/ManageCollectionsDialog.cs b/osu.Game/Collections/ManageCollectionsDialog.cs index 16645d6796..9f8158af53 100644 --- a/osu.Game/Collections/ManageCollectionsDialog.cs +++ b/osu.Game/Collections/ManageCollectionsDialog.cs @@ -1,17 +1,17 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; -using osu.Framework.Audio; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; -using osu.Game.Audio.Effects; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; using osuTK; namespace osu.Game.Collections @@ -21,11 +21,14 @@ namespace osu.Game.Collections private const double enter_duration = 500; private const double exit_duration = 200; - private AudioFilter lowPassFilter = null!; - protected override string PopInSampleName => @"UI/overlay-big-pop-in"; protected override string PopOutSampleName => @"UI/overlay-big-pop-out"; + private IDisposable? duckOperation; + + [Resolved] + private MusicController? musicController { get; set; } + public ManageCollectionsDialog() { Anchor = Anchor.Centre; @@ -39,7 +42,7 @@ namespace osu.Game.Collections } [BackgroundDependencyLoader] - private void load(OsuColour colours, AudioManager audio) + private void load(OsuColour colours) { Children = new Drawable[] { @@ -110,19 +113,25 @@ namespace osu.Game.Collections }, } } - }, - lowPassFilter = new AudioFilter(audio.TrackMixer) + } }; } - public override bool IsPresent => base.IsPresent - // Safety for low pass filter potentially getting stuck in applied state due to - // transforms on `this` causing children to no longer be updated. - || lowPassFilter.IsAttached; + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + duckOperation?.Dispose(); + } protected override void PopIn() { - lowPassFilter.CutoffTo(300, 100, Easing.OutCubic); + duckOperation = musicController?.Duck(new DuckParameters + { + DuckVolumeTo = 1, + DuckDuration = 100, + RestoreDuration = 100, + }); + this.FadeIn(enter_duration, Easing.OutQuint); this.ScaleTo(0.9f).Then().ScaleTo(1f, enter_duration, Easing.OutQuint); } @@ -131,13 +140,13 @@ namespace osu.Game.Collections { base.PopOut(); - lowPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, 100, Easing.InCubic); + duckOperation?.Dispose(); this.FadeOut(exit_duration, Easing.OutQuint); this.ScaleTo(0.9f, exit_duration); // Ensure that textboxes commit - GetContainingInputManager()?.TriggerFocusContention(this); + GetContainingFocusManager()?.TriggerFocusContention(this); } } } diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index f4a4c553d8..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; @@ -67,13 +68,7 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.Username, string.Empty); SetDefault(OsuSetting.Token, string.Empty); -#pragma warning disable CS0618 // Type or member is obsolete - // this default set MUST remain despite the setting being deprecated, because `SetDefault()` calls are implicitly used to declare the type returned for the lookup. - // if this is removed, the setting will be interpreted as a string, and `Migrate()` will fail due to cast failure. - // can be removed 20240618 - SetDefault(OsuSetting.AutomaticallyDownloadWhenSpectating, false); -#pragma warning restore CS0618 // Type or member is obsolete - SetDefault(OsuSetting.AutomaticallyDownloadMissingBeatmaps, false); + SetDefault(OsuSetting.AutomaticallyDownloadMissingBeatmaps, true); SetDefault(OsuSetting.SavePassword, true).ValueChanged += enabled => { @@ -199,6 +194,11 @@ 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); SetDefault(OsuSetting.MultiplayerRoomFilter, RoomPermissionsFilter.All); @@ -206,6 +206,14 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.ComboColourNormalisationAmount, 0.2f, 0f, 1f, 0.01f); SetDefault(OsuSetting.UserOnlineStatus, null); + + SetDefault(OsuSetting.EditorTimelineShowTimingChanges, true); + SetDefault(OsuSetting.EditorTimelineShowBreaks, true); + SetDefault(OsuSetting.EditorTimelineShowTicks, true); + + SetDefault(OsuSetting.EditorContractSidebars, false); + + SetDefault(OsuSetting.AlwaysShowHoldForMenuButton, false); } protected override bool CheckLookupContainsPrivateInformation(OsuSetting lookup) @@ -239,12 +247,6 @@ namespace osu.Game.Configuration // migrations can be added here using a condition like: // if (combined < 20220103) { performMigration() } - if (combined < 20230918) - { -#pragma warning disable CS0618 // Type or member is obsolete - SetValue(OsuSetting.AutomaticallyDownloadMissingBeatmaps, Get(OsuSetting.AutomaticallyDownloadWhenSpectating)); // can be removed 20240618 -#pragma warning restore CS0618 // Type or member is obsolete - } } public override TrackedSettings CreateTrackedSettings() @@ -419,9 +421,6 @@ namespace osu.Game.Configuration EditorAutoSeekOnPlacement, DiscordRichPresence, - [Obsolete($"Use {nameof(AutomaticallyDownloadMissingBeatmaps)} instead.")] // can be removed 20240318 - AutomaticallyDownloadWhenSpectating, - ShowOnlineExplicitContent, LastProcessedMetadataId, SafeAreaConsiderations, @@ -435,6 +434,15 @@ namespace osu.Game.Configuration TouchDisableGameplayTaps, ModSelectTextSearchStartsActive, UserOnlineStatus, - MultiplayerRoomFilter + MultiplayerRoomFilter, + HideCountryFlags, + EditorTimelineShowTimingChanges, + EditorTimelineShowTicks, + AlwaysShowHoldForMenuButton, + EditorContractSidebars, + EditorScaleOrigin, + EditorRotationOrigin, + EditorTimelineShowBreaks, + EditorAdjustExistingObjectsOnTimingChanges, } } diff --git a/osu.Game/Configuration/SessionStatics.cs b/osu.Game/Configuration/SessionStatics.cs index 1548b781a7..225f209380 100644 --- a/osu.Game/Configuration/SessionStatics.cs +++ b/osu.Game/Configuration/SessionStatics.cs @@ -80,5 +80,11 @@ namespace osu.Game.Configuration /// Stores the local user's last score (can be completed or aborted). /// LastLocalUserScore, + + /// + /// Whether the intro animation for the daily challenge screen has been played once. + /// This is reset when a new challenge is up. + /// + DailyChallengeIntroPlayed, } } diff --git a/osu.Game/Configuration/SettingSourceAttribute.cs b/osu.Game/Configuration/SettingSourceAttribute.cs index 1e425c88a6..30cda4047e 100644 --- a/osu.Game/Configuration/SettingSourceAttribute.cs +++ b/osu.Game/Configuration/SettingSourceAttribute.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Reflection; using JetBrains.Annotations; @@ -15,6 +14,7 @@ using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Settings; +using osu.Game.Utils; namespace osu.Game.Configuration { @@ -186,6 +186,16 @@ namespace osu.Game.Configuration break; + case BindableColour4 bColour: + yield return new SettingsColour + { + LabelText = attr.Label, + TooltipText = attr.Description, + Current = bColour + }; + + break; + case IBindable bindable: var dropdownType = typeof(ModSettingsEnumDropdown<>).MakeGenericType(bindable.GetType().GetGenericArguments()[0]); var dropdown = (Drawable)Activator.CreateInstance(dropdownType)!; @@ -227,11 +237,11 @@ namespace osu.Game.Configuration case Bindable b: return b.Value; + case BindableColour4 c: + return c.Value.ToHex(); + case IBindable u: - // An unknown (e.g. enum) generic type. - var valueMethod = u.GetType().GetProperty(nameof(IBindable.Value)); - Debug.Assert(valueMethod != null); - return valueMethod.GetValue(u)!; + return BindableValueAccessor.GetValue(u); default: // fall back for non-bindable cases. diff --git a/osu.Game/Database/BackgroundDataStoreProcessor.cs b/osu.Game/Database/BackgroundDataStoreProcessor.cs index 7074c89b84..1512b6be93 100644 --- a/osu.Game/Database/BackgroundDataStoreProcessor.cs +++ b/osu.Game/Database/BackgroundDataStoreProcessor.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -11,6 +12,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Logging; +using osu.Framework.Platform; using osu.Game.Beatmaps; using osu.Game.Extensions; using osu.Game.Online.API; @@ -44,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!; @@ -61,6 +63,9 @@ namespace osu.Game.Database [Resolved] private IAPIProvider api { get; set; } = null!; + [Resolved] + private Storage storage { get; set; } = null!; + protected virtual int TimeToSleepDuringGameplay => 30000; protected override void LoadComplete() @@ -78,6 +83,7 @@ namespace osu.Game.Database processScoresWithMissingStatistics(); convertLegacyTotalScoreToStandardised(); upgradeScoreRanks(); + backpopulateMissingSubmissionAndRankDates(); }, TaskCreationOptions.LongRunning).ContinueWith(t => { if (t.Exception?.InnerException is ObjectDisposedException) @@ -389,7 +395,7 @@ namespace osu.Game.Database HashSet scoreIds = realmAccess.Run(r => new HashSet( r.All() - .Where(s => s.TotalScoreVersion < 30000013) // last total score version with a significant change to ranks + .Where(s => s.TotalScoreVersion < 30000013 && !s.BackgroundReprocessingFailed) // last total score version with a significant change to ranks .AsEnumerable() // must be done after materialisation, as realm doesn't support // filtering on nested property predicates or projection via `.Select()` @@ -443,6 +449,104 @@ namespace osu.Game.Database completeNotification(notification, processedCount, scoreIds.Count, failedCount); } + private void backpopulateMissingSubmissionAndRankDates() + { + var localMetadataSource = new LocalCachedBeatmapMetadataSource(storage); + + if (!localMetadataSource.Available) + { + Logger.Log("Cannot backpopulate missing submission/rank dates because the local metadata cache is missing."); + return; + } + + try + { + if (localMetadataSource.GetCacheVersion() < 2) + { + Logger.Log("Cannot backpopulate missing submission/rank dates because the local metadata cache is too old."); + return; + } + } + catch (Exception ex) + { + Logger.Log($"Error when trying to query version of local metadata cache: {ex}"); + return; + } + + Logger.Log("Querying for beatmap sets that contain missing submission/rank date..."); + + HashSet beatmapSetIds = realmAccess.Run(r => new HashSet( + r.All() + .Where(b => b.StatusInt > 0 && (b.DateRanked == null || b.DateSubmitted == null)) + .AsEnumerable() + .Select(b => b.ID))); + + if (beatmapSetIds.Count == 0) + return; + + Logger.Log($"Found {beatmapSetIds.Count} beatmap sets with missing submission/rank date."); + + var notification = showProgressNotification(beatmapSetIds.Count, "Populating missing submission and rank dates", "beatmap sets now have correct submission and rank dates."); + + int processedCount = 0; + int failedCount = 0; + + foreach (var id in beatmapSetIds) + { + if (notification?.State == ProgressNotificationState.Cancelled) + break; + + updateNotificationProgress(notification, processedCount, beatmapSetIds.Count); + + sleepIfRequired(); + + try + { + // Can't use async overload because we're not on the update thread. + // ReSharper disable once MethodHasAsyncOverload + bool succeeded = realmAccess.Write(r => + { + BeatmapSetInfo beatmapSet = r.Find(id)!; + + // we want any ranked representative of the set. + // the reason for checking ranked status of the difficulty is that it can be locally modified, + // at which point the lookup will fail - but there might still be another unmodified difficulty on which it will work. + if (beatmapSet.Beatmaps.FirstOrDefault(b => b.Status >= BeatmapOnlineStatus.Ranked) is not BeatmapInfo beatmap) + return false; + + bool lookupSucceeded = localMetadataSource.TryLookup(beatmap, out var result); + + if (lookupSucceeded) + { + Debug.Assert(result != null); + beatmapSet.DateRanked = result.DateRanked; + beatmapSet.DateSubmitted = result.DateSubmitted; + return true; + } + + Logger.Log($"Could not find {beatmapSet.GetDisplayString()} in local cache while backpopulating missing submission/rank date"); + return false; + }); + + if (succeeded) + ++processedCount; + else + ++failedCount; + } + catch (ObjectDisposedException) + { + throw; + } + catch (Exception e) + { + Logger.Log($"Failed to update ranked/submitted dates for beatmap set {id}: {e}"); + ++failedCount; + } + } + + completeNotification(notification, processedCount, beatmapSetIds.Count, failedCount); + } + private void updateNotificationProgress(ProgressNotification? notification, int processedCount, int totalCount) { if (notification == null) @@ -502,7 +606,7 @@ namespace osu.Game.Database { // Importantly, also sleep if high performance session is active. // If we don't do this, memory usage can become runaway due to GC running in a more lenient mode. - while (localUserPlayInfo?.IsPlaying.Value == true || highPerformanceSessionManager?.IsSessionActive == true) + while (localUserPlayInfo?.PlayingState.Value != LocalUserPlayingState.NotPlaying || highPerformanceSessionManager?.IsSessionActive == true) { Logger.Log("Background processing sleeping due to active gameplay..."); Thread.Sleep(TimeToSleepDuringGameplay); diff --git a/osu.Game/Database/BeatmapExporter.cs b/osu.Game/Database/BeatmapExporter.cs index f37c57dea5..01ef09d3d7 100644 --- a/osu.Game/Database/BeatmapExporter.cs +++ b/osu.Game/Database/BeatmapExporter.cs @@ -17,6 +17,8 @@ namespace osu.Game.Database { } + protected override bool UseFixedEncoding => false; + protected override string FileExtension => @".olz"; } } diff --git a/osu.Game/Database/DetachedBeatmapStore.cs b/osu.Game/Database/DetachedBeatmapStore.cs new file mode 100644 index 0000000000..5b65f608b2 --- /dev/null +++ b/osu.Game/Database/DetachedBeatmapStore.cs @@ -0,0 +1,163 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Online.Multiplayer; +using Realms; + +namespace osu.Game.Database +{ + public partial class DetachedBeatmapStore : Component + { + private readonly ManualResetEventSlim loaded = new ManualResetEventSlim(); + + private readonly BindableList detachedBeatmapSets = new BindableList(); + + private IDisposable? realmSubscription; + + private readonly Queue pendingOperations = new Queue(); + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + public IBindableList GetDetachedBeatmaps(CancellationToken? cancellationToken) + { + loaded.Wait(cancellationToken ?? CancellationToken.None); + return detachedBeatmapSets.GetBoundCopy(); + } + + [BackgroundDependencyLoader] + private void load() + { + realmSubscription = realm.RegisterForNotifications(r => r.All().Where(s => !s.DeletePending && !s.Protected), beatmapSetsChanged); + } + + private void beatmapSetsChanged(IRealmCollection sender, ChangeSet? changes) + { + if (changes == null) + { + if (sender is RealmResetEmptySet) + { + // Usually we'd reset stuff here, but doing so triggers a silly flow which ends up deadlocking realm. + // Additionally, user should not be at song select when realm is blocking all operations in the first place. + // + // Note that due to the catch-up logic below, once operations are restored we will still be in a roughly + // correct state. The only things that this return will change is the carousel will not empty *during* the blocking + // operation. + return; + } + + // Detaching beatmaps takes some time, so let's make sure it doesn't run on the update thread. + var frozenSets = sender.Freeze(); + + Task.Factory.StartNew(() => + { + try + { + realm.Run(_ => + { + var detached = frozenSets.Detach(); + + detachedBeatmapSets.Clear(); + detachedBeatmapSets.AddRange(detached); + }); + } + finally + { + loaded.Set(); + } + }, TaskCreationOptions.LongRunning).FireAndForget(); + + return; + } + + foreach (int i in changes.DeletedIndices.OrderDescending()) + { + pendingOperations.Enqueue(new OperationArgs + { + Type = OperationType.Remove, + Index = i, + }); + } + + foreach (int i in changes.InsertedIndices) + { + pendingOperations.Enqueue(new OperationArgs + { + Type = OperationType.Insert, + BeatmapSet = sender[i].Detach(), + Index = i, + }); + } + + foreach (int i in changes.NewModifiedIndices) + { + pendingOperations.Enqueue(new OperationArgs + { + Type = OperationType.Update, + BeatmapSet = sender[i].Detach(), + Index = i, + }); + } + } + + protected override void Update() + { + base.Update(); + + // We can't start processing operations until we have finished detaching the initial list. + if (!loaded.IsSet) + return; + + // If this ever leads to performance issues, we could dequeue a limited number of operations per update frame. + while (pendingOperations.TryDequeue(out var op)) + { + switch (op.Type) + { + case OperationType.Insert: + detachedBeatmapSets.Insert(op.Index, op.BeatmapSet!); + break; + + case OperationType.Update: + detachedBeatmapSets.ReplaceRange(op.Index, 1, new[] { op.BeatmapSet! }); + break; + + case OperationType.Remove: + detachedBeatmapSets.RemoveAt(op.Index); + break; + } + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + loaded.Set(); + loaded.Dispose(); + realmSubscription?.Dispose(); + } + + private record OperationArgs + { + public OperationType Type; + public BeatmapSetInfo? BeatmapSet; + public int Index; + } + + private enum OperationType + { + Insert, + Update, + Remove + } + } +} diff --git a/osu.Game/Database/ExternalEditOperation.cs b/osu.Game/Database/ExternalEditOperation.cs new file mode 100644 index 0000000000..a98d597b3c --- /dev/null +++ b/osu.Game/Database/ExternalEditOperation.cs @@ -0,0 +1,65 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.IO; +using System.Threading.Tasks; +using osu.Game.Overlays.Notifications; + +namespace osu.Game.Database +{ + /// + /// Contains information related to an active external edit operation. + /// + public class ExternalEditOperation where TModel : class, IHasGuidPrimaryKey + { + /// + /// The temporary path at which the model has been exported to for editing. + /// + public readonly string MountedPath; + + /// + /// Whether the model is still mounted at . + /// + public bool IsMounted { get; private set; } + + private readonly IModelImporter importer; + private readonly TModel original; + + public ExternalEditOperation(IModelImporter importer, TModel original, string path) + { + this.importer = importer; + this.original = original; + + MountedPath = path; + + IsMounted = true; + } + + /// + /// Finish the external edit operation. + /// + /// + /// This will trigger an asynchronous reimport of the model. + /// Subsequent calls will be a no-op. + /// + /// A task which will eventuate in the newly imported model with changes applied. + public async Task?> Finish() + { + if (!Directory.Exists(MountedPath) || !IsMounted) + return null; + + IsMounted = false; + + Live? imported = await importer.ImportAsUpdate(new ProgressNotification(), new ImportTask(MountedPath), original) + .ConfigureAwait(false); + + try + { + Directory.Delete(MountedPath, true); + } + catch { } + + return imported; + } + } +} diff --git a/osu.Game/Database/IModelImporter.cs b/osu.Game/Database/IModelImporter.cs index dcbbad0d35..ce1563f2df 100644 --- a/osu.Game/Database/IModelImporter.cs +++ b/osu.Game/Database/IModelImporter.cs @@ -34,6 +34,15 @@ namespace osu.Game.Database /// The imported model. Task?> ImportAsUpdate(ProgressNotification notification, ImportTask task, TModel original); + /// + /// Mount all files for a model to a temporary directory to allow for external editing. + /// + /// + /// When editing is completed, call Finish() on the returned operation class to begin the import-and-update process. + /// + /// The model to mount. + public Task> BeginExternalEditing(TModel model); + /// /// A user displayable name for the model type associated with this manager. /// diff --git a/osu.Game/Database/LegacyArchiveExporter.cs b/osu.Game/Database/LegacyArchiveExporter.cs index 9805207591..e4d3ed4681 100644 --- a/osu.Game/Database/LegacyArchiveExporter.cs +++ b/osu.Game/Database/LegacyArchiveExporter.cs @@ -3,10 +3,12 @@ using System.IO; using System.Linq; +using System.Text; using System.Threading; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Extensions; +using osu.Game.IO.Archives; using osu.Game.Overlays.Notifications; using Realms; using SharpCompress.Common; @@ -22,6 +24,11 @@ namespace osu.Game.Database public abstract class LegacyArchiveExporter : LegacyExporter where TModel : RealmObject, IHasNamedFiles, IHasGuidPrimaryKey { + /// + /// Whether to always use Shift-JIS encoding for archive filenames (like osu!stable did). + /// + protected virtual bool UseFixedEncoding => true; + protected LegacyArchiveExporter(Storage storage) : base(storage) { @@ -29,7 +36,12 @@ namespace osu.Game.Database public override void ExportToStream(TModel model, Stream outputStream, ProgressNotification? notification, CancellationToken cancellationToken = default) { - using (var writer = new ZipWriter(outputStream, new ZipWriterOptions(CompressionType.Deflate))) + var zipWriterOptions = new ZipWriterOptions(CompressionType.Deflate) + { + ArchiveEncoding = UseFixedEncoding ? ZipArchiveReader.DEFAULT_ENCODING : new ArchiveEncoding(Encoding.UTF8, Encoding.UTF8) + }; + + using (var writer = new ZipWriter(outputStream, zipWriterOptions)) { int i = 0; int fileCount = model.Files.Count(); diff --git a/osu.Game/Database/LegacyBeatmapExporter.cs b/osu.Game/Database/LegacyBeatmapExporter.cs index 69120ea885..eb48425588 100644 --- a/osu.Game/Database/LegacyBeatmapExporter.cs +++ b/osu.Game/Database/LegacyBeatmapExporter.cs @@ -8,6 +8,7 @@ using System.Text; using osu.Framework.Platform; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Formats; +using osu.Game.Beatmaps.Timing; using osu.Game.IO; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; @@ -58,10 +59,32 @@ namespace osu.Game.Database }; // Convert beatmap elements to be compatible with legacy format - // So we truncate time and position values to integers, and convert paths with multiple segments to bezier curves + // So we truncate time and position values to integers, and convert paths with multiple segments to Bézier curves + + // We must first truncate all timing points and move all objects in the timing section with it to ensure everything stays snapped + for (int i = 0; i < playableBeatmap.ControlPointInfo.TimingPoints.Count; i++) + { + var timingPoint = playableBeatmap.ControlPointInfo.TimingPoints[i]; + double offset = Math.Floor(timingPoint.Time) - timingPoint.Time; + double nextTimingPointTime = i + 1 < playableBeatmap.ControlPointInfo.TimingPoints.Count + ? playableBeatmap.ControlPointInfo.TimingPoints[i + 1].Time + : double.PositiveInfinity; + + // Offset all control points in the timing section (including the current one) + foreach (var controlPoint in playableBeatmap.ControlPointInfo.AllControlPoints.Where(o => o.Time >= timingPoint.Time && o.Time < nextTimingPointTime)) + controlPoint.Time += offset; + + // Offset all hit objects in the timing section + foreach (var hitObject in playableBeatmap.HitObjects.Where(o => o.StartTime >= timingPoint.Time && o.StartTime < nextTimingPointTime)) + hitObject.StartTime += offset; + } + foreach (var controlPoint in playableBeatmap.ControlPointInfo.AllControlPoints) controlPoint.Time = Math.Floor(controlPoint.Time); + for (int i = 0; i < playableBeatmap.Breaks.Count; i++) + playableBeatmap.Breaks[i] = new BreakPeriod(Math.Floor(playableBeatmap.Breaks[i].StartTime), Math.Floor(playableBeatmap.Breaks[i].EndTime)); + foreach (var hitObject in playableBeatmap.HitObjects) { // Truncate end time before truncating start time because end time is dependent on start time diff --git a/osu.Game/Database/LegacyImportManager.cs b/osu.Game/Database/LegacyImportManager.cs index 7e1641d16f..5c2f220045 100644 --- a/osu.Game/Database/LegacyImportManager.cs +++ b/osu.Game/Database/LegacyImportManager.cs @@ -10,7 +10,6 @@ using System.Threading; using System.Threading.Tasks; using osu.Framework; using osu.Framework.Allocation; -using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Platform; using osu.Game.Beatmaps; @@ -164,13 +163,13 @@ namespace osu.Game.Database var importTasks = new List(); Task beatmapImportTask = Task.CompletedTask; - if (content.HasFlagFast(StableContent.Beatmaps)) + if (content.HasFlag(StableContent.Beatmaps)) importTasks.Add(beatmapImportTask = new LegacyBeatmapImporter(beatmaps).ImportFromStableAsync(stableStorage)); - if (content.HasFlagFast(StableContent.Skins)) + if (content.HasFlag(StableContent.Skins)) importTasks.Add(new LegacySkinImporter(skins).ImportFromStableAsync(stableStorage)); - if (content.HasFlagFast(StableContent.Collections)) + if (content.HasFlag(StableContent.Collections)) { importTasks.Add(beatmapImportTask.ContinueWith(_ => new LegacyCollectionImporter(realmAccess) { @@ -180,7 +179,7 @@ namespace osu.Game.Database }.ImportFromStorage(stableStorage), TaskContinuationOptions.OnlyOnRanToCompletion)); } - if (content.HasFlagFast(StableContent.Scores)) + if (content.HasFlag(StableContent.Scores)) importTasks.Add(beatmapImportTask.ContinueWith(_ => new LegacyScoreImporter(scores).ImportFromStableAsync(stableStorage), TaskContinuationOptions.OnlyOnRanToCompletion)); await Task.WhenAll(importTasks.ToArray()).ConfigureAwait(false); diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs index 167d170c81..eb7182820b 100644 --- a/osu.Game/Database/RealmAccess.cs +++ b/osu.Game/Database/RealmAccess.cs @@ -15,7 +15,6 @@ using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Development; using osu.Framework.Extensions; -using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Input.Bindings; using osu.Framework.Logging; using osu.Framework.Platform; @@ -35,6 +34,7 @@ using osu.Game.Rulesets.Mods; using osu.Game.Scoring; using osu.Game.Scoring.Legacy; using osu.Game.Skinning; +using osu.Game.Utils; using osuTK.Input; using Realms; using Realms.Exceptions; @@ -91,8 +91,11 @@ namespace osu.Game.Database /// 38 2023-12-10 Add EndTimeObjectCount and TotalObjectCount to BeatmapInfo. /// 39 2023-12-19 Migrate any EndTimeObjectCount and TotalObjectCount values of 0 to -1 to better identify non-calculated values. /// 40 2023-12-21 Add ScoreInfo.Version to keep track of which build scores were set on. + /// 41 2024-04-17 Add ScoreInfo.TotalScoreWithoutMods for future mod multiplier rebalances. + /// 42 2024-08-07 Update mania key bindings to reflect changes to ManiaAction + /// 43 2024-10-14 Reset keybind for toggling FPS display to avoid conflict with "convert to stream" in the editor, if not already changed by user. /// - private const int schema_version = 40; + private const int schema_version = 43; /// /// Lock object which is held during sections, blocking realm retrieval during blocking periods. @@ -321,12 +324,32 @@ namespace osu.Game.Database { Logger.Error(e, "Your local database is too new to work with this version of osu!. Please close osu! and install the latest release to recover your data."); - // If a newer version database already exists, don't backup again. We can presume that the first backup is the one we care about. + // If a newer version database already exists, don't create another backup. We can presume that the first backup is the one we care about. if (!storage.Exists(newerVersionFilename)) createBackup(newerVersionFilename); } else { + // This error can occur due to file handles still being open by a previous instance. + // If this is the case, rather than assuming the realm file is corrupt, block game startup. + if (e.Message.StartsWith("SetEndOfFile() failed", StringComparison.Ordinal)) + { + // This will throw if the realm file is not available for write access after 5 seconds. + FileUtils.AttemptOperation(() => + { + if (storage.Exists(Filename)) + { + using (var _ = storage.GetStream(Filename, FileAccess.ReadWrite)) + { + } + } + }, 20); + + // If the above eventually succeeds, try and continue startup as per normal. + // This may throw again but let's allow it to, and block startup. + return getRealmInstance(); + } + Logger.Error(e, "Realm startup failed with unrecoverable error; starting with a fresh database. A backup of your database has been made."); createBackup($"{Filename.Replace(realm_extension, string.Empty)}_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}_corrupt{realm_extension}"); } @@ -353,10 +376,6 @@ namespace osu.Game.Database { foreach (var beatmap in beatmapSet.Beatmaps) { - // Cascade delete related scores, else they will have a null beatmap against the model's spec. - foreach (var score in beatmap.Scores) - realm.Remove(score); - realm.Remove(beatmap.Metadata); realm.Remove(beatmap); } @@ -546,7 +565,7 @@ namespace osu.Game.Database lock (notificationsResetMap) { // Store an action which is used when blocking to ensure consumers don't use results of a stale changeset firing. - notificationsResetMap.Add(action, () => callback(new EmptyRealmSet(), null)); + notificationsResetMap.Add(action, () => callback(new RealmResetEmptySet(), null)); } return RegisterCustomSubscription(action); @@ -1013,7 +1032,7 @@ namespace osu.Game.Database var legacyMods = (LegacyMods)sr.ReadInt32(); - if (!legacyMods.HasFlagFast(LegacyMods.ScoreV2) || score.APIMods.Any(mod => mod.Acronym == @"SV2")) + if (!legacyMods.HasFlag(LegacyMods.ScoreV2) || score.APIMods.Any(mod => mod.Acronym == @"SV2")) return; score.APIMods = score.APIMods.Append(new APIMod(new ModScoreV2())).ToArray(); @@ -1109,6 +1128,82 @@ namespace osu.Game.Database } break; + + case 41: + foreach (var score in migration.NewRealm.All()) + { + try + { + // this can fail e.g. if a user has a score set on a ruleset that can no longer be loaded. + LegacyScoreDecoder.PopulateTotalScoreWithoutMods(score); + } + catch (Exception ex) + { + Logger.Log($@"Failed to populate total score without mods for score {score.ID}: {ex}", LoggingTarget.Database); + } + } + + break; + + case 42: + for (int columns = 1; columns <= 10; columns++) + { + remapKeyBindingsForVariant(columns, false); + remapKeyBindingsForVariant(columns, true); + } + + // Replace existing key bindings with new ones reflecting changes to ManiaAction: + // - "Special#" actions are removed and "Key#" actions are inserted in their place. + // - All actions are renumbered to remove the old offsets. + void remapKeyBindingsForVariant(int columns, bool dual) + { + // https://github.com/ppy/osu/blob/8773c2f7ebc226942d6124eb95c07a83934272ea/osu.Game.Rulesets.Mania/ManiaRuleset.cs#L327-L336 + int variant = dual ? 1000 + (columns * 2) : columns; + + var oldKeyBindingsQuery = migration.NewRealm + .All() + .Where(kb => kb.RulesetName == @"mania" && kb.Variant == variant); + var oldKeyBindings = oldKeyBindingsQuery.Detach(); + + migration.NewRealm.RemoveRange(oldKeyBindingsQuery); + + // https://github.com/ppy/osu/blob/8773c2f7ebc226942d6124eb95c07a83934272ea/osu.Game.Rulesets.Mania/ManiaInputManager.cs#L22-L31 + int oldNormalAction = 10; // Old Key1 offset + int oldSpecialAction = 1; // Old Special1 offset + + for (int column = 0; column < columns * (dual ? 2 : 1); column++) + { + if (columns % 2 == 1 && column % columns == columns / 2) + remapKeyBinding(oldSpecialAction++, column); + else + remapKeyBinding(oldNormalAction++, column); + } + + void remapKeyBinding(int oldAction, int newAction) + { + var oldKeyBinding = oldKeyBindings.Find(kb => kb.ActionInt == oldAction); + + if (oldKeyBinding != null) + migration.NewRealm.Add(new RealmKeyBinding(newAction, oldKeyBinding.KeyCombination, @"mania", variant)); + } + } + + break; + + case 43: + { + // Clear default bindings for "Toggle FPS Display", + // as it conflicts with "Convert to Stream" in the editor. + // Only apply change if set to the conflicting bind + // i.e. has been manually rebound by the user. + var keyBindings = migration.NewRealm.All(); + + var toggleFpsBind = keyBindings.FirstOrDefault(bind => bind.ActionInt == (int)GlobalAction.ToggleFPSDisplay); + if (toggleFpsBind != null && toggleFpsBind.KeyCombination.Keys.SequenceEqual(new[] { InputKey.Shift, InputKey.Control, InputKey.F })) + migration.NewRealm.Remove(toggleFpsBind); + + break; + } } Logger.Log($"Migration completed in {stopwatch.ElapsedMilliseconds}ms"); @@ -1142,33 +1237,18 @@ namespace osu.Game.Database { Logger.Log($"Creating full realm database backup at {backupFilename}", LoggingTarget.Database); - int attempts = 10; - - while (true) + FileUtils.AttemptOperation(() => { - try + using (var source = storage.GetStream(Filename, mode: FileMode.Open)) { - using (var source = storage.GetStream(Filename, mode: FileMode.Open)) - { - // source may not exist. - if (source == null) - return; + // source may not exist. + if (source == null) + return; - using (var destination = storage.GetStream(backupFilename, FileAccess.Write, FileMode.CreateNew)) - source.CopyTo(destination); - } - - return; + using (var destination = storage.GetStream(backupFilename, FileAccess.Write, FileMode.CreateNew)) + source.CopyTo(destination); } - catch (IOException) - { - if (attempts-- <= 0) - throw; - - // file may be locked during use. - Thread.Sleep(500); - } - } + }, 20); } /// diff --git a/osu.Game/Database/RealmArchiveModelImporter.cs b/osu.Game/Database/RealmArchiveModelImporter.cs index bc4954c6ea..75462797f8 100644 --- a/osu.Game/Database/RealmArchiveModelImporter.cs +++ b/osu.Game/Database/RealmArchiveModelImporter.cs @@ -179,6 +179,31 @@ namespace osu.Game.Database public virtual Task?> ImportAsUpdate(ProgressNotification notification, ImportTask task, TModel original) => throw new NotImplementedException(); + public async Task> BeginExternalEditing(TModel model) + { + string mountedPath = Path.Join(Path.GetTempPath(), model.Hash); + + if (Directory.Exists(mountedPath)) + Directory.Delete(mountedPath, true); + + Directory.CreateDirectory(mountedPath); + + foreach (var realmFile in model.Files) + { + string sourcePath = Files.Storage.GetFullPath(realmFile.File.GetStoragePath()); + string destinationPath = Path.Join(mountedPath, realmFile.Filename); + + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); + + // Consider using hard links here to make this instant. + using (var inStream = Files.Storage.GetStream(sourcePath)) + using (var outStream = File.Create(destinationPath)) + await inStream.CopyToAsync(outStream).ConfigureAwait(false); + } + + return new ExternalEditOperation(this, model, mountedPath); + } + /// /// Import one from the filesystem and delete the file on success. /// Note that this bypasses the UI flow and should only be used for special cases or testing. @@ -449,16 +474,6 @@ namespace osu.Game.Database return reader.Name.ComputeSHA2Hash(); } - /// - /// Create all required s for the provided archive, adding them to the global file store. - /// - private List createFileInfos(ArchiveReader reader, RealmFileStore files, Realm realm) - { - var fileInfos = new List(); - - return fileInfos; - } - private IEnumerable<(string original, string shortened)> getShortenedFilenames(ArchiveReader reader) { string prefix = reader.Filenames.GetCommonPrefix(); @@ -513,7 +528,7 @@ namespace osu.Game.Database /// The new model proposed for import. /// The current realm context. /// An existing model which matches the criteria to skip importing, else null. - protected TModel? CheckForExisting(TModel model, Realm realm) => string.IsNullOrEmpty(model.Hash) ? null : realm.All().FirstOrDefault(b => b.Hash == model.Hash); + protected TModel? CheckForExisting(TModel model, Realm realm) => string.IsNullOrEmpty(model.Hash) ? null : realm.All().OrderBy(b => b.DeletePending).FirstOrDefault(b => b.Hash == model.Hash); /// /// Whether import can be skipped after finding an existing import early in the process. diff --git a/osu.Game/Database/RealmObjectExtensions.cs b/osu.Game/Database/RealmObjectExtensions.cs index 72529ed9ff..2fa3b8a880 100644 --- a/osu.Game/Database/RealmObjectExtensions.cs +++ b/osu.Game/Database/RealmObjectExtensions.cs @@ -65,7 +65,8 @@ namespace osu.Game.Database if (!d.Beatmaps.Contains(existingBeatmap)) { Debug.Fail("Beatmaps should never become detached under normal circumstances. If this ever triggers, it should be investigated further."); - Logger.Log("WARNING: One of the difficulties in a beatmap was detached from its set. Please save a copy of logs and report this to devs.", LoggingTarget.Database, LogLevel.Important); + Logger.Log("WARNING: One of the difficulties in a beatmap was detached from its set. Please save a copy of logs and report this to devs.", LoggingTarget.Database, + LogLevel.Important); d.Beatmaps.Add(existingBeatmap); } @@ -291,7 +292,24 @@ namespace osu.Game.Database if (!RealmAccess.CurrentThreadSubscriptionsAllowed) throw new InvalidOperationException($"Make sure to call {nameof(RealmAccess)}.{nameof(RealmAccess.RegisterForNotifications)}"); - return collection.SubscribeForNotifications(callback); + bool initial = true; + return collection.SubscribeForNotifications(((sender, changes) => + { + if (initial) + { + initial = false; + + // Realm might coalesce the initial callback, meaning we never receive a `ChangeSet` of `null` marking the first callback. + // Let's decouple it for simplicity in handling. + if (changes != null) + { + callback(sender, null); + return; + } + } + + callback(sender, changes); + })); } /// diff --git a/osu.Game/Database/EmptyRealmSet.cs b/osu.Game/Database/RealmResetEmptySet.cs similarity index 78% rename from osu.Game/Database/EmptyRealmSet.cs rename to osu.Game/Database/RealmResetEmptySet.cs index 02dfa50fe5..9f9a1ba6d7 100644 --- a/osu.Game/Database/EmptyRealmSet.cs +++ b/osu.Game/Database/RealmResetEmptySet.cs @@ -6,17 +6,28 @@ using System.Collections; using System.Collections.Generic; using System.Collections.Specialized; using System.ComponentModel; +using JetBrains.Annotations; using Realms; using Realms.Schema; namespace osu.Game.Database { - public class EmptyRealmSet : IRealmCollection + /// + /// This can arrive in callbacks to imply that realm access has been reset. + /// + /// + /// Usually implies that the original database may return soon and the callback can usually be silently ignored. + /// + public class RealmResetEmptySet : IRealmCollection { private IList emptySet => Array.Empty(); + [MustDisposeResource] public IEnumerator GetEnumerator() => emptySet.GetEnumerator(); + + [MustDisposeResource] IEnumerator IEnumerable.GetEnumerator() => emptySet.GetEnumerator(); + public int Count => emptySet.Count; public T this[int index] => emptySet[index]; public int IndexOf(object? item) => item == null ? -1 : emptySet.IndexOf((T)item); diff --git a/osu.Game/Database/StandardisedScoreMigrationTools.cs b/osu.Game/Database/StandardisedScoreMigrationTools.cs index 6f2f8d64fa..db44731bed 100644 --- a/osu.Game/Database/StandardisedScoreMigrationTools.cs +++ b/osu.Game/Database/StandardisedScoreMigrationTools.cs @@ -247,7 +247,7 @@ namespace osu.Game.Database // warning: ordering is important here - both total score and ranks are dependent on accuracy! score.Accuracy = computeAccuracy(score, scoreProcessor); score.Rank = computeRank(score, scoreProcessor); - score.TotalScore = convertFromLegacyTotalScore(score, ruleset, beatmap); + (score.TotalScoreWithoutMods, score.TotalScore) = convertFromLegacyTotalScore(score, ruleset, beatmap); } /// @@ -271,7 +271,7 @@ namespace osu.Game.Database // warning: ordering is important here - both total score and ranks are dependent on accuracy! score.Accuracy = computeAccuracy(score, scoreProcessor); score.Rank = computeRank(score, scoreProcessor); - score.TotalScore = convertFromLegacyTotalScore(score, ruleset, difficulty, attributes); + (score.TotalScoreWithoutMods, score.TotalScore) = convertFromLegacyTotalScore(score, ruleset, difficulty, attributes); } /// @@ -281,17 +281,13 @@ namespace osu.Game.Database /// The in which the score was set. /// The applicable for this score. /// The standardised total score. - private static long convertFromLegacyTotalScore(ScoreInfo score, Ruleset ruleset, WorkingBeatmap beatmap) + private static (long withoutMods, long withMods) convertFromLegacyTotalScore(ScoreInfo score, Ruleset ruleset, WorkingBeatmap beatmap) { if (!score.IsLegacyScore) - return score.TotalScore; + return (score.TotalScoreWithoutMods, score.TotalScore); if (ruleset is not ILegacyRuleset legacyRuleset) - return score.TotalScore; - - var mods = score.Mods; - if (mods.Any(mod => mod is ModScoreV2)) - return score.TotalScore; + return (score.TotalScoreWithoutMods, score.TotalScore); var playableBeatmap = beatmap.GetPlayableBeatmap(ruleset.RulesetInfo, score.Mods); @@ -300,8 +296,13 @@ namespace osu.Game.Database ILegacyScoreSimulator sv1Simulator = legacyRuleset.CreateLegacyScoreSimulator(); LegacyScoreAttributes attributes = sv1Simulator.Simulate(beatmap, playableBeatmap); + var legacyBeatmapConversionDifficultyInfo = LegacyBeatmapConversionDifficultyInfo.FromBeatmap(beatmap.Beatmap); - return convertFromLegacyTotalScore(score, ruleset, LegacyBeatmapConversionDifficultyInfo.FromBeatmap(beatmap.Beatmap), attributes); + var mods = score.Mods; + if (mods.Any(mod => mod is ModScoreV2)) + return ((long)Math.Round(score.TotalScore / sv1Simulator.GetLegacyScoreMultiplier(mods, legacyBeatmapConversionDifficultyInfo)), score.TotalScore); + + return convertFromLegacyTotalScore(score, ruleset, legacyBeatmapConversionDifficultyInfo, attributes); } /// @@ -312,15 +313,15 @@ namespace osu.Game.Database /// The beatmap difficulty. /// The legacy scoring attributes for the beatmap which the score was set on. /// The standardised total score. - private static long convertFromLegacyTotalScore(ScoreInfo score, Ruleset ruleset, LegacyBeatmapConversionDifficultyInfo difficulty, LegacyScoreAttributes attributes) + private static (long withoutMods, long withMods) convertFromLegacyTotalScore(ScoreInfo score, Ruleset ruleset, LegacyBeatmapConversionDifficultyInfo difficulty, LegacyScoreAttributes attributes) { if (!score.IsLegacyScore) - return score.TotalScore; + return (score.TotalScoreWithoutMods, score.TotalScore); Debug.Assert(score.LegacyTotalScore != null); if (ruleset is not ILegacyRuleset legacyRuleset) - return score.TotalScore; + return (score.TotalScoreWithoutMods, score.TotalScore); double legacyModMultiplier = legacyRuleset.CreateLegacyScoreSimulator().GetLegacyScoreMultiplier(score.Mods, difficulty); int maximumLegacyAccuracyScore = attributes.AccuracyScore; @@ -352,17 +353,18 @@ namespace osu.Game.Database double modMultiplier = score.Mods.Select(m => m.ScoreMultiplier).Aggregate(1.0, (c, n) => c * n); - long convertedTotalScore; + long convertedTotalScoreWithoutMods; switch (score.Ruleset.OnlineID) { case 0: if (score.MaxCombo == 0 || score.Accuracy == 0) { - return (long)Math.Round(( + convertedTotalScoreWithoutMods = (long)Math.Round( 0 + 500000 * Math.Pow(score.Accuracy, 5) - + bonusProportion) * modMultiplier); + + bonusProportion); + break; } // see similar check above. @@ -370,10 +372,11 @@ namespace osu.Game.Database // are either pointless or wildly wrong. if (maximumLegacyComboScore + maximumLegacyBonusScore == 0) { - return (long)Math.Round(( + convertedTotalScoreWithoutMods = (long)Math.Round( 500000 * comboProportion // as above, zero if mods result in zero multiplier, one otherwise + 500000 * Math.Pow(score.Accuracy, 5) - + bonusProportion) * modMultiplier); + + bonusProportion); + break; } // Assumptions: @@ -470,17 +473,17 @@ namespace osu.Game.Database double newComboScoreProportion = estimatedComboPortionInStandardisedScore / maximumAchievableComboPortionInStandardisedScore; - convertedTotalScore = (long)Math.Round(( + convertedTotalScoreWithoutMods = (long)Math.Round( 500000 * newComboScoreProportion * score.Accuracy + 500000 * Math.Pow(score.Accuracy, 5) - + bonusProportion) * modMultiplier); + + bonusProportion); break; case 1: - convertedTotalScore = (long)Math.Round(( + convertedTotalScoreWithoutMods = (long)Math.Round( 250000 * comboProportion + 750000 * Math.Pow(score.Accuracy, 3.6) - + bonusProportion) * modMultiplier); + + bonusProportion); break; case 2: @@ -505,28 +508,28 @@ namespace osu.Game.Database ? 0 : (double)score.Statistics.GetValueOrDefault(HitResult.SmallTickHit) / score.MaximumStatistics.GetValueOrDefault(HitResult.SmallTickHit); - convertedTotalScore = (long)Math.Round(( + convertedTotalScoreWithoutMods = (long)Math.Round( comboPortion * estimateComboProportionForCatch(attributes.MaxCombo, score.MaxCombo, score.Statistics.GetValueOrDefault(HitResult.Miss)) + dropletsPortion * dropletsHit - + bonusProportion) * modMultiplier); + + bonusProportion); break; case 3: - convertedTotalScore = (long)Math.Round(( + convertedTotalScoreWithoutMods = (long)Math.Round( 850000 * comboProportion + 150000 * Math.Pow(score.Accuracy, 2 + 2 * score.Accuracy) - + bonusProportion) * modMultiplier); + + bonusProportion); break; default: - convertedTotalScore = score.TotalScore; - break; + return (score.TotalScoreWithoutMods, score.TotalScore); } - if (convertedTotalScore < 0) - throw new InvalidOperationException($"Total score conversion operation returned invalid total of {convertedTotalScore}"); + if (convertedTotalScoreWithoutMods < 0) + throw new InvalidOperationException($"Total score conversion operation returned invalid total of {convertedTotalScoreWithoutMods}"); - return convertedTotalScore; + long convertedTotalScore = (long)Math.Round(convertedTotalScoreWithoutMods * modMultiplier); + return (convertedTotalScoreWithoutMods, convertedTotalScore); } /// diff --git a/osu.Game/Database/UserLookupCache.cs b/osu.Game/Database/UserLookupCache.cs index e581d5ce82..8c96b34666 100644 --- a/osu.Game/Database/UserLookupCache.cs +++ b/osu.Game/Database/UserLookupCache.cs @@ -10,7 +10,7 @@ using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Database { - public partial class UserLookupCache : OnlineLookupCache + public partial class UserLookupCache : OnlineLookupCache { /// /// Perform an API lookup on the specified user, populating a model. @@ -28,8 +28,8 @@ namespace osu.Game.Database /// The populated users. May include null results for failed retrievals. public Task GetUsersAsync(int[] userIds, CancellationToken token = default) => LookupAsync(userIds, token); - protected override GetUsersRequest CreateRequest(IEnumerable ids) => new GetUsersRequest(ids.ToArray()); + protected override LookupUsersRequest CreateRequest(IEnumerable ids) => new LookupUsersRequest(ids.ToArray()); - protected override IEnumerable? RetrieveResults(GetUsersRequest request) => request.Response?.Users; + protected override IEnumerable? RetrieveResults(LookupUsersRequest request) => request.Response?.Users; } } diff --git a/osu.Game/Graphics/Containers/OsuRearrangeableListItem.cs b/osu.Game/Graphics/Containers/OsuRearrangeableListItem.cs index 39a3edb82c..445588d525 100644 --- a/osu.Game/Graphics/Containers/OsuRearrangeableListItem.cs +++ b/osu.Game/Graphics/Containers/OsuRearrangeableListItem.cs @@ -64,6 +64,7 @@ namespace osu.Game.Graphics.Containers { InternalChildren = new Drawable[] { + new HoverClickSounds(), new GridContainer { RelativeSizeAxes = Axes.X, @@ -92,7 +93,6 @@ namespace osu.Game.Graphics.Containers ColumnDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) } }, - new HoverClickSounds() }; } diff --git a/osu.Game/Graphics/Cursor/GlobalCursorDisplay.cs b/osu.Game/Graphics/Cursor/GlobalCursorDisplay.cs index 85a2d68e55..d32544fc42 100644 --- a/osu.Game/Graphics/Cursor/GlobalCursorDisplay.cs +++ b/osu.Game/Graphics/Cursor/GlobalCursorDisplay.cs @@ -53,7 +53,7 @@ namespace osu.Game.Graphics.Cursor { base.LoadComplete(); - inputManager = GetContainingInputManager(); + inputManager = GetContainingInputManager()!; showDuringTouch = config.GetBindable(OsuSetting.GameplayCursorDuringTouch); } diff --git a/osu.Game/Graphics/Cursor/OsuContextMenuContainer.cs b/osu.Game/Graphics/Cursor/OsuContextMenuContainer.cs index c5bcfcd2df..7b21a413f7 100644 --- a/osu.Game/Graphics/Cursor/OsuContextMenuContainer.cs +++ b/osu.Game/Graphics/Cursor/OsuContextMenuContainer.cs @@ -8,16 +8,24 @@ using osu.Game.Graphics.UserInterface; namespace osu.Game.Graphics.Cursor { + [Cached(typeof(OsuContextMenuContainer))] public partial class OsuContextMenuContainer : ContextMenuContainer { [Cached] private OsuContextMenuSamples samples = new OsuContextMenuSamples(); + private OsuContextMenu menu = null!; + public OsuContextMenuContainer() { AddInternal(samples); } - protected override Menu CreateMenu() => new OsuContextMenu(true); + protected override Menu CreateMenu() => menu = new OsuContextMenu(true); + + public void CloseMenu() + { + menu.Close(); + } } } diff --git a/osu.Game/Graphics/Cursor/OsuTooltipContainer.cs b/osu.Game/Graphics/Cursor/OsuTooltipContainer.cs index aab5b3ee36..0d36cc1d08 100644 --- a/osu.Game/Graphics/Cursor/OsuTooltipContainer.cs +++ b/osu.Game/Graphics/Cursor/OsuTooltipContainer.cs @@ -10,7 +10,7 @@ using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Localisation; -using osu.Game.Graphics.Sprites; +using osu.Framework.Graphics.Containers; namespace osu.Game.Graphics.Cursor { @@ -27,15 +27,20 @@ namespace osu.Game.Graphics.Cursor public partial class OsuTooltip : Tooltip { + private const float max_width = 500; + private readonly Box background; - private readonly OsuSpriteText text; + private readonly TextFlowContainer text; private bool instantMovement = true; - public override void SetContent(LocalisableString contentString) - { - if (contentString == text.Text) return; + private LocalisableString lastContent; - text.Text = contentString; + public override void SetContent(LocalisableString content) + { + if (content.Equals(lastContent)) + return; + + text.Text = content; if (IsPresent) { @@ -44,6 +49,8 @@ namespace osu.Game.Graphics.Cursor } else AutoSizeDuration = 0; + + lastContent = content; } public OsuTooltip() @@ -65,10 +72,14 @@ namespace osu.Game.Graphics.Cursor RelativeSizeAxes = Axes.Both, Alpha = 0.9f, }, - text = new OsuSpriteText + text = new TextFlowContainer(f => { - Padding = new MarginPadding(5), - Font = OsuFont.GetFont(weight: FontWeight.Regular) + f.Font = OsuFont.GetFont(weight: FontWeight.Regular); + }) + { + Margin = new MarginPadding(5), + AutoSizeAxes = Axes.Both, + MaximumSize = new Vector2(max_width, float.PositiveInfinity), } }; } diff --git a/osu.Game/Graphics/OsuIcon.cs b/osu.Game/Graphics/OsuIcon.cs index 32e780f11c..9879ef5d14 100644 --- a/osu.Game/Graphics/OsuIcon.cs +++ b/osu.Game/Graphics/OsuIcon.cs @@ -120,6 +120,7 @@ namespace osu.Game.Graphics public static IconUsage Cross => get(OsuIconMapping.Cross); public static IconUsage CrossCircle => get(OsuIconMapping.CrossCircle); public static IconUsage Crown => get(OsuIconMapping.Crown); + public static IconUsage DailyChallenge => get(OsuIconMapping.DailyChallenge); public static IconUsage Debug => get(OsuIconMapping.Debug); public static IconUsage Delete => get(OsuIconMapping.Delete); public static IconUsage Details => get(OsuIconMapping.Details); @@ -218,6 +219,9 @@ namespace osu.Game.Graphics [Description(@"crown")] Crown, + [Description(@"daily-challenge")] + DailyChallenge, + [Description(@"debug")] Debug, diff --git a/osu.Game/Graphics/ParticleSpewer.cs b/osu.Game/Graphics/ParticleSpewer.cs index 64c70095bf..51fbd134d5 100644 --- a/osu.Game/Graphics/ParticleSpewer.cs +++ b/osu.Game/Graphics/ParticleSpewer.cs @@ -6,7 +6,6 @@ using System; using System.Diagnostics; using osu.Framework.Bindables; -using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Rendering; @@ -190,9 +189,9 @@ namespace osu.Game.Graphics float width = Texture.DisplayWidth * scale; float height = Texture.DisplayHeight * scale; - if (relativePositionAxes.HasFlagFast(Axes.X)) + if (relativePositionAxes.HasFlag(Axes.X)) position.X *= sourceSize.X; - if (relativePositionAxes.HasFlagFast(Axes.Y)) + if (relativePositionAxes.HasFlag(Axes.Y)) position.Y *= sourceSize.Y; return new RectangleF( diff --git a/osu.Game/Graphics/UserInterface/BackButton.cs b/osu.Game/Graphics/UserInterface/BackButton.cs index cd9a357ea4..29bac8fbae 100644 --- a/osu.Game/Graphics/UserInterface/BackButton.cs +++ b/osu.Game/Graphics/UserInterface/BackButton.cs @@ -7,19 +7,18 @@ using System; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Input.Bindings; -using osu.Framework.Input.Events; -using osu.Game.Input.Bindings; +using osu.Game.Screens.Footer; namespace osu.Game.Graphics.UserInterface { + // todo: remove this once all screens migrate to display the new game footer and back button. public partial class BackButton : VisibilityContainer { public Action Action; private readonly TwoLayerButton button; - public BackButton(Receptor receptor = null) + public BackButton(ScreenFooter.BackReceptor receptor = null) { Size = TwoLayerButton.SIZE_EXTENDED; @@ -35,7 +34,7 @@ namespace osu.Game.Graphics.UserInterface if (receptor == null) { // if a receptor wasn't provided, create our own locally. - Add(receptor = new Receptor()); + Add(receptor = new ScreenFooter.BackReceptor()); } receptor.OnBackPressed = () => button.TriggerClick(); @@ -59,29 +58,5 @@ namespace osu.Game.Graphics.UserInterface button.MoveToX(-TwoLayerButton.SIZE_EXTENDED.X / 2, 400, Easing.OutQuint); button.FadeOut(400, Easing.OutQuint); } - - public partial class Receptor : Drawable, IKeyBindingHandler - { - public Action OnBackPressed; - - public bool OnPressed(KeyBindingPressEvent e) - { - if (e.Repeat) - return false; - - switch (e.Action) - { - case GlobalAction.Back: - OnBackPressed?.Invoke(); - return true; - } - - return false; - } - - public void OnReleased(KeyBindingReleaseEvent e) - { - } - } } } diff --git a/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs b/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs index 06ef75cf58..cd44bd8fb9 100644 --- a/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs +++ b/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs @@ -3,8 +3,10 @@ #nullable disable +using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -20,12 +22,15 @@ namespace osu.Game.Graphics.UserInterface { public partial class DrawableOsuMenuItem : Menu.DrawableMenuItem { - public const int MARGIN_HORIZONTAL = 17; + public const int MARGIN_HORIZONTAL = 10; public const int MARGIN_VERTICAL = 4; - private const int text_size = 17; - private const int transition_length = 80; + public const int TEXT_SIZE = 17; + public const int TRANSITION_LENGTH = 80; + + public BindableBool ShowCheckbox { get; } = new BindableBool(); private TextContainer text; + private HotkeyDisplay hotkey; private HoverClickSounds hoverClickSounds; public DrawableOsuMenuItem(MenuItem item) @@ -39,43 +44,47 @@ namespace osu.Game.Graphics.UserInterface BackgroundColour = Color4.Transparent; BackgroundColourHover = Color4Extensions.FromHex(@"172023"); + AddInternal(hotkey = new HotkeyDisplay + { + Alpha = 0, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Margin = new MarginPadding { Right = 10, Top = 1 }, + }); AddInternal(hoverClickSounds = new HoverClickSounds()); - updateTextColour(); + updateText(); - bool hasSubmenu = Item.Items.Any(); - - // Only add right chevron if direction of menu items is vertical (i.e. width is relative size, see `DrawableMenuItem.SetFlowDirection()`). - if (hasSubmenu && RelativeSizeAxes == Axes.X) + if (showChevron) { AddInternal(new SpriteIcon { - Margin = new MarginPadding(6), + Margin = new MarginPadding { Horizontal = 10, }, Size = new Vector2(8), Icon = FontAwesome.Solid.ChevronRight, Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, }); - - text.Padding = new MarginPadding - { - // Add some padding for the chevron above. - Right = 5, - }; } } + // Only add right chevron if direction of menu items is vertical (i.e. width is relative size, see `DrawableMenuItem.SetFlowDirection()`). + private bool showChevron => Item.Items.Any() && RelativeSizeAxes == Axes.X; + protected override void LoadComplete() { base.LoadComplete(); + ShowCheckbox.BindValueChanged(_ => updateState()); Item.Action.BindDisabledChanged(_ => updateState(), true); FinishTransforms(); } - private void updateTextColour() + private void updateText() { - switch ((Item as OsuMenuItem)?.Type) + var osuMenuItem = Item as OsuMenuItem; + + switch (osuMenuItem?.Type) { default: case MenuItemType.Standard: @@ -90,6 +99,20 @@ namespace osu.Game.Graphics.UserInterface text.Colour = Color4Extensions.FromHex(@"ffcc22"); break; } + + hotkey.Hotkey = osuMenuItem?.Hotkey ?? default; + hotkey.Alpha = EqualityComparer.Default.Equals(hotkey.Hotkey, default) ? 0 : 1; + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + // this hack ensures that the menu can auto-size while leaving enough space for the hotkey display. + // the gist of it is that while the hotkey display is not in the text / "content" that determines sizing + // (because it cannot be, because we want the hotkey display to align to the *right* and not the left), + // enough padding to fit the hotkey with _its_ spacing is added as padding of the text to compensate. + text.Padding = new MarginPadding { Right = hotkey.Alpha > 0 || showChevron ? hotkey.DrawWidth + 15 : 0 }; } protected override bool OnHover(HoverEvent e) @@ -111,14 +134,16 @@ namespace osu.Game.Graphics.UserInterface if (IsHovered && IsActionable) { - text.BoldText.FadeIn(transition_length, Easing.OutQuint); - text.NormalText.FadeOut(transition_length, Easing.OutQuint); + text.BoldText.FadeIn(TRANSITION_LENGTH, Easing.OutQuint); + text.NormalText.FadeOut(TRANSITION_LENGTH, Easing.OutQuint); } else { - text.BoldText.FadeOut(transition_length, Easing.OutQuint); - text.NormalText.FadeIn(transition_length, Easing.OutQuint); + text.BoldText.FadeOut(TRANSITION_LENGTH, Easing.OutQuint); + text.NormalText.FadeIn(TRANSITION_LENGTH, Easing.OutQuint); } + + text.CheckboxContainer.Alpha = ShowCheckbox.Value ? 1 : 0; } protected sealed override Drawable CreateContent() => text = CreateTextContainer(); @@ -138,32 +163,53 @@ namespace osu.Game.Graphics.UserInterface public readonly SpriteText NormalText; public readonly SpriteText BoldText; + public readonly Container CheckboxContainer; public TextContainer() { - Anchor = Anchor.CentreLeft; - Origin = Anchor.CentreLeft; - AutoSizeAxes = Axes.Both; - Children = new Drawable[] + Child = new FillFlowContainer { - NormalText = new OsuSpriteText + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(10), + Direction = FillDirection.Horizontal, + Padding = new MarginPadding { Horizontal = MARGIN_HORIZONTAL, Vertical = MARGIN_VERTICAL, }, + + Children = new Drawable[] { - AlwaysPresent = true, // ensures that the menu item does not change width when switching between normal and bold text. - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Font = OsuFont.GetFont(size: text_size), - Margin = new MarginPadding { Horizontal = MARGIN_HORIZONTAL, Vertical = MARGIN_VERTICAL }, - }, - BoldText = new OsuSpriteText - { - AlwaysPresent = true, // ensures that the menu item does not change width when switching between normal and bold text. - Alpha = 0, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Font = OsuFont.GetFont(size: text_size, weight: FontWeight.Bold), - Margin = new MarginPadding { Horizontal = MARGIN_HORIZONTAL, Vertical = MARGIN_VERTICAL }, + CheckboxContainer = new Container + { + RelativeSizeAxes = Axes.Y, + Width = MARGIN_HORIZONTAL, + }, + new Container + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + NormalText = new OsuSpriteText + { + AlwaysPresent = true, // ensures that the menu item does not change width when switching between normal and bold text. + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: TEXT_SIZE), + }, + BoldText = new OsuSpriteText + { + AlwaysPresent = true, // ensures that the menu item does not change width when switching between normal and bold text. + Alpha = 0, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: TEXT_SIZE, weight: FontWeight.Bold), + } + } + }, } }; } diff --git a/osu.Game/Graphics/UserInterface/DrawableStatefulMenuItem.cs b/osu.Game/Graphics/UserInterface/DrawableStatefulMenuItem.cs index 5af275c9e7..4206f77c98 100644 --- a/osu.Game/Graphics/UserInterface/DrawableStatefulMenuItem.cs +++ b/osu.Game/Graphics/UserInterface/DrawableStatefulMenuItem.cs @@ -4,7 +4,9 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; using osuTK; +using osuTK.Input; namespace osu.Game.Graphics.UserInterface { @@ -19,6 +21,19 @@ namespace osu.Game.Graphics.UserInterface protected override TextContainer CreateTextContainer() => new ToggleTextContainer(Item); + protected override bool OnMouseDown(MouseDownEvent e) + { + // Right mouse button is a special case where we allow actioning without dismissing the menu. + // This is achieved by not calling `Clicked` (as done by the base implementation in OnClick). + if (IsActionable && e.Button == MouseButton.Right) + { + Item.Action.Value?.Invoke(); + return true; + } + + return false; + } + private partial class ToggleTextContainer : TextContainer { private readonly StatefulMenuItem menuItem; @@ -31,12 +46,11 @@ namespace osu.Game.Graphics.UserInterface state = menuItem.State.GetBoundCopy(); - Add(stateIcon = new SpriteIcon + CheckboxContainer.Add(stateIcon = new SpriteIcon { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, Size = new Vector2(10), - Margin = new MarginPadding { Horizontal = MARGIN_HORIZONTAL }, AlwaysPresent = true, }); } @@ -47,14 +61,6 @@ namespace osu.Game.Graphics.UserInterface state.BindValueChanged(updateState, true); } - protected override void Update() - { - base.Update(); - - // Todo: This is bad. This can maybe be done better with a refactor of DrawableOsuMenuItem. - stateIcon.X = BoldText.DrawWidth + 10; - } - private void updateState(ValueChangedEvent state) { var icon = menuItem.GetIconForState(state.NewValue); diff --git a/osu.Game/Graphics/UserInterface/ExpandableSlider.cs b/osu.Game/Graphics/UserInterface/ExpandableSlider.cs index 121a1eef49..4cc77e218f 100644 --- a/osu.Game/Graphics/UserInterface/ExpandableSlider.cs +++ b/osu.Game/Graphics/UserInterface/ExpandableSlider.cs @@ -1,7 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; +using System.Numerics; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -10,7 +10,7 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; -using osuTK; +using Vector2 = osuTK.Vector2; namespace osu.Game.Graphics.UserInterface { @@ -18,7 +18,7 @@ namespace osu.Game.Graphics.UserInterface /// An implementation for the UI slider bar control. /// public partial class ExpandableSlider : CompositeDrawable, IExpandable, IHasCurrentValue - where T : struct, IEquatable, IComparable, IConvertible + where T : struct, INumber, IMinMaxValue where TSlider : RoundedSliderBar, new() { private readonly OsuSpriteText label; @@ -119,9 +119,14 @@ namespace osu.Game.Graphics.UserInterface Expanded.BindValueChanged(v => { label.Text = v.NewValue ? expandedLabelText : contractedLabelText; - slider.FadeTo(v.NewValue ? 1f : 0f, 500, Easing.OutQuint); + slider.FadeTo(v.NewValue ? Current.Disabled ? 0.3f : 1f : 0f, 500, Easing.OutQuint); slider.BypassAutoSizeAxes = !v.NewValue ? Axes.Y : Axes.None; }, true); + + Current.BindDisabledChanged(disabled => + { + slider.Alpha = Expanded.Value ? disabled ? 0.3f : 1 : 0f; + }); } } @@ -129,7 +134,7 @@ namespace osu.Game.Graphics.UserInterface /// An implementation for the UI slider bar control. /// public partial class ExpandableSlider : ExpandableSlider> - where T : struct, IEquatable, IComparable, IConvertible + where T : struct, INumber, IMinMaxValue { } } diff --git a/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs b/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs index 7ba3d55162..b3ffd15816 100644 --- a/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs +++ b/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs @@ -10,9 +10,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.Overlays; -using osu.Game.Overlays.OSD; +using osu.Game.Localisation; using osuTK; using osuTK.Graphics; @@ -25,13 +23,7 @@ namespace osu.Game.Graphics.UserInterface private Color4 hoverColour; [Resolved] - private GameHost host { get; set; } = null!; - - [Resolved] - private Clipboard clipboard { get; set; } = null!; - - [Resolved] - private OnScreenDisplay? onScreenDisplay { get; set; } + private OsuGame? game { get; set; } private readonly SpriteIcon linkIcon; @@ -71,7 +63,7 @@ namespace osu.Game.Graphics.UserInterface protected override bool OnClick(ClickEvent e) { if (Link != null) - host.OpenUrlExternally(Link); + game?.OpenUrlExternally(Link); return true; } @@ -85,8 +77,8 @@ namespace osu.Game.Graphics.UserInterface if (Link != null) { - items.Add(new OsuMenuItem("Open", MenuItemType.Highlighted, () => host.OpenUrlExternally(Link))); - items.Add(new OsuMenuItem("Copy URL", MenuItemType.Standard, copyUrl)); + items.Add(new OsuMenuItem("Open", MenuItemType.Highlighted, () => game?.OpenUrlExternally(Link))); + items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, copyUrl)); } return items.ToArray(); @@ -95,11 +87,9 @@ namespace osu.Game.Graphics.UserInterface private void copyUrl() { - if (Link != null) - { - clipboard.SetText(Link); - onScreenDisplay?.Display(new CopyUrlToast()); - } + if (Link == null) return; + + game?.CopyUrlToClipboard(Link); } } } diff --git a/osu.Game/Graphics/UserInterface/FocusedTextBox.cs b/osu.Game/Graphics/UserInterface/FocusedTextBox.cs index 338f32f321..f4ca00b7d0 100644 --- a/osu.Game/Graphics/UserInterface/FocusedTextBox.cs +++ b/osu.Game/Graphics/UserInterface/FocusedTextBox.cs @@ -31,7 +31,7 @@ namespace osu.Game.Graphics.UserInterface if (!allowImmediateFocus) return; - Scheduler.Add(() => GetContainingInputManager().ChangeFocus(this)); + Scheduler.Add(() => GetContainingFocusManager()!.ChangeFocus(this)); } public new void KillFocus() => base.KillFocus(); diff --git a/osu.Game/Graphics/UserInterface/Hotkey.cs b/osu.Game/Graphics/UserInterface/Hotkey.cs new file mode 100644 index 0000000000..8b3014bdc5 --- /dev/null +++ b/osu.Game/Graphics/UserInterface/Hotkey.cs @@ -0,0 +1,59 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Input; +using osu.Framework.Input.Bindings; +using osu.Framework.Platform; +using osu.Game.Input; +using osu.Game.Input.Bindings; + +namespace osu.Game.Graphics.UserInterface +{ + public readonly record struct Hotkey + { + public KeyCombination[]? KeyCombinations { get; init; } + public GlobalAction? GlobalAction { get; init; } + public PlatformAction? PlatformAction { get; init; } + + public Hotkey(params KeyCombination[] keyCombinations) + { + KeyCombinations = keyCombinations; + } + + public Hotkey(GlobalAction globalAction) + { + GlobalAction = globalAction; + } + + public Hotkey(PlatformAction platformAction) + { + PlatformAction = platformAction; + } + + public IEnumerable ResolveKeyCombination(ReadableKeyCombinationProvider keyCombinationProvider, RealmKeyBindingStore keyBindingStore, GameHost gameHost) + { + var result = new List(); + + if (KeyCombinations != null) + { + result.AddRange(KeyCombinations.Select(keyCombinationProvider.GetReadableString)); + } + + if (GlobalAction != null) + { + result.AddRange(keyBindingStore.GetReadableKeyCombinationsFor(GlobalAction.Value)); + } + + if (PlatformAction != null) + { + var action = PlatformAction.Value; + var bindings = gameHost.PlatformKeyBindings.Where(kb => (PlatformAction)kb.Action == action); + result.AddRange(bindings.Select(b => keyCombinationProvider.GetReadableString(b.KeyCombination))); + } + + return result; + } + } +} diff --git a/osu.Game/Graphics/UserInterface/HotkeyDisplay.cs b/osu.Game/Graphics/UserInterface/HotkeyDisplay.cs new file mode 100644 index 0000000000..63970249d1 --- /dev/null +++ b/osu.Game/Graphics/UserInterface/HotkeyDisplay.cs @@ -0,0 +1,110 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input; +using osu.Framework.Platform; +using osu.Game.Graphics.Sprites; +using osu.Game.Input; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Graphics.UserInterface +{ + public partial class HotkeyDisplay : CompositeDrawable + { + private Hotkey hotkey; + + public Hotkey Hotkey + { + get => hotkey; + set + { + if (EqualityComparer.Default.Equals(hotkey, value)) + return; + + hotkey = value; + + if (IsLoaded) + updateState(); + } + } + + private FillFlowContainer flow = null!; + + [Resolved] + private ReadableKeyCombinationProvider readableKeyCombinationProvider { get; set; } = null!; + + [Resolved] + private RealmKeyBindingStore realmKeyBindingStore { get; set; } = null!; + + [Resolved] + private GameHost gameHost { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + + InternalChild = flow = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5) + }; + + updateState(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + updateState(); + } + + private void updateState() + { + flow.Clear(); + foreach (string h in hotkey.ResolveKeyCombination(readableKeyCombinationProvider, realmKeyBindingStore, gameHost)) + flow.Add(new HotkeyBox(h)); + } + + private partial class HotkeyBox : CompositeDrawable + { + private readonly string hotkey; + + public HotkeyBox(string hotkey) + { + this.hotkey = hotkey; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider? colourProvider, OsuColour colours) + { + AutoSizeAxes = Axes.Both; + Masking = true; + CornerRadius = 3; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider?.Background6 ?? Colour4.Black.Opacity(0.7f), + }, + new OsuSpriteText + { + Margin = new MarginPadding { Horizontal = 5, Bottom = 1, }, + Text = hotkey.ToUpperInvariant(), + Font = OsuFont.Default.With(size: 12, weight: FontWeight.Bold), + Colour = colourProvider?.Light1 ?? colours.GrayA, + } + }; + } + } + } +} diff --git a/osu.Game/Graphics/UserInterface/HoverSampleSet.cs b/osu.Game/Graphics/UserInterface/HoverSampleSet.cs index 72d50eb042..5b0fbc693e 100644 --- a/osu.Game/Graphics/UserInterface/HoverSampleSet.cs +++ b/osu.Game/Graphics/UserInterface/HoverSampleSet.cs @@ -16,15 +16,9 @@ namespace osu.Game.Graphics.UserInterface [Description("button-sidebar")] ButtonSidebar, - [Description("toolbar")] - Toolbar, - [Description("tabselect")] TabSelect, - [Description("scrolltotop")] - ScrollToTop, - [Description("dialog-cancel")] DialogCancel, diff --git a/osu.Game/Graphics/UserInterface/OsuCheckbox.cs b/osu.Game/Graphics/UserInterface/OsuCheckbox.cs index b7b405a7e8..caab3d97f8 100644 --- a/osu.Game/Graphics/UserInterface/OsuCheckbox.cs +++ b/osu.Game/Graphics/UserInterface/OsuCheckbox.cs @@ -52,8 +52,6 @@ namespace osu.Game.Graphics.UserInterface AutoSizeAxes = Axes.Y; RelativeSizeAxes = Axes.X; - const float nub_padding = 5; - Children = new Drawable[] { LabelTextFlowContainer = new OsuTextFlowContainer(ApplyLabelParameters) @@ -69,15 +67,13 @@ namespace osu.Game.Graphics.UserInterface { Nub.Anchor = Anchor.CentreRight; Nub.Origin = Anchor.CentreRight; - Nub.Margin = new MarginPadding { Right = nub_padding }; - LabelTextFlowContainer.Padding = new MarginPadding { Right = Nub.DEFAULT_EXPANDED_SIZE + nub_padding * 2 }; + LabelTextFlowContainer.Padding = new MarginPadding { Right = Nub.DEFAULT_EXPANDED_SIZE + 10f }; } else { Nub.Anchor = Anchor.CentreLeft; Nub.Origin = Anchor.CentreLeft; - Nub.Margin = new MarginPadding { Left = nub_padding }; - LabelTextFlowContainer.Padding = new MarginPadding { Left = Nub.DEFAULT_EXPANDED_SIZE + nub_padding * 2 }; + LabelTextFlowContainer.Padding = new MarginPadding { Left = Nub.DEFAULT_EXPANDED_SIZE + 10f }; } Nub.Current.BindTo(Current); diff --git a/osu.Game/Graphics/UserInterface/OsuDropdown.cs b/osu.Game/Graphics/UserInterface/OsuDropdown.cs index 38e90bf4ea..dc42216c55 100644 --- a/osu.Game/Graphics/UserInterface/OsuDropdown.cs +++ b/osu.Game/Graphics/UserInterface/OsuDropdown.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.Diagnostics; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -30,6 +31,12 @@ namespace osu.Game.Graphics.UserInterface protected override DropdownMenu CreateMenu() => new OsuDropdownMenu(); + public OsuDropdown() + { + if (Header is OsuDropdownHeader osuHeader) + osuHeader.Dropdown = this; + } + public bool OnPressed(KeyBindingPressEvent e) { if (e.Repeat) return false; @@ -68,7 +75,7 @@ namespace osu.Game.Graphics.UserInterface [BackgroundDependencyLoader(true)] private void load(OverlayColourProvider? colourProvider, OsuColour colours, AudioManager audio) { - BackgroundColour = colourProvider?.Background5 ?? Color4.Black.Opacity(0.5f); + BackgroundColour = colourProvider?.Background5 ?? Color4.Black; HoverColour = colourProvider?.Light4 ?? colours.PinkDarker; SelectionColour = colourProvider?.Background3 ?? colours.PinkDarker.Opacity(0.5f); @@ -307,7 +314,9 @@ namespace osu.Game.Graphics.UserInterface set => Text.Text = value; } - protected readonly SpriteIcon Icon; + protected readonly SpriteIcon Chevron; + + public OsuDropdown? Dropdown { get; set; } public OsuDropdownHeader() { @@ -341,7 +350,7 @@ namespace osu.Game.Graphics.UserInterface Origin = Anchor.CentreLeft, RelativeSizeAxes = Axes.X, }, - Icon = new SpriteIcon + Chevron = new SpriteIcon { Icon = FontAwesome.Solid.ChevronDown, Anchor = Anchor.CentreRight, @@ -365,6 +374,9 @@ namespace osu.Game.Graphics.UserInterface { base.LoadComplete(); + if (Dropdown != null) + Dropdown.Menu.StateChanged += _ => updateChevron(); + SearchBar.State.ValueChanged += _ => updateColour(); Enabled.BindValueChanged(_ => updateColour()); updateColour(); @@ -385,23 +397,30 @@ namespace osu.Game.Graphics.UserInterface { bool hovered = Enabled.Value && IsHovered; var hoveredColour = colourProvider?.Light4 ?? colours.PinkDarker; - var unhoveredColour = colourProvider?.Background5 ?? Color4.Black.Opacity(0.5f); + var unhoveredColour = colourProvider?.Background5 ?? Color4.Black; Colour = Color4.White; Alpha = Enabled.Value ? 1 : 0.3f; if (SearchBar.State.Value == Visibility.Visible) { - Icon.Colour = hovered ? hoveredColour.Lighten(0.5f) : Colour4.White; + Chevron.Colour = hovered ? hoveredColour.Lighten(0.5f) : Colour4.White; Background.Colour = unhoveredColour; } else { - Icon.Colour = Color4.White; + Chevron.Colour = Color4.White; Background.Colour = hovered ? hoveredColour : unhoveredColour; } } + private void updateChevron() + { + Debug.Assert(Dropdown != null); + bool open = Dropdown.Menu.State == MenuState.Open; + Chevron.ScaleTo(open ? new Vector2(1f, -1f) : Vector2.One, 300, Easing.OutQuint); + } + protected override DropdownSearchBar CreateSearchBar() => new OsuDropdownSearchBar { Padding = new MarginPadding { Right = 26 }, @@ -418,16 +437,19 @@ namespace osu.Game.Graphics.UserInterface FontSize = OsuFont.Default.Size, }; - private partial class DropdownSearchTextBox : SearchTextBox + private partial class DropdownSearchTextBox : OsuTextBox { - public override bool OnPressed(KeyBindingPressEvent e) + [BackgroundDependencyLoader] + private void load(OverlayColourProvider? colourProvider) { - if (e.Action == GlobalAction.Back) - // this method is blocking Dropdown from receiving the back action, despite this text box residing in a separate input manager. - // to fix this properly, a local global action container needs to be added as well, but for simplicity, just don't handle the back action here. - return false; + BackgroundUnfocused = colourProvider?.Background5 ?? new Color4(10, 10, 10, 255); + BackgroundFocused = colourProvider?.Background5 ?? new Color4(10, 10, 10, 255); + } - return base.OnPressed(e); + protected override void OnFocus(FocusEvent e) + { + base.OnFocus(e); + BorderThickness = 0; } } } diff --git a/osu.Game/Graphics/UserInterface/OsuMenu.cs b/osu.Game/Graphics/UserInterface/OsuMenu.cs index e2aac297e3..6e7dad2b5f 100644 --- a/osu.Game/Graphics/UserInterface/OsuMenu.cs +++ b/osu.Game/Graphics/UserInterface/OsuMenu.cs @@ -42,6 +42,25 @@ namespace osu.Game.Graphics.UserInterface sampleClose = audio.Samples.Get(@"UI/dropdown-close"); } + protected override void Update() + { + base.Update(); + + bool showCheckboxes = false; + + foreach (var drawableItem in ItemsContainer) + { + if (drawableItem.Item is StatefulMenuItem) + showCheckboxes = true; + } + + foreach (var drawableItem in ItemsContainer) + { + if (drawableItem is DrawableOsuMenuItem osuItem) + osuItem.ShowCheckbox.Value = showCheckboxes; + } + } + protected override void AnimateOpen() { if (!TopLevelMenu && !wasOpened) @@ -109,7 +128,7 @@ namespace osu.Game.Graphics.UserInterface Colour = BackgroundColourHover, RelativeSizeAxes = Axes.X, Height = 2f, - Width = 0.8f, + Width = 0.9f, }); } diff --git a/osu.Game/Graphics/UserInterface/OsuMenuItem.cs b/osu.Game/Graphics/UserInterface/OsuMenuItem.cs index 20461de08f..f122990a0f 100644 --- a/osu.Game/Graphics/UserInterface/OsuMenuItem.cs +++ b/osu.Game/Graphics/UserInterface/OsuMenuItem.cs @@ -11,6 +11,8 @@ namespace osu.Game.Graphics.UserInterface { public readonly MenuItemType Type; + public Hotkey Hotkey { get; init; } + public OsuMenuItem(LocalisableString text, MenuItemType type = MenuItemType.Standard) : this(text, type, null) { diff --git a/osu.Game/Graphics/UserInterface/OsuNumberBox.cs b/osu.Game/Graphics/UserInterface/OsuNumberBox.cs index e9b28f4771..db4b7b2ab3 100644 --- a/osu.Game/Graphics/UserInterface/OsuNumberBox.cs +++ b/osu.Game/Graphics/UserInterface/OsuNumberBox.cs @@ -7,6 +7,11 @@ namespace osu.Game.Graphics.UserInterface { protected override bool AllowIme => false; + public OsuNumberBox() + { + SelectAllOnFocus = true; + } + protected override bool CanAddCharacter(char character) => char.IsAsciiDigit(character); } } diff --git a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs index 191a7ca246..334fe343ae 100644 --- a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs +++ b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Numerics; using System.Globalization; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -15,7 +16,7 @@ using osu.Game.Utils; namespace osu.Game.Graphics.UserInterface { public abstract partial class OsuSliderBar : SliderBar, IHasTooltip - where T : struct, IEquatable, IComparable, IConvertible + where T : struct, INumber, IMinMaxValue { public bool PlaySamplesOnAdjust { get; set; } = true; @@ -45,7 +46,7 @@ namespace osu.Game.Graphics.UserInterface protected override void LoadComplete() { base.LoadComplete(); - CurrentNumber.BindValueChanged(current => TooltipText = getTooltipText(current.NewValue), true); + CurrentNumber.BindValueChanged(current => TooltipText = GetDisplayableValue(current.NewValue), true); } protected override void OnUserChange(T value) @@ -54,7 +55,7 @@ namespace osu.Game.Graphics.UserInterface playSample(value); - TooltipText = getTooltipText(value); + TooltipText = GetDisplayableValue(value); } private void playSample(T value) @@ -82,14 +83,14 @@ namespace osu.Game.Graphics.UserInterface channel.Play(); } - private LocalisableString getTooltipText(T value) + public LocalisableString GetDisplayableValue(T value) { if (CurrentNumber.IsInteger) - return value.ToInt32(NumberFormatInfo.InvariantInfo).ToString("N0"); + return int.CreateTruncating(value).ToString("N0"); - double floatValue = value.ToDouble(NumberFormatInfo.InvariantInfo); + double floatValue = double.CreateTruncating(value); - decimal decimalPrecision = normalise(CurrentNumber.Precision.ToDecimal(NumberFormatInfo.InvariantInfo), max_decimal_digits); + decimal decimalPrecision = normalise(decimal.CreateTruncating(CurrentNumber.Precision), max_decimal_digits); // Find the number of significant digits (we could have less than 5 after normalize()) int significantDigits = FormatUtils.FindPrecision(decimalPrecision); diff --git a/osu.Game/Graphics/UserInterface/OsuTabDropdown.cs b/osu.Game/Graphics/UserInterface/OsuTabDropdown.cs index 6272f95510..7a17be57a8 100644 --- a/osu.Game/Graphics/UserInterface/OsuTabDropdown.cs +++ b/osu.Game/Graphics/UserInterface/OsuTabDropdown.cs @@ -122,8 +122,6 @@ namespace osu.Game.Graphics.UserInterface Anchor = Anchor.Centre, } }; - - Padding = new MarginPadding { Left = 5, Right = 5 }; } protected override bool OnHover(HoverEvent e) diff --git a/osu.Game/Graphics/UserInterface/OsuTextBox.cs b/osu.Game/Graphics/UserInterface/OsuTextBox.cs index 08d38837f6..6388f56f61 100644 --- a/osu.Game/Graphics/UserInterface/OsuTextBox.cs +++ b/osu.Game/Graphics/UserInterface/OsuTextBox.cs @@ -63,6 +63,11 @@ namespace osu.Game.Graphics.UserInterface private Dictionary sampleMap = new Dictionary(); + /// + /// Whether all text should be selected when the gains focus. + /// + public bool SelectAllOnFocus { get; set; } + public OsuTextBox() { Height = 40; @@ -255,6 +260,10 @@ namespace osu.Game.Graphics.UserInterface BorderThickness = 3; base.OnFocus(e); + + // we may become focused from an ongoing drag operation, we don't want to overwrite selection in that case. + if (SelectAllOnFocus && string.IsNullOrEmpty(SelectedText)) + SelectAll(); } protected override void OnFocusLost(FocusLostEvent e) diff --git a/osu.Game/Graphics/UserInterface/RangeSlider.cs b/osu.Game/Graphics/UserInterface/RangeSlider.cs index f83dff6295..422c2ca4a3 100644 --- a/osu.Game/Graphics/UserInterface/RangeSlider.cs +++ b/osu.Game/Graphics/UserInterface/RangeSlider.cs @@ -98,7 +98,7 @@ namespace osu.Game.Graphics.UserInterface { const float vertical_offset = 13; - InternalChildren = new Drawable[] + InternalChildren = new[] { label = new OsuSpriteText { @@ -115,7 +115,9 @@ namespace osu.Game.Graphics.UserInterface KeyboardStep = 0.1f, RelativeSizeAxes = Axes.X, Y = vertical_offset, - } + }, + upperBound.Nub.CreateProxy(), + lowerBound.Nub.CreateProxy(), }; } @@ -160,6 +162,8 @@ namespace osu.Game.Graphics.UserInterface protected partial class BoundSlider : RoundedSliderBar { + public new Nub Nub => base.Nub; + public string? DefaultString; public LocalisableString? DefaultTooltip; public string? TooltipSuffix; diff --git a/osu.Game/Graphics/UserInterface/RoundedSliderBar.cs b/osu.Game/Graphics/UserInterface/RoundedSliderBar.cs index 0981881ead..aeab7c34b2 100644 --- a/osu.Game/Graphics/UserInterface/RoundedSliderBar.cs +++ b/osu.Game/Graphics/UserInterface/RoundedSliderBar.cs @@ -2,20 +2,22 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osuTK; +using System.Numerics; using osuTK.Graphics; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Game.Overlays; +using Vector2 = osuTK.Vector2; namespace osu.Game.Graphics.UserInterface { public partial class RoundedSliderBar : OsuSliderBar - where T : struct, IEquatable, IComparable, IConvertible + where T : struct, INumber, IMinMaxValue { protected readonly Nub Nub; protected readonly Box LeftBox; @@ -24,6 +26,8 @@ namespace osu.Game.Graphics.UserInterface private readonly HoverClickSounds hoverClickSounds; + private readonly Container mainContent; + private Color4 accentColour; public Color4 AccentColour @@ -61,7 +65,7 @@ namespace osu.Game.Graphics.UserInterface Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Padding = new MarginPadding { Horizontal = 2 }, - Child = new CircularContainer + Child = mainContent = new CircularContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, @@ -134,6 +138,26 @@ namespace osu.Game.Graphics.UserInterface }, true); } + protected override void OnFocus(FocusEvent e) + { + base.OnFocus(e); + + mainContent.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = AccentColour.Darken(1), + Hollow = true, + Radius = 2, + }; + } + + protected override void OnFocusLost(FocusLostEvent e) + { + base.OnFocusLost(e); + + mainContent.EdgeEffect = default; + } + protected override bool OnHover(HoverEvent e) { updateGlow(); diff --git a/osu.Game/Graphics/UserInterface/SectionHeader.cs b/osu.Game/Graphics/UserInterface/SectionHeader.cs new file mode 100644 index 0000000000..0ee430c501 --- /dev/null +++ b/osu.Game/Graphics/UserInterface/SectionHeader.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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; +using osu.Game.Graphics.Containers; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Graphics.UserInterface +{ + public partial class SectionHeader : CompositeDrawable + { + private readonly LocalisableString text; + + public SectionHeader(LocalisableString text) + { + this.text = text; + + Margin = new MarginPadding { Vertical = 10, Horizontal = 5 }; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(2), + Children = new Drawable[] + { + new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: 16, weight: FontWeight.SemiBold)) + { + Text = text, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }, + new Circle + { + Colour = colourProvider.Highlight1, + Size = new Vector2(28, 2), + } + } + }; + } + } +} diff --git a/osu.Game/Graphics/UserInterface/ShearedButton.cs b/osu.Game/Graphics/UserInterface/ShearedButton.cs index b1e7066a01..87d269ccd4 100644 --- a/osu.Game/Graphics/UserInterface/ShearedButton.cs +++ b/osu.Game/Graphics/UserInterface/ShearedButton.cs @@ -17,7 +17,7 @@ namespace osu.Game.Graphics.UserInterface { public partial class ShearedButton : OsuClickableContainer { - public const float HEIGHT = 50; + public const float DEFAULT_HEIGHT = 50; public const float CORNER_RADIUS = 7; public const float BORDER_THICKNESS = 2; @@ -66,7 +66,7 @@ namespace osu.Game.Graphics.UserInterface private readonly Box background; private readonly OsuSpriteText text; - private const float shear = 0.2f; + private const float shear = OsuGame.SHEAR; private Colour4? darkerColour; private Colour4? lighterColour; @@ -75,6 +75,8 @@ namespace osu.Game.Graphics.UserInterface private readonly Container backgroundLayer; private readonly Box flashLayer; + protected readonly Container ButtonContent; + /// /// Creates a new /// @@ -85,10 +87,11 @@ namespace osu.Game.Graphics.UserInterface /// If a value is provided (or the argument is omitted entirely), the button will autosize in width to fit the text. /// /// - public ShearedButton(float? width = null) + /// The height of the button. + public ShearedButton(float? width = null, float height = DEFAULT_HEIGHT) { - Height = HEIGHT; - Padding = new MarginPadding { Horizontal = shear * 50 }; + Height = height; + Padding = new MarginPadding { Horizontal = shear * height }; Content.CornerRadius = CORNER_RADIUS; Content.Shear = new Vector2(shear, 0); @@ -109,12 +112,16 @@ namespace osu.Game.Graphics.UserInterface { RelativeSizeAxes = Axes.Both }, - text = new OsuSpriteText + ButtonContent = new Container { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Font = OsuFont.TorusAlternate.With(size: 17), - Shear = new Vector2(-shear, 0) + AutoSizeAxes = Axes.Both, + Shear = new Vector2(-shear, 0), + Child = text = new OsuSpriteText + { + Font = OsuFont.TorusAlternate.With(size: 17), + } }, } }, @@ -188,7 +195,7 @@ namespace osu.Game.Graphics.UserInterface { var colourDark = darkerColour ?? ColourProvider.Background3; var colourLight = lighterColour ?? ColourProvider.Background1; - var colourText = textColour ?? ColourProvider.Content1; + var colourContent = textColour ?? ColourProvider.Content1; if (!Enabled.Value) { @@ -205,9 +212,9 @@ namespace osu.Game.Graphics.UserInterface backgroundLayer.TransformTo(nameof(BorderColour), ColourInfo.GradientVertical(colourDark, colourLight), 150, Easing.OutQuint); if (!Enabled.Value) - colourText = colourText.Opacity(0.6f); + colourContent = colourContent.Opacity(0.6f); - text.FadeColour(colourText, 150, Easing.OutQuint); + ButtonContent.FadeColour(colourContent, 150, Easing.OutQuint); } } } diff --git a/osu.Game/Graphics/UserInterface/ShearedSearchTextBox.cs b/osu.Game/Graphics/UserInterface/ShearedSearchTextBox.cs index c3a9f8a586..c6565726b5 100644 --- a/osu.Game/Graphics/UserInterface/ShearedSearchTextBox.cs +++ b/osu.Game/Graphics/UserInterface/ShearedSearchTextBox.cs @@ -11,7 +11,6 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; using osu.Game.Graphics.Sprites; using osu.Game.Overlays; -using osu.Game.Overlays.Mods; using osu.Game.Resources.Localisation.Web; using osuTK; @@ -53,7 +52,7 @@ namespace osu.Game.Graphics.UserInterface public ShearedSearchTextBox() { Height = 42; - Shear = new Vector2(ShearedOverlayContainer.SHEAR, 0); + Shear = new Vector2(OsuGame.SHEAR, 0); Masking = true; CornerRadius = corner_radius; @@ -116,7 +115,7 @@ namespace osu.Game.Graphics.UserInterface PlaceholderText = CommonStrings.InputSearch; CornerRadius = corner_radius; - TextContainer.Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0); + TextContainer.Shear = new Vector2(-OsuGame.SHEAR, 0); } protected override SpriteText CreatePlaceholder() => new SearchPlaceholder(); diff --git a/osu.Game/Graphics/UserInterface/ShearedSliderBar.cs b/osu.Game/Graphics/UserInterface/ShearedSliderBar.cs index 60a6670492..a36b9c7a4c 100644 --- a/osu.Game/Graphics/UserInterface/ShearedSliderBar.cs +++ b/osu.Game/Graphics/UserInterface/ShearedSliderBar.cs @@ -2,21 +2,23 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osuTK; +using System.Numerics; using osuTK.Graphics; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Game.Overlays; using static osu.Game.Graphics.UserInterface.ShearedNub; +using Vector2 = osuTK.Vector2; namespace osu.Game.Graphics.UserInterface { public partial class ShearedSliderBar : OsuSliderBar - where T : struct, IEquatable, IComparable, IConvertible + where T : struct, INumber, IMinMaxValue { protected readonly ShearedNub Nub; protected readonly Box LeftBox; @@ -25,6 +27,8 @@ namespace osu.Game.Graphics.UserInterface private readonly HoverClickSounds hoverClickSounds; + private readonly Container mainContent; + private Color4 accentColour; public Color4 AccentColour @@ -59,12 +63,13 @@ namespace osu.Game.Graphics.UserInterface RangePadding = EXPANDED_SIZE / 2; Children = new Drawable[] { - new Container + mainContent = new Container { RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 5, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Padding = new MarginPadding { Horizontal = 2 }, Child = new Container { RelativeSizeAxes = Axes.Both, @@ -137,6 +142,26 @@ namespace osu.Game.Graphics.UserInterface }, true); } + protected override void OnFocus(FocusEvent e) + { + base.OnFocus(e); + + mainContent.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = AccentColour.Darken(1), + Hollow = true, + Radius = 2, + }; + } + + protected override void OnFocusLost(FocusLostEvent e) + { + base.OnFocusLost(e); + + mainContent.EdgeEffect = default; + } + protected override bool OnHover(HoverEvent e) { updateGlow(); @@ -166,8 +191,8 @@ namespace osu.Game.Graphics.UserInterface protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); - LeftBox.Scale = new Vector2(Math.Clamp(RangePadding + Nub.DrawPosition.X - Nub.DrawWidth / 2.15f, 0, Math.Max(0, DrawWidth)), 1); - RightBox.Scale = new Vector2(Math.Clamp(DrawWidth - Nub.DrawPosition.X - RangePadding - Nub.DrawWidth / 2.15f, 0, Math.Max(0, DrawWidth)), 1); + LeftBox.Scale = new Vector2(Math.Clamp(RangePadding + Nub.DrawPosition.X - Nub.DrawWidth / 2.3f, 0, Math.Max(0, DrawWidth)), 1); + RightBox.Scale = new Vector2(Math.Clamp(DrawWidth - Nub.DrawPosition.X - RangePadding - Nub.DrawWidth / 2.3f, 0, Math.Max(0, DrawWidth)), 1); } protected override void UpdateValue(float value) diff --git a/osu.Game/Graphics/UserInterface/TernaryStateMenuItem.cs b/osu.Game/Graphics/UserInterface/TernaryStateMenuItem.cs index d2b6ff2dba..f98628a486 100644 --- a/osu.Game/Graphics/UserInterface/TernaryStateMenuItem.cs +++ b/osu.Game/Graphics/UserInterface/TernaryStateMenuItem.cs @@ -5,6 +5,7 @@ using System; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; namespace osu.Game.Graphics.UserInterface { @@ -20,7 +21,7 @@ namespace osu.Game.Graphics.UserInterface /// A function to inform what the next state should be when this item is clicked. /// The type of action which this performs. /// A delegate to be invoked when this is pressed. - protected TernaryStateMenuItem(string text, Func nextStateFunction, MenuItemType type = MenuItemType.Standard, Action action = null) + protected TernaryStateMenuItem(LocalisableString text, Func nextStateFunction, MenuItemType type = MenuItemType.Standard, Action action = null) : base(text, nextStateFunction, type, action) { } diff --git a/osu.Game/Graphics/UserInterface/TernaryStateRadioMenuItem.cs b/osu.Game/Graphics/UserInterface/TernaryStateRadioMenuItem.cs index 133362d3e6..30fea62cd7 100644 --- a/osu.Game/Graphics/UserInterface/TernaryStateRadioMenuItem.cs +++ b/osu.Game/Graphics/UserInterface/TernaryStateRadioMenuItem.cs @@ -4,6 +4,7 @@ #nullable disable using System; +using osu.Framework.Localisation; namespace osu.Game.Graphics.UserInterface { @@ -18,7 +19,7 @@ namespace osu.Game.Graphics.UserInterface /// The text to display. /// The type of action which this performs. /// A delegate to be invoked when this is pressed. - public TernaryStateRadioMenuItem(string text, MenuItemType type = MenuItemType.Standard, Action action = null) + public TernaryStateRadioMenuItem(LocalisableString text, MenuItemType type = MenuItemType.Standard, Action action = null) : base(text, getNextState, type, action) { } diff --git a/osu.Game/Graphics/UserInterface/TwoLayerButton.cs b/osu.Game/Graphics/UserInterface/TwoLayerButton.cs index 5532e5c6a7..6f61a14b75 100644 --- a/osu.Game/Graphics/UserInterface/TwoLayerButton.cs +++ b/osu.Game/Graphics/UserInterface/TwoLayerButton.cs @@ -12,7 +12,6 @@ using osu.Game.Graphics.Containers; using osu.Game.Beatmaps.ControlPoints; using osu.Framework.Audio.Track; using System; -using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; @@ -57,15 +56,15 @@ namespace osu.Game.Graphics.UserInterface set { base.Origin = value; - c1.Origin = c1.Anchor = value.HasFlagFast(Anchor.x2) ? Anchor.TopLeft : Anchor.TopRight; - c2.Origin = c2.Anchor = value.HasFlagFast(Anchor.x2) ? Anchor.TopRight : Anchor.TopLeft; + c1.Origin = c1.Anchor = value.HasFlag(Anchor.x2) ? Anchor.TopLeft : Anchor.TopRight; + c2.Origin = c2.Anchor = value.HasFlag(Anchor.x2) ? Anchor.TopRight : Anchor.TopLeft; - X = value.HasFlagFast(Anchor.x2) ? SIZE_RETRACTED.X * shear.X * 0.5f : 0; + X = value.HasFlag(Anchor.x2) ? SIZE_RETRACTED.X * shear.X * 0.5f : 0; Remove(c1, false); Remove(c2, false); - c1.Depth = value.HasFlagFast(Anchor.x2) ? 0 : 1; - c2.Depth = value.HasFlagFast(Anchor.x2) ? 1 : 0; + c1.Depth = value.HasFlag(Anchor.x2) ? 0 : 1; + c2.Depth = value.HasFlag(Anchor.x2) ? 1 : 0; Add(c1); Add(c2); } diff --git a/osu.Game/Graphics/UserInterfaceV2/FileSelection/BackgroundLayer.cs b/osu.Game/Graphics/UserInterfaceV2/FileSelection/BackgroundLayer.cs new file mode 100644 index 0000000000..cd3199c6f5 --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/FileSelection/BackgroundLayer.cs @@ -0,0 +1,59 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; + +namespace osu.Game.Graphics.UserInterfaceV2.FileSelection +{ + internal partial class BackgroundLayer : CompositeDrawable + { + private Box background = null!; + + private readonly float defaultAlpha; + + public BackgroundLayer(float defaultAlpha = 0f) + { + Depth = float.MaxValue; + + this.defaultAlpha = defaultAlpha; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider overlayColourProvider) + { + RelativeSizeAxes = Axes.Both; + + Masking = true; + CornerRadius = 5; + + InternalChildren = new Drawable[] + { + new HoverClickSounds(), + background = new Box + { + Alpha = defaultAlpha, + Colour = overlayColourProvider.Background3, + RelativeSizeAxes = Axes.Both, + }, + }; + } + + protected override bool OnHover(HoverEvent e) + { + background.FadeTo(1, 200, Easing.OutQuint); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + background.FadeTo(defaultAlpha, 500, Easing.OutQuint); + } + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorHiddenToggle.cs b/osu.Game/Graphics/UserInterfaceV2/FileSelection/HiddenFilesToggleCheckbox.cs similarity index 72% rename from osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorHiddenToggle.cs rename to osu.Game/Graphics/UserInterfaceV2/FileSelection/HiddenFilesToggleCheckbox.cs index 7665ed507f..07d84a0095 100644 --- a/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorHiddenToggle.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FileSelection/HiddenFilesToggleCheckbox.cs @@ -8,20 +8,23 @@ using osu.Game.Overlays; using osuTK; using osuTK.Graphics; -namespace osu.Game.Graphics.UserInterfaceV2 +namespace osu.Game.Graphics.UserInterfaceV2.FileSelection { - internal partial class OsuDirectorySelectorHiddenToggle : OsuCheckbox + internal partial class HiddenFilesToggleCheckbox : OsuCheckbox { - public OsuDirectorySelectorHiddenToggle() + public HiddenFilesToggleCheckbox() { RelativeSizeAxes = Axes.None; AutoSizeAxes = Axes.None; - Size = new Vector2(100, 50); + Size = new Vector2(140, OsuDirectorySelectorBreadcrumbDisplay.HEIGHT); + Margin = new MarginPadding { Right = OsuDirectorySelectorBreadcrumbDisplay.HORIZONTAL_PADDING, }; Anchor = Anchor.CentreLeft; Origin = Anchor.CentreLeft; LabelTextFlowContainer.Anchor = Anchor.CentreLeft; LabelTextFlowContainer.Origin = Anchor.CentreLeft; LabelText = @"Show hidden"; + + Scale = new Vector2(0.8f); } [BackgroundDependencyLoader(true)] diff --git a/osu.Game/Graphics/UserInterfaceV2/FileSelection/OsuDirectorySelectorBreadcrumbDisplay.cs b/osu.Game/Graphics/UserInterfaceV2/FileSelection/OsuDirectorySelectorBreadcrumbDisplay.cs new file mode 100644 index 0000000000..aeeda82bfb --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/FileSelection/OsuDirectorySelectorBreadcrumbDisplay.cs @@ -0,0 +1,106 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.IO; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Graphics.UserInterfaceV2.FileSelection +{ + internal partial class OsuDirectorySelectorBreadcrumbDisplay : DirectorySelectorBreadcrumbDisplay + { + public const float HEIGHT = 45; + public const float HORIZONTAL_PADDING = 20; + + protected override Drawable CreateCaption() => Empty().With(d => + { + d.Origin = Anchor.CentreLeft; + d.Anchor = Anchor.CentreLeft; + d.Alpha = 0; + }); + + protected override DirectorySelectorDirectory CreateRootDirectoryItem() => new OsuBreadcrumbDisplayComputer(); + + protected override DirectorySelectorDirectory CreateDirectoryItem(DirectoryInfo directory, string? displayName = null) => new OsuBreadcrumbDisplayDirectory(directory, displayName); + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + ((FillFlowContainer)InternalChild).Padding = new MarginPadding + { + Horizontal = HORIZONTAL_PADDING, + Vertical = 10, + }; + + AddInternal(new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4, + Depth = 1, + }); + } + + private partial class OsuBreadcrumbDisplayComputer : OsuBreadcrumbDisplayDirectory + { + protected override IconUsage? Icon => null; + + public OsuBreadcrumbDisplayComputer() + : base(null, "Computer") + { + } + } + + private partial class OsuBreadcrumbDisplayDirectory : DirectorySelectorDirectory + { + public OsuBreadcrumbDisplayDirectory(DirectoryInfo? directory, string? displayName = null) + : base(directory, displayName) + { + } + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + Anchor = Anchor.CentreLeft; + Origin = Anchor.CentreLeft; + + Flow.AutoSizeAxes = Axes.X; + Flow.Height = 25; + Flow.Margin = new MarginPadding { Horizontal = 10, }; + + AddRangeInternal(new Drawable[] + { + new BackgroundLayer(0.5f) + { + Depth = 1 + }, + new HoverClickSounds(), + }); + + Flow.Add(new SpriteIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Icon = FontAwesome.Solid.ChevronRight, + Size = new Vector2(FONT_SIZE / 2), + Margin = new MarginPadding { Left = 5, }, + }); + Flow.Colour = colourProvider.Light3; + } + + protected override SpriteText CreateSpriteText() => new OsuSpriteText().With(t => t.Font = OsuFont.Default.With(weight: FontWeight.SemiBold)); + + protected override IconUsage? Icon => Directory.Name.Contains(Path.DirectorySeparatorChar) ? FontAwesome.Solid.Database : null; + } + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/FileSelection/OsuDirectorySelectorDirectory.cs b/osu.Game/Graphics/UserInterfaceV2/FileSelection/OsuDirectorySelectorDirectory.cs new file mode 100644 index 0000000000..0da4e1929f --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/FileSelection/OsuDirectorySelectorDirectory.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.IO; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Graphics.UserInterfaceV2.FileSelection +{ + internal partial class OsuDirectorySelectorDirectory : DirectorySelectorDirectory + { + public OsuDirectorySelectorDirectory(DirectoryInfo directory, string? displayName = null) + : base(directory, displayName) + { + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Flow.AutoSizeAxes = Axes.X; + Flow.Height = OsuDirectorySelector.ITEM_HEIGHT; + + AddInternal(new BackgroundLayer()); + + Colour = colours.Orange1; + } + + protected override SpriteText CreateSpriteText() => new OsuSpriteText().With(t => t.Font = OsuFont.Default.With(weight: FontWeight.Bold)); + + protected override IconUsage? Icon => Directory.Name.Contains(Path.DirectorySeparatorChar) + ? FontAwesome.Solid.Database + : FontAwesome.Regular.Folder; + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorParentDirectory.cs b/osu.Game/Graphics/UserInterfaceV2/FileSelection/OsuDirectorySelectorParentDirectory.cs similarity index 64% rename from osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorParentDirectory.cs rename to osu.Game/Graphics/UserInterfaceV2/FileSelection/OsuDirectorySelectorParentDirectory.cs index c0ac9f21ca..e5e1e0b7f3 100644 --- a/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorParentDirectory.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FileSelection/OsuDirectorySelectorParentDirectory.cs @@ -2,9 +2,11 @@ // See the LICENCE file in the repository root for full licence text. using System.IO; +using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; +using osu.Game.Overlays; -namespace osu.Game.Graphics.UserInterfaceV2 +namespace osu.Game.Graphics.UserInterfaceV2.FileSelection { internal partial class OsuDirectorySelectorParentDirectory : OsuDirectorySelectorDirectory { @@ -14,5 +16,11 @@ namespace osu.Game.Graphics.UserInterfaceV2 : base(directory, "..") { } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Colour = colourProvider.Content1; + } } } diff --git a/osu.Game/Graphics/UserInterfaceV2/FormCheckBox.cs b/osu.Game/Graphics/UserInterfaceV2/FormCheckBox.cs new file mode 100644 index 0000000000..d4cd86010f --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/FormCheckBox.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 osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; +using osu.Game.Overlays; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public partial class FormCheckBox : CompositeDrawable, IHasCurrentValue + { + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + /// + /// Caption describing this slider bar, displayed on top of the controls. + /// + public LocalisableString Caption { get; init; } + + /// + /// Hint text containing an extended description of this slider bar, displayed in a tooltip when hovering the caption. + /// + public LocalisableString HintText { get; init; } + + private Box background = null!; + private FormFieldCaption caption = null!; + private OsuSpriteText text = null!; + private Nub checkbox = null!; + + private Sample? sampleChecked; + private Sample? sampleUnchecked; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + RelativeSizeAxes = Axes.X; + Height = 50; + + Masking = true; + CornerRadius = 5; + CornerExponent = 2.5f; + + InternalChildren = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(9), + Children = new Drawable[] + { + caption = new FormFieldCaption + { + Caption = Caption, + TooltipText = HintText, + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + }, + text = new OsuSpriteText + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + }, + checkbox = new Nub + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Current = Current, + }, + }, + }, + }; + + sampleChecked = audio.Samples.Get(@"UI/check-on"); + sampleUnchecked = audio.Samples.Get(@"UI/check-off"); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + current.BindValueChanged(_ => + { + updateState(); + playSamples(); + background.FlashColour(ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Dark2), 800, Easing.OutQuint); + }); + current.BindDisabledChanged(_ => updateState(), true); + } + + private void playSamples() + { + if (Current.Value) + sampleChecked?.Play(); + else + sampleUnchecked?.Play(); + } + + protected override bool OnHover(HoverEvent e) + { + updateState(); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + updateState(); + } + + protected override bool OnClick(ClickEvent e) + { + if (!Current.Disabled) + Current.Value = !Current.Value; + return true; + } + + private void updateState() + { + background.Colour = Current.Disabled ? colourProvider.Background4 : colourProvider.Background5; + caption.Colour = Current.Disabled ? colourProvider.Foreground1 : colourProvider.Content2; + checkbox.Colour = Current.Disabled ? colourProvider.Foreground1 : colourProvider.Content1; + text.Colour = Current.Disabled ? colourProvider.Foreground1 : colourProvider.Content1; + + text.Text = Current.Value ? CommonStrings.Enabled : CommonStrings.Disabled; + + if (!Current.Disabled) + { + BorderThickness = IsHovered ? 2 : 0; + + if (IsHovered) + BorderColour = colourProvider.Light4; + } + } + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/FormColourPalette.cs b/osu.Game/Graphics/UserInterfaceV2/FormColourPalette.cs new file mode 100644 index 0000000000..fad58841e3 --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/FormColourPalette.cs @@ -0,0 +1,238 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Specialized; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osu.Game.Resources.Localisation.Web; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public partial class FormColourPalette : CompositeDrawable + { + public BindableList Colours { get; } = new BindableList(); + + public LocalisableString Caption { get; init; } + public LocalisableString HintText { get; init; } + + private Box background = null!; + private FormFieldCaption caption = null!; + private FillFlowContainer flow = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + Masking = true; + CornerRadius = 5; + + RoundedButton button; + + InternalChildren = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5, + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(9), + Spacing = new Vector2(7), + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + caption = new FormFieldCaption + { + Caption = Caption, + TooltipText = HintText, + }, + flow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Full, + Spacing = new Vector2(5), + Child = button = new RoundedButton + { + Action = addNewColour, + Size = new Vector2(70), + Text = "+", + } + } + }, + }, + }; + + flow.SetLayoutPosition(button, float.MaxValue); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Colours.BindCollectionChanged((_, args) => + { + if (args.Action != NotifyCollectionChangedAction.Replace) + updateColours(); + }, true); + updateState(); + } + + protected override bool OnHover(HoverEvent e) + { + updateState(); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + updateState(); + } + + private void addNewColour() + { + Color4 startingColour = Colours.Count > 0 + ? Colours.Last() + : Colour4.White; + + Colours.Add(startingColour); + flow.OfType().Last().TriggerClick(); + } + + private void updateState() + { + background.Colour = colourProvider.Background5; + caption.Colour = colourProvider.Content2; + + BorderThickness = IsHovered ? 2 : 0; + + if (IsHovered) + BorderColour = colourProvider.Light4; + } + + private void updateColours() + { + flow.RemoveAll(d => d is ColourButton, true); + + for (int i = 0; i < Colours.Count; ++i) + { + // copy to avoid accesses to modified closure. + int colourIndex = i; + var colourButton = new ColourButton { Current = { Value = Colours[colourIndex] } }; + colourButton.Current.BindValueChanged(colour => Colours[colourIndex] = colour.NewValue); + colourButton.DeleteRequested = () => Colours.RemoveAt(colourIndex); + flow.Add(colourButton); + } + } + + private partial class ColourButton : OsuClickableContainer, IHasPopover, IHasContextMenu + { + public Bindable Current { get; } = new Bindable(); + public Action? DeleteRequested { get; set; } + + private Box background = null!; + private OsuSpriteText hexCode = null!; + + [BackgroundDependencyLoader] + private void load() + { + Size = new Vector2(70); + + Masking = true; + CornerRadius = 35; + Action = this.ShowPopover; + + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + }, + hexCode = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(_ => updateState(), true); + } + + public Popover GetPopover() => new ColourPickerPopover + { + Current = { BindTarget = Current } + }; + + public MenuItem[] ContextMenuItems => new MenuItem[] + { + new OsuMenuItem(CommonStrings.ButtonsDelete, MenuItemType.Destructive, () => DeleteRequested?.Invoke()) + }; + + private void updateState() + { + background.Colour = Current.Value; + hexCode.Text = Current.Value.ToHex(); + hexCode.Colour = OsuColour.ForegroundTextColourFor(Current.Value); + } + } + + private partial class ColourPickerPopover : OsuPopover, IHasCurrentValue + { + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + public ColourPickerPopover() + : base(false) + { + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Child = new OsuColourPicker + { + Current = { BindTarget = Current } + }; + + Body.BorderThickness = 2; + Body.BorderColour = colourProvider.Highlight1; + Content.Padding = new MarginPadding(2); + } + } + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/FormDropdown.cs b/osu.Game/Graphics/UserInterfaceV2/FormDropdown.cs new file mode 100644 index 0000000000..d47b9ac73d --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/FormDropdown.cs @@ -0,0 +1,251 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public partial class FormDropdown : OsuDropdown + { + /// + /// Caption describing this slider bar, displayed on top of the controls. + /// + public LocalisableString Caption { get; init; } + + /// + /// Hint text containing an extended description of this slider bar, displayed in a tooltip when hovering the caption. + /// + public LocalisableString HintText { get; init; } + + private FormDropdownHeader header = null!; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + + header.Caption = Caption; + header.HintText = HintText; + } + + protected override DropdownHeader CreateHeader() => header = new FormDropdownHeader + { + Dropdown = this, + }; + + protected override DropdownMenu CreateMenu() => new FormDropdownMenu(); + + private partial class FormDropdownHeader : DropdownHeader + { + public FormDropdown Dropdown { get; set; } = null!; + + protected override DropdownSearchBar CreateSearchBar() => SearchBar = new FormDropdownSearchBar(); + + private LocalisableString captionText; + private LocalisableString hintText; + private LocalisableString labelText; + + public LocalisableString Caption + { + get => captionText; + set + { + captionText = value; + + if (caption.IsNotNull()) + caption.Caption = value; + } + } + + public LocalisableString HintText + { + get => hintText; + set + { + hintText = value; + + if (caption.IsNotNull()) + caption.TooltipText = value; + } + } + + protected override LocalisableString Label + { + get => labelText; + set + { + labelText = value; + + if (label.IsNotNull()) + label.Text = labelText; + } + } + + protected new FormDropdownSearchBar SearchBar { get; set; } = null!; + + private FormFieldCaption caption = null!; + private OsuSpriteText label = null!; + private SpriteIcon chevron = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.None; + Height = 50; + + Masking = true; + CornerRadius = 5; + + Foreground.AutoSizeAxes = Axes.None; + Foreground.RelativeSizeAxes = Axes.Both; + Foreground.Padding = new MarginPadding(9); + Foreground.Children = new Drawable[] + { + caption = new FormFieldCaption + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + Caption = Caption, + TooltipText = HintText, + }, + label = new OsuSpriteText + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + }, + chevron = new SpriteIcon + { + Icon = FontAwesome.Solid.ChevronDown, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Size = new Vector2(16), + }, + }; + + AddInternal(new HoverClickSounds()); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Dropdown.Current.BindDisabledChanged(_ => updateState()); + SearchBar.SearchTerm.BindValueChanged(_ => updateState(), true); + Dropdown.Menu.StateChanged += _ => + { + updateState(); + updateChevron(); + }; + SearchBar.TextBox.OnCommit += (_, _) => + { + Background.FlashColour(ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Dark2), 800, Easing.OutQuint); + }; + } + + protected override bool OnHover(HoverEvent e) + { + updateState(); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + updateState(); + } + + private void updateState() + { + label.Alpha = string.IsNullOrEmpty(SearchBar.SearchTerm.Value) ? 1 : 0; + + caption.Colour = Dropdown.Current.Disabled ? colourProvider.Foreground1 : colourProvider.Content2; + label.Colour = Dropdown.Current.Disabled ? colourProvider.Foreground1 : colourProvider.Content1; + chevron.Colour = Dropdown.Current.Disabled ? colourProvider.Foreground1 : colourProvider.Content1; + DisabledColour = Colour4.White; + + bool dropdownOpen = Dropdown.Menu.State == MenuState.Open; + + if (!Dropdown.Current.Disabled) + { + BorderThickness = IsHovered || dropdownOpen ? 2 : 0; + BorderColour = dropdownOpen ? colourProvider.Highlight1 : colourProvider.Light4; + + if (dropdownOpen) + Background.Colour = ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Dark3); + else if (IsHovered) + Background.Colour = ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Dark4); + else + Background.Colour = colourProvider.Background5; + } + else + { + Background.Colour = colourProvider.Background4; + } + } + + private void updateChevron() + { + bool open = Dropdown.Menu.State == MenuState.Open; + chevron.ScaleTo(open ? new Vector2(1f, -1f) : Vector2.One, 300, Easing.OutQuint); + } + } + + private partial class FormDropdownSearchBar : DropdownSearchBar + { + public FormTextBox.InnerTextBox TextBox { get; private set; } = null!; + + protected override void PopIn() => this.FadeIn(); + protected override void PopOut() => this.FadeOut(); + + protected override TextBox CreateTextBox() => TextBox = new FormTextBox.InnerTextBox(); + + [BackgroundDependencyLoader] + private void load() + { + TextBox.Anchor = Anchor.BottomLeft; + TextBox.Origin = Anchor.BottomLeft; + TextBox.RelativeSizeAxes = Axes.X; + TextBox.Margin = new MarginPadding(9); + } + } + + private partial class FormDropdownMenu : OsuDropdownMenu + { + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + ItemsContainer.Padding = new MarginPadding(9); + Margin = new MarginPadding { Top = 5 }; + + MaskingContainer.BorderThickness = 2; + MaskingContainer.BorderColour = colourProvider.Highlight1; + } + } + } + + public partial class FormEnumDropdown : FormDropdown + where T : struct, Enum + { + public FormEnumDropdown() + { + Items = Enum.GetValues(); + } + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/FormFieldCaption.cs b/osu.Game/Graphics/UserInterfaceV2/FormFieldCaption.cs new file mode 100644 index 0000000000..75c27618e9 --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/FormFieldCaption.cs @@ -0,0 +1,68 @@ +// 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.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public partial class FormFieldCaption : CompositeDrawable, IHasTooltip + { + private LocalisableString caption; + + public LocalisableString Caption + { + get => caption; + set + { + caption = value; + + if (captionText.IsNotNull()) + captionText.Text = value; + } + } + + private OsuSpriteText captionText = null!; + + public LocalisableString TooltipText { get; set; } + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + + InternalChild = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + Children = new Drawable[] + { + captionText = new OsuSpriteText + { + Text = caption, + Font = OsuFont.Default.With(size: 12, weight: FontWeight.SemiBold), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + new SpriteIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Alpha = TooltipText == default ? 0 : 1, + Size = new Vector2(10), + Icon = FontAwesome.Solid.QuestionCircle, + Margin = new MarginPadding { Top = 1, }, + } + }, + }; + } + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/FormFileSelector.cs b/osu.Game/Graphics/UserInterfaceV2/FormFileSelector.cs new file mode 100644 index 0000000000..81023417a5 --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/FormFileSelector.cs @@ -0,0 +1,297 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +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.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Framework.Platform; +using osu.Game.Database; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public partial class FormFileSelector : CompositeDrawable, IHasCurrentValue, ICanAcceptFiles, IHasPopover + { + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + public IEnumerable HandledExtensions => handledExtensions; + + private readonly string[] handledExtensions; + + /// + /// The initial path to use when displaying the . + /// + /// + /// Uses a value before the first selection is made + /// to ensure that the first selection starts at . + /// + private string? initialChooserPath; + + private readonly Bindable popoverState = new Bindable(); + + /// + /// Caption describing this file selector, displayed on top of the controls. + /// + public LocalisableString Caption { get; init; } + + /// + /// Hint text containing an extended description of this file selector, displayed in a tooltip when hovering the caption. + /// + public LocalisableString HintText { get; init; } + + /// + /// Text displayed in the selector when no file is selected. + /// + public LocalisableString PlaceholderText { get; init; } + + public Container PreviewContainer { get; private set; } = null!; + + private Box background = null!; + + private FormFieldCaption caption = null!; + private OsuSpriteText placeholderText = null!; + private OsuSpriteText filenameText = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [Resolved] + private OsuGameBase game { get; set; } = null!; + + public FormFileSelector(params string[] handledExtensions) + { + this.handledExtensions = handledExtensions; + } + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + Masking = true; + CornerRadius = 5; + + InternalChildren = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5, + }, + PreviewContainer = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding + { + Horizontal = 1.5f, + Top = 1.5f, + Bottom = 50 + }, + }, + new Container + { + RelativeSizeAxes = Axes.X, + Height = 50, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Padding = new MarginPadding(9), + Children = new Drawable[] + { + caption = new FormFieldCaption + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + Caption = Caption, + TooltipText = HintText, + }, + placeholderText = new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.X, + Width = 1, + Text = PlaceholderText, + Colour = colourProvider.Foreground1, + }, + filenameText = new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.X, + Width = 1, + }, + new SpriteIcon + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Icon = FontAwesome.Solid.FolderOpen, + Size = new Vector2(16), + Colour = colourProvider.Light1, + } + }, + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + popoverState.BindValueChanged(_ => updateState()); + current.BindDisabledChanged(_ => updateState()); + current.BindValueChanged(_ => + { + updateState(); + onFileSelected(); + }, true); + FinishTransforms(true); + game.RegisterImportHandler(this); + } + + private void onFileSelected() + { + if (Current.Value != null) + this.HidePopover(); + + initialChooserPath = Current.Value?.DirectoryName; + placeholderText.Alpha = Current.Value == null ? 1 : 0; + filenameText.Text = Current.Value?.Name ?? string.Empty; + background.FlashColour(ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Dark2), 800, Easing.OutQuint); + } + + protected override bool OnClick(ClickEvent e) + { + this.ShowPopover(); + return true; + } + + protected override bool OnHover(HoverEvent e) + { + updateState(); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + updateState(); + } + + private void updateState() + { + caption.Colour = Current.Disabled ? colourProvider.Foreground1 : colourProvider.Content2; + filenameText.Colour = Current.Disabled || Current.Value == null ? colourProvider.Foreground1 : colourProvider.Content1; + + if (!Current.Disabled) + { + BorderThickness = IsHovered || popoverState.Value == Visibility.Visible ? 2 : 0; + BorderColour = popoverState.Value == Visibility.Visible ? colourProvider.Highlight1 : colourProvider.Light4; + + if (popoverState.Value == Visibility.Visible) + background.Colour = ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Dark3); + else if (IsHovered) + background.Colour = ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Dark4); + else + background.Colour = colourProvider.Background5; + } + else + { + background.Colour = colourProvider.Background4; + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (game.IsNotNull()) + game.UnregisterImportHandler(this); + } + + Task ICanAcceptFiles.Import(params string[] paths) + { + Schedule(() => Current.Value = new FileInfo(paths.First())); + return Task.CompletedTask; + } + + Task ICanAcceptFiles.Import(ImportTask[] tasks, ImportParameters parameters) => throw new NotImplementedException(); + + public Popover GetPopover() + { + var popover = new FileChooserPopover(handledExtensions, Current, initialChooserPath); + popoverState.UnbindBindings(); + popoverState.BindTo(popover.State); + return popover; + } + + private partial class FileChooserPopover : OsuPopover + { + protected override string PopInSampleName => "UI/overlay-big-pop-in"; + protected override string PopOutSampleName => "UI/overlay-big-pop-out"; + + public FileChooserPopover(string[] handledExtensions, Bindable currentFile, string? chooserPath) + : base(false) + { + Child = new Container + { + Size = new Vector2(600, 400), + // simplest solution to avoid underlying text to bleed through the bottom border + // https://github.com/ppy/osu/pull/30005#issuecomment-2378884430 + Padding = new MarginPadding { Bottom = 1 }, + Child = new OsuFileSelector(chooserPath, handledExtensions) + { + RelativeSizeAxes = Axes.Both, + CurrentFile = { BindTarget = currentFile } + }, + }; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Add(new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + BorderThickness = 2, + CornerRadius = 10, + BorderColour = colourProvider.Highlight1, + Children = new Drawable[] + { + new Box + { + Colour = Color4.Transparent, + RelativeSizeAxes = Axes.Both, + }, + } + }); + } + } + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/FormNumberBox.cs b/osu.Game/Graphics/UserInterfaceV2/FormNumberBox.cs new file mode 100644 index 0000000000..c3256e0038 --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/FormNumberBox.cs @@ -0,0 +1,26 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Globalization; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public partial class FormNumberBox : FormTextBox + { + public bool AllowDecimals { get; init; } + + internal override InnerTextBox CreateTextBox() => new InnerNumberBox + { + AllowDecimals = AllowDecimals, + SelectAllOnFocus = true, + }; + + internal partial class InnerNumberBox : InnerTextBox + { + public bool AllowDecimals { get; init; } + + protected override bool CanAddCharacter(char character) + => char.IsAsciiDigit(character) || (AllowDecimals && CultureInfo.CurrentCulture.NumberFormat.NumberDecimalSeparator.Contains(character)); + } + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs b/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs new file mode 100644 index 0000000000..532423876e --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs @@ -0,0 +1,426 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Globalization; +using System.Numerics; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; +using osu.Game.Overlays; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public partial class FormSliderBar : CompositeDrawable, IHasCurrentValue + where T : struct, INumber, IMinMaxValue + { + public Bindable Current + { + get => current.Current; + set + { + current.Current = value; + currentNumberInstantaneous.Default = current.Default; + } + } + + private readonly BindableNumberWithCurrent current = new BindableNumberWithCurrent(); + + private readonly BindableNumber currentNumberInstantaneous = new BindableNumber(); + + /// + /// Whether changes to the value should instantaneously transfer to outside bindables. + /// If , the transfer will happen on text box commit (explicit, or implicit via focus loss), or on slider commit. + /// + public bool TransferValueOnCommit { get; set; } + + private CompositeDrawable? tabbableContentContainer; + + public CompositeDrawable? TabbableContentContainer + { + set + { + tabbableContentContainer = value; + + if (textBox.IsNotNull()) + textBox.TabbableContentContainer = tabbableContentContainer; + } + } + + /// + /// Caption describing this slider bar, displayed on top of the controls. + /// + public LocalisableString Caption { get; init; } + + /// + /// Hint text containing an extended description of this slider bar, displayed in a tooltip when hovering the caption. + /// + public LocalisableString HintText { get; init; } + + private Box background = null!; + private Box flashLayer = null!; + private FormTextBox.InnerTextBox textBox = null!; + private InnerSlider slider = null!; + private FormFieldCaption caption = null!; + private IFocusManager focusManager = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + private readonly Bindable currentLanguage = new Bindable(); + + [BackgroundDependencyLoader] + private void load(OsuColour colours, OsuGame? game) + { + RelativeSizeAxes = Axes.X; + Height = 50; + + Masking = true; + CornerRadius = 5; + + InternalChildren = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5, + }, + flashLayer = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.Transparent, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding + { + Vertical = 9, + Left = 9, + Right = 5, + }, + Children = new Drawable[] + { + caption = new FormFieldCaption + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + Caption = Caption, + TooltipText = HintText, + }, + textBox = new FormNumberBox.InnerNumberBox + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.X, + Width = 0.5f, + CommitOnFocusLost = true, + SelectAllOnFocus = true, + AllowDecimals = true, + OnInputError = () => + { + flashLayer.Colour = ColourInfo.GradientVertical(colours.Red3.Opacity(0), colours.Red3); + flashLayer.FadeOutFromOne(200, Easing.OutQuint); + }, + TabbableContentContainer = tabbableContentContainer, + }, + slider = new InnerSlider + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.X, + Width = 0.5f, + Current = currentNumberInstantaneous, + OnCommit = () => current.Value = currentNumberInstantaneous.Value, + } + }, + }, + }; + + if (game != null) + currentLanguage.BindTo(game.CurrentLanguage); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + focusManager = GetContainingFocusManager()!; + + textBox.Focused.BindValueChanged(_ => updateState()); + textBox.OnCommit += textCommitted; + textBox.Current.BindValueChanged(textChanged); + + slider.IsDragging.BindValueChanged(_ => updateState()); + slider.Focused.BindValueChanged(_ => updateState()); + + current.ValueChanged += e => currentNumberInstantaneous.Value = e.NewValue; + current.MinValueChanged += v => currentNumberInstantaneous.MinValue = v; + current.MaxValueChanged += v => currentNumberInstantaneous.MaxValue = v; + current.PrecisionChanged += v => currentNumberInstantaneous.Precision = v; + current.DisabledChanged += disabled => + { + if (disabled) + { + // revert any changes before disabling to make sure we are in a consistent state. + currentNumberInstantaneous.Value = current.Value; + } + + currentNumberInstantaneous.Disabled = disabled; + }; + + current.CopyTo(currentNumberInstantaneous); + currentLanguage.BindValueChanged(_ => Schedule(updateValueDisplay)); + currentNumberInstantaneous.BindValueChanged(e => + { + if (!TransferValueOnCommit) + current.Value = e.NewValue; + + updateState(); + updateValueDisplay(); + }, true); + } + + private bool updatingFromTextBox; + + private void textChanged(ValueChangedEvent change) + { + tryUpdateSliderFromTextBox(); + } + + private void textCommitted(TextBox t, bool isNew) + { + tryUpdateSliderFromTextBox(); + // If the attempted update above failed, restore text box to match the slider. + currentNumberInstantaneous.TriggerChange(); + current.Value = currentNumberInstantaneous.Value; + + flashLayer.Colour = ColourInfo.GradientVertical(colourProvider.Dark2.Opacity(0), colourProvider.Dark2); + flashLayer.FadeOutFromOne(800, Easing.OutQuint); + } + + private void tryUpdateSliderFromTextBox() + { + updatingFromTextBox = true; + + try + { + switch (currentNumberInstantaneous) + { + case Bindable bindableInt: + bindableInt.Value = int.Parse(textBox.Current.Value); + break; + + case Bindable bindableDouble: + bindableDouble.Value = double.Parse(textBox.Current.Value); + break; + + default: + currentNumberInstantaneous.Parse(textBox.Current.Value, CultureInfo.CurrentCulture); + break; + } + } + catch + { + // ignore parsing failures. + // sane state will eventually be restored by a commit (either explicit, or implicit via focus loss). + } + + updatingFromTextBox = false; + } + + protected override bool OnHover(HoverEvent e) + { + updateState(); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + updateState(); + } + + protected override bool OnClick(ClickEvent e) + { + focusManager.ChangeFocus(textBox); + return true; + } + + private void updateState() + { + bool childHasFocus = slider.Focused.Value || textBox.Focused.Value; + + textBox.Alpha = 1; + + background.Colour = currentNumberInstantaneous.Disabled ? colourProvider.Background4 : colourProvider.Background5; + caption.Colour = currentNumberInstantaneous.Disabled ? colourProvider.Foreground1 : colourProvider.Content2; + textBox.Colour = currentNumberInstantaneous.Disabled ? colourProvider.Foreground1 : colourProvider.Content1; + + BorderThickness = childHasFocus || IsHovered || slider.IsDragging.Value ? 2 : 0; + BorderColour = childHasFocus ? colourProvider.Highlight1 : colourProvider.Light4; + + if (childHasFocus) + background.Colour = ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Dark3); + else if (IsHovered || slider.IsDragging.Value) + background.Colour = ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Dark4); + else + background.Colour = colourProvider.Background5; + } + + private void updateValueDisplay() + { + if (updatingFromTextBox) return; + + textBox.Text = slider.GetDisplayableValue(currentNumberInstantaneous.Value).ToString(); + } + + private partial class InnerSlider : OsuSliderBar + { + public BindableBool Focused { get; } = new BindableBool(); + + public BindableBool IsDragging { get; set; } = new BindableBool(); + public Action? OnCommit { get; set; } + + private Box leftBox = null!; + private Box rightBox = null!; + private Circle nub = null!; + private const float nub_width = 10; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + Height = 40; + RelativeSizeAxes = Axes.X; + RangePadding = nub_width / 2; + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 5, + Children = new Drawable[] + { + leftBox = new Box + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + rightBox = new Box + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + }, + }, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Horizontal = RangePadding, }, + Child = nub = new Circle + { + Width = nub_width, + RelativeSizeAxes = Axes.Y, + RelativePositionAxes = Axes.X, + Origin = Anchor.TopCentre, + } + }, + new HoverClickSounds() + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + updateState(); + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + leftBox.Width = Math.Clamp(RangePadding + nub.DrawPosition.X, 0, Math.Max(0, DrawWidth)) / DrawWidth; + rightBox.Width = Math.Clamp(DrawWidth - nub.DrawPosition.X - RangePadding, 0, Math.Max(0, DrawWidth)) / DrawWidth; + } + + protected override bool OnDragStart(DragStartEvent e) + { + bool dragging = base.OnDragStart(e); + IsDragging.Value = dragging; + updateState(); + return dragging; + } + + protected override void OnDragEnd(DragEndEvent e) + { + base.OnDragEnd(e); + IsDragging.Value = false; + updateState(); + } + + protected override bool OnHover(HoverEvent e) + { + updateState(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateState(); + base.OnHoverLost(e); + } + + protected override void OnFocus(FocusEvent e) + { + updateState(); + Focused.Value = true; + base.OnFocus(e); + } + + protected override void OnFocusLost(FocusLostEvent e) + { + updateState(); + Focused.Value = false; + base.OnFocusLost(e); + } + + private void updateState() + { + rightBox.Colour = colourProvider.Background6; + leftBox.Colour = HasFocus || IsHovered || IsDragged ? colourProvider.Highlight1.Opacity(0.5f) : colourProvider.Dark2; + nub.Colour = HasFocus || IsHovered || IsDragged ? colourProvider.Highlight1 : colourProvider.Light4; + } + + protected override void UpdateValue(float value) + { + nub.MoveToX(value, 200, Easing.OutPow10); + } + + protected override bool Commit() + { + bool result = base.Commit(); + + if (result) + OnCommit?.Invoke(); + + return result; + } + } + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/FormTextBox.cs b/osu.Game/Graphics/UserInterfaceV2/FormTextBox.cs new file mode 100644 index 0000000000..973419310c --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/FormTextBox.cs @@ -0,0 +1,251 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public partial class FormTextBox : CompositeDrawable, IHasCurrentValue + { + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + private bool readOnly; + + public bool ReadOnly + { + get => readOnly; + set + { + readOnly = value; + + if (textBox.IsNotNull()) + updateState(); + } + } + + private CompositeDrawable? tabbableContentContainer; + + public CompositeDrawable? TabbableContentContainer + { + set + { + tabbableContentContainer = value; + + if (textBox.IsNotNull()) + textBox.TabbableContentContainer = tabbableContentContainer; + } + } + + public event TextBox.OnCommitHandler? OnCommit; + + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + /// + /// Caption describing this slider bar, displayed on top of the controls. + /// + public LocalisableString Caption { get; init; } + + /// + /// Hint text containing an extended description of this slider bar, displayed in a tooltip when hovering the caption. + /// + public LocalisableString HintText { get; init; } + + /// + /// Text displayed in the text box when its contents are empty. + /// + public LocalisableString PlaceholderText { get; init; } + + private Box background = null!; + private Box flashLayer = null!; + private InnerTextBox textBox = null!; + private FormFieldCaption caption = null!; + private IFocusManager focusManager = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + RelativeSizeAxes = Axes.X; + Height = 50; + + Masking = true; + CornerRadius = 5; + CornerExponent = 2.5f; + + InternalChildren = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5, + }, + flashLayer = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.Transparent, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(9), + Children = new Drawable[] + { + caption = new FormFieldCaption + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + Caption = Caption, + TooltipText = HintText, + }, + textBox = CreateTextBox().With(t => + { + t.Anchor = Anchor.BottomRight; + t.Origin = Anchor.BottomRight; + t.RelativeSizeAxes = Axes.X; + t.Width = 1; + t.PlaceholderText = PlaceholderText; + t.Current = Current; + t.CommitOnFocusLost = true; + t.OnCommit += (textBox, newText) => + { + OnCommit?.Invoke(textBox, newText); + + if (!current.Disabled && !ReadOnly) + { + flashLayer.Colour = ColourInfo.GradientVertical(colourProvider.Dark2.Opacity(0), colourProvider.Dark2); + flashLayer.FadeOutFromOne(800, Easing.OutQuint); + } + }; + t.OnInputError = () => + { + flashLayer.Colour = ColourInfo.GradientVertical(colours.Red3.Opacity(0), colours.Red3); + flashLayer.FadeOutFromOne(200, Easing.OutQuint); + }; + t.TabbableContentContainer = tabbableContentContainer; + }), + }, + }, + }; + } + + internal virtual InnerTextBox CreateTextBox() => new InnerTextBox(); + + protected override void LoadComplete() + { + base.LoadComplete(); + + focusManager = GetContainingFocusManager()!; + textBox.Focused.BindValueChanged(_ => updateState()); + current.BindDisabledChanged(_ => updateState(), true); + } + + protected override bool OnHover(HoverEvent e) + { + updateState(); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + updateState(); + } + + protected override bool OnClick(ClickEvent e) + { + focusManager.ChangeFocus(textBox); + return true; + } + + private void updateState() + { + bool disabled = Current.Disabled || ReadOnly; + + textBox.ReadOnly = disabled; + textBox.Alpha = 1; + + caption.Colour = disabled ? colourProvider.Foreground1 : colourProvider.Content2; + textBox.Colour = disabled ? colourProvider.Foreground1 : colourProvider.Content1; + + if (!disabled) + { + BorderThickness = IsHovered || textBox.Focused.Value ? 2 : 0; + BorderColour = textBox.Focused.Value ? colourProvider.Highlight1 : colourProvider.Light4; + + if (textBox.Focused.Value) + background.Colour = ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Dark3); + else if (IsHovered) + background.Colour = ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Dark4); + else + background.Colour = colourProvider.Background5; + } + else + { + BorderThickness = 0; + background.Colour = colourProvider.Background4; + } + } + + internal partial class InnerTextBox : OsuTextBox + { + public BindableBool Focused { get; } = new BindableBool(); + + public Action? OnInputError { get; set; } + + protected override float LeftRightPadding => 0; + + [BackgroundDependencyLoader] + private void load() + { + Height = 16; + TextContainer.Height = 1; + Masking = false; + BackgroundUnfocused = BackgroundFocused = BackgroundCommit = Colour4.Transparent; + } + + protected override SpriteText CreatePlaceholder() => base.CreatePlaceholder().With(t => t.Margin = default); + + protected override void OnFocus(FocusEvent e) + { + base.OnFocus(e); + + Focused.Value = true; + } + + protected override void OnFocusLost(FocusLostEvent e) + { + base.OnFocusLost(e); + + Focused.Value = false; + } + + protected override void NotifyInputError() + { + PlayFeedbackSample(FeedbackSampleType.TextInvalid); + // base call intentionally suppressed + OnInputError?.Invoke(); + } + } + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledSliderBar.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledSliderBar.cs index 4585d3a4c9..4912a21fab 100644 --- a/osu.Game/Graphics/UserInterfaceV2/LabelledSliderBar.cs +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledSliderBar.cs @@ -1,14 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; +using System.Numerics; using osu.Framework.Graphics; using osu.Game.Overlays.Settings; namespace osu.Game.Graphics.UserInterfaceV2 { public partial class LabelledSliderBar : LabelledComponent, TNumber> - where TNumber : struct, IEquatable, IComparable, IConvertible + where TNumber : struct, INumber, IMinMaxValue { public LabelledSliderBar() : base(true) diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs index 8b9d35e343..b2e3ff077e 100644 --- a/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs @@ -28,6 +28,12 @@ namespace osu.Game.Graphics.UserInterfaceV2 set => Component.ReadOnly = value; } + public bool SelectAllOnFocus + { + get => Component.SelectAllOnFocus; + set => Component.SelectAllOnFocus = value; + } + public LocalisableString PlaceholderText { set => Component.PlaceholderText = value; @@ -50,6 +56,8 @@ namespace osu.Game.Graphics.UserInterfaceV2 Component.BorderColour = colours.Blue; } + public bool SelectAll() => Component.SelectAll(); + protected virtual OsuTextBox CreateTextBox() => new OsuTextBox(); public override bool AcceptsFocus => true; @@ -57,7 +65,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 protected override void OnFocus(FocusEvent e) { base.OnFocus(e); - GetContainingInputManager().ChangeFocus(Component); + GetContainingFocusManager()!.ChangeFocus(Component); } protected override OsuTextBox CreateComponent() => CreateTextBox().With(t => diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelector.cs b/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelector.cs index 21f926ba42..65ffdcaa5b 100644 --- a/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelector.cs +++ b/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelector.cs @@ -1,41 +1,73 @@ // 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.IO; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterfaceV2.FileSelection; +using osu.Game.Overlays; namespace osu.Game.Graphics.UserInterfaceV2 { public partial class OsuDirectorySelector : DirectorySelector { - public const float ITEM_HEIGHT = 20; + public const float ITEM_HEIGHT = 16; - public OsuDirectorySelector(string initialPath = null) + private Box hiddenToggleBackground = null!; + + public OsuDirectorySelector(string? initialPath = null) : base(initialPath) { } [BackgroundDependencyLoader] - private void load() + private void load(OverlayColourProvider colourProvider) { - Padding = new MarginPadding(10); + AddInternal(new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5, + Depth = float.MaxValue, + }); + + hiddenToggleBackground.Colour = colourProvider.Background4; } - protected override ScrollContainer CreateScrollContainer() => new OsuScrollContainer(); + protected override ScrollContainer CreateScrollContainer() => new OsuScrollContainer + { + Padding = new MarginPadding + { + Horizontal = 20, + Vertical = 15, + } + }; protected override DirectorySelectorBreadcrumbDisplay CreateBreadcrumb() => new OsuDirectorySelectorBreadcrumbDisplay(); - protected override Drawable CreateHiddenToggleButton() => new OsuDirectorySelectorHiddenToggle { Current = { BindTarget = ShowHiddenItems } }; + protected override Drawable CreateHiddenToggleButton() => new Container + { + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Children = new Drawable[] + { + hiddenToggleBackground = new Box + { + RelativeSizeAxes = Axes.Both, + }, + new HiddenFilesToggleCheckbox + { + Current = { BindTarget = ShowHiddenItems }, + }, + } + }; protected override DirectorySelectorDirectory CreateParentDirectoryItem(DirectoryInfo directory) => new OsuDirectorySelectorParentDirectory(directory); - protected override DirectorySelectorDirectory CreateDirectoryItem(DirectoryInfo directory, string displayName = null) => new OsuDirectorySelectorDirectory(directory, displayName); + protected override DirectorySelectorDirectory CreateDirectoryItem(DirectoryInfo directory, string? displayName = null) => new OsuDirectorySelectorDirectory(directory, displayName); protected override void NotifySelectionError() => this.FlashColour(Colour4.Red, 300); } diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorBreadcrumbDisplay.cs b/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorBreadcrumbDisplay.cs deleted file mode 100644 index 0917b9db97..0000000000 --- a/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorBreadcrumbDisplay.cs +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using System.IO; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.UserInterface; -using osu.Game.Graphics.Sprites; -using osuTK; - -namespace osu.Game.Graphics.UserInterfaceV2 -{ - internal partial class OsuDirectorySelectorBreadcrumbDisplay : DirectorySelectorBreadcrumbDisplay - { - protected override Drawable CreateCaption() => new OsuSpriteText - { - Text = "Current Directory: ", - Font = OsuFont.Default.With(size: OsuDirectorySelector.ITEM_HEIGHT), - }; - - protected override DirectorySelectorDirectory CreateRootDirectoryItem() => new OsuBreadcrumbDisplayComputer(); - - protected override DirectorySelectorDirectory CreateDirectoryItem(DirectoryInfo directory, string displayName = null) => new OsuBreadcrumbDisplayDirectory(directory, displayName); - - public OsuDirectorySelectorBreadcrumbDisplay() - { - Padding = new MarginPadding(15); - } - - private partial class OsuBreadcrumbDisplayComputer : OsuBreadcrumbDisplayDirectory - { - protected override IconUsage? Icon => null; - - public OsuBreadcrumbDisplayComputer() - : base(null, "Computer") - { - } - } - - private partial class OsuBreadcrumbDisplayDirectory : OsuDirectorySelectorDirectory - { - public OsuBreadcrumbDisplayDirectory(DirectoryInfo directory, string displayName = null) - : base(directory, displayName) - { - } - - [BackgroundDependencyLoader] - private void load() - { - Flow.Add(new SpriteIcon - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Icon = FontAwesome.Solid.ChevronRight, - Size = new Vector2(FONT_SIZE / 2) - }); - } - - protected override IconUsage? Icon => Directory.Name.Contains(Path.DirectorySeparatorChar) ? base.Icon : null; - } - } -} diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorDirectory.cs b/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorDirectory.cs deleted file mode 100644 index 932017b03e..0000000000 --- a/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorDirectory.cs +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using System.IO; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.UserInterface; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; -using osu.Game.Overlays; - -namespace osu.Game.Graphics.UserInterfaceV2 -{ - internal partial class OsuDirectorySelectorDirectory : DirectorySelectorDirectory - { - public OsuDirectorySelectorDirectory(DirectoryInfo directory, string displayName = null) - : base(directory, displayName) - { - } - - [BackgroundDependencyLoader] - private void load() - { - Flow.AutoSizeAxes = Axes.X; - Flow.Height = OsuDirectorySelector.ITEM_HEIGHT; - - AddRangeInternal(new Drawable[] - { - new Background - { - Depth = 1 - }, - new HoverClickSounds() - }); - } - - protected override SpriteText CreateSpriteText() => new OsuSpriteText(); - - protected override IconUsage? Icon => Directory.Name.Contains(Path.DirectorySeparatorChar) - ? FontAwesome.Solid.Database - : FontAwesome.Regular.Folder; - - internal partial class Background : CompositeDrawable - { - [BackgroundDependencyLoader(true)] - private void load(OverlayColourProvider overlayColourProvider, OsuColour colours) - { - RelativeSizeAxes = Axes.Both; - - Masking = true; - CornerRadius = 5; - - InternalChild = new Box - { - Colour = overlayColourProvider?.Background5 ?? colours.GreySeaFoamDarker, - RelativeSizeAxes = Axes.Both, - }; - } - } - } -} diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs b/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs index 7097102335..c7b559d9ed 100644 --- a/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs +++ b/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs @@ -1,43 +1,74 @@ // 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.IO; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2.FileSelection; +using osu.Game.Overlays; namespace osu.Game.Graphics.UserInterfaceV2 { public partial class OsuFileSelector : FileSelector { - public OsuFileSelector(string initialPath = null, string[] validFileExtensions = null) + private Box hiddenToggleBackground = null!; + + public OsuFileSelector(string? initialPath = null, string[]? validFileExtensions = null) : base(initialPath, validFileExtensions) { } [BackgroundDependencyLoader] - private void load() + private void load(OverlayColourProvider colourProvider) { - Padding = new MarginPadding(10); + AddInternal(new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5, + Depth = float.MaxValue, + }); + + hiddenToggleBackground.Colour = colourProvider.Background4; } - protected override ScrollContainer CreateScrollContainer() => new OsuScrollContainer(); + protected override ScrollContainer CreateScrollContainer() => new OsuScrollContainer + { + Padding = new MarginPadding + { + Horizontal = 20, + Vertical = 15, + } + }; protected override DirectorySelectorBreadcrumbDisplay CreateBreadcrumb() => new OsuDirectorySelectorBreadcrumbDisplay(); - protected override Drawable CreateHiddenToggleButton() => new OsuDirectorySelectorHiddenToggle { Current = { BindTarget = ShowHiddenItems } }; + protected override Drawable CreateHiddenToggleButton() => new Container + { + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Children = new Drawable[] + { + hiddenToggleBackground = new Box + { + RelativeSizeAxes = Axes.Both, + }, + new HiddenFilesToggleCheckbox + { + Current = { BindTarget = ShowHiddenItems }, + }, + } + }; protected override DirectorySelectorDirectory CreateParentDirectoryItem(DirectoryInfo directory) => new OsuDirectorySelectorParentDirectory(directory); - protected override DirectorySelectorDirectory CreateDirectoryItem(DirectoryInfo directory, string displayName = null) => new OsuDirectorySelectorDirectory(directory, displayName); + protected override DirectorySelectorDirectory CreateDirectoryItem(DirectoryInfo directory, string? displayName = null) => new OsuDirectorySelectorDirectory(directory, displayName); protected override DirectoryListingFile CreateFileItem(FileInfo file) => new OsuDirectoryListingFile(file); @@ -51,19 +82,14 @@ namespace osu.Game.Graphics.UserInterfaceV2 } [BackgroundDependencyLoader] - private void load() + private void load(OverlayColourProvider colourProvider) { Flow.AutoSizeAxes = Axes.X; Flow.Height = OsuDirectorySelector.ITEM_HEIGHT; - AddRangeInternal(new Drawable[] - { - new OsuDirectorySelectorDirectory.Background - { - Depth = 1 - }, - new HoverClickSounds() - }); + AddInternal(new BackgroundLayer()); + + Colour = colourProvider.Light3; } protected override IconUsage? Icon @@ -91,7 +117,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 } } - protected override SpriteText CreateSpriteText() => new OsuSpriteText(); + protected override SpriteText CreateSpriteText() => new OsuSpriteText().With(t => t.Font = OsuFont.Default.With(weight: FontWeight.SemiBold)); } } } diff --git a/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs b/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs index e5ba7f61bf..50d8d763e1 100644 --- a/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs +++ b/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs @@ -1,7 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; +using System.Numerics; using System.Globalization; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -10,12 +10,12 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; using osu.Game.Overlays.Settings; using osu.Game.Utils; -using osuTK; +using Vector2 = osuTK.Vector2; namespace osu.Game.Graphics.UserInterfaceV2 { public partial class SliderWithTextBoxInput : CompositeDrawable, IHasCurrentValue - where T : struct, IEquatable, IComparable, IConvertible + where T : struct, INumber, IMinMaxValue { /// /// A custom step value for each key press which actuates a change on this control. @@ -85,7 +85,9 @@ namespace osu.Game.Graphics.UserInterfaceV2 Current.BindValueChanged(updateTextBoxFromSlider, true); } - public bool TakeFocus() => GetContainingInputManager().ChangeFocus(textBox); + public bool TakeFocus() => GetContainingFocusManager()?.ChangeFocus(textBox) == true; + + public bool SelectAll() => textBox.SelectAll(); private bool updatingFromTextBox; @@ -138,7 +140,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 { if (updatingFromTextBox) return; - decimal decimalValue = slider.Current.Value.ToDecimal(NumberFormatInfo.InvariantInfo); + decimal decimalValue = decimal.CreateTruncating(slider.Current.Value); textBox.Text = decimalValue.ToString($@"N{FormatUtils.FindPrecision(decimalValue)}"); } } diff --git a/osu.Game/IO/Archives/ZipArchiveReader.cs b/osu.Game/IO/Archives/ZipArchiveReader.cs index cc5c65d184..6bb2a314e7 100644 --- a/osu.Game/IO/Archives/ZipArchiveReader.cs +++ b/osu.Game/IO/Archives/ZipArchiveReader.cs @@ -7,23 +7,45 @@ using System.Buffers; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text; using Microsoft.Toolkit.HighPerformance; using osu.Framework.IO.Stores; using SharpCompress.Archives.Zip; +using SharpCompress.Common; +using SharpCompress.Readers; using SixLabors.ImageSharp.Memory; namespace osu.Game.IO.Archives { public sealed class ZipArchiveReader : ArchiveReader { + /// + /// Archives created by osu!stable still write out as Shift-JIS. + /// We want to force this fallback rather than leave it up to the library/system. + /// In the future we may want to change exports to set the zip UTF-8 flag and use that instead. + /// + public static readonly ArchiveEncoding DEFAULT_ENCODING; + private readonly Stream archiveStream; private readonly ZipArchive archive; + static ZipArchiveReader() + { + // Required to support rare code pages. + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + + DEFAULT_ENCODING = new ArchiveEncoding(Encoding.GetEncoding(932), Encoding.GetEncoding(932)); + } + public ZipArchiveReader(Stream archiveStream, string name = null) : base(name) { this.archiveStream = archiveStream; - archive = ZipArchive.Open(archiveStream); + + archive = ZipArchive.Open(archiveStream, new ReaderOptions + { + ArchiveEncoding = DEFAULT_ENCODING + }); } public override Stream GetStream(string name) diff --git a/osu.Game/IO/Legacy/SerializationReader.cs b/osu.Game/IO/Legacy/SerializationReader.cs index 2d3d5bffd5..fc61b028a4 100644 --- a/osu.Game/IO/Legacy/SerializationReader.cs +++ b/osu.Game/IO/Legacy/SerializationReader.cs @@ -123,58 +123,58 @@ namespace osu.Game.IO.Legacy switch (t) { - case ObjType.boolType: + case ObjType.BoolType: return ReadBoolean(); - case ObjType.byteType: + case ObjType.ByteType: return ReadByte(); - case ObjType.uint16Type: + case ObjType.UInt16Type: return ReadUInt16(); - case ObjType.uint32Type: + case ObjType.UInt32Type: return ReadUInt32(); - case ObjType.uint64Type: + case ObjType.UInt64Type: return ReadUInt64(); - case ObjType.sbyteType: + case ObjType.SByteType: return ReadSByte(); - case ObjType.int16Type: + case ObjType.Int16Type: return ReadInt16(); - case ObjType.int32Type: + case ObjType.Int32Type: return ReadInt32(); - case ObjType.int64Type: + case ObjType.Int64Type: return ReadInt64(); - case ObjType.charType: + case ObjType.CharType: return ReadChar(); - case ObjType.stringType: + case ObjType.StringType: return base.ReadString(); - case ObjType.singleType: + case ObjType.SingleType: return ReadSingle(); - case ObjType.doubleType: + case ObjType.DoubleType: return ReadDouble(); - case ObjType.decimalType: + case ObjType.DecimalType: return ReadDecimal(); - case ObjType.dateTimeType: + case ObjType.DateTimeType: return ReadDateTime(); - case ObjType.byteArrayType: + case ObjType.ByteArrayType: return ReadByteArray(); - case ObjType.charArrayType: + case ObjType.CharArrayType: return ReadCharArray(); - case ObjType.otherType: + case ObjType.OtherType: throw new IOException("Deserialization of arbitrary type is not supported."); default: @@ -185,25 +185,25 @@ namespace osu.Game.IO.Legacy public enum ObjType : byte { - nullType, - boolType, - byteType, - uint16Type, - uint32Type, - uint64Type, - sbyteType, - int16Type, - int32Type, - int64Type, - charType, - stringType, - singleType, - doubleType, - decimalType, - dateTimeType, - byteArrayType, - charArrayType, - otherType, - ILegacySerializableType + NullType, + BoolType, + ByteType, + UInt16Type, + UInt32Type, + UInt64Type, + SByteType, + Int16Type, + Int32Type, + Int64Type, + CharType, + StringType, + SingleType, + DoubleType, + DecimalType, + DateTimeType, + ByteArrayType, + CharArrayType, + OtherType, + LegacySerializableType } } diff --git a/osu.Game/IO/Legacy/SerializationWriter.cs b/osu.Game/IO/Legacy/SerializationWriter.cs index 10572a6478..afe86cd096 100644 --- a/osu.Game/IO/Legacy/SerializationWriter.cs +++ b/osu.Game/IO/Legacy/SerializationWriter.cs @@ -34,11 +34,11 @@ namespace osu.Game.IO.Legacy { if (str == null) { - Write((byte)ObjType.nullType); + Write((byte)ObjType.NullType); } else { - Write((byte)ObjType.stringType); + Write((byte)ObjType.StringType); base.Write(str); } } @@ -125,94 +125,94 @@ namespace osu.Game.IO.Legacy { if (obj == null) { - Write((byte)ObjType.nullType); + Write((byte)ObjType.NullType); } else { switch (obj) { case bool boolObj: - Write((byte)ObjType.boolType); + Write((byte)ObjType.BoolType); Write(boolObj); break; case byte byteObj: - Write((byte)ObjType.byteType); + Write((byte)ObjType.ByteType); Write(byteObj); break; case ushort ushortObj: - Write((byte)ObjType.uint16Type); + Write((byte)ObjType.UInt16Type); Write(ushortObj); break; case uint uintObj: - Write((byte)ObjType.uint32Type); + Write((byte)ObjType.UInt32Type); Write(uintObj); break; case ulong ulongObj: - Write((byte)ObjType.uint64Type); + Write((byte)ObjType.UInt64Type); Write(ulongObj); break; case sbyte sbyteObj: - Write((byte)ObjType.sbyteType); + Write((byte)ObjType.SByteType); Write(sbyteObj); break; case short shortObj: - Write((byte)ObjType.int16Type); + Write((byte)ObjType.Int16Type); Write(shortObj); break; case int intObj: - Write((byte)ObjType.int32Type); + Write((byte)ObjType.Int32Type); Write(intObj); break; case long longObj: - Write((byte)ObjType.int64Type); + Write((byte)ObjType.Int64Type); Write(longObj); break; case char charObj: - Write((byte)ObjType.charType); + Write((byte)ObjType.CharType); base.Write(charObj); break; case string stringObj: - Write((byte)ObjType.stringType); + Write((byte)ObjType.StringType); base.Write(stringObj); break; case float floatObj: - Write((byte)ObjType.singleType); + Write((byte)ObjType.SingleType); Write(floatObj); break; case double doubleObj: - Write((byte)ObjType.doubleType); + Write((byte)ObjType.DoubleType); Write(doubleObj); break; case decimal decimalObj: - Write((byte)ObjType.decimalType); + Write((byte)ObjType.DecimalType); Write(decimalObj); break; case DateTime dateTimeObj: - Write((byte)ObjType.dateTimeType); + Write((byte)ObjType.DateTimeType); Write(dateTimeObj); break; case byte[] byteArray: - Write((byte)ObjType.byteArrayType); + Write((byte)ObjType.ByteArrayType); base.Write(byteArray); break; case char[] charArray: - Write((byte)ObjType.charArrayType); + Write((byte)ObjType.CharArrayType); base.Write(charArray); break; diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index 436334cfe1..02ede0a2f8 100644 --- a/osu.Game/Input/Bindings/GlobalActionContainer.cs +++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs @@ -34,6 +34,7 @@ namespace osu.Game.Input.Bindings /// public override IEnumerable DefaultKeyBindings => globalKeyBindings .Concat(editorKeyBindings) + .Concat(editorTestPlayKeyBindings) .Concat(inGameKeyBindings) .Concat(replayKeyBindings) .Concat(songSelectKeyBindings) @@ -68,6 +69,9 @@ namespace osu.Game.Input.Bindings case GlobalActionCategory.Overlays: return overlayKeyBindings; + case GlobalActionCategory.EditorTestPlay: + return editorTestPlayKeyBindings; + default: throw new ArgumentOutOfRangeException(nameof(category), category, $"Unexpected {nameof(GlobalActionCategory)}"); } @@ -97,10 +101,9 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.Alt, InputKey.Home }, GlobalAction.Home), - new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.F }, GlobalAction.ToggleFPSDisplay), + new KeyBinding(InputKey.None, GlobalAction.ToggleFPSDisplay), new KeyBinding(new[] { InputKey.Control, InputKey.T }, GlobalAction.ToggleToolbar), new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.S }, GlobalAction.ToggleSkinEditor), - new KeyBinding(new[] { InputKey.Control, InputKey.P }, GlobalAction.ToggleProfile), new KeyBinding(new[] { InputKey.Control, InputKey.Alt, InputKey.R }, GlobalAction.ResetInputSettings), @@ -118,6 +121,7 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.Control, InputKey.B }, GlobalAction.ToggleBeatmapListing), new KeyBinding(new[] { InputKey.Control, InputKey.O }, GlobalAction.ToggleSettings), new KeyBinding(new[] { InputKey.Control, InputKey.N }, GlobalAction.ToggleNotifications), + new KeyBinding(new[] { InputKey.Control, InputKey.P }, GlobalAction.ToggleProfile), }; private static IEnumerable editorKeyBindings => new[] @@ -130,7 +134,8 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.Control, InputKey.D }, GlobalAction.EditorCloneSelection), new KeyBinding(new[] { InputKey.J }, GlobalAction.EditorNudgeLeft), new KeyBinding(new[] { InputKey.K }, GlobalAction.EditorNudgeRight), - new KeyBinding(new[] { InputKey.G }, GlobalAction.EditorCycleGridDisplayMode), + new KeyBinding(new[] { InputKey.G }, GlobalAction.EditorCycleGridSpacing), + new KeyBinding(new[] { InputKey.Shift, InputKey.G }, GlobalAction.EditorCycleGridType), new KeyBinding(new[] { InputKey.F5 }, GlobalAction.EditorTestGameplay), new KeyBinding(new[] { InputKey.T }, GlobalAction.EditorTapForBPM), new KeyBinding(new[] { InputKey.Control, InputKey.H }, GlobalAction.EditorFlipHorizontally), @@ -142,6 +147,19 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.MouseWheelRight }, GlobalAction.EditorCyclePreviousBeatSnapDivisor), new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.MouseWheelLeft }, GlobalAction.EditorCycleNextBeatSnapDivisor), new KeyBinding(new[] { InputKey.Control, InputKey.R }, GlobalAction.EditorToggleRotateControl), + new KeyBinding(new[] { InputKey.Control, InputKey.E }, GlobalAction.EditorToggleScaleControl), + new KeyBinding(new[] { InputKey.Control, InputKey.Left }, GlobalAction.EditorSeekToPreviousHitObject), + new KeyBinding(new[] { InputKey.Control, InputKey.Right }, GlobalAction.EditorSeekToNextHitObject), + new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.Left }, GlobalAction.EditorSeekToPreviousSamplePoint), + new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.Right }, GlobalAction.EditorSeekToNextSamplePoint), + }; + + private static IEnumerable editorTestPlayKeyBindings => new[] + { + new KeyBinding(new[] { InputKey.Tab }, GlobalAction.EditorTestPlayToggleAutoplay), + new KeyBinding(new[] { InputKey.Control, InputKey.P }, GlobalAction.EditorTestPlayToggleQuickPause), + new KeyBinding(new[] { InputKey.F1 }, GlobalAction.EditorTestPlayQuickExitToInitialTime), + new KeyBinding(new[] { InputKey.F2 }, GlobalAction.EditorTestPlayQuickExitToCurrentTime), }; private static IEnumerable inGameKeyBindings => new[] @@ -182,6 +200,8 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.Shift, InputKey.F2 }, GlobalAction.SelectPreviousRandom), new KeyBinding(InputKey.F3, GlobalAction.ToggleBeatmapOptions), new KeyBinding(InputKey.BackSpace, GlobalAction.DeselectAllMods), + new KeyBinding(new[] { InputKey.Control, InputKey.Up }, GlobalAction.IncreaseModSpeed), + new KeyBinding(new[] { InputKey.Control, InputKey.Down }, GlobalAction.DecreaseModSpeed), }; private static IEnumerable audioControlKeyBindings => new[] @@ -349,8 +369,8 @@ namespace osu.Game.Input.Bindings [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleChatFocus))] ToggleChatFocus, - [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorCycleGridDisplayMode))] - EditorCycleGridDisplayMode, + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorCycleGridSpacing))] + EditorCycleGridSpacing, [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorTestGameplay))] EditorTestGameplay, @@ -420,6 +440,42 @@ namespace osu.Game.Input.Bindings [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.StepReplayBackward))] StepReplayBackward, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.IncreaseModSpeed))] + IncreaseModSpeed, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.DecreaseModSpeed))] + DecreaseModSpeed, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorToggleScaleControl))] + EditorToggleScaleControl, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorTestPlayToggleAutoplay))] + EditorTestPlayToggleAutoplay, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorTestPlayToggleQuickPause))] + EditorTestPlayToggleQuickPause, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorTestPlayQuickExitToInitialTime))] + EditorTestPlayQuickExitToInitialTime, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorTestPlayQuickExitToCurrentTime))] + EditorTestPlayQuickExitToCurrentTime, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorSeekToPreviousHitObject))] + EditorSeekToPreviousHitObject, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorSeekToNextHitObject))] + EditorSeekToNextHitObject, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorSeekToPreviousSamplePoint))] + EditorSeekToPreviousSamplePoint, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorSeekToNextSamplePoint))] + EditorSeekToNextSamplePoint, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorCycleGridType))] + EditorCycleGridType, } public enum GlobalActionCategory @@ -430,6 +486,7 @@ namespace osu.Game.Input.Bindings Replay, SongSelect, AudioControl, - Overlays + Overlays, + EditorTestPlay, } } diff --git a/osu.Game/Input/ConfineMouseTracker.cs b/osu.Game/Input/ConfineMouseTracker.cs index 926f68df45..eeda92a585 100644 --- a/osu.Game/Input/ConfineMouseTracker.cs +++ b/osu.Game/Input/ConfineMouseTracker.cs @@ -15,7 +15,7 @@ namespace osu.Game.Input { /// /// Connects with . - /// If is true, we should also confine the mouse cursor if it has been + /// If is playing, we should also confine the mouse cursor if it has been /// requested with . /// public partial class ConfineMouseTracker : Component @@ -25,7 +25,7 @@ namespace osu.Game.Input private Bindable frameworkMinimiseOnFocusLossInFullscreen; private Bindable osuConfineMode; - private IBindable localUserPlaying; + private IBindable localUserPlaying; [BackgroundDependencyLoader] private void load(ILocalUserPlayInfo localUserInfo, FrameworkConfigManager frameworkConfigManager, OsuConfigManager osuConfigManager) @@ -37,7 +37,7 @@ namespace osu.Game.Input frameworkMinimiseOnFocusLossInFullscreen.BindValueChanged(_ => updateConfineMode()); osuConfineMode = osuConfigManager.GetBindable(OsuSetting.ConfineMouseMode); - localUserPlaying = localUserInfo.IsPlaying.GetBoundCopy(); + localUserPlaying = localUserInfo.PlayingState.GetBoundCopy(); osuConfineMode.ValueChanged += _ => updateConfineMode(); localUserPlaying.BindValueChanged(_ => updateConfineMode(), true); @@ -63,7 +63,7 @@ namespace osu.Game.Input break; case OsuConfineMouseMode.DuringGameplay: - frameworkConfineMode.Value = localUserPlaying.Value ? ConfineMouseMode.Always : ConfineMouseMode.Never; + frameworkConfineMode.Value = localUserPlaying.Value == LocalUserPlayingState.Playing ? ConfineMouseMode.Always : ConfineMouseMode.Never; break; case OsuConfineMouseMode.Always: diff --git a/osu.Game/Input/OsuUserInputManager.cs b/osu.Game/Input/OsuUserInputManager.cs index b8fd0bb11c..2138a8b247 100644 --- a/osu.Game/Input/OsuUserInputManager.cs +++ b/osu.Game/Input/OsuUserInputManager.cs @@ -3,15 +3,16 @@ using osu.Framework.Bindables; using osu.Framework.Input; +using osu.Game.Screens.Play; using osuTK.Input; namespace osu.Game.Input { public partial class OsuUserInputManager : UserInputManager { - protected override bool AllowRightClickFromLongTouch => !LocalUserPlaying.Value; + protected override bool AllowRightClickFromLongTouch => PlayingState.Value != LocalUserPlayingState.Playing; - public readonly BindableBool LocalUserPlaying = new BindableBool(); + public readonly IBindable PlayingState = new Bindable(); internal OsuUserInputManager() { diff --git a/osu.Game/Localisation/ButtonSystemStrings.cs b/osu.Game/Localisation/ButtonSystemStrings.cs index ba4abf63a6..b0a205eebe 100644 --- a/osu.Game/Localisation/ButtonSystemStrings.cs +++ b/osu.Game/Localisation/ButtonSystemStrings.cs @@ -1,4 +1,4 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using osu.Framework.Localisation; @@ -54,6 +54,11 @@ namespace osu.Game.Localisation /// public static LocalisableString Exit => new TranslatableString(getKey(@"exit"), @"exit"); + /// + /// "daily challenge" + /// + public static LocalisableString DailyChallenge => new TranslatableString(getKey(@"daily_challenge"), @"daily challenge"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/ChatStrings.cs b/osu.Game/Localisation/ChatStrings.cs index 6b0a6bd8e1..6841e7d938 100644 --- a/osu.Game/Localisation/ChatStrings.cs +++ b/osu.Game/Localisation/ChatStrings.cs @@ -24,6 +24,11 @@ namespace osu.Game.Localisation /// public static LocalisableString MentionUser => new TranslatableString(getKey(@"mention_user"), @"Mention"); - private static string getKey(string key) => $"{prefix}:{key}"; + /// + /// "press enter to chat..." + /// + public static LocalisableString InGameInputPlaceholder => new TranslatableString(getKey(@"in_game_input_placeholder"), @"press enter to chat..."); + + private static string getKey(string key) => $@"{prefix}:{key}"; } } 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/DailyChallengeStatsDisplayStrings.cs b/osu.Game/Localisation/DailyChallengeStatsDisplayStrings.cs new file mode 100644 index 0000000000..2ef5e45c92 --- /dev/null +++ b/osu.Game/Localisation/DailyChallengeStatsDisplayStrings.cs @@ -0,0 +1,24 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class DailyChallengeStatsDisplayStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.DailyChallengeStatsDisplay"; + + /// + /// "{0}d" + /// + public static LocalisableString UnitDay(LocalisableString count) => new TranslatableString(getKey(@"unit_day"), @"{0}d", count); + + /// + /// "{0}w" + /// + public static LocalisableString UnitWeek(LocalisableString count) => new TranslatableString(getKey(@"unit_week"), @"{0}w", count); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/DailyChallengeStrings.cs b/osu.Game/Localisation/DailyChallengeStrings.cs new file mode 100644 index 0000000000..32ff98db06 --- /dev/null +++ b/osu.Game/Localisation/DailyChallengeStrings.cs @@ -0,0 +1,29 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class DailyChallengeStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.DailyChallenge"; + + /// + /// "Today's daily challenge has concluded – thanks for playing! + /// + /// Tomorrow's challenge is now being prepared and will appear soon." + /// + public static LocalisableString ChallengeEndedNotification => new TranslatableString(getKey(@"todays_daily_challenge_has_concluded"), + @"Today's daily challenge has concluded – thanks for playing! + +Tomorrow's challenge is now being prepared and will appear soon."); + + /// + /// "Today's daily challenge is now live! Click here to play." + /// + public static LocalisableString ChallengeLiveNotification => new TranslatableString(getKey(@"todays_daily_challenge_is_now"), @"Today's daily challenge is now live! Click here to play."); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/DeleteConfirmationContentStrings.cs b/osu.Game/Localisation/DeleteConfirmationContentStrings.cs new file mode 100644 index 0000000000..2b2f4dda54 --- /dev/null +++ b/osu.Game/Localisation/DeleteConfirmationContentStrings.cs @@ -0,0 +1,49 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class DeleteConfirmationContentStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.DeleteConfirmationContent"; + + /// + /// "Are you sure you want to delete all beatmaps?" + /// + public static LocalisableString Beatmaps => new TranslatableString(getKey(@"beatmaps"), @"Are you sure you want to delete all beatmaps?"); + + /// + /// "Are you sure you want to delete all beatmaps videos? This cannot be undone!" + /// + public static LocalisableString BeatmapVideos => new TranslatableString(getKey(@"beatmap_videos"), @"Are you sure you want to delete all beatmaps videos? This cannot be undone!"); + + /// + /// "Are you sure you want to reset all local beatmap offsets? This cannot be undone!" + /// + public static LocalisableString Offsets => new TranslatableString(getKey(@"offsets"), @"Are you sure you want to reset all local beatmap offsets? This cannot be undone!"); + + /// + /// "Are you sure you want to delete all skins? This cannot be undone!" + /// + public static LocalisableString Skins => new TranslatableString(getKey(@"skins"), @"Are you sure you want to delete all skins? This cannot be undone!"); + + /// + /// "Are you sure you want to delete all collections? This cannot be undone!" + /// + public static LocalisableString Collections => new TranslatableString(getKey(@"collections"), @"Are you sure you want to delete all collections? This cannot be undone!"); + + /// + /// "Are you sure you want to delete all scores? This cannot be undone!" + /// + public static LocalisableString Scores => new TranslatableString(getKey(@"scores"), @"Are you sure you want to delete all scores? This cannot be undone!"); + + /// + /// "Are you sure you want to delete all mod presets?" + /// + public static LocalisableString ModPresets => new TranslatableString(getKey(@"mod_presets"), @"Are you sure you want to delete all mod presets?"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/DeleteConfirmationDialogStrings.cs b/osu.Game/Localisation/DialogStrings.cs similarity index 62% rename from osu.Game/Localisation/DeleteConfirmationDialogStrings.cs rename to osu.Game/Localisation/DialogStrings.cs index 33738fe95e..a7634575b8 100644 --- a/osu.Game/Localisation/DeleteConfirmationDialogStrings.cs +++ b/osu.Game/Localisation/DialogStrings.cs @@ -5,14 +5,19 @@ using osu.Framework.Localisation; namespace osu.Game.Localisation { - public static class DeleteConfirmationDialogStrings + public static class DialogStrings { - private const string prefix = @"osu.Game.Resources.Localisation.DeleteConfirmationDialog"; + private const string prefix = @"osu.Game.Resources.Localisation.Dialog"; /// - /// "Confirm deletion of" + /// "Caution" /// - public static LocalisableString HeaderText => new TranslatableString(getKey(@"header_text"), @"Confirm deletion of"); + public static LocalisableString CautionHeaderText => new TranslatableString(getKey(@"header_text"), @"Caution"); + + /// + /// "Are you sure you want to delete the following:" + /// + public static LocalisableString DeletionHeaderText => new TranslatableString(getKey(@"deletion_header_text"), @"Are you sure you want to delete the following:"); /// /// "Yes. Go for it." diff --git a/osu.Game/Localisation/EditorDialogsStrings.cs b/osu.Game/Localisation/EditorDialogsStrings.cs index fc4c2b7f2a..94f28c617c 100644 --- a/osu.Game/Localisation/EditorDialogsStrings.cs +++ b/osu.Game/Localisation/EditorDialogsStrings.cs @@ -49,6 +49,11 @@ namespace osu.Game.Localisation /// public static LocalisableString ContinueEditing => new TranslatableString(getKey(@"continue_editing"), @"Oops, continue editing"); + /// + /// "The editor must be reloaded to apply this change. The beatmap will be saved." + /// + public static LocalisableString EditorReloadDialogHeader => new TranslatableString(getKey(@"editor_reload_dialog_header"), @"The editor must be reloaded to apply this change. The beatmap will be saved."); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/EditorStrings.cs b/osu.Game/Localisation/EditorStrings.cs index 6ad12f54df..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)" /// @@ -99,16 +104,6 @@ namespace osu.Game.Localisation /// public static LocalisableString TestBeatmap => new TranslatableString(getKey(@"test_beatmap"), @"Test!"); - /// - /// "Waveform" - /// - public static LocalisableString TimelineWaveform => new TranslatableString(getKey(@"timeline_waveform"), @"Waveform"); - - /// - /// "Ticks" - /// - public static LocalisableString TimelineTicks => new TranslatableString(getKey(@"timeline_ticks"), @"Ticks"); - /// /// "{0:0}°" /// @@ -124,6 +119,11 @@ namespace osu.Game.Localisation /// public static LocalisableString LimitedDistanceSnap => new TranslatableString(getKey(@"limited_distance_snap_grid"), @"Limit distance snap placement to current time"); + /// + /// "Contract sidebars when not hovered" + /// + public static LocalisableString ContractSidebars => new TranslatableString(getKey(@"contract_sidebars"), @"Contract sidebars when not hovered"); + /// /// "Must be in edit mode to handle editor links" /// @@ -134,6 +134,26 @@ namespace osu.Game.Localisation /// public static LocalisableString FailedToParseEditorLink => new TranslatableString(getKey(@"failed_to_parse_edtior_link"), @"Failed to parse editor link"); + /// + /// "Timeline" + /// + public static LocalisableString Timeline => new TranslatableString(getKey(@"timeline"), @"Timeline"); + + /// + /// "Show timing changes" + /// + public static LocalisableString TimelineShowTimingChanges => new TranslatableString(getKey(@"timeline_show_timing_changes"), @"Show timing changes"); + + /// + /// "Show breaks" + /// + public static LocalisableString TimelineShowBreaks => new TranslatableString(getKey(@"timeline_show_breaks"), @"Show breaks"); + + /// + /// "Show ticks" + /// + public static LocalisableString TimelineShowTicks => new TranslatableString(getKey(@"timeline_show_ticks"), @"Show ticks"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/FirstRunOverlayImportFromStableScreenStrings.cs b/osu.Game/Localisation/FirstRunOverlayImportFromStableScreenStrings.cs index 04fecab3df..521a77fe20 100644 --- a/osu.Game/Localisation/FirstRunOverlayImportFromStableScreenStrings.cs +++ b/osu.Game/Localisation/FirstRunOverlayImportFromStableScreenStrings.cs @@ -47,7 +47,7 @@ namespace osu.Game.Localisation public static LocalisableString Calculating => new TranslatableString(getKey(@"calculating"), @"calculating..."); /// - /// "{0} items" + /// "{0} item(s)" /// public static LocalisableString Items(int arg0) => new TranslatableString(getKey(@"items"), @"{0} item(s)", arg0); diff --git a/osu.Game/Localisation/FirstRunSetupBeatmapScreenStrings.cs b/osu.Game/Localisation/FirstRunSetupBeatmapScreenStrings.cs index a77ee066e4..50a417312d 100644 --- a/osu.Game/Localisation/FirstRunSetupBeatmapScreenStrings.cs +++ b/osu.Game/Localisation/FirstRunSetupBeatmapScreenStrings.cs @@ -39,6 +39,11 @@ namespace osu.Game.Localisation /// public static LocalisableString BundledButton => new TranslatableString(getKey(@"bundled_button"), @"Get recommended beatmaps"); + /// + /// "Beatmaps will be downloaded in the background. You can continue with setup while this happens!" + /// + public static LocalisableString DownloadingInBackground => new TranslatableString(getKey(@"downloading_in_background"), @"Beatmaps will be downloaded in the background. You can continue with setup while this happens!"); + /// /// "You can also obtain more beatmaps from the main menu "browse" button at any time." /// diff --git a/osu.Game/Localisation/GameplaySettingsStrings.cs b/osu.Game/Localisation/GameplaySettingsStrings.cs index 8ee76fdd55..6de61f7ebe 100644 --- a/osu.Game/Localisation/GameplaySettingsStrings.cs +++ b/osu.Game/Localisation/GameplaySettingsStrings.cs @@ -84,6 +84,11 @@ namespace osu.Game.Localisation /// public static LocalisableString AlwaysShowGameplayLeaderboard => new TranslatableString(getKey(@"gameplay_leaderboard"), @"Always show gameplay leaderboard"); + /// + /// "Always show hold for menu button" + /// + public static LocalisableString AlwaysShowHoldForMenuButton => new TranslatableString(getKey(@"always_show_hold_for_menu_button"), @"Always show hold for menu button"); + /// /// "Always play first combo break sound" /// diff --git a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs index 703e0ff1ca..ed80704a0a 100644 --- a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs +++ b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs @@ -190,9 +190,14 @@ namespace osu.Game.Localisation public static LocalisableString EditorCloneSelection => new TranslatableString(getKey(@"editor_clone_selection"), @"Clone selection"); /// - /// "Cycle grid display mode" + /// "Cycle grid spacing" /// - public static LocalisableString EditorCycleGridDisplayMode => new TranslatableString(getKey(@"editor_cycle_grid_display_mode"), @"Cycle grid display mode"); + public static LocalisableString EditorCycleGridSpacing => new TranslatableString(getKey(@"editor_cycle_grid_spacing"), @"Cycle grid spacing"); + + /// + /// "Cycle grid type" + /// + public static LocalisableString EditorCycleGridType => new TranslatableString(getKey(@"editor_cycle_grid_type"), @"Cycle grid type"); /// /// "Test gameplay" @@ -369,6 +374,61 @@ namespace osu.Game.Localisation /// public static LocalisableString EditorToggleRotateControl => new TranslatableString(getKey(@"editor_toggle_rotate_control"), @"Toggle rotate control"); + /// + /// "Toggle scale control" + /// + public static LocalisableString EditorToggleScaleControl => new TranslatableString(getKey(@"editor_toggle_scale_control"), @"Toggle scale control"); + + /// + /// "Toggle autoplay" + /// + public static LocalisableString EditorTestPlayToggleAutoplay => new TranslatableString(getKey(@"editor_test_play_toggle_autoplay"), @"Toggle autoplay"); + + /// + /// "Toggle quick pause" + /// + public static LocalisableString EditorTestPlayToggleQuickPause => new TranslatableString(getKey(@"editor_test_play_toggle_quick_pause"), @"Toggle quick pause"); + + /// + /// "Quick exit to initial time" + /// + public static LocalisableString EditorTestPlayQuickExitToInitialTime => new TranslatableString(getKey(@"editor_test_play_quick_exit_to_initial_time"), @"Quick exit to initial time"); + + /// + /// "Quick exit to current time" + /// + public static LocalisableString EditorTestPlayQuickExitToCurrentTime => new TranslatableString(getKey(@"editor_test_play_quick_exit_to_current_time"), @"Quick exit to current time"); + + /// + /// "Increase mod speed" + /// + public static LocalisableString IncreaseModSpeed => new TranslatableString(getKey(@"increase_mod_speed"), @"Increase mod speed"); + + /// + /// "Decrease mod speed" + /// + public static LocalisableString DecreaseModSpeed => new TranslatableString(getKey(@"decrease_mod_speed"), @"Decrease mod speed"); + + /// + /// "Seek to previous hit object" + /// + public static LocalisableString EditorSeekToPreviousHitObject => new TranslatableString(getKey(@"editor_seek_to_previous_hit_object"), @"Seek to previous hit object"); + + /// + /// "Seek to next hit object" + /// + public static LocalisableString EditorSeekToNextHitObject => new TranslatableString(getKey(@"editor_seek_to_next_hit_object"), @"Seek to next hit object"); + + /// + /// "Seek to previous sample point" + /// + public static LocalisableString EditorSeekToPreviousSamplePoint => new TranslatableString(getKey(@"editor_seek_to_previous_sample_point"), @"Seek to previous sample point"); + + /// + /// "Seek to next sample point" + /// + public static LocalisableString EditorSeekToNextSamplePoint => new TranslatableString(getKey(@"editor_seek_to_next_sample_point"), @"Seek to next sample point"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/HUD/SongProgressStrings.cs b/osu.Game/Localisation/HUD/SongProgressStrings.cs index 4c621e8e8c..332f15cb17 100644 --- a/osu.Game/Localisation/HUD/SongProgressStrings.cs +++ b/osu.Game/Localisation/HUD/SongProgressStrings.cs @@ -19,6 +19,16 @@ namespace osu.Game.Localisation.HUD /// public static LocalisableString ShowGraphDescription => new TranslatableString(getKey(@"show_graph_description"), "Whether a graph displaying difficulty throughout the beatmap should be shown"); + /// + /// "Show time" + /// + public static LocalisableString ShowTime => new TranslatableString(getKey(@"show_time"), "Show time"); + + /// + /// "Whether the passed and remaining time should be shown" + /// + public static LocalisableString ShowTimeDescription => new TranslatableString(getKey(@"show_time_description"), "Whether the passed and remaining time should be shown"); + private static string getKey(string key) => $"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/InputSettingsStrings.cs b/osu.Game/Localisation/InputSettingsStrings.cs index fcfe48bedb..bc1a7e68ab 100644 --- a/osu.Game/Localisation/InputSettingsStrings.cs +++ b/osu.Game/Localisation/InputSettingsStrings.cs @@ -49,6 +49,11 @@ namespace osu.Game.Localisation /// public static LocalisableString EditorSection => new TranslatableString(getKey(@"editor_section"), @"Editor"); + /// + /// "Editor: Test play" + /// + public static LocalisableString EditorTestPlaySection => new TranslatableString(getKey(@"editor_test_play_section"), @"Editor: Test play"); + /// /// "Reset all bindings in section" /// diff --git a/osu.Game/Localisation/Language.cs b/osu.Game/Localisation/Language.cs index 711e95486f..4e1fc3a474 100644 --- a/osu.Game/Localisation/Language.cs +++ b/osu.Game/Localisation/Language.cs @@ -2,10 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using JetBrains.Annotations; namespace osu.Game.Localisation { + [SuppressMessage("ReSharper", "InconsistentNaming")] [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] public enum Language { diff --git a/osu.Game/Localisation/MaintenanceSettingsStrings.cs b/osu.Game/Localisation/MaintenanceSettingsStrings.cs index 2e5f1d29df..6d5e0d5e0e 100644 --- a/osu.Game/Localisation/MaintenanceSettingsStrings.cs +++ b/osu.Game/Localisation/MaintenanceSettingsStrings.cs @@ -34,11 +34,6 @@ namespace osu.Game.Localisation /// public static LocalisableString ProhibitedInteractDuringMigration => new TranslatableString(getKey(@"prohibited_interact_during_migration"), @"Please avoid interacting with the game!"); - /// - /// "Some files couldn't be cleaned up during migration. Clicking this notification will open the folder so you can manually clean things up." - /// - public static LocalisableString FailedCleanupNotification => new TranslatableString(getKey(@"failed_cleanup_notification"), @"Some files couldn't be cleaned up during migration. Clicking this notification will open the folder so you can manually clean things up."); - /// /// "Please select a new location" /// @@ -64,6 +59,11 @@ namespace osu.Game.Localisation /// public static LocalisableString DeleteAllBeatmapVideos => new TranslatableString(getKey(@"delete_all_beatmap_videos"), @"Delete ALL beatmap videos"); + /// + /// "Reset ALL beatmap offsets" + /// + public static LocalisableString ResetAllOffsets => new TranslatableString(getKey(@"reset_all_offsets"), @"Reset ALL beatmap offsets"); + /// /// "Delete ALL scores" /// diff --git a/osu.Game/Localisation/ModSelectOverlayStrings.cs b/osu.Game/Localisation/ModSelectOverlayStrings.cs index 7a9bb698d8..10037d30c3 100644 --- a/osu.Game/Localisation/ModSelectOverlayStrings.cs +++ b/osu.Game/Localisation/ModSelectOverlayStrings.cs @@ -14,10 +14,16 @@ namespace osu.Game.Localisation /// public static LocalisableString ModSelectTitle => new TranslatableString(getKey(@"mod_select_title"), @"Mod Select"); + /// + /// "{0} mods" + /// + public static LocalisableString Mods(int count) => new TranslatableString(getKey(@"mods"), @"{0} mods", count); + /// /// "Mods provide different ways to enjoy gameplay. Some have an effect on the score you can achieve during ranked play. Others are just for fun." /// - public static LocalisableString ModSelectDescription => new TranslatableString(getKey(@"mod_select_description"), @"Mods provide different ways to enjoy gameplay. Some have an effect on the score you can achieve during ranked play. Others are just for fun."); + public static LocalisableString ModSelectDescription => new TranslatableString(getKey(@"mod_select_description"), + @"Mods provide different ways to enjoy gameplay. Some have an effect on the score you can achieve during ranked play. Others are just for fun."); /// /// "Mod Customisation" @@ -69,6 +75,16 @@ namespace osu.Game.Localisation /// public static LocalisableString UnrankedExplanation => new TranslatableString(getKey(@"unranked_explanation"), @"Performance points will not be granted due to active mods."); + /// + /// "Customise" + /// + public static LocalisableString CustomisationPanelHeader => new TranslatableString(getKey(@"customisation_panel_header"), @"Customise"); + + /// + /// "No mod selected which can be customised." + /// + public static LocalisableString CustomisationPanelDisabledReason => new TranslatableString(getKey(@"customisation_panel_disabled_reason"), @"No mod selected which can be customised."); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/NotificationsStrings.cs b/osu.Game/Localisation/NotificationsStrings.cs index 3188ca5533..d8f768f2d8 100644 --- a/osu.Game/Localisation/NotificationsStrings.cs +++ b/osu.Game/Localisation/NotificationsStrings.cs @@ -114,7 +114,7 @@ Please try changing your audio device to a working setting."); public static LocalisableString MismatchingBeatmapForReplay => new TranslatableString(getKey(@"mismatching_beatmap_for_replay"), @"Your local copy of the beatmap for this replay appears to be different than expected. You may need to update or re-download it."); /// - /// "You are now running osu! {version}. + /// "You are now running osu! {0}. /// Click to see what's new!" /// public static LocalisableString GameVersionAfterUpdate(string version) => new TranslatableString(getKey(@"game_version_after_update"), @"You are now running osu! {0}. @@ -135,11 +135,6 @@ Click to see what's new!", version); /// public static LocalisableString DownloadingUpdate => new TranslatableString(getKey(@"downloading_update"), @"Downloading update..."); - /// - /// "Installing update..." - /// - public static LocalisableString InstallingUpdate => new TranslatableString(getKey(@"installing_update"), @"Installing update..."); - private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/OnlineSettingsStrings.cs b/osu.Game/Localisation/OnlineSettingsStrings.cs index 0660bac172..8e8c81cf59 100644 --- a/osu.Game/Localisation/OnlineSettingsStrings.cs +++ b/osu.Game/Localisation/OnlineSettingsStrings.cs @@ -79,6 +79,11 @@ namespace osu.Game.Localisation /// public static LocalisableString DiscordPresenceOff => new TranslatableString(getKey(@"discord_presence_off"), @"Off"); + /// + /// "Hide country flags" + /// + public static LocalisableString HideCountryFlags => new TranslatableString(getKey(@"hide_country_flags"), @"Hide country flags"); + private static string getKey(string key) => $"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/PlayerLoaderStrings.cs b/osu.Game/Localisation/PlayerLoaderStrings.cs index eba98c7aa7..f9d6f80676 100644 --- a/osu.Game/Localisation/PlayerLoaderStrings.cs +++ b/osu.Game/Localisation/PlayerLoaderStrings.cs @@ -26,10 +26,10 @@ namespace osu.Game.Localisation /// /// "No performance points will be awarded. - /// Leaderboards may be reset by the beatmap creator." + /// Leaderboards may be reset." /// public static LocalisableString LovedBeatmapDisclaimerContent => new TranslatableString(getKey(@"loved_beatmap_disclaimer_content"), @"No performance points will be awarded. -Leaderboards may be reset by the beatmap creator."); +Leaderboards may be reset."); /// /// "This beatmap is qualified" diff --git a/osu.Game/Localisation/SkinComponents/BeatmapAttributeTextStrings.cs b/osu.Game/Localisation/SkinComponents/BeatmapAttributeTextStrings.cs index b2e2285faf..390a6f9ca4 100644 --- a/osu.Game/Localisation/SkinComponents/BeatmapAttributeTextStrings.cs +++ b/osu.Game/Localisation/SkinComponents/BeatmapAttributeTextStrings.cs @@ -12,23 +12,28 @@ namespace osu.Game.Localisation.SkinComponents /// /// "Attribute" /// - public static LocalisableString Attribute => new TranslatableString(getKey(@"attribute"), "Attribute"); + public static LocalisableString Attribute => new TranslatableString(getKey(@"attribute"), @"Attribute"); /// /// "The attribute to be displayed." /// - public static LocalisableString AttributeDescription => new TranslatableString(getKey(@"attribute_description"), "The attribute to be displayed."); + public static LocalisableString AttributeDescription => new TranslatableString(getKey(@"attribute_description"), @"The attribute to be displayed."); /// /// "Template" /// - public static LocalisableString Template => new TranslatableString(getKey(@"template"), "Template"); + public static LocalisableString Template => new TranslatableString(getKey(@"template"), @"Template"); /// /// "Supports {{Label}} and {{Value}}, but also including arbitrary attributes like {{StarRating}} (see attribute list for supported values)." /// public static LocalisableString TemplateDescription => new TranslatableString(getKey(@"template_description"), @"Supports {{Label}} and {{Value}}, but also including arbitrary attributes like {{StarRating}} (see attribute list for supported values)."); - private static string getKey(string key) => $"{prefix}:{key}"; + /// + /// "Max PP" + /// + public static LocalisableString MaxPP => new TranslatableString(getKey(@"max_pp"), @"Max PP"); + + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/SkinComponents/SkinnableComponentStrings.cs b/osu.Game/Localisation/SkinComponents/SkinnableComponentStrings.cs index d5c8d5ccec..b21446e18a 100644 --- a/osu.Game/Localisation/SkinComponents/SkinnableComponentStrings.cs +++ b/osu.Game/Localisation/SkinComponents/SkinnableComponentStrings.cs @@ -59,6 +59,31 @@ namespace osu.Game.Localisation.SkinComponents /// public static LocalisableString ShowLabelDescription => new TranslatableString(getKey(@"show_label_description"), @"Whether the component's label should be shown."); + /// + /// "Colour" + /// + public static LocalisableString Colour => new TranslatableString(getKey(@"colour"), @"Colour"); + + /// + /// "The colour of the component." + /// + public static LocalisableString ColourDescription => new TranslatableString(getKey(@"colour_description"), @"The colour of the component."); + + /// + /// "Text colour" + /// + public static LocalisableString TextColour => new TranslatableString(getKey(@"text_colour"), @"Text colour"); + + /// + /// "The colour of the text." + /// + public static LocalisableString TextColourDescription => new TranslatableString(getKey(@"text_colour_description"), @"The colour of the text."); + + /// + /// "Use relative size" + /// + public static LocalisableString UseRelativeSize => new TranslatableString(getKey(@"use_relative_size"), @"Use relative size"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/SkinEditorStrings.cs b/osu.Game/Localisation/SkinEditorStrings.cs index 3c1d1ff40d..d96ea7dd9f 100644 --- a/osu.Game/Localisation/SkinEditorStrings.cs +++ b/osu.Game/Localisation/SkinEditorStrings.cs @@ -49,6 +49,51 @@ namespace osu.Game.Localisation /// public static LocalisableString RevertToDefaultDescription => new TranslatableString(getKey(@"revert_to_default_description"), @"All layout elements for layers in the current screen will be reset to defaults."); + /// + /// "Closest" + /// + public static LocalisableString Closest => new TranslatableString(getKey(@"closest"), @"Closest"); + + /// + /// "Anchor" + /// + public static LocalisableString Anchor => new TranslatableString(getKey(@"anchor"), @"Anchor"); + + /// + /// "Origin" + /// + public static LocalisableString Origin => new TranslatableString(getKey(@"origin"), @"Origin"); + + /// + /// "Reset position" + /// + public static LocalisableString ResetPosition => new TranslatableString(getKey(@"reset_position"), @"Reset position"); + + /// + /// "Reset rotation" + /// + public static LocalisableString ResetRotation => new TranslatableString(getKey(@"reset_rotation"), @"Reset rotation"); + + /// + /// "Reset scale" + /// + public static LocalisableString ResetScale => new TranslatableString(getKey(@"reset_scale"), @"Reset scale"); + + /// + /// "Bring to front" + /// + public static LocalisableString BringToFront => new TranslatableString(getKey(@"bring_to_front"), @"Bring to front"); + + /// + /// "Send to back" + /// + public static LocalisableString SendToBack => new TranslatableString(getKey(@"send_to_back"), @"Send to back"); + + /// + /// "Current working layer" + /// + public static LocalisableString CurrentWorkingLayer => new TranslatableString(getKey(@"current_working_layer"), @"Current working layer"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/ToastStrings.cs b/osu.Game/Localisation/ToastStrings.cs index da798a3937..49e8d00371 100644 --- a/osu.Game/Localisation/ToastStrings.cs +++ b/osu.Game/Localisation/ToastStrings.cs @@ -45,9 +45,14 @@ namespace osu.Game.Localisation public static LocalisableString SkinSaved => new TranslatableString(getKey(@"skin_saved"), @"Skin saved"); /// - /// "URL copied" + /// "Link copied to clipboard" /// - public static LocalisableString UrlCopied => new TranslatableString(getKey(@"url_copied"), @"URL copied"); + public static LocalisableString UrlCopied => new TranslatableString(getKey(@"url_copied"), @"Link copied to clipboard"); + + /// + /// "Speed changed to {0:N2}x" + /// + public static LocalisableString SpeedChangedTo(double speed) => new TranslatableString(getKey(@"speed_changed"), @"Speed changed to {0:N2}x", speed); private static string getKey(string key) => $@"{prefix}:{key}"; } diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs index d3707fe74d..c8992c108e 100644 --- a/osu.Game/Online/API/APIAccess.cs +++ b/osu.Game/Online/API/APIAccess.cs @@ -42,7 +42,11 @@ namespace osu.Game.Online.API public string WebsiteRootUrl { get; } - public int APIVersion => 20220705; // We may want to pull this from the game version eventually. + /// + /// The API response version. + /// See: https://osu.ppy.sh/docs/index.html#api-versions + /// + public int APIVersion { get; } public Exception LastLoginError { get; private set; } @@ -53,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; @@ -63,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(); @@ -84,12 +88,23 @@ namespace osu.Game.Online.API this.config = config; this.versionHash = versionHash; + if (game.IsDeployedBuild) + APIVersion = game.AssemblyVersion.Major * 10000 + game.AssemblyVersion.Minor; + else + { + var now = DateTimeOffset.Now; + APIVersion = now.Year * 10000 + now.Month * 100 + now.Day; + } + APIEndpointUrl = endpointConfiguration.APIEndpointUrl; WebsiteRootUrl = endpointConfiguration.WebsiteRootUrl; NotificationsClient = setUpNotificationsClient(); authentication = new OAuth(endpointConfiguration.APIClientID, endpointConfiguration.APIClientSecret, APIEndpointUrl); + log = Logger.GetLogger(LoggingTarget.Network); + log.Add($@"API endpoint root: {APIEndpointUrl}"); + log.Add($@"API request version: {APIVersion}"); ProvidedUsername = config.Get(OsuSetting.Username); @@ -103,12 +118,11 @@ namespace osu.Game.Online.API u.OldValue?.Activity.UnbindFrom(activity); u.NewValue.Activity.BindTo(activity); - if (u.OldValue != null) - localUserStatus.UnbindFrom(u.OldValue.Status); - localUserStatus.BindTo(u.NewValue.Status); + u.OldValue?.Status.UnbindFrom(localUserStatus); + u.NewValue.Status.BindTo(localUserStatus); }, true); - localUserStatus.BindValueChanged(val => configStatus.Value = val.NewValue); + localUserStatus.BindTo(configStatus); var thread = new Thread(run) { @@ -145,10 +159,12 @@ namespace osu.Game.Online.API private void onTokenChanged(ValueChangedEvent e) => config.SetValue(OsuSetting.Token, config.Get(OsuSetting.SavePassword) ? authentication.TokenString : string.Empty); - internal new void Schedule(Action action) => base.Schedule(action); + void IAPIProvider.Schedule(Action action) => base.Schedule(action); public string AccessToken => authentication.RequestAccessToken(); + public Guid SessionIdentifier { get; } = Guid.NewGuid(); + /// /// Number of consecutive requests which failed due to network issues. /// @@ -344,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 @@ -369,7 +373,8 @@ namespace osu.Game.Online.API { try { - request.Perform(this); + request.AttachAPI(this); + request.Perform(); } catch (Exception e) { @@ -467,7 +472,8 @@ namespace osu.Game.Online.API { try { - req.Perform(this); + req.AttachAPI(this); + req.Perform(); if (req.CompletionState != APIRequestCompletionState.Completed) return false; @@ -552,6 +558,8 @@ namespace osu.Game.Online.API { lock (queue) { + request.AttachAPI(this); + if (state.Value == APIState.Offline) { request.Fail(new WebException(@"User not logged in")); @@ -583,6 +591,7 @@ namespace osu.Game.Online.API password = null; SecondFactorCode = null; authentication.Clear(); + configStatus.Value = UserStatus.Online; // Scheduled prior to state change such that the state changed event is invoked with the correct user and their friends present Schedule(() => @@ -603,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/APIDownloadRequest.cs b/osu.Game/Online/API/APIDownloadRequest.cs index c48372278a..f8db52139d 100644 --- a/osu.Game/Online/API/APIDownloadRequest.cs +++ b/osu.Game/Online/API/APIDownloadRequest.cs @@ -4,6 +4,7 @@ #nullable disable using System; +using System.Diagnostics; using System.IO; using osu.Framework.IO.Network; @@ -34,7 +35,11 @@ namespace osu.Game.Online.API return request; } - private void request_Progress(long current, long total) => API.Schedule(() => Progressed?.Invoke(current, total)); + private void request_Progress(long current, long total) + { + Debug.Assert(API != null); + API.Schedule(() => Progressed?.Invoke(current, total)); + } protected void TriggerSuccess(string filename) { diff --git a/osu.Game/Online/API/APIRequest.cs b/osu.Game/Online/API/APIRequest.cs index 6b6b222043..5cbe9040ba 100644 --- a/osu.Game/Online/API/APIRequest.cs +++ b/osu.Game/Online/API/APIRequest.cs @@ -1,11 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; +using System.Diagnostics; using System.Globalization; -using JetBrains.Annotations; using Newtonsoft.Json; using osu.Framework.Extensions.TypeExtensions; using osu.Framework.IO.Network; @@ -26,18 +24,17 @@ namespace osu.Game.Online.API /// /// The deserialised response object. May be null if the request or deserialisation failed. /// - [CanBeNull] - public T Response { get; private set; } + public T? Response { get; private set; } /// /// Invoked on successful completion of an API request. /// This will be scheduled to the API's internal scheduler (run on update thread automatically). /// - public new event APISuccessHandler Success; + public new event APISuccessHandler? Success; protected APIRequest() { - base.Success += () => Success?.Invoke(Response); + base.Success += () => Success?.Invoke(Response!); } protected override void PostProcess() @@ -49,6 +46,9 @@ namespace osu.Game.Online.API Response = ((OsuJsonWebRequest)WebRequest).ResponseObject; Logger.Log($"{GetType().ReadableName()} finished with response size of {WebRequest.ResponseStream.Length:#,0} bytes", LoggingTarget.Network); } + + if (Response == null) + TriggerFailure(new ArgumentNullException(nameof(Response))); } internal void TriggerSuccess(T result) @@ -71,27 +71,28 @@ namespace osu.Game.Online.API protected virtual WebRequest CreateWebRequest() => new OsuWebRequest(Uri); - protected virtual string Uri => $@"{API.APIEndpointUrl}/api/v2/{Target}"; + protected virtual string Uri => $@"{API!.APIEndpointUrl}/api/v2/{Target}"; - protected APIAccess API; - protected WebRequest WebRequest; + protected IAPIProvider? API; + + protected WebRequest? WebRequest; /// /// The currently logged in user. Note that this will only be populated during . /// - protected APIUser User { get; private set; } + protected APIUser? User { get; private set; } /// /// Invoked on successful completion of an API request. /// This will be scheduled to the API's internal scheduler (run on update thread automatically). /// - public event APISuccessHandler Success; + public event APISuccessHandler? Success; /// /// Invoked on failure to complete an API request. /// This will be scheduled to the API's internal scheduler (run on update thread automatically). /// - public event APIFailureHandler Failure; + public event APIFailureHandler? Failure; private readonly object completionStateLock = new object(); @@ -101,16 +102,29 @@ namespace osu.Game.Online.API /// public APIRequestCompletionState CompletionState { get; private set; } - public void Perform(IAPIProvider api) + /// + /// Should be called before to give API context. + /// + /// + /// This allows scheduling of operations back to the correct thread (which may be required before is called). + /// + public void AttachAPI(IAPIProvider apiAccess) { - if (!(api is APIAccess apiAccess)) + if (API != null && API != apiAccess) + throw new InvalidOperationException("Attached API cannot be changed after initial set."); + + API = apiAccess; + } + + public void Perform() + { + if (API == null) { Fail(new NotSupportedException($"A {nameof(APIAccess)} is required to perform requests.")); return; } - API = apiAccess; - User = apiAccess.LocalUser.Value; + User = API.LocalUser.Value; if (isFailing) return; @@ -141,6 +155,8 @@ namespace osu.Game.Online.API PostProcess(); + if (isFailing) return; + TriggerSuccess(); } @@ -153,6 +169,8 @@ namespace osu.Game.Online.API internal void TriggerSuccess() { + Debug.Assert(API != null); + lock (completionStateLock) { if (CompletionState != APIRequestCompletionState.Waiting) @@ -161,14 +179,13 @@ namespace osu.Game.Online.API CompletionState = APIRequestCompletionState.Completed; } - if (API == null) - Success?.Invoke(); - else - API.Schedule(() => Success?.Invoke()); + API.Schedule(() => Success?.Invoke()); } internal void TriggerFailure(Exception e) { + Debug.Assert(API != null); + lock (completionStateLock) { if (CompletionState != APIRequestCompletionState.Waiting) @@ -177,10 +194,7 @@ namespace osu.Game.Online.API CompletionState = APIRequestCompletionState.Failed; } - if (API == null) - Failure?.Invoke(e); - else - API.Schedule(() => Failure?.Invoke(e)); + API.Schedule(() => Failure?.Invoke(e)); } public void Cancel() => Fail(new OperationCanceledException(@"Request cancelled")); @@ -197,7 +211,7 @@ namespace osu.Game.Online.API // in the case of a cancellation we don't care about whether there's an error in the response. if (!(e is OperationCanceledException)) { - string responseString = WebRequest?.GetResponseString(); + string? responseString = WebRequest?.GetResponseString(); // naive check whether there's an error in the response to avoid unnecessary JSON deserialisation. if (!string.IsNullOrEmpty(responseString) && responseString.Contains(@"""error""")) @@ -235,7 +249,7 @@ namespace osu.Game.Online.API private class DisplayableError { [JsonProperty("error")] - public string ErrorMessage { get; set; } + public string ErrorMessage { get; set; } = string.Empty; } } diff --git a/osu.Game/Online/API/DummyAPIAccess.cs b/osu.Game/Online/API/DummyAPIAccess.cs index 4962838bd9..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(); @@ -39,6 +39,8 @@ namespace osu.Game.Online.API public string AccessToken => "token"; + public Guid SessionIdentifier { get; } = Guid.NewGuid(); + /// public bool IsLoggedIn => State.Value > APIState.Offline; @@ -80,19 +82,35 @@ namespace osu.Game.Online.API public virtual void Queue(APIRequest request) { + request.AttachAPI(this); + Schedule(() => { if (HandleRequest?.Invoke(request) != true) { + // Noisy so let's silently allow these to succeed. + if (request is ChatAckRequest ack) + { + ack.TriggerSuccess(new ChatAckResponse()); + return; + } + request.Fail(new InvalidOperationException($@"{nameof(DummyAPIAccess)} cannot process this request.")); } }); } - public void Perform(APIRequest request) => HandleRequest?.Invoke(request); + void IAPIProvider.Schedule(Action action) => base.Schedule(action); + + public void Perform(APIRequest request) + { + request.AttachAPI(this); + HandleRequest?.Invoke(request); + } public Task PerformAsync(APIRequest request) { + request.AttachAPI(this); HandleRequest?.Invoke(request); return Task.CompletedTask; } @@ -146,6 +164,8 @@ namespace osu.Game.Online.API state.Value = APIState.Connecting; LastLoginError = null; + request.AttachAPI(this); + // if no handler installed / handler can't handle verification, just assume that the server would verify for simplicity. if (HandleRequest?.Invoke(request) != true) onSuccessfulLogin(); @@ -181,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); @@ -194,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 66f124f7c3..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. @@ -44,6 +44,12 @@ namespace osu.Game.Online.API /// string AccessToken { get; } + /// + /// Used as an identifier of a single local lazer session. + /// Sent across the wire for the purposes of concurrency control to spectator server. + /// + Guid SessionIdentifier { get; } + /// /// Returns whether the local user is logged in. /// @@ -61,7 +67,7 @@ namespace osu.Game.Online.API string APIEndpointUrl { get; } /// - /// The root URL of of the website, excluding the trailing slash. + /// The root URL of the website, excluding the trailing slash. /// string WebsiteRootUrl { get; } @@ -128,6 +134,16 @@ 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. + /// + internal void Schedule(Action action); + /// /// Constructs a new . May be null if not supported. /// diff --git a/osu.Game/Online/API/Requests/AddFriendRequest.cs b/osu.Game/Online/API/Requests/AddFriendRequest.cs new file mode 100644 index 0000000000..11045cedbe --- /dev/null +++ b/osu.Game/Online/API/Requests/AddFriendRequest.cs @@ -0,0 +1,30 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Net.Http; +using osu.Framework.IO.Network; + +namespace osu.Game.Online.API.Requests +{ + public class AddFriendRequest : APIRequest + { + public readonly int TargetId; + + public AddFriendRequest(int targetId) + { + TargetId = targetId; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + + req.Method = HttpMethod.Post; + req.AddParameter("target", TargetId.ToString(), RequestParameterType.Query); + + return req; + } + + protected override string Target => @"friends"; + } +} diff --git a/osu.Game/Online/API/Requests/AddFriendResponse.cs b/osu.Game/Online/API/Requests/AddFriendResponse.cs new file mode 100644 index 0000000000..af9d037e47 --- /dev/null +++ b/osu.Game/Online/API/Requests/AddFriendResponse.cs @@ -0,0 +1,14 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Newtonsoft.Json; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Online.API.Requests +{ + public class AddFriendResponse + { + [JsonProperty("user_relation")] + public APIRelation UserRelation = null!; + } +} diff --git a/osu.Game/Online/API/Requests/DeleteFriendRequest.cs b/osu.Game/Online/API/Requests/DeleteFriendRequest.cs new file mode 100644 index 0000000000..42ceb2c55a --- /dev/null +++ b/osu.Game/Online/API/Requests/DeleteFriendRequest.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Net.Http; +using osu.Framework.IO.Network; + +namespace osu.Game.Online.API.Requests +{ + public class DeleteFriendRequest : APIRequest + { + public readonly int TargetId; + + public DeleteFriendRequest(int targetId) + { + TargetId = targetId; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.Method = HttpMethod.Delete; + return req; + } + + protected override string Target => $@"friends/{TargetId}"; + } +} diff --git a/osu.Game/Online/API/Requests/GetBeatmapRequest.cs b/osu.Game/Online/API/Requests/GetBeatmapRequest.cs index 3383d21dfc..14bb0d3122 100644 --- a/osu.Game/Online/API/Requests/GetBeatmapRequest.cs +++ b/osu.Game/Online/API/Requests/GetBeatmapRequest.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Globalization; using osu.Framework.IO.Network; using osu.Game.Beatmaps; using osu.Game.Online.API.Requests.Responses; @@ -9,23 +10,30 @@ namespace osu.Game.Online.API.Requests { public class GetBeatmapRequest : APIRequest { - public readonly IBeatmapInfo BeatmapInfo; - public readonly string Filename; + public readonly int OnlineID; + public readonly string? MD5Hash; + public readonly string? Filename; public GetBeatmapRequest(IBeatmapInfo beatmapInfo) + : this(onlineId: beatmapInfo.OnlineID, md5Hash: beatmapInfo.MD5Hash, filename: (beatmapInfo as BeatmapInfo)?.Path) { - BeatmapInfo = beatmapInfo; - Filename = (beatmapInfo as BeatmapInfo)?.Path ?? string.Empty; + } + + public GetBeatmapRequest(int onlineId = -1, string? md5Hash = null, string? filename = null) + { + OnlineID = onlineId; + MD5Hash = md5Hash; + Filename = filename; } protected override WebRequest CreateWebRequest() { var request = base.CreateWebRequest(); - if (BeatmapInfo.OnlineID > 0) - request.AddParameter(@"id", BeatmapInfo.OnlineID.ToString()); - if (!string.IsNullOrEmpty(BeatmapInfo.MD5Hash)) - request.AddParameter(@"checksum", BeatmapInfo.MD5Hash); + if (OnlineID > 0) + request.AddParameter(@"id", OnlineID.ToString(CultureInfo.InvariantCulture)); + if (!string.IsNullOrEmpty(MD5Hash)) + request.AddParameter(@"checksum", MD5Hash); if (!string.IsNullOrEmpty(Filename)) request.AddParameter(@"filename", Filename); diff --git a/osu.Game/Online/API/Requests/GetFriendsRequest.cs b/osu.Game/Online/API/Requests/GetFriendsRequest.cs index 63a221d91a..77b37e87d0 100644 --- a/osu.Game/Online/API/Requests/GetFriendsRequest.cs +++ b/osu.Game/Online/API/Requests/GetFriendsRequest.cs @@ -6,7 +6,7 @@ using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Online.API.Requests { - public class GetFriendsRequest : APIRequest> + public class GetFriendsRequest : APIRequest> { protected override string Target => @"friends"; } diff --git a/osu.Game/Online/API/Requests/GetUsersRequest.cs b/osu.Game/Online/API/Requests/GetUsersRequest.cs index 6f7e9c07d2..cd75ff4e31 100644 --- a/osu.Game/Online/API/Requests/GetUsersRequest.cs +++ b/osu.Game/Online/API/Requests/GetUsersRequest.cs @@ -2,9 +2,15 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Online.API.Requests { + /// + /// Looks up users with the given . + /// In comparison to , the response here contains , + /// but in exchange is subject to more stringent rate limiting. + /// public class GetUsersRequest : APIRequest { public readonly int[] UserIds; diff --git a/osu.Game/Online/API/Requests/JoinChannelRequest.cs b/osu.Game/Online/API/Requests/JoinChannelRequest.cs index 33eab7e355..0109e653d9 100644 --- a/osu.Game/Online/API/Requests/JoinChannelRequest.cs +++ b/osu.Game/Online/API/Requests/JoinChannelRequest.cs @@ -23,6 +23,6 @@ namespace osu.Game.Online.API.Requests return req; } - protected override string Target => $@"chat/channels/{channel.Id}/users/{User.Id}"; + protected override string Target => $@"chat/channels/{channel.Id}/users/{User!.Id}"; } } diff --git a/osu.Game/Online/API/Requests/LeaveChannelRequest.cs b/osu.Game/Online/API/Requests/LeaveChannelRequest.cs index 7dfc9a0aed..36cfd79c60 100644 --- a/osu.Game/Online/API/Requests/LeaveChannelRequest.cs +++ b/osu.Game/Online/API/Requests/LeaveChannelRequest.cs @@ -23,6 +23,6 @@ namespace osu.Game.Online.API.Requests return req; } - protected override string Target => $@"chat/channels/{channel.Id}/users/{User.Id}"; + protected override string Target => $@"chat/channels/{channel.Id}/users/{User!.Id}"; } } diff --git a/osu.Game/Online/API/Requests/LookupUsersRequest.cs b/osu.Game/Online/API/Requests/LookupUsersRequest.cs new file mode 100644 index 0000000000..6e98ce064e --- /dev/null +++ b/osu.Game/Online/API/Requests/LookupUsersRequest.cs @@ -0,0 +1,30 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Online.API.Requests +{ + /// + /// Looks up users with the given . + /// In comparison to , the response here does not contain , + /// but in exchange is subject to less stringent rate limiting, making it suitable for mass user listings. + /// + public class LookupUsersRequest : APIRequest + { + public readonly int[] UserIds; + + private const int max_ids_per_request = 50; + + public LookupUsersRequest(int[] userIds) + { + if (userIds.Length > max_ids_per_request) + throw new ArgumentException($"{nameof(LookupUsersRequest)} calls only support up to {max_ids_per_request} IDs at once"); + + UserIds = userIds; + } + + protected override string Target => @"users/lookup/?ids[]=" + string.Join(@"&ids[]=", UserIds); + } +} diff --git a/osu.Game/Online/API/Requests/Responses/APIRelation.cs b/osu.Game/Online/API/Requests/Responses/APIRelation.cs new file mode 100644 index 0000000000..c7315db8b9 --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/APIRelation.cs @@ -0,0 +1,30 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace osu.Game.Online.API.Requests.Responses +{ + public class APIRelation + { + [JsonProperty("target_id")] + public int TargetID { get; set; } + + [JsonProperty("relation_type")] + public RelationType RelationType { get; set; } + + [JsonProperty("mutual")] + public bool Mutual { get; set; } + + [JsonProperty("target")] + public APIUser? TargetUser { get; set; } + } + + [JsonConverter(typeof(StringEnumConverter))] + public enum RelationType + { + Friend, + Block, + } +} diff --git a/osu.Game/Online/API/Requests/Responses/APIUser.cs b/osu.Game/Online/API/Requests/Responses/APIUser.cs index 4a31718f28..5d80fde515 100644 --- a/osu.Game/Online/API/Requests/Responses/APIUser.cs +++ b/osu.Game/Online/API/Requests/Responses/APIUser.cs @@ -201,6 +201,9 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"playmode")] public string PlayMode; + [JsonProperty(@"profile_hue")] + public int? ProfileHue; + [JsonProperty(@"profile_order")] public string[] ProfileOrder; @@ -258,7 +261,7 @@ namespace osu.Game.Online.API.Requests.Responses public APIUserHistoryCount[] ReplaysWatchedCounts; /// - /// All user statistics per ruleset's short name (in the case of a response). + /// All user statistics per ruleset's short name (in the case of a or response). /// Otherwise empty. Can be altered for testing purposes. /// // todo: this should likely be moved to a separate UserCompact class at some point. @@ -269,6 +272,9 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty("groups")] public APIUserGroup[] Groups; + [JsonProperty("daily_challenge_user_stats")] + public APIUserDailyChallengeStatistics DailyChallengeStatistics = new APIUserDailyChallengeStatistics(); + public override string ToString() => Username; /// diff --git a/osu.Game/Online/API/Requests/Responses/APIUserDailyChallengeStatistics.cs b/osu.Game/Online/API/Requests/Responses/APIUserDailyChallengeStatistics.cs new file mode 100644 index 0000000000..e77f2b8f68 --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/APIUserDailyChallengeStatistics.cs @@ -0,0 +1,41 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using Newtonsoft.Json; + +namespace osu.Game.Online.API.Requests.Responses +{ + public class APIUserDailyChallengeStatistics + { + [JsonProperty("user_id")] + public int UserID; + + [JsonProperty("daily_streak_best")] + public int DailyStreakBest; + + [JsonProperty("daily_streak_current")] + public int DailyStreakCurrent; + + [JsonProperty("weekly_streak_best")] + public int WeeklyStreakBest; + + [JsonProperty("weekly_streak_current")] + public int WeeklyStreakCurrent; + + [JsonProperty("top_10p_placements")] + public int Top10PercentPlacements; + + [JsonProperty("top_50p_placements")] + public int Top50PercentPlacements; + + [JsonProperty("playcount")] + public int PlayCount; + + [JsonProperty("last_update")] + public DateTimeOffset? LastUpdate; + + [JsonProperty("last_weekly_streak")] + public DateTimeOffset? LastWeeklyStreak; + } +} diff --git a/osu.Game/Online/API/Requests/Responses/ChatAckResponse.cs b/osu.Game/Online/API/Requests/Responses/ChatAckResponse.cs index 6ed22a19b2..f68735d390 100644 --- a/osu.Game/Online/API/Requests/Responses/ChatAckResponse.cs +++ b/osu.Game/Online/API/Requests/Responses/ChatAckResponse.cs @@ -1,7 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; +using System; using Newtonsoft.Json; namespace osu.Game.Online.API.Requests.Responses @@ -10,6 +10,6 @@ namespace osu.Game.Online.API.Requests.Responses public class ChatAckResponse { [JsonProperty("silences")] - public List Silences { get; set; } = null!; + public ChatSilence[] Silences { get; set; } = Array.Empty(); } } diff --git a/osu.Game/Online/API/Requests/Responses/CommentableMeta.cs b/osu.Game/Online/API/Requests/Responses/CommentableMeta.cs index 1084f1c900..4b4595fef6 100644 --- a/osu.Game/Online/API/Requests/Responses/CommentableMeta.cs +++ b/osu.Game/Online/API/Requests/Responses/CommentableMeta.cs @@ -24,5 +24,14 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty("url")] public string Url { get; set; } = string.Empty; + + [JsonProperty("current_user_attributes")] + public CommentableCurrentUserAttributes? CurrentUserAttributes { get; set; } + + public struct CommentableCurrentUserAttributes + { + [JsonProperty("can_new_comment_reason")] + public string? CanNewCommentReason { get; set; } + } } } diff --git a/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs b/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs index 64caddb2fc..36f1311f9d 100644 --- a/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs +++ b/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs @@ -33,6 +33,9 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty("total_score")] public long TotalScore { get; set; } + [JsonProperty("total_score_without_mods")] + public long TotalScoreWithoutMods { get; set; } + [JsonProperty("accuracy")] public double Accuracy { get; set; } @@ -206,6 +209,7 @@ namespace osu.Game.Online.API.Requests.Responses Ruleset = new RulesetInfo { OnlineID = RulesetID }, Passed = Passed, TotalScore = TotalScore, + TotalScoreWithoutMods = TotalScoreWithoutMods, LegacyTotalScore = LegacyTotalScore, Accuracy = Accuracy, MaxCombo = MaxCombo, @@ -239,6 +243,7 @@ namespace osu.Game.Online.API.Requests.Responses { Rank = score.Rank, TotalScore = score.TotalScore, + TotalScoreWithoutMods = score.TotalScoreWithoutMods, Accuracy = score.Accuracy, PP = score.PP, MaxCombo = score.MaxCombo, diff --git a/osu.Game/Online/BeatmapDownloadTracker.cs b/osu.Game/Online/BeatmapDownloadTracker.cs index 3db602c353..6a2163c3a2 100644 --- a/osu.Game/Online/BeatmapDownloadTracker.cs +++ b/osu.Game/Online/BeatmapDownloadTracker.cs @@ -40,7 +40,10 @@ namespace osu.Game.Online // Used to interact with manager classes that don't support interface types. Will eventually be replaced. var beatmapSetInfo = new BeatmapSetInfo { OnlineID = TrackedItem.OnlineID }; - realmSubscription = realm.RegisterForNotifications(r => r.All().Where(s => s.OnlineID == TrackedItem.OnlineID && !s.DeletePending), (items, _) => + // Required local for iOS. Will cause runtime crash if inlined. + int onlineId = TrackedItem.OnlineID; + + realmSubscription = realm.RegisterForNotifications(r => r.All().Where(s => s.OnlineID == onlineId && !s.DeletePending), (items, _) => { if (items.Any()) Schedule(() => UpdateState(DownloadState.LocallyAvailable)); diff --git a/osu.Game/Online/Chat/ExternalLinkOpener.cs b/osu.Game/Online/Chat/ExternalLinkOpener.cs index 56d24e35bb..75b161d57b 100644 --- a/osu.Game/Online/Chat/ExternalLinkOpener.cs +++ b/osu.Game/Online/Chat/ExternalLinkOpener.cs @@ -8,8 +8,10 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Platform; using osu.Game.Configuration; +using osu.Game.Localisation; using osu.Game.Overlays; using osu.Game.Overlays.Dialog; +using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; namespace osu.Game.Online.Chat { @@ -44,8 +46,8 @@ namespace osu.Game.Online.Chat { public ExternalLinkDialog(string url, Action openExternalLinkAction, Action copyExternalLinkAction) { - HeaderText = "Just checking..."; - BodyText = $"You are about to leave osu! and open the following link in a web browser:\n\n{url}"; + HeaderText = DialogStrings.CautionHeaderText; + BodyText = $"Are you sure you want to open the following link in a web browser?\n\n{url}"; Icon = FontAwesome.Solid.ExclamationTriangle; @@ -53,17 +55,17 @@ namespace osu.Game.Online.Chat { new PopupDialogOkButton { - Text = @"Yes. Go for it.", + Text = @"Open in browser", Action = openExternalLinkAction }, new PopupDialogCancelButton { - Text = @"Copy URL to the clipboard instead.", + Text = CommonStrings.CopyLink, Action = copyExternalLinkAction }, new PopupDialogCancelButton { - Text = @"No! Abort mission!" + Text = WebCommonStrings.ButtonsCancel, }, }; } diff --git a/osu.Game/Online/Chat/MessageFormatter.cs b/osu.Game/Online/Chat/MessageFormatter.cs index f055633d64..f354eea027 100644 --- a/osu.Game/Online/Chat/MessageFormatter.cs +++ b/osu.Game/Online/Chat/MessageFormatter.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; +using System.Web; using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets.Edit; @@ -234,7 +235,7 @@ namespace osu.Game.Online.Chat return new LinkDetails(LinkAction.External, url); } - return new LinkDetails(linkType, args[2]); + return new LinkDetails(linkType, HttpUtility.UrlDecode(args[2])); case "osump": return new LinkDetails(LinkAction.JoinMultiplayerMatch, args[1]); @@ -271,7 +272,7 @@ namespace osu.Game.Online.Chat handleAdvanced(advanced_link_regex, result, startIndex); // handle editor times - handleMatches(EditorTimestampParser.TIME_REGEX, "{0}", $@"{OsuGameBase.OSU_PROTOCOL}edit/{{0}}", result, startIndex, LinkAction.OpenEditorTimestamp); + handleMatches(EditorTimestampParser.TIME_REGEX_STRICT, "{0}", $@"{OsuGameBase.OSU_PROTOCOL}edit/{{0}}", result, startIndex, LinkAction.OpenEditorTimestamp); // handle channels handleMatches(channel_regex, "{0}", $@"{OsuGameBase.OSU_PROTOCOL}chan/{{0}}", result, startIndex, LinkAction.OpenChannel); diff --git a/osu.Game/Online/Chat/StandAloneChatDisplay.cs b/osu.Game/Online/Chat/StandAloneChatDisplay.cs index e3b5037367..187191d232 100644 --- a/osu.Game/Online/Chat/StandAloneChatDisplay.cs +++ b/osu.Game/Online/Chat/StandAloneChatDisplay.cs @@ -128,6 +128,9 @@ namespace osu.Game.Online.Chat public partial class ChatTextBox : HistoryTextBox { + public Action Focus; + public Action FocusLost; + protected override bool OnKeyDown(KeyDownEvent e) { // Chat text boxes are generally used in places where they retain focus, but shouldn't block interaction with other @@ -153,13 +156,17 @@ namespace osu.Game.Online.Chat BackgroundFocused = new Color4(10, 10, 10, 255); } + protected override void OnFocus(FocusEvent e) + { + base.OnFocus(e); + Focus?.Invoke(); + } + protected override void OnFocusLost(FocusLostEvent e) { base.OnFocusLost(e); FocusLost?.Invoke(); } - - public Action FocusLost; } public partial class StandAloneDrawableChannel : DrawableChannel @@ -178,7 +185,7 @@ namespace osu.Game.Online.Chat protected partial class StandAloneDaySeparator : DaySeparator { - protected override float TextSize => 14; + protected override float TextSize => 13; protected override float LineHeight => 1; protected override float Spacing => 5; protected override float DateAlign => 125; @@ -198,9 +205,8 @@ namespace osu.Game.Online.Chat protected partial class StandAloneMessage : ChatLine { - protected override float FontSize => 15; protected override float Spacing => 5; - protected override float UsernameWidth => 75; + protected override float UsernameWidth => 90; public StandAloneMessage(Message message) : base(message) diff --git a/osu.Game/Online/Chat/WebSocketChatClient.cs b/osu.Game/Online/Chat/WebSocketChatClient.cs index 37774a1f5d..a74f0222f2 100644 --- a/osu.Game/Online/Chat/WebSocketChatClient.cs +++ b/osu.Game/Online/Chat/WebSocketChatClient.cs @@ -80,7 +80,7 @@ namespace osu.Game.Online.Chat fetchReq.Success += updates => { - if (updates?.Presence != null) + if (updates.Presence != null) { foreach (var channel in updates.Presence) joinChannel(channel); diff --git a/osu.Game/Online/HubClientConnector.cs b/osu.Game/Online/HubClientConnector.cs index 9d414deade..9288a32052 100644 --- a/osu.Game/Online/HubClientConnector.cs +++ b/osu.Game/Online/HubClientConnector.cs @@ -19,6 +19,9 @@ namespace osu.Game.Online { public const string SERVER_SHUTDOWN_MESSAGE = "Server is shutting down."; + public const string VERSION_HASH_HEADER = @"X-Osu-Version-Hash"; + public const string CLIENT_SESSION_ID_HEADER = @"X-Client-Session-ID"; + /// /// Invoked whenever a new hub connection is built, to configure it before it's started. /// @@ -68,8 +71,11 @@ namespace osu.Game.Online options.Proxy.Credentials = CredentialCache.DefaultCredentials; } - options.Headers.Add("Authorization", $"Bearer {API.AccessToken}"); - options.Headers.Add("OsuVersionHash", versionHash); + options.Headers.Add(@"Authorization", @$"Bearer {API.AccessToken}"); + // non-standard header name kept for backwards compatibility, can be removed after server side has migrated to `VERSION_HASH_HEADER` + options.Headers.Add(@"OsuVersionHash", versionHash); + options.Headers.Add(VERSION_HASH_HEADER, versionHash); + options.Headers.Add(CLIENT_SESSION_ID_HEADER, API.SessionIdentifier.ToString()); }); if (RuntimeFeature.IsDynamicCodeCompiled && preferMessagePack) diff --git a/osu.Game/Online/Leaderboards/DrawableRank.cs b/osu.Game/Online/Leaderboards/DrawableRank.cs index 0b0ab11410..3bc80c8b37 100644 --- a/osu.Game/Online/Leaderboards/DrawableRank.cs +++ b/osu.Game/Online/Leaderboards/DrawableRank.cs @@ -18,12 +18,8 @@ namespace osu.Game.Online.Leaderboards { public partial class DrawableRank : CompositeDrawable { - private readonly ScoreRank rank; - public DrawableRank(ScoreRank rank) { - this.rank = rank; - RelativeSizeAxes = Axes.Both; FillMode = FillMode.Fit; FillAspectRatio = 2; @@ -57,7 +53,7 @@ namespace osu.Game.Online.Leaderboards Origin = Anchor.Centre, Spacing = new Vector2(-3, 0), Padding = new MarginPadding { Top = 5 }, - Colour = getRankNameColour(), + Colour = GetRankNameColour(rank), Font = OsuFont.Numeric.With(size: 25), Text = GetRankName(rank), ShadowColour = Color4.Black.Opacity(0.3f), @@ -74,7 +70,7 @@ namespace osu.Game.Online.Leaderboards /// /// Retrieves the grade text colour. /// - private ColourInfo getRankNameColour() + public static ColourInfo GetRankNameColour(ScoreRank rank) { switch (rank) { 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/Leaderboards/UpdateableRank.cs b/osu.Game/Online/Leaderboards/UpdateableRank.cs index 46cfe8ec65..b64fab6861 100644 --- a/osu.Game/Online/Leaderboards/UpdateableRank.cs +++ b/osu.Game/Online/Leaderboards/UpdateableRank.cs @@ -1,16 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - +using System; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Transforms; using osu.Game.Scoring; namespace osu.Game.Online.Leaderboards { public partial class UpdateableRank : ModelBackedDrawable { + protected override double TransformDuration => 600; + protected override bool TransformImmediately => true; + public ScoreRank? Rank { get => Model; @@ -22,7 +25,17 @@ namespace osu.Game.Online.Leaderboards Rank = rank; } - protected override Drawable CreateDrawable(ScoreRank? rank) + protected override DelayedLoadWrapper CreateDelayedLoadWrapper(Func createContentFunc, double timeBeforeLoad) + { + return base.CreateDelayedLoadWrapper(createContentFunc, timeBeforeLoad) + .With(w => + { + w.Anchor = Anchor.Centre; + w.Origin = Anchor.Centre; + }); + } + + protected override Drawable? CreateDrawable(ScoreRank? rank) { if (rank.HasValue) { @@ -35,5 +48,18 @@ namespace osu.Game.Online.Leaderboards return null; } + + protected override TransformSequence ApplyShowTransforms(Drawable drawable) + { + drawable.ScaleTo(1); + return base.ApplyShowTransforms(drawable); + } + + protected override TransformSequence ApplyHideTransforms(Drawable drawable) + { + drawable.ScaleTo(1.8f, TransformDuration, Easing.Out); + + return base.ApplyHideTransforms(drawable); + } } } diff --git a/osu.Game/Online/Metadata/DailyChallengeInfo.cs b/osu.Game/Online/Metadata/DailyChallengeInfo.cs new file mode 100644 index 0000000000..7c49556653 --- /dev/null +++ b/osu.Game/Online/Metadata/DailyChallengeInfo.cs @@ -0,0 +1,16 @@ +// 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 MessagePack; + +namespace osu.Game.Online.Metadata +{ + [MessagePackObject] + [Serializable] + public struct DailyChallengeInfo + { + [Key(0)] + public long RoomID { get; set; } + } +} diff --git a/osu.Game/Online/Metadata/IMetadataClient.cs b/osu.Game/Online/Metadata/IMetadataClient.cs index 7102554ae9..97c1bbde5f 100644 --- a/osu.Game/Online/Metadata/IMetadataClient.cs +++ b/osu.Game/Online/Metadata/IMetadataClient.cs @@ -20,5 +20,17 @@ namespace osu.Game.Online.Metadata /// Delivers an update of the of the user with the supplied . /// Task UserPresenceUpdated(int userId, UserPresence? status); + + /// + /// Delivers an update of the current "daily challenge" status. + /// Null value means there is no "daily challenge" currently active. + /// + Task DailyChallengeUpdated(DailyChallengeInfo? info); + + /// + /// Delivers information that a multiplayer score was set in a watched room. + /// To receive these, the client must call for a given room first. + /// + Task MultiplayerRoomScoreSet(MultiplayerRoomScoreSetEvent roomScoreSetEvent); } } diff --git a/osu.Game/Online/Metadata/IMetadataServer.cs b/osu.Game/Online/Metadata/IMetadataServer.cs index 9780045333..79ed8b5634 100644 --- a/osu.Game/Online/Metadata/IMetadataServer.cs +++ b/osu.Game/Online/Metadata/IMetadataServer.cs @@ -7,7 +7,12 @@ using osu.Game.Users; namespace osu.Game.Online.Metadata { /// - /// Metadata server is responsible for keeping the osu! client up-to-date with any changes. + /// Metadata server is responsible for keeping the osu! client up-to-date with various real-time happenings, such as: + /// + /// beatmap updates via BSS, + /// online user activity/status updates, + /// other real-time happenings, such as current "daily challenge" status. + /// /// public interface IMetadataServer { @@ -38,5 +43,15 @@ namespace osu.Game.Online.Metadata /// Signals to the server that the current user would like to stop receiving updates on other users' online presence. /// Task EndWatchingUserPresence(); + + /// + /// Signals to the server that the current user would like to begin receiving updates about the state of the multiplayer room with the given . + /// + Task BeginWatchingMultiplayerRoom(long id); + + /// + /// Signals to the server that the current user would like to stop receiving updates about the state of the multiplayer room with the given . + /// + Task EndWatchingMultiplayerRoom(long id); } } diff --git a/osu.Game/Online/Metadata/MetadataClient.cs b/osu.Game/Online/Metadata/MetadataClient.cs index 8e99a9b2cb..8a5fe1733e 100644 --- a/osu.Game/Online/Metadata/MetadataClient.cs +++ b/osu.Game/Online/Metadata/MetadataClient.cs @@ -59,6 +59,33 @@ namespace osu.Game.Online.Metadata #endregion + #region Daily Challenge + + public abstract IBindable DailyChallengeInfo { get; } + + /// + public abstract Task DailyChallengeUpdated(DailyChallengeInfo? info); + + #endregion + + #region Multiplayer room watching + + public abstract Task BeginWatchingMultiplayerRoom(long id); + + public abstract Task EndWatchingMultiplayerRoom(long id); + + public event Action? MultiplayerRoomScoreSet; + + Task IMetadataClient.MultiplayerRoomScoreSet(MultiplayerRoomScoreSetEvent roomScoreSetEvent) + { + if (MultiplayerRoomScoreSet != null) + Schedule(MultiplayerRoomScoreSet, roomScoreSetEvent); + + return Task.CompletedTask; + } + + #endregion + #region Disconnection handling public event Action? Disconnecting; diff --git a/osu.Game/Online/Metadata/MultiplayerPlaylistItemStats.cs b/osu.Game/Online/Metadata/MultiplayerPlaylistItemStats.cs new file mode 100644 index 0000000000..19a2bde497 --- /dev/null +++ b/osu.Game/Online/Metadata/MultiplayerPlaylistItemStats.cs @@ -0,0 +1,41 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using MessagePack; + +namespace osu.Game.Online.Metadata +{ + [MessagePackObject] + [Serializable] + public class MultiplayerPlaylistItemStats + { + public const int TOTAL_SCORE_DISTRIBUTION_BINS = 13; + + /// + /// The ID of the playlist item which these stats pertain to. + /// + [Key(0)] + public long PlaylistItemID { get; set; } + + /// + /// The count of scores with given total ranges in the room. + /// The ranges are bracketed into bins, each of 100,000 score width. + /// The last bin will contain count of all scores with total of 1,200,000 or larger. + /// + [Key(1)] + public long[] TotalScoreDistribution { get; set; } = new long[TOTAL_SCORE_DISTRIBUTION_BINS]; + + /// + /// The cumulative total of all passing scores (across all users) for the playlist item so far. + /// + [Key(2)] + public long CumulativeScore { get; set; } + + /// + /// The last score to have been processed into provided statistics. Generally only for server-side accounting purposes. + /// + [Key(3)] + public ulong LastProcessedScoreID { get; set; } + } +} diff --git a/osu.Game/Online/Metadata/MultiplayerRoomScoreSetEvent.cs b/osu.Game/Online/Metadata/MultiplayerRoomScoreSetEvent.cs new file mode 100644 index 0000000000..00bc5dc840 --- /dev/null +++ b/osu.Game/Online/Metadata/MultiplayerRoomScoreSetEvent.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; +using MessagePack; + +namespace osu.Game.Online.Metadata +{ + [Serializable] + [MessagePackObject] + public class MultiplayerRoomScoreSetEvent + { + /// + /// The ID of the room in which the score was set. + /// + [Key(0)] + public long RoomID { get; set; } + + /// + /// The ID of the playlist item on which the score was set. + /// + [Key(1)] + public long PlaylistItemID { get; set; } + + /// + /// The ID of the score set. + /// + [Key(2)] + public long ScoreID { get; set; } + + /// + /// The ID of the user who set the score. + /// + [Key(3)] + public int UserID { get; set; } + + /// + /// The total score set by the player. + /// + [Key(4)] + public long TotalScore { get; set; } + + /// + /// If the set score is the user's new best on a playlist item, this member will contain the user's new rank in the room overall. + /// Otherwise, it will contain . + /// + [Key(5)] + public int? NewRank { get; set; } + } +} diff --git a/osu.Game/Online/Metadata/OnlineMetadataClient.cs b/osu.Game/Online/Metadata/OnlineMetadataClient.cs index 3805d12688..a3041c6753 100644 --- a/osu.Game/Online/Metadata/OnlineMetadataClient.cs +++ b/osu.Game/Online/Metadata/OnlineMetadataClient.cs @@ -26,6 +26,9 @@ namespace osu.Game.Online.Metadata public override IBindableDictionary UserStates => userStates; private readonly BindableDictionary userStates = new BindableDictionary(); + public override IBindable DailyChallengeInfo => dailyChallengeInfo; + private readonly Bindable dailyChallengeInfo = new Bindable(); + private readonly string endpoint; private IHubClientConnector? connector; @@ -58,6 +61,8 @@ namespace osu.Game.Online.Metadata // https://github.com/dotnet/aspnetcore/issues/15198 connection.On(nameof(IMetadataClient.BeatmapSetsUpdated), ((IMetadataClient)this).BeatmapSetsUpdated); connection.On(nameof(IMetadataClient.UserPresenceUpdated), ((IMetadataClient)this).UserPresenceUpdated); + connection.On(nameof(IMetadataClient.DailyChallengeUpdated), ((IMetadataClient)this).DailyChallengeUpdated); + connection.On(nameof(IMetadataClient.MultiplayerRoomScoreSet), ((IMetadataClient)this).MultiplayerRoomScoreSet); connection.On(nameof(IStatefulUserHubClient.DisconnectRequested), ((IMetadataClient)this).DisconnectRequested); }; @@ -101,6 +106,7 @@ namespace osu.Game.Online.Metadata { isWatchingUserPresence.Value = false; userStates.Clear(); + dailyChallengeInfo.Value = null; }); return; } @@ -209,6 +215,7 @@ namespace osu.Game.Online.Metadata Debug.Assert(connection != null); await connection.InvokeAsync(nameof(IMetadataServer.BeginWatchingUserPresence)).ConfigureAwait(false); Schedule(() => isWatchingUserPresence.Value = true); + Logger.Log($@"{nameof(OnlineMetadataClient)} began watching user presence", LoggingTarget.Network); } public override async Task EndWatchingUserPresence() @@ -222,6 +229,7 @@ namespace osu.Game.Online.Metadata Schedule(() => userStates.Clear()); Debug.Assert(connection != null); await connection.InvokeAsync(nameof(IMetadataServer.EndWatchingUserPresence)).ConfigureAwait(false); + Logger.Log($@"{nameof(OnlineMetadataClient)} stopped watching user presence", LoggingTarget.Network); } finally { @@ -229,6 +237,33 @@ namespace osu.Game.Online.Metadata } } + public override Task DailyChallengeUpdated(DailyChallengeInfo? info) + { + Schedule(() => dailyChallengeInfo.Value = info); + return Task.CompletedTask; + } + + public override async Task BeginWatchingMultiplayerRoom(long id) + { + if (connector?.IsConnected.Value != true) + throw new OperationCanceledException(); + + Debug.Assert(connection != null); + var result = await connection.InvokeAsync(nameof(IMetadataServer.BeginWatchingMultiplayerRoom), id).ConfigureAwait(false); + Logger.Log($@"{nameof(OnlineMetadataClient)} began watching multiplayer room with ID {id}", LoggingTarget.Network); + return result; + } + + public override async Task EndWatchingMultiplayerRoom(long id) + { + if (connector?.IsConnected.Value != true) + throw new OperationCanceledException(); + + Debug.Assert(connection != null); + await connection.InvokeAsync(nameof(IMetadataServer.EndWatchingMultiplayerRoom), id).ConfigureAwait(false); + Logger.Log($@"{nameof(OnlineMetadataClient)} stopped watching multiplayer room with ID {id}", LoggingTarget.Network); + } + public override async Task DisconnectRequested() { await base.DisconnectRequested().ConfigureAwait(false); diff --git a/osu.Game/Online/Multiplayer/InvalidPasswordException.cs b/osu.Game/Online/Multiplayer/InvalidPasswordException.cs index d3da8f491b..b76a1cc05d 100644 --- a/osu.Game/Online/Multiplayer/InvalidPasswordException.cs +++ b/osu.Game/Online/Multiplayer/InvalidPasswordException.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Runtime.Serialization; using Microsoft.AspNetCore.SignalR; namespace osu.Game.Online.Multiplayer @@ -10,13 +9,5 @@ namespace osu.Game.Online.Multiplayer [Serializable] public class InvalidPasswordException : HubException { - public InvalidPasswordException() - { - } - - protected InvalidPasswordException(SerializationInfo info, StreamingContext context) - : base(info, context) - { - } } } diff --git a/osu.Game/Online/Multiplayer/InvalidStateChangeException.cs b/osu.Game/Online/Multiplayer/InvalidStateChangeException.cs index 4c793dba68..2bae31196a 100644 --- a/osu.Game/Online/Multiplayer/InvalidStateChangeException.cs +++ b/osu.Game/Online/Multiplayer/InvalidStateChangeException.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Runtime.Serialization; using Microsoft.AspNetCore.SignalR; namespace osu.Game.Online.Multiplayer @@ -14,10 +13,5 @@ namespace osu.Game.Online.Multiplayer : base($"Cannot change from {oldState} to {newState}") { } - - protected InvalidStateChangeException(SerializationInfo info, StreamingContext context) - : base(info, context) - { - } } } diff --git a/osu.Game/Online/Multiplayer/InvalidStateException.cs b/osu.Game/Online/Multiplayer/InvalidStateException.cs index 27b111a781..c9705e9e53 100644 --- a/osu.Game/Online/Multiplayer/InvalidStateException.cs +++ b/osu.Game/Online/Multiplayer/InvalidStateException.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Runtime.Serialization; using Microsoft.AspNetCore.SignalR; namespace osu.Game.Online.Multiplayer @@ -14,10 +13,5 @@ namespace osu.Game.Online.Multiplayer : base(message) { } - - protected InvalidStateException(SerializationInfo info, StreamingContext context) - : base(info, context) - { - } } } diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index bbf0e3697a..ff147aba10 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -5,15 +5,17 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Text; using System.Threading; using System.Threading.Tasks; -using Newtonsoft.Json; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Development; using osu.Framework.Graphics; using osu.Game.Database; +using osu.Game.Localisation; using osu.Game.Online.API; +using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer.Countdown; using osu.Game.Online.Rooms; @@ -22,7 +24,6 @@ using osu.Game.Overlays.Notifications; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Utils; -using osu.Game.Localisation; namespace osu.Game.Online.Multiplayer { @@ -188,7 +189,7 @@ namespace osu.Game.Online.Multiplayer // Populate users. Debug.Assert(joinedRoom.Users != null); - await Task.WhenAll(joinedRoom.Users.Select(PopulateUser)).ConfigureAwait(false); + await PopulateUsers(joinedRoom.Users).ConfigureAwait(false); // Update the stored room (must be done on update thread for thread-safety). await runOnUpdateThreadAsync(() => @@ -201,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. @@ -396,7 +397,7 @@ namespace osu.Game.Online.Multiplayer switch (state) { case MultiplayerRoomState.Open: - APIRoom.Status.Value = new RoomStatusOpen(); + APIRoom.Status.Value = APIRoom.HasPassword.Value ? new RoomStatusOpenPrivate() : new RoomStatusOpen(); break; case MultiplayerRoomState.Playing: @@ -416,7 +417,7 @@ namespace osu.Game.Online.Multiplayer async Task IMultiplayerClient.UserJoined(MultiplayerRoomUser user) { - await PopulateUser(user).ConfigureAwait(false); + await PopulateUsers([user]).ConfigureAwait(false); Scheduler.Add(() => { @@ -733,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(); @@ -777,12 +778,22 @@ namespace osu.Game.Online.Multiplayer Room.Playlist[Room.Playlist.IndexOf(Room.Playlist.Single(existing => existing.ID == item.ID))] = item; int existingIndex = APIRoom.Playlist.IndexOf(APIRoom.Playlist.Single(existing => existing.ID == item.ID)); + APIRoom.Playlist.RemoveAt(existingIndex); - APIRoom.Playlist.Insert(existingIndex, createPlaylistItem(item)); + APIRoom.Playlist.Insert(existingIndex, new PlaylistItem(item)); } catch (Exception ex) { - throw new AggregateException($"Item: {JsonConvert.SerializeObject(createPlaylistItem(item))}\n\nRoom:{JsonConvert.SerializeObject(APIRoom)}", ex); + // Temporary code to attempt to figure out long-term failing tests. + StringBuilder exceptionText = new StringBuilder(); + + exceptionText.AppendLine("MultiplayerClient test failure investigation"); + exceptionText.AppendLine($"Exception : {ex.ToString()}"); + exceptionText.AppendLine($"Lookup : {item.ID}"); + exceptionText.AppendLine($"Items in Room.Playlist : {string.Join(',', Room.Playlist.Select(i => i.ID))}"); + exceptionText.AppendLine($"Items in APIRoom.Playlist: {string.Join(',', APIRoom!.Playlist.Select(i => i.ID))}"); + + throw new AggregateException(exceptionText.ToString()); } ItemChanged?.Invoke(item); @@ -793,10 +804,26 @@ namespace osu.Game.Online.Multiplayer } /// - /// Populates the for a given . + /// Populates the for a given collection of s. /// - /// The to populate. - protected async Task PopulateUser(MultiplayerRoomUser multiplayerUser) => multiplayerUser.User ??= await userLookupCache.GetUserAsync(multiplayerUser.UserID).ConfigureAwait(false); + /// The s to populate. + protected async Task PopulateUsers(IEnumerable multiplayerUsers) + { + var request = new GetUsersRequest(multiplayerUsers.Select(u => u.UserID).Distinct().ToArray()); + + await API.PerformAsync(request).ConfigureAwait(false); + + if (request.Response == null) + return; + + Dictionary users = request.Response.Users.ToDictionary(user => user.Id); + + foreach (var multiplayerUser in multiplayerUsers) + { + if (users.TryGetValue(multiplayerUser.UserID, out var user)) + multiplayerUser.User = user; + } + } /// /// Updates the local room settings with the given . @@ -816,6 +843,7 @@ namespace osu.Game.Online.Multiplayer Room.Settings = settings; APIRoom.Name.Value = Room.Settings.Name; APIRoom.Password.Value = Room.Settings.Password; + APIRoom.Status.Value = string.IsNullOrEmpty(Room.Settings.Password) ? new RoomStatusOpen() : new RoomStatusOpenPrivate(); APIRoom.Type.Value = Room.Settings.MatchType; APIRoom.QueueMode.Value = Room.Settings.QueueMode; APIRoom.AutoStartDuration.Value = Room.Settings.AutoStartDuration; @@ -825,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/Online/Multiplayer/NotHostException.cs b/osu.Game/Online/Multiplayer/NotHostException.cs index cd43b13e52..f4fd217c87 100644 --- a/osu.Game/Online/Multiplayer/NotHostException.cs +++ b/osu.Game/Online/Multiplayer/NotHostException.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Runtime.Serialization; using Microsoft.AspNetCore.SignalR; namespace osu.Game.Online.Multiplayer @@ -14,10 +13,5 @@ namespace osu.Game.Online.Multiplayer : base("User is attempting to perform a host level operation while not the host") { } - - protected NotHostException(SerializationInfo info, StreamingContext context) - : base(info, context) - { - } } } diff --git a/osu.Game/Online/Multiplayer/NotJoinedRoomException.cs b/osu.Game/Online/Multiplayer/NotJoinedRoomException.cs index 0a96406c16..72773e28db 100644 --- a/osu.Game/Online/Multiplayer/NotJoinedRoomException.cs +++ b/osu.Game/Online/Multiplayer/NotJoinedRoomException.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Runtime.Serialization; using Microsoft.AspNetCore.SignalR; namespace osu.Game.Online.Multiplayer @@ -14,10 +13,5 @@ namespace osu.Game.Online.Multiplayer : base("This user has not yet joined a multiplayer room.") { } - - protected NotJoinedRoomException(SerializationInfo info, StreamingContext context) - : base(info, context) - { - } } } diff --git a/osu.Game/Online/Multiplayer/UserBlockedException.cs b/osu.Game/Online/Multiplayer/UserBlockedException.cs index e964b13c75..58e86d9f32 100644 --- a/osu.Game/Online/Multiplayer/UserBlockedException.cs +++ b/osu.Game/Online/Multiplayer/UserBlockedException.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Runtime.Serialization; using Microsoft.AspNetCore.SignalR; namespace osu.Game.Online.Multiplayer @@ -16,10 +15,5 @@ namespace osu.Game.Online.Multiplayer : base(MESSAGE) { } - - protected UserBlockedException(SerializationInfo info, StreamingContext context) - : base(info, context) - { - } } } diff --git a/osu.Game/Online/Multiplayer/UserBlocksPMsException.cs b/osu.Game/Online/Multiplayer/UserBlocksPMsException.cs index 14ed6fc212..0ea583ae2c 100644 --- a/osu.Game/Online/Multiplayer/UserBlocksPMsException.cs +++ b/osu.Game/Online/Multiplayer/UserBlocksPMsException.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Runtime.Serialization; using Microsoft.AspNetCore.SignalR; namespace osu.Game.Online.Multiplayer @@ -16,10 +15,5 @@ namespace osu.Game.Online.Multiplayer : base(MESSAGE) { } - - protected UserBlocksPMsException(SerializationInfo info, StreamingContext context) - : base(info, context) - { - } } } diff --git a/osu.Game/Online/Placeholders/ClickablePlaceholder.cs b/osu.Game/Online/Placeholders/ClickablePlaceholder.cs index 9bef1d4b7a..ee8762fca3 100644 --- a/osu.Game/Online/Placeholders/ClickablePlaceholder.cs +++ b/osu.Game/Online/Placeholders/ClickablePlaceholder.cs @@ -17,19 +17,21 @@ namespace osu.Game.Online.Placeholders public ClickablePlaceholder(LocalisableString actionMessage, IconUsage icon) { + OsuAnimatedButton button; OsuTextFlowContainer textFlow; - AddArbitraryDrawable(new OsuAnimatedButton + AddArbitraryDrawable(button = new OsuAnimatedButton { AutoSizeAxes = Framework.Graphics.Axes.Both, - Child = textFlow = new OsuTextFlowContainer(cp => cp.Font = cp.Font.With(size: TEXT_SIZE)) - { - AutoSizeAxes = Framework.Graphics.Axes.Both, - Margin = new Framework.Graphics.MarginPadding(5) - }, Action = () => Action?.Invoke() }); + button.Add(textFlow = new OsuTextFlowContainer(cp => cp.Font = cp.Font.With(size: TEXT_SIZE)) + { + AutoSizeAxes = Framework.Graphics.Axes.Both, + Margin = new Framework.Graphics.MarginPadding(5) + }); + textFlow.AddIcon(icon, i => { i.Padding = new Framework.Graphics.MarginPadding { Right = 10 }; diff --git a/osu.Game/Online/Rooms/GetRoomsRequest.cs b/osu.Game/Online/Rooms/GetRoomsRequest.cs index 7feb709acb..1b5e08c729 100644 --- a/osu.Game/Online/Rooms/GetRoomsRequest.cs +++ b/osu.Game/Online/Rooms/GetRoomsRequest.cs @@ -1,10 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using osu.Framework.IO.Network; using osu.Game.Extensions; using osu.Game.Online.API; +using osu.Game.Online.Rooms.RoomStatuses; using osu.Game.Screens.OnlinePlay.Lounge.Components; namespace osu.Game.Online.Rooms @@ -33,6 +35,25 @@ namespace osu.Game.Online.Rooms return req; } + protected override void PostProcess() + { + base.PostProcess(); + + if (Response != null) + { + // API doesn't populate status so let's do it here. + foreach (var room in Response) + { + if (room.EndDate.Value != null && DateTimeOffset.Now >= room.EndDate.Value) + room.Status.Value = new RoomStatusEnded(); + else if (room.HasPassword.Value) + room.Status.Value = new RoomStatusOpenPrivate(); + else + room.Status.Value = new RoomStatusOpen(); + } + } + } + protected override string Target => "rooms"; } } diff --git a/osu.Game/Online/Rooms/JoinRoomRequest.cs b/osu.Game/Online/Rooms/JoinRoomRequest.cs index 8645f2a2c0..9a73104b60 100644 --- a/osu.Game/Online/Rooms/JoinRoomRequest.cs +++ b/osu.Game/Online/Rooms/JoinRoomRequest.cs @@ -27,6 +27,6 @@ namespace osu.Game.Online.Rooms return req; } - protected override string Target => $@"rooms/{Room.RoomID.Value}/users/{User.Id}"; + protected override string Target => $@"rooms/{Room.RoomID.Value}/users/{User!.Id}"; } } diff --git a/osu.Game/Online/Rooms/MultiplayerScore.cs b/osu.Game/Online/Rooms/MultiplayerScore.cs index f1b9584d57..faa66c571d 100644 --- a/osu.Game/Online/Rooms/MultiplayerScore.cs +++ b/osu.Game/Online/Rooms/MultiplayerScore.cs @@ -46,6 +46,9 @@ namespace osu.Game.Online.Rooms [JsonProperty("statistics")] public Dictionary Statistics = new Dictionary(); + [JsonProperty("maximum_statistics")] + public Dictionary MaximumStatistics = new Dictionary(); + [JsonProperty("passed")] public bool Passed { get; set; } @@ -58,9 +61,15 @@ namespace osu.Game.Online.Rooms [JsonProperty("position")] public int? Position { get; set; } + [JsonProperty("pp")] + public double? PP { get; set; } + [JsonProperty("has_replay")] public bool HasReplay { get; set; } + [JsonProperty("ranked")] + public bool Ranked { get; set; } + /// /// Any scores in the room around this score. /// @@ -83,13 +92,17 @@ namespace osu.Game.Online.Rooms MaxCombo = MaxCombo, BeatmapInfo = beatmap, Ruleset = rulesets.GetRuleset(playlistItem.RulesetID) ?? throw new InvalidOperationException($"Ruleset with ID of {playlistItem.RulesetID} not found locally"), + Passed = Passed, Statistics = Statistics, + MaximumStatistics = MaximumStatistics, User = User, Accuracy = Accuracy, Date = EndedAt, HasOnlineReplay = HasReplay, Rank = Rank, Mods = Mods?.Select(m => m.ToMod(rulesetInstance)).ToArray() ?? Array.Empty(), + PP = PP, + Ranked = Ranked, Position = Position, }; diff --git a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs index ceb8e53778..45f52f3cd8 100644 --- a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs +++ b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs @@ -29,7 +29,7 @@ namespace osu.Game.Online.Rooms /// public partial class OnlinePlayBeatmapAvailabilityTracker : CompositeComponent { - public readonly IBindable SelectedItem = new Bindable(); + public readonly Bindable SelectedItem = new Bindable(); [Resolved] private RealmAccess realm { get; set; } = null!; diff --git a/osu.Game/Online/Rooms/PartRoomRequest.cs b/osu.Game/Online/Rooms/PartRoomRequest.cs index 09ba6f65c3..2416833a1e 100644 --- a/osu.Game/Online/Rooms/PartRoomRequest.cs +++ b/osu.Game/Online/Rooms/PartRoomRequest.cs @@ -23,6 +23,6 @@ namespace osu.Game.Online.Rooms return req; } - protected override string Target => $"rooms/{room.RoomID.Value}/users/{User.Id}"; + protected override string Target => $"rooms/{room.RoomID.Value}/users/{User!.Id}"; } } diff --git a/osu.Game/Online/Rooms/PlaylistExtensions.cs b/osu.Game/Online/Rooms/PlaylistExtensions.cs index cd52a3c6e6..8591b5bb47 100644 --- a/osu.Game/Online/Rooms/PlaylistExtensions.cs +++ b/osu.Game/Online/Rooms/PlaylistExtensions.cs @@ -6,6 +6,8 @@ using System.Linq; using Humanizer; using Humanizer.Localisation; using osu.Framework.Bindables; +using osu.Game.Rulesets; +using osu.Game.Utils; namespace osu.Game.Online.Rooms { @@ -38,7 +40,21 @@ namespace osu.Game.Online.Rooms : GetUpcomingItems(playlist).First(); } - public static string GetTotalDuration(this BindableList playlist) => - playlist.Select(p => p.Beatmap.Length).Sum().Milliseconds().Humanize(minUnit: TimeUnit.Second, maxUnit: TimeUnit.Hour, precision: 2); + /// + /// Returns the total duration from the in playlist order from the supplied , + /// + public static string GetTotalDuration(this BindableList playlist, RulesetStore rulesetStore) => + playlist.Select(p => + { + double rate = 1; + + if (p.RequiredMods.Length > 0) + { + var ruleset = rulesetStore.GetRuleset(p.RulesetID)!.CreateInstance(); + rate = ModUtils.CalculateRateWithMods(p.RequiredMods.Select(mod => mod.ToMod(ruleset))); + } + + return p.Beatmap.Length / rate; + }).Sum().Milliseconds().Humanize(minUnit: TimeUnit.Second, maxUnit: TimeUnit.Hour, precision: 2); } } diff --git a/osu.Game/Online/Rooms/Room.cs b/osu.Game/Online/Rooms/Room.cs index 8f346c4057..c39932c3bf 100644 --- a/osu.Game/Online/Rooms/Room.cs +++ b/osu.Game/Online/Rooms/Room.cs @@ -112,7 +112,7 @@ namespace osu.Game.Online.Rooms public readonly Bindable UserScore = new Bindable(); [JsonProperty("has_password")] - public readonly BindableBool HasPassword = new BindableBool(); + public readonly Bindable HasPassword = new Bindable(); [Cached] [JsonProperty("recent_participants")] @@ -146,6 +146,11 @@ namespace osu.Game.Online.Rooms #endregion + // Only supports retrieval for now + [Cached] + [JsonProperty("starts_at")] + public readonly Bindable StartDate = new Bindable(); + // Only supports retrieval for now [Cached] [JsonProperty("ends_at")] @@ -201,9 +206,6 @@ namespace osu.Game.Online.Rooms CurrentPlaylistItem.Value = other.CurrentPlaylistItem.Value; AutoSkip.Value = other.AutoSkip.Value; - if (EndDate.Value != null && DateTimeOffset.Now >= EndDate.Value) - Status.Value = new RoomStatusEnded(); - other.RemoveExpiredPlaylistItems(); if (!Playlist.SequenceEqual(other.Playlist)) diff --git a/osu.Game/Online/Rooms/RoomCategory.cs b/osu.Game/Online/Rooms/RoomCategory.cs index 17afb0dc7f..4534e7de59 100644 --- a/osu.Game/Online/Rooms/RoomCategory.cs +++ b/osu.Game/Online/Rooms/RoomCategory.cs @@ -13,5 +13,8 @@ namespace osu.Game.Online.Rooms [Description("Featured Artist")] FeaturedArtist, + + [Description("Daily Challenge")] + DailyChallenge, } } diff --git a/osu.Game/Online/Rooms/RoomStatuses/RoomStatusOpenPrivate.cs b/osu.Game/Online/Rooms/RoomStatuses/RoomStatusOpenPrivate.cs new file mode 100644 index 0000000000..d71e706c76 --- /dev/null +++ b/osu.Game/Online/Rooms/RoomStatuses/RoomStatusOpenPrivate.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 osu.Game.Graphics; +using osuTK.Graphics; + +namespace osu.Game.Online.Rooms.RoomStatuses +{ + public class RoomStatusOpenPrivate : RoomStatus + { + public override string Message => "Open (Private)"; + public override Color4 GetAppropriateColour(OsuColour colours) => colours.GreenDark; + } +} diff --git a/osu.Game/Online/Rooms/ShowPlaylistScoreRequest.cs b/osu.Game/Online/Rooms/ShowPlaylistScoreRequest.cs new file mode 100644 index 0000000000..d8f977a1d4 --- /dev/null +++ b/osu.Game/Online/Rooms/ShowPlaylistScoreRequest.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Online.API; + +namespace osu.Game.Online.Rooms +{ + public class ShowPlaylistScoreRequest : APIRequest + { + private readonly long roomId; + private readonly long playlistItemId; + private readonly long scoreId; + + public ShowPlaylistScoreRequest(long roomId, long playlistItemId, long scoreId) + { + this.roomId = roomId; + this.playlistItemId = playlistItemId; + this.scoreId = scoreId; + } + + protected override string Target => $@"rooms/{roomId}/playlist/{playlistItemId}/scores/{scoreId}"; + } +} diff --git a/osu.Game/Online/ScoreDownloadTracker.cs b/osu.Game/Online/ScoreDownloadTracker.cs index dfdac24d19..5f6ba15d05 100644 --- a/osu.Game/Online/ScoreDownloadTracker.cs +++ b/osu.Game/Online/ScoreDownloadTracker.cs @@ -46,10 +46,15 @@ namespace osu.Game.Online Downloader.DownloadBegan += downloadBegan; Downloader.DownloadFailed += downloadFailed; + // Required local for iOS. Will cause runtime crash if inlined. + long onlineId = TrackedItem.OnlineID; + long legacyOnlineId = TrackedItem.LegacyOnlineID; + string hash = TrackedItem.Hash; + realmSubscription = realm.RegisterForNotifications(r => r.All().Where(s => - ((s.OnlineID > 0 && s.OnlineID == TrackedItem.OnlineID) - || (s.LegacyOnlineID > 0 && s.LegacyOnlineID == TrackedItem.LegacyOnlineID) - || (!string.IsNullOrEmpty(s.Hash) && s.Hash == TrackedItem.Hash)) + ((s.OnlineID > 0 && s.OnlineID == onlineId) + || (s.LegacyOnlineID > 0 && s.LegacyOnlineID == legacyOnlineId) + || (!string.IsNullOrEmpty(s.Hash) && s.Hash == hash)) && !s.DeletePending), (items, _) => { if (items.Any()) diff --git a/osu.Game/Online/Spectator/FrameHeader.cs b/osu.Game/Online/Spectator/FrameHeader.cs index 45f920e65b..e0fd1f0682 100644 --- a/osu.Game/Online/Spectator/FrameHeader.cs +++ b/osu.Game/Online/Spectator/FrameHeader.cs @@ -3,8 +3,10 @@ using System; using System.Collections.Generic; +using System.Linq; using MessagePack; using Newtonsoft.Json; +using osu.Game.Online.API; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; @@ -56,6 +58,17 @@ namespace osu.Game.Online.Spectator [Key(6)] public DateTimeOffset ReceivedTime { get; set; } + /// + /// The set of mods currently active. + /// + /// + /// Nullable for backwards compatibility with older clients + /// (these structures are also used server-side, and will be used as marker that the data isn't there). + /// can be made non-nullable 20250407 + /// + [Key(7)] + public APIMod[]? Mods { get; set; } + /// /// Construct header summary information from a point-in-time reference to a score which is actively being played. /// @@ -69,6 +82,7 @@ namespace osu.Game.Online.Spectator MaxCombo = score.MaxCombo; // copy for safety Statistics = new Dictionary(score.Statistics); + Mods = score.APIMods.ToArray(); ScoreProcessorStatistics = statistics; } diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 98533a5c82..dce24c6ee7 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -8,6 +8,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; +using System.Reflection; using System.Threading; using System.Threading.Tasks; using Humanizer; @@ -22,6 +23,7 @@ using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; using osu.Framework.Input; using osu.Framework.Input.Bindings; @@ -49,8 +51,10 @@ using osu.Game.Online.Chat; using osu.Game.Online.Rooms; using osu.Game.Overlays; using osu.Game.Overlays.BeatmapListing; +using osu.Game.Overlays.Mods; using osu.Game.Overlays.Music; using osu.Game.Overlays.Notifications; +using osu.Game.Overlays.OSD; using osu.Game.Overlays.SkinEditor; using osu.Game.Overlays.Toolbar; using osu.Game.Overlays.Volume; @@ -58,7 +62,9 @@ using osu.Game.Rulesets.Mods; using osu.Game.Scoring; using osu.Game.Screens; using osu.Game.Screens.Edit; +using osu.Game.Screens.Footer; using osu.Game.Screens.Menu; +using osu.Game.Screens.OnlinePlay.DailyChallenge; using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.Play; using osu.Game.Screens.Ranking; @@ -80,7 +86,7 @@ namespace osu.Game public partial class OsuGame : OsuGameBase, IKeyBindingHandler, ILocalUserPlayInfo, IPerformFromScreenRunner, IOverlayManager, ILinkHandler { #if DEBUG - // Different port allows runnning release and debug builds alongside each other. + // Different port allows running release and debug builds alongside each other. public const int IPC_PORT = 44824; #else public const int IPC_PORT = 44823; @@ -91,6 +97,11 @@ namespace osu.Game /// protected const float SIDE_OVERLAY_OFFSET_RATIO = 0.05f; + /// + /// A common shear factor applied to most components of the game. + /// + public const float SHEAR = 0.2f; + public Toolbar Toolbar { get; private set; } private ChatOverlay chatOverlay; @@ -124,12 +135,16 @@ namespace osu.Game private Container topMostOverlayContent; + private Container footerBasedOverlayContent; + protected ScalingContainer ScreenContainer { get; private set; } protected Container ScreenOffsetContainer { get; private set; } private Container overlayOffsetContainer; + private OnScreenDisplay onScreenDisplay; + [Resolved] private FrameworkConfigManager frameworkConfig { get; set; } @@ -160,18 +175,14 @@ namespace osu.Game /// public readonly IBindable OverlayActivationMode = new Bindable(); - /// - /// Whether the local user is currently interacting with the game in a way that should not be interrupted. - /// - /// - /// This is exclusively managed by . If other components are mutating this state, a more - /// resilient method should be used to ensure correct state. - /// - public Bindable LocalUserPlaying = new BindableBool(); + IBindable ILocalUserPlayInfo.PlayingState => playingState; + + private readonly Bindable playingState = new Bindable(); protected OsuScreenStack ScreenStack; protected BackButton BackButton; + protected ScreenFooter ScreenFooter; protected SettingsOverlay Settings; @@ -231,7 +242,11 @@ namespace osu.Game throw new ArgumentException($@"{overlayContainer} has already been registered via {nameof(IOverlayManager.RegisterBlockingOverlay)} once."); externalOverlays.Add(overlayContainer); - overlayContent.Add(overlayContainer); + + if (overlayContainer is ShearedOverlayContainer) + footerBasedOverlayContent.Add(overlayContainer); + else + overlayContent.Add(overlayContainer); if (overlayContainer is OsuFocusedOverlayContainer focusedOverlayContainer) focusedOverlays.Add(focusedOverlayContainer); @@ -282,7 +297,7 @@ namespace osu.Game protected override UserInputManager CreateUserInputManager() { var userInputManager = base.CreateUserInputManager(); - (userInputManager as OsuUserInputManager)?.LocalUserPlaying.BindTo(LocalUserPlaying); + (userInputManager as OsuUserInputManager)?.PlayingState.BindTo(playingState); return userInputManager; } @@ -371,11 +386,11 @@ namespace osu.Game // Transfer any runtime changes back to configuration file. SkinManager.CurrentSkinInfo.ValueChanged += skin => configSkin.Value = skin.NewValue.ID.ToString(); - LocalUserPlaying.BindValueChanged(p => + playingState.BindValueChanged(p => { - BeatmapManager.PauseImports = p.NewValue; - SkinManager.PauseImports = p.NewValue; - ScoreManager.PauseImports = p.NewValue; + BeatmapManager.PauseImports = p.NewValue != LocalUserPlayingState.NotPlaying; + SkinManager.PauseImports = p.NewValue != LocalUserPlayingState.NotPlaying; + ScoreManager.PauseImports = p.NewValue != LocalUserPlayingState.NotPlaying; }, true); IsActive.BindValueChanged(active => updateActiveState(active.NewValue), true); @@ -425,10 +440,11 @@ namespace osu.Game break; case LinkAction.SearchBeatmapSet: - if (link.Argument is RomanisableString romanisable) - SearchBeatmapSet(romanisable.GetPreferred(Localisation.CurrentParameters.Value.PreferOriginalScript)); + if (link.Argument is LocalisableString localisable) + SearchBeatmapSet(Localisation.GetLocalisedString(localisable)); else SearchBeatmapSet(argString); + break; case LinkAction.FilterBeatmapSetGenre: @@ -480,10 +496,25 @@ namespace osu.Game } }); - public void OpenUrlExternally(string url, bool bypassExternalUrlWarning = false) => waitForReady(() => externalLinkOpener, _ => + public void CopyUrlToClipboard(string url) => waitForReady(() => onScreenDisplay, _ => { + dependencies.Get().SetText(url); + onScreenDisplay.Display(new CopyUrlToast()); + }); + + public void OpenUrlExternally(string url, bool forceBypassExternalUrlWarning = false) => waitForReady(() => externalLinkOpener, _ => + { + bool isTrustedDomain; + if (url.StartsWith('/')) - url = $"{API.APIEndpointUrl}{url}"; + { + url = $"{API.WebsiteRootUrl}{url}"; + isTrustedDomain = true; + } + else + { + isTrustedDomain = url.StartsWith(API.WebsiteRootUrl, StringComparison.Ordinal); + } if (!url.CheckIsValidUrl()) { @@ -495,7 +526,7 @@ namespace osu.Game return; } - externalLinkOpener.OpenUrlExternally(url, bypassExternalUrlWarning); + externalLinkOpener.OpenUrlExternally(url, forceBypassExternalUrlWarning || isTrustedDomain); }); /// @@ -576,7 +607,7 @@ namespace osu.Game return; } - editor.HandleTimestamp(timestamp); + editor.HandleTimestamp(timestamp, notifyOnError: true); } /// @@ -616,10 +647,10 @@ namespace osu.Game Live databasedSet = null; if (beatmap.OnlineID > 0) - databasedSet = BeatmapManager.QueryBeatmapSet(s => s.OnlineID == beatmap.OnlineID); + databasedSet = BeatmapManager.QueryBeatmapSet(s => s.OnlineID == beatmap.OnlineID && !s.DeletePending); if (beatmap is BeatmapSetInfo localBeatmap) - databasedSet ??= BeatmapManager.QueryBeatmapSet(s => s.Hash == localBeatmap.Hash); + databasedSet ??= BeatmapManager.QueryBeatmapSet(s => s.Hash == localBeatmap.Hash && !s.DeletePending); if (databasedSet == null) { @@ -724,23 +755,36 @@ namespace osu.Game return; } - // This should be able to be performed from song select, but that is disabled for now + // This should be able to be performed from song select always, but that is disabled for now // due to the weird decoupled ruleset logic (which can cause a crash in certain filter scenarios). // // As a special case, if the beatmap and ruleset already match, allow immediately displaying the score from song select. // This is guaranteed to not crash, and feels better from a user's perspective (ie. if they are clicking a score in the // song select leaderboard). + // Similar exemptions are made here for daily challenge where it is guaranteed that beatmap and ruleset match. + // `OnlinePlayScreen` is excluded because when resuming back to it, + // `RoomSubScreen` changes the global beatmap to the next playlist item on resume, + // which may not match the score, and thus crash. IEnumerable validScreens = Beatmap.Value.BeatmapInfo.Equals(databasedBeatmap) && Ruleset.Value.Equals(databasedScore.ScoreInfo.Ruleset) - ? new[] { typeof(SongSelect) } + ? new[] { typeof(SongSelect), typeof(DailyChallenge) } : Array.Empty(); PerformFromScreen(screen => { Logger.Log($"{nameof(PresentScore)} updating beatmap ({databasedBeatmap}) and ruleset ({databasedScore.ScoreInfo.Ruleset}) to match score"); - Ruleset.Value = databasedScore.ScoreInfo.Ruleset; - Beatmap.Value = BeatmapManager.GetWorkingBeatmap(databasedBeatmap); + // some screens (mostly online) disable the ruleset/beatmap bindable. + // attempting to set the ruleset/beatmap in that state will crash. + // however, the `validScreens` pre-check above should ensure that we actually never come from one of those screens + // while simultaneously having mismatched ruleset/beatmap. + // therefore this is just a safety against touching the possibly-disabled bindables if we don't actually have to touch them. + // if it ever fails, then this probably *should* crash anyhow (so that we can fix it). + if (!Ruleset.Value.Equals(databasedScore.ScoreInfo.Ruleset)) + Ruleset.Value = databasedScore.ScoreInfo.Ruleset; + + if (!Beatmap.Value.BeatmapInfo.Equals(databasedBeatmap)) + Beatmap.Value = BeatmapManager.GetWorkingBeatmap(databasedBeatmap); switch (presentType) { @@ -841,7 +885,10 @@ namespace osu.Game { // General expectation that osu! starts in fullscreen by default (also gives the most predictable performance). // However, macOS is bound to have issues when using exclusive fullscreen as it takes full control away from OS, therefore borderless is default there. - { FrameworkSetting.WindowMode, RuntimeInfo.OS == RuntimeInfo.Platform.macOS ? WindowMode.Borderless : WindowMode.Fullscreen } + { FrameworkSetting.WindowMode, RuntimeInfo.OS == RuntimeInfo.Platform.macOS ? WindowMode.Borderless : WindowMode.Fullscreen }, + { FrameworkSetting.VolumeUniversal, 0.6 }, + { FrameworkSetting.VolumeMusic, 0.6 }, + { FrameworkSetting.VolumeEffect, 0.6 }, }; } @@ -849,6 +896,9 @@ namespace osu.Game { base.LoadComplete(); + if (RuntimeInfo.EntryAssembly.GetCustomAttribute() == null) + Logger.Log(NotificationsStrings.NotOfficialBuild.ToString()); + var languages = Enum.GetValues(); var mappings = languages.Select(language => @@ -908,8 +958,7 @@ namespace osu.Game return string.Join(" / ", combinations); }; - Container logoContainer; - BackButton.Receptor receptor; + ScreenFooter.BackReceptor backReceptor; dependencies.CacheAs(idleTracker = new GameIdleTracker(6000)); @@ -922,6 +971,8 @@ namespace osu.Game Add(sessionIdleTracker); + Container logoContainer; + AddRange(new Drawable[] { new VolumeControlReceptor @@ -942,22 +993,37 @@ namespace osu.Game Origin = Anchor.Centre, Children = new Drawable[] { - receptor = new BackButton.Receptor(), + backReceptor = new ScreenFooter.BackReceptor(), ScreenStack = new OsuScreenStack { RelativeSizeAxes = Axes.Both }, - BackButton = new BackButton(receptor) + BackButton = new BackButton(backReceptor) { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, - Action = () => - { - if (!(ScreenStack.CurrentScreen is IOsuScreen currentScreen)) - return; - - if (!((Drawable)currentScreen).IsLoaded || (currentScreen.AllowBackButton && !currentScreen.OnBackButton())) - ScreenStack.Exit(); - } + Action = () => ScreenFooter.OnBack?.Invoke(), }, logoContainer = new Container { RelativeSizeAxes = Axes.Both }, + footerBasedOverlayContent = new Container + { + Depth = -1, + RelativeSizeAxes = Axes.Both, + }, + new PopoverContainer + { + Depth = -1, + RelativeSizeAxes = Axes.Both, + Child = ScreenFooter = new ScreenFooter(backReceptor) + { + RequestLogoInFront = inFront => ScreenContainer.ChangeChildDepth(logoContainer, inFront ? float.MinValue : 0), + OnBack = () => + { + if (!(ScreenStack.CurrentScreen is IOsuScreen currentScreen)) + return; + + if (!((Drawable)currentScreen).IsLoaded || (currentScreen.AllowBackButton && !currentScreen.OnBackButton())) + ScreenStack.Exit(); + } + }, + }, } }, } @@ -977,6 +1043,8 @@ namespace osu.Game new ConfineMouseTracker() }); + dependencies.Cache(ScreenFooter); + ScreenStack.ScreenPushed += screenPushed; ScreenStack.ScreenExited += screenExited; @@ -989,7 +1057,7 @@ namespace osu.Game if (!IsDeployedBuild) { - dependencies.Cache(versionManager = new VersionManager { Depth = int.MinValue }); + dependencies.Cache(versionManager = new VersionManager()); loadComponentSingleFile(versionManager, ScreenContainer.Add); } @@ -1015,7 +1083,7 @@ namespace osu.Game loadComponentSingleFile(volume = new VolumeOverlay(), leftFloatingOverlayContent.Add, true); - var onScreenDisplay = new OnScreenDisplay(); + onScreenDisplay = new OnScreenDisplay(); onScreenDisplay.BeginTracking(this, frameworkConfig); onScreenDisplay.BeginTracking(this, LocalConfig); @@ -1036,7 +1104,7 @@ namespace osu.Game loadComponentSingleFile(CreateUpdateManager(), Add, true); // overlay elements - loadComponentSingleFile(FirstRunOverlay = new FirstRunSetupOverlay(), overlayContent.Add, true); + loadComponentSingleFile(FirstRunOverlay = new FirstRunSetupOverlay(), footerBasedOverlayContent.Add, true); loadComponentSingleFile(new ManageCollectionsDialog(), overlayContent.Add, true); loadComponentSingleFile(beatmapListing = new BeatmapListingOverlay(), overlayContent.Add, true); loadComponentSingleFile(dashboard = new DashboardOverlay(), overlayContent.Add, true); @@ -1069,6 +1137,7 @@ namespace osu.Game loadComponentSingleFile(new MedalOverlay(), topMostOverlayContent.Add); loadComponentSingleFile(new BackgroundDataStoreProcessor(), Add); + loadComponentSingleFile(new DetachedBeatmapStore(), Add, true); Add(difficultyRecommender); Add(externalLinkOpener = new ExternalLinkOpener()); @@ -1101,7 +1170,7 @@ namespace osu.Game } // ensure only one of these overlays are open at once. - var singleDisplayOverlays = new OverlayContainer[] { FirstRunOverlay, chatOverlay, news, dashboard, beatmapListing, changelogOverlay, rankingsOverlay, wikiOverlay }; + var singleDisplayOverlays = new OverlayContainer[] { chatOverlay, news, dashboard, beatmapListing, changelogOverlay, rankingsOverlay, wikiOverlay }; foreach (var overlay in singleDisplayOverlays) { @@ -1479,6 +1548,16 @@ namespace osu.Game scope.SetTag(@"screen", newScreen?.GetType().ReadableName() ?? @"none"); }); + switch (current) + { + case Player player: + player.PlayingState.UnbindFrom(playingState); + + // reset for sanity. + playingState.Value = LocalUserPlayingState.NotPlaying; + break; + } + switch (newScreen) { case IntroScreen intro: @@ -1491,14 +1570,15 @@ namespace osu.Game versionManager?.Show(); break; + case Player player: + player.PlayingState.BindTo(playingState); + break; + default: versionManager?.Hide(); break; } - // reset on screen change for sanity. - LocalUserPlaying.Value = false; - if (current is IOsuScreen currentOsuScreen) { OverlayActivationMode.UnbindFrom(currentOsuScreen.OverlayActivationMode); @@ -1521,6 +1601,18 @@ namespace osu.Game BackButton.Show(); else BackButton.Hide(); + + if (newOsuScreen.ShowFooter) + { + BackButton.Hide(); + ScreenFooter.SetButtons(newOsuScreen.CreateFooterButtons()); + ScreenFooter.Show(); + } + else + { + ScreenFooter.SetButtons(Array.Empty()); + ScreenFooter.Hide(); + } } skinEditor.SetTarget((OsuScreen)newScreen); @@ -1535,7 +1627,5 @@ namespace osu.Game if (newScreen == null) Exit(); } - - IBindable ILocalUserPlayInfo.IsPlaying => LocalUserPlaying; } } diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index fb7a238c46..dc13924b4f 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -75,6 +75,12 @@ namespace osu.Game { public static readonly string[] VIDEO_EXTENSIONS = { ".mp4", ".mov", ".avi", ".flv", ".mpg", ".wmv", ".m4v" }; +#if DEBUG + public const string GAME_NAME = "osu! (development)"; +#else + public const string GAME_NAME = "osu!"; +#endif + public const string OSU_PROTOCOL = "osu://"; public const string CLIENT_STREAM_NAME = @"lazer"; @@ -94,7 +100,7 @@ namespace osu.Game public const int SAMPLE_DEBOUNCE_TIME = 20; /// - /// The maximum volume at which audio tracks should playback. This can be set lower than 1 to create some head-room for sound effects. + /// The maximum volume at which audio tracks should play back at. This can be set lower than 1 to create some head-room for sound effects. /// private const double global_track_volume_adjust = 0.8; @@ -192,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; @@ -241,11 +247,7 @@ namespace osu.Game public OsuGameBase() { - Name = @"osu!"; - -#if DEBUG - Name += " (development)"; -#endif + Name = GAME_NAME; allowableExceptions = UnhandledExceptionsBeforeCrash; } @@ -322,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)); @@ -407,6 +409,7 @@ namespace osu.Game KeyBindingStore = new RealmKeyBindingStore(realm, keyCombinationProvider); KeyBindingStore.Register(globalBindings, RulesetStore.AvailableRulesets); + dependencies.Cache(KeyBindingStore); dependencies.Cache(globalBindings); @@ -513,6 +516,12 @@ namespace osu.Game /// Whether a restart operation was queued. public virtual bool RestartAppWhenExited() => false; + /// + /// Perform migration of user data to a specified path. + /// + /// The path to migrate to. + /// Whether migration succeeded to completion. If false, some files were left behind. + /// public bool Migrate(string path) { Logger.Log($@"Migrating osu! data from ""{Storage.GetFullPath(string.Empty)}"" to ""{path}""..."); @@ -532,7 +541,10 @@ namespace osu.Game realmBlocker = realm.BlockAllOperations("migration"); success = true; } - catch { } + catch (Exception ex) + { + Logger.Log($"Attempting to block all operations failed: {ex}", LoggingTarget.Database); + } readyToRun.Set(); }, false); @@ -540,10 +552,10 @@ namespace osu.Game if (!readyToRun.Wait(30000) || !success) throw new TimeoutException("Attempting to block for migration took too long."); - bool? cleanupSucceded = (Storage as OsuStorage)?.Migrate(Host.GetStorage(path)); + bool? cleanupSucceeded = (Storage as OsuStorage)?.Migrate(Host.GetStorage(path)); Logger.Log(@"Migration complete!"); - return cleanupSucceded != false; + return cleanupSucceeded != false; } finally { @@ -551,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; @@ -576,17 +590,17 @@ namespace osu.Game { case ITabletHandler th: return new TabletSettings(th); - - case MouseHandler mh: - return new MouseSettings(mh); - - case JoystickHandler jh: - return new JoystickSettings(jh); } } switch (handler) { + case MouseHandler mh: + return new MouseSettings(mh); + + case JoystickHandler jh: + return new JoystickSettings(jh); + case TouchHandler th: return new TouchSettings(th); @@ -684,7 +698,7 @@ namespace osu.Game if (Interlocked.Decrement(ref allowableExceptions) < 0) { Logger.Log("Too many unhandled exceptions, crashing out."); - RulesetStore.TryDisableCustomRulesetsCausing(ex); + RulesetStore?.TryDisableCustomRulesetsCausing(ex); return false; } diff --git a/osu.Game/Overlays/AccountCreation/ScreenEntry.cs b/osu.Game/Overlays/AccountCreation/ScreenEntry.cs index f57c7d22a2..fb6a5796a1 100644 --- a/osu.Game/Overlays/AccountCreation/ScreenEntry.cs +++ b/osu.Game/Overlays/AccountCreation/ScreenEntry.cs @@ -243,7 +243,7 @@ namespace osu.Game.Overlays.AccountCreation if (nextTextBox != null) { - Schedule(() => GetContainingInputManager().ChangeFocus(nextTextBox)); + Schedule(() => GetContainingFocusManager()!.ChangeFocus(nextTextBox)); return true; } diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingSortTabControl.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingSortTabControl.cs index e3e2bcaf9a..7f8b68fd6c 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapListingSortTabControl.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingSortTabControl.cs @@ -126,7 +126,8 @@ namespace osu.Game.Overlays.BeatmapListing Origin = Anchor.Centre, AlwaysPresent = true, Alpha = 0, - Size = new Vector2(6) + Size = new Vector2(6), + Icon = FontAwesome.Solid.CaretDown, }); } @@ -136,7 +137,7 @@ namespace osu.Game.Overlays.BeatmapListing SortDirection.BindValueChanged(direction => { - icon.Icon = direction.NewValue == Overlays.SortDirection.Ascending && Active.Value ? FontAwesome.Solid.CaretUp : FontAwesome.Solid.CaretDown; + icon.ScaleTo(direction.NewValue == Overlays.SortDirection.Ascending && Active.Value ? new Vector2(1f, -1f) : Vector2.One, 300, Easing.OutQuint); }, true); } diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapSearchGeneralFilterRow.cs b/osu.Game/Overlays/BeatmapListing/BeatmapSearchGeneralFilterRow.cs index a4a914db55..2d56c60de6 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapSearchGeneralFilterRow.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapSearchGeneralFilterRow.cs @@ -56,8 +56,6 @@ namespace osu.Game.Overlays.BeatmapListing [Resolved(canBeNull: true)] private IDialogOverlay dialogOverlay { get; set; } - protected override Color4 GetStateColour() => colours.Orange1; - protected override void LoadComplete() { base.LoadComplete(); @@ -65,6 +63,9 @@ namespace osu.Game.Overlays.BeatmapListing disclaimerShown = sessionStatics.GetBindable(Static.FeaturedArtistDisclaimerShownOnce); } + protected override Color4 ColourNormal => colours.Orange1; + protected override Color4 ColourActive => colours.Orange2; + protected override bool OnClick(ClickEvent e) { if (!disclaimerShown.Value && dialogOverlay != null) diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapSearchMultipleSelectionFilterRow.cs b/osu.Game/Overlays/BeatmapListing/BeatmapSearchMultipleSelectionFilterRow.cs index 4bd25f6561..958297b559 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapSearchMultipleSelectionFilterRow.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapSearchMultipleSelectionFilterRow.cs @@ -1,21 +1,23 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; using osu.Framework.Localisation; +using osu.Game.Graphics; using osuTK; +using osuTK.Graphics; +using FontWeight = osu.Game.Graphics.FontWeight; namespace osu.Game.Overlays.BeatmapListing { @@ -24,7 +26,7 @@ namespace osu.Game.Overlays.BeatmapListing { public new readonly BindableList Current = new BindableList(); - private MultipleSelectionFilter filter; + private MultipleSelectionFilter filter = null!; public BeatmapSearchMultipleSelectionFilterRow(LocalisableString header) : base(header) @@ -42,7 +44,6 @@ namespace osu.Game.Overlays.BeatmapListing /// /// Creates a filter control that can be used to simultaneously select multiple values of type . /// - [NotNull] protected virtual MultipleSelectionFilter CreateMultipleSelectionFilter() => new MultipleSelectionFilter(); protected partial class MultipleSelectionFilter : FillFlowContainer @@ -54,7 +55,7 @@ namespace osu.Game.Overlays.BeatmapListing { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; - Spacing = new Vector2(10, 0); + Spacing = new Vector2(10, 5); AddRange(GetValues().Select(CreateTabItem)); } @@ -69,7 +70,7 @@ namespace osu.Game.Overlays.BeatmapListing Current.BindCollectionChanged(currentChanged, true); } - private void currentChanged(object sender, NotifyCollectionChangedEventArgs e) + private void currentChanged(object? sender, NotifyCollectionChangedEventArgs e) { foreach (var c in Children) c.Active.Value = Current.Contains(c.Value); @@ -99,30 +100,91 @@ namespace osu.Game.Overlays.BeatmapListing protected partial class MultipleSelectionFilterTabItem : FilterTabItem { - private readonly Box selectedUnderline; - - protected override bool HighlightOnHoverWhenActive => true; + private Drawable activeContent = null!; + private Circle background = null!; public MultipleSelectionFilterTabItem(T value) : base(value) { + } + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeDuration = 100; + AutoSizeEasing = Easing.OutQuint; + // This doesn't match any actual design, but should make it easier for the user to understand // that filters are applied until we settle on a final design. - AddInternal(selectedUnderline = new Box + AddInternal(activeContent = new Container { Depth = float.MaxValue, - RelativeSizeAxes = Axes.X, - Height = 1.5f, - Anchor = Anchor.BottomLeft, - Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Padding = new MarginPadding + { + Left = -16, + Right = -4, + Vertical = -2 + }, + Children = new Drawable[] + { + background = new Circle + { + Colour = Color4.White, + RelativeSizeAxes = Axes.Both, + }, + new SpriteIcon + { + Icon = FontAwesome.Solid.TimesCircle, + Size = new Vector2(10), + Colour = ColourProvider.Background4, + Position = new Vector2(3, 0.5f), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + } + } }); } + protected override Color4 ColourActive => ColourProvider.Light1; + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) + { + return Active.Value + ? background.ReceivePositionalInputAt(screenSpacePos) + : base.ReceivePositionalInputAt(screenSpacePos); + } + protected override void UpdateState() { - base.UpdateState(); - selectedUnderline.FadeTo(Active.Value ? 1 : 0, 200, Easing.OutQuint); - selectedUnderline.FadeColour(IsHovered ? ColourProvider.Content2 : GetStateColour(), 200, Easing.OutQuint); + Color4 colour = Active.Value ? ColourActive : ColourNormal; + + if (IsHovered) + colour = Active.Value ? colour.Darken(0.2f) : colour.Lighten(0.2f); + + if (Active.Value) + { + // This just allows enough spacing for adjacent tab items to show the "x". + Padding = new MarginPadding { Left = 12 }; + + activeContent.FadeIn(200, Easing.OutQuint); + background.FadeColour(colour, 200, Easing.OutQuint); + + // flipping colours + Text.FadeColour(ColourProvider.Background4, 200, Easing.OutQuint); + Text.Font = Text.Font.With(weight: FontWeight.SemiBold); + } + else + { + Padding = new MarginPadding(); + + activeContent.FadeOut(); + + background.FadeColour(colour, 200, Easing.OutQuint); + Text.FadeColour(colour, 200, Easing.OutQuint); + Text.Font = Text.Font.With(weight: FontWeight.Regular); + } } protected override bool OnClick(ClickEvent e) diff --git a/osu.Game/Overlays/BeatmapListing/FilterTabItem.cs b/osu.Game/Overlays/BeatmapListing/FilterTabItem.cs index ee188d34ce..8f4ecaa0f5 100644 --- a/osu.Game/Overlays/BeatmapListing/FilterTabItem.cs +++ b/osu.Game/Overlays/BeatmapListing/FilterTabItem.cs @@ -8,6 +8,7 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Extensions; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; @@ -24,7 +25,7 @@ namespace osu.Game.Overlays.BeatmapListing [Resolved] protected OverlayColourProvider ColourProvider { get; private set; } - private OsuSpriteText text; + protected OsuSpriteText Text; protected Sample SelectSample { get; private set; } = null!; @@ -39,7 +40,7 @@ namespace osu.Game.Overlays.BeatmapListing AutoSizeAxes = Axes.Both; AddRangeInternal(new Drawable[] { - text = new OsuSpriteText + Text = new OsuSpriteText { Font = OsuFont.GetFont(size: 13, weight: FontWeight.Regular), Text = LabelFor(Value) @@ -84,16 +85,18 @@ namespace osu.Game.Overlays.BeatmapListing /// protected virtual LocalisableString LabelFor(T value) => (value as Enum)?.GetLocalisableDescription() ?? value.ToString(); - protected virtual bool HighlightOnHoverWhenActive => false; + protected virtual Color4 ColourActive => ColourProvider.Content1; + protected virtual Color4 ColourNormal => ColourProvider.Light2; protected virtual void UpdateState() { - bool highlightHover = IsHovered && (!Active.Value || HighlightOnHoverWhenActive); + Color4 colour = Active.Value ? ColourActive : ColourNormal; - text.FadeColour(highlightHover ? ColourProvider.Content2 : GetStateColour(), 200, Easing.OutQuint); - text.Font = text.Font.With(weight: Active.Value ? FontWeight.Bold : FontWeight.Regular); + if (IsHovered) + colour = colour.Lighten(0.2f); + + Text.FadeColour(colour, 200, Easing.OutQuint); + Text.Font = Text.Font.With(weight: Active.Value ? FontWeight.Bold : FontWeight.Regular); } - - protected virtual Color4 GetStateColour() => Active.Value ? ColourProvider.Content1 : ColourProvider.Light2; } } diff --git a/osu.Game/Overlays/BeatmapListingOverlay.cs b/osu.Game/Overlays/BeatmapListingOverlay.cs index a645683c5f..b47e2b82c0 100644 --- a/osu.Game/Overlays/BeatmapListingOverlay.cs +++ b/osu.Game/Overlays/BeatmapListingOverlay.cs @@ -98,7 +98,7 @@ namespace osu.Game.Overlays apiUser.BindValueChanged(_ => Schedule(() => { if (api.IsLoggedIn) - replaceResultsAreaContent(Drawable.Empty()); + replaceResultsAreaContent(Empty()); })); } @@ -198,6 +198,7 @@ namespace osu.Game.Overlays { c.Anchor = Anchor.TopCentre; c.Origin = Anchor.TopCentre; + c.Scale = new Vector2(0.8f); })).ToArray(); private static ReverseChildIDFillFlowContainer createCardContainerFor(IEnumerable newCards) diff --git a/osu.Game/Overlays/BeatmapSet/BasicStats.cs b/osu.Game/Overlays/BeatmapSet/BasicStats.cs index 0b1befe7b9..364874cdf7 100644 --- a/osu.Game/Overlays/BeatmapSet/BasicStats.cs +++ b/osu.Game/Overlays/BeatmapSet/BasicStats.cs @@ -16,7 +16,6 @@ using osu.Game.Beatmaps; using osu.Game.Extensions; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; -using osu.Game.Online.API.Requests.Responses; using osu.Game.Resources.Localisation.Web; using osuTK; @@ -26,9 +25,9 @@ namespace osu.Game.Overlays.BeatmapSet { private readonly Statistic length, bpm, circleCount, sliderCount; - private APIBeatmapSet beatmapSet; + private IBeatmapSetInfo beatmapSet; - public APIBeatmapSet BeatmapSet + public IBeatmapSetInfo BeatmapSet { get => beatmapSet; set diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs index 7ff8352054..a50043f0f0 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs @@ -200,7 +200,8 @@ namespace osu.Game.Overlays.BeatmapSet private void updateExternalLink() { - if (externalLink != null) externalLink.Link = $@"{api.WebsiteRootUrl}/beatmapsets/{BeatmapSet.Value?.OnlineID}#{Picker.Beatmap.Value?.Ruleset.ShortName}/{Picker.Beatmap.Value?.OnlineID}"; + if (externalLink != null) + externalLink.Link = Picker.Beatmap.Value?.GetOnlineURL(api) ?? BeatmapSet.Value?.GetOnlineURL(api); } [BackgroundDependencyLoader] @@ -241,7 +242,7 @@ namespace osu.Game.Overlays.BeatmapSet title.Clear(); artist.Clear(); - title.AddLink(titleText, LinkAction.SearchBeatmapSet, titleText); + title.AddLink(titleText, LinkAction.SearchBeatmapSet, LocalisableString.Interpolate($@"title=""""{titleText}""""")); title.AddArbitraryDrawable(Empty().With(d => d.Width = 5)); title.AddArbitraryDrawable(externalLink = new ExternalLinkButton()); @@ -258,7 +259,7 @@ namespace osu.Game.Overlays.BeatmapSet title.AddArbitraryDrawable(new SpotlightBeatmapBadge()); } - artist.AddLink(artistText, LinkAction.SearchBeatmapSet, artistText); + artist.AddLink(artistText, LinkAction.SearchBeatmapSet, LocalisableString.Interpolate($@"artist=""""{artistText}""""")); if (setInfo.NewValue.TrackId != null) { diff --git a/osu.Game/Overlays/BeatmapSet/Buttons/PlayButton.cs b/osu.Game/Overlays/BeatmapSet/Buttons/PlayButton.cs index 5f9cdf5065..921f136de9 100644 --- a/osu.Game/Overlays/BeatmapSet/Buttons/PlayButton.cs +++ b/osu.Game/Overlays/BeatmapSet/Buttons/PlayButton.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; using osu.Game.Audio; +using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API.Requests.Responses; @@ -28,9 +29,9 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons [CanBeNull] public PreviewTrack Preview { get; private set; } - private APIBeatmapSet beatmapSet; + private IBeatmapSetInfo beatmapSet; - public APIBeatmapSet BeatmapSet + public IBeatmapSetInfo BeatmapSet { get => beatmapSet; set diff --git a/osu.Game/Overlays/BeatmapSet/Buttons/PreviewButton.cs b/osu.Game/Overlays/BeatmapSet/Buttons/PreviewButton.cs index 2254514a44..1eff4a7c11 100644 --- a/osu.Game/Overlays/BeatmapSet/Buttons/PreviewButton.cs +++ b/osu.Game/Overlays/BeatmapSet/Buttons/PreviewButton.cs @@ -8,9 +8,9 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Game.Audio; +using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.Containers; -using osu.Game.Online.API.Requests.Responses; using osuTK; namespace osu.Game.Overlays.BeatmapSet.Buttons @@ -24,7 +24,7 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons public IBindable Playing => playButton.Playing; - public APIBeatmapSet BeatmapSet + public IBeatmapSetInfo BeatmapSet { get => playButton.BeatmapSet; set => playButton.BeatmapSet = value; @@ -32,8 +32,6 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons public PreviewButton() { - Height = 42; - Children = new Drawable[] { background = new Box diff --git a/osu.Game/Overlays/BeatmapSet/Details.cs b/osu.Game/Overlays/BeatmapSet/Details.cs index d656a6b14b..7d69cb7329 100644 --- a/osu.Game/Overlays/BeatmapSet/Details.cs +++ b/osu.Game/Overlays/BeatmapSet/Details.cs @@ -68,6 +68,7 @@ namespace osu.Game.Overlays.BeatmapSet preview = new PreviewButton { RelativeSizeAxes = Axes.X, + Height = 42, }, new DetailBox { diff --git a/osu.Game/Overlays/BeatmapSet/MetadataSectionSource.cs b/osu.Game/Overlays/BeatmapSet/MetadataSectionSource.cs index 544dc0dfe4..4d55359e63 100644 --- a/osu.Game/Overlays/BeatmapSet/MetadataSectionSource.cs +++ b/osu.Game/Overlays/BeatmapSet/MetadataSectionSource.cs @@ -17,9 +17,9 @@ namespace osu.Game.Overlays.BeatmapSet protected override void AddMetadata(string metadata, LinkFlowContainer loaded) { if (SearchAction != null) - loaded.AddLink(metadata, () => SearchAction(metadata)); + loaded.AddLink(metadata, () => SearchAction($@"source=""""{metadata}""""")); else - loaded.AddLink(metadata, LinkAction.SearchBeatmapSet, metadata); + loaded.AddLink(metadata, LinkAction.SearchBeatmapSet, $@"source=""""{metadata}"""""); } } } diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs index 7a817c43eb..c70c41feed 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs @@ -9,7 +9,6 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions; -using osu.Framework.Extensions.EnumExtensions; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -58,9 +57,11 @@ namespace osu.Game.Overlays.BeatmapSet.Scores } /// - /// The statistics that appear in the table, in order of appearance. + /// The names of the statistics that appear in the table. If multiple HitResults have the same + /// DisplayName (for example, "slider end" is the name for both and + /// in osu!) the name will only be listed once. /// - private readonly List<(HitResult result, LocalisableString displayName)> statisticResultTypes = new List<(HitResult, LocalisableString)>(); + private readonly List statisticResultNames = new List(); private bool showPerformancePoints; @@ -72,7 +73,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores return; showPerformancePoints = showPerformanceColumn; - statisticResultTypes.Clear(); + statisticResultNames.Clear(); for (int i = 0; i < scores.Count; i++) backgroundFlow.Add(new ScoreTableRowBackground(i, scores[i], row_height)); @@ -105,20 +106,18 @@ namespace osu.Game.Overlays.BeatmapSet.Scores var ruleset = scores.First().Ruleset.CreateInstance(); - foreach (var result in EnumExtensions.GetValuesInOrder()) + foreach (var resultGroup in ruleset.GetHitResults().GroupBy(r => r.displayName)) { - if (!allScoreStatistics.Contains(result)) + if (!resultGroup.Any(r => allScoreStatistics.Contains(r.result))) continue; // for the time being ignore bonus result types. // this is not being sent from the API and will be empty in all cases. - if (result.IsBonus()) + if (resultGroup.All(r => r.result.IsBonus())) continue; - var displayName = ruleset.GetDisplayNameForHitResult(result); - - columns.Add(new TableColumn(displayName, Anchor.CentreLeft, new Dimension(minSize: 35, maxSize: 60))); - statisticResultTypes.Add((result, displayName)); + columns.Add(new TableColumn(resultGroup.Key, Anchor.CentreLeft, new Dimension(minSize: 35, maxSize: 60))); + statisticResultNames.Add(resultGroup.Key); } if (showPerformancePoints) @@ -160,7 +159,6 @@ namespace osu.Game.Overlays.BeatmapSet.Scores new UpdateableFlag(score.User.CountryCode) { Size = new Vector2(19, 14), - ShowPlaceholderOnUnknown = false, }, username, #pragma warning disable 618 @@ -168,14 +166,25 @@ namespace osu.Game.Overlays.BeatmapSet.Scores #pragma warning restore 618 }; - var availableStatistics = score.GetStatisticsForDisplay().ToDictionary(tuple => tuple.Result); + var availableStatistics = score.GetStatisticsForDisplay().ToLookup(tuple => tuple.DisplayName); - foreach (var result in statisticResultTypes) + foreach (var columnName in statisticResultNames) { - if (!availableStatistics.TryGetValue(result.result, out var stat)) - stat = new HitResultDisplayStatistic(result.result, 0, null, result.displayName); + int count = 0; + int? maxCount = null; - content.Add(new StatisticText(stat.Count, stat.MaxCount, @"N0") { Colour = stat.Count == 0 ? Color4.Gray : Color4.White }); + if (availableStatistics.Contains(columnName)) + { + maxCount = 0; + + foreach (var s in availableStatistics[columnName]) + { + count += s.Count; + maxCount += s.MaxCount; + } + } + + content.Add(new StatisticText(count, maxCount, @"N0") { Colour = count == 0 ? Color4.Gray : Color4.White }); } if (showPerformancePoints) diff --git a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs index 17704f63ee..e8833fa0a3 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs @@ -96,10 +96,17 @@ namespace osu.Game.Overlays.BeatmapSet.Scores } [BackgroundDependencyLoader] - private void load() + private void load(OsuColour colours) { if (score != null) + { totalScoreColumn.Current = scoreManager.GetBindableTotalScoreString(score); + + if (score.Accuracy == 1.0) accuracyColumn.TextColour = colours.GreenLight; +#pragma warning disable CS0618 + if (score.MaxCombo == score.BeatmapInfo!.MaxCombo) maxComboColumn.TextColour = colours.GreenLight; +#pragma warning restore CS0618 + } } private ScoreInfo score; @@ -228,6 +235,11 @@ namespace osu.Game.Overlays.BeatmapSet.Scores set => text.Text = value; } + public Colour4 TextColour + { + set => text.Colour = value; + } + public Drawable Drawable { set diff --git a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs index 9dc2ce204f..13ba9fb74b 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs @@ -118,7 +118,6 @@ namespace osu.Game.Overlays.BeatmapSet.Scores Origin = Anchor.CentreLeft, Size = new Vector2(19, 14), Margin = new MarginPadding { Top = 3 }, // makes spacing look more even - ShowPlaceholderOnUnknown = false, }, } } diff --git a/osu.Game/Overlays/BeatmapSetOverlay.cs b/osu.Game/Overlays/BeatmapSetOverlay.cs index 873336bb6e..8de21129d3 100644 --- a/osu.Game/Overlays/BeatmapSetOverlay.cs +++ b/osu.Game/Overlays/BeatmapSetOverlay.cs @@ -3,8 +3,6 @@ #nullable disable -using System; -using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -16,8 +14,6 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.BeatmapSet; using osu.Game.Overlays.BeatmapSet.Scores; using osu.Game.Overlays.Comments; -using osu.Game.Rulesets.Mods; -using osu.Game.Screens.Select.Details; using osuTK; using osuTK.Graphics; @@ -37,14 +33,6 @@ namespace osu.Game.Overlays private (BeatmapSetLookupType type, int id)? lastLookup; - /// - /// Isolates the beatmap set overlay from the game-wide selected mods bindable - /// to avoid affecting the beatmap details section (i.e. ). - /// - [Cached] - [Cached(typeof(IBindable>))] - protected readonly Bindable> SelectedMods = new Bindable>(Array.Empty()); - public BeatmapSetOverlay() : base(OverlayColourScheme.Blue) { diff --git a/osu.Game/Overlays/Chat/ChannelList/ChannelList.cs b/osu.Game/Overlays/Chat/ChannelList/ChannelList.cs index e6fe97f3c6..fc0060d86a 100644 --- a/osu.Game/Overlays/Chat/ChannelList/ChannelList.cs +++ b/osu.Game/Overlays/Chat/ChannelList/ChannelList.cs @@ -9,13 +9,17 @@ using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; +using osu.Framework.Testing; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; using osu.Game.Online.Chat; using osu.Game.Overlays.Chat.Listing; using osu.Game.Resources.Localisation.Web; +using osuTK; namespace osu.Game.Overlays.Chat.ChannelList { @@ -34,11 +38,12 @@ namespace osu.Game.Overlays.Chat.ChannelList private readonly Dictionary channelMap = new Dictionary(); private OsuScrollContainer scroll = null!; - private FillFlowContainer groupFlow = null!; + private SearchContainer groupFlow = null!; private ChannelGroup announceChannelGroup = null!; private ChannelGroup publicChannelGroup = null!; private ChannelGroup privateChannelGroup = null!; private ChannelListItem selector = null!; + private TextBox searchTextBox = null!; [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) @@ -55,13 +60,23 @@ namespace osu.Game.Overlays.Chat.ChannelList RelativeSizeAxes = Axes.Both, ScrollbarAnchor = Anchor.TopRight, ScrollDistance = 35f, - Child = groupFlow = new FillFlowContainer + Child = groupFlow = new SearchContainer { Direction = FillDirection.Vertical, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Children = new Drawable[] { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = 10, Top = 8 }, + Child = searchTextBox = new ChannelSearchTextBox + { + RelativeSizeAxes = Axes.X, + } + }, announceChannelGroup = new ChannelGroup(ChatStrings.ChannelsListTitleANNOUNCE.ToUpper()), publicChannelGroup = new ChannelGroup(ChatStrings.ChannelsListTitlePUBLIC.ToUpper()), selector = new ChannelListItem(ChannelListingChannel), @@ -71,6 +86,19 @@ namespace osu.Game.Overlays.Chat.ChannelList }, }; + searchTextBox.Current.BindValueChanged(_ => groupFlow.SearchTerm = searchTextBox.Current.Value, true); + searchTextBox.OnCommit += (_, _) => + { + if (string.IsNullOrEmpty(searchTextBox.Current.Value)) + return; + + var firstMatchingItem = this.ChildrenOfType().FirstOrDefault(item => item.MatchingFilter); + if (firstMatchingItem == null) + return; + + OnRequestSelect?.Invoke(firstMatchingItem.Channel); + }; + selector.OnRequestSelect += chan => OnRequestSelect?.Invoke(chan); } @@ -168,5 +196,17 @@ namespace osu.Game.Overlays.Chat.ChannelList }; } } + + private partial class ChannelSearchTextBox : BasicSearchTextBox + { + protected override bool AllowCommit => true; + + public ChannelSearchTextBox() + { + const float scale_factor = 0.8f; + Scale = new Vector2(scale_factor); + Width = 1 / scale_factor; + } + } } } diff --git a/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.cs b/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.cs index 87b1f4ef01..b197fe199d 100644 --- a/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.cs +++ b/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -9,6 +10,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -19,7 +21,7 @@ using osuTK; namespace osu.Game.Overlays.Chat.ChannelList { - public partial class ChannelListItem : OsuClickableContainer + public partial class ChannelListItem : OsuClickableContainer, IFilterable { public event Action? OnRequestSelect; public event Action? OnRequestLeave; @@ -49,7 +51,7 @@ namespace osu.Game.Overlays.Chat.ChannelList [BackgroundDependencyLoader] private void load() { - Height = 30; + Height = 25; RelativeSizeAxes = Axes.X; Children = new Drawable[] @@ -87,7 +89,7 @@ namespace osu.Game.Overlays.Chat.ChannelList Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Text = Channel.Name, - Font = OsuFont.Torus.With(size: 17, weight: FontWeight.SemiBold), + Font = OsuFont.Torus.With(size: 14, weight: FontWeight.SemiBold), Colour = colourProvider.Light3, Margin = new MarginPadding { Bottom = 2 }, RelativeSizeAxes = Axes.X, @@ -186,5 +188,28 @@ namespace osu.Game.Overlays.Chat.ChannelList } private bool isSelector => Channel is ChannelListing.ChannelListingChannel; + + #region Filtering support + + public IEnumerable FilterTerms => isSelector ? Enumerable.Empty() : [Channel.Name]; + + private bool matchingFilter = true; + + public bool MatchingFilter + { + get => matchingFilter; + set + { + if (matchingFilter == value) + return; + + matchingFilter = value; + Alpha = matchingFilter ? 1 : 0; + } + } + + public bool FilteringActive { get; set; } + + #endregion } } diff --git a/osu.Game/Overlays/Chat/ChatLine.cs b/osu.Game/Overlays/Chat/ChatLine.cs index bbc3ee5bf4..e386f2ac09 100644 --- a/osu.Game/Overlays/Chat/ChatLine.cs +++ b/osu.Game/Overlays/Chat/ChatLine.cs @@ -18,10 +18,11 @@ using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; +using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Chat; +using osuTK; using osuTK.Graphics; -using Message = osu.Game.Online.Chat.Message; namespace osu.Game.Overlays.Chat { @@ -47,11 +48,11 @@ namespace osu.Game.Overlays.Chat public IReadOnlyCollection DrawableContentFlow => drawableContentFlow; - protected virtual float FontSize => 20; + private const float font_size = 13; protected virtual float Spacing => 15; - protected virtual float UsernameWidth => 130; + protected virtual float UsernameWidth => 150; [Resolved] private ChannelManager? chatManager { get; set; } @@ -69,6 +70,43 @@ namespace osu.Game.Overlays.Chat private Container? highlight; + private Drawable? background; + + private bool alternatingBackground; + private bool requiresTimestamp = true; + + public bool RequiresTimestamp + { + get => requiresTimestamp; + set + { + if (requiresTimestamp == value) + return; + + requiresTimestamp = value; + + if (!IsLoaded) + return; + + updateMessageContent(); + } + } + + public bool AlternatingBackground + { + get => alternatingBackground; + set + { + if (alternatingBackground == value) + return; + + alternatingBackground = value; + updateBackground(); + } + } + + private bool isMention; + /// /// The colour used to paint the author's username. /// @@ -102,48 +140,74 @@ namespace osu.Game.Overlays.Chat configManager.BindWith(OsuSetting.Prefer24HourTime, prefer24HourTime); prefer24HourTime.BindValueChanged(_ => updateTimestamp()); - InternalChild = new GridContainer + Padding = new MarginPadding { Right = 5 }; + + InternalChildren = new[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, - ColumnDimensions = new[] + background = new Container { - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.Absolute, Spacing + UsernameWidth + Spacing), - new Dimension(), - }, - Content = new[] - { - new Drawable[] + Masking = true, + CornerRadius = 4, + Alpha = 0, + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + Child = new Box { - drawableTimestamp = new OsuSpriteText - { - Shadow = false, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Font = OsuFont.GetFont(size: FontSize * 0.75f, weight: FontWeight.SemiBold, fixedWidth: true), - AlwaysPresent = true, - }, - drawableUsername = new DrawableChatUsername(message.Sender) - { - Width = UsernameWidth, - FontSize = FontSize, - AutoSizeAxes = Axes.Y, - Origin = Anchor.TopRight, - Anchor = Anchor.TopRight, - Margin = new MarginPadding { Horizontal = Spacing }, - AccentColour = UsernameColour, - Inverted = !string.IsNullOrEmpty(message.Sender.Colour), - }, - drawableContentFlow = new LinkFlowContainer(styleMessageContent) - { - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - } + Colour = Colour4.FromHex("#3b3234"), + RelativeSizeAxes = Axes.Both, }, + }, + new GridContainer + { + Padding = new MarginPadding + { + Horizontal = 2, + Vertical = 2, + }, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.Absolute, 45), + new Dimension(GridSizeMode.Absolute, Spacing + UsernameWidth + Spacing), + new Dimension(), + }, + Content = new[] + { + new Drawable[] + { + drawableTimestamp = new OsuSpriteText + { + Shadow = false, + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + Spacing = new Vector2(-1, 0), + Font = OsuFont.GetFont(size: font_size, weight: FontWeight.SemiBold, fixedWidth: true), + AlwaysPresent = true, + }, + drawableUsername = new DrawableChatUsername(message.Sender) + { + Width = UsernameWidth, + FontSize = font_size, + AutoSizeAxes = Axes.Y, + Origin = Anchor.TopRight, + Anchor = Anchor.TopRight, + Margin = new MarginPadding { Horizontal = Spacing }, + AccentColour = UsernameColour, + Inverted = !string.IsNullOrEmpty(message.Sender.Colour), + }, + drawableContentFlow = new LinkFlowContainer(styleMessageContent) + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + } + }, + } } }; + + updateBackground(); } protected override void LoadComplete() @@ -194,30 +258,49 @@ namespace osu.Game.Overlays.Chat private void styleMessageContent(SpriteText text) { text.Shadow = false; - text.Font = text.Font.With(size: FontSize, italics: Message.IsAction); + text.Font = text.Font.With(size: font_size, italics: Message.IsAction, weight: isMention ? FontWeight.SemiBold : FontWeight.Medium); - bool messageHasColour = Message.IsAction && !string.IsNullOrEmpty(message.Sender.Colour); - text.Colour = messageHasColour ? Color4Extensions.FromHex(message.Sender.Colour) : colourProvider?.Content1 ?? Colour4.White; + Color4 messageColour = colourProvider?.Content1 ?? Colour4.White; + + if (isMention) + messageColour = colourProvider?.Highlight1 ?? Color4.Orange; + else if (Message.IsAction && !string.IsNullOrEmpty(message.Sender.Colour)) + messageColour = Color4Extensions.FromHex(message.Sender.Colour); + + text.Colour = messageColour; } + [Resolved] + private IAPIProvider api { get; set; } = null!; + private void updateMessageContent() { this.FadeTo(message is LocalEchoMessage ? 0.4f : 1.0f, 500, Easing.OutQuint); - drawableTimestamp.FadeTo(message is LocalEchoMessage ? 0 : 1, 500, Easing.OutQuint); - updateTimestamp(); + if (requiresTimestamp && !(message is LocalEchoMessage)) + { + drawableTimestamp.Show(); + updateTimestamp(); + } + else + { + drawableTimestamp.Hide(); + } + drawableUsername.Text = $@"{message.Sender.Username}"; // remove non-existent channels from the link list message.Links.RemoveAll(link => link.Action == LinkAction.OpenChannel && chatManager?.AvailableChannels.Any(c => c.Name == link.Argument.ToString()) != true); + isMention = MessageNotifier.CheckContainsUsername(message.DisplayContent, api.LocalUser.Value.Username); + drawableContentFlow.Clear(); drawableContentFlow.AddLinks(message.DisplayContent, message.Links); } private void updateTimestamp() { - drawableTimestamp.Text = message.Timestamp.LocalDateTime.ToLocalisableString(prefer24HourTime.Value ? @"HH:mm:ss" : @"hh:mm:ss tt"); + drawableTimestamp.Text = message.Timestamp.LocalDateTime.ToLocalisableString(prefer24HourTime.Value ? @"HH:mm" : @"hh:mm tt"); } private static readonly Color4[] default_username_colours = @@ -258,5 +341,11 @@ namespace osu.Game.Overlays.Chat Color4Extensions.FromHex("812a96"), Color4Extensions.FromHex("992861"), }; + + private void updateBackground() + { + if (background != null) + background.Alpha = alternatingBackground ? 0.2f : 0; + } } } diff --git a/osu.Game/Overlays/Chat/ChatTextBar.cs b/osu.Game/Overlays/Chat/ChatTextBar.cs index 16a8d14b10..0a42363279 100644 --- a/osu.Game/Overlays/Chat/ChatTextBar.cs +++ b/osu.Game/Overlays/Chat/ChatTextBar.cs @@ -19,6 +19,8 @@ namespace osu.Game.Overlays.Chat { public partial class ChatTextBar : Container { + public const float HEIGHT = 40; + public readonly BindableBool ShowSearch = new BindableBool(); public event Action? OnChatMessageCommitted; @@ -45,7 +47,7 @@ namespace osu.Game.Overlays.Chat private void load(OverlayColourProvider colourProvider) { RelativeSizeAxes = Axes.X; - Height = 60; + Height = HEIGHT; Children = new Drawable[] { @@ -76,7 +78,7 @@ namespace osu.Game.Overlays.Chat Child = chattingText = new TruncatingSpriteText { MaxWidth = chatting_text_width - padding * 2, - Font = OsuFont.Torus.With(size: 20), + Font = OsuFont.Torus, Colour = colourProvider.Background1, Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, @@ -91,7 +93,7 @@ namespace osu.Game.Overlays.Chat Icon = FontAwesome.Solid.Search, Origin = Anchor.CentreRight, Anchor = Anchor.CentreRight, - Size = new Vector2(20), + Size = new Vector2(OsuFont.DEFAULT_FONT_SIZE), Margin = new MarginPadding { Right = 2 }, }, }, @@ -101,6 +103,7 @@ namespace osu.Game.Overlays.Chat Padding = new MarginPadding { Right = padding }, Child = chatTextBox = new ChatTextBox { + FontSize = OsuFont.DEFAULT_FONT_SIZE, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, RelativeSizeAxes = Axes.X, diff --git a/osu.Game/Overlays/Chat/DaySeparator.cs b/osu.Game/Overlays/Chat/DaySeparator.cs index e737b787ba..c371877fcb 100644 --- a/osu.Game/Overlays/Chat/DaySeparator.cs +++ b/osu.Game/Overlays/Chat/DaySeparator.cs @@ -14,7 +14,7 @@ namespace osu.Game.Overlays.Chat { public partial class DaySeparator : Container { - protected virtual float TextSize => 15; + protected virtual float TextSize => 13; protected virtual float LineHeight => 2; @@ -75,7 +75,7 @@ namespace osu.Game.Overlays.Chat Height = LineHeight, Colour = colourProvider?.Background5 ?? Colour4.White, }, - Drawable.Empty(), + Empty(), new OsuSpriteText { Anchor = Anchor.CentreRight, @@ -87,7 +87,7 @@ namespace osu.Game.Overlays.Chat } }, }, - Drawable.Empty(), + Empty(), new Circle { Anchor = Anchor.Centre, diff --git a/osu.Game/Overlays/Chat/DrawableChannel.cs b/osu.Game/Overlays/Chat/DrawableChannel.cs index aa17df4907..41098ef823 100644 --- a/osu.Game/Overlays/Chat/DrawableChannel.cs +++ b/osu.Game/Overlays/Chat/DrawableChannel.cs @@ -61,7 +61,7 @@ namespace osu.Game.Overlays.Chat Padding = new MarginPadding { Bottom = 5 }, Child = ChatLineFlow = new FillFlowContainer { - Padding = new MarginPadding { Horizontal = 10 }, + Padding = new MarginPadding { Left = 3, Right = 10 }, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Vertical, @@ -84,6 +84,25 @@ namespace osu.Game.Overlays.Chat highlightedMessage.BindValueChanged(_ => processMessageHighlighting(), true); } + protected override void Update() + { + base.Update(); + + long? lastMinutes = null; + + for (int i = 0; i < ChatLineFlow.Count; i++) + { + if (ChatLineFlow[i] is ChatLine chatline) + { + long minutes = chatline.Message.Timestamp.ToUnixTimeSeconds() / 60; + + chatline.AlternatingBackground = i % 2 == 0; + chatline.RequiresTimestamp = minutes != lastMinutes; + lastMinutes = minutes; + } + } + } + /// /// Processes any pending message in . /// diff --git a/osu.Game/Overlays/Chat/Listing/ChannelListingItem.cs b/osu.Game/Overlays/Chat/Listing/ChannelListingItem.cs index 9c85c73ee4..466f8b2f5d 100644 --- a/osu.Game/Overlays/Chat/Listing/ChannelListingItem.cs +++ b/osu.Game/Overlays/Chat/Listing/ChannelListingItem.cs @@ -44,7 +44,7 @@ namespace osu.Game.Overlays.Chat.Listing [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; - private const float text_size = 18; + private const float text_size = 14; private const float icon_size = 14; private const float vertical_margin = 1.5f; diff --git a/osu.Game/Overlays/ChatOverlay.cs b/osu.Game/Overlays/ChatOverlay.cs index 8f3b7031c2..b11483e678 100644 --- a/osu.Game/Overlays/ChatOverlay.cs +++ b/osu.Game/Overlays/ChatOverlay.cs @@ -55,7 +55,6 @@ namespace osu.Game.Overlays private const int transition_length = 500; private const float top_bar_height = 40; private const float side_bar_width = 190; - private const float chat_bar_height = 60; protected override string PopInSampleName => @"UI/overlay-big-pop-in"; protected override string PopOutSampleName => @"UI/overlay-big-pop-out"; @@ -136,7 +135,7 @@ namespace osu.Game.Overlays Padding = new MarginPadding { Left = side_bar_width, - Bottom = chat_bar_height, + Bottom = ChatTextBar.HEIGHT, }, Children = new Drawable[] { diff --git a/osu.Game/Overlays/Comments/Buttons/ChevronButton.cs b/osu.Game/Overlays/Comments/Buttons/ChevronButton.cs index 45024f25db..3902f89688 100644 --- a/osu.Game/Overlays/Comments/Buttons/ChevronButton.cs +++ b/osu.Game/Overlays/Comments/Buttons/ChevronButton.cs @@ -24,6 +24,7 @@ namespace osu.Game.Overlays.Comments.Buttons Anchor = Anchor.Centre, Origin = Anchor.Centre, Size = new Vector2(12), + Icon = FontAwesome.Solid.ChevronDown }; } @@ -38,11 +39,12 @@ namespace osu.Game.Overlays.Comments.Buttons base.LoadComplete(); Action = Expanded.Toggle; Expanded.BindValueChanged(onExpandedChanged, true); + FinishTransforms(true); } private void onExpandedChanged(ValueChangedEvent expanded) { - icon.Icon = expanded.NewValue ? FontAwesome.Solid.ChevronUp : FontAwesome.Solid.ChevronDown; + icon.ScaleTo(expanded.NewValue ? new Vector2(1f, -1f) : Vector2.One, 300, Easing.OutQuint); } } } diff --git a/osu.Game/Overlays/Comments/Buttons/CommentRepliesButton.cs b/osu.Game/Overlays/Comments/Buttons/CommentRepliesButton.cs index 400820ddd9..543ed7e722 100644 --- a/osu.Game/Overlays/Comments/Buttons/CommentRepliesButton.cs +++ b/osu.Game/Overlays/Comments/Buttons/CommentRepliesButton.cs @@ -89,7 +89,7 @@ namespace osu.Game.Overlays.Comments.Buttons background.Colour = colourProvider.Background2; } - protected void SetIconDirection(bool upwards) => icon.ScaleTo(new Vector2(1, upwards ? -1 : 1)); + protected void SetIconDirection(bool upwards) => icon.ScaleTo(upwards ? new Vector2(1f, -1f) : Vector2.One, 300, Easing.OutQuint); public void ToggleTextVisibility(bool visible) => text.FadeTo(visible ? 1 : 0, 200, Easing.OutQuint); diff --git a/osu.Game/Overlays/Comments/CommentEditor.cs b/osu.Game/Overlays/Comments/CommentEditor.cs index 02bcbb9d05..c456592383 100644 --- a/osu.Game/Overlays/Comments/CommentEditor.cs +++ b/osu.Game/Overlays/Comments/CommentEditor.cs @@ -14,6 +14,8 @@ using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Resources.Localisation.Web; using osuTK; using osuTK.Graphics; @@ -21,6 +23,8 @@ namespace osu.Game.Overlays.Comments { public abstract partial class CommentEditor : CompositeDrawable { + public Bindable CommentableMeta { get; set; } = new Bindable(); + private const int side_padding = 8; protected abstract LocalisableString FooterText { get; } @@ -53,8 +57,7 @@ namespace osu.Game.Overlays.Comments /// /// Returns the placeholder text for the comment box. /// - /// Whether the current user is logged in. - protected abstract LocalisableString GetPlaceholderText(bool isLoggedIn); + protected abstract LocalisableString GetPlaceholderText(); protected bool ShowLoadingSpinner { @@ -65,7 +68,7 @@ namespace osu.Game.Overlays.Comments else loadingSpinner.Hide(); - updateCommitButtonState(); + updateState(); } } @@ -167,25 +170,33 @@ namespace osu.Game.Overlays.Comments protected override void LoadComplete() { base.LoadComplete(); - Current.BindValueChanged(_ => updateCommitButtonState(), true); - apiState.BindValueChanged(updateStateForLoggedIn, true); + Current.BindValueChanged(_ => updateState()); + apiState.BindValueChanged(_ => Scheduler.AddOnce(updateState)); + CommentableMeta.BindValueChanged(_ => Scheduler.AddOnce(updateState)); + updateState(); } protected abstract void OnCommit(string text); - private void updateCommitButtonState() => - commitButton.Enabled.Value = loadingSpinner.State.Value == Visibility.Hidden && !string.IsNullOrEmpty(Current.Value); - - private void updateStateForLoggedIn(ValueChangedEvent state) => Schedule(() => + private void updateState() { - bool isAvailable = state.NewValue > APIState.Offline; + bool isOnline = apiState.Value > APIState.Offline; + LocalisableString? canNewCommentReason = CommentEditor.canNewCommentReason(CommentableMeta.Value); + bool commentsDisabled = canNewCommentReason != null; + bool canComment = isOnline && !commentsDisabled; - TextBox.PlaceholderText = GetPlaceholderText(isAvailable); - TextBox.ReadOnly = !isAvailable; + if (!isOnline) + TextBox.PlaceholderText = AuthorizationStrings.RequireLogin; + else if (canNewCommentReason != null) + TextBox.PlaceholderText = canNewCommentReason.Value; + else + TextBox.PlaceholderText = GetPlaceholderText(); + TextBox.ReadOnly = !canComment; - if (isAvailable) + if (isOnline) { commitButton.Show(); + commitButton.Enabled.Value = !commentsDisabled && loadingSpinner.State.Value == Visibility.Hidden && !string.IsNullOrEmpty(Current.Value); logInButton.Hide(); } else @@ -193,7 +204,25 @@ namespace osu.Game.Overlays.Comments commitButton.Hide(); logInButton.Show(); } - }); + } + + // https://github.com/ppy/osu-web/blob/83816dbe24ad2927273cba968f2fcd2694a121a9/resources/js/components/comment-editor.tsx#L54-L60 + // careful here, logic is VERY finicky. + private static LocalisableString? canNewCommentReason(CommentableMeta? meta) + { + if (meta == null) + return null; + + if (meta.CurrentUserAttributes != null) + { + if (meta.CurrentUserAttributes.Value.CanNewCommentReason is string reason) + return reason; + + return null; + } + + return AuthorizationStrings.CommentStoreDisabled; + } private partial class EditorTextBox : OsuTextBox { diff --git a/osu.Game/Overlays/Comments/CommentsContainer.cs b/osu.Game/Overlays/Comments/CommentsContainer.cs index 2e5f13aa99..921c1682f5 100644 --- a/osu.Game/Overlays/Comments/CommentsContainer.cs +++ b/osu.Game/Overlays/Comments/CommentsContainer.cs @@ -20,6 +20,7 @@ using System.Collections.Generic; using JetBrains.Annotations; using osu.Framework.Localisation; using osu.Framework.Logging; +using osu.Game.Extensions; using osu.Game.Graphics.Sprites; using osu.Game.Resources.Localisation.Web; using osu.Game.Users.Drawables; @@ -49,6 +50,7 @@ namespace osu.Game.Overlays.Comments private int currentPage; private FillFlowContainer pinnedContent; + private NewCommentEditor newCommentEditor; private FillFlowContainer content; private DeletedCommentsCounter deletedCommentsCounter; private CommentsShowMoreButton moreButton; @@ -114,7 +116,7 @@ namespace osu.Game.Overlays.Comments Padding = new MarginPadding { Left = 60 }, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Child = new NewCommentEditor + Child = newCommentEditor = new NewCommentEditor { OnPost = prependPostedComments } @@ -242,6 +244,7 @@ namespace osu.Game.Overlays.Comments protected void OnSuccess(CommentBundle response) { commentCounter.Current.Value = response.Total; + newCommentEditor.CommentableMeta.Value = response.CommentableMeta.SingleOrDefault(m => m.Id == id.Value && m.Type == type.Value.ToString().ToSnakeCase().ToLowerInvariant()); if (!response.Comments.Any()) { @@ -413,8 +416,7 @@ namespace osu.Game.Overlays.Comments protected override LocalisableString GetButtonText(bool isLoggedIn) => isLoggedIn ? CommonStrings.ButtonsPost : CommentsStrings.GuestButtonNew; - protected override LocalisableString GetPlaceholderText(bool isLoggedIn) => - isLoggedIn ? CommentsStrings.PlaceholderNew : AuthorizationStrings.RequireLogin; + protected override LocalisableString GetPlaceholderText() => CommentsStrings.PlaceholderNew; protected override void OnCommit(string text) { diff --git a/osu.Game/Overlays/Comments/DrawableComment.cs b/osu.Game/Overlays/Comments/DrawableComment.cs index afd4b96c68..d664a44be9 100644 --- a/osu.Game/Overlays/Comments/DrawableComment.cs +++ b/osu.Game/Overlays/Comments/DrawableComment.cs @@ -47,7 +47,7 @@ namespace osu.Game.Overlays.Comments public readonly BindableList Replies = new BindableList(); - private readonly BindableBool childrenExpanded = new BindableBool(true); + private readonly BindableBool childrenExpanded; private int currentPage; @@ -92,6 +92,8 @@ namespace osu.Game.Overlays.Comments { Comment = comment; Meta = meta; + + childrenExpanded = new BindableBool(!comment.Pinned); } [BackgroundDependencyLoader] @@ -426,7 +428,7 @@ namespace osu.Game.Overlays.Comments if (replyEditorContainer.Count == 0) { replyEditorContainer.Show(); - replyEditorContainer.Add(new ReplyCommentEditor(Comment) + replyEditorContainer.Add(new ReplyCommentEditor(Comment, Meta) { OnPost = comments => { diff --git a/osu.Game/Overlays/Comments/ReplyCommentEditor.cs b/osu.Game/Overlays/Comments/ReplyCommentEditor.cs index 8e9e82507d..8350887ec0 100644 --- a/osu.Game/Overlays/Comments/ReplyCommentEditor.cs +++ b/osu.Game/Overlays/Comments/ReplyCommentEditor.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Localisation; @@ -26,12 +27,12 @@ namespace osu.Game.Overlays.Comments protected override LocalisableString GetButtonText(bool isLoggedIn) => isLoggedIn ? CommonStrings.ButtonsReply : CommentsStrings.GuestButtonReply; - protected override LocalisableString GetPlaceholderText(bool isLoggedIn) => - isLoggedIn ? CommentsStrings.PlaceholderReply : AuthorizationStrings.RequireLogin; + protected override LocalisableString GetPlaceholderText() => CommentsStrings.PlaceholderReply; - public ReplyCommentEditor(Comment parent) + public ReplyCommentEditor(Comment parent, IEnumerable meta) { parentComment = parent; + CommentableMeta.Value = meta.SingleOrDefault(m => m.Id == parent.CommentableId && m.Type == parent.CommentableType); } protected override void LoadComplete() @@ -39,7 +40,7 @@ namespace osu.Game.Overlays.Comments base.LoadComplete(); if (!TextBox.ReadOnly) - GetContainingInputManager().ChangeFocus(TextBox); + GetContainingFocusManager()!.ChangeFocus(TextBox); } protected override void OnCommit(string text) diff --git a/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs b/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs index e3accfd2ad..3e393ced01 100644 --- a/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs +++ b/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs @@ -6,14 +6,17 @@ using System.Collections.Generic; using System.Linq; using System.Threading; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Resources.Localisation.Web; using osu.Game.Users; using osuTK; @@ -35,7 +38,8 @@ namespace osu.Game.Overlays.Dashboard.Friends private CancellationTokenSource cancellationToken; - private Drawable currentContent; + [CanBeNull] + private SearchContainer currentContent; private FriendOnlineStreamControl onlineStreamControl; private Box background; @@ -43,8 +47,9 @@ namespace osu.Game.Overlays.Dashboard.Friends private UserListToolbar userListToolbar; private Container itemsPlaceholder; private LoadingLayer loading; + private BasicSearchTextBox searchTextBox; - private readonly IBindableList apiFriends = new BindableList(); + private readonly IBindableList apiFriends = new BindableList(); public FriendDisplay() { @@ -104,7 +109,7 @@ namespace osu.Game.Overlays.Dashboard.Friends Margin = new MarginPadding { Bottom = 20 }, Children = new Drawable[] { - new Container + new GridContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, @@ -113,11 +118,38 @@ namespace osu.Game.Overlays.Dashboard.Friends Horizontal = 40, Vertical = 20 }, - Child = userListToolbar = new UserListToolbar + ColumnDimensions = new[] { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - } + new Dimension(), + new Dimension(GridSizeMode.Absolute, 50), + new Dimension(GridSizeMode.AutoSize), + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new[] + { + searchTextBox = new BasicSearchTextBox + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Height = 40, + ReleaseFocusOnCommit = false, + HoldFocus = true, + PlaceholderText = HomeStrings.SearchPlaceholder, + }, + Empty(), + userListToolbar = new UserListToolbar + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + }, + }, + }, }, new Container { @@ -145,7 +177,7 @@ namespace osu.Game.Overlays.Dashboard.Friends controlBackground.Colour = colourProvider.Background5; apiFriends.BindTo(api.Friends); - apiFriends.BindCollectionChanged((_, _) => Schedule(() => Users = apiFriends.ToList()), true); + apiFriends.BindCollectionChanged((_, _) => Schedule(() => Users = apiFriends.Select(f => f.TargetUser).ToList()), true); } protected override void LoadComplete() @@ -155,6 +187,11 @@ namespace osu.Game.Overlays.Dashboard.Friends onlineStreamControl.Current.BindValueChanged(_ => recreatePanels()); userListToolbar.DisplayStyle.BindValueChanged(_ => recreatePanels()); userListToolbar.SortCriteria.BindValueChanged(_ => recreatePanels()); + searchTextBox.Current.BindValueChanged(_ => + { + if (currentContent.IsNotNull()) + currentContent.SearchTerm = searchTextBox.Current.Value; + }); } private void recreatePanels() @@ -188,7 +225,7 @@ namespace osu.Game.Overlays.Dashboard.Friends } } - private void addContentToPlaceholder(Drawable content) + private void addContentToPlaceholder(SearchContainer content) { loading.Hide(); @@ -204,16 +241,17 @@ namespace osu.Game.Overlays.Dashboard.Friends currentContent.FadeIn(200, Easing.OutQuint); } - private FillFlowContainer createTable(List users) + private SearchContainer createTable(List users) { var style = userListToolbar.DisplayStyle.Value; - return new FillFlowContainer + return new SearchContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Spacing = new Vector2(style == OverlayPanelDisplayStyle.Card ? 10 : 2), - Children = users.Select(u => createUserPanel(u, style)).ToList() + Children = users.Select(u => createUserPanel(u, style)).ToList(), + SearchTerm = searchTextBox.Current.Value, }; } diff --git a/osu.Game/Overlays/Dialog/DangerousActionDialog.cs b/osu.Game/Overlays/Dialog/DangerousActionDialog.cs index 42a3ff827c..287b0fa2c6 100644 --- a/osu.Game/Overlays/Dialog/DangerousActionDialog.cs +++ b/osu.Game/Overlays/Dialog/DangerousActionDialog.cs @@ -30,20 +30,20 @@ namespace osu.Game.Overlays.Dialog protected DangerousActionDialog() { - HeaderText = DeleteConfirmationDialogStrings.HeaderText; + HeaderText = DialogStrings.CautionHeaderText; - Icon = FontAwesome.Regular.TrashAlt; + Icon = FontAwesome.Solid.ExclamationTriangle; Buttons = new PopupDialogButton[] { new PopupDialogDangerousButton { - Text = DeleteConfirmationDialogStrings.Confirm, + Text = DialogStrings.Confirm, Action = () => DangerousAction?.Invoke() }, new PopupDialogCancelButton { - Text = DeleteConfirmationDialogStrings.Cancel, + Text = DialogStrings.Cancel, Action = () => CancelAction?.Invoke() } }; diff --git a/osu.Game/Overlays/Dialog/DeletionDialog.cs b/osu.Game/Overlays/Dialog/DeletionDialog.cs new file mode 100644 index 0000000000..26a29068a9 --- /dev/null +++ b/osu.Game/Overlays/Dialog/DeletionDialog.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics.Sprites; +using osu.Game.Localisation; + +namespace osu.Game.Overlays.Dialog +{ + /// + /// A dialog which provides confirmation for deletion of something. + /// + public abstract partial class DeletionDialog : DangerousActionDialog + { + protected DeletionDialog() + { + HeaderText = DialogStrings.DeletionHeaderText; + Icon = FontAwesome.Solid.Trash; + } + } +} diff --git a/osu.Game/Overlays/Dialog/PopupDialog.cs b/osu.Game/Overlays/Dialog/PopupDialog.cs index 4ac37a63e2..a23c394c9f 100644 --- a/osu.Game/Overlays/Dialog/PopupDialog.cs +++ b/osu.Game/Overlays/Dialog/PopupDialog.cs @@ -210,7 +210,7 @@ namespace osu.Game.Overlays.Dialog RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, TextAnchor = Anchor.TopCentre, - Padding = new MarginPadding { Horizontal = 5 }, + Padding = new MarginPadding { Horizontal = 15 }, }, body = new OsuTextFlowContainer(t => t.Font = t.Font.With(size: 18)) { @@ -219,7 +219,7 @@ namespace osu.Game.Overlays.Dialog TextAnchor = Anchor.TopCentre, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Horizontal = 5 }, + Padding = new MarginPadding { Horizontal = 15 }, }, buttonsContainer = new FillFlowContainer { diff --git a/osu.Game/Overlays/Dialog/PopupDialogDangerousButton.cs b/osu.Game/Overlays/Dialog/PopupDialogDangerousButton.cs index 19d7ea7a87..d84e1d760d 100644 --- a/osu.Game/Overlays/Dialog/PopupDialogDangerousButton.cs +++ b/osu.Game/Overlays/Dialog/PopupDialogDangerousButton.cs @@ -10,7 +10,6 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; -using osu.Game.Audio.Effects; using osu.Game.Graphics; using osu.Game.Graphics.Containers; @@ -56,7 +55,6 @@ namespace osu.Game.Overlays.Dialog private Sample tickSample; private Sample confirmSample; private double lastTickPlaybackTime; - private AudioFilter lowPassFilter = null!; private bool mouseDown; [BackgroundDependencyLoader] @@ -64,8 +62,6 @@ namespace osu.Game.Overlays.Dialog { tickSample = audio.Samples.Get(@"UI/dialog-dangerous-tick"); confirmSample = audio.Samples.Get(@"UI/dialog-dangerous-select"); - - AddInternal(lowPassFilter = new AudioFilter(audio.SampleMixer)); } protected override void LoadComplete() @@ -74,15 +70,8 @@ namespace osu.Game.Overlays.Dialog Progress.BindValueChanged(progressChanged); } - protected override void AbortConfirm() - { - lowPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF); - base.AbortConfirm(); - } - protected override void Confirm() { - lowPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF); confirmSample?.Play(); base.Confirm(); } @@ -122,16 +111,16 @@ namespace osu.Game.Overlays.Dialog private void progressChanged(ValueChangedEvent progress) { - if (progress.NewValue < progress.OldValue) return; + if (progress.NewValue < progress.OldValue) + return; - if (Clock.CurrentTime - lastTickPlaybackTime < 30) return; - - lowPassFilter.CutoffTo((int)(progress.NewValue * AudioFilter.MAX_LOWPASS_CUTOFF * 0.5)); + if (Clock.CurrentTime - lastTickPlaybackTime < 40) + return; var channel = tickSample.GetChannel(); - channel.Frequency.Value = 1 + progress.NewValue * 0.5f; - channel.Volume.Value = 0.5f + progress.NewValue / 2f; + channel.Frequency.Value = 1 + progress.NewValue; + channel.Volume.Value = 0.1f + progress.NewValue / 2f; channel.Play(); diff --git a/osu.Game/Overlays/DialogOverlay.cs b/osu.Game/Overlays/DialogOverlay.cs index 9ad532ae50..4e7aff84bc 100644 --- a/osu.Game/Overlays/DialogOverlay.cs +++ b/osu.Game/Overlays/DialogOverlay.cs @@ -3,16 +3,16 @@ #nullable disable +using System; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Overlays.Dialog; using osu.Game.Graphics.Containers; using osu.Game.Input.Bindings; using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; -using osu.Framework.Audio; using osu.Framework.Input.Events; -using osu.Game.Audio.Effects; namespace osu.Game.Overlays { @@ -23,15 +23,16 @@ namespace osu.Game.Overlays protected override string PopInSampleName => "UI/dialog-pop-in"; protected override string PopOutSampleName => "UI/dialog-pop-out"; - private AudioFilter lowPassFilter; + [Resolved] + private MusicController musicController { get; set; } public PopupDialog CurrentDialog { get; private set; } public override bool IsPresent => Scheduler.HasPendingTasks - || dialogContainer.Children.Count > 0 - // Safety for low pass filter potentially getting stuck in applied state due to - // transforms on `this` causing children to no longer be updated. - || lowPassFilter.IsAttached; + || dialogContainer.Children.Count > 0; + + [CanBeNull] + private IDisposable duckOperation; public DialogOverlay() { @@ -49,10 +50,10 @@ namespace osu.Game.Overlays Origin = Anchor.Centre; } - [BackgroundDependencyLoader] - private void load(AudioManager audio) + protected override void Dispose(bool isDisposing) { - AddInternal(lowPassFilter = new AudioFilter(audio.TrackMixer)); + base.Dispose(isDisposing); + duckOperation?.Dispose(); } public void Push(PopupDialog dialog) @@ -105,13 +106,18 @@ namespace osu.Game.Overlays protected override void PopIn() { - lowPassFilter.CutoffTo(300, 100, Easing.OutCubic); + duckOperation = musicController?.Duck(new DuckParameters + { + DuckVolumeTo = 1, + DuckDuration = 100, + RestoreDuration = 100, + }); } protected override void PopOut() { base.PopOut(); - lowPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, 100, Easing.InCubic); + duckOperation?.Dispose(); // PopOut gets called initially, but we only want to hide dialog when we have been loaded and are present. if (IsLoaded && CurrentDialog?.State.Value == Visibility.Visible) diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenBeatmaps.cs b/osu.Game/Overlays/FirstRunSetup/ScreenBeatmaps.cs index 385695f669..da60951ab6 100644 --- a/osu.Game/Overlays/FirstRunSetup/ScreenBeatmaps.cs +++ b/osu.Game/Overlays/FirstRunSetup/ScreenBeatmaps.cs @@ -15,6 +15,7 @@ using osu.Game.Graphics.Containers; using osu.Game.Localisation; using osu.Game.Online; using osuTK; +using osuTK.Graphics; using Realms; namespace osu.Game.Overlays.FirstRunSetup @@ -25,6 +26,8 @@ namespace osu.Game.Overlays.FirstRunSetup private ProgressRoundedButton downloadBundledButton = null!; private ProgressRoundedButton downloadTutorialButton = null!; + private OsuTextFlowContainer downloadInBackgroundText = null!; + private OsuTextFlowContainer currentlyLoadedBeatmaps = null!; private BundledBeatmapDownloader? tutorialDownloader; @@ -100,6 +103,15 @@ namespace osu.Game.Overlays.FirstRunSetup Text = FirstRunSetupBeatmapScreenStrings.BundledButton, Action = downloadBundled }, + downloadInBackgroundText = new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: CONTENT_FONT_SIZE)) + { + Colour = OverlayColourProvider.Light2, + Alpha = 0, + TextAnchor = Anchor.TopCentre, + Text = FirstRunSetupBeatmapScreenStrings.DownloadingInBackground, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + }, new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: CONTENT_FONT_SIZE)) { Colour = OverlayColourProvider.Content1, @@ -169,6 +181,10 @@ namespace osu.Game.Overlays.FirstRunSetup if (bundledDownloader != null) return; + downloadInBackgroundText + .FlashColour(Color4.White, 500) + .FadeIn(200); + bundledDownloader = new BundledBeatmapDownloader(false); AddInternal(bundledDownloader); diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenImportFromStable.cs b/osu.Game/Overlays/FirstRunSetup/ScreenImportFromStable.cs index b19a9c6c99..5eb38b6e11 100644 --- a/osu.Game/Overlays/FirstRunSetup/ScreenImportFromStable.cs +++ b/osu.Game/Overlays/FirstRunSetup/ScreenImportFromStable.cs @@ -128,6 +128,7 @@ namespace osu.Game.Overlays.FirstRunSetup if (available) { copyInformation.Text = FirstRunOverlayImportFromStableScreenStrings.DataMigrationNoExtraSpace; + copyInformation.AddText(@" "); // just to ensure correct spacing copyInformation.AddLink(FirstRunOverlayImportFromStableScreenStrings.LearnAboutHardLinks, LinkAction.OpenWiki, @"Client/Release_stream/Lazer/File_storage#via-hard-links"); } else if (!RuntimeInfo.IsDesktop) @@ -313,6 +314,7 @@ namespace osu.Game.Overlays.FirstRunSetup private partial class DirectoryChooserPopover : OsuPopover { public DirectoryChooserPopover(Bindable currentDirectory) + : base(false) { Child = new Container { @@ -324,6 +326,13 @@ namespace osu.Game.Overlays.FirstRunSetup }, }; } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Body.BorderColour = colourProvider.Highlight1; + Body.BorderThickness = 2; + } } } } diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs b/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs index 02f0ad9506..d0eefa55c5 100644 --- a/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs +++ b/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs @@ -23,6 +23,7 @@ using osu.Game.Overlays.Settings; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Screens; +using osu.Game.Screens.Footer; using osu.Game.Screens.Menu; using osu.Game.Screens.Select; using osu.Game.Tests.Visual; @@ -153,6 +154,7 @@ namespace osu.Game.Overlays.FirstRunSetup OsuScreenStack stack; OsuLogo logo; + ScreenFooter footer; Padding = new MarginPadding(5); @@ -166,7 +168,8 @@ namespace osu.Game.Overlays.FirstRunSetup { RelativePositionAxes = Axes.Both, Position = new Vector2(0.5f), - }) + }), + (typeof(ScreenFooter), footer = new ScreenFooter()), }, RelativeSizeAxes = Axes.Both, Children = new Drawable[] @@ -178,7 +181,8 @@ namespace osu.Game.Overlays.FirstRunSetup Children = new Drawable[] { stack = new OsuScreenStack(), - logo + footer, + logo, }, }, } diff --git a/osu.Game/Overlays/FirstRunSetupOverlay.cs b/osu.Game/Overlays/FirstRunSetupOverlay.cs index f2fdaefbb4..1a302cf51d 100644 --- a/osu.Game/Overlays/FirstRunSetupOverlay.cs +++ b/osu.Game/Overlays/FirstRunSetupOverlay.cs @@ -26,6 +26,7 @@ using osu.Game.Overlays.FirstRunSetup; using osu.Game.Overlays.Mods; using osu.Game.Overlays.Notifications; using osu.Game.Screens; +using osu.Game.Screens.Footer; using osu.Game.Screens.Menu; namespace osu.Game.Overlays @@ -44,8 +45,7 @@ namespace osu.Game.Overlays private ScreenStack? stack; - public ShearedButton NextButton = null!; - public ShearedButton BackButton = null!; + public ShearedButton? NextButton => DisplayedFooterContent?.NextButton; private readonly Bindable showFirstRunSetup = new Bindable(); @@ -90,7 +90,7 @@ namespace osu.Game.Overlays Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Bottom = 20, }, + Padding = new MarginPadding { Bottom = 20 }, Child = new GridContainer { Anchor = Anchor.Centre, @@ -134,51 +134,6 @@ namespace osu.Game.Overlays } }, }); - - FooterContent.Add(new GridContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Margin = new MarginPadding { Vertical = PADDING }, - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - ColumnDimensions = new[] - { - new Dimension(GridSizeMode.Absolute, 10), - new Dimension(GridSizeMode.AutoSize), - new Dimension(), - new Dimension(GridSizeMode.Absolute, 10), - }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - }, - Content = new[] - { - new[] - { - Empty(), - BackButton = new ShearedButton(300) - { - Text = CommonStrings.Back, - Action = showPreviousStep, - Enabled = { Value = false }, - DarkerColour = colours.Pink2, - LighterColour = colours.Pink1, - }, - NextButton = new ShearedButton(0) - { - RelativeSizeAxes = Axes.X, - Width = 1, - Text = FirstRunSetupOverlayStrings.GetStarted, - DarkerColour = ColourProvider.Colour2, - LighterColour = ColourProvider.Colour1, - Action = showNextStep - }, - Empty(), - }, - } - }); } protected override void LoadComplete() @@ -190,6 +145,36 @@ namespace osu.Game.Overlays if (showFirstRunSetup.Value) Show(); } + [Resolved] + private ScreenFooter footer { get; set; } = null!; + + public new FirstRunSetupFooterContent? DisplayedFooterContent => base.DisplayedFooterContent as FirstRunSetupFooterContent; + + public override VisibilityContainer CreateFooterContent() + { + var footerContent = new FirstRunSetupFooterContent + { + ShowNextStep = showNextStep, + }; + + footerContent.OnLoadComplete += _ => updateButtons(); + return footerContent; + } + + public override bool OnBackButton() + { + if (currentStepIndex == 0) + return false; + + Debug.Assert(stack != null); + + stack.CurrentScreen.Exit(); + currentStepIndex--; + + updateButtons(); + return true; + } + public override bool OnPressed(KeyBindingPressEvent e) { if (!e.Repeat) @@ -197,19 +182,12 @@ namespace osu.Game.Overlays switch (e.Action) { case GlobalAction.Select: - NextButton.TriggerClick(); + DisplayedFooterContent?.NextButton.TriggerClick(); return true; case GlobalAction.Back: - if (BackButton.Enabled.Value) - { - BackButton.TriggerClick(); - return true; - } - - // If back button is disabled, we are at the first step. - // The base call will handle dismissal of the overlay. - break; + footer.BackButton.TriggerClick(); + return false; } } @@ -279,19 +257,6 @@ namespace osu.Game.Overlays showNextStep(); } - private void showPreviousStep() - { - if (currentStepIndex == 0) - return; - - Debug.Assert(stack != null); - - stack.CurrentScreen.Exit(); - currentStepIndex--; - - updateButtons(); - } - private void showNextStep() { Debug.Assert(currentStepIndex != null); @@ -322,29 +287,61 @@ namespace osu.Game.Overlays updateButtons(); } - private void updateButtons() + private void updateButtons() => DisplayedFooterContent?.UpdateButtons(currentStepIndex, steps); + + public partial class FirstRunSetupFooterContent : VisibilityContainer { - BackButton.Enabled.Value = currentStepIndex > 0; - NextButton.Enabled.Value = currentStepIndex != null; + public ShearedButton NextButton { get; private set; } = null!; - if (currentStepIndex == null) - return; + public Action? ShowNextStep; - bool isFirstStep = currentStepIndex == 0; - bool isLastStep = currentStepIndex == steps.Count - 1; - - if (isFirstStep) + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) { - BackButton.Text = CommonStrings.Back; - NextButton.Text = FirstRunSetupOverlayStrings.GetStarted; + RelativeSizeAxes = Axes.Both; + + InternalChild = NextButton = new ShearedButton(0) + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Margin = new MarginPadding { Right = 12f }, + RelativeSizeAxes = Axes.X, + Width = 1, + Text = FirstRunSetupOverlayStrings.GetStarted, + DarkerColour = colourProvider.Colour2, + LighterColour = colourProvider.Colour1, + Action = () => ShowNextStep?.Invoke(), + }; } - else - { - BackButton.Text = LocalisableString.Interpolate($@"{CommonStrings.Back} ({steps[currentStepIndex.Value - 1].GetLocalisableDescription()})"); - NextButton.Text = isLastStep - ? CommonStrings.Finish - : LocalisableString.Interpolate($@"{CommonStrings.Next} ({steps[currentStepIndex.Value + 1].GetLocalisableDescription()})"); + public void UpdateButtons(int? currentStep, IReadOnlyList steps) + { + NextButton.Enabled.Value = currentStep != null; + + if (currentStep == null) + return; + + bool isFirstStep = currentStep == 0; + bool isLastStep = currentStep == steps.Count - 1; + + if (isFirstStep) + NextButton.Text = FirstRunSetupOverlayStrings.GetStarted; + else + { + NextButton.Text = isLastStep + ? CommonStrings.Finish + : LocalisableString.Interpolate($@"{CommonStrings.Next} ({steps[currentStep.Value + 1].GetLocalisableDescription()})"); + } + } + + protected override void PopIn() + { + this.FadeIn(); + } + + protected override void PopOut() + { + this.Delay(400).FadeOut(); } } } diff --git a/osu.Game/Overlays/FullscreenOverlay.cs b/osu.Game/Overlays/FullscreenOverlay.cs index 6ddf1eecf0..c2ecb55814 100644 --- a/osu.Game/Overlays/FullscreenOverlay.cs +++ b/osu.Game/Overlays/FullscreenOverlay.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.Diagnostics.CodeAnalysis; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -22,7 +23,7 @@ namespace osu.Game.Overlays public virtual LocalisableString Title => Header.Title.Title; public virtual LocalisableString Description => Header.Title.Description; - public T Header { get; } + public T Header { get; private set; } protected virtual Color4 BackgroundColour => ColourProvider.Background5; @@ -34,11 +35,12 @@ namespace osu.Game.Overlays protected override Container Content => content; + private readonly Box background; private readonly Container content; protected FullscreenOverlay(OverlayColourScheme colourScheme) { - Header = CreateHeader(); + RecreateHeader(); ColourProvider = new OverlayColourProvider(colourScheme); @@ -60,10 +62,9 @@ namespace osu.Game.Overlays base.Content.AddRange(new Drawable[] { - new Box + background = new Box { RelativeSizeAxes = Axes.Both, - Colour = BackgroundColour }, content = new Container { @@ -75,14 +76,17 @@ namespace osu.Game.Overlays [BackgroundDependencyLoader] private void load() { - Waves.FirstWaveColour = ColourProvider.Light4; - Waves.SecondWaveColour = ColourProvider.Light3; - Waves.ThirdWaveColour = ColourProvider.Dark4; - Waves.FourthWaveColour = ColourProvider.Dark3; + UpdateColours(); } protected abstract T CreateHeader(); + [MemberNotNull(nameof(Header))] + protected void RecreateHeader() + { + Header = CreateHeader(); + } + public override void Show() { if (State.Value == Visibility.Visible) @@ -96,6 +100,18 @@ namespace osu.Game.Overlays } } + /// + /// Updates the colours of the background and the top waves with the latest colour shades provided by . + /// + protected void UpdateColours() + { + Waves.FirstWaveColour = ColourProvider.Light4; + Waves.SecondWaveColour = ColourProvider.Light3; + Waves.ThirdWaveColour = ColourProvider.Dark4; + Waves.FourthWaveColour = ColourProvider.Dark3; + background.Colour = BackgroundColour; + } + protected override void PopIn() { base.PopIn(); diff --git a/osu.Game/Overlays/Login/LoginForm.cs b/osu.Game/Overlays/Login/LoginForm.cs index 80dfca93d2..13e528ff8f 100644 --- a/osu.Game/Overlays/Login/LoginForm.cs +++ b/osu.Game/Overlays/Login/LoginForm.cs @@ -150,7 +150,7 @@ namespace osu.Game.Overlays.Login protected override void OnFocus(FocusEvent e) { - Schedule(() => { GetContainingInputManager().ChangeFocus(string.IsNullOrEmpty(username.Text) ? username : password); }); + Schedule(() => { GetContainingFocusManager()!.ChangeFocus(string.IsNullOrEmpty(username.Text) ? username : password); }); } } } diff --git a/osu.Game/Overlays/Login/LoginPanel.cs b/osu.Game/Overlays/Login/LoginPanel.cs index a8adf4ce8c..84bd0c36b9 100644 --- a/osu.Game/Overlays/Login/LoginPanel.cs +++ b/osu.Game/Overlays/Login/LoginPanel.cs @@ -157,6 +157,7 @@ namespace osu.Game.Overlays.Login }, }; + updateDropdownCurrent(status.Value); dropdown.Current.BindValueChanged(action => { switch (action.NewValue) @@ -186,7 +187,7 @@ namespace osu.Game.Overlays.Login } if (form != null) - ScheduleAfterChildren(() => GetContainingInputManager()?.ChangeFocus(form)); + ScheduleAfterChildren(() => GetContainingFocusManager()?.ChangeFocus(form)); }); private void updateDropdownCurrent(UserStatus? status) @@ -216,7 +217,7 @@ namespace osu.Game.Overlays.Login protected override void OnFocus(FocusEvent e) { - if (form != null) GetContainingInputManager().ChangeFocus(form); + if (form != null) GetContainingFocusManager()!.ChangeFocus(form); base.OnFocus(e); } } diff --git a/osu.Game/Overlays/Login/SecondFactorAuthForm.cs b/osu.Game/Overlays/Login/SecondFactorAuthForm.cs index dcd3119f33..77835b1f09 100644 --- a/osu.Game/Overlays/Login/SecondFactorAuthForm.cs +++ b/osu.Game/Overlays/Login/SecondFactorAuthForm.cs @@ -141,7 +141,7 @@ namespace osu.Game.Overlays.Login protected override void OnFocus(FocusEvent e) { - Schedule(() => { GetContainingInputManager().ChangeFocus(codeTextBox); }); + Schedule(() => { GetContainingFocusManager()!.ChangeFocus(codeTextBox); }); } } } diff --git a/osu.Game/Overlays/LoginOverlay.cs b/osu.Game/Overlays/LoginOverlay.cs index c0aff6aae9..d570983f98 100644 --- a/osu.Game/Overlays/LoginOverlay.cs +++ b/osu.Game/Overlays/LoginOverlay.cs @@ -78,7 +78,7 @@ namespace osu.Game.Overlays this.FadeIn(transition_time, Easing.OutQuint); FadeEdgeEffectTo(WaveContainer.SHADOW_OPACITY, WaveContainer.APPEAR_DURATION, Easing.Out); - ScheduleAfterChildren(() => GetContainingInputManager().ChangeFocus(panel)); + ScheduleAfterChildren(() => GetContainingFocusManager()!.ChangeFocus(panel)); } protected override void PopOut() diff --git a/osu.Game/Overlays/MedalAnimation.cs b/osu.Game/Overlays/MedalAnimation.cs index 25776d50db..daceeedf47 100644 --- a/osu.Game/Overlays/MedalAnimation.cs +++ b/osu.Game/Overlays/MedalAnimation.cs @@ -30,7 +30,8 @@ namespace osu.Game.Overlays private const float border_width = 5; - private readonly Medal medal; + public readonly Medal Medal; + private readonly Box background; private readonly Container backgroundStrip, particleContainer; private readonly BackgroundStrip leftStrip, rightStrip; @@ -44,7 +45,7 @@ namespace osu.Game.Overlays public MedalAnimation(Medal medal) { - this.medal = medal; + Medal = medal; RelativeSizeAxes = Axes.Both; Child = content = new Container @@ -168,7 +169,7 @@ namespace osu.Game.Overlays { base.LoadComplete(); - LoadComponentAsync(drawableMedal = new DrawableMedal(medal) + LoadComponentAsync(drawableMedal = new DrawableMedal(Medal) { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, diff --git a/osu.Game/Overlays/MedalOverlay.cs b/osu.Game/Overlays/MedalOverlay.cs index 072d7db6c7..19f61cb910 100644 --- a/osu.Game/Overlays/MedalOverlay.cs +++ b/osu.Game/Overlays/MedalOverlay.cs @@ -8,6 +8,7 @@ using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; +using osu.Framework.Logging; using osu.Game.Graphics.Containers; using osu.Game.Input.Bindings; using osu.Game.Online.API; @@ -81,7 +82,10 @@ namespace osu.Game.Overlays }; var medalAnimation = new MedalAnimation(medal); + queuedMedals.Enqueue(medalAnimation); + Logger.Log($"Queueing medal unlock for \"{medal.Name}\" ({queuedMedals.Count} to display)"); + if (OverlayActivationMode.Value == OverlayActivation.All) Scheduler.AddOnce(Show); } @@ -95,10 +99,12 @@ namespace osu.Game.Overlays if (!queuedMedals.TryDequeue(out lastAnimation)) { + Logger.Log("All queued medals have been displayed!"); Hide(); return; } + Logger.Log($"Preparing to display \"{lastAnimation.Medal.Name}\""); LoadComponentAsync(lastAnimation, medalContainer.Add); } diff --git a/osu.Game/Overlays/Mods/AddPresetPopover.cs b/osu.Game/Overlays/Mods/AddPresetPopover.cs index b782b5d6ba..7df7d6339c 100644 --- a/osu.Game/Overlays/Mods/AddPresetPopover.cs +++ b/osu.Game/Overlays/Mods/AddPresetPopover.cs @@ -89,7 +89,7 @@ namespace osu.Game.Overlays.Mods { base.LoadComplete(); - ScheduleAfterChildren(() => GetContainingInputManager().ChangeFocus(nameTextBox)); + ScheduleAfterChildren(() => GetContainingFocusManager()!.ChangeFocus(nameTextBox)); nameTextBox.Current.BindValueChanged(s => { diff --git a/osu.Game/Overlays/Mods/BeatmapAttributesDisplay.cs b/osu.Game/Overlays/Mods/BeatmapAttributesDisplay.cs index c58cf710bd..2670c20d26 100644 --- a/osu.Game/Overlays/Mods/BeatmapAttributesDisplay.cs +++ b/osu.Game/Overlays/Mods/BeatmapAttributesDisplay.cs @@ -66,7 +66,7 @@ namespace osu.Game.Overlays.Mods [BackgroundDependencyLoader] private void load() { - const float shear = ShearedOverlayContainer.SHEAR; + const float shear = OsuGame.SHEAR; LeftContent.AddRange(new Drawable[] { @@ -108,8 +108,6 @@ namespace osu.Game.Overlays.Mods updateValues(); }, true); - BeatmapInfo.BindValueChanged(_ => updateValues()); - Collapsed.BindValueChanged(_ => { // Only start autosize animations on first collapse toggle. This avoids an ugly initial presentation. @@ -120,12 +118,32 @@ namespace osu.Game.Overlays.Mods GameRuleset = game.Ruleset.GetBoundCopy(); GameRuleset.BindValueChanged(_ => updateValues()); - BeatmapInfo.BindValueChanged(_ => updateValues()); + BeatmapInfo.BindValueChanged(_ => + { + updateStarDifficultyBindable(); + updateValues(); + }, true); - updateValues(); updateCollapsedState(); } + private void updateStarDifficultyBindable() + { + cancellationSource?.Cancel(); + + if (BeatmapInfo.Value == null) + return; + + starDifficulty = difficultyCache.GetBindableDifficulty(BeatmapInfo.Value, (cancellationSource = new CancellationTokenSource()).Token); + starDifficulty.BindValueChanged(s => + { + starRatingDisplay.Current.Value = s.NewValue ?? default; + + if (!starRatingDisplay.IsPresent) + starRatingDisplay.FinishTransforms(true); + }); + } + protected override bool OnHover(HoverEvent e) { startAnimating(); @@ -154,20 +172,7 @@ namespace osu.Game.Overlays.Mods if (BeatmapInfo.Value == null) return; - cancellationSource?.Cancel(); - - starDifficulty = difficultyCache.GetBindableDifficulty(BeatmapInfo.Value, (cancellationSource = new CancellationTokenSource()).Token); - starDifficulty.BindValueChanged(s => - { - starRatingDisplay.Current.Value = s.NewValue ?? default; - - if (!starRatingDisplay.IsPresent) - starRatingDisplay.FinishTransforms(true); - }); - - double rate = 1; - foreach (var mod in Mods.Value.OfType()) - rate = mod.ApplyToRate(0, rate); + double rate = ModUtils.CalculateRateWithMods(Mods.Value); bpmDisplay.Current.Value = FormatUtils.RoundBPM(BeatmapInfo.Value.BPM, rate); diff --git a/osu.Game/Overlays/Mods/DeleteModPresetDialog.cs b/osu.Game/Overlays/Mods/DeleteModPresetDialog.cs index 9788764453..5651ecb34c 100644 --- a/osu.Game/Overlays/Mods/DeleteModPresetDialog.cs +++ b/osu.Game/Overlays/Mods/DeleteModPresetDialog.cs @@ -7,7 +7,7 @@ using osu.Game.Rulesets.Mods; namespace osu.Game.Overlays.Mods { - public partial class DeleteModPresetDialog : DangerousActionDialog + public partial class DeleteModPresetDialog : DeletionDialog { public DeleteModPresetDialog(Live modPreset) { diff --git a/osu.Game/Overlays/Mods/EditPresetPopover.cs b/osu.Game/Overlays/Mods/EditPresetPopover.cs index 9554ba8ce2..526ab6fc63 100644 --- a/osu.Game/Overlays/Mods/EditPresetPopover.cs +++ b/osu.Game/Overlays/Mods/EditPresetPopover.cs @@ -136,7 +136,7 @@ namespace osu.Game.Overlays.Mods { base.LoadComplete(); - ScheduleAfterChildren(() => GetContainingInputManager().ChangeFocus(nameTextBox)); + ScheduleAfterChildren(() => GetContainingFocusManager()!.ChangeFocus(nameTextBox)); } public override bool OnPressed(KeyBindingPressEvent e) diff --git a/osu.Game/Overlays/Mods/IncompatibilityDisplayingModPanel.cs b/osu.Game/Overlays/Mods/IncompatibilityDisplayingModPanel.cs index 26c5b2ac49..84336319b7 100644 --- a/osu.Game/Overlays/Mods/IncompatibilityDisplayingModPanel.cs +++ b/osu.Game/Overlays/Mods/IncompatibilityDisplayingModPanel.cs @@ -16,6 +16,9 @@ namespace osu.Game.Overlays.Mods { private readonly BindableBool incompatible = new BindableBool(); + [Resolved] + private OverlayColourProvider overlayColourProvider { get; set; } = null!; + [Resolved] private Bindable> selectedMods { get; set; } = null!; @@ -55,7 +58,7 @@ namespace osu.Game.Overlays.Mods #region IHasCustomTooltip - public ITooltip GetCustomTooltip() => new IncompatibilityDisplayingTooltip(); + public ITooltip GetCustomTooltip() => new IncompatibilityDisplayingTooltip(overlayColourProvider); public Mod TooltipContent => Mod; diff --git a/osu.Game/Overlays/Mods/IncompatibilityDisplayingTooltip.cs b/osu.Game/Overlays/Mods/IncompatibilityDisplayingTooltip.cs index 2f82711162..3ac541eaa3 100644 --- a/osu.Game/Overlays/Mods/IncompatibilityDisplayingTooltip.cs +++ b/osu.Game/Overlays/Mods/IncompatibilityDisplayingTooltip.cs @@ -24,13 +24,15 @@ namespace osu.Game.Overlays.Mods [Resolved] private Bindable ruleset { get; set; } = null!; - public IncompatibilityDisplayingTooltip() + public IncompatibilityDisplayingTooltip(OverlayColourProvider colourProvider) + : base(colourProvider) { AddRange(new Drawable[] { incompatibleText = new OsuSpriteText { Margin = new MarginPadding { Top = 5 }, + Colour = colourProvider.Content2, Font = OsuFont.GetFont(weight: FontWeight.Regular), Text = "Incompatible with:" }, @@ -43,12 +45,6 @@ namespace osu.Game.Overlays.Mods }); } - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - incompatibleText.Colour = colours.BlueLight; - } - protected override void UpdateDisplay(Mod mod) { base.UpdateDisplay(mod); diff --git a/osu.Game/Overlays/Mods/ModButtonTooltip.cs b/osu.Game/Overlays/Mods/ModButtonTooltip.cs index 52b27f1e00..061c3e3e3a 100644 --- a/osu.Game/Overlays/Mods/ModButtonTooltip.cs +++ b/osu.Game/Overlays/Mods/ModButtonTooltip.cs @@ -1,9 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - -using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; @@ -18,11 +15,10 @@ namespace osu.Game.Overlays.Mods public partial class ModButtonTooltip : VisibilityContainer, ITooltip { private readonly OsuSpriteText descriptionText; - private readonly Box background; protected override Container Content { get; } - public ModButtonTooltip() + public ModButtonTooltip(OverlayColourProvider colourProvider) { AutoSizeAxes = Axes.Both; Masking = true; @@ -30,9 +26,10 @@ namespace osu.Game.Overlays.Mods InternalChildren = new Drawable[] { - background = new Box + new Box { - RelativeSizeAxes = Axes.Both + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background6, }, Content = new FillFlowContainer { @@ -43,6 +40,7 @@ namespace osu.Game.Overlays.Mods { descriptionText = new OsuSpriteText { + Colour = colourProvider.Content1, Font = OsuFont.GetFont(weight: FontWeight.Regular), }, } @@ -50,17 +48,10 @@ namespace osu.Game.Overlays.Mods }; } - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - background.Colour = colours.Gray3; - descriptionText.Colour = colours.BlueLighter; - } - protected override void PopIn() => this.FadeIn(200, Easing.OutQuint); protected override void PopOut() => this.FadeOut(200, Easing.OutQuint); - private Mod lastMod; + private Mod? lastMod; public void SetContent(Mod mod) { diff --git a/osu.Game/Overlays/Mods/ModColumn.cs b/osu.Game/Overlays/Mods/ModColumn.cs index e9f21338bd..326394a207 100644 --- a/osu.Game/Overlays/Mods/ModColumn.cs +++ b/osu.Game/Overlays/Mods/ModColumn.cs @@ -106,7 +106,7 @@ namespace osu.Game.Overlays.Mods Origin = Anchor.CentreLeft, Scale = new Vector2(0.8f), RelativeSizeAxes = Axes.X, - Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0) + Shear = new Vector2(-OsuGame.SHEAR, 0) }); ItemsFlow.Padding = new MarginPadding { diff --git a/osu.Game/Overlays/Mods/ModCustomisationHeader.cs b/osu.Game/Overlays/Mods/ModCustomisationHeader.cs new file mode 100644 index 0000000000..54fbd37dbe --- /dev/null +++ b/osu.Game/Overlays/Mods/ModCustomisationHeader.cs @@ -0,0 +1,147 @@ +// 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.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Localisation; +using osuTK; +using osuTK.Graphics; +using static osu.Game.Overlays.Mods.ModCustomisationPanel; + +namespace osu.Game.Overlays.Mods +{ + public partial class ModCustomisationHeader : OsuClickableContainer + { + private Box background = null!; + private Box hoverBackground = null!; + private Box backgroundFlash = null!; + private SpriteIcon icon = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public readonly Bindable ExpandedState = new Bindable(); + + private readonly ModCustomisationPanel panel; + + public ModCustomisationHeader(ModCustomisationPanel panel) + { + this.panel = panel; + Enabled.Value = false; + } + + [BackgroundDependencyLoader] + private void load() + { + CornerRadius = 10f; + Masking = true; + + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + }, + hoverBackground = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = OsuColour.Gray(50), + Blending = BlendingParameters.Additive, + Alpha = 0, + }, + backgroundFlash = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.White.Opacity(0.4f), + Blending = BlendingParameters.Additive, + Alpha = 0, + }, + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Text = ModSelectOverlayStrings.CustomisationPanelHeader, + UseFullGlyphHeight = false, + Font = OsuFont.Torus.With(size: 20f, weight: FontWeight.SemiBold), + Margin = new MarginPadding { Left = 20f }, + }, + new Container + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Size = new Vector2(16f), + Margin = new MarginPadding { Right = 20f }, + Child = icon = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Icon = FontAwesome.Solid.ChevronDown, + RelativeSizeAxes = Axes.Both, + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Enabled.BindValueChanged(e => + { + TooltipText = e.NewValue + ? string.Empty + : ModSelectOverlayStrings.CustomisationPanelDisabledReason; + + if (e.NewValue) + { + backgroundFlash.FadeInFromZero(150, Easing.OutQuad).Then() + .FadeOutFromOne(350, Easing.OutQuad); + } + }, true); + + ExpandedState.BindValueChanged(v => + { + icon.ScaleTo(v.NewValue > ModCustomisationPanelState.Collapsed ? new Vector2(1, -1) : Vector2.One, 300, Easing.OutQuint); + + switch (v.NewValue) + { + case ModCustomisationPanelState.Collapsed: + background.FadeColour(colourProvider.Dark3, 500, Easing.OutQuint); + break; + + case ModCustomisationPanelState.Expanded: + case ModCustomisationPanelState.ExpandedByMod: + background.FadeColour(colourProvider.Light4, 500, Easing.OutQuint); + break; + } + }, true); + } + + protected override bool OnHover(HoverEvent e) + { + if (!Enabled.Value) + return base.OnHover(e); + + if (panel.ExpandedState.Value == ModCustomisationPanelState.Collapsed) + panel.ExpandedState.Value = ModCustomisationPanelState.Expanded; + + hoverBackground.FadeTo(0.4f, 200, Easing.OutQuint); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + hoverBackground.FadeOut(200, Easing.OutQuint); + base.OnHoverLost(e); + } + } +} diff --git a/osu.Game/Overlays/Mods/ModCustomisationPanel.cs b/osu.Game/Overlays/Mods/ModCustomisationPanel.cs new file mode 100644 index 0000000000..03a1b3d0dd --- /dev/null +++ b/osu.Game/Overlays/Mods/ModCustomisationPanel.cs @@ -0,0 +1,246 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Input.Bindings; +using osu.Game.Rulesets.Mods; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays.Mods +{ + public partial class ModCustomisationPanel : OverlayContainer, IKeyBindingHandler + { + private const float header_height = 42f; + private const float content_vertical_padding = 20f; + private const float content_border_thickness = 2f; + + private Container content = null!; + private OsuScrollContainer scrollContainer = null!; + private FillFlowContainer sectionsFlow = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public readonly BindableBool Enabled = new BindableBool(); + + public readonly Bindable ExpandedState = new Bindable(); + + public Bindable> SelectedMods { get; } = new Bindable>(Array.Empty()); + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; + + // Handle{Non}PositionalInput controls whether the panel should act as a blocking layer on the screen. only block when the panel is expanded. + // These properties are used because they correctly handle blocking/unblocking hover when mouse is pointing at a drawable outside + // (handling OnHover or overriding Block{Non}PositionalInput doesn't work). + public override bool HandlePositionalInput => ExpandedState.Value != ModCustomisationPanelState.Collapsed; + public override bool HandleNonPositionalInput => ExpandedState.Value != ModCustomisationPanelState.Collapsed; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Y; + + InternalChildren = new Drawable[] + { + new ModCustomisationHeader(this) + { + Depth = float.MinValue, + RelativeSizeAxes = Axes.X, + Height = header_height, + Enabled = { BindTarget = Enabled }, + ExpandedState = { BindTarget = ExpandedState }, + }, + content = new FocusGrabbingContainer(this) + { + RelativeSizeAxes = Axes.X, + BorderColour = colourProvider.Dark3, + BorderThickness = content_border_thickness, + CornerRadius = 10f, + Masking = true, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Offset = new Vector2(0f, 5f), + Radius = 20f, + Roundness = 5f, + Colour = Color4.Black.Opacity(0.25f), + }, + ExpandedState = { BindTarget = ExpandedState }, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Dark4, + }, + scrollContainer = new OsuScrollContainer(Direction.Vertical) + { + RelativeSizeAxes = Axes.X, + Margin = new MarginPadding + { + Top = header_height + content_border_thickness, + Bottom = content_border_thickness + }, + Child = sectionsFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(0f, 40f), + Margin = new MarginPadding + { + Top = content_vertical_padding, + Bottom = 5f + content_vertical_padding + }, + } + } + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Enabled.BindValueChanged(e => + { + this.FadeColour(OsuColour.Gray(e.NewValue ? 1f : 0.6f), 300, Easing.OutQuint); + }, true); + + ExpandedState.BindValueChanged(_ => updateDisplay(), true); + SelectedMods.BindValueChanged(_ => updateMods(), true); + + FinishTransforms(true); + } + + protected override void PopIn() => this.FadeIn(300, Easing.OutQuint); + + protected override void PopOut() => this.FadeOut(300, Easing.OutQuint); + + protected override bool OnClick(ClickEvent e) + { + ExpandedState.Value = ModCustomisationPanelState.Collapsed; + return base.OnClick(e); + } + + protected override bool OnKeyDown(KeyDownEvent e) => true; + + protected override bool OnScroll(ScrollEvent e) => true; + + public bool OnPressed(KeyBindingPressEvent e) + { + switch (e.Action) + { + case GlobalAction.Back: + ExpandedState.Value = ModCustomisationPanelState.Collapsed; + return true; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + + private void updateDisplay() + { + content.ClearTransforms(); + + if (ExpandedState.Value != ModCustomisationPanelState.Collapsed) + { + content.AutoSizeDuration = 400; + content.AutoSizeEasing = Easing.OutQuint; + content.AutoSizeAxes = Axes.Y; + content.FadeIn(120, Easing.OutQuint); + } + else + { + content.AutoSizeAxes = Axes.None; + content.ResizeHeightTo(header_height, 400, Easing.OutQuint); + content.FadeOut(400, Easing.OutSine); + } + } + + private void updateMods() + { + ExpandedState.Value = ModCustomisationPanelState.Collapsed; + sectionsFlow.Clear(); + + // Importantly, the selected mods bindable is already ordered by the mod select overlay (following the order of mod columns and panels). + // Using AsOrdered produces a slightly different order (e.g. DT and NC no longer becoming adjacent), + // which breaks user expectations when interacting with the overlay. + foreach (var mod in SelectedMods.Value) + { + var settings = mod.CreateSettingsControls().ToList(); + + if (settings.Count > 0) + sectionsFlow.Add(new ModCustomisationSection(mod, settings)); + } + } + + protected override void Update() + { + base.Update(); + scrollContainer.Height = Math.Min(scrollContainer.AvailableContent, DrawHeight - header_height); + } + + private partial class FocusGrabbingContainer : InputBlockingContainer + { + public readonly Bindable ExpandedState = new Bindable(); + + public override bool RequestsFocus => panel.ExpandedState.Value != ModCustomisationPanelState.Collapsed; + public override bool AcceptsFocus => panel.ExpandedState.Value != ModCustomisationPanelState.Collapsed; + + private readonly ModCustomisationPanel panel; + + public FocusGrabbingContainer(ModCustomisationPanel panel) + { + this.panel = panel; + } + + private InputManager inputManager = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + inputManager = GetContainingInputManager()!; + } + + protected override void Update() + { + base.Update(); + + if (ExpandedState.Value == ModCustomisationPanelState.Expanded + && !ReceivePositionalInputAt(inputManager.CurrentState.Mouse.Position) + && inputManager.DraggedDrawable == null) + { + ExpandedState.Value = ModCustomisationPanelState.Collapsed; + } + } + } + + public enum ModCustomisationPanelState + { + Collapsed = 0, + Expanded = 1, + ExpandedByMod = 2, + } + } +} diff --git a/osu.Game/Overlays/Mods/ModCustomisationSection.cs b/osu.Game/Overlays/Mods/ModCustomisationSection.cs new file mode 100644 index 0000000000..1dc97a8b0b --- /dev/null +++ b/osu.Game/Overlays/Mods/ModCustomisationSection.cs @@ -0,0 +1,82 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; +using osuTK; + +namespace osu.Game.Overlays.Mods +{ + public partial class ModCustomisationSection : CompositeDrawable + { + public readonly Mod Mod; + + private readonly IReadOnlyList settings; + + public ModCustomisationSection(Mod mod, IReadOnlyList settings) + { + Mod = mod; + + this.settings = settings; + } + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + FillFlowContainer flow; + + InternalChild = flow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 8f), + Padding = new MarginPadding { Left = 7f }, + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Left = 20f, Right = 27f }, + Margin = new MarginPadding { Bottom = 4f }, + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Text = Mod.Name, + Font = OsuFont.TorusAlternate.With(size: 20, weight: FontWeight.SemiBold), + }, + new ModSwitchTiny(Mod) + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Active = { Value = true }, + Scale = new Vector2(0.5f), + } + } + }, + } + }; + + flow.AddRange(settings); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + FinishTransforms(true); + } + } +} diff --git a/osu.Game/Overlays/Mods/ModFooterInformationDisplay.cs b/osu.Game/Overlays/Mods/ModFooterInformationDisplay.cs index 7fccf0cc13..6665a3b8dc 100644 --- a/osu.Game/Overlays/Mods/ModFooterInformationDisplay.cs +++ b/osu.Game/Overlays/Mods/ModFooterInformationDisplay.cs @@ -36,8 +36,8 @@ namespace osu.Game.Overlays.Mods Origin = Anchor.BottomRight, Anchor = Anchor.BottomRight, AutoSizeAxes = Axes.X, - Height = ShearedButton.HEIGHT, - Shear = new Vector2(ShearedOverlayContainer.SHEAR, 0), + Height = ShearedButton.DEFAULT_HEIGHT, + Shear = new Vector2(OsuGame.SHEAR, 0), CornerRadius = ShearedButton.CORNER_RADIUS, BorderThickness = ShearedButton.BORDER_THICKNESS, Masking = true, diff --git a/osu.Game/Overlays/Mods/ModPanel.cs b/osu.Game/Overlays/Mods/ModPanel.cs index cf173b0d6a..b85904f22b 100644 --- a/osu.Game/Overlays/Mods/ModPanel.cs +++ b/osu.Game/Overlays/Mods/ModPanel.cs @@ -6,6 +6,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Rulesets.Mods; @@ -36,7 +37,7 @@ namespace osu.Game.Overlays.Mods Anchor = Anchor.Centre, Origin = Anchor.Centre, Active = { BindTarget = Active }, - Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0), + Shear = new Vector2(-OsuGame.SHEAR, 0), Scale = new Vector2(HEIGHT / ModSwitchSmall.DEFAULT_SIZE) }; } @@ -58,6 +59,21 @@ namespace osu.Game.Overlays.Mods modState.ValidForSelection.BindValueChanged(_ => updateFilterState()); modState.MatchingTextFilter.BindValueChanged(_ => updateFilterState(), true); + modState.Preselected.BindValueChanged(b => + { + if (b.NewValue) + { + Content.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = AccentColour, + Hollow = true, + Radius = 2, + }; + } + else + Content.EdgeEffect = default; + }, true); } protected override void Select() diff --git a/osu.Game/Overlays/Mods/ModPresetPanel.cs b/osu.Game/Overlays/Mods/ModPresetPanel.cs index 3982abeba7..568ca5ecc9 100644 --- a/osu.Game/Overlays/Mods/ModPresetPanel.cs +++ b/osu.Game/Overlays/Mods/ModPresetPanel.cs @@ -55,7 +55,12 @@ namespace osu.Game.Overlays.Mods protected override void Select() { - var selectedSystemMods = selectedMods.Value.Where(mod => mod.Type == ModType.System); + // this implicitly presumes that if a system mod declares incompatibility with a non-system mod, + // the non-system mod should take precedence. + // if this assumption is ever broken, this should be reconsidered. + var selectedSystemMods = selectedMods.Value.Where(mod => mod.Type == ModType.System && + !mod.IncompatibleMods.Any(t => Preset.Value.Mods.Any(t.IsInstanceOfType))); + // will also have the side effect of activating the preset (see `updateActiveState()`). selectedMods.Value = Preset.Value.Mods.Concat(selectedSystemMods).ToArray(); } diff --git a/osu.Game/Overlays/Mods/ModPresetTooltip.cs b/osu.Game/Overlays/Mods/ModPresetTooltip.cs index 077bd14751..6ffcfca1e0 100644 --- a/osu.Game/Overlays/Mods/ModPresetTooltip.cs +++ b/osu.Game/Overlays/Mods/ModPresetTooltip.cs @@ -6,6 +6,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; using osu.Game.Rulesets.Mods; using osuTK; @@ -17,6 +18,8 @@ namespace osu.Game.Overlays.Mods private const double transition_duration = 200; + private readonly TextFlowContainer descriptionText; + public ModPresetTooltip(OverlayColourProvider colourProvider) { Width = 250; @@ -36,8 +39,20 @@ namespace osu.Game.Overlays.Mods { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Padding = new MarginPadding(7), - Spacing = new Vector2(7) + Padding = new MarginPadding { Left = 10, Right = 10, Top = 5, Bottom = 5 }, + Spacing = new Vector2(7), + Children = new[] + { + descriptionText = new TextFlowContainer(f => + { + f.Font = OsuFont.GetFont(weight: FontWeight.Regular); + f.Colour = colourProvider.Content1; + }) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + } + } } }; } @@ -49,8 +64,12 @@ namespace osu.Game.Overlays.Mods if (ReferenceEquals(preset, lastPreset)) return; + descriptionText.Text = preset.Description; + lastPreset = preset; - Content.ChildrenEnumerable = preset.Mods.AsOrdered().Select(mod => new ModPresetRow(mod)); + + Content.RemoveAll(d => d is ModPresetRow, true); + Content.AddRange(preset.Mods.AsOrdered().Select(mod => new ModPresetRow(mod))); } protected override void PopIn() => this.FadeIn(transition_duration, Easing.OutQuint); diff --git a/osu.Game/Overlays/Mods/ModSelectColumn.cs b/osu.Game/Overlays/Mods/ModSelectColumn.cs index 61b29ef65b..8a499a391c 100644 --- a/osu.Game/Overlays/Mods/ModSelectColumn.cs +++ b/osu.Game/Overlays/Mods/ModSelectColumn.cs @@ -70,7 +70,7 @@ namespace osu.Game.Overlays.Mods { Width = WIDTH; RelativeSizeAxes = Axes.Y; - Shear = new Vector2(ShearedOverlayContainer.SHEAR, 0); + Shear = new Vector2(OsuGame.SHEAR, 0); InternalChildren = new Drawable[] { @@ -96,7 +96,7 @@ namespace osu.Game.Overlays.Mods { RelativeSizeAxes = Axes.X, Height = header_height, - Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0), + Shear = new Vector2(-OsuGame.SHEAR, 0), Velocity = 0.7f, ClampAxes = Axes.Y }, @@ -111,7 +111,7 @@ namespace osu.Game.Overlays.Mods AutoSizeAxes = Axes.Y, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0), + Shear = new Vector2(-OsuGame.SHEAR, 0), Padding = new MarginPadding { Horizontal = 17, @@ -138,6 +138,7 @@ namespace osu.Game.Overlays.Mods }, new GridContainer { + Padding = new MarginPadding { Top = 1, Bottom = 3 }, RelativeSizeAxes = Axes.Both, RowDimensions = new[] { diff --git a/osu.Game/Overlays/Mods/ModSelectFooterContent.cs b/osu.Game/Overlays/Mods/ModSelectFooterContent.cs new file mode 100644 index 0000000000..146b8e4ebe --- /dev/null +++ b/osu.Game/Overlays/Mods/ModSelectFooterContent.cs @@ -0,0 +1,177 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Mods; +using osuTK; + +namespace osu.Game.Overlays.Mods +{ + public partial class ModSelectFooterContent : VisibilityContainer + { + private readonly ModSelectOverlay overlay; + + private RankingInformationDisplay? rankingInformationDisplay; + private BeatmapAttributesDisplay? beatmapAttributesDisplay; + private FillFlowContainer buttonFlow = null!; + private FillFlowContainer contentFlow = null!; + + public DeselectAllModsButton? DeselectAllModsButton { get; set; } + + public readonly IBindable Beatmap = new Bindable(); + public readonly IBindable> ActiveMods = new Bindable>(); + + /// + /// Whether the effects (on score multiplier, on or beatmap difficulty) of the current selected set of mods should be shown. + /// + protected virtual bool ShowModEffects => true; + + /// + /// Whether the ranking information and beatmap attributes displays are stacked vertically due to small space. + /// + public bool DisplaysStackedVertically { get; private set; } + + public ModSelectFooterContent(ModSelectOverlay overlay) + { + this.overlay = overlay; + } + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Both; + + InternalChild = buttonFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Padding = new MarginPadding { Horizontal = 20 }, + Spacing = new Vector2(10), + ChildrenEnumerable = CreateButtons(), + }; + + if (ShowModEffects) + { + AddInternal(contentFlow = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(30, 10), + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Margin = new MarginPadding { Horizontal = 20 }, + Children = new Drawable[] + { + rankingInformationDisplay = new RankingInformationDisplay + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight + }, + beatmapAttributesDisplay = new BeatmapAttributesDisplay + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + BeatmapInfo = { Value = Beatmap.Value?.BeatmapInfo }, + }, + } + }); + } + } + + private ModSettingChangeTracker? modSettingChangeTracker; + + protected override void LoadComplete() + { + base.LoadComplete(); + + Beatmap.BindValueChanged(b => + { + if (beatmapAttributesDisplay != null) + beatmapAttributesDisplay.BeatmapInfo.Value = b.NewValue?.BeatmapInfo; + }, true); + + ActiveMods.BindValueChanged(m => + { + updateInformation(); + + modSettingChangeTracker?.Dispose(); + + // Importantly, use ActiveMods.Value here (and not the ValueChanged NewValue) as the latter can + // potentially be stale, due to complexities in the way change trackers work. + // + // See https://github.com/ppy/osu/pull/23284#issuecomment-1529056988 + modSettingChangeTracker = new ModSettingChangeTracker(ActiveMods.Value); + modSettingChangeTracker.SettingChanged += _ => updateInformation(); + }, true); + } + + private void updateInformation() + { + if (rankingInformationDisplay != null) + { + double multiplier = 1.0; + + foreach (var mod in ActiveMods.Value) + multiplier *= mod.ScoreMultiplier; + + rankingInformationDisplay.ModMultiplier.Value = multiplier; + rankingInformationDisplay.Ranked.Value = ActiveMods.Value.All(m => m.Ranked); + } + + if (beatmapAttributesDisplay != null) + beatmapAttributesDisplay.Mods.Value = ActiveMods.Value; + } + + protected override void Update() + { + base.Update(); + + if (beatmapAttributesDisplay != null) + { + float rightEdgeOfLastButton = buttonFlow[^1].ScreenSpaceDrawQuad.TopRight.X; + + // this is cheating a bit; the 640 value is hardcoded based on how wide the expanded panel _generally_ is. + // due to the transition applied, the raw screenspace quad of the panel cannot be used, as it will trigger an ugly feedback cycle of expanding and collapsing. + float projectedLeftEdgeOfExpandedBeatmapAttributesDisplay = buttonFlow.ToScreenSpace(buttonFlow.DrawSize - new Vector2(640, 0)).X; + + DisplaysStackedVertically = rightEdgeOfLastButton > projectedLeftEdgeOfExpandedBeatmapAttributesDisplay; + + // only update preview panel's collapsed state after we are fully visible, to ensure all the buttons are where we expect them to be. + if (Alpha == 1) + beatmapAttributesDisplay.Collapsed.Value = DisplaysStackedVertically; + + contentFlow.LayoutDuration = 200; + contentFlow.LayoutEasing = Easing.OutQuint; + contentFlow.Direction = DisplaysStackedVertically ? FillDirection.Vertical : FillDirection.Horizontal; + } + } + + protected virtual IEnumerable CreateButtons() => new[] + { + DeselectAllModsButton = new DeselectAllModsButton(overlay) + }; + + protected override void PopIn() + { + this.MoveToY(0, 400, Easing.OutQuint) + .FadeIn(400, Easing.OutQuint); + } + + protected override void PopOut() + { + this.MoveToY(-20f, 200, Easing.OutQuint) + .FadeOut(200, Easing.OutQuint); + } + } +} diff --git a/osu.Game/Overlays/Mods/ModSelectOverlay.cs b/osu.Game/Overlays/Mods/ModSelectOverlay.cs index 25293e8e20..ed73340eeb 100644 --- a/osu.Game/Overlays/Mods/ModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/ModSelectOverlay.cs @@ -27,8 +27,10 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osu.Game.Localisation; using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Footer; using osu.Game.Utils; using osuTK; +using osuTK.Graphics; using osuTK.Input; namespace osu.Game.Overlays.Mods @@ -86,11 +88,6 @@ namespace osu.Game.Overlays.Mods public ShearedSearchTextBox SearchTextBox { get; private set; } = null!; - /// - /// Whether the effects (on score multiplier, on or beatmap difficulty) of the current selected set of mods should be shown. - /// - protected virtual bool ShowModEffects => true; - /// /// Whether per-mod customisation controls are visible. /// @@ -107,58 +104,26 @@ namespace osu.Game.Overlays.Mods protected virtual IReadOnlyList ComputeActiveMods() => SelectedMods.Value; - protected virtual IEnumerable CreateFooterButtons() - { - if (AllowCustomisation) - { - yield return CustomisationButton = new ShearedToggleButton(BUTTON_WIDTH) - { - Text = ModSelectOverlayStrings.ModCustomisation, - Active = { BindTarget = customisationVisible } - }; - } - - yield return deselectAllModsButton = new DeselectAllModsButton(this); - } - private readonly Bindable>> globalAvailableMods = new Bindable>>(); public IEnumerable AllAvailableMods => AvailableMods.Value.SelectMany(pair => pair.Value); - private readonly BindableBool customisationVisible = new BindableBool(); private Bindable textSearchStartsActive = null!; - private ModSettingsArea modSettingsArea = null!; private ColumnScrollContainer columnScroll = null!; private ColumnFlowContainer columnFlow = null!; - private FillFlowContainer footerButtonFlow = null!; - private FillFlowContainer footerContentFlow = null!; - private DeselectAllModsButton deselectAllModsButton = null!; private Container aboveColumnsContent = null!; - private RankingInformationDisplay? rankingInformationDisplay; - private BeatmapAttributesDisplay? beatmapAttributesDisplay; + private ModCustomisationPanel customisationPanel = null!; - protected ShearedButton BackButton { get; private set; } = null!; - protected ShearedToggleButton? CustomisationButton { get; private set; } - protected SelectAllModsButton? SelectAllModsButton { get; set; } + protected virtual SelectAllModsButton? SelectAllModsButton => null; private Sample? columnAppearSample; - private WorkingBeatmap? beatmap; + public readonly Bindable Beatmap = new Bindable(); - public WorkingBeatmap? Beatmap - { - get => beatmap; - set - { - if (beatmap == value) return; - - beatmap = value; - if (IsLoaded && beatmapAttributesDisplay != null) - beatmapAttributesDisplay.BeatmapInfo.Value = beatmap?.BeatmapInfo; - } - } + [Resolved] + private ScreenFooter? footer { get; set; } protected ModSelectOverlay(OverlayColourScheme colourScheme = OverlayColourScheme.Green) : base(colourScheme) @@ -173,126 +138,70 @@ namespace osu.Game.Overlays.Mods columnAppearSample = audio.Samples.Get(@"SongSelect/mod-column-pop-in"); - AddRange(new Drawable[] + MainAreaContent.Add(new OsuContextMenuContainer { - new ClickToReturnContainer + RelativeSizeAxes = Axes.Both, + Child = new PopoverContainer { RelativeSizeAxes = Axes.Both, - HandleMouse = { BindTarget = customisationVisible }, - OnClicked = () => customisationVisible.Value = false - }, - modSettingsArea = new ModSettingsArea - { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - Height = 0 - } - }); - - MainAreaContent.AddRange(new Drawable[] - { - aboveColumnsContent = new Container - { - RelativeSizeAxes = Axes.X, - Height = RankingInformationDisplay.HEIGHT, - Padding = new MarginPadding { Horizontal = 100 }, - Child = SearchTextBox = new ShearedSearchTextBox + Children = new Drawable[] { - HoldFocus = false, - Width = 300 - } - }, - new OsuContextMenuContainer - { - RelativeSizeAxes = Axes.Both, - Child = new PopoverContainer - { - Padding = new MarginPadding + new Container { - Top = RankingInformationDisplay.HEIGHT + PADDING, - Bottom = PADDING - }, - RelativeSizeAxes = Axes.Both, - RelativePositionAxes = Axes.Both, - Children = new Drawable[] - { - columnScroll = new ColumnScrollContainer + Padding = new MarginPadding { - RelativeSizeAxes = Axes.Both, - Masking = false, - ClampExtension = 100, - ScrollbarOverlapsContent = false, - Child = columnFlow = new ColumnFlowContainer + Top = RankingInformationDisplay.HEIGHT + PADDING, + Bottom = PADDING + }, + RelativeSizeAxes = Axes.Both, + RelativePositionAxes = Axes.Both, + Children = new Drawable[] + { + columnScroll = new ColumnScrollContainer { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Direction = FillDirection.Horizontal, - Shear = new Vector2(SHEAR, 0), - RelativeSizeAxes = Axes.Y, - AutoSizeAxes = Axes.X, - Margin = new MarginPadding { Horizontal = 70 }, - Padding = new MarginPadding { Bottom = 10 }, - ChildrenEnumerable = createColumns() + RelativeSizeAxes = Axes.Both, + Masking = false, + ClampExtension = 100, + ScrollbarOverlapsContent = false, + Child = columnFlow = new ColumnFlowContainer + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Direction = FillDirection.Horizontal, + Shear = new Vector2(OsuGame.SHEAR, 0), + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Margin = new MarginPadding { Horizontal = 70 }, + Padding = new MarginPadding { Bottom = 10 }, + ChildrenEnumerable = createColumns() + } + } + } + }, + aboveColumnsContent = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Horizontal = 100, Bottom = 15f }, + Children = new Drawable[] + { + SearchTextBox = new ShearedSearchTextBox + { + HoldFocus = false, + Width = 300, + }, + customisationPanel = new ModCustomisationPanel + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Width = 400, + State = { Value = Visibility.Visible }, } } } - } + }, } }); - FooterContent.Add(footerButtonFlow = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Horizontal, - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Padding = new MarginPadding - { - Vertical = PADDING, - Horizontal = 70 - }, - Spacing = new Vector2(10), - ChildrenEnumerable = CreateFooterButtons().Prepend(BackButton = new ShearedButton(BUTTON_WIDTH) - { - Text = CommonStrings.Back, - Action = Hide, - DarkerColour = colours.Pink2, - LighterColour = colours.Pink1 - }) - }); - - if (ShowModEffects) - { - FooterContent.Add(footerContentFlow = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Spacing = new Vector2(30, 10), - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - Margin = new MarginPadding - { - Vertical = PADDING, - Horizontal = 20 - }, - Children = new Drawable[] - { - rankingInformationDisplay = new RankingInformationDisplay - { - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight - }, - beatmapAttributesDisplay = new BeatmapAttributesDisplay - { - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - BeatmapInfo = { Value = Beatmap?.BeatmapInfo }, - }, - } - }); - } - globalAvailableMods.BindTo(game.AvailableMods); textSearchStartsActive = configManager.GetBindable(OsuSetting.ModSelectTextSearchStartsActive); @@ -306,8 +215,6 @@ namespace osu.Game.Overlays.Mods SearchTextBox.Current.Value = string.Empty; } - private ModSettingChangeTracker? modSettingChangeTracker; - protected override void LoadComplete() { // this is called before base call so that the mod state is populated early, and the transition in `PopIn()` can play out properly. @@ -320,7 +227,7 @@ namespace osu.Game.Overlays.Mods // This is an optimisation to prevent refreshing the available settings controls when it can be // reasonably assumed that the settings panel is never to be displayed (e.g. FreeModSelectOverlay). if (AllowCustomisation) - ((IBindable>)modSettingsArea.SelectedMods).BindTo(SelectedMods); + ((IBindable>)customisationPanel.SelectedMods).BindTo(SelectedMods); SelectedMods.BindValueChanged(_ => { @@ -330,29 +237,15 @@ namespace osu.Game.Overlays.Mods ActiveMods.Value = ComputeActiveMods(); }, true); - ActiveMods.BindValueChanged(_ => - { - updateOverlayInformation(); - - modSettingChangeTracker?.Dispose(); - - if (AllowCustomisation) - { - // Importantly, use ActiveMods.Value here (and not the ValueChanged NewValue) as the latter can - // potentially be stale, due to complexities in the way change trackers work. - // - // See https://github.com/ppy/osu/pull/23284#issuecomment-1529056988 - modSettingChangeTracker = new ModSettingChangeTracker(ActiveMods.Value); - modSettingChangeTracker.SettingChanged += _ => updateOverlayInformation(); - } - }, true); - - customisationVisible.BindValueChanged(_ => updateCustomisationVisualState(), true); + customisationPanel.ExpandedState.BindValueChanged(_ => updateCustomisationVisualState(), true); SearchTextBox.Current.BindValueChanged(query => { foreach (var column in columnFlow.Columns) column.SearchTerm = query.NewValue; + + if (SearchTextBox.HasFocus) + preselectMod(); }, true); // Start scrolling from the end, to give the user a sense that @@ -364,6 +257,34 @@ namespace osu.Game.Overlays.Mods }); } + private void preselectMod() + { + var visibleMods = columnFlow.Columns.OfType().Where(c => c.IsPresent).SelectMany(c => c.AvailableMods.Where(m => m.Visible)); + + // Search for an exact acronym or name match, or otherwise default to the first visible mod. + ModState? matchingMod = + visibleMods.FirstOrDefault(m => m.Mod.Acronym.Equals(SearchTerm, StringComparison.OrdinalIgnoreCase) || m.Mod.Name.Equals(SearchTerm, StringComparison.OrdinalIgnoreCase)) + ?? visibleMods.FirstOrDefault(); + var preselectedMod = matchingMod; + + foreach (var mod in AllAvailableMods) + mod.Preselected.Value = mod == preselectedMod && SearchTextBox.Current.Value.Length > 0; + } + + private void clearPreselection() + { + foreach (var mod in AllAvailableMods) + mod.Preselected.Value = false; + } + + public new ModSelectFooterContent? DisplayedFooterContent => base.DisplayedFooterContent as ModSelectFooterContent; + + public override VisibilityContainer CreateFooterContent() => new ModSelectFooterContent(this) + { + Beatmap = { BindTarget = Beatmap }, + ActiveMods = { BindTarget = ActiveMods }, + }; + private static readonly LocalisableString input_search_placeholder = Resources.Localisation.Web.CommonStrings.InputSearch; private static readonly LocalisableString tab_to_search_placeholder = ModSelectOverlayStrings.TabToSearch; @@ -372,25 +293,7 @@ namespace osu.Game.Overlays.Mods base.Update(); SearchTextBox.PlaceholderText = SearchTextBox.HasFocus ? input_search_placeholder : tab_to_search_placeholder; - - if (beatmapAttributesDisplay != null) - { - float rightEdgeOfLastButton = footerButtonFlow[^1].ScreenSpaceDrawQuad.TopRight.X; - - // this is cheating a bit; the 640 value is hardcoded based on how wide the expanded panel _generally_ is. - // due to the transition applied, the raw screenspace quad of the panel cannot be used, as it will trigger an ugly feedback cycle of expanding and collapsing. - float projectedLeftEdgeOfExpandedBeatmapAttributesDisplay = footerButtonFlow.ToScreenSpace(footerButtonFlow.DrawSize - new Vector2(640, 0)).X; - - bool screenIsntWideEnough = rightEdgeOfLastButton > projectedLeftEdgeOfExpandedBeatmapAttributesDisplay; - - // only update preview panel's collapsed state after we are fully visible, to ensure all the buttons are where we expect them to be. - if (Alpha == 1) - beatmapAttributesDisplay.Collapsed.Value = screenIsntWideEnough; - - footerContentFlow.LayoutDuration = 200; - footerContentFlow.LayoutEasing = Easing.OutQuint; - footerContentFlow.Direction = screenIsntWideEnough ? FillDirection.Vertical : FillDirection.Horizontal; - } + aboveColumnsContent.Padding = aboveColumnsContent.Padding with { Bottom = DisplayedFooterContent?.DisplaysStackedVertically == true ? 75f : 15f }; } /// @@ -468,30 +371,9 @@ namespace osu.Game.Overlays.Mods modState.ValidForSelection.Value = modState.Mod.Type != ModType.System && modState.Mod.HasImplementation && IsValidMod.Invoke(modState.Mod); } - /// - /// Updates any information displayed on the overlay regarding the effects of the active mods. - /// This reads from instead of . - /// - private void updateOverlayInformation() - { - if (rankingInformationDisplay != null) - { - double multiplier = 1.0; - - foreach (var mod in ActiveMods.Value) - multiplier *= mod.ScoreMultiplier; - - rankingInformationDisplay.ModMultiplier.Value = multiplier; - rankingInformationDisplay.Ranked.Value = ActiveMods.Value.All(m => m.Ranked); - } - - if (beatmapAttributesDisplay != null) - beatmapAttributesDisplay.Mods.Value = ActiveMods.Value; - } - private void updateCustomisation() { - if (CustomisationButton == null) + if (!AllowCustomisation) return; bool anyCustomisableModActive = false; @@ -506,41 +388,32 @@ namespace osu.Game.Overlays.Mods if (anyCustomisableModActive) { - customisationVisible.Disabled = false; + customisationPanel.Enabled.Value = true; - if (anyModPendingConfiguration && !customisationVisible.Value) - customisationVisible.Value = true; + if (anyModPendingConfiguration) + customisationPanel.ExpandedState.Value = ModCustomisationPanel.ModCustomisationPanelState.ExpandedByMod; } else { - if (customisationVisible.Value) - customisationVisible.Value = false; - - customisationVisible.Disabled = true; + customisationPanel.ExpandedState.Value = ModCustomisationPanel.ModCustomisationPanelState.Collapsed; + customisationPanel.Enabled.Value = false; } } private void updateCustomisationVisualState() { - const double transition_duration = 300; - - MainAreaContent.FadeColour(customisationVisible.Value ? Colour4.Gray : Colour4.White, transition_duration, Easing.InOutCubic); - - foreach (var button in footerButtonFlow) + if (customisationPanel.ExpandedState.Value != ModCustomisationPanel.ModCustomisationPanelState.Collapsed) { - if (button != CustomisationButton) - button.Enabled.Value = !customisationVisible.Value; + columnScroll.FadeColour(OsuColour.Gray(0.5f), 400, Easing.OutQuint); + SearchTextBox.FadeColour(OsuColour.Gray(0.5f), 400, Easing.OutQuint); + setTextBoxFocus(false); } - - float modAreaHeight = customisationVisible.Value ? ModSettingsArea.HEIGHT : 0; - - modSettingsArea.ResizeHeightTo(modAreaHeight, transition_duration, Easing.InOutCubic); - TopLevelContent.MoveToY(-modAreaHeight, transition_duration, Easing.InOutCubic); - - if (customisationVisible.Value) - SearchTextBox.KillFocus(); else + { + columnScroll.FadeColour(Color4.White, 400, Easing.OutQuint); + SearchTextBox.FadeColour(Color4.White, 400, Easing.OutQuint); setTextBoxFocus(textSearchStartsActive.Value); + } } /// @@ -693,6 +566,8 @@ namespace osu.Game.Overlays.Mods if (!allFiltered) nonFilteredColumnCount += 1; } + + customisationPanel.ExpandedState.Value = ModCustomisationPanel.ModCustomisationPanelState.Collapsed; } #endregion @@ -706,16 +581,12 @@ namespace osu.Game.Overlays.Mods switch (e.Action) { + // If the customisation panel is expanded, the back action will be handled by it first. case GlobalAction.Back: - // Pressing the back binding should only go back one step at a time. - hideOverlay(false); - return true; - // This is handled locally here because this overlay is being registered at the game level // and therefore takes away keyboard focus from the screen stack. case GlobalAction.ToggleModSelection: - // Pressing toggle should completely hide the overlay in one shot. - hideOverlay(true); + hideOverlay(); return true; // This is handled locally here due to conflicts in input handling between the search text box and the deselect all mods button. @@ -723,9 +594,9 @@ namespace osu.Game.Overlays.Mods // wherein activating the binding will both change the contents of the search text box and deselect all mods. case GlobalAction.DeselectAllMods: { - if (!SearchTextBox.HasFocus) + if (!SearchTextBox.HasFocus && customisationPanel.ExpandedState.Value == ModCustomisationPanel.ModCustomisationPanelState.Collapsed) { - deselectAllModsButton.TriggerClick(); + DisplayedFooterContent?.DeselectAllModsButton?.TriggerClick(); return true; } @@ -738,15 +609,15 @@ namespace osu.Game.Overlays.Mods // If there is no search in progress, it should exit the dialog (a bit weird, but this is the expectation from stable). if (string.IsNullOrEmpty(SearchTerm)) { - hideOverlay(true); + hideOverlay(); return true; } - ModState? firstMod = columnFlow.Columns.OfType().FirstOrDefault(m => m.IsPresent)?.AvailableMods.FirstOrDefault(x => x.Visible); + var matchingMod = AllAvailableMods.SingleOrDefault(m => m.Preselected.Value); - if (firstMod is not null) + if (matchingMod is not null) { - firstMod.Active.Value = !firstMod.Active.Value; + matchingMod.Active.Value = !matchingMod.Active.Value; SearchTextBox.SelectAll(); } @@ -756,18 +627,12 @@ namespace osu.Game.Overlays.Mods return base.OnPressed(e); - void hideOverlay(bool immediate) + void hideOverlay() { - if (customisationVisible.Value) - { - Debug.Assert(CustomisationButton != null); - CustomisationButton.TriggerClick(); - - if (!immediate) - return; - } - - BackButton.TriggerClick(); + if (footer != null) + footer.BackButton.TriggerClick(); + else + Hide(); } } @@ -776,7 +641,7 @@ namespace osu.Game.Overlays.Mods /// This is handled locally here due to conflicts in input handling between the search text box and the select all mods button. /// Attempting to handle this action locally in both places leads to a possible scenario /// wherein activating the "select all" platform binding will both select all text in the search box and select all mods. - /// > + /// public bool OnPressed(KeyBindingPressEvent e) { if (e.Repeat || e.Action != PlatformAction.SelectAll || SelectAllModsButton == null) @@ -795,6 +660,9 @@ namespace osu.Game.Overlays.Mods if (e.Repeat || e.Key != Key.Tab) return false; + if (customisationPanel.ExpandedState.Value != ModCustomisationPanel.ModCustomisationPanelState.Collapsed) + return true; + // TODO: should probably eventually support typical platform search shortcuts (`Ctrl-F`, `/`) setTextBoxFocus(!SearchTextBox.HasFocus); return true; @@ -803,9 +671,15 @@ namespace osu.Game.Overlays.Mods private void setTextBoxFocus(bool focus) { if (focus) + { SearchTextBox.TakeFocus(); + preselectMod(); + } else + { SearchTextBox.KillFocus(); + clearPreselection(); + } } #endregion @@ -823,6 +697,8 @@ namespace osu.Game.Overlays.Mods [Cached] internal partial class ColumnScrollContainer : OsuScrollContainer { + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; + public ColumnScrollContainer() : base(Direction.Horizontal) { @@ -847,7 +723,7 @@ namespace osu.Game.Overlays.Mods // DrawWidth/DrawPosition do not include shear effects, and we want to know the full extents of the columns post-shear, // so we have to manually compensate. var topLeft = column.ToSpaceOfOtherDrawable(Vector2.Zero, ScrollContent); - var bottomRight = column.ToSpaceOfOtherDrawable(new Vector2(column.DrawWidth - column.DrawHeight * SHEAR, 0), ScrollContent); + var bottomRight = column.ToSpaceOfOtherDrawable(new Vector2(column.DrawWidth - column.DrawHeight * OsuGame.SHEAR, 0), ScrollContent); bool isCurrentlyVisible = Precision.AlmostBigger(topLeft.X, leftVisibleBound) && Precision.DefinitelyBigger(rightVisibleBound, bottomRight.X); @@ -949,7 +825,7 @@ namespace osu.Game.Overlays.Mods RequestScroll?.Invoke(this); // Killing focus is done here because it's the only feasible place on ModSelectOverlay you can click on without triggering any action. - Scheduler.Add(() => GetContainingInputManager().ChangeFocus(null)); + Scheduler.Add(() => GetContainingFocusManager()!.ChangeFocus(null)); return true; } @@ -967,38 +843,5 @@ namespace osu.Game.Overlays.Mods updateState(); } } - - /// - /// A container which blocks and handles input, managing the "return from customisation" state change. - /// - private partial class ClickToReturnContainer : Container - { - public BindableBool HandleMouse { get; } = new BindableBool(); - - public Action? OnClicked { get; set; } - - public override bool HandlePositionalInput => base.HandlePositionalInput && HandleMouse.Value; - - protected override bool Handle(UIEvent e) - { - if (!HandleMouse.Value) - return base.Handle(e); - - switch (e) - { - case ClickEvent: - OnClicked?.Invoke(); - return true; - - case HoverEvent: - return false; - - case MouseEvent: - return true; - } - - return base.Handle(e); - } - } } } diff --git a/osu.Game/Overlays/Mods/ModSelectPanel.cs b/osu.Game/Overlays/Mods/ModSelectPanel.cs index 29f4c93e88..284356f37e 100644 --- a/osu.Game/Overlays/Mods/ModSelectPanel.cs +++ b/osu.Game/Overlays/Mods/ModSelectPanel.cs @@ -87,7 +87,7 @@ namespace osu.Game.Overlays.Mods Content.CornerRadius = CORNER_RADIUS; Content.BorderThickness = 2; - Shear = new Vector2(ShearedOverlayContainer.SHEAR, 0); + Shear = new Vector2(OsuGame.SHEAR, 0); Children = new Drawable[] { @@ -128,10 +128,10 @@ namespace osu.Game.Overlays.Mods { Font = OsuFont.TorusAlternate.With(size: 18, weight: FontWeight.SemiBold), RelativeSizeAxes = Axes.X, - Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0), + Shear = new Vector2(-OsuGame.SHEAR, 0), Margin = new MarginPadding { - Left = -18 * ShearedOverlayContainer.SHEAR + Left = -18 * OsuGame.SHEAR }, ShowTooltip = false, // Tooltip is handled by `IncompatibilityDisplayingModPanel`. }, @@ -139,7 +139,7 @@ namespace osu.Game.Overlays.Mods { Font = OsuFont.Default.With(size: 12), RelativeSizeAxes = Axes.X, - Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0), + Shear = new Vector2(-OsuGame.SHEAR, 0), ShowTooltip = false, // Tooltip is handled by `IncompatibilityDisplayingModPanel`. } } diff --git a/osu.Game/Overlays/Mods/ModSettingsArea.cs b/osu.Game/Overlays/Mods/ModSettingsArea.cs deleted file mode 100644 index d0e0f7e648..0000000000 --- a/osu.Game/Overlays/Mods/ModSettingsArea.cs +++ /dev/null @@ -1,189 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Input.Events; -using osu.Game.Configuration; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.UI; -using osuTK; - -namespace osu.Game.Overlays.Mods -{ - public partial class ModSettingsArea : CompositeDrawable - { - public Bindable> SelectedMods { get; } = new Bindable>(Array.Empty()); - - public const float HEIGHT = 250; - - private readonly Box background; - private readonly FillFlowContainer modSettingsFlow; - - [Resolved] - private OverlayColourProvider colourProvider { get; set; } = null!; - - public override bool AcceptsFocus => true; - - public ModSettingsArea() - { - RelativeSizeAxes = Axes.X; - Height = HEIGHT; - - Anchor = Anchor.BottomRight; - Origin = Anchor.BottomRight; - - InternalChild = new Container - { - RelativeSizeAxes = Axes.Both, - Masking = true, - Children = new Drawable[] - { - background = new Box - { - RelativeSizeAxes = Axes.Both - }, - new OsuScrollContainer(Direction.Horizontal) - { - RelativeSizeAxes = Axes.Both, - ScrollbarOverlapsContent = false, - ClampExtension = 100, - Child = modSettingsFlow = new FillFlowContainer - { - AutoSizeAxes = Axes.X, - RelativeSizeAxes = Axes.Y, - Padding = new MarginPadding { Vertical = 7, Horizontal = 70 }, - Spacing = new Vector2(7), - Direction = FillDirection.Horizontal - } - } - } - }; - } - - [BackgroundDependencyLoader] - private void load() - { - background.Colour = colourProvider.Dark3; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - SelectedMods.BindValueChanged(_ => updateMods(), true); - } - - private void updateMods() - { - modSettingsFlow.Clear(); - - // Importantly, the selected mods bindable is already ordered by the mod select overlay (following the order of mod columns and panels). - // Using AsOrdered produces a slightly different order (e.g. DT and NC no longer becoming adjacent), - // which breaks user expectations when interacting with the overlay. - foreach (var mod in SelectedMods.Value) - { - var settings = mod.CreateSettingsControls().ToList(); - - if (settings.Count > 0) - { - if (modSettingsFlow.Any()) - { - modSettingsFlow.Add(new Box - { - RelativeSizeAxes = Axes.Y, - Width = 2, - Colour = colourProvider.Dark4, - }); - } - - modSettingsFlow.Add(new ModSettingsColumn(mod, settings)); - } - } - } - - protected override bool OnMouseDown(MouseDownEvent e) => true; - protected override bool OnHover(HoverEvent e) => true; - - public partial class ModSettingsColumn : CompositeDrawable - { - public readonly Mod Mod; - - public ModSettingsColumn(Mod mod, IEnumerable settingsControls) - { - Mod = mod; - - Width = 250; - RelativeSizeAxes = Axes.Y; - Padding = new MarginPadding { Bottom = 7 }; - - InternalChild = new GridContainer - { - RelativeSizeAxes = Axes.Both, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.Absolute, 10), - new Dimension() - }, - Content = new[] - { - new Drawable[] - { - new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(7), - Children = new Drawable[] - { - new ModSwitchTiny(mod) - { - Active = { Value = true }, - Scale = new Vector2(0.6f), - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft - }, - new OsuSpriteText - { - Text = mod.Name, - Font = OsuFont.Default.With(size: 16, weight: FontWeight.SemiBold), - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft, - Margin = new MarginPadding { Bottom = 2 } - } - } - } - }, - new[] { Empty() }, - new Drawable[] - { - new OsuScrollContainer(Direction.Vertical) - { - RelativeSizeAxes = Axes.Both, - ClampExtension = 100, - Child = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Right = 7 }, - ChildrenEnumerable = settingsControls, - Spacing = new Vector2(0, 7) - } - } - } - } - }; - } - } - } -} diff --git a/osu.Game/Overlays/Mods/ModState.cs b/osu.Game/Overlays/Mods/ModState.cs index 7a5bc0f3ae..48fde2fc44 100644 --- a/osu.Game/Overlays/Mods/ModState.cs +++ b/osu.Game/Overlays/Mods/ModState.cs @@ -22,6 +22,8 @@ namespace osu.Game.Overlays.Mods /// public BindableBool Active { get; } = new BindableBool(); + public BindableBool Preselected { get; } = new BindableBool(); + /// /// Whether the mod requires further customisation. /// This flag is read by the to determine if the customisation panel should be opened after a mod change diff --git a/osu.Game/Overlays/Mods/RankingInformationDisplay.cs b/osu.Game/Overlays/Mods/RankingInformationDisplay.cs index 494f8a377f..75a8f289d8 100644 --- a/osu.Game/Overlays/Mods/RankingInformationDisplay.cs +++ b/osu.Game/Overlays/Mods/RankingInformationDisplay.cs @@ -52,7 +52,7 @@ namespace osu.Game.Overlays.Mods Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, RelativeSizeAxes = Axes.Both, - Shear = new Vector2(ShearedOverlayContainer.SHEAR, 0), + Shear = new Vector2(OsuGame.SHEAR, 0), CornerRadius = ShearedButton.CORNER_RADIUS, Masking = true, Children = new Drawable[] @@ -79,7 +79,7 @@ namespace osu.Game.Overlays.Mods { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0), + Shear = new Vector2(-OsuGame.SHEAR, 0), Font = OsuFont.Default.With(size: 17, weight: FontWeight.SemiBold) } } @@ -94,7 +94,7 @@ namespace osu.Game.Overlays.Mods Origin = Anchor.Centre, Child = counter = new EffectCounter { - Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0), + Shear = new Vector2(-OsuGame.SHEAR, 0), Anchor = Anchor.Centre, Origin = Anchor.Centre, Current = { BindTarget = ModMultiplier } diff --git a/osu.Game/Overlays/Mods/ShearedOverlayContainer.cs b/osu.Game/Overlays/Mods/ShearedOverlayContainer.cs index a372ec70db..dfa49f3779 100644 --- a/osu.Game/Overlays/Mods/ShearedOverlayContainer.cs +++ b/osu.Game/Overlays/Mods/ShearedOverlayContainer.cs @@ -1,62 +1,67 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - +using System; using osu.Framework.Allocation; +using osu.Framework.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.Containers; using osu.Game.Graphics.UserInterface; +using osu.Game.Screens.Footer; namespace osu.Game.Overlays.Mods { /// - /// A sheared overlay which provides a header and footer and basic animations. - /// Exposes , and as valid targets for content. + /// A sheared overlay which provides a header and basic animations. + /// Exposes and as valid targets for content. /// public abstract partial class ShearedOverlayContainer : OsuFocusedOverlayContainer { - protected const float PADDING = 14; - - public const float SHEAR = 0.2f; + public const float PADDING = 14; [Cached] - protected readonly OverlayColourProvider ColourProvider; + public readonly OverlayColourProvider ColourProvider; /// /// The overlay's header. /// - protected ShearedOverlayHeader Header { get; private set; } + protected ShearedOverlayHeader Header { get; private set; } = null!; /// /// The overlay's footer. /// - protected Container Footer { get; private set; } + protected Container Footer { get; private set; } = null!; + + [Resolved] + private ScreenFooter? footer { get; set; } /// /// A container containing all content, including the header and footer. /// May be used for overlay-wide animations. /// - protected Container TopLevelContent { get; private set; } + protected Container TopLevelContent { get; private set; } = null!; /// /// A container for content that is to be displayed between the header and footer. /// - protected Container MainAreaContent { get; private set; } + protected Container MainAreaContent { get; private set; } = null!; /// /// A container for content that is to be displayed inside the footer. /// - protected Container FooterContent { get; private set; } + protected Container FooterContent { get; private set; } = null!; protected override bool StartHidden => true; protected override bool BlockNonPositionalInput => true; + // ShearedOverlayContainers are placed at a layer within the screen container as they rely on ScreenFooter which must be placed there. + // Therefore, dimming must be managed locally, since DimMainContent dims the entire screen layer. + protected sealed override bool DimMainContent => false; + protected ShearedOverlayContainer(OverlayColourScheme colourScheme) { RelativeSizeAxes = Axes.Both; @@ -67,13 +72,16 @@ namespace osu.Game.Overlays.Mods [BackgroundDependencyLoader] private void load() { - const float footer_height = 50; - Child = TopLevelContent = new Container { RelativeSizeAxes = Axes.Both, Children = new Drawable[] { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourProvider.Background6.Opacity(0.75f), + }, Header = new ShearedOverlayHeader { Anchor = Anchor.TopCentre, @@ -87,34 +95,26 @@ namespace osu.Game.Overlays.Mods Padding = new MarginPadding { Top = ShearedOverlayHeader.HEIGHT, - Bottom = footer_height + PADDING, + Bottom = ScreenFooter.HEIGHT + PADDING, } }, - Footer = new InputBlockingContainer - { - RelativeSizeAxes = Axes.X, - Depth = float.MinValue, - Height = footer_height, - Margin = new MarginPadding { Top = PADDING }, - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourProvider.Background5 - }, - FooterContent = new Container - { - RelativeSizeAxes = Axes.Both, - }, - } - } } }; } + public VisibilityContainer? DisplayedFooterContent { get; private set; } + + /// + /// Creates content to be displayed on the game-wide footer. + /// + public virtual VisibilityContainer? CreateFooterContent() => null; + + /// + /// Invoked when the back button in the footer is pressed. + /// + /// Whether the back button should not close the overlay. + public virtual bool OnBackButton() => false; + protected override bool OnClick(ClickEvent e) { if (State.Value == Visibility.Visible) @@ -126,6 +126,9 @@ namespace osu.Game.Overlays.Mods return base.OnClick(e); } + private IDisposable? activeOverlayRegistration; + private bool hideFooterOnPopOut; + protected override void PopIn() { const double fade_in_duration = 400; @@ -133,7 +136,18 @@ namespace osu.Game.Overlays.Mods this.FadeIn(fade_in_duration, Easing.OutQuint); Header.MoveToY(0, fade_in_duration, Easing.OutQuint); - Footer.MoveToY(0, fade_in_duration, Easing.OutQuint); + + if (footer != null) + { + activeOverlayRegistration = footer.RegisterActiveOverlayContainer(this, out var footerContent); + DisplayedFooterContent = footerContent; + + if (footer.State.Value == Visibility.Hidden) + { + footer.Show(); + hideFooterOnPopOut = true; + } + } } protected override void PopOut() @@ -144,7 +158,19 @@ namespace osu.Game.Overlays.Mods this.FadeOut(fade_out_duration, Easing.OutQuint); Header.MoveToY(-Header.DrawHeight, fade_out_duration, Easing.OutQuint); - Footer.MoveToY(Footer.DrawHeight, fade_out_duration, Easing.OutQuint); + + if (footer != null) + { + activeOverlayRegistration?.Dispose(); + activeOverlayRegistration = null; + DisplayedFooterContent = null; + + if (hideFooterOnPopOut) + { + footer.Hide(); + hideFooterOnPopOut = false; + } + } } } } diff --git a/osu.Game/Overlays/Mods/UserModSelectOverlay.cs b/osu.Game/Overlays/Mods/UserModSelectOverlay.cs index 49469b99f3..16d71e557b 100644 --- a/osu.Game/Overlays/Mods/UserModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/UserModSelectOverlay.cs @@ -3,18 +3,30 @@ using System.Collections.Generic; using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Input.Events; +using osu.Game.Input.Bindings; using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Select; using osu.Game.Utils; namespace osu.Game.Overlays.Mods { public partial class UserModSelectOverlay : ModSelectOverlay { + private ModSpeedHotkeyHandler modSpeedHotkeyHandler = null!; + public UserModSelectOverlay(OverlayColourScheme colourScheme = OverlayColourScheme.Green) : base(colourScheme) { } + [BackgroundDependencyLoader] + private void load() + { + Add(modSpeedHotkeyHandler = new ModSpeedHotkeyHandler()); + } + protected override ModColumn CreateModColumn(ModType modType) => new UserModColumn(modType, false); protected override IReadOnlyList ComputeNewModsFromSelection(IReadOnlyList oldSelection, IReadOnlyList newSelection) @@ -38,6 +50,20 @@ namespace osu.Game.Overlays.Mods return modsAfterRemoval.ToList(); } + public override bool OnPressed(KeyBindingPressEvent e) + { + switch (e.Action) + { + case GlobalAction.IncreaseModSpeed: + return modSpeedHotkeyHandler.ChangeSpeed(0.05, AllAvailableMods.Where(state => state.ValidForSelection.Value).Select(state => state.Mod)); + + case GlobalAction.DecreaseModSpeed: + return modSpeedHotkeyHandler.ChangeSpeed(-0.05, AllAvailableMods.Where(state => state.ValidForSelection.Value).Select(state => state.Mod)); + } + + return base.OnPressed(e); + } + private partial class UserModColumn : ModColumn { public UserModColumn(ModType modType, bool allowIncompatibleSelection) diff --git a/osu.Game/Overlays/Music/NowPlayingCollectionDropdown.cs b/osu.Game/Overlays/Music/NowPlayingCollectionDropdown.cs index fa9a2e3972..0f2e9400d9 100644 --- a/osu.Game/Overlays/Music/NowPlayingCollectionDropdown.cs +++ b/osu.Game/Overlays/Music/NowPlayingCollectionDropdown.cs @@ -53,8 +53,8 @@ namespace osu.Game.Overlays.Music { CornerRadius = 5; Height = 30; - Icon.Size = new Vector2(14); - Icon.Margin = new MarginPadding(0); + Chevron.Size = new Vector2(14); + Chevron.Margin = new MarginPadding(0); Foreground.Padding = new MarginPadding { Top = 4, Bottom = 4, Left = 10, Right = 10 }; EdgeEffect = new EdgeEffectParameters { diff --git a/osu.Game/Overlays/Music/PlaylistOverlay.cs b/osu.Game/Overlays/Music/PlaylistOverlay.cs index 6ecd0f51d3..b49c794aa3 100644 --- a/osu.Game/Overlays/Music/PlaylistOverlay.cs +++ b/osu.Game/Overlays/Music/PlaylistOverlay.cs @@ -8,7 +8,6 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; @@ -103,7 +102,7 @@ namespace osu.Game.Overlays.Music { base.LoadComplete(); - beatmapSubscription = realm.RegisterForNotifications(r => r.All().Where(s => !s.DeletePending), beatmapsChanged); + beatmapSubscription = realm.RegisterForNotifications(r => r.All().Where(s => !s.DeletePending && !s.Protected), beatmapsChanged); list.Items.BindTo(beatmapSets); beatmap.BindValueChanged(working => list.SelectedSet.Value = working.NewValue.BeatmapSetInfo.ToLive(realm), true); @@ -131,7 +130,7 @@ namespace osu.Game.Overlays.Music filter.Search.HoldFocus = true; Schedule(() => filter.Search.TakeFocus()); - this.ResizeTo(new Vector2(1, RelativeSizeAxes.HasFlagFast(Axes.Y) ? 1f : PLAYLIST_HEIGHT), transition_duration, Easing.OutQuint); + this.ResizeTo(new Vector2(1, RelativeSizeAxes.HasFlag(Axes.Y) ? 1f : PLAYLIST_HEIGHT), transition_duration, Easing.OutQuint); this.FadeIn(transition_duration, Easing.OutQuint); } diff --git a/osu.Game/Overlays/MusicController.cs b/osu.Game/Overlays/MusicController.cs index 0986c0513c..87920fdf55 100644 --- a/osu.Game/Overlays/MusicController.cs +++ b/osu.Game/Overlays/MusicController.cs @@ -1,12 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Track; @@ -16,7 +14,10 @@ using osu.Framework.Graphics.Audio; using osu.Framework.Graphics.Containers; using osu.Framework.Logging; using osu.Framework.Threading; +using osu.Framework.Utils; +using osu.Game.Audio.Effects; using osu.Game.Beatmaps; +using osu.Game.Configuration; using osu.Game.Database; using osu.Game.Rulesets.Mods; @@ -28,7 +29,7 @@ namespace osu.Game.Overlays public partial class MusicController : CompositeDrawable { [Resolved] - private BeatmapManager beatmaps { get; set; } + private BeatmapManager beatmaps { get; set; } = null!; /// /// Point in time after which the current track will be restarted on triggering a "previous track" action. @@ -45,29 +46,55 @@ namespace osu.Game.Overlays /// public readonly BindableBool AllowTrackControl = new BindableBool(true); + public readonly BindableBool Shuffle = new BindableBool(true); + /// /// Fired when the global has changed. /// Includes direction information for display purposes. /// - public event Action TrackChanged; + public event Action? TrackChanged; [Resolved] - private IBindable beatmap { get; set; } + private IBindable beatmap { get; set; } = null!; [Resolved] - private IBindable> mods { get; set; } + private IBindable> mods { get; set; } = null!; - [NotNull] public DrawableTrack CurrentTrack { get; private set; } = new DrawableTrack(new TrackVirtual(1000)); [Resolved] - private RealmAccess realm { get; set; } + private RealmAccess realm { get; set; } = null!; + + private BindableNumber sampleVolume = null!; + + private readonly BindableDouble audioDuckVolume = new BindableDouble(1); + + private AudioFilter audioDuckFilter = null!; + + private readonly Bindable randomSelectAlgorithm = new Bindable(); + private readonly List> previousRandomSets = new List>(); + private int randomHistoryDirection; + private int lastRandomTrackDirection; + + [BackgroundDependencyLoader] + private void load(AudioManager audio, OsuConfigManager configManager) + { + AddInternal(audioDuckFilter = new AudioFilter(audio.TrackMixer)); + audio.Tracks.AddAdjustment(AdjustableProperty.Volume, audioDuckVolume); + sampleVolume = audio.VolumeSample.GetBoundCopy(); + + configManager.BindWith(OsuSetting.RandomSelectAlgorithm, randomSelectAlgorithm); + } protected override void LoadComplete() { base.LoadComplete(); - beatmap.BindValueChanged(b => changeBeatmap(b.NewValue), true); + beatmap.BindValueChanged(b => + { + if (b.NewValue != null) + changeBeatmap(b.NewValue); + }, true); mods.BindValueChanged(_ => ResetTrackAdjustments(), true); } @@ -76,6 +103,9 @@ namespace osu.Game.Overlays /// public void ReloadCurrentTrack() { + if (current == null) + return; + changeTrack(); TrackChanged?.Invoke(current, TrackChangeDirection.None); } @@ -90,14 +120,14 @@ namespace osu.Game.Overlays /// public bool TrackLoaded => CurrentTrack.TrackLoaded; - private ScheduledDelegate seekDelegate; + private ScheduledDelegate? seekDelegate; public void SeekTo(double position) { seekDelegate?.Cancel(); seekDelegate = Schedule(() => { - if (beatmap.Disabled || !AllowTrackControl.Value) + if (!AllowTrackControl.Value) return; CurrentTrack.Seek(position); @@ -118,7 +148,7 @@ namespace osu.Game.Overlays return; Logger.Log($"{nameof(MusicController)} skipping next track to {nameof(EnsurePlayingSomething)}"); - NextTrack(); + NextTrack(allowProtectedTracks: true); } else if (!IsPlaying) { @@ -192,9 +222,10 @@ namespace osu.Game.Overlays /// Play the previous track or restart the current track if it's current time below . /// /// Invoked when the operation has been performed successfully. - public void PreviousTrack(Action onSuccess = null) => Schedule(() => + /// Whether to include beatmap sets when navigating. + public void PreviousTrack(Action? onSuccess = null, bool allowProtectedTracks = false) => Schedule(() => { - PreviousTrackResult res = prev(); + PreviousTrackResult res = prev(allowProtectedTracks); if (res != PreviousTrackResult.None) onSuccess?.Invoke(res); }); @@ -202,8 +233,9 @@ namespace osu.Game.Overlays /// /// Play the previous track or restart the current track if it's current time below . /// + /// Whether to include beatmap sets when navigating. /// The that indicate the decided action. - private PreviousTrackResult prev() + private PreviousTrackResult prev(bool allowProtectedTracks) { if (beatmap.Disabled || !AllowTrackControl.Value) return PreviousTrackResult.None; @@ -218,12 +250,19 @@ namespace osu.Game.Overlays queuedDirection = TrackChangeDirection.Prev; - var playableSet = getBeatmapSets().AsEnumerable().TakeWhile(i => !i.Equals(current.BeatmapSetInfo)).LastOrDefault() - ?? getBeatmapSets().LastOrDefault(); + Live? playableSet; + + if (Shuffle.Value) + playableSet = getNextRandom(-1, allowProtectedTracks); + else + { + playableSet = getBeatmapSets().TakeWhile(i => !i.Value.Equals(current?.BeatmapSetInfo)).LastOrDefault(s => !s.Value.Protected || allowProtectedTracks) + ?? getBeatmapSets().LastOrDefault(s => !s.Value.Protected || allowProtectedTracks); + } if (playableSet != null) { - changeBeatmap(beatmaps.GetWorkingBeatmap(playableSet.Beatmaps.First())); + changeBeatmap(beatmaps.GetWorkingBeatmap(playableSet.Value.Beatmaps.First())); restartTrack(); return PreviousTrackResult.Previous; } @@ -235,25 +274,91 @@ namespace osu.Game.Overlays /// Play the next random or playlist track. /// /// Invoked when the operation has been performed successfully. + /// Whether to include beatmap sets when navigating. /// A of the operation. - public void NextTrack(Action onSuccess = null) => Schedule(() => + public void NextTrack(Action? onSuccess = null, bool allowProtectedTracks = false) => Schedule(() => { - bool res = next(); + bool res = next(allowProtectedTracks); if (res) onSuccess?.Invoke(); }); - private bool next() + private readonly List duckOperations = new List(); + + /// + /// Applies ducking, attenuating the volume and/or low-pass cutoff of the currently playing track to make headroom for effects (or just to apply an effect). + /// + /// A which will restore the duck operation when disposed. + public IDisposable Duck(DuckParameters? parameters = null) + { + // Don't duck if samples have no volume, it sounds weird. + if (sampleVolume.Value == 0) + return new InvokeOnDisposal(() => { }); + + parameters ??= new DuckParameters(); + + duckOperations.Add(parameters); + + DuckParameters volumeOperation = duckOperations.MinBy(p => p.DuckVolumeTo)!; + DuckParameters lowPassOperation = duckOperations.MinBy(p => p.DuckCutoffTo)!; + + audioDuckFilter.CutoffTo(lowPassOperation.DuckCutoffTo, lowPassOperation.DuckDuration, lowPassOperation.DuckEasing); + this.TransformBindableTo(audioDuckVolume, volumeOperation.DuckVolumeTo, volumeOperation.DuckDuration, volumeOperation.DuckEasing); + + return new InvokeOnDisposal(restoreDucking); + + void restoreDucking() => Schedule(() => + { + if (!duckOperations.Remove(parameters)) + return; + + DuckParameters? restoreVolumeOperation = duckOperations.MinBy(p => p.DuckVolumeTo); + DuckParameters? restoreLowPassOperation = duckOperations.MinBy(p => p.DuckCutoffTo); + + // If another duck operation is in the list, restore ducking to its level, else reset back to defaults. + audioDuckFilter.CutoffTo(restoreLowPassOperation?.DuckCutoffTo ?? AudioFilter.MAX_LOWPASS_CUTOFF, parameters.RestoreDuration, parameters.RestoreEasing); + this.TransformBindableTo(audioDuckVolume, restoreVolumeOperation?.DuckVolumeTo ?? 1, parameters.RestoreDuration, parameters.RestoreEasing); + }); + } + + /// + /// A convenience method that ducks the currently playing track, then after a delay, restores automatically. + /// + /// A delay in milliseconds which defines how long to delay restoration after ducking completes. + /// Parameters defining the ducking operation. + public void DuckMomentarily(double delayUntilRestore, DuckParameters? parameters = null) + { + // Don't duck if samples have no volume, it sounds weird. + if (sampleVolume.Value == 0) + return; + + parameters ??= new DuckParameters(); + + IDisposable duckOperation = Duck(parameters); + + Scheduler.AddDelayed(() => duckOperation.Dispose(), delayUntilRestore); + } + + private bool next(bool allowProtectedTracks) { if (beatmap.Disabled || !AllowTrackControl.Value) return false; queuedDirection = TrackChangeDirection.Next; - var playableSet = getBeatmapSets().AsEnumerable().SkipWhile(i => !i.Equals(current.BeatmapSetInfo)).ElementAtOrDefault(1) - ?? getBeatmapSets().FirstOrDefault(); + Live? playableSet; - var playableBeatmap = playableSet?.Beatmaps.FirstOrDefault(); + if (Shuffle.Value) + playableSet = getNextRandom(1, allowProtectedTracks); + else + { + playableSet = getBeatmapSets().SkipWhile(i => !i.Value.Equals(current?.BeatmapSetInfo)) + .Where(i => !i.Value.Protected || allowProtectedTracks) + .ElementAtOrDefault(1) + ?? getBeatmapSets().FirstOrDefault(i => !i.Value.Protected || allowProtectedTracks); + } + + var playableBeatmap = playableSet?.Value.Beatmaps.FirstOrDefault(); if (playableBeatmap != null) { @@ -265,6 +370,84 @@ namespace osu.Game.Overlays return false; } + private Live? getNextRandom(int direction, bool allowProtectedTracks) + { + try + { + Live result; + + var possibleSets = getBeatmapSets().Where(s => !s.Value.Protected || allowProtectedTracks).ToList(); + + if (possibleSets.Count == 0) + return null; + + // if there is only one possible set left, play it, even if it is the same as the current track. + // looping is preferable over playing nothing. + if (possibleSets.Count == 1) + return possibleSets.Single(); + + // now that we actually know there is a choice, do not allow the current track to be played again. + possibleSets.RemoveAll(s => s.Value.Equals(current?.BeatmapSetInfo)); + + // condition below checks if the signs of `randomHistoryDirection` and `direction` are opposite and not zero. + // if that is the case, it means that the user had previously chosen next track `randomHistoryDirection` times and wants to go back, + // or that the user had previously chosen previous track `randomHistoryDirection` times and wants to go forward. + // in both cases, it means that we have a history of previous random selections that we can rewind. + if (randomHistoryDirection * direction < 0) + { + Debug.Assert(Math.Abs(randomHistoryDirection) == previousRandomSets.Count); + + // if the user has been shuffling backwards and now going forwards (or vice versa), + // the topmost item from history needs to be discarded because it's the *current* track. + if (direction * lastRandomTrackDirection < 0) + { + previousRandomSets.RemoveAt(previousRandomSets.Count - 1); + randomHistoryDirection += direction; + } + + if (previousRandomSets.Count > 0) + { + result = previousRandomSets[^1]; + previousRandomSets.RemoveAt(previousRandomSets.Count - 1); + return result; + } + } + + // if the early-return above didn't cover it, it means that we have no history to fall back on + // and need to actually choose something random. + switch (randomSelectAlgorithm.Value) + { + case RandomSelectAlgorithm.Random: + result = possibleSets[RNG.Next(possibleSets.Count)]; + break; + + case RandomSelectAlgorithm.RandomPermutation: + var notYetPlayedSets = possibleSets.Except(previousRandomSets).ToList(); + + if (notYetPlayedSets.Count == 0) + { + notYetPlayedSets = possibleSets; + previousRandomSets.Clear(); + randomHistoryDirection = 0; + } + + result = notYetPlayedSets[RNG.Next(notYetPlayedSets.Count)]; + break; + + default: + throw new ArgumentOutOfRangeException(nameof(randomSelectAlgorithm), randomSelectAlgorithm.Value, "Unsupported random select algorithm"); + } + + previousRandomSets.Add(result); + return result; + } + finally + { + randomHistoryDirection += direction; + lastRandomTrackDirection = direction; + } + } + private void restartTrack() { // if not scheduled, the previously track will be stopped one frame later (see ScheduleAfterChildren logic in GameBase). @@ -272,11 +455,13 @@ namespace osu.Game.Overlays Schedule(() => CurrentTrack.RestartAsync()); } - private WorkingBeatmap current; + private WorkingBeatmap? current; private TrackChangeDirection? queuedDirection; - private IQueryable getBeatmapSets() => realm.Realm.All().Where(s => !s.DeletePending); + private IEnumerable> getBeatmapSets() => realm.Realm.All().Where(s => !s.DeletePending) + .AsEnumerable() + .Select(s => new RealmLive(s, realm)); private void changeBeatmap(WorkingBeatmap newWorking) { @@ -289,7 +474,7 @@ namespace osu.Game.Overlays TrackChangeDirection direction = TrackChangeDirection.None; - bool audioEquals = newWorking?.BeatmapInfo?.AudioEquals(current?.BeatmapInfo) == true; + bool audioEquals = newWorking.BeatmapInfo?.AudioEquals(current?.BeatmapInfo) == true; if (current != null) { @@ -303,8 +488,8 @@ namespace osu.Game.Overlays else { // figure out the best direction based on order in playlist. - int last = getBeatmapSets().AsEnumerable().TakeWhile(b => !b.Equals(current.BeatmapSetInfo)).Count(); - int next = newWorking == null ? -1 : getBeatmapSets().AsEnumerable().TakeWhile(b => !b.Equals(newWorking.BeatmapSetInfo)).Count(); + int last = getBeatmapSets().TakeWhile(b => !b.Value.Equals(current.BeatmapSetInfo)).Count(); + int next = getBeatmapSets().TakeWhile(b => !b.Value.Equals(newWorking.BeatmapSetInfo)).Count(); direction = last > next ? TrackChangeDirection.Prev : TrackChangeDirection.Next; } @@ -361,7 +546,7 @@ namespace osu.Game.Overlays { // Important to keep this in its own method to avoid inadvertently capturing unnecessary variables in the callback. // Can lead to leaks. - var queuedTrack = new DrawableTrack(current.LoadTrack()); + var queuedTrack = new DrawableTrack(current!.LoadTrack()); queuedTrack.Completed += onTrackCompleted; return queuedTrack; } @@ -369,7 +554,7 @@ namespace osu.Game.Overlays private void onTrackCompleted() { if (!CurrentTrack.Looping && !beatmap.Disabled && AllowTrackControl.Value) - NextTrack(); + NextTrack(allowProtectedTracks: true); } private bool applyModTrackAdjustments; @@ -390,7 +575,7 @@ namespace osu.Game.Overlays } } - private AudioAdjustments modTrackAdjustments; + private AudioAdjustments? modTrackAdjustments; /// /// Resets the adjustments currently applied on and applies the mod adjustments if is true. @@ -416,6 +601,45 @@ namespace osu.Game.Overlays } } + public class DuckParameters + { + /// + /// The duration of the ducking transition in milliseconds. + /// Defaults to 100 ms. + /// + public double DuckDuration = 100; + + /// + /// The final volume which should be reached during ducking, when 0 is silent and 1 is original volume. + /// Defaults to 25%. + /// + public double DuckVolumeTo = 0.25; + + /// + /// The low-pass cutoff frequency which should be reached during ducking. If not required, set to . + /// Defaults to 300 Hz. + /// + public int DuckCutoffTo = 300; + + /// + /// The easing curve to be applied during ducking. + /// Defaults to . + /// + public Easing DuckEasing = Easing.Out; + + /// + /// The duration of the restoration transition in milliseconds. + /// Defaults to 500 ms. + /// + public double RestoreDuration = 500; + + /// + /// The easing curve to be applied during restoration. + /// Defaults to . + /// + public Easing RestoreEasing = Easing.In; + } + public enum TrackChangeDirection { None, diff --git a/osu.Game/Overlays/News/Sidebar/MonthSection.cs b/osu.Game/Overlays/News/Sidebar/MonthSection.cs index 9a748b2001..26490c36c8 100644 --- a/osu.Game/Overlays/News/Sidebar/MonthSection.cs +++ b/osu.Game/Overlays/News/Sidebar/MonthSection.cs @@ -118,7 +118,7 @@ namespace osu.Game.Overlays.News.Sidebar Expanded.BindValueChanged(open => { - icon.Scale = new Vector2(1, open.NewValue ? -1 : 1); + icon.ScaleTo(open.NewValue ? new Vector2(1f, -1f) : Vector2.One, 300, Easing.OutQuint); }, true); } } diff --git a/osu.Game/Overlays/NotificationOverlayToastTray.cs b/osu.Game/Overlays/NotificationOverlayToastTray.cs index 0ebaff9437..df07b4f138 100644 --- a/osu.Game/Overlays/NotificationOverlayToastTray.cs +++ b/osu.Game/Overlays/NotificationOverlayToastTray.cs @@ -31,7 +31,7 @@ namespace osu.Game.Overlays /// /// All notifications currently being displayed by the toast tray. /// - public IEnumerable Notifications => toastFlow; + public IEnumerable Notifications => toastFlow.Concat(InternalChildren.OfType()); public bool IsDisplayingToasts => toastFlow.Count > 0; @@ -43,12 +43,7 @@ namespace osu.Game.Overlays public Action? ForwardNotificationToPermanentStore { get; set; } - public int UnreadCount => allDisplayedNotifications.Count(n => !n.WasClosed && !n.Read); - - /// - /// Notifications contained in the toast flow, or in a detached state while they animate during forwarding to the main overlay. - /// - private IEnumerable allDisplayedNotifications => toastFlow.Concat(InternalChildren.OfType()); + public int UnreadCount => Notifications.Count(n => !n.WasClosed && !n.Read); private int runningDepth; @@ -91,11 +86,7 @@ namespace osu.Game.Overlays }; } - public void MarkAllRead() - { - toastFlow.Children.ForEach(n => n.Read = true); - InternalChildren.OfType().ForEach(n => n.Read = true); - } + public void MarkAllRead() => Notifications.ForEach(n => n.Read = true); public void FlushAllToasts() { @@ -162,8 +153,22 @@ namespace osu.Game.Overlays { base.Update(); - float height = toastFlow.Count > 0 ? toastFlow.DrawHeight + 120 : 0; - float alpha = toastFlow.Count > 0 ? MathHelper.Clamp(toastFlow.DrawHeight / 41, 0, 1) * toastFlow.Children.Max(n => n.Alpha) : 0; + float height = 0; + float alpha = 0; + + if (toastFlow.Count > 0) + { + float maxNotificationAlpha = 0; + + foreach (var t in toastFlow) + { + if (t.Alpha > maxNotificationAlpha) + maxNotificationAlpha = t.Alpha; + } + + height = toastFlow.DrawHeight + 120; + alpha = MathHelper.Clamp(toastFlow.DrawHeight / 41, 0, 1) * maxNotificationAlpha; + } toastContentBackground.Height = (float)Interpolation.DampContinuously(toastContentBackground.Height, height, 10, Clock.ElapsedFrameTime); toastContentBackground.Alpha = (float)Interpolation.DampContinuously(toastContentBackground.Alpha, alpha, 10, Clock.ElapsedFrameTime); diff --git a/osu.Game/Overlays/NowPlayingOverlay.cs b/osu.Game/Overlays/NowPlayingOverlay.cs index ab99370603..f4da9a92dc 100644 --- a/osu.Game/Overlays/NowPlayingOverlay.cs +++ b/osu.Game/Overlays/NowPlayingOverlay.cs @@ -5,6 +5,7 @@ using System; using System.Threading; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Configuration; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; @@ -33,6 +34,7 @@ namespace osu.Game.Overlays public LocalisableString Title => NowPlayingStrings.HeaderTitle; public LocalisableString Description => NowPlayingStrings.HeaderDescription; + private const float player_width = 400; private const float player_height = 130; private const float transition_length = 800; private const float progress_height = 10; @@ -45,9 +47,10 @@ namespace osu.Game.Overlays private IconButton prevButton = null!; private IconButton playButton = null!; private IconButton nextButton = null!; + private MusicIconButton shuffleButton = null!; private IconButton playlistButton = null!; - private SpriteText title = null!, artist = null!; + private ScrollingTextContainer title = null!, artist = null!; private PlaylistOverlay? playlist; @@ -67,10 +70,11 @@ namespace osu.Game.Overlays private OsuColour colours { get; set; } = null!; private Bindable allowTrackControl = null!; + private readonly BindableBool shuffle = new BindableBool(true); public NowPlayingOverlay() { - Width = 400; + Width = player_width; Margin = new MarginPadding(margin); } @@ -101,7 +105,7 @@ namespace osu.Game.Overlays Children = new[] { background = Empty(), - title = new OsuSpriteText + title = new ScrollingTextContainer { Origin = Anchor.BottomCentre, Anchor = Anchor.TopCentre, @@ -110,7 +114,7 @@ namespace osu.Game.Overlays Colour = Color4.White, Text = @"Nothing to play", }, - artist = new OsuSpriteText + artist = new ScrollingTextContainer { Origin = Anchor.TopCentre, Anchor = Anchor.TopCentre, @@ -162,6 +166,14 @@ namespace osu.Game.Overlays }, } }, + shuffleButton = new MusicIconButton + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.Centre, + Position = new Vector2(bottom_black_area_height / 2, 0), + Action = shuffle.Toggle, + Icon = FontAwesome.Solid.Random, + }, playlistButton = new MusicIconButton { Origin = Anchor.Centre, @@ -225,6 +237,9 @@ namespace osu.Game.Overlays allowTrackControl = musicController.AllowTrackControl.GetBoundCopy(); allowTrackControl.BindValueChanged(_ => Scheduler.AddOnce(updateEnabledStates), true); + shuffle.BindTo(musicController.Shuffle); + shuffle.BindValueChanged(s => shuffleButton.FadeColour(s.NewValue ? colours.Yellow : Color4.White, 200, Easing.OutQuint), true); + musicController.TrackChanged += trackChanged; trackChanged(beatmap.Value); } @@ -319,15 +334,15 @@ namespace osu.Game.Overlays switch (direction) { case TrackChangeDirection.Next: - newBackground.Position = new Vector2(400, 0); + newBackground.Position = new Vector2(player_width, 0); newBackground.MoveToX(0, 500, Easing.OutCubic); - background.MoveToX(-400, 500, Easing.OutCubic); + background.MoveToX(-player_width, 500, Easing.OutCubic); break; case TrackChangeDirection.Prev: - newBackground.Position = new Vector2(-400, 0); + newBackground.Position = new Vector2(-player_width, 0); newBackground.MoveToX(0, 500, Easing.OutCubic); - background.MoveToX(400, 500, Easing.OutCubic); + background.MoveToX(player_width, 500, Easing.OutCubic); break; } @@ -422,7 +437,7 @@ namespace osu.Game.Overlays [BackgroundDependencyLoader] private void load(LargeTextureStore textures) { - sprite.Texture = beatmap.GetBackground() ?? textures.Get(@"Backgrounds/bg4"); + sprite.Texture = beatmap.GetBackground() ?? textures.Get(@"Backgrounds/bg2"); } } @@ -469,5 +484,111 @@ namespace osu.Game.Overlays base.OnHoverLost(e); } } + + private partial class ScrollingTextContainer : CompositeDrawable + { + private const float initial_move_delay = 1000; + private const float pixels_per_second = 50; + + private OsuSpriteText mainSpriteText = null!; + private OsuSpriteText fillerSpriteText = null!; + + private Bindable showUnicode = null!; + + [Resolved] + private FrameworkConfigManager frameworkConfig { get; set; } = null!; + + private LocalisableString text; + + public LocalisableString Text + { + get => text; + set + { + text = value; + + if (IsLoaded) + updateText(); + } + } + + private FontUsage font = OsuFont.Default; + + public FontUsage Font + { + get => font; + set + { + font = value; + + if (IsLoaded) + updateFontAndText(); + } + } + + public ScrollingTextContainer() + { + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Children = new[] + { + mainSpriteText = new OsuSpriteText { Padding = new MarginPadding { Horizontal = margin } }, + fillerSpriteText = new OsuSpriteText { Padding = new MarginPadding { Horizontal = margin }, Alpha = 0 }, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + showUnicode = frameworkConfig.GetBindable(FrameworkSetting.ShowUnicode); + showUnicode.BindValueChanged(_ => updateText()); + + updateFontAndText(); + } + + private void updateFontAndText() + { + mainSpriteText.Font = font; + fillerSpriteText.Font = font; + + updateText(); + } + + private void updateText() + { + mainSpriteText.Text = text; + fillerSpriteText.Alpha = 0; + + ClearTransforms(); + X = 0; + + float textOverflowWidth = mainSpriteText.Width - player_width; + + // apply half margin of tolerance on both sides before the text scrolls + if (textOverflowWidth > margin) + { + fillerSpriteText.Alpha = 1; + fillerSpriteText.Text = text; + + float initialX = (textOverflowWidth + mainSpriteText.Width) / 2; + float targetX = (textOverflowWidth - mainSpriteText.Width) / 2; + + this.MoveToX(initialX) + .Delay(initial_move_delay) + .MoveToX(targetX, mainSpriteText.Width * 1000 / pixels_per_second) + .Loop(); + } + } + } } } diff --git a/osu.Game/Overlays/OSD/SpeedChangeToast.cs b/osu.Game/Overlays/OSD/SpeedChangeToast.cs new file mode 100644 index 0000000000..49d3985b04 --- /dev/null +++ b/osu.Game/Overlays/OSD/SpeedChangeToast.cs @@ -0,0 +1,17 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Configuration; +using osu.Game.Input.Bindings; +using osu.Game.Localisation; + +namespace osu.Game.Overlays.OSD +{ + public partial class SpeedChangeToast : Toast + { + public SpeedChangeToast(OsuConfigManager config, double newSpeed) + : base(ModSelectOverlayStrings.ModCustomisation, ToastStrings.SpeedChangedTo(newSpeed), config.LookupKeyBindings(GlobalAction.IncreaseModSpeed) + " / " + config.LookupKeyBindings(GlobalAction.DecreaseModSpeed)) + { + } + } +} diff --git a/osu.Game/Overlays/OverlayColourProvider.cs b/osu.Game/Overlays/OverlayColourProvider.cs index a4f6527024..9f5583cf73 100644 --- a/osu.Game/Overlays/OverlayColourProvider.cs +++ b/osu.Game/Overlays/OverlayColourProvider.cs @@ -1,19 +1,25 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using osuTK; using osuTK.Graphics; namespace osu.Game.Overlays { public class OverlayColourProvider { - private readonly OverlayColourScheme colourScheme; + /// + /// The hue degree associated with the colour shades provided by this . + /// + public int Hue { get; private set; } public OverlayColourProvider(OverlayColourScheme colourScheme) + : this(colourScheme.GetHue()) { - this.colourScheme = colourScheme; + } + + public OverlayColourProvider(int hue) + { + Hue = hue; } // Note that the following five colours are also defined in `OsuColour` as `{colourScheme}{0,1,2,3,4}`. @@ -47,56 +53,20 @@ namespace osu.Game.Overlays public Color4 Background5 => getColour(0.1f, 0.15f); public Color4 Background6 => getColour(0.1f, 0.1f); - private Color4 getColour(float saturation, float lightness) => Color4.FromHsl(new Vector4(getBaseHue(colourScheme), saturation, lightness, 1)); + /// + /// Changes the to a different degree. + /// Note that this does not trigger any kind of signal to any drawable that received colours from here, all drawables need to be updated manually. + /// + /// The proposed colour scheme. + public void ChangeColourScheme(OverlayColourScheme colourScheme) => ChangeColourScheme(colourScheme.GetHue()); - // See https://github.com/ppy/osu-web/blob/5a536d217a21582aad999db50a981003d3ad5659/app/helpers.php#L1620-L1628 - private static float getBaseHue(OverlayColourScheme colourScheme) - { - switch (colourScheme) - { - default: - throw new ArgumentException($@"{colourScheme} colour scheme does not provide a hue value in {nameof(getBaseHue)}."); + /// + /// Changes the to a different degree. + /// Note that this does not trigger any kind of signal to any drawable that received colours from here, all drawables need to be updated manually. + /// + /// The proposed hue degree. + public void ChangeColourScheme(int hue) => Hue = hue; - case OverlayColourScheme.Red: - return 0; - - case OverlayColourScheme.Pink: - return 333 / 360f; - - case OverlayColourScheme.Orange: - return 45 / 360f; - - case OverlayColourScheme.Lime: - return 90 / 360f; - - case OverlayColourScheme.Green: - return 125 / 360f; - - case OverlayColourScheme.Aquamarine: - return 160 / 360f; - - case OverlayColourScheme.Purple: - return 255 / 360f; - - case OverlayColourScheme.Blue: - return 200 / 360f; - - case OverlayColourScheme.Plum: - return 320 / 360f; - } - } - } - - public enum OverlayColourScheme - { - Red, - Pink, - Orange, - Lime, - Green, - Purple, - Blue, - Plum, - Aquamarine + private Color4 getColour(float saturation, float lightness) => Framework.Graphics.Colour4.FromHSL(Hue / 360f, saturation, lightness); } } diff --git a/osu.Game/Overlays/OverlayColourScheme.cs b/osu.Game/Overlays/OverlayColourScheme.cs new file mode 100644 index 0000000000..0126f9060f --- /dev/null +++ b/osu.Game/Overlays/OverlayColourScheme.cs @@ -0,0 +1,60 @@ +// 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.Overlays +{ + public enum OverlayColourScheme + { + Red, + Orange, + Lime, + Green, + Aquamarine, + Blue, + Purple, + Plum, + Pink, + } + + public static class OverlayColourSchemeExtensions + { + public static int GetHue(this OverlayColourScheme colourScheme) + { + // See https://github.com/ppy/osu-web/blob/5a536d217a21582aad999db50a981003d3ad5659/app/helpers.php#L1620-L1628 + switch (colourScheme) + { + default: + throw new ArgumentOutOfRangeException(nameof(colourScheme)); + + case OverlayColourScheme.Red: + return 0; + + case OverlayColourScheme.Orange: + return 45; + + case OverlayColourScheme.Lime: + return 90; + + case OverlayColourScheme.Green: + return 125; + + case OverlayColourScheme.Aquamarine: + return 160; + + case OverlayColourScheme.Blue: + return 200; + + case OverlayColourScheme.Purple: + return 255; + + case OverlayColourScheme.Plum: + return 320; + + case OverlayColourScheme.Pink: + return 333; + } + } + } +} diff --git a/osu.Game/Overlays/OverlayScrollContainer.cs b/osu.Game/Overlays/OverlayScrollContainer.cs index 9ff0a65652..4328977a8d 100644 --- a/osu.Game/Overlays/OverlayScrollContainer.cs +++ b/osu.Game/Overlays/OverlayScrollContainer.cs @@ -5,6 +5,8 @@ using System.Collections.Generic; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -112,8 +114,12 @@ namespace osu.Game.Overlays public Bindable LastScrollTarget = new Bindable(); + protected override HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverSounds(); + + private Sample scrollToTopSample; + private Sample scrollToPreviousSample; + public ScrollBackButton() - : base(HoverSampleSet.ScrollToTop) { Size = new Vector2(50); Alpha = 0; @@ -150,11 +156,14 @@ namespace osu.Game.Overlays } [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) + private void load(OverlayColourProvider colourProvider, AudioManager audio) { IdleColour = colourProvider.Background6; HoverColour = colourProvider.Background5; flashColour = colourProvider.Light1; + + scrollToTopSample = audio.Samples.Get(@"UI/scroll-to-top"); + scrollToPreviousSample = audio.Samples.Get(@"UI/scroll-to-previous"); } protected override void LoadComplete() @@ -163,7 +172,7 @@ namespace osu.Game.Overlays LastScrollTarget.BindValueChanged(target => { - spriteIcon.RotateTo(target.NewValue != null ? 180 : 0, fade_duration, Easing.OutQuint); + spriteIcon.ScaleTo(target.NewValue != null ? new Vector2(1f, -1f) : Vector2.One, fade_duration, Easing.OutQuint); TooltipText = target.NewValue != null ? CommonStrings.ButtonsBackToPrevious : CommonStrings.ButtonsBackToTop; }, true); } @@ -171,6 +180,12 @@ namespace osu.Game.Overlays protected override bool OnClick(ClickEvent e) { background.FlashColour(flashColour, 800, Easing.OutQuint); + + if (LastScrollTarget.Value == null) + scrollToTopSample?.Play(); + else + scrollToPreviousSample?.Play(); + return base.OnClick(e); } diff --git a/osu.Game/Overlays/Profile/Header/Components/DailyChallengeStatsDisplay.cs b/osu.Game/Overlays/Profile/Header/Components/DailyChallengeStatsDisplay.cs new file mode 100644 index 0000000000..3e86b2268f --- /dev/null +++ b/osu.Game/Overlays/Profile/Header/Components/DailyChallengeStatsDisplay.cs @@ -0,0 +1,124 @@ +// 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.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Localisation; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Overlays.Profile.Header.Components +{ + public partial class DailyChallengeStatsDisplay : CompositeDrawable, IHasCustomTooltip + { + public readonly Bindable User = new Bindable(); + + public DailyChallengeTooltipData? TooltipContent { get; private set; } + + private OsuSpriteText dailyPlayCount = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + CornerRadius = 5; + Masking = true; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4, + }, + new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Padding = new MarginPadding(5f), + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(size: 12)) + { + AutoSizeAxes = Axes.Both, + // can't use this because osu-web does weird stuff with \\n. + // Text = UsersStrings.ShowDailyChallengeTitle., + Text = "Daily\nChallenge", + Margin = new MarginPadding { Horizontal = 5f, Bottom = 2f }, + }, + new Container + { + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + CornerRadius = 5f, + Masking = true, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background6, + }, + dailyPlayCount = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + UseFullGlyphHeight = false, + Colour = colourProvider.Content2, + Margin = new MarginPadding { Horizontal = 10f, Vertical = 5f }, + }, + } + }, + } + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + User.BindValueChanged(_ => updateDisplay(), true); + } + + private void updateDisplay() + { + if (User.Value == null) + { + Hide(); + return; + } + + APIUserDailyChallengeStatistics stats = User.Value.User.DailyChallengeStatistics; + + if (stats.PlayCount == 0) + { + Hide(); + return; + } + + dailyPlayCount.Text = DailyChallengeStatsDisplayStrings.UnitDay(stats.PlayCount.ToLocalisableString("N0")); + dailyPlayCount.Colour = colours.ForRankingTier(DailyChallengeStatsTooltip.TierForPlayCount(stats.PlayCount)); + + TooltipContent = new DailyChallengeTooltipData(colourProvider, stats); + + Show(); + } + + public ITooltip GetCustomTooltip() => new DailyChallengeStatsTooltip(); + } +} diff --git a/osu.Game/Overlays/Profile/Header/Components/DailyChallengeStatsTooltip.cs b/osu.Game/Overlays/Profile/Header/Components/DailyChallengeStatsTooltip.cs new file mode 100644 index 0000000000..24e531bd87 --- /dev/null +++ b/osu.Game/Overlays/Profile/Header/Components/DailyChallengeStatsTooltip.cs @@ -0,0 +1,254 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Localisation; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Scoring; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays.Profile.Header.Components +{ + public partial class DailyChallengeStatsTooltip : VisibilityContainer, ITooltip + { + private StreakPiece currentDaily = null!; + private StreakPiece currentWeekly = null!; + private StreakPiece totalParticipation = null!; + private StatisticsPiece bestDaily = null!; + private StatisticsPiece bestWeekly = null!; + private StatisticsPiece topTen = null!; + private StatisticsPiece topFifty = null!; + + private Box topBackground = null!; + private Box background = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + CornerRadius = 20f; + Masking = true; + + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Colour = Color4.Black.Opacity(0.25f), + Radius = 30f, + }; + + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new Container + { + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + topBackground = new Box + { + RelativeSizeAxes = Axes.Both, + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Padding = new MarginPadding(15f), + Spacing = new Vector2(30f), + Children = new[] + { + totalParticipation = new StreakPiece(UsersStrings.ShowDailyChallengePlaycount), + currentDaily = new StreakPiece(UsersStrings.ShowDailyChallengeDailyStreakCurrent), + currentWeekly = new StreakPiece(UsersStrings.ShowDailyChallengeWeeklyStreakCurrent), + } + }, + } + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(15f), + Spacing = new Vector2(10f), + Children = new[] + { + bestDaily = new StatisticsPiece(UsersStrings.ShowDailyChallengeDailyStreakBest), + bestWeekly = new StatisticsPiece(UsersStrings.ShowDailyChallengeWeeklyStreakBest), + topTen = new StatisticsPiece(UsersStrings.ShowDailyChallengeTop10pPlacements), + topFifty = new StatisticsPiece(UsersStrings.ShowDailyChallengeTop50pPlacements), + } + }, + } + } + }; + } + + public void SetContent(DailyChallengeTooltipData content) + { + var statistics = content.Statistics; + var colourProvider = content.ColourProvider; + + background.Colour = colourProvider.Background4; + topBackground.Colour = colourProvider.Background5; + + totalParticipation.Value = DailyChallengeStatsDisplayStrings.UnitDay(statistics.PlayCount.ToLocalisableString(@"N0")); + totalParticipation.ValueColour = colours.ForRankingTier(TierForPlayCount(statistics.PlayCount)); + + currentDaily.Value = DailyChallengeStatsDisplayStrings.UnitDay(content.Statistics.DailyStreakCurrent.ToLocalisableString(@"N0")); + currentDaily.ValueColour = colours.ForRankingTier(TierForDaily(statistics.DailyStreakCurrent)); + + currentWeekly.Value = DailyChallengeStatsDisplayStrings.UnitWeek(statistics.WeeklyStreakCurrent.ToLocalisableString(@"N0")); + currentWeekly.ValueColour = colours.ForRankingTier(TierForWeekly(statistics.WeeklyStreakCurrent)); + + bestDaily.Value = DailyChallengeStatsDisplayStrings.UnitDay(statistics.DailyStreakBest.ToLocalisableString(@"N0")); + bestDaily.ValueColour = colours.ForRankingTier(TierForDaily(statistics.DailyStreakBest)); + + bestWeekly.Value = DailyChallengeStatsDisplayStrings.UnitWeek(statistics.WeeklyStreakBest.ToLocalisableString(@"N0")); + bestWeekly.ValueColour = colours.ForRankingTier(TierForWeekly(statistics.WeeklyStreakBest)); + + topTen.Value = statistics.Top10PercentPlacements.ToLocalisableString(@"N0"); + topTen.ValueColour = colourProvider.Content2; + + topFifty.Value = statistics.Top50PercentPlacements.ToLocalisableString(@"N0"); + topFifty.ValueColour = colourProvider.Content2; + } + + // reference: https://github.com/ppy/osu-web/blob/adf1e94754ba9625b85eba795f4a310caf169eec/resources/js/profile-page/daily-challenge.tsx#L13-L47 + + // Rounding up is needed here to ensure the overlay shows the same colour as osu-web for the play count. + // This is because, for example, 31 / 3 > 10 in JavaScript because floats are used, while here it would + // get truncated to 10 with an integer division and show a lower tier. + public static RankingTier TierForPlayCount(int playCount) => TierForDaily((int)Math.Ceiling(playCount / 3.0d)); + + public static RankingTier TierForDaily(int daily) + { + if (daily > 360) + return RankingTier.Lustrous; + + if (daily > 240) + return RankingTier.Radiant; + + if (daily > 120) + return RankingTier.Rhodium; + + if (daily > 60) + return RankingTier.Platinum; + + if (daily > 30) + return RankingTier.Gold; + + if (daily > 10) + return RankingTier.Silver; + + if (daily > 5) + return RankingTier.Bronze; + + return RankingTier.Iron; + } + + public static RankingTier TierForWeekly(int weekly) => TierForDaily((weekly - 1) * 7); + + protected override void PopIn() => this.FadeIn(200, Easing.OutQuint); + + protected override void PopOut() => this.FadeOut(200, Easing.OutQuint); + + public void Move(Vector2 pos) => Position = pos; + + private partial class StreakPiece : FillFlowContainer + { + private readonly OsuSpriteText valueText; + + public LocalisableString Value + { + set => valueText.Text = value; + } + + public ColourInfo ValueColour + { + set => valueText.Colour = value; + } + + public StreakPiece(LocalisableString title) + { + AutoSizeAxes = Axes.Both; + Direction = FillDirection.Vertical; + + Children = new Drawable[] + { + new OsuSpriteText + { + Font = OsuFont.GetFont(size: 12), + Text = title, + }, + valueText = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 40, weight: FontWeight.Light), + } + }; + } + } + + private partial class StatisticsPiece : CompositeDrawable + { + private readonly OsuSpriteText valueText; + + public LocalisableString Value + { + set => valueText.Text = value; + } + + public ColourInfo ValueColour + { + set => valueText.Colour = value; + } + + public StatisticsPiece(LocalisableString title) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChildren = new Drawable[] + { + new OsuSpriteText + { + Font = OsuFont.GetFont(size: 12), + Text = title, + }, + valueText = new OsuSpriteText + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Font = OsuFont.GetFont(size: 12), + } + }; + } + } + } + + public record DailyChallengeTooltipData(OverlayColourProvider ColourProvider, APIUserDailyChallengeStatistics Statistics); +} 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/MainDetails.cs b/osu.Game/Overlays/Profile/Header/Components/MainDetails.cs index 2505c1bc8c..3d97082230 100644 --- a/osu.Game/Overlays/Profile/Header/Components/MainDetails.cs +++ b/osu.Game/Overlays/Profile/Header/Components/MainDetails.cs @@ -44,22 +44,41 @@ namespace osu.Game.Overlays.Profile.Header.Components Spacing = new Vector2(0, 15), Children = new Drawable[] { - new FillFlowContainer + new GridContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(20), - Children = new Drawable[] + ColumnDimensions = new[] { - detailGlobalRank = new ProfileValueDisplay(true) + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, 20), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new[] { - Title = UsersStrings.ShowRankGlobalSimple, - }, - detailCountryRank = new ProfileValueDisplay(true) - { - Title = UsersStrings.ShowRankCountrySimple, - }, + detailGlobalRank = new ProfileValueDisplay(true) + { + Title = UsersStrings.ShowRankGlobalSimple, + }, + Empty(), + detailCountryRank = new ProfileValueDisplay(true) + { + Title = UsersStrings.ShowRankCountrySimple, + }, + new DailyChallengeStatsDisplay + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + User = { BindTarget = User }, + } + } } }, new Container diff --git a/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderButton.cs b/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderButton.cs index 414ca4d077..4fa72de5cc 100644 --- a/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderButton.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.Profile.Header.Components { @@ -14,6 +15,7 @@ namespace osu.Game.Overlays.Profile.Header.Components { private readonly Box background; private readonly Container content; + private readonly LoadingLayer loading; protected override Container Content => content; @@ -40,11 +42,22 @@ namespace osu.Game.Overlays.Profile.Header.Components AutoSizeAxes = Axes.X, RelativeSizeAxes = Axes.Y, Padding = new MarginPadding { Horizontal = 10 }, - } + }, + loading = new LoadingLayer(true, false) } }); } + protected void ShowLoadingLayer() + { + loading.Show(); + } + + protected void HideLoadingLayer() + { + loading.Hide(); + } + [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) { diff --git a/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderStatisticsButton.cs b/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderStatisticsButton.cs index 32c5ebee2c..3c2e603da8 100644 --- a/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderStatisticsButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderStatisticsButton.cs @@ -14,6 +14,7 @@ namespace osu.Game.Overlays.Profile.Header.Components public abstract partial class ProfileHeaderStatisticsButton : ProfileHeaderButton { private readonly OsuSpriteText drawableText; + private readonly Container iconContainer; protected ProfileHeaderStatisticsButton() { @@ -26,13 +27,11 @@ namespace osu.Game.Overlays.Profile.Header.Components Direction = FillDirection.Horizontal, Children = new Drawable[] { - new SpriteIcon + iconContainer = new Container { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Icon = Icon, - FillMode = FillMode.Fit, - Size = new Vector2(50, 14) + AutoSizeAxes = Axes.Both, }, drawableText = new OsuSpriteText { @@ -43,10 +42,24 @@ namespace osu.Game.Overlays.Profile.Header.Components } } }; + + SetIcon(Icon); } protected abstract IconUsage Icon { get; } + protected void SetIcon(IconUsage icon) + { + iconContainer.Child = new SpriteIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Icon = icon, + FillMode = FillMode.Fit, + Size = new Vector2(50, 14) + }; + } + protected void SetValue(int value) => drawableText.Text = value.ToLocalisableString("#,##0"); } } diff --git a/osu.Game/Overlays/Profile/Header/Components/ToggleCoverButton.cs b/osu.Game/Overlays/Profile/Header/Components/ToggleCoverButton.cs index 9171d5de7d..b2d024c1d7 100644 --- a/osu.Game/Overlays/Profile/Header/Components/ToggleCoverButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/ToggleCoverButton.cs @@ -50,12 +50,13 @@ namespace osu.Game.Overlays.Profile.Header.Components { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Size = new Vector2(10.5f, 12) + Size = new Vector2(10.5f, 12), + Icon = FontAwesome.Solid.ChevronDown, }; CoverExpanded.BindValueChanged(visible => updateState(visible.NewValue), true); } - private void updateState(bool detailsVisible) => icon.Icon = detailsVisible ? FontAwesome.Solid.ChevronUp : FontAwesome.Solid.ChevronDown; + private void updateState(bool detailsVisible) => icon.ScaleTo(detailsVisible ? new Vector2(1f, -1f) : Vector2.One, 300, Easing.OutQuint); } } diff --git a/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs index c9e5068b2a..165a576c03 100644 --- a/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs @@ -165,7 +165,6 @@ namespace osu.Game.Overlays.Profile.Header userFlag = new UpdateableFlag { Size = new Vector2(28, 20), - ShowPlaceholderOnUnknown = false, }, userCountryContainer = new OsuHoverContainer { diff --git a/osu.Game/Overlays/Rankings/SpotlightSelector.cs b/osu.Game/Overlays/Rankings/SpotlightSelector.cs index 190da04a5d..69dc8aba85 100644 --- a/osu.Game/Overlays/Rankings/SpotlightSelector.cs +++ b/osu.Game/Overlays/Rankings/SpotlightSelector.cs @@ -200,7 +200,7 @@ namespace osu.Game.Overlays.Rankings Text.Font = OsuFont.GetFont(size: 15); Text.Padding = new MarginPadding { Vertical = 1.5f }; // osu-web line-height difference compensation Foreground.Padding = new MarginPadding { Horizontal = 10, Vertical = 15 }; - Margin = Icon.Margin = new MarginPadding(0); + Margin = Chevron.Margin = new MarginPadding(0); } [BackgroundDependencyLoader] diff --git a/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs b/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs index 27d894cdc2..b9f7e443ca 100644 --- a/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs @@ -99,7 +99,6 @@ namespace osu.Game.Overlays.Rankings.Tables new UpdateableFlag(GetCountryCode(item)) { Size = new Vector2(28, 20), - ShowPlaceholderOnUnknown = false, }, CreateFlagContent(item) } diff --git a/osu.Game/Overlays/RevertToDefaultButton.cs b/osu.Game/Overlays/RevertToDefaultButton.cs index 6fa5209f64..1ebe7b7934 100644 --- a/osu.Game/Overlays/RevertToDefaultButton.cs +++ b/osu.Game/Overlays/RevertToDefaultButton.cs @@ -87,6 +87,7 @@ namespace osu.Game.Overlays protected override void LoadComplete() { base.LoadComplete(); + updateState(); FinishTransforms(true); } @@ -95,33 +96,50 @@ namespace osu.Game.Overlays protected override bool OnHover(HoverEvent e) { - UpdateState(); + updateHover(); return false; } protected override void OnHoverLost(HoverLostEvent e) { - UpdateState(); + updateHover(); } public void UpdateState() => Scheduler.AddOnce(updateState); private const double fade_duration = 200; + private bool? isDisplayed; + private void updateState() { if (current == null) return; - Enabled.Value = !current.Disabled; + // Avoid running animations if we are already in an up-to-date state. + if (Enabled.Value == !current.Disabled && isDisplayed == !current.IsDefault) + return; - if (current.IsDefault) + Enabled.Value = !current.Disabled; + isDisplayed = !current.IsDefault; + + updateHover(); + + if (isDisplayed == false) this.FadeTo(0, fade_duration, Easing.OutQuint); else if (current.Disabled) this.FadeTo(0.2f, fade_duration, Easing.OutQuint); else - this.FadeTo(1, fade_duration, Easing.OutQuint); + { + icon.RotateTo(150).RotateTo(0, fade_duration * 2, Easing.OutQuint); + icon.ScaleTo(0.7f).ScaleTo(1, fade_duration * 2, Easing.OutQuint); + this.FadeTo(1, fade_duration, Easing.OutQuint); + } + } + + private void updateHover() + { if (IsHovered && Enabled.Value) { icon.RotateTo(-40, 500, Easing.OutQuint); diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/HUDSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/HUDSettings.cs index 3e67b2f103..f4dd319152 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/HUDSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/HUDSettings.cs @@ -41,6 +41,11 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay Current = config.GetBindable(OsuSetting.GameplayLeaderboard), }, new SettingsCheckbox + { + LabelText = GameplaySettingsStrings.AlwaysShowHoldForMenuButton, + Current = config.GetBindable(OsuSetting.AlwaysShowHoldForMenuButton), + }, + new SettingsCheckbox { ClassicDefault = false, LabelText = GameplaySettingsStrings.ShowHealthDisplayWhenCantFail, diff --git a/osu.Game/Overlays/Settings/Sections/Input/GlobalKeyBindingsSection.cs b/osu.Game/Overlays/Settings/Sections/Input/GlobalKeyBindingsSection.cs index 5a05d78905..e5bc6cbe8a 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/GlobalKeyBindingsSection.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/GlobalKeyBindingsSection.cs @@ -31,6 +31,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input new GlobalKeyBindingsSubsection(InputSettingsStrings.InGameSection, GlobalActionCategory.InGame), new GlobalKeyBindingsSubsection(InputSettingsStrings.ReplaySection, GlobalActionCategory.Replay), new GlobalKeyBindingsSubsection(InputSettingsStrings.EditorSection, GlobalActionCategory.Editor), + new GlobalKeyBindingsSubsection(InputSettingsStrings.EditorTestPlaySection, GlobalActionCategory.EditorTestPlay), }); } } diff --git a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs index e82cebe9f4..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; @@ -465,7 +465,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input } if (HasFocus) - GetContainingInputManager().ChangeFocus(null); + GetContainingFocusManager()!.ChangeFocus(null); cancelAndClearButtons.FadeOut(300, Easing.OutQuint); cancelAndClearButtons.BypassAutoSizeAxes |= Axes.Y; @@ -489,12 +489,25 @@ namespace osu.Game.Overlays.Settings.Sections.Input base.OnFocusLost(e); } - private void tryPersistKeyBinding(RealmKeyBinding keyBinding, bool advanceToNextBinding) + private bool isConflictingBinding(RealmKeyBinding first, RealmKeyBinding second, bool restoringDefaults) + { + if (first.ID == second.ID) + return false; + + // ignore conflicts with same action bindings during revert. the assumption is that the other binding will be reverted subsequently in the same higher-level operation. + // this happens if the bindings for an action are rebound to the same keys, but the ordering of the bindings itself is different. + if (restoringDefaults && first.ActionInt == second.ActionInt) + return false; + + return first.KeyCombination.Equals(second.KeyCombination); + } + + private void tryPersistKeyBinding(RealmKeyBinding keyBinding, bool advanceToNextBinding, bool restoringDefaults = false) { List bindings = GetAllSectionBindings(); RealmKeyBinding? existingBinding = keyBinding.KeyCombination.Equals(new KeyCombination(InputKey.None)) ? null - : bindings.FirstOrDefault(other => other.ID != keyBinding.ID && other.KeyCombination.Equals(keyBinding.KeyCombination)); + : bindings.FirstOrDefault(other => isConflictingBinding(keyBinding, other, restoringDefaults)); if (existingBinding == null) { diff --git a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingsSubsection.cs b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingsSubsection.cs index dd0a88bfb1..cde9f10549 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingsSubsection.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingsSubsection.cs @@ -106,7 +106,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input { var next = Children.SkipWhile(c => c != sender).Skip(1).FirstOrDefault(); if (next != null) - GetContainingInputManager().ChangeFocus(next); + GetContainingFocusManager()?.ChangeFocus(next); } } } diff --git a/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs index 7805ed5834..6eb512fa35 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs @@ -105,12 +105,17 @@ namespace osu.Game.Overlays.Settings.Sections.Input highPrecisionMouse.Current.BindValueChanged(highPrecision => { - if (RuntimeInfo.OS != RuntimeInfo.Platform.Windows) + switch (RuntimeInfo.OS) { - if (highPrecision.NewValue) - highPrecisionMouse.SetNoticeText(MouseSettingsStrings.HighPrecisionPlatformWarning, true); - else - highPrecisionMouse.ClearNoticeText(); + case RuntimeInfo.Platform.Linux: + case RuntimeInfo.Platform.macOS: + case RuntimeInfo.Platform.iOS: + if (highPrecision.NewValue) + highPrecisionMouse.SetNoticeText(MouseSettingsStrings.HighPrecisionPlatformWarning, true); + else + highPrecisionMouse.ClearNoticeText(); + + break; } }, true); } diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/BeatmapSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/BeatmapSettings.cs index 4b1836ed86..597e03fab2 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/BeatmapSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/BeatmapSettings.cs @@ -16,6 +16,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance private SettingsButton deleteBeatmapsButton = null!; private SettingsButton deleteBeatmapVideosButton = null!; + private SettingsButton resetOffsetsButton = null!; private SettingsButton restoreButton = null!; private SettingsButton undeleteButton = null!; @@ -31,7 +32,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance { deleteBeatmapsButton.Enabled.Value = false; Task.Run(() => beatmaps.Delete()).ContinueWith(_ => Schedule(() => deleteBeatmapsButton.Enabled.Value = true)); - })); + }, DeleteConfirmationContentStrings.Beatmaps)); } }); @@ -40,13 +41,27 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance Text = MaintenanceSettingsStrings.DeleteAllBeatmapVideos, Action = () => { - dialogOverlay?.Push(new MassVideoDeleteConfirmationDialog(() => + dialogOverlay?.Push(new MassDeleteConfirmationDialog(() => { deleteBeatmapVideosButton.Enabled.Value = false; Task.Run(beatmaps.DeleteAllVideos).ContinueWith(_ => Schedule(() => deleteBeatmapVideosButton.Enabled.Value = true)); - })); + }, DeleteConfirmationContentStrings.BeatmapVideos)); } }); + + Add(resetOffsetsButton = new DangerousSettingsButton + { + Text = MaintenanceSettingsStrings.ResetAllOffsets, + Action = () => + { + dialogOverlay?.Push(new MassDeleteConfirmationDialog(() => + { + resetOffsetsButton.Enabled.Value = false; + Task.Run(beatmaps.ResetAllOffsets).ContinueWith(_ => Schedule(() => resetOffsetsButton.Enabled.Value = true)); + }, DeleteConfirmationContentStrings.Offsets)); + } + }); + AddRange(new Drawable[] { restoreButton = new SettingsButton diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/CollectionsSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/CollectionsSettings.cs index b373535a8b..b1c44aa93c 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/CollectionsSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/CollectionsSettings.cs @@ -29,7 +29,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance Text = MaintenanceSettingsStrings.DeleteAllCollections, Action = () => { - dialogOverlay?.Push(new MassDeleteConfirmationDialog(deleteAllCollections)); + dialogOverlay?.Push(new MassDeleteConfirmationDialog(deleteAllCollections, DeleteConfirmationContentStrings.Collections)); } }); } diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/DirectorySelectScreen.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/DirectorySelectScreen.cs index e87ca32bf6..f6f8d3b336 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/DirectorySelectScreen.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/DirectorySelectScreen.cs @@ -48,8 +48,11 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance /// protected virtual DirectoryInfo InitialPath => null; + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { InternalChild = new Container { @@ -64,7 +67,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance new Box { RelativeSizeAxes = Axes.Both, - Colour = colours.GreySeaFoamDark + Colour = colourProvider.Background4, }, new GridContainer { diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/MassDeleteConfirmationDialog.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/MassDeleteConfirmationDialog.cs index 99ef62d94b..a7a7ee2590 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/MassDeleteConfirmationDialog.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/MassDeleteConfirmationDialog.cs @@ -2,15 +2,18 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Overlays.Dialog; namespace osu.Game.Overlays.Settings.Sections.Maintenance { public partial class MassDeleteConfirmationDialog : DangerousActionDialog { - public MassDeleteConfirmationDialog(Action deleteAction) + public MassDeleteConfirmationDialog(Action deleteAction, LocalisableString deleteContent) { - BodyText = "Everything?"; + BodyText = deleteContent; + Icon = FontAwesome.Solid.Trash; DangerousAction = deleteAction; } } diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/MassVideoDeleteConfirmationDialog.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/MassVideoDeleteConfirmationDialog.cs deleted file mode 100644 index 6312e09b3e..0000000000 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/MassVideoDeleteConfirmationDialog.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; - -namespace osu.Game.Overlays.Settings.Sections.Maintenance -{ - public partial class MassVideoDeleteConfirmationDialog : MassDeleteConfirmationDialog - { - public MassVideoDeleteConfirmationDialog(Action deleteAction) - : base(deleteAction) - { - BodyText = "All beatmap videos? This cannot be undone!"; - } - } -} diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs index 5b24460ac2..3bba480aaa 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs @@ -6,17 +6,14 @@ using System.IO; using System.Threading.Tasks; using osu.Framework.Allocation; -using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Logging; -using osu.Framework.Platform; using osu.Framework.Screens; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; -using osu.Game.Overlays.Notifications; using osu.Game.Screens; using osuTK; @@ -29,15 +26,6 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance [Resolved(canBeNull: true)] private OsuGame game { get; set; } - [Resolved] - private INotificationOverlay notifications { get; set; } - - [Resolved] - private Storage storage { get; set; } - - [Resolved] - private GameHost host { get; set; } - public override bool AllowBackButton => false; public override bool AllowExternalScreenChange => false; @@ -99,8 +87,6 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance Beatmap.Value = Beatmap.Default; - var originalStorage = new NativeStorage(storage.GetFullPath(string.Empty), host); - migrationTask = Task.Run(PerformMigration) .ContinueWith(task => { @@ -108,18 +94,6 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance { Logger.Error(task.Exception, $"Error during migration: {task.Exception?.Message}"); } - else if (!task.GetResultSafely()) - { - notifications.Post(new SimpleNotification - { - Text = MaintenanceSettingsStrings.FailedCleanupNotification, - Activated = () => - { - originalStorage.PresentExternally(); - return true; - } - }); - } Schedule(this.Exit); }); diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/ModPresetSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/ModPresetSettings.cs index f0d6d10e51..9c55308abe 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/ModPresetSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/ModPresetSettings.cs @@ -42,7 +42,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance { deleteAllButton.Enabled.Value = false; Task.Run(deleteAllModPresets).ContinueWith(t => Schedule(onAllModPresetsDeleted, t)); - })); + }, DeleteConfirmationContentStrings.ModPresets)); } }, undeleteButton = new SettingsButton diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/ScoreSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/ScoreSettings.cs index c6f4f1e1a5..235f239c7c 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/ScoreSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/ScoreSettings.cs @@ -27,7 +27,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance { deleteScoresButton.Enabled.Value = false; Task.Run(() => scores.Delete()).ContinueWith(_ => Schedule(() => deleteScoresButton.Enabled.Value = true)); - })); + }, DeleteConfirmationContentStrings.Scores)); } }); } diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/SkinSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/SkinSettings.cs index c3ac49af6d..e962118a36 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/SkinSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/SkinSettings.cs @@ -27,7 +27,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance { deleteSkinsButton.Enabled.Value = false; Task.Run(() => skins.Delete()).ContinueWith(_ => Schedule(() => deleteSkinsButton.Enabled.Value = true)); - })); + }, DeleteConfirmationContentStrings.Skins)); } }); } diff --git a/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs b/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs index e7b6aa56a8..7bd0829add 100644 --- a/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs @@ -28,6 +28,11 @@ namespace osu.Game.Overlays.Settings.Sections.Online LabelText = OnlineSettingsStrings.NotifyOnPrivateMessage, Current = config.GetBindable(OsuSetting.NotifyOnPrivateMessage) }, + new SettingsCheckbox + { + LabelText = OnlineSettingsStrings.HideCountryFlags, + Current = config.GetBindable(OsuSetting.HideCountryFlags) + }, }; } } diff --git a/osu.Game/Overlays/Settings/Sections/OnlineSection.cs b/osu.Game/Overlays/Settings/Sections/OnlineSection.cs index 1484f2c756..6593eb69fa 100644 --- a/osu.Game/Overlays/Settings/Sections/OnlineSection.cs +++ b/osu.Game/Overlays/Settings/Sections/OnlineSection.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 osu.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; @@ -25,8 +26,10 @@ namespace osu.Game.Overlays.Settings.Sections { new WebSettings(), new AlertsAndPrivacySettings(), - new IntegrationSettings() }; + + if (RuntimeInfo.IsDesktop) + Add(new IntegrationSettings()); } } } diff --git a/osu.Game/Overlays/Settings/Sections/SizeSlider.cs b/osu.Game/Overlays/Settings/Sections/SizeSlider.cs index c73831d8d1..14ef58ff88 100644 --- a/osu.Game/Overlays/Settings/Sections/SizeSlider.cs +++ b/osu.Game/Overlays/Settings/Sections/SizeSlider.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Numerics; using System.Globalization; using osu.Framework.Localisation; using osu.Game.Graphics.UserInterface; @@ -12,7 +13,7 @@ namespace osu.Game.Overlays.Settings.Sections /// A slider intended to show a "size" multiplier number, where 1x is 1.0. /// public partial class SizeSlider : RoundedSliderBar - where T : struct, IEquatable, IComparable, IConvertible, IFormattable + where T : struct, INumber, IMinMaxValue, IFormattable { public override LocalisableString TooltipText => Current.Value.ToString(@"0.##x", NumberFormatInfo.CurrentInfo); } diff --git a/osu.Game/Overlays/Settings/SettingsButton.cs b/osu.Game/Overlays/Settings/SettingsButton.cs index a837444758..3f5d612eb8 100644 --- a/osu.Game/Overlays/Settings/SettingsButton.cs +++ b/osu.Game/Overlays/Settings/SettingsButton.cs @@ -20,13 +20,13 @@ namespace osu.Game.Overlays.Settings Padding = new MarginPadding { Left = SettingsPanel.CONTENT_MARGINS, Right = SettingsPanel.CONTENT_MARGINS }; } - public LocalisableString TooltipText { get; set; } - public IEnumerable Keywords { get; set; } = Array.Empty(); public BindableBool CanBeShown { get; } = new BindableBool(true); IBindable IConditionalFilterable.CanBeShown => CanBeShown; + public LocalisableString TooltipText { get; set; } + public override IEnumerable FilterTerms { get diff --git a/osu.Game/Overlays/Settings/SettingsColour.cs b/osu.Game/Overlays/Settings/SettingsColour.cs new file mode 100644 index 0000000000..7a091f1a54 --- /dev/null +++ b/osu.Game/Overlays/Settings/SettingsColour.cs @@ -0,0 +1,80 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterfaceV2; + +namespace osu.Game.Overlays.Settings +{ + public partial class SettingsColour : SettingsItem + { + protected override Drawable CreateControl() => new ColourControl(); + + public partial class ColourControl : OsuClickableContainer, IHasPopover, IHasCurrentValue + { + private readonly BindableWithCurrent current = new BindableWithCurrent(Colour4.White); + + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + private readonly Box fill; + private readonly OsuSpriteText colourHexCode; + + public ColourControl() + { + RelativeSizeAxes = Axes.X; + Height = 40; + CornerRadius = 20; + Masking = true; + Action = this.ShowPopover; + + Children = new Drawable[] + { + fill = new Box + { + RelativeSizeAxes = Axes.Both + }, + colourHexCode = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Default.With(size: 20) + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(_ => updateColour(), true); + } + + private void updateColour() + { + fill.Colour = Current.Value; + colourHexCode.Text = Current.Value.ToHex(); + colourHexCode.Colour = OsuColour.ForegroundTextColourFor(Current.Value); + } + + public Popover GetPopover() => new OsuPopover(false) + { + Child = new OsuColourPicker + { + Current = { BindTarget = Current } + } + }; + } + } +} diff --git a/osu.Game/Overlays/Settings/SettingsEnumDropdown.cs b/osu.Game/Overlays/Settings/SettingsEnumDropdown.cs index cf6bc30f85..2b74557c1a 100644 --- a/osu.Game/Overlays/Settings/SettingsEnumDropdown.cs +++ b/osu.Game/Overlays/Settings/SettingsEnumDropdown.cs @@ -2,7 +2,11 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Extensions; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.Settings @@ -10,6 +14,8 @@ namespace osu.Game.Overlays.Settings public partial class SettingsEnumDropdown : SettingsDropdown where T : struct, Enum { + public override IEnumerable FilterTerms => base.FilterTerms.Concat(Control.Items.Select(i => i.GetLocalisableDescription())); + protected override OsuDropdown CreateDropdown() => new DropdownControl(); protected new partial class DropdownControl : OsuEnumDropdown diff --git a/osu.Game/Overlays/Settings/SettingsPercentageSlider.cs b/osu.Game/Overlays/Settings/SettingsPercentageSlider.cs index fa59d18de1..d7a09d3392 100644 --- a/osu.Game/Overlays/Settings/SettingsPercentageSlider.cs +++ b/osu.Game/Overlays/Settings/SettingsPercentageSlider.cs @@ -1,7 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; +using System.Numerics; using osu.Framework.Graphics; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; @@ -13,7 +13,7 @@ namespace osu.Game.Overlays.Settings /// Mostly provided for convenience of use with . /// public partial class SettingsPercentageSlider : SettingsSlider - where TValue : struct, IEquatable, IComparable, IConvertible + where TValue : struct, INumber, IMinMaxValue { protected override Drawable CreateControl() => ((RoundedSliderBar)base.CreateControl()).With(sliderBar => sliderBar.DisplayAsPercentage = true); } diff --git a/osu.Game/Overlays/Settings/SettingsSidebar.cs b/osu.Game/Overlays/Settings/SettingsSidebar.cs index ddbcd60ef6..d24c0a778c 100644 --- a/osu.Game/Overlays/Settings/SettingsSidebar.cs +++ b/osu.Game/Overlays/Settings/SettingsSidebar.cs @@ -66,7 +66,7 @@ namespace osu.Game.Overlays.Settings [BackgroundDependencyLoader] private void load() { - Size = new Vector2(SettingsSidebar.EXPANDED_WIDTH); + Size = new Vector2(EXPANDED_WIDTH); Padding = new MarginPadding(40); diff --git a/osu.Game/Overlays/Settings/SettingsSlider.cs b/osu.Game/Overlays/Settings/SettingsSlider.cs index 6c81fece13..2460d78099 100644 --- a/osu.Game/Overlays/Settings/SettingsSlider.cs +++ b/osu.Game/Overlays/Settings/SettingsSlider.cs @@ -1,7 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; +using System.Numerics; using osu.Framework.Graphics; using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics.UserInterface; @@ -9,12 +9,12 @@ using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.Settings { public partial class SettingsSlider : SettingsSlider> - where T : struct, IEquatable, IComparable, IConvertible + where T : struct, INumber, IMinMaxValue { } public partial class SettingsSlider : SettingsItem - where TValue : struct, IEquatable, IComparable, IConvertible + where TValue : struct, INumber, IMinMaxValue where TSlider : RoundedSliderBar, new() { protected override Drawable CreateControl() => new TSlider diff --git a/osu.Game/Overlays/SettingsPanel.cs b/osu.Game/Overlays/SettingsPanel.cs index 748673035b..df50e0f339 100644 --- a/osu.Game/Overlays/SettingsPanel.cs +++ b/osu.Game/Overlays/SettingsPanel.cs @@ -201,7 +201,7 @@ namespace osu.Game.Overlays searchTextBox.HoldFocus = false; if (searchTextBox.HasFocus) - GetContainingInputManager().ChangeFocus(null); + GetContainingFocusManager()!.ChangeFocus(null); } public override bool AcceptsFocus => true; diff --git a/osu.Game/Overlays/SettingsToolboxGroup.cs b/osu.Game/Overlays/SettingsToolboxGroup.cs index de13bd96d4..53849fa53c 100644 --- a/osu.Game/Overlays/SettingsToolboxGroup.cs +++ b/osu.Game/Overlays/SettingsToolboxGroup.cs @@ -4,7 +4,6 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Caching; -using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -161,7 +160,7 @@ namespace osu.Game.Overlays protected override bool OnInvalidate(Invalidation invalidation, InvalidationSource source) { - if (invalidation.HasFlagFast(Invalidation.DrawSize)) + if (invalidation.HasFlag(Invalidation.DrawSize)) headerTextVisibilityCache.Invalidate(); return base.OnInvalidate(invalidation, source); diff --git a/osu.Game/Overlays/SkinEditor/SkinBlueprint.cs b/osu.Game/Overlays/SkinEditor/SkinBlueprint.cs index 8f8d899fad..6b59d940cc 100644 --- a/osu.Game/Overlays/SkinEditor/SkinBlueprint.cs +++ b/osu.Game/Overlays/SkinEditor/SkinBlueprint.cs @@ -40,7 +40,9 @@ namespace osu.Game.Overlays.SkinEditor public override bool Contains(Vector2 screenSpacePos) => drawableQuad.Contains(screenSpacePos); - public override Vector2 ScreenSpaceSelectionPoint => drawable.ToScreenSpace(drawable.OriginPosition); + public override Vector2 ScreenSpaceSelectionPoint => + // Important to use a stable position (not based on origin) as origin may be automatically updated during drag operations. + drawable.ScreenSpaceDrawQuad.Centre; protected override bool ReceivePositionalInputAtSubTree(Vector2 screenSpacePos) => drawableQuad.Contains(screenSpacePos); diff --git a/osu.Game/Overlays/SkinEditor/SkinComponentToolbox.cs b/osu.Game/Overlays/SkinEditor/SkinComponentToolbox.cs index a476fc1a6d..85becc1a23 100644 --- a/osu.Game/Overlays/SkinEditor/SkinComponentToolbox.cs +++ b/osu.Game/Overlays/SkinEditor/SkinComponentToolbox.cs @@ -24,7 +24,7 @@ namespace osu.Game.Overlays.SkinEditor { public Action? RequestPlacement; - private readonly SkinComponentsContainer target; + private readonly SkinnableContainer target; private readonly RulesetInfo? ruleset; @@ -35,7 +35,7 @@ namespace osu.Game.Overlays.SkinEditor /// /// The target. This is mainly used as a dependency source to find candidate components. /// A ruleset to filter components by. If null, only components which are not ruleset-specific will be included. - public SkinComponentToolbox(SkinComponentsContainer target, RulesetInfo? ruleset) + public SkinComponentToolbox(SkinnableContainer target, RulesetInfo? ruleset) : base(ruleset == null ? SkinEditorStrings.Components : LocalisableString.Interpolate($"{SkinEditorStrings.Components} ({ruleset.Name})")) { this.target = target; diff --git a/osu.Game/Overlays/SkinEditor/SkinEditor.cs b/osu.Game/Overlays/SkinEditor/SkinEditor.cs index bc929177d1..42908f7102 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditor.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditor.cs @@ -33,6 +33,7 @@ using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Components; using osu.Game.Screens.Edit.Components.Menus; using osu.Game.Skinning; +using osu.Framework.Graphics.Cursor; namespace osu.Game.Overlays.SkinEditor { @@ -72,7 +73,7 @@ namespace osu.Game.Overlays.SkinEditor [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); - private readonly Bindable selectedTarget = new Bindable(); + private readonly Bindable selectedTarget = new Bindable(); private bool hasBegunMutating; @@ -118,107 +119,111 @@ namespace osu.Game.Overlays.SkinEditor InternalChild = new OsuContextMenuContainer { RelativeSizeAxes = Axes.Both, - Child = new GridContainer + Child = new PopoverContainer { RelativeSizeAxes = Axes.Both, - RowDimensions = new[] + Child = new GridContainer { - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.AutoSize), - new Dimension(), - }, + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + }, - Content = new[] - { - new Drawable[] + Content = new[] { - new Container + new Drawable[] { - Name = @"Menu container", - RelativeSizeAxes = Axes.X, - Depth = float.MinValue, - Height = MENU_HEIGHT, - Children = new Drawable[] + new Container { - new EditorMenuBar + Name = @"Menu container", + RelativeSizeAxes = Axes.X, + Depth = float.MinValue, + Height = MENU_HEIGHT, + Children = new Drawable[] { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - RelativeSizeAxes = Axes.Both, - Items = new[] + new EditorMenuBar { - new MenuItem(CommonStrings.MenuBarFile) - { - Items = new OsuMenuItem[] - { - new EditorMenuItem(Web.CommonStrings.ButtonsSave, MenuItemType.Standard, () => Save()), - new EditorMenuItem(CommonStrings.Export, MenuItemType.Standard, () => skins.ExportCurrentSkin()) { Action = { Disabled = !RuntimeInfo.IsDesktop } }, - new OsuMenuItemSpacer(), - new EditorMenuItem(CommonStrings.RevertToDefault, MenuItemType.Destructive, () => dialogOverlay?.Push(new RevertConfirmDialog(revert))), - new OsuMenuItemSpacer(), - new EditorMenuItem(CommonStrings.Exit, MenuItemType.Standard, () => skinEditorOverlay?.Hide()), - }, - }, - new MenuItem(CommonStrings.MenuBarEdit) - { - Items = new OsuMenuItem[] - { - undoMenuItem = new EditorMenuItem(CommonStrings.Undo, MenuItemType.Standard, Undo), - redoMenuItem = new EditorMenuItem(CommonStrings.Redo, MenuItemType.Standard, Redo), - new OsuMenuItemSpacer(), - cutMenuItem = new EditorMenuItem(CommonStrings.Cut, MenuItemType.Standard, Cut), - copyMenuItem = new EditorMenuItem(CommonStrings.Copy, MenuItemType.Standard, Copy), - pasteMenuItem = new EditorMenuItem(CommonStrings.Paste, MenuItemType.Standard, Paste), - cloneMenuItem = new EditorMenuItem(CommonStrings.Clone, MenuItemType.Standard, Clone), - } - }, - } - }, - headerText = new OsuTextFlowContainer - { - TextAnchor = Anchor.TopRight, - Padding = new MarginPadding(5), - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - AutoSizeAxes = Axes.X, - RelativeSizeAxes = Axes.Y, - }, - }, - }, - }, - new Drawable[] - { - new SkinEditorSceneLibrary - { - RelativeSizeAxes = Axes.X, - }, - }, - new Drawable[] - { - new GridContainer - { - RelativeSizeAxes = Axes.Both, - ColumnDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(), - new Dimension(GridSizeMode.AutoSize), - }, - Content = new[] - { - new Drawable[] - { - componentsSidebar = new EditorSidebar(), - content = new Container - { - Depth = float.MaxValue, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, RelativeSizeAxes = Axes.Both, + Items = new[] + { + new MenuItem(CommonStrings.MenuBarFile) + { + Items = new OsuMenuItem[] + { + new EditorMenuItem(Web.CommonStrings.ButtonsSave, MenuItemType.Standard, () => Save()), + new EditorMenuItem(CommonStrings.Export, MenuItemType.Standard, () => skins.ExportCurrentSkin()) { Action = { Disabled = !RuntimeInfo.IsDesktop } }, + new OsuMenuItemSpacer(), + new EditorMenuItem(CommonStrings.RevertToDefault, MenuItemType.Destructive, () => dialogOverlay?.Push(new RevertConfirmDialog(revert))), + new OsuMenuItemSpacer(), + new EditorMenuItem(CommonStrings.Exit, MenuItemType.Standard, () => skinEditorOverlay?.Hide()), + }, + }, + new MenuItem(CommonStrings.MenuBarEdit) + { + Items = new OsuMenuItem[] + { + undoMenuItem = new EditorMenuItem(CommonStrings.Undo, MenuItemType.Standard, Undo), + redoMenuItem = new EditorMenuItem(CommonStrings.Redo, MenuItemType.Standard, Redo), + new OsuMenuItemSpacer(), + cutMenuItem = new EditorMenuItem(CommonStrings.Cut, MenuItemType.Standard, Cut), + copyMenuItem = new EditorMenuItem(CommonStrings.Copy, MenuItemType.Standard, Copy), + pasteMenuItem = new EditorMenuItem(CommonStrings.Paste, MenuItemType.Standard, Paste), + cloneMenuItem = new EditorMenuItem(CommonStrings.Clone, MenuItemType.Standard, Clone), + } + }, + } }, - settingsSidebar = new EditorSidebar(), + headerText = new OsuTextFlowContainer + { + TextAnchor = Anchor.TopRight, + Padding = new MarginPadding(5), + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + }, + }, + }, + }, + new Drawable[] + { + new SkinEditorSceneLibrary + { + RelativeSizeAxes = Axes.X, + }, + }, + new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new Drawable[] + { + componentsSidebar = new EditorSidebar(), + content = new Container + { + Depth = float.MaxValue, + RelativeSizeAxes = Axes.Both, + }, + settingsSidebar = new EditorSidebar(), + } } } - } - }, + }, + } } } }; @@ -255,8 +260,11 @@ namespace osu.Game.Overlays.SkinEditor // schedule ensures this only happens when the skin editor is visible. // also avoid some weird endless recursion / bindable feedback loop (something to do with tracking skins across three different bindable types). // probably something which will be factored out in a future database refactor so not too concerning for now. - currentSkin.BindValueChanged(_ => + currentSkin.BindValueChanged(val => { + if (val.OldValue != null && hasBegunMutating) + save(val.OldValue); + hasBegunMutating = false; Scheduler.AddOnce(skinChanged); }, true); @@ -327,7 +335,7 @@ namespace osu.Game.Overlays.SkinEditor } } - private void targetChanged(ValueChangedEvent target) + private void targetChanged(ValueChangedEvent target) { foreach (var toolbox in componentsSidebar.OfType()) toolbox.Expire(); @@ -353,11 +361,11 @@ namespace osu.Game.Overlays.SkinEditor componentsSidebar.Children = new[] { - new EditorSidebarSection("Current working layer") + new EditorSidebarSection(SkinEditorStrings.CurrentWorkingLayer) { Children = new Drawable[] { - new SettingsDropdown + new SettingsDropdown { Items = availableTargets.Select(t => t.Lookup).Distinct(), Current = selectedTarget, @@ -418,6 +426,9 @@ namespace osu.Game.Overlays.SkinEditor if (targetContainer != null) changeHandler = new SkinEditorChangeHandler(targetContainer); hasBegunMutating = true; + + // Reload sidebar components. + selectedTarget.TriggerChange(); } /// @@ -454,7 +465,7 @@ namespace osu.Game.Overlays.SkinEditor } SelectedComponents.Add(component); - SkinSelectionHandler.ApplyClosestAnchor(drawableComponent); + SkinSelectionHandler.ApplyClosestAnchorOrigin(drawableComponent); return true; } @@ -466,18 +477,18 @@ namespace osu.Game.Overlays.SkinEditor settingsSidebar.Add(new SkinSettingsToolbox(component)); } - private IEnumerable availableTargets => targetScreen.ChildrenOfType(); + private IEnumerable availableTargets => targetScreen.ChildrenOfType(); - private SkinComponentsContainer? getFirstTarget() => availableTargets.FirstOrDefault(); + private SkinnableContainer? getFirstTarget() => availableTargets.FirstOrDefault(); - private SkinComponentsContainer? getTarget(SkinComponentsContainerLookup? target) + private SkinnableContainer? getTarget(GlobalSkinnableContainerLookup? target) { return availableTargets.FirstOrDefault(c => c.Lookup.Equals(target)); } private void revert() { - SkinComponentsContainer[] targetContainers = availableTargets.ToArray(); + SkinnableContainer[] targetContainers = availableTargets.ToArray(); foreach (var t in targetContainers) { @@ -537,7 +548,11 @@ namespace osu.Game.Overlays.SkinEditor protected void Redo() => changeHandler?.RestoreState(1); - public void Save(bool userTriggered = true) + void IEditorChangeHandler.RestoreState(int direction) => changeHandler?.RestoreState(direction); + + public void Save(bool userTriggered = true) => save(currentSkin.Value, userTriggered); + + private void save(Skin skin, bool userTriggered = true) { if (!hasBegunMutating) return; @@ -545,17 +560,17 @@ namespace osu.Game.Overlays.SkinEditor if (targetScreen?.IsLoaded != true) return; - SkinComponentsContainer[] targetContainers = availableTargets.ToArray(); + SkinnableContainer[] targetContainers = availableTargets.ToArray(); if (!targetContainers.All(c => c.ComponentsLoaded)) return; foreach (var t in targetContainers) - currentSkin.Value.UpdateDrawableTarget(t); + skin.UpdateDrawableTarget(t); // In the case the save was user triggered, always show the save message to make them feel confident. - if (skins.Save(skins.CurrentSkin.Value) || userTriggered) - onScreenDisplay?.Display(new SkinEditorToast(ToastStrings.SkinSaved, currentSkin.Value.SkinInfo.ToString() ?? "Unknown")); + if (skins.Save(skin) || userTriggered) + onScreenDisplay?.Display(new SkinEditorToast(ToastStrings.SkinSaved, skin.SkinInfo.ToString() ?? "Unknown")); } protected override bool OnHover(HoverEvent e) => true; @@ -590,7 +605,7 @@ namespace osu.Game.Overlays.SkinEditor public void BringSelectionToFront() { - if (getTarget(selectedTarget.Value) is not SkinComponentsContainer target) + if (getTarget(selectedTarget.Value) is not SkinnableContainer target) return; changeHandler?.BeginChange(); @@ -614,7 +629,7 @@ namespace osu.Game.Overlays.SkinEditor public void SendSelectionToBack() { - if (getTarget(selectedTarget.Value) is not SkinComponentsContainer target) + if (getTarget(selectedTarget.Value) is not SkinnableContainer target) return; changeHandler?.BeginChange(); @@ -662,7 +677,7 @@ namespace osu.Game.Overlays.SkinEditor { SpriteName = { Value = file.Name }, Origin = Anchor.Centre, - Position = skinnableTarget.ToLocalSpace(GetContainingInputManager().CurrentState.Mouse.Position), + Position = skinnableTarget.ToLocalSpace(GetContainingInputManager()!.CurrentState.Mouse.Position), }; SelectedComponents.Clear(); diff --git a/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs b/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs index 2f4820e207..571f99bd08 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs @@ -13,6 +13,7 @@ using osu.Framework.Graphics.Primitives; using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Framework.Layout; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Beatmaps; @@ -30,7 +31,6 @@ using osu.Game.Screens.Play; using osu.Game.Screens.Select; using osu.Game.Users; using osu.Game.Utils; -using osuTK; namespace osu.Game.Overlays.SkinEditor { @@ -70,12 +70,14 @@ namespace osu.Game.Overlays.SkinEditor private OsuScreen? lastTargetScreen; private InvokeOnDisposal? nestedInputManagerDisable; - private Vector2 lastDrawSize; + private readonly LayoutValue drawSizeLayout; public SkinEditorOverlay(ScalingContainer scalingContainer) { this.scalingContainer = scalingContainer; RelativeSizeAxes = Axes.Both; + + AddLayout(drawSizeLayout = new LayoutValue(Invalidation.DrawSize)); } [BackgroundDependencyLoader] @@ -199,10 +201,10 @@ namespace osu.Game.Overlays.SkinEditor { base.Update(); - if (game.DrawSize != lastDrawSize) + if (!drawSizeLayout.IsValid) { - lastDrawSize = game.DrawSize; updateScreenSizing(); + drawSizeLayout.Validate(); } } diff --git a/osu.Game/Overlays/SkinEditor/SkinSelectionHandler.cs b/osu.Game/Overlays/SkinEditor/SkinSelectionHandler.cs index cf6fb60636..bc878b9214 100644 --- a/osu.Game/Overlays/SkinEditor/SkinSelectionHandler.cs +++ b/osu.Game/Overlays/SkinEditor/SkinSelectionHandler.cs @@ -5,16 +5,15 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; -using osu.Framework.Extensions.EnumExtensions; +using osu.Framework.Extensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.UserInterface; -using osu.Framework.Utils; using osu.Game.Extensions; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; using osu.Game.Screens.Edit.Compose.Components; +using osu.Game.Localisation; using osu.Game.Skinning; using osu.Game.Utils; using osuTK; @@ -23,6 +22,8 @@ namespace osu.Game.Overlays.SkinEditor { public partial class SkinSelectionHandler : SelectionHandler { + private OsuMenuItem originMenu = null!; + [Resolved] private SkinEditor skinEditor { get; set; } = null!; @@ -31,148 +32,16 @@ namespace osu.Game.Overlays.SkinEditor UpdatePosition = updateDrawablePosition }; - private bool allSelectedSupportManualSizing(Axes axis) => SelectedItems.All(b => (b as CompositeDrawable)?.AutoSizeAxes.HasFlagFast(axis) == false); - - public override bool HandleScale(Vector2 scale, Anchor anchor) + public override SelectionScaleHandler CreateScaleHandler() { - Axes adjustAxis; - - switch (anchor) + var scaleHandler = new SkinSelectionScaleHandler { - // for corners, adjust scale. - case Anchor.TopLeft: - case Anchor.TopRight: - case Anchor.BottomLeft: - case Anchor.BottomRight: - adjustAxis = Axes.Both; - break; + UpdatePosition = updateDrawablePosition + }; - // for edges, adjust size. - // autosize elements can't be easily handled so just disable sizing for now. - case Anchor.TopCentre: - case Anchor.BottomCentre: - if (!allSelectedSupportManualSizing(Axes.Y)) - return false; + scaleHandler.PerformFlipFromScaleHandles += a => SelectionBox.PerformFlipFromScaleHandles(a); - adjustAxis = Axes.Y; - break; - - case Anchor.CentreLeft: - case Anchor.CentreRight: - if (!allSelectedSupportManualSizing(Axes.X)) - return false; - - adjustAxis = Axes.X; - break; - - default: - throw new ArgumentOutOfRangeException(nameof(anchor), anchor, null); - } - - // convert scale to screen space - scale = ToScreenSpace(scale) - ToScreenSpace(Vector2.Zero); - - adjustScaleFromAnchor(ref scale, anchor); - - // the selection quad is always upright, so use an AABB rect to make mutating the values easier. - var selectionRect = getSelectionQuad().AABBFloat; - - // If the selection has no area we cannot scale it - if (selectionRect.Area == 0) - return false; - - // copy to mutate, as we will need to compare to the original later on. - var adjustedRect = selectionRect; - bool isRotated = false; - - // for now aspect lock scale adjustments that occur at corners.. - if (!anchor.HasFlagFast(Anchor.x1) && !anchor.HasFlagFast(Anchor.y1)) - { - // project scale vector along diagonal - Vector2 diag = (selectionRect.TopLeft - selectionRect.BottomRight).Normalized(); - scale = Vector2.Dot(scale, diag) * diag; - } - // ..or if any of the selection have been rotated. - // this is to avoid requiring skew logic (which would likely not be the user's expected transform anyway). - else if (SelectedBlueprints.Any(b => !Precision.AlmostEquals(((Drawable)b.Item).Rotation % 90, 0))) - { - isRotated = true; - if (anchor.HasFlagFast(Anchor.x1)) - // if dragging from the horizontal centre, only a vertical component is available. - scale.X = scale.Y / selectionRect.Height * selectionRect.Width; - else - // in all other cases (arbitrarily) use the horizontal component for aspect lock. - scale.Y = scale.X / selectionRect.Width * selectionRect.Height; - } - - if (anchor.HasFlagFast(Anchor.x0)) adjustedRect.X -= scale.X; - if (anchor.HasFlagFast(Anchor.y0)) adjustedRect.Y -= scale.Y; - - // Maintain the selection's centre position if dragging from the centre anchors and selection is rotated. - if (isRotated && anchor.HasFlagFast(Anchor.x1)) adjustedRect.X -= scale.X / 2; - if (isRotated && anchor.HasFlagFast(Anchor.y1)) adjustedRect.Y -= scale.Y / 2; - - adjustedRect.Width += scale.X; - adjustedRect.Height += scale.Y; - - if (adjustedRect.Width <= 0 || adjustedRect.Height <= 0) - { - Axes toFlip = Axes.None; - - if (adjustedRect.Width <= 0) toFlip |= Axes.X; - if (adjustedRect.Height <= 0) toFlip |= Axes.Y; - - SelectionBox.PerformFlipFromScaleHandles(toFlip); - return true; - } - - // scale adjust applied to each individual item should match that of the quad itself. - var scaledDelta = new Vector2( - adjustedRect.Width / selectionRect.Width, - adjustedRect.Height / selectionRect.Height - ); - - foreach (var b in SelectedBlueprints) - { - var drawableItem = (Drawable)b.Item; - - // each drawable's relative position should be maintained in the scaled quad. - var screenPosition = b.ScreenSpaceSelectionPoint; - - var relativePositionInOriginal = - new Vector2( - (screenPosition.X - selectionRect.TopLeft.X) / selectionRect.Width, - (screenPosition.Y - selectionRect.TopLeft.Y) / selectionRect.Height - ); - - var newPositionInAdjusted = new Vector2( - adjustedRect.TopLeft.X + adjustedRect.Width * relativePositionInOriginal.X, - adjustedRect.TopLeft.Y + adjustedRect.Height * relativePositionInOriginal.Y - ); - - updateDrawablePosition(drawableItem, newPositionInAdjusted); - - var currentScaledDelta = scaledDelta; - if (Precision.AlmostEquals(MathF.Abs(drawableItem.Rotation) % 180, 90)) - currentScaledDelta = new Vector2(scaledDelta.Y, scaledDelta.X); - - switch (adjustAxis) - { - case Axes.X: - drawableItem.Width *= currentScaledDelta.X; - break; - - case Axes.Y: - drawableItem.Height *= currentScaledDelta.Y; - break; - - case Axes.Both: - drawableItem.Scale *= currentScaledDelta; - break; - } - } - - return true; + return scaleHandler; } public override bool HandleFlip(Direction direction, bool flipOverOrigin) @@ -202,25 +71,27 @@ namespace osu.Game.Overlays.SkinEditor var item = c.Item; Drawable drawable = (Drawable)item; + if (!item.UsesFixedAnchor) + ApplyClosestAnchorOrigin(drawable); + drawable.Position += drawable.ScreenSpaceDeltaToParentSpace(moveEvent.ScreenSpaceDelta); - - if (item.UsesFixedAnchor) continue; - - ApplyClosestAnchor(drawable); } return true; } - public static void ApplyClosestAnchor(Drawable drawable) => applyAnchor(drawable, getClosestAnchor(drawable)); + public static void ApplyClosestAnchorOrigin(Drawable drawable) + { + var closest = getClosestAnchor(drawable); + + applyAnchor(drawable, closest); + applyOrigin(drawable, closest); + } protected override void OnSelectionChanged() { base.OnSelectionChanged(); - SelectionBox.CanScaleX = allSelectedSupportManualSizing(Axes.X); - SelectionBox.CanScaleY = allSelectedSupportManualSizing(Axes.Y); - SelectionBox.CanScaleDiagonally = true; SelectionBox.CanFlipX = true; SelectionBox.CanFlipY = true; SelectionBox.CanReverse = false; @@ -231,56 +102,61 @@ namespace osu.Game.Overlays.SkinEditor protected override IEnumerable GetContextMenuItemsForSelection(IEnumerable> selection) { - var closestItem = new TernaryStateRadioMenuItem("Closest", MenuItemType.Standard, _ => applyClosestAnchors()) + var closestItem = new TernaryStateRadioMenuItem(SkinEditorStrings.Closest, MenuItemType.Standard, _ => applyClosestAnchors()) { State = { Value = GetStateFromSelection(selection, c => !c.Item.UsesFixedAnchor) } }; - yield return new OsuMenuItem("Anchor") + yield return new OsuMenuItem(SkinEditorStrings.Anchor) { Items = createAnchorItems((d, a) => d.UsesFixedAnchor && ((Drawable)d).Anchor == a, applyFixedAnchors) .Prepend(closestItem) .ToArray() }; - yield return new OsuMenuItem("Origin") + yield return originMenu = new OsuMenuItem(SkinEditorStrings.Origin); + + closestItem.State.BindValueChanged(s => { - Items = createAnchorItems((d, o) => ((Drawable)d).Origin == o, applyOrigins).ToArray() - }; + // For UX simplicity, origin should only be user-editable when "closest" anchor mode is disabled. + originMenu.Items = s.NewValue == TernaryState.True + ? Array.Empty() + : createAnchorItems((d, o) => ((Drawable)d).Origin == o, applyOrigins).ToArray(); + }, true); yield return new OsuMenuItemSpacer(); - yield return new OsuMenuItem("Reset position", MenuItemType.Standard, () => + yield return new OsuMenuItem(SkinEditorStrings.ResetPosition, MenuItemType.Standard, () => { foreach (var blueprint in SelectedBlueprints) ((Drawable)blueprint.Item).Position = Vector2.Zero; }); - yield return new OsuMenuItem("Reset rotation", MenuItemType.Standard, () => + yield return new OsuMenuItem(SkinEditorStrings.ResetRotation, MenuItemType.Standard, () => { foreach (var blueprint in SelectedBlueprints) ((Drawable)blueprint.Item).Rotation = 0; }); - yield return new OsuMenuItem("Reset scale", MenuItemType.Standard, () => + yield return new OsuMenuItem(SkinEditorStrings.ResetScale, MenuItemType.Standard, () => { foreach (var blueprint in SelectedBlueprints) { var blueprintItem = ((Drawable)blueprint.Item); blueprintItem.Scale = Vector2.One; - if (blueprintItem.RelativeSizeAxes.HasFlagFast(Axes.X)) + if (blueprintItem.RelativeSizeAxes.HasFlag(Axes.X)) blueprintItem.Width = 1; - if (blueprintItem.RelativeSizeAxes.HasFlagFast(Axes.Y)) + if (blueprintItem.RelativeSizeAxes.HasFlag(Axes.Y)) blueprintItem.Height = 1; } }); yield return new OsuMenuItemSpacer(); - yield return new OsuMenuItem("Bring to front", MenuItemType.Standard, () => skinEditor.BringSelectionToFront()); + yield return new OsuMenuItem(SkinEditorStrings.BringToFront, MenuItemType.Standard, () => skinEditor.BringSelectionToFront()); - yield return new OsuMenuItem("Send to back", MenuItemType.Standard, () => skinEditor.SendSelectionToBack()); + yield return new OsuMenuItem(SkinEditorStrings.SendToBack, MenuItemType.Standard, () => skinEditor.SendSelectionToBack()); yield return new OsuMenuItemSpacer(); @@ -325,15 +201,10 @@ namespace osu.Game.Overlays.SkinEditor { var drawable = (Drawable)item; - if (origin == drawable.Origin) continue; + applyOrigin(drawable, origin); - var previousOrigin = drawable.OriginPosition; - drawable.Origin = origin; - drawable.Position += drawable.OriginPosition - previousOrigin; - - if (item.UsesFixedAnchor) continue; - - ApplyClosestAnchor(drawable); + if (!item.UsesFixedAnchor) + ApplyClosestAnchorOrigin(drawable); } OnOperationEnded(); @@ -368,7 +239,7 @@ namespace osu.Game.Overlays.SkinEditor foreach (var item in SelectedItems) { item.UsesFixedAnchor = false; - ApplyClosestAnchor((Drawable)item); + ApplyClosestAnchorOrigin((Drawable)item); } OnOperationEnded(); @@ -414,15 +285,43 @@ namespace osu.Game.Overlays.SkinEditor drawable.Position -= drawable.AnchorPosition - previousAnchor; } - private static void adjustScaleFromAnchor(ref Vector2 scale, Anchor reference) + private static void applyOrigin(Drawable drawable, Anchor screenSpaceOrigin) { - // cancel out scale in axes we don't care about (based on which drag handle was used). - if ((reference & Anchor.x1) > 0) scale.X = 0; - if ((reference & Anchor.y1) > 0) scale.Y = 0; + var boundingBox = drawable.ScreenSpaceDrawQuad.AABBFloat; - // reverse the scale direction if dragging from top or left. - if ((reference & Anchor.x0) > 0) scale.X = -scale.X; - if ((reference & Anchor.y0) > 0) scale.Y = -scale.Y; + var targetScreenSpacePosition = screenSpaceOrigin.PositionOnQuad(boundingBox); + + Anchor localOrigin = Anchor.TopLeft; + float smallestDistanceFromTargetPosition = float.PositiveInfinity; + + void checkOrigin(Anchor originToTest) + { + Vector2 positionToTest = drawable.ToScreenSpace(originToTest.PositionOnQuad(drawable.DrawRectangle)); + float testedDistance = Vector2.Distance(targetScreenSpacePosition, positionToTest); + + if (testedDistance < smallestDistanceFromTargetPosition) + { + localOrigin = originToTest; + smallestDistanceFromTargetPosition = testedDistance; + } + } + + checkOrigin(Anchor.TopLeft); + checkOrigin(Anchor.TopCentre); + checkOrigin(Anchor.TopRight); + + checkOrigin(Anchor.CentreLeft); + checkOrigin(Anchor.Centre); + checkOrigin(Anchor.CentreRight); + + checkOrigin(Anchor.BottomLeft); + checkOrigin(Anchor.BottomCentre); + checkOrigin(Anchor.BottomRight); + + Vector2 offset = drawable.ToParentSpace(localOrigin.PositionOnQuad(drawable.DrawRectangle)) - drawable.ToParentSpace(drawable.Origin.PositionOnQuad(drawable.DrawRectangle)); + + drawable.Origin = localOrigin; + drawable.Position += offset; } } } diff --git a/osu.Game/Overlays/SkinEditor/SkinSelectionRotationHandler.cs b/osu.Game/Overlays/SkinEditor/SkinSelectionRotationHandler.cs index 7ecf116b68..9fd28a1cad 100644 --- a/osu.Game/Overlays/SkinEditor/SkinSelectionRotationHandler.cs +++ b/osu.Game/Overlays/SkinEditor/SkinSelectionRotationHandler.cs @@ -41,12 +41,11 @@ namespace osu.Game.Overlays.SkinEditor private void updateState() { - CanRotateSelectionOrigin.Value = selectedItems.Count > 0; + CanRotateAroundSelectionOrigin.Value = selectedItems.Count > 0; } private Drawable[]? objectsInRotation; - private Vector2? defaultOrigin; private Dictionary? originalRotations; private Dictionary? originalPositions; @@ -60,7 +59,9 @@ namespace osu.Game.Overlays.SkinEditor objectsInRotation = selectedItems.Cast().ToArray(); originalRotations = objectsInRotation.ToDictionary(d => d, d => d.Rotation); originalPositions = objectsInRotation.ToDictionary(d => d, d => d.ToScreenSpace(d.OriginPosition)); - defaultOrigin = GeometryUtils.GetSurroundingQuad(objectsInRotation.SelectMany(d => d.ScreenSpaceDrawQuad.GetVertices().ToArray())).Centre; + DefaultOrigin = GeometryUtils.GetSurroundingQuad(objectsInRotation.SelectMany(d => d.ScreenSpaceDrawQuad.GetVertices().ToArray())).Centre; + + base.Begin(); } public override void Update(float rotation, Vector2? origin = null) @@ -68,7 +69,7 @@ namespace osu.Game.Overlays.SkinEditor if (objectsInRotation == null) throw new InvalidOperationException($"Cannot {nameof(Update)} a rotate operation without calling {nameof(Begin)} first!"); - Debug.Assert(originalRotations != null && originalPositions != null && defaultOrigin != null); + Debug.Assert(originalRotations != null && originalPositions != null && DefaultOrigin != null); if (objectsInRotation.Length == 1 && origin == null) { @@ -77,7 +78,7 @@ namespace osu.Game.Overlays.SkinEditor return; } - var actualOrigin = origin ?? defaultOrigin.Value; + var actualOrigin = origin ?? DefaultOrigin.Value; foreach (var drawableItem in objectsInRotation) { @@ -98,7 +99,9 @@ namespace osu.Game.Overlays.SkinEditor objectsInRotation = null; originalPositions = null; originalRotations = null; - defaultOrigin = null; + DefaultOrigin = null; + + base.Commit(); } } } diff --git a/osu.Game/Overlays/SkinEditor/SkinSelectionScaleHandler.cs b/osu.Game/Overlays/SkinEditor/SkinSelectionScaleHandler.cs new file mode 100644 index 0000000000..6915769212 --- /dev/null +++ b/osu.Game/Overlays/SkinEditor/SkinSelectionScaleHandler.cs @@ -0,0 +1,177 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Utils; +using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.Compose.Components; +using osu.Game.Skinning; +using osu.Game.Utils; +using osuTK; + +namespace osu.Game.Overlays.SkinEditor +{ + public partial class SkinSelectionScaleHandler : SelectionScaleHandler + { + public Action UpdatePosition { get; init; } = null!; + + public event Action? PerformFlipFromScaleHandles; + + [Resolved] + private IEditorChangeHandler? changeHandler { get; set; } + + private BindableList selectedItems { get; } = new BindableList(); + + [BackgroundDependencyLoader] + private void load(SkinEditor skinEditor) + { + selectedItems.BindTo(skinEditor.SelectedComponents); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + selectedItems.CollectionChanged += (_, __) => updateState(); + updateState(); + } + + private void updateState() + { + CanScaleX.Value = allSelectedSupportManualSizing(Axes.X); + CanScaleY.Value = allSelectedSupportManualSizing(Axes.Y); + CanScaleDiagonally.Value = true; + } + + private bool allSelectedSupportManualSizing(Axes axis) => selectedItems.All(b => (b as CompositeDrawable)?.AutoSizeAxes.HasFlag(axis) == false); + + private Dictionary? objectsInScale; + private Vector2? defaultOrigin; + + private bool isFlippedX; + private bool isFlippedY; + + public override void Begin() + { + if (objectsInScale != null) + throw new InvalidOperationException($"Cannot {nameof(Begin)} a scale operation while another is in progress!"); + + changeHandler?.BeginChange(); + + objectsInScale = selectedItems.Cast().ToDictionary(d => d, d => new OriginalDrawableState(d)); + OriginalSurroundingQuad = ToLocalSpace(GeometryUtils.GetSurroundingQuad(objectsInScale.SelectMany(d => d.Key.ScreenSpaceDrawQuad.GetVertices().ToArray()))); + defaultOrigin = ToLocalSpace(GeometryUtils.MinimumEnclosingCircle(objectsInScale.SelectMany(d => d.Key.ScreenSpaceDrawQuad.GetVertices().ToArray())).Item1); + + isFlippedX = false; + isFlippedY = false; + } + + public override void Update(Vector2 scale, Vector2? origin = null, Axes adjustAxis = Axes.Both, float axisRotation = 0) + { + if (objectsInScale == null) + throw new InvalidOperationException($"Cannot {nameof(Update)} a scale operation without calling {nameof(Begin)} first!"); + + Debug.Assert(defaultOrigin != null && OriginalSurroundingQuad != null); + + var actualOrigin = ToScreenSpace(origin ?? defaultOrigin.Value); + + if ((adjustAxis == Axes.Y && !allSelectedSupportManualSizing(Axes.Y)) || + (adjustAxis == Axes.X && !allSelectedSupportManualSizing(Axes.X))) + return; + + // If the selection has no area we cannot scale it + if (OriginalSurroundingQuad.Value.Width == 0 || OriginalSurroundingQuad.Value.Height == 0) + return; + + // for now aspect lock scale adjustments that occur at corners. + if (adjustAxis == Axes.Both) + { + // project scale vector along diagonal + scale = new Vector2((scale.X + scale.Y) * 0.5f); + } + // If any of the selection have been rotated and the adjust axis is not both, + // we would require skew logic to achieve a correct image editor-like scale. + // For now we just ignore, because it would likely not be the user's expected transform anyway. + + bool flippedX = scale.X < 0; + bool flippedY = scale.Y < 0; + Axes toFlip = Axes.None; + + if (flippedX != isFlippedX) + { + isFlippedX = flippedX; + toFlip |= Axes.X; + } + + if (flippedY != isFlippedY) + { + isFlippedY = flippedY; + toFlip |= Axes.Y; + } + + if (toFlip != Axes.None) + { + PerformFlipFromScaleHandles?.Invoke(toFlip); + return; + } + + foreach (var (b, originalState) in objectsInScale) + { + UpdatePosition(b, GeometryUtils.GetScaledPosition(scale, actualOrigin, originalState.ScreenSpaceOriginPosition)); + + var currentScale = scale; + if (Precision.AlmostEquals(MathF.Abs(b.Rotation) % 180, 90)) + currentScale = new Vector2(scale.Y, scale.X); + + switch (adjustAxis) + { + case Axes.X: + b.Width = MathF.Abs(originalState.Width * currentScale.X); + break; + + case Axes.Y: + b.Height = MathF.Abs(originalState.Height * currentScale.Y); + break; + + case Axes.Both: + b.Scale = originalState.Scale * currentScale; + break; + } + } + } + + public override void Commit() + { + if (objectsInScale == null) + throw new InvalidOperationException($"Cannot {nameof(Commit)} a scale operation without calling {nameof(Begin)} first!"); + + changeHandler?.EndChange(); + + objectsInScale = null; + defaultOrigin = null; + } + + private struct OriginalDrawableState + { + public float Width { get; } + public float Height { get; } + public Vector2 Scale { get; } + public Vector2 ScreenSpaceOriginPosition { get; } + + public OriginalDrawableState(Drawable drawable) + { + Width = drawable.Width; + Height = drawable.Height; + Scale = drawable.Scale; + ScreenSpaceOriginPosition = drawable.ToScreenSpace(drawable.OriginPosition); + } + } + } +} diff --git a/osu.Game/Overlays/Toolbar/ToolbarButton.cs b/osu.Game/Overlays/Toolbar/ToolbarButton.cs index 1da2e1b744..221282ef13 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarButton.cs @@ -4,7 +4,6 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -150,9 +149,9 @@ namespace osu.Game.Overlays.Toolbar { Direction = FillDirection.Vertical, RelativeSizeAxes = Axes.Both, // stops us being considered in parent's autosize - Anchor = TooltipAnchor.HasFlagFast(Anchor.x0) ? Anchor.BottomLeft : Anchor.BottomRight, + Anchor = TooltipAnchor.HasFlag(Anchor.x0) ? Anchor.BottomLeft : Anchor.BottomRight, Origin = TooltipAnchor, - Position = new Vector2(TooltipAnchor.HasFlagFast(Anchor.x0) ? 5 : -5, 5), + Position = new Vector2(TooltipAnchor.HasFlag(Anchor.x0) ? 5 : -5, 5), Alpha = 0, Children = new Drawable[] { diff --git a/osu.Game/Overlays/Toolbar/ToolbarRulesetSelector.cs b/osu.Game/Overlays/Toolbar/ToolbarRulesetSelector.cs index d49c340ed4..a979575a0b 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarRulesetSelector.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarRulesetSelector.cs @@ -3,8 +3,12 @@ #nullable disable +using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -21,6 +25,13 @@ namespace osu.Game.Overlays.Toolbar { protected Drawable ModeButtonLine { get; private set; } + [Resolved] + private MusicController musicController { get; set; } + + private readonly Dictionary rulesetSelectionSample = new Dictionary(); + private readonly Dictionary rulesetSelectionChannel = new Dictionary(); + private Sample defaultSelectSample; + public ToolbarRulesetSelector() { RelativeSizeAxes = Axes.Y; @@ -28,7 +39,7 @@ namespace osu.Game.Overlays.Toolbar } [BackgroundDependencyLoader] - private void load() + private void load(AudioManager audio) { AddRangeInternal(new[] { @@ -54,6 +65,13 @@ namespace osu.Game.Overlays.Toolbar } }, }); + + foreach (var r in Rulesets.AvailableRulesets) + rulesetSelectionSample[r] = audio.Samples.Get($@"UI/ruleset-select-{r.ShortName}"); + + defaultSelectSample = audio.Samples.Get(@"UI/default-select"); + + Current.ValueChanged += playRulesetSelectionSample; } protected override void LoadComplete() @@ -84,6 +102,32 @@ namespace osu.Game.Overlays.Toolbar } } + private void playRulesetSelectionSample(ValueChangedEvent r) + { + // Don't play sample on first setting of value + if (r.OldValue == null) + return; + + var channel = rulesetSelectionSample[r.NewValue]?.GetChannel(); + + // Skip sample choking and ducking for the default/fallback sample + if (channel == null) + { + defaultSelectSample.Play(); + return; + } + + foreach (var pair in rulesetSelectionChannel) + pair.Value?.Stop(); + + rulesetSelectionChannel[r.NewValue] = channel; + channel.Play(); + + // 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; public override bool HandlePositionalInput => !Current.Disabled && base.HandlePositionalInput; diff --git a/osu.Game/Overlays/Toolbar/ToolbarRulesetTabButton.cs b/osu.Game/Overlays/Toolbar/ToolbarRulesetTabButton.cs index 0315bede64..3287ac6eaa 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarRulesetTabButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarRulesetTabButton.cs @@ -2,8 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Framework.Audio; -using osu.Framework.Audio.Sample; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.UserInterface; @@ -19,8 +17,6 @@ namespace osu.Game.Overlays.Toolbar { private readonly RulesetButton ruleset; - private Sample? selectSample; - public ToolbarRulesetTabButton(RulesetInfo value) : base(value) { @@ -38,18 +34,10 @@ namespace osu.Game.Overlays.Toolbar ruleset.SetIcon(rInstance.CreateIcon()); } - [BackgroundDependencyLoader] - private void load(AudioManager audio) - { - selectSample = audio.Samples.Get($@"UI/ruleset-select-{Value.ShortName}"); - } - protected override void OnActivated() => ruleset.Active = true; protected override void OnDeactivated() => ruleset.Active = false; - protected override void OnActivatedByUser() => selectSample?.Play(); - private partial class RulesetButton : ToolbarButton { protected override HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverSounds(); diff --git a/osu.Game/Overlays/Toolbar/TransientUserStatisticsUpdateDisplay.cs b/osu.Game/Overlays/Toolbar/TransientUserStatisticsUpdateDisplay.cs index c6f373d55f..07c2e72774 100644 --- a/osu.Game/Overlays/Toolbar/TransientUserStatisticsUpdateDisplay.cs +++ b/osu.Game/Overlays/Toolbar/TransientUserStatisticsUpdateDisplay.cs @@ -24,7 +24,7 @@ namespace osu.Game.Overlays.Toolbar public Bindable LatestUpdate { get; } = new Bindable(); private Statistic globalRank = null!; - private Statistic pp = null!; + private Statistic pp = null!; [BackgroundDependencyLoader] private void load(UserStatisticsWatcher? userStatisticsWatcher) @@ -43,7 +43,7 @@ namespace osu.Game.Overlays.Toolbar Children = new Drawable[] { globalRank = new Statistic(UsersStrings.ShowRankGlobalSimple, @"#", Comparer.Create((before, after) => before - after)), - pp = new Statistic(RankingsStrings.StatPerformance, string.Empty, Comparer.Create((before, after) => Math.Sign(after - before))), + pp = new Statistic(RankingsStrings.StatPerformance, string.Empty, Comparer.Create((before, after) => Math.Sign(after - before))), } }; @@ -83,7 +83,7 @@ namespace osu.Game.Overlays.Toolbar } if (update.After.PP != null) - pp.Display(update.Before.PP ?? update.After.PP.Value, Math.Abs((update.After.PP - update.Before.PP) ?? 0M), update.After.PP.Value); + pp.Display((int)(update.Before.PP ?? update.After.PP.Value), (int)Math.Abs(((int?)update.After.PP - (int?)update.Before.PP) ?? 0M), (int)update.After.PP.Value); this.Delay(5000).FadeOut(500, Easing.OutQuint); }); diff --git a/osu.Game/Overlays/UserProfileOverlay.cs b/osu.Game/Overlays/UserProfileOverlay.cs index 9840551d9f..076905819e 100644 --- a/osu.Game/Overlays/UserProfileOverlay.cs +++ b/osu.Game/Overlays/UserProfileOverlay.cs @@ -96,14 +96,14 @@ namespace osu.Game.Overlays { Debug.Assert(user != null); - if (user.OnlineID == Header.User.Value?.User.Id && ruleset?.MatchesOnlineID(Header.User.Value?.Ruleset) == true) + bool sameUser = user.OnlineID == Header.User.Value?.User.Id; + if (sameUser && ruleset?.MatchesOnlineID(Header.User.Value?.Ruleset) == true) return; if (sectionsContainer != null) sectionsContainer.ExpandableHeader = null; userReq?.Cancel(); - Clear(); lastSection = null; sections = !user.IsBot @@ -119,20 +119,69 @@ namespace osu.Game.Overlays } : Array.Empty(); - tabs = new ProfileSectionTabControl - { - RelativeSizeAxes = Axes.X, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - }; + if (!sameUser) + changeOverlayColours(OverlayColourScheme.Pink.GetHue()); - Add(new OsuContextMenuContainer + recreateBaseContent(); + + if (API.State.Value != APIState.Offline) + { + userReq = user.OnlineID > 1 ? new GetUserRequest(user.OnlineID, ruleset) : new GetUserRequest(user.Username, ruleset); + userReq.Success += u => userLoadComplete(u, ruleset); + + API.Queue(userReq); + loadingLayer.Show(); + } + } + + private void userLoadComplete(APIUser loadedUser, IRulesetInfo? userRuleset) + { + Debug.Assert(sections != null && sectionsContainer != null && tabs != null); + + // reuse header and content if same colour scheme, otherwise recreate both. + int profileHue = loadedUser.ProfileHue ?? OverlayColourScheme.Pink.GetHue(); + + if (changeOverlayColours(profileHue)) + recreateBaseContent(); + + var actualRuleset = rulesets.GetRuleset(userRuleset?.ShortName ?? loadedUser.PlayMode).AsNonNull(); + + var userProfile = new UserProfileData(loadedUser, actualRuleset); + Header.User.Value = userProfile; + + if (loadedUser.ProfileOrder != null) + { + foreach (string id in loadedUser.ProfileOrder) + { + var sec = sections.FirstOrDefault(s => s.Identifier == id); + + if (sec != null) + { + sec.User.Value = userProfile; + + sectionsContainer.Add(sec); + tabs.AddItem(sec); + } + } + } + + loadingLayer.Hide(); + } + + private void recreateBaseContent() + { + Child = new OsuContextMenuContainer { RelativeSizeAxes = Axes.Both, Child = sectionsContainer = new ProfileSectionsContainer { ExpandableHeader = Header, - FixedHeader = tabs, + FixedHeader = tabs = new ProfileSectionTabControl + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, HeaderBackground = new Box { // this is only visible as the ProfileTabControl background @@ -140,7 +189,7 @@ namespace osu.Game.Overlays RelativeSizeAxes = Axes.Both }, } - }); + }; sectionsContainer.SelectedSection.ValueChanged += section => { @@ -167,45 +216,18 @@ namespace osu.Game.Overlays sectionsContainer.ScrollTo(lastSection); } }; - - sectionsContainer.ScrollToTop(); - - if (API.State.Value != APIState.Offline) - { - userReq = user.OnlineID > 1 ? new GetUserRequest(user.OnlineID, ruleset) : new GetUserRequest(user.Username, ruleset); - userReq.Success += u => userLoadComplete(u, ruleset); - - API.Queue(userReq); - loadingLayer.Show(); - } } - private void userLoadComplete(APIUser loadedUser, IRulesetInfo? userRuleset) + private bool changeOverlayColours(int hue) { - Debug.Assert(sections != null && sectionsContainer != null && tabs != null); + if (hue == ColourProvider.Hue) + return false; - var actualRuleset = rulesets.GetRuleset(userRuleset?.ShortName ?? loadedUser.PlayMode).AsNonNull(); + ColourProvider.ChangeColourScheme(hue); - var userProfile = new UserProfileData(loadedUser, actualRuleset); - Header.User.Value = userProfile; - - if (loadedUser.ProfileOrder != null) - { - foreach (string id in loadedUser.ProfileOrder) - { - var sec = sections.FirstOrDefault(s => s.Identifier == id); - - if (sec != null) - { - sec.User.Value = userProfile; - - sectionsContainer.Add(sec); - tabs.AddItem(sec); - } - } - } - - loadingLayer.Hide(); + RecreateHeader(); + UpdateColours(); + return true; } private partial class ProfileSectionTabControl : OsuTabControl diff --git a/osu.Game/Overlays/Volume/MasterVolumeMeter.cs b/osu.Game/Overlays/Volume/MasterVolumeMeter.cs new file mode 100644 index 0000000000..951a6d53b1 --- /dev/null +++ b/osu.Game/Overlays/Volume/MasterVolumeMeter.cs @@ -0,0 +1,54 @@ +// 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.Audio; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osuTK.Graphics; + +namespace osu.Game.Overlays.Volume +{ + public partial class MasterVolumeMeter : VolumeMeter + { + private MuteButton muteButton = null!; + + public Bindable IsMuted { get; } = new Bindable(); + + private readonly BindableDouble muteAdjustment = new BindableDouble(); + + [Resolved] + private VolumeOverlay volumeOverlay { get; set; } = null!; + + public MasterVolumeMeter(string name, float circleSize, Color4 meterColour) + : base(name, circleSize, meterColour) + { + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + IsMuted.BindValueChanged(muted => + { + if (muted.NewValue) + audio.AddAdjustment(AdjustableProperty.Volume, muteAdjustment); + else + audio.RemoveAdjustment(AdjustableProperty.Volume, muteAdjustment); + }); + + Add(muteButton = new MuteButton + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.Centre, + Blending = BlendingParameters.Additive, + X = CircleSize / 2, + Y = CircleSize * 0.23f, + Current = { BindTarget = IsMuted } + }); + + muteButton.Current.ValueChanged += _ => volumeOverlay.Show(); + } + + public void ToggleMute() => muteButton.Current.Value = !muteButton.Current.Value; + } +} diff --git a/osu.Game/Overlays/Volume/MuteButton.cs b/osu.Game/Overlays/Volume/MuteButton.cs index 1dc8d754b7..878842867d 100644 --- a/osu.Game/Overlays/Volume/MuteButton.cs +++ b/osu.Game/Overlays/Volume/MuteButton.cs @@ -7,13 +7,13 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; using osuTK; -using osuTK.Graphics; namespace osu.Game.Overlays.Volume { @@ -33,18 +33,18 @@ namespace osu.Game.Overlays.Volume } } - private Color4 hoveredColour, unhoveredColour; - - private const float width = 100; - public const float HEIGHT = 35; + private ColourInfo hoveredBorderColour; + private ColourInfo unhoveredBorderColour; + private CompositeDrawable border = null!; public MuteButton() { - Content.BorderThickness = 3; - Content.CornerRadius = HEIGHT / 2; - Content.CornerExponent = 2; + const float width = 30; + const float height = 30; - Size = new Vector2(width, HEIGHT); + Size = new Vector2(width, height); + Content.CornerRadius = height / 2; + Content.CornerExponent = 2; Action = () => Current.Value = !Current.Value; } @@ -52,10 +52,9 @@ namespace osu.Game.Overlays.Volume [BackgroundDependencyLoader] private void load(OsuColour colours) { - hoveredColour = colours.YellowDark; - - Content.BorderColour = unhoveredColour = colours.Gray1; BackgroundColour = colours.Gray1; + hoveredBorderColour = colours.PinkLight; + unhoveredBorderColour = colours.Gray1; SpriteIcon icon; @@ -65,26 +64,39 @@ namespace osu.Game.Overlays.Volume { Anchor = Anchor.Centre, Origin = Anchor.Centre, + }, + border = new CircularContainer + { + RelativeSizeAxes = Axes.Both, + Masking = true, + BorderThickness = 3, + BorderColour = unhoveredBorderColour, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true + } } }); Current.BindValueChanged(muted => { icon.Icon = muted.NewValue ? FontAwesome.Solid.VolumeMute : FontAwesome.Solid.VolumeUp; - icon.Size = new Vector2(muted.NewValue ? 18 : 20); + icon.Size = new Vector2(muted.NewValue ? 12 : 16); icon.Margin = new MarginPadding { Right = muted.NewValue ? 2 : 0 }; }, true); } protected override bool OnHover(HoverEvent e) { - Content.TransformTo, ColourInfo>("BorderColour", hoveredColour, 500, Easing.OutQuint); + border.TransformTo(nameof(BorderColour), hoveredBorderColour, 500, Easing.OutQuint); return false; } protected override void OnHoverLost(HoverLostEvent e) { - Content.TransformTo, ColourInfo>("BorderColour", unhoveredColour, 500, Easing.OutQuint); + border.TransformTo(nameof(BorderColour), unhoveredBorderColour, 500, Easing.OutQuint); } protected override bool OnMouseDown(MouseDownEvent e) diff --git a/osu.Game/Overlays/Volume/VolumeControlReceptor.cs b/osu.Game/Overlays/Volume/VolumeControlReceptor.cs index 4ddbc9dd48..2e8d86d4c7 100644 --- a/osu.Game/Overlays/Volume/VolumeControlReceptor.cs +++ b/osu.Game/Overlays/Volume/VolumeControlReceptor.cs @@ -23,15 +23,15 @@ namespace osu.Game.Overlays.Volume { case GlobalAction.DecreaseVolume: case GlobalAction.IncreaseVolume: - ActionRequested?.Invoke(e.Action); - return true; + return ActionRequested?.Invoke(e.Action) == true; case GlobalAction.ToggleMute: case GlobalAction.NextVolumeMeter: case GlobalAction.PreviousVolumeMeter: if (!e.Repeat) - ActionRequested?.Invoke(e.Action); - return true; + return ActionRequested?.Invoke(e.Action) == true; + + return false; } return false; diff --git a/osu.Game/Overlays/Volume/VolumeMeter.cs b/osu.Game/Overlays/Volume/VolumeMeter.cs index e96cd0fa46..9e0c599386 100644 --- a/osu.Game/Overlays/Volume/VolumeMeter.cs +++ b/osu.Game/Overlays/Volume/VolumeMeter.cs @@ -35,8 +35,12 @@ namespace osu.Game.Overlays.Volume private CircularProgress volumeCircle; private CircularProgress volumeCircleGlow; + protected static readonly Vector2 LABEL_SIZE = new Vector2(120, 20); + public BindableDouble Bindable { get; } = new BindableDouble { MinValue = 0, MaxValue = 1, Precision = 0.01 }; - private readonly float circleSize; + + protected readonly float CircleSize; + private readonly Color4 meterColour; private readonly string name; @@ -73,7 +77,7 @@ namespace osu.Game.Overlays.Volume public VolumeMeter(string name, float circleSize, Color4 meterColour) { - this.circleSize = circleSize; + CircleSize = circleSize; this.meterColour = meterColour; this.name = name; @@ -101,7 +105,7 @@ namespace osu.Game.Overlays.Volume { new Container { - Size = new Vector2(circleSize), + Size = new Vector2(CircleSize), Children = new Drawable[] { new BufferedContainer @@ -199,7 +203,7 @@ namespace osu.Game.Overlays.Volume { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Font = OsuFont.Numeric.With(size: 0.16f * circleSize) + Font = OsuFont.Numeric.With(size: 0.16f * CircleSize) }).WithEffect(new GlowEffect { Colour = Color4.Transparent, @@ -209,10 +213,10 @@ namespace osu.Game.Overlays.Volume }, new Container { - Size = new Vector2(120, 20), + Size = LABEL_SIZE, CornerRadius = 10, Masking = true, - Margin = new MarginPadding { Left = circleSize + 10 }, + Margin = new MarginPadding { Left = CircleSize + 10 }, Origin = Anchor.CentreLeft, Anchor = Anchor.CentreLeft, Children = new Drawable[] diff --git a/osu.Game/Overlays/VolumeOverlay.cs b/osu.Game/Overlays/VolumeOverlay.cs index 5470c70400..bb2ad60695 100644 --- a/osu.Game/Overlays/VolumeOverlay.cs +++ b/osu.Game/Overlays/VolumeOverlay.cs @@ -1,8 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; @@ -19,24 +18,21 @@ using osu.Game.Input.Bindings; using osu.Game.Overlays.Volume; using osuTK; using osuTK.Graphics; -using osuTK.Input; namespace osu.Game.Overlays { + [Cached] public partial class VolumeOverlay : VisibilityContainer { - private const float offset = 10; - - private VolumeMeter volumeMeterMaster; - private VolumeMeter volumeMeterEffect; - private VolumeMeter volumeMeterMusic; - private MuteButton muteButton; - - private readonly BindableDouble muteAdjustment = new BindableDouble(); - public Bindable IsMuted { get; } = new Bindable(); - private SelectionCycleFillFlowContainer volumeMeters; + private const float offset = 10; + + private VolumeMeter volumeMeterMaster = null!; + private VolumeMeter volumeMeterEffect = null!; + private VolumeMeter volumeMeterMusic = null!; + + private SelectionCycleFillFlowContainer volumeMeters = null!; [BackgroundDependencyLoader] private void load(AudioManager audio, OsuColour colours) @@ -52,14 +48,7 @@ namespace osu.Game.Overlays Width = 300, Colour = ColourInfo.GradientHorizontal(Color4.Black.Opacity(0.75f), Color4.Black.Opacity(0)) }, - muteButton = new MuteButton - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Margin = new MarginPadding(10), - Current = { BindTarget = IsMuted } - }, - volumeMeters = new SelectionCycleFillFlowContainer + new FillFlowContainer { Direction = FillDirection.Vertical, AutoSizeAxes = Axes.Both, @@ -67,26 +56,29 @@ namespace osu.Game.Overlays Origin = Anchor.CentreLeft, Spacing = new Vector2(0, offset), Margin = new MarginPadding { Left = offset }, - Children = new[] + Children = new Drawable[] { - volumeMeterEffect = new VolumeMeter("EFFECTS", 125, colours.BlueDarker), - volumeMeterMaster = new VolumeMeter("MASTER", 150, colours.PinkDarker), - volumeMeterMusic = new VolumeMeter("MUSIC", 125, colours.BlueDarker), - } - } + volumeMeters = new SelectionCycleFillFlowContainer + { + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Spacing = new Vector2(0, offset), + Children = new[] + { + volumeMeterEffect = new VolumeMeter("EFFECTS", 125, colours.BlueDarker), + volumeMeterMaster = new MasterVolumeMeter("MASTER", 150, colours.PinkDarker) { IsMuted = { BindTarget = IsMuted }, }, + volumeMeterMusic = new VolumeMeter("MUSIC", 125, colours.BlueDarker), + } + }, + }, + }, }); volumeMeterMaster.Bindable.BindTo(audio.Volume); volumeMeterEffect.Bindable.BindTo(audio.VolumeSample); volumeMeterMusic.Bindable.BindTo(audio.VolumeTrack); - - IsMuted.BindValueChanged(muted => - { - if (muted.NewValue) - audio.AddAdjustment(AdjustableProperty.Volume, muteAdjustment); - else - audio.RemoveAdjustment(AdjustableProperty.Volume, muteAdjustment); - }); } protected override void LoadComplete() @@ -95,8 +87,6 @@ namespace osu.Game.Overlays foreach (var volumeMeter in volumeMeters) volumeMeter.Bindable.ValueChanged += _ => Show(); - - muteButton.Current.ValueChanged += _ => Show(); } public bool Adjust(GlobalAction action, float amount = 1, bool isPrecise = false) @@ -120,28 +110,30 @@ namespace osu.Game.Overlays return true; case GlobalAction.NextVolumeMeter: - if (State.Value == Visibility.Visible) - volumeMeters.SelectNext(); + if (State.Value != Visibility.Visible) + return false; + + volumeMeters.SelectNext(); Show(); return true; case GlobalAction.PreviousVolumeMeter: - if (State.Value == Visibility.Visible) - volumeMeters.SelectPrevious(); + if (State.Value != Visibility.Visible) + return false; + + volumeMeters.SelectPrevious(); Show(); return true; case GlobalAction.ToggleMute: Show(); - muteButton.Current.Value = !muteButton.Current.Value; + volumeMeters.OfType().First().ToggleMute(); return true; } return false; } - private ScheduledDelegate popOutDelegate; - public void FocusMasterVolume() { volumeMeters.Select(volumeMeterMaster); @@ -179,30 +171,6 @@ namespace osu.Game.Overlays return base.OnMouseMove(e); } - protected override bool OnKeyDown(KeyDownEvent e) - { - switch (e.Key) - { - case Key.Left: - Adjust(GlobalAction.PreviousVolumeMeter); - return true; - - case Key.Right: - Adjust(GlobalAction.NextVolumeMeter); - return true; - - case Key.Down: - Adjust(GlobalAction.DecreaseVolume); - return true; - - case Key.Up: - Adjust(GlobalAction.IncreaseVolume); - return true; - } - - return base.OnKeyDown(e); - } - protected override bool OnHover(HoverEvent e) { schedulePopOut(); @@ -215,6 +183,8 @@ namespace osu.Game.Overlays base.OnHoverLost(e); } + private ScheduledDelegate? popOutDelegate; + private void schedulePopOut() { popOutDelegate?.Cancel(); diff --git a/osu.Game/Overlays/WikiOverlay.cs b/osu.Game/Overlays/WikiOverlay.cs index ffbc168fb7..14a25a909d 100644 --- a/osu.Game/Overlays/WikiOverlay.cs +++ b/osu.Game/Overlays/WikiOverlay.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Linq; using System.Threading; @@ -10,6 +8,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Extensions; +using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; @@ -24,25 +23,37 @@ namespace osu.Game.Overlays public string CurrentPath => path.Value; private readonly Bindable path = new Bindable(INDEX_PATH); - - private readonly Bindable wikiData = new Bindable(); + private readonly Bindable wikiData = new Bindable(); + private readonly IBindable language = new Bindable(); [Resolved] - private IAPIProvider api { get; set; } + private IAPIProvider api { get; set; } = null!; - private GetWikiRequest request; + [Resolved] + private OsuGameBase game { get; set; } = null!; - private CancellationTokenSource cancellationToken; + private GetWikiRequest? request; + private CancellationTokenSource? cancellationToken; + private WikiArticlePage? articlePage; private bool displayUpdateRequired = true; - private WikiArticlePage articlePage; - public WikiOverlay() : base(OverlayColourScheme.Orange, false) { } + protected override void LoadComplete() + { + base.LoadComplete(); + + path.BindValueChanged(onPathChanged); + wikiData.BindTo(Header.WikiPageData); + + language.BindTo(game.CurrentLanguage); + language.BindValueChanged(onLangChanged); + } + public void ShowPage(string pagePath = INDEX_PATH) { path.Value = pagePath.Trim('/'); @@ -55,13 +66,6 @@ namespace osu.Game.Overlays ShowParentPage = showParentPage, }; - protected override void LoadComplete() - { - base.LoadComplete(); - path.BindValueChanged(onPathChanged); - wikiData.BindTo(Header.WikiPageData); - } - protected override void PopIn() { base.PopIn(); @@ -100,25 +104,18 @@ namespace osu.Game.Overlays } } - private void onPathChanged(ValueChangedEvent e) + private void loadPage(string path, Language lang) { - // the path could change as a result of redirecting to a newer location of the same page. - // we already have the correct wiki data, so we can safely return here. - if (e.NewValue == wikiData.Value?.Path) - return; - - if (e.NewValue == "error") - return; - cancellationToken?.Cancel(); request?.Cancel(); - string[] values = e.NewValue.Split('/', 2); + // Language code + path, or just path1 + path2 in case + string[] values = path.Split('/', 2); - if (values.Length > 1 && LanguageExtensions.TryParseCultureCode(values[0], out var language)) - request = new GetWikiRequest(values[1], language); + if (values.Length > 1 && LanguageExtensions.TryParseCultureCode(values[0], out var parsedLang)) + request = new GetWikiRequest(values[1], parsedLang); else - request = new GetWikiRequest(e.NewValue); + request = new GetWikiRequest(path, lang); Loading.Show(); @@ -132,6 +129,25 @@ namespace osu.Game.Overlays api.PerformAsync(request); } + private void onPathChanged(ValueChangedEvent e) + { + // the path could change as a result of redirecting to a newer location of the same page. + // we already have the correct wiki data, so we can safely return here. + if (e.NewValue == wikiData.Value?.Path) + return; + + if (e.NewValue == "error") + return; + + loadPage(e.NewValue, language.Value); + } + + private void onLangChanged(ValueChangedEvent e) + { + // Path unmodified, just reload the page with new language value. + loadPage(path.Value, e.NewValue); + } + private void onSuccess(APIWikiPage response) { wikiData.Value = response; diff --git a/osu.Game/Replays/Legacy/LegacyReplayFrame.cs b/osu.Game/Replays/Legacy/LegacyReplayFrame.cs index f345504ca1..b48fc44963 100644 --- a/osu.Game/Replays/Legacy/LegacyReplayFrame.cs +++ b/osu.Game/Replays/Legacy/LegacyReplayFrame.cs @@ -3,7 +3,6 @@ using MessagePack; using Newtonsoft.Json; -using osu.Framework.Extensions.EnumExtensions; using osu.Game.Rulesets.Replays; using osuTK; @@ -32,23 +31,23 @@ namespace osu.Game.Replays.Legacy [JsonIgnore] [IgnoreMember] - public bool MouseLeft1 => ButtonState.HasFlagFast(ReplayButtonState.Left1); + public bool MouseLeft1 => ButtonState.HasFlag(ReplayButtonState.Left1); [JsonIgnore] [IgnoreMember] - public bool MouseRight1 => ButtonState.HasFlagFast(ReplayButtonState.Right1); + public bool MouseRight1 => ButtonState.HasFlag(ReplayButtonState.Right1); [JsonIgnore] [IgnoreMember] - public bool MouseLeft2 => ButtonState.HasFlagFast(ReplayButtonState.Left2); + public bool MouseLeft2 => ButtonState.HasFlag(ReplayButtonState.Left2); [JsonIgnore] [IgnoreMember] - public bool MouseRight2 => ButtonState.HasFlagFast(ReplayButtonState.Right2); + public bool MouseRight2 => ButtonState.HasFlag(ReplayButtonState.Right2); [JsonIgnore] [IgnoreMember] - public bool Smoke => ButtonState.HasFlagFast(ReplayButtonState.Smoke); + public bool Smoke => ButtonState.HasFlag(ReplayButtonState.Smoke); [Key(3)] public ReplayButtonState ButtonState; diff --git a/osu.Game/Rulesets/AssemblyRulesetStore.cs b/osu.Game/Rulesets/AssemblyRulesetStore.cs index 03554ef2db..935ef241dc 100644 --- a/osu.Game/Rulesets/AssemblyRulesetStore.cs +++ b/osu.Game/Rulesets/AssemblyRulesetStore.cs @@ -43,7 +43,12 @@ namespace osu.Game.Rulesets // add all legacy rulesets first to ensure they have exclusive choice of primary key. foreach (var r in instances.Where(r => r is ILegacyRuleset)) - availableRulesets.Add(new RulesetInfo(r.RulesetInfo.ShortName, r.RulesetInfo.Name, r.RulesetInfo.InstantiationInfo, r.RulesetInfo.OnlineID)); + { + availableRulesets.Add(new RulesetInfo(r.RulesetInfo.ShortName, r.RulesetInfo.Name, r.RulesetInfo.InstantiationInfo, r.RulesetInfo.OnlineID) + { + Available = true + }); + } } } } diff --git a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs index 9690924b1c..7b6bc37a61 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs @@ -26,6 +26,10 @@ namespace osu.Game.Rulesets.Difficulty protected const int ATTRIB_ID_FLASHLIGHT = 17; protected const int ATTRIB_ID_SLIDER_FACTOR = 19; protected const int ATTRIB_ID_SPEED_NOTE_COUNT = 21; + protected const int ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT = 23; + protected const int ATTRIB_ID_AIM_DIFFICULT_STRAIN_COUNT = 25; + protected const int ATTRIB_ID_OK_HIT_WINDOW = 27; + protected const int ATTRIB_ID_MONO_STAMINA_FACTOR = 29; /// /// The mods which were applied to the beatmap. diff --git a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs index 1599dff8d9..63b27243d0 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs @@ -10,6 +10,7 @@ using System.Threading; using JetBrains.Annotations; using osu.Framework.Audio.Track; using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Lists; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Timing; @@ -109,26 +110,24 @@ namespace osu.Game.Rulesets.Difficulty var progressiveBeatmap = new ProgressiveCalculationBeatmap(Beatmap); var difficultyObjects = getDifficultyHitObjects().ToArray(); - foreach (var obj in difficultyObjects) + int currentIndex = 0; + + foreach (var obj in Beatmap.HitObjects) { - // Implementations expect the progressive beatmap to only contain top-level objects from the original beatmap. - // At the same time, we also need to consider the possibility DHOs may not be generated for any given object, - // so we'll add all remaining objects up to the current point in time to the progressive beatmap. - for (int i = progressiveBeatmap.HitObjects.Count; i < Beatmap.HitObjects.Count; i++) - { - if (obj != difficultyObjects[^1] && Beatmap.HitObjects[i].StartTime > obj.BaseObject.StartTime) - break; + progressiveBeatmap.HitObjects.Add(obj); - progressiveBeatmap.HitObjects.Add(Beatmap.HitObjects[i]); + while (currentIndex < difficultyObjects.Length && difficultyObjects[currentIndex].BaseObject.GetEndTime() <= obj.GetEndTime()) + { + foreach (var skill in skills) + { + cancellationToken.ThrowIfCancellationRequested(); + skill.Process(difficultyObjects[currentIndex]); + } + + currentIndex++; } - foreach (var skill in skills) - { - cancellationToken.ThrowIfCancellationRequested(); - skill.Process(obj); - } - - attribs.Add(new TimedDifficultyAttributes(obj.EndTime * clockRate, CreateDifficultyAttributes(progressiveBeatmap, playableMods, skills, clockRate))); + attribs.Add(new TimedDifficultyAttributes(obj.GetEndTime(), CreateDifficultyAttributes(progressiveBeatmap, playableMods, skills, clockRate))); } return attribs; @@ -329,7 +328,14 @@ namespace osu.Game.Rulesets.Difficulty set => baseBeatmap.Difficulty = value; } - public List Breaks => baseBeatmap.Breaks; + public SortedList Breaks + { + get => baseBeatmap.Breaks; + set => baseBeatmap.Breaks = value; + } + + public List UnhandledEventLines => baseBeatmap.UnhandledEventLines; + public double TotalBreakTime => baseBeatmap.TotalBreakTime; public IEnumerable GetStatistics() => baseBeatmap.GetStatistics(); public double GetMostCommonBeatLength() => baseBeatmap.GetMostCommonBeatLength(); diff --git a/osu.Game/Rulesets/Difficulty/PerformanceBreakdownCalculator.cs b/osu.Game/Rulesets/Difficulty/PerformanceBreakdownCalculator.cs deleted file mode 100644 index 946d83b14b..0000000000 --- a/osu.Game/Rulesets/Difficulty/PerformanceBreakdownCalculator.cs +++ /dev/null @@ -1,121 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using JetBrains.Annotations; -using osu.Framework.Extensions.ObjectExtensions; -using osu.Game.Beatmaps; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Scoring; -using osu.Game.Scoring; - -namespace osu.Game.Rulesets.Difficulty -{ - public class PerformanceBreakdownCalculator - { - private readonly IBeatmap playableBeatmap; - private readonly BeatmapDifficultyCache difficultyCache; - - public PerformanceBreakdownCalculator(IBeatmap playableBeatmap, BeatmapDifficultyCache difficultyCache) - { - this.playableBeatmap = playableBeatmap; - this.difficultyCache = difficultyCache; - } - - [ItemCanBeNull] - public async Task CalculateAsync(ScoreInfo score, CancellationToken cancellationToken = default) - { - var attributes = await difficultyCache.GetDifficultyAsync(score.BeatmapInfo!, score.Ruleset, score.Mods, cancellationToken).ConfigureAwait(false); - - var performanceCalculator = score.Ruleset.CreateInstance().CreatePerformanceCalculator(); - - // Performance calculation requires the beatmap and ruleset to be locally available. If not, return a default value. - if (attributes?.Attributes == null || performanceCalculator == null) - return null; - - cancellationToken.ThrowIfCancellationRequested(); - - PerformanceAttributes[] performanceArray = await Task.WhenAll( - // compute actual performance - performanceCalculator.CalculateAsync(score, attributes.Value.Attributes, cancellationToken), - // compute performance for perfect play - getPerfectPerformance(score, cancellationToken) - ).ConfigureAwait(false); - - return new PerformanceBreakdown(performanceArray[0] ?? new PerformanceAttributes(), performanceArray[1] ?? new PerformanceAttributes()); - } - - [ItemCanBeNull] - private Task getPerfectPerformance(ScoreInfo score, CancellationToken cancellationToken = default) - { - return Task.Run(async () => - { - Ruleset ruleset = score.Ruleset.CreateInstance(); - ScoreInfo perfectPlay = score.DeepClone(); - perfectPlay.Accuracy = 1; - perfectPlay.Passed = true; - - // calculate max combo - // todo: Get max combo from difficulty calculator instead when diffcalc properly supports lazer-first scores - perfectPlay.MaxCombo = calculateMaxCombo(playableBeatmap); - - // create statistics assuming all hit objects have perfect hit result - var statistics = playableBeatmap.HitObjects - .SelectMany(getPerfectHitResults) - .GroupBy(hr => hr, (hr, list) => (hitResult: hr, count: list.Count())) - .ToDictionary(pair => pair.hitResult, pair => pair.count); - perfectPlay.Statistics = statistics; - perfectPlay.MaximumStatistics = statistics; - - // calculate total score - ScoreProcessor scoreProcessor = ruleset.CreateScoreProcessor(); - scoreProcessor.Mods.Value = perfectPlay.Mods; - scoreProcessor.ApplyBeatmap(playableBeatmap); - perfectPlay.TotalScore = scoreProcessor.MaximumTotalScore; - - // compute rank achieved - // default to SS, then adjust the rank with mods - perfectPlay.Rank = ScoreRank.X; - - foreach (IApplicableToScoreProcessor mod in perfectPlay.Mods.OfType()) - { - perfectPlay.Rank = mod.AdjustRank(perfectPlay.Rank, 1); - } - - // calculate performance for this perfect score - var difficulty = await difficultyCache.GetDifficultyAsync( - playableBeatmap.BeatmapInfo, - score.Ruleset, - score.Mods, - cancellationToken - ).ConfigureAwait(false); - - var performanceCalculator = ruleset.CreatePerformanceCalculator(); - - if (performanceCalculator == null || difficulty == null) - return null; - - return await performanceCalculator.CalculateAsync(perfectPlay, difficulty.Value.Attributes.AsNonNull(), cancellationToken).ConfigureAwait(false); - }, cancellationToken); - } - - private int calculateMaxCombo(IBeatmap beatmap) - { - return beatmap.HitObjects.SelectMany(getPerfectHitResults).Count(r => r.AffectsCombo()); - } - - private IEnumerable getPerfectHitResults(HitObject hitObject) - { - foreach (HitObject nested in hitObject.NestedHitObjects) - yield return nested.Judgement.MaxResult; - - yield return hitObject.Judgement.MaxResult; - } - } -} diff --git a/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs b/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs index cd9dd3572c..9785865192 100644 --- a/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs +++ b/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs @@ -15,11 +15,6 @@ namespace osu.Game.Rulesets.Difficulty.Preprocessing { private readonly IReadOnlyList difficultyHitObjects; - /// - /// The index of this in the list of all s. - /// - public int Count => difficultyHitObjects.Count; - /// /// The index of this in the list of all s. /// diff --git a/osu.Game/Rulesets/Difficulty/Skills/Skill.cs b/osu.Game/Rulesets/Difficulty/Skills/Skill.cs index 8b8892113b..6b48fa041f 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/Skill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/Skill.cs @@ -1,7 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; +using System.Linq; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Mods; @@ -27,6 +29,8 @@ namespace osu.Game.Rulesets.Difficulty.Skills this.mods = mods; } + protected List ObjectStrains = new List(); + /// /// Process a . /// @@ -37,5 +41,23 @@ namespace osu.Game.Rulesets.Difficulty.Skills /// Returns the calculated difficulty value representing all s that have been processed up to this point. /// public abstract double DifficultyValue(); + + /// + /// Calculates the number of strains weighted against the top strain. + /// The result is scaled by clock rate as it affects the total number of strains. + /// + public virtual double CountTopWeightedStrains() + { + if (ObjectStrains.Count == 0) + return 0.0; + + double consistentTopStrain = DifficultyValue() / 10; // What would the top strain be if all strain values were identical + + if (consistentTopStrain == 0) + return ObjectStrains.Count; + + // Use a weighted sum of all strains. Constants are arbitrary and give nice values + return ObjectStrains.Sum(s => 1.1 / (1 + Math.Exp(-10 * (s / consistentTopStrain - 0.88)))); + } } } diff --git a/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs index b07e8399c0..0d71ba0680 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs @@ -20,21 +20,21 @@ namespace osu.Game.Rulesets.Difficulty.Skills /// protected virtual double DecayWeight => 0.9; + protected StrainSkill(Mod[] mods) + : base(mods) + { + } + /// /// The length of each strain section. /// protected virtual int SectionLength => 400; - private double currentSectionPeak; // We also keep track of the peak strain level in the current section. + public double CurrentSectionPeak { get; protected set; } // We also keep track of the peak level in the current section. - private double currentSectionEnd; + protected double CurrentSectionEnd; - private readonly List strainPeaks = new List(); - - protected StrainSkill(Mod[] mods) - : base(mods) - { - } + protected readonly List StrainPeaks = new List(); /// /// Returns the strain value at . This value is calculated with or without respect to previous objects. @@ -44,20 +44,24 @@ namespace osu.Game.Rulesets.Difficulty.Skills /// /// Process a and update current strain values accordingly. /// - public sealed override void Process(DifficultyHitObject current) + public override void Process(DifficultyHitObject current) { // The first object doesn't generate a strain, so we begin with an incremented section end if (current.Index == 0) - currentSectionEnd = Math.Ceiling(current.StartTime / SectionLength) * SectionLength; + CurrentSectionEnd = Math.Ceiling(current.StartTime / SectionLength) * SectionLength; - while (current.StartTime > currentSectionEnd) + while (current.StartTime > CurrentSectionEnd) { saveCurrentPeak(); - startNewSectionFrom(currentSectionEnd, current); - currentSectionEnd += SectionLength; + startNewSectionFrom(CurrentSectionEnd, current); + 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); } /// @@ -65,7 +69,7 @@ namespace osu.Game.Rulesets.Difficulty.Skills /// private void saveCurrentPeak() { - strainPeaks.Add(currentSectionPeak); + StrainPeaks.Add(CurrentSectionPeak); } /// @@ -77,7 +81,7 @@ namespace osu.Game.Rulesets.Difficulty.Skills { // The maximum strain of the new section is not zero by default // This means we need to capture the strain level at the beginning of the new section, and use that as the initial peak level. - currentSectionPeak = CalculateInitialStrain(time, current); + CurrentSectionPeak = CalculateInitialStrain(time, current); } /// @@ -89,10 +93,9 @@ namespace osu.Game.Rulesets.Difficulty.Skills protected abstract double CalculateInitialStrain(double time, DifficultyHitObject current); /// - /// Returns a live enumerable of the peak strains for each section of the beatmap, - /// including the peak of the current section. + /// Returns a live enumerable of the difficulties /// - public IEnumerable GetCurrentStrainPeaks() => strainPeaks.Append(currentSectionPeak); + public virtual IEnumerable GetCurrentStrainPeaks() => StrainPeaks.Append(CurrentSectionPeak); /// /// Returns the calculated difficulty value representing all s that have been processed up to this point. 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/BeatmapVerifier.cs b/osu.Game/Rulesets/Edit/BeatmapVerifier.cs index a9681e13ba..642b878a7b 100644 --- a/osu.Game/Rulesets/Edit/BeatmapVerifier.cs +++ b/osu.Game/Rulesets/Edit/BeatmapVerifier.cs @@ -46,6 +46,9 @@ namespace osu.Game.Rulesets.Edit // Events new CheckBreaks(), + + // Metadata + new CheckTitleMarkers(), }; public IEnumerable Run(BeatmapVerifierContext context) diff --git a/osu.Game/Rulesets/Edit/Checks/CheckBreaks.cs b/osu.Game/Rulesets/Edit/Checks/CheckBreaks.cs index 0842ff5453..f7be36beab 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckBreaks.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckBreaks.cs @@ -13,13 +13,7 @@ namespace osu.Game.Rulesets.Edit.Checks { // Breaks may be off by 1 ms. private const int leniency_threshold = 1; - private const double minimum_gap_before_break = 200; - // Break end time depends on the upcoming object's pre-empt time. - // As things stand, "pre-empt time" is only defined for osu! standard - // This is a generic value representing AR=10 - // Relevant: https://github.com/ppy/osu/issues/14330#issuecomment-1002158551 - private const double min_end_threshold = 450; public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Events, "Breaks not achievable using the editor"); public IEnumerable PossibleTemplates => new IssueTemplate[] @@ -45,8 +39,8 @@ namespace osu.Game.Rulesets.Edit.Checks if (previousObjectEndTimeIndex >= 0) { double gapBeforeBreak = breakPeriod.StartTime - endTimes[previousObjectEndTimeIndex]; - if (gapBeforeBreak < minimum_gap_before_break - leniency_threshold) - yield return new IssueTemplateEarlyStart(this).Create(breakPeriod.StartTime, minimum_gap_before_break - gapBeforeBreak); + if (gapBeforeBreak < BreakPeriod.GAP_BEFORE_BREAK - leniency_threshold) + yield return new IssueTemplateEarlyStart(this).Create(breakPeriod.StartTime, BreakPeriod.GAP_BEFORE_BREAK - gapBeforeBreak); } int nextObjectStartTimeIndex = startTimes.BinarySearch(breakPeriod.EndTime); @@ -55,8 +49,8 @@ namespace osu.Game.Rulesets.Edit.Checks if (nextObjectStartTimeIndex < startTimes.Count) { double gapAfterBreak = startTimes[nextObjectStartTimeIndex] - breakPeriod.EndTime; - if (gapAfterBreak < min_end_threshold - leniency_threshold) - yield return new IssueTemplateLateEnd(this).Create(breakPeriod.StartTime, min_end_threshold - gapAfterBreak); + if (gapAfterBreak < BreakPeriod.GAP_AFTER_BREAK - leniency_threshold) + yield return new IssueTemplateLateEnd(this).Create(breakPeriod.StartTime, BreakPeriod.GAP_AFTER_BREAK - gapAfterBreak); } } } diff --git a/osu.Game/Rulesets/Edit/Checks/CheckTitleMarkers.cs b/osu.Game/Rulesets/Edit/Checks/CheckTitleMarkers.cs new file mode 100644 index 0000000000..9c702ad58a --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/CheckTitleMarkers.cs @@ -0,0 +1,71 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using osu.Game.Rulesets.Edit.Checks.Components; + +namespace osu.Game.Rulesets.Edit.Checks +{ + public class CheckTitleMarkers : ICheck + { + public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Metadata, "Checks for incorrect formats of (TV Size) / (Game Ver.) / (Short Ver.) / (Cut Ver.) / (Sped Up Ver.) / etc in title."); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateIncorrectMarker(this), + }; + + private readonly IEnumerable markerChecks = + [ + new MarkerCheck(@"(TV Size)", @"(?i)(tv (size|ver))"), + new MarkerCheck(@"(Game Ver.)", @"(?i)(game (size|ver))"), + new MarkerCheck(@"(Short Ver.)", @"(?i)(short (size|ver))"), + new MarkerCheck(@"(Cut Ver.)", @"(?i)(? Run(BeatmapVerifierContext context) + { + string romanisedTitle = context.Beatmap.Metadata.Title; + string unicodeTitle = context.Beatmap.Metadata.TitleUnicode; + + foreach (var check in markerChecks) + { + bool hasRomanisedTitle = unicodeTitle != romanisedTitle; + + if (check.AnyRegex.IsMatch(unicodeTitle) && !unicodeTitle.Contains(check.CorrectMarkerFormat, StringComparison.Ordinal)) + yield return new IssueTemplateIncorrectMarker(this).Create("Title", check.CorrectMarkerFormat); + + if (hasRomanisedTitle && check.AnyRegex.IsMatch(romanisedTitle) && !romanisedTitle.Contains(check.CorrectMarkerFormat, StringComparison.Ordinal)) + yield return new IssueTemplateIncorrectMarker(this).Create("Romanised title", check.CorrectMarkerFormat); + } + } + + private class MarkerCheck + { + public readonly string CorrectMarkerFormat; + public readonly Regex AnyRegex; + + public MarkerCheck(string exact, string anyRegex) + { + CorrectMarkerFormat = exact; + AnyRegex = new Regex(anyRegex, RegexOptions.Compiled); + } + } + + public class IssueTemplateIncorrectMarker : IssueTemplate + { + public IssueTemplateIncorrectMarker(ICheck check) + : base(check, IssueType.Problem, "{0} field has an incorrect format of marker {1}") + { + } + + public Issue Create(string titleField, string correctMarkerFormat) => new Issue(this, titleField, correctMarkerFormat); + } + } +} diff --git a/osu.Game/Rulesets/Edit/ComposerDistanceSnapProvider.cs b/osu.Game/Rulesets/Edit/ComposerDistanceSnapProvider.cs index 62ad2ce7e9..cf41c8e108 100644 --- a/osu.Game/Rulesets/Edit/ComposerDistanceSnapProvider.cs +++ b/osu.Game/Rulesets/Edit/ComposerDistanceSnapProvider.cs @@ -63,6 +63,7 @@ namespace osu.Game.Rulesets.Edit public readonly Bindable DistanceSnapToggle = new Bindable(); private bool distanceSnapMomentary; + private TernaryState? distanceSnapStateBeforeMomentaryToggle; private EditorToolboxGroup? toolboxGroup; @@ -73,6 +74,7 @@ namespace osu.Game.Rulesets.Edit toolboxContainer.Add(toolboxGroup = new EditorToolboxGroup("snapping") { + Name = "snapping", Alpha = DistanceSpacingMultiplier.Disabled ? 0 : 1, Children = new Drawable[] { @@ -194,29 +196,23 @@ namespace osu.Game.Rulesets.Edit new TernaryButton(DistanceSnapToggle, "Distance Snap", () => new SpriteIcon { Icon = OsuIcon.EditorDistanceSnap }) }; - protected override bool OnKeyDown(KeyDownEvent e) - { - if (e.Repeat) - return false; - - handleToggleViaKey(e); - return base.OnKeyDown(e); - } - - protected override void OnKeyUp(KeyUpEvent e) - { - handleToggleViaKey(e); - base.OnKeyUp(e); - } - - private void handleToggleViaKey(KeyboardEvent key) + public void HandleToggleViaKey(KeyboardEvent key) { bool altPressed = key.AltPressed; - if (altPressed != distanceSnapMomentary) + if (altPressed && !distanceSnapMomentary) { - distanceSnapMomentary = altPressed; + distanceSnapStateBeforeMomentaryToggle = DistanceSnapToggle.Value; DistanceSnapToggle.Value = DistanceSnapToggle.Value == TernaryState.False ? TernaryState.True : TernaryState.False; + distanceSnapMomentary = true; + } + + if (!altPressed && distanceSnapMomentary) + { + Debug.Assert(distanceSnapStateBeforeMomentaryToggle != null); + DistanceSnapToggle.Value = distanceSnapStateBeforeMomentaryToggle.Value; + distanceSnapStateBeforeMomentaryToggle = null; + distanceSnapMomentary = false; } } @@ -285,22 +281,36 @@ namespace osu.Game.Rulesets.Edit public virtual double FindSnappedDuration(HitObject referenceObject, float distance) => beatSnapProvider.SnapTime(referenceObject.StartTime + DistanceToDuration(referenceObject, distance), referenceObject.StartTime) - referenceObject.StartTime; - public virtual float FindSnappedDistance(HitObject referenceObject, float distance) + public virtual float FindSnappedDistance(HitObject referenceObject, float distance, DistanceSnapTarget target) { - double startTime = referenceObject.StartTime; + double referenceTime; - double actualDuration = startTime + DistanceToDuration(referenceObject, distance); + switch (target) + { + case DistanceSnapTarget.Start: + referenceTime = referenceObject.StartTime; + break; - double snappedEndTime = beatSnapProvider.SnapTime(actualDuration, startTime); + case DistanceSnapTarget.End: + referenceTime = referenceObject.GetEndTime(); + break; - double beatLength = beatSnapProvider.GetBeatLengthAtTime(startTime); + default: + throw new ArgumentOutOfRangeException(nameof(target), target, $"Unknown {nameof(DistanceSnapTarget)} value"); + } + + double actualDuration = referenceTime + DistanceToDuration(referenceObject, distance); + + double snappedTime = beatSnapProvider.SnapTime(actualDuration, referenceTime); + + double beatLength = beatSnapProvider.GetBeatLengthAtTime(referenceTime); // we don't want to exceed the actual duration and snap to a point in the future. // as we are snapping to beat length via SnapTime (which will round-to-nearest), check for snapping in the forward direction and reverse it. - if (snappedEndTime > actualDuration + 1) - snappedEndTime -= beatLength; + if (snappedTime > actualDuration + 1) + snappedTime -= beatLength; - return DurationToDistance(referenceObject, snappedEndTime - startTime); + return DurationToDistance(referenceObject, snappedTime - referenceTime); } #endregion diff --git a/osu.Game/Rulesets/Edit/EditorTimestampParser.cs b/osu.Game/Rulesets/Edit/EditorTimestampParser.cs index bdfdce432e..9e637e55bc 100644 --- a/osu.Game/Rulesets/Edit/EditorTimestampParser.cs +++ b/osu.Game/Rulesets/Edit/EditorTimestampParser.cs @@ -9,13 +9,45 @@ namespace osu.Game.Rulesets.Edit { public static class EditorTimestampParser { - // 00:00:000 (...) - test - // original osu-web regex: https://github.com/ppy/osu-web/blob/3b1698639244cfdaf0b41c68bfd651ea729ec2e3/resources/js/utils/beatmapset-discussion-helper.ts#L78 - public static readonly Regex TIME_REGEX = new Regex(@"\b(((?\d{2,}):(?[0-5]\d)[:.](?\d{3}))(?\s\([^)]+\))?)", RegexOptions.Compiled); + /// + /// Used for parsing in contexts where we don't want e.g. normal times of day to be parsed as timestamps (e.g. chat) + /// Original osu-web regex: + /// https://github.com/ppy/osu-web/blob/3b1698639244cfdaf0b41c68bfd651ea729ec2e3/resources/js/utils/beatmapset-discussion-helper.ts#L78 + /// + /// + /// 00:00:000 (...) - test + /// + public static readonly Regex TIME_REGEX_STRICT = new Regex(@"\b(((?\d{2,}):(?[0-5]\d)[:.](?\d{3}))(?\s\([^)]+\))?)", RegexOptions.Compiled); + + /// + /// Used for editor-specific context wherein we want to try as hard as we can to process user input as a timestamp. + /// + /// + /// + /// 1 - parses to 00:00:001 (bare numbers are treated as milliseconds) + /// 1:2 - parses to 01:02:000 + /// 1:02 - parses to 01:02:000 + /// 1:92 - does not parse + /// 1:02:3 - parses to 01:02:003 + /// 1:02:300 - parses to 01:02:300 + /// 1:02:300 (1,2,3) - parses to 01:02:300 with selection + /// + /// + private static readonly Regex time_regex_lenient = new Regex( + @"^(((?\d{1,3}):(?([0-5]?\d))([:.](?\d{0,3}))?)(?\s\([^)]+\))?)(?\s-.*)?$", + RegexOptions.Compiled | RegexOptions.Singleline + ); public static bool TryParse(string timestamp, [NotNullWhen(true)] out TimeSpan? parsedTime, out string? parsedSelection) { - Match match = TIME_REGEX.Match(timestamp); + if (double.TryParse(timestamp, out double msec)) + { + parsedTime = TimeSpan.FromMilliseconds(msec); + parsedSelection = null; + return true; + } + + Match match = time_regex_lenient.Match(timestamp); if (!match.Success) { @@ -24,16 +56,14 @@ namespace osu.Game.Rulesets.Edit return false; } - bool result = true; + int timeMin, timeSec, timeMsec; - result &= int.TryParse(match.Groups[@"minutes"].Value, out int timeMin); - result &= int.TryParse(match.Groups[@"seconds"].Value, out int timeSec); - result &= int.TryParse(match.Groups[@"milliseconds"].Value, out int timeMsec); + int.TryParse(match.Groups[@"minutes"].Value, out timeMin); + int.TryParse(match.Groups[@"seconds"].Value, out timeSec); + int.TryParse(match.Groups[@"milliseconds"].Value, out timeMsec); // somewhat sane limit for timestamp duration (10 hours). - result &= timeMin < 600; - - if (!result) + if (timeMin >= 600) { parsedTime = null; parsedSelection = null; @@ -42,8 +72,7 @@ namespace osu.Game.Rulesets.Edit parsedTime = TimeSpan.FromMinutes(timeMin) + TimeSpan.FromSeconds(timeSec) + TimeSpan.FromMilliseconds(timeMsec); parsedSelection = match.Groups[@"selection"].Value.Trim(); - if (!string.IsNullOrEmpty(parsedSelection)) - parsedSelection = parsedSelection[1..^1]; + parsedSelection = !string.IsNullOrEmpty(parsedSelection) ? parsedSelection[1..^1] : null; return true; } } diff --git a/osu.Game/Rulesets/Edit/ExpandableButton.cs b/osu.Game/Rulesets/Edit/ExpandableButton.cs index a708f76845..9139802d68 100644 --- a/osu.Game/Rulesets/Edit/ExpandableButton.cs +++ b/osu.Game/Rulesets/Edit/ExpandableButton.cs @@ -11,7 +11,7 @@ using osu.Game.Graphics.UserInterfaceV2; namespace osu.Game.Rulesets.Edit { - internal partial class ExpandableButton : RoundedButton, IExpandable + public partial class ExpandableButton : RoundedButton, IExpandable { private float actualHeight; diff --git a/osu.Game/Rulesets/Edit/ExpandableSpriteText.cs b/osu.Game/Rulesets/Edit/ExpandableSpriteText.cs new file mode 100644 index 0000000000..b45fce1d22 --- /dev/null +++ b/osu.Game/Rulesets/Edit/ExpandableSpriteText.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Rulesets.Edit +{ + internal partial class ExpandableSpriteText : OsuSpriteText, IExpandable + { + public BindableBool Expanded { get; } = new BindableBool(); + + [Resolved(canBeNull: true)] + private IExpandingContainer? expandingContainer { get; set; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + expandingContainer?.Expanded.BindValueChanged(containerExpanded => + { + Expanded.Value = containerExpanded.NewValue; + }, true); + + Expanded.BindValueChanged(expanded => + { + this.FadeTo(expanded.NewValue ? 1 : 0, 150, Easing.OutQuint); + }, true); + } + } +} diff --git a/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs b/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs index 36cbf49885..8af795f880 100644 --- a/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs +++ b/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs @@ -1,9 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Input.Events; +using osu.Game.Configuration; using osu.Game.Graphics.Containers; +using osu.Game.Screens.Edit; using osuTK; namespace osu.Game.Rulesets.Edit @@ -12,13 +16,43 @@ namespace osu.Game.Rulesets.Edit { protected override double HoverExpansionDelay => 250; + protected override bool ExpandOnHover => expandOnHover; + + private readonly Bindable contractSidebars = new Bindable(); + + private bool expandOnHover; + + [Resolved] + private Editor? editor { get; set; } + public ExpandingToolboxContainer(float contractedWidth, float expandedWidth) : base(contractedWidth, expandedWidth) { RelativeSizeAxes = Axes.Y; FillFlow.Spacing = new Vector2(5); - Padding = new MarginPadding { Vertical = 5 }; + FillFlow.Padding = new MarginPadding { Vertical = 5 }; + + Expanded.Value = true; + } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + config.BindWith(OsuSetting.EditorContractSidebars, contractSidebars); + } + + protected override void Update() + { + base.Update(); + + bool requireContracting = contractSidebars.Value || editor?.DrawSize.X / editor?.DrawSize.Y < 1.7f; + + if (expandOnHover != requireContracting) + { + expandOnHover = requireContracting; + Expanded.Value = !expandOnHover; + } } protected override bool ReceivePositionalInputAtSubTree(Vector2 screenSpacePos) => base.ReceivePositionalInputAtSubTree(screenSpacePos) && anyToolboxHovered(screenSpacePos); diff --git a/osu.Game/Rulesets/Edit/HitObjectComposer.cs b/osu.Game/Rulesets/Edit/HitObjectComposer.cs index 4d92a08bed..4b64548f9c 100644 --- a/osu.Game/Rulesets/Edit/HitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/HitObjectComposer.cs @@ -7,9 +7,9 @@ using System; using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -18,6 +18,7 @@ using osu.Framework.Input.Events; using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Graphics; using osu.Game.Overlays; using osu.Game.Rulesets.Configuration; using osu.Game.Rulesets.Edit.Tools; @@ -66,7 +67,8 @@ namespace osu.Game.Rulesets.Edit [Resolved] private OverlayColourProvider colourProvider { get; set; } - protected ComposeBlueprintContainer BlueprintContainer { get; private set; } + public override ComposeBlueprintContainer BlueprintContainer => blueprintContainer; + private ComposeBlueprintContainer blueprintContainer; protected ExpandingToolboxContainer LeftToolbox { get; private set; } @@ -78,14 +80,19 @@ namespace osu.Game.Rulesets.Edit protected InputManager InputManager { get; private set; } + private Box leftToolboxBackground; + private Box rightToolboxBackground; + private EditorRadioButtonCollection toolboxCollection; - private FillFlowContainer togglesCollection; - private FillFlowContainer sampleBankTogglesCollection; private IBindable hasTiming; private Bindable autoSeekOnPlacement; + private readonly Bindable composerFocusMode = new Bindable(); + + [CanBeNull] + private RadioButton lastTool; protected DrawableRuleset DrawableRuleset { get; private set; } @@ -97,11 +104,14 @@ namespace osu.Game.Rulesets.Edit protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); - [BackgroundDependencyLoader] - private void load(OsuConfigManager config) + [BackgroundDependencyLoader(true)] + private void load(OsuConfigManager config, [CanBeNull] Editor editor) { autoSeekOnPlacement = config.GetBindable(OsuSetting.EditorAutoSeekOnPlacement); + if (editor != null) + composerFocusMode.BindTo(editor.ComposerFocusMode); + Config = Dependencies.Get().GetConfigFor(Ruleset); try @@ -137,7 +147,7 @@ namespace osu.Game.Rulesets.Edit drawableRulesetWrapper, // layers above playfield drawableRulesetWrapper.CreatePlayfieldAdjustmentContainer() - .WithChild(BlueprintContainer = CreateBlueprintContainer()) + .WithChild(blueprintContainer = CreateBlueprintContainer()) } }, new Container @@ -146,7 +156,7 @@ namespace osu.Game.Rulesets.Edit AutoSizeAxes = Axes.X, Children = new Drawable[] { - new Box + leftToolboxBackground = new Box { Colour = colourProvider.Background5, RelativeSizeAxes = Axes.Both, @@ -169,16 +179,56 @@ namespace osu.Game.Rulesets.Edit Spacing = new Vector2(0, 5), }, }, - new EditorToolboxGroup("bank (Shift-Q~R)") + new EditorToolboxGroup("bank (Shift/Alt-Q~R)") { - Child = sampleBankTogglesCollection = new FillFlowContainer + Child = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Vertical, Spacing = new Vector2(0, 5), - }, - } + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new ExpandableSpriteText + { + Text = "Normal", + AlwaysPresent = true, + AllowMultiline = false, + RelativePositionAxes = Axes.X, + X = 0.25f, + Origin = Anchor.TopCentre, + Anchor = Anchor.TopLeft, + Font = OsuFont.GetFont(weight: FontWeight.Regular, size: 17), + }, + new ExpandableSpriteText + { + Text = "Addition", + AlwaysPresent = true, + AllowMultiline = false, + RelativePositionAxes = Axes.X, + X = 0.75f, + Origin = Anchor.TopCentre, + Anchor = Anchor.TopLeft, + Font = OsuFont.GetFont(weight: FontWeight.Regular, size: 17), + }, + } + }, + sampleBankTogglesCollection = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5), + }, + } + } + }, } }, } @@ -191,7 +241,7 @@ namespace osu.Game.Rulesets.Edit AutoSizeAxes = Axes.X, Children = new Drawable[] { - new Box + rightToolboxBackground = new Box { Colour = colourProvider.Background5, RelativeSizeAxes = Axes.Both, @@ -200,32 +250,31 @@ namespace osu.Game.Rulesets.Edit { Child = new EditorToolboxGroup("inspector") { - Child = new HitObjectInspector() + Child = CreateHitObjectInspector() }, } } }, }; - toolboxCollection.Items = CompositionTools - .Prepend(new SelectTool()) - .Select(t => new RadioButton(t.Name, () => toolSelected(t), t.CreateIcon)) + toolboxCollection.Items = (CompositionTools.Prepend(new SelectTool())) + .Select(t => new HitObjectCompositionToolButton(t, () => toolSelected(t))) .ToList(); foreach (var item in toolboxCollection.Items) { item.Selected.DisabledChanged += isDisabled => { - item.TooltipText = isDisabled ? "Add at least one timing point first!" : string.Empty; + item.TooltipText = isDisabled ? "Add at least one timing point first!" : ((HitObjectCompositionToolButton)item).TooltipText; }; } TernaryStates = CreateTernaryButtons().ToArray(); togglesCollection.AddRange(TernaryStates.Select(b => new DrawableTernaryButton(b))); - sampleBankTogglesCollection.AddRange(BlueprintContainer.SampleBankTernaryStates.Select(b => new DrawableTernaryButton(b))); + sampleBankTogglesCollection.AddRange(BlueprintContainer.SampleBankTernaryStates.Zip(BlueprintContainer.SampleAdditionBankTernaryStates).Select(b => new SampleBankTernaryButton(b.First, b.Second))); - setSelectTool(); + SetSelectTool(); EditorBeatmap.SelectedHitObjects.CollectionChanged += selectionChanged; } @@ -250,7 +299,7 @@ namespace osu.Game.Rulesets.Edit { // it's important this is performed before the similar code in EditorRadioButton disables the button. if (!timing.NewValue) - setSelectTool(); + SetSelectTool(); }); EditorBeatmap.HasTiming.BindValueChanged(hasTiming => @@ -260,6 +309,21 @@ namespace osu.Game.Rulesets.Edit item.Selected.Disabled = !hasTiming.NewValue; } }, true); + + composerFocusMode.BindValueChanged(_ => + { + // Transforms should be kept in sync with other usages of composer focus mode. + if (!composerFocusMode.Value) + { + leftToolboxBackground.FadeIn(750, Easing.OutQuint); + rightToolboxBackground.FadeIn(750, Easing.OutQuint); + } + else + { + leftToolboxBackground.Delay(600).FadeTo(0.5f, 4000, Easing.OutQuint); + rightToolboxBackground.Delay(600).FadeTo(0.5f, 4000, Easing.OutQuint); + } + }, true); } protected override void Update() @@ -280,9 +344,13 @@ namespace osu.Game.Rulesets.Edit PlayfieldContentContainer.Anchor = Anchor.CentreLeft; PlayfieldContentContainer.Origin = Anchor.CentreLeft; - PlayfieldContentContainer.Width = Math.Max(1024, DrawWidth) - (TOOLBOX_CONTRACTED_SIZE_LEFT + TOOLBOX_CONTRACTED_SIZE_RIGHT); - PlayfieldContentContainer.X = TOOLBOX_CONTRACTED_SIZE_LEFT; + PlayfieldContentContainer.Width = Math.Max(1024, DrawWidth); + PlayfieldContentContainer.X = LeftToolbox.DrawWidth; } + + composerFocusMode.Value = PlayfieldContentContainer.Contains(InputManager.CurrentState.Mouse.Position) + && !LeftToolbox.Contains(InputManager.CurrentState.Mouse.Position) + && !RightToolbox.Contains(InputManager.CurrentState.Mouse.Position); } public override Playfield Playfield => drawableRulesetWrapper.Playfield; @@ -298,7 +366,7 @@ namespace osu.Game.Rulesets.Edit /// /// A "select" tool is automatically added as the first tool. /// - protected abstract IReadOnlyList CompositionTools { get; } + protected abstract IReadOnlyList CompositionTools { get; } /// /// A collection of states which will be displayed to the user in the toolbox. @@ -315,6 +383,8 @@ namespace osu.Game.Rulesets.Edit /// protected virtual ComposeBlueprintContainer CreateBlueprintContainer() => new ComposeBlueprintContainer(this); + protected virtual Drawable CreateHitObjectInspector() => new HitObjectInspector(); + /// /// Construct a drawable ruleset for the provided ruleset. /// @@ -333,10 +403,10 @@ namespace osu.Game.Rulesets.Edit protected override bool OnKeyDown(KeyDownEvent e) { - if (e.ControlPressed || e.AltPressed || e.SuperPressed) + if (e.ControlPressed || e.SuperPressed) return false; - if (checkLeftToggleFromKey(e.Key, out int leftIndex)) + if (checkToolboxMappingFromKey(e.Key, out int leftIndex)) { var item = toolboxCollection.Items.ElementAtOrDefault(leftIndex); @@ -348,23 +418,35 @@ namespace osu.Game.Rulesets.Edit } } - if (checkRightToggleFromKey(e.Key, out int rightIndex)) + if (checkToggleMappingFromKey(e.Key, out int rightIndex)) { - var item = e.ShiftPressed - ? sampleBankTogglesCollection.ElementAtOrDefault(rightIndex) - : togglesCollection.ElementAtOrDefault(rightIndex); - - if (item is DrawableTernaryButton button) + if (e.ShiftPressed || e.AltPressed) { - button.Button.Toggle(); - return true; + if (sampleBankTogglesCollection.ElementAtOrDefault(rightIndex) is SampleBankTernaryButton sampleBankTernaryButton) + { + if (e.ShiftPressed) + sampleBankTernaryButton.NormalButton.Toggle(); + + if (e.AltPressed) + sampleBankTernaryButton.AdditionsButton.Toggle(); + + return true; + } + } + else + { + if (togglesCollection.ElementAtOrDefault(rightIndex) is DrawableTernaryButton button) + { + button.Button.Toggle(); + return true; + } } } return base.OnKeyDown(e); } - private bool checkLeftToggleFromKey(Key key, out int index) + private bool checkToolboxMappingFromKey(Key key, out int index) { if (key < Key.Number1 || key > Key.Number9) { @@ -376,7 +458,7 @@ namespace osu.Game.Rulesets.Edit return true; } - private bool checkRightToggleFromKey(Key key, out int index) + private bool checkToggleMappingFromKey(Key key, out int index) { switch (key) { @@ -433,14 +515,18 @@ namespace osu.Game.Rulesets.Edit if (EditorBeatmap.SelectedHitObjects.Any()) { // ensure in selection mode if a selection is made. - setSelectTool(); + SetSelectTool(); } } - private void setSelectTool() => toolboxCollection.Items.First().Select(); + public void SetSelectTool() => toolboxCollection.Items.First().Select(); - private void toolSelected(HitObjectCompositionTool tool) + public void SetLastTool() => (lastTool ?? toolboxCollection.Items.First()).Select(); + + private void toolSelected(CompositionTool tool) { + lastTool = toolboxCollection.Items.OfType().FirstOrDefault(i => i.Tool == BlueprintContainer.CurrentTool); + BlueprintContainer.CurrentTool = tool; if (!(tool is SelectTool)) @@ -451,22 +537,23 @@ namespace osu.Game.Rulesets.Edit #region IPlacementHandler - public void BeginPlacement(HitObject hitObject) + public void ShowPlacement(HitObject hitObject) { EditorBeatmap.PlacementObject.Value = hitObject; } - public void EndPlacement(HitObject hitObject, bool commit) + public void HidePlacement() { EditorBeatmap.PlacementObject.Value = null; + } - if (commit) - { - EditorBeatmap.Add(hitObject); + public void CommitPlacement(HitObject hitObject) + { + EditorBeatmap.PlacementObject.Value = null; + EditorBeatmap.Add(hitObject); - if (autoSeekOnPlacement.Value && EditorClock.CurrentTime < hitObject.StartTime) - EditorClock.SeekSmoothlyTo(hitObject.StartTime); - } + if (autoSeekOnPlacement.Value && EditorClock.CurrentTime < hitObject.StartTime) + EditorClock.SeekSmoothlyTo(hitObject.StartTime); } public void Delete(HitObject hitObject) => EditorBeatmap.Remove(hitObject); @@ -489,7 +576,7 @@ namespace osu.Game.Rulesets.Edit var playfield = PlayfieldAtScreenSpacePosition(screenSpacePosition); double? targetTime = null; - if (snapType.HasFlagFast(SnapType.GlobalGrids)) + if (snapType.HasFlag(SnapType.GlobalGrids)) { if (playfield is ScrollingPlayfield scrollingPlayfield) { @@ -532,6 +619,8 @@ namespace osu.Game.Rulesets.Edit /// public abstract Playfield Playfield { get; } + public abstract ComposeBlueprintContainer BlueprintContainer { get; } + /// /// All s in currently loaded beatmap. /// diff --git a/osu.Game/Rulesets/Edit/HitObjectCompositionToolButton.cs b/osu.Game/Rulesets/Edit/HitObjectCompositionToolButton.cs new file mode 100644 index 0000000000..641d60dbd3 --- /dev/null +++ b/osu.Game/Rulesets/Edit/HitObjectCompositionToolButton.cs @@ -0,0 +1,22 @@ +// 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.Edit.Tools; +using osu.Game.Screens.Edit.Components.RadioButtons; + +namespace osu.Game.Rulesets.Edit +{ + public class HitObjectCompositionToolButton : RadioButton + { + public CompositionTool Tool { get; } + + public HitObjectCompositionToolButton(CompositionTool tool, Action? action) + : base(tool.Name, action, tool.CreateIcon) + { + Tool = tool; + + TooltipText = tool.TooltipText; + } + } +} diff --git a/osu.Game/Rulesets/Edit/HitObjectPlacementBlueprint.cs b/osu.Game/Rulesets/Edit/HitObjectPlacementBlueprint.cs new file mode 100644 index 0000000000..4df2a52743 --- /dev/null +++ b/osu.Game/Rulesets/Edit/HitObjectPlacementBlueprint.cs @@ -0,0 +1,154 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using 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; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.Compose; + +namespace osu.Game.Rulesets.Edit +{ + /// + /// A blueprint which governs the creation of a new to actualisation. + /// + public abstract partial class HitObjectPlacementBlueprint : PlacementBlueprint + { + /// + /// Whether the sample bank should be taken from the previous hit object. + /// + public bool AutomaticBankAssignment { get; set; } + + /// + /// Whether the sample addition bank should be taken from the previous hit objects. + /// + public bool AutomaticAdditionBankAssignment { get; set; } + + /// + /// The that is being placed. + /// + public readonly HitObject HitObject; + + [Resolved] + protected EditorClock EditorClock { get; private set; } = null!; + + [Resolved] + private EditorBeatmap beatmap { get; set; } = null!; + + private Bindable startTimeBindable = null!; + + private HitObject? getPreviousHitObject() => beatmap.HitObjects.TakeWhile(h => h.StartTime <= startTimeBindable.Value).LastOrDefault(); + + [Resolved] + private IPlacementHandler placementHandler { get; set; } = null!; + + protected HitObjectPlacementBlueprint(HitObject hitObject) + { + HitObject = hitObject; + + // adding the default hit sample should be the case regardless of the ruleset. + HitObject.Samples.Add(new HitSampleInfo(HitSampleInfo.HIT_NORMAL)); + } + + [BackgroundDependencyLoader] + private void load() + { + startTimeBindable = HitObject.StartTimeBindable.GetBoundCopy(); + startTimeBindable.BindValueChanged(_ => ApplyDefaultsToHitObject(), true); + } + + private bool placementBegun; + + protected override void BeginPlacement(bool commitStart = false) + { + base.BeginPlacement(commitStart); + + if (State.Value == Visibility.Visible) + placementHandler.ShowPlacement(HitObject); + + placementBegun = true; + } + + public override void EndPlacement(bool commit) + { + base.EndPlacement(commit); + + if (IsValidForPlacement && commit) + placementHandler.CommitPlacement(HitObject); + else + placementHandler.HidePlacement(); + } + + /// + /// Updates the time and position of this based on the provided snap information. + /// + /// The snap result information. + public override void UpdateTimeAndPosition(SnapResult result) + { + if (PlacementActive == PlacementState.Waiting) + { + HitObject.StartTime = result.Time ?? EditorClock.CurrentTime; + + if (HitObject is IHasComboInformation comboInformation) + comboInformation.UpdateComboInformation(getPreviousHitObject() as IHasComboInformation); + } + + var lastHitObject = getPreviousHitObject(); + var lastHitNormal = lastHitObject?.Samples?.FirstOrDefault(o => o.Name == HitSampleInfo.HIT_NORMAL); + + if (AutomaticAdditionBankAssignment) + { + // Inherit the addition bank from the previous hit object + // If there is no previous addition, inherit from the normal sample + var lastAddition = lastHitObject?.Samples?.FirstOrDefault(o => o.Name != HitSampleInfo.HIT_NORMAL) ?? lastHitNormal; + + if (lastAddition != null) + HitObject.Samples = HitObject.Samples.Select(s => s.Name != HitSampleInfo.HIT_NORMAL ? s.With(newBank: lastAddition.Bank) : s).ToList(); + } + + if (lastHitNormal != null) + { + if (AutomaticBankAssignment) + // Inherit the bank from the previous hit object + HitObject.Samples = HitObject.Samples.Select(s => s.Name == HitSampleInfo.HIT_NORMAL ? s.With(newBank: lastHitNormal.Bank) : s).ToList(); + + // Inherit the volume from the previous hit object + HitObject.Samples = HitObject.Samples.Select(s => s.With(newVolume: lastHitNormal.Volume)).ToList(); + } + + if (HitObject is IHasRepeats hasRepeats) + { + // Make sure all the node samples are identical to the hit object's samples + for (int i = 0; i < hasRepeats.NodeSamples.Count; i++) + hasRepeats.NodeSamples[i] = HitObject.Samples.Select(o => o.With()).ToList(); + } + } + + /// + /// Invokes , + /// refreshing and parameters for the . + /// + protected void ApplyDefaultsToHitObject() => HitObject.ApplyDefaults(beatmap.ControlPointInfo, beatmap.Difficulty); + + protected override void PopIn() + { + base.PopIn(); + + if (placementBegun) + placementHandler.ShowPlacement(HitObject); + } + + protected override void PopOut() + { + base.PopOut(); + placementHandler.HidePlacement(); + } + } +} diff --git a/osu.Game/Rulesets/Edit/IDistanceSnapProvider.cs b/osu.Game/Rulesets/Edit/IDistanceSnapProvider.cs index 380038eadf..17fae9e8b2 100644 --- a/osu.Game/Rulesets/Edit/IDistanceSnapProvider.cs +++ b/osu.Game/Rulesets/Edit/IDistanceSnapProvider.cs @@ -58,10 +58,17 @@ namespace osu.Game.Rulesets.Edit /// /// An object to be used as a reference point for this operation. /// The distance to convert. + /// Whether the distance measured should be from the start or the end of . /// /// A value that represents snapped to the closest beat of the timing point. /// The distance will always be less than or equal to the provided . /// - float FindSnappedDistance(HitObject referenceObject, float distance); + float FindSnappedDistance(HitObject referenceObject, float distance, DistanceSnapTarget target); + } + + public enum DistanceSnapTarget + { + Start, + End, } } diff --git a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs index 5cb9adfd72..52b8a5c796 100644 --- a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs +++ b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs @@ -1,60 +1,26 @@ // 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 System.Threading; -using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; -using osu.Game.Audio; -using osu.Game.Beatmaps; -using osu.Game.Beatmaps.ControlPoints; using osu.Game.Input.Bindings; using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Objects.Types; -using osu.Game.Screens.Edit; -using osu.Game.Screens.Edit.Compose; -using osuTK; using osuTK.Input; namespace osu.Game.Rulesets.Edit { /// - /// A blueprint which governs the creation of a new to actualisation. + /// 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. /// public PlacementState PlacementActive { get; private set; } - /// - /// Whether the sample bank should be taken from the previous hit object. - /// - public bool AutomaticBankAssignment { get; set; } - - /// - /// The that is being placed. - /// - public readonly HitObject HitObject; - - [Resolved] - protected EditorClock EditorClock { get; private set; } = null!; - - [Resolved] - private EditorBeatmap beatmap { get; set; } = null!; - - private Bindable startTimeBindable = null!; - - private HitObject? getPreviousHitObject() => beatmap.HitObjects.TakeWhile(h => h.StartTime <= startTimeBindable.Value).LastOrDefault(); - - [Resolved] - private IPlacementHandler placementHandler { get; set; } = null!; - /// /// Whether this blueprint is currently in a state that can be committed. /// @@ -64,44 +30,36 @@ namespace osu.Game.Rulesets.Edit /// protected virtual bool IsValidForPlacement => true; - protected PlacementBlueprint(HitObject hitObject) + // 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() { - HitObject = hitObject; - - // adding the default hit sample should be the case regardless of the ruleset. - HitObject.Samples.Add(new HitSampleInfo(HitSampleInfo.HIT_NORMAL)); - 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; } - [BackgroundDependencyLoader] - private void load() - { - startTimeBindable = HitObject.StartTimeBindable.GetBoundCopy(); - startTimeBindable.BindValueChanged(_ => ApplyDefaultsToHitObject(), true); - } - /// - /// Signals that the placement of has started. + /// Signals that the placement has started. /// - /// Whether this call is committing a value for HitObject.StartTime and continuing with further adjustments. - protected void BeginPlacement(bool commitStart = false) + /// Whether this call is committing a value and continuing with further adjustments. + protected virtual void BeginPlacement(bool commitStart = false) { - placementHandler.BeginPlacement(HitObject); if (commitStart) PlacementActive = PlacementState.Active; } /// /// Signals that the placement of has finished. - /// This will destroy this , and add the HitObject.StartTime to the . + /// This will destroy this , and commit the changes. /// - /// Whether the object should be committed. Note that a commit may fail if is false. - public void EndPlacement(bool commit) + /// Whether the changes should be committed. Note that a commit may fail if is false. + public virtual void EndPlacement(bool commit) { switch (PlacementActive) { @@ -114,10 +72,22 @@ namespace osu.Game.Rulesets.Edit break; } - placementHandler.EndPlacement(HitObject, IsValidForPlacement && commit); PlacementActive = PlacementState.Finished; } + /// + /// Determines which objects to snap to for the snap result in . + /// + public virtual SnapType SnapType => SnapType.All; + + /// + /// Updates the time and position of this based on the provided snap information. + /// + /// The snap result information. + public virtual void UpdateTimeAndPosition(SnapResult result) + { + } + public bool OnPressed(KeyBindingPressEvent e) { if (PlacementActive == PlacementState.Waiting) @@ -125,10 +95,6 @@ namespace osu.Game.Rulesets.Edit switch (e.Action) { - case GlobalAction.Select: - EndPlacement(true); - return true; - case GlobalAction.Back: EndPlacement(false); return true; @@ -142,37 +108,6 @@ namespace osu.Game.Rulesets.Edit { } - /// - /// Updates the time and position of this based on the provided snap information. - /// - /// The snap result information. - public virtual void UpdateTimeAndPosition(SnapResult result) - { - if (PlacementActive == PlacementState.Waiting) - { - HitObject.StartTime = result.Time ?? EditorClock.CurrentTime; - - if (HitObject is IHasComboInformation comboInformation) - comboInformation.UpdateComboInformation(getPreviousHitObject() as IHasComboInformation); - } - - if (AutomaticBankAssignment) - { - // Take the hitnormal sample of the last hit object - var lastHitNormal = getPreviousHitObject()?.Samples?.FirstOrDefault(o => o.Name == HitSampleInfo.HIT_NORMAL); - if (lastHitNormal != null) - HitObject.Samples[0] = lastHitNormal; - } - } - - /// - /// Invokes , - /// refreshing and parameters for the . - /// - protected void ApplyDefaultsToHitObject() => HitObject.ApplyDefaults(beatmap.ControlPointInfo, beatmap.Difficulty); - - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; - protected override bool Handle(UIEvent e) { base.Handle(e); @@ -187,15 +122,16 @@ namespace osu.Game.Rulesets.Edit case MouseButtonEvent mouse: // placement blueprints should generally block mouse from reaching underlying components (ie. performing clicks on interface buttons). - // for now, the one exception we want to allow is when using a non-main mouse button when shift is pressed, which is used to trigger object deletion - // while in placement mode. - return mouse.Button == MouseButton.Left || !mouse.ShiftPressed; + return mouse.Button == MouseButton.Left || PlacementActive == PlacementState.Active; default: return false; } } + protected override void PopIn() => this.FadeIn(); + protected override void PopOut() => this.FadeOut(); + public enum PlacementState { Waiting, diff --git a/osu.Game/Rulesets/Edit/ScrollingHitObjectComposer.cs b/osu.Game/Rulesets/Edit/ScrollingHitObjectComposer.cs index eb73cef01a..223b770b48 100644 --- a/osu.Game/Rulesets/Edit/ScrollingHitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/ScrollingHitObjectComposer.cs @@ -4,6 +4,7 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; @@ -12,6 +13,7 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Components.TernaryButtons; using osu.Game.Screens.Edit.Compose.Components; using osuTK; @@ -21,6 +23,9 @@ namespace osu.Game.Rulesets.Edit public abstract partial class ScrollingHitObjectComposer : HitObjectComposer where TObject : HitObject { + [Resolved] + private Editor? editor { get; set; } + private readonly Bindable showSpeedChanges = new Bindable(); private Bindable configShowSpeedChanges = null!; @@ -72,6 +77,8 @@ namespace osu.Game.Rulesets.Edit if (beatSnapGrid != null) AddInternal(beatSnapGrid); + + EditorBeatmap.ControlPointInfo.ControlPointsChanged += expireComposeScreenOnControlPointChange; } protected override void UpdateAfterChildren() @@ -104,5 +111,15 @@ namespace osu.Game.Rulesets.Edit beatSnapGrid.SelectionTimeRange = null; } } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (EditorBeatmap.IsNotNull()) + EditorBeatmap.ControlPointInfo.ControlPointsChanged -= expireComposeScreenOnControlPointChange; + } + + private void expireComposeScreenOnControlPointChange() => editor?.ReloadComposeScreen(); } } diff --git a/osu.Game/Rulesets/Edit/Tools/HitObjectCompositionTool.cs b/osu.Game/Rulesets/Edit/Tools/CompositionTool.cs similarity index 54% rename from osu.Game/Rulesets/Edit/Tools/HitObjectCompositionTool.cs rename to osu.Game/Rulesets/Edit/Tools/CompositionTool.cs index 707645edeb..f509302daa 100644 --- a/osu.Game/Rulesets/Edit/Tools/HitObjectCompositionTool.cs +++ b/osu.Game/Rulesets/Edit/Tools/CompositionTool.cs @@ -1,24 +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 osu.Framework.Graphics; +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Edit.Tools { - public abstract class HitObjectCompositionTool + public abstract class CompositionTool { public readonly string Name; - protected HitObjectCompositionTool(string name) + public LocalisableString TooltipText { get; init; } + + protected CompositionTool(string name) { Name = name; } - public abstract PlacementBlueprint CreatePlacementBlueprint(); + public abstract PlacementBlueprint? CreatePlacementBlueprint(); - public virtual Drawable CreateIcon() => null; + public virtual Drawable? CreateIcon() => null; public override string ToString() => Name; } diff --git a/osu.Game/Rulesets/Edit/Tools/SelectTool.cs b/osu.Game/Rulesets/Edit/Tools/SelectTool.cs index a272e9f480..7f8889bfca 100644 --- a/osu.Game/Rulesets/Edit/Tools/SelectTool.cs +++ b/osu.Game/Rulesets/Edit/Tools/SelectTool.cs @@ -9,7 +9,7 @@ using osu.Game.Graphics; namespace osu.Game.Rulesets.Edit.Tools { - public class SelectTool : HitObjectCompositionTool + public class SelectTool : CompositionTool { public SelectTool() : base("Select") @@ -18,6 +18,6 @@ namespace osu.Game.Rulesets.Edit.Tools public override Drawable CreateIcon() => new SpriteIcon { Icon = OsuIcon.EditorSelect }; - public override PlacementBlueprint CreatePlacementBlueprint() => null; + public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => null; } } diff --git a/osu.Game/Rulesets/Judgements/DrawableJudgement.cs b/osu.Game/Rulesets/Judgements/DrawableJudgement.cs index b4686c52f3..3e70f52ee7 100644 --- a/osu.Game/Rulesets/Judgements/DrawableJudgement.cs +++ b/osu.Game/Rulesets/Judgements/DrawableJudgement.cs @@ -1,15 +1,14 @@ // 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 JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Pooling; +using osu.Framework.Logging; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; using osu.Game.Skinning; @@ -24,30 +23,19 @@ namespace osu.Game.Rulesets.Judgements { private const float judgement_size = 128; - public JudgementResult Result { get; private set; } + public JudgementResult? Result { get; private set; } - public DrawableHitObject JudgedObject { get; private set; } + public HitObject? JudgedHitObject { get; private set; } public override bool RemoveCompletedTransforms => false; - protected SkinnableDrawable JudgementBody { get; private set; } + protected SkinnableDrawable? JudgementBody { get; private set; } private readonly Container aboveHitObjectsContent; private readonly Lazy proxiedAboveHitObjectsContent; public Drawable ProxiedAboveHitObjectsContent => proxiedAboveHitObjectsContent.Value; - /// - /// Creates a drawable which visualises a . - /// - /// The judgement to visualise. - /// The object which was judged. - public DrawableJudgement(JudgementResult result, DrawableHitObject judgedObject) - : this() - { - Apply(result, judgedObject); - } - public DrawableJudgement() { Size = new Vector2(judgement_size); @@ -97,16 +85,26 @@ namespace osu.Game.Rulesets.Judgements /// /// The applicable judgement. /// The drawable object. - public void Apply([NotNull] JudgementResult result, [CanBeNull] DrawableHitObject judgedObject) + public virtual void Apply(JudgementResult result, DrawableHitObject? judgedObject) { Result = result; - JudgedObject = judgedObject; + JudgedHitObject = judgedObject?.HitObject; + } + + protected override void FreeAfterUse() + { + base.FreeAfterUse(); + + JudgedHitObject = null; } protected override void PrepareForUse() { base.PrepareForUse(); + if (!IsInPool) + Logger.Log($"{nameof(DrawableJudgement)} for judgement type {Result} was not retrieved from a pool. Consider adding to a JudgementPooler."); + Debug.Assert(Result != null); runAnimation(); @@ -114,13 +112,12 @@ namespace osu.Game.Rulesets.Judgements private void runAnimation() { - // is a no-op if the drawables are already in a correct state. - prepareDrawables(); - // undo any transforms applies in ApplyMissAnimations/ApplyHitAnimations to get a sane initial state. ApplyTransformsAt(double.MinValue, true); ClearTransforms(true); + Debug.Assert(Result != null && JudgementBody != null); + LifetimeStart = Result.TimeAbsolute; using (BeginAbsoluteSequence(Result.TimeAbsolute)) @@ -166,7 +163,7 @@ namespace osu.Game.Rulesets.Judgements if (JudgementBody != null) RemoveInternal(JudgementBody, true); - AddInternal(JudgementBody = new SkinnableDrawable(new GameplaySkinComponentLookup(type), _ => + AddInternal(JudgementBody = new SkinnableDrawable(new SkinComponentLookup(type), _ => CreateDefaultJudgement(type), confineMode: ConfineMode.NoScaling)); JudgementBody.OnSkinChanged += () => diff --git a/osu.Game/Rulesets/Mods/DifficultyBindable.cs b/osu.Game/Rulesets/Mods/DifficultyBindable.cs index a207048882..099806d320 100644 --- a/osu.Game/Rulesets/Mods/DifficultyBindable.cs +++ b/osu.Game/Rulesets/Mods/DifficultyBindable.cs @@ -38,6 +38,7 @@ namespace osu.Game.Rulesets.Mods public float MinValue { + get => minValue; set { if (value == minValue) @@ -48,10 +49,11 @@ namespace osu.Game.Rulesets.Mods } } - private float maxValue; + private float maxValue = 10; // matches default max value of `CurrentNumber` public float MaxValue { + get => maxValue; set { if (value == maxValue) @@ -69,6 +71,7 @@ namespace osu.Game.Rulesets.Mods /// public float? ExtendedMinValue { + get => extendedMinValue; set { if (value == extendedMinValue) @@ -86,6 +89,7 @@ namespace osu.Game.Rulesets.Mods /// public float? ExtendedMaxValue { + get => extendedMaxValue; set { if (value == extendedMaxValue) @@ -114,9 +118,14 @@ namespace osu.Game.Rulesets.Mods { // Ensure that in the case serialisation runs in the wrong order (and limit extensions aren't applied yet) the deserialised value is still propagated. if (value != null) - CurrentNumber.MaxValue = MathF.Max(CurrentNumber.MaxValue, value.Value); + { + CurrentNumber.MinValue = Math.Clamp(MathF.Min(CurrentNumber.MinValue, value.Value), ExtendedMinValue ?? MinValue, MinValue); + CurrentNumber.MaxValue = Math.Clamp(MathF.Max(CurrentNumber.MaxValue, value.Value), MaxValue, ExtendedMaxValue ?? MaxValue); - base.Value = value; + base.Value = Math.Clamp(value.Value, CurrentNumber.MinValue, CurrentNumber.MaxValue); + } + else + base.Value = value; } } @@ -138,6 +147,8 @@ namespace osu.Game.Rulesets.Mods // the following max value copies are only safe as long as these values are effectively constants. otherDifficultyBindable.MaxValue = maxValue; otherDifficultyBindable.ExtendedMaxValue = extendedMaxValue; + otherDifficultyBindable.MinValue = minValue; + otherDifficultyBindable.ExtendedMinValue = extendedMinValue; } public override void BindTo(Bindable them) diff --git a/osu.Game/Rulesets/Mods/Mod.cs b/osu.Game/Rulesets/Mods/Mod.cs index 50c867f41b..1b21216235 100644 --- a/osu.Game/Rulesets/Mods/Mod.cs +++ b/osu.Game/Rulesets/Mods/Mod.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; using System.Reflection; @@ -21,6 +22,7 @@ namespace osu.Game.Rulesets.Mods /// /// The base class for gameplay modifiers. /// + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] public abstract class Mod : IMod, IEquatable, IDeepCloneable { [JsonIgnore] @@ -264,8 +266,7 @@ namespace osu.Game.Rulesets.Mods // TODO: special case for handling number types - PropertyInfo property = targetSetting.GetType().GetProperty(nameof(Bindable.Value))!; - property.SetValue(targetSetting, property.GetValue(sourceSetting)); + BindableValueAccessor.SetValue(targetSetting, BindableValueAccessor.GetValue(sourceSetting)); } } diff --git a/osu.Game/Rulesets/Mods/ModBarrelRoll.cs b/osu.Game/Rulesets/Mods/ModBarrelRoll.cs index 0c301d293f..67f9da37be 100644 --- a/osu.Game/Rulesets/Mods/ModBarrelRoll.cs +++ b/osu.Game/Rulesets/Mods/ModBarrelRoll.cs @@ -40,9 +40,11 @@ namespace osu.Game.Rulesets.Mods public override string SettingDescription => $"{SpinSpeed.Value:N2} rpm {Direction.Value.GetDescription().ToLowerInvariant()}"; - public void Update(Playfield playfield) + private PlayfieldAdjustmentContainer playfieldAdjustmentContainer = null!; + + public virtual void Update(Playfield playfield) { - playfield.Rotation = CurrentRotation = (Direction.Value == RotationDirection.Counterclockwise ? -1 : 1) * 360 * (float)(playfield.Time.Current / 60000 * SpinSpeed.Value); + playfieldAdjustmentContainer.Rotation = CurrentRotation = (Direction.Value == RotationDirection.Counterclockwise ? -1 : 1) * 360 * (float)(playfield.Time.Current / 60000 * SpinSpeed.Value); } public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) @@ -52,7 +54,9 @@ namespace osu.Game.Rulesets.Mods var playfieldSize = drawableRuleset.Playfield.DrawSize; float minSide = MathF.Min(playfieldSize.X, playfieldSize.Y); float maxSide = MathF.Max(playfieldSize.X, playfieldSize.Y); - drawableRuleset.Playfield.Scale = new Vector2(minSide / maxSide); + + playfieldAdjustmentContainer = drawableRuleset.PlayfieldAdjustmentContainer; + playfieldAdjustmentContainer.Scale = new Vector2(minSide / maxSide); } } } diff --git a/osu.Game/Rulesets/Mods/ModCinema.cs b/osu.Game/Rulesets/Mods/ModCinema.cs index 7c88a8a588..0c00eb6ae0 100644 --- a/osu.Game/Rulesets/Mods/ModCinema.cs +++ b/osu.Game/Rulesets/Mods/ModCinema.cs @@ -36,6 +36,8 @@ namespace osu.Game.Rulesets.Mods { overlay.ShowHud.Value = false; overlay.ShowHud.Disabled = true; + + overlay.PlayfieldSkinLayer.Hide(); } public void ApplyToPlayer(Player player) diff --git a/osu.Game/Rulesets/Mods/ModFlashlight.cs b/osu.Game/Rulesets/Mods/ModFlashlight.cs index c924915bd0..64c193d25f 100644 --- a/osu.Game/Rulesets/Mods/ModFlashlight.cs +++ b/osu.Game/Rulesets/Mods/ModFlashlight.cs @@ -83,8 +83,6 @@ namespace osu.Game.Rulesets.Mods flashlight.RelativeSizeAxes = Axes.Both; flashlight.Colour = Color4.Black; - // Flashlight mods should always draw above any other mod adding overlays. - flashlight.Depth = float.MinValue; flashlight.Combo.BindTo(Combo); flashlight.GetPlayfieldScale = () => drawableRuleset.Playfield.Scale; @@ -95,6 +93,9 @@ namespace osu.Game.Rulesets.Mods // workaround for 1px gaps on the edges of the playfield which would sometimes show with "gameplay" screen scaling active. Padding = new MarginPadding(-1), Child = flashlight, + // Flashlight mods should always draw above any other mod adding overlays. + // NegativeInfinity is not used to allow one more thing drawn on top (used in replay analysis overlay in osu!). + Depth = float.MinValue, }); } diff --git a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs index de05219212..1f735576bc 100644 --- a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs +++ b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs @@ -15,7 +15,6 @@ using osu.Framework.Extensions.ListExtensions; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Primitives; using osu.Framework.Lists; using osu.Framework.Threading; using osu.Framework.Utils; @@ -315,11 +314,11 @@ namespace osu.Game.Rulesets.Objects.Drawables private void updateStateFromResult() { if (Result.IsHit) - updateState(ArmedState.Hit, true); + UpdateState(ArmedState.Hit, true); else if (Result.HasResult) - updateState(ArmedState.Miss, true); + UpdateState(ArmedState.Miss, true); else - updateState(ArmedState.Idle, true); + UpdateState(ArmedState.Idle, true); } protected sealed override void OnFree(HitObjectLifetimeEntry entry) @@ -403,7 +402,7 @@ namespace osu.Game.Rulesets.Objects.Drawables private void onRevertResult() { - updateState(ArmedState.Idle); + UpdateState(ArmedState.Idle); OnRevertResult?.Invoke(this, Result); } @@ -422,7 +421,7 @@ namespace osu.Game.Rulesets.Objects.Drawables if (Result is not null) { Result.TimeOffset = 0; - updateState(State.Value, true); + UpdateState(State.Value, true); } DefaultsApplied?.Invoke(this); @@ -462,7 +461,7 @@ namespace osu.Game.Rulesets.Objects.Drawables throw new InvalidOperationException( $"Should never clear a {nameof(DrawableHitObject)} as the base implementation adds components. If attempting to use {nameof(InternalChild)} or {nameof(InternalChildren)}, using {nameof(AddInternal)} or {nameof(AddRangeInternal)} instead."); - private void updateState(ArmedState newState, bool force = false) + protected void UpdateState(ArmedState newState, bool force = false) { if (State.Value == newState && !force) return; @@ -507,7 +506,7 @@ namespace osu.Game.Rulesets.Objects.Drawables /// /// Reapplies the current . /// - public void RefreshStateTransforms() => updateState(State.Value, true); + public void RefreshStateTransforms() => UpdateState(State.Value, true); /// /// Apply (generally fade-in) transforms leading into the start time. @@ -566,7 +565,7 @@ namespace osu.Game.Rulesets.Objects.Drawables ApplySkin(CurrentSkin, true); if (IsLoaded) - updateState(State.Value, true); + UpdateState(State.Value, true); } protected void UpdateComboColour() @@ -632,7 +631,7 @@ namespace osu.Game.Rulesets.Objects.Drawables #endregion - public override bool UpdateSubTreeMasking(Drawable source, RectangleF maskingBounds) => false; + public override bool UpdateSubTreeMasking() => false; protected override void UpdateAfterChildren() { @@ -726,7 +725,7 @@ namespace osu.Game.Rulesets.Objects.Drawables Result.GameplayRate = (Clock as IGameplayClock)?.GetTrueGameplayRate() ?? Clock.Rate; if (Result.HasResult) - updateState(Result.IsHit ? ArmedState.Hit : ArmedState.Miss); + UpdateState(Result.IsHit ? ArmedState.Hit : ArmedState.Miss); OnNewResult?.Invoke(this, Result); } diff --git a/osu.Game/Rulesets/Objects/HitObject.cs b/osu.Game/Rulesets/Objects/HitObject.cs index 04bdc35941..9f980769e2 100644 --- a/osu.Game/Rulesets/Objects/HitObject.cs +++ b/osu.Game/Rulesets/Objects/HitObject.cs @@ -12,6 +12,7 @@ using JetBrains.Annotations; using Newtonsoft.Json; using osu.Framework.Bindables; using osu.Framework.Extensions.ListExtensions; +using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Lists; using osu.Game.Audio; using osu.Game.Beatmaps; @@ -223,11 +224,21 @@ namespace osu.Game.Rulesets.Objects /// A populated . public HitSampleInfo CreateHitSampleInfo(string sampleName = HitSampleInfo.HIT_NORMAL) { - if (Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL) is HitSampleInfo existingSample) - return existingSample.With(newName: sampleName); + // As per stable, all non-normal "addition" samples should use the same bank. + if (sampleName != HitSampleInfo.HIT_NORMAL) + { + if (Samples.FirstOrDefault(s => s.Name != HitSampleInfo.HIT_NORMAL) is HitSampleInfo existingAddition) + return existingAddition.With(newName: sampleName); + } + + // Fall back to using the normal sample bank otherwise. + if (Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL) is HitSampleInfo existingNormal) + return existingNormal.With(newName: sampleName, newEditorAutoBank: true); return new HitSampleInfo(sampleName); } + + public override string ToString() => $"{GetType().ReadableName()} @ {StartTime}"; } public static class HitObjectExtensions diff --git a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHit.cs b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHit.cs deleted file mode 100644 index 96c779e79b..0000000000 --- a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHit.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Rulesets.Objects.Types; -using osuTK; - -namespace osu.Game.Rulesets.Objects.Legacy.Catch -{ - /// - /// Legacy osu!catch Hit-type, used for parsing Beatmaps. - /// - internal sealed class ConvertHit : ConvertHitObject, IHasPosition - { - public float X => Position.X; - - public float Y => Position.Y; - - public Vector2 Position { get; set; } - } -} diff --git a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHitObjectParser.cs deleted file mode 100644 index a5c1a73fa7..0000000000 --- a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHitObjectParser.cs +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using osuTK; -using osu.Game.Audio; -using System.Collections.Generic; - -namespace osu.Game.Rulesets.Objects.Legacy.Catch -{ - /// - /// A HitObjectParser to parse legacy osu!catch Beatmaps. - /// - public class ConvertHitObjectParser : Legacy.ConvertHitObjectParser - { - private ConvertHitObject lastObject; - - public ConvertHitObjectParser(double offset, int formatVersion) - : base(offset, formatVersion) - { - } - - protected override HitObject CreateHit(Vector2 position, bool newCombo, int comboOffset) - { - return lastObject = new ConvertHit - { - Position = position, - NewCombo = FirstObject || lastObject is ConvertSpinner || newCombo, - ComboOffset = newCombo ? comboOffset : 0 - }; - } - - protected override HitObject CreateSlider(Vector2 position, bool newCombo, int comboOffset, PathControlPoint[] controlPoints, double? length, int repeatCount, - IList> nodeSamples) - { - return lastObject = new ConvertSlider - { - Position = position, - NewCombo = FirstObject || lastObject is ConvertSpinner || newCombo, - ComboOffset = newCombo ? comboOffset : 0, - Path = new SliderPath(controlPoints, length), - NodeSamples = nodeSamples, - RepeatCount = repeatCount - }; - } - - protected override HitObject CreateSpinner(Vector2 position, bool newCombo, int comboOffset, double duration) - { - return lastObject = new ConvertSpinner - { - Duration = duration, - NewCombo = newCombo - // Spinners cannot have combo offset. - }; - } - - protected override HitObject CreateHold(Vector2 position, bool newCombo, int comboOffset, double duration) - { - return lastObject = null; - } - } -} diff --git a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertSlider.cs b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertSlider.cs deleted file mode 100644 index bcf1c7fae2..0000000000 --- a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertSlider.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Rulesets.Objects.Types; -using osuTK; - -namespace osu.Game.Rulesets.Objects.Legacy.Catch -{ - /// - /// Legacy osu!catch Slider-type, used for parsing Beatmaps. - /// - internal sealed class ConvertSlider : Legacy.ConvertSlider, IHasPosition - { - public float X => Position.X; - - public float Y => Position.Y; - - public Vector2 Position { get; set; } - } -} diff --git a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertSpinner.cs b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertSpinner.cs deleted file mode 100644 index 5ef3d51cb3..0000000000 --- a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertSpinner.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Rulesets.Objects.Types; - -namespace osu.Game.Rulesets.Objects.Legacy.Catch -{ - /// - /// Legacy osu!catch Spinner-type, used for parsing Beatmaps. - /// - internal sealed class ConvertSpinner : ConvertHitObject, IHasDuration, IHasXPosition - { - public double EndTime => StartTime + Duration; - - public double Duration { get; set; } - - public float X => 256; // Required for CatchBeatmapConverter - } -} diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitCircle.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitCircle.cs new file mode 100644 index 0000000000..d1852d7032 --- /dev/null +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitCircle.cs @@ -0,0 +1,13 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Rulesets.Objects.Legacy +{ + /// + /// Legacy "HitCircle" hit object type. + /// + /// + /// Only used for parsing beatmaps and not gameplay. + /// + internal sealed class ConvertHitCircle : ConvertHitObject; +} diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObject.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObject.cs index bb36aab0b3..28683583ee 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObject.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObject.cs @@ -1,21 +1,34 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Game.Beatmaps.Legacy; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Scoring; +using osuTK; namespace osu.Game.Rulesets.Objects.Legacy { /// - /// A hit object only used for conversion, not actual gameplay. + /// Represents a legacy hit object. /// - internal abstract class ConvertHitObject : HitObject, IHasCombo + /// + /// Only used for parsing beatmaps and not gameplay. + /// + internal abstract class ConvertHitObject : HitObject, IHasCombo, IHasPosition, IHasLegacyHitObjectType { public bool NewCombo { get; set; } public int ComboOffset { get; set; } + public float X => Position.X; + + public float Y => Position.Y; + + public Vector2 Position { get; set; } + + public LegacyHitObjectType LegacyType { get; set; } + public override Judgement CreateJudgement() => new IgnoreJudgement(); protected override HitWindows CreateHitWindows() => HitWindows.Empty; diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs index 66b3033f90..f8bc0ce112 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osuTK; using osu.Game.Rulesets.Objects.Types; using System; @@ -11,8 +9,6 @@ using System.IO; using osu.Game.Beatmaps.Formats; using osu.Game.Audio; using System.Linq; -using JetBrains.Annotations; -using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Utils; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Legacy; @@ -25,55 +21,66 @@ namespace osu.Game.Rulesets.Objects.Legacy /// /// A HitObjectParser to parse legacy Beatmaps. /// - public abstract class ConvertHitObjectParser : HitObjectParser + public class ConvertHitObjectParser : HitObjectParser { /// /// The offset to apply to all time values. /// - protected readonly double Offset; + private readonly double offset; /// - /// The beatmap version. + /// The .osu format (beatmap) version. /// - protected readonly int FormatVersion; + private readonly int formatVersion; - protected bool FirstObject { get; private set; } = true; + /// + /// Whether the current hitobject is the first hitobject in the beatmap. + /// + private bool firstObject = true; - protected ConvertHitObjectParser(double offset, int formatVersion) + /// + /// The last parsed hitobject. + /// + private ConvertHitObject? lastObject; + + internal ConvertHitObjectParser(double offset, int formatVersion) { - Offset = offset; - FormatVersion = formatVersion; + this.offset = offset; + this.formatVersion = formatVersion; } public override HitObject Parse(string text) { string[] split = text.Split(','); - Vector2 pos = new Vector2((int)Parsing.ParseFloat(split[0], Parsing.MAX_COORDINATE_VALUE), (int)Parsing.ParseFloat(split[1], Parsing.MAX_COORDINATE_VALUE)); + Vector2 pos = + formatVersion >= LegacyBeatmapEncoder.FIRST_LAZER_VERSION + ? new Vector2(Parsing.ParseFloat(split[0], Parsing.MAX_COORDINATE_VALUE), Parsing.ParseFloat(split[1], Parsing.MAX_COORDINATE_VALUE)) + : new Vector2((int)Parsing.ParseFloat(split[0], Parsing.MAX_COORDINATE_VALUE), (int)Parsing.ParseFloat(split[1], Parsing.MAX_COORDINATE_VALUE)); - double startTime = Parsing.ParseDouble(split[2]) + Offset; + double startTime = Parsing.ParseDouble(split[2]) + offset; LegacyHitObjectType type = (LegacyHitObjectType)Parsing.ParseInt(split[3]); int comboOffset = (int)(type & LegacyHitObjectType.ComboOffset) >> 4; type &= ~LegacyHitObjectType.ComboOffset; - bool combo = type.HasFlagFast(LegacyHitObjectType.NewCombo); + bool combo = type.HasFlag(LegacyHitObjectType.NewCombo); type &= ~LegacyHitObjectType.NewCombo; var soundType = (LegacyHitSoundType)Parsing.ParseInt(split[4]); var bankInfo = new SampleBankInfo(); - HitObject result = null; + ConvertHitObject? result = null; - if (type.HasFlagFast(LegacyHitObjectType.Circle)) + if (type.HasFlag(LegacyHitObjectType.Circle)) { - result = CreateHit(pos, combo, comboOffset); + result = createHitCircle(pos, combo, comboOffset); if (split.Length > 5) readCustomSampleBanks(split[5], bankInfo); } - else if (type.HasFlagFast(LegacyHitObjectType.Slider)) + else if (type.HasFlag(LegacyHitObjectType.Slider)) { double? length = null; @@ -143,18 +150,18 @@ namespace osu.Game.Rulesets.Objects.Legacy for (int i = 0; i < nodes; i++) nodeSamples.Add(convertSoundType(nodeSoundTypes[i], nodeBankInfos[i])); - result = CreateSlider(pos, combo, comboOffset, convertPathString(split[5], pos), length, repeatCount, nodeSamples); + result = createSlider(pos, combo, comboOffset, convertPathString(split[5], pos), length, repeatCount, nodeSamples); } - else if (type.HasFlagFast(LegacyHitObjectType.Spinner)) + else if (type.HasFlag(LegacyHitObjectType.Spinner)) { - double duration = Math.Max(0, Parsing.ParseDouble(split[5]) + Offset - startTime); + double duration = Math.Max(0, Parsing.ParseDouble(split[5]) + offset - startTime); - result = CreateSpinner(new Vector2(512, 384) / 2, combo, comboOffset, duration); + result = createSpinner(new Vector2(512, 384) / 2, combo, duration); if (split.Length > 6) readCustomSampleBanks(split[6], bankInfo); } - else if (type.HasFlagFast(LegacyHitObjectType.Hold)) + else if (type.HasFlag(LegacyHitObjectType.Hold)) { // Note: Hold is generated by BMS converts @@ -167,18 +174,19 @@ namespace osu.Game.Rulesets.Objects.Legacy readCustomSampleBanks(string.Join(':', ss.Skip(1)), bankInfo); } - result = CreateHold(pos, combo, comboOffset, endTime + Offset - startTime); + result = createHold(pos, endTime + offset - startTime); } if (result == null) throw new InvalidDataException($"Unknown hit object type: {split[3]}"); result.StartTime = startTime; + result.LegacyType = type; if (result.Samples.Count == 0) result.Samples = convertSoundType(soundType, bankInfo); - FirstObject = false; + firstObject = false; return result; } @@ -198,12 +206,19 @@ namespace osu.Game.Rulesets.Objects.Legacy if (!Enum.IsDefined(addBank)) addBank = LegacySampleBank.Normal; - string stringBank = bank.ToString().ToLowerInvariant(); + string? stringBank = bank.ToString().ToLowerInvariant(); + string? stringAddBank = addBank.ToString().ToLowerInvariant(); + if (stringBank == @"none") stringBank = null; - string stringAddBank = addBank.ToString().ToLowerInvariant(); + if (stringAddBank == @"none") + { + bankInfo.EditorAutoBank = true; stringAddBank = null; + } + else + bankInfo.EditorAutoBank = false; bankInfo.BankForNormal = stringBank; bankInfo.BankForAdditions = string.IsNullOrEmpty(stringAddBank) ? stringBank : stringAddBank; @@ -349,13 +364,19 @@ namespace osu.Game.Rulesets.Objects.Legacy { int endPointLength = endPoint == null ? 0 : 1; - if (vertices.Length + endPointLength != 3) - type = PathType.BEZIER; - else if (isLinear(points[0], points[1], endPoint ?? points[2])) + if (formatVersion < LegacyBeatmapEncoder.FIRST_LAZER_VERSION) { - // osu-stable special-cased colinear perfect curves to a linear path - type = PathType.LINEAR; + if (vertices.Length + endPointLength != 3) + type = PathType.BEZIER; + else if (isLinear(points[0], points[1], endPoint ?? points[2])) + { + // osu-stable special-cased colinear perfect curves to a linear path + type = PathType.LINEAR; + } } + else if (vertices.Length + endPointLength > 3) + // Lazer supports perfect curves with less than 3 points and colinear points + type = PathType.BEZIER; } // The first control point must have a definite type. @@ -379,7 +400,7 @@ namespace osu.Game.Rulesets.Objects.Legacy // Legacy CATMULL sliders don't support multiple segments, so adjacent CATMULL segments should be treated as a single one. // Importantly, this is not applied to the first control point, which may duplicate the slider path's position // resulting in a duplicate (0,0) control point in the resultant list. - if (type == PathType.CATMULL && endIndex > 1 && FormatVersion < LegacyBeatmapEncoder.FIRST_LAZER_VERSION) + if (type == PathType.CATMULL && endIndex > 1 && formatVersion < LegacyBeatmapEncoder.FIRST_LAZER_VERSION) continue; // The last control point of each segment is not allowed to start a new implicit segment. @@ -428,7 +449,15 @@ namespace osu.Game.Rulesets.Objects.Legacy /// Whether the hit object creates a new combo. /// When starting a new combo, the offset of the new combo relative to the current one. /// The hit object. - protected abstract HitObject CreateHit(Vector2 position, bool newCombo, int comboOffset); + private ConvertHitObject createHitCircle(Vector2 position, bool newCombo, int comboOffset) + { + return lastObject = new ConvertHitCircle + { + Position = position, + NewCombo = firstObject || lastObject is ConvertSpinner || newCombo, + ComboOffset = newCombo ? comboOffset : 0 + }; + } /// /// Creats a legacy Slider-type hit object. @@ -441,27 +470,51 @@ namespace osu.Game.Rulesets.Objects.Legacy /// The slider repeat count. /// The samples to be played when the slider nodes are hit. This includes the head and tail of the slider. /// The hit object. - protected abstract HitObject CreateSlider(Vector2 position, bool newCombo, int comboOffset, PathControlPoint[] controlPoints, double? length, int repeatCount, - IList> nodeSamples); + private ConvertHitObject createSlider(Vector2 position, bool newCombo, int comboOffset, PathControlPoint[] controlPoints, double? length, int repeatCount, + IList> nodeSamples) + { + return lastObject = new ConvertSlider + { + Position = position, + NewCombo = firstObject || lastObject is ConvertSpinner || newCombo, + ComboOffset = newCombo ? comboOffset : 0, + Path = new SliderPath(controlPoints, length), + NodeSamples = nodeSamples, + RepeatCount = repeatCount + }; + } /// /// Creates a legacy Spinner-type hit object. /// /// The position of the hit object. /// Whether the hit object creates a new combo. - /// When starting a new combo, the offset of the new combo relative to the current one. /// The spinner duration. /// The hit object. - protected abstract HitObject CreateSpinner(Vector2 position, bool newCombo, int comboOffset, double duration); + private ConvertHitObject createSpinner(Vector2 position, bool newCombo, double duration) + { + return lastObject = new ConvertSpinner + { + Position = position, + Duration = duration, + NewCombo = newCombo + // Spinners cannot have combo offset. + }; + } /// /// Creates a legacy Hold-type hit object. /// /// The position of the hit object. - /// Whether the hit object creates a new combo. - /// When starting a new combo, the offset of the new combo relative to the current one. /// The hold duration. - protected abstract HitObject CreateHold(Vector2 position, bool newCombo, int comboOffset, double duration); + private ConvertHitObject createHold(Vector2 position, double duration) + { + return lastObject = new ConvertHold + { + Position = position, + Duration = duration + }; + } private List convertSoundType(LegacyHitSoundType type, SampleBankInfo bankInfo) { @@ -469,10 +522,10 @@ namespace osu.Game.Rulesets.Objects.Legacy if (string.IsNullOrEmpty(bankInfo.Filename)) { - soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_NORMAL, bankInfo.BankForNormal, bankInfo.Volume, bankInfo.CustomSampleBank, + soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_NORMAL, bankInfo.BankForNormal, bankInfo.Volume, true, bankInfo.CustomSampleBank, // if the sound type doesn't have the Normal flag set, attach it anyway as a layered sample. // None also counts as a normal non-layered sample: https://osu.ppy.sh/help/wiki/osu!_File_Formats/Osu_(file_format)#hitsounds - type != LegacyHitSoundType.None && !type.HasFlagFast(LegacyHitSoundType.Normal))); + type != LegacyHitSoundType.None && !type.HasFlag(LegacyHitSoundType.Normal))); } else { @@ -480,14 +533,14 @@ namespace osu.Game.Rulesets.Objects.Legacy soundTypes.Add(new FileHitSampleInfo(bankInfo.Filename, bankInfo.Volume)); } - if (type.HasFlagFast(LegacyHitSoundType.Finish)) - soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_FINISH, bankInfo.BankForAdditions, bankInfo.Volume, bankInfo.CustomSampleBank)); + if (type.HasFlag(LegacyHitSoundType.Finish)) + soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_FINISH, bankInfo.BankForAdditions, bankInfo.Volume, bankInfo.EditorAutoBank, bankInfo.CustomSampleBank)); - if (type.HasFlagFast(LegacyHitSoundType.Whistle)) - soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_WHISTLE, bankInfo.BankForAdditions, bankInfo.Volume, bankInfo.CustomSampleBank)); + if (type.HasFlag(LegacyHitSoundType.Whistle)) + soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_WHISTLE, bankInfo.BankForAdditions, bankInfo.Volume, bankInfo.EditorAutoBank, bankInfo.CustomSampleBank)); - if (type.HasFlagFast(LegacyHitSoundType.Clap)) - soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_CLAP, bankInfo.BankForAdditions, bankInfo.Volume, bankInfo.CustomSampleBank)); + if (type.HasFlag(LegacyHitSoundType.Clap)) + soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_CLAP, bankInfo.BankForAdditions, bankInfo.Volume, bankInfo.EditorAutoBank, bankInfo.CustomSampleBank)); return soundTypes; } @@ -497,21 +550,19 @@ namespace osu.Game.Rulesets.Objects.Legacy /// /// An optional overriding filename which causes all bank/sample specifications to be ignored. /// - public string Filename; + public string? Filename; /// /// The bank identifier to use for the base ("hitnormal") sample. /// Transferred to when appropriate. /// - [CanBeNull] - public string BankForNormal; + public string? BankForNormal; /// /// The bank identifier to use for additions ("hitwhistle", "hitfinish", "hitclap"). /// Transferred to when appropriate. /// - [CanBeNull] - public string BankForAdditions; + public string? BankForAdditions; /// /// Hit sample volume (0-100). @@ -526,11 +577,14 @@ namespace osu.Game.Rulesets.Objects.Legacy /// public int CustomSampleBank; + /// + /// Whether the bank for additions should be inherited from the normal sample in edit. + /// + public bool EditorAutoBank = true; + public SampleBankInfo Clone() => (SampleBankInfo)MemberwiseClone(); } -#nullable enable - public class LegacyHitSampleInfo : HitSampleInfo, IEquatable { public readonly int CustomSampleBank; @@ -550,21 +604,22 @@ namespace osu.Game.Rulesets.Objects.Legacy /// public bool BankSpecified; - public LegacyHitSampleInfo(string name, string? bank = null, int volume = 0, int customSampleBank = 0, bool isLayered = false) - : base(name, bank ?? SampleControlPoint.DEFAULT_BANK, customSampleBank >= 2 ? customSampleBank.ToString() : null, volume) + public LegacyHitSampleInfo(string name, string? bank = null, int volume = 0, bool editorAutoBank = false, int customSampleBank = 0, bool isLayered = false) + : base(name, bank ?? SampleControlPoint.DEFAULT_BANK, customSampleBank >= 2 ? customSampleBank.ToString() : null, volume, editorAutoBank) { CustomSampleBank = customSampleBank; BankSpecified = !string.IsNullOrEmpty(bank); IsLayered = isLayered; } - public sealed override HitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newSuffix = default, Optional newVolume = default) - => With(newName, newBank, newVolume); + public sealed override HitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newSuffix = default, Optional newVolume = default, + Optional newEditorAutoBank = default) + => With(newName, newBank, newVolume, newEditorAutoBank); public virtual LegacyHitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newVolume = default, - Optional newCustomSampleBank = default, - Optional newIsLayered = default) - => new LegacyHitSampleInfo(newName.GetOr(Name), newBank.GetOr(Bank), newVolume.GetOr(Volume), newCustomSampleBank.GetOr(CustomSampleBank), newIsLayered.GetOr(IsLayered)); + Optional newEditorAutoBank = default, Optional newCustomSampleBank = default, Optional newIsLayered = default) + => new LegacyHitSampleInfo(newName.GetOr(Name), newBank.GetOr(Bank), newVolume.GetOr(Volume), newEditorAutoBank.GetOr(EditorAutoBank), newCustomSampleBank.GetOr(CustomSampleBank), + newIsLayered.GetOr(IsLayered)); public bool Equals(LegacyHitSampleInfo? other) // The additions to equality checks here are *required* to ensure that pooling works correctly. @@ -597,8 +652,7 @@ namespace osu.Game.Rulesets.Objects.Legacy }; public sealed override LegacyHitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newVolume = default, - Optional newCustomSampleBank = default, - Optional newIsLayered = default) + Optional newEditorAutoBank = default, Optional newCustomSampleBank = default, Optional newIsLayered = default) => new FileHitSampleInfo(Filename, newVolume.GetOr(Volume)); public bool Equals(FileHitSampleInfo? other) diff --git a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSpinner.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHold.cs similarity index 54% rename from osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSpinner.cs rename to osu.Game/Rulesets/Objects/Legacy/ConvertHold.cs index c05aaceb9c..d74224892b 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSpinner.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHold.cs @@ -3,17 +3,18 @@ using osu.Game.Rulesets.Objects.Types; -namespace osu.Game.Rulesets.Objects.Legacy.Mania +namespace osu.Game.Rulesets.Objects.Legacy { /// - /// Legacy osu!mania Spinner-type, used for parsing Beatmaps. + /// Legacy "Hold" hit object type. Generally only valid in the mania ruleset. /// - internal sealed class ConvertSpinner : ConvertHitObject, IHasDuration, IHasXPosition + /// + /// Only used for parsing beatmaps and not gameplay. + /// + internal sealed class ConvertHold : ConvertHitObject, IHasDuration { public double Duration { get; set; } public double EndTime => StartTime + Duration; - - public float X { get; set; } } } diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs index 683eefa8f4..fee68f2f11 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Game.Rulesets.Objects.Types; using System.Collections.Generic; using Newtonsoft.Json; @@ -13,7 +11,13 @@ using osu.Game.Beatmaps.ControlPoints; namespace osu.Game.Rulesets.Objects.Legacy { - internal abstract class ConvertSlider : ConvertHitObject, IHasPathWithRepeats, IHasSliderVelocity + /// + /// Legacy "Slider" hit object type. + /// + /// + /// Only used for parsing beatmaps and not gameplay. + /// + internal class ConvertSlider : ConvertHitObject, IHasPathWithRepeats, IHasSliderVelocity, IHasGenerateTicks { /// /// Scoring distance with a speed-adjusted beat length of 1 second. @@ -50,6 +54,8 @@ namespace osu.Game.Rulesets.Objects.Legacy set => SliderVelocityMultiplierBindable.Value = value; } + public bool GenerateTicks { get; set; } = true; + protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty) { base.ApplyDefaultsToSelf(controlPointInfo, difficulty); diff --git a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSpinner.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertSpinner.cs similarity index 70% rename from osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSpinner.cs rename to osu.Game/Rulesets/Objects/Legacy/ConvertSpinner.cs index 1d5ecb1ef3..59551cd37a 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSpinner.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertSpinner.cs @@ -3,11 +3,14 @@ using osu.Game.Rulesets.Objects.Types; -namespace osu.Game.Rulesets.Objects.Legacy.Taiko +namespace osu.Game.Rulesets.Objects.Legacy { /// - /// Legacy osu!taiko Spinner-type, used for parsing Beatmaps. + /// Legacy "Spinner" hit object type. /// + /// + /// Only used for parsing beatmaps and not gameplay. + /// internal sealed class ConvertSpinner : ConvertHitObject, IHasDuration { public double Duration { get; set; } diff --git a/osu.Game/Rulesets/Objects/Legacy/IHasLegacyHitObjectType.cs b/osu.Game/Rulesets/Objects/Legacy/IHasLegacyHitObjectType.cs new file mode 100644 index 0000000000..71af57700d --- /dev/null +++ b/osu.Game/Rulesets/Objects/Legacy/IHasLegacyHitObjectType.cs @@ -0,0 +1,18 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Beatmaps.Legacy; + +namespace osu.Game.Rulesets.Objects.Legacy +{ + /// + /// A hit object from a legacy beatmap representation. + /// + public interface IHasLegacyHitObjectType + { + /// + /// The hit object type. + /// + LegacyHitObjectType LegacyType { get; } + } +} diff --git a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHit.cs b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHit.cs deleted file mode 100644 index 0b69817c13..0000000000 --- a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHit.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Rulesets.Objects.Types; - -namespace osu.Game.Rulesets.Objects.Legacy.Mania -{ - /// - /// Legacy osu!mania Hit-type, used for parsing Beatmaps. - /// - internal sealed class ConvertHit : ConvertHitObject, IHasXPosition - { - public float X { get; set; } - } -} diff --git a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHitObjectParser.cs deleted file mode 100644 index 386eb8d3ee..0000000000 --- a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHitObjectParser.cs +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osuTK; -using osu.Game.Audio; -using System.Collections.Generic; - -namespace osu.Game.Rulesets.Objects.Legacy.Mania -{ - /// - /// A HitObjectParser to parse legacy osu!mania Beatmaps. - /// - public class ConvertHitObjectParser : Legacy.ConvertHitObjectParser - { - public ConvertHitObjectParser(double offset, int formatVersion) - : base(offset, formatVersion) - { - } - - protected override HitObject CreateHit(Vector2 position, bool newCombo, int comboOffset) - { - return new ConvertHit - { - X = position.X - }; - } - - protected override HitObject CreateSlider(Vector2 position, bool newCombo, int comboOffset, PathControlPoint[] controlPoints, double? length, int repeatCount, - IList> nodeSamples) - { - return new ConvertSlider - { - X = position.X, - Path = new SliderPath(controlPoints, length), - NodeSamples = nodeSamples, - RepeatCount = repeatCount - }; - } - - protected override HitObject CreateSpinner(Vector2 position, bool newCombo, int comboOffset, double duration) - { - return new ConvertSpinner - { - X = position.X, - Duration = duration - }; - } - - protected override HitObject CreateHold(Vector2 position, bool newCombo, int comboOffset, double duration) - { - return new ConvertHold - { - X = position.X, - Duration = duration - }; - } - } -} diff --git a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHold.cs b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHold.cs deleted file mode 100644 index 2fa4766c1d..0000000000 --- a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHold.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Rulesets.Objects.Types; - -namespace osu.Game.Rulesets.Objects.Legacy.Mania -{ - internal sealed class ConvertHold : ConvertHitObject, IHasXPosition, IHasDuration - { - public float X { get; set; } - - public double Duration { get; set; } - - public double EndTime => StartTime + Duration; - } -} diff --git a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSlider.cs b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSlider.cs deleted file mode 100644 index 84cde5fa95..0000000000 --- a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSlider.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Rulesets.Objects.Types; - -namespace osu.Game.Rulesets.Objects.Legacy.Mania -{ - /// - /// Legacy osu!mania Slider-type, used for parsing Beatmaps. - /// - internal sealed class ConvertSlider : Legacy.ConvertSlider, IHasXPosition - { - public float X { get; set; } - } -} diff --git a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHit.cs b/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHit.cs deleted file mode 100644 index b7cd4b0dcc..0000000000 --- a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHit.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Rulesets.Objects.Types; -using osuTK; - -namespace osu.Game.Rulesets.Objects.Legacy.Osu -{ - /// - /// Legacy osu! Hit-type, used for parsing Beatmaps. - /// - internal sealed class ConvertHit : ConvertHitObject, IHasPosition - { - public Vector2 Position { get; set; } - - public float X => Position.X; - - public float Y => Position.Y; - } -} diff --git a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHitObjectParser.cs deleted file mode 100644 index 43c346b621..0000000000 --- a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHitObjectParser.cs +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using osuTK; -using System.Collections.Generic; -using osu.Game.Audio; - -namespace osu.Game.Rulesets.Objects.Legacy.Osu -{ - /// - /// A HitObjectParser to parse legacy osu! Beatmaps. - /// - public class ConvertHitObjectParser : Legacy.ConvertHitObjectParser - { - private ConvertHitObject lastObject; - - public ConvertHitObjectParser(double offset, int formatVersion) - : base(offset, formatVersion) - { - } - - protected override HitObject CreateHit(Vector2 position, bool newCombo, int comboOffset) - { - return lastObject = new ConvertHit - { - Position = position, - NewCombo = FirstObject || lastObject is ConvertSpinner || newCombo, - ComboOffset = newCombo ? comboOffset : 0 - }; - } - - protected override HitObject CreateSlider(Vector2 position, bool newCombo, int comboOffset, PathControlPoint[] controlPoints, double? length, int repeatCount, - IList> nodeSamples) - { - return lastObject = new ConvertSlider - { - Position = position, - NewCombo = FirstObject || lastObject is ConvertSpinner || newCombo, - ComboOffset = newCombo ? comboOffset : 0, - Path = new SliderPath(controlPoints, length), - NodeSamples = nodeSamples, - RepeatCount = repeatCount - }; - } - - protected override HitObject CreateSpinner(Vector2 position, bool newCombo, int comboOffset, double duration) - { - return lastObject = new ConvertSpinner - { - Position = position, - Duration = duration, - NewCombo = newCombo - // Spinners cannot have combo offset. - }; - } - - protected override HitObject CreateHold(Vector2 position, bool newCombo, int comboOffset, double duration) - { - return lastObject = null; - } - } -} diff --git a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertSlider.cs b/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertSlider.cs deleted file mode 100644 index 8c37154f95..0000000000 --- a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertSlider.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Rulesets.Objects.Types; -using osuTK; - -namespace osu.Game.Rulesets.Objects.Legacy.Osu -{ - /// - /// Legacy osu! Slider-type, used for parsing Beatmaps. - /// - internal sealed class ConvertSlider : Legacy.ConvertSlider, IHasPosition, IHasGenerateTicks - { - public Vector2 Position { get; set; } - - public float X => Position.X; - - public float Y => Position.Y; - - public bool GenerateTicks { get; set; } = true; - } -} diff --git a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertSpinner.cs b/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertSpinner.cs deleted file mode 100644 index d6e24b6bbf..0000000000 --- a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertSpinner.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Rulesets.Objects.Types; -using osuTK; - -namespace osu.Game.Rulesets.Objects.Legacy.Osu -{ - /// - /// Legacy osu! Spinner-type, used for parsing Beatmaps. - /// - internal sealed class ConvertSpinner : ConvertHitObject, IHasDuration, IHasPosition - { - public double Duration { get; set; } - - public double EndTime => StartTime + Duration; - - public Vector2 Position { get; set; } - - public float X => Position.X; - - public float Y => Position.Y; - } -} diff --git a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHit.cs b/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHit.cs deleted file mode 100644 index cb5178ce48..0000000000 --- a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHit.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -namespace osu.Game.Rulesets.Objects.Legacy.Taiko -{ - /// - /// Legacy osu!taiko Hit-type, used for parsing Beatmaps. - /// - internal sealed class ConvertHit : ConvertHitObject - { - } -} diff --git a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHitObjectParser.cs deleted file mode 100644 index d62e8cd04c..0000000000 --- a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHitObjectParser.cs +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using osuTK; -using System.Collections.Generic; -using osu.Game.Audio; - -namespace osu.Game.Rulesets.Objects.Legacy.Taiko -{ - /// - /// A HitObjectParser to parse legacy osu!taiko Beatmaps. - /// - public class ConvertHitObjectParser : Legacy.ConvertHitObjectParser - { - public ConvertHitObjectParser(double offset, int formatVersion) - : base(offset, formatVersion) - { - } - - protected override HitObject CreateHit(Vector2 position, bool newCombo, int comboOffset) - { - return new ConvertHit(); - } - - protected override HitObject CreateSlider(Vector2 position, bool newCombo, int comboOffset, PathControlPoint[] controlPoints, double? length, int repeatCount, - IList> nodeSamples) - { - return new ConvertSlider - { - Path = new SliderPath(controlPoints, length), - NodeSamples = nodeSamples, - RepeatCount = repeatCount - }; - } - - protected override HitObject CreateSpinner(Vector2 position, bool newCombo, int comboOffset, double duration) - { - return new ConvertSpinner - { - Duration = duration - }; - } - - protected override HitObject CreateHold(Vector2 position, bool newCombo, int comboOffset, double duration) - { - return null; - } - } -} diff --git a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSlider.cs b/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSlider.cs deleted file mode 100644 index 821554f7ee..0000000000 --- a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSlider.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -namespace osu.Game.Rulesets.Objects.Legacy.Taiko -{ - /// - /// Legacy osu!taiko Slider-type, used for parsing Beatmaps. - /// - internal sealed class ConvertSlider : Legacy.ConvertSlider - { - } -} diff --git a/osu.Game/Rulesets/Objects/SliderPath.cs b/osu.Game/Rulesets/Objects/SliderPath.cs index e8e769e3fa..5550815370 100644 --- a/osu.Game/Rulesets/Objects/SliderPath.cs +++ b/osu.Game/Rulesets/Objects/SliderPath.cs @@ -330,6 +330,20 @@ namespace osu.Game.Rulesets.Objects if (subControlPoints.Length != 3) break; + CircularArcProperties circularArcProperties = new CircularArcProperties(subControlPoints); + + // `PathApproximator` will already internally revert to B-spline if the arc isn't valid. + if (!circularArcProperties.IsValid) + break; + + // taken from https://github.com/ppy/osu-framework/blob/1201e641699a1d50d2f6f9295192dad6263d5820/osu.Framework/Utils/PathApproximator.cs#L181-L186 + int subPoints = (2f * circularArcProperties.Radius <= 0.1f) ? 2 : Math.Max(2, (int)Math.Ceiling(circularArcProperties.ThetaRange / (2.0 * Math.Acos(1f - (0.1f / circularArcProperties.Radius))))); + + // 1000 subpoints requires an arc length of at least ~120 thousand to occur + // See here for calculations https://www.desmos.com/calculator/umj6jvmcz7 + if (subPoints >= 1000) + break; + List subPath = PathApproximator.CircularArcToPiecewiseLinear(subControlPoints); // If for some reason a circular arc could not be fit to the 3 given points, fall back to a numerically stable bezier approximation. diff --git a/osu.Game/Rulesets/Objects/SliderPathExtensions.cs b/osu.Game/Rulesets/Objects/SliderPathExtensions.cs index c03d3646da..a631274f74 100644 --- a/osu.Game/Rulesets/Objects/SliderPathExtensions.cs +++ b/osu.Game/Rulesets/Objects/SliderPathExtensions.cs @@ -17,7 +17,7 @@ namespace osu.Game.Rulesets.Objects public static void SnapTo(this THitObject hitObject, IDistanceSnapProvider? snapProvider) where THitObject : HitObject, IHasPath { - hitObject.Path.ExpectedDistance.Value = snapProvider?.FindSnappedDistance(hitObject, (float)hitObject.Path.CalculatedDistance) ?? hitObject.Path.CalculatedDistance; + hitObject.Path.ExpectedDistance.Value = snapProvider?.FindSnappedDistance(hitObject, (float)hitObject.Path.CalculatedDistance, DistanceSnapTarget.Start) ?? hitObject.Path.CalculatedDistance; } /// diff --git a/osu.Game/Rulesets/Objects/Types/IHasRepeats.cs b/osu.Game/Rulesets/Objects/Types/IHasRepeats.cs index 2a4215b960..9677ac4fbd 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasRepeats.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasRepeats.cs @@ -3,6 +3,7 @@ using osu.Game.Audio; using System.Collections.Generic; +using System.Linq; namespace osu.Game.Rulesets.Objects.Types { @@ -45,5 +46,19 @@ namespace osu.Game.Rulesets.Objects.Types public static IList GetNodeSamples(this T obj, int nodeIndex) where T : HitObject, IHasRepeats => nodeIndex < obj.NodeSamples.Count ? obj.NodeSamples[nodeIndex] : obj.Samples; + + /// + /// Ensures that the list of node samples is at least as long as the number of nodes. + /// + /// The . + public static void PopulateNodeSamples(this T obj) + where T : HitObject, IHasRepeats + { + if (obj.NodeSamples.Count >= obj.RepeatCount + 2) + return; + + while (obj.NodeSamples.Count < obj.RepeatCount + 2) + obj.NodeSamples.Add(obj.Samples.Select(o => o.With()).ToList()); + } } } diff --git a/osu.Game/Rulesets/Objects/Types/IHasTimePreempt.cs b/osu.Game/Rulesets/Objects/Types/IHasTimePreempt.cs new file mode 100644 index 0000000000..e7239515f6 --- /dev/null +++ b/osu.Game/Rulesets/Objects/Types/IHasTimePreempt.cs @@ -0,0 +1,13 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Rulesets.Objects.Types +{ + /// + /// A that appears on screen at a fixed time interval before its . + /// + public interface IHasTimePreempt + { + double TimePreempt { get; } + } +} diff --git a/osu.Game/Rulesets/Ruleset.cs b/osu.Game/Rulesets/Ruleset.cs index 37a35fd3ae..bd1f273b49 100644 --- a/osu.Game/Rulesets/Ruleset.cs +++ b/osu.Game/Rulesets/Ruleset.cs @@ -8,6 +8,7 @@ using System.Linq; using osu.Framework.Extensions; using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; using osu.Framework.IO.Stores; @@ -30,6 +31,7 @@ using osu.Game.Screens.Edit.Setup; using osu.Game.Screens.Ranking.Statistics; using osu.Game.Skinning; using osu.Game.Users; +using osuTK; namespace osu.Game.Rulesets { @@ -99,7 +101,7 @@ namespace osu.Game.Rulesets /// The acronym to query for . public Mod? CreateModFromAcronym(string acronym) { - return AllMods.FirstOrDefault(m => m.Acronym == acronym)?.CreateInstance(); + return AllMods.FirstOrDefault(m => string.Equals(m.Acronym, acronym, StringComparison.OrdinalIgnoreCase))?.CreateInstance(); } /// @@ -394,13 +396,35 @@ namespace osu.Game.Rulesets public virtual IRulesetFilterCriteria? CreateRulesetFilterCriteria() => null; /// - /// Can be overridden to add a ruleset-specific section to the editor beatmap setup screen. + /// Can be overridden to add ruleset-specific sections to the editor beatmap setup screen. /// - public virtual RulesetSetupSection? CreateEditorSetupSection() => null; + public virtual IEnumerable CreateEditorSetupSections() => + [ + new MetadataSection(), + new DifficultySection(), + new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(25), + Children = new Drawable[] + { + new ResourcesSection + { + RelativeSizeAxes = Axes.X, + }, + new ColoursSection + { + RelativeSizeAxes = Axes.X, + } + } + }, + new DesignSection(), + ]; /// - /// Can be overridden to alter the difficulty section to the editor beatmap setup screen. + /// Can be overridden to avoid showing scroll speed changes in the editor. /// - public virtual DifficultySection? CreateEditorDifficultySection() => null; + public virtual bool EditorShowScrollSpeed => true; } } diff --git a/osu.Game/Rulesets/Scoring/HealthProcessor.cs b/osu.Game/Rulesets/Scoring/HealthProcessor.cs index 9e4c06b783..2799cd4b36 100644 --- a/osu.Game/Rulesets/Scoring/HealthProcessor.cs +++ b/osu.Game/Rulesets/Scoring/HealthProcessor.cs @@ -36,6 +36,9 @@ namespace osu.Game.Rulesets.Scoring /// public void TriggerFailure() { + if (HasFailed) + return; + if (Failed?.Invoke() != false) HasFailed = true; } diff --git a/osu.Game/Rulesets/Scoring/HitEventExtensions.cs b/osu.Game/Rulesets/Scoring/HitEventExtensions.cs index 6e2852676a..fc4eef13ba 100644 --- a/osu.Game/Rulesets/Scoring/HitEventExtensions.cs +++ b/osu.Game/Rulesets/Scoring/HitEventExtensions.cs @@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Scoring foreach (var e in hitEvents) { - if (!affectsUnstableRate(e)) + if (!AffectsUnstableRate(e)) continue; count++; @@ -57,7 +57,7 @@ namespace osu.Game.Rulesets.Scoring /// public static double? CalculateAverageHitError(this IEnumerable hitEvents) { - double[] timeOffsets = hitEvents.Where(affectsUnstableRate).Select(ev => ev.TimeOffset).ToArray(); + double[] timeOffsets = hitEvents.Where(AffectsUnstableRate).Select(ev => ev.TimeOffset).ToArray(); if (timeOffsets.Length == 0) return null; @@ -65,6 +65,6 @@ namespace osu.Game.Rulesets.Scoring return timeOffsets.Average(); } - private static bool affectsUnstableRate(HitEvent e) => !(e.HitObject.HitWindows is HitWindows.EmptyHitWindows) && e.Result.IsHit(); + public static bool AffectsUnstableRate(HitEvent e) => !(e.HitObject.HitWindows is HitWindows.EmptyHitWindows) && e.Result.IsHit(); } } diff --git a/osu.Game/Rulesets/Scoring/LegacyDrainingHealthProcessor.cs b/osu.Game/Rulesets/Scoring/LegacyDrainingHealthProcessor.cs index 7cee5ebecf..25c5b3643a 100644 --- a/osu.Game/Rulesets/Scoring/LegacyDrainingHealthProcessor.cs +++ b/osu.Game/Rulesets/Scoring/LegacyDrainingHealthProcessor.cs @@ -129,6 +129,13 @@ namespace osu.Game.Rulesets.Scoring OnIterationFail?.Invoke($"FAILED drop {testDrop}: recovery too low ({recovery} < {hpRecoveryAvailable})"); } + if (!fail && double.IsInfinity(HpMultiplierNormal)) + { + OnIterationSuccess?.Invoke("Drain computation algorithm diverged to infinity. PASSING with zero drop, resetting HP multiplier to 1."); + HpMultiplierNormal = 1; + return 0; + } + if (!fail) { OnIterationSuccess?.Invoke($"PASSED drop {testDrop}"); diff --git a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs index 9d12daad04..7b5af9beda 100644 --- a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs +++ b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs @@ -56,6 +56,14 @@ namespace osu.Game.Rulesets.Scoring /// public readonly BindableLong TotalScore = new BindableLong { MinValue = 0 }; + /// + /// The total number of points awarded for the score without including mod multipliers. + /// + /// + /// The purpose of this property is to enable future lossless rebalances of mod multipliers. + /// + public readonly BindableLong TotalScoreWithoutMods = new BindableLong { MinValue = 0 }; + /// /// The current accuracy. /// @@ -111,6 +119,11 @@ namespace osu.Game.Rulesets.Scoring /// public long MaximumTotalScore { get; private set; } + /// + /// The maximum achievable combo. + /// + public int MaximumCombo { get; private set; } + /// /// The maximum sum of accuracy-affecting judgements at the current point in time. /// @@ -173,6 +186,8 @@ namespace osu.Game.Rulesets.Scoring } } + public IReadOnlyDictionary Statistics => ScoreResultCounts; + private bool beatmapApplied; protected readonly Dictionary ScoreResultCounts = new Dictionary(); @@ -361,9 +376,10 @@ namespace osu.Game.Rulesets.Scoring MaximumAccuracy.Value = maximumBaseScore > 0 ? (currentBaseScore + (maximumBaseScore - currentMaximumBaseScore)) / maximumBaseScore : 1; double comboProgress = maximumComboPortion > 0 ? currentComboPortion / maximumComboPortion : 1; - double accuracyProcess = maximumAccuracyJudgementCount > 0 ? (double)currentAccuracyJudgementCount / maximumAccuracyJudgementCount : 1; + double accuracyProgress = maximumAccuracyJudgementCount > 0 ? (double)currentAccuracyJudgementCount / maximumAccuracyJudgementCount : 1; - TotalScore.Value = (long)Math.Round(ComputeTotalScore(comboProgress, accuracyProcess, currentBonusPortion) * scoreMultiplier); + TotalScoreWithoutMods.Value = (long)Math.Round(ComputeTotalScore(comboProgress, accuracyProgress, currentBonusPortion)); + TotalScore.Value = (long)Math.Round(TotalScoreWithoutMods.Value * scoreMultiplier); } private void updateRank() @@ -372,9 +388,12 @@ namespace osu.Game.Rulesets.Scoring if (rank.Value == ScoreRank.F) return; - rank.Value = RankFromScore(Accuracy.Value, ScoreResultCounts); + ScoreRank newRank = RankFromScore(Accuracy.Value, ScoreResultCounts); + foreach (var mod in Mods.Value.OfType()) - rank.Value = mod.AdjustRank(Rank.Value, Accuracy.Value); + newRank = mod.AdjustRank(newRank, Accuracy.Value); + + rank.Value = newRank; } protected virtual double ComputeTotalScore(double comboProgress, double accuracyProgress, double bonusPortion) @@ -409,6 +428,7 @@ namespace osu.Game.Rulesets.Scoring MaximumResultCounts.AddRange(ScoreResultCounts); MaximumTotalScore = TotalScore.Value; + MaximumCombo = HighestCombo.Value; } ScoreResultCounts.Clear(); @@ -446,6 +466,7 @@ namespace osu.Game.Rulesets.Scoring score.MaximumStatistics[result] = MaximumResultCounts.GetValueOrDefault(result); // Populate total score after everything else. + score.TotalScoreWithoutMods = TotalScoreWithoutMods.Value; score.TotalScore = TotalScore.Value; } diff --git a/osu.Game/Rulesets/UI/DrawableRuleset.cs b/osu.Game/Rulesets/UI/DrawableRuleset.cs index a422761800..ebd84fd91b 100644 --- a/osu.Game/Rulesets/UI/DrawableRuleset.cs +++ b/osu.Game/Rulesets/UI/DrawableRuleset.cs @@ -65,22 +65,20 @@ namespace osu.Game.Rulesets.UI /// public override Playfield Playfield => playfield.Value; + public override PlayfieldAdjustmentContainer PlayfieldAdjustmentContainer => playfieldAdjustmentContainer; + public override Container Overlays { get; } = new Container { RelativeSizeAxes = Axes.Both }; public override IAdjustableAudioComponent Audio => audioContainer; private readonly AudioContainer audioContainer = new AudioContainer { RelativeSizeAxes = Axes.Both }; - /// - /// A container which encapsulates the and provides any adjustments to - /// ensure correct scale and position. - /// - public virtual PlayfieldAdjustmentContainer PlayfieldAdjustmentContainer { get; private set; } - public override Container FrameStableComponents { get; } = new Container { RelativeSizeAxes = Axes.Both }; public override IFrameStableClock FrameStableClock => frameStabilityContainer; + private readonly PlayfieldAdjustmentContainer playfieldAdjustmentContainer; + private bool allowBackwardsSeeks; public override bool AllowBackwardsSeeks @@ -146,6 +144,7 @@ namespace osu.Game.Rulesets.UI RelativeSizeAxes = Axes.Both; KeyBindingInputManager = CreateInputManager(); + playfieldAdjustmentContainer = CreatePlayfieldAdjustmentContainer(); playfield = new Lazy(() => CreatePlayfield().With(p => { p.NewResult += (_, r) => NewResult?.Invoke(r); @@ -197,8 +196,7 @@ namespace osu.Game.Rulesets.UI audioContainer.WithChild(KeyBindingInputManager .WithChildren(new Drawable[] { - PlayfieldAdjustmentContainer = CreatePlayfieldAdjustmentContainer() - .WithChild(Playfield), + playfieldAdjustmentContainer.WithChild(Playfield), Overlays })), } @@ -456,6 +454,12 @@ namespace osu.Game.Rulesets.UI /// public abstract Playfield Playfield { get; } + /// + /// A container which encapsulates the and provides any adjustments to + /// ensure correct scale and position. + /// + public abstract PlayfieldAdjustmentContainer PlayfieldAdjustmentContainer { get; } + /// /// Content to be placed above hitobjects. Will be affected by frame stability and adjustments applied to . /// @@ -529,7 +533,7 @@ namespace osu.Game.Rulesets.UI public ResumeOverlay ResumeOverlay { get; protected set; } /// - /// Whether the should be used to return the user's cursor position to its previous location after a pause. + /// Whether a should be displayed on resuming after a pause. /// /// /// Defaults to true. diff --git a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs index b49924762e..c4feb249f4 100644 --- a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs +++ b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs @@ -119,7 +119,7 @@ namespace osu.Game.Rulesets.UI break; base.UpdateSubTree(); - UpdateSubTreeMasking(this, ScreenSpaceDrawQuad.AABBFloat); + UpdateSubTreeMasking(); } while (state == PlaybackState.RequiresCatchUp && stopwatch.ElapsedMilliseconds < max_catchup_milliseconds); return true; diff --git a/osu.Game/Rulesets/UI/JudgementContainer.cs b/osu.Game/Rulesets/UI/JudgementContainer.cs index 886dd34fc7..86ab213ca1 100644 --- a/osu.Game/Rulesets/UI/JudgementContainer.cs +++ b/osu.Game/Rulesets/UI/JudgementContainer.cs @@ -16,7 +16,7 @@ namespace osu.Game.Rulesets.UI // remove any existing judgements for the judged object. // this can be the case when rewinding. - RemoveAll(c => c.JudgedObject == judgement.JudgedObject, false); + RemoveAll(c => c.JudgedHitObject == judgement.JudgedHitObject, false); base.Add(judgement); } diff --git a/osu.Game/Rulesets/UI/ModIcon.cs b/osu.Game/Rulesets/UI/ModIcon.cs index 5d9fafd60c..5237425075 100644 --- a/osu.Game/Rulesets/UI/ModIcon.cs +++ b/osu.Game/Rulesets/UI/ModIcon.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Framework.Localisation; +using osu.Framework.Utils; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; @@ -139,7 +140,7 @@ namespace osu.Game.Rulesets.UI Origin = Anchor.Centre, Anchor = Anchor.Centre, Alpha = 0, - Font = OsuFont.Numeric.With(null, 22f), + Font = OsuFont.Numeric.With(size: 22f, weight: FontWeight.Black), UseFullGlyphHeight = false, Text = mod.Acronym }, @@ -204,7 +205,7 @@ namespace osu.Game.Rulesets.UI private void updateColour() { - modAcronym.Colour = modIcon.Colour = OsuColour.Gray(84); + modAcronym.Colour = modIcon.Colour = Interpolation.ValueAt(0.1f, Colour4.Black, backgroundColour, 0, 1); extendedText.Colour = background.Colour = Selected.Value ? backgroundColour.Lighten(0.2f) : backgroundColour; extendedBackground.Colour = Selected.Value ? backgroundColour.Darken(2.4f) : backgroundColour.Darken(2.8f); diff --git a/osu.Game/Rulesets/UI/ModSwitchTiny.cs b/osu.Game/Rulesets/UI/ModSwitchTiny.cs index 4d50e702af..4a3bc9e31b 100644 --- a/osu.Game/Rulesets/UI/ModSwitchTiny.cs +++ b/osu.Game/Rulesets/UI/ModSwitchTiny.cs @@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.UI public BindableBool Active { get; } = new BindableBool(); public const float DEFAULT_HEIGHT = 30; - private const float width = 73; + public const float WIDTH = 73; protected readonly IMod Mod; private readonly bool showExtendedInformation; @@ -56,7 +56,7 @@ namespace osu.Game.Rulesets.UI Width = 100 + DEFAULT_HEIGHT / 2, RelativeSizeAxes = Axes.Y, Masking = true, - X = width, + X = WIDTH, Margin = new MarginPadding { Left = -DEFAULT_HEIGHT }, Children = new Drawable[] { @@ -77,7 +77,7 @@ namespace osu.Game.Rulesets.UI }, new CircularContainer { - Width = width, + Width = WIDTH, RelativeSizeAxes = Axes.Y, Masking = true, Children = new Drawable[] diff --git a/osu.Game/Rulesets/UI/RulesetInputManager.cs b/osu.Game/Rulesets/UI/RulesetInputManager.cs index a08c3bab08..31c7c34572 100644 --- a/osu.Game/Rulesets/UI/RulesetInputManager.cs +++ b/osu.Game/Rulesets/UI/RulesetInputManager.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; @@ -12,6 +10,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Framework.Input.StateChanges; using osu.Framework.Input.StateChanges.Events; using osu.Framework.Input.States; using osu.Game.Configuration; @@ -21,6 +20,7 @@ using osu.Game.Input.Handlers; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.HUD.ClicksPerSecond; +using osuTK; using static osu.Game.Input.Handlers.ReplayInputHandler; namespace osu.Game.Rulesets.UI @@ -32,12 +32,12 @@ namespace osu.Game.Rulesets.UI public readonly KeyBindingContainer KeyBindingContainer; - [Resolved(CanBeNull = true)] - private ScoreProcessor scoreProcessor { get; set; } + [Resolved] + private ScoreProcessor? scoreProcessor { get; set; } - private ReplayRecorder recorder; + private ReplayRecorder? recorder; - public ReplayRecorder Recorder + public ReplayRecorder? Recorder { set { @@ -103,14 +103,23 @@ namespace osu.Game.Rulesets.UI #region IHasReplayHandler - private ReplayInputHandler replayInputHandler; + private ReplayInputHandler? replayInputHandler; - public ReplayInputHandler ReplayInputHandler + public ReplayInputHandler? ReplayInputHandler { get => replayInputHandler; set { - if (replayInputHandler != null) RemoveHandler(replayInputHandler); + if (replayInputHandler == value) + return; + + if (replayInputHandler != null) + RemoveHandler(replayInputHandler); + + // ensures that all replay keys are released, that the last replay state is correctly cleared, + // and that all user-pressed keys are released, so that the replay handler may trigger them itself + // setting `UseParentInput` will only sync releases (https://github.com/ppy/osu-framework/blob/17d65f476d51cc5f2aaea818534f8fbac47e5fe6/osu.Framework/Input/PassThroughInputManager.cs#L179-L182) + new ReplayStateReset().Apply(CurrentState, this); replayInputHandler = value; UseParentInput = replayInputHandler == null; @@ -124,8 +133,8 @@ namespace osu.Game.Rulesets.UI #region Setting application (disables etc.) - private Bindable mouseDisabled; - private Bindable tapsDisabled; + private Bindable mouseDisabled = null!; + private Bindable tapsDisabled = null!; protected override bool Handle(UIEvent e) { @@ -222,14 +231,34 @@ namespace osu.Game.Rulesets.UI RealmKeyBindingStore.ClearDuplicateBindings(KeyBindings); } } + + private class ReplayStateReset : IInput + { + public void Apply(InputState state, IInputStateChangeHandler handler) + { + if (!(state is RulesetInputManagerInputState inputState)) + throw new InvalidOperationException($"{nameof(ReplayState)} should only be applied to a {nameof(RulesetInputManagerInputState)}"); + + new MouseButtonInput([], state.Mouse.Buttons).Apply(state, handler); + new KeyboardKeyInput([], state.Keyboard.Keys).Apply(state, handler); + new TouchInput(Enum.GetValues().Select(s => new Touch(s, Vector2.Zero)), false).Apply(state, handler); + new JoystickButtonInput([], state.Joystick.Buttons).Apply(state, handler); + new MidiKeyInput(new MidiState(), state.Midi).Apply(state, handler); + new TabletPenButtonInput([], state.Tablet.PenButtons).Apply(state, handler); + new TabletAuxiliaryButtonInput([], state.Tablet.AuxiliaryButtons).Apply(state, handler); + + handler.HandleInputStateChange(new ReplayStateChangeEvent(state, this, inputState.LastReplayState?.PressedActions.ToArray() ?? [], [])); + inputState.LastReplayState = null; + } + } } public class RulesetInputManagerInputState : InputState where T : struct { - public ReplayState LastReplayState; + public ReplayState? LastReplayState; - public RulesetInputManagerInputState(InputState state = null) + public RulesetInputManagerInputState(InputState state) : base(state) { } diff --git a/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs b/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs index d23658ac33..ba3a9bd483 100644 --- a/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs +++ b/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs @@ -64,8 +64,6 @@ namespace osu.Game.Rulesets.UI.Scrolling MaxValue = time_span_max }; - ScrollVisualisationMethod IDrawableScrollingRuleset.VisualisationMethod => VisualisationMethod; - /// /// Whether the player can change . /// diff --git a/osu.Game/Rulesets/UI/Scrolling/IDrawableScrollingRuleset.cs b/osu.Game/Rulesets/UI/Scrolling/IDrawableScrollingRuleset.cs index b348a22009..27531492d6 100644 --- a/osu.Game/Rulesets/UI/Scrolling/IDrawableScrollingRuleset.cs +++ b/osu.Game/Rulesets/UI/Scrolling/IDrawableScrollingRuleset.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. -using osu.Game.Configuration; - namespace osu.Game.Rulesets.UI.Scrolling { /// @@ -10,8 +8,6 @@ namespace osu.Game.Rulesets.UI.Scrolling /// public interface IDrawableScrollingRuleset { - ScrollVisualisationMethod VisualisationMethod { get; } - IScrollingInfo ScrollingInfo { get; } } } diff --git a/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs b/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs index 4e72291b9c..7841e65935 100644 --- a/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs +++ b/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs @@ -136,7 +136,7 @@ namespace osu.Game.Rulesets.UI.Scrolling // Scroll info is not available until loaded. // The lifetime of all entries will be updated in the first Update. if (IsLoaded) - setComputedLifetimeStart(entry); + setComputedLifetime(entry); base.Add(entry); } @@ -171,7 +171,7 @@ namespace osu.Game.Rulesets.UI.Scrolling layoutComputed.Clear(); foreach (var entry in Entries) - setComputedLifetimeStart(entry); + setComputedLifetime(entry); algorithm.Value.Reset(); @@ -234,12 +234,20 @@ namespace osu.Game.Rulesets.UI.Scrolling return algorithm.Value.GetDisplayStartTime(entry.HitObject.StartTime, startOffset, timeRange.Value, scrollLength); } - private void setComputedLifetimeStart(HitObjectLifetimeEntry entry) + private void setComputedLifetime(HitObjectLifetimeEntry entry) { double computedStartTime = computeDisplayStartTime(entry); // always load the hitobject before its first judgement offset entry.LifetimeStart = Math.Min(entry.HitObject.StartTime - entry.HitObject.MaximumJudgementOffset, computedStartTime); + + // This is likely not entirely correct, but sets a sane expectation of the ending lifetime. + // A more correct lifetime will be overwritten after a DrawableHitObject is assigned via DrawableHitObject.updateState. + // + // It is required that we set a lifetime end here to ensure that in scenarios like loading a Player instance to a seeked + // location in a beatmap doesn't churn every hit object into a DrawableHitObject. Even in a pooled scenario, the overhead + // of this can be quite crippling. + entry.LifetimeEnd = entry.HitObject.GetEndTime() + timeRange.Value; } private void updateLayoutRecursive(DrawableHitObject hitObject, double? parentHitObjectStartTime = null) @@ -261,7 +269,7 @@ namespace osu.Game.Rulesets.UI.Scrolling // Nested hitobjects don't need to scroll, but they do need accurate positions and start lifetime updatePosition(obj, hitObject.HitObject.StartTime, parentHitObjectStartTime); - setComputedLifetimeStart(obj.Entry); + setComputedLifetime(obj.Entry); } } diff --git a/osu.Game/Scoring/Legacy/LegacyReplaySoloScoreInfo.cs b/osu.Game/Scoring/Legacy/LegacyReplaySoloScoreInfo.cs index afdcef1d21..c99f104418 100644 --- a/osu.Game/Scoring/Legacy/LegacyReplaySoloScoreInfo.cs +++ b/osu.Game/Scoring/Legacy/LegacyReplaySoloScoreInfo.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; using Newtonsoft.Json; +using Newtonsoft.Json.Converters; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets.Scoring; @@ -38,6 +39,16 @@ namespace osu.Game.Scoring.Legacy [JsonProperty("client_version")] public string ClientVersion = string.Empty; + [JsonProperty("rank")] + [JsonConverter(typeof(StringEnumConverter))] + public ScoreRank? Rank; + + [JsonProperty("user_id")] + public int UserID = -1; + + [JsonProperty("total_score_without_mods")] + public long? TotalScoreWithoutMods { get; set; } + public static LegacyReplaySoloScoreInfo FromScore(ScoreInfo score) => new LegacyReplaySoloScoreInfo { OnlineID = score.OnlineID, @@ -45,6 +56,9 @@ namespace osu.Game.Scoring.Legacy Statistics = score.Statistics.Where(kvp => kvp.Value != 0).ToDictionary(), MaximumStatistics = score.MaximumStatistics.Where(kvp => kvp.Value != 0).ToDictionary(), ClientVersion = score.ClientVersion, + Rank = score.Rank, + UserID = score.User.OnlineID, + TotalScoreWithoutMods = score.TotalScoreWithoutMods > 0 ? score.TotalScoreWithoutMods : null, }; } } diff --git a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs index 65e2c02655..6ad118547b 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs @@ -1,4 +1,4 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. #nullable disable @@ -21,6 +21,7 @@ using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Scoring; +using osuTK; using SharpCompress.Compressors.LZMA; namespace osu.Game.Scoring.Legacy @@ -40,6 +41,7 @@ namespace osu.Game.Scoring.Legacy }; WorkingBeatmap workingBeatmap; + ScoreRank? decodedRank = null; using (SerializationReader sr = new SerializationReader(stream)) { @@ -129,6 +131,14 @@ namespace osu.Game.Scoring.Legacy score.ScoreInfo.MaximumStatistics = readScore.MaximumStatistics; score.ScoreInfo.Mods = readScore.Mods.Select(m => m.ToMod(currentRuleset)).ToArray(); score.ScoreInfo.ClientVersion = readScore.ClientVersion; + decodedRank = readScore.Rank; + if (readScore.UserID > 1) + score.ScoreInfo.RealmUser.OnlineID = readScore.UserID; + + if (readScore.TotalScoreWithoutMods is long totalScoreWithoutMods) + score.ScoreInfo.TotalScoreWithoutMods = totalScoreWithoutMods; + else + PopulateTotalScoreWithoutMods(score.ScoreInfo); }); } } @@ -140,6 +150,9 @@ namespace osu.Game.Scoring.Legacy StandardisedScoreMigrationTools.UpdateFromLegacy(score.ScoreInfo, workingBeatmap); + if (decodedRank != null) + score.ScoreInfo.Rank = decodedRank.Value; + // before returning for database import, we must restore the database-sourced BeatmapInfo. // if not, the clone operation in GetPlayableBeatmap will cause a dereference and subsequent database exception. score.ScoreInfo.BeatmapInfo = workingBeatmap.BeatmapInfo; @@ -237,10 +250,20 @@ namespace osu.Game.Scoring.Legacy #pragma warning restore CS0618 } + public static void PopulateTotalScoreWithoutMods(ScoreInfo score) + { + double modMultiplier = 1; + + foreach (var mod in score.Mods) + modMultiplier *= mod.ScoreMultiplier; + + score.TotalScoreWithoutMods = (long)Math.Round(score.TotalScore / modMultiplier); + } + private void readLegacyReplay(Replay replay, StreamReader reader) { float lastTime = beatmapOffset; - ReplayFrame currentFrame = null; + var legacyFrames = new List(); string[] frames = reader.ReadToEnd().Split(','); @@ -257,29 +280,53 @@ namespace osu.Game.Scoring.Legacy continue; } + // In mania, mouseX encodes the pressed keys in the lower 20 bits + int mouseXParseLimit = currentRuleset.RulesetInfo.OnlineID == 3 ? (1 << 20) - 1 : Parsing.MAX_COORDINATE_VALUE; + float diff = Parsing.ParseFloat(split[0]); - float mouseX = Parsing.ParseFloat(split[1], Parsing.MAX_COORDINATE_VALUE); + float mouseX = Parsing.ParseFloat(split[1], mouseXParseLimit); float mouseY = Parsing.ParseFloat(split[2], Parsing.MAX_COORDINATE_VALUE); lastTime += diff; - if (i < 2 && mouseX == 256 && mouseY == -500) - // at the start of the replay, stable places two replay frames, at time 0 and SkipBoundary - 1, respectively. - // both frames use a position of (256, -500). - // ignore these frames as they serve no real purpose (and can even mislead ruleset-specific handlers - see mania) - continue; - - // Todo: At some point we probably want to rewind and play back the negative-time frames - // but for now we'll achieve equal playback to stable by skipping negative frames - if (diff < 0) - continue; - - currentFrame = convertFrame(new LegacyReplayFrame(lastTime, + legacyFrames.Add(new LegacyReplayFrame(lastTime, mouseX, mouseY, - (ReplayButtonState)Parsing.ParseInt(split[3])), currentFrame); + (ReplayButtonState)Parsing.ParseInt(split[3]))); + } - replay.Frames.Add(currentFrame); + // https://github.com/peppy/osu-stable-reference/blob/e53980dd76857ee899f66ce519ba1597e7874f28/osu!/GameModes/Play/ReplayWatcher.cs#L62-L67 + if (legacyFrames.Count >= 2 && legacyFrames[1].Time < legacyFrames[0].Time) + { + legacyFrames[1].Time = legacyFrames[0].Time; + legacyFrames[0].Time = 0; + } + + // https://github.com/peppy/osu-stable-reference/blob/e53980dd76857ee899f66ce519ba1597e7874f28/osu!/GameModes/Play/ReplayWatcher.cs#L69-L71 + if (legacyFrames.Count >= 3 && legacyFrames[0].Time > legacyFrames[2].Time) + legacyFrames[0].Time = legacyFrames[1].Time = legacyFrames[2].Time; + + // at the start of the replay, stable places two replay frames, at time 0 and SkipBoundary - 1, respectively. + // both frames use a position of (256, -500). + // ignore these frames as they serve no real purpose (and can even mislead ruleset-specific handlers - see mania) + if (legacyFrames.Count >= 2 && legacyFrames[1].Position == new Vector2(256, -500)) + legacyFrames.RemoveAt(1); + + if (legacyFrames.Count >= 1 && legacyFrames[0].Position == new Vector2(256, -500)) + legacyFrames.RemoveAt(0); + + ReplayFrame currentFrame = null; + + foreach (var legacyFrame in legacyFrames) + { + // never allow backwards time traversal in relation to the current frame. + // this handles frames with negative delta. + // this doesn't match stable 100% as stable will do something similar to adding an interpolated "intermediate frame" + // at the point wherein time flow changes from backwards to forwards, but it'll do for now. + if (currentFrame != null && legacyFrame.Time < currentFrame.Time) + continue; + + replay.Frames.Add(currentFrame = convertFrame(legacyFrame, currentFrame)); } } diff --git a/osu.Game/Scoring/ScoreImporter.cs b/osu.Game/Scoring/ScoreImporter.cs index 768c28cc38..69c53af16f 100644 --- a/osu.Game/Scoring/ScoreImporter.cs +++ b/osu.Game/Scoring/ScoreImporter.cs @@ -103,6 +103,14 @@ namespace osu.Game.Scoring } // Very naive local caching to improve performance of large score imports (where the username is usually the same for most or all scores). + + // TODO: `UserLookupCache` cannot currently be used here because of async foibles. + // It only supports lookups by user ID (username would require web changes), and even then the ID lookups cannot be used. + // That is because that component provides an async interface, and async functions cannot be consumed safely here due to the rigid structure of `RealmArchiveModelImporter`. + // The importer has two paths, one async and one sync; the async path runs the sync path in a task. + // This means that sometimes `PostImport()` is called from a sync context, and sometimes from an async one, whilst itself being a sync method. + // That in turn makes `.GetResultSafely()` not callable inside `PostImport()`, as it will throw when called from an async context, + private readonly Dictionary idLookupCache = new Dictionary(); private readonly Dictionary usernameLookupCache = new Dictionary(); protected override void PostImport(ScoreInfo model, Realm realm, ImportParameters parameters) @@ -127,21 +135,34 @@ namespace osu.Game.Scoring if (model.RealmUser.OnlineID == APIUser.SYSTEM_USER_ID) return; - string username = model.RealmUser.Username; - - if (usernameLookupCache.TryGetValue(username, out var existing)) + if (model.RealmUser.OnlineID > 1) { - model.User = existing; + model.User = lookupUserById(model.RealmUser.OnlineID) ?? model.User; return; } - var userRequest = new GetUserRequest(username); + if (model.OnlineID < 0 && model.LegacyOnlineID <= 0) + return; + + model.User = lookupUserByName(model.RealmUser.Username) ?? model.User; + } + + private APIUser? lookupUserById(int id) + { + if (idLookupCache.TryGetValue(id, out var existing)) + { + return existing; + } + + var userRequest = new GetUserRequest(id); api.Perform(userRequest); if (userRequest.Response is APIUser user) { - usernameLookupCache.TryAdd(username, new APIUser + APIUser cachedUser; + + idLookupCache.TryAdd(id, cachedUser = new APIUser { // Because this is a permanent cache, let's only store the pieces we're interested in, // rather than the full API response. If we start to store more than these three fields @@ -151,8 +172,41 @@ namespace osu.Game.Scoring CountryCode = user.CountryCode, }); - model.User = user; + return cachedUser; } + + return null; + } + + private APIUser? lookupUserByName(string username) + { + if (usernameLookupCache.TryGetValue(username, out var existing)) + { + return existing; + } + + var userRequest = new GetUserRequest(username); + + api.Perform(userRequest); + + if (userRequest.Response is APIUser user) + { + APIUser cachedUser; + + usernameLookupCache.TryAdd(username, cachedUser = new APIUser + { + // Because this is a permanent cache, let's only store the pieces we're interested in, + // rather than the full API response. If we start to store more than these three fields + // in realm, this should be undone. + Id = user.Id, + Username = user.Username, + CountryCode = user.CountryCode, + }); + + return cachedUser; + } + + return null; } } } diff --git a/osu.Game/Scoring/ScoreInfo.cs b/osu.Game/Scoring/ScoreInfo.cs index fd98107792..a3dabc7945 100644 --- a/osu.Game/Scoring/ScoreInfo.cs +++ b/osu.Game/Scoring/ScoreInfo.cs @@ -65,8 +65,19 @@ namespace osu.Game.Scoring public bool DeletePending { get; set; } + /// + /// The total number of points awarded for the score. + /// public long TotalScore { get; set; } + /// + /// The total number of points awarded for the score without including mod multipliers. + /// + /// + /// The purpose of this property is to enable future lossless rebalances of mod multipliers. + /// + public long TotalScoreWithoutMods { get; set; } + /// /// The version of processing applied to calculate total score as stored in the database. /// If this does not match , @@ -154,7 +165,7 @@ namespace osu.Game.Scoring } [UsedImplicitly] // Realm - private ScoreInfo() + protected ScoreInfo() { } diff --git a/osu.Game/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs index 1ba5c7d4cf..e3601fe91e 100644 --- a/osu.Game/Scoring/ScoreManager.cs +++ b/osu.Game/Scoring/ScoreManager.cs @@ -15,10 +15,10 @@ using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Database; using osu.Game.IO.Archives; +using osu.Game.Online.API; using osu.Game.Overlays.Notifications; using osu.Game.Rulesets; using osu.Game.Rulesets.Scoring; -using osu.Game.Online.API; using osu.Game.Scoring.Legacy; namespace osu.Game.Scoring @@ -88,15 +88,15 @@ namespace osu.Game.Scoring { ScoreInfo? databasedScoreInfo = null; + if (originalScoreInfo is ScoreInfo scoreInfo && !string.IsNullOrEmpty(scoreInfo.Hash)) + databasedScoreInfo = Query(s => s.Hash == scoreInfo.Hash); + if (originalScoreInfo.OnlineID > 0) - databasedScoreInfo = Query(s => s.OnlineID == originalScoreInfo.OnlineID); + databasedScoreInfo ??= Query(s => s.OnlineID == originalScoreInfo.OnlineID); if (originalScoreInfo.LegacyOnlineID > 0) databasedScoreInfo ??= Query(s => s.LegacyOnlineID == originalScoreInfo.LegacyOnlineID); - if (originalScoreInfo is ScoreInfo scoreInfo) - databasedScoreInfo ??= Query(s => s.Hash == scoreInfo.Hash); - if (databasedScoreInfo == null) { Logger.Log("The requested score could not be found locally.", LoggingTarget.Information); @@ -214,6 +214,7 @@ namespace osu.Game.Scoring } public Task?> ImportAsUpdate(ProgressNotification notification, ImportTask task, ScoreInfo original) => scoreImporter.ImportAsUpdate(notification, task, original); + public Task> BeginExternalEditing(ScoreInfo model) => scoreImporter.BeginExternalEditing(model); public Live? Import(ScoreInfo item, ArchiveReader? archive = null, ImportParameters parameters = default, CancellationToken cancellationToken = default) => scoreImporter.ImportModel(item, archive, parameters, cancellationToken); diff --git a/osu.Game/Scoring/ScoreRank.cs b/osu.Game/Scoring/ScoreRank.cs index 327e4191d7..957cfc9b95 100644 --- a/osu.Game/Scoring/ScoreRank.cs +++ b/osu.Game/Scoring/ScoreRank.cs @@ -35,6 +35,7 @@ namespace osu.Game.Scoring [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.RankSH))] [Description(@"S+")] + // ReSharper disable once InconsistentNaming SH, [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.RankX))] @@ -43,6 +44,7 @@ namespace osu.Game.Scoring [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.RankXH))] [Description(@"SS+")] + // ReSharper disable once InconsistentNaming XH, } } diff --git a/osu.Game/Screens/BackgroundScreen.cs b/osu.Game/Screens/BackgroundScreen.cs index 73af9b1bf2..53f0b39ef7 100644 --- a/osu.Game/Screens/BackgroundScreen.cs +++ b/osu.Game/Screens/BackgroundScreen.cs @@ -17,13 +17,12 @@ namespace osu.Game.Screens private const float x_movement_amount = 50; - private readonly bool animateOnEnter; - public override bool IsPresent => base.IsPresent || Scheduler.HasPendingTasks; - protected BackgroundScreen(bool animateOnEnter = true) + public bool AnimateEntry { get; set; } = true; + + protected BackgroundScreen() { - this.animateOnEnter = animateOnEnter; Anchor = Anchor.Centre; Origin = Anchor.Centre; } @@ -53,12 +52,11 @@ namespace osu.Game.Screens public override void OnEntering(ScreenTransitionEvent e) { - if (animateOnEnter) + if (AnimateEntry) { this.FadeOut(); - this.MoveToX(x_movement_amount); - this.FadeIn(TRANSITION_LENGTH, Easing.InOutQuart); + this.MoveToX(x_movement_amount); this.MoveToX(0, TRANSITION_LENGTH, Easing.InOutQuart); } diff --git a/osu.Game/Screens/BackgroundScreenStack.cs b/osu.Game/Screens/BackgroundScreenStack.cs index 99ca383b9f..55cd270581 100644 --- a/osu.Game/Screens/BackgroundScreenStack.cs +++ b/osu.Game/Screens/BackgroundScreenStack.cs @@ -27,10 +27,14 @@ namespace osu.Game.Screens if (screen == null) return false; - if (EqualityComparer.Default.Equals((BackgroundScreen)CurrentScreen, screen)) + bool isFirstScreen = CurrentScreen == null; + screen.AnimateEntry = !isFirstScreen; + + if (EqualityComparer.Default.Equals((BackgroundScreen?)CurrentScreen, screen)) return false; base.Push(screen); + return true; } } diff --git a/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs b/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs index a552b22c11..7be96718bd 100644 --- a/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs +++ b/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs @@ -42,11 +42,6 @@ namespace osu.Game.Screens.Backgrounds protected virtual bool AllowStoryboardBackground => true; - public BackgroundScreenDefault(bool animateOnEnter = true) - : base(animateOnEnter) - { - } - [BackgroundDependencyLoader] private void load(IAPIProvider api, SkinManager skinManager, OsuConfigManager config) { @@ -56,10 +51,6 @@ namespace osu.Game.Screens.Backgrounds introSequence = config.GetBindable(OsuSetting.IntroSequence); AddInternal(seasonalBackgroundLoader); - - // Load first background asynchronously as part of BDL load. - currentDisplay = RNG.Next(0, background_count); - Next(); } protected override void LoadComplete() @@ -73,6 +64,9 @@ namespace osu.Game.Screens.Backgrounds introSequence.ValueChanged += _ => Scheduler.AddOnce(next); seasonalBackgroundLoader.SeasonalBackgroundChanged += () => Scheduler.AddOnce(next); + currentDisplay = RNG.Next(0, background_count); + Next(); + // helper function required for AddOnce usage. void next() => Next(); } diff --git a/osu.Game/Screens/Edit/BindableBeatDivisor.cs b/osu.Game/Screens/Edit/BindableBeatDivisor.cs index 4b0726658f..bd9c9bab9a 100644 --- a/osu.Game/Screens/Edit/BindableBeatDivisor.cs +++ b/osu.Game/Screens/Edit/BindableBeatDivisor.cs @@ -16,6 +16,9 @@ namespace osu.Game.Screens.Edit { public static readonly int[] PREDEFINED_DIVISORS = { 1, 2, 3, 4, 6, 8, 12, 16 }; + public const int MINIMUM_DIVISOR = 1; + public const int MAXIMUM_DIVISOR = 64; + public Bindable ValidDivisors { get; } = new Bindable(BeatDivisorPresetCollection.COMMON); public BindableBeatDivisor(int value = 1) @@ -30,8 +33,12 @@ namespace osu.Game.Screens.Edit /// /// The intended divisor. /// Forces changing the valid divisors to a known preset. - public void SetArbitraryDivisor(int divisor, bool preferKnownPresets = false) + /// Whether the divisor was successfully set. + public bool SetArbitraryDivisor(int divisor, bool preferKnownPresets = false) { + if (divisor < MINIMUM_DIVISOR || divisor > MAXIMUM_DIVISOR) + return false; + // If the current valid divisor range doesn't contain the proposed value, attempt to find one which does. if (preferKnownPresets || !ValidDivisors.Value.Presets.Contains(divisor)) { @@ -44,6 +51,7 @@ namespace osu.Game.Screens.Edit } Value = divisor; + return true; } private void updateBindableProperties() @@ -137,18 +145,18 @@ namespace osu.Game.Screens.Edit { case 1: case 2: - return new Vector2(0.6f, 0.9f); + return new Vector2(1, 0.9f); case 3: case 4: - return new Vector2(0.5f, 0.8f); + return new Vector2(0.8f, 0.8f); case 6: case 8: - return new Vector2(0.4f, 0.7f); + return new Vector2(0.8f, 0.7f); default: - return new Vector2(0.3f, 0.6f); + return new Vector2(0.8f, 0.6f); } } diff --git a/osu.Game/Screens/Edit/BottomBar.cs b/osu.Game/Screens/Edit/BottomBar.cs index bc7dfaab88..6af8217d41 100644 --- a/osu.Game/Screens/Edit/BottomBar.cs +++ b/osu.Game/Screens/Edit/BottomBar.cs @@ -1,15 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; -using osu.Framework.Graphics.Shapes; -using osu.Game.Overlays; +using osu.Framework.Testing; using osu.Game.Rulesets.Edit; using osu.Game.Screens.Edit.Components; using osu.Game.Screens.Edit.Components.Timelines.Summary; @@ -20,17 +18,20 @@ namespace osu.Game.Screens.Edit { internal partial class BottomBar : CompositeDrawable { - public TestGameplayButton TestGameplayButton { get; private set; } + public TestGameplayButton TestGameplayButton { get; private set; } = null!; + + private IBindable saveInProgress = null!; + private Bindable composerFocusMode = null!; [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider, Editor editor) + private void load(Editor editor) { Anchor = Anchor.BottomLeft; Origin = Anchor.BottomLeft; RelativeSizeAxes = Axes.X; - Height = 60; + Height = 50; Masking = true; EdgeEffect = new EdgeEffectParameters @@ -42,17 +43,12 @@ namespace osu.Game.Screens.Edit InternalChildren = new Drawable[] { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = colourProvider.Background4, - }, new GridContainer { RelativeSizeAxes = Axes.Both, ColumnDimensions = new[] { - new Dimension(GridSizeMode.Absolute, 170), + new Dimension(GridSizeMode.Absolute, 150), new Dimension(), new Dimension(GridSizeMode.Absolute, 220), new Dimension(GridSizeMode.Absolute, HitObjectComposer.TOOLBOX_CONTRACTED_SIZE_RIGHT), @@ -74,6 +70,27 @@ namespace osu.Game.Screens.Edit } } }; + + saveInProgress = editor.MutationTracker.InProgress.GetBoundCopy(); + composerFocusMode = editor.ComposerFocusMode.GetBoundCopy(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + saveInProgress.BindValueChanged(_ => TestGameplayButton.Enabled.Value = !saveInProgress.Value, true); + composerFocusMode.BindValueChanged(_ => + { + // Transforms should be kept in sync with other usages of composer focus mode. + foreach (var c in this.ChildrenOfType()) + { + if (!composerFocusMode.Value) + c.Background.FadeIn(750, Easing.OutQuint); + else + c.Background.Delay(600).FadeTo(0.5f, 4000, Easing.OutQuint); + } + }, true); } } } diff --git a/osu.Game/Screens/Edit/Components/BottomBarContainer.cs b/osu.Game/Screens/Edit/Components/BottomBarContainer.cs index 0ba1ab9258..da71457004 100644 --- a/osu.Game/Screens/Edit/Components/BottomBarContainer.cs +++ b/osu.Game/Screens/Edit/Components/BottomBarContainer.cs @@ -20,7 +20,7 @@ namespace osu.Game.Screens.Edit.Components protected readonly IBindable Track = new Bindable(); - protected readonly Drawable Background; + public readonly Drawable Background; private readonly Container content; protected override Container Content => content; diff --git a/osu.Game/Screens/Edit/Components/EditorSidebarSection.cs b/osu.Game/Screens/Edit/Components/EditorSidebarSection.cs index 279793c0a1..5dd8f78f6d 100644 --- a/osu.Game/Screens/Edit/Components/EditorSidebarSection.cs +++ b/osu.Game/Screens/Edit/Components/EditorSidebarSection.cs @@ -1,15 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; using osu.Framework.Localisation; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Overlays; -using osuTK; +using osu.Game.Graphics.UserInterface; namespace osu.Game.Screens.Edit.Components { @@ -38,46 +33,5 @@ namespace osu.Game.Screens.Edit.Components } }; } - - public partial class SectionHeader : CompositeDrawable - { - private readonly LocalisableString text; - - public SectionHeader(LocalisableString text) - { - this.text = text; - - Margin = new MarginPadding { Vertical = 10, Horizontal = 5 }; - - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - } - - [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) - { - InternalChild = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Spacing = new Vector2(2), - Children = new Drawable[] - { - new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: 16, weight: FontWeight.SemiBold)) - { - Text = text, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - }, - new Circle - { - Colour = colourProvider.Highlight1, - Size = new Vector2(28, 2), - } - } - }; - } - } } } diff --git a/osu.Game/Screens/Edit/Components/Menus/EditorMenuBar.cs b/osu.Game/Screens/Edit/Components/Menus/EditorMenuBar.cs index 0e125d0ec0..47a13dcfba 100644 --- a/osu.Game/Screens/Edit/Components/Menus/EditorMenuBar.cs +++ b/osu.Game/Screens/Edit/Components/Menus/EditorMenuBar.cs @@ -7,7 +7,10 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; using osuTK; @@ -71,12 +74,18 @@ namespace osu.Game.Screens.Edit.Components.Menus }); } - protected override Framework.Graphics.UserInterface.Menu CreateSubMenu() => new SubMenu(); + protected override Framework.Graphics.UserInterface.Menu CreateSubMenu() => new SubMenu + { + MaxHeight = MaxHeight, + }; protected override DrawableMenuItem CreateDrawableMenuItem(MenuItem item) => new DrawableEditorBarMenuItem(item); - private partial class DrawableEditorBarMenuItem : DrawableOsuMenuItem + internal partial class DrawableEditorBarMenuItem : DrawableMenuItem { + private HoverClickSounds hoverClickSounds = null!; + private TextContainer text = null!; + public DrawableEditorBarMenuItem(MenuItem item) : base(item) { @@ -89,6 +98,8 @@ namespace osu.Game.Screens.Edit.Components.Menus BackgroundColour = colourProvider.Background2; ForegroundColourHover = colourProvider.Content1; BackgroundColourHover = colourProvider.Background1; + + AddInternal(hoverClickSounds = new HoverClickSounds()); } protected override void LoadComplete() @@ -97,6 +108,36 @@ namespace osu.Game.Screens.Edit.Components.Menus Foreground.Anchor = Anchor.CentreLeft; Foreground.Origin = Anchor.CentreLeft; + Item.Action.BindDisabledChanged(_ => updateState(), true); + } + + protected override bool OnHover(HoverEvent e) + { + updateState(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateState(); + base.OnHoverLost(e); + } + + private void updateState() + { + hoverClickSounds.Enabled.Value = IsActionable; + Alpha = IsActionable ? 1 : 0.2f; + + if (IsHovered && IsActionable) + { + text.BoldText.FadeIn(DrawableOsuMenuItem.TRANSITION_LENGTH, Easing.OutQuint); + text.NormalText.FadeOut(DrawableOsuMenuItem.TRANSITION_LENGTH, Easing.OutQuint); + } + else + { + text.BoldText.FadeOut(DrawableOsuMenuItem.TRANSITION_LENGTH, Easing.OutQuint); + text.NormalText.FadeIn(DrawableOsuMenuItem.TRANSITION_LENGTH, Easing.OutQuint); + } } protected override void UpdateBackgroundColour() @@ -115,16 +156,56 @@ namespace osu.Game.Screens.Edit.Components.Menus base.UpdateForegroundColour(); } - protected override DrawableOsuMenuItem.TextContainer CreateTextContainer() => new TextContainer(); + protected sealed override Drawable CreateContent() => text = new TextContainer(); + } - private new partial class TextContainer : DrawableOsuMenuItem.TextContainer + private partial class TextContainer : Container, IHasText + { + public LocalisableString Text { - public TextContainer() + get => NormalText.Text; + set { - NormalText.Font = OsuFont.TorusAlternate; - BoldText.Font = OsuFont.TorusAlternate.With(weight: FontWeight.Bold); + NormalText.Text = value; + BoldText.Text = value; } } + + public readonly SpriteText NormalText; + public readonly SpriteText BoldText; + + public TextContainer() + { + AutoSizeAxes = Axes.Both; + + Child = new Container + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + + AutoSizeAxes = Axes.Both, + Padding = new MarginPadding { Horizontal = 17, Vertical = DrawableOsuMenuItem.MARGIN_VERTICAL, }, + + Children = new Drawable[] + { + NormalText = new OsuSpriteText + { + AlwaysPresent = true, // ensures that the menu item does not change width when switching between normal and bold text. + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: DrawableOsuMenuItem.TEXT_SIZE), + }, + BoldText = new OsuSpriteText + { + AlwaysPresent = true, // ensures that the menu item does not change width when switching between normal and bold text. + Alpha = 0, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: DrawableOsuMenuItem.TEXT_SIZE, weight: FontWeight.Bold), + } + } + }; + } } private partial class SubMenu : OsuMenu @@ -143,7 +224,10 @@ namespace osu.Game.Screens.Edit.Components.Menus BackgroundColour = colourProvider.Background2; } - protected override Framework.Graphics.UserInterface.Menu CreateSubMenu() => new SubMenu(); + protected override Framework.Graphics.UserInterface.Menu CreateSubMenu() => new SubMenu + { + MaxHeight = MaxHeight, + }; protected override DrawableMenuItem CreateDrawableMenuItem(MenuItem item) { diff --git a/osu.Game/Screens/Edit/Components/PlaybackControl.cs b/osu.Game/Screens/Edit/Components/PlaybackControl.cs index a5ed0d680f..9fe6160ab4 100644 --- a/osu.Game/Screens/Edit/Components/PlaybackControl.cs +++ b/osu.Game/Screens/Edit/Components/PlaybackControl.cs @@ -10,9 +10,11 @@ using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -25,51 +27,85 @@ namespace osu.Game.Screens.Edit.Components public partial class PlaybackControl : BottomBarContainer { private IconButton playButton = null!; + private PlaybackSpeedControl playbackSpeedControl = null!; [Resolved] private EditorClock editorClock { get; set; } = null!; - private readonly BindableNumber freqAdjust = new BindableDouble(1); + private readonly Bindable currentScreenMode = new Bindable(); + private readonly BindableNumber tempoAdjustment = new BindableDouble(1); [BackgroundDependencyLoader] - private void load() + private void load(OverlayColourProvider colourProvider, Editor? editor) { + Background.Colour = colourProvider.Background4; + Children = new Drawable[] { playButton = new IconButton { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Scale = new Vector2(1.4f), - IconScale = new Vector2(1.4f), + Scale = new Vector2(1.2f), + IconScale = new Vector2(1.2f), Icon = FontAwesome.Regular.PlayCircle, Action = togglePause, }, - new OsuSpriteText + playbackSpeedControl = new PlaybackSpeedControl { - Origin = Anchor.BottomLeft, - Text = EditorStrings.PlaybackSpeed, - RelativePositionAxes = Axes.Y, - Y = 0.5f, - Padding = new MarginPadding { Left = 45 } - }, - new Container - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.Both, - Height = 0.5f, - Padding = new MarginPadding { Left = 45 }, - Child = new PlaybackTabControl { Current = freqAdjust }, + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Padding = new MarginPadding { Left = 45, }, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new OsuSpriteText + { + Text = EditorStrings.PlaybackSpeed, + }, + new PlaybackTabControl + { + Current = tempoAdjustment, + RelativeSizeAxes = Axes.X, + Height = 16, + }, + } } }; - Track.BindValueChanged(tr => tr.NewValue?.AddAdjustment(AdjustableProperty.Frequency, freqAdjust), true); + Track.BindValueChanged(tr => tr.NewValue?.AddAdjustment(AdjustableProperty.Tempo, tempoAdjustment), true); + + if (editor != null) + currentScreenMode.BindTo(editor.Mode); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + currentScreenMode.BindValueChanged(_ => + { + if (currentScreenMode.Value == EditorScreenMode.Timing) + { + tempoAdjustment.Value = 1; + tempoAdjustment.Disabled = true; + playbackSpeedControl.FadeTo(0.5f, 400, Easing.OutQuint); + playbackSpeedControl.TooltipText = "Speed adjustment is unavailable in timing mode. Timing at slower speeds is inaccurate due to resampling artifacts."; + } + else + { + tempoAdjustment.Disabled = false; + playbackSpeedControl.FadeTo(1, 400, Easing.OutQuint); + playbackSpeedControl.TooltipText = default; + } + }); } protected override void Dispose(bool isDisposing) { - Track.Value?.RemoveAdjustment(AdjustableProperty.Frequency, freqAdjust); + Track.Value?.RemoveAdjustment(AdjustableProperty.Tempo, tempoAdjustment); base.Dispose(isDisposing); } @@ -107,6 +143,11 @@ namespace osu.Game.Screens.Edit.Components playButton.Icon = editorClock.IsRunning ? pause_icon : play_icon; } + private partial class PlaybackSpeedControl : FillFlowContainer, IHasTooltip + { + public LocalisableString TooltipText { get; set; } + } + private partial class PlaybackTabControl : OsuTabControl { private static readonly double[] tempo_values = { 0.25, 0.5, 0.75, 1 }; @@ -172,7 +213,7 @@ namespace osu.Game.Screens.Edit.Components protected override bool OnHover(HoverEvent e) { updateState(); - return true; + return false; } protected override void OnHoverLost(HoverLostEvent e) => updateState(); diff --git a/osu.Game/Screens/Edit/Components/RadioButtons/RadioButton.cs b/osu.Game/Screens/Edit/Components/RadioButtons/RadioButton.cs index f49fc6f6ab..26022aa746 100644 --- a/osu.Game/Screens/Edit/Components/RadioButtons/RadioButton.cs +++ b/osu.Game/Screens/Edit/Components/RadioButtons/RadioButton.cs @@ -24,11 +24,11 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons /// /// A function which creates a drawable icon to represent this item. If null, a sane default should be used. /// - public readonly Func? CreateIcon; + public readonly Func? CreateIcon; private readonly Action? action; - public RadioButton(string label, Action? action, Func? createIcon = null) + public RadioButton(string label, Action? action, Func? createIcon = null) { Label = label; CreateIcon = createIcon; diff --git a/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs b/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs index 95d5dd36d8..fcbc719f46 100644 --- a/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs +++ b/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs @@ -4,8 +4,10 @@ using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; @@ -14,14 +16,14 @@ using osuTK.Graphics; namespace osu.Game.Screens.Edit.Components.TernaryButtons { - public partial class DrawableTernaryButton : OsuButton + public partial class DrawableTernaryButton : OsuButton, IHasTooltip { private Color4 defaultBackgroundColour; private Color4 defaultIconColour; private Color4 selectedBackgroundColour; private Color4 selectedIconColour; - private Drawable icon = null!; + protected Drawable Icon { get; private set; } = null!; public readonly TernaryButton Button; @@ -43,7 +45,7 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons defaultIconColour = defaultBackgroundColour.Darken(0.5f); selectedIconColour = selectedBackgroundColour.Lighten(0.5f); - Add(icon = (Button.CreateIcon?.Invoke() ?? new Circle()).With(b => + Add(Icon = (Button.CreateIcon?.Invoke() ?? new Circle()).With(b => { b.Blending = BlendingParameters.Additive; b.Anchor = Anchor.CentreLeft; @@ -58,12 +60,16 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons base.LoadComplete(); Button.Bindable.BindValueChanged(_ => updateSelectionState(), true); + Button.Enabled.BindTo(Enabled); Action = onAction; } private void onAction() { + if (!Button.Enabled.Value) + return; + Button.Toggle(); } @@ -75,17 +81,17 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons switch (Button.Bindable.Value) { case TernaryState.Indeterminate: - icon.Colour = selectedIconColour.Darken(0.5f); + Icon.Colour = selectedIconColour.Darken(0.5f); BackgroundColour = selectedBackgroundColour.Darken(0.5f); break; case TernaryState.False: - icon.Colour = defaultIconColour; + Icon.Colour = defaultIconColour; BackgroundColour = defaultBackgroundColour; break; case TernaryState.True: - icon.Colour = selectedIconColour; + Icon.Colour = selectedIconColour; BackgroundColour = selectedBackgroundColour; break; } @@ -98,5 +104,7 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons Anchor = Anchor.CentreLeft, X = 40f }; + + public LocalisableString TooltipText => Button.Tooltip; } } diff --git a/osu.Game/Screens/Edit/Components/TernaryButtons/SampleBankTernaryButton.cs b/osu.Game/Screens/Edit/Components/TernaryButtons/SampleBankTernaryButton.cs new file mode 100644 index 0000000000..33eb2ac0b4 --- /dev/null +++ b/osu.Game/Screens/Edit/Components/TernaryButtons/SampleBankTernaryButton.cs @@ -0,0 +1,78 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Rulesets.Edit; + +namespace osu.Game.Screens.Edit.Components.TernaryButtons +{ + public partial class SampleBankTernaryButton : CompositeDrawable + { + public readonly TernaryButton NormalButton; + public readonly TernaryButton AdditionsButton; + + public SampleBankTernaryButton(TernaryButton normalButton, TernaryButton additionsButton) + { + NormalButton = normalButton; + AdditionsButton = additionsButton; + } + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Masking = true; + CornerRadius = 5; + + InternalChildren = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Width = 0.5f, + Padding = new MarginPadding { Right = 1 }, + Child = new InlineDrawableTernaryButton(NormalButton), + }, + new Container + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Width = 0.5f, + Padding = new MarginPadding { Left = 1 }, + Child = new InlineDrawableTernaryButton(AdditionsButton), + }, + }; + } + + private partial class InlineDrawableTernaryButton : DrawableTernaryButton + { + public InlineDrawableTernaryButton(TernaryButton button) + : base(button) + { + } + + [BackgroundDependencyLoader] + private void load() + { + Content.Masking = false; + Content.CornerRadius = 0; + Icon.X = 4.5f; + } + + protected override SpriteText CreateText() => new ExpandableSpriteText + { + Depth = -1, + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + X = 31f + }; + } + } +} diff --git a/osu.Game/Screens/Edit/Components/TernaryButtons/TernaryButton.cs b/osu.Game/Screens/Edit/Components/TernaryButtons/TernaryButton.cs index 0ff2aa83b5..b7aaf517f5 100644 --- a/osu.Game/Screens/Edit/Components/TernaryButtons/TernaryButton.cs +++ b/osu.Game/Screens/Edit/Components/TernaryButtons/TernaryButton.cs @@ -12,6 +12,8 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons { public readonly Bindable Bindable; + public readonly Bindable Enabled = new Bindable(true); + public readonly string Description; /// @@ -19,6 +21,8 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons /// public readonly Func? CreateIcon; + public string Tooltip { get; set; } = string.Empty; + public TernaryButton(Bindable bindable, string description, Func? createIcon = null) { Bindable = bindable; diff --git a/osu.Game/Screens/Edit/Components/TimeInfoContainer.cs b/osu.Game/Screens/Edit/Components/TimeInfoContainer.cs index 4747828bca..8f2a3d49ca 100644 --- a/osu.Game/Screens/Edit/Components/TimeInfoContainer.cs +++ b/osu.Game/Screens/Edit/Components/TimeInfoContainer.cs @@ -4,8 +4,12 @@ using osu.Framework.Graphics; using osu.Game.Graphics.Sprites; using osu.Framework.Allocation; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; using osu.Game.Extensions; using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; using osuTK; @@ -13,7 +17,6 @@ namespace osu.Game.Screens.Edit.Components { public partial class TimeInfoContainer : BottomBarContainer { - private OsuSpriteText trackTimer = null!; private OsuSpriteText bpm = null!; [Resolved] @@ -29,37 +32,23 @@ namespace osu.Game.Screens.Edit.Components Children = new Drawable[] { - trackTimer = new OsuSpriteText - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Spacing = new Vector2(-2, 0), - Font = OsuFont.Torus.With(size: 36, fixedWidth: true, weight: FontWeight.Light), - Y = -10, - }, + new TimestampControl(), bpm = new OsuSpriteText { Colour = colours.Orange1, Anchor = Anchor.CentreLeft, - Font = OsuFont.Torus.With(size: 18, weight: FontWeight.SemiBold), - Position = new Vector2(2, 5), + Font = OsuFont.Torus.With(size: 14, weight: FontWeight.SemiBold), + Position = new Vector2(2, 4), } }; } - private double? lastTime; private double? lastBPM; protected override void Update() { base.Update(); - if (lastTime != editorClock.CurrentTime) - { - lastTime = editorClock.CurrentTime; - trackTimer.Text = editorClock.CurrentTime.ToEditorFormattedString(); - } - double newBPM = editorBeatmap.ControlPointInfo.TimingPointAt(editorClock.CurrentTime).BPM; if (lastBPM != newBPM) @@ -68,5 +57,119 @@ namespace osu.Game.Screens.Edit.Components bpm.Text = @$"{newBPM:0} BPM"; } } + + private partial class TimestampControl : OsuClickableContainer + { + private Container hoverLayer = null!; + private OsuSpriteText trackTimer = null!; + private OsuTextBox inputTextBox = null!; + + [Resolved] + private Editor? editor { get; set; } + + [Resolved] + private EditorClock editorClock { get; set; } = null!; + + public TimestampControl() + : base(HoverSampleSet.Button) + { + } + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + + AddRangeInternal(new Drawable[] + { + hoverLayer = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding + { + Top = 4, + Bottom = 1, + Horizontal = -2 + }, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + CornerRadius = 5, + Masking = true, + Children = new Drawable[] + { + new Box { RelativeSizeAxes = Axes.Both, }, + } + }, + Alpha = 0, + }, + trackTimer = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Spacing = new Vector2(-2, 0), + Font = OsuFont.Torus.With(size: 32, fixedWidth: true, weight: FontWeight.Light), + }, + inputTextBox = new TimestampTextBox + { + Position = new Vector2(-2, 4), + Width = 128, + Height = 26, + Alpha = 0, + CommitOnFocusLost = true, + }, + }); + + Action = () => + { + trackTimer.Alpha = 0; + inputTextBox.Alpha = 1; + inputTextBox.Text = editorClock.CurrentTime.ToEditorFormattedString(); + Schedule(() => + { + GetContainingFocusManager()!.ChangeFocus(inputTextBox); + inputTextBox.SelectAll(); + }); + }; + + inputTextBox.Current.BindValueChanged(val => editor?.HandleTimestamp(val.NewValue)); + + inputTextBox.OnCommit += (_, __) => + { + trackTimer.Alpha = 1; + inputTextBox.Alpha = 0; + }; + } + + private double? lastTime; + private bool showingHoverLayer; + + protected override void Update() + { + base.Update(); + + if (lastTime != editorClock.CurrentTime) + { + lastTime = editorClock.CurrentTime; + trackTimer.Text = editorClock.CurrentTime.ToEditorFormattedString(); + } + + bool shouldShowHoverLayer = IsHovered && inputTextBox.Alpha == 0; + + if (shouldShowHoverLayer != showingHoverLayer) + { + hoverLayer.FadeTo(shouldShowHoverLayer ? 0.2f : 0, 400, Easing.OutQuint); + showingHoverLayer = shouldShowHoverLayer; + } + } + + private partial class TimestampTextBox : OsuTextBox + { + public TimestampTextBox() + { + TextContainer.Height = 0.8f; + } + } + } } } diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BookmarkPart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BookmarkPart.cs index 3102bf7c06..189cb4ba4a 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BookmarkPart.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BookmarkPart.cs @@ -2,6 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Localisation; +using osu.Game.Extensions; using osu.Game.Graphics; using osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations; @@ -19,7 +22,7 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts Add(new BookmarkVisualisation(bookmark)); } - private partial class BookmarkVisualisation : PointVisualisation + private partial class BookmarkVisualisation : PointVisualisation, IHasTooltip { public BookmarkVisualisation(double startTime) : base(startTime) @@ -28,6 +31,8 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts [BackgroundDependencyLoader] private void load(OsuColour colours) => Colour = colours.Blue; + + public LocalisableString TooltipText => $"{StartTime.ToEditorFormattedString()} bookmark"; } } } diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BreakPart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BreakPart.cs index e502dd951b..be3a7b7268 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BreakPart.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BreakPart.cs @@ -2,9 +2,15 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Pooling; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; using osu.Game.Beatmaps.Timing; +using osu.Game.Extensions; using osu.Game.Graphics; -using osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations; namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts { @@ -13,22 +19,62 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts /// public partial class BreakPart : TimelinePart { + private readonly BindableList breaks = new BindableList(); + + private DrawablePool pool = null!; + + [BackgroundDependencyLoader] + private void load() + { + AddInternal(pool = new DrawablePool(10)); + } + protected override void LoadBeatmap(EditorBeatmap beatmap) { base.LoadBeatmap(beatmap); - foreach (var breakPeriod in beatmap.Breaks) - Add(new BreakVisualisation(breakPeriod)); + + breaks.UnbindAll(); + breaks.BindTo(beatmap.Breaks); } - private partial class BreakVisualisation : DurationVisualisation + protected override void LoadComplete() { - public BreakVisualisation(BreakPeriod breakPeriod) - : base(breakPeriod.StartTime, breakPeriod.EndTime) + base.LoadComplete(); + + breaks.BindCollectionChanged((_, _) => { + Clear(disposeChildren: false); + foreach (var breakPeriod in breaks) + Add(pool.Get(v => v.BreakPeriod = breakPeriod)); + }, true); + } + + private partial class BreakVisualisation : PoolableDrawable, IHasTooltip + { + private BreakPeriod breakPeriod = null!; + + public BreakPeriod BreakPeriod + { + set + { + breakPeriod = value; + X = (float)value.StartTime; + Width = (float)value.Duration; + } } [BackgroundDependencyLoader] - private void load(OsuColour colours) => Colour = colours.GreyCarmineLight; + private void load(OsuColour colours) + { + RelativePositionAxes = Axes.X; + RelativeSizeAxes = Axes.Both; + + InternalChild = new Box { RelativeSizeAxes = Axes.Both }; + Colour = colours.Gray5; + Alpha = 0.4f; + } + + public LocalisableString TooltipText => $"{breakPeriod.StartTime.ToEditorFormattedString()} - {breakPeriod.EndTime.ToEditorFormattedString()} break time"; } } } diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/ControlPointVisualisation.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/ControlPointVisualisation.cs index 12620963e1..17c98003b0 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/ControlPointVisualisation.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/ControlPointVisualisation.cs @@ -2,23 +2,27 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Localisation; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Extensions; using osu.Game.Graphics; using osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations; namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts { - public partial class ControlPointVisualisation : PointVisualisation, IControlPointVisualisation + public partial class ControlPointVisualisation : PointVisualisation, IControlPointVisualisation, IHasTooltip { protected readonly ControlPoint Point; public ControlPointVisualisation(ControlPoint point) + : base(point.Time) { Point = point; - - Height = 0.25f; - Origin = Anchor.TopCentre; + Alpha = 0.5f; + Blending = BlendingParameters.Additive; } [BackgroundDependencyLoader] @@ -28,5 +32,22 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts } public bool IsVisuallyRedundant(ControlPoint other) => other.GetType() == Point.GetType(); + + public LocalisableString TooltipText + { + get + { + switch (Point) + { + case EffectControlPoint effect: + return $"{StartTime.ToEditorFormattedString()} effect [{effect.ScrollSpeed:N2}x scroll{(effect.KiaiMode ? " kiai" : "")}]"; + + case TimingControlPoint timing: + return $"{StartTime.ToEditorFormattedString()} timing [{timing.BPM:N2} bpm {timing.TimeSignature.GetDescription()}]"; + } + + return string.Empty; + } + } } } diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/EffectPointVisualisation.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/EffectPointVisualisation.cs deleted file mode 100644 index bf87470e01..0000000000 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/EffectPointVisualisation.cs +++ /dev/null @@ -1,109 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Graphics; -using osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations; - -namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts -{ - public partial class EffectPointVisualisation : CompositeDrawable, IControlPointVisualisation - { - private readonly EffectControlPoint effect; - private Bindable kiai = null!; - - [Resolved] - private EditorBeatmap beatmap { get; set; } = null!; - - [Resolved] - private OsuColour colours { get; set; } = null!; - - public EffectPointVisualisation(EffectControlPoint point) - { - RelativePositionAxes = Axes.Both; - RelativeSizeAxes = Axes.Y; - - effect = point; - } - - [BackgroundDependencyLoader] - private void load() - { - kiai = effect.KiaiModeBindable.GetBoundCopy(); - kiai.BindValueChanged(_ => refreshDisplay(), true); - } - - private EffectControlPoint? nextControlPoint; - - protected override void LoadComplete() - { - base.LoadComplete(); - - // Due to the limitations of ControlPointInfo, it's impossible to know via event flow when the next kiai point has changed. - // This is due to the fact that an EffectPoint can be added to an existing group. We would need to bind to ItemAdded on *every* - // future group to track this. - // - // I foresee this being a potential performance issue on beatmaps with many control points, so let's limit how often we check - // for changes. ControlPointInfo needs a refactor to make this flow better, but it should do for now. - Scheduler.AddDelayed(() => - { - EffectControlPoint? next = null; - - for (int i = 0; i < beatmap.ControlPointInfo.EffectPoints.Count; i++) - { - var point = beatmap.ControlPointInfo.EffectPoints[i]; - - if (point.Time > effect.Time) - { - next = point; - break; - } - } - - if (!ReferenceEquals(nextControlPoint, next)) - { - nextControlPoint = next; - refreshDisplay(); - } - }, 100, true); - } - - private void refreshDisplay() - { - ClearInternal(); - - AddInternal(new ControlPointVisualisation(effect)); - - if (!kiai.Value) - return; - - // handle kiai duration - // eventually this will be simpler when we have control points with durations. - if (nextControlPoint != null) - { - RelativeSizeAxes = Axes.Both; - Origin = Anchor.TopLeft; - - Width = (float)(nextControlPoint.Time - effect.Time); - - AddInternal(new PointVisualisation - { - RelativeSizeAxes = Axes.Both, - Origin = Anchor.TopLeft, - Width = 1, - Height = 0.25f, - Depth = float.MaxValue, - Colour = effect.GetRepresentingColour(colours).Darken(0.5f), - }); - } - } - - // kiai sections display duration, so are required to be visualised. - public bool IsVisuallyRedundant(ControlPoint other) => other is EffectControlPoint otherEffect && effect.KiaiMode == otherEffect.KiaiMode; - } -} diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/GroupVisualisation.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/GroupVisualisation.cs index b39365277f..0dd945805b 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/GroupVisualisation.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/GroupVisualisation.cs @@ -2,6 +2,7 @@ // 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.Graphics; using osu.Framework.Graphics.Containers; @@ -15,6 +16,8 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts private readonly IBindableList controlPoints = new BindableList(); + private bool showScrollSpeed; + public GroupVisualisation(ControlPointGroup group) { RelativePositionAxes = Axes.X; @@ -24,8 +27,13 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts Group = group; X = (float)group.Time; + } + + [BackgroundDependencyLoader] + private void load(EditorBeatmap beatmap) + { + showScrollSpeed = beatmap.BeatmapInfo.Ruleset.CreateInstance().EditorShowScrollSpeed; - // Run in constructor so IsRedundant calls can work correctly. controlPoints.BindTo(Group.ControlPoints); controlPoints.BindCollectionChanged((_, _) => { @@ -39,19 +47,23 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts switch (point) { case TimingControlPoint: - AddInternal(new ControlPointVisualisation(point) { Y = 0, }); + AddInternal(new ControlPointVisualisation(point) + { + // importantly, override the x position being set since we do that above. + X = 0, + Y = -0.4f, + }); break; - case DifficultyControlPoint: - AddInternal(new ControlPointVisualisation(point) { Y = 0.25f, }); - break; + case EffectControlPoint: + if (!showScrollSpeed) + return; - case SampleControlPoint: - AddInternal(new ControlPointVisualisation(point) { Y = 0.5f, }); - break; - - case EffectControlPoint effect: - AddInternal(new EffectPointVisualisation(effect) { Y = 0.75f }); + AddInternal(new ControlPointVisualisation(point) + { + // importantly, override the x position being set since we do that above. + X = 0, + }); break; } } diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/KiaiPart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/KiaiPart.cs new file mode 100644 index 0000000000..ee44df8598 --- /dev/null +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/KiaiPart.cs @@ -0,0 +1,123 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Pooling; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; +using osu.Game.Extensions; +using osu.Game.Graphics; + +namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts +{ + /// + /// The part of the timeline that displays kiai sections in the song. + /// + public partial class KiaiPart : TimelinePart + { + private DrawablePool pool = null!; + + [BackgroundDependencyLoader] + private void load() + { + AddInternal(pool = new DrawablePool(10)); + } + + protected override void LoadBeatmap(EditorBeatmap beatmap) + { + base.LoadBeatmap(beatmap); + EditorBeatmap.ControlPointInfo.ControlPointsChanged += updateParts; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + updateParts(); + } + + private void updateParts() => Scheduler.AddOnce(() => + { + Clear(disposeChildren: false); + + double? startTime = null; + + foreach (var effectPoint in EditorBeatmap.ControlPointInfo.EffectPoints) + { + if (startTime.HasValue) + { + if (effectPoint.KiaiMode) + continue; + + var section = new KiaiSection + { + StartTime = startTime.Value, + EndTime = effectPoint.Time + }; + + Add(pool.Get(v => v.Section = section)); + + startTime = null; + } + else + { + if (!effectPoint.KiaiMode) + continue; + + startTime = effectPoint.Time; + } + } + + // last effect point has kiai enabled, kiai should last until the end of the map + if (startTime.HasValue) + { + Add(pool.Get(v => v.Section = new KiaiSection + { + StartTime = startTime.Value, + EndTime = Content.RelativeChildSize.X + })); + } + }); + + private partial class KiaiVisualisation : PoolableDrawable, IHasTooltip + { + private KiaiSection section; + + public KiaiSection Section + { + set + { + section = value; + + X = (float)value.StartTime; + Width = (float)value.Duration; + } + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + RelativePositionAxes = Axes.X; + RelativeSizeAxes = Axes.Both; + Anchor = Anchor.CentreLeft; + Origin = Anchor.CentreLeft; + Height = 0.2f; + AddInternal(new FastCircle + { + RelativeSizeAxes = Axes.Both, + Colour = colours.Purple1 + }); + } + + public LocalisableString TooltipText => $"{section.StartTime.ToEditorFormattedString()} - {section.EndTime.ToEditorFormattedString()} kiai time"; + } + + private readonly struct KiaiSection + { + public double StartTime { get; init; } + public double EndTime { get; init; } + public double Duration => EndTime - StartTime; + } + } +} diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs index ff707407dd..21b3b38388 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs @@ -8,7 +8,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Framework.Threading; -using osu.Game.Graphics; +using osu.Game.Overlays; using osuTK; namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts @@ -73,8 +73,6 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts { public MarkerVisualisation() { - const float box_height = 4; - Anchor = Anchor.CentreLeft; Origin = Anchor.Centre; RelativePositionAxes = Axes.X; @@ -82,32 +80,18 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts AutoSizeAxes = Axes.X; InternalChildren = new Drawable[] { - new Box - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Size = new Vector2(14, box_height), - }, new Triangle { Anchor = Anchor.TopCentre, Origin = Anchor.BottomCentre, Scale = new Vector2(1, -1), Size = new Vector2(10, 5), - Y = box_height, }, new Triangle { Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, Size = new Vector2(10, 5), - Y = -box_height, - }, - new Box - { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - Size = new Vector2(14, box_height), }, new Box { @@ -121,7 +105,7 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts } [BackgroundDependencyLoader] - private void load(OsuColour colours) => Colour = colours.Red1; + private void load(OverlayColourProvider colours) => Colour = colours.Highlight1; } } } diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/PreviewTimePart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/PreviewTimePart.cs index c63bb7ac24..67bb1ef500 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/PreviewTimePart.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/PreviewTimePart.cs @@ -3,6 +3,9 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Localisation; +using osu.Game.Extensions; using osu.Game.Graphics; using osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations; @@ -27,15 +30,18 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts }, true); } - private partial class PreviewTimeVisualisation : PointVisualisation + private partial class PreviewTimeVisualisation : PointVisualisation, IHasTooltip { public PreviewTimeVisualisation(double time) : base(time) { + Alpha = 0.8f; } [BackgroundDependencyLoader] private void load(OsuColour colours) => Colour = colours.Green1; + + public LocalisableString TooltipText => $"{StartTime.ToEditorFormattedString()} preview time"; } } } diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/SummaryTimeline.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/SummaryTimeline.cs index 6199cefb57..c01481e840 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/SummaryTimeline.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/SummaryTimeline.cs @@ -23,29 +23,6 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary Children = new Drawable[] { - new MarkerPart { RelativeSizeAxes = Axes.Both }, - new ControlPointPart - { - Anchor = Anchor.Centre, - Origin = Anchor.BottomCentre, - RelativeSizeAxes = Axes.Both, - Y = -10, - Height = 0.35f - }, - new BookmarkPart - { - Anchor = Anchor.Centre, - Origin = Anchor.TopCentre, - RelativeSizeAxes = Axes.Both, - Height = 0.35f - }, - new PreviewTimePart - { - Anchor = Anchor.Centre, - Origin = Anchor.TopCentre, - RelativeSizeAxes = Axes.Both, - Height = 0.35f - }, new Container { Name = "centre line", @@ -75,13 +52,40 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary }, } }, + new PreviewTimePart + { + Anchor = Anchor.Centre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.Both, + Height = 0.4f, + }, new BreakPart { Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, - Height = 0.10f - } + }, + new KiaiPart + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + }, + new ControlPointPart + { + Anchor = Anchor.Centre, + Origin = Anchor.BottomCentre, + RelativeSizeAxes = Axes.Both, + Height = 0.4f + }, + new BookmarkPart + { + Anchor = Anchor.Centre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.Both, + Height = 0.4f + }, + new MarkerPart { RelativeSizeAxes = Axes.Both }, }; } } diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Visualisations/DurationVisualisation.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Visualisations/DurationVisualisation.cs deleted file mode 100644 index bfb50a05ea..0000000000 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Visualisations/DurationVisualisation.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Graphics; -using osu.Framework.Graphics.Shapes; - -namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations -{ - /// - /// Represents a spanning point on a timeline part. - /// - public partial class DurationVisualisation : Circle - { - protected DurationVisualisation(double startTime, double endTime) - { - RelativePositionAxes = Axes.X; - RelativeSizeAxes = Axes.Both; - - X = (float)startTime; - Width = (float)(endTime - startTime); - } - } -} diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Visualisations/PointVisualisation.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Visualisations/PointVisualisation.cs index 3f0c125ada..6c9af53964 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Visualisations/PointVisualisation.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Visualisations/PointVisualisation.cs @@ -9,17 +9,13 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations /// /// Represents a singular point on a timeline part. /// - public partial class PointVisualisation : Circle + public partial class PointVisualisation : FastCircle { + public readonly double StartTime; + public const float MAX_WIDTH = 4; public PointVisualisation(double startTime) - : this() - { - X = (float)startTime; - } - - public PointVisualisation() { RelativePositionAxes = Axes.Both; RelativeSizeAxes = Axes.Y; @@ -28,7 +24,10 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations Origin = Anchor.Centre; Width = MAX_WIDTH; - Height = 0.75f; + Height = 0.4f; + + X = (float)startTime; + StartTime = startTime; } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs b/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs index 40b97d2137..43a2abe4c4 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs @@ -150,7 +150,7 @@ namespace osu.Game.Screens.Edit.Compose.Components { new TextFlowContainer(s => s.Font = s.Font.With(size: 14)) { - Padding = new MarginPadding { Horizontal = 15, Vertical = 8 }, + Padding = new MarginPadding { Horizontal = 15, Vertical = 2 }, Text = "beat snap", RelativeSizeAxes = Axes.X, TextAnchor = Anchor.TopCentre, @@ -159,7 +159,7 @@ namespace osu.Game.Screens.Edit.Compose.Components }, RowDimensions = new[] { - new Dimension(GridSizeMode.Absolute, 30), + new Dimension(GridSizeMode.Absolute, 40), new Dimension(GridSizeMode.Absolute, 20), new Dimension(GridSizeMode.Absolute, 15) } @@ -330,14 +330,14 @@ namespace osu.Game.Screens.Edit.Compose.Components private void setPresetsFromTextBoxEntry() { - if (!int.TryParse(divisorTextBox.Text, out int divisor) || divisor < 1 || divisor > 64) + if (!int.TryParse(divisorTextBox.Text, out int divisor) || !BeatDivisor.SetArbitraryDivisor(divisor)) { + // the text either didn't parse as a divisor, or the divisor was not set due to being out of range. + // force a state update to reset the text box's value to the last sane value. updateState(); return; } - BeatDivisor.SetArbitraryDivisor(divisor); - this.HidePopover(); } @@ -526,7 +526,7 @@ namespace osu.Game.Screens.Edit.Compose.Components AlwaysDisplayed = alwaysDisplayed; Divisor = divisor; - Size = new Vector2(6f, 12) * BindableBeatDivisor.GetSize(divisor); + Size = new Vector2(4, 18) * BindableBeatDivisor.GetSize(divisor); Alpha = alwaysDisplayed ? 1 : 0; InternalChild = new Box { RelativeSizeAxes = Axes.Both }; @@ -580,7 +580,7 @@ namespace osu.Game.Screens.Edit.Compose.Components { base.LoadComplete(); - GetContainingInputManager().ChangeFocus(this); + GetContainingFocusManager()!.ChangeFocus(this); SelectAll(); } } diff --git a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs index c66be90605..e12574f7ee 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs @@ -32,7 +32,12 @@ namespace osu.Game.Screens.Edit.Compose.Components { protected DragBox DragBox { get; private set; } - public Container> SelectionBlueprints { get; private set; } + public SelectionBlueprintContainer SelectionBlueprints { get; private set; } + + public partial class SelectionBlueprintContainer : Container> + { + public new virtual void ChangeChildDepth(SelectionBlueprint child, float newDepth) => base.ChangeChildDepth(child, newDepth); + } public SelectionHandler SelectionHandler { get; private set; } @@ -95,7 +100,7 @@ namespace osu.Game.Screens.Edit.Compose.Components }); } - protected virtual Container> CreateSelectionBlueprintContainer() => new Container> { RelativeSizeAxes = Axes.Both }; + protected virtual SelectionBlueprintContainer CreateSelectionBlueprintContainer() => new SelectionBlueprintContainer { RelativeSizeAxes = Axes.Both }; /// /// Creates a which outlines items and handles movement of selections. @@ -196,6 +201,11 @@ namespace osu.Game.Screens.Edit.Compose.Components DragBox.HandleDrag(e); DragBox.Show(); + + selectionBeforeDrag.Clear(); + if (e.ControlPressed) + selectionBeforeDrag.UnionWith(SelectedItems); + return true; } @@ -217,6 +227,7 @@ namespace osu.Game.Screens.Edit.Compose.Components } DragBox.Hide(); + selectionBeforeDrag.Clear(); } protected override void Update() @@ -227,7 +238,7 @@ namespace osu.Game.Screens.Edit.Compose.Components { lastDragEvent.Target = this; DragBox.HandleDrag(lastDragEvent); - UpdateSelectionFromDragBox(); + UpdateSelectionFromDragBox(selectionBeforeDrag); } } @@ -426,7 +437,7 @@ namespace osu.Game.Screens.Edit.Compose.Components private bool endClickSelection(MouseButtonEvent e) { // If already handled a selection, double-click, or drag, we don't want to perform a mouse up / click action. - if (clickSelectionHandled || doubleClickHandled || isDraggingBlueprint) return true; + if (clickSelectionHandled || doubleClickHandled || isDraggingBlueprint || wasDragStarted) return true; if (e.Button != MouseButton.Left) return false; @@ -442,7 +453,7 @@ namespace osu.Game.Screens.Edit.Compose.Components return false; } - if (!wasDragStarted && selectedBlueprintAlreadySelectedOnMouseDown && SelectedItems.Count == 1) + if (selectedBlueprintAlreadySelectedOnMouseDown && SelectedItems.Count == 1) { // If a click occurred and was handled by the currently selected blueprint but didn't result in a drag, // cycle between other blueprints which are also under the cursor. @@ -472,7 +483,7 @@ namespace osu.Game.Screens.Edit.Compose.Components /// /// Select all blueprints in a selection area specified by . /// - protected virtual void UpdateSelectionFromDragBox() + protected virtual void UpdateSelectionFromDragBox(HashSet selectionBeforeDrag) { var quad = DragBox.Box.ScreenSpaceDrawQuad; @@ -482,7 +493,7 @@ namespace osu.Game.Screens.Edit.Compose.Components { case SelectionState.Selected: // Selection is preserved even after blueprint becomes dead. - if (!quad.Contains(blueprint.ScreenSpaceSelectionPoint)) + if (!quad.Contains(blueprint.ScreenSpaceSelectionPoint) && !selectionBeforeDrag.Contains(blueprint.Item)) blueprint.Deselect(); break; @@ -535,6 +546,8 @@ namespace osu.Game.Screens.Edit.Compose.Components /// private bool wasDragStarted; + private readonly HashSet selectionBeforeDrag = new HashSet(); + /// /// Attempts to begin the movement of any selected blueprints. /// diff --git a/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs index 92fe52148c..bd750dac76 100644 --- a/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs +++ b/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs @@ -59,7 +59,7 @@ namespace osu.Game.Screens.Edit.Compose.Components // Picture the scenario where the user has just placed an object on a 1/2 snap, then changes to // 1/3 snap and expects to be able to place the next object on a valid 1/3 snap, regardless of the // fact that the 1/2 snap reference object is not valid for 1/3 snapping. - float offset = SnapProvider.FindSnappedDistance(ReferenceObject, 0); + float offset = SnapProvider.FindSnappedDistance(ReferenceObject, 0, DistanceSnapTarget.End); for (int i = 0; i < requiredCircles; i++) { @@ -104,7 +104,7 @@ namespace osu.Game.Screens.Edit.Compose.Components ? SnapProvider.DurationToDistance(ReferenceObject, editorClock.CurrentTime - ReferenceObject.GetEndTime()) // When interacting with the resolved snap provider, the distance spacing multiplier should first be removed // to allow for snapping at a non-multiplied ratio. - : SnapProvider.FindSnappedDistance(ReferenceObject, travelLength / distanceSpacingMultiplier); + : SnapProvider.FindSnappedDistance(ReferenceObject, travelLength / distanceSpacingMultiplier, DistanceSnapTarget.End); double snappedTime = StartTime + SnapProvider.DistanceToDuration(ReferenceObject, snappedDistance); diff --git a/osu.Game/Screens/Edit/Compose/Components/CircularPositionSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/CircularPositionSnapGrid.cs new file mode 100644 index 0000000000..8e63d6bcc0 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/CircularPositionSnapGrid.cs @@ -0,0 +1,98 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Utils; +using osuTK; + +namespace osu.Game.Screens.Edit.Compose.Components +{ + public partial class CircularPositionSnapGrid : PositionSnapGrid + { + /// + /// The spacing between grid lines of this . + /// + public BindableFloat Spacing { get; } = new BindableFloat(1f) + { + MinValue = 0f, + }; + + public CircularPositionSnapGrid() + { + Spacing.BindValueChanged(_ => GridCache.Invalidate()); + } + + protected override void CreateContent() + { + var drawSize = DrawSize; + + // Calculate the required number of circles based on the maximum distance from the origin to the edge of the grid. + float dx = Math.Max(StartPosition.Value.X, DrawWidth - StartPosition.Value.X); + float dy = Math.Max(StartPosition.Value.Y, DrawHeight - StartPosition.Value.Y); + float maxDistance = new Vector2(dx, dy).Length; + // We need to add one because the first circle starts at zero radius. + int requiredCircles = (int)(maxDistance / Spacing.Value) + 1; + + generateCircles(requiredCircles); + GenerateOutline(drawSize); + } + + private void generateCircles(int count) + { + // Make lines the same width independent of display resolution. + float lineWidth = 2 * DrawWidth / ScreenSpaceDrawQuad.Width; + + List generatedCircles = new List(); + + for (int i = 0; i < count; i++) + { + // Add a minimum diameter so the center circle is clearly visible. + float diameter = MathF.Max(lineWidth * 1.5f, i * Spacing.Value * 2); + + var gridCircle = new CircularProgress + { + Position = StartPosition.Value, + Origin = Anchor.Centre, + Size = new Vector2(diameter), + InnerRadius = lineWidth * 1f / diameter, + Colour = Colour4.White, + Alpha = 0.2f, + Progress = 1, + }; + + generatedCircles.Add(gridCircle); + } + + if (generatedCircles.Count == 0) + return; + + generatedCircles.First().Alpha = 0.8f; + + AddInternal(new Container + { + Masking = true, + RelativeSizeAxes = Axes.Both, + Children = generatedCircles, + }); + } + + public override Vector2 GetSnappedPosition(Vector2 original) + { + Vector2 relativeToStart = original - StartPosition.Value; + + if (relativeToStart.LengthSquared < Precision.FLOAT_EPSILON) + return StartPosition.Value; + + float length = relativeToStart.Length; + float wantedLength = MathF.Round(length / Spacing.Value) * Spacing.Value; + + return StartPosition.Value + Vector2.Multiply(relativeToStart, wantedLength / length); + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs index 4fba798a26..0ffd1072cd 100644 --- a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs @@ -9,6 +9,7 @@ using Humanizer; 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.Sprites; @@ -40,6 +41,8 @@ namespace osu.Game.Screens.Edit.Compose.Components public PlacementBlueprint CurrentPlacement { get; private set; } + public HitObjectPlacementBlueprint CurrentHitObjectPlacement => CurrentPlacement as HitObjectPlacementBlueprint; + [Resolved(canBeNull: true)] private EditorScreenWithTimeline editorScreen { get; set; } @@ -62,7 +65,11 @@ namespace osu.Game.Screens.Edit.Compose.Components private void load() { MainTernaryStates = CreateTernaryButtons().ToArray(); - SampleBankTernaryStates = createSampleBankTernaryButtons().ToArray(); + SampleBankTernaryStates = createSampleBankTernaryButtons(SelectionHandler.SelectionBankStates).ToArray(); + SampleAdditionBankTernaryStates = createSampleBankTernaryButtons(SelectionHandler.SelectionAdditionBankStates).ToArray(); + + SelectionHandler.AutoSelectionBankEnabled.BindValueChanged(_ => updateAutoBankTernaryButtonTooltip(), true); + SelectionHandler.SelectionAdditionBanksEnabled.BindValueChanged(_ => updateAdditionBankTernaryButtonTooltips(), true); AddInternal(new DrawableRulesetDependenciesProvidingContainer(Composer.Ruleset) { @@ -88,6 +95,9 @@ namespace osu.Game.Screens.Edit.Compose.Components foreach (var kvp in SelectionHandler.SelectionBankStates) kvp.Value.BindValueChanged(_ => updatePlacementSamples()); + + foreach (var kvp in SelectionHandler.SelectionAdditionBankStates) + kvp.Value.BindValueChanged(_ => updatePlacementSamples()); } protected override void TransferBlueprintFor(HitObject hitObject, DrawableHitObject drawableObject) @@ -163,26 +173,29 @@ namespace osu.Game.Screens.Edit.Compose.Components private void updatePlacementNewCombo() { - if (CurrentPlacement?.HitObject is IHasComboInformation c) + if (CurrentHitObjectPlacement?.HitObject is IHasComboInformation c) c.NewCombo = NewCombo.Value == TernaryState.True; } private void updatePlacementSamples() { - if (CurrentPlacement == null) return; + if (CurrentHitObjectPlacement == null) return; foreach (var kvp in SelectionHandler.SelectionSampleStates) sampleChanged(kvp.Key, kvp.Value.Value); foreach (var kvp in SelectionHandler.SelectionBankStates) bankChanged(kvp.Key, kvp.Value.Value); + + foreach (var kvp in SelectionHandler.SelectionAdditionBankStates) + additionBankChanged(kvp.Key, kvp.Value.Value); } private void sampleChanged(string sampleName, TernaryState state) { - if (CurrentPlacement == null) return; + if (CurrentHitObjectPlacement == null) return; - var samples = CurrentPlacement.HitObject.Samples; + var samples = CurrentHitObjectPlacement.HitObject.Samples; var existingSample = samples.FirstOrDefault(s => s.Name == sampleName); @@ -195,19 +208,29 @@ namespace osu.Game.Screens.Edit.Compose.Components case TernaryState.True: if (existingSample == null) - samples.Add(CurrentPlacement.HitObject.CreateHitSampleInfo(sampleName)); + samples.Add(CurrentHitObjectPlacement.HitObject.CreateHitSampleInfo(sampleName)); break; } } private void bankChanged(string bankName, TernaryState state) { - if (CurrentPlacement == null) return; + if (CurrentHitObjectPlacement == null) return; if (bankName == EditorSelectionHandler.HIT_BANK_AUTO) - CurrentPlacement.AutomaticBankAssignment = state == TernaryState.True; + CurrentHitObjectPlacement.AutomaticBankAssignment = state == TernaryState.True; else if (state == TernaryState.True) - CurrentPlacement.HitObject.Samples = CurrentPlacement.HitObject.Samples.Select(s => s.With(newBank: bankName)).ToList(); + CurrentHitObjectPlacement.HitObject.Samples = CurrentHitObjectPlacement.HitObject.Samples.Select(s => s.Name == HitSampleInfo.HIT_NORMAL ? s.With(newBank: bankName) : s).ToList(); + } + + private void additionBankChanged(string bankName, TernaryState state) + { + if (CurrentHitObjectPlacement == null) return; + + if (bankName == EditorSelectionHandler.HIT_BANK_AUTO) + CurrentHitObjectPlacement.AutomaticAdditionBankAssignment = state == TernaryState.True; + else if (state == TernaryState.True) + CurrentHitObjectPlacement.HitObject.Samples = CurrentHitObjectPlacement.HitObject.Samples.Select(s => s.Name != HitSampleInfo.HIT_NORMAL ? s.With(newBank: bankName) : s).ToList(); } public readonly Bindable NewCombo = new Bindable { Description = "New Combo" }; @@ -219,6 +242,8 @@ namespace osu.Game.Screens.Edit.Compose.Components public TernaryButton[] SampleBankTernaryStates { get; private set; } + public TernaryButton[] SampleAdditionBankTernaryStates { get; private set; } + /// /// Create all ternary states required to be displayed to the user. /// @@ -228,43 +253,28 @@ namespace osu.Game.Screens.Edit.Compose.Components yield return new TernaryButton(NewCombo, "New combo", () => new SpriteIcon { Icon = OsuIcon.EditorNewComboA }); foreach (var kvp in SelectionHandler.SelectionSampleStates) - yield return new TernaryButton(kvp.Value, kvp.Key.Replace("hit", string.Empty).Titleize(), () => getIconForSample(kvp.Key)); + yield return new TernaryButton(kvp.Value, kvp.Key.Replace("hit", string.Empty).Titleize(), () => GetIconForSample(kvp.Key)); } - private IEnumerable createSampleBankTernaryButtons() + private IEnumerable createSampleBankTernaryButtons(Dictionary> sampleBankStates) { - foreach (var kvp in SelectionHandler.SelectionBankStates) + foreach (var kvp in sampleBankStates) yield return new TernaryButton(kvp.Value, kvp.Key.Titleize(), () => getIconForBank(kvp.Key)); } private Drawable getIconForBank(string sampleName) { - return new Container + return new OsuSpriteText { - Size = new Vector2(30, 20), - Children = new Drawable[] - { - new SpriteIcon - { - Size = new Vector2(8), - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Icon = FontAwesome.Solid.VolumeOff - }, - new OsuSpriteText - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - X = 10, - Y = -1, - Font = OsuFont.Default.With(weight: FontWeight.Bold, size: 20), - Text = $"{char.ToUpperInvariant(sampleName.First())}" - } - } + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Y = -1, + Font = OsuFont.Default.With(weight: FontWeight.Bold, size: 20), + Text = $"{char.ToUpperInvariant(sampleName.First())}" }; } - private Drawable getIconForSample(string sampleName) + public static Drawable GetIconForSample(string sampleName) { switch (sampleName) { @@ -281,20 +291,43 @@ namespace osu.Game.Screens.Edit.Compose.Components return null; } + private void updateAutoBankTernaryButtonTooltip() + { + bool enabled = SelectionHandler.AutoSelectionBankEnabled.Value; + + var autoBankButton = SampleBankTernaryStates.Single(t => t.Bindable == SelectionHandler.SelectionBankStates[EditorSelectionHandler.HIT_BANK_AUTO]); + autoBankButton.Enabled.Value = enabled; + autoBankButton.Tooltip = !enabled ? "Auto normal bank can only be used during hit object placement" : string.Empty; + } + + private void updateAdditionBankTernaryButtonTooltips() + { + bool enabled = SelectionHandler.SelectionAdditionBanksEnabled.Value; + + foreach (var ternaryButton in SampleAdditionBankTernaryStates) + { + ternaryButton.Enabled.Value = enabled; + ternaryButton.Tooltip = !enabled ? "Add an addition sample first to be able to set a bank" : string.Empty; + } + } + #region Placement /// /// Refreshes the current placement tool. /// - private void refreshTool() + private void refreshPlacement() { - removePlacement(); + CurrentPlacement?.EndPlacement(false); + CurrentPlacement?.Expire(); + CurrentPlacement = null; + ensurePlacementCreated(); } - private void updatePlacementPosition() + private void updatePlacementTimeAndPosition() { - var snapResult = Composer.FindSnappedPositionAndTime(InputManager.CurrentState.Mouse.Position); + var snapResult = Composer.FindSnappedPositionAndTime(InputManager.CurrentState.Mouse.Position, CurrentPlacement.SnapType); // if no time was found from positional snapping, we should still quantize to the beat. snapResult.Time ??= Beatmap.SnapTime(EditorClock.CurrentTime, null); @@ -314,20 +347,33 @@ namespace osu.Game.Screens.Edit.Compose.Components { case PlacementBlueprint.PlacementState.Waiting: if (!Composer.CursorInPlacementArea) - removePlacement(); + CurrentPlacement.Hide(); + else + CurrentPlacement.Show(); + + break; + + case PlacementBlueprint.PlacementState.Active: + CurrentPlacement.Show(); break; case PlacementBlueprint.PlacementState.Finished: - removePlacement(); + refreshPlacement(); break; } + + // updates the placement with the latest editor clock time. + updatePlacementTimeAndPosition(); } + } - if (Composer.CursorInPlacementArea) - ensurePlacementCreated(); - + protected override bool OnMouseMove(MouseMoveEvent e) + { + // updates the placement with the latest mouse position. if (CurrentPlacement != null) - updatePlacementPosition(); + updatePlacementTimeAndPosition(); + + return base.OnMouseMove(e); } protected sealed override SelectionBlueprint CreateBlueprintFor(HitObject item) @@ -346,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) @@ -364,7 +410,7 @@ namespace osu.Game.Screens.Edit.Compose.Components placementBlueprintContainer.Child = CurrentPlacement = blueprint; // Fixes a 1-frame position discrepancy due to the first mouse move event happening in the next frame - updatePlacementPosition(); + updatePlacementTimeAndPosition(); updatePlacementSamples(); @@ -372,25 +418,18 @@ namespace osu.Game.Screens.Edit.Compose.Components } } - private void commitIfPlacementActive() + public void CommitIfPlacementActive() { CurrentPlacement?.EndPlacement(CurrentPlacement.PlacementActive == PlacementBlueprint.PlacementState.Active); - removePlacement(); + refreshPlacement(); } - private void removePlacement() - { - CurrentPlacement?.EndPlacement(false); - CurrentPlacement?.Expire(); - CurrentPlacement = null; - } - - private HitObjectCompositionTool currentTool; + private CompositionTool currentTool; /// /// The current placement tool. /// - public HitObjectCompositionTool CurrentTool + public CompositionTool CurrentTool { get => currentTool; @@ -402,8 +441,16 @@ namespace osu.Game.Screens.Edit.Compose.Components currentTool = value; // As per stable editor, when changing tools, we should forcefully commit any pending placement. - commitIfPlacementActive(); + CommitIfPlacementActive(); } } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (Beatmap.IsNotNull()) + Beatmap.HitObjectAdded -= hitObjectAdded; + } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs index 8aa2fa9f45..7003d632ca 100644 --- a/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs +++ b/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs @@ -155,7 +155,7 @@ namespace osu.Game.Screens.Edit.Compose.Components { var timingPoint = Beatmap.ControlPointInfo.TimingPointAt(StartTime); double beatLength = timingPoint.BeatLength / beatDivisor.Value; - int beatIndex = (int)Math.Round((StartTime - timingPoint.Time) / beatLength); + int beatIndex = (int)Math.Floor((StartTime - timingPoint.Time) / beatLength); var colour = BindableBeatDivisor.GetColourFor(BindableBeatDivisor.GetDivisorForBeatIndex(beatIndex + placementIndex + 1, beatDivisor.Value), Colours); diff --git a/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs index 378d378be3..7b046251e0 100644 --- a/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs @@ -9,7 +9,6 @@ using System.Collections.Specialized; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Game.Rulesets.Edit; @@ -136,7 +135,7 @@ namespace osu.Game.Screens.Edit.Compose.Components base.ApplySelectionOrder(blueprints) .OrderBy(b => Math.Min(Math.Abs(EditorClock.CurrentTime - b.Item.GetEndTime()), Math.Abs(EditorClock.CurrentTime - b.Item.StartTime))); - protected override Container> CreateSelectionBlueprintContainer() => new HitObjectOrderedSelectionContainer { RelativeSizeAxes = Axes.Both }; + protected override SelectionBlueprintContainer CreateSelectionBlueprintContainer() => new HitObjectOrderedSelectionContainer { RelativeSizeAxes = Axes.Both }; protected override SelectionHandler CreateSelectionHandler() => new EditorSelectionHandler(); diff --git a/osu.Game/Screens/Edit/Compose/Components/EditorInspector.cs b/osu.Game/Screens/Edit/Compose/Components/EditorInspector.cs index 442454f97a..5837dd7946 100644 --- a/osu.Game/Screens/Edit/Compose/Components/EditorInspector.cs +++ b/osu.Game/Screens/Edit/Compose/Components/EditorInspector.cs @@ -10,7 +10,7 @@ using osu.Game.Overlays; namespace osu.Game.Screens.Edit.Compose.Components { - internal partial class EditorInspector : CompositeDrawable + public partial class EditorInspector : CompositeDrawable { protected OsuTextFlowContainer InspectorText = null!; diff --git a/osu.Game/Screens/Edit/Compose/Components/EditorOrigin.cs b/osu.Game/Screens/Edit/Compose/Components/EditorOrigin.cs new file mode 100644 index 0000000000..4aa7bf68d7 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/EditorOrigin.cs @@ -0,0 +1,12 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Screens.Edit.Compose.Components +{ + public enum EditorOrigin + { + GridCentre, + PlayfieldCentre, + SelectionCentre + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs index a73278a61e..a9dbfc29a9 100644 --- a/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs @@ -3,13 +3,14 @@ using System; using System.Collections.Generic; +using System.Collections.Specialized; using System.Linq; using Humanizer; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Bindings; using osu.Game.Audio; -using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; @@ -36,7 +37,7 @@ namespace osu.Game.Screens.Edit.Compose.Components // bring in updates from selection changes EditorBeatmap.HitObjectUpdated += _ => Scheduler.AddOnce(UpdateTernaryStates); - SelectedItems.CollectionChanged += (_, _) => Scheduler.AddOnce(UpdateTernaryStates); + SelectedItems.CollectionChanged += onSelectedItemsChanged; } protected override void DeleteItems(IEnumerable items) => EditorBeatmap.RemoveRange(items); @@ -58,6 +59,21 @@ namespace osu.Game.Screens.Edit.Compose.Components /// public readonly Dictionary> SelectionBankStates = new Dictionary>(); + /// + /// The state of each sample addition bank type for all selected hitobjects. + /// + public readonly Dictionary> SelectionAdditionBankStates = new Dictionary>(); + + /// + /// Whether there is no selection and the auto can be used. + /// + public readonly Bindable AutoSelectionBankEnabled = new Bindable(); + + /// + /// Whether the selection contains any addition samples and the can be used. + /// + public readonly Bindable SelectionAdditionBanksEnabled = new Bindable(); + /// /// Set up ternary state bindables and bind them to selection/hitobject changes (in both directions) /// @@ -90,7 +106,7 @@ namespace osu.Game.Screens.Edit.Compose.Components // Never remove a sample bank. // These are basically radio buttons, not toggles. - if (SelectedItems.All(h => h.Samples.All(s => s.Bank == bankName))) + if (SelectedItems.All(h => h.Samples.Where(o => o.Name == HitSampleInfo.HIT_NORMAL).All(s => s.Bank == bankName))) bindable.Value = TernaryState.True; } @@ -117,7 +133,7 @@ namespace osu.Game.Screens.Edit.Compose.Components break; } - AddSampleBank(bankName); + SetSampleBank(bankName); } break; @@ -127,8 +143,78 @@ namespace osu.Game.Screens.Edit.Compose.Components SelectionBankStates[bankName] = bindable; } - // start with normal selected. - SelectionBankStates[SampleControlPoint.DEFAULT_BANK].Value = TernaryState.True; + foreach (string bankName in HitSampleInfo.AllBanks.Prepend(HIT_BANK_AUTO)) + { + var bindable = new Bindable + { + Description = bankName.Titleize() + }; + + bindable.ValueChanged += state => + { + switch (state.NewValue) + { + case TernaryState.False: + if (SelectedItems.Count == 0) + { + // Ensure that if this is the last selected bank, it should remain selected. + if (SelectionAdditionBankStates.Values.All(b => b.Value == TernaryState.False)) + bindable.Value = TernaryState.True; + } + else + { + // Completely empty selections should be allowed in the case that none of the selected objects have any addition samples. + // This is also required to stop a bindable feedback loop when a HitObject has zero addition samples (and LINQ `All` below becomes true). + if (SelectedItems.SelectMany(enumerateAllSamples).All(h => h.All(o => o.Name == HitSampleInfo.HIT_NORMAL))) + break; + + // Never remove a sample bank. + // These are basically radio buttons, not toggles. + if (bankName == HIT_BANK_AUTO) + { + if (SelectedItems.SelectMany(enumerateAllSamples).All(h => h.Where(o => o.Name != HitSampleInfo.HIT_NORMAL).All(s => s.EditorAutoBank))) + bindable.Value = TernaryState.True; + } + else + { + if (SelectedItems.SelectMany(enumerateAllSamples).All(h => h.Where(o => o.Name != HitSampleInfo.HIT_NORMAL).All(s => s.Bank == bankName && !s.EditorAutoBank))) + bindable.Value = TernaryState.True; + } + } + + break; + + case TernaryState.True: + if (SelectedItems.Count == 0) + { + // Ensure the user can't stack multiple bank selections when there's no hitobject selection. + // Note that in normal scenarios this is sorted out by the feedback from applying the bank to the selected objects. + foreach (var other in SelectionAdditionBankStates.Values) + { + if (other != bindable) + other.Value = TernaryState.False; + } + } + else + { + // If none of the selected objects have any addition samples, we should not apply the addition bank. + if (SelectedItems.SelectMany(enumerateAllSamples).All(h => h.All(o => o.Name == HitSampleInfo.HIT_NORMAL))) + { + bindable.Value = TernaryState.False; + break; + } + + SetSampleAdditionBank(bankName); + } + + break; + } + }; + + SelectionAdditionBankStates[bankName] = bindable; + } + + resetTernaryStates(); foreach (string sampleName in HitSampleInfo.AllAdditions) { @@ -170,21 +256,62 @@ namespace osu.Game.Screens.Edit.Compose.Components }; } + private void resetTernaryStates() + { + if (SelectionNewComboState.Value == TernaryState.Indeterminate) + SelectionNewComboState.Value = TernaryState.False; + AutoSelectionBankEnabled.Value = true; + SelectionAdditionBanksEnabled.Value = true; + SelectionBankStates[HIT_BANK_AUTO].Value = TernaryState.True; + SelectionAdditionBankStates[HIT_BANK_AUTO].Value = TernaryState.True; + } + /// /// Called when context menu ternary states may need to be recalculated (selection changed or hitobject updated). /// protected virtual void UpdateTernaryStates() { - SelectionNewComboState.Value = GetStateFromSelection(SelectedItems.OfType(), h => h.NewCombo); + if (SelectedItems.Any()) + SelectionNewComboState.Value = GetStateFromSelection(SelectedItems.OfType(), h => h.NewCombo); + AutoSelectionBankEnabled.Value = SelectedItems.Count == 0; + + var samplesInSelection = SelectedItems.SelectMany(enumerateAllSamples).ToArray(); foreach ((string sampleName, var bindable) in SelectionSampleStates) { - bindable.Value = GetStateFromSelection(SelectedItems, h => h.Samples.Any(s => s.Name == sampleName)); + bindable.Value = GetStateFromSelection(samplesInSelection, h => h.Any(s => s.Name == sampleName)); } foreach ((string bankName, var bindable) in SelectionBankStates) { - bindable.Value = GetStateFromSelection(SelectedItems, h => h.Samples.All(s => s.Bank == bankName)); + bindable.Value = GetStateFromSelection(samplesInSelection.SelectMany(s => s).Where(o => o.Name == HitSampleInfo.HIT_NORMAL), h => h.Bank == bankName); + } + + SelectionAdditionBanksEnabled.Value = samplesInSelection.SelectMany(s => s).Any(o => o.Name != HitSampleInfo.HIT_NORMAL); + + foreach ((string bankName, var bindable) in SelectionAdditionBankStates) + { + bindable.Value = GetStateFromSelection(samplesInSelection.SelectMany(s => s).Where(o => o.Name != HitSampleInfo.HIT_NORMAL), h => (bankName != HIT_BANK_AUTO && h.Bank == bankName && !h.EditorAutoBank) || (bankName == HIT_BANK_AUTO && h.EditorAutoBank)); + } + } + + private void onSelectedItemsChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + // Reset the ternary states when the selection is cleared. + if (e.OldStartingIndex >= 0 && e.NewStartingIndex < 0) + Scheduler.AddOnce(resetTernaryStates); + else + Scheduler.AddOnce(UpdateTernaryStates); + } + + private IEnumerable> enumerateAllSamples(HitObject hitObject) + { + yield return hitObject.Samples; + + if (hitObject is IHasRepeats withRepeats) + { + foreach (var node in withRepeats.NodeSamples) + yield return node; } } @@ -193,34 +320,124 @@ namespace osu.Game.Screens.Edit.Compose.Components #region Ternary state changes /// - /// Adds a sample bank to all selected s. + /// Sets the sample bank for all selected s. /// /// The name of the sample bank. - public void AddSampleBank(string bankName) + public void SetSampleBank(string bankName) { + bool hasRelevantBank(HitObject hitObject) + { + bool result = hitObject.Samples.Where(o => o.Name == HitSampleInfo.HIT_NORMAL).All(s => s.Bank == bankName); + + if (hitObject is IHasRepeats hasRepeats) + { + foreach (var node in hasRepeats.NodeSamples) + result &= node.Where(o => o.Name == HitSampleInfo.HIT_NORMAL).All(s => s.Bank == bankName); + } + + return result; + } + + if (SelectedItems.All(hasRelevantBank)) + return; + EditorBeatmap.PerformOnSelection(h => { - if (h.Samples.All(s => s.Bank == bankName)) + if (hasRelevantBank(h)) return; - h.Samples = h.Samples.Select(s => s.With(newBank: bankName)).ToList(); + h.Samples = h.Samples.Select(s => s.Name == HitSampleInfo.HIT_NORMAL ? s.With(newBank: bankName) : s).ToList(); + + if (h is IHasRepeats hasRepeats) + { + for (int i = 0; i < hasRepeats.NodeSamples.Count; ++i) + hasRepeats.NodeSamples[i] = hasRepeats.NodeSamples[i].Select(s => s.Name == HitSampleInfo.HIT_NORMAL ? s.With(newBank: bankName) : s).ToList(); + } + EditorBeatmap.Update(h); }); } + /// + /// Sets the sample addition bank for all selected s. + /// + /// The name of the sample bank. + public void SetSampleAdditionBank(string bankName) + { + bool hasRelevantBank(HitObject hitObject) => + bankName == HIT_BANK_AUTO + ? enumerateAllSamples(hitObject).SelectMany(o => o).Where(o => o.Name != HitSampleInfo.HIT_NORMAL).All(s => s.EditorAutoBank) + : enumerateAllSamples(hitObject).SelectMany(o => o).Where(o => o.Name != HitSampleInfo.HIT_NORMAL).All(s => s.Bank == bankName && !s.EditorAutoBank); + + if (SelectedItems.All(hasRelevantBank)) + return; + + EditorBeatmap.PerformOnSelection(h => + { + if (hasRelevantBank(h)) + return; + + string normalBank = h.Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL)?.Bank ?? HitSampleInfo.BANK_SOFT; + h.Samples = h.Samples.Select(s => s.Name != HitSampleInfo.HIT_NORMAL ? bankName == HIT_BANK_AUTO ? s.With(newBank: normalBank, newEditorAutoBank: true) : s.With(newBank: bankName, newEditorAutoBank: false) : s).ToList(); + + if (h is IHasRepeats hasRepeats) + { + for (int i = 0; i < hasRepeats.NodeSamples.Count; ++i) + { + normalBank = hasRepeats.NodeSamples[i].FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL)?.Bank ?? HitSampleInfo.BANK_SOFT; + hasRepeats.NodeSamples[i] = hasRepeats.NodeSamples[i].Select(s => s.Name != HitSampleInfo.HIT_NORMAL ? bankName == HIT_BANK_AUTO ? s.With(newBank: normalBank, newEditorAutoBank: true) : s.With(newBank: bankName, newEditorAutoBank: false) : s).ToList(); + } + } + + EditorBeatmap.Update(h); + }); + } + + private bool hasRelevantSample(HitObject hitObject, string sampleName) + { + bool result = hitObject.Samples.Any(s => s.Name == sampleName); + + if (hitObject is IHasRepeats hasRepeats) + { + foreach (var node in hasRepeats.NodeSamples) + result &= node.Any(s => s.Name == sampleName); + } + + return result; + } + /// /// Adds a hit sample to all selected s. /// /// The name of the hit sample. public void AddHitSample(string sampleName) { + if (SelectedItems.All(h => hasRelevantSample(h, sampleName))) + return; + EditorBeatmap.PerformOnSelection(h => { // Make sure there isn't already an existing sample - if (h.Samples.Any(s => s.Name == sampleName)) - return; + if (h.Samples.All(s => s.Name != sampleName)) + h.Samples.Add(h.CreateHitSampleInfo(sampleName)); + + if (h is IHasRepeats hasRepeats) + { + foreach (var node in hasRepeats.NodeSamples) + { + if (node.Any(s => s.Name == sampleName)) + continue; + + var hitSample = h.CreateHitSampleInfo(sampleName); + + HitSampleInfo? existingAddition = node.FirstOrDefault(s => s.Name != HitSampleInfo.HIT_NORMAL); + if (existingAddition != null) + hitSample = hitSample.With(newBank: existingAddition.Bank, newEditorAutoBank: existingAddition.EditorAutoBank); + + node.Add(hitSample); + } + } - h.Samples.Add(h.CreateHitSampleInfo(sampleName)); EditorBeatmap.Update(h); }); } @@ -231,9 +448,19 @@ namespace osu.Game.Screens.Edit.Compose.Components /// The name of the hit sample. public void RemoveHitSample(string sampleName) { + if (SelectedItems.All(h => !hasRelevantSample(h, sampleName))) + return; + EditorBeatmap.PerformOnSelection(h => { h.SamplesBindable.RemoveAll(s => s.Name == sampleName); + + if (h is IHasRepeats hasRepeats) + { + for (int i = 0; i < hasRepeats.NodeSamples.Count; ++i) + hasRepeats.NodeSamples[i] = hasRepeats.NodeSamples[i].Where(s => s.Name != sampleName).ToList(); + } + EditorBeatmap.Update(h); }); } @@ -245,6 +472,9 @@ namespace osu.Game.Screens.Edit.Compose.Components /// Throws if any selected object doesn't implement public void SetNewCombo(bool state) { + if (SelectedItems.OfType().All(h => h.NewCombo == state)) + return; + EditorBeatmap.PerformOnSelection(h => { var comboInfo = h as IHasComboInformation; @@ -269,18 +499,74 @@ namespace osu.Game.Screens.Edit.Compose.Components { if (SelectedBlueprints.All(b => b.Item is IHasComboInformation)) { - yield return new TernaryStateToggleMenuItem("New combo") { State = { BindTarget = SelectionNewComboState } }; + yield return new TernaryStateToggleMenuItem("New combo") + { + State = { BindTarget = SelectionNewComboState }, + Hotkey = new Hotkey(new KeyCombination(InputKey.Q)) + }; } - yield return new OsuMenuItem("Sample") + yield return new OsuMenuItem("Sample") { Items = getSampleSubmenuItems().ToArray(), }; + yield return new OsuMenuItem("Bank") { Items = getBankSubmenuItems().ToArray(), }; + } + + private IEnumerable getSampleSubmenuItems() + { + var whistle = SelectionSampleStates[HitSampleInfo.HIT_WHISTLE]; + yield return new TernaryStateToggleMenuItem(whistle.Description) { - Items = SelectionSampleStates.Select(kvp => - new TernaryStateToggleMenuItem(kvp.Value.Description) { State = { BindTarget = kvp.Value } }).ToArray() + State = { BindTarget = whistle }, + Hotkey = new Hotkey(new KeyCombination(InputKey.W)) }; - yield return new OsuMenuItem("Bank") + var finish = SelectionSampleStates[HitSampleInfo.HIT_FINISH]; + yield return new TernaryStateToggleMenuItem(finish.Description) { - Items = SelectionBankStates.Select(kvp => + State = { BindTarget = finish }, + Hotkey = new Hotkey(new KeyCombination(InputKey.E)) + }; + + var clap = SelectionSampleStates[HitSampleInfo.HIT_CLAP]; + yield return new TernaryStateToggleMenuItem(clap.Description) + { + State = { BindTarget = clap }, + Hotkey = new Hotkey(new KeyCombination(InputKey.R)) + }; + } + + private IEnumerable getBankSubmenuItems() + { + var auto = SelectionBankStates[HIT_BANK_AUTO]; + yield return new TernaryStateToggleMenuItem(auto.Description) + { + State = { BindTarget = auto }, + Hotkey = new Hotkey(new KeyCombination(InputKey.Shift, InputKey.Q)) + }; + + var normal = SelectionBankStates[HitSampleInfo.BANK_NORMAL]; + yield return new TernaryStateToggleMenuItem(normal.Description) + { + State = { BindTarget = normal }, + Hotkey = new Hotkey(new KeyCombination(InputKey.Shift, InputKey.W)) + }; + + var soft = SelectionBankStates[HitSampleInfo.BANK_SOFT]; + yield return new TernaryStateToggleMenuItem(soft.Description) + { + State = { BindTarget = soft }, + Hotkey = new Hotkey(new KeyCombination(InputKey.Shift, InputKey.E)) + }; + + var drum = SelectionBankStates[HitSampleInfo.BANK_DRUM]; + yield return new TernaryStateToggleMenuItem(drum.Description) + { + State = { BindTarget = drum }, + Hotkey = new Hotkey(new KeyCombination(InputKey.Shift, InputKey.R)) + }; + + yield return new OsuMenuItem("Addition bank") + { + Items = SelectionAdditionBankStates.Select(kvp => new TernaryStateToggleMenuItem(kvp.Value.Description) { State = { BindTarget = kvp.Value } }).ToArray() }; } diff --git a/osu.Game/Screens/Edit/Compose/Components/HitObjectInspector.cs b/osu.Game/Screens/Edit/Compose/Components/HitObjectInspector.cs index ac339dc9d9..b74a89e3fe 100644 --- a/osu.Game/Screens/Edit/Compose/Components/HitObjectInspector.cs +++ b/osu.Game/Screens/Edit/Compose/Components/HitObjectInspector.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Linq; using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Threading; @@ -9,13 +10,14 @@ using osu.Game.Rulesets.Objects.Types; namespace osu.Game.Screens.Edit.Compose.Components { - internal partial class HitObjectInspector : EditorInspector + public partial class HitObjectInspector : EditorInspector { protected override void LoadComplete() { base.LoadComplete(); EditorBeatmap.SelectedHitObjects.CollectionChanged += (_, _) => updateInspectorText(); + EditorBeatmap.PlacementObject.BindValueChanged(_ => updateInspectorText()); EditorBeatmap.TransactionBegan += updateInspectorText; EditorBeatmap.TransactionEnded += updateInspectorText; updateInspectorText(); @@ -29,14 +31,33 @@ namespace osu.Game.Screens.Edit.Compose.Components rollingTextUpdate?.Cancel(); rollingTextUpdate = null; - switch (EditorBeatmap.SelectedHitObjects.Count) + HitObject[] objects; + + if (EditorBeatmap.SelectedHitObjects.Count > 0) + objects = EditorBeatmap.SelectedHitObjects.ToArray(); + else if (EditorBeatmap.PlacementObject.Value != null) + objects = new[] { EditorBeatmap.PlacementObject.Value }; + else + objects = Array.Empty(); + + AddInspectorValues(objects); + + // I'd hope there's a better way to do this, but I don't want to bind to each and every property above to watch for changes. + // This is a good middle-ground for the time being. + if (objects.Length > 0) + rollingTextUpdate ??= Scheduler.AddDelayed(updateInspectorText, 250); + } + + protected virtual void AddInspectorValues(HitObject[] objects) + { + switch (objects.Length) { case 0: AddValue("No selection"); break; case 1: - var selected = EditorBeatmap.SelectedHitObjects.Single(); + var selected = objects.Single(); AddHeader("Type"); AddValue($"{selected.GetType().ReadableName()}"); @@ -48,7 +69,8 @@ namespace osu.Game.Screens.Edit.Compose.Components { case IHasPosition pos: AddHeader("Position"); - AddValue($"x:{pos.X:#,0.##} y:{pos.Y:#,0.##}"); + AddValue($"x:{pos.X:#,0.##}"); + AddValue($"y:{pos.Y:#,0.##}"); break; case IHasXPosition x: @@ -90,20 +112,17 @@ namespace osu.Game.Screens.Edit.Compose.Components AddValue($"{duration.Duration:#,0.##}ms"); } - // I'd hope there's a better way to do this, but I don't want to bind to each and every property above to watch for changes. - // This is a good middle-ground for the time being. - rollingTextUpdate ??= Scheduler.AddDelayed(updateInspectorText, 250); break; default: AddHeader("Selected Objects"); - AddValue($"{EditorBeatmap.SelectedHitObjects.Count:#,0.##}"); + AddValue($"{objects.Length:#,0.##}"); AddHeader("Start Time"); - AddValue($"{EditorBeatmap.SelectedHitObjects.Min(o => o.StartTime):#,0.##}ms"); + AddValue($"{objects.Min(o => o.StartTime):#,0.##}ms"); AddHeader("End Time"); - AddValue($"{EditorBeatmap.SelectedHitObjects.Max(o => o.GetEndTime()):#,0.##}ms"); + AddValue($"{objects.Max(o => o.GetEndTime()):#,0.##}ms"); break; } } diff --git a/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs b/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs index 8f54d55d5d..a7f8fd0d4c 100644 --- a/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs @@ -4,7 +4,6 @@ using osu.Framework.Allocation; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; @@ -14,7 +13,7 @@ namespace osu.Game.Screens.Edit.Compose.Components /// /// A container for ordered by their start times. /// - public sealed partial class HitObjectOrderedSelectionContainer : Container> + public sealed partial class HitObjectOrderedSelectionContainer : BlueprintContainer.SelectionBlueprintContainer { [Resolved] private EditorBeatmap editorBeatmap { get; set; } = null!; @@ -28,16 +27,18 @@ namespace osu.Game.Screens.Edit.Compose.Components public override void Add(SelectionBlueprint drawable) { - SortInternal(); + Sort(); base.Add(drawable); } public override bool Remove(SelectionBlueprint drawable, bool disposeImmediately) { - SortInternal(); + Sort(); return base.Remove(drawable, disposeImmediately); } + internal void Sort() => SortInternal(); + protected override int Compare(Drawable x, Drawable y) { var xObj = ((SelectionBlueprint)x).Item; diff --git a/osu.Game/Screens/Edit/Compose/Components/LinedPositionSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/LinedPositionSnapGrid.cs new file mode 100644 index 0000000000..79b4fa2841 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/LinedPositionSnapGrid.cs @@ -0,0 +1,166 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Utils; +using osuTK; + +namespace osu.Game.Screens.Edit.Compose.Components +{ + public abstract partial class LinedPositionSnapGrid : PositionSnapGrid + { + protected void GenerateGridLines(Vector2 step, Vector2 drawSize) + { + if (Precision.AlmostEquals(step, Vector2.Zero)) + return; + + int index = 0; + + // Make lines the same width independent of display resolution. + float lineWidth = DrawWidth / ScreenSpaceDrawQuad.Width; + float rotation = MathHelper.RadiansToDegrees(MathF.Atan2(step.Y, step.X)); + + List generatedLines = new List(); + + while (true) + { + Vector2 currentPosition = StartPosition.Value + index * step; + index++; + + if (!lineDefinitelyIntersectsBox(currentPosition, step.PerpendicularLeft, drawSize, out var p1, out var p2)) + { + if (!isMovingTowardsBox(currentPosition, step, drawSize)) + break; + + continue; + } + + var gridLine = new Box + { + Colour = Colour4.White, + Alpha = 0.1f, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.None, + Width = lineWidth, + Height = Vector2.Distance(p1, p2), + Position = (p1 + p2) / 2, + Rotation = rotation, + }; + + generatedLines.Add(gridLine); + } + + if (generatedLines.Count == 0) + return; + + generatedLines.First().Alpha = 0.2f; + + AddRangeInternal(generatedLines); + } + + private bool isMovingTowardsBox(Vector2 currentPosition, Vector2 step, Vector2 box) + { + return (currentPosition + step).LengthSquared < currentPosition.LengthSquared || + (currentPosition + step - box).LengthSquared < (currentPosition - box).LengthSquared; + } + + /// + /// Determines if the line starting at and going in the direction of + /// definitely intersects the box on (0, 0) with the given width and height and returns the intersection points if it does. + /// + /// The start point of the line. + /// The direction of the line. + /// The width and height of the box. + /// The first intersection point. + /// The second intersection point. + /// Whether the line definitely intersects the box. + private bool lineDefinitelyIntersectsBox(Vector2 lineStart, Vector2 lineDir, Vector2 box, out Vector2 p1, out Vector2 p2) + { + p1 = Vector2.Zero; + p2 = Vector2.Zero; + + if (Precision.AlmostEquals(lineDir.X, 0)) + { + // If the line is vertical, we only need to check if the X coordinate of the line is within the box. + if (!Precision.DefinitelyBigger(lineStart.X, 0) || !Precision.DefinitelyBigger(box.X, lineStart.X)) + return false; + + p1 = new Vector2(lineStart.X, 0); + p2 = new Vector2(lineStart.X, box.Y); + return true; + } + + if (Precision.AlmostEquals(lineDir.Y, 0)) + { + // If the line is horizontal, we only need to check if the Y coordinate of the line is within the box. + if (!Precision.DefinitelyBigger(lineStart.Y, 0) || !Precision.DefinitelyBigger(box.Y, lineStart.Y)) + return false; + + p1 = new Vector2(0, lineStart.Y); + p2 = new Vector2(box.X, lineStart.Y); + return true; + } + + float m = lineDir.Y / lineDir.X; + float mInv = lineDir.X / lineDir.Y; // Use this to improve numerical stability if X is close to zero. + float b = lineStart.Y - m * lineStart.X; + + // Calculate intersection points with the sides of the box. + var p = new List(4); + + if (0 <= b && b <= box.Y) + p.Add(new Vector2(0, b)); + if (0 <= (box.Y - b) * mInv && (box.Y - b) * mInv <= box.X) + p.Add(new Vector2((box.Y - b) * mInv, box.Y)); + if (0 <= m * box.X + b && m * box.X + b <= box.Y) + p.Add(new Vector2(box.X, m * box.X + b)); + if (0 <= -b * mInv && -b * mInv <= box.X) + p.Add(new Vector2(-b * mInv, 0)); + + switch (p.Count) + { + case 4: + // If there are 4 intersection points, the line is a diagonal of the box. + if (m > 0) + { + p1 = Vector2.Zero; + p2 = box; + } + else + { + p1 = new Vector2(0, box.Y); + p2 = new Vector2(box.X, 0); + } + + break; + + case 3: + // If there are 3 intersection points, the line goes through a corner of the box. + if (p[0] == p[1]) + { + p1 = p[0]; + p2 = p[2]; + } + else + { + p1 = p[0]; + p2 = p[1]; + } + + break; + + case 2: + p1 = p[0]; + p2 = p[1]; + + break; + } + + return !Precision.AlmostEquals(p1, p2); + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/PositionSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/PositionSnapGrid.cs new file mode 100644 index 0000000000..cbdf02488a --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/PositionSnapGrid.cs @@ -0,0 +1,97 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Layout; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Edit.Compose.Components +{ + public abstract partial class PositionSnapGrid : BufferedContainer + { + /// + /// The position of the origin of this in local coordinates. + /// + public Bindable StartPosition { get; } = new Bindable(Vector2.Zero); + + protected readonly LayoutValue GridCache = new LayoutValue(Invalidation.RequiredParentSizeToFit); + + protected PositionSnapGrid() + : base(cachedFrameBuffer: true) + { + BackgroundColour = Color4.White.Opacity(0); + + StartPosition.BindValueChanged(_ => GridCache.Invalidate()); + + AddLayout(GridCache); + } + + protected override void Update() + { + base.Update(); + + if (GridCache.IsValid) + return; + + ClearInternal(); + + if (DrawWidth > 0 && DrawHeight > 0) + CreateContent(); + + GridCache.Validate(); + ForceRedraw(); + } + + protected abstract void CreateContent(); + + protected void GenerateOutline(Vector2 drawSize) + { + // Make lines the same width independent of display resolution. + float lineWidth = DrawWidth / ScreenSpaceDrawQuad.Width; + + AddRangeInternal(new[] + { + new Box + { + Colour = Colour4.White, + Alpha = 0.3f, + RelativeSizeAxes = Axes.X, + Height = lineWidth, + Y = 0, + }, + new Box + { + Colour = Colour4.White, + Alpha = 0.3f, + Origin = Anchor.BottomLeft, + Anchor = Anchor.BottomLeft, + RelativeSizeAxes = Axes.X, + Height = lineWidth + }, + new Box + { + Colour = Colour4.White, + Alpha = 0.3f, + RelativeSizeAxes = Axes.Y, + Width = lineWidth + }, + new Box + { + Colour = Colour4.White, + Alpha = 0.3f, + Origin = Anchor.TopRight, + Anchor = Anchor.TopRight, + RelativeSizeAxes = Axes.Y, + Width = lineWidth + }, + }); + } + + public abstract Vector2 GetSnappedPosition(Vector2 original); + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/RectangularPositionSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/RectangularPositionSnapGrid.cs index cfc01fe17b..3bf0ef8ac3 100644 --- a/osu.Game/Screens/Edit/Compose/Components/RectangularPositionSnapGrid.cs +++ b/osu.Game/Screens/Edit/Compose/Components/RectangularPositionSnapGrid.cs @@ -2,132 +2,51 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; -using System.Linq; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Layout; -using osu.Framework.Utils; +using osu.Framework.Bindables; +using osu.Game.Utils; using osuTK; namespace osu.Game.Screens.Edit.Compose.Components { - public partial class RectangularPositionSnapGrid : CompositeDrawable + public partial class RectangularPositionSnapGrid : LinedPositionSnapGrid { - /// - /// The position of the origin of this in local coordinates. - /// - public Vector2 StartPosition { get; } - - private Vector2 spacing = Vector2.One; - /// /// The spacing between grid lines of this . /// - public Vector2 Spacing - { - get => spacing; - set - { - if (spacing.X <= 0 || spacing.Y <= 0) - throw new ArgumentException("Grid spacing must be positive."); + public Bindable Spacing { get; } = new Bindable(Vector2.One); - spacing = value; - gridCache.Invalidate(); - } + /// + /// The rotation in degrees of the grid lines of this . + /// + public BindableFloat GridLineRotation { get; } = new BindableFloat(); + + public RectangularPositionSnapGrid() + { + Spacing.BindValueChanged(_ => GridCache.Invalidate()); + GridLineRotation.BindValueChanged(_ => GridCache.Invalidate()); } - private readonly LayoutValue gridCache = new LayoutValue(Invalidation.RequiredParentSizeToFit); - - public RectangularPositionSnapGrid(Vector2 startPosition) - { - StartPosition = startPosition; - - AddLayout(gridCache); - } - - protected override void Update() - { - base.Update(); - - if (!gridCache.IsValid) - { - ClearInternal(); - - if (DrawWidth > 0 && DrawHeight > 0) - createContent(); - - gridCache.Validate(); - } - } - - private void createContent() + protected override void CreateContent() { var drawSize = DrawSize; + var rot = Quaternion.FromAxisAngle(Vector3.UnitZ, MathHelper.DegreesToRadians(GridLineRotation.Value)); - generateGridLines(Direction.Horizontal, StartPosition.Y, 0, -Spacing.Y); - generateGridLines(Direction.Horizontal, StartPosition.Y, drawSize.Y, Spacing.Y); + GenerateGridLines(Vector2.Transform(new Vector2(0, -Spacing.Value.Y), rot), drawSize); + GenerateGridLines(Vector2.Transform(new Vector2(0, Spacing.Value.Y), rot), drawSize); - generateGridLines(Direction.Vertical, StartPosition.X, 0, -Spacing.X); - generateGridLines(Direction.Vertical, StartPosition.X, drawSize.X, Spacing.X); + GenerateGridLines(Vector2.Transform(new Vector2(-Spacing.Value.X, 0), rot), drawSize); + GenerateGridLines(Vector2.Transform(new Vector2(Spacing.Value.X, 0), rot), drawSize); + + GenerateOutline(drawSize); } - private void generateGridLines(Direction direction, float startPosition, float endPosition, float step) + public override Vector2 GetSnappedPosition(Vector2 original) { - int index = 0; - float currentPosition = startPosition; - - // Make lines the same width independent of display resolution. - float lineWidth = DrawWidth / ScreenSpaceDrawQuad.Width; - - List generatedLines = new List(); - - while (Precision.AlmostBigger((endPosition - currentPosition) * Math.Sign(step), 0)) - { - var gridLine = new Box - { - Colour = Colour4.White, - Alpha = 0.1f, - }; - - if (direction == Direction.Horizontal) - { - gridLine.Origin = Anchor.CentreLeft; - gridLine.RelativeSizeAxes = Axes.X; - gridLine.Height = lineWidth; - gridLine.Y = currentPosition; - } - else - { - gridLine.Origin = Anchor.TopCentre; - gridLine.RelativeSizeAxes = Axes.Y; - gridLine.Width = lineWidth; - gridLine.X = currentPosition; - } - - generatedLines.Add(gridLine); - - index += 1; - currentPosition = startPosition + index * step; - } - - if (generatedLines.Count == 0) - return; - - generatedLines.First().Alpha = 0.3f; - generatedLines.Last().Alpha = 0.3f; - - AddRangeInternal(generatedLines); - } - - public Vector2 GetSnappedPosition(Vector2 original) - { - Vector2 relativeToStart = original - StartPosition; - Vector2 offset = Vector2.Divide(relativeToStart, Spacing); + Vector2 relativeToStart = GeometryUtils.RotateVector(original - StartPosition.Value, GridLineRotation.Value); + Vector2 offset = Vector2.Divide(relativeToStart, Spacing.Value); Vector2 roundedOffset = new Vector2(MathF.Round(offset.X), MathF.Round(offset.Y)); - return StartPosition + Vector2.Multiply(roundedOffset, Spacing); + return StartPosition.Value + GeometryUtils.RotateVector(Vector2.Multiply(roundedOffset, Spacing.Value), -GridLineRotation.Value); } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs index 85ea7364e8..2171ba696f 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs @@ -2,9 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -27,7 +27,9 @@ namespace osu.Game.Screens.Edit.Compose.Components [Resolved] private SelectionRotationHandler? rotationHandler { get; set; } - public Func? OnScale; + [Resolved] + private SelectionScaleHandler? scaleHandler { get; set; } + public Func? OnFlip; public Func? OnReverse; @@ -57,60 +59,11 @@ namespace osu.Game.Screens.Edit.Compose.Components private readonly IBindable canRotate = new BindableBool(); - private bool canScaleX; + private readonly IBindable canScaleX = new BindableBool(); - /// - /// Whether horizontal scaling (from the left or right edge) support should be enabled. - /// - public bool CanScaleX - { - get => canScaleX; - set - { - if (canScaleX == value) return; + private readonly IBindable canScaleY = new BindableBool(); - canScaleX = value; - recreate(); - } - } - - private bool canScaleY; - - /// - /// Whether vertical scaling (from the top or bottom edge) support should be enabled. - /// - public bool CanScaleY - { - get => canScaleY; - set - { - if (canScaleY == value) return; - - canScaleY = value; - recreate(); - } - } - - private bool canScaleDiagonally; - - /// - /// Whether diagonal scaling (from a corner) support should be enabled. - /// - /// - /// There are some cases where we only want to allow proportional resizing, and not allow - /// one or both explicit directions of scale. - /// - public bool CanScaleDiagonally - { - get => canScaleDiagonally; - set - { - if (canScaleDiagonally == value) return; - - canScaleDiagonally = value; - recreate(); - } - } + private readonly IBindable canScaleDiagonally = new BindableBool(); private bool canFlipX; @@ -174,9 +127,19 @@ namespace osu.Game.Screens.Edit.Compose.Components private void load() { if (rotationHandler != null) - canRotate.BindTo(rotationHandler.CanRotateSelectionOrigin); + canRotate.BindTo(rotationHandler.CanRotateAroundSelectionOrigin); - canRotate.BindValueChanged(_ => recreate(), true); + if (scaleHandler != null) + { + canScaleX.BindTo(scaleHandler.CanScaleX); + canScaleY.BindTo(scaleHandler.CanScaleY); + canScaleDiagonally.BindTo(scaleHandler.CanScaleDiagonally); + } + + canRotate.BindValueChanged(_ => recreate()); + canScaleX.BindValueChanged(_ => recreate()); + canScaleY.BindValueChanged(_ => recreate()); + canScaleDiagonally.BindValueChanged(_ => recreate(), true); } protected override bool OnKeyDown(KeyDownEvent e) @@ -187,13 +150,25 @@ namespace osu.Game.Screens.Edit.Compose.Components switch (e.Key) { case Key.G: - return CanReverse && reverseButton?.TriggerClick() == true; + if (!CanReverse || reverseButton == null) + return false; + + reverseButton.TriggerAction(); + return true; case Key.Comma: - return canRotate.Value && rotateCounterClockwiseButton?.TriggerClick() == true; + if (!canRotate.Value || rotateCounterClockwiseButton == null) + return false; + + rotateCounterClockwiseButton.TriggerAction(); + return true; case Key.Period: - return canRotate.Value && rotateClockwiseButton?.TriggerClick() == true; + if (!canRotate.Value || rotateClockwiseButton == null) + return false; + + rotateClockwiseButton.TriggerAction(); + return true; } return base.OnKeyDown(e); @@ -265,9 +240,9 @@ namespace osu.Game.Screens.Edit.Compose.Components } }; - if (CanScaleX) addXScaleComponents(); - if (CanScaleDiagonally) addFullScaleComponents(); - if (CanScaleY) addYScaleComponents(); + if (canScaleX.Value) addXScaleComponents(); + if (canScaleDiagonally.Value) addFullScaleComponents(); + if (canScaleY.Value) addYScaleComponents(); if (CanFlipX) addXFlipComponents(); if (CanFlipY) addYFlipComponents(); if (canRotate.Value) addRotationComponents(); @@ -322,8 +297,12 @@ namespace osu.Game.Screens.Edit.Compose.Components Action = action }; + button.Clicked += freezeButtonPosition; + button.HoverLost += unfreezeButtonPosition; + button.OperationStarted += operationStarted; button.OperationEnded += operationEnded; + buttons.Add(button); return button; @@ -335,13 +314,13 @@ namespace osu.Game.Screens.Edit.Compose.Components /// public void PerformFlipFromScaleHandles(Axes axes) { - if (axes.HasFlagFast(Axes.X)) + if (axes.HasFlag(Axes.X)) { dragHandles.FlipScaleHandles(Direction.Horizontal); OnFlip?.Invoke(Direction.Horizontal, false); } - if (axes.HasFlagFast(Axes.Y)) + if (axes.HasFlag(Axes.Y)) { dragHandles.FlipScaleHandles(Direction.Vertical); OnFlip?.Invoke(Direction.Vertical, false); @@ -353,7 +332,6 @@ namespace osu.Game.Screens.Edit.Compose.Components var handle = new SelectionBoxScaleHandle { Anchor = anchor, - HandleScale = (delta, a) => OnScale?.Invoke(delta, a) }; handle.OperationStarted += operationStarted; @@ -396,9 +374,35 @@ namespace osu.Game.Screens.Edit.Compose.Components OperationStarted?.Invoke(); } - private void ensureButtonsOnScreen() + private Vector2? frozenButtonsPosition; + + private void freezeButtonPosition() { - buttons.Position = Vector2.Zero; + frozenButtonsPosition = buttons.ScreenSpaceDrawQuad.TopLeft; + } + + private void unfreezeButtonPosition() + { + if (frozenButtonsPosition != null) + { + frozenButtonsPosition = null; + ensureButtonsOnScreen(true); + } + } + + private void ensureButtonsOnScreen(bool animated = false) + { + if (frozenButtonsPosition != null) + { + buttons.Anchor = Anchor.TopLeft; + buttons.Origin = Anchor.TopLeft; + + buttons.Position = ToLocalSpace(frozenButtonsPosition.Value) - new Vector2(button_padding); + return; + } + + if (!animated && buttons.Transforms.Any()) + return; var thisQuad = ScreenSpaceDrawQuad; @@ -413,24 +417,51 @@ namespace osu.Game.Screens.Edit.Compose.Components float minHeight = buttons.ScreenSpaceDrawQuad.Height; + Anchor targetAnchor; + Anchor targetOrigin; + Vector2 targetPosition = Vector2.Zero; + if (topExcess < minHeight && bottomExcess < minHeight) { - buttons.Anchor = Anchor.BottomCentre; - buttons.Origin = Anchor.BottomCentre; - buttons.Y = Math.Min(0, ToLocalSpace(Parent!.ScreenSpaceDrawQuad.BottomLeft).Y - DrawHeight); + targetAnchor = Anchor.BottomCentre; + targetOrigin = Anchor.BottomCentre; + targetPosition.Y = Math.Min(0, ToLocalSpace(Parent!.ScreenSpaceDrawQuad.BottomLeft).Y - DrawHeight); } else if (topExcess > bottomExcess) { - buttons.Anchor = Anchor.TopCentre; - buttons.Origin = Anchor.BottomCentre; + targetAnchor = Anchor.TopCentre; + targetOrigin = Anchor.BottomCentre; } else { - buttons.Anchor = Anchor.BottomCentre; - buttons.Origin = Anchor.TopCentre; + targetAnchor = Anchor.BottomCentre; + targetOrigin = Anchor.TopCentre; } - buttons.X += ToLocalSpace(thisQuad.TopLeft - new Vector2(Math.Min(0, leftExcess)) + new Vector2(Math.Min(0, rightExcess))).X; + targetPosition.X += ToLocalSpace(thisQuad.TopLeft - new Vector2(Math.Min(0, leftExcess)) + new Vector2(Math.Min(0, rightExcess))).X; + + if (animated) + { + var originalPosition = ToLocalSpace(buttons.ScreenSpaceDrawQuad.TopLeft); + + buttons.Origin = targetOrigin; + buttons.Anchor = targetAnchor; + buttons.Position = targetPosition; + + var newPosition = ToLocalSpace(buttons.ScreenSpaceDrawQuad.TopLeft); + + var delta = newPosition - originalPosition; + + buttons.Position -= delta; + + buttons.MoveTo(targetPosition, 300, Easing.OutQuint); + } + else + { + buttons.Anchor = targetAnchor; + buttons.Origin = targetOrigin; + buttons.Position = targetPosition; + } } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxButton.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxButton.cs index 6108d44c81..8f263cdf4f 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxButton.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxButton.cs @@ -21,6 +21,10 @@ namespace osu.Game.Screens.Edit.Compose.Components public Action? Action; + public event Action? Clicked; + + public event Action? HoverLost; + public SelectionBoxButton(IconUsage iconUsage, string tooltip) { this.iconUsage = iconUsage; @@ -47,11 +51,10 @@ namespace osu.Game.Screens.Edit.Compose.Components protected override bool OnClick(ClickEvent e) { - Circle.FlashColour(Colours.GrayF, 300); + Clicked?.Invoke(); + + TriggerAction(); - TriggerOperationStarted(); - Action?.Invoke(); - TriggerOperationEnded(); return true; } @@ -61,6 +64,22 @@ namespace osu.Game.Screens.Edit.Compose.Components icon.FadeColour(!IsHeld && IsHovered ? Color4.White : Color4.Black, TRANSFORM_DURATION, Easing.OutQuint); } + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + + HoverLost?.Invoke(); + } + public LocalisableString TooltipText { get; } + + public void TriggerAction() + { + Circle.FlashColour(Colours.GrayF, 300); + + TriggerOperationStarted(); + Action?.Invoke(); + TriggerOperationEnded(); + } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandleContainer.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandleContainer.cs index e7f69b7b37..e5ac05ca6a 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandleContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandleContainer.cs @@ -7,7 +7,6 @@ using System.Collections.Generic; using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; -using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -74,9 +73,9 @@ namespace osu.Game.Screens.Edit.Compose.Components { foreach (var handle in scaleHandles) { - if (direction == Direction.Horizontal && !handle.Anchor.HasFlagFast(Anchor.x1)) + if (direction == Direction.Horizontal && !handle.Anchor.HasFlag(Anchor.x1)) handle.Anchor ^= Anchor.x0 | Anchor.x2; - if (direction == Direction.Vertical && !handle.Anchor.HasFlagFast(Anchor.y1)) + if (direction == Direction.Vertical && !handle.Anchor.HasFlag(Anchor.y1)) handle.Anchor ^= Anchor.y0 | Anchor.y2; } } diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxRotationHandle.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxRotationHandle.cs index 5270162189..03d600bfa2 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxRotationHandle.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxRotationHandle.cs @@ -4,7 +4,6 @@ using System; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; @@ -46,8 +45,8 @@ namespace osu.Game.Screens.Edit.Compose.Components Icon = FontAwesome.Solid.Redo, Scale = new Vector2 { - X = Anchor.HasFlagFast(Anchor.x0) ? 1f : -1f, - Y = Anchor.HasFlagFast(Anchor.y0) ? 1f : -1f + X = Anchor.HasFlag(Anchor.x0) ? 1f : -1f, + Y = Anchor.HasFlag(Anchor.y0) ? 1f : -1f } }); } @@ -67,6 +66,9 @@ namespace osu.Game.Screens.Edit.Compose.Components if (rotationHandler == null) return false; + if (rotationHandler.OperationInProgress.Value) + return false; + rotationHandler.Begin(); return true; } @@ -75,6 +77,8 @@ namespace osu.Game.Screens.Edit.Compose.Components { base.OnDrag(e); + if (rotationHandler == null || !rotationHandler.OperationInProgress.Value) return; + rawCumulativeRotation += convertDragEventToAngleOfRotation(e); applyRotation(shouldSnap: e.ShiftPressed); @@ -111,9 +115,11 @@ namespace osu.Game.Screens.Edit.Compose.Components private float convertDragEventToAngleOfRotation(DragEvent e) { - // Adjust coordinate system to the center of SelectionBox - float startAngle = MathF.Atan2(e.LastMousePosition.Y - selectionBox.DrawHeight / 2, e.LastMousePosition.X - selectionBox.DrawWidth / 2); - float endAngle = MathF.Atan2(e.MousePosition.Y - selectionBox.DrawHeight / 2, e.MousePosition.X - selectionBox.DrawWidth / 2); + // Adjust coordinate system to the center of the selection + Vector2 center = selectionBox.ToLocalSpace(rotationHandler!.ToScreenSpace(rotationHandler!.DefaultOrigin!.Value)); + + float startAngle = MathF.Atan2(e.LastMousePosition.Y - center.Y, e.LastMousePosition.X - center.X); + float endAngle = MathF.Atan2(e.MousePosition.Y - center.Y, e.MousePosition.X - center.X); return (endAngle - startAngle) * 180 / MathF.PI; } diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxScaleHandle.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxScaleHandle.cs index 7943065c82..3b7e29cf3d 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxScaleHandle.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxScaleHandle.cs @@ -1,19 +1,20 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - -using System; using osu.Framework.Allocation; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Input.Events; +using osu.Framework.Utils; using osuTK; +using osuTK.Input; namespace osu.Game.Screens.Edit.Compose.Components { public partial class SelectionBoxScaleHandle : SelectionBoxDragHandle { - public Action HandleScale { get; set; } + [Resolved] + private SelectionScaleHandler? scaleHandler { get; set; } [BackgroundDependencyLoader] private void load() @@ -21,10 +22,111 @@ namespace osu.Game.Screens.Edit.Compose.Components Size = new Vector2(10); } + private Anchor originalAnchor; + + protected override bool OnDragStart(DragStartEvent e) + { + if (e.Button != MouseButton.Left) + return false; + + if (scaleHandler == null) return false; + + if (scaleHandler.OperationInProgress.Value) + return false; + + originalAnchor = Anchor; + + scaleHandler.Begin(); + return true; + } + + private Vector2 rawScale; + protected override void OnDrag(DragEvent e) { - HandleScale?.Invoke(e.Delta, Anchor); base.OnDrag(e); + + if (scaleHandler == null) return; + + rawScale = convertDragEventToScaleMultiplier(e); + + applyScale(shouldLockAspectRatio: isCornerAnchor(originalAnchor) && e.ShiftPressed, useDefaultOrigin: e.AltPressed); } + + protected override bool OnKeyDown(KeyDownEvent e) + { + if (IsDragged) + { + applyScale(shouldLockAspectRatio: isCornerAnchor(originalAnchor) && e.ShiftPressed, useDefaultOrigin: e.AltPressed); + return true; + } + + return base.OnKeyDown(e); + } + + protected override void OnKeyUp(KeyUpEvent e) + { + base.OnKeyUp(e); + + if (IsDragged) + applyScale(shouldLockAspectRatio: isCornerAnchor(originalAnchor) && e.ShiftPressed, useDefaultOrigin: e.AltPressed); + } + + protected override void OnDragEnd(DragEndEvent e) + { + scaleHandler?.Commit(); + } + + private Vector2 convertDragEventToScaleMultiplier(DragEvent e) + { + Vector2 scale = e.MousePosition - e.MouseDownPosition; + adjustScaleFromAnchor(ref scale); + + var surroundingQuad = scaleHandler!.OriginalSurroundingQuad!.Value; + scale.X = Precision.AlmostEquals(surroundingQuad.Width, 0) ? 0 : scale.X / surroundingQuad.Width; + scale.Y = Precision.AlmostEquals(surroundingQuad.Height, 0) ? 0 : scale.Y / surroundingQuad.Height; + + return scale + Vector2.One; + } + + private void adjustScaleFromAnchor(ref Vector2 scale) + { + // cancel out scale in axes we don't care about (based on which drag handle was used). + if ((originalAnchor & Anchor.x1) > 0) scale.X = 0; + if ((originalAnchor & Anchor.y1) > 0) scale.Y = 0; + + // reverse the scale direction if dragging from top or left. + if ((originalAnchor & Anchor.x0) > 0) scale.X = -scale.X; + if ((originalAnchor & Anchor.y0) > 0) scale.Y = -scale.Y; + } + + private void applyScale(bool shouldLockAspectRatio, bool useDefaultOrigin = false) + { + var newScale = shouldLockAspectRatio + ? new Vector2((rawScale.X + rawScale.Y) * 0.5f) + : rawScale; + + Vector2? scaleOrigin = useDefaultOrigin ? null : originalAnchor.Opposite().PositionOnQuad(scaleHandler!.OriginalSurroundingQuad!.Value); + scaleHandler!.Update(newScale, scaleOrigin, getAdjustAxis()); + } + + private Axes getAdjustAxis() + { + switch (originalAnchor) + { + case Anchor.TopCentre: + case Anchor.BottomCentre: + return Axes.Y; + + case Anchor.CentreLeft: + case Anchor.CentreRight: + return Axes.X; + + default: + return Axes.Both; + } + } + + private bool isCornerAnchor(Anchor anchor) => !anchor.HasFlag(Anchor.x1) && !anchor.HasFlag(Anchor.y1); } } diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs index 3c859c65ff..39fff169b7 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Linq; @@ -16,6 +14,7 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osu.Game.Resources.Localisation.Web; @@ -50,12 +49,17 @@ namespace osu.Game.Screens.Edit.Compose.Components private readonly List> selectedBlueprints; - protected SelectionBox SelectionBox { get; private set; } + protected SelectionBox SelectionBox { get; private set; } = null!; [Resolved(CanBeNull = true)] - protected IEditorChangeHandler ChangeHandler { get; private set; } + protected IEditorChangeHandler? ChangeHandler { get; private set; } - public SelectionRotationHandler RotationHandler { get; private set; } + public SelectionRotationHandler RotationHandler { get; private set; } = null!; + + public SelectionScaleHandler ScaleHandler { get; private set; } = null!; + + [Resolved(CanBeNull = true)] + protected OsuContextMenuContainer? ContextMenuContainer { get; private set; } protected SelectionHandler() { @@ -69,6 +73,7 @@ namespace osu.Game.Screens.Edit.Compose.Components { var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); dependencies.CacheAs(RotationHandler = CreateRotationHandler()); + dependencies.CacheAs(ScaleHandler = CreateScaleHandler()); return dependencies; } @@ -78,13 +83,11 @@ namespace osu.Game.Screens.Edit.Compose.Components AddRangeInternal(new Drawable[] { RotationHandler, + ScaleHandler, SelectionBox = CreateSelectionBox(), }); - SelectedItems.CollectionChanged += (_, _) => - { - Scheduler.AddOnce(updateVisibility); - }; + SelectedItems.BindCollectionChanged((_, _) => Scheduler.AddOnce(updateVisibility), true); } public SelectionBox CreateSelectionBox() @@ -93,7 +96,6 @@ namespace osu.Game.Screens.Edit.Compose.Components OperationStarted = OnOperationBegan, OperationEnded = OnOperationEnded, - OnScale = HandleScale, OnFlip = HandleFlip, OnReverse = HandleReverse, }; @@ -157,6 +159,11 @@ namespace osu.Game.Screens.Edit.Compose.Components /// Whether any items could be scaled. public virtual bool HandleScale(Vector2 scale, Anchor anchor) => false; + /// + /// Creates the handler to use for scale operations. + /// + public virtual SelectionScaleHandler CreateScaleHandler() => new SelectionScaleHandler(); + /// /// Handles the selected items being flipped. /// @@ -225,7 +232,11 @@ namespace osu.Game.Screens.Edit.Compose.Components /// /// Deselect all selected items. /// - protected void DeselectAll() => SelectedItems.Clear(); + protected void DeselectAll() + { + SelectedItems.Clear(); + ContextMenuContainer?.CloseMenu(); + } /// /// Handle a blueprint becoming selected. @@ -238,6 +249,8 @@ namespace osu.Game.Screens.Edit.Compose.Components SelectedItems.Add(blueprint.Item); selectedBlueprints.Add(blueprint); + + ContextMenuContainer?.CloseMenu(); } /// @@ -258,7 +271,7 @@ namespace osu.Game.Screens.Edit.Compose.Components /// Whether a selection was performed. internal virtual bool MouseDownSelectionRequested(SelectionBlueprint blueprint, MouseButtonEvent e) { - if (e.ShiftPressed && e.Button == MouseButton.Right) + if (e.Button == MouseButton.Middle || (e.ShiftPressed && e.Button == MouseButton.Right)) { handleQuickDeletion(blueprint); return true; @@ -305,7 +318,7 @@ namespace osu.Game.Screens.Edit.Compose.Components /// /// Given a selection target and a function of truth, retrieve the correct ternary state for display. /// - protected static TernaryState GetStateFromSelection(IEnumerable selection, Func func) + public static TernaryState GetStateFromSelection(IEnumerable selection, Func func) { if (selection.Any(func)) return selection.All(func) ? TernaryState.True : TernaryState.Indeterminate; @@ -402,7 +415,10 @@ namespace osu.Game.Screens.Edit.Compose.Components if (SelectedBlueprints.Count == 1) items.AddRange(SelectedBlueprints[0].ContextMenuItems); - items.Add(new OsuMenuItem(CommonStrings.ButtonsDelete, MenuItemType.Destructive, DeleteSelected)); + items.Add(new OsuMenuItem(CommonStrings.ButtonsDelete, MenuItemType.Destructive, DeleteSelected) + { + Hotkey = new Hotkey { PlatformAction = PlatformAction.Delete, KeyCombinations = [new KeyCombination(InputKey.Shift, InputKey.MouseRight), new KeyCombination(InputKey.MouseMiddle)] } + }); return items.ToArray(); } diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionRotationHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionRotationHandler.cs index 459e4b0c41..af3b3d6489 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionRotationHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionRotationHandler.cs @@ -12,15 +12,26 @@ namespace osu.Game.Screens.Edit.Compose.Components /// public partial class SelectionRotationHandler : Component { + /// + /// Whether there is any ongoing rotation operation right now. + /// + public Bindable OperationInProgress { get; private set; } = new BindableBool(); + /// /// Whether rotation anchored by the selection origin can currently be performed. /// - public Bindable CanRotateSelectionOrigin { get; private set; } = new BindableBool(); + public Bindable CanRotateAroundSelectionOrigin { get; private set; } = new BindableBool(); /// /// Whether rotation anchored by the center of the playfield can currently be performed. /// - public Bindable CanRotatePlayfieldOrigin { get; private set; } = new BindableBool(); + public Bindable CanRotateAroundPlayfieldOrigin { get; private set; } = new BindableBool(); + + /// + /// Implementation-defined origin point to rotate around when no explicit origin is provided. + /// This field is only assigned during a rotation operation. + /// + public Vector2? DefaultOrigin { get; protected set; } /// /// Performs a single, instant, atomic rotation operation. @@ -50,6 +61,7 @@ namespace osu.Game.Screens.Edit.Compose.Components /// public virtual void Begin() { + OperationInProgress.Value = true; } /// @@ -85,6 +97,7 @@ namespace osu.Game.Screens.Edit.Compose.Components /// public virtual void Commit() { + OperationInProgress.Value = false; } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionScaleHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionScaleHandler.cs new file mode 100644 index 0000000000..177de9df33 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionScaleHandler.cs @@ -0,0 +1,113 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Primitives; +using osuTK; + +namespace osu.Game.Screens.Edit.Compose.Components +{ + /// + /// Base handler for editor scale operations. + /// + public partial class SelectionScaleHandler : Component + { + /// + /// Whether there is any ongoing scale operation right now. + /// + public Bindable OperationInProgress { get; private set; } = new BindableBool(); + + /// + /// Whether horizontal scaling (from the left or right edge) support should be enabled. + /// + public Bindable CanScaleX { get; private set; } = new BindableBool(); + + /// + /// Whether vertical scaling (from the top or bottom edge) support should be enabled. + /// + public Bindable CanScaleY { get; private set; } = new BindableBool(); + + /// + /// Whether diagonal scaling (from a corner) support should be enabled. + /// + /// + /// There are some cases where we only want to allow proportional resizing, and not allow + /// one or both explicit directions of scale. + /// + public Bindable CanScaleDiagonally { get; private set; } = new BindableBool(); + + public Quad? OriginalSurroundingQuad { get; protected set; } + + /// + /// Performs a single, instant, atomic scale operation. + /// + /// + /// This method is intended to be used in atomic contexts (such as when pressing a single button). + /// For continuous operations, see the -- flow. + /// + /// The scale to apply, as multiplier. + /// + /// The origin point to scale from. + /// If the default value is supplied, a sane implementation-defined default will be used. + /// + /// The axes to adjust the scale in. + /// The rotation of the axes in degrees. + public void ScaleSelection(Vector2 scale, Vector2? origin = null, Axes adjustAxis = Axes.Both, float axisRotation = 0) + { + Begin(); + Update(scale, origin, adjustAxis, axisRotation); + Commit(); + } + + /// + /// Begins a continuous scale operation. + /// + /// + /// This flow is intended to be used when a scale operation is made incrementally (such as when dragging a scale handle or slider). + /// For instantaneous, atomic operations, use the convenience method. + /// + public virtual void Begin() + { + OperationInProgress.Value = true; + } + + /// + /// Updates a continuous scale operation. + /// Must be preceded by a call. + /// + /// + /// + /// This flow is intended to be used when a scale operation is made incrementally (such as when dragging a scale handle or slider). + /// As such, the values of and supplied should be relative to the state of the objects being scaled + /// when was called, rather than instantaneous deltas. + /// + /// + /// For instantaneous, atomic operations, use the convenience method. + /// + /// + /// The Scale to apply, as multiplier. + /// + /// The origin point to scale from. + /// If the default value is supplied, a sane implementation-defined default will be used. + /// + /// The axes to adjust the scale in. + /// The rotation of the axes in degrees. + public virtual void Update(Vector2 scale, Vector2? origin = null, Axes adjustAxis = Axes.Both, float axisRotation = 0) + { + } + + /// + /// Ends a continuous scale operation. + /// Must be preceded by a call. + /// + /// + /// This flow is intended to be used when a scale operation is made incrementally (such as when dragging a scale handle or slider). + /// For instantaneous, atomic operations, use the convenience method. + /// + public virtual void Commit() + { + OperationInProgress.Value = false; + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/CentreMarker.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/CentreMarker.cs index 74786cc0c9..c63dfdfb55 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/CentreMarker.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/CentreMarker.cs @@ -5,48 +5,54 @@ 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.Overlays; using osuTK; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { public partial class CentreMarker : CompositeDrawable { - private const float triangle_width = 8; - - private const float bar_width = 1.6f; - - public CentreMarker() + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colours) { + const float triangle_width = 8; + const float bar_width = 2f; + RelativeSizeAxes = Axes.Y; - Size = new Vector2(triangle_width, 1); Anchor = Anchor.TopCentre; Origin = Anchor.TopCentre; + Size = new Vector2(triangle_width, 1); + InternalChildren = new Drawable[] { - new Box + new Circle { Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Y, Width = bar_width, + Colour = colours.Colour2, }, new Triangle { Anchor = Anchor.TopCentre, Origin = Anchor.BottomCentre, Size = new Vector2(triangle_width, triangle_width * 0.8f), - Scale = new Vector2(1, -1) + Scale = new Vector2(1, -1), + EdgeSmoothness = new Vector2(1, 0), + Colour = colours.Colour2, + }, + new Triangle + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Size = new Vector2(triangle_width, triangle_width * 0.8f), + Scale = new Vector2(1, 1), + Colour = colours.Colour2, }, }; } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - Colour = colours.Red1; - } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs index fc240c570b..44235e5d0b 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs @@ -33,6 +33,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline public DifficultyPointPiece(HitObject hitObject) { HitObject = hitObject; + Y = -2.5f; speedMultiplier = (hitObject as IHasSliderVelocity)?.SliderVelocityMultiplierBindable.GetBoundCopy(); } @@ -138,7 +139,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline protected override void LoadComplete() { base.LoadComplete(); - ScheduleAfterChildren(() => GetContainingInputManager().ChangeFocus(sliderVelocitySlider)); + ScheduleAfterChildren(() => GetContainingFocusManager()!.ChangeFocus(sliderVelocitySlider)); } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/HitObjectPointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/HitObjectPointPiece.cs index 4b357d3a62..76323ac08c 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/HitObjectPointPiece.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/HitObjectPointPiece.cs @@ -17,6 +17,8 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { protected OsuSpriteText Label { get; private set; } + protected Container LabelContainer { get; private set; } + [BackgroundDependencyLoader] private void load(OsuColour colours) { @@ -26,7 +28,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline InternalChildren = new Drawable[] { - new Container + LabelContainer = new Container { AutoSizeAxes = Axes.X, Height = 16, diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/NodeSamplePointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/NodeSamplePointPiece.cs new file mode 100644 index 0000000000..46e1ee2193 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/NodeSamplePointPiece.cs @@ -0,0 +1,58 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Audio; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; + +namespace osu.Game.Screens.Edit.Compose.Components.Timeline +{ + public partial class NodeSamplePointPiece : SamplePointPiece + { + public readonly int NodeIndex; + + public NodeSamplePointPiece(HitObject hitObject, int nodeIndex) + : base(hitObject) + { + if (hitObject is not IHasRepeats) + throw new System.ArgumentException($"HitObject must implement {nameof(IHasRepeats)}", nameof(hitObject)); + + NodeIndex = nodeIndex; + } + + protected override double GetTime() + { + var hasRepeats = (IHasRepeats)HitObject; + return HitObject.StartTime + hasRepeats.Duration * NodeIndex / hasRepeats.SpanCount(); + } + + protected override IList GetSamples() + { + var hasRepeats = (IHasRepeats)HitObject; + return NodeIndex < hasRepeats.NodeSamples.Count ? hasRepeats.NodeSamples[NodeIndex] : HitObject.Samples; + } + + public override Popover GetPopover() => new NodeSampleEditPopover(HitObject, NodeIndex); + + public partial class NodeSampleEditPopover : SampleEditPopover + { + private readonly int nodeIndex; + + protected override IEnumerable<(HitObject hitObject, IList samples)> GetRelevantSamples(HitObject[] hitObjects) + { + if (hitObjects.Length > 1 || hitObjects[0] is not IHasRepeats hasRepeats) + return base.GetRelevantSamples(hitObjects); + + return [(hitObjects[0], nodeIndex < hasRepeats.NodeSamples.Count ? hasRepeats.NodeSamples[nodeIndex] : hitObjects[0].Samples)]; + } + + public NodeSampleEditPopover(HitObject hitObject, int nodeIndex) + : base(hitObject) + { + this.nodeIndex = nodeIndex; + } + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs index 28841fc9e5..c3a56c8df9 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs @@ -1,8 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using System.Linq; +using Humanizer; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; @@ -12,14 +14,20 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; +using osu.Framework.Utils; using osu.Game.Audio; +using osu.Game.Configuration; using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Rulesets.Objects; +using osu.Game.Screens.Edit.Components.TernaryButtons; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Screens.Edit.Timing; using osuTK; using osuTK.Graphics; +using osuTK.Input; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { @@ -27,20 +35,81 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { public readonly HitObject HitObject; - private readonly BindableList samplesBindable; + [Resolved] + private EditorClock? editorClock { get; set; } + + [Resolved] + private Editor? editor { get; set; } + + [Resolved] + private TimelineBlueprintContainer? timelineBlueprintContainer { get; set; } public SamplePointPiece(HitObject hitObject) { HitObject = hitObject; - samplesBindable = hitObject.SamplesBindable.GetBoundCopy(); + Y = 2.5f; } - protected override Color4 GetRepresentingColour(OsuColour colours) => colours.Pink; + public bool AlternativeColor { get; init; } + + protected override Color4 GetRepresentingColour(OsuColour colours) => AlternativeColor ? colours.Pink2 : colours.Pink1; + + protected virtual double GetTime() => HitObject is IHasRepeats r ? HitObject.StartTime + r.Duration / r.SpanCount() / 2 : HitObject.StartTime; [BackgroundDependencyLoader] - private void load() + private void load(OsuConfigManager config) { - samplesBindable.BindCollectionChanged((_, _) => updateText(), true); + HitObject.DefaultsApplied += _ => updateText(); + Label.AllowMultiline = false; + LabelContainer.AutoSizeAxes = Axes.None; + updateText(); + + if (editor != null) + editor.ShowSampleEditPopoverRequested += onShowSampleEditPopoverRequested; + } + + private readonly Bindable contracted = new Bindable(); + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (timelineBlueprintContainer != null) + contracted.BindTo(timelineBlueprintContainer.SamplePointContracted); + + contracted.BindValueChanged(v => + { + if (v.NewValue) + { + Label.FadeOut(200, Easing.OutQuint); + LabelContainer.ResizeTo(new Vector2(12), 200, Easing.OutQuint); + LabelContainer.CornerRadius = 6; + } + else + { + Label.FadeIn(200, Easing.OutQuint); + LabelContainer.ResizeTo(new Vector2(Label.Width, 16), 200, Easing.OutQuint); + LabelContainer.CornerRadius = 8; + } + }, true); + + FinishTransforms(); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (editor != null) + editor.ShowSampleEditPopoverRequested -= onShowSampleEditPopoverRequested; + } + + private void onShowSampleEditPopoverRequested(double time) + { + if (!Precision.AlmostEquals(time, GetTime())) return; + + editorClock?.SeekSmoothlyTo(GetTime()); + this.ShowPopover(); } protected override bool OnClick(ClickEvent e) @@ -51,12 +120,35 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private void updateText() { - Label.Text = $"{GetBankValue(samplesBindable)} {GetVolumeValue(samplesBindable)}"; + Label.Text = $"{abbreviateBank(GetBankValue(GetSamples()))} {GetVolumeValue(GetSamples())}"; + + if (!contracted.Value) + LabelContainer.ResizeWidthTo(Label.Width, 200, Easing.OutQuint); + } + + private static string? abbreviateBank(string? bank) + { + return bank switch + { + HitSampleInfo.BANK_NORMAL => @"N", + HitSampleInfo.BANK_SOFT => @"S", + HitSampleInfo.BANK_DRUM => @"D", + _ => bank + }; } public static string? GetBankValue(IEnumerable samples) { - return samples.FirstOrDefault()?.Bank; + return samples.FirstOrDefault(o => o.Name == HitSampleInfo.HIT_NORMAL)?.Bank; + } + + public static string? GetAdditionBankValue(IEnumerable samples) + { + var firstAddition = samples.FirstOrDefault(o => o.Name != HitSampleInfo.HIT_NORMAL); + if (firstAddition == null) + return null; + + return firstAddition.EditorAutoBank ? EditorSelectionHandler.HIT_BANK_AUTO : firstAddition.Bank; } public static int GetVolumeValue(ICollection samples) @@ -64,15 +156,55 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline return samples.Count == 0 ? 0 : samples.Max(o => o.Volume); } - public Popover GetPopover() => new SampleEditPopover(HitObject); + /// + /// Gets the samples to be edited by this sample point piece. + /// This could be the samples of the hit object itself, or of one of the nested hit objects. For example a slider repeat. + /// + /// The samples to be edited. + protected virtual IList GetSamples() => HitObject.Samples; + + public virtual Popover GetPopover() => new SampleEditPopover(HitObject); public partial class SampleEditPopover : OsuPopover { private readonly HitObject hitObject; private LabelledTextBox bank = null!; + private LabelledTextBox additionBank = null!; private IndeterminateSliderWithTextBoxInput volume = null!; + private FillFlowContainer togglesCollection = null!; + + private HitObject[] relevantObjects = null!; + private (HitObject hitObject, IList samples)[] allRelevantSamples = null!; + + /// + /// Gets the sub-set of samples relevant to this sample point piece. + /// For example, to edit node samples this should return the samples at the index of the node. + /// + /// The hit objects to get the relevant samples from. + /// The relevant list of samples. + protected virtual IEnumerable<(HitObject hitObject, IList samples)> GetRelevantSamples(HitObject[] hitObjects) + { + if (hitObjects.Length == 1) + { + yield return (hitObjects[0], hitObjects[0].Samples); + + yield break; + } + + foreach (var ho in hitObjects) + { + yield return (ho, ho.Samples); + + if (ho is IHasRepeats hasRepeats) + { + foreach (var node in hasRepeats.NodeSamples) + yield return (ho, node); + } + } + } + [Resolved(canBeNull: true)] private EditorBeatmap beatmap { get; set; } = null!; @@ -96,9 +228,22 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline Spacing = new Vector2(0, 10), Children = new Drawable[] { + togglesCollection = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5, 5), + }, bank = new LabelledTextBox { Label = "Bank Name", + SelectAllOnFocus = true, + }, + additionBank = new LabelledTextBox + { + Label = "Addition Bank", + SelectAllOnFocus = true, }, volume = new IndeterminateSliderWithTextBoxInput("Volume", new BindableInt(100) { @@ -110,89 +255,302 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline }; bank.TabbableContentContainer = flow; + additionBank.TabbableContentContainer = flow; volume.TabbableContentContainer = flow; // if the piece belongs to a currently selected object, assume that the user wants to change all selected objects. // if the piece belongs to an unselected object, operate on that object alone, independently of the selection. - var relevantObjects = (beatmap.SelectedHitObjects.Contains(hitObject) ? beatmap.SelectedHitObjects : hitObject.Yield()).ToArray(); - var relevantSamples = relevantObjects.Select(h => h.Samples).ToArray(); + relevantObjects = (beatmap.SelectedHitObjects.Contains(hitObject) ? beatmap.SelectedHitObjects : hitObject.Yield()).ToArray(); + allRelevantSamples = GetRelevantSamples(relevantObjects).ToArray(); // even if there are multiple objects selected, we can still display sample volume or bank if they all have the same value. - string? commonBank = getCommonBank(relevantSamples); - if (!string.IsNullOrEmpty(commonBank)) - bank.Current.Value = commonBank; - - int? commonVolume = getCommonVolume(relevantSamples); + int? commonVolume = getCommonVolume(); if (commonVolume != null) volume.Current.Value = commonVolume.Value; - updateBankPlaceholderText(relevantObjects); + updatePrimaryBankState(); bank.Current.BindValueChanged(val => { - updateBankFor(relevantObjects, val.NewValue); - updateBankPlaceholderText(relevantObjects); + if (string.IsNullOrEmpty(val.NewValue)) + return; + + setBank(val.NewValue); + updatePrimaryBankState(); }); // on commit, ensure that the value is correct by sourcing it from the objects' samples again. // this ensures that committing empty text causes a revert to the previous value. - bank.OnCommit += (_, _) => bank.Current.Value = getCommonBank(relevantSamples); + bank.OnCommit += (_, _) => updatePrimaryBankState(); - volume.Current.BindValueChanged(val => updateVolumeFor(relevantObjects, val.NewValue)); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - ScheduleAfterChildren(() => GetContainingInputManager().ChangeFocus(volume)); - } - - private static string? getCommonBank(IList[] relevantSamples) => relevantSamples.Select(GetBankValue).Distinct().Count() == 1 ? GetBankValue(relevantSamples.First()) : null; - private static int? getCommonVolume(IList[] relevantSamples) => relevantSamples.Select(GetVolumeValue).Distinct().Count() == 1 ? GetVolumeValue(relevantSamples.First()) : null; - - private void updateBankFor(IEnumerable objects, string? newBank) - { - if (string.IsNullOrEmpty(newBank)) - return; - - beatmap.BeginChange(); - - foreach (var h in objects) + updateAdditionBankState(); + additionBank.Current.BindValueChanged(val => { - for (int i = 0; i < h.Samples.Count; i++) - { - h.Samples[i] = h.Samples[i].With(newBank: newBank); - } + if (string.IsNullOrEmpty(val.NewValue)) + return; - beatmap.Update(h); - } + setAdditionBank(val.NewValue); + updateAdditionBankState(); + }); + additionBank.OnCommit += (_, _) => updateAdditionBankState(); - beatmap.EndChange(); + volume.Current.BindValueChanged(val => + { + if (val.NewValue != null) + setVolume(val.NewValue.Value); + }); + + createStateBindables(); + updateTernaryStates(); + togglesCollection.AddRange(createTernaryButtons().Select(b => new DrawableTernaryButton(b) { RelativeSizeAxes = Axes.None, Size = new Vector2(40, 40) })); } - private void updateBankPlaceholderText(IEnumerable objects) + private string? getCommonBank() => allRelevantSamples.Select(h => GetBankValue(h.samples)).Distinct().Count() == 1 + ? GetBankValue(allRelevantSamples.First().samples) + : null; + + private string? getCommonAdditionBank() { - string? commonBank = getCommonBank(objects.Select(h => h.Samples).ToArray()); + string[] additionBanks = allRelevantSamples.Select(h => GetAdditionBankValue(h.samples)).Where(o => o is not null).Cast().Distinct().ToArray(); + return additionBanks.Length == 1 ? additionBanks[0] : null; + } + + private int? getCommonVolume() => allRelevantSamples.Select(h => GetVolumeValue(h.samples)).Distinct().Count() == 1 + ? GetVolumeValue(allRelevantSamples.First().samples) + : null; + + private void updatePrimaryBankState() + { + string? commonBank = getCommonBank(); + bank.Current.Value = commonBank; bank.PlaceholderText = string.IsNullOrEmpty(commonBank) ? "(multiple)" : string.Empty; } - private void updateVolumeFor(IEnumerable objects, int? newVolume) + private void updateAdditionBankState() { - if (newVolume == null) - return; + string? commonAdditionBank = getCommonAdditionBank(); + additionBank.PlaceholderText = string.IsNullOrEmpty(commonAdditionBank) ? "(multiple)" : string.Empty; + additionBank.Current.Value = commonAdditionBank; + bool anyAdditions = allRelevantSamples.Any(o => o.samples.Any(s => s.Name != HitSampleInfo.HIT_NORMAL)); + if (anyAdditions) + additionBank.Show(); + else + additionBank.Hide(); + } + + /// + /// Applies the given update action on all samples of + /// and invokes the necessary update notifiers for the beatmap and hit objects. + /// + /// The action to perform on each element of . + private void updateAllRelevantSamples(Action> updateAction) + { beatmap.BeginChange(); - foreach (var h in objects) + foreach (var (relevantHitObject, relevantSamples) in GetRelevantSamples(relevantObjects)) { - for (int i = 0; i < h.Samples.Count; i++) - { - h.Samples[i] = h.Samples[i].With(newVolume: newVolume.Value); - } - - beatmap.Update(h); + updateAction(relevantHitObject, relevantSamples); + beatmap.Update(relevantHitObject); } beatmap.EndChange(); } + + private void setBank(string newBank) + { + updateAllRelevantSamples((_, relevantSamples) => + { + for (int i = 0; i < relevantSamples.Count; i++) + { + if (relevantSamples[i].Name != HitSampleInfo.HIT_NORMAL && !relevantSamples[i].EditorAutoBank) continue; + + relevantSamples[i] = relevantSamples[i].With(newBank: newBank); + } + }); + } + + private void setAdditionBank(string newBank) + { + updateAllRelevantSamples((_, relevantSamples) => + { + string normalBank = relevantSamples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL)?.Bank ?? HitSampleInfo.BANK_SOFT; + + for (int i = 0; i < relevantSamples.Count; i++) + { + if (relevantSamples[i].Name == HitSampleInfo.HIT_NORMAL) + continue; + + // Addition samples with bank set to auto should inherit the bank of the normal sample + if (newBank == EditorSelectionHandler.HIT_BANK_AUTO) + { + relevantSamples[i] = relevantSamples[i].With(newBank: normalBank, newEditorAutoBank: true); + } + else + relevantSamples[i] = relevantSamples[i].With(newBank: newBank, newEditorAutoBank: false); + } + }); + } + + private void setVolume(int newVolume) + { + updateAllRelevantSamples((_, relevantSamples) => + { + for (int i = 0; i < relevantSamples.Count; i++) + { + relevantSamples[i] = relevantSamples[i].With(newVolume: newVolume); + } + }); + } + + #region hitsound toggles + + private readonly Dictionary> selectionSampleStates = new Dictionary>(); + + private readonly List banks = new List(); + + private void createStateBindables() + { + foreach (string sampleName in HitSampleInfo.AllAdditions) + { + var bindable = new Bindable + { + Description = sampleName.Replace("hit", string.Empty).Titleize() + }; + + bindable.ValueChanged += state => + { + switch (state.NewValue) + { + case TernaryState.False: + removeHitSample(sampleName); + break; + + case TernaryState.True: + addHitSample(sampleName); + break; + } + }; + + selectionSampleStates[sampleName] = bindable; + } + + banks.AddRange(HitSampleInfo.AllBanks.Prepend(EditorSelectionHandler.HIT_BANK_AUTO)); + } + + private void updateTernaryStates() + { + foreach ((string sampleName, var bindable) in selectionSampleStates) + { + bindable.Value = SelectionHandler.GetStateFromSelection(GetRelevantSamples(relevantObjects), h => h.samples.Any(s => s.Name == sampleName)); + } + } + + private IEnumerable createTernaryButtons() + { + foreach ((string sampleName, var bindable) in selectionSampleStates) + yield return new TernaryButton(bindable, string.Empty, () => ComposeBlueprintContainer.GetIconForSample(sampleName)); + } + + private void addHitSample(string sampleName) + { + if (string.IsNullOrEmpty(sampleName)) + return; + + updateAllRelevantSamples((h, relevantSamples) => + { + // Make sure there isn't already an existing sample + if (relevantSamples.Any(s => s.Name == sampleName)) + return; + + // First try inheriting the sample info from the node samples instead of the samples of the hitobject + var relevantSample = relevantSamples.FirstOrDefault(s => s.Name != HitSampleInfo.HIT_NORMAL) ?? relevantSamples.FirstOrDefault(); + relevantSamples.Add(relevantSample?.With(sampleName) ?? h.CreateHitSampleInfo(sampleName)); + }); + + updateAdditionBankState(); + } + + private void removeHitSample(string sampleName) + { + if (string.IsNullOrEmpty(sampleName)) + return; + + updateAllRelevantSamples((_, relevantSamples) => + { + for (int i = 0; i < relevantSamples.Count; i++) + { + if (relevantSamples[i].Name == sampleName) + relevantSamples.RemoveAt(i--); + } + }); + + updateAdditionBankState(); + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + if (e.ControlPressed || e.SuperPressed || !checkRightToggleFromKey(e.Key, out int rightIndex)) + return base.OnKeyDown(e); + + if (e.ShiftPressed || e.AltPressed) + { + string? newBank = banks.ElementAtOrDefault(rightIndex); + + if (string.IsNullOrEmpty(newBank)) + return true; + + if (e.ShiftPressed && newBank != EditorSelectionHandler.HIT_BANK_AUTO) + { + setBank(newBank); + updatePrimaryBankState(); + } + + if (e.AltPressed) + { + setAdditionBank(newBank); + updateAdditionBankState(); + } + } + else + { + var item = togglesCollection.ElementAtOrDefault(rightIndex - 1); + + if (item is not DrawableTernaryButton button) return base.OnKeyDown(e); + + button.Button.Toggle(); + } + + return true; + } + + private bool checkRightToggleFromKey(Key key, out int index) + { + switch (key) + { + case Key.Q: + index = 0; + break; + + case Key.W: + index = 1; + break; + + case Key.E: + index = 2; + break; + + case Key.R: + index = 3; + break; + + default: + index = -1; + break; + } + + return index >= 0; + } + + #endregion } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs index a2704e550c..aea8d02838 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using osu.Framework.Allocation; using osu.Framework.Audio.Track; @@ -16,6 +14,7 @@ using osu.Framework.Input.Events; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics; +using osu.Game.Overlays; using osu.Game.Rulesets.Edit; using osuTK; using osuTK.Input; @@ -25,22 +24,30 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline [Cached] public partial class Timeline : ZoomableScrollContainer, IPositionSnapProvider { - private const float timeline_height = 72; - private const float timeline_expanded_height = 94; + private const float timeline_height = 80; private readonly Drawable userContent; - public readonly Bindable WaveformVisible = new Bindable(); + private bool alwaysShowControlPoints; - public readonly Bindable ControlPointsVisible = new Bindable(); + public bool AlwaysShowControlPoints + { + get => alwaysShowControlPoints; + set + { + if (value == alwaysShowControlPoints) + return; - public readonly Bindable TicksVisible = new Bindable(); + alwaysShowControlPoints = value; + controlPointsVisible.TriggerChange(); + } + } [Resolved] - private EditorClock editorClock { get; set; } + private EditorClock editorClock { get; set; } = null!; [Resolved] - private EditorBeatmap editorBeatmap { get; set; } + private EditorBeatmap editorBeatmap { get; set; } = null!; /// /// The timeline's scroll position in the last frame. @@ -67,6 +74,20 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline /// private float defaultTimelineZoom; + private WaveformGraph waveform = null!; + + private TimelineTickDisplay ticks = null!; + + private TimelineTimingChangeDisplay controlPoints = null!; + + private Bindable waveformOpacity = null!; + private Bindable controlPointsVisible = null!; + private Bindable ticksVisible = null!; + + private double trackLengthForZoom; + + private readonly IBindable track = new Bindable(); + public Timeline(Drawable userContent) { this.userContent = userContent; @@ -79,22 +100,8 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline ScrollbarVisible = false; } - private WaveformGraph waveform; - - private TimelineTickDisplay ticks; - - private TimelineControlPointDisplay controlPoints; - - private Container mainContent; - - private Bindable waveformOpacity; - - private double trackLengthForZoom; - - private readonly IBindable track = new Bindable(); - [BackgroundDependencyLoader] - private void load(IBindable beatmap, OsuColour colours, OsuConfigManager config) + private void load(IBindable beatmap, OsuColour colours, OverlayColourProvider colourProvider, OsuConfigManager config) { CentreMarker centreMarker; @@ -103,16 +110,25 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline AddRange(new Drawable[] { - controlPoints = new TimelineControlPointDisplay + ticks = new TimelineTickDisplay(), + new Box { - RelativeSizeAxes = Axes.X, - Height = timeline_expanded_height, + Name = "zero marker", + RelativeSizeAxes = Axes.Y, + Width = TimelineTickDisplay.TICK_WIDTH / 2, + Origin = Anchor.TopCentre, + Colour = colourProvider.Background1, }, - mainContent = new Container + controlPoints = new TimelineTimingChangeDisplay + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + new Container { RelativeSizeAxes = Axes.X, Height = timeline_height, - Depth = float.MaxValue, Children = new[] { waveform = new WaveformGraph @@ -124,21 +140,15 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline HighColour = colours.BlueDarker, }, centreMarker.CreateProxy(), - ticks = new TimelineTickDisplay(), - new Box - { - Name = "zero marker", - RelativeSizeAxes = Axes.Y, - Width = 2, - Origin = Anchor.TopCentre, - Colour = colours.YellowDarker, - }, + ticks.CreateProxy(), userContent, } }, }); waveformOpacity = config.GetBindable(OsuSetting.EditorWaveformOpacity); + controlPointsVisible = config.GetBindable(OsuSetting.EditorTimelineShowTimingChanges); + ticksVisible = config.GetBindable(OsuSetting.EditorTimelineShowTicks); track.BindTo(editorClock.Track); track.BindValueChanged(_ => @@ -168,34 +178,21 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { base.LoadComplete(); - WaveformVisible.BindValueChanged(_ => updateWaveformOpacity()); waveformOpacity.BindValueChanged(_ => updateWaveformOpacity(), true); - TicksVisible.BindValueChanged(visible => ticks.FadeTo(visible.NewValue ? 1 : 0, 200, Easing.OutQuint), true); + ticksVisible.BindValueChanged(visible => ticks.FadeTo(visible.NewValue ? 1 : 0, 200, Easing.OutQuint), true); - ControlPointsVisible.BindValueChanged(visible => + controlPointsVisible.BindValueChanged(visible => { - if (visible.NewValue) - { - this.ResizeHeightTo(timeline_expanded_height, 200, Easing.OutQuint); - mainContent.MoveToY(20, 200, Easing.OutQuint); - - // delay the fade in else masking looks weird. - controlPoints.Delay(180).FadeIn(400, Easing.OutQuint); - } + if (visible.NewValue || alwaysShowControlPoints) + controlPoints.FadeIn(400, Easing.OutQuint); else - { controlPoints.FadeOut(200, Easing.OutQuint); - - // likewise, delay the resize until the fade is complete. - this.Delay(180).ResizeHeightTo(timeline_height, 200, Easing.OutQuint); - mainContent.Delay(180).MoveToY(0, 200, Easing.OutQuint); - } }, true); } private void updateWaveformOpacity() => - waveform.FadeTo(WaveformVisible.Value ? waveformOpacity.Value : 0, 200, Easing.OutQuint); + waveform.FadeTo(waveformOpacity.Value, 200, Easing.OutQuint); protected override void Update() { @@ -315,7 +312,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } [Resolved] - private IBeatSnapProvider beatSnapProvider { get; set; } + private IBeatSnapProvider beatSnapProvider { get; set; } = null!; /// /// The total amount of time visible on the timeline. diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineArea.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineArea.cs index b973ac3731..1db067c846 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineArea.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineArea.cs @@ -2,15 +2,13 @@ // 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.Shapes; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; -using osu.Game.Graphics.UserInterface; -using osu.Game.Localisation; using osu.Game.Overlays; -using osu.Game.Resources.Localisation.Web; using osu.Game.Rulesets.Edit; using osuTK; @@ -22,6 +20,9 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private readonly Drawable userContent; + private Box timelineBackground = null!; + private readonly Bindable composerFocusMode = new Bindable(); + public TimelineArea(Drawable? content = null) { RelativeSizeAxes = Axes.X; @@ -31,16 +32,20 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider, OsuColour colours) + private void load(OverlayColourProvider colourProvider, OsuColour colours, Editor? editor) { - OsuCheckbox waveformCheckbox; - OsuCheckbox controlPointsCheckbox; - OsuCheckbox ticksCheckbox; - const float padding = 10; InternalChildren = new Drawable[] { + new Box + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Width = 35 + HitObjectComposer.TOOLBOX_CONTRACTED_SIZE_RIGHT, + RelativeSizeAxes = Axes.Y, + Colour = colourProvider.Background4 + }, new GridContainer { RelativeSizeAxes = Axes.X, @@ -51,70 +56,21 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline }, ColumnDimensions = new[] { - new Dimension(GridSizeMode.Absolute, 135), new Dimension(), new Dimension(GridSizeMode.Absolute, 35), - new Dimension(GridSizeMode.Absolute, HitObjectComposer.TOOLBOX_CONTRACTED_SIZE_RIGHT - padding * 2), + new Dimension(GridSizeMode.Absolute, HitObjectComposer.TOOLBOX_CONTRACTED_SIZE_RIGHT), }, Content = new[] { new Drawable[] { - new Container - { - RelativeSizeAxes = Axes.Both, - Name = @"Toggle controls", - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = colourProvider.Background2, - }, - new FillFlowContainer - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding(padding), - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 4), - Children = new[] - { - waveformCheckbox = new OsuCheckbox(nubSize: 30f) - { - LabelText = EditorStrings.TimelineWaveform, - Current = { Value = true }, - }, - ticksCheckbox = new OsuCheckbox(nubSize: 30f) - { - LabelText = EditorStrings.TimelineTicks, - Current = { Value = true }, - }, - controlPointsCheckbox = new OsuCheckbox(nubSize: 30f) - { - LabelText = BeatmapsetsStrings.ShowStatsBpm, - Current = { Value = true }, - }, - } - } - } - }, new Container { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Children = new Drawable[] { - // the out-of-bounds portion of the centre marker. - new Box - { - Width = 24, - Height = EditorScreenWithTimeline.PADDING, - Depth = float.MaxValue, - Colour = colours.Red1, - Anchor = Anchor.TopCentre, - Origin = Anchor.BottomCentre, - }, - new Box + timelineBackground = new Box { RelativeSizeAxes = Axes.Both, Depth = float.MaxValue, @@ -168,9 +124,22 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } }; - Timeline.WaveformVisible.BindTo(waveformCheckbox.Current); - Timeline.ControlPointsVisible.BindTo(controlPointsCheckbox.Current); - Timeline.TicksVisible.BindTo(ticksCheckbox.Current); + if (editor != null) + composerFocusMode.BindTo(editor.ComposerFocusMode); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + composerFocusMode.BindValueChanged(_ => + { + // Transforms should be kept in sync with other usages of composer focus mode. + if (!composerFocusMode.Value) + timelineBackground.FadeIn(750, Easing.OutQuint); + else + timelineBackground.Delay(600).FadeTo(0.5f, 4000, Easing.OutQuint); + }, true); } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs index 6ebd1961a2..a4083f58b6 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs @@ -3,6 +3,7 @@ #nullable disable +using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -18,17 +19,22 @@ using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Screens.Edit.Components.Timelines.Summary.Parts; using osuTK; using osuTK.Graphics; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { + [Cached] internal partial class TimelineBlueprintContainer : EditorBlueprintContainer { [Resolved(CanBeNull = true)] private Timeline timeline { get; set; } + [Resolved(CanBeNull = true)] + private EditorClock editorClock { get; set; } + private Bindable placement; private SelectionBlueprint placementBlueprint; @@ -90,7 +96,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } } - protected override Container> CreateSelectionBlueprintContainer() => new TimelineSelectionBlueprintContainer { RelativeSizeAxes = Axes.Both }; + protected override SelectionBlueprintContainer CreateSelectionBlueprintContainer() => new TimelineSelectionBlueprintContainer { RelativeSizeAxes = Axes.Both }; protected override bool OnDragStart(DragStartEvent e) { @@ -100,10 +106,14 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline return base.OnDragStart(e); } + private float dragTimeAccumulated; + protected override void Update() { if (IsDragged || hitObjectDragged) handleScrollViaDrag(); + else + dragTimeAccumulated = 0; if (Composer != null && timeline != null) { @@ -113,9 +123,53 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline base.Update(); + updateSamplePointContractedState(); updateStacking(); } + public Bindable SamplePointContracted = new Bindable(); + + private void updateSamplePointContractedState() + { + const double minimum_gap = 28; + + if (timeline == null || editorClock == null) + return; + + // Find the smallest time gap between any two sample point pieces + double smallestTimeGap = double.PositiveInfinity; + double lastTime = double.PositiveInfinity; + + // The blueprints are ordered in reverse chronological order + foreach (var selectionBlueprint in SelectionBlueprints) + { + var hitObject = selectionBlueprint.Item; + + // Only check the hit objects which are visible in the timeline + // SelectionBlueprints can contain hit objects which are not visible in the timeline due to selection keeping them alive + if (hitObject.StartTime > editorClock.CurrentTime + timeline.VisibleRange / 2) + continue; + + if (hitObject.GetEndTime() < editorClock.CurrentTime - timeline.VisibleRange / 2) + break; + + if (hitObject is IHasRepeats hasRepeats) + smallestTimeGap = Math.Min(smallestTimeGap, hasRepeats.Duration / hasRepeats.SpanCount() / 2); + + double gap = lastTime - hitObject.GetEndTime(); + + // If the gap is less than 1ms, we can assume that the objects are stacked on top of each other + // Contracting doesn't make sense in this case + if (gap > 1 && gap < smallestTimeGap) + smallestTimeGap = gap; + + lastTime = hitObject.StartTime; + } + + double smallestAbsoluteGap = ((TimelineSelectionBlueprintContainer)SelectionBlueprints).ContentRelativeToAbsoluteFactor.X * smallestTimeGap; + SamplePointContracted.Value = smallestAbsoluteGap < minimum_gap; + } + private readonly Stack currentConcurrentObjects = new Stack(); private void updateStacking() @@ -168,8 +222,10 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline protected sealed override DragBox CreateDragBox() => new TimelineDragBox(); - protected override void UpdateSelectionFromDragBox() + protected override void UpdateSelectionFromDragBox(HashSet selectionBeforeDrag) { + Composer.BlueprintContainer.CommitIfPlacementActive(); + var dragBox = (TimelineDragBox)DragBox; double minTime = dragBox.MinTime; double maxTime = dragBox.MaxTime; @@ -184,6 +240,9 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline bool shouldBeSelected(HitObject hitObject) { + if (selectionBeforeDrag.Contains(hitObject)) + return true; + double midTime = (hitObject.StartTime + hitObject.GetEndTime()) / 2; return minTime <= midTime && midTime <= maxTime; } @@ -191,16 +250,42 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private void handleScrollViaDrag() { + // The amount of time dragging before we reach maximum drag speed. + const float time_ramp_multiplier = 5000; + + // A maximum drag speed to ensure things don't get out of hand. + const float max_velocity = 10; + if (timeline == null) return; - var timelineQuad = timeline.ScreenSpaceDrawQuad; - float mouseX = InputManager.CurrentState.Mouse.Position.X; + var mousePos = timeline.ToLocalSpace(InputManager.CurrentState.Mouse.Position); - // scroll if in a drag and dragging outside visible extents - if (mouseX > timelineQuad.TopRight.X) - timeline.ScrollBy((float)((mouseX - timelineQuad.TopRight.X) / 10 * Clock.ElapsedFrameTime)); - else if (mouseX < timelineQuad.TopLeft.X) - timeline.ScrollBy((float)((mouseX - timelineQuad.TopLeft.X) / 10 * Clock.ElapsedFrameTime)); + // for better UX do not require the user to drag all the way to the edge and beyond to initiate a drag-scroll. + // this is especially important in scenarios like fullscreen, where mouse confine will usually be on + // and the user physically *won't be able to* drag beyond the edge of the timeline + // (since its left edge is co-incident with the window edge). + const float scroll_tolerance = 40; + + float leftBound = timeline.BoundingBox.TopLeft.X + scroll_tolerance; + float rightBound = timeline.BoundingBox.TopRight.X - scroll_tolerance; + + float amount = 0; + + if (mousePos.X > rightBound) + amount = mousePos.X - rightBound; + else if (mousePos.X < leftBound) + amount = mousePos.X - leftBound; + + if (amount == 0) + { + dragTimeAccumulated = 0; + return; + } + + amount = Math.Sign(amount) * Math.Min(max_velocity, MathF.Pow(Math.Clamp(Math.Abs(amount), 0, scroll_tolerance), 2)); + dragTimeAccumulated += (float)Clock.ElapsedFrameTime; + + timeline.ScrollBy(amount * (float)Clock.ElapsedFrameTime * Math.Min(1, dragTimeAccumulated / time_ramp_multiplier)); } private partial class SelectableAreaBackground : CompositeDrawable @@ -251,14 +336,29 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } } - protected partial class TimelineSelectionBlueprintContainer : Container> + protected partial class TimelineSelectionBlueprintContainer : SelectionBlueprintContainer { - protected override Container> Content { get; } + protected override HitObjectOrderedSelectionContainer Content { get; } + + public Vector2 ContentRelativeToAbsoluteFactor => Content.RelativeToAbsoluteFactor; public TimelineSelectionBlueprintContainer() { AddInternal(new TimelinePart>(Content = new HitObjectOrderedSelectionContainer { RelativeSizeAxes = Axes.Both }) { RelativeSizeAxes = Axes.Both }); } + + public override void ChangeChildDepth(SelectionBlueprint child, float newDepth) + { + // timeline blueprint container also contains a blueprint for current placement, if present + // (see `placementChanged()` callback above). + // because the current placement hitobject is generally going to be mutated during the placement, + // it is possible for `Content`'s children to become unsorted when the user moves the placement around, + // which can culminate in a critical failure when attempting to binary-search children here + // using `HitObjectOrderedSelectionContainer`'s custom comparer. + // thus, always force a re-sort of objects before attempting to change child depth to avoid this scenario. + Content.Sort(); + base.ChangeChildDepth(child, newDepth); + } } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBreak.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBreak.cs new file mode 100644 index 0000000000..721af97674 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBreak.cs @@ -0,0 +1,235 @@ +// 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 osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Game.Beatmaps.Timing; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Rulesets.Objects; +using osuTK; + +namespace osu.Game.Screens.Edit.Compose.Components.Timeline +{ + public partial class TimelineBreak : CompositeDrawable, IHasContextMenu + { + public Bindable Break { get; } = new Bindable(); + + public Action? OnDeleted { get; init; } + + public TimelineBreak(BreakPeriod b) + { + Break.Value = b; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + RelativePositionAxes = Axes.X; + RelativeSizeAxes = Axes.Both; + Origin = Anchor.TopLeft; + Padding = new MarginPadding { Horizontal = -5 }; + + InternalChildren = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Horizontal = 5 }, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.Gray5, + Alpha = 0.9f, + }, + }, + new DragHandle(isStartHandle: true) + { + Break = { BindTarget = Break }, + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + Action = (time, breakPeriod) => new ManualBreakPeriod(time, breakPeriod.EndTime), + }, + new DragHandle(isStartHandle: false) + { + Break = { BindTarget = Break }, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Action = (time, breakPeriod) => new ManualBreakPeriod(breakPeriod.StartTime, time), + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Break.BindValueChanged(_ => + { + X = (float)Break.Value.StartTime; + Width = (float)Break.Value.Duration; + }, true); + } + + public MenuItem[] ContextMenuItems => new MenuItem[] + { + new OsuMenuItem(CommonStrings.ButtonsDelete, MenuItemType.Destructive, () => OnDeleted?.Invoke(Break.Value)), + }; + + private partial class DragHandle : FillFlowContainer + { + public Bindable Break { get; } = new Bindable(); + + public new Anchor Anchor + { + get => base.Anchor; + init => base.Anchor = value; + } + + public Func? Action { get; init; } + + private readonly bool isStartHandle; + + private Container handle = null!; + private (double min, double max)? allowedDragRange; + + [Resolved] + private EditorBeatmap beatmap { get; set; } = null!; + + [Resolved] + private Timeline timeline { get; set; } = null!; + + [Resolved] + private IEditorChangeHandler? changeHandler { get; set; } + + [Resolved] + private OsuColour colours { get; set; } = null!; + + public DragHandle(bool isStartHandle) + { + this.isStartHandle = isStartHandle; + } + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.X; + RelativeSizeAxes = Axes.Y; + Direction = FillDirection.Horizontal; + Spacing = new Vector2(5); + + Children = new Drawable[] + { + handle = new Container + { + Anchor = Anchor, + Origin = Anchor, + RelativeSizeAxes = Axes.Y, + CornerRadius = 4, + Masking = true, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.White, + }, + }, + new OsuSpriteText + { + BypassAutoSizeAxes = Axes.X, + Anchor = Anchor, + Origin = Anchor, + Text = "Break", + Margin = new MarginPadding { Top = 2, }, + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + updateState(); + FinishTransforms(true); + } + + protected override bool OnHover(HoverEvent e) + { + updateState(); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateState(); + base.OnHoverLost(e); + } + + protected override bool OnDragStart(DragStartEvent e) + { + changeHandler?.BeginChange(); + updateState(); + + double min = beatmap.HitObjects.LastOrDefault(ho => ho.GetEndTime() <= Break.Value.StartTime)?.GetEndTime() ?? double.NegativeInfinity; + double max = beatmap.HitObjects.FirstOrDefault(ho => ho.StartTime >= Break.Value.EndTime)?.StartTime ?? double.PositiveInfinity; + + if (isStartHandle) + max = Math.Min(max, Break.Value.EndTime - BreakPeriod.MIN_BREAK_DURATION); + else + min = Math.Max(min, Break.Value.StartTime + BreakPeriod.MIN_BREAK_DURATION); + + allowedDragRange = (min, max); + + return true; + } + + protected override void OnDrag(DragEvent e) + { + base.OnDrag(e); + + Debug.Assert(allowedDragRange != null); + + if (Action != null + && timeline.FindSnappedPositionAndTime(e.ScreenSpaceMousePosition).Time is double time + && time > allowedDragRange.Value.min + && time < allowedDragRange.Value.max) + { + int index = beatmap.Breaks.IndexOf(Break.Value); + beatmap.Breaks[index] = Break.Value = Action.Invoke(time, Break.Value); + } + + updateState(); + } + + protected override void OnDragEnd(DragEndEvent e) + { + changeHandler?.EndChange(); + updateState(); + base.OnDragEnd(e); + } + + private void updateState() + { + bool active = IsHovered || IsDragged; + + var colour = colours.Gray8; + if (active) + colour = colour.Lighten(0.3f); + + handle.FadeColour(colour, 400, Easing.OutQuint); + handle.ResizeWidthTo(active ? 10 : 8, 400, Easing.OutElasticHalf); + } + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBreakDisplay.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBreakDisplay.cs new file mode 100644 index 0000000000..381816c546 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBreakDisplay.cs @@ -0,0 +1,104 @@ +// 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.Specialized; +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 +{ + public partial class TimelineBreakDisplay : TimelinePart + { + [Resolved] + private Timeline timeline { get; set; } = null!; + + [Resolved] + private IEditorChangeHandler? editorChangeHandler { get; set; } + + /// + /// The visible time/position range of the timeline. + /// + private (float min, float max) visibleRange = (float.MinValue, float.MaxValue); + + private readonly Cached breakCache = new Cached(); + + 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); + + breaks.UnbindAll(); + breaks.BindTo(beatmap.Breaks); + breaks.BindCollectionChanged((_, e) => + { + if (e.Action != NotifyCollectionChangedAction.Replace) + breakCache.Invalidate(); + }); + } + + protected override void Update() + { + base.Update(); + + if (DrawWidth <= 0) return; + + (float, float) newRange = ( + (ToLocalSpace(timeline.ScreenSpaceDrawQuad.TopLeft).X) / DrawWidth * Content.RelativeChildSize.X, + (ToLocalSpace(timeline.ScreenSpaceDrawQuad.TopRight).X) / DrawWidth * Content.RelativeChildSize.X); + + if (visibleRange != newRange) + { + visibleRange = newRange; + breakCache.Invalidate(); + } + + if (!breakCache.IsValid) + { + recreateBreaks(); + breakCache.Validate(); + } + } + + private void recreateBreaks() + { + Clear(); + + if (!showBreaks.Value) + return; + + for (int i = 0; i < breaks.Count; i++) + { + var breakPeriod = breaks[i]; + + if (!shouldBeVisible(breakPeriod)) + continue; + + Add(new TimelineBreak(breakPeriod) + { + OnDeleted = b => + { + editorChangeHandler?.BeginChange(); + breaks.Remove(b); + editorChangeHandler?.EndChange(); + }, + }); + } + } + + private bool shouldBeVisible(BreakPeriod breakPeriod) => breakPeriod.EndTime >= visibleRange.min && breakPeriod.StartTime <= visibleRange.max; + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointDisplay.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointDisplay.cs deleted file mode 100644 index 116a3ee105..0000000000 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointDisplay.cs +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Caching; -using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Screens.Edit.Components.Timelines.Summary.Parts; - -namespace osu.Game.Screens.Edit.Compose.Components.Timeline -{ - /// - /// The part of the timeline that displays the control points. - /// - public partial class TimelineControlPointDisplay : TimelinePart - { - [Resolved] - private Timeline timeline { get; set; } = null!; - - /// - /// The visible time/position range of the timeline. - /// - private (float min, float max) visibleRange = (float.MinValue, float.MaxValue); - - private readonly Cached groupCache = new Cached(); - - private readonly IBindableList controlPointGroups = new BindableList(); - - protected override void LoadBeatmap(EditorBeatmap beatmap) - { - base.LoadBeatmap(beatmap); - - controlPointGroups.UnbindAll(); - controlPointGroups.BindTo(beatmap.ControlPointInfo.Groups); - controlPointGroups.BindCollectionChanged((_, _) => groupCache.Invalidate(), true); - } - - protected override void Update() - { - base.Update(); - - if (DrawWidth <= 0) return; - - (float, float) newRange = ( - (ToLocalSpace(timeline.ScreenSpaceDrawQuad.TopLeft).X - TopPointPiece.WIDTH) / DrawWidth * Content.RelativeChildSize.X, - (ToLocalSpace(timeline.ScreenSpaceDrawQuad.TopRight).X) / DrawWidth * Content.RelativeChildSize.X); - - if (visibleRange != newRange) - { - visibleRange = newRange; - groupCache.Invalidate(); - } - - if (!groupCache.IsValid) - { - recreateDrawableGroups(); - groupCache.Validate(); - } - } - - private void recreateDrawableGroups() - { - // Remove groups outside the visible range - foreach (TimelineControlPointGroup drawableGroup in this) - { - if (!shouldBeVisible(drawableGroup.Group)) - drawableGroup.Expire(); - } - - // Add remaining ones - for (int i = 0; i < controlPointGroups.Count; i++) - { - var group = controlPointGroups[i]; - - if (!shouldBeVisible(group)) - continue; - - bool alreadyVisible = false; - - foreach (var g in this) - { - if (ReferenceEquals(g.Group, group)) - { - alreadyVisible = true; - break; - } - } - - if (alreadyVisible) - continue; - - Add(new TimelineControlPointGroup(group)); - } - } - - private bool shouldBeVisible(ControlPointGroup group) => group.Time >= visibleRange.min && group.Time <= visibleRange.max; - } -} diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointGroup.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointGroup.cs deleted file mode 100644 index c1b6069523..0000000000 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointGroup.cs +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Beatmaps.ControlPoints; - -namespace osu.Game.Screens.Edit.Compose.Components.Timeline -{ - public partial class TimelineControlPointGroup : CompositeDrawable - { - public readonly ControlPointGroup Group; - - private readonly IBindableList controlPoints = new BindableList(); - - public TimelineControlPointGroup(ControlPointGroup group) - { - Group = group; - - RelativePositionAxes = Axes.X; - RelativeSizeAxes = Axes.Y; - AutoSizeAxes = Axes.X; - - Origin = Anchor.TopLeft; - - X = (float)group.Time; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - controlPoints.BindTo(Group.ControlPoints); - controlPoints.BindCollectionChanged((_, _) => - { - ClearInternal(); - - foreach (var point in controlPoints) - { - switch (point) - { - case TimingControlPoint timingPoint: - AddInternal(new TimingPointPiece(timingPoint)); - break; - } - } - }, true); - } - } -} diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs index 7e6e886ff7..6c0d5af247 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs @@ -30,7 +30,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { public partial class TimelineHitObjectBlueprint : SelectionBlueprint { - private const float circle_size = 38; + private const float circle_size = 32; private Container? repeatsContainer; @@ -50,6 +50,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private readonly Border border; private readonly Container colouredComponents; + private readonly Container sampleComponents; private readonly OsuSpriteText comboIndexText; private readonly SamplePointPiece samplePointPiece; private readonly DifficultyPointPiece? difficultyPointPiece; @@ -107,7 +108,13 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline samplePointPiece = new SamplePointPiece(Item) { Anchor = Anchor.BottomLeft, - Origin = Anchor.TopCentre + Origin = Anchor.TopCentre, + RelativePositionAxes = Axes.X, + AlternativeColor = Item is IHasRepeats + }, + sampleComponents = new Container + { + RelativeSizeAxes = Axes.Both, }, }); @@ -236,6 +243,22 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline X = (float)(i + 1) / (repeats.RepeatCount + 1) }); } + + // Add node sample pieces + sampleComponents.Clear(); + + for (int i = 0; i < repeats.RepeatCount + 2; i++) + { + sampleComponents.Add(new NodeSamplePointPiece(Item, i) + { + X = (float)i / (repeats.RepeatCount + 1), + RelativePositionAxes = Axes.X, + Anchor = Anchor.BottomLeft, + Origin = Anchor.TopCentre + }); + } + + samplePointPiece.X = 1f / (repeats.RepeatCount + 1) / 2; } protected override bool ShouldBeConsideredForInput(Drawable child) => true; @@ -496,7 +519,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { Type = EdgeEffectType.Shadow, Radius = 5, - Colour = Color4.Black.Opacity(0.4f) + Colour = Color4.Black.Opacity(0.05f) } }; } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTickDisplay.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTickDisplay.cs index c3adb43032..66d0df9e18 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTickDisplay.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTickDisplay.cs @@ -2,14 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Caching; using osu.Framework.Graphics; -using osu.Framework.Graphics.Primitives; -using osu.Framework.Logging; using osu.Game.Beatmaps; +using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Screens.Edit.Components.Timelines.Summary.Parts; using osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations; @@ -19,8 +17,10 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { public partial class TimelineTickDisplay : TimelinePart { + public const float TICK_WIDTH = 3; + // With current implementation every tick in the sub-tree should be visible, no need to check whether they are masked away. - public override bool UpdateSubTreeMasking(Drawable source, RectangleF maskingBounds) => false; + public override bool UpdateSubTreeMasking() => false; [Resolved] private EditorBeatmap beatmap { get; set; } = null!; @@ -42,16 +42,21 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline RelativeSizeAxes = Axes.Both; } + private readonly BindableBool showTimingChanges = new BindableBool(true); + private readonly Cached tickCache = new Cached(); [BackgroundDependencyLoader] - private void load() + private void load(OsuConfigManager configManager) { beatDivisor.BindValueChanged(_ => invalidateTicks()); if (changeHandler != null) // currently this is the best way to handle any kind of timing changes. changeHandler.OnStateChange += invalidateTicks; + + configManager.BindWith(OsuSetting.EditorTimelineShowTimingChanges, showTimingChanges); + showTimingChanges.BindValueChanged(_ => invalidateTicks()); } private void invalidateTicks() @@ -135,15 +140,15 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline // even though "bar lines" take up the full vertical space, we render them in two pieces because it allows for less anchor/origin churn. - Vector2 size = Vector2.One; - - if (indexInBar != 0) - size = BindableBeatDivisor.GetSize(divisor); + var size = indexInBar == 0 + ? new Vector2(1.3f, 1) + : BindableBeatDivisor.GetSize(divisor); var line = getNextUsableLine(); line.X = xPos; - line.Width = PointVisualisation.MAX_WIDTH * size.X; - line.Height = 0.9f * size.Y; + + line.Width = TICK_WIDTH * size.X; + line.Height = size.Y; line.Colour = colour; } @@ -151,20 +156,6 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } } - if (Children.Count > 512) - { - // There should always be a sanely small number of ticks rendered. - // If this assertion triggers, either the zoom logic is broken or a beatmap is - // probably doing weird things... - // - // Let's hope the latter never happens. - // If it does, we can choose to either fix it or ignore it as an outlier. - string message = $"Timeline is rendering many ticks ({Children.Count})"; - - Logger.Log(message); - Debug.Fail(message); - } - int usedDrawables = drawableIndex; // save a few drawables beyond the currently used for edge cases. @@ -180,8 +171,15 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline Drawable getNextUsableLine() { PointVisualisation point; + if (drawableIndex >= Count) - Add(point = new PointVisualisation()); + { + Add(point = new PointVisualisation(0) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.Centre, + }); + } else point = Children[drawableIndex]; diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTimingChangeDisplay.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTimingChangeDisplay.cs new file mode 100644 index 0000000000..419f7e111f --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTimingChangeDisplay.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.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Caching; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Screens.Edit.Components.Timelines.Summary.Parts; +using osuTK.Graphics; + +namespace osu.Game.Screens.Edit.Compose.Components.Timeline +{ + /// + /// The part of the timeline that displays the control points. + /// + public partial class TimelineTimingChangeDisplay : TimelinePart + { + [Resolved] + private Timeline timeline { get; set; } = null!; + + /// + /// The visible time/position range of the timeline. + /// + private (float min, float max) visibleRange = (float.MinValue, float.MaxValue); + + private readonly Cached groupCache = new Cached(); + + private ControlPointInfo controlPointInfo = null!; + + protected override void LoadBeatmap(EditorBeatmap beatmap) + { + base.LoadBeatmap(beatmap); + + beatmap.ControlPointInfo.ControlPointsChanged += () => groupCache.Invalidate(); + controlPointInfo = beatmap.ControlPointInfo; + } + + protected override void Update() + { + base.Update(); + + if (DrawWidth <= 0) return; + + (float, float) newRange = ( + (ToLocalSpace(timeline.ScreenSpaceDrawQuad.TopLeft).X - TimingPointPiece.WIDTH) / DrawWidth * Content.RelativeChildSize.X, + (ToLocalSpace(timeline.ScreenSpaceDrawQuad.TopRight).X + TimingPointPiece.WIDTH) / DrawWidth * Content.RelativeChildSize.X); + + if (visibleRange != newRange) + { + visibleRange = newRange; + groupCache.Invalidate(); + } + + if (!groupCache.IsValid) + { + recreateDrawableGroups(); + groupCache.Validate(); + } + } + + private void recreateDrawableGroups() + { + // Remove groups outside the visible range (or timing points which have since been removed from the beatmap). + foreach (TimingPointPiece drawableGroup in this) + { + if (!controlPointInfo.TimingPoints.Contains(drawableGroup.Point) || !shouldBeVisible(drawableGroup.Point)) + drawableGroup.Expire(); + } + + // Add remaining / new ones. + foreach (TimingControlPoint t in controlPointInfo.TimingPoints) + attemptAddTimingPoint(t); + } + + private void attemptAddTimingPoint(TimingControlPoint point) + { + if (!shouldBeVisible(point)) + return; + + foreach (var child in this) + { + if (ReferenceEquals(child.Point, point)) + return; + } + + Add(new TimingPointPiece(point)); + } + + private bool shouldBeVisible(TimingControlPoint point) => point.Time >= visibleRange.min && point.Time <= visibleRange.max; + + public partial class TimingPointPiece : CompositeDrawable + { + public const float WIDTH = 16; + + public readonly TimingControlPoint Point; + + private readonly BindableNumber beatLength; + + protected OsuSpriteText Label { get; private set; } = null!; + + public TimingPointPiece(TimingControlPoint timingPoint) + { + RelativePositionAxes = Axes.X; + + RelativeSizeAxes = Axes.Y; + Width = WIDTH; + + Origin = Anchor.TopRight; + + Point = timingPoint; + + beatLength = timingPoint.BeatLengthBindable.GetBoundCopy(); + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + InternalChildren = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Colour = Point.GetRepresentingColour(colours), + Masking = true, + CornerRadius = TimelineTickDisplay.TICK_WIDTH / 2, + Child = new Box + { + Colour = Color4.White, + RelativeSizeAxes = Axes.Both, + }, + }, + Label = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Rotation = 90, + Padding = new MarginPadding { Horizontal = 2 }, + Font = OsuFont.Default.With(size: 12, weight: FontWeight.SemiBold), + } + }; + + beatLength.BindValueChanged(beatLength => + { + Label.Text = $"{60000 / beatLength.NewValue:n1} BPM"; + }, true); + } + + protected override void Update() + { + base.Update(); + X = (float)Point.Time; + } + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimingPointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimingPointPiece.cs deleted file mode 100644 index 2a4ad66918..0000000000 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimingPointPiece.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Game.Beatmaps.ControlPoints; - -namespace osu.Game.Screens.Edit.Compose.Components.Timeline -{ - public partial class TimingPointPiece : TopPointPiece - { - private readonly BindableNumber beatLength; - - public TimingPointPiece(TimingControlPoint point) - : base(point) - { - beatLength = point.BeatLengthBindable.GetBoundCopy(); - } - - [BackgroundDependencyLoader] - private void load() - { - beatLength.BindValueChanged(beatLength => - { - Label.Text = $"{60000 / beatLength.NewValue:n1} BPM"; - }, true); - } - } -} diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TopPointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TopPointPiece.cs deleted file mode 100644 index a40a805361..0000000000 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TopPointPiece.cs +++ /dev/null @@ -1,91 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Screens.Edit.Compose.Components.Timeline -{ - public partial class TopPointPiece : CompositeDrawable - { - protected readonly ControlPoint Point; - - protected OsuSpriteText Label { get; private set; } = null!; - - public const float WIDTH = 80; - - public TopPointPiece(ControlPoint point) - { - Point = point; - Width = WIDTH; - Height = 16; - Margin = new MarginPadding { Vertical = 4 }; - - Origin = Anchor.TopCentre; - Anchor = Anchor.TopCentre; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - const float corner_radius = 4; - const float arrow_extension = 3; - const float triangle_portion = 15; - - InternalChildren = new Drawable[] - { - // This is a triangle, trust me. - // Doing it this way looks okay. Doing it using Triangle primitive is basically impossible. - new Container - { - Colour = Point.GetRepresentingColour(colours), - X = -corner_radius, - Size = new Vector2(triangle_portion * arrow_extension, Height), - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Masking = true, - CornerRadius = Height, - CornerExponent = 1.4f, - Children = new Drawable[] - { - new Box - { - Colour = Color4.White, - RelativeSizeAxes = Axes.Both, - }, - } - }, - new Container - { - RelativeSizeAxes = Axes.Y, - Width = WIDTH - triangle_portion, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Colour = Point.GetRepresentingColour(colours), - Masking = true, - CornerRadius = corner_radius, - Child = new Box - { - Colour = Color4.White, - RelativeSizeAxes = Axes.Both, - }, - }, - Label = new OsuSpriteText - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Padding = new MarginPadding(3), - Font = OsuFont.Default.With(size: 14, weight: FontWeight.SemiBold), - Colour = colours.B5, - } - }; - } - } -} diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs index 848c8f9a0f..31a0936eb4 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Transforms; @@ -32,10 +33,10 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline protected override Container Content => zoomedContent; /// - /// The current zoom level of . + /// The current (final) zoom level of . /// It may differ from during transitions. /// - public float CurrentZoom { get; private set; } = 1; + public BindableFloat CurrentZoom { get; private set; } = new BindableFloat(1); private bool isZoomSetUp; @@ -98,7 +99,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline minZoom = minimum; maxZoom = maximum; - CurrentZoom = zoomTarget = initial; + CurrentZoom.Value = zoomTarget = initial; zoomedContentWidthCache.Invalidate(); isZoomSetUp = true; @@ -124,7 +125,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline if (IsLoaded) setZoomTarget(newZoom, ToSpaceOfOtherDrawable(new Vector2(DrawWidth / 2, 0), zoomedContent).X); else - CurrentZoom = zoomTarget = newZoom; + CurrentZoom.Value = zoomTarget = newZoom; } protected override void UpdateAfterChildren() @@ -154,7 +155,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private void updateZoomedContentWidth() { - zoomedContent.Width = DrawWidth * CurrentZoom; + zoomedContent.Width = DrawWidth * CurrentZoom.Value; zoomedContentWidthCache.Validate(); } @@ -238,7 +239,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline float expectedWidth = d.DrawWidth * newZoom; float targetOffset = expectedWidth * (focusPoint / contentSize) - focusOffset; - d.CurrentZoom = newZoom; + d.CurrentZoom.Value = newZoom; d.updateZoomedContentWidth(); // Temporarily here to make sure ScrollTo gets the correct DrawSize for scrollable area. @@ -247,7 +248,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline d.ScrollTo(targetOffset, false); } - protected override void ReadIntoStartValue(ZoomableScrollContainer d) => StartValue = d.CurrentZoom; + protected override void ReadIntoStartValue(ZoomableScrollContainer d) => StartValue = d.CurrentZoom.Value; } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/TriangularPositionSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/TriangularPositionSnapGrid.cs new file mode 100644 index 0000000000..91aea1de8d --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/TriangularPositionSnapGrid.cs @@ -0,0 +1,89 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Bindables; +using osu.Game.Utils; +using osuTK; + +namespace osu.Game.Screens.Edit.Compose.Components +{ + public partial class TriangularPositionSnapGrid : LinedPositionSnapGrid + { + /// + /// The spacing between grid lines of this . + /// + public BindableFloat Spacing { get; } = new BindableFloat(1f) + { + MinValue = 0f, + }; + + /// + /// The rotation in degrees of the grid lines of this . + /// + public BindableFloat GridLineRotation { get; } = new BindableFloat(); + + public TriangularPositionSnapGrid() + { + Spacing.BindValueChanged(_ => GridCache.Invalidate()); + GridLineRotation.BindValueChanged(_ => GridCache.Invalidate()); + } + + private static readonly float sqrt3 = float.Sqrt(3); + private static readonly float sqrt3_over2 = sqrt3 / 2; + private static readonly float one_over_sqrt3 = 1 / sqrt3; + + protected override void CreateContent() + { + var drawSize = DrawSize; + float stepSpacing = Spacing.Value * sqrt3_over2; + var step1 = GeometryUtils.RotateVector(new Vector2(stepSpacing, 0), -GridLineRotation.Value - 30); + var step2 = GeometryUtils.RotateVector(new Vector2(stepSpacing, 0), -GridLineRotation.Value - 90); + var step3 = GeometryUtils.RotateVector(new Vector2(stepSpacing, 0), -GridLineRotation.Value - 150); + + GenerateGridLines(step1, drawSize); + GenerateGridLines(-step1, drawSize); + + GenerateGridLines(step2, drawSize); + GenerateGridLines(-step2, drawSize); + + GenerateGridLines(step3, drawSize); + GenerateGridLines(-step3, drawSize); + + GenerateOutline(drawSize); + } + + public override Vector2 GetSnappedPosition(Vector2 original) + { + Vector2 relativeToStart = GeometryUtils.RotateVector(original - StartPosition.Value, GridLineRotation.Value); + Vector2 hex = pixelToHex(relativeToStart); + + return StartPosition.Value + GeometryUtils.RotateVector(hexToPixel(hex), -GridLineRotation.Value); + } + + private Vector2 pixelToHex(Vector2 pixel) + { + float x = pixel.X / Spacing.Value; + float y = pixel.Y / Spacing.Value; + // Algorithm from Charles Chambers + // with modifications and comments by Chris Cox 2023 + // + float t = sqrt3 * y + 1; // scaled y, plus phase + float temp1 = MathF.Floor(t + x); // (y+x) diagonal, this calc needs floor + float temp2 = t - x; // (y-x) diagonal, no floor needed + float temp3 = 2 * x + 1; // scaled horizontal, no floor needed, needs +1 to get correct phase + float qf = (temp1 + temp3) / 3.0f; // pseudo x with fraction + float rf = (temp1 + temp2) / 3.0f; // pseudo y with fraction + float q = MathF.Floor(qf); // pseudo x, quantized and thus requires floor + float r = MathF.Floor(rf); // pseudo y, quantized and thus requires floor + return new Vector2(q, r); + } + + private Vector2 hexToPixel(Vector2 hex) + { + // Taken from + // with modifications for the different definition of size. + return new Vector2(Spacing.Value * (hex.X - hex.Y / 2), Spacing.Value * one_over_sqrt3 * 1.5f * hex.Y); + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs index 0a58b34da6..f7e523db25 100644 --- a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs +++ b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs @@ -69,7 +69,26 @@ namespace osu.Game.Screens.Edit.Compose if (ruleset == null || composer == null) return base.CreateTimelineContent(); - return wrapSkinnableContent(new TimelineBlueprintContainer(composer)); + TimelineBreakDisplay breakDisplay = new TimelineBreakDisplay + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Height = 0.75f, + }; + + return wrapSkinnableContent(new Container + { + RelativeSizeAxes = Axes.Both, + Children = new[] + { + // We want to display this below hitobjects to better expose placement objects visually. + // It needs to be above the blueprint container to handle drags on breaks though. + breakDisplay.CreateProxy(), + new TimelineBlueprintContainer(composer), + breakDisplay + } + }); } private Drawable wrapSkinnableContent(Drawable content) diff --git a/osu.Game/Screens/Edit/Compose/IPlacementHandler.cs b/osu.Game/Screens/Edit/Compose/IPlacementHandler.cs index 57960a76a1..e2046cd532 100644 --- a/osu.Game/Screens/Edit/Compose/IPlacementHandler.cs +++ b/osu.Game/Screens/Edit/Compose/IPlacementHandler.cs @@ -10,17 +10,21 @@ namespace osu.Game.Screens.Edit.Compose public interface IPlacementHandler { /// - /// Notifies that a placement has begun. + /// Notifies that a placement blueprint became visible on the screen. /// - /// The being placed. - void BeginPlacement(HitObject hitObject); + /// The representing the placement. + void ShowPlacement(HitObject hitObject); /// - /// Notifies that a placement has finished. + /// Notifies that a visible placement blueprint has been hidden. + /// + void HidePlacement(); + + /// + /// Notifies that a placement has been committed. /// /// The that has been placed. - /// Whether the object should be committed. - void EndPlacement(HitObject hitObject, bool commit); + void CommitPlacement(HitObject hitObject); /// /// Deletes a . diff --git a/osu.Game/Screens/Edit/DeleteDifficultyConfirmationDialog.cs b/osu.Game/Screens/Edit/DeleteDifficultyConfirmationDialog.cs index 8556949528..1aeb1d8a40 100644 --- a/osu.Game/Screens/Edit/DeleteDifficultyConfirmationDialog.cs +++ b/osu.Game/Screens/Edit/DeleteDifficultyConfirmationDialog.cs @@ -7,7 +7,7 @@ using osu.Game.Overlays.Dialog; namespace osu.Game.Screens.Edit { - public partial class DeleteDifficultyConfirmationDialog : DangerousActionDialog + public partial class DeleteDifficultyConfirmationDialog : DeletionDialog { public DeleteDifficultyConfirmationDialog(BeatmapInfo beatmapInfo, Action deleteAction) { diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index 980c613311..644e1afb3b 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -6,6 +6,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using JetBrains.Annotations; using osu.Framework; using osu.Framework.Allocation; @@ -35,12 +37,14 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osu.Game.Localisation; using osu.Game.Online.API; +using osu.Game.Online.Multiplayer; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; using osu.Game.Overlays.OSD; using osu.Game.Rulesets; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Screens.Edit.Components.Menus; using osu.Game.Screens.Edit.Compose; using osu.Game.Screens.Edit.Compose.Components.Timeline; @@ -49,6 +53,7 @@ using osu.Game.Screens.Edit.GameplayTest; using osu.Game.Screens.Edit.Setup; using osu.Game.Screens.Edit.Timing; using osu.Game.Screens.Edit.Verify; +using osu.Game.Screens.OnlinePlay; using osu.Game.Screens.Play; using osu.Game.Users; using osuTK.Input; @@ -142,6 +147,13 @@ namespace osu.Game.Screens.Edit private readonly Bindable samplePlaybackDisabled = new Bindable(); private bool canSave; + private readonly List saveRelatedMenuItems = new List(); + + /// + /// Tracks ongoing mutually-exclusive operations related to changing the beatmap + /// (e.g. save, export). + /// + public OngoingOperationTracker MutationTracker { get; } = new OngoingOperationTracker(); protected bool ExitConfirmed { get; private set; } @@ -149,7 +161,7 @@ namespace osu.Game.Screens.Edit private string lastSavedHash; - private Container screenContainer; + private ScreenContainer screenContainer; [CanBeNull] private readonly EditorLoader loader; @@ -201,6 +213,22 @@ namespace osu.Game.Screens.Edit private Bindable editorHitMarkers; private Bindable editorAutoSeekOnPlacement; private Bindable editorLimitedDistanceSnap; + private Bindable editorTimelineShowTimingChanges; + private Bindable editorTimelineShowBreaks; + private Bindable editorTimelineShowTicks; + private Bindable editorContractSidebars; + + /// + /// This controls the opacity of components like the timelines, sidebars, etc. + /// In "composer focus" mode the opacity of the aforementioned components is reduced so that the user can focus on the composer better. + /// + /// + /// The state of this bindable is controlled by when in mode. + /// + public Bindable ComposerFocusMode { get; } = new Bindable(); + + [CanBeNull] + public event Action ShowSampleEditPopoverRequested; public Editor(EditorLoader loader = null) { @@ -274,7 +302,7 @@ namespace osu.Game.Screens.Edit dependencies.CacheAs(changeHandler); } - beatDivisor.Value = editorBeatmap.BeatmapInfo.BeatDivisor; + beatDivisor.SetArbitraryDivisor(editorBeatmap.BeatmapInfo.BeatDivisor); beatDivisor.BindValueChanged(divisor => editorBeatmap.BeatmapInfo.BeatDivisor = divisor.NewValue); updateLastSavedHash(); @@ -295,6 +323,10 @@ namespace osu.Game.Screens.Edit editorHitMarkers = config.GetBindable(OsuSetting.EditorShowHitMarkers); editorAutoSeekOnPlacement = config.GetBindable(OsuSetting.EditorAutoSeekOnPlacement); editorLimitedDistanceSnap = config.GetBindable(OsuSetting.EditorLimitedDistanceSnap); + editorTimelineShowTimingChanges = config.GetBindable(OsuSetting.EditorTimelineShowTimingChanges); + editorTimelineShowBreaks = config.GetBindable(OsuSetting.EditorTimelineShowBreaks); + editorTimelineShowTicks = config.GetBindable(OsuSetting.EditorTimelineShowTicks); + editorContractSidebars = config.GetBindable(OsuSetting.EditorContractSidebars); AddInternal(new OsuContextMenuContainer { @@ -305,11 +337,10 @@ namespace osu.Game.Screens.Edit { Name = "Screen container", RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Top = 40, Bottom = 60 }, - Child = screenContainer = new Container + Padding = new MarginPadding { Top = 40, Bottom = 50 }, + Child = screenContainer = new ScreenContainer { RelativeSizeAxes = Axes.Both, - Masking = true } }, new Container @@ -324,30 +355,49 @@ namespace osu.Game.Screens.Edit Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, RelativeSizeAxes = Axes.Both, + MaxHeight = 600, Items = new[] { new MenuItem(CommonStrings.MenuBarFile) { - Items = createFileMenuItems() + Items = createFileMenuItems().ToList() }, new MenuItem(CommonStrings.MenuBarEdit) { Items = new[] { - undoMenuItem = new EditorMenuItem(CommonStrings.Undo, MenuItemType.Standard, Undo), - redoMenuItem = new EditorMenuItem(CommonStrings.Redo, MenuItemType.Standard, Redo), + undoMenuItem = new EditorMenuItem(CommonStrings.Undo, MenuItemType.Standard, Undo) { Hotkey = new Hotkey(PlatformAction.Undo) }, + redoMenuItem = new EditorMenuItem(CommonStrings.Redo, MenuItemType.Standard, Redo) { Hotkey = new Hotkey(PlatformAction.Redo) }, new OsuMenuItemSpacer(), - cutMenuItem = new EditorMenuItem(CommonStrings.Cut, MenuItemType.Standard, Cut), - copyMenuItem = new EditorMenuItem(CommonStrings.Copy, MenuItemType.Standard, Copy), - pasteMenuItem = new EditorMenuItem(CommonStrings.Paste, MenuItemType.Standard, Paste), - cloneMenuItem = new EditorMenuItem(CommonStrings.Clone, MenuItemType.Standard, Clone), + cutMenuItem = new EditorMenuItem(CommonStrings.Cut, MenuItemType.Standard, Cut) { Hotkey = new Hotkey(PlatformAction.Cut) }, + copyMenuItem = new EditorMenuItem(CommonStrings.Copy, MenuItemType.Standard, Copy) { Hotkey = new Hotkey(PlatformAction.Copy) }, + pasteMenuItem = new EditorMenuItem(CommonStrings.Paste, MenuItemType.Standard, Paste) { Hotkey = new Hotkey(PlatformAction.Paste) }, + cloneMenuItem = new EditorMenuItem(CommonStrings.Clone, MenuItemType.Standard, Clone) { Hotkey = new Hotkey(GlobalAction.EditorCloneSelection) }, } }, new MenuItem(CommonStrings.MenuBarView) { - Items = new MenuItem[] + Items = new[] { - new WaveformOpacityMenuItem(config.GetBindable(OsuSetting.EditorWaveformOpacity)), + new MenuItem(EditorStrings.Timeline) + { + Items = + [ + new WaveformOpacityMenuItem(config.GetBindable(OsuSetting.EditorWaveformOpacity)), + new ToggleMenuItem(EditorStrings.TimelineShowTimingChanges) + { + State = { BindTarget = editorTimelineShowTimingChanges } + }, + new ToggleMenuItem(EditorStrings.TimelineShowTicks) + { + State = { BindTarget = editorTimelineShowTicks } + }, + new ToggleMenuItem(EditorStrings.TimelineShowBreaks) + { + State = { BindTarget = editorTimelineShowBreaks } + }, + ] + }, new BackgroundDimMenuItem(editorBackgroundDim), new ToggleMenuItem(EditorStrings.ShowHitMarkers) { @@ -360,14 +410,18 @@ namespace osu.Game.Screens.Edit new ToggleMenuItem(EditorStrings.LimitedDistanceSnap) { State = { BindTarget = editorLimitedDistanceSnap }, - } + }, + new ToggleMenuItem(EditorStrings.ContractSidebars) + { + State = { BindTarget = editorContractSidebars } + }, } }, new MenuItem(EditorStrings.Timing) { Items = new MenuItem[] { - new EditorMenuItem(EditorStrings.SetPreviewPointToCurrent, MenuItemType.Standard, SetPreviewPointToCurrentTime) + new EditorMenuItem(EditorStrings.SetPreviewPointToCurrent, MenuItemType.Standard, SetPreviewPointToCurrentTime), } } } @@ -382,8 +436,10 @@ namespace osu.Game.Screens.Edit }, }, bottomBar = new BottomBar(), + MutationTracker, } }); + changeHandler?.CanUndo.BindValueChanged(v => undoMenuItem.Action.Disabled = !v.NewValue, true); changeHandler?.CanRedo.BindValueChanged(v => redoMenuItem.Action.Disabled = !v.NewValue, true); @@ -402,6 +458,12 @@ namespace osu.Game.Screens.Edit Mode.BindValueChanged(onModeChanged, true); musicController.TrackChanged += onTrackChanged; + + MutationTracker.InProgress.BindValueChanged(_ => + { + foreach (var item in saveRelatedMenuItems) + item.Action.Disabled = MutationTracker.InProgress.Value; + }, true); } protected override void Dispose(bool isDisposing) @@ -440,12 +502,13 @@ namespace osu.Game.Screens.Edit { if (HasUnsavedChanges) { - dialogOverlay.Push(new SaveRequiredPopupDialog("The beatmap will be saved in order to test it.", () => + dialogOverlay.Push(new SaveRequiredPopupDialog(() => attemptMutationOperation(() => { - if (!Save()) return; + if (!Save()) return false; pushEditorPlayer(); - })); + return true; + }))); } else { @@ -455,11 +518,31 @@ namespace osu.Game.Screens.Edit void pushEditorPlayer() => this.Push(new EditorPlayerLoader(this)); } + private bool attemptMutationOperation(Func mutationOperation) + { + if (MutationTracker.InProgress.Value) + return false; + + using (MutationTracker.BeginOperation()) + return mutationOperation.Invoke(); + } + + private bool attemptAsyncMutationOperation(Func mutationTask) + { + if (MutationTracker.InProgress.Value) + return false; + + var operation = MutationTracker.BeginOperation(); + var task = mutationTask.Invoke(); + task.FireAndForget(operation.Dispose, _ => operation.Dispose()); + return true; + } + /// /// Saves the currently edited beatmap. /// /// Whether the save was successful. - protected bool Save() + internal bool Save() { if (!canSave) { @@ -520,8 +603,7 @@ namespace osu.Game.Screens.Edit if (e.Repeat) return false; - Save(); - return true; + return attemptMutationOperation(Save); } return false; @@ -647,6 +729,32 @@ namespace osu.Game.Screens.Edit public bool OnPressed(KeyBindingPressEvent e) { + // Repeatable actions + switch (e.Action) + { + case GlobalAction.EditorSeekToPreviousHitObject: + if (editorBeatmap.SelectedHitObjects.Any()) + return false; + + seekHitObject(-1); + return true; + + case GlobalAction.EditorSeekToNextHitObject: + if (editorBeatmap.SelectedHitObjects.Any()) + return false; + + seekHitObject(1); + return true; + + case GlobalAction.EditorSeekToPreviousSamplePoint: + seekSamplePoint(-1); + return true; + + case GlobalAction.EditorSeekToNextSamplePoint: + seekSamplePoint(1); + return true; + } + if (e.Repeat) return false; @@ -684,10 +792,9 @@ namespace osu.Game.Screens.Edit case GlobalAction.EditorTestGameplay: bottomBar.TestGameplayButton.TriggerClick(); return true; - - default: - return false; } + + return false; } public void OnReleased(KeyBindingReleaseEvent e) @@ -719,6 +826,8 @@ namespace osu.Game.Screens.Edit public override bool OnExiting(ScreenExitEvent e) { + currentScreen?.OnExiting(e); + if (!ExitConfirmed) { // dialog overlay may not be available in visual tests. @@ -785,7 +894,8 @@ namespace osu.Game.Screens.Edit private void confirmExitWithSave() { - if (!Save()) return; + if (!attemptMutationOperation(Save)) + return; ExitConfirmed = true; this.Exit(); @@ -940,7 +1050,7 @@ namespace osu.Game.Screens.Edit throw new InvalidOperationException("Editor menu bar switched to an unsupported mode"); } - LoadComponentAsync(currentScreen, newScreen => + screenContainer.LoadComponentAsync(currentScreen, newScreen => { if (newScreen == currentScreen) { @@ -951,11 +1061,27 @@ namespace osu.Game.Screens.Edit } finally { + if (Mode.Value != EditorScreenMode.Compose) + ComposerFocusMode.Value = false; + updateSampleDisabledState(); rebindClipboardBindables(); } } + /// + /// Forces a reload of the compose screen after significant configuration changes. + /// + public void ReloadComposeScreen() + { + screenContainer.SingleOrDefault(s => s.Type == EditorScreenMode.Compose)?.RemoveAndDisposeImmediately(); + + // If not currently on compose screen, the reload will happen on next mode change. + // That said, control points *can* change on compose screen (e.g. via undo), so we have to handle that case too. + if (Mode.Value == EditorScreenMode.Compose) + Mode.TriggerChange(); + } + [CanBeNull] private ScheduledDelegate playbackDisabledDebounce; @@ -984,14 +1110,78 @@ namespace osu.Game.Screens.Edit private void seekControlPoint(int direction) { - var found = direction < 1 - ? editorBeatmap.ControlPointInfo.AllControlPoints.LastOrDefault(p => p.Time < clock.CurrentTime) + // In the case of a backwards seek while playing, it can be hard to jump before a timing point. + // Adding some lenience here makes it more user-friendly. + double seekLenience = clock.IsRunning ? 1000 * ((IAdjustableClock)clock).Rate : 0; + + ControlPoint found = direction < 1 + ? editorBeatmap.ControlPointInfo.AllControlPoints.LastOrDefault(p => p.Time < clock.CurrentTime - seekLenience) : editorBeatmap.ControlPointInfo.AllControlPoints.FirstOrDefault(p => p.Time > clock.CurrentTime); if (found != null) clock.Seek(found.Time); } + private void seekHitObject(int direction) + { + var found = direction < 1 + ? editorBeatmap.HitObjects.LastOrDefault(p => p.StartTime < clock.CurrentTimeAccurate) + : editorBeatmap.HitObjects.FirstOrDefault(p => p.StartTime > clock.CurrentTimeAccurate); + + if (found != null) + clock.SeekSmoothlyTo(found.StartTime); + } + + private void seekSamplePoint(int direction) + { + double currentTime = clock.CurrentTimeAccurate; + + // Check if we are currently inside a hit object with node samples, if so seek to the next node sample point + var current = direction < 1 + ? editorBeatmap.HitObjects.LastOrDefault(p => p is IHasRepeats r && p.StartTime < currentTime && r.EndTime >= currentTime) + : editorBeatmap.HitObjects.LastOrDefault(p => p is IHasRepeats r && p.StartTime <= currentTime && r.EndTime > currentTime); + + if (current != null) + { + // Find the next node sample point + var r = (IHasRepeats)current; + double[] nodeSamplePointTimes = new double[r.RepeatCount + 3]; + + nodeSamplePointTimes[0] = current.StartTime; + // The sample point for the main samples is sandwiched between the head and the first repeat + nodeSamplePointTimes[1] = current.StartTime + r.Duration / r.SpanCount() / 2; + + for (int i = 0; i < r.SpanCount(); i++) + { + nodeSamplePointTimes[i + 2] = current.StartTime + r.Duration * (i + 1) / r.SpanCount(); + } + + double found = direction < 1 + ? nodeSamplePointTimes.Last(p => p < currentTime) + : nodeSamplePointTimes.First(p => p > currentTime); + + clock.SeekSmoothlyTo(found); + } + else + { + if (direction < 1) + { + current = editorBeatmap.HitObjects.LastOrDefault(p => p.StartTime < currentTime); + if (current != null) + clock.SeekSmoothlyTo(current is IHasRepeats r ? r.EndTime : current.StartTime); + } + else + { + current = editorBeatmap.HitObjects.FirstOrDefault(p => p.StartTime > currentTime); + if (current != null) + clock.SeekSmoothlyTo(current.StartTime); + } + } + + // Show the sample edit popover at the current time + ShowSampleEditPopoverRequested?.Invoke(clock.CurrentTimeAccurate); + } + private void seek(UIEvent e, int direction) { double amount = e.ShiftPressed ? 4 : 1; @@ -1018,52 +1208,91 @@ namespace osu.Game.Screens.Edit lastSavedHash = changeHandler?.CurrentStateHash; } - private List createFileMenuItems() => new List + private IEnumerable createFileMenuItems() { - createDifficultyCreationMenu(), - createDifficultySwitchMenu(), - new OsuMenuItemSpacer(), - new EditorMenuItem(EditorStrings.DeleteDifficulty, MenuItemType.Standard, deleteDifficulty) { Action = { Disabled = Beatmap.Value.BeatmapSetInfo.Beatmaps.Count < 2 } }, - new OsuMenuItemSpacer(), - new EditorMenuItem(WebCommonStrings.ButtonsSave, MenuItemType.Standard, () => Save()), - createExportMenu(), - new OsuMenuItemSpacer(), - new EditorMenuItem(CommonStrings.Exit, MenuItemType.Standard, this.Exit) - }; + yield return createDifficultyCreationMenu(); + yield return createDifficultySwitchMenu(); + yield return new OsuMenuItemSpacer(); + yield return new EditorMenuItem(EditorStrings.DeleteDifficulty, MenuItemType.Standard, deleteDifficulty) { Action = { Disabled = Beatmap.Value.BeatmapSetInfo.Beatmaps.Count < 2 } }; + yield return new OsuMenuItemSpacer(); + + var save = new EditorMenuItem(WebCommonStrings.ButtonsSave, MenuItemType.Standard, () => attemptMutationOperation(Save)) { Hotkey = new Hotkey(PlatformAction.Save) }; + saveRelatedMenuItems.Add(save); + yield return save; + + if (RuntimeInfo.IsDesktop) + { + var export = createExportMenu(); + saveRelatedMenuItems.AddRange(export.Items); + yield return export; + + var externalEdit = new EditorMenuItem("Edit externally", MenuItemType.Standard, editExternally); + saveRelatedMenuItems.Add(externalEdit); + yield return externalEdit; + } + + yield return new OsuMenuItemSpacer(); + yield return new EditorMenuItem(CommonStrings.Exit, MenuItemType.Standard, this.Exit); + } private EditorMenuItem createExportMenu() { var exportItems = new List { - new EditorMenuItem(EditorStrings.ExportForEditing, MenuItemType.Standard, () => exportBeatmap(false)) { Action = { Disabled = !RuntimeInfo.IsDesktop } }, - new EditorMenuItem(EditorStrings.ExportForCompatibility, MenuItemType.Standard, () => exportBeatmap(true)) { Action = { Disabled = !RuntimeInfo.IsDesktop } }, + new EditorMenuItem(EditorStrings.ExportForEditing, MenuItemType.Standard, () => exportBeatmap(false)), + new EditorMenuItem(EditorStrings.ExportForCompatibility, MenuItemType.Standard, () => exportBeatmap(true)), }; return new EditorMenuItem(CommonStrings.Export) { Items = exportItems }; } + private void editExternally() + { + if (HasUnsavedChanges) + { + dialogOverlay.Push(new SaveRequiredPopupDialog(() => attemptMutationOperation(() => + { + if (!Save()) + return false; + + startEdit(); + return true; + }))); + } + else + { + startEdit(); + } + + void startEdit() + { + this.Push(new ExternalEditScreen()); + } + } + private void exportBeatmap(bool legacy) { if (HasUnsavedChanges) { - dialogOverlay.Push(new SaveRequiredPopupDialog("The beatmap will be saved in order to export it.", () => + dialogOverlay.Push(new SaveRequiredPopupDialog(() => attemptAsyncMutationOperation(() => { - if (!Save()) return; + if (!Save()) + return Task.CompletedTask; - runExport(); - })); + return runExport(); + }))); } else { - runExport(); + attemptAsyncMutationOperation(runExport); } - void runExport() + Task runExport() { if (legacy) - beatmapManager.ExportLegacy(Beatmap.Value.BeatmapSetInfo); + return beatmapManager.ExportLegacy(Beatmap.Value.BeatmapSetInfo); else - beatmapManager.Export(Beatmap.Value.BeatmapSetInfo); + return beatmapManager.Export(Beatmap.Value.BeatmapSetInfo); } } @@ -1114,6 +1343,8 @@ namespace osu.Game.Screens.Edit foreach (var ruleset in rulesets.AvailableRulesets) rulesetItems.Add(new EditorMenuItem(ruleset.Name, MenuItemType.Standard, () => CreateNewDifficulty(ruleset))); + saveRelatedMenuItems.AddRange(rulesetItems); + return new EditorMenuItem(EditorStrings.CreateNewDifficulty) { Items = rulesetItems }; } @@ -1121,13 +1352,14 @@ namespace osu.Game.Screens.Edit { if (isNewBeatmap) { - dialogOverlay.Push(new SaveRequiredPopupDialog("This beatmap will be saved in order to create another difficulty.", () => + dialogOverlay.Push(new SaveRequiredPopupDialog(() => attemptMutationOperation(() => { if (!Save()) - return; + return false; CreateNewDifficulty(rulesetInfo); - })); + return true; + }))); return; } @@ -1159,14 +1391,31 @@ namespace osu.Game.Screens.Edit foreach (var beatmap in rulesetBeatmaps) { bool isCurrentDifficulty = playableBeatmap.BeatmapInfo.Equals(beatmap); - difficultyItems.Add(new DifficultyMenuItem(beatmap, isCurrentDifficulty, SwitchToDifficulty)); + var difficultyMenuItem = new DifficultyMenuItem(beatmap, isCurrentDifficulty, SwitchToDifficulty); + difficultyItems.Add(difficultyMenuItem); } } + // Ensure difficulty names are updated when modified in the editor. + // Maybe we could trigger less often but this seems to work well enough. + editorBeatmap.SaveStateTriggered += () => + { + foreach (var beatmapInfo in Beatmap.Value.BeatmapSetInfo.Beatmaps) + { + var menuItem = difficultyItems.OfType().FirstOrDefault(i => i.BeatmapInfo.Equals(beatmapInfo)); + if (menuItem != null) + menuItem.Text.Value = string.IsNullOrEmpty(beatmapInfo.DifficultyName) ? "(unnamed)" : beatmapInfo.DifficultyName; + } + }; + return new EditorMenuItem(EditorStrings.ChangeDifficulty) { Items = difficultyItems }; } - protected void SwitchToDifficulty(BeatmapInfo nextBeatmap) => loader?.ScheduleSwitchToExistingDifficulty(nextBeatmap, GetState(nextBeatmap.Ruleset)); + public void SwitchToDifficulty(BeatmapInfo nextBeatmap) + { + switchingDifficulty = true; + loader?.ScheduleSwitchToExistingDifficulty(nextBeatmap, GetState(nextBeatmap.Ruleset)); + } private void cancelExit() { @@ -1174,16 +1423,41 @@ namespace osu.Game.Screens.Edit loader?.CancelPendingDifficultySwitch(); } - public void HandleTimestamp(string timestamp) + public Task Reload() + { + var tcs = new TaskCompletionSource(); + + dialogOverlay.Push(new ReloadEditorDialog( + reload: () => + { + bool reloadedSuccessfully = attemptMutationOperation(() => + { + if (!Save()) + return false; + + SwitchToDifficulty(editorBeatmap.BeatmapInfo); + return true; + }); + tcs.SetResult(reloadedSuccessfully); + }, + cancel: () => tcs.SetResult(false))); + return tcs.Task; + } + + public bool HandleTimestamp(string timestamp, bool notifyOnError = false) { if (!EditorTimestampParser.TryParse(timestamp, out var timeSpan, out string selection)) { - Schedule(() => notifications?.Post(new SimpleErrorNotification + if (notifyOnError) { - Icon = FontAwesome.Solid.ExclamationTriangle, - Text = EditorStrings.FailedToParseEditorLink - })); - return; + Schedule(() => notifications?.Post(new SimpleErrorNotification + { + Icon = FontAwesome.Solid.ExclamationTriangle, + Text = EditorStrings.FailedToParseEditorLink + })); + } + + return false; } editorBeatmap.SelectedHitObjects.Clear(); @@ -1196,7 +1470,7 @@ namespace osu.Game.Screens.Edit if (string.IsNullOrEmpty(selection)) { clock.SeekSmoothlyTo(position); - return; + return true; } // Seek to the next closest HitObject instead @@ -1211,6 +1485,7 @@ namespace osu.Game.Screens.Edit // Delegate handling the selection to the ruleset. currentScreen.Dependencies.Get().SelectFromTimestamp(position, selection); + return true; } public double SnapTime(double time, double? referenceTime) => editorBeatmap.SnapTime(time, referenceTime); @@ -1230,5 +1505,12 @@ namespace osu.Game.Screens.Edit { } } + + private partial class ScreenContainer : Container + { + public new Task LoadComponentAsync([NotNull] TLoadable component, Action onLoaded = null, CancellationToken cancellation = default, Scheduler scheduler = null) + where TLoadable : Drawable + => base.LoadComponentAsync(component, onLoaded, cancellation, scheduler); + } } } diff --git a/osu.Game/Screens/Edit/EditorBeatmap.cs b/osu.Game/Screens/Edit/EditorBeatmap.cs index dc1fda13f4..ad31c2ccc3 100644 --- a/osu.Game/Screens/Edit/EditorBeatmap.cs +++ b/osu.Game/Screens/Edit/EditorBeatmap.cs @@ -10,6 +10,7 @@ using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Lists; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Legacy; @@ -105,11 +106,18 @@ namespace osu.Game.Screens.Edit BeatmapSkin.BeatmapSkinChanged += SaveState; } - beatmapProcessor = playableBeatmap.BeatmapInfo.Ruleset.CreateInstance().CreateBeatmapProcessor(PlayableBeatmap); + beatmapProcessor = new EditorBeatmapProcessor(this, playableBeatmap.BeatmapInfo.Ruleset.CreateInstance()); foreach (var obj in HitObjects) trackStartTime(obj); + Breaks = new BindableList(playableBeatmap.Breaks); + Breaks.BindCollectionChanged((_, _) => + { + playableBeatmap.Breaks.Clear(); + playableBeatmap.Breaks.AddRange(Breaks); + }); + PreviewTime = new BindableInt(BeatmapInfo.Metadata.PreviewTime); PreviewTime.BindValueChanged(s => { @@ -172,7 +180,15 @@ namespace osu.Game.Screens.Edit set => PlayableBeatmap.ControlPointInfo = value; } - public List Breaks => PlayableBeatmap.Breaks; + public readonly BindableList Breaks; + + SortedList IBeatmap.Breaks + { + get => PlayableBeatmap.Breaks; + set => PlayableBeatmap.Breaks = value; + } + + public List UnhandledEventLines => PlayableBeatmap.UnhandledEventLines; public double TotalBreakTime => PlayableBeatmap.TotalBreakTime; @@ -196,6 +212,11 @@ namespace osu.Game.Screens.Edit /// Perform the provided action on every selected hitobject. /// Changes will be grouped as one history action. /// + /// + /// Note that this incurs a full state save, and as such requires the entire beatmap to be encoded, etc. + /// Very frequent use of this method (e.g. once a frame) is most discouraged. + /// If there is need to do so, use local precondition checks to eliminate changes that are known to be no-ops. + /// /// The action to perform. public void PerformOnSelection(Action action) { @@ -342,13 +363,13 @@ namespace osu.Game.Screens.Edit if (batchPendingUpdates.Count == 0 && batchPendingDeletes.Count == 0 && batchPendingInserts.Count == 0) return; - beatmapProcessor?.PreProcess(); + beatmapProcessor.PreProcess(); foreach (var h in batchPendingDeletes) processHitObject(h); foreach (var h in batchPendingInserts) processHitObject(h); foreach (var h in batchPendingUpdates) processHitObject(h); - beatmapProcessor?.PostProcess(); + beatmapProcessor.PostProcess(); BeatmapReprocessed?.Invoke(); diff --git a/osu.Game/Screens/Edit/EditorBeatmapProcessor.cs b/osu.Game/Screens/Edit/EditorBeatmapProcessor.cs new file mode 100644 index 0000000000..4fe431498f --- /dev/null +++ b/osu.Game/Screens/Edit/EditorBeatmapProcessor.cs @@ -0,0 +1,104 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Timing; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; + +namespace osu.Game.Screens.Edit +{ + public class EditorBeatmapProcessor : IBeatmapProcessor + { + public EditorBeatmap Beatmap { get; } + + IBeatmap IBeatmapProcessor.Beatmap => Beatmap; + + private readonly IBeatmapProcessor? rulesetBeatmapProcessor; + + /// + /// Kept for the purposes of reducing redundant regeneration of automatic breaks. + /// + private HashSet<(double, double)> objectDurationCache = new HashSet<(double, double)>(); + + public EditorBeatmapProcessor(EditorBeatmap beatmap, Ruleset ruleset) + { + Beatmap = beatmap; + rulesetBeatmapProcessor = ruleset.CreateBeatmapProcessor(beatmap); + } + + public void PreProcess() + { + rulesetBeatmapProcessor?.PreProcess(); + } + + public void PostProcess() + { + rulesetBeatmapProcessor?.PostProcess(); + + autoGenerateBreaks(); + } + + private void autoGenerateBreaks() + { + var objectDuration = Beatmap.HitObjects.Select(ho => (ho.StartTime - ((ho as IHasTimePreempt)?.TimePreempt ?? 0), ho.GetEndTime())).ToHashSet(); + + if (objectDuration.SetEquals(objectDurationCache)) + return; + + objectDurationCache = objectDuration; + + Beatmap.Breaks.RemoveAll(b => b is not ManualBreakPeriod); + + foreach (var manualBreak in Beatmap.Breaks.ToList()) + { + if (manualBreak.EndTime <= Beatmap.HitObjects.FirstOrDefault()?.StartTime + || manualBreak.StartTime >= Beatmap.GetLastObjectTime() + || Beatmap.HitObjects.Any(ho => ho.StartTime <= manualBreak.EndTime && ho.GetEndTime() >= manualBreak.StartTime)) + { + Beatmap.Breaks.Remove(manualBreak); + } + } + + double currentMaxEndTime = double.MinValue; + + for (int i = 1; i < Beatmap.HitObjects.Count; ++i) + { + var previousObject = Beatmap.HitObjects[i - 1]; + var nextObject = Beatmap.HitObjects[i]; + + // Keep track of the maximum end time encountered thus far. + // This handles cases like osu!mania's hold notes, which could have concurrent other objects after their start time. + // Note that we're relying on the implicit assumption that objects are sorted by start time, + // which is why similar tracking is not done for start time. + currentMaxEndTime = Math.Max(currentMaxEndTime, previousObject.GetEndTime()); + + if (nextObject.StartTime - currentMaxEndTime < BreakPeriod.MIN_GAP_DURATION) + continue; + + double breakStartTime = currentMaxEndTime + BreakPeriod.GAP_BEFORE_BREAK; + + double breakEndTime = nextObject.StartTime; + + if (nextObject is IHasTimePreempt hasTimePreempt) + breakEndTime -= hasTimePreempt.TimePreempt; + else + breakEndTime -= Math.Max(BreakPeriod.GAP_AFTER_BREAK, Beatmap.ControlPointInfo.TimingPointAt(nextObject.StartTime).BeatLength * 2); + + if (breakEndTime - breakStartTime < BreakPeriod.MIN_BREAK_DURATION) + continue; + + var breakPeriod = new BreakPeriod(breakStartTime, breakEndTime); + + if (Beatmap.Breaks.Any(b => b.Intersects(breakPeriod))) + continue; + + Beatmap.Breaks.Add(breakPeriod); + } + } + } +} diff --git a/osu.Game/Screens/Edit/EditorChangeHandler.cs b/osu.Game/Screens/Edit/EditorChangeHandler.cs index 0bb17e4c5d..f8ef133549 100644 --- a/osu.Game/Screens/Edit/EditorChangeHandler.cs +++ b/osu.Game/Screens/Edit/EditorChangeHandler.cs @@ -83,10 +83,6 @@ namespace osu.Game.Screens.Edit } } - /// - /// Restores an older or newer state. - /// - /// The direction to restore in. If less than 0, an older state will be used. If greater than 0, a newer state will be used. public void RestoreState(int direction) { if (TransactionActive) diff --git a/osu.Game/Screens/Edit/EditorClock.cs b/osu.Game/Screens/Edit/EditorClock.cs index d5ca6fc35e..5b9c662c95 100644 --- a/osu.Game/Screens/Edit/EditorClock.cs +++ b/osu.Game/Screens/Edit/EditorClock.cs @@ -132,7 +132,7 @@ namespace osu.Game.Screens.Edit seekTime = timingPoint.Time + closestBeat * seekAmount; // limit forward seeking to only up to the next timing point's start time. - var nextTimingPoint = ControlPointInfo.TimingPoints.FirstOrDefault(t => t.Time > timingPoint.Time); + var nextTimingPoint = ControlPointInfo.TimingPointAfter(timingPoint.Time); if (seekTime > nextTimingPoint?.Time) seekTime = nextTimingPoint.Time; @@ -154,7 +154,7 @@ namespace osu.Game.Screens.Edit /// The current time of this clock, include any active transform seeks performed via . /// public double CurrentTimeAccurate => - Transforms.OfType().FirstOrDefault()?.EndValue ?? CurrentTime; + Transforms.OfType().LastOrDefault()?.EndValue ?? CurrentTime; public double CurrentTime => underlyingClock.CurrentTime; diff --git a/osu.Game/Screens/Edit/EditorLoader.cs b/osu.Game/Screens/Edit/EditorLoader.cs index 8bcfa7b9f0..0e0fb9f795 100644 --- a/osu.Game/Screens/Edit/EditorLoader.cs +++ b/osu.Game/Screens/Edit/EditorLoader.cs @@ -121,7 +121,11 @@ namespace osu.Game.Screens.Edit scheduledDifficultySwitch = Schedule(() => { - Beatmap.Value = nextBeatmap.Invoke(); + var workingBeatmap = nextBeatmap.Invoke(); + + Ruleset.Value = workingBeatmap.BeatmapInfo.Ruleset; + Beatmap.Value = workingBeatmap; + state = editorState; // This screen is a weird exception to the rule that nothing after song select changes the global beatmap. diff --git a/osu.Game/Screens/Edit/EditorScreen.cs b/osu.Game/Screens/Edit/EditorScreen.cs index 3bc870b898..a795b310a2 100644 --- a/osu.Game/Screens/Edit/EditorScreen.cs +++ b/osu.Game/Screens/Edit/EditorScreen.cs @@ -6,6 +6,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; +using osu.Framework.Screens; namespace osu.Game.Screens.Edit { @@ -37,6 +38,10 @@ namespace osu.Game.Screens.Edit protected override void PopOut() => this.FadeOut(); + public virtual void OnExiting(ScreenExitEvent e) + { + } + #region Clipboard operations public BindableBool CanCut { get; } = new BindableBool(); diff --git a/osu.Game/Screens/Edit/EditorScreenWithTimeline.cs b/osu.Game/Screens/Edit/EditorScreenWithTimeline.cs index 2b97d363f1..01908e45c7 100644 --- a/osu.Game/Screens/Edit/EditorScreenWithTimeline.cs +++ b/osu.Game/Screens/Edit/EditorScreenWithTimeline.cs @@ -4,9 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; using osu.Game.Graphics.UserInterface; -using osu.Game.Overlays; using osu.Game.Screens.Edit.Compose.Components.Timeline; namespace osu.Game.Screens.Edit @@ -14,13 +12,12 @@ namespace osu.Game.Screens.Edit [Cached] public abstract partial class EditorScreenWithTimeline : EditorScreen { - public const float PADDING = 10; - - public Container TimelineContent { get; private set; } = null!; + public TimelineArea TimelineArea { get; private set; } = null!; public Container MainContent { get; private set; } = null!; private LoadingSpinner spinner = null!; + private Container timelineContent = null!; protected EditorScreenWithTimeline(EditorScreenMode type) : base(type) @@ -28,7 +25,7 @@ namespace osu.Game.Screens.Edit } [BackgroundDependencyLoader(true)] - private void load(OverlayColourProvider colourProvider) + private void load() { // Grid with only two rows. // First is the timeline area, which should be allowed to expand as required. @@ -52,22 +49,16 @@ namespace osu.Game.Screens.Edit AutoSizeAxes = Axes.Y, Children = new Drawable[] { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = colourProvider.Background4 - }, new GridContainer { Name = "Timeline content", RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Horizontal = PADDING, Top = PADDING }, Content = new[] { new Drawable[] { - TimelineContent = new Container + timelineContent = new Container { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, @@ -115,10 +106,18 @@ namespace osu.Game.Screens.Edit MainContent.Add(content); content.FadeInFromZero(300, Easing.OutQuint); - LoadComponentAsync(new TimelineArea(CreateTimelineContent()), TimelineContent.Add); + LoadComponentAsync(TimelineArea = new TimelineArea(CreateTimelineContent()), timeline => + { + ConfigureTimeline(timeline); + timelineContent.Add(timeline); + }); }); } + protected virtual void ConfigureTimeline(TimelineArea timelineArea) + { + } + protected abstract Drawable CreateMainContent(); protected virtual Drawable CreateTimelineContent() => new Container(); diff --git a/osu.Game/Screens/Edit/EditorTable.cs b/osu.Game/Screens/Edit/EditorTable.cs deleted file mode 100644 index 4d8393e829..0000000000 --- a/osu.Game/Screens/Edit/EditorTable.cs +++ /dev/null @@ -1,189 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Diagnostics; -using osu.Framework.Allocation; -using osu.Framework.Extensions.LocalisationExtensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Input.Events; -using osu.Framework.Localisation; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; -using osu.Game.Overlays; -using osuTK.Graphics; - -namespace osu.Game.Screens.Edit -{ - public abstract partial class EditorTable : TableContainer - { - public event Action? OnRowSelected; - - private const float horizontal_inset = 20; - - protected const float ROW_HEIGHT = 25; - - public const int TEXT_SIZE = 14; - - protected readonly FillFlowContainer BackgroundFlow; - - // We can avoid potentially thousands of objects being added to the input sub-tree since item selection is being handled by the BackgroundFlow - // and no items in the underlying table are clickable. - protected override bool ShouldBeConsideredForInput(Drawable child) => child == BackgroundFlow && base.ShouldBeConsideredForInput(child); - - protected EditorTable() - { - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - - Padding = new MarginPadding { Horizontal = horizontal_inset }; - RowSize = new Dimension(GridSizeMode.Absolute, ROW_HEIGHT); - - AddInternal(BackgroundFlow = new FillFlowContainer - { - RelativeSizeAxes = Axes.Both, - Depth = 1f, - Padding = new MarginPadding { Horizontal = -horizontal_inset }, - Margin = new MarginPadding { Top = ROW_HEIGHT } - }); - } - - protected int GetIndexForObject(object? item) - { - for (int i = 0; i < BackgroundFlow.Count; i++) - { - if (BackgroundFlow[i].Item == item) - return i; - } - - return -1; - } - - protected virtual bool SetSelectedRow(object? item) - { - bool foundSelection = false; - - foreach (var b in BackgroundFlow) - { - b.Selected = ReferenceEquals(b.Item, item); - - if (b.Selected) - { - Debug.Assert(!foundSelection); - OnRowSelected?.Invoke(b); - foundSelection = true; - } - } - - return foundSelection; - } - - protected object? GetObjectAtIndex(int index) - { - if (index < 0 || index > BackgroundFlow.Count - 1) - return null; - - return BackgroundFlow[index].Item; - } - - protected override Drawable CreateHeader(int index, TableColumn? column) => new HeaderText(column?.Header ?? default); - - private partial class HeaderText : OsuSpriteText - { - public HeaderText(LocalisableString text) - { - Text = text.ToUpper(); - Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold); - } - } - - public partial class RowBackground : OsuClickableContainer - { - public readonly object Item; - - private const int fade_duration = 100; - - private readonly Box hoveredBackground; - - public RowBackground(object item) - { - Item = item; - - RelativeSizeAxes = Axes.X; - Height = 25; - - AlwaysPresent = true; - - CornerRadius = 3; - Masking = true; - - Children = new Drawable[] - { - hoveredBackground = new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - }, - }; - } - - private Color4 colourHover; - private Color4 colourSelected; - - [BackgroundDependencyLoader] - private void load(OverlayColourProvider colours) - { - colourHover = colours.Background1; - colourSelected = colours.Colour3; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - updateState(); - FinishTransforms(true); - } - - private bool selected; - - public bool Selected - { - get => selected; - set - { - if (value == selected) - return; - - selected = value; - updateState(); - } - } - - protected override bool OnHover(HoverEvent e) - { - updateState(); - return base.OnHover(e); - } - - protected override void OnHoverLost(HoverLostEvent e) - { - updateState(); - base.OnHoverLost(e); - } - - private void updateState() - { - hoveredBackground.FadeColour(selected ? colourSelected : colourHover, 450, Easing.OutQuint); - - if (selected || IsHovered) - hoveredBackground.FadeIn(fade_duration, Easing.OutQuint); - else - hoveredBackground.FadeOut(fade_duration, Easing.OutQuint); - } - } - } -} diff --git a/osu.Game/Screens/Edit/ExternalEditScreen.cs b/osu.Game/Screens/Edit/ExternalEditScreen.cs new file mode 100644 index 0000000000..8a97e3dcb2 --- /dev/null +++ b/osu.Game/Screens/Edit/ExternalEditScreen.cs @@ -0,0 +1,252 @@ +// 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.IO; +using System.Linq; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Online.Multiplayer; +using osu.Game.Overlays; +using osu.Game.Screens.OnlinePlay.Match.Components; +using osuTK; + +namespace osu.Game.Screens.Edit +{ + internal partial class ExternalEditScreen : OsuScreen + { + [Resolved] + private GameHost gameHost { get; set; } = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [Resolved] + private BeatmapManager beatmapManager { get; set; } = null!; + + [Resolved] + private Editor editor { get; set; } = null!; + + [Resolved] + private EditorBeatmap editorBeatmap { get; set; } = null!; + + private Task? fileMountOperation; + + public ExternalEditOperation? EditOperation; + + private FillFlowContainer flow = null!; + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new Container + { + Masking = true, + CornerRadius = 20, + AutoSizeAxes = Axes.Both, + AutoSizeDuration = 500, + AutoSizeEasing = Easing.OutQuint, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + new Box + { + Colour = colourProvider.Background5, + RelativeSizeAxes = Axes.Both, + }, + flow = new FillFlowContainer + { + Margin = new MarginPadding(20), + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Spacing = new Vector2(15), + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + fileMountOperation = begin(); + } + + public override bool OnExiting(ScreenExitEvent e) + { + // Don't allow exiting until the file mount operation has completed. + // This is mainly to simplify the flow (once the screen is pushed we are guaranteed an attempted mount). + if (fileMountOperation?.IsCompleted == false) + return true; + + // If the operation completed successfully, ensure that we finish the operation before exiting. + // The finish() call will subsequently call Exit() when done. + if (EditOperation != null) + { + finish().FireAndForget(); + return true; + } + + return base.OnExiting(e); + } + + private async Task begin() + { + showSpinner("Exporting for edit..."); + + await Task.Delay(500).ConfigureAwait(true); + + try + { + EditOperation = await beatmapManager.BeginExternalEditing(editorBeatmap.BeatmapInfo.BeatmapSet!).ConfigureAwait(true); + } + catch (Exception ex) + { + Logger.Log($@"Failed to initiate external edit operation: {ex}", LoggingTarget.Database); + fileMountOperation = null; + showSpinner("Export failed!"); + await Task.Delay(1000).ConfigureAwait(true); + this.Exit(); + } + + flow.Children = new Drawable[] + { + new OsuSpriteText + { + Text = "Beatmap is mounted externally", + Font = OsuFont.Default.With(size: 30), + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + new OsuTextFlowContainer + { + Padding = new MarginPadding(5), + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Width = 350, + AutoSizeAxes = Axes.Y, + Text = "Any changes made to the exported folder will be imported to the game, including file additions, modifications and deletions.", + }, + new PurpleRoundedButton + { + Text = "Open folder", + Width = 350, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Action = openDirectory, + Enabled = { Value = false } + }, + new DangerousRoundedButton + { + Text = "Finish editing and import changes", + Width = 350, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Action = () => finish().FireAndForget(), + Enabled = { Value = false } + } + }; + + Scheduler.AddDelayed(() => + { + foreach (var b in flow.ChildrenOfType()) + b.Enabled.Value = true; + openDirectory(); + }, 1000); + } + + private void openDirectory() + { + if (EditOperation == null) + return; + + // Ensure the trailing separator is present in order to show the folder contents. + gameHost.OpenFileExternally(EditOperation.MountedPath.TrimDirectorySeparator() + Path.DirectorySeparatorChar); + } + + private async Task finish() + { + string originalDifficulty = editor.Beatmap.Value.Beatmap.BeatmapInfo.DifficultyName; + + showSpinner("Cleaning up..."); + + Live? beatmap = null; + + try + { + beatmap = await EditOperation!.Finish().ConfigureAwait(true); + } + catch (Exception ex) + { + Logger.Log($@"Failed to finish external edit operation: {ex}", LoggingTarget.Database); + showSpinner("Import failed!"); + await Task.Delay(1000).ConfigureAwait(true); + } + + // Setting to null will allow exit to succeed. + EditOperation = null; + + if (beatmap == null) + this.Exit(); + else + { + // the `ImportAsUpdate()` flow will yield beatmap(sets) with online status of `None` if online lookup fails. + // coerce such models to `LocallyModified` state instead to unify behaviour with normal editing flow. + beatmap.PerformWrite(s => + { + if (s.Status == BeatmapOnlineStatus.None) + s.Status = BeatmapOnlineStatus.LocallyModified; + foreach (var difficulty in s.Beatmaps.Where(b => b.Status == BeatmapOnlineStatus.None)) + difficulty.Status = BeatmapOnlineStatus.LocallyModified; + }); + + var closestMatchingBeatmap = + beatmap.Value.Beatmaps.FirstOrDefault(b => b.DifficultyName == originalDifficulty) + ?? beatmap.Value.Beatmaps.First(); + + editor.SwitchToDifficulty(closestMatchingBeatmap); + } + } + + private void showSpinner(string text) + { + foreach (var b in flow.ChildrenOfType()) + b.Enabled.Value = false; + + flow.Children = new Drawable[] + { + new OsuSpriteText + { + Text = text, + Font = OsuFont.Default.With(size: 30), + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + new LoadingSpinner + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + State = { Value = Visibility.Visible } + }, + }; + } + } +} diff --git a/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs b/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs index 55607cbb7c..4377cc6219 100644 --- a/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs +++ b/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs @@ -1,17 +1,24 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Framework.Screens; using osu.Game.Beatmaps; +using osu.Game.Input.Bindings; using osu.Game.Overlays; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; using osu.Game.Screens.Play; using osu.Game.Users; namespace osu.Game.Screens.Edit.GameplayTest { - public partial class EditorPlayer : Player + public partial class EditorPlayer : Player, IKeyBindingHandler { private readonly Editor editor; private readonly EditorState editorState; @@ -43,6 +50,10 @@ namespace osu.Game.Screens.Edit.GameplayTest protected override void LoadComplete() { base.LoadComplete(); + + markPreviousObjectsHit(); + markVisibleDrawableObjectsHit(); + ScoreProcessor.HasCompleted.BindValueChanged(completed => { if (completed.NewValue) @@ -56,6 +67,73 @@ namespace osu.Game.Screens.Edit.GameplayTest }); } + private void markPreviousObjectsHit() + { + foreach (var hitObject in enumerateHitObjects(DrawableRuleset.Objects, editorState.Time)) + { + var judgement = hitObject.Judgement; + var result = new JudgementResult(hitObject, judgement) + { + Type = judgement.MaxResult, + GameplayRate = GameplayClockContainer.GetTrueGameplayRate(), + }; + + HealthProcessor.ApplyResult(result); + ScoreProcessor.ApplyResult(result); + } + + static IEnumerable enumerateHitObjects(IEnumerable hitObjects, double cutoffTime) + { + foreach (var hitObject in hitObjects) + { + foreach (var nested in enumerateHitObjects(hitObject.NestedHitObjects, cutoffTime)) + { + if (nested.GetEndTime() < cutoffTime) + yield return nested; + } + + if (hitObject.GetEndTime() < cutoffTime) + yield return hitObject; + } + } + } + + private void markVisibleDrawableObjectsHit() + { + if (!DrawableRuleset.Playfield.IsLoaded) + { + Schedule(markVisibleDrawableObjectsHit); + return; + } + + foreach (var drawableObjectEntry in enumerateDrawableEntries( + DrawableRuleset.Playfield.AllHitObjects + .Select(ho => ho.Entry) + .Where(e => e != null) + .Cast(), editorState.Time)) + { + drawableObjectEntry.Result = new JudgementResult(drawableObjectEntry.HitObject, drawableObjectEntry.HitObject.Judgement) + { + Type = drawableObjectEntry.HitObject.Judgement.MaxResult + }; + } + + static IEnumerable enumerateDrawableEntries(IEnumerable entries, double cutoffTime) + { + foreach (var entry in entries) + { + foreach (var nested in enumerateDrawableEntries(entry.NestedEntries, cutoffTime)) + { + if (nested.HitObject.GetEndTime() < cutoffTime) + yield return nested; + } + + if (entry.HitObject.GetEndTime() < cutoffTime) + yield return entry; + } + } + } + protected override void PrepareReplay() { // don't record replays. @@ -63,6 +141,76 @@ namespace osu.Game.Screens.Edit.GameplayTest protected override bool CheckModsAllowFailure() => false; // never fail. + public bool OnPressed(KeyBindingPressEvent e) + { + if (e.Repeat) + return false; + + switch (e.Action) + { + case GlobalAction.EditorTestPlayToggleAutoplay: + toggleAutoplay(); + return true; + + case GlobalAction.EditorTestPlayToggleQuickPause: + toggleQuickPause(); + return true; + + case GlobalAction.EditorTestPlayQuickExitToInitialTime: + quickExit(false); + return true; + + case GlobalAction.EditorTestPlayQuickExitToCurrentTime: + quickExit(true); + return true; + + default: + return false; + } + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + + private void toggleAutoplay() + { + if (DrawableRuleset.ReplayScore == null) + { + var autoplay = Ruleset.Value.CreateInstance().GetAutoplayMod(); + if (autoplay == null) + return; + + var score = autoplay.CreateScoreFromReplayData(GameplayState.Beatmap, [autoplay]); + + // remove past frames to prevent replay frame handler from seeking back to start in an attempt to play back the entirety of the replay. + score.Replay.Frames.RemoveAll(f => f.Time <= GameplayClockContainer.CurrentTime); + + DrawableRuleset.SetReplayScore(score); + // Without this schedule, the `GlobalCursorDisplay.Update()` machinery will fade the gameplay cursor out, but we still want it to show. + Schedule(() => DrawableRuleset.Cursor?.Show()); + } + else + DrawableRuleset.SetReplayScore(null); + } + + private void toggleQuickPause() + { + if (GameplayClockContainer.IsPaused.Value) + GameplayClockContainer.Start(); + else + GameplayClockContainer.Stop(); + } + + private void quickExit(bool useCurrentTime) + { + if (useCurrentTime) + editorState.Time = GameplayClockContainer.CurrentTime; + + editor.RestoreState(editorState); + this.Exit(); + } + public override void OnEntering(ScreenTransitionEvent e) { base.OnEntering(e); diff --git a/osu.Game/Screens/Edit/IEditorChangeHandler.cs b/osu.Game/Screens/Edit/IEditorChangeHandler.cs index 9fe40ba1b1..2259b52ea8 100644 --- a/osu.Game/Screens/Edit/IEditorChangeHandler.cs +++ b/osu.Game/Screens/Edit/IEditorChangeHandler.cs @@ -43,5 +43,11 @@ namespace osu.Game.Screens.Edit /// Note that this will be a no-op if there is a change in progress via . /// void SaveState(); + + /// + /// Restores an older or newer state. + /// + /// The direction to restore in. If less than 0, an older state will be used. If greater than 0, a newer state will be used. + void RestoreState(int direction); } } diff --git a/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs b/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs index bb9f702cb5..a1ee41fc48 100644 --- a/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs +++ b/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs @@ -45,6 +45,7 @@ namespace osu.Game.Screens.Edit editorBeatmap.BeginChange(); processHitObjects(result, () => newBeatmap ??= readBeatmap(newState)); processTimingPoints(() => newBeatmap ??= readBeatmap(newState)); + processBreaks(() => newBeatmap ??= readBeatmap(newState)); processHitObjectLocalData(() => newBeatmap ??= readBeatmap(newState)); editorBeatmap.EndChange(); } @@ -75,6 +76,27 @@ namespace osu.Game.Screens.Edit } } + private void processBreaks(Func getNewBeatmap) + { + var newBreaks = getNewBeatmap().Breaks.ToArray(); + + foreach (var oldBreak in editorBeatmap.Breaks.ToArray()) + { + if (newBreaks.Any(b => b.Equals(oldBreak))) + continue; + + editorBeatmap.Breaks.Remove(oldBreak); + } + + foreach (var newBreak in newBreaks) + { + if (editorBeatmap.Breaks.Any(b => b.Equals(newBreak))) + continue; + + editorBeatmap.Breaks.Add(newBreak); + } + } + private void processHitObjects(DiffResult result, Func getNewBeatmap) { findChangedIndices(result, LegacyDecoder.Section.HitObjects, out var removedIndices, out var addedIndices); diff --git a/osu.Game/Screens/Edit/ManualBreakPeriod.cs b/osu.Game/Screens/Edit/ManualBreakPeriod.cs new file mode 100644 index 0000000000..3ab77d84ce --- /dev/null +++ b/osu.Game/Screens/Edit/ManualBreakPeriod.cs @@ -0,0 +1,15 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Beatmaps.Timing; + +namespace osu.Game.Screens.Edit +{ + public class ManualBreakPeriod : BreakPeriod + { + public ManualBreakPeriod(double startTime, double endTime) + : base(startTime, endTime) + { + } + } +} diff --git a/osu.Game/Screens/Edit/ReloadEditorDialog.cs b/osu.Game/Screens/Edit/ReloadEditorDialog.cs new file mode 100644 index 0000000000..72a9f81347 --- /dev/null +++ b/osu.Game/Screens/Edit/ReloadEditorDialog.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Graphics.Sprites; +using osu.Game.Overlays.Dialog; +using osu.Game.Localisation; + +namespace osu.Game.Screens.Edit +{ + public partial class ReloadEditorDialog : PopupDialog + { + public ReloadEditorDialog(Action reload, Action cancel) + { + HeaderText = EditorDialogsStrings.EditorReloadDialogHeader; + + Icon = FontAwesome.Solid.Sync; + + Buttons = new PopupDialogButton[] + { + new PopupDialogOkButton + { + Text = DialogStrings.Confirm, + Action = reload + }, + new PopupDialogCancelButton + { + Text = DialogStrings.Cancel, + Action = cancel + } + }; + } + } +} diff --git a/osu.Game/Screens/Edit/SaveRequiredPopupDialog.cs b/osu.Game/Screens/Edit/SaveRequiredPopupDialog.cs index 3ca92876f1..618efb7cda 100644 --- a/osu.Game/Screens/Edit/SaveRequiredPopupDialog.cs +++ b/osu.Game/Screens/Edit/SaveRequiredPopupDialog.cs @@ -9,9 +9,9 @@ namespace osu.Game.Screens.Edit { public partial class SaveRequiredPopupDialog : PopupDialog { - public SaveRequiredPopupDialog(string headerText, Action saveAndAction) + public SaveRequiredPopupDialog(Action saveAndAction) { - HeaderText = headerText; + HeaderText = "The beatmap will be saved to continue with this operation."; Icon = FontAwesome.Regular.Save; diff --git a/osu.Game/Screens/Edit/Setup/ColoursSection.cs b/osu.Game/Screens/Edit/Setup/ColoursSection.cs index 8cd5c0f779..8de7f86523 100644 --- a/osu.Game/Screens/Edit/Setup/ColoursSection.cs +++ b/osu.Game/Screens/Edit/Setup/ColoursSection.cs @@ -6,30 +6,70 @@ using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; +using osu.Game.Skinning; namespace osu.Game.Screens.Edit.Setup { - internal partial class ColoursSection : SetupSection + public partial class ColoursSection : SetupSection { public override LocalisableString Title => EditorSetupStrings.ColoursHeader; - private LabelledColourPalette comboColours = null!; + private FormColourPalette comboColours = null!; [BackgroundDependencyLoader] private void load() { Children = new Drawable[] { - comboColours = new LabelledColourPalette + comboColours = new FormColourPalette { - Label = EditorSetupStrings.HitCircleSliderCombos, - FixedLabelWidth = LABEL_WIDTH, - ColourNamePrefix = EditorSetupStrings.ComboColourPrefix + Caption = EditorSetupStrings.HitCircleSliderCombos, } }; + } + private bool syncingColours; + + protected override void LoadComplete() + { if (Beatmap.BeatmapSkin != null) - comboColours.Colours.BindTo(Beatmap.BeatmapSkin.ComboColours); + comboColours.Colours.AddRange(Beatmap.BeatmapSkin.ComboColours); + + if (comboColours.Colours.Count == 0) + { + // compare ctor of `EditorBeatmapSkin` + for (int i = 0; i < SkinConfiguration.DefaultComboColours.Count; ++i) + comboColours.Colours.Add(SkinConfiguration.DefaultComboColours[(i + 1) % SkinConfiguration.DefaultComboColours.Count]); + } + + comboColours.Colours.BindCollectionChanged((_, _) => + { + if (Beatmap.BeatmapSkin != null) + { + if (syncingColours) + return; + + syncingColours = true; + + Beatmap.BeatmapSkin.ComboColours.Clear(); + Beatmap.BeatmapSkin.ComboColours.AddRange(comboColours.Colours); + + syncingColours = false; + } + }); + + Beatmap.BeatmapSkin?.ComboColours.BindCollectionChanged((_, _) => + { + if (syncingColours) + return; + + syncingColours = true; + + comboColours.Colours.Clear(); + comboColours.Colours.AddRange(Beatmap.BeatmapSkin?.ComboColours); + + syncingColours = false; + }); } } } diff --git a/osu.Game/Screens/Edit/Setup/DesignSection.cs b/osu.Game/Screens/Edit/Setup/DesignSection.cs index b05a073146..7def5394e6 100644 --- a/osu.Game/Screens/Edit/Setup/DesignSection.cs +++ b/osu.Game/Screens/Edit/Setup/DesignSection.cs @@ -15,77 +15,77 @@ using osu.Game.Localisation; namespace osu.Game.Screens.Edit.Setup { - internal partial class DesignSection : SetupSection + public partial class DesignSection : SetupSection { - protected LabelledSwitchButton EnableCountdown = null!; + protected FormCheckBox EnableCountdown = null!; protected FillFlowContainer CountdownSettings = null!; - protected LabelledEnumDropdown CountdownSpeed = null!; - protected LabelledNumberBox CountdownOffset = null!; + protected FormEnumDropdown CountdownSpeed = null!; + protected FormNumberBox CountdownOffset = null!; - private LabelledSwitchButton widescreenSupport = null!; - private LabelledSwitchButton epilepsyWarning = null!; - private LabelledSwitchButton letterboxDuringBreaks = null!; - private LabelledSwitchButton samplesMatchPlaybackRate = null!; + private FormCheckBox widescreenSupport = null!; + private FormCheckBox epilepsyWarning = null!; + private FormCheckBox letterboxDuringBreaks = null!; + private FormCheckBox samplesMatchPlaybackRate = null!; public override LocalisableString Title => EditorSetupStrings.DesignHeader; [BackgroundDependencyLoader] private void load() { - Children = new[] + Children = new Drawable[] { - EnableCountdown = new LabelledSwitchButton + EnableCountdown = new FormCheckBox { - Label = EditorSetupStrings.EnableCountdown, + Caption = EditorSetupStrings.EnableCountdown, + HintText = EditorSetupStrings.CountdownDescription, Current = { Value = Beatmap.BeatmapInfo.Countdown != CountdownType.None }, - Description = EditorSetupStrings.CountdownDescription }, CountdownSettings = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Spacing = new Vector2(10), + Spacing = new Vector2(5), Direction = FillDirection.Vertical, Children = new Drawable[] { - CountdownSpeed = new LabelledEnumDropdown + CountdownSpeed = new FormEnumDropdown { - Label = EditorSetupStrings.CountdownSpeed, + Caption = EditorSetupStrings.CountdownSpeed, Current = { Value = Beatmap.BeatmapInfo.Countdown != CountdownType.None ? Beatmap.BeatmapInfo.Countdown : CountdownType.Normal }, Items = Enum.GetValues().Where(type => type != CountdownType.None) }, - CountdownOffset = new LabelledNumberBox + CountdownOffset = new FormNumberBox { - Label = EditorSetupStrings.CountdownOffset, + Caption = EditorSetupStrings.CountdownOffset, + HintText = EditorSetupStrings.CountdownOffsetDescription, Current = { Value = Beatmap.BeatmapInfo.CountdownOffset.ToString() }, - Description = EditorSetupStrings.CountdownOffsetDescription, + TabbableContentContainer = this, } } }, - Empty(), - widescreenSupport = new LabelledSwitchButton + widescreenSupport = new FormCheckBox { - Label = EditorSetupStrings.WidescreenSupport, - Description = EditorSetupStrings.WidescreenSupportDescription, + Caption = EditorSetupStrings.WidescreenSupport, + HintText = EditorSetupStrings.WidescreenSupportDescription, Current = { Value = Beatmap.BeatmapInfo.WidescreenStoryboard } }, - epilepsyWarning = new LabelledSwitchButton + epilepsyWarning = new FormCheckBox { - Label = EditorSetupStrings.EpilepsyWarning, - Description = EditorSetupStrings.EpilepsyWarningDescription, + Caption = EditorSetupStrings.EpilepsyWarning, + HintText = EditorSetupStrings.EpilepsyWarningDescription, Current = { Value = Beatmap.BeatmapInfo.EpilepsyWarning } }, - letterboxDuringBreaks = new LabelledSwitchButton + letterboxDuringBreaks = new FormCheckBox { - Label = EditorSetupStrings.LetterboxDuringBreaks, - Description = EditorSetupStrings.LetterboxDuringBreaksDescription, + Caption = EditorSetupStrings.LetterboxDuringBreaks, + HintText = EditorSetupStrings.LetterboxDuringBreaksDescription, Current = { Value = Beatmap.BeatmapInfo.LetterboxInBreaks } }, - samplesMatchPlaybackRate = new LabelledSwitchButton + samplesMatchPlaybackRate = new FormCheckBox { - Label = EditorSetupStrings.SamplesMatchPlaybackRate, - Description = EditorSetupStrings.SamplesMatchPlaybackRateDescription, + Caption = EditorSetupStrings.SamplesMatchPlaybackRate, + HintText = EditorSetupStrings.SamplesMatchPlaybackRateDescription, Current = { Value = Beatmap.BeatmapInfo.SamplesMatchPlaybackRate } } }; diff --git a/osu.Game/Screens/Edit/Setup/DifficultySection.cs b/osu.Game/Screens/Edit/Setup/DifficultySection.cs index 8028df6c0f..88241451cf 100644 --- a/osu.Game/Screens/Edit/Setup/DifficultySection.cs +++ b/osu.Game/Screens/Edit/Setup/DifficultySection.cs @@ -15,12 +15,12 @@ namespace osu.Game.Screens.Edit.Setup { public partial class DifficultySection : SetupSection { - protected LabelledSliderBar CircleSizeSlider { get; private set; } = null!; - protected LabelledSliderBar HealthDrainSlider { get; private set; } = null!; - protected LabelledSliderBar ApproachRateSlider { get; private set; } = null!; - protected LabelledSliderBar OverallDifficultySlider { get; private set; } = null!; - protected LabelledSliderBar BaseVelocitySlider { get; private set; } = null!; - protected LabelledSliderBar TickRateSlider { get; private set; } = null!; + private FormSliderBar circleSizeSlider { get; set; } = null!; + private FormSliderBar healthDrainSlider { get; set; } = null!; + private FormSliderBar approachRateSlider { get; set; } = null!; + private FormSliderBar overallDifficultySlider { get; set; } = null!; + private FormSliderBar baseVelocitySlider { get; set; } = null!; + private FormSliderBar tickRateSlider { get; set; } = null!; public override LocalisableString Title => EditorSetupStrings.DifficultyHeader; @@ -29,90 +29,96 @@ namespace osu.Game.Screens.Edit.Setup { Children = new Drawable[] { - CircleSizeSlider = new LabelledSliderBar + circleSizeSlider = new FormSliderBar { - Label = BeatmapsetsStrings.ShowStatsCs, - FixedLabelWidth = LABEL_WIDTH, - Description = EditorSetupStrings.CircleSizeDescription, + Caption = BeatmapsetsStrings.ShowStatsCs, + HintText = EditorSetupStrings.CircleSizeDescription, Current = new BindableFloat(Beatmap.Difficulty.CircleSize) { Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, MinValue = 0, MaxValue = 10, Precision = 0.1f, - } + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, }, - HealthDrainSlider = new LabelledSliderBar + healthDrainSlider = new FormSliderBar { - Label = BeatmapsetsStrings.ShowStatsDrain, - FixedLabelWidth = LABEL_WIDTH, - Description = EditorSetupStrings.DrainRateDescription, + Caption = BeatmapsetsStrings.ShowStatsDrain, + HintText = EditorSetupStrings.DrainRateDescription, Current = new BindableFloat(Beatmap.Difficulty.DrainRate) { Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, MinValue = 0, MaxValue = 10, Precision = 0.1f, - } + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, }, - ApproachRateSlider = new LabelledSliderBar + approachRateSlider = new FormSliderBar { - Label = BeatmapsetsStrings.ShowStatsAr, - FixedLabelWidth = LABEL_WIDTH, - Description = EditorSetupStrings.ApproachRateDescription, + Caption = BeatmapsetsStrings.ShowStatsAr, + HintText = EditorSetupStrings.ApproachRateDescription, Current = new BindableFloat(Beatmap.Difficulty.ApproachRate) { Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, MinValue = 0, MaxValue = 10, Precision = 0.1f, - } + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, }, - OverallDifficultySlider = new LabelledSliderBar + overallDifficultySlider = new FormSliderBar { - Label = BeatmapsetsStrings.ShowStatsAccuracy, - FixedLabelWidth = LABEL_WIDTH, - Description = EditorSetupStrings.OverallDifficultyDescription, + Caption = BeatmapsetsStrings.ShowStatsAccuracy, + HintText = EditorSetupStrings.OverallDifficultyDescription, Current = new BindableFloat(Beatmap.Difficulty.OverallDifficulty) { Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, MinValue = 0, MaxValue = 10, Precision = 0.1f, - } + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, }, - BaseVelocitySlider = new LabelledSliderBar + baseVelocitySlider = new FormSliderBar { - Label = EditorSetupStrings.BaseVelocity, - FixedLabelWidth = LABEL_WIDTH, - Description = EditorSetupStrings.BaseVelocityDescription, + Caption = EditorSetupStrings.BaseVelocity, + HintText = EditorSetupStrings.BaseVelocityDescription, Current = new BindableDouble(Beatmap.Difficulty.SliderMultiplier) { Default = 1.4, MinValue = 0.4, MaxValue = 3.6, Precision = 0.01f, - } + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, }, - TickRateSlider = new LabelledSliderBar + tickRateSlider = new FormSliderBar { - Label = EditorSetupStrings.TickRate, - FixedLabelWidth = LABEL_WIDTH, - Description = EditorSetupStrings.TickRateDescription, + Caption = EditorSetupStrings.TickRate, + HintText = EditorSetupStrings.TickRateDescription, Current = new BindableDouble(Beatmap.Difficulty.SliderTickRate) { Default = 1, MinValue = 1, MaxValue = 4, Precision = 1, - } + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, }, }; - foreach (var item in Children.OfType>()) + foreach (var item in Children.OfType>()) item.Current.ValueChanged += _ => updateValues(); - foreach (var item in Children.OfType>()) + foreach (var item in Children.OfType>()) item.Current.ValueChanged += _ => updateValues(); } @@ -120,12 +126,12 @@ namespace osu.Game.Screens.Edit.Setup { // for now, update these on commit rather than making BeatmapMetadata bindables. // after switching database engines we can reconsider if switching to bindables is a good direction. - Beatmap.Difficulty.CircleSize = CircleSizeSlider.Current.Value; - Beatmap.Difficulty.DrainRate = HealthDrainSlider.Current.Value; - Beatmap.Difficulty.ApproachRate = ApproachRateSlider.Current.Value; - Beatmap.Difficulty.OverallDifficulty = OverallDifficultySlider.Current.Value; - Beatmap.Difficulty.SliderMultiplier = BaseVelocitySlider.Current.Value; - Beatmap.Difficulty.SliderTickRate = TickRateSlider.Current.Value; + Beatmap.Difficulty.CircleSize = circleSizeSlider.Current.Value; + Beatmap.Difficulty.DrainRate = healthDrainSlider.Current.Value; + Beatmap.Difficulty.ApproachRate = approachRateSlider.Current.Value; + Beatmap.Difficulty.OverallDifficulty = overallDifficultySlider.Current.Value; + Beatmap.Difficulty.SliderMultiplier = baseVelocitySlider.Current.Value; + Beatmap.Difficulty.SliderTickRate = tickRateSlider.Current.Value; Beatmap.UpdateAllHitObjects(); Beatmap.SaveState(); diff --git a/osu.Game/Screens/Edit/Setup/LabelledFileChooser.cs b/osu.Game/Screens/Edit/Setup/LabelledFileChooser.cs index 61f33c4bdc..a113ca5407 100644 --- a/osu.Game/Screens/Edit/Setup/LabelledFileChooser.cs +++ b/osu.Game/Screens/Edit/Setup/LabelledFileChooser.cs @@ -18,6 +18,7 @@ using osu.Framework.Localisation; using osu.Framework.Platform; using osu.Game.Database; using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; using osuTK; namespace osu.Game.Screens.Edit.Setup @@ -118,6 +119,7 @@ namespace osu.Game.Screens.Edit.Setup protected override string PopOutSampleName => "UI/overlay-big-pop-out"; public FileChooserPopover(string[] handledExtensions, Bindable currentFile, string? chooserPath) + : base(false) { Child = new Container { @@ -129,6 +131,13 @@ namespace osu.Game.Screens.Edit.Setup }, }; } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Body.BorderColour = colourProvider.Highlight1; + Body.BorderThickness = 2; + } } } } diff --git a/osu.Game/Screens/Edit/Setup/LabelledRomanisedTextBox.cs b/osu.Game/Screens/Edit/Setup/LabelledRomanisedTextBox.cs deleted file mode 100644 index 85c697bf14..0000000000 --- a/osu.Game/Screens/Edit/Setup/LabelledRomanisedTextBox.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Beatmaps; -using osu.Game.Graphics.UserInterface; -using osu.Game.Graphics.UserInterfaceV2; - -namespace osu.Game.Screens.Edit.Setup -{ - internal partial class LabelledRomanisedTextBox : LabelledTextBox - { - protected override OsuTextBox CreateTextBox() => new RomanisedTextBox(); - - private partial class RomanisedTextBox : OsuTextBox - { - protected override bool AllowIme => false; - - protected override bool CanAddCharacter(char character) - => MetadataUtils.IsRomanised(character); - } - } -} diff --git a/osu.Game/Screens/Edit/Setup/LabelledTextBoxWithPopover.cs b/osu.Game/Screens/Edit/Setup/LabelledTextBoxWithPopover.cs index 79288e2977..f9e93e7b0e 100644 --- a/osu.Game/Screens/Edit/Setup/LabelledTextBoxWithPopover.cs +++ b/osu.Game/Screens/Edit/Setup/LabelledTextBoxWithPopover.cs @@ -45,7 +45,7 @@ namespace osu.Game.Screens.Edit.Setup OnFocused?.Invoke(); base.OnFocus(e); - GetContainingInputManager().TriggerFocusContention(this); + GetContainingFocusManager()!.TriggerFocusContention(this); } } } diff --git a/osu.Game/Screens/Edit/Setup/MetadataSection.cs b/osu.Game/Screens/Edit/Setup/MetadataSection.cs index 752f590308..20c0a74d84 100644 --- a/osu.Game/Screens/Edit/Setup/MetadataSection.cs +++ b/osu.Game/Screens/Edit/Setup/MetadataSection.cs @@ -14,16 +14,16 @@ namespace osu.Game.Screens.Edit.Setup { public partial class MetadataSection : SetupSection { - protected LabelledTextBox ArtistTextBox = null!; - protected LabelledTextBox RomanisedArtistTextBox = null!; + protected FormTextBox ArtistTextBox = null!; + protected FormTextBox RomanisedArtistTextBox = null!; - protected LabelledTextBox TitleTextBox = null!; - protected LabelledTextBox RomanisedTitleTextBox = null!; + protected FormTextBox TitleTextBox = null!; + protected FormTextBox RomanisedTitleTextBox = null!; - private LabelledTextBox creatorTextBox = null!; - private LabelledTextBox difficultyTextBox = null!; - private LabelledTextBox sourceTextBox = null!; - private LabelledTextBox tagsTextBox = null!; + private FormTextBox creatorTextBox = null!; + private FormTextBox difficultyTextBox = null!; + private FormTextBox sourceTextBox = null!; + private FormTextBox tagsTextBox = null!; public override LocalisableString Title => EditorSetupStrings.MetadataHeader; @@ -34,36 +34,26 @@ namespace osu.Game.Screens.Edit.Setup Children = new[] { - ArtistTextBox = createTextBox(EditorSetupStrings.Artist, + ArtistTextBox = createTextBox(EditorSetupStrings.Artist, !string.IsNullOrEmpty(metadata.ArtistUnicode) ? metadata.ArtistUnicode : metadata.Artist), - RomanisedArtistTextBox = createTextBox(EditorSetupStrings.RomanisedArtist, + RomanisedArtistTextBox = createTextBox(EditorSetupStrings.RomanisedArtist, !string.IsNullOrEmpty(metadata.Artist) ? metadata.Artist : MetadataUtils.StripNonRomanisedCharacters(metadata.ArtistUnicode)), - - Empty(), - - TitleTextBox = createTextBox(EditorSetupStrings.Title, + TitleTextBox = createTextBox(EditorSetupStrings.Title, !string.IsNullOrEmpty(metadata.TitleUnicode) ? metadata.TitleUnicode : metadata.Title), - RomanisedTitleTextBox = createTextBox(EditorSetupStrings.RomanisedTitle, + RomanisedTitleTextBox = createTextBox(EditorSetupStrings.RomanisedTitle, !string.IsNullOrEmpty(metadata.Title) ? metadata.Title : MetadataUtils.StripNonRomanisedCharacters(metadata.ArtistUnicode)), - - Empty(), - - creatorTextBox = createTextBox(EditorSetupStrings.Creator, metadata.Author.Username), - difficultyTextBox = createTextBox(EditorSetupStrings.DifficultyName, Beatmap.BeatmapInfo.DifficultyName), - sourceTextBox = createTextBox(BeatmapsetsStrings.ShowInfoSource, metadata.Source), - tagsTextBox = createTextBox(BeatmapsetsStrings.ShowInfoTags, metadata.Tags) + creatorTextBox = createTextBox(EditorSetupStrings.Creator, metadata.Author.Username), + difficultyTextBox = createTextBox(EditorSetupStrings.DifficultyName, Beatmap.BeatmapInfo.DifficultyName), + sourceTextBox = createTextBox(BeatmapsetsStrings.ShowInfoSource, metadata.Source), + tagsTextBox = createTextBox(BeatmapsetsStrings.ShowInfoTags, metadata.Tags) }; - - foreach (var item in Children.OfType()) - item.OnCommit += onCommit; } private TTextBox createTextBox(LocalisableString label, string initialValue) - where TTextBox : LabelledTextBox, new() + where TTextBox : FormTextBox, new() => new TTextBox { - Label = label, - FixedLabelWidth = LABEL_WIDTH, + Caption = label, Current = { Value = initialValue }, TabbableContentContainer = this }; @@ -73,20 +63,23 @@ namespace osu.Game.Screens.Edit.Setup base.LoadComplete(); if (string.IsNullOrEmpty(ArtistTextBox.Current.Value)) - ScheduleAfterChildren(() => GetContainingInputManager().ChangeFocus(ArtistTextBox)); + ScheduleAfterChildren(() => GetContainingFocusManager()!.ChangeFocus(ArtistTextBox)); ArtistTextBox.Current.BindValueChanged(artist => transferIfRomanised(artist.NewValue, RomanisedArtistTextBox)); TitleTextBox.Current.BindValueChanged(title => transferIfRomanised(title.NewValue, RomanisedTitleTextBox)); + + foreach (var item in Children.OfType()) + item.OnCommit += onCommit; + updateReadOnlyState(); } - private void transferIfRomanised(string value, LabelledTextBox target) + private void transferIfRomanised(string value, FormTextBox target) { if (MetadataUtils.IsRomanised(value)) target.Current.Value = value; updateReadOnlyState(); - Scheduler.AddOnce(updateMetadata); } private void updateReadOnlyState() @@ -101,7 +94,7 @@ namespace osu.Game.Screens.Edit.Setup // for now, update on commit rather than making BeatmapMetadata bindables. // after switching database engines we can reconsider if switching to bindables is a good direction. - Scheduler.AddOnce(updateMetadata); + updateMetadata(); } private void updateMetadata() @@ -119,5 +112,18 @@ namespace osu.Game.Screens.Edit.Setup Beatmap.SaveState(); } + + private partial class FormRomanisedTextBox : FormTextBox + { + internal override InnerTextBox CreateTextBox() => new RomanisedTextBox(); + + private partial class RomanisedTextBox : InnerTextBox + { + protected override bool AllowIme => false; + + protected override bool CanAddCharacter(char character) + => MetadataUtils.IsRomanised(character); + } + } } } diff --git a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs index f6d20319cb..845c21b598 100644 --- a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs +++ b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs @@ -7,15 +7,16 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Overlays; using osu.Game.Localisation; namespace osu.Game.Screens.Edit.Setup { - internal partial class ResourcesSection : SetupSection + public partial class ResourcesSection : SetupSection { - private LabelledFileChooser audioTrackChooser = null!; - private LabelledFileChooser backgroundChooser = null!; + private FormFileSelector audioTrackChooser = null!; + private FormFileSelector backgroundChooser = null!; public override LocalisableString Title => EditorSetupStrings.ResourcesHeader; @@ -34,28 +35,33 @@ namespace osu.Game.Screens.Edit.Setup [Resolved] private Editor? editor { get; set; } - [Resolved] - private SetupScreenHeader header { get; set; } = null!; + private SetupScreenHeaderBackground headerBackground = null!; [BackgroundDependencyLoader] private void load() { + headerBackground = new SetupScreenHeaderBackground + { + RelativeSizeAxes = Axes.X, + Height = 110, + }; + Children = new Drawable[] { - backgroundChooser = new LabelledFileChooser(".jpg", ".jpeg", ".png") + backgroundChooser = new FormFileSelector(".jpg", ".jpeg", ".png") { - Label = GameplaySettingsStrings.BackgroundHeader, - FixedLabelWidth = LABEL_WIDTH, - TabbableContentContainer = this + Caption = GameplaySettingsStrings.BackgroundHeader, + PlaceholderText = EditorSetupStrings.ClickToSelectBackground, }, - audioTrackChooser = new LabelledFileChooser(".mp3", ".ogg") + audioTrackChooser = new FormFileSelector(".mp3", ".ogg") { - Label = EditorSetupStrings.AudioTrack, - FixedLabelWidth = LABEL_WIDTH, - TabbableContentContainer = this + Caption = EditorSetupStrings.AudioTrack, + PlaceholderText = EditorSetupStrings.ClickToSelectTrack, }, }; + backgroundChooser.PreviewContainer.Add(headerBackground); + if (!string.IsNullOrEmpty(working.Value.Metadata.BackgroundFile)) backgroundChooser.Current.Value = new FileInfo(working.Value.Metadata.BackgroundFile); @@ -64,8 +70,6 @@ namespace osu.Game.Screens.Edit.Setup backgroundChooser.Current.BindValueChanged(backgroundChanged); audioTrackChooser.Current.BindValueChanged(audioTrackChanged); - - updatePlaceholderText(); } public bool ChangeBackgroundImage(FileInfo source) @@ -92,7 +96,7 @@ namespace osu.Game.Screens.Edit.Setup editorBeatmap.SaveState(); working.Value.Metadata.BackgroundFile = destination.Name; - header.Background.UpdateBackground(); + headerBackground.UpdateBackground(); editor?.ApplyToBackground(bg => bg.RefreshBackground()); @@ -132,22 +136,12 @@ namespace osu.Game.Screens.Edit.Setup { if (file.NewValue == null || !ChangeBackgroundImage(file.NewValue)) backgroundChooser.Current.Value = file.OldValue; - - updatePlaceholderText(); } private void audioTrackChanged(ValueChangedEvent file) { if (file.NewValue == null || !ChangeAudioTrack(file.NewValue)) audioTrackChooser.Current.Value = file.OldValue; - - updatePlaceholderText(); - } - - private void updatePlaceholderText() - { - audioTrackChooser.Text = audioTrackChooser.Current.Value?.Name ?? EditorSetupStrings.ClickToSelectTrack; - backgroundChooser.Text = backgroundChooser.Current.Value?.Name ?? EditorSetupStrings.ClickToSelectBackground; } } } diff --git a/osu.Game/Screens/Edit/Setup/SetupScreen.cs b/osu.Game/Screens/Edit/Setup/SetupScreen.cs index 266ea1f929..f8c4998263 100644 --- a/osu.Game/Screens/Edit/Setup/SetupScreen.cs +++ b/osu.Game/Screens/Edit/Setup/SetupScreen.cs @@ -1,72 +1,92 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Screens; using osu.Game.Graphics.Containers; using osu.Game.Overlays; +using osuTK; namespace osu.Game.Screens.Edit.Setup { public partial class SetupScreen : EditorScreen { - [Cached] - private SectionsContainer sections { get; } = new SetupScreenSectionsContainer(); - - [Cached] - private SetupScreenHeader header = new SetupScreenHeader(); + public const float COLUMN_WIDTH = 450; + public const float SPACING = 28; + public const float MAX_WIDTH = 2 * COLUMN_WIDTH + SPACING; public SetupScreen() : base(EditorScreenMode.SongSetup) { } + private OsuScrollContainer scroll = null!; + private FillFlowContainer flow = null!; + [BackgroundDependencyLoader] private void load(EditorBeatmap beatmap, OverlayColourProvider colourProvider) { var ruleset = beatmap.BeatmapInfo.Ruleset.CreateInstance(); - var sectionsEnumerable = new List + Children = new Drawable[] { - new ResourcesSection(), - new MetadataSection(), - ruleset.CreateEditorDifficultySection() ?? new DifficultySection(), - new ColoursSection(), - new DesignSection(), + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background3, + }, + scroll = new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(15), + Child = flow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Full, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Spacing = new Vector2(25), + ChildrenEnumerable = ruleset.CreateEditorSetupSections().Select(section => section.With(s => + { + s.Width = 450; + s.Anchor = Anchor.TopCentre; + s.Origin = Anchor.TopCentre; + })), + } + } }; - - var rulesetSpecificSection = ruleset.CreateEditorSetupSection(); - if (rulesetSpecificSection != null) - sectionsEnumerable.Add(rulesetSpecificSection); - - Add(new Box - { - Colour = colourProvider.Background3, - RelativeSizeAxes = Axes.Both, - }); - - Add(sections.With(s => - { - s.RelativeSizeAxes = Axes.Both; - s.ChildrenEnumerable = sectionsEnumerable; - s.FixedHeader = header; - })); } - private partial class SetupScreenSectionsContainer : SectionsContainer + protected override void UpdateAfterChildren() { - protected override UserTrackingScrollContainer CreateScrollContainer() + base.UpdateAfterChildren(); + + if (scroll.DrawWidth > MAX_WIDTH) { - var scrollContainer = base.CreateScrollContainer(); - - // Workaround for masking issues (see https://github.com/ppy/osu-framework/issues/1675#issuecomment-910023157) - // Note that this actually causes the full scroll range to be reduced by 2px at the bottom, but it's not really noticeable. - scrollContainer.Margin = new MarginPadding { Top = 2 }; - - return scrollContainer; + flow.RelativeSizeAxes = Axes.None; + flow.Width = MAX_WIDTH; } + else + { + flow.RelativeSizeAxes = Axes.X; + flow.Width = 1; + } + } + + public override void OnExiting(ScreenExitEvent e) + { + base.OnExiting(e); + + // Before exiting, trigger a focus loss. + // + // This is important to ensure that if the user is still editing a textbox, it will commit + // (and potentially block the exit procedure for save). + GetContainingFocusManager()?.TriggerFocusContention(this); } } } diff --git a/osu.Game/Screens/Edit/Setup/SetupScreenHeaderBackground.cs b/osu.Game/Screens/Edit/Setup/SetupScreenHeaderBackground.cs index 033e5361bb..5f3e6eb469 100644 --- a/osu.Game/Screens/Edit/Setup/SetupScreenHeaderBackground.cs +++ b/osu.Game/Screens/Edit/Setup/SetupScreenHeaderBackground.cs @@ -29,7 +29,8 @@ namespace osu.Game.Screens.Edit.Setup InternalChild = content = new Container { RelativeSizeAxes = Axes.Both, - Masking = true + Masking = true, + CornerRadius = 3.5f, }; } diff --git a/osu.Game/Screens/Edit/Setup/SetupSection.cs b/osu.Game/Screens/Edit/Setup/SetupSection.cs index 5f676798f1..bd1eb51b48 100644 --- a/osu.Game/Screens/Edit/Setup/SetupSection.cs +++ b/osu.Game/Screens/Edit/Setup/SetupSection.cs @@ -6,7 +6,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Localisation; using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osuTK; @@ -34,33 +34,25 @@ namespace osu.Game.Screens.Edit.Setup [BackgroundDependencyLoader] private void load() { - RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; - Padding = new MarginPadding - { - Vertical = 10, - Horizontal = 100 - }; - InternalChild = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Spacing = new Vector2(20), + Spacing = new Vector2(10), Direction = FillDirection.Vertical, Children = new Drawable[] { - new OsuSpriteText + new SectionHeader(Title) { - Font = OsuFont.GetFont(weight: FontWeight.Bold), - Text = Title + Margin = new MarginPadding { Left = 9, }, }, flow = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Spacing = new Vector2(10), + Spacing = new Vector2(5), Direction = FillDirection.Vertical, } } diff --git a/osu.Game/Screens/Edit/TableHeaderText.cs b/osu.Game/Screens/Edit/TableHeaderText.cs new file mode 100644 index 0000000000..61301f86ed --- /dev/null +++ b/osu.Game/Screens/Edit/TableHeaderText.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Screens.Edit +{ + public partial class TableHeaderText : OsuSpriteText + { + public TableHeaderText(LocalisableString text) + { + Text = text.ToUpper(); + Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold); + } + } +} diff --git a/osu.Game/Screens/Edit/Timing/ControlPointList.cs b/osu.Game/Screens/Edit/Timing/ControlPointList.cs index 4e4090ccd0..49e5b76dd6 100644 --- a/osu.Game/Screens/Edit/Timing/ControlPointList.cs +++ b/osu.Game/Screens/Edit/Timing/ControlPointList.cs @@ -10,7 +10,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Graphics.Containers; +using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Overlays; @@ -20,13 +20,11 @@ namespace osu.Game.Screens.Edit.Timing { public partial class ControlPointList : CompositeDrawable { - private OsuButton deleteButton = null!; private ControlPointTable table = null!; - private OsuScrollContainer scroll = null!; + private Container controls = null!; + private OsuButton deleteButton = null!; private RoundedButton addButton = null!; - private readonly IBindableList controlPointGroups = new BindableList(); - [Resolved] private EditorClock clock { get; set; } = null!; @@ -36,57 +34,78 @@ namespace osu.Game.Screens.Edit.Timing [Resolved] private Bindable selectedGroup { get; set; } = null!; - [Resolved] - private IEditorChangeHandler? changeHandler { get; set; } - [BackgroundDependencyLoader] - private void load(OverlayColourProvider colours) + private void load(OsuColour colours, OverlayColourProvider colourProvider) { RelativeSizeAxes = Axes.Both; const float margins = 10; InternalChildren = new Drawable[] { - new Box - { - Colour = colours.Background4, - RelativeSizeAxes = Axes.Both, - }, - new Box - { - Colour = colours.Background3, - RelativeSizeAxes = Axes.Y, - Width = ControlPointTable.TIMING_COLUMN_WIDTH + margins, - }, - scroll = new OsuScrollContainer + table = new ControlPointTable { RelativeSizeAxes = Axes.Both, - Child = table = new ControlPointTable(), + Groups = { BindTarget = Beatmap.ControlPointInfo.Groups, }, }, - new FillFlowContainer + controls = new Container { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - Direction = FillDirection.Horizontal, - Margin = new MarginPadding(margins), - Spacing = new Vector2(5), + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, Children = new Drawable[] { - deleteButton = new RoundedButton + new Box { - Text = "-", - Size = new Vector2(30, 30), - Action = delete, - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background2, }, - addButton = new RoundedButton + new FillFlowContainer { - Action = addNew, - Size = new Vector2(160, 30), - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Padding = new MarginPadding { Left = margins, Vertical = margins, }, + Children = new Drawable[] + { + new RoundedButton + { + Text = "Select closest to current time", + Action = goToCurrentGroup, + Size = new Vector2(220, 30), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + } + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Spacing = new Vector2(5), + Padding = new MarginPadding { Right = margins, Vertical = margins, }, + Children = new Drawable[] + { + deleteButton = new RoundedButton + { + Text = "-", + Size = new Vector2(30, 30), + Action = delete, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + BackgroundColour = colours.Red3, + }, + addButton = new RoundedButton + { + Action = addNew, + Size = new Vector2(160, 30), + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + }, + } }, } }, @@ -105,20 +124,6 @@ namespace osu.Game.Screens.Edit.Timing ? "+ Clone to current time" : "+ Add at current time"; }, true); - - controlPointGroups.BindTo(Beatmap.ControlPointInfo.Groups); - controlPointGroups.BindCollectionChanged((_, _) => - { - // This callback can happen many times in a change operation. It gets expensive. - // We really should be handling the `CollectionChanged` event properly. - Scheduler.AddOnce(() => - { - table.ControlGroups = controlPointGroups; - changeHandler?.SaveState(); - }); - }, true); - - table.OnRowSelected += drawable => scroll.ScrollIntoView(drawable); } protected override bool OnClick(ClickEvent e) @@ -131,78 +136,19 @@ namespace osu.Game.Screens.Edit.Timing { base.Update(); - trackActivePoint(); - addButton.Enabled.Value = clock.CurrentTimeAccurate != selectedGroup.Value?.Time; + table.Padding = new MarginPadding { Bottom = controls.DrawHeight }; } - private Type? trackedType; - - /// - /// Given the user has selected a control point group, we want to track any group which is - /// active at the current point in time which matches the type the user has selected. - /// - /// So if the user is currently looking at a timing point and seeks into the future, a - /// future timing point would be automatically selected if it is now the new "current" point. - /// - private void trackActivePoint() + private void goToCurrentGroup() { - // For simplicity only match on the first type of the active control point. - if (selectedGroup.Value == null) - trackedType = null; - else - { - switch (selectedGroup.Value.ControlPoints.Count) - { - // If the selected group has no control points, clear the tracked type. - // Otherwise the user will be unable to select a group with no control points. - case 0: - trackedType = null; - break; + double accurateTime = clock.CurrentTimeAccurate; - // If the selected group only has one control point, update the tracking type. - case 1: - trackedType = selectedGroup.Value?.ControlPoints[0].GetType(); - break; + var activeTimingPoint = Beatmap.ControlPointInfo.TimingPointAt(accurateTime); + var activeEffectPoint = Beatmap.ControlPointInfo.EffectPointAt(accurateTime); - // If the selected group has more than one control point, choose the first as the tracking type - // if we don't already have a singular tracked type. - default: - trackedType ??= selectedGroup.Value?.ControlPoints[0].GetType(); - break; - } - } - - if (trackedType != null) - { - double accurateTime = clock.CurrentTimeAccurate; - - // We don't have an efficient way of looking up groups currently, only individual point types. - // To improve the efficiency of this in the future, we should reconsider the overall structure of ControlPointInfo. - - // Find the next group which has the same type as the selected one. - ControlPointGroup? found = null; - - for (int i = 0; i < Beatmap.ControlPointInfo.Groups.Count; i++) - { - var g = Beatmap.ControlPointInfo.Groups[i]; - - if (g.Time > accurateTime) - continue; - - for (int j = 0; j < g.ControlPoints.Count; j++) - { - if (g.ControlPoints[j].GetType() == trackedType) - { - found = g; - break; - } - } - } - - if (found != null) - selectedGroup.Value = found; - } + double latestActiveTime = Math.Max(activeTimingPoint.Time, activeEffectPoint.Time); + selectedGroup.Value = Beatmap.ControlPointInfo.GroupAt(latestActiveTime); } private void delete() diff --git a/osu.Game/Screens/Edit/Timing/ControlPointTable.cs b/osu.Game/Screens/Edit/Timing/ControlPointTable.cs index 219575a380..fd812cfe2b 100644 --- a/osu.Game/Screens/Edit/Timing/ControlPointTable.cs +++ b/osu.Game/Screens/Edit/Timing/ControlPointTable.cs @@ -2,149 +2,366 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Pooling; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Extensions; using osu.Game.Graphics; +using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; using osu.Game.Screens.Edit.Timing.RowAttributes; using osuTK; namespace osu.Game.Screens.Edit.Timing { - public partial class ControlPointTable : EditorTable + public partial class ControlPointTable : CompositeDrawable { + public BindableList Groups { get; } = new BindableList(); + + public new MarginPadding Padding + { + get => base.Padding; + set => base.Padding = value; + } + + [Cached] + private Bindable activeTimingPoint { get; } = new Bindable(); + + [Cached] + private Bindable activeEffectPoint { get; } = new Bindable(); + + [Resolved] + private EditorBeatmap beatmap { get; set; } = null!; + [Resolved] private Bindable selectedGroup { get; set; } = null!; [Resolved] - private EditorClock clock { get; set; } = null!; + private EditorClock editorClock { get; set; } = null!; - public const float TIMING_COLUMN_WIDTH = 300; + private const float timing_column_width = 300; + private const float row_height = 25; + private const float row_horizontal_padding = 20; - public IEnumerable ControlGroups + private ControlPointRowList list = null!; + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colours) { - set + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] { - int selectedIndex = GetIndexForObject(selectedGroup.Value); - - Content = null; - BackgroundFlow.Clear(); - - if (!value.Any()) - return; - - foreach (var group in value) + new Box { - BackgroundFlow.Add(new RowBackground(group) + Colour = colours.Background4, + RelativeSizeAxes = Axes.Both, + }, + new Box + { + Colour = colours.Background3, + RelativeSizeAxes = Axes.Y, + Width = timing_column_width + 10, + }, + new Container + { + RelativeSizeAxes = Axes.X, + Height = row_height, + Padding = new MarginPadding { Horizontal = row_horizontal_padding }, + Children = new Drawable[] { - // schedule to give time for any modified focused text box to lose focus and commit changes (e.g. BPM / time signature textboxes) before switching to new point. - Action = () => Schedule(() => + new TableHeaderText("Time") { - SetSelectedRow(group); - clock.SeekSmoothlyTo(group.Time); - }) - }); - } - - Columns = createHeaders(); - Content = value.Select(createContent).ToArray().ToRectangular(); - - // Attempt to retain selection. - if (SetSelectedRow(selectedGroup.Value)) - return; - - // Some operations completely obliterate references, so best-effort reselect based on index. - if (SetSelectedRow(GetObjectAtIndex(selectedIndex))) - return; - - // Selection could not be retained. - selectedGroup.Value = null; - } + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + new TableHeaderText("Attributes") + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Margin = new MarginPadding { Left = timing_column_width } + }, + } + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Top = row_height }, + Child = list = new ControlPointRowList + { + RelativeSizeAxes = Axes.Both, + RowData = { BindTarget = Groups, }, + }, + }, + }; } protected override void LoadComplete() { base.LoadComplete(); - // Handle external selections. - selectedGroup.BindValueChanged(g => SetSelectedRow(g.NewValue), true); + selectedGroup.BindValueChanged(_ => scrollToMostRelevantRow(force: true), true); } - protected override bool SetSelectedRow(object? item) + protected override void Update() { - if (!base.SetSelectedRow(item)) - return false; + base.Update(); - selectedGroup.Value = item as ControlPointGroup; - return true; + scrollToMostRelevantRow(force: false); } - private TableColumn[] createHeaders() + private void scrollToMostRelevantRow(bool force) { - var columns = new List + double accurateTime = editorClock.CurrentTimeAccurate; + + activeTimingPoint.Value = beatmap.ControlPointInfo.TimingPointAt(accurateTime); + activeEffectPoint.Value = beatmap.ControlPointInfo.EffectPointAt(accurateTime); + + double latestActiveTime = Math.Max(activeTimingPoint.Value?.Time ?? double.NegativeInfinity, activeEffectPoint.Value?.Time ?? double.NegativeInfinity); + var groupToShow = selectedGroup.Value ?? beatmap.ControlPointInfo.GroupAt(latestActiveTime); + list.ScrollTo(groupToShow, force); + } + + private partial class ControlPointRowList : VirtualisedListContainer + { + public ControlPointRowList() + : base(row_height, 50) { - new TableColumn("Time", Anchor.CentreLeft, new Dimension(GridSizeMode.Absolute, TIMING_COLUMN_WIDTH)), - new TableColumn("Attributes", Anchor.CentreLeft), - }; + } - return columns.ToArray(); + protected override ScrollContainer CreateScrollContainer() => new UserTrackingScrollContainer(); + + protected new UserTrackingScrollContainer Scroll => (UserTrackingScrollContainer)base.Scroll; + + public void ScrollTo(ControlPointGroup group, bool force) + { + if (Scroll.UserScrolling && !force) + return; + + // can't use `.ScrollIntoView()` here because of the list virtualisation not giving + // child items valid coordinates from the start, so ballpark something similar + // using estimated row height. + var row = Items.FlowingChildren.SingleOrDefault(item => item.Row.Equals(group)); + + if (row == null) + return; + + float minPos = row.Y; + float maxPos = minPos + row_height; + + if (minPos < Scroll.Current) + Scroll.ScrollTo(minPos); + else if (maxPos > Scroll.Current + Scroll.DisplayableContent) + Scroll.ScrollTo(maxPos - Scroll.DisplayableContent); + } } - private Drawable[] createContent(ControlPointGroup group) + public partial class DrawableControlGroup : PoolableDrawable, IHasCurrentValue { - return new Drawable[] + public Bindable Current { - new ControlGroupTiming(group), - new ControlGroupAttributes(group, c => c is not TimingControlPoint) - }; + get => current.Current; + set => current.Current = value; + } + + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + private Box background = null!; + private Box currentIndicator = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private Bindable selectedGroup { get; set; } = null!; + + [Resolved] + private Bindable activeTimingPoint { get; set; } = null!; + + [Resolved] + private Bindable activeEffectPoint { get; set; } = null!; + + [Resolved] + private EditorClock editorClock { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background1, + Alpha = 0, + }, + currentIndicator = new Box + { + RelativeSizeAxes = Axes.Y, + Width = 5, + Alpha = 0, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Horizontal = row_horizontal_padding, }, + Children = new Drawable[] + { + new ControlGroupTiming { Group = { BindTarget = current }, }, + new ControlGroupAttributes(point => point is not TimingControlPoint) + { + Group = { BindTarget = current }, + Margin = new MarginPadding { Left = timing_column_width } + } + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + selectedGroup.BindValueChanged(_ => updateState()); + activeEffectPoint.BindValueChanged(_ => updateState()); + activeTimingPoint.BindValueChanged(_ => updateState(), true); + FinishTransforms(true); + } + + protected override void PrepareForUse() + { + base.PrepareForUse(); + + updateState(); + } + + protected override bool OnHover(HoverEvent e) + { + updateState(); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + updateState(); + } + + protected override bool OnClick(ClickEvent e) + { + // schedule to give time for any modified focused text box to lose focus and commit changes (e.g. BPM / time signature textboxes) before switching to new point. + var currentGroup = Current.Value; + Schedule(() => + { + selectedGroup.Value = currentGroup; + editorClock.SeekSmoothlyTo(currentGroup.Time); + }); + return true; + } + + private void updateState() + { + bool isSelected = selectedGroup.Value?.Equals(current.Value) == true; + + bool hasCurrentTimingPoint = activeTimingPoint.Value != null && current.Value.ControlPoints.Contains(activeTimingPoint.Value); + bool hasCurrentEffectPoint = activeEffectPoint.Value != null && current.Value.ControlPoints.Contains(activeEffectPoint.Value); + + if (IsHovered || isSelected) + background.FadeIn(100, Easing.OutQuint); + else if (hasCurrentTimingPoint || hasCurrentEffectPoint) + background.FadeTo(0.2f, 100, Easing.OutQuint); + else + background.FadeOut(100, Easing.OutQuint); + + background.Colour = isSelected ? colourProvider.Colour3 : colourProvider.Background1; + + if (hasCurrentTimingPoint || hasCurrentEffectPoint) + { + currentIndicator.FadeIn(100, Easing.OutQuint); + + if (hasCurrentTimingPoint && hasCurrentEffectPoint) + currentIndicator.Colour = ColourInfo.GradientVertical(activeTimingPoint.Value!.GetRepresentingColour(colours), activeEffectPoint.Value!.GetRepresentingColour(colours)); + else if (hasCurrentTimingPoint) + currentIndicator.Colour = activeTimingPoint.Value!.GetRepresentingColour(colours); + else + currentIndicator.Colour = activeEffectPoint.Value!.GetRepresentingColour(colours); + } + else + currentIndicator.FadeOut(100, Easing.OutQuint); + } } private partial class ControlGroupTiming : FillFlowContainer { - public ControlGroupTiming(ControlPointGroup group) + public Bindable Group { get; } = new Bindable(); + + private OsuSpriteText timeText = null!; + + [BackgroundDependencyLoader] + private void load() { Name = @"ControlGroupTiming"; RelativeSizeAxes = Axes.Y; - Width = TIMING_COLUMN_WIDTH; + Width = timing_column_width; Spacing = new Vector2(5); Children = new Drawable[] { - new OsuSpriteText + timeText = new OsuSpriteText { - Text = group.Time.ToEditorFormattedString(), - Font = OsuFont.GetFont(size: TEXT_SIZE, weight: FontWeight.Bold), + Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold), Width = 70, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, }, - new ControlGroupAttributes(group, c => c is TimingControlPoint) + new ControlGroupAttributes(c => c is TimingControlPoint) { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, + Group = { BindTarget = Group }, } }; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Group.BindValueChanged(_ => timeText.Text = Group.Value?.Time.ToEditorFormattedString() ?? default(LocalisableString), true); + } } private partial class ControlGroupAttributes : CompositeDrawable { + public Bindable Group { get; } = new Bindable(); + private BindableList controlPoints { get; } = new BindableList(); + private readonly Func matchFunction; - private readonly IBindableList controlPoints = new BindableList(); + private FillFlowContainer fill = null!; - private readonly FillFlowContainer fill; - - public ControlGroupAttributes(ControlPointGroup group, Func matchFunction) + public ControlGroupAttributes(Func matchFunction) { this.matchFunction = matchFunction; + } + [BackgroundDependencyLoader] + private void load() + { AutoSizeAxes = Axes.X; RelativeSizeAxes = Axes.Y; Name = @"ControlGroupAttributes"; @@ -156,20 +373,21 @@ namespace osu.Game.Screens.Edit.Timing Direction = FillDirection.Horizontal, Spacing = new Vector2(2) }; - - controlPoints.BindTo(group.ControlPoints); - } - - [BackgroundDependencyLoader] - private void load() - { - createChildren(); } protected override void LoadComplete() { base.LoadComplete(); - controlPoints.CollectionChanged += (_, _) => createChildren(); + + Group.BindValueChanged(_ => + { + controlPoints.UnbindBindings(); + controlPoints.Clear(); + if (Group.Value != null) + ((IBindableList)controlPoints).BindTo(Group.Value.ControlPoints); + }, true); + + controlPoints.BindCollectionChanged((_, _) => createChildren(), true); } private void createChildren() @@ -189,14 +407,8 @@ namespace osu.Game.Screens.Edit.Timing case TimingControlPoint timing: return new TimingRowAttribute(timing); - case DifficultyControlPoint difficulty: - return new DifficultyRowAttribute(difficulty); - case EffectControlPoint effect: return new EffectRowAttribute(effect); - - case SampleControlPoint sample: - return new SampleRowAttribute(sample); } throw new ArgumentOutOfRangeException(nameof(controlPoint), $"Control point type {controlPoint.GetType()} is not supported"); diff --git a/osu.Game/Screens/Edit/Timing/EffectSection.cs b/osu.Game/Screens/Edit/Timing/EffectSection.cs index f321f7eeb0..f9ef460232 100644 --- a/osu.Game/Screens/Edit/Timing/EffectSection.cs +++ b/osu.Game/Screens/Edit/Timing/EffectSection.cs @@ -5,9 +5,7 @@ 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.Rulesets.UI.Scrolling; namespace osu.Game.Screens.Edit.Timing { @@ -38,8 +36,7 @@ namespace osu.Game.Screens.Edit.Timing kiai.Current.BindValueChanged(_ => saveChanges()); scrollSpeedSlider.Current.BindValueChanged(_ => saveChanges()); - var drawableRuleset = Beatmap.BeatmapInfo.Ruleset.CreateInstance().CreateDrawableRulesetWith(Beatmap.PlayableBeatmap); - if (drawableRuleset is not IDrawableScrollingRuleset scrollingRuleset || scrollingRuleset.VisualisationMethod == ScrollVisualisationMethod.Constant) + if (!Beatmap.BeatmapInfo.Ruleset.CreateInstance().EditorShowScrollSpeed) scrollSpeedSlider.Hide(); void saveChanges() @@ -59,7 +56,7 @@ namespace osu.Game.Screens.Edit.Timing isRebinding = true; kiai.Current = newEffectPoint.KiaiModeBindable; - scrollSpeedSlider.Current = new BindableDouble + scrollSpeedSlider.Current = new BindableDouble(1) { MinValue = 0.01, MaxValue = 10, diff --git a/osu.Game/Screens/Edit/Timing/GroupSection.cs b/osu.Game/Screens/Edit/Timing/GroupSection.cs index 487a871881..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!; @@ -51,7 +55,8 @@ namespace osu.Game.Screens.Edit.Timing { textBox = new LabelledTextBox { - Label = "Time" + Label = "Time", + SelectAllOnFocus = true, }, button = new RoundedButton { @@ -109,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/IndeterminateSliderWithTextBoxInput.cs b/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs index 151d469415..00cf2e3493 100644 --- a/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs +++ b/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs @@ -1,7 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; +using System.Numerics; using System.Globalization; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -12,7 +12,7 @@ using osu.Framework.Localisation; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Overlays.Settings; using osu.Game.Utils; -using osuTK; +using Vector2 = osuTK.Vector2; namespace osu.Game.Screens.Edit.Timing { @@ -22,7 +22,7 @@ namespace osu.Game.Screens.Edit.Timing /// by providing an "indeterminate state". /// public partial class IndeterminateSliderWithTextBoxInput : CompositeDrawable, IHasCurrentValue - where T : struct, IEquatable, IComparable, IConvertible + where T : struct, INumber, IMinMaxValue { /// /// A custom step value for each key press which actuates a change on this control. @@ -75,6 +75,7 @@ namespace osu.Game.Screens.Edit.Timing textBox = new LabelledTextBox { Label = labelText, + SelectAllOnFocus = true, }, slider = new SettingsSlider { @@ -126,7 +127,7 @@ namespace osu.Game.Screens.Edit.Timing protected override void OnFocus(FocusEvent e) { base.OnFocus(e); - GetContainingInputManager().ChangeFocus(textBox); + GetContainingFocusManager()!.ChangeFocus(textBox); } private void updateState() @@ -136,7 +137,7 @@ namespace osu.Game.Screens.Edit.Timing slider.Current.Value = nonNullValue; // use the value from the slider to ensure that any precision/min/max set on it via the initial indeterminate value have been applied correctly. - decimal decimalValue = slider.Current.Value.ToDecimal(NumberFormatInfo.InvariantInfo); + decimal decimalValue = decimal.CreateTruncating(slider.Current.Value); textBox.Text = decimalValue.ToString($@"N{FormatUtils.FindPrecision(decimalValue)}"); textBox.PlaceholderText = string.Empty; } diff --git a/osu.Game/Screens/Edit/Timing/RowAttributes/AttributeProgressBar.cs b/osu.Game/Screens/Edit/Timing/RowAttributes/AttributeProgressBar.cs index 4cae774078..0a89f196fa 100644 --- a/osu.Game/Screens/Edit/Timing/RowAttributes/AttributeProgressBar.cs +++ b/osu.Game/Screens/Edit/Timing/RowAttributes/AttributeProgressBar.cs @@ -36,6 +36,7 @@ namespace osu.Game.Screens.Edit.Timing.RowAttributes BackgroundColour = overlayColours.Background6; FillColour = controlPoint.GetRepresentingColour(colours); + FinishTransforms(true); } } } diff --git a/osu.Game/Screens/Edit/Timing/RowAttributes/DifficultyRowAttribute.cs b/osu.Game/Screens/Edit/Timing/RowAttributes/DifficultyRowAttribute.cs deleted file mode 100644 index 43f3739503..0000000000 --- a/osu.Game/Screens/Edit/Timing/RowAttributes/DifficultyRowAttribute.cs +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Graphics.Sprites; - -namespace osu.Game.Screens.Edit.Timing.RowAttributes -{ - public partial class DifficultyRowAttribute : RowAttribute - { - private readonly BindableNumber speedMultiplier; - - private OsuSpriteText text = null!; - - public DifficultyRowAttribute(DifficultyControlPoint difficulty) - : base(difficulty, "difficulty") - { - speedMultiplier = difficulty.SliderVelocityBindable.GetBoundCopy(); - } - - [BackgroundDependencyLoader] - private void load() - { - Content.AddRange(new Drawable[] - { - new AttributeProgressBar(Point) - { - Current = speedMultiplier, - }, - text = new AttributeText(Point) - { - Width = 45, - }, - }); - - speedMultiplier.BindValueChanged(_ => updateText(), true); - } - - private void updateText() => text.Text = $"{speedMultiplier.Value:n2}x"; - } -} diff --git a/osu.Game/Screens/Edit/Timing/RowAttributes/EffectRowAttribute.cs b/osu.Game/Screens/Edit/Timing/RowAttributes/EffectRowAttribute.cs index ad22aa81fc..87ee675e7f 100644 --- a/osu.Game/Screens/Edit/Timing/RowAttributes/EffectRowAttribute.cs +++ b/osu.Game/Screens/Edit/Timing/RowAttributes/EffectRowAttribute.cs @@ -15,6 +15,10 @@ namespace osu.Game.Screens.Edit.Timing.RowAttributes private AttributeText kiaiModeBubble = null!; private AttributeText text = null!; + private AttributeProgressBar progressBar = null!; + + [Resolved] + protected EditorBeatmap Beatmap { get; private set; } = null!; public EffectRowAttribute(EffectControlPoint effect) : base(effect, "effect") @@ -28,7 +32,7 @@ namespace osu.Game.Screens.Edit.Timing.RowAttributes { Content.AddRange(new Drawable[] { - new AttributeProgressBar(Point) + progressBar = new AttributeProgressBar(Point) { Current = scrollSpeed, }, @@ -36,6 +40,12 @@ namespace osu.Game.Screens.Edit.Timing.RowAttributes kiaiModeBubble = new AttributeText(Point) { Text = "kiai" }, }); + if (!Beatmap.BeatmapInfo.Ruleset.CreateInstance().EditorShowScrollSpeed) + { + text.Hide(); + progressBar.Hide(); + } + kiaiMode.BindValueChanged(enabled => kiaiModeBubble.FadeTo(enabled.NewValue ? 1 : 0), true); scrollSpeed.BindValueChanged(_ => updateText(), true); } diff --git a/osu.Game/Screens/Edit/Timing/RowAttributes/SampleRowAttribute.cs b/osu.Game/Screens/Edit/Timing/RowAttributes/SampleRowAttribute.cs deleted file mode 100644 index e86a991521..0000000000 --- a/osu.Game/Screens/Edit/Timing/RowAttributes/SampleRowAttribute.cs +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Graphics.Sprites; - -namespace osu.Game.Screens.Edit.Timing.RowAttributes -{ - public partial class SampleRowAttribute : RowAttribute - { - private AttributeText sampleText = null!; - private OsuSpriteText volumeText = null!; - - private readonly Bindable sampleBank; - private readonly BindableNumber volume; - - public SampleRowAttribute(SampleControlPoint sample) - : base(sample, "sample") - { - sampleBank = sample.SampleBankBindable.GetBoundCopy(); - volume = sample.SampleVolumeBindable.GetBoundCopy(); - } - - [BackgroundDependencyLoader] - private void load() - { - AttributeProgressBar progress; - - Content.AddRange(new Drawable[] - { - sampleText = new AttributeText(Point), - progress = new AttributeProgressBar(Point), - volumeText = new AttributeText(Point) - { - Width = 40, - }, - }); - - volume.BindValueChanged(vol => - { - progress.Current.Value = vol.NewValue / 100f; - updateText(); - }, true); - - sampleBank.BindValueChanged(_ => updateText(), true); - } - - private void updateText() - { - volumeText.Text = $"{volume.Value}%"; - sampleText.Text = $"{sampleBank.Value}"; - } - } -} 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/TimingScreen.cs b/osu.Game/Screens/Edit/Timing/TimingScreen.cs index 3f911f5067..67d4429be8 100644 --- a/osu.Game/Screens/Edit/Timing/TimingScreen.cs +++ b/osu.Game/Screens/Edit/Timing/TimingScreen.cs @@ -6,6 +6,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Screens.Edit.Compose.Components.Timeline; namespace osu.Game.Screens.Edit.Timing { @@ -53,5 +54,12 @@ namespace osu.Game.Screens.Edit.Timing SelectedGroup.Value = EditorBeatmap.ControlPointInfo.GroupAt(nearestTimingPoint.Time); } } + + protected override void ConfigureTimeline(TimelineArea timelineArea) + { + base.ConfigureTimeline(timelineArea); + + timelineArea.Timeline.AlwaysShowControlPoints = true; + } } } diff --git a/osu.Game/Screens/Edit/Timing/TimingSection.cs b/osu.Game/Screens/Edit/Timing/TimingSection.cs index 2757753b07..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,16 +97,21 @@ 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() { 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) @@ -97,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/Edit/Verify/IssueList.cs b/osu.Game/Screens/Edit/Verify/IssueList.cs index d07190fca0..de7b760bcd 100644 --- a/osu.Game/Screens/Edit/Verify/IssueList.cs +++ b/osu.Game/Screens/Edit/Verify/IssueList.cs @@ -11,7 +11,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Beatmaps; -using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Overlays; using osu.Game.Rulesets.Edit; @@ -56,10 +55,9 @@ namespace osu.Game.Screens.Edit.Verify Colour = colours.Background3, RelativeSizeAxes = Axes.Both, }, - new OsuScrollContainer + table = new IssueTable { RelativeSizeAxes = Axes.Both, - Child = table = new IssueTable(), }, new FillFlowContainer { @@ -101,9 +99,10 @@ namespace osu.Game.Screens.Edit.Verify issues = filter(issues); - table.Issues = issues - .OrderBy(issue => issue.Template.Type) - .ThenBy(issue => issue.Check.Metadata.Category); + table.Issues.Clear(); + table.Issues.AddRange(issues + .OrderBy(issue => issue.Template.Type) + .ThenBy(issue => issue.Check.Metadata.Category)); } private IEnumerable filter(IEnumerable issues) diff --git a/osu.Game/Screens/Edit/Verify/IssueTable.cs b/osu.Game/Screens/Edit/Verify/IssueTable.cs index ba5f98a772..fbe789d452 100644 --- a/osu.Game/Screens/Edit/Verify/IssueTable.cs +++ b/osu.Game/Screens/Edit/Verify/IssueTable.cs @@ -1,132 +1,239 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Pooling; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Game.Graphics; +using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Input.Bindings; +using osu.Game.Overlays; using osu.Game.Rulesets.Edit.Checks.Components; namespace osu.Game.Screens.Edit.Verify { - public partial class IssueTable : EditorTable + public partial class IssueTable : CompositeDrawable { - private Bindable selectedIssue = null!; + public BindableList Issues { get; } = new BindableList(); - [Resolved] - private VerifyScreen verify { get; set; } = null!; + public const float COLUMN_WIDTH = 70; + public const float COLUMN_GAP = 10; + public const float ROW_HEIGHT = 25; + public const float ROW_HORIZONTAL_PADDING = 20; + public const int TEXT_SIZE = 14; - [Resolved] - private EditorClock clock { get; set; } = null!; - - [Resolved] - private EditorBeatmap editorBeatmap { get; set; } = null!; - - [Resolved] - private Editor editor { get; set; } = null!; - - public IEnumerable Issues + [BackgroundDependencyLoader] + private void load() { - set + InternalChildren = new Drawable[] { - Content = null; - BackgroundFlow.Clear(); - - if (!value.Any()) - return; - - foreach (var issue in value) + new Container { - BackgroundFlow.Add(new RowBackground(issue) + RelativeSizeAxes = Axes.X, + Height = ROW_HEIGHT, + Padding = new MarginPadding { Horizontal = ROW_HORIZONTAL_PADDING, }, + Children = new[] { - Action = () => + new TableHeaderText("Type") { - selectedIssue.Value = issue; - - if (issue.Time != null) - { - clock.Seek(issue.Time.Value); - editor.OnPressed(new KeyBindingPressEvent(GetContainingInputManager().CurrentState, GlobalAction.EditorComposeMode)); - } - - if (!issue.HitObjects.Any()) - return; - - editorBeatmap.SelectedHitObjects.Clear(); - editorBeatmap.SelectedHitObjects.AddRange(issue.HitObjects); + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, }, - }); + new TableHeaderText("Time") + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Margin = new MarginPadding { Left = COLUMN_WIDTH + COLUMN_GAP }, + }, + new TableHeaderText("Message") + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Margin = new MarginPadding { Left = 2 * (COLUMN_WIDTH + COLUMN_GAP) }, + }, + new TableHeaderText("Category") + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + }, + } + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Top = ROW_HEIGHT, }, + Child = new IssueRowList + { + RelativeSizeAxes = Axes.Both, + RowData = { BindTarget = Issues } + } + } + }; + } + + private partial class IssueRowList : VirtualisedListContainer + { + public IssueRowList() + : base(ROW_HEIGHT, 50) + { + } + + protected override ScrollContainer CreateScrollContainer() => new OsuScrollContainer(); + } + + public partial class DrawableIssue : PoolableDrawable, IHasCurrentValue + { + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + private readonly Bindable selectedIssue = new Bindable(); + + private Box background = null!; + private OsuSpriteText issueTypeText = null!; + private OsuSpriteText issueTimestampText = null!; + private OsuSpriteText issueDetailText = null!; + private OsuSpriteText issueCategoryText = null!; + + [Resolved] + private EditorClock clock { get; set; } = null!; + + [Resolved] + private EditorBeatmap editorBeatmap { get; set; } = null!; + + [Resolved] + private Editor editor { get; set; } = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + [BackgroundDependencyLoader] + private void load(VerifyScreen verify) + { + RelativeSizeAxes = Axes.X; + Height = ROW_HEIGHT; + + InternalChildren = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Horizontal = 20, }, + Children = new Drawable[] + { + issueTypeText = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: TEXT_SIZE, weight: FontWeight.Bold), + }, + issueTimestampText = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: TEXT_SIZE, weight: FontWeight.Bold), + Margin = new MarginPadding { Left = COLUMN_WIDTH + COLUMN_GAP }, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding + { + Left = 2 * (COLUMN_GAP + COLUMN_WIDTH), + Right = COLUMN_GAP + COLUMN_WIDTH, + }, + Child = issueDetailText = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: TEXT_SIZE, weight: FontWeight.Medium) + }, + }, + issueCategoryText = new OsuSpriteText + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Font = OsuFont.GetFont(size: TEXT_SIZE, weight: FontWeight.Bold), + } + } + } + }; + + selectedIssue.BindTo(verify.SelectedIssue); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + selectedIssue.BindValueChanged(_ => updateState()); + Current.BindValueChanged(_ => updateState(), true); + FinishTransforms(true); + } + + protected override bool OnHover(HoverEvent e) + { + updateState(); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateState(); + base.OnHoverLost(e); + } + + protected override bool OnClick(ClickEvent e) + { + selectedIssue.Value = current.Value; + + if (current.Value.Time != null) + { + clock.Seek(current.Value.Time.Value); + editor.OnPressed(new KeyBindingPressEvent(GetContainingInputManager()!.CurrentState, GlobalAction.EditorComposeMode)); } - Columns = createHeaders(); - Content = value.Select((g, i) => createContent(i, g)).ToArray().ToRectangular(); + if (current.Value.HitObjects.Any()) + { + editorBeatmap.SelectedHitObjects.Clear(); + editorBeatmap.SelectedHitObjects.AddRange(current.Value.HitObjects); + } + + return true; + } + + private void updateState() + { + issueTypeText.Text = Current.Value.Template.Type.ToString(); + issueTypeText.Colour = Current.Value.Template.Colour; + issueTimestampText.Text = Current.Value.GetEditorTimestamp(); + issueDetailText.Text = Current.Value.ToString(); + issueCategoryText.Text = Current.Value.Check.Metadata.Category.ToString(); + + bool isSelected = selectedIssue.Value == current.Value; + + if (IsHovered || isSelected) + background.FadeIn(100, Easing.OutQuint); + else + background.FadeOut(100, Easing.OutQuint); + + background.Colour = isSelected ? colourProvider.Colour3 : colourProvider.Background1; } } - - protected override void LoadComplete() - { - base.LoadComplete(); - - selectedIssue = verify.SelectedIssue.GetBoundCopy(); - selectedIssue.BindValueChanged(issue => - { - SetSelectedRow(issue.NewValue); - }, true); - } - - private TableColumn[] createHeaders() - { - var columns = new List - { - new TableColumn(string.Empty, Anchor.CentreLeft, new Dimension(GridSizeMode.AutoSize)), - new TableColumn("Type", Anchor.CentreLeft, new Dimension(GridSizeMode.AutoSize, minSize: 60)), - new TableColumn("Time", Anchor.CentreLeft, new Dimension(GridSizeMode.AutoSize, minSize: 60)), - new TableColumn("Message", Anchor.CentreLeft), - new TableColumn("Category", Anchor.CentreRight, new Dimension(GridSizeMode.AutoSize)), - }; - - return columns.ToArray(); - } - - private Drawable[] createContent(int index, Issue issue) => new Drawable[] - { - new OsuSpriteText - { - Text = $"#{index + 1}", - Font = OsuFont.GetFont(size: TEXT_SIZE, weight: FontWeight.Medium), - Margin = new MarginPadding { Right = 10 } - }, - new OsuSpriteText - { - Text = issue.Template.Type.ToString(), - Font = OsuFont.GetFont(size: TEXT_SIZE, weight: FontWeight.Bold), - Margin = new MarginPadding { Right = 10 }, - Colour = issue.Template.Colour - }, - new OsuSpriteText - { - Text = issue.GetEditorTimestamp(), - Font = OsuFont.GetFont(size: TEXT_SIZE, weight: FontWeight.Bold), - Margin = new MarginPadding { Right = 10 }, - }, - new OsuSpriteText - { - Text = issue.ToString(), - Font = OsuFont.GetFont(size: TEXT_SIZE, weight: FontWeight.Medium) - }, - new OsuSpriteText - { - Text = issue.Check.Metadata.Category.ToString(), - Font = OsuFont.GetFont(size: TEXT_SIZE, weight: FontWeight.Bold), - Margin = new MarginPadding(10) - } - }; } } diff --git a/osu.Game/Screens/Edit/WaveformOpacityMenuItem.cs b/osu.Game/Screens/Edit/WaveformOpacityMenuItem.cs index 9dc0ea0d07..c379e56940 100644 --- a/osu.Game/Screens/Edit/WaveformOpacityMenuItem.cs +++ b/osu.Game/Screens/Edit/WaveformOpacityMenuItem.cs @@ -20,6 +20,7 @@ namespace osu.Game.Screens.Edit { Items = new[] { + createMenuItem(0f), createMenuItem(0.25f), createMenuItem(0.5f), createMenuItem(0.75f), diff --git a/osu.Game/Screens/Footer/ScreenBackButton.cs b/osu.Game/Screens/Footer/ScreenBackButton.cs new file mode 100644 index 0000000000..bf29186bb1 --- /dev/null +++ b/osu.Game/Screens/Footer/ScreenBackButton.cs @@ -0,0 +1,61 @@ +// 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.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Footer +{ + public partial class ScreenBackButton : ShearedButton + { + public const float BUTTON_WIDTH = 240; + + public ScreenBackButton() + : base(BUTTON_WIDTH) + { + } + + [BackgroundDependencyLoader] + private void load() + { + ButtonContent.Child = new FillFlowContainer + { + X = -10f, + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(20f, 0f), + Children = new Drawable[] + { + new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(17f), + Icon = FontAwesome.Solid.ChevronLeft, + }, + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.TorusAlternate.With(size: 17), + Text = CommonStrings.Back, + UseFullGlyphHeight = false, + } + } + }; + + DarkerColour = Color4Extensions.FromHex("#DE31AE"); + LighterColour = Color4Extensions.FromHex("#FF86DD"); + TextColour = Color4.White; + } + } +} diff --git a/osu.Game/Screens/Footer/ScreenFooter.cs b/osu.Game/Screens/Footer/ScreenFooter.cs new file mode 100644 index 0000000000..ea32507ca0 --- /dev/null +++ b/osu.Game/Screens/Footer/ScreenFooter.cs @@ -0,0 +1,334 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Framework.Threading; +using osu.Game.Graphics.Containers; +using osu.Game.Input.Bindings; +using osu.Game.Overlays; +using osu.Game.Overlays.Mods; +using osu.Game.Screens.Menu; +using osuTK; + +namespace osu.Game.Screens.Footer +{ + public partial class ScreenFooter : OverlayContainer + { + private const int padding = 60; + private const float delay_per_button = 30; + private const double transition_duration = 400; + + public const int HEIGHT = 50; + + private readonly List overlays = new List(); + + private Box background = null!; + private FillFlowContainer buttonsFlow = null!; + private Container removedButtonsContainer = null!; + private LogoTrackingContainer logoTrackingContainer = null!; + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + + [Resolved] + private OsuGame? game { get; set; } + + public ScreenBackButton BackButton { get; private set; } = null!; + + public Action? RequestLogoInFront { get; set; } + + public Action? OnBack; + + public ScreenFooter(BackReceptor? receptor = null) + { + RelativeSizeAxes = Axes.X; + Height = HEIGHT; + Anchor = Anchor.BottomLeft; + Origin = Anchor.BottomLeft; + + if (receptor == null) + Add(receptor = new BackReceptor()); + + receptor.OnBackPressed = () => BackButton.TriggerClick(); + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5 + }, + buttonsFlow = new FillFlowContainer + { + Margin = new MarginPadding { Left = 12f + ScreenBackButton.BUTTON_WIDTH + padding }, + Y = 10f, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(7, 0), + AutoSizeAxes = Axes.Both + }, + BackButton = new ScreenBackButton + { + Margin = new MarginPadding { Bottom = 15f, Left = 12f }, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Action = onBackPressed, + }, + removedButtonsContainer = new Container + { + Margin = new MarginPadding { Left = 12f + ScreenBackButton.BUTTON_WIDTH + padding }, + Y = 10f, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + AutoSizeAxes = Axes.Both, + }, + (logoTrackingContainer = new LogoTrackingContainer + { + RelativeSizeAxes = Axes.Both, + }).WithChild(logoTrackingContainer.LogoFacade.With(f => + { + f.Anchor = Anchor.BottomRight; + f.Origin = Anchor.Centre; + f.Position = new Vector2(-76, -36); + })), + }; + } + + private ScheduledDelegate? changeLogoDepthDelegate; + + public void StartTrackingLogo(OsuLogo logo, float duration = 0, Easing easing = Easing.None) + { + changeLogoDepthDelegate?.Cancel(); + changeLogoDepthDelegate = null; + + logoTrackingContainer.StartTracking(logo, duration, easing); + RequestLogoInFront?.Invoke(true); + } + + public void StopTrackingLogo() + { + logoTrackingContainer.StopTracking(); + + if (game != null) + changeLogoDepthDelegate = Scheduler.AddDelayed(() => RequestLogoInFront?.Invoke(false), transition_duration); + } + + protected override void PopIn() + { + this.MoveToY(0, transition_duration, Easing.OutQuint) + .FadeIn(transition_duration, Easing.OutQuint); + } + + protected override void PopOut() + { + this.MoveToY(HEIGHT, transition_duration, Easing.OutQuint) + .FadeOut(transition_duration, Easing.OutQuint); + } + + public void SetButtons(IReadOnlyList buttons) + { + temporarilyHiddenButtons.Clear(); + overlays.Clear(); + + clearActiveOverlayContainer(); + + var oldButtons = buttonsFlow.ToArray(); + + for (int i = 0; i < oldButtons.Length; i++) + { + var oldButton = oldButtons[i]; + + buttonsFlow.Remove(oldButton, false); + removedButtonsContainer.Add(oldButton); + + if (buttons.Count > 0) + makeButtonDisappearToRight(oldButton, i, oldButtons.Length, true); + else + makeButtonDisappearToBottom(oldButton, i, oldButtons.Length, true); + } + + for (int i = 0; i < buttons.Count; i++) + { + var newButton = buttons[i]; + + if (newButton.Overlay != null) + { + newButton.Action = () => showOverlay(newButton.Overlay); + overlays.Add(newButton.Overlay); + } + + Debug.Assert(!newButton.IsLoaded); + buttonsFlow.Add(newButton); + + int index = i; + + // ensure transforms are added after LoadComplete to not be aborted by the FinishTransforms call. + newButton.OnLoadComplete += _ => + { + if (oldButtons.Length > 0) + makeButtonAppearFromLeft(newButton, index, buttons.Count, 240); + else + makeButtonAppearFromBottom(newButton, index); + }; + } + } + + private ShearedOverlayContainer? activeOverlay; + private Container? contentContainer; + + private readonly List temporarilyHiddenButtons = new List(); + + public IDisposable RegisterActiveOverlayContainer(ShearedOverlayContainer overlay, out VisibilityContainer? footerContent) + { + if (activeOverlay != null) + { + throw new InvalidOperationException(@"Cannot set overlay content while one is already present. " + + $@"The previous overlay ({activeOverlay.GetType().Name}) should be hidden first."); + } + + activeOverlay = overlay; + + Debug.Assert(temporarilyHiddenButtons.Count == 0); + + var targetButton = buttonsFlow.SingleOrDefault(b => b.Overlay == overlay); + + temporarilyHiddenButtons.AddRange(targetButton != null + ? buttonsFlow.SkipWhile(b => b != targetButton).Skip(1) + : buttonsFlow); + + for (int i = 0; i < temporarilyHiddenButtons.Count; i++) + makeButtonDisappearToBottom(temporarilyHiddenButtons[i], 0, 0, false); + + var fallbackPosition = buttonsFlow.Any() + ? buttonsFlow.ToSpaceOfOtherDrawable(Vector2.Zero, this) + : BackButton.ToSpaceOfOtherDrawable(BackButton.LayoutRectangle.TopRight + new Vector2(5f, 0f), this); + + var targetPosition = targetButton?.ToSpaceOfOtherDrawable(targetButton.LayoutRectangle.TopRight, this) ?? fallbackPosition; + + updateColourScheme(overlay.ColourProvider.Hue); + + footerContent = overlay.CreateFooterContent(); + + var content = footerContent ?? Empty(); + + Add(contentContainer = new Container + { + Y = -15f, + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Left = targetPosition.X }, + Child = content, + }); + + if (temporarilyHiddenButtons.Count > 0) + this.Delay(60).Schedule(() => content.Show()); + else + content.Show(); + + return new InvokeOnDisposal(clearActiveOverlayContainer); + } + + private void clearActiveOverlayContainer() + { + if (activeOverlay == null) + return; + + Debug.Assert(contentContainer != null); + contentContainer.Child.Hide(); + + double timeUntilRun = contentContainer.Child.LatestTransformEndTime - Time.Current; + + for (int i = 0; i < temporarilyHiddenButtons.Count; i++) + makeButtonAppearFromBottom(temporarilyHiddenButtons[i], 0); + + temporarilyHiddenButtons.Clear(); + + updateColourScheme(OverlayColourScheme.Aquamarine.GetHue()); + + contentContainer.Delay(timeUntilRun).Expire(); + contentContainer = null; + activeOverlay = null; + } + + private void updateColourScheme(int hue) + { + colourProvider.ChangeColourScheme(hue); + + background.FadeColour(colourProvider.Background5, 150, Easing.OutQuint); + + foreach (var button in buttonsFlow) + button.UpdateDisplay(); + } + + private void makeButtonAppearFromLeft(ScreenFooterButton button, int index, int count, float startDelay) + => button.AppearFromLeft(startDelay + (count - index) * delay_per_button); + + private void makeButtonAppearFromBottom(ScreenFooterButton button, int index) + => button.AppearFromBottom(index * delay_per_button); + + private void makeButtonDisappearToRight(ScreenFooterButton button, int index, int count, bool expire) + => button.DisappearToRight((count - index) * delay_per_button, expire); + + private void makeButtonDisappearToBottom(ScreenFooterButton button, int index, int count, bool expire) + => button.DisappearToBottom((count - index) * delay_per_button, expire); + + private void showOverlay(OverlayContainer overlay) + { + foreach (var o in overlays.Where(o => o != overlay)) + o.Hide(); + + overlay.ToggleVisibility(); + } + + private void onBackPressed() + { + if (activeOverlay != null) + { + if (activeOverlay.OnBackButton()) + return; + + activeOverlay.Hide(); + return; + } + + OnBack?.Invoke(); + } + + public partial class BackReceptor : Drawable, IKeyBindingHandler + { + public Action? OnBackPressed; + + public bool OnPressed(KeyBindingPressEvent e) + { + if (e.Repeat) + return false; + + switch (e.Action) + { + case GlobalAction.Back: + OnBackPressed?.Invoke(); + return true; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + } + } +} diff --git a/osu.Game/Screens/Footer/ScreenFooterButton.cs b/osu.Game/Screens/Footer/ScreenFooterButton.cs new file mode 100644 index 0000000000..6515203ca0 --- /dev/null +++ b/osu.Game/Screens/Footer/ScreenFooterButton.cs @@ -0,0 +1,273 @@ +// 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.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Input.Bindings; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Footer +{ + public partial class ScreenFooterButton : OsuClickableContainer, IKeyBindingHandler + { + private const float shear = OsuGame.SHEAR; + + protected const int CORNER_RADIUS = 10; + protected const int BUTTON_HEIGHT = 75; + protected const int BUTTON_WIDTH = 116; + + public Bindable OverlayState = new Bindable(); + + protected static readonly Vector2 BUTTON_SHEAR = new Vector2(shear, 0); + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + private Colour4 buttonAccentColour; + + public Colour4 AccentColour + { + set + { + buttonAccentColour = value; + bar.Colour = buttonAccentColour; + icon.Colour = buttonAccentColour; + } + } + + public IconUsage Icon + { + set => icon.Icon = value; + } + + public LocalisableString Text + { + set => text.Text = value; + } + + private readonly SpriteText text; + private readonly SpriteIcon icon; + + protected Container TextContainer; + private readonly Box bar; + private readonly Box backgroundBox; + private readonly Box glowBox; + private readonly Box flashLayer; + + public readonly OverlayContainer? Overlay; + + public ScreenFooterButton(OverlayContainer? overlay = null) + { + Overlay = overlay; + + Size = new Vector2(BUTTON_WIDTH, BUTTON_HEIGHT); + + Children = new Drawable[] + { + new Container + { + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Radius = 4, + // Figma says 50% opacity, but it does not match up visually if taken at face value, and looks bad. + Colour = Colour4.Black.Opacity(0.25f), + Offset = new Vector2(0, 2), + }, + Shear = BUTTON_SHEAR, + Masking = true, + CornerRadius = CORNER_RADIUS, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + backgroundBox = new Box + { + RelativeSizeAxes = Axes.Both + }, + glowBox = new Box + { + RelativeSizeAxes = Axes.Both + }, + // For elements that should not be sheared. + new Container + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Shear = -BUTTON_SHEAR, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + TextContainer = new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Y = 35, + AutoSizeAxes = Axes.Both, + Child = text = new OsuSpriteText + { + Font = OsuFont.TorusAlternate.With(size: 16), + AlwaysPresent = true + } + }, + icon = new SpriteIcon + { + Y = 10, + Size = new Vector2(16), + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre + }, + } + }, + new Container + { + Shear = -BUTTON_SHEAR, + Anchor = Anchor.BottomCentre, + Origin = Anchor.Centre, + Y = -CORNER_RADIUS, + Size = new Vector2(100, 5), + Masking = true, + CornerRadius = 3, + Child = bar = new Box + { + RelativeSizeAxes = Axes.Both, + } + }, + flashLayer = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.White.Opacity(0.9f), + Blending = BlendingParameters.Additive, + Alpha = 0, + }, + }, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (Overlay != null) + OverlayState.BindTo(Overlay.State); + + OverlayState.BindValueChanged(_ => UpdateDisplay()); + Enabled.BindValueChanged(_ => UpdateDisplay(), true); + + FinishTransforms(true); + } + + // use Content for tracking input as some buttons might be temporarily hidden with DisappearToBottom, and they become hidden by moving Content away from screen. + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Content.ReceivePositionalInputAt(screenSpacePos); + + public GlobalAction? Hotkey; + + protected override bool OnClick(ClickEvent e) + { + if (Enabled.Value) + Flash(); + + return base.OnClick(e); + } + + protected virtual void Flash() => flashLayer.FadeOutFromOne(800, Easing.OutQuint); + + protected override bool OnHover(HoverEvent e) + { + UpdateDisplay(); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) => UpdateDisplay(); + + public virtual bool OnPressed(KeyBindingPressEvent e) + { + if (e.Action != Hotkey || e.Repeat) return false; + + TriggerClick(); + return true; + } + + public virtual void OnReleased(KeyBindingReleaseEvent e) { } + + public void UpdateDisplay() + { + Color4 backgroundColour = OverlayState.Value == Visibility.Visible ? buttonAccentColour : colourProvider.Background3; + Color4 textColour = OverlayState.Value == Visibility.Visible ? colourProvider.Background6 : colourProvider.Content1; + Color4 accentColour = OverlayState.Value == Visibility.Visible ? colourProvider.Background6 : buttonAccentColour; + + if (!Enabled.Value) + backgroundColour = backgroundColour.Darken(1f); + else if (IsHovered) + backgroundColour = backgroundColour.Lighten(0.2f); + + backgroundBox.FadeColour(backgroundColour, 150, Easing.OutQuint); + + if (!Enabled.Value) + textColour = textColour.Opacity(0.6f); + + text.FadeColour(textColour, 150, Easing.OutQuint); + icon.FadeColour(accentColour, 150, Easing.OutQuint); + bar.FadeColour(accentColour, 150, Easing.OutQuint); + + glowBox.FadeColour(ColourInfo.GradientVertical(buttonAccentColour.Opacity(0f), buttonAccentColour.Opacity(0.2f)), 150, Easing.OutQuint); + } + + public void AppearFromLeft(double delay) + { + Content.FinishTransforms(); + Content.MoveToX(-300f) + .FadeOut() + .Delay(delay) + .MoveToX(0f, 240, Easing.OutCubic) + .FadeIn(240, Easing.OutCubic); + } + + public void AppearFromBottom(double delay) + { + Content.FinishTransforms(); + Content.MoveToY(100f) + .FadeOut() + .Delay(delay) + .MoveToY(0f, 240, Easing.OutCubic) + .FadeIn(240, Easing.OutCubic); + } + + public void DisappearToRight(double delay, bool expire) + { + Content.FinishTransforms(); + Content.Delay(delay) + .FadeOut(240, Easing.InOutCubic) + .MoveToX(300f, 360, Easing.InOutCubic); + + if (expire) + this.Delay(Content.LatestTransformEndTime - Time.Current).Expire(); + } + + public void DisappearToBottom(double delay, bool expire) + { + Content.FinishTransforms(); + Content.Delay(delay) + .FadeOut(240, Easing.InOutCubic) + .MoveToY(100f, 240, Easing.InOutCubic); + + if (expire) + this.Delay(Content.LatestTransformEndTime - Time.Current).Expire(); + } + } +} diff --git a/osu.Game/Screens/IOsuScreen.cs b/osu.Game/Screens/IOsuScreen.cs index 5b4e2d75f4..b80c1f87a4 100644 --- a/osu.Game/Screens/IOsuScreen.cs +++ b/osu.Game/Screens/IOsuScreen.cs @@ -1,11 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; using osu.Framework.Bindables; using osu.Framework.Screens; using osu.Game.Beatmaps; using osu.Game.Overlays; using osu.Game.Rulesets; +using osu.Game.Screens.Footer; using osu.Game.Users; namespace osu.Game.Screens @@ -19,10 +21,18 @@ namespace osu.Game.Screens bool DisallowExternalBeatmapRulesetChanges { get; } /// - /// Whether the user can exit this this by pressing the back button. + /// Whether the user can exit this by pressing the back button. /// bool AllowBackButton { get; } + /// + /// Whether a footer (and a back button) should be displayed underneath the screen. + /// + /// + /// Temporarily, the back button is shown regardless of whether is true. + /// + bool ShowFooter { get; } + /// /// Whether a top-level component should be allowed to exit the current screen to, for example, /// complete an import. Note that this can be overridden by a user if they specifically request. @@ -63,6 +73,11 @@ namespace osu.Game.Screens Bindable Ruleset { get; } + /// + /// A list of footer buttons to be added to the game footer, or empty to display no buttons. + /// + IReadOnlyList CreateFooterButtons(); + /// /// Whether mod track adjustments should be applied on entering this screen. /// A value means that the parent screen's value of this setting will be used. diff --git a/osu.Game/Screens/Import/FileImportScreen.cs b/osu.Game/Screens/Import/FileImportScreen.cs index 6b7a269d12..1bdacae87f 100644 --- a/osu.Game/Screens/Import/FileImportScreen.cs +++ b/osu.Game/Screens/Import/FileImportScreen.cs @@ -15,6 +15,7 @@ using osu.Framework.Screens; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; using osuTK; namespace osu.Game.Screens.Import @@ -36,8 +37,8 @@ namespace osu.Game.Screens.Import [Resolved] private OsuGameBase game { get; set; } - [Resolved] - private OsuColour colours { get; set; } + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); [BackgroundDependencyLoader(true)] private void load() @@ -52,11 +53,6 @@ namespace osu.Game.Screens.Import Size = new Vector2(0.9f, 0.8f), Children = new Drawable[] { - new Box - { - Colour = colours.GreySeaFoamDark, - RelativeSizeAxes = Axes.Both, - }, fileSelector = new OsuFileSelector(validFileExtensions: game.HandledExtensions.ToArray()) { RelativeSizeAxes = Axes.Both, @@ -72,7 +68,7 @@ namespace osu.Game.Screens.Import { new Box { - Colour = colours.GreySeaFoamDarker, + Colour = colourProvider.Background4, RelativeSizeAxes = Axes.Both }, new Container diff --git a/osu.Game/Screens/Loader.cs b/osu.Game/Screens/Loader.cs index 4dba512cbd..d71ee05b27 100644 --- a/osu.Game/Screens/Loader.cs +++ b/osu.Game/Screens/Loader.cs @@ -118,12 +118,20 @@ namespace osu.Game.Screens { loadTargets.Add(manager.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.TEXTURE)); loadTargets.Add(manager.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.BLUR)); - - loadTargets.Add(manager.Load(@"CursorTrail", FragmentShaderDescriptor.TEXTURE)); - - loadTargets.Add(manager.Load(VertexShaderDescriptor.TEXTURE_2, "TriangleBorder")); - loadTargets.Add(manager.Load(VertexShaderDescriptor.TEXTURE_3, FragmentShaderDescriptor.TEXTURE)); + + loadTargets.Add(manager.Load(VertexShaderDescriptor.TEXTURE_2, @"TriangleBorder")); + loadTargets.Add(manager.Load(VertexShaderDescriptor.TEXTURE_2, @"FastCircle")); + loadTargets.Add(manager.Load(VertexShaderDescriptor.TEXTURE_2, @"CircularProgress")); + loadTargets.Add(manager.Load(VertexShaderDescriptor.TEXTURE_2, @"ArgonBarPath")); + loadTargets.Add(manager.Load(VertexShaderDescriptor.TEXTURE_2, @"ArgonBarPathBackground")); + loadTargets.Add(manager.Load(VertexShaderDescriptor.TEXTURE_2, @"SaturationSelectorBackground")); + loadTargets.Add(manager.Load(VertexShaderDescriptor.TEXTURE_2, @"HueSelectorBackground")); + loadTargets.Add(manager.Load(@"LogoAnimation", @"LogoAnimation")); + + // Ruleset local shader usage (should probably move somewhere else). + loadTargets.Add(manager.Load(VertexShaderDescriptor.TEXTURE_2, @"SpinnerGlow")); + loadTargets.Add(manager.Load(@"CursorTrail", FragmentShaderDescriptor.TEXTURE)); } protected virtual bool AllLoaded => loadTargets.All(s => s.IsLoaded); diff --git a/osu.Game/Screens/Menu/ButtonSystem.cs b/osu.Game/Screens/Menu/ButtonSystem.cs index 15a2740160..41920605b0 100644 --- a/osu.Game/Screens/Menu/ButtonSystem.cs +++ b/osu.Game/Screens/Menu/ButtonSystem.cs @@ -24,6 +24,7 @@ using osu.Game.Input; using osu.Game.Input.Bindings; using osu.Game.Localisation; using osu.Game.Online.API; +using osu.Game.Online.Rooms; using osu.Game.Overlays; using osuTK; using osuTK.Graphics; @@ -40,12 +41,13 @@ namespace osu.Game.Screens.Menu public Action? OnEditBeatmap; public Action? OnEditSkin; - public Action? OnExit; + public Action? OnExit; public Action? OnBeatmapListing; public Action? OnSolo; public Action? OnSettings; public Action? OnMultiplayer; public Action? OnPlaylists; + public Action? OnDailyChallenge; private readonly IBindable isIdle = new BindableBool(); @@ -102,10 +104,13 @@ namespace osu.Game.Screens.Menu buttonArea.AddRange(new Drawable[] { - new MainMenuButton(ButtonSystemStrings.Settings, string.Empty, OsuIcon.Settings, new Color4(85, 85, 85, 255), () => OnSettings?.Invoke(), -WEDGE_WIDTH, Key.O, Key.S), - backButton = new MainMenuButton(ButtonSystemStrings.Back, @"back-to-top", OsuIcon.PrevCircle, new Color4(51, 58, 94, 255), () => State = ButtonSystemState.TopLevel, - -WEDGE_WIDTH) + new MainMenuButton(ButtonSystemStrings.Settings, string.Empty, OsuIcon.Settings, new Color4(85, 85, 85, 255), (_, _) => OnSettings?.Invoke(), Key.O, Key.S) { + Padding = new MarginPadding { Right = WEDGE_WIDTH }, + }, + backButton = new MainMenuButton(ButtonSystemStrings.Back, @"back-to-top", OsuIcon.PrevCircle, new Color4(51, 58, 94, 255), (_, _) => State = ButtonSystemState.TopLevel) + { + Padding = new MarginPadding { Right = WEDGE_WIDTH }, VisibleStateMin = ButtonSystemState.Play, VisibleStateMax = ButtonSystemState.Edit, }, @@ -127,21 +132,31 @@ namespace osu.Game.Screens.Menu [BackgroundDependencyLoader] private void load(AudioManager audio, IdleTracker? idleTracker, GameHost host) { - buttonsPlay.Add(new MainMenuButton(ButtonSystemStrings.Solo, @"button-default-select", OsuIcon.Player, new Color4(102, 68, 204, 255), () => OnSolo?.Invoke(), WEDGE_WIDTH, Key.P)); - buttonsPlay.Add(new MainMenuButton(ButtonSystemStrings.Multi, @"button-default-select", OsuIcon.Online, new Color4(94, 63, 186, 255), onMultiplayer, 0, Key.M)); - buttonsPlay.Add(new MainMenuButton(ButtonSystemStrings.Playlists, @"button-default-select", OsuIcon.Tournament, new Color4(94, 63, 186, 255), onPlaylists, 0, Key.L)); + buttonsPlay.Add(new MainMenuButton(ButtonSystemStrings.Solo, @"button-default-select", OsuIcon.Player, new Color4(102, 68, 204, 255), (_, _) => OnSolo?.Invoke(), Key.P) + { + Padding = new MarginPadding { Left = WEDGE_WIDTH }, + }); + buttonsPlay.Add(new MainMenuButton(ButtonSystemStrings.Multi, @"button-default-select", OsuIcon.Online, new Color4(94, 63, 186, 255), onMultiplayer, Key.M)); + buttonsPlay.Add(new MainMenuButton(ButtonSystemStrings.Playlists, @"button-default-select", OsuIcon.Tournament, new Color4(94, 63, 186, 255), onPlaylists, Key.L)); + buttonsPlay.Add(new DailyChallengeButton(@"button-daily-select", new Color4(94, 63, 186, 255), onDailyChallenge, Key.D)); buttonsPlay.ForEach(b => b.VisibleState = ButtonSystemState.Play); - buttonsEdit.Add(new MainMenuButton(EditorStrings.BeatmapEditor.ToLower(), @"button-default-select", OsuIcon.Beatmap, new Color4(238, 170, 0, 255), () => OnEditBeatmap?.Invoke(), WEDGE_WIDTH, Key.B, Key.E)); - buttonsEdit.Add(new MainMenuButton(SkinEditorStrings.SkinEditor.ToLower(), @"button-default-select", OsuIcon.SkinB, new Color4(220, 160, 0, 255), () => OnEditSkin?.Invoke(), 0, Key.S)); + buttonsEdit.Add(new MainMenuButton(EditorStrings.BeatmapEditor.ToLower(), @"button-default-select", OsuIcon.Beatmap, new Color4(238, 170, 0, 255), (_, _) => OnEditBeatmap?.Invoke(), Key.B, Key.E) + { + Padding = new MarginPadding { Left = WEDGE_WIDTH }, + }); + buttonsEdit.Add(new MainMenuButton(SkinEditorStrings.SkinEditor.ToLower(), @"button-default-select", OsuIcon.SkinB, new Color4(220, 160, 0, 255), (_, _) => OnEditSkin?.Invoke(), Key.S)); buttonsEdit.ForEach(b => b.VisibleState = ButtonSystemState.Edit); - buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Play, @"button-play-select", OsuIcon.Logo, new Color4(102, 68, 204, 255), () => State = ButtonSystemState.Play, WEDGE_WIDTH, Key.P, Key.M, Key.L)); - buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Edit, @"button-play-select", OsuIcon.EditCircle, new Color4(238, 170, 0, 255), () => State = ButtonSystemState.Edit, 0, Key.E)); - buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Browse, @"button-default-select", OsuIcon.Beatmap, new Color4(165, 204, 0, 255), () => OnBeatmapListing?.Invoke(), 0, Key.B, Key.D)); + buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Play, @"button-play-select", OsuIcon.Logo, new Color4(102, 68, 204, 255), (_, _) => State = ButtonSystemState.Play, Key.P, Key.M, Key.L) + { + Padding = new MarginPadding { Left = WEDGE_WIDTH }, + }); + buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Edit, @"button-play-select", OsuIcon.EditCircle, new Color4(238, 170, 0, 255), (_, _) => State = ButtonSystemState.Edit, Key.E)); + buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Browse, @"button-default-select", OsuIcon.Beatmap, new Color4(165, 204, 0, 255), (_, _) => OnBeatmapListing?.Invoke(), Key.B, Key.D)); if (host.CanExit) - buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Exit, string.Empty, OsuIcon.CrossCircle, new Color4(238, 51, 153, 255), () => OnExit?.Invoke(), 0, Key.Q)); + buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Exit, string.Empty, OsuIcon.CrossCircle, new Color4(238, 51, 153, 255), (_, e) => OnExit?.Invoke(e), Key.Q)); buttonArea.AddRange(buttonsPlay); buttonArea.AddRange(buttonsEdit); @@ -164,7 +179,7 @@ namespace osu.Game.Screens.Menu sampleLogoSwoosh = audio.Samples.Get(@"Menu/osu-logo-swoosh"); } - private void onMultiplayer() + private void onMultiplayer(MainMenuButton mainMenuButton, UIEvent uiEvent) { if (api.State.Value != APIState.Online) { @@ -175,7 +190,7 @@ namespace osu.Game.Screens.Menu OnMultiplayer?.Invoke(); } - private void onPlaylists() + private void onPlaylists(MainMenuButton mainMenuButton, UIEvent uiEvent) { if (api.State.Value != APIState.Online) { @@ -186,6 +201,20 @@ namespace osu.Game.Screens.Menu OnPlaylists?.Invoke(); } + private void onDailyChallenge(MainMenuButton button, UIEvent uiEvent) + { + if (api.State.Value != APIState.Online) + { + loginOverlay?.Show(); + return; + } + + var dailyChallengeButton = (DailyChallengeButton)button; + + if (dailyChallengeButton.Room != null) + OnDailyChallenge?.Invoke(dailyChallengeButton.Room); + } + private void updateIdleState(bool isIdle) { if (!ReturnToTopOnIdle) diff --git a/osu.Game/Screens/Menu/ConfirmExitDialog.cs b/osu.Game/Screens/Menu/ConfirmExitDialog.cs index 0041d047bd..e33071e78c 100644 --- a/osu.Game/Screens/Menu/ConfirmExitDialog.cs +++ b/osu.Game/Screens/Menu/ConfirmExitDialog.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; using osu.Game.Localisation; @@ -37,9 +38,14 @@ namespace osu.Game.Screens.Menu { string text = "There are currently some background operations which will be aborted if you continue:\n\n"; - foreach (var n in notifications.OngoingOperations) + var ongoingOperations = notifications.OngoingOperations.ToArray(); + + foreach (var n in ongoingOperations.Take(10)) text += $"{n.Text} ({n.Progress:0%})\n"; + if (ongoingOperations.Length > 10) + text += $"\nand {ongoingOperations.Length - 10} other operation(s).\n"; + text += "\nLast chance to turn back"; BodyText = text; diff --git a/osu.Game/Screens/Menu/DailyChallengeButton.cs b/osu.Game/Screens/Menu/DailyChallengeButton.cs new file mode 100644 index 0000000000..44a53efa7b --- /dev/null +++ b/osu.Game/Screens/Menu/DailyChallengeButton.cs @@ -0,0 +1,212 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Threading; +using osu.Framework.Utils; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Localisation; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Metadata; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Screens.OnlinePlay.DailyChallenge; +using osuTK; +using osuTK.Graphics; +using osuTK.Input; + +namespace osu.Game.Screens.Menu +{ + public partial class DailyChallengeButton : MainMenuButton + { + public Room? Room { get; private set; } + + private readonly OsuSpriteText countdown; + private ScheduledDelegate? scheduledCountdownUpdate; + + private UpdateableOnlineBeatmapSetCover cover = null!; + private IBindable info = null!; + + private Box gradientLayer = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private INotificationOverlay? notificationOverlay { get; set; } + + [Resolved] + private SessionStatics statics { get; set; } = null!; + + public DailyChallengeButton(string sampleName, Color4 colour, Action? clickAction = null, params Key[] triggerKeys) + : base(ButtonSystemStrings.DailyChallenge, sampleName, OsuIcon.DailyChallenge, colour, clickAction, triggerKeys) + { + BaseSize = new Vector2(ButtonSystem.BUTTON_WIDTH * 1.3f, ButtonArea.BUTTON_AREA_HEIGHT); + + Content.Add(countdown = new OsuSpriteText + { + Shadow = true, + AllowMultiline = false, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Margin = new MarginPadding + { + Left = -3, + Bottom = 22, + }, + Font = OsuFont.Default.With(size: 12), + Alpha = 0, + }); + } + + protected override Drawable CreateBackground(Colour4 accentColour) => new BufferedContainer + { + Children = new Drawable[] + { + cover = new UpdateableOnlineBeatmapSetCover(timeBeforeLoad: 0, timeBeforeUnload: 600_000) + { + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativePositionAxes = Axes.Both, + }, + gradientLayer = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientVertical(accentColour.Opacity(0.2f), accentColour), + Blending = BlendingParameters.Additive, + }, + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = accentColour.Opacity(0.7f) + }, + }, + }; + + [BackgroundDependencyLoader] + private void load(MetadataClient metadataClient) + { + info = metadataClient.DailyChallengeInfo.GetBoundCopy(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + info.BindValueChanged(dailyChallengeChanged, true); + } + + protected override void Update() + { + base.Update(); + + if (cover.LatestTransformEndTime == Time.Current) + { + const double duration = 3000; + + float scale = 1 + RNG.NextSingle(); + + cover.ScaleTo(scale, duration, Easing.InOutSine) + .RotateTo(RNG.NextSingle(-4, 4) * (scale - 1), duration, Easing.InOutSine) + .MoveTo(new Vector2( + RNG.NextSingle(-0.5f, 0.5f) * (scale - 1), + RNG.NextSingle(-0.5f, 0.5f) * (scale - 1) + ), duration, Easing.InOutSine); + + gradientLayer.FadeIn(duration / 2) + .Then() + .FadeOut(duration / 2); + } + } + + private long? lastDailyChallengeRoomID; + + private void dailyChallengeChanged(ValueChangedEvent _) + { + UpdateState(); + + scheduledCountdownUpdate?.Cancel(); + scheduledCountdownUpdate = null; + + if (info.Value == null) + { + Room = null; + cover.OnlineInfo = TooltipContent = null; + } + else + { + var roomRequest = new GetRoomRequest(info.Value.Value.RoomID); + + roomRequest.Success += room => + { + Room = room; + cover.OnlineInfo = TooltipContent = room.Playlist.FirstOrDefault()?.Beatmap.BeatmapSet as APIBeatmapSet; + + if (room.StartDate.Value != null && room.RoomID.Value != lastDailyChallengeRoomID) + { + lastDailyChallengeRoomID = room.RoomID.Value; + + // new challenge is live, reset intro played static. + statics.SetValue(Static.DailyChallengeIntroPlayed, false); + + // we only want to notify the user if the new challenge just went live. + if (Math.Abs((DateTimeOffset.Now - room.StartDate.Value!.Value).TotalSeconds) < 1800) + notificationOverlay?.Post(new NewDailyChallengeNotification(room)); + } + + updateCountdown(); + Scheduler.AddDelayed(updateCountdown, 1000, true); + }; + api.Queue(roomRequest); + } + } + + private void updateCountdown() + { + if (Room == null) + return; + + var remaining = (Room.EndDate.Value - DateTimeOffset.Now) ?? TimeSpan.Zero; + + if (remaining <= TimeSpan.Zero) + { + countdown.FadeOut(250, Easing.OutQuint); + } + else + { + if (countdown.Alpha == 0) + countdown.FadeIn(250, Easing.OutQuint); + + countdown.Text = remaining.ToString(@"hh\:mm\:ss"); + } + } + + protected override void UpdateState() + { + if (info.IsNotNull() && info.Value == null) + { + ContractStyle = 0; + State = ButtonState.Contracted; + return; + } + + base.UpdateState(); + } + + public APIBeatmapSet? TooltipContent { get; private set; } + } +} diff --git a/osu.Game/Screens/Menu/IntroScreen.cs b/osu.Game/Screens/Menu/IntroScreen.cs index ac7dffc241..0dc54b321f 100644 --- a/osu.Game/Screens/Menu/IntroScreen.cs +++ b/osu.Game/Screens/Menu/IntroScreen.cs @@ -90,7 +90,7 @@ namespace osu.Game.Screens.Menu /// protected bool UsingThemedIntro { get; private set; } - protected override BackgroundScreen CreateBackground() => new BackgroundScreenDefault(false) + protected override BackgroundScreen CreateBackground() => new BackgroundScreenDefault { Colour = Color4.Black }; diff --git a/osu.Game/Screens/Menu/MainMenu.cs b/osu.Game/Screens/Menu/MainMenu.cs index 235c5d5c56..35c6bab81b 100644 --- a/osu.Game/Screens/Menu/MainMenu.cs +++ b/osu.Game/Screens/Menu/MainMenu.cs @@ -31,6 +31,7 @@ using osu.Game.Overlays.SkinEditor; using osu.Game.Rulesets; using osu.Game.Screens.Backgrounds; using osu.Game.Screens.Edit; +using osu.Game.Screens.OnlinePlay.DailyChallenge; using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Screens.Select; @@ -53,8 +54,6 @@ namespace osu.Game.Screens.Menu public override bool? AllowGlobalTrackControl => true; - private Screen songSelect; - private MenuSideFlashes sideFlashes; protected ButtonSystem Buttons; @@ -147,9 +146,16 @@ namespace osu.Game.Screens.Menu OnSolo = loadSoloSongSelect, OnMultiplayer = () => this.Push(new Multiplayer()), OnPlaylists = () => this.Push(new Playlists()), - OnExit = () => + OnDailyChallenge = room => { - exitConfirmedViaHoldOrClick = true; + if (statics.Get(Static.DailyChallengeIntroPlayed)) + this.Push(new DailyChallenge(room)); + else + this.Push(new DailyChallengeIntro(room)); + }, + OnExit = e => + { + exitConfirmedViaHoldOrClick = e is MouseEvent; this.Exit(); } } @@ -215,26 +221,11 @@ namespace osu.Game.Screens.Menu Buttons.OnBeatmapListing = () => beatmapListing?.ToggleVisibility(); reappearSampleSwoosh = audio.Samples.Get(@"Menu/reappear-swoosh"); - - preloadSongSelect(); } public void ReturnToOsuLogo() => Buttons.State = ButtonSystemState.Initial; - private void preloadSongSelect() - { - if (songSelect == null) - LoadComponentAsync(songSelect = new PlaySongSelect()); - } - - private void loadSoloSongSelect() => this.Push(consumeSongSelect()); - - private Screen consumeSongSelect() - { - var s = songSelect; - songSelect = null; - return s; - } + private void loadSoloSongSelect() => this.Push(new PlaySongSelect()); public override void OnEntering(ScreenTransitionEvent e) { @@ -368,9 +359,6 @@ namespace osu.Game.Screens.Menu ApplyToBackground(b => (b as BackgroundScreenDefault)?.Next()); - // we may have consumed our preloaded instance, so let's make another. - preloadSongSelect(); - musicController.EnsurePlayingSomething(); // Cycle tip on resuming diff --git a/osu.Game/Screens/Menu/MainMenuButton.cs b/osu.Game/Screens/Menu/MainMenuButton.cs index 1dc79e9b1a..f8824795d8 100644 --- a/osu.Game/Screens/Menu/MainMenuButton.cs +++ b/osu.Game/Screens/Menu/MainMenuButton.cs @@ -38,11 +38,8 @@ namespace osu.Game.Screens.Menu public readonly Key[] TriggerKeys; - private readonly Container iconText; - private readonly Container box; - private readonly Box boxHoverLayer; - private readonly SpriteIcon icon; - private readonly string sampleName; + protected override Container Content => content; + private readonly Container content; /// /// The menu state for which we are visible for (assuming only one). @@ -59,7 +56,24 @@ namespace osu.Game.Screens.Menu public ButtonSystemState VisibleStateMin = ButtonSystemState.TopLevel; public ButtonSystemState VisibleStateMax = ButtonSystemState.TopLevel; - private readonly Action? clickAction; + public new MarginPadding Padding + { + get => Content.Padding; + set => Content.Padding = value; + } + + protected Vector2 BaseSize { get; init; } = new Vector2(ButtonSystem.BUTTON_WIDTH, ButtonArea.BUTTON_AREA_HEIGHT); + + private readonly Action? clickAction; + + private readonly Container background; + private readonly Drawable backgroundContent; + private readonly Box boxHoverLayer; + private readonly SpriteIcon icon; + + private Vector2 initialSize => BaseSize + Padding.Total; + + private readonly string sampleName; private Sample? sampleClick; private Sample? sampleHover; private SampleChannel? sampleChannel; @@ -68,9 +82,9 @@ namespace osu.Game.Screens.Menu // Allow keyboard interaction based on state rather than waiting for delayed animations. || state == ButtonState.Expanded; - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => box.ReceivePositionalInputAt(screenSpacePos); + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => background.ReceivePositionalInputAt(screenSpacePos); - public MainMenuButton(LocalisableString text, string sampleName, IconUsage symbol, Color4 colour, Action? clickAction = null, float extraWidth = 0, params Key[] triggerKeys) + public MainMenuButton(LocalisableString text, string sampleName, IconUsage symbol, Color4 colour, Action? clickAction = null, params Key[] triggerKeys) { this.sampleName = sampleName; this.clickAction = clickAction; @@ -79,11 +93,9 @@ namespace osu.Game.Screens.Menu AutoSizeAxes = Axes.Both; Alpha = 0; - Vector2 boxSize = new Vector2(ButtonSystem.BUTTON_WIDTH + Math.Abs(extraWidth), ButtonArea.BUTTON_AREA_HEIGHT); - - Children = new Drawable[] + AddRangeInternal(new Drawable[] { - box = new Container + background = new Container { // box needs to be always present to ensure the button is always sized correctly for flow AlwaysPresent = true, @@ -98,35 +110,45 @@ namespace osu.Game.Screens.Menu }, Anchor = Anchor.Centre, Origin = Anchor.Centre, - Scale = new Vector2(0, 1), - Size = boxSize, - Shear = new Vector2(ButtonSystem.WEDGE_WIDTH / boxSize.Y, 0), Children = new[] { - new Box + backgroundContent = CreateBackground(colour).With(bg => { - EdgeSmoothness = new Vector2(1.5f, 0), - RelativeSizeAxes = Axes.Both, - Colour = colour, - }, + bg.RelativeSizeAxes = Axes.Y; + bg.Anchor = Anchor.Centre; + bg.Origin = Anchor.Centre; + }), boxHoverLayer = new Box { EdgeSmoothness = new Vector2(1.5f, 0), RelativeSizeAxes = Axes.Both, Blending = BlendingParameters.Additive, Colour = Color4.White, + Depth = float.MinValue, Alpha = 0, }, } }, - iconText = new Container + content = new Container { - AutoSizeAxes = Axes.Both, - Position = new Vector2(extraWidth / 2, 0), + RelativeSizeAxes = Axes.Both, Anchor = Anchor.Centre, Origin = Anchor.Centre, Children = new Drawable[] { + new OsuSpriteText + { + Shadow = true, + AllowMultiline = false, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Margin = new MarginPadding + { + Left = -3, + Bottom = 7, + }, + Text = text + }, icon = new SpriteIcon { Shadow = true, @@ -136,20 +158,38 @@ namespace osu.Game.Screens.Menu Position = new Vector2(0, 0), Margin = new MarginPadding { Top = -4 }, Icon = symbol - }, - new OsuSpriteText - { - Shadow = true, - AllowMultiline = false, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Position = new Vector2(0, 35), - Margin = new MarginPadding { Left = -3 }, - Text = text } } } - }; + }); + } + + protected virtual Drawable CreateBackground(Colour4 accentColour) => new Container + { + Child = new Box + { + EdgeSmoothness = new Vector2(1.5f, 0), + RelativeSizeAxes = Axes.Both, + Colour = accentColour, + } + }; + + protected override void LoadComplete() + { + base.LoadComplete(); + + background.Shear = new Vector2(ButtonSystem.WEDGE_WIDTH / initialSize.Y, 0); + + // for whatever reason, attempting to size the background "just in time" to cover the visible width + // results in gaps when the width changes are quick (only visible when testing menu at 100% speed, not visible slowed down). + // to ensure there's no missing backdrop, just use a ballpark that should be enough to always cover the width and then some. + // note that while on a code inspections it would seem that `1.5 * initialSize.X` would be enough, elastic usings are used in this button + // (which can exceed the [0;1] range during interpolation). + backgroundContent.Width = 2 * initialSize.X; + backgroundContent.Shear = -background.Shear; + + animateState(); + FinishTransforms(true); } private bool rightward; @@ -179,15 +219,15 @@ namespace osu.Game.Screens.Menu { if (State != ButtonState.Expanded) return true; - sampleHover?.Play(); - - box.ScaleTo(new Vector2(1.5f, 1), 500, Easing.OutElastic); - double duration = TimeUntilNextBeat; icon.ClearTransforms(); icon.RotateTo(rightward ? -BOUNCE_ROTATION : BOUNCE_ROTATION, duration, Easing.InOutSine); icon.ScaleTo(new Vector2(HOVER_SCALE, HOVER_SCALE * BOUNCE_COMPRESSION), duration, Easing.Out); + + sampleHover?.Play(); + background.ResizeTo(Vector2.Multiply(initialSize, new Vector2(1.5f, 1)), 500, Easing.OutElastic); + return true; } @@ -199,7 +239,7 @@ namespace osu.Game.Screens.Menu icon.ScaleTo(Vector2.One, 200, Easing.Out); if (State == ButtonState.Expanded) - box.ScaleTo(new Vector2(1, 1), 500, Easing.OutElastic); + background.ResizeTo(initialSize, 500, Easing.OutElastic); } [BackgroundDependencyLoader] @@ -223,7 +263,7 @@ namespace osu.Game.Screens.Menu protected override bool OnClick(ClickEvent e) { - trigger(); + trigger(e); return true; } @@ -234,19 +274,19 @@ namespace osu.Game.Screens.Menu if (TriggerKeys.Contains(e.Key)) { - trigger(); + trigger(e); return true; } return false; } - private void trigger() + private void trigger(UIEvent e) { sampleChannel = sampleClick?.GetChannel(); sampleChannel?.Play(); - clickAction?.Invoke(); + clickAction?.Invoke(this, e); boxHoverLayer.ClearTransforms(); boxHoverLayer.Alpha = 0.9f; @@ -254,13 +294,13 @@ namespace osu.Game.Screens.Menu } public override bool HandleNonPositionalInput => state == ButtonState.Expanded; - public override bool HandlePositionalInput => state != ButtonState.Exploded && box.Scale.X >= 0.8f; + public override bool HandlePositionalInput => state != ButtonState.Exploded && background.Width / initialSize.X >= 0.8f; public void StopSamplePlayback() => sampleChannel?.Stop(); protected override void Update() { - iconText.Alpha = Math.Clamp((box.Scale.X - 0.5f) / 0.3f, 0, 1); + content.Alpha = Math.Clamp((background.Width / initialSize.X - 0.5f) / 0.3f, 0, 1); base.Update(); } @@ -279,67 +319,84 @@ namespace osu.Game.Screens.Menu state = value; - switch (state) - { - case ButtonState.Contracted: - switch (ContractStyle) - { - default: - box.ScaleTo(new Vector2(0, 1), 500, Easing.OutExpo); - this.FadeOut(500); - break; - - case 1: - box.ScaleTo(new Vector2(0, 1), 400, Easing.InSine); - this.FadeOut(800); - break; - } - - break; - - case ButtonState.Expanded: - const int expand_duration = 500; - box.ScaleTo(new Vector2(1, 1), expand_duration, Easing.OutExpo); - this.FadeIn(expand_duration / 6f); - break; - - case ButtonState.Exploded: - const int explode_duration = 200; - box.ScaleTo(new Vector2(2, 1), explode_duration, Easing.OutExpo); - this.FadeOut(explode_duration / 4f * 3); - break; - } + animateState(); StateChanged?.Invoke(State); } } + private void animateState() + { + switch (state) + { + case ButtonState.Contracted: + switch (ContractStyle) + { + default: + background.ResizeTo(Vector2.Multiply(initialSize, new Vector2(0, 1)), 500, Easing.OutExpo); + this.FadeOut(500); + break; + + case 1: + background.ResizeTo(Vector2.Multiply(initialSize, new Vector2(0, 1)), 400, Easing.InSine); + this.FadeOut(800); + break; + } + + break; + + case ButtonState.Expanded: + const int expand_duration = 500; + background.ResizeTo(initialSize, expand_duration, Easing.OutExpo); + this.FadeIn(expand_duration / 6f); + break; + + case ButtonState.Exploded: + const int explode_duration = 200; + background.ResizeTo(Vector2.Multiply(initialSize, new Vector2(2, 1)), explode_duration, Easing.OutExpo); + this.FadeOut(explode_duration / 4f * 3); + break; + } + } + + private ButtonSystemState buttonSystemState; + public ButtonSystemState ButtonSystemState { + get => buttonSystemState; set { - ContractStyle = 0; + if (buttonSystemState == value) + return; - switch (value) - { - case ButtonSystemState.Initial: + buttonSystemState = value; + UpdateState(); + } + } + + protected virtual void UpdateState() + { + ContractStyle = 0; + + switch (ButtonSystemState) + { + case ButtonSystemState.Initial: + State = ButtonState.Contracted; + break; + + case ButtonSystemState.EnteringMode: + ContractStyle = 1; + State = ButtonState.Contracted; + break; + + default: + if (ButtonSystemState <= VisibleStateMax && ButtonSystemState >= VisibleStateMin) + State = ButtonState.Expanded; + else if (ButtonSystemState < VisibleStateMin) State = ButtonState.Contracted; - break; - - case ButtonSystemState.EnteringMode: - ContractStyle = 1; - State = ButtonState.Contracted; - break; - - default: - if (value <= VisibleStateMax && value >= VisibleStateMin) - State = ButtonState.Expanded; - else if (value < VisibleStateMin) - State = ButtonState.Contracted; - else - State = ButtonState.Exploded; - break; - } + else + State = ButtonState.Exploded; + break; } } } diff --git a/osu.Game/Screens/Menu/OnlineMenuBanner.cs b/osu.Game/Screens/Menu/OnlineMenuBanner.cs index 6f98b73939..aa73ce2136 100644 --- a/osu.Game/Screens/Menu/OnlineMenuBanner.cs +++ b/osu.Game/Screens/Menu/OnlineMenuBanner.cs @@ -13,6 +13,7 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Framework.Input.Events; using osu.Framework.Threading; +using osu.Framework.Utils; using osu.Game.Graphics.Containers; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; @@ -72,8 +73,19 @@ namespace osu.Game.Screens.Menu Task.Run(() => request.Perform()) .ContinueWith(r => { + if (!FetchOnlineContent) + return; + if (r.IsCompletedSuccessfully) - Schedule(() => Current.Value = request.ResponseObject); + { + Schedule(() => + { + if (!FetchOnlineContent) + return; + + Current.Value = request.ResponseObject; + }); + } // if the request failed, "observe" the exception. // it isn't very important why this failed, as it's only for display. @@ -111,7 +123,9 @@ namespace osu.Game.Screens.Menu content.AddRange(loaded); - displayIndex = -1; + // Many users don't spend much time at the main menu, so let's randomise where in the + // carousel of available images we start at to give each a fair chance. + displayIndex = RNG.Next(0, images.NewValue.Images.Length) - 1; showNext(); }, (cancellationTokenSource ??= new CancellationTokenSource()).Token); } @@ -167,6 +181,11 @@ namespace osu.Game.Screens.Menu private Sprite flash = null!; + /// + /// Overridden as a safety for functioning correctly. + /// + public override bool IsPresent => base.IsPresent || Scheduler.HasPendingTasks; + private ScheduledDelegate? openUrlAction; public MenuImage(APIMenuImage image) diff --git a/osu.Game/Screens/Menu/StarFountain.cs b/osu.Game/Screens/Menu/StarFountain.cs index dd5171c6be..92e9dd6df9 100644 --- a/osu.Game/Screens/Menu/StarFountain.cs +++ b/osu.Game/Screens/Menu/StarFountain.cs @@ -21,9 +21,11 @@ namespace osu.Game.Screens.Menu [BackgroundDependencyLoader] private void load() { - InternalChild = spewer = new StarFountainSpewer(); + InternalChild = spewer = CreateSpewer(); } + protected virtual StarFountainSpewer CreateSpewer() => new StarFountainSpewer(); + public void Shoot(int direction) => spewer.Shoot(direction); protected override void SkinChanged(ISkinSource skin) @@ -38,17 +40,23 @@ namespace osu.Game.Screens.Menu private const int particle_duration_max = 1000; private double? lastShootTime; - private int lastShootDirection; + + protected int LastShootDirection { get; private set; } protected override float ParticleGravity => 800; - private const double shoot_duration = 800; + protected virtual double ShootDuration => 800; [Resolved] private ISkinSource skin { get; set; } = null!; public StarFountainSpewer() - : base(null, 240, particle_duration_max) + : this(240) + { + } + + protected StarFountainSpewer(int perSecond) + : base(null, perSecond, particle_duration_max) { } @@ -67,16 +75,16 @@ namespace osu.Game.Screens.Menu StartAngle = getRandomVariance(4), EndAngle = getRandomVariance(2), EndScale = 2.2f + getRandomVariance(0.4f), - Velocity = new Vector2(getCurrentAngle(), -1400 + getRandomVariance(100)), + Velocity = new Vector2(GetCurrentAngle(), -1400 + getRandomVariance(100)), }; } - private float getCurrentAngle() + protected virtual float GetCurrentAngle() { - const float x_velocity_from_direction = 500; const float x_velocity_random_variance = 60; + const float x_velocity_from_direction = 500; - return lastShootDirection * x_velocity_from_direction * (float)(1 - 2 * (Clock.CurrentTime - lastShootTime!.Value) / shoot_duration) + getRandomVariance(x_velocity_random_variance); + return LastShootDirection * x_velocity_from_direction * (float)(1 - 2 * (Clock.CurrentTime - lastShootTime!.Value) / ShootDuration) + getRandomVariance(x_velocity_random_variance); } private ScheduledDelegate? deactivateDelegate; @@ -86,10 +94,10 @@ namespace osu.Game.Screens.Menu Active.Value = true; deactivateDelegate?.Cancel(); - deactivateDelegate = Scheduler.AddDelayed(() => Active.Value = false, shoot_duration); + deactivateDelegate = Scheduler.AddDelayed(() => Active.Value = false, ShootDuration); lastShootTime = Clock.CurrentTime; - lastShootDirection = direction; + LastShootDirection = direction; } private static float getRandomVariance(float variance) => RNG.NextSingle(-variance, variance); diff --git a/osu.Game/Screens/Menu/StorageErrorDialog.cs b/osu.Game/Screens/Menu/StorageErrorDialog.cs index b48046d190..677a3b0278 100644 --- a/osu.Game/Screens/Menu/StorageErrorDialog.cs +++ b/osu.Game/Screens/Menu/StorageErrorDialog.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; +using osu.Game.Database; using osu.Game.IO; using osu.Game.Localisation; using osu.Game.Overlays; @@ -16,6 +17,9 @@ namespace osu.Game.Screens.Menu [Resolved] private IDialogOverlay dialogOverlay { get; set; } = null!; + [Resolved] + private RealmAccess realmAccess { get; set; } = null!; + public StorageErrorDialog(OsuStorage storage, OsuStorageError error) { HeaderText = StorageErrorDialogStrings.StorageError; @@ -35,7 +39,15 @@ namespace osu.Game.Screens.Menu Text = StorageErrorDialogStrings.TryAgain, Action = () => { - if (!storage.TryChangeToCustomStorage(out var nextError)) + bool success; + OsuStorageError nextError; + + // blocking all operations has a side effect of closing & reopening the realm db, + // which is desirable here since the restoration of the old storage - if it succeeds - means the realm db has moved. + using (realmAccess.BlockAllOperations(@"restoration of previously unavailable storage")) + success = storage.TryChangeToCustomStorage(out nextError); + + if (!success) dialogOverlay.Push(new StorageErrorDialog(storage, nextError)); } }, diff --git a/osu.Game/Screens/OnlinePlay/Components/ListingPollingComponent.cs b/osu.Game/Screens/OnlinePlay/Components/ListingPollingComponent.cs index c296e2a86b..4b38ea68b3 100644 --- a/osu.Game/Screens/OnlinePlay/Components/ListingPollingComponent.cs +++ b/osu.Game/Screens/OnlinePlay/Components/ListingPollingComponent.cs @@ -53,6 +53,8 @@ namespace osu.Game.Screens.OnlinePlay.Components req.Success += result => { + result = result.Where(r => r.Category.Value != RoomCategory.DailyChallenge).ToList(); + foreach (var existing in RoomManager.Rooms.ToArray()) { if (result.All(r => r.RoomID.Value != existing.RoomID.Value)) diff --git a/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundScreen.cs b/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundScreen.cs index 014473dfee..ef7c1747e9 100644 --- a/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundScreen.cs @@ -3,10 +3,8 @@ using System.Threading; using osu.Framework.Allocation; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; -using osu.Framework.Graphics.Shapes; using osu.Framework.Screens; using osu.Game.Beatmaps; using osu.Game.Online.Rooms; @@ -20,17 +18,6 @@ namespace osu.Game.Screens.OnlinePlay.Components private CancellationTokenSource? cancellationSource; private PlaylistItemBackground? background; - protected OnlinePlayBackgroundScreen() - : base(false) - { - AddInternal(new Box - { - RelativeSizeAxes = Axes.Both, - Depth = float.MinValue, - Colour = ColourInfo.GradientVertical(Color4.Black.Opacity(0.9f), Color4.Black.Opacity(0.6f)) - }); - } - [BackgroundDependencyLoader] private void load() { @@ -84,6 +71,7 @@ namespace osu.Game.Screens.OnlinePlay.Components } newBackground.Depth = newDepth; + newBackground.Colour = ColourInfo.GradientVertical(new Color4(0.1f, 0.1f, 0.1f, 1f), new Color4(0.4f, 0.4f, 0.4f, 1f)); newBackground.BlurTo(new Vector2(10)); AddInternal(background = newBackground); diff --git a/osu.Game/Screens/OnlinePlay/Components/OverlinedPlaylistHeader.cs b/osu.Game/Screens/OnlinePlay/Components/OverlinedPlaylistHeader.cs index fc86cbbbdd..dd728e460b 100644 --- a/osu.Game/Screens/OnlinePlay/Components/OverlinedPlaylistHeader.cs +++ b/osu.Game/Screens/OnlinePlay/Components/OverlinedPlaylistHeader.cs @@ -1,12 +1,17 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Allocation; using osu.Game.Online.Rooms; +using osu.Game.Rulesets; namespace osu.Game.Screens.OnlinePlay.Components { public partial class OverlinedPlaylistHeader : OverlinedHeader { + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + public OverlinedPlaylistHeader() : base("Playlist") { @@ -16,7 +21,7 @@ namespace osu.Game.Screens.OnlinePlay.Components { base.LoadComplete(); - Playlist.BindCollectionChanged((_, _) => Details.Value = Playlist.GetTotalDuration(), true); + Playlist.BindCollectionChanged((_, _) => Details.Value = Playlist.GetTotalDuration(rulesets), true); } } } diff --git a/osu.Game/Screens/OnlinePlay/Components/ReadyButton.cs b/osu.Game/Screens/OnlinePlay/Components/ReadyButton.cs index 813e243449..2e669fd1b2 100644 --- a/osu.Game/Screens/OnlinePlay/Components/ReadyButton.cs +++ b/osu.Game/Screens/OnlinePlay/Components/ReadyButton.cs @@ -21,8 +21,8 @@ namespace osu.Game.Screens.OnlinePlay.Components private void load(OnlinePlayBeatmapAvailabilityTracker beatmapTracker) { availability.BindTo(beatmapTracker.Availability); - availability.BindValueChanged(_ => updateState()); + Enabled.BindValueChanged(_ => updateState(), true); } @@ -33,7 +33,7 @@ namespace osu.Game.Screens.OnlinePlay.Components { get { - if (Enabled.Value) + if (base.Enabled.Value) return string.Empty; if (availability.Value.State != DownloadState.LocallyAvailable) diff --git a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallenge.cs b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallenge.cs new file mode 100644 index 0000000000..1aaf0a4321 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallenge.cs @@ -0,0 +1,575 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Logging; +using osu.Framework.Screens; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Cursor; +using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Metadata; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Screens.OnlinePlay.Components; +using osu.Game.Screens.OnlinePlay.DailyChallenge.Events; +using osu.Game.Screens.OnlinePlay.Match; +using osu.Game.Screens.OnlinePlay.Match.Components; +using osu.Game.Screens.OnlinePlay.Playlists; +using osu.Game.Screens.Play; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.DailyChallenge +{ + [Cached(typeof(IPreviewTrackOwner))] + public partial class DailyChallenge : OsuScreen, IPreviewTrackOwner, IHandlePresentBeatmap + { + private readonly Room room; + private readonly PlaylistItem playlistItem; + + /// + /// Any mods applied by/to the local user. + /// + private readonly Bindable> userMods = new Bindable>(Array.Empty()); + + private readonly IBindable apiState = new Bindable(); + private readonly IBindable dailyChallengeInfo = new Bindable(); + + private OnlinePlayScreenWaveContainer waves = null!; + private DailyChallengeLeaderboard leaderboard = null!; + private RoomModSelectOverlay userModsSelectOverlay = null!; + private Sample? sampleStart; + private IDisposable? userModsSelectOverlayRegistration; + + private DailyChallengeScoreBreakdown breakdown = null!; + private DailyChallengeTotalsDisplay totals = null!; + private DailyChallengeEventFeed feed = null!; + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Plum); + + [Cached(Type = typeof(IRoomManager))] + private RoomManager roomManager { get; set; } + + [Cached] + private readonly OnlinePlayBeatmapAvailabilityTracker beatmapAvailabilityTracker = new OnlinePlayBeatmapAvailabilityTracker(); + + [Resolved] + private OsuGame? game { get; set; } + + [Resolved] + private BeatmapManager beatmapManager { get; set; } = null!; + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + [Resolved] + private MusicController musicController { get; set; } = null!; + + [Resolved] + private IOverlayManager? overlayManager { get; set; } + + [Resolved] + private MetadataClient metadataClient { get; set; } = null!; + + [Resolved] + private UserLookupCache userLookupCache { get; set; } = null!; + + [Resolved] + protected IAPIProvider API { get; private set; } = null!; + + [Resolved] + private PreviewTrackManager previewTrackManager { get; set; } = null!; + + [Resolved] + private INotificationOverlay? notificationOverlay { get; set; } + + public override bool DisallowExternalBeatmapRulesetChanges => true; + + public override bool? ApplyModTrackAdjustments => true; + + public DailyChallenge(Room room) + { + this.room = room; + playlistItem = room.Playlist.Single(); + roomManager = new RoomManager(); + Padding = new MarginPadding { Horizontal = -HORIZONTAL_OVERFLOW_PADDING }; + } + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + return new CachedModelDependencyContainer(base.CreateChildDependencies(parent)) + { + Model = { Value = room } + }; + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + sampleStart = audio.Samples.Get(@"SongSelect/confirm-selection"); + + FillFlowContainer footerButtons; + + InternalChild = waves = new OnlinePlayScreenWaveContainer + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + roomManager, + beatmapAvailabilityTracker, + new ScreenStack(new RoomBackgroundScreen(playlistItem)) + { + RelativeSizeAxes = Axes.Both, + }, + new Header(ButtonSystemStrings.DailyChallenge.ToSentence(), null), + new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding + { + Horizontal = WaveOverlayContainer.WIDTH_PADDING, + Top = Header.HEIGHT, + }, + RowDimensions = + [ + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, 10), + new Dimension(), + new Dimension(GridSizeMode.Absolute, 30), + new Dimension(GridSizeMode.Absolute, 50) + ], + Content = new[] + { + new Drawable[] + { + new DrawableRoomPlaylistItem(playlistItem) + { + RelativeSizeAxes = Axes.X, + AllowReordering = false, + Scale = new Vector2(1.4f), + Width = 1 / 1.4f, + } + }, + null, + [ + new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 10, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4, + }, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(10), + ColumnDimensions = + [ + new Dimension(), + new Dimension(GridSizeMode.Absolute, 10), + new Dimension(), + new Dimension(GridSizeMode.Absolute, 10), + new Dimension() + ], + Content = new[] + { + new Drawable?[] + { + new GridContainer + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RowDimensions = + [ + new Dimension(), + new Dimension() + ], + Content = new[] + { + new Drawable[] + { + new DailyChallengeCarousel + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + new DailyChallengeTimeRemainingRing(), + breakdown = new DailyChallengeScoreBreakdown(), + totals = new DailyChallengeTotalsDisplay(), + } + } + }, + [ + feed = new DailyChallengeEventFeed + { + RelativeSizeAxes = Axes.Both, + PresentScore = presentScore + } + ], + }, + }, + null, + // Middle column (leaderboard) + leaderboard = new DailyChallengeLeaderboard(room, playlistItem) + { + RelativeSizeAxes = Axes.Both, + PresentScore = presentScore, + SelectedMods = { BindTarget = userMods }, + }, + // Spacer + null, + // Main right column + new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] + { + new SectionHeader("Chat") + }, + [new MatchChatDisplay(room) { RelativeSizeAxes = Axes.Both }] + }, + RowDimensions = + [ + new Dimension(GridSizeMode.AutoSize), + new Dimension() + ] + }, + } + } + } + } + } + ], + null, + [ + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding + { + Horizontal = -WaveOverlayContainer.WIDTH_PADDING, + }, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5, + }, + footerButtons = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Padding = new MarginPadding(5), + Spacing = new Vector2(10), + Children = new Drawable[] + { + new PlaylistsReadyButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Y, + Size = new Vector2(250, 1), + Action = startPlay + } + } + }, + } + } + ], + } + } + } + } + }; + + LoadComponent(userModsSelectOverlay = new RoomModSelectOverlay + { + Beatmap = { BindTarget = Beatmap }, + SelectedMods = { BindTarget = userMods }, + IsValidMod = _ => false + }); + + if (playlistItem.AllowedMods.Any()) + { + footerButtons.Insert(-1, new UserModSelectButton + { + Text = "Free mods", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Y, + Size = new Vector2(250, 1), + Action = () => userModsSelectOverlay.Show(), + }); + + var rulesetInstance = rulesets.GetRuleset(playlistItem.RulesetID)!.CreateInstance(); + var allowedMods = playlistItem.AllowedMods.Select(m => m.ToMod(rulesetInstance)); + userModsSelectOverlay.IsValidMod = leaderboard.IsValidMod = m => allowedMods.Any(a => a.GetType() == m.GetType()); + } + + metadataClient.MultiplayerRoomScoreSet += onRoomScoreSet; + dailyChallengeInfo.BindTo(metadataClient.DailyChallengeInfo); + + ((IBindable)breakdown.UserBestScore).BindTo(leaderboard.UserBestScore); + } + + private void presentScore(long id) + { + if (this.IsCurrentScreen()) + this.Push(new PlaylistItemScoreResultsScreen(room.RoomID.Value!.Value, playlistItem, id)); + } + + private void onRoomScoreSet(MultiplayerRoomScoreSetEvent e) + { + if (e.RoomID != room.RoomID.Value || e.PlaylistItemID != playlistItem.ID) + return; + + userLookupCache.GetUserAsync(e.UserID).ContinueWith(t => + { + if (t.Exception != null) + { + Logger.Log($@"Could not display room score set event: {t.Exception}", LoggingTarget.Network); + return; + } + + APIUser? user = t.GetResultSafely(); + if (user == null) return; + + var ev = new NewScoreEvent(e.ScoreID, user, e.TotalScore, e.NewRank); + Schedule(() => + { + breakdown.AddNewScore(ev); + totals.AddNewScore(ev); + feed.AddNewScore(ev); + + if (e.NewRank <= 50) + Scheduler.AddOnce(() => leaderboard.RefetchScores()); + }); + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + beatmapAvailabilityTracker.SelectedItem.Value = playlistItem; + beatmapAvailabilityTracker.Availability.BindValueChanged(_ => TrySetDailyChallengeBeatmap(this, beatmapManager, rulesets, musicController, playlistItem), true); + + userModsSelectOverlayRegistration = overlayManager?.RegisterBlockingOverlay(userModsSelectOverlay); + userModsSelectOverlay.SelectedItem.Value = playlistItem; + userMods.BindValueChanged(_ => Scheduler.AddOnce(updateMods), true); + + apiState.BindTo(API.State); + apiState.BindValueChanged(onlineStateChanged, true); + + dailyChallengeInfo.BindValueChanged(dailyChallengeChanged); + } + + private void onlineStateChanged(ValueChangedEvent state) => Schedule(() => + { + if (state.NewValue != APIState.Online) + Schedule(forcefullyExit); + }); + + private void dailyChallengeChanged(ValueChangedEvent change) + { + if (change.OldValue?.RoomID == room.RoomID.Value && change.NewValue == null && metadataClient.IsConnected.Value) + { + notificationOverlay?.Post(new SimpleNotification { Text = DailyChallengeStrings.ChallengeEndedNotification }); + } + } + + private void forcefullyExit() + { + Logger.Log(@$"{this} forcefully exiting due to loss of API connection"); + + // This is temporary since we don't currently have a way to force screens to be exited + // See also: `OnlinePlayScreen.forcefullyExit()` + if (this.IsCurrentScreen()) + { + while (this.IsCurrentScreen()) + this.Exit(); + } + } + + public override void OnEntering(ScreenTransitionEvent e) + { + base.OnEntering(e); + + waves.Show(); + roomManager.JoinRoom(room); + startLoopingTrack(this, musicController); + + metadataClient.BeginWatchingMultiplayerRoom(room.RoomID.Value!.Value).ContinueWith(t => + { + if (t.Exception != null) + { + Logger.Error(t.Exception, @"Failed to subscribe to room updates", LoggingTarget.Network); + return; + } + + MultiplayerPlaylistItemStats[] stats = t.GetResultSafely(); + var itemStats = stats.SingleOrDefault(item => item.PlaylistItemID == playlistItem.ID); + + if (itemStats == null) return; + + Schedule(() => + { + breakdown.SetInitialCounts(itemStats.TotalScoreDistribution); + totals.SetInitialCounts(itemStats.TotalScoreDistribution.Sum(c => c), itemStats.CumulativeScore); + }); + }, TaskContinuationOptions.OnlyOnRanToCompletion); + + userModsSelectOverlay.SelectedItem.Value = playlistItem; + + TrySetDailyChallengeBeatmap(this, beatmapManager, rulesets, musicController, playlistItem); + } + + public override void OnResuming(ScreenTransitionEvent e) + { + base.OnResuming(e); + startLoopingTrack(this, musicController); + // re-apply mods as they may have been changed by a child screen + // (one known instance of this is showing a replay). + updateMods(); + } + + public override void OnSuspending(ScreenTransitionEvent e) + { + base.OnSuspending(e); + + userModsSelectOverlay.Hide(); + cancelTrackLooping(); + previewTrackManager.StopAnyPlaying(this); + } + + public override bool OnExiting(ScreenExitEvent e) + { + waves.Hide(); + userModsSelectOverlay.Hide(); + cancelTrackLooping(); + previewTrackManager.StopAnyPlaying(this); + this.Delay(WaveContainer.DISAPPEAR_DURATION).FadeOut(); + + roomManager.PartRoom(); + metadataClient.EndWatchingMultiplayerRoom(room.RoomID.Value!.Value).FireAndForget(); + + return base.OnExiting(e); + } + + public static void TrySetDailyChallengeBeatmap(OsuScreen screen, BeatmapManager beatmaps, RulesetStore rulesets, MusicController music, PlaylistItem item) + { + if (!screen.IsCurrentScreen()) + return; + + var beatmap = beatmaps.QueryBeatmap(b => b.OnlineID == item.Beatmap.OnlineID); + + screen.Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmap); // this will gracefully fall back to dummy beatmap if missing locally. + screen.Ruleset.Value = rulesets.GetRuleset(item.RulesetID); + + startLoopingTrack(screen, music); + } + + private static void startLoopingTrack(OsuScreen screen, MusicController music) + { + if (!screen.IsCurrentScreen()) + return; + + var track = screen.Beatmap.Value?.Track; + + if (track != null) + { + screen.Beatmap.Value?.PrepareTrackForPreview(true); + music.EnsurePlayingSomething(); + } + } + + private void cancelTrackLooping() + { + var track = Beatmap.Value?.Track; + + if (track != null) + track.Looping = false; + } + + private void updateMods() + { + if (!this.IsCurrentScreen()) + return; + + Mods.Value = userMods.Value.Concat(playlistItem.RequiredMods.Select(m => m.ToMod(Ruleset.Value.CreateInstance()))).ToList(); + } + + private void startPlay() + { + sampleStart?.Play(); + this.Push(new PlayerLoader(() => new PlaylistsPlayer(room, playlistItem) + { + Exited = () => Scheduler.AddOnce(() => leaderboard.RefetchScores()) + })); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + userModsSelectOverlayRegistration?.Dispose(); + + if (metadataClient.IsNotNull()) + metadataClient.MultiplayerRoomScoreSet -= onRoomScoreSet; + } + + public void PresentBeatmap(WorkingBeatmap beatmap, RulesetInfo ruleset) + { + if (!this.IsCurrentScreen()) + return; + + // We can only handle the current daily challenge beatmap. + // If the import was for a different beatmap, pass the duty off to global handling. + if (beatmap.BeatmapSetInfo.OnlineID != playlistItem.Beatmap.BeatmapSet!.OnlineID) + { + this.Exit(); + game?.PresentBeatmap(beatmap.BeatmapSetInfo, b => b.ID == beatmap.BeatmapInfo.ID); + } + + // And if we're handling, we don't really have much to do here. + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeCarousel.cs b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeCarousel.cs new file mode 100644 index 0000000000..09c0c3f017 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeCarousel.cs @@ -0,0 +1,233 @@ +// 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.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.DailyChallenge +{ + public partial class DailyChallengeCarousel : Container + { + private const int switch_interval = 20_500; + + private readonly Container content; + private readonly FillFlowContainer navigationFlow; + + protected override Container Content => content; + + private double clockStartTime; + private int lastDisplayed = -1; + + public DailyChallengeCarousel() + { + InternalChildren = new Drawable[] + { + content = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Bottom = 40 }, + }, + navigationFlow = new FillFlowContainer + { + AutoSizeAxes = Axes.X, + Height = 15, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Spacing = new Vector2(10), + } + }; + } + + public override void Add(Drawable drawable) + { + drawable.RelativeSizeAxes = Axes.Both; + drawable.Size = Vector2.One; + drawable.Alpha = 0; + + base.Add(drawable); + + navigationFlow.Add(new NavigationDot { Clicked = onManualNavigation }); + } + + public override bool Remove(Drawable drawable, bool disposeImmediately) + { + int index = content.IndexOf(drawable); + + if (index > 0) + navigationFlow.Remove(navigationFlow[index], true); + + return base.Remove(drawable, disposeImmediately); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + clockStartTime = Clock.CurrentTime; + } + + protected override void Update() + { + base.Update(); + + if (content.Count == 0) + { + lastDisplayed = -1; + return; + } + + double elapsed = Clock.CurrentTime - clockStartTime; + + int currentDisplay = (int)(elapsed / switch_interval) % content.Count; + double displayProgress = (elapsed % switch_interval) / switch_interval; + + navigationFlow[currentDisplay].Active.Value = true; + + if (content.Count > 1) + navigationFlow[currentDisplay].Progress = (float)displayProgress; + + if (currentDisplay == lastDisplayed) + return; + + if (lastDisplayed >= 0) + { + content[lastDisplayed].FadeOutFromOne(250, Easing.OutQuint); + navigationFlow[lastDisplayed].Active.Value = false; + } + + content[currentDisplay].Delay(250).Then().FadeInFromZero(250, Easing.OutQuint); + + lastDisplayed = currentDisplay; + } + + private void onManualNavigation(NavigationDot dot) + { + int index = navigationFlow.IndexOf(dot); + + if (index < 0) + return; + + clockStartTime = Clock.CurrentTime - index * switch_interval; + } + + private partial class NavigationDot : CompositeDrawable + { + public required Action Clicked { get; init; } + + public BindableBool Active { get; } = new BindableBool(); + + private double progress; + + public float Progress + { + set + { + if (progress == value) + return; + + progress = value; + progressLayer.Width = value; + } + } + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + private Box background = null!; + private Box progressLayer = null!; + private Box hoverLayer = null!; + + [BackgroundDependencyLoader] + private void load() + { + Size = new Vector2(15); + + InternalChildren = new Drawable[] + { + new CircularContainer + { + RelativeSizeAxes = Axes.Both, + Masking = true, + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Light4, + }, + progressLayer = new Box + { + RelativeSizeAxes = Axes.Both, + Width = 0, + Colour = colourProvider.Highlight1, + Blending = BlendingParameters.Additive, + Alpha = 0, + }, + hoverLayer = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.White, + Blending = BlendingParameters.Additive, + Alpha = 0, + } + } + }, + new HoverClickSounds() + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Active.BindValueChanged(val => + { + if (val.NewValue) + { + background.FadeColour(colourProvider.Highlight1, 250, Easing.OutQuint); + this.ResizeWidthTo(30, 250, Easing.OutQuint); + progressLayer.Width = 0; + progressLayer.Alpha = 0.5f; + } + else + { + background.FadeColour(colourProvider.Light4, 250, Easing.OutQuint); + this.ResizeWidthTo(15, 250, Easing.OutQuint); + progressLayer.FadeOut(250, Easing.OutQuint); + } + }, true); + } + + protected override bool OnHover(HoverEvent e) + { + base.OnHover(e); + hoverLayer.FadeTo(0.2f, 250, Easing.OutQuint); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + hoverLayer.FadeOut(250, Easing.OutQuint); + base.OnHoverLost(e); + } + + protected override bool OnClick(ClickEvent e) + { + Clicked(this); + + hoverLayer.FadeTo(1) + .Then().FadeTo(IsHovered ? 0.2f : 0, 250, Easing.OutQuint); + + return true; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeEventFeed.cs b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeEventFeed.cs new file mode 100644 index 0000000000..044c599ae9 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeEventFeed.cs @@ -0,0 +1,152 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Screens.OnlinePlay.DailyChallenge.Events; +using osu.Game.Users.Drawables; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.DailyChallenge +{ + public partial class DailyChallengeEventFeed : CompositeDrawable + { + private DailyChallengeEventFeedFlow flow = null!; + + public Action? PresentScore { get; init; } + + private readonly Queue newScores = new Queue(); + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + new SectionHeader("Events"), + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Top = 35 }, + Child = flow = new DailyChallengeEventFeedFlow + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Origin = Anchor.BottomCentre, + Anchor = Anchor.BottomCentre, + Spacing = new Vector2(5), + Masking = true, + } + } + }; + } + + public void AddNewScore(NewScoreEvent newScoreEvent) + { + newScores.Enqueue(newScoreEvent); + + // ensure things don't get too out-of-hand. + if (newScores.Count > 25) + newScores.Dequeue(); + } + + protected override void Update() + { + base.Update(); + + while (newScores.TryDequeue(out var newScore)) + { + flow.Add(new NewScoreEventRow(newScore) + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + PresentScore = PresentScore, + }); + } + + for (int i = 0; i < flow.Count; ++i) + { + var row = flow[i]; + + row.Alpha = Interpolation.ValueAt(Math.Clamp(row.Y + flow.DrawHeight, 0, flow.DrawHeight), 0f, 1f, 0, flow.DrawHeight, Easing.Out); + + if (row.Y < -flow.DrawHeight) + { + row.RemoveAndDisposeImmediately(); + i -= 1; + } + } + } + + private partial class DailyChallengeEventFeedFlow : FillFlowContainer + { + public override IEnumerable FlowingChildren => base.FlowingChildren.Reverse(); + } + + private partial class NewScoreEventRow : CompositeDrawable + { + private readonly NewScoreEvent newScore; + + public Action? PresentScore { get; init; } + + public NewScoreEventRow(NewScoreEvent newScore) + { + this.newScore = newScore; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + LinkFlowContainer text; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + AutoSizeDuration = 300; + AutoSizeEasing = Easing.OutQuint; + + InternalChildren = new Drawable[] + { + new ClickableAvatar(newScore.User) + { + Size = new Vector2(16), + Masking = true, + CornerRadius = 8, + }, + text = new LinkFlowContainer(t => + { + FontWeight fontWeight = FontWeight.Medium; + + if (newScore.NewRank < 100) + fontWeight = FontWeight.Bold; + else if (newScore.NewRank < 1000) + fontWeight = FontWeight.SemiBold; + + t.Font = OsuFont.Default.With(weight: fontWeight); + t.Colour = newScore.NewRank < 10 ? colours.Orange1 : Colour4.White; + }) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Left = 21 }, + } + }; + + text.AddUserLink(newScore.User); + text.AddText(" scored "); + text.AddLink($"{newScore.TotalScore:N0}", () => PresentScore?.Invoke(newScore.ScoreID)); + + if (newScore.NewRank != null) + text.AddText($" and achieved rank #{newScore.NewRank.Value:N0}"); + + text.AddText("!"); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeIntro.cs b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeIntro.cs new file mode 100644 index 0000000000..7f0f26097c --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeIntro.cs @@ -0,0 +1,540 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Screens; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Online; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Rulesets; +using osu.Game.Screens.OnlinePlay.Match; +using osu.Game.Screens.Play.HUD; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.DailyChallenge +{ + public partial class DailyChallengeIntro : OsuScreen + { + public override bool DisallowExternalBeatmapRulesetChanges => true; + + public override bool? ApplyModTrackAdjustments => true; + + private readonly Room room; + private readonly PlaylistItem item; + + private Container introContent = null!; + private Container topTitleDisplay = null!; + private Container bottomDateDisplay = null!; + private Container beatmapBackground = null!; + private Box flash = null!; + + private FillFlowContainer beatmapContent = null!; + + private Container titleContainer = null!; + + private bool beatmapBackgroundLoaded; + + private bool animationBegan; + + private IBindable starDifficulty = null!; + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Plum); + + [Cached] + private readonly OnlinePlayBeatmapAvailabilityTracker beatmapAvailabilityTracker = new OnlinePlayBeatmapAvailabilityTracker(); + + private bool shouldBePlayingMusic; + + [Resolved] + private BeatmapManager beatmapManager { get; set; } = null!; + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + [Resolved] + private MusicController musicController { get; set; } = null!; + + [Resolved] + private SessionStatics statics { get; set; } = null!; + + private Sample? dateWindupSample; + private Sample? dateImpactSample; + private Sample? beatmapWindupSample; + private Sample? beatmapImpactSample; + + private SampleChannel? dateWindupChannel; + private SampleChannel? dateImpactChannel; + private SampleChannel? beatmapWindupChannel; + private SampleChannel? beatmapImpactChannel; + + private IDisposable? duckOperation; + + public DailyChallengeIntro(Room room) + { + this.room = room; + item = room.Playlist.Single(); + + ValidForResume = false; + } + + protected override BackgroundScreen CreateBackground() => new DailyChallengeIntroBackgroundScreen(colourProvider); + + [BackgroundDependencyLoader] + private void load(RulesetStore rulesets, BeatmapDifficultyCache difficultyCache, BeatmapModelDownloader beatmapDownloader, OsuConfigManager config, AudioManager audio) + { + const float horizontal_info_size = 500f; + + StarRatingDisplay starRatingDisplay; + + IBeatmapInfo beatmap = item.Beatmap; + Ruleset ruleset = rulesets.GetRuleset(item.Beatmap.Ruleset.ShortName)!.CreateInstance(); + + InternalChildren = new Drawable[] + { + beatmapAvailabilityTracker, + introContent = new Container + { + Alpha = 0f, + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Shear = new Vector2(OsuGame.SHEAR, 0f), + Children = new Drawable[] + { + titleContainer = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + topTitleDisplay = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.CentreRight, + AutoSizeAxes = Axes.Both, + CornerRadius = 10f, + Masking = true, + X = -10, + Children = new Drawable[] + { + new Box + { + Colour = colourProvider.Background3, + RelativeSizeAxes = Axes.Both, + }, + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Today's Challenge", + Margin = new MarginPadding { Horizontal = 10f, Vertical = 5f }, + Shear = new Vector2(-OsuGame.SHEAR, 0f), + Font = OsuFont.GetFont(size: 32, weight: FontWeight.Light, typeface: Typeface.TorusAlternate), + }, + } + }, + bottomDateDisplay = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + CornerRadius = 10f, + Masking = true, + X = 10, + Children = new Drawable[] + { + new Box + { + Colour = colourProvider.Background3, + RelativeSizeAxes = Axes.Both, + }, + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = room.Name.Value.Split(':', StringSplitOptions.TrimEntries).Last(), + Margin = new MarginPadding { Horizontal = 10f, Vertical = 5f }, + Shear = new Vector2(-OsuGame.SHEAR, 0f), + Font = OsuFont.GetFont(size: 32, weight: FontWeight.Light, typeface: Typeface.TorusAlternate), + }, + } + }, + } + }, + beatmapContent = new FillFlowContainer + { + AlwaysPresent = true, // so we can get the size ahead of time + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0, + Scale = new Vector2(0.001f), + Spacing = new Vector2(10), + Children = new Drawable[] + { + beatmapBackground = new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Size = new Vector2(horizontal_info_size, 150f), + CornerRadius = 20f, + BorderColour = colourProvider.Content2, + BorderThickness = 3f, + Masking = true, + Children = new Drawable[] + { + new Box + { + Colour = colourProvider.Background3, + RelativeSizeAxes = Axes.Both, + }, + flash = new Box + { + Colour = Color4.White, + Blending = BlendingParameters.Additive, + RelativeSizeAxes = Axes.Both, + Depth = float.MinValue, + } + } + }, + new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Width = horizontal_info_size, + AutoSizeAxes = Axes.Y, + CornerRadius = 10f, + Masking = true, + Children = new Drawable[] + { + new Box + { + Colour = colourProvider.Background3, + RelativeSizeAxes = Axes.Both, + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Direction = FillDirection.Vertical, + Padding = new MarginPadding(5f), + Children = new Drawable[] + { + new TruncatingSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Shear = new Vector2(-OsuGame.SHEAR, 0f), + MaxWidth = horizontal_info_size, + Text = beatmap.BeatmapSet!.Metadata.GetDisplayTitleRomanisable(false), + Padding = new MarginPadding { Horizontal = 5f }, + Font = OsuFont.GetFont(size: 26), + }, + new TruncatingSpriteText + { + Text = $"Difficulty: {beatmap.DifficultyName}", + Font = OsuFont.GetFont(size: 20, italics: true), + MaxWidth = horizontal_info_size, + Shear = new Vector2(-OsuGame.SHEAR, 0f), + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + new TruncatingSpriteText + { + Text = $"by {beatmap.Metadata.Author.Username}", + Font = OsuFont.GetFont(size: 16, italics: true), + MaxWidth = horizontal_info_size, + Shear = new Vector2(-OsuGame.SHEAR, 0f), + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + starRatingDisplay = new StarRatingDisplay(default) + { + Shear = new Vector2(-OsuGame.SHEAR, 0f), + Margin = new MarginPadding(5), + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + } + } + }, + } + }, + new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Width = horizontal_info_size, + AutoSizeAxes = Axes.Y, + CornerRadius = 10f, + Masking = true, + Children = new Drawable[] + { + new Box + { + Colour = colourProvider.Background3, + RelativeSizeAxes = Axes.Both, + }, + new ModFlowDisplay + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Both, + Shear = new Vector2(-OsuGame.SHEAR, 0f), + Current = + { + Value = item.RequiredMods.Select(m => m.ToMod(ruleset)).ToArray() + }, + } + } + } + } + }, + } + } + }; + + starDifficulty = difficultyCache.GetBindableDifficulty(beatmap); + starDifficulty.BindValueChanged(star => + { + if (star.NewValue != null) + starRatingDisplay.Current.Value = star.NewValue.Value; + }, true); + + LoadComponentAsync(new OnlineBeatmapSetCover(beatmap.BeatmapSet as IBeatmapSetOnlineInfo) + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + FillMode = FillMode.Fit, + Scale = new Vector2(1.2f), + Shear = new Vector2(-OsuGame.SHEAR, 0f), + }, c => + { + beatmapBackground.Add(c); + + beatmapBackgroundLoaded = true; + updateAnimationState(); + }); + + if (config.Get(OsuSetting.AutomaticallyDownloadMissingBeatmaps)) + { + if (!beatmapManager.IsAvailableLocally(new BeatmapSetInfo { OnlineID = beatmap.BeatmapSet!.OnlineID })) + beatmapDownloader.Download(beatmap.BeatmapSet!, config.Get(OsuSetting.PreferNoVideo)); + } + + dateWindupSample = audio.Samples.Get(@"DailyChallenge/date-windup"); + dateImpactSample = audio.Samples.Get(@"DailyChallenge/date-impact"); + beatmapWindupSample = audio.Samples.Get(@"DailyChallenge/beatmap-windup"); + beatmapImpactSample = audio.Samples.Get(@"DailyChallenge/beatmap-impact"); + } + + public override void OnEntering(ScreenTransitionEvent e) + { + base.OnEntering(e); + + beatmapAvailabilityTracker.SelectedItem.Value = item; + beatmapAvailabilityTracker.Availability.BindValueChanged(availability => + { + if (shouldBePlayingMusic && availability.NewValue.State == DownloadState.LocallyAvailable) + DailyChallenge.TrySetDailyChallengeBeatmap(this, beatmapManager, rulesets, musicController, item); + }, true); + + this.FadeInFromZero(400, Easing.OutQuint); + updateAnimationState(); + + playDateWindupSample(); + } + + public override void OnSuspending(ScreenTransitionEvent e) + { + this.FadeOut(800, Easing.OutQuint); + base.OnSuspending(e); + } + + private void updateAnimationState() + { + if (!beatmapBackgroundLoaded || !this.IsCurrentScreen()) + return; + + if (animationBegan) + return; + + beginAnimation(); + animationBegan = true; + } + + private void beginAnimation() + { + using (BeginDelayedSequence(200)) + { + introContent.Show(); + + const float y_offset_start = 260; + const float y_offset_end = 20; + + topTitleDisplay + .FadeInFromZero(400, Easing.OutQuint); + + topTitleDisplay.MoveToY(-y_offset_start) + .MoveToY(-y_offset_end, 300, Easing.OutQuint) + .Then() + .MoveToY(0, 4000); + + bottomDateDisplay.MoveToY(y_offset_start) + .MoveToY(y_offset_end, 300, Easing.OutQuint) + .Then() + .MoveToY(0, 4000); + + using (BeginDelayedSequence(150)) + { + Schedule(() => + { + playDateImpactSample(); + playBeatmapWindupSample(); + + duckOperation?.Dispose(); + duckOperation = musicController.Duck(new DuckParameters + { + RestoreDuration = 1500f, + }); + }); + + using (BeginDelayedSequence(2750)) + { + Schedule(() => + { + duckOperation?.Dispose(); + }); + } + } + + using (BeginDelayedSequence(1000)) + { + beatmapContent + .ScaleTo(3) + .ScaleTo(1f, 500, Easing.In) + .Then() + .ScaleTo(1.1f, 4000); + + using (BeginDelayedSequence(100)) + { + titleContainer + .ScaleTo(0.4f, 400, Easing.In) + .FadeOut(500, Easing.OutQuint); + } + + using (BeginDelayedSequence(240)) + { + beatmapContent.FadeInFromZero(280, Easing.InQuad); + + using (BeginDelayedSequence(300)) + { + Schedule(() => + { + shouldBePlayingMusic = true; + DailyChallenge.TrySetDailyChallengeBeatmap(this, beatmapManager, rulesets, musicController, item); + ApplyToBackground(bs => ((RoomBackgroundScreen)bs).SelectedItem.Value = item); + playBeatmapImpactSample(); + }); + } + + using (BeginDelayedSequence(400)) + flash.FadeOutFromOne(5000, Easing.OutQuint); + + using (BeginDelayedSequence(2600)) + { + Schedule(() => + { + statics.SetValue(Static.DailyChallengeIntroPlayed, true); + + if (this.IsCurrentScreen()) + this.Push(new DailyChallenge(room)); + }); + } + } + } + } + } + + private void playDateWindupSample() + { + dateWindupChannel = dateWindupSample?.GetChannel(); + dateWindupChannel?.Play(); + } + + private void playDateImpactSample() + { + dateImpactChannel = dateImpactSample?.GetChannel(); + dateImpactChannel?.Play(); + } + + private void playBeatmapWindupSample() + { + beatmapWindupChannel = beatmapWindupSample?.GetChannel(); + beatmapWindupChannel?.Play(); + } + + private void playBeatmapImpactSample() + { + beatmapImpactChannel = beatmapImpactSample?.GetChannel(); + beatmapImpactChannel?.Play(); + } + + protected override void Dispose(bool isDisposing) + { + resetAudio(); + base.Dispose(isDisposing); + } + + private void resetAudio() + { + dateWindupChannel?.Stop(); + dateImpactChannel?.Stop(); + beatmapWindupChannel?.Stop(); + beatmapImpactChannel?.Stop(); + duckOperation?.Dispose(); + } + + private partial class DailyChallengeIntroBackgroundScreen : RoomBackgroundScreen + { + private readonly OverlayColourProvider colourProvider; + + public DailyChallengeIntroBackgroundScreen(OverlayColourProvider colourProvider) + : base(null) + { + this.colourProvider = colourProvider; + } + + [BackgroundDependencyLoader] + private void load() + { + AddInternal(new Box + { + Depth = float.MinValue, + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5.Opacity(0.6f), + }); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeLeaderboard.cs b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeLeaderboard.cs new file mode 100644 index 0000000000..c9152393e7 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeLeaderboard.cs @@ -0,0 +1,199 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Scoring; +using osu.Game.Screens.SelectV2.Leaderboards; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.DailyChallenge +{ + public partial class DailyChallengeLeaderboard : CompositeDrawable + { + public IBindable UserBestScore => userBestScore; + private readonly Bindable userBestScore = new Bindable(); + public Bindable> SelectedMods = new Bindable>(); + + /// + /// A function determining whether each mod in the score can be selected. + /// A return value of means that the mod can be selected in the current context. + /// A return value of means that the mod cannot be selected in the current context. + /// + public Func IsValidMod { get; set; } = _ => true; + + public Action? PresentScore { get; init; } + + private readonly Room room; + private readonly PlaylistItem playlistItem; + + private FillFlowContainer scoreFlow = null!; + private Container userBestContainer = null!; + private SectionHeader userBestHeader = null!; + private LoadingLayer loadingLayer = null!; + + private CancellationTokenSource? cancellationTokenSource; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private ScoreManager scoreManager { get; set; } = null!; + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + [Resolved] + private IBindable beatmap { get; set; } = null!; + + public DailyChallengeLeaderboard(Room room, PlaylistItem playlistItem) + { + this.room = room; + this.playlistItem = playlistItem; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = + [ + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize) + ], + Content = new[] + { + new Drawable[] { new SectionHeader("Leaderboard") }, + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = scoreFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Right = 20, }, + Direction = FillDirection.Vertical, + Spacing = new Vector2(5), + Scale = new Vector2(0.8f), + Width = 1 / 0.8f, + } + }, + loadingLayer = new LoadingLayer + { + RelativeSizeAxes = Axes.Both, + }, + } + } + }, + new Drawable[] { userBestHeader = new SectionHeader("Personal best") { Alpha = 0, } }, + new Drawable[] + { + userBestContainer = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Right = 20, }, + Scale = new Vector2(0.8f), + Width = 1 / 0.8f, + } + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + RefetchScores(); + } + + private IndexPlaylistScoresRequest? request; + + public void RefetchScores() + { + if (request?.CompletionState == APIRequestCompletionState.Waiting) + return; + + request = new IndexPlaylistScoresRequest(room.RoomID.Value!.Value, playlistItem.ID); + + request.Success += req => Schedule(() => + { + var best = req.Scores.Select(s => s.CreateScoreInfo(scoreManager, rulesets, playlistItem, beatmap.Value.BeatmapInfo)).ToArray(); + + userBestScore.Value = req.UserScore; + var userBest = userBestScore.Value?.CreateScoreInfo(scoreManager, rulesets, playlistItem, beatmap.Value.BeatmapInfo); + + cancellationTokenSource?.Cancel(); + cancellationTokenSource = null; + cancellationTokenSource ??= new CancellationTokenSource(); + + if (best.Length == 0) + { + scoreFlow.Clear(); + loadingLayer.Hide(); + } + else + { + LoadComponentsAsync(best.Select((s, index) => new LeaderboardScoreV2(s, sheared: false) + { + Rank = index + 1, + IsPersonalBest = s.UserID == api.LocalUser.Value.Id, + Action = () => PresentScore?.Invoke(s.OnlineID), + SelectedMods = { BindTarget = SelectedMods }, + IsValidMod = IsValidMod, + }), loaded => + { + scoreFlow.Clear(); + scoreFlow.AddRange(loaded); + scoreFlow.FadeTo(1, 400, Easing.OutQuint); + loadingLayer.Hide(); + }, cancellationTokenSource.Token); + } + + userBestContainer.Clear(); + + if (userBest != null) + { + userBestContainer.Add(new LeaderboardScoreV2(userBest, sheared: false) + { + Rank = userBest.Position, + IsPersonalBest = true, + Action = () => PresentScore?.Invoke(userBest.OnlineID), + SelectedMods = { BindTarget = SelectedMods }, + IsValidMod = IsValidMod, + }); + } + + userBestHeader.FadeTo(userBest == null ? 0 : 1); + }); + + loadingLayer.Show(); + scoreFlow.FadeTo(0.5f, 400, Easing.OutQuint); + api.Queue(request); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeScoreBreakdown.cs b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeScoreBreakdown.cs new file mode 100644 index 0000000000..71ab73b535 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeScoreBreakdown.cs @@ -0,0 +1,291 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Metadata; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Screens.OnlinePlay.DailyChallenge.Events; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.DailyChallenge +{ + public partial class DailyChallengeScoreBreakdown : CompositeDrawable + { + public Bindable UserBestScore { get; } = new Bindable(); + + private FillFlowContainer barsContainer = null!; + + private const int bin_count = MultiplayerPlaylistItemStats.TOTAL_SCORE_DISTRIBUTION_BINS; + private long[] bins = new long[bin_count]; + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + new SectionHeader("Score breakdown"), + barsContainer = new FillFlowContainer + { + Direction = FillDirection.Horizontal, + RelativeSizeAxes = Axes.Both, + Height = 0.9f, + Padding = new MarginPadding { Top = 35 }, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + } + }; + + for (int i = 0; i < bin_count; ++i) + { + barsContainer.Add(new Bar(100_000 * i, 100_000 * (i + 1) - 1) + { + Width = 1f / bin_count, + }); + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + UserBestScore.BindValueChanged(_ => + { + foreach (var bar in barsContainer) + bar.ContainsLocalUser.Value = UserBestScore.Value is not null && bar.BinStart <= UserBestScore.Value.TotalScore && UserBestScore.Value.TotalScore <= bar.BinEnd; + }); + } + + private readonly Queue newScores = new Queue(); + + public void AddNewScore(NewScoreEvent newScoreEvent) + { + newScores.Enqueue(newScoreEvent); + + // ensure things don't get too out-of-hand. + if (newScores.Count > 25) + { + bins[getTargetBin(newScores.Dequeue())] += 1; + Scheduler.AddOnce(updateCounts); + } + } + + private double lastScoreDisplay; + + protected override void Update() + { + base.Update(); + + if (Time.Current - lastScoreDisplay > 150 && newScores.TryDequeue(out var newScore)) + { + if (lastScoreDisplay < Time.Current) + lastScoreDisplay = Time.Current; + + int targetBin = getTargetBin(newScore); + bins[targetBin] += 1; + + updateCounts(); + + var text = new OsuSpriteText + { + Text = newScore.TotalScore.ToString(@"N0"), + Anchor = Anchor.TopCentre, + Origin = Anchor.BottomCentre, + Font = OsuFont.Default.With(size: 30), + RelativePositionAxes = Axes.X, + X = (targetBin + 0.5f) / bin_count - 0.5f, + Alpha = 0, + }; + AddInternal(text); + + Scheduler.AddDelayed(() => + { + float startY = ToLocalSpace(barsContainer[targetBin].CircularBar.ScreenSpaceDrawQuad.TopLeft).Y; + text.FadeInFromZero() + .ScaleTo(new Vector2(0.8f), 500, Easing.OutElasticHalf) + .MoveToY(startY) + .MoveToOffset(new Vector2(0, -50), 2500, Easing.OutQuint) + .FadeOut(2500, Easing.OutQuint) + .Expire(); + }, 150); + + lastScoreDisplay = Time.Current; + } + } + + public void SetInitialCounts(long[] counts) + { + if (counts.Length != bin_count) + throw new ArgumentException(@"Incorrect number of bins.", nameof(counts)); + + bins = counts; + updateCounts(); + } + + private static int getTargetBin(NewScoreEvent score) => + (int)Math.Clamp(Math.Floor((float)score.TotalScore / 100000), 0, bin_count - 1); + + private void updateCounts() + { + long max = Math.Max(bins.Max(), 1); + for (int i = 0; i < bin_count; ++i) + barsContainer[i].UpdateCounts(bins[i], max); + } + + private partial class Bar : CompositeDrawable, IHasTooltip + { + public BindableBool ContainsLocalUser { get; } = new BindableBool(); + + public readonly int BinStart; + public readonly int BinEnd; + + private long count; + private long max; + + public Container CircularBar { get; private set; } = null!; + + private Box fill = null!; + private Box flashLayer = null!; + private OsuSpriteText userIndicator = null!; + + public Bar(int binStart, int binEnd) + { + BinStart = binStart; + BinEnd = binEnd; + } + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + RelativeSizeAxes = Axes.Both; + + AddInternal(new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding + { + Bottom = 20, + Horizontal = 3, + }, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Children = new Drawable[] + { + CircularBar = new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Height = 0.01f, + Masking = true, + CornerRadius = 10, + Children = new Drawable[] + { + fill = new Box + { + RelativeSizeAxes = Axes.Both, + }, + flashLayer = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + }, + } + }, + userIndicator = new OsuSpriteText + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Colour = colours.Orange1, + Text = "You", + Font = OsuFont.Default.With(weight: FontWeight.Bold), + Alpha = 0, + RelativePositionAxes = Axes.Y, + Margin = new MarginPadding { Bottom = 5, }, + } + }, + }); + + string? label = null; + + switch (BinStart) + { + case 200_000: + case 400_000: + case 600_000: + case 800_000: + label = @$"{BinStart / 1000}k"; + break; + + case 1_000_000: + label = @"1M"; + break; + } + + if (label != null) + { + AddInternal(new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomCentre, + Text = label, + Colour = colourProvider.Content2, + }); + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + ContainsLocalUser.BindValueChanged(_ => + { + fill.FadeColour(ContainsLocalUser.Value ? colours.Orange1 : colourProvider.Highlight1, 300, Easing.OutQuint); + userIndicator.FadeTo(ContainsLocalUser.Value ? 1 : 0, 300, Easing.OutQuint); + }, true); + FinishTransforms(true); + } + + protected override void Update() + { + base.Update(); + + CircularBar.CornerRadius = Math.Min(CircularBar.DrawHeight / 2, CircularBar.DrawWidth / 4); + } + + public void UpdateCounts(long newCount, long newMax) + { + bool isIncrement = newCount > count; + + count = newCount; + max = newMax; + + float height = 0.01f + 0.99f * count / max; + CircularBar.ResizeHeightTo(height, 300, Easing.OutQuint); + userIndicator.MoveToY(-height, 300, Easing.OutQuint); + if (isIncrement) + flashLayer.FadeOutFromOne(600, Easing.OutQuint); + } + + public LocalisableString TooltipText => LocalisableString.Format("{0:N0} passes in {1:N0} - {2:N0} range", count, BinStart, BinEnd); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeTimeRemainingRing.cs b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeTimeRemainingRing.cs new file mode 100644 index 0000000000..e86f26ad6b --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeTimeRemainingRing.cs @@ -0,0 +1,142 @@ +// 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; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Threading; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.DailyChallenge +{ + public partial class DailyChallengeTimeRemainingRing : OnlinePlayComposite + { + private CircularProgress progress = null!; + private OsuSpriteText timeText = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + new SectionHeader("Time remaining"), + new DrawSizePreservingFillContainer + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Top = 35 }, + TargetDrawSize = new Vector2(200), + Strategy = DrawSizePreservationStrategy.Minimum, + Children = new Drawable[] + { + new CircularProgress + { + Size = new Vector2(180), + InnerRadius = 0.1f, + Progress = 1, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = colourProvider.Background5, + }, + progress = new CircularProgress + { + Size = new Vector2(180), + InnerRadius = 0.1f, + Progress = 1, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Vertical, + Children = new[] + { + timeText = new OsuSpriteText + { + Text = "00:00:00", + Font = OsuFont.TorusAlternate.With(size: 40), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new OsuSpriteText + { + Text = "remaining", + Font = OsuFont.Default.With(size: 20), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + } + } + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + StartDate.BindValueChanged(_ => Scheduler.AddOnce(updateState)); + EndDate.BindValueChanged(_ => Scheduler.AddOnce(updateState)); + updateState(); + FinishTransforms(true); + } + + private ScheduledDelegate? scheduledUpdate; + + private void updateState() + { + scheduledUpdate?.Cancel(); + scheduledUpdate = null; + + const float transition_duration = 300; + + if (StartDate.Value == null || EndDate.Value == null || EndDate.Value < DateTimeOffset.Now) + { + timeText.Text = TimeSpan.Zero.ToString(@"hh\:mm\:ss"); + progress.Progress = 0; + timeText.FadeColour(colours.Red2, transition_duration, Easing.OutQuint); + progress.FadeColour(colours.Red2, transition_duration, Easing.OutQuint); + return; + } + + var roomDuration = EndDate.Value.Value - StartDate.Value.Value; + var remaining = EndDate.Value.Value - DateTimeOffset.Now; + + timeText.Text = remaining.ToString(@"hh\:mm\:ss"); + progress.Progress = remaining.TotalSeconds / roomDuration.TotalSeconds; + + if (remaining < TimeSpan.FromMinutes(15)) + { + timeText.Colour = progress.Colour = colours.Red1; + timeText + .FadeColour(colours.Red1) + .Then().FlashColour(colours.Red0, transition_duration, Easing.OutQuint); + progress + .FadeColour(colours.Red1) + .Then().FlashColour(colours.Red0, transition_duration, Easing.OutQuint); + } + else + { + timeText.FadeColour(Colour4.White, transition_duration, Easing.OutQuint); + progress.FadeColour(colourProvider.Highlight1, transition_duration, Easing.OutQuint); + } + + scheduledUpdate = Scheduler.AddDelayed(updateState, 1000); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeTotalsDisplay.cs b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeTotalsDisplay.cs new file mode 100644 index 0000000000..e2535ed806 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/DailyChallenge/DailyChallengeTotalsDisplay.cs @@ -0,0 +1,141 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Screens.OnlinePlay.DailyChallenge.Events; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.DailyChallenge +{ + public partial class DailyChallengeTotalsDisplay : CompositeDrawable + { + private Container passCountContainer = null!; + private TotalRollingCounter passCounter = null!; + private Container totalScoreContainer = null!; + private TotalRollingCounter totalScoreCounter = null!; + + private long totalPassCountInstantaneous; + private long cumulativeTotalScoreInstantaneous; + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = + [ + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + ], + Content = new[] + { + new Drawable[] + { + new SectionHeader("Total pass count") + }, + new Drawable[] + { + passCountContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Child = passCounter = new TotalRollingCounter + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + } + }, + new Drawable[] + { + new SectionHeader("Cumulative total score") + }, + new Drawable[] + { + totalScoreContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Child = totalScoreCounter = new TotalRollingCounter + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + } + }, + } + }; + } + + public void SetInitialCounts(long totalPassCount, long cumulativeTotalScore) + { + totalPassCountInstantaneous = totalPassCount; + cumulativeTotalScoreInstantaneous = cumulativeTotalScore; + } + + public void AddNewScore(NewScoreEvent ev) + { + totalPassCountInstantaneous += 1; + cumulativeTotalScoreInstantaneous += ev.TotalScore; + } + + protected override void Update() + { + base.Update(); + + passCounter.Current.Value = totalPassCountInstantaneous; + totalScoreCounter.Current.Value = cumulativeTotalScoreInstantaneous; + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + var totalPassCountProportionOfParent = Vector2.Divide(passCountContainer.DrawSize, passCounter.DrawSize); + passCounter.Scale = new Vector2(Math.Min(Math.Min(totalPassCountProportionOfParent.X, totalPassCountProportionOfParent.Y) * 0.8f, 1)); + + var totalScoreTextProportionOfParent = Vector2.Divide(totalScoreContainer.DrawSize, totalScoreCounter.DrawSize); + totalScoreCounter.Scale = new Vector2(Math.Min(Math.Min(totalScoreTextProportionOfParent.X, totalScoreTextProportionOfParent.Y) * 0.8f, 1)); + } + + private partial class TotalRollingCounter : RollingCounter + { + protected override double RollingDuration => 1000; + + protected override Easing RollingEasing => Easing.OutPow10; + + protected override bool IsRollingProportional => true; + + protected override double GetProportionalDuration(long currentValue, long newValue) + { + long change = Math.Abs(newValue - currentValue); + + if (change < 10) + return 0; + + return Math.Min(6000, RollingDuration * Math.Sqrt(change) / 100); + } + + protected override OsuSpriteText CreateSpriteText() => new OsuSpriteText + { + Font = OsuFont.Default.With(size: 80f, fixedWidth: true), + Spacing = new Vector2(-4, 0) + }; + + protected override LocalisableString FormatCount(long count) => count.ToLocalisableString(@"N0"); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/DailyChallenge/Events/NewScoreEvent.cs b/osu.Game/Screens/OnlinePlay/DailyChallenge/Events/NewScoreEvent.cs new file mode 100644 index 0000000000..bc4c4e1a1e --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/DailyChallenge/Events/NewScoreEvent.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Screens.OnlinePlay.DailyChallenge.Events +{ + public class NewScoreEvent + { + public NewScoreEvent(long scoreID, APIUser user, long totalScore, int? newRank) + { + ScoreID = scoreID; + User = user; + TotalScore = totalScore; + NewRank = newRank; + } + + public long ScoreID { get; } + public APIUser User { get; } + public long TotalScore { get; } + public int? NewRank { get; } + } +} diff --git a/osu.Game/Screens/OnlinePlay/DailyChallenge/NewDailyChallengeNotification.cs b/osu.Game/Screens/OnlinePlay/DailyChallenge/NewDailyChallengeNotification.cs new file mode 100644 index 0000000000..7ae6992bec --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/DailyChallenge/NewDailyChallengeNotification.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.Linq; +using osu.Framework.Allocation; +using osu.Framework.Screens; +using osu.Game.Beatmaps.Drawables.Cards; +using osu.Game.Configuration; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Rooms; +using osu.Game.Overlays.Notifications; +using osu.Game.Screens.Menu; +using osu.Game.Localisation; + +namespace osu.Game.Screens.OnlinePlay.DailyChallenge +{ + public partial class NewDailyChallengeNotification : SimpleNotification + { + private readonly Room room; + + private BeatmapCardNano card = null!; + + public NewDailyChallengeNotification(Room room) + { + this.room = room; + } + + [BackgroundDependencyLoader] + private void load(OsuGame? game, SessionStatics statics) + { + Text = DailyChallengeStrings.ChallengeLiveNotification; + Content.Add(card = new BeatmapCardNano((APIBeatmapSet)room.Playlist.Single().Beatmap.BeatmapSet!)); + Activated = () => + { + if (statics.Get(Static.DailyChallengeIntroPlayed)) + game?.PerformFromScreen(s => s.Push(new DailyChallenge(room)), [typeof(MainMenu)]); + else + game?.PerformFromScreen(s => s.Push(new DailyChallengeIntro(room)), [typeof(MainMenu)]); + + return true; + }; + } + + protected override void Update() + { + base.Update(); + card.Width = Content.DrawWidth; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs index 1b8e2d8be6..43ffaf947e 100644 --- a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs +++ b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs @@ -22,6 +22,7 @@ using osu.Framework.Localisation; using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; +using osu.Game.Beatmaps.Drawables.Cards; using osu.Game.Collections; using osu.Game.Database; using osu.Game.Graphics; @@ -48,6 +49,8 @@ namespace osu.Game.Screens.OnlinePlay private const float icon_height = 34; + private const float border_thickness = 3; + /// /// Invoked when this item requests to be deleted. /// @@ -80,8 +83,8 @@ namespace osu.Game.Screens.OnlinePlay private IRulesetInfo ruleset; private Mod[] requiredMods = Array.Empty(); - private Container maskingContainer; - private Container difficultyIconContainer; + private Container borderContainer; + private FillFlowContainer difficultyIconContainer; private LinkFlowContainer beatmapText; private LinkFlowContainer authorText; private ExplicitContentBeatmapBadge explicitContent; @@ -93,6 +96,7 @@ namespace osu.Game.Screens.OnlinePlay private Drawable removeButton; private PanelBackground panelBackground; private FillFlowContainer mainFillFlow; + private BeatmapCardThumbnail thumbnail; [Resolved] private RealmAccess realm { get; set; } @@ -132,7 +136,7 @@ namespace osu.Game.Screens.OnlinePlay [BackgroundDependencyLoader] private void load() { - maskingContainer.BorderColour = colours.Yellow; + borderContainer.BorderColour = colours.Yellow; ruleset = rulesets.GetRuleset(Item.RulesetID); var rulesetInstance = ruleset?.CreateInstance(); @@ -159,7 +163,7 @@ namespace osu.Game.Screens.OnlinePlay return; } - maskingContainer.BorderThickness = IsSelectedItem ? 5 : 0; + borderContainer.BorderThickness = IsSelectedItem ? border_thickness : 0; }, true); valid.BindValueChanged(_ => Scheduler.AddOnce(refresh)); @@ -276,16 +280,31 @@ namespace osu.Game.Screens.OnlinePlay { if (!valid.Value) { - maskingContainer.BorderThickness = 5; - maskingContainer.BorderColour = colours.Red; + borderContainer.BorderThickness = border_thickness; + borderContainer.BorderColour = colours.Red; } if (beatmap != null) { - difficultyIconContainer.Child = new DifficultyIcon(beatmap, ruleset, requiredMods) + difficultyIconContainer.Children = new Drawable[] { - Size = new Vector2(icon_height), - TooltipType = DifficultyIconTooltipType.Extended, + thumbnail = new BeatmapCardThumbnail(beatmap.BeatmapSet!, (IBeatmapSetOnlineInfo)beatmap.BeatmapSet!) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Width = 60, + Masking = true, + CornerRadius = 10, + RelativeSizeAxes = Axes.Y, + Dimmed = { Value = IsHovered } + }, + new DifficultyIcon(beatmap, ruleset, requiredMods) + { + Size = new Vector2(24), + TooltipType = DifficultyIconTooltipType.Extended, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, }; } else @@ -329,72 +348,68 @@ namespace osu.Game.Screens.OnlinePlay protected override Drawable CreateContent() { - Action fontParameters = s => s.Font = OsuFont.Default.With(weight: FontWeight.SemiBold); + Action fontParameters = s => s.Font = OsuFont.Default.With(size: 14, weight: FontWeight.SemiBold); - return maskingContainer = new Container + return new Container { RelativeSizeAxes = Axes.X, Height = HEIGHT, - Masking = true, - CornerRadius = 10, Children = new Drawable[] { - new Box // A transparent box that forces the border to be drawn if the panel background is opaque + new Container { RelativeSizeAxes = Axes.Both, - Alpha = 0, - AlwaysPresent = true - }, - onScreenLoader, - panelBackground = new PanelBackground - { - RelativeSizeAxes = Axes.Both, - }, - new GridContainer - { - RelativeSizeAxes = Axes.Both, - ColumnDimensions = new[] + Masking = true, + CornerRadius = 10, + Children = new Drawable[] { - new Dimension(GridSizeMode.AutoSize), - new Dimension(), - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.AutoSize) - }, - Content = new[] - { - new Drawable[] + onScreenLoader, + panelBackground = new PanelBackground { - difficultyIconContainer = new Container + RelativeSizeAxes = Axes.Both, + }, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - AutoSizeAxes = Axes.Both, - Margin = new MarginPadding { Left = 8, Right = 8 }, + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize) }, - mainFillFlow = new MainFlow(() => SelectedItem.Value == Model || !AllowSelection) + Content = new[] { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - Direction = FillDirection.Vertical, - Children = new Drawable[] + new Drawable[] { - beatmapText = new LinkFlowContainer(fontParameters) + difficultyIconContainer = new FillFlowContainer { - RelativeSizeAxes = Axes.X, - // workaround to ensure only the first line of text shows, emulating truncation (but without ellipsis at the end). - // TODO: remove when text/link flow can support truncation with ellipsis natively. - Height = OsuFont.DEFAULT_FONT_SIZE, - Masking = true - }, - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, Direction = FillDirection.Horizontal, - Spacing = new Vector2(10f, 0), + Spacing = new Vector2(4), + Margin = new MarginPadding { Right = 4 }, + }, + mainFillFlow = new MainFlow(() => SelectedItem.Value == Model || !AllowSelection) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, -2), Children = new Drawable[] { + beatmapText = new LinkFlowContainer(fontParameters) + { + RelativeSizeAxes = Axes.X, + // workaround to ensure only the first line of text shows, emulating truncation (but without ellipsis at the end). + // TODO: remove when text/link flow can support truncation with ellipsis natively. + Height = OsuFont.DEFAULT_FONT_SIZE, + Masking = true + }, new FillFlowContainer { AutoSizeAxes = Axes.Both, @@ -402,59 +417,86 @@ namespace osu.Game.Screens.OnlinePlay Spacing = new Vector2(10f, 0), Children = new Drawable[] { - authorText = new LinkFlowContainer(fontParameters) { AutoSizeAxes = Axes.Both }, - explicitContent = new ExplicitContentBeatmapBadge + new FillFlowContainer { - Alpha = 0f, + AutoSizeAxes = Axes.Both, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Margin = new MarginPadding { Top = 3f }, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10f, 0), + Children = new Drawable[] + { + authorText = new LinkFlowContainer(fontParameters) { AutoSizeAxes = Axes.Both }, + explicitContent = new ExplicitContentBeatmapBadge + { + Alpha = 0f, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Margin = new MarginPadding { Top = 3f }, + } + }, + }, + new Container + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Child = modDisplay = new ModDisplay + { + Scale = new Vector2(0.4f), + ExpansionMode = ExpansionMode.AlwaysExpanded, + Margin = new MarginPadding { Vertical = -6 }, + } } - }, - }, - new Container - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - AutoSizeAxes = Axes.Both, - Child = modDisplay = new ModDisplay - { - Scale = new Vector2(0.4f), - ExpansionMode = ExpansionMode.AlwaysExpanded } } } - } + }, + buttonsFlow = new FillFlowContainer + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Direction = FillDirection.Horizontal, + Margin = new MarginPadding { Horizontal = 8 }, + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(5), + ChildrenEnumerable = createButtons().Select(button => button.With(b => + { + b.Anchor = Anchor.Centre; + b.Origin = Anchor.Centre; + })) + }, + ownerAvatar = new OwnerAvatar + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(icon_height), + Margin = new MarginPadding { Right = 8 }, + Masking = true, + CornerRadius = 4, + Alpha = ShowItemOwner ? 1 : 0 + }, } - }, - buttonsFlow = new FillFlowContainer - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Direction = FillDirection.Horizontal, - Margin = new MarginPadding { Horizontal = 8 }, - AutoSizeAxes = Axes.Both, - Spacing = new Vector2(5), - ChildrenEnumerable = createButtons().Select(button => button.With(b => - { - b.Anchor = Anchor.Centre; - b.Origin = Anchor.Centre; - })) - }, - ownerAvatar = new OwnerAvatar - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(icon_height), - Margin = new MarginPadding { Right = 8 }, - Masking = true, - CornerRadius = 4, - Alpha = ShowItemOwner ? 1 : 0 - }, - } - } + } + }, + }, }, - }, + borderContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 10, + Children = new Drawable[] + { + new Box // A transparent box that forces the border to be drawn if the panel background is opaque + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true + }, + } + } + } }; } @@ -484,6 +526,24 @@ namespace osu.Game.Screens.OnlinePlay }, }; + protected override bool OnHover(HoverEvent e) + { + if (thumbnail != null) + thumbnail.Dimmed.Value = true; + + panelBackground.FadeColour(OsuColour.Gray(0.7f), BeatmapCard.TRANSITION_DURATION, Easing.OutQuint); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + if (thumbnail != null) + thumbnail.Dimmed.Value = false; + + panelBackground.FadeColour(OsuColour.Gray(1f), BeatmapCard.TRANSITION_DURATION, Easing.OutQuint); + base.OnHoverLost(e); + } + protected override bool OnClick(ClickEvent e) { if (AllowSelection && valid.Value) @@ -607,7 +667,6 @@ namespace osu.Game.Screens.OnlinePlay backgroundSprite = new UpdateableBeatmapBackgroundSprite { RelativeSizeAxes = Axes.Both, - FillMode = FillMode.Fill, }, new FillFlowContainer { @@ -616,7 +675,7 @@ namespace osu.Game.Screens.OnlinePlay Direction = FillDirection.Horizontal, // This makes the gradient not be perfectly horizontal, but diagonal at a ~40° angle Shear = new Vector2(0.8f, 0), - Alpha = 0.5f, + Alpha = 0.6f, Children = new[] { // The left half with no gradient applied diff --git a/osu.Game/Screens/OnlinePlay/FreeModSelectOverlay.cs b/osu.Game/Screens/OnlinePlay/FreeModSelectOverlay.cs index 7f090aca57..8937abb775 100644 --- a/osu.Game/Screens/OnlinePlay/FreeModSelectOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/FreeModSelectOverlay.cs @@ -6,6 +6,7 @@ using osu.Game.Overlays; using System.Collections.Generic; using System.Linq; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Mods; using osu.Game.Rulesets.Mods; @@ -14,8 +15,6 @@ namespace osu.Game.Screens.OnlinePlay { public partial class FreeModSelectOverlay : ModSelectOverlay { - protected override bool ShowModEffects => false; - protected override bool AllowCustomisation => false; public new Func IsValidMod @@ -24,6 +23,8 @@ namespace osu.Game.Screens.OnlinePlay set => base.IsValidMod = m => m.UserPlayable && value.Invoke(m); } + protected override SelectAllModsButton? SelectAllModsButton => DisplayedFooterContent?.SelectAllModsButton; + public FreeModSelectOverlay() : base(OverlayColourScheme.Plum) { @@ -32,12 +33,35 @@ namespace osu.Game.Screens.OnlinePlay protected override ModColumn CreateModColumn(ModType modType) => new ModColumn(modType, true); - protected override IEnumerable CreateFooterButtons() - => base.CreateFooterButtons() - .Prepend(SelectAllModsButton = new SelectAllModsButton(this) - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - }); + public new FreeModSelectFooterContent? DisplayedFooterContent => base.DisplayedFooterContent as FreeModSelectFooterContent; + + public override VisibilityContainer CreateFooterContent() => new FreeModSelectFooterContent(this) + { + Beatmap = { BindTarget = Beatmap }, + ActiveMods = { BindTarget = ActiveMods }, + }; + + public partial class FreeModSelectFooterContent : ModSelectFooterContent + { + private readonly FreeModSelectOverlay overlay; + + protected override bool ShowModEffects => false; + + public SelectAllModsButton? SelectAllModsButton; + + public FreeModSelectFooterContent(FreeModSelectOverlay overlay) + : base(overlay) + { + this.overlay = overlay; + } + + protected override IEnumerable CreateButtons() + => base.CreateButtons() + .Prepend(SelectAllModsButton = new SelectAllModsButton(overlay) + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + }); + } } } diff --git a/osu.Game/Screens/OnlinePlay/Header.cs b/osu.Game/Screens/OnlinePlay/Header.cs index 4c4851c3ac..860042fd37 100644 --- a/osu.Game/Screens/OnlinePlay/Header.cs +++ b/osu.Game/Screens/OnlinePlay/Header.cs @@ -1,13 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using Humanizer; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; using osu.Framework.Screens; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; @@ -20,10 +18,10 @@ namespace osu.Game.Screens.OnlinePlay { public const float HEIGHT = 80; - private readonly ScreenStack stack; + private readonly ScreenStack? stack; private readonly MultiHeaderTitle title; - public Header(string mainTitle, ScreenStack stack) + public Header(LocalisableString mainTitle, ScreenStack? stack) { this.stack = stack; @@ -37,12 +35,15 @@ namespace osu.Game.Screens.OnlinePlay Origin = Anchor.CentreLeft, }; - // unnecessary to unbind these as this header has the same lifetime as the screen stack we are attaching to. - stack.ScreenPushed += (_, _) => updateSubScreenTitle(); - stack.ScreenExited += (_, _) => updateSubScreenTitle(); + if (stack != null) + { + // unnecessary to unbind these as this header has the same lifetime as the screen stack we are attaching to. + stack.ScreenPushed += (_, _) => updateSubScreenTitle(); + stack.ScreenExited += (_, _) => updateSubScreenTitle(); + } } - private void updateSubScreenTitle() => title.Screen = stack.CurrentScreen as IOnlinePlaySubScreen; + private void updateSubScreenTitle() => title.Screen = stack?.CurrentScreen as IOnlinePlaySubScreen; private partial class MultiHeaderTitle : CompositeDrawable { @@ -51,13 +52,16 @@ namespace osu.Game.Screens.OnlinePlay private readonly OsuSpriteText dot; private readonly OsuSpriteText pageTitle; - [CanBeNull] - public IOnlinePlaySubScreen Screen + public IOnlinePlaySubScreen? Screen { - set => pageTitle.Text = value?.ShortTitle.Titleize() ?? string.Empty; + set + { + pageTitle.Text = value?.ShortTitle.Titleize() ?? default(LocalisableString); + dot.Alpha = pageTitle.Text == default ? 0 : 1; + } } - public MultiHeaderTitle(string mainTitle) + public MultiHeaderTitle(LocalisableString mainTitle) { AutoSizeAxes = Axes.Both; @@ -82,14 +86,14 @@ namespace osu.Game.Screens.OnlinePlay Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Font = OsuFont.TorusAlternate.With(size: 24), - Text = "·" + Text = "·", + Alpha = 0, }, pageTitle = new OsuSpriteText { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Font = OsuFont.TorusAlternate.With(size: 24), - Text = "Lounge" } } }, diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RankRangePill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RankRangePill.cs index adfc44fbd4..09aafa415a 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RankRangePill.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RankRangePill.cs @@ -1,23 +1,25 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; using osu.Game.Graphics.Containers; -using osu.Game.Screens.OnlinePlay.Multiplayer; +using osu.Game.Online.Multiplayer; using osuTK; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { - public partial class RankRangePill : MultiplayerRoomComposite + public partial class RankRangePill : CompositeDrawable { - private OsuTextFlowContainer rankFlow; + private OsuTextFlowContainer rankFlow = null!; + + [Resolved] + private MultiplayerClient client { get; set; } = null!; public RankRangePill() { @@ -55,20 +57,28 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components }; } - protected override void OnRoomUpdated() + protected override void LoadComplete() { - base.OnRoomUpdated(); + base.LoadComplete(); + client.RoomUpdated += onRoomUpdated; + updateState(); + } + + private void onRoomUpdated() => Scheduler.AddOnce(updateState); + + private void updateState() + { rankFlow.Clear(); - if (Room == null || Room.Users.All(u => u.User == null)) + if (client.Room == null || client.Room.Users.All(u => u.User == null)) { rankFlow.AddText("-"); return; } - int minRank = Room.Users.Select(u => u.User?.Statistics.GlobalRank ?? 0).DefaultIfEmpty(0).Min(); - int maxRank = Room.Users.Select(u => u.User?.Statistics.GlobalRank ?? 0).DefaultIfEmpty(0).Max(); + int minRank = client.Room.Users.Select(u => u.User?.Statistics.GlobalRank ?? 0).DefaultIfEmpty(0).Min(); + int maxRank = client.Room.Users.Select(u => u.User?.Statistics.GlobalRank ?? 0).DefaultIfEmpty(0).Max(); rankFlow.AddText("#"); rankFlow.AddText(minRank.ToString("#,0"), s => s.Font = s.Font.With(weight: FontWeight.Bold)); @@ -78,5 +88,13 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components rankFlow.AddText("#"); rankFlow.AddText(maxRank.ToString("#,0"), s => s.Font = s.Font.With(weight: FontWeight.Bold)); } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + client.RoomUpdated -= onRoomUpdated; + } } } diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusPill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusPill.cs index aae82b6721..96d698a184 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusPill.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusPill.cs @@ -1,13 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; using osu.Game.Online.Rooms; -using osu.Game.Online.Rooms.RoomStatuses; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { @@ -36,18 +34,10 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components private void updateDisplay() { - RoomStatus status = getDisplayStatus(); + RoomStatus status = Status.Value; Pill.Background.FadeColour(status.GetAppropriateColour(colours), 100); TextFlow.Text = status.Message; } - - private RoomStatus getDisplayStatus() - { - if (EndDate.Value < DateTimeOffset.Now) - return new RoomStatusEnded(); - - return Status.Value; - } } } diff --git a/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs b/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs index 66bbf92e58..fed47e847a 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs @@ -248,21 +248,21 @@ namespace osu.Game.Screens.OnlinePlay.Lounge { base.LoadComplete(); - ScheduleAfterChildren(() => GetContainingInputManager().ChangeFocus(passwordTextBox)); + ScheduleAfterChildren(() => GetContainingFocusManager()!.ChangeFocus(passwordTextBox)); passwordTextBox.OnCommit += (_, _) => performJoin(); } private void performJoin() { lounge?.Join(room, passwordTextBox.Text, null, joinFailed); - GetContainingInputManager().TriggerFocusContention(passwordTextBox); + GetContainingFocusManager()?.TriggerFocusContention(passwordTextBox); } private void joinFailed(string error) => Schedule(() => { passwordTextBox.Text = string.Empty; - GetContainingInputManager().ChangeFocus(passwordTextBox); + GetContainingFocusManager()!.ChangeFocus(passwordTextBox); errorText.Text = error; errorText diff --git a/osu.Game/Screens/OnlinePlay/Match/RoomModSelectOverlay.cs b/osu.Game/Screens/OnlinePlay/Match/RoomModSelectOverlay.cs index 55e077df0f..6a856d8d72 100644 --- a/osu.Game/Screens/OnlinePlay/Match/RoomModSelectOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Match/RoomModSelectOverlay.cs @@ -16,8 +16,7 @@ namespace osu.Game.Screens.OnlinePlay.Match { public partial class RoomModSelectOverlay : UserModSelectOverlay { - [Resolved] - private IBindable selectedItem { get; set; } = null!; + public Bindable SelectedItem { get; } = new Bindable(); [Resolved] private RulesetStore rulesets { get; set; } = null!; @@ -33,7 +32,7 @@ namespace osu.Game.Screens.OnlinePlay.Match { base.LoadComplete(); - selectedItem.BindValueChanged(v => + SelectedItem.BindValueChanged(v => { roomRequiredMods.Clear(); diff --git a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs index 97fbb83992..7c8931c04e 100644 --- a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs @@ -17,12 +17,9 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; -using osu.Framework.Input.Bindings; -using osu.Framework.Input.Events; using osu.Framework.Screens; using osu.Game.Audio; using osu.Game.Beatmaps; -using osu.Game.Input.Bindings; using osu.Game.Online.API; using osu.Game.Online.Rooms; using osu.Game.Overlays; @@ -83,6 +80,9 @@ namespace osu.Game.Screens.OnlinePlay.Match [Resolved(canBeNull: true)] protected OnlinePlayScreen ParentScreen { get; private set; } + [Resolved] + private PreviewTrackManager previewTrackManager { get; set; } = null!; + [Cached] private readonly OnlinePlayBeatmapAvailabilityTracker beatmapAvailabilityTracker = new OnlinePlayBeatmapAvailabilityTracker(); @@ -243,6 +243,7 @@ namespace osu.Game.Screens.OnlinePlay.Match LoadComponent(UserModsSelectOverlay = new RoomModSelectOverlay { + SelectedItem = { BindTarget = SelectedItem }, SelectedMods = { BindTarget = UserMods }, IsValidMod = _ => false }); @@ -455,7 +456,7 @@ namespace osu.Game.Screens.OnlinePlay.Match // Retrieve the corresponding local beatmap, since we can't directly use the playlist's beatmap info var localBeatmap = beatmap == null ? null : beatmapManager.QueryBeatmap(b => b.OnlineID == beatmap.OnlineID); - UserModsSelectOverlay.Beatmap = Beatmap.Value = beatmapManager.GetWorkingBeatmap(localBeatmap); + UserModsSelectOverlay.Beatmap.Value = Beatmap.Value = beatmapManager.GetWorkingBeatmap(localBeatmap); } protected virtual void UpdateMods() @@ -485,6 +486,8 @@ namespace osu.Game.Screens.OnlinePlay.Match { UserModsSelectOverlay.Hide(); endHandlingTrack(); + + previewTrackManager.StopAnyPlaying(this); } private void endHandlingTrack() @@ -531,22 +534,6 @@ namespace osu.Game.Screens.OnlinePlay.Match /// The room to change the settings of. protected abstract RoomSettingsOverlay CreateRoomSettingsOverlay(Room room); - public partial class UserModSelectButton : PurpleRoundedButton, IKeyBindingHandler - { - public bool OnPressed(KeyBindingPressEvent e) - { - if (e.Action == GlobalAction.ToggleModSelection && !e.Repeat) - { - TriggerClick(); - return true; - } - - return false; - } - - public void OnReleased(KeyBindingReleaseEvent e) { } - } - protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game/Screens/OnlinePlay/Match/UserModSelectButton.cs b/osu.Game/Screens/OnlinePlay/Match/UserModSelectButton.cs new file mode 100644 index 0000000000..f3ea82be99 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Match/UserModSelectButton.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. + +#nullable disable +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Input.Bindings; +using osu.Game.Screens.OnlinePlay.Match.Components; + +namespace osu.Game.Screens.OnlinePlay.Match +{ + public partial class UserModSelectButton : PurpleRoundedButton, IKeyBindingHandler + { + public bool OnPressed(KeyBindingPressEvent e) + { + if (e.Action == GlobalAction.ToggleModSelection && !e.Repeat) + { + TriggerClick(); + return true; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) { } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayChatDisplay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayChatDisplay.cs index d003110039..d4483044e0 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayChatDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayChatDisplay.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Game.Input.Bindings; +using osu.Game.Localisation; using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay.Match.Components; using osu.Game.Screens.Play; @@ -22,9 +23,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer [CanBeNull] private ILocalUserPlayInfo localUserInfo { get; set; } - private readonly IBindable localUserPlaying = new Bindable(); + private readonly IBindable localUserPlaying = new Bindable(); - public override bool PropagatePositionalInputSubTree => !localUserPlaying.Value; + public override bool PropagatePositionalInputSubTree => localUserPlaying.Value != LocalUserPlayingState.Playing; public Bindable Expanded = new Bindable(); @@ -41,7 +42,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer Background.Alpha = 0.2f; - TextBox.FocusLost = () => expandedFromTextBoxFocus.Value = false; + TextBox.PlaceholderText = ChatStrings.InGameInputPlaceholder; + TextBox.Focus = () => TextBox.PlaceholderText = Resources.Localisation.Web.ChatStrings.InputPlaceholder; + TextBox.FocusLost = () => + { + TextBox.PlaceholderText = ChatStrings.InGameInputPlaceholder; + expandedFromTextBoxFocus.Value = false; + }; } protected override bool OnHover(HoverEvent e) => true; // use UI mouse cursor. @@ -51,7 +58,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer base.LoadComplete(); if (localUserInfo != null) - localUserPlaying.BindTo(localUserInfo.IsPlaying); + localUserPlaying.BindTo(localUserInfo.PlayingState); localUserPlaying.BindValueChanged(playing => { @@ -60,7 +67,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer TextBox.HoldFocus = false; // only hold focus (after sending a message) during breaks - TextBox.ReleaseFocusOnCommit = playing.NewValue; + TextBox.ReleaseFocusOnCommit = playing.NewValue == LocalUserPlayingState.Playing; }, true); Expanded.BindValueChanged(_ => updateExpandedState(), true); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs index ba3508b24f..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/MultiplayerMatchSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs index 66acd6d1b0..5446211ced 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs @@ -486,16 +486,16 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match Off = 0, [Description("30 seconds")] - Seconds_30 = 30, + Seconds30 = 30, [Description("1 minute")] - Seconds_60 = 60, + Seconds60 = 60, [Description("3 minutes")] - Seconds_180 = 180, + Seconds180 = 180, [Description("5 minutes")] - Seconds_300 = 300 + Seconds300 = 300 } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs index 1d308ed39c..92edc9b979 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs @@ -1,27 +1,39 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - +using System.Threading; 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; using osu.Game.Graphics; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; 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; } + private OngoingOperationTracker ongoingOperationTracker { get; set; } = null!; [Resolved] - private OsuColour colours { get; set; } + private OsuColour colours { get; set; } = null!; - private IBindable operationInProgress; + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + [Resolved] + private IBindable currentItem { get; set; } = null!; + + private IBindable operationInProgress = null!; private readonly RoundedButton button; @@ -40,28 +52,35 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { var clickOperation = ongoingOperationTracker.BeginOperation(); - Client.ToggleSpectate().ContinueWith(_ => endOperation()); + client.ToggleSpectate().ContinueWith(_ => endOperation()); void endOperation() => clickOperation?.Dispose(); } [BackgroundDependencyLoader] - private void load() + private void load(OsuConfigManager config) { operationInProgress = ongoingOperationTracker.InProgress.GetBoundCopy(); operationInProgress.BindValueChanged(_ => updateState()); + + automaticallyDownload = config.GetBindable(OsuSetting.AutomaticallyDownloadMissingBeatmaps); + automaticallyDownload.BindValueChanged(_ => Scheduler.AddOnce(checkForAutomaticDownload)); } - protected override void OnRoomUpdated() + protected override void LoadComplete() { - base.OnRoomUpdated(); + base.LoadComplete(); + 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"; @@ -74,9 +93,75 @@ 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); + } + + #region Automatic download handling + + [Resolved] + private BeatmapLookupCache beatmapLookupCache { get; set; } = null!; + + [Resolved] + private BeatmapModelDownloader beatmapDownloader { get; set; } = null!; + + [Resolved] + private BeatmapManager beatmaps { get; set; } = null!; + + private Bindable automaticallyDownload = null!; + + private CancellationTokenSource? downloadCheckCancellation; + + private void checkForAutomaticDownload() + { + PlaylistItem? item = currentItem.Value; + + downloadCheckCancellation?.Cancel(); + + if (item == null) + return; + + if (!automaticallyDownload.Value) + return; + + // While we can support automatic downloads when not spectating, there are some usability concerns. + // - In host rotate mode, this could potentially be unwanted by some users (even though they want automatic downloads everywhere else). + // - When first joining a room, the expectation should be that the user is checking out the room, and they may not immediately want to download the selected beatmap. + // + // 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) + 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(item.Beatmap.OnlineID, (downloadCheckCancellation = new CancellationTokenSource()).Token) + .ContinueWith(resolved => Schedule(() => + { + var beatmapSet = resolved.GetResultSafely()?.BeatmapSet; + + if (beatmapSet == null) + return; + + if (beatmaps.IsAvailableLocally(new BeatmapSetInfo { OnlineID = beatmapSet.OnlineID })) + return; + + beatmapDownloader.Download(beatmapSet); + })); + } + + #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/Multiplayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs index 7d27725775..bf316bb3da 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs @@ -108,7 +108,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer base.Dispose(isDisposing); if (client.IsNotNull()) + { client.RoomUpdated -= onRoomUpdated; + client.GameplayAborted -= onGameplayAborted; + } } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs index e560c5ca5d..a50f3440f5 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs @@ -158,7 +158,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer if (!string.IsNullOrEmpty(message)) Logger.Log(message, LoggingTarget.Runtime, LogLevel.Important); - Schedule(() => PerformExit(false)); + Schedule(() => PerformExit()); } private void onGameplayStarted() => Scheduler.Add(() => diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerResultsScreen.cs index 6ed75508dc..c439df82a6 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerResultsScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerResultsScreen.cs @@ -7,7 +7,7 @@ using osu.Game.Screens.OnlinePlay.Playlists; namespace osu.Game.Screens.OnlinePlay.Multiplayer { - public partial class MultiplayerResultsScreen : PlaylistsResultsScreen + public partial class MultiplayerResultsScreen : PlaylistItemUserResultsScreen { public MultiplayerResultsScreen(ScoreInfo score, long roomId, PlaylistItem playlistItem) : base(score, roomId, playlistItem) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs deleted file mode 100644 index ee5c84bf40..0000000000 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs +++ /dev/null @@ -1,125 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using JetBrains.Annotations; -using osu.Framework.Allocation; -using osu.Game.Online.Multiplayer; -using osu.Game.Online.Rooms; - -namespace osu.Game.Screens.OnlinePlay.Multiplayer -{ - public abstract partial class MultiplayerRoomComposite : OnlinePlayComposite - { - [CanBeNull] - protected MultiplayerRoom Room => Client.Room; - - [Resolved] - protected MultiplayerClient Client { get; private set; } - - protected override void LoadComplete() - { - base.LoadComplete(); - - Client.RoomUpdated += invokeOnRoomUpdated; - Client.LoadRequested += invokeOnRoomLoadRequested; - Client.UserLeft += invokeUserLeft; - Client.UserKicked += invokeUserKicked; - Client.UserJoined += invokeUserJoined; - Client.ItemAdded += invokeItemAdded; - Client.ItemRemoved += invokeItemRemoved; - Client.ItemChanged += invokeItemChanged; - - OnRoomUpdated(); - } - - private void invokeOnRoomUpdated() => Scheduler.AddOnce(OnRoomUpdated); - private void invokeUserJoined(MultiplayerRoomUser user) => Scheduler.Add(() => UserJoined(user)); - private void invokeUserKicked(MultiplayerRoomUser user) => Scheduler.Add(() => UserKicked(user)); - private void invokeUserLeft(MultiplayerRoomUser user) => Scheduler.Add(() => UserLeft(user)); - private void invokeItemAdded(MultiplayerPlaylistItem item) => Schedule(() => PlaylistItemAdded(item)); - private void invokeItemRemoved(long item) => Schedule(() => PlaylistItemRemoved(item)); - private void invokeItemChanged(MultiplayerPlaylistItem item) => Schedule(() => PlaylistItemChanged(item)); - private void invokeOnRoomLoadRequested() => Scheduler.AddOnce(OnRoomLoadRequested); - - /// - /// Invoked when a user has joined the room. - /// - /// The user. - protected virtual void UserJoined(MultiplayerRoomUser user) - { - } - - /// - /// Invoked when a user has been kicked from the room (including the local user). - /// - /// The user. - protected virtual void UserKicked(MultiplayerRoomUser user) - { - } - - /// - /// Invoked when a user has left the room. - /// - /// The user. - protected virtual void UserLeft(MultiplayerRoomUser user) - { - } - - /// - /// Invoked when a playlist item is added to the room. - /// - /// The added playlist item. - protected virtual void PlaylistItemAdded(MultiplayerPlaylistItem item) - { - } - - /// - /// Invoked when a playlist item is removed from the room. - /// - /// The ID of the removed playlist item. - protected virtual void PlaylistItemRemoved(long item) - { - } - - /// - /// Invoked when a playlist item is changed in the room. - /// - /// The new playlist item, with an existing item's ID. - protected virtual void PlaylistItemChanged(MultiplayerPlaylistItem item) - { - } - - /// - /// Invoked when any change occurs to the multiplayer room. - /// - protected virtual void OnRoomUpdated() - { - } - - /// - /// Invoked when the room requests the local user to load into gameplay. - /// - protected virtual void OnRoomLoadRequested() - { - } - - protected override void Dispose(bool isDisposing) - { - if (Client != null) - { - Client.RoomUpdated -= invokeOnRoomUpdated; - Client.LoadRequested -= invokeOnRoomLoadRequested; - Client.UserLeft -= invokeUserLeft; - Client.UserKicked -= invokeUserKicked; - Client.UserJoined -= invokeUserJoined; - Client.ItemAdded -= invokeItemAdded; - Client.ItemRemoved -= invokeItemRemoved; - Client.ItemChanged -= invokeItemChanged; - } - - base.Dispose(isDisposing); - } - } -} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomSounds.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomSounds.cs index 90595bc33b..d53e485c86 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomSounds.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomSounds.cs @@ -1,23 +1,26 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - +using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; -using osu.Framework.Bindables; -using osu.Game.Online.API.Requests.Responses; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics.Containers; using osu.Game.Online.Multiplayer; namespace osu.Game.Screens.OnlinePlay.Multiplayer { - public partial class MultiplayerRoomSounds : MultiplayerRoomComposite + public partial class MultiplayerRoomSounds : CompositeDrawable { - private Sample hostChangedSample; - private Sample userJoinedSample; - private Sample userLeftSample; - private Sample userKickedSample; + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + private Sample? hostChangedSample; + private Sample? userJoinedSample; + private Sample? userLeftSample; + private Sample? userKickedSample; + private MultiplayerRoomUser? host; [BackgroundDependencyLoader] private void load(AudioManager audio) @@ -32,36 +35,47 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { base.LoadComplete(); - Host.BindValueChanged(hostChanged); + client.RoomUpdated += onRoomUpdated; + client.UserJoined += onUserJoined; + client.UserLeft += onUserLeft; + client.UserKicked += onUserKicked; + updateState(); } - protected override void UserJoined(MultiplayerRoomUser user) + private void onRoomUpdated() => Scheduler.AddOnce(updateState); + + private void updateState() { - base.UserJoined(user); + if (EqualityComparer.Default.Equals(host, client.Room?.Host)) + return; - Scheduler.AddOnce(() => userJoinedSample?.Play()); - } - - protected override void UserLeft(MultiplayerRoomUser user) - { - base.UserLeft(user); - - Scheduler.AddOnce(() => userLeftSample?.Play()); - } - - protected override void UserKicked(MultiplayerRoomUser user) - { - base.UserKicked(user); - - Scheduler.AddOnce(() => userKickedSample?.Play()); - } - - private void hostChanged(ValueChangedEvent value) - { // only play sound when the host changes from an already-existing host. - if (value.OldValue == null) return; + if (host != null) + Scheduler.AddOnce(() => hostChangedSample?.Play()); - Scheduler.AddOnce(() => hostChangedSample?.Play()); + host = client.Room?.Host; + } + + private void onUserJoined(MultiplayerRoomUser user) + => Scheduler.AddOnce(() => userJoinedSample?.Play()); + + private void onUserLeft(MultiplayerRoomUser user) + => Scheduler.AddOnce(() => userLeftSample?.Play()); + + private void onUserKicked(MultiplayerRoomUser user) + => Scheduler.AddOnce(() => userKickedSample?.Play()); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + { + client.RoomUpdated -= onRoomUpdated; + client.UserJoined -= onUserJoined; + client.UserLeft -= onUserLeft; + client.UserKicked -= onUserKicked; + } } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs index c79c210e30..7e42b18240 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; @@ -30,7 +31,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants { - public partial class ParticipantPanel : MultiplayerRoomComposite, IHasContextMenu + public partial class ParticipantPanel : CompositeDrawable, IHasContextMenu { public readonly MultiplayerRoomUser User; @@ -40,6 +41,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants [Resolved] private IRulesetStore rulesets { get; set; } = null!; + [Resolved] + private MultiplayerClient client { get; set; } = null!; + private SpriteIcon crown = null!; private OsuSpriteText userRankText = null!; @@ -171,23 +175,31 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants Origin = Anchor.Centre, Alpha = 0, Margin = new MarginPadding(4), - Action = () => Client.KickUser(User.UserID).FireAndForget(), + Action = () => client.KickUser(User.UserID).FireAndForget(), }, }, } }; } - protected override void OnRoomUpdated() + protected override void LoadComplete() { - base.OnRoomUpdated(); + base.LoadComplete(); - if (Room == null || Client.LocalUser == null) + client.RoomUpdated += onRoomUpdated; + updateState(); + } + + private void onRoomUpdated() => Scheduler.AddOnce(updateState); + + private void updateState() + { + if (client.Room == null || client.LocalUser == null) return; const double fade_time = 50; - var currentItem = Playlist.GetCurrentItem(); + MultiplayerPlaylistItem? currentItem = client.Room.GetCurrentItem(); Ruleset? ruleset = currentItem != null ? rulesets.GetRuleset(currentItem.RulesetID)?.CreateInstance() : null; int? currentModeRank = ruleset != null ? User.User?.RulesetsStatistics?.GetValueOrDefault(ruleset.ShortName)?.GlobalRank : null; @@ -200,8 +212,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants else userModsDisplay.FadeOut(fade_time); - kickButton.Alpha = Client.IsHost && !User.Equals(Client.LocalUser) ? 1 : 0; - crown.Alpha = Room.Host?.Equals(User) == true ? 1 : 0; + kickButton.Alpha = client.IsHost && !User.Equals(client.LocalUser) ? 1 : 0; + crown.Alpha = client.Room.Host?.Equals(User) == true ? 1 : 0; // If the mods are updated at the end of the frame, the flow container will skip a reflow cycle: https://github.com/ppy/osu-framework/issues/4187 // This looks particularly jarring here, so re-schedule the update to that start of our frame as a fix. @@ -215,7 +227,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants { get { - if (Room == null) + if (client.Room == null) return null; // If the local user is targetted. @@ -223,7 +235,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants return null; // If the local user is not the host of the room. - if (Room.Host?.UserID != api.LocalUser.Value.Id) + if (client.Room.Host?.UserID != api.LocalUser.Value.Id) return null; int targetUser = User.UserID; @@ -233,23 +245,31 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants new OsuMenuItem("Give host", MenuItemType.Standard, () => { // Ensure the local user is still host. - if (!Client.IsHost) + if (!client.IsHost) return; - Client.TransferHost(targetUser).FireAndForget(); + client.TransferHost(targetUser).FireAndForget(); }), new OsuMenuItem("Kick", MenuItemType.Destructive, () => { // Ensure the local user is still host. - if (!Client.IsHost) + if (!client.IsHost) return; - Client.KickUser(targetUser).FireAndForget(); + client.KickUser(targetUser).FireAndForget(); }) }; } } + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + client.RoomUpdated -= onRoomUpdated; + } + public partial class KickButton : IconButton { public KickButton() diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs index 6a7a3758c3..a9d7f4ab52 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs @@ -1,24 +1,24 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics.Containers; +using osu.Game.Online.Multiplayer; using osuTK; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants { - public partial class ParticipantsList : MultiplayerRoomComposite + public partial class ParticipantsList : CompositeDrawable { - private FillFlowContainer panels; + private FillFlowContainer panels = null!; + private ParticipantPanel? currentHostPanel; - [CanBeNull] - private ParticipantPanel currentHostPanel; + [Resolved] + private MultiplayerClient client { get; set; } = null!; [BackgroundDependencyLoader] private void load() @@ -37,11 +37,19 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants }; } - protected override void OnRoomUpdated() + protected override void LoadComplete() { - base.OnRoomUpdated(); + base.LoadComplete(); - if (Room == null) + client.RoomUpdated += onRoomUpdated; + updateState(); + } + + private void onRoomUpdated() => Scheduler.AddOnce(updateState); + + private void updateState() + { + if (client.Room == null) panels.Clear(); else { @@ -49,15 +57,15 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants foreach (var p in panels) { // Note that we *must* use reference equality here, as this call is scheduled and a user may have left and joined since it was last run. - if (Room.Users.All(u => !ReferenceEquals(p.User, u))) + if (client.Room.Users.All(u => !ReferenceEquals(p.User, u))) p.Expire(); } // Add panels for all users new to the room. - foreach (var user in Room.Users.Except(panels.Select(p => p.User))) + foreach (var user in client.Room.Users.Except(panels.Select(p => p.User))) panels.Add(new ParticipantPanel(user)); - if (currentHostPanel == null || !currentHostPanel.User.Equals(Room.Host)) + if (currentHostPanel == null || !currentHostPanel.User.Equals(client.Room.Host)) { // Reset position of previous host back to normal, if one existing. if (currentHostPanel != null && panels.Contains(currentHostPanel)) @@ -66,9 +74,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants currentHostPanel = null; // Change position of new host to display above all participants. - if (Room.Host != null) + if (client.Room.Host != null) { - currentHostPanel = panels.SingleOrDefault(u => u.User.Equals(Room.Host)); + currentHostPanel = panels.SingleOrDefault(u => u.User.Equals(client.Room.Host)); if (currentHostPanel != null) panels.SetLayoutPosition(currentHostPanel, -1); @@ -76,5 +84,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants } } } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + client.RoomUpdated -= onRoomUpdated; + } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs index fe57ad26a5..bd9511d50d 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs @@ -1,12 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; @@ -20,27 +19,26 @@ using osuTK.Graphics; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants { - internal partial class TeamDisplay : MultiplayerRoomComposite + internal partial class TeamDisplay : CompositeDrawable { private readonly MultiplayerRoomUser user; - private Drawable box; - - private Sample sampleTeamSwap; + [Resolved] + private OsuColour colours { get; set; } = null!; [Resolved] - private OsuColour colours { get; set; } + private MultiplayerClient client { get; set; } = null!; - private OsuClickableContainer clickableContent; + private OsuClickableContainer clickableContent = null!; + private Drawable box = null!; + private Sample? sampleTeamSwap; public TeamDisplay(MultiplayerRoomUser user) { this.user = user; RelativeSizeAxes = Axes.Y; - AutoSizeAxes = Axes.X; - Margin = new MarginPadding { Horizontal = 3 }; } @@ -71,7 +69,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants } }; - if (Client.LocalUser?.Equals(user) == true) + if (client.LocalUser?.Equals(user) == true) { clickableContent.Action = changeTeam; clickableContent.TooltipText = "Change team"; @@ -80,23 +78,31 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants sampleTeamSwap = audio.Samples.Get(@"Multiplayer/team-swap"); } + protected override void LoadComplete() + { + base.LoadComplete(); + + client.RoomUpdated += onRoomUpdated; + updateState(); + } + private void changeTeam() { - Client.SendMatchRequest(new ChangeTeamRequest + client.SendMatchRequest(new ChangeTeamRequest { - TeamID = ((Client.LocalUser?.MatchState as TeamVersusUserState)?.TeamID + 1) % 2 ?? 0, + TeamID = ((client.LocalUser?.MatchState as TeamVersusUserState)?.TeamID + 1) % 2 ?? 0, }).FireAndForget(); } public int? DisplayedTeam { get; private set; } - protected override void OnRoomUpdated() - { - base.OnRoomUpdated(); + private void onRoomUpdated() => Scheduler.AddOnce(updateState); + private void updateState() + { // we don't have a way of knowing when an individual user's state has updated, so just handle on RoomUpdated for now. - var userRoomState = Room?.Users.FirstOrDefault(u => u.Equals(user))?.MatchState; + var userRoomState = client.Room?.Users.FirstOrDefault(u => u.Equals(user))?.MatchState; const double duration = 400; @@ -138,5 +144,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants return colours.Blue; } } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + client.RoomUpdated -= onRoomUpdated; + } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs index e2159f0e3b..cb00763e6b 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs @@ -244,10 +244,19 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate playerArea.LoadScore(spectatorGameplayState.Score); }); - protected override void FailGameplay(int userId) + protected override void FailGameplay(int userId) => Schedule(() => { // We probably want to visualise this in the future. - } + + var instance = instances.Single(i => i.UserId == userId); + syncManager.RemoveManagedClock(instance.SpectatorPlayerClock); + }); + + protected override void PassGameplay(int userId) => Schedule(() => + { + var instance = instances.Single(i => i.UserId == userId); + syncManager.RemoveManagedClock(instance.SpectatorPlayerClock); + }); protected override void QuitGameplay(int userId) => Schedule(() => { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/SpectatorSyncManager.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/SpectatorSyncManager.cs index fd61b60fe4..1638102089 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/SpectatorSyncManager.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/SpectatorSyncManager.cs @@ -76,6 +76,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate public void RemoveManagedClock(SpectatorPlayerClock clock) { playerClocks.Remove(clock); + Logger.Log($"Removing managed clock from {nameof(SpectatorSyncManager)} ({playerClocks.Count} remain)"); clock.IsRunning = false; } @@ -130,7 +131,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate } /// - /// Updates the catchup states of all player clocks clocks. + /// Updates the catchup states of all player clocks. /// private void updatePlayerCatchup() { @@ -176,7 +177,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate /// private void updateMasterState() { - MasterClockState newState = playerClocks.Any(s => !s.IsCatchingUp) ? MasterClockState.Synchronised : MasterClockState.TooFarAhead; + // Clocks are removed as players complete the beatmap. + // Once there are no clocks we want to make sure the track plays out to the end. + MasterClockState newState = playerClocks.Count == 0 || playerClocks.Any(s => !s.IsCatchingUp) ? MasterClockState.Synchronised : MasterClockState.TooFarAhead; if (masterState == newState) return; diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlayComposite.cs b/osu.Game/Screens/OnlinePlay/OnlinePlayComposite.cs index ff536a65c4..5be5c4b4f4 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlayComposite.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlayComposite.cs @@ -68,6 +68,9 @@ namespace osu.Game.Screens.OnlinePlay [Resolved(typeof(Room))] public Bindable UserScore { get; private set; } + [Resolved(typeof(Room))] + protected Bindable StartDate { get; private set; } + [Resolved(typeof(Room))] protected Bindable EndDate { get; private set; } diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs index 9de458b5c6..1b7041c9bb 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs @@ -6,7 +6,6 @@ using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Logging; using osu.Framework.Screens; @@ -36,7 +35,7 @@ namespace osu.Game.Screens.OnlinePlay protected LoungeSubScreen Lounge { get; private set; } - private MultiplayerWaveContainer waves; + private OnlinePlayScreenWaveContainer waves; private ScreenStack screenStack; [Cached(Type = typeof(IRoomManager))] @@ -63,7 +62,7 @@ namespace osu.Game.Screens.OnlinePlay [BackgroundDependencyLoader] private void load() { - InternalChild = waves = new MultiplayerWaveContainer + InternalChild = waves = new OnlinePlayScreenWaveContainer { RelativeSizeAxes = Axes.Both, Children = new Drawable[] @@ -100,6 +99,7 @@ namespace osu.Game.Screens.OnlinePlay Logger.Log($"{this} forcefully exiting due to loss of API connection"); // This is temporary since we don't currently have a way to force screens to be exited + // See also: `DailyChallenge.forcefullyExit()` if (this.IsCurrentScreen()) { while (this.IsCurrentScreen()) @@ -230,19 +230,6 @@ namespace osu.Game.Screens.OnlinePlay protected abstract LoungeSubScreen CreateLounge(); - private partial class MultiplayerWaveContainer : WaveContainer - { - protected override bool StartHidden => true; - - public MultiplayerWaveContainer() - { - FirstWaveColour = Color4Extensions.FromHex(@"654d8c"); - SecondWaveColour = Color4Extensions.FromHex(@"554075"); - ThirdWaveColour = Color4Extensions.FromHex(@"44325e"); - FourthWaveColour = Color4Extensions.FromHex(@"392850"); - } - } - ScreenStack IHasSubScreenStack.SubScreenStack => screenStack; } } diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlayScreenWaveContainer.cs b/osu.Game/Screens/OnlinePlay/OnlinePlayScreenWaveContainer.cs new file mode 100644 index 0000000000..7898e0845a --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/OnlinePlayScreenWaveContainer.cs @@ -0,0 +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.Extensions.Color4Extensions; +using osu.Game.Graphics.Containers; + +namespace osu.Game.Screens.OnlinePlay +{ + public partial class OnlinePlayScreenWaveContainer : WaveContainer + { + protected override bool StartHidden => true; + + public OnlinePlayScreenWaveContainer() + { + FirstWaveColour = Color4Extensions.FromHex(@"654d8c"); + SecondWaveColour = Color4Extensions.FromHex(@"554075"); + ThirdWaveColour = Color4Extensions.FromHex(@"44325e"); + FourthWaveColour = Color4Extensions.FromHex(@"392850"); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs index 622ffddba6..a8dfece916 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs @@ -173,9 +173,9 @@ namespace osu.Game.Screens.OnlinePlay IsValidMod = IsValidMod }; - protected override IEnumerable<(FooterButton, OverlayContainer?)> CreateFooterButtons() + protected override IEnumerable<(FooterButton, OverlayContainer?)> CreateSongSelectFooterButtons() { - var baseButtons = base.CreateFooterButtons().ToList(); + var baseButtons = base.CreateSongSelectFooterButtons().ToList(); var freeModsButton = new FooterButtonFreeMods(freeModSelectOverlay) { Current = FreeMods }; baseButtons.Insert(baseButtons.FindIndex(b => b.Item1 is FooterButtonMods) + 1, (freeModsButton, freeModSelectOverlay)); diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemResultsScreen.cs similarity index 80% rename from osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs rename to osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemResultsScreen.cs index fdb83b5ae8..dc06b88823 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemResultsScreen.cs @@ -17,10 +17,10 @@ using osu.Game.Screens.Ranking; namespace osu.Game.Screens.OnlinePlay.Playlists { - public partial class PlaylistsResultsScreen : ResultsScreen + public abstract partial class PlaylistItemResultsScreen : ResultsScreen { - private readonly long roomId; - private readonly PlaylistItem playlistItem; + protected readonly long RoomId; + protected readonly PlaylistItem PlaylistItem; protected LoadingSpinner LeftSpinner { get; private set; } = null!; protected LoadingSpinner CentreSpinner { get; private set; } = null!; @@ -30,19 +30,19 @@ namespace osu.Game.Screens.OnlinePlay.Playlists private MultiplayerScores? lowerScores; [Resolved] - private IAPIProvider api { get; set; } = null!; + protected IAPIProvider API { get; private set; } = null!; [Resolved] - private ScoreManager scoreManager { get; set; } = null!; + protected ScoreManager ScoreManager { get; private set; } = null!; [Resolved] - private RulesetStore rulesets { get; set; } = null!; + protected RulesetStore Rulesets { get; private set; } = null!; - public PlaylistsResultsScreen(ScoreInfo? score, long roomId, PlaylistItem playlistItem) + protected PlaylistItemResultsScreen(ScoreInfo? score, long roomId, PlaylistItem playlistItem) : base(score) { - this.roomId = roomId; - this.playlistItem = playlistItem; + RoomId = roomId; + PlaylistItem = playlistItem; } [BackgroundDependencyLoader] @@ -74,13 +74,15 @@ namespace osu.Game.Screens.OnlinePlay.Playlists }); } - protected override APIRequest FetchScores(Action> scoresCallback) + protected abstract APIRequest CreateScoreRequest(); + + protected sealed override APIRequest FetchScores(Action> scoresCallback) { // This performs two requests: - // 1. A request to show the user's score (and scores around). + // 1. A request to show the relevant score (and scores around). // 2. If that fails, a request to index the room starting from the highest score. - var userScoreReq = new ShowPlaylistUserScoreRequest(roomId, playlistItem.ID, api.LocalUser.Value.Id); + var userScoreReq = CreateScoreRequest(); userScoreReq.Success += userScore => { @@ -111,11 +113,15 @@ namespace osu.Game.Screens.OnlinePlay.Playlists setPositions(lowerScores, userScore.Position.Value, 1); } - performSuccessCallback(scoresCallback, allScores); + Schedule(() => + { + PerformSuccessCallback(scoresCallback, allScores); + hideLoadingSpinners(); + }); }; // On failure, fallback to a normal index. - userScoreReq.Failure += _ => api.Queue(createIndexRequest(scoresCallback)); + userScoreReq.Failure += _ => API.Queue(createIndexRequest(scoresCallback)); return userScoreReq; } @@ -147,8 +153,8 @@ namespace osu.Game.Screens.OnlinePlay.Playlists private APIRequest createIndexRequest(Action> scoresCallback, MultiplayerScores? pivot = null) { var indexReq = pivot != null - ? new IndexPlaylistScoresRequest(roomId, playlistItem.ID, pivot.Cursor, pivot.Params) - : new IndexPlaylistScoresRequest(roomId, playlistItem.ID); + ? new IndexPlaylistScoresRequest(RoomId, PlaylistItem.ID, pivot.Cursor, pivot.Params) + : new IndexPlaylistScoresRequest(RoomId, PlaylistItem.ID); indexReq.Success += r => { @@ -163,7 +169,11 @@ namespace osu.Game.Screens.OnlinePlay.Playlists setPositions(r, pivot, -1); } - performSuccessCallback(scoresCallback, r.Scores, r); + Schedule(() => + { + PerformSuccessCallback(scoresCallback, r.Scores, r); + hideLoadingSpinners(r); + }); }; indexReq.Failure += _ => hideLoadingSpinners(pivot); @@ -177,26 +187,15 @@ namespace osu.Game.Screens.OnlinePlay.Playlists /// The callback to invoke with the final s. /// The s that were retrieved from s. /// An optional pivot around which the scores were retrieved. - private void performSuccessCallback(Action> callback, List scores, MultiplayerScores? pivot = null) => Schedule(() => + protected virtual ScoreInfo[] PerformSuccessCallback(Action> callback, List scores, MultiplayerScores? pivot = null) { - var scoreInfos = scores.Select(s => s.CreateScoreInfo(scoreManager, rulesets, playlistItem, Beatmap.Value.BeatmapInfo)).OrderByTotalScore().ToArray(); + var scoreInfos = scores.Select(s => s.CreateScoreInfo(ScoreManager, Rulesets, PlaylistItem, Beatmap.Value.BeatmapInfo)).OrderByTotalScore().ToArray(); - // Select a score if we don't already have one selected. - // Note: This is done before the callback so that the panel list centres on the selected score before panels are added (eliminating initial scroll). - if (SelectedScore.Value == null) - { - Schedule(() => - { - // Prefer selecting the local user's score, or otherwise default to the first visible score. - SelectedScore.Value = scoreInfos.FirstOrDefault(s => s.User.OnlineID == api.LocalUser.Value.Id) ?? scoreInfos.FirstOrDefault(); - }); - } + // Invoke callback to add the scores. + callback.Invoke(scoreInfos); - // Invoke callback to add the scores. Exclude the user's current score which was added previously. - callback.Invoke(scoreInfos.Where(s => s.OnlineID != Score?.OnlineID)); - - hideLoadingSpinners(pivot); - }); + return scoreInfos; + } private void hideLoadingSpinners(MultiplayerScores? pivot = null) { diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemScoreResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemScoreResultsScreen.cs new file mode 100644 index 0000000000..32be7f21b0 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemScoreResultsScreen.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Game.Online.API; +using osu.Game.Online.Rooms; +using osu.Game.Scoring; + +namespace osu.Game.Screens.OnlinePlay.Playlists +{ + /// + /// Shows a selected arbitrary score for a playlist item, with scores around included. + /// + public partial class PlaylistItemScoreResultsScreen : PlaylistItemResultsScreen + { + private readonly long scoreId; + + public PlaylistItemScoreResultsScreen(long roomId, PlaylistItem playlistItem, long scoreId) + : base(null, roomId, playlistItem) + { + this.scoreId = scoreId; + } + + protected override APIRequest CreateScoreRequest() => new ShowPlaylistScoreRequest(RoomId, PlaylistItem.ID, scoreId); + + protected override ScoreInfo[] PerformSuccessCallback(Action> callback, List scores, MultiplayerScores? pivot = null) + { + var scoreInfos = base.PerformSuccessCallback(callback, scores, pivot); + + Schedule(() => SelectedScore.Value ??= scoreInfos.SingleOrDefault(score => score.OnlineID == scoreId)); + + return scoreInfos; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemUserResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemUserResultsScreen.cs new file mode 100644 index 0000000000..e038cf3288 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemUserResultsScreen.cs @@ -0,0 +1,46 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Game.Online.API; +using osu.Game.Online.Rooms; +using osu.Game.Scoring; + +namespace osu.Game.Screens.OnlinePlay.Playlists +{ + /// + /// Shows the user's best score for a given playlist item, with scores around included. + /// + public partial class PlaylistItemUserResultsScreen : PlaylistItemResultsScreen + { + public PlaylistItemUserResultsScreen(ScoreInfo? score, long roomId, PlaylistItem playlistItem) + : base(score, roomId, playlistItem) + { + } + + protected override APIRequest CreateScoreRequest() => new ShowPlaylistUserScoreRequest(RoomId, PlaylistItem.ID, API.LocalUser.Value.Id); + + protected override ScoreInfo[] PerformSuccessCallback(Action> callback, List scores, MultiplayerScores? pivot = null) + { + var scoreInfos = scores.Select(s => s.CreateScoreInfo(ScoreManager, Rulesets, PlaylistItem, Beatmap.Value.BeatmapInfo)).OrderByTotalScore().ToArray(); + + // Select a score if we don't already have one selected. + // Note: This is done before the callback so that the panel list centres on the selected score before panels are added (eliminating initial scroll). + if (SelectedScore.Value == null) + { + Schedule(() => + { + // Prefer selecting the local user's score, or otherwise default to the first visible score. + SelectedScore.Value = scoreInfos.FirstOrDefault(s => s.User.OnlineID == API.LocalUser.Value.Id) ?? scoreInfos.FirstOrDefault(); + }); + } + + // Invoke callback to add the scores. Exclude the user's current score which was added previously. + callback.Invoke(scoreInfos.Where(s => s.OnlineID != Score?.OnlineID)); + + return scoreInfos; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Playlists/Playlists.cs b/osu.Game/Screens/OnlinePlay/Playlists/Playlists.cs index f1d2384c2f..9e615ffa98 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/Playlists.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/Playlists.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 osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay.Lounge; namespace osu.Game.Screens.OnlinePlay.Playlists @@ -10,5 +11,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists protected override string ScreenTitle => "Playlists"; protected override LoungeSubScreen CreateLounge() => new PlaylistsLoungeSubScreen(); + + public void Join(Room room) => Schedule(() => Lounge.Join(room, string.Empty)); } } diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs index 48f63731e1..4a2d8f8f6b 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs @@ -58,7 +58,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists protected override ResultsScreen CreateResults(ScoreInfo score) { Debug.Assert(Room.RoomID.Value != null); - return new PlaylistsResultsScreen(score, Room.RoomID.Value.Value, PlaylistItem) + return new PlaylistItemUserResultsScreen(score, Room.RoomID.Value.Value, PlaylistItem) { AllowRetry = true, ShowUserStatistics = true, diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsReadyButton.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsReadyButton.cs index 91a3edbea3..4b00678b01 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsReadyButton.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsReadyButton.cs @@ -68,9 +68,6 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { get { - if (Enabled.Value) - return string.Empty; - if (!enoughTimeLeft) return "No time left!"; diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs index 84e419d67a..9166cac9de 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs @@ -24,6 +24,7 @@ using osu.Game.Overlays; using osu.Game.Screens.OnlinePlay.Match.Components; using osuTK; using osu.Game.Localisation; +using osu.Game.Rulesets; namespace osu.Game.Screens.OnlinePlay.Playlists { @@ -78,6 +79,9 @@ namespace osu.Game.Screens.OnlinePlay.Playlists [Resolved] private IAPIProvider api { get; set; } = null!; + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + private IBindable localUser = null!; private readonly Room room; @@ -366,7 +370,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists public void SelectBeatmap() => editPlaylistButton.TriggerClick(); private void onPlaylistChanged(object? sender, NotifyCollectionChangedEventArgs e) => - playlistLength.Text = $"Length: {Playlist.GetTotalDuration()}"; + playlistLength.Text = $"Length: {Playlist.GetTotalDuration(rulesets)}"; private bool hasValidSettings => RoomID.Value == null && NameField.Text.Length > 0 && Playlist.Count > 0 && hasValidDuration; diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs index 3fb9de428a..3126bbf2eb 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs @@ -114,7 +114,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists RequestResults = item => { Debug.Assert(RoomId.Value != null); - ParentScreen?.Push(new PlaylistsResultsScreen(null, RoomId.Value.Value, item)); + ParentScreen?.Push(new PlaylistItemUserResultsScreen(null, RoomId.Value.Value, item)); } } }, diff --git a/osu.Game/Screens/OsuScreen.cs b/osu.Game/Screens/OsuScreen.cs index 2e8f85423d..695a074907 100644 --- a/osu.Game/Screens/OsuScreen.cs +++ b/osu.Game/Screens/OsuScreen.cs @@ -16,6 +16,7 @@ using osu.Game.Beatmaps; using osu.Game.Overlays; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Footer; using osu.Game.Screens.Menu; using osu.Game.Users; @@ -38,6 +39,8 @@ namespace osu.Game.Screens public virtual bool AllowBackButton => true; + public virtual bool ShowFooter => false; + public virtual bool AllowExternalScreenChange => false; public virtual bool HideOverlaysOnEnter => false; @@ -141,6 +144,10 @@ namespace osu.Game.Screens [Resolved(canBeNull: true)] private OsuLogo logo { get; set; } + [Resolved(canBeNull: true)] + [CanBeNull] + protected ScreenFooter Footer { get; private set; } + protected OsuScreen() { Anchor = Anchor.Centre; @@ -298,6 +305,8 @@ namespace osu.Game.Screens /// protected virtual BackgroundScreen CreateBackground() => null; + public virtual IReadOnlyList CreateFooterButtons() => Array.Empty(); + public virtual bool OnBackButton() => false; } } diff --git a/osu.Game/Screens/OsuScreenStack.cs b/osu.Game/Screens/OsuScreenStack.cs index 7d1f6419ad..7103fd6466 100644 --- a/osu.Game/Screens/OsuScreenStack.cs +++ b/osu.Game/Screens/OsuScreenStack.cs @@ -17,6 +17,12 @@ namespace osu.Game.Screens protected float ParallaxAmount => parallaxContainer.ParallaxAmount; + public new MarginPadding Padding + { + get => base.Padding; + set => base.Padding = value; + } + public OsuScreenStack() { InternalChild = parallaxContainer = new ParallaxContainer diff --git a/osu.Game/Screens/Play/ArgonKeyCounterDisplay.cs b/osu.Game/Screens/Play/ArgonKeyCounterDisplay.cs index 44b90fcad0..d5044b9f06 100644 --- a/osu.Game/Screens/Play/ArgonKeyCounterDisplay.cs +++ b/osu.Game/Screens/Play/ArgonKeyCounterDisplay.cs @@ -14,11 +14,10 @@ namespace osu.Game.Screens.Play public ArgonKeyCounterDisplay() { - InternalChild = KeyFlow = new FillFlowContainer + Child = KeyFlow = new FillFlowContainer { Direction = FillDirection.Horizontal, AutoSizeAxes = Axes.Both, - Alpha = 0, Spacing = new Vector2(2), }; } 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/Break/LetterboxOverlay.cs b/osu.Game/Screens/Play/Break/LetterboxOverlay.cs index c4e2dbf403..9308a02b07 100644 --- a/osu.Game/Screens/Play/Break/LetterboxOverlay.cs +++ b/osu.Game/Screens/Play/Break/LetterboxOverlay.cs @@ -11,12 +11,12 @@ namespace osu.Game.Screens.Play.Break { public partial class LetterboxOverlay : CompositeDrawable { - private const int height = 350; - private static readonly Color4 transparent_black = new Color4(0, 0, 0, 0); public LetterboxOverlay() { + const int height = 150; + RelativeSizeAxes = Axes.Both; InternalChildren = new Drawable[] { diff --git a/osu.Game/Screens/Play/BreakOverlay.cs b/osu.Game/Screens/Play/BreakOverlay.cs index e18612c955..1fdb9402bc 100644 --- a/osu.Game/Screens/Play/BreakOverlay.cs +++ b/osu.Game/Screens/Play/BreakOverlay.cs @@ -1,22 +1,27 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - -using System.Collections.Generic; +using System; +using osu.Framework.Audio.Track; using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Timing; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.Play.Break; +using osu.Game.Utils; namespace osu.Game.Screens.Play { - public partial class BreakOverlay : Container + public partial class BreakOverlay : BeatSyncedContainer { /// /// The duration of the break overlay fading. @@ -24,26 +29,14 @@ namespace osu.Game.Screens.Play public const double BREAK_FADE_DURATION = BreakPeriod.MIN_BREAK_DURATION / 2; private const float remaining_time_container_max_size = 0.3f; - private const int vertical_margin = 25; + private const int vertical_margin = 15; private readonly Container fadeContainer; - private IReadOnlyList breaks; - - public IReadOnlyList Breaks - { - get => breaks; - set - { - breaks = value; - - if (IsLoaded) - initializeBreaks(); - } - } - public override bool RemoveCompletedTransforms => false; + public BreakTracker BreakTracker { get; init; } = null!; + private readonly Container remainingTimeAdjustmentBox; private readonly Container remainingTimeBox; private readonly RemainingTimeCounter remainingTimeCounter; @@ -51,11 +44,19 @@ namespace osu.Game.Screens.Play private readonly ScoreProcessor scoreProcessor; private readonly BreakInfo info; + private readonly IBindable currentPeriod = new Bindable(); + public BreakOverlay(bool letterboxing, ScoreProcessor scoreProcessor) { this.scoreProcessor = scoreProcessor; RelativeSizeAxes = Axes.Both; + MinimumBeatLength = 200; + + // Doesn't play well with pause/unpause. + // This might mean that some beats don't animate if the user is running <60fps, but we'll deal with that if anyone notices. + AllowMistimedEventFiring = false; + Child = fadeContainer = new Container { Alpha = 0, @@ -68,6 +69,30 @@ namespace osu.Game.Screens.Play Anchor = Anchor.Centre, Origin = Anchor.Centre, }, + new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 80, + Height = 4, + Masking = true, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Radius = 260, + Colour = OsuColour.Gray(0.2f).Opacity(0.8f), + Roundness = 12 + }, + Children = new Drawable[] + { + new Box + { + Alpha = 0, + AlwaysPresent = true, + RelativeSizeAxes = Axes.Both, + }, + } + }, remainingTimeAdjustmentBox = new Container { Anchor = Anchor.Centre, @@ -75,28 +100,26 @@ namespace osu.Game.Screens.Play AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, Width = 0, - Child = remainingTimeBox = new Container + Child = remainingTimeBox = new Circle { Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.X, Height = 8, - CornerRadius = 4, Masking = true, - Child = new Box { RelativeSizeAxes = Axes.Both } } }, remainingTimeCounter = new RemainingTimeCounter { Anchor = Anchor.Centre, Origin = Anchor.BottomCentre, - Margin = new MarginPadding { Bottom = vertical_margin }, + Y = -vertical_margin, }, info = new BreakInfo { Anchor = Anchor.Centre, Origin = Anchor.TopCentre, - Margin = new MarginPadding { Top = vertical_margin }, + Y = vertical_margin, }, breakArrows = new BreakArrows { @@ -110,49 +133,76 @@ namespace osu.Game.Screens.Play protected override void LoadComplete() { base.LoadComplete(); - initializeBreaks(); - if (scoreProcessor != null) + info.AccuracyDisplay.Current.BindTo(scoreProcessor.Accuracy); + ((IBindable)info.GradeDisplay.Current).BindTo(scoreProcessor.Rank); + + currentPeriod.BindTo(BreakTracker.CurrentPeriod); + currentPeriod.BindValueChanged(updateDisplay, true); + } + + private float remainingTimeForCurrentPeriod => + currentPeriod.Value == null ? 0 : (float)Math.Max(0, (currentPeriod.Value.Value.End - Time.Current - BREAK_FADE_DURATION) / currentPeriod.Value.Value.Duration); + + protected override void Update() + { + base.Update(); + + remainingTimeBox.Height = Math.Min(8, remainingTimeBox.DrawWidth); + + // Keep things simple by resetting beat synced transforms on a rewind. + if (Clock.ElapsedFrameTime < 0) { - info.AccuracyDisplay.Current.BindTo(scoreProcessor.Accuracy); - ((IBindable)info.GradeDisplay.Current).BindTo(scoreProcessor.Rank); + remainingTimeBox.ClearTransforms(targetMember: nameof(Width)); + remainingTimeBox.Width = remainingTimeForCurrentPeriod; } } - private void initializeBreaks() + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + { + base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes); + + if (currentPeriod.Value == null) + return; + + float timeBoxTargetWidth = (float)Math.Max(0, (remainingTimeForCurrentPeriod - timingPoint.BeatLength / currentPeriod.Value.Value.Duration)); + remainingTimeBox.ResizeWidthTo(timeBoxTargetWidth, timingPoint.BeatLength * 2, Easing.OutQuint); + } + + private void updateDisplay(ValueChangedEvent period) { FinishTransforms(true); Scheduler.CancelDelayedTasks(); - if (breaks == null) return; // we need breaks. + if (period.NewValue == null) + return; - foreach (var b in breaks) + var b = period.NewValue.Value; + + using (BeginAbsoluteSequence(b.Start)) { - if (!b.HasEffect) - continue; + fadeContainer.FadeIn(BREAK_FADE_DURATION); + breakArrows.Show(BREAK_FADE_DURATION); - using (BeginAbsoluteSequence(b.StartTime)) + remainingTimeAdjustmentBox + .ResizeWidthTo(remaining_time_container_max_size, BREAK_FADE_DURATION, Easing.OutQuint) + .Delay(b.Duration - BREAK_FADE_DURATION) + .ResizeWidthTo(0); + + remainingTimeBox.ResizeWidthTo(remainingTimeForCurrentPeriod); + + remainingTimeCounter.CountTo(b.Duration).CountTo(0, b.Duration); + + remainingTimeCounter.MoveToX(-50) + .MoveToX(0, BREAK_FADE_DURATION, Easing.OutQuint); + + info.MoveToX(50) + .MoveToX(0, BREAK_FADE_DURATION, Easing.OutQuint); + + using (BeginDelayedSequence(b.Duration - BREAK_FADE_DURATION)) { - fadeContainer.FadeIn(BREAK_FADE_DURATION); - breakArrows.Show(BREAK_FADE_DURATION); - - remainingTimeAdjustmentBox - .ResizeWidthTo(remaining_time_container_max_size, BREAK_FADE_DURATION, Easing.OutQuint) - .Delay(b.Duration - BREAK_FADE_DURATION) - .ResizeWidthTo(0); - - remainingTimeBox - .ResizeWidthTo(0, b.Duration - BREAK_FADE_DURATION) - .Then() - .ResizeWidthTo(1); - - remainingTimeCounter.CountTo(b.Duration).CountTo(0, b.Duration); - - using (BeginDelayedSequence(b.Duration - BREAK_FADE_DURATION)) - { - fadeContainer.FadeOut(BREAK_FADE_DURATION); - breakArrows.Hide(BREAK_FADE_DURATION); - } + fadeContainer.FadeOut(BREAK_FADE_DURATION); + breakArrows.Hide(BREAK_FADE_DURATION); } } } diff --git a/osu.Game/Screens/Play/BreakTracker.cs b/osu.Game/Screens/Play/BreakTracker.cs index 20ef1dc4bf..3c3f31053a 100644 --- a/osu.Game/Screens/Play/BreakTracker.cs +++ b/osu.Game/Screens/Play/BreakTracker.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System.Collections.Generic; using System.Linq; using osu.Framework.Bindables; @@ -18,7 +16,7 @@ namespace osu.Game.Screens.Play private readonly ScoreProcessor scoreProcessor; private readonly double gameplayStartTime; - private PeriodTracker breaks; + private PeriodTracker breaks = new PeriodTracker(Enumerable.Empty()); /// /// Whether the gameplay is currently in a break. @@ -27,6 +25,8 @@ namespace osu.Game.Screens.Play private readonly BindableBool isBreakTime = new BindableBool(true); + public readonly Bindable CurrentPeriod = new Bindable(); + public IReadOnlyList Breaks { set @@ -39,7 +39,7 @@ namespace osu.Game.Screens.Play } } - public BreakTracker(double gameplayStartTime = 0, ScoreProcessor scoreProcessor = null) + public BreakTracker(double gameplayStartTime, ScoreProcessor scoreProcessor) { this.gameplayStartTime = gameplayStartTime; this.scoreProcessor = scoreProcessor; @@ -55,9 +55,16 @@ namespace osu.Game.Screens.Play { double time = Clock.CurrentTime; - isBreakTime.Value = breaks?.IsInAny(time) == true - || time < gameplayStartTime - || scoreProcessor?.HasCompleted.Value == true; + if (breaks.IsInAny(time, out var currentBreak)) + { + CurrentPeriod.Value = currentBreak; + isBreakTime.Value = true; + } + else + { + CurrentPeriod.Value = null; + isBreakTime.Value = time < gameplayStartTime || scoreProcessor.HasCompleted.Value; + } } } } diff --git a/osu.Game/Screens/Play/DelayedResumeOverlay.cs b/osu.Game/Screens/Play/DelayedResumeOverlay.cs index 147d48ae02..8acb94a5af 100644 --- a/osu.Game/Screens/Play/DelayedResumeOverlay.cs +++ b/osu.Game/Screens/Play/DelayedResumeOverlay.cs @@ -11,7 +11,6 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; -using osu.Framework.Threading; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Overlays; @@ -24,8 +23,9 @@ namespace osu.Game.Screens.Play /// public partial class DelayedResumeOverlay : ResumeOverlay { - // todo: this shouldn't define its own colour provider, but nothing in Player screen does, so let's do that for now. - private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + // todo: this shouldn't define its own colour provider, but nothing in DrawableRuleset guarantees this, so let's do it locally for now. + // (of note, Player does cache one but any test which uses a DrawableRuleset without Player will fail without this). + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); private const float outer_size = 200; private const float inner_size = 150; @@ -34,9 +34,10 @@ namespace osu.Game.Screens.Play private const double countdown_time = 2000; + private const int total_count = 3; + protected override LocalisableString Message => string.Empty; - private ScheduledDelegate? scheduledResume; private int? countdownCount; private double countdownStartTime; private bool countdownComplete; @@ -120,21 +121,17 @@ namespace osu.Game.Screens.Play innerContent.FadeIn().ScaleTo(Vector2.Zero).Then().ScaleTo(Vector2.One, 400, Easing.OutElasticHalf); countdownComponents.FadeOut().Delay(50).FadeTo(1, 100); + countdownProgress.Progress = 0; + // Reset states for various components. countdownBackground.FadeIn(); countdownText.FadeIn(); + countdownText.Text = string.Empty; countdownProgress.FadeIn().ScaleTo(1); countdownComplete = false; countdownCount = null; - countdownStartTime = Time.Current; - - scheduledResume?.Cancel(); - scheduledResume = Scheduler.AddDelayed(() => - { - countdownComplete = true; - Resume(); - }, countdown_time); + countdownStartTime = Time.Current + 200; } protected override void PopOut() @@ -152,8 +149,6 @@ namespace osu.Game.Screens.Play } else countdownProgress.FadeOut(); - - scheduledResume?.Cancel(); } protected override void Update() @@ -164,12 +159,17 @@ namespace osu.Game.Screens.Play private void updateCountdown() { - double amountTimePassed = Math.Min(countdown_time, Time.Current - countdownStartTime) / countdown_time; - int newCount = 3 - (int)Math.Floor(amountTimePassed * 3); + if (State.Value == Visibility.Hidden || countdownComplete || Time.Current < countdownStartTime) + return; + + double amountTimePassed = Math.Clamp((Time.Current - countdownStartTime) / countdown_time, 0, countdown_time); + int newCount = Math.Clamp(total_count - (int)Math.Floor(amountTimePassed * total_count), 0, total_count); countdownProgress.Progress = amountTimePassed; countdownProgress.InnerRadius = progress_stroke_width / progress_size / countdownProgress.Scale.X; + Alpha = 0.2f + 0.8f * newCount / total_count; + if (countdownCount != newCount) { if (newCount > 0) @@ -191,6 +191,12 @@ namespace osu.Game.Screens.Play } countdownCount = newCount; + + if (countdownCount == 0) + { + countdownComplete = true; + Resume(); + } } } } diff --git a/osu.Game/Screens/Play/DimmableStoryboard.cs b/osu.Game/Screens/Play/DimmableStoryboard.cs index 40cc0f66ad..84d99ea863 100644 --- a/osu.Game/Screens/Play/DimmableStoryboard.cs +++ b/osu.Game/Screens/Play/DimmableStoryboard.cs @@ -3,7 +3,9 @@ #nullable disable +using System; using System.Collections.Generic; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; @@ -24,6 +26,21 @@ namespace osu.Game.Screens.Play private readonly Storyboard storyboard; private readonly IReadOnlyList mods; + /// + /// In certain circumstances, the storyboard cannot be hidden entirely even if it is fully dimmed. Such circumstances include: + /// + /// + /// cases where the storyboard has an overlay layer sprite, as it should continue to display fully dimmed + /// in front of the playfield (https://github.com/ppy/osu/issues/29867), + /// + /// + /// cases where the storyboard includes samples - as they are played back via drawable samples, + /// they must be present for the playback to occur (https://github.com/ppy/osu/issues/9315). + /// + /// + /// + private readonly Lazy storyboardMustAlwaysBePresent; + private DrawableStoryboard drawableStoryboard; /// @@ -38,6 +55,8 @@ namespace osu.Game.Screens.Play { this.storyboard = storyboard; this.mods = mods; + + storyboardMustAlwaysBePresent = new Lazy(() => storyboard.GetLayer(@"Overlay").Elements.Any() || storyboard.Layers.Any(l => l.Elements.OfType().Any())); } [BackgroundDependencyLoader] @@ -54,7 +73,7 @@ namespace osu.Game.Screens.Play base.LoadComplete(); } - protected override bool ShowDimContent => IgnoreUserSettings.Value || (ShowStoryboard.Value && DimLevel < 1); + protected override bool ShowDimContent => IgnoreUserSettings.Value || (ShowStoryboard.Value && (DimLevel < 1 || storyboardMustAlwaysBePresent.Value)); private void initializeStoryboard(bool async) { diff --git a/osu.Game/Screens/Play/GameplayMenuOverlay.cs b/osu.Game/Screens/Play/GameplayMenuOverlay.cs index da239d585e..2b961278d5 100644 --- a/osu.Game/Screens/Play/GameplayMenuOverlay.cs +++ b/osu.Game/Screens/Play/GameplayMenuOverlay.cs @@ -32,8 +32,6 @@ namespace osu.Game.Screens.Play private const int button_height = 70; private const float background_alpha = 0.75f; - protected override bool BlockNonPositionalInput => true; - protected override bool BlockScrollInput => false; public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; diff --git a/osu.Game/Screens/Play/GameplayState.cs b/osu.Game/Screens/Play/GameplayState.cs index 8b0207a340..478acd7229 100644 --- a/osu.Game/Screens/Play/GameplayState.cs +++ b/osu.Game/Screens/Play/GameplayState.cs @@ -40,6 +40,7 @@ namespace osu.Game.Screens.Play public readonly Score Score; public readonly ScoreProcessor ScoreProcessor; + public readonly HealthProcessor HealthProcessor; /// /// The storyboard associated with the beatmap. @@ -68,7 +69,14 @@ namespace osu.Game.Screens.Play private readonly Bindable lastJudgementResult = new Bindable(); - public GameplayState(IBeatmap beatmap, Ruleset ruleset, IReadOnlyList? mods = null, Score? score = null, ScoreProcessor? scoreProcessor = null, Storyboard? storyboard = null) + public GameplayState( + IBeatmap beatmap, + Ruleset ruleset, + IReadOnlyList? mods = null, + Score? score = null, + ScoreProcessor? scoreProcessor = null, + HealthProcessor? healthProcessor = null, + Storyboard? storyboard = null) { Beatmap = beatmap; Ruleset = ruleset; @@ -82,6 +90,7 @@ namespace osu.Game.Screens.Play }; Mods = mods ?? Array.Empty(); ScoreProcessor = scoreProcessor ?? ruleset.CreateScoreProcessor(); + HealthProcessor = healthProcessor ?? ruleset.CreateHealthProcessor(beatmap.HitObjects[0].StartTime); Storyboard = storyboard ?? new Storyboard(); } diff --git a/osu.Game/Screens/Play/HUD/ArgonComboCounter.cs b/osu.Game/Screens/Play/HUD/ArgonComboCounter.cs index db0480c566..e82e8f4b6f 100644 --- a/osu.Game/Screens/Play/HUD/ArgonComboCounter.cs +++ b/osu.Game/Screens/Play/HUD/ArgonComboCounter.cs @@ -19,10 +19,12 @@ namespace osu.Game.Screens.Play.HUD { public partial class ArgonComboCounter : ComboCounter { - private ArgonCounterTextComponent text = null!; + protected ArgonCounterTextComponent Text = null!; protected override double RollingDuration => 250; + protected virtual bool DisplayXSymbol => true; + [SettingSource("Wireframe opacity", "Controls the opacity of the wireframes behind the digits.")] public BindableFloat WireframeOpacity { get; } = new BindableFloat(0.25f) { @@ -43,16 +45,16 @@ namespace osu.Game.Screens.Play.HUD bool wasIncrease = combo.NewValue > combo.OldValue; bool wasMiss = combo.OldValue > 1 && combo.NewValue == 0; - float newScale = Math.Clamp(text.NumberContainer.Scale.X * (wasIncrease ? 1.1f : 0.8f), 0.6f, 1.4f); + float newScale = Math.Clamp(Text.NumberContainer.Scale.X * (wasIncrease ? 1.1f : 0.8f), 0.6f, 1.4f); float duration = wasMiss ? 2000 : 500; - text.NumberContainer + Text.NumberContainer .ScaleTo(new Vector2(newScale)) .ScaleTo(Vector2.One, duration, Easing.OutQuint); if (wasMiss) - text.FlashColour(Color4.Red, duration, Easing.OutQuint); + Text.FlashColour(Color4.Red, duration, Easing.OutQuint); }); } @@ -70,23 +72,23 @@ namespace osu.Game.Screens.Play.HUD { int digitsRequiredForDisplayCount = getDigitsRequiredForDisplayCount(); - if (digitsRequiredForDisplayCount != text.WireframeTemplate.Length) - text.WireframeTemplate = new string('#', digitsRequiredForDisplayCount); + if (digitsRequiredForDisplayCount != Text.WireframeTemplate.Length) + Text.WireframeTemplate = new string('#', digitsRequiredForDisplayCount); } private int getDigitsRequiredForDisplayCount() { - // one for the single presumed starting digit, one for the "x" at the end. - int digitsRequired = 2; + // one for the single presumed starting digit, one for the "x" at the end (unless disabled). + int digitsRequired = DisplayXSymbol ? 2 : 1; long c = DisplayedCount; while ((c /= 10) > 0) digitsRequired++; return digitsRequired; } - protected override LocalisableString FormatCount(int count) => $@"{count}x"; + protected override LocalisableString FormatCount(int count) => DisplayXSymbol ? $@"{count}x" : count.ToString(); - protected override IHasText CreateText() => text = new ArgonCounterTextComponent(Anchor.TopLeft, MatchesStrings.MatchScoreStatsCombo.ToUpper()) + protected override IHasText CreateText() => Text = new ArgonCounterTextComponent(Anchor.TopLeft, MatchesStrings.MatchScoreStatsCombo.ToUpper()) { WireframeOpacity = { BindTarget = WireframeOpacity }, ShowLabel = { BindTarget = ShowLabel }, diff --git a/osu.Game/Screens/Play/HUD/ArgonCounterTextComponent.cs b/osu.Game/Screens/Play/HUD/ArgonCounterTextComponent.cs index efb4d2108e..bd8f17185b 100644 --- a/osu.Game/Screens/Play/HUD/ArgonCounterTextComponent.cs +++ b/osu.Game/Screens/Play/HUD/ArgonCounterTextComponent.cs @@ -58,6 +58,7 @@ namespace osu.Game.Screens.Play.HUD labelText = new OsuSpriteText { Alpha = 0, + BypassAutoSizeAxes = Axes.X, Text = label.GetValueOrDefault(), Font = OsuFont.Torus.With(size: 12, weight: FontWeight.Bold), Margin = new MarginPadding { Left = 2.5f }, @@ -65,6 +66,8 @@ namespace osu.Game.Screens.Play.HUD NumberContainer = new Container { AutoSizeAxes = Axes.Both, + Anchor = anchor, + Origin = anchor, Children = new[] { wireframesPart = new ArgonCounterSpriteText(wireframesLookup) diff --git a/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs b/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs index 71996718d9..44f021f93e 100644 --- a/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs +++ b/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs @@ -13,6 +13,7 @@ using osu.Framework.Layout; using osu.Framework.Threading; using osu.Framework.Utils; using osu.Game.Configuration; +using osu.Game.Localisation.SkinComponents; using osu.Game.Rulesets.Judgements; using osu.Game.Screens.Play.HUD.ArgonHealthDisplayParts; using osu.Game.Skinning; @@ -33,7 +34,7 @@ namespace osu.Game.Screens.Play.HUD Precision = 1 }; - [SettingSource("Use relative size")] + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.UseRelativeSize))] public BindableBool UseRelativeSize { get; } = new BindableBool(true); private ArgonHealthDisplayBar mainBar = null!; diff --git a/osu.Game/Screens/Play/HUD/ArgonSongProgress.cs b/osu.Game/Screens/Play/HUD/ArgonSongProgress.cs index 7db3f9fd3c..8dc5d60352 100644 --- a/osu.Game/Screens/Play/HUD/ArgonSongProgress.cs +++ b/osu.Game/Screens/Play/HUD/ArgonSongProgress.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics.Containers; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Localisation.HUD; +using osu.Game.Localisation.SkinComponents; using osu.Game.Rulesets.Objects; namespace osu.Game.Screens.Play.HUD @@ -26,6 +27,15 @@ namespace osu.Game.Screens.Play.HUD [SettingSource(typeof(SongProgressStrings), nameof(SongProgressStrings.ShowGraph), nameof(SongProgressStrings.ShowGraphDescription))] public Bindable ShowGraph { get; } = new BindableBool(true); + [SettingSource(typeof(SongProgressStrings), nameof(SongProgressStrings.ShowTime), nameof(SongProgressStrings.ShowTimeDescription))] + public Bindable ShowTime { get; } = new BindableBool(true); + + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.UseRelativeSize))] + public BindableBool UseRelativeSize { get; } = new BindableBool(true); + + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Colour), nameof(SkinnableComponentStrings.ColourDescription))] + public BindableColour4 AccentColour { get; } = new BindableColour4(Colour4.White); + [Resolved] private Player? player { get; set; } @@ -90,6 +100,13 @@ namespace osu.Game.Screens.Play.HUD Interactive.BindValueChanged(_ => bar.Interactive = Interactive.Value, true); ShowGraph.BindValueChanged(_ => updateGraphVisibility(), true); + ShowTime.BindValueChanged(_ => info.FadeTo(ShowTime.Value ? 1 : 0, 200, Easing.In), true); + AccentColour.BindValueChanged(_ => Colour = AccentColour.Value, true); + + // see comment in ArgonHealthDisplay.cs regarding RelativeSizeAxes + float previousWidth = Width; + UseRelativeSize.BindValueChanged(v => RelativeSizeAxes = v.NewValue ? Axes.X : Axes.None, true); + Width = previousWidth; } protected override void UpdateObjects(IEnumerable objects) diff --git a/osu.Game/Screens/Play/HUD/ArgonSongProgressBar.cs b/osu.Game/Screens/Play/HUD/ArgonSongProgressBar.cs index 7a7870a775..9ab2366b3e 100644 --- a/osu.Game/Screens/Play/HUD/ArgonSongProgressBar.cs +++ b/osu.Game/Screens/Play/HUD/ArgonSongProgressBar.cs @@ -6,15 +6,17 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Graphics; using osuTK; namespace osu.Game.Screens.Play.HUD { - public partial class ArgonSongProgressBar : SongProgressBar + public partial class ArgonSongProgressBar : SongProgressBar, IHasTooltip { // Parent will handle restricting the area of valid input. public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; @@ -33,6 +35,26 @@ namespace osu.Game.Screens.Play.HUD private double trackTime => (EndTime - StartTime) * Progress; + private float lastMouseX; + + public LocalisableString TooltipText + { + get + { + if (!Interactive) + return default; + + double progress = Math.Clamp(lastMouseX, 0, DrawWidth) / DrawWidth; + + TimeSpan currentSpan = TimeSpan.FromMilliseconds(Math.Round((EndTime - StartTime) * progress)); + + int seconds = currentSpan.Duration().Seconds; + int minutes = (int)Math.Floor(currentSpan.Duration().TotalMinutes); + + return $"{minutes}:{seconds:D2} ({progress:P0})"; + } + } + public ArgonSongProgressBar(float barHeight) { RelativeSizeAxes = Axes.X; @@ -84,6 +106,14 @@ namespace osu.Game.Screens.Play.HUD playfieldBar.TransformTo(nameof(playfieldBar.AccentColour), mainColour, 200, Easing.In); } + protected override bool OnMouseMove(MouseMoveEvent e) + { + base.OnMouseMove(e); + + lastMouseX = e.MousePosition.X; + return false; + } + protected override bool OnHover(HoverEvent e) { if (Interactive) diff --git a/osu.Game/Screens/Play/HUD/ArgonWedgePiece.cs b/osu.Game/Screens/Play/HUD/ArgonWedgePiece.cs index 3c2e3e05ea..46a658cd1c 100644 --- a/osu.Game/Screens/Play/HUD/ArgonWedgePiece.cs +++ b/osu.Game/Screens/Play/HUD/ArgonWedgePiece.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Configuration; +using osu.Game.Localisation.SkinComponents; using osu.Game.Skinning; using osuTK; @@ -21,6 +22,9 @@ namespace osu.Game.Screens.Play.HUD [SettingSource("Inverted shear")] public BindableBool InvertShear { get; } = new BindableBool(); + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Colour), nameof(SkinnableComponentStrings.ColourDescription))] + public BindableColour4 AccentColour { get; } = new BindableColour4(Color4Extensions.FromHex("#66CCFF")); + public ArgonWedgePiece() { CornerRadius = 10f; @@ -37,7 +41,6 @@ namespace osu.Game.Screens.Play.HUD InternalChild = new Box { RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientVertical(Color4Extensions.FromHex("#66CCFF").Opacity(0.0f), Color4Extensions.FromHex("#66CCFF").Opacity(0.25f)), }; } @@ -46,6 +49,7 @@ namespace osu.Game.Screens.Play.HUD base.LoadComplete(); InvertShear.BindValueChanged(v => Shear = new Vector2(0.8f, 0f) * (v.NewValue ? -1 : 1), true); + AccentColour.BindValueChanged(c => InternalChild.Colour = ColourInfo.GradientVertical(AccentColour.Value.Opacity(0.0f), AccentColour.Value.Opacity(0.25f)), true); } } } diff --git a/osu.Game/Screens/Play/HUD/DefaultKeyCounterDisplay.cs b/osu.Game/Screens/Play/HUD/DefaultKeyCounterDisplay.cs index e0f96d32bc..dfb547453e 100644 --- a/osu.Game/Screens/Play/HUD/DefaultKeyCounterDisplay.cs +++ b/osu.Game/Screens/Play/HUD/DefaultKeyCounterDisplay.cs @@ -16,11 +16,10 @@ namespace osu.Game.Screens.Play.HUD public DefaultKeyCounterDisplay() { - InternalChild = KeyFlow = new FillFlowContainer + Child = KeyFlow = new FillFlowContainer { Direction = FillDirection.Horizontal, AutoSizeAxes = Axes.Both, - Alpha = 0, }; } diff --git a/osu.Game/Screens/Play/HUD/DefaultRankDisplay.cs b/osu.Game/Screens/Play/HUD/DefaultRankDisplay.cs new file mode 100644 index 0000000000..09ab7d156c --- /dev/null +++ b/osu.Game/Screens/Play/HUD/DefaultRankDisplay.cs @@ -0,0 +1,45 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Online.Leaderboards; +using osu.Game.Rulesets.Scoring; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Screens.Play.HUD +{ + public partial class DefaultRankDisplay : Container, ISerialisableDrawable + { + [Resolved] + private ScoreProcessor scoreProcessor { get; set; } = null!; + + public bool UsesFixedAnchor { get; set; } + + private readonly UpdateableRank rank; + + public DefaultRankDisplay() + { + Size = new Vector2(70, 35); + + InternalChildren = new Drawable[] + { + rank = new UpdateableRank(Scoring.ScoreRank.X) + { + RelativeSizeAxes = Axes.Both + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + rank.Rank = scoreProcessor.Rank.Value; + + scoreProcessor.Rank.BindValueChanged(v => rank.Rank = v.NewValue); + } + } +} \ No newline at end of file diff --git a/osu.Game/Screens/Play/HUD/DefaultSongProgress.cs b/osu.Game/Screens/Play/HUD/DefaultSongProgress.cs index f01c11855c..672017750d 100644 --- a/osu.Game/Screens/Play/HUD/DefaultSongProgress.cs +++ b/osu.Game/Screens/Play/HUD/DefaultSongProgress.cs @@ -10,6 +10,7 @@ using osu.Framework.Utils; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Localisation.HUD; +using osu.Game.Localisation.SkinComponents; using osu.Game.Rulesets.Objects; using osuTK; @@ -33,6 +34,15 @@ namespace osu.Game.Screens.Play.HUD [SettingSource(typeof(SongProgressStrings), nameof(SongProgressStrings.ShowGraph), nameof(SongProgressStrings.ShowGraphDescription))] public Bindable ShowGraph { get; } = new BindableBool(true); + [SettingSource(typeof(SongProgressStrings), nameof(SongProgressStrings.ShowTime), nameof(SongProgressStrings.ShowTimeDescription))] + public Bindable ShowTime { get; } = new BindableBool(true); + + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.UseRelativeSize))] + public BindableBool UseRelativeSize { get; } = new BindableBool(true); + + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Colour), nameof(SkinnableComponentStrings.ColourDescription))] + public BindableColour4 AccentColour { get; } = new BindableColour4(Colour4.White); + [Resolved] private Player? player { get; set; } @@ -76,12 +86,19 @@ namespace osu.Game.Screens.Play.HUD private void load(OsuColour colours) { graph.FillColour = bar.FillColour = colours.BlueLighter; + + // see comment in ArgonHealthDisplay.cs regarding RelativeSizeAxes + float previousWidth = Width; + UseRelativeSize.BindValueChanged(v => RelativeSizeAxes = v.NewValue ? Axes.X : Axes.None, true); + Width = previousWidth; } protected override void LoadComplete() { Interactive.BindValueChanged(_ => updateBarVisibility(), true); ShowGraph.BindValueChanged(_ => updateGraphVisibility(), true); + ShowTime.BindValueChanged(_ => updateTimeVisibility(), true); + AccentColour.BindValueChanged(_ => Colour = AccentColour.Value, true); base.LoadComplete(); } @@ -129,6 +146,13 @@ namespace osu.Game.Screens.Play.HUD updateInfoMargin(); } + private void updateTimeVisibility() + { + info.FadeTo(ShowTime.Value ? 1 : 0, transition_duration, Easing.In); + + updateInfoMargin(); + } + private void updateInfoMargin() { float finalMargin = bottom_bar_height + (Interactive.Value ? handle_size.Y : 0) + (ShowGraph.Value ? graph_height : 0); diff --git a/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs b/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs index 7471955493..3d46517a68 100644 --- a/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs +++ b/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs @@ -316,7 +316,7 @@ namespace osu.Game.Screens.Play.HUD HasQuit.BindValueChanged(_ => updateState()); - isFriend = User != null && api.Friends.Any(u => User.OnlineID == u.Id); + isFriend = User != null && api.Friends.Any(u => User.OnlineID == u.TargetID); } protected override void LoadComplete() diff --git a/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs b/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs index 6d045e5f01..806985e19d 100644 --- a/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs +++ b/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs @@ -30,6 +30,8 @@ namespace osu.Game.Screens.Play.HUD { public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; + public override bool PropagatePositionalInputSubTree => alwaysShow.Value || touchActive.Value; + public readonly Bindable IsPaused = new Bindable(); public readonly Bindable ReplayLoaded = new Bindable(); @@ -40,6 +42,8 @@ namespace osu.Game.Screens.Play.HUD private OsuSpriteText text; + private Bindable alwaysShow; + public HoldForMenuButton() { Direction = FillDirection.Horizontal; @@ -50,7 +54,7 @@ namespace osu.Game.Screens.Play.HUD } [BackgroundDependencyLoader(true)] - private void load(Player player) + private void load(Player player, OsuConfigManager config) { Children = new Drawable[] { @@ -71,6 +75,8 @@ namespace osu.Game.Screens.Play.HUD }; AutoSizeAxes = Axes.Both; + + alwaysShow = config.GetBindable(OsuSetting.AlwaysShowHoldForMenuButton); } [Resolved] @@ -117,9 +123,14 @@ namespace osu.Game.Screens.Play.HUD { base.Update(); + // While the button is hovered or still animating, keep fully visible. if (text.Alpha > 0 || button.Progress.Value > 0 || button.IsHovered) Alpha = 1; - else + // When touch input is detected, keep visible at a constant opacity. + else if (touchActive.Value) + Alpha = 0.5f; + // Otherwise, if the user chooses, show it when the mouse is nearby. + else if (alwaysShow.Value) { float minAlpha = touchActive.Value ? .08f : 0; @@ -127,6 +138,8 @@ namespace osu.Game.Screens.Play.HUD Math.Clamp(Clock.ElapsedFrameTime, 0, 200), Alpha, Math.Clamp(1 - positionalAdjust, minAlpha, 1), 0, 200, Easing.OutQuint); } + else + Alpha = 0; } private partial class HoldButton : HoldToConfirmContainer, IKeyBindingHandler @@ -286,7 +299,7 @@ namespace osu.Game.Screens.Play.HUD { case GlobalAction.Back: if (!pendingAnimation) - BeginConfirm(); + Confirm(); return true; case GlobalAction.PauseGameplay: @@ -294,7 +307,7 @@ namespace osu.Game.Screens.Play.HUD if (ReplayLoaded.Value) return false; if (!pendingAnimation) - BeginConfirm(); + Confirm(); return true; } diff --git a/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementCount.cs b/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementCount.cs new file mode 100644 index 0000000000..ad70e519a2 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementCount.cs @@ -0,0 +1,18 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Localisation; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Screens.Play.HUD.JudgementCounter +{ + public struct JudgementCount + { + public LocalisableString DisplayName { get; set; } + + public HitResult[] Types { get; set; } + + public BindableInt ResultCount { get; set; } + } +} diff --git a/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementCountController.cs b/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementCountController.cs index 8134c97bac..c00cb3487b 100644 --- a/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementCountController.cs +++ b/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementCountController.cs @@ -6,7 +6,6 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Localisation; using osu.Game.Rulesets; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; @@ -53,10 +52,29 @@ namespace osu.Game.Screens.Play.HUD.JudgementCounter { base.LoadComplete(); + scoreProcessor.OnResetFromReplayFrame += updateAllCountsFromReplayFrame; scoreProcessor.NewJudgement += judgement => updateCount(judgement, false); scoreProcessor.JudgementReverted += judgement => updateCount(judgement, true); } + private bool hasUpdatedCountsFromReplayFrame; + + private void updateAllCountsFromReplayFrame() + { + if (hasUpdatedCountsFromReplayFrame) + return; + + foreach (var kvp in scoreProcessor.Statistics) + { + if (!results.TryGetValue(kvp.Key, out var count)) + continue; + + count.ResultCount.Value = kvp.Value; + } + + hasUpdatedCountsFromReplayFrame = true; + } + private void updateCount(JudgementResult judgement, bool revert) { if (!results.TryGetValue(judgement.Type, out var count)) @@ -67,14 +85,5 @@ namespace osu.Game.Screens.Play.HUD.JudgementCounter else count.ResultCount.Value++; } - - public struct JudgementCount - { - public LocalisableString DisplayName { get; set; } - - public HitResult[] Types { get; set; } - - public BindableInt ResultCount { get; set; } - } } } diff --git a/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementCounter.cs b/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementCounter.cs index 45ed8d749b..d69416f34a 100644 --- a/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementCounter.cs +++ b/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementCounter.cs @@ -9,7 +9,6 @@ using osu.Framework.Graphics.Containers; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; -using osu.Game.Rulesets; using osu.Game.Rulesets.Scoring; namespace osu.Game.Screens.Play.HUD.JudgementCounter @@ -19,16 +18,16 @@ namespace osu.Game.Screens.Play.HUD.JudgementCounter public BindableBool ShowName = new BindableBool(); public Bindable Direction = new Bindable(); - public readonly JudgementCountController.JudgementCount Result; + public readonly JudgementCount Result; - public JudgementCounter(JudgementCountController.JudgementCount result) => Result = result; + public JudgementCounter(JudgementCount result) => Result = result; public OsuSpriteText ResultName = null!; private FillFlowContainer flowContainer = null!; private JudgementRollingCounter counter = null!; [BackgroundDependencyLoader] - private void load(OsuColour colours, IBindable ruleset) + private void load(OsuColour colours) { AutoSizeAxes = Axes.Both; diff --git a/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementCounterDisplay.cs b/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementCounterDisplay.cs index 25e5464205..bc953435b7 100644 --- a/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementCounterDisplay.cs +++ b/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementCounterDisplay.cs @@ -126,7 +126,7 @@ namespace osu.Game.Screens.Play.HUD.JudgementCounter } } - private JudgementCounter createCounter(JudgementCountController.JudgementCount info) => + private JudgementCounter createCounter(JudgementCount info) => new JudgementCounter(info) { State = { Value = Visibility.Hidden }, diff --git a/osu.Game/Screens/Play/HUD/KeyCounter.cs b/osu.Game/Screens/Play/HUD/KeyCounter.cs index f12d2166fc..66f9dfd6f2 100644 --- a/osu.Game/Screens/Play/HUD/KeyCounter.cs +++ b/osu.Game/Screens/Play/HUD/KeyCounter.cs @@ -24,7 +24,9 @@ namespace osu.Game.Screens.Play.HUD /// /// Whether this is currently in the "activated" state because the associated key is currently pressed. /// - protected readonly Bindable IsActive = new BindableBool(); + public IBindable IsActive => isActive; + + private readonly Bindable isActive = new BindableBool(); protected KeyCounter(InputTrigger trigger) { @@ -36,12 +38,12 @@ namespace osu.Game.Screens.Play.HUD protected virtual void Activate(bool forwardPlayback = true) { - IsActive.Value = true; + isActive.Value = true; } protected virtual void Deactivate(bool forwardPlayback = true) { - IsActive.Value = false; + isActive.Value = false; } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Screens/Play/HUD/KeyCounterDisplay.cs b/osu.Game/Screens/Play/HUD/KeyCounterDisplay.cs index 0a5d6b763e..a1e90687a8 100644 --- a/osu.Game/Screens/Play/HUD/KeyCounterDisplay.cs +++ b/osu.Game/Screens/Play/HUD/KeyCounterDisplay.cs @@ -15,7 +15,7 @@ namespace osu.Game.Screens.Play.HUD /// /// A flowing display of all gameplay keys. Individual keys can be added using implementations. /// - public abstract partial class KeyCounterDisplay : CompositeDrawable, ISerialisableDrawable + public abstract partial class KeyCounterDisplay : Container, ISerialisableDrawable { /// /// Whether the key counter should be visible regardless of the configuration value. @@ -29,25 +29,22 @@ namespace osu.Game.Screens.Play.HUD private readonly IBindableList triggers = new BindableList(); + protected override Container Content { get; } = new Container + { + Alpha = 0, + AutoSizeAxes = Axes.Both, + }; + [Resolved] private InputCountController controller { get; set; } = null!; private const int duration = 100; - protected void UpdateVisibility() + protected KeyCounterDisplay() { - bool visible = AlwaysVisible.Value || ConfigVisibility.Value; - - // Isolate changing visibility of the key counters from fading this component. - KeyFlow.FadeTo(visible ? 1 : 0, duration); - - // Ensure a valid size is immediately obtained even if partially off-screen - // See https://github.com/ppy/osu/issues/14793. - KeyFlow.AlwaysPresent = visible; + AddInternal(Content); } - protected abstract KeyCounter CreateCounter(InputTrigger trigger); - [BackgroundDependencyLoader] private void load(OsuConfigManager config, DrawableRuleset? drawableRuleset) { @@ -70,6 +67,20 @@ namespace osu.Game.Screens.Play.HUD ConfigVisibility.BindValueChanged(_ => UpdateVisibility(), true); } + protected void UpdateVisibility() + { + bool visible = AlwaysVisible.Value || ConfigVisibility.Value; + + // Isolate changing visibility of the key counters from fading this component. + Content.FadeTo(visible ? 1 : 0, duration); + + // Ensure a valid size is immediately obtained even if partially off-screen + // See https://github.com/ppy/osu/issues/14793. + Content.AlwaysPresent = visible; + } + + protected abstract KeyCounter CreateCounter(InputTrigger trigger); + private void triggersChanged(object? sender, NotifyCollectionChangedEventArgs e) { KeyFlow.Clear(); diff --git a/osu.Game/Screens/Play/HUD/ModDisplay.cs b/osu.Game/Screens/Play/HUD/ModDisplay.cs index ba948b516e..b37d41e7a2 100644 --- a/osu.Game/Screens/Play/HUD/ModDisplay.cs +++ b/osu.Game/Screens/Play/HUD/ModDisplay.cs @@ -37,10 +37,13 @@ namespace osu.Game.Screens.Play.HUD } } + private readonly bool showExtendedInformation; private readonly FillFlowContainer iconsContainer; - public ModDisplay() + public ModDisplay(bool showExtendedInformation = true) { + this.showExtendedInformation = showExtendedInformation; + AutoSizeAxes = Axes.Both; InternalChild = iconsContainer = new ReverseChildIDFillFlowContainer @@ -57,6 +60,9 @@ namespace osu.Game.Screens.Play.HUD Current.BindValueChanged(updateDisplay, true); iconsContainer.FadeInFromZero(fade_duration, Easing.OutQuint); + + if (ExpansionMode == ExpansionMode.AlwaysExpanded || ExpansionMode == ExpansionMode.AlwaysContracted) + FinishTransforms(true); } private void updateDisplay(ValueChangedEvent> mods) @@ -64,7 +70,7 @@ namespace osu.Game.Screens.Play.HUD iconsContainer.Clear(); foreach (Mod mod in mods.NewValue.AsOrdered()) - iconsContainer.Add(new ModIcon(mod) { Scale = new Vector2(0.6f) }); + iconsContainer.Add(new ModIcon(mod, showExtendedInformation: showExtendedInformation) { Scale = new Vector2(0.6f) }); appearTransform(); } diff --git a/osu.Game/Screens/Play/HUD/PlayerAvatar.cs b/osu.Game/Screens/Play/HUD/PlayerAvatar.cs index 06d0f7bc9a..8e4406c2c1 100644 --- a/osu.Game/Screens/Play/HUD/PlayerAvatar.cs +++ b/osu.Game/Screens/Play/HUD/PlayerAvatar.cs @@ -39,14 +39,23 @@ namespace osu.Game.Screens.Play.HUD private IBindable? apiUser; + private readonly Container cornerContainer; + public PlayerAvatar() { Size = new Vector2(default_size); - InternalChild = avatar = new UpdateableAvatar(isInteractive: false) + InternalChild = cornerContainer = new Container { + Masking = true, RelativeSizeAxes = Axes.Both, - Masking = true + Child = avatar = new UpdateableAvatar(isInteractive: false) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fill, + } }; } @@ -66,7 +75,7 @@ namespace osu.Game.Screens.Play.HUD { base.LoadComplete(); - CornerRadius.BindValueChanged(e => avatar.CornerRadius = e.NewValue * default_size, true); + CornerRadius.BindValueChanged(e => cornerContainer.CornerRadius = e.NewValue * default_size, true); } public bool UsesFixedAnchor { get; set; } diff --git a/osu.Game/Screens/Play/HUDOverlay.cs b/osu.Game/Screens/Play/HUDOverlay.cs index 2ec2a011a6..a6c2405eb6 100644 --- a/osu.Game/Screens/Play/HUDOverlay.cs +++ b/osu.Game/Screens/Play/HUDOverlay.cs @@ -8,7 +8,6 @@ using System.Collections.Generic; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; @@ -96,10 +95,10 @@ namespace osu.Game.Screens.Play private readonly BindableBool holdingForHUD = new BindableBool(); - private readonly SkinComponentsContainer mainComponents; + private readonly SkinnableContainer mainComponents; [CanBeNull] - private readonly SkinComponentsContainer rulesetComponents; + private readonly SkinnableContainer rulesetComponents; /// /// A flow which sits at the left side of the screen to house leaderboard (and related) components. @@ -109,7 +108,10 @@ namespace osu.Game.Screens.Play private readonly List hideTargets; - private readonly Drawable playfieldComponents; + /// + /// The container for skin components attached to + /// + internal readonly Drawable PlayfieldSkinLayer; public HUDOverlay([CanBeNull] DrawableRuleset drawableRuleset, IReadOnlyList mods, bool alwaysShowLeaderboard = true) { @@ -129,8 +131,8 @@ namespace osu.Game.Screens.Play drawableRuleset != null ? (rulesetComponents = new HUDComponentsContainer(drawableRuleset.Ruleset.RulesetInfo) { AlwaysPresent = true, }) : Empty(), - playfieldComponents = drawableRuleset != null - ? new SkinComponentsContainer(new SkinComponentsContainerLookup(SkinComponentsContainerLookup.TargetArea.Playfield, drawableRuleset.Ruleset.RulesetInfo)) { AlwaysPresent = true, } + PlayfieldSkinLayer = drawableRuleset != null + ? new SkinnableContainer(new GlobalSkinnableContainerLookup(GlobalSkinnableContainers.Playfield, drawableRuleset.Ruleset.RulesetInfo)) { AlwaysPresent = true, } : Empty(), topRightElements = new FillFlowContainer { @@ -171,7 +173,7 @@ namespace osu.Game.Screens.Play }, }; - hideTargets = new List { mainComponents, playfieldComponents, topRightElements }; + hideTargets = new List { mainComponents, topRightElements }; if (rulesetComponents != null) hideTargets.Add(rulesetComponents); @@ -247,10 +249,10 @@ namespace osu.Game.Screens.Play { Quad playfieldScreenSpaceDrawQuad = drawableRuleset.Playfield.SkinnableComponentScreenSpaceDrawQuad; - playfieldComponents.Position = ToLocalSpace(playfieldScreenSpaceDrawQuad.TopLeft); - playfieldComponents.Width = (ToLocalSpace(playfieldScreenSpaceDrawQuad.TopRight) - ToLocalSpace(playfieldScreenSpaceDrawQuad.TopLeft)).Length; - playfieldComponents.Height = (ToLocalSpace(playfieldScreenSpaceDrawQuad.BottomLeft) - ToLocalSpace(playfieldScreenSpaceDrawQuad.TopLeft)).Length; - playfieldComponents.Rotation = drawableRuleset.Playfield.Rotation; + PlayfieldSkinLayer.Position = ToLocalSpace(playfieldScreenSpaceDrawQuad.TopLeft); + PlayfieldSkinLayer.Width = (ToLocalSpace(playfieldScreenSpaceDrawQuad.TopRight) - ToLocalSpace(playfieldScreenSpaceDrawQuad.TopLeft)).Length; + PlayfieldSkinLayer.Height = (ToLocalSpace(playfieldScreenSpaceDrawQuad.BottomLeft) - ToLocalSpace(playfieldScreenSpaceDrawQuad.TopLeft)).Length; + PlayfieldSkinLayer.Rotation = drawableRuleset.PlayfieldAdjustmentContainer.Rotation; } float? lowestTopScreenSpaceLeft = null; @@ -278,7 +280,7 @@ namespace osu.Game.Screens.Play else bottomRightElements.Y = 0; - void processDrawables(SkinComponentsContainer components) + void processDrawables(SkinnableContainer components) { // Avoid using foreach due to missing GetEnumerator implementation. // See https://github.com/ppy/osu-framework/blob/e10051e6643731e393b09de40a3a3d209a545031/osu.Framework/Bindables/IBindableList.cs#L41-L44. @@ -292,32 +294,34 @@ namespace osu.Game.Screens.Play Drawable drawable = (Drawable)element; // for now align some top components with the bottom-edge of the lowest top-anchored hud element. - if (drawable.Anchor.HasFlagFast(Anchor.y0)) + if (drawable.Anchor.HasFlag(Anchor.y0)) { // health bars are excluded for the sake of hacky legacy skins which extend the health bar to take up the full screen area. 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; - if (drawable.Anchor.HasFlagFast(Anchor.TopRight) || isRelativeX) + if (drawable.Anchor.HasFlag(Anchor.TopRight) || isRelativeX) { if (lowestTopScreenSpaceRight == null || bottom > lowestTopScreenSpaceRight.Value) lowestTopScreenSpaceRight = bottom; } - if (drawable.Anchor.HasFlagFast(Anchor.TopLeft) || isRelativeX) + if (drawable.Anchor.HasFlag(Anchor.TopLeft) || isRelativeX) { if (lowestTopScreenSpaceLeft == null || bottom > lowestTopScreenSpaceLeft.Value) lowestTopScreenSpaceLeft = bottom; } } // and align bottom-right components with the top-edge of the highest bottom-anchored hud element. - else if (drawable.Anchor.HasFlagFast(Anchor.BottomRight) || (drawable.Anchor.HasFlagFast(Anchor.y2) && drawable.RelativeSizeAxes == Axes.X)) + 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; } @@ -438,7 +442,7 @@ namespace osu.Game.Screens.Play } } - private partial class HUDComponentsContainer : SkinComponentsContainer + private partial class HUDComponentsContainer : SkinnableContainer { private Bindable scoringMode; @@ -446,7 +450,7 @@ namespace osu.Game.Screens.Play private OsuConfigManager config { get; set; } public HUDComponentsContainer([CanBeNull] RulesetInfo ruleset = null) - : base(new SkinComponentsContainerLookup(SkinComponentsContainerLookup.TargetArea.MainHUDComponents, ruleset)) + : base(new GlobalSkinnableContainerLookup(GlobalSkinnableContainers.MainHUDComponents, ruleset)) { RelativeSizeAxes = Axes.Both; } diff --git a/osu.Game/Screens/Play/ILocalUserPlayInfo.cs b/osu.Game/Screens/Play/ILocalUserPlayInfo.cs index 2d181a09d4..dd24549c55 100644 --- a/osu.Game/Screens/Play/ILocalUserPlayInfo.cs +++ b/osu.Game/Screens/Play/ILocalUserPlayInfo.cs @@ -10,8 +10,8 @@ namespace osu.Game.Screens.Play public interface ILocalUserPlayInfo { /// - /// Whether the local user is currently playing. + /// Whether the local user is currently interacting (playing) with the game in a way that should not be interrupted. /// - IBindable IsPlaying { get; } + IBindable PlayingState { get; } } } diff --git a/osu.Game/Screens/Play/KiaiGameplayFountains.cs b/osu.Game/Screens/Play/KiaiGameplayFountains.cs new file mode 100644 index 0000000000..7659c61123 --- /dev/null +++ b/osu.Game/Screens/Play/KiaiGameplayFountains.cs @@ -0,0 +1,94 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable disable +using System; +using osu.Framework.Allocation; +using osu.Framework.Audio.Track; +using osu.Framework.Graphics; +using osu.Framework.Utils; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics.Containers; +using osu.Game.Screens.Menu; + +namespace osu.Game.Screens.Play +{ + public partial class KiaiGameplayFountains : BeatSyncedContainer + { + private StarFountain leftFountain = null!; + private StarFountain rightFountain = null!; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Both; + + Children = new[] + { + leftFountain = new GameplayStarFountain + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + X = 75, + }, + rightFountain = new GameplayStarFountain + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + X = -75, + }, + }; + } + + private bool isTriggered; + + private double? lastTrigger; + + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + { + base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes); + + if (effectPoint.KiaiMode && !isTriggered) + { + bool isNearEffectPoint = Math.Abs(BeatSyncSource.Clock.CurrentTime - effectPoint.Time) < 500; + if (isNearEffectPoint) + Shoot(); + } + + isTriggered = effectPoint.KiaiMode; + } + + public void Shoot() + { + if (lastTrigger != null && Clock.CurrentTime - lastTrigger < 500) + return; + + leftFountain.Shoot(1); + rightFountain.Shoot(-1); + lastTrigger = Clock.CurrentTime; + } + + public partial class GameplayStarFountain : StarFountain + { + protected override StarFountainSpewer CreateSpewer() => new GameplayStarFountainSpewer(); + + private partial class GameplayStarFountainSpewer : StarFountainSpewer + { + protected override double ShootDuration => 400; + + public GameplayStarFountainSpewer() + : base(perSecond: 180) + { + } + + protected override float GetCurrentAngle() + { + const float x_velocity_from_direction = 450; + const float x_velocity_to_direction = 600; + + return LastShootDirection * RNG.NextSingle(x_velocity_from_direction, x_velocity_to_direction); + } + } + } + } +} diff --git a/osu.Game/Screens/Play/LocalUserPlayingState.cs b/osu.Game/Screens/Play/LocalUserPlayingState.cs new file mode 100644 index 0000000000..9ae4130298 --- /dev/null +++ b/osu.Game/Screens/Play/LocalUserPlayingState.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Screens.Play +{ + public enum LocalUserPlayingState + { + /// + /// The local player is not current in gameplay. If watching a replay, gameplay always remains in this state. + /// + NotPlaying, + + /// + /// The local player is in a break, paused, or failed but still at the gameplay screen. + /// + Break, + + /// + /// The local user is in active gameplay. + /// + Playing, + } +} diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index 42ff1d74f3..e9722350bd 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -32,6 +32,7 @@ using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; +using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Scoring; using osu.Game.Scoring.Legacy; using osu.Game.Screens.Play.HUD; @@ -85,6 +86,7 @@ namespace osu.Game.Screens.Play public Action RestartRequested; private bool isRestarting; + private bool skipExitTransition; private Bindable mouseWheelDisabled; @@ -93,6 +95,7 @@ namespace osu.Game.Screens.Play public IBindable LocalUserPlaying => localUserPlaying; private readonly Bindable localUserPlaying = new Bindable(); + private readonly Bindable playingState = new Bindable(); public int RestartCount; @@ -230,12 +233,12 @@ namespace osu.Game.Screens.Play if (game != null) gameActive.BindTo(game.IsActive); - if (game is OsuGame osuGame) - LocalUserPlaying.BindTo(osuGame.LocalUserPlaying); - DrawableRuleset = ruleset.CreateDrawableRulesetWith(playableBeatmap, gameplayMods); dependencies.CacheAs(DrawableRuleset); + if (DrawableRuleset is IDrawableScrollingRuleset scrollingRuleset) + dependencies.CacheAs(scrollingRuleset.ScrollingInfo); + ScoreProcessor = ruleset.CreateScoreProcessor(); ScoreProcessor.Mods.Value = gameplayMods; ScoreProcessor.ApplyBeatmap(playableBeatmap); @@ -260,7 +263,7 @@ namespace osu.Game.Screens.Play Score.ScoreInfo.Ruleset = ruleset.RulesetInfo; Score.ScoreInfo.Mods = gameplayMods; - dependencies.CacheAs(GameplayState = new GameplayState(playableBeatmap, ruleset, gameplayMods, Score, ScoreProcessor, Beatmap.Value.Storyboard)); + dependencies.CacheAs(GameplayState = new GameplayState(playableBeatmap, ruleset, gameplayMods, Score, ScoreProcessor, HealthProcessor, Beatmap.Value.Storyboard)); var rulesetSkinProvider = new RulesetSkinProvidingContainer(ruleset, playableBeatmap, Beatmap.Value.Skin); @@ -287,7 +290,7 @@ namespace osu.Game.Screens.Play { SaveReplay = async () => await prepareAndImportScoreAsync(true).ConfigureAwait(false), OnRetry = Configuration.AllowUserInteraction ? () => Restart() : null, - OnQuit = () => PerformExit(true), + OnQuit = () => PerformExitWithConfirmation(), }, new HotkeyExitOverlay { @@ -295,10 +298,7 @@ namespace osu.Game.Screens.Play { if (!this.IsCurrentScreen()) return; - if (PerformExit(false)) - // The hotkey overlay dims the screen. - // If the operation succeeds, we want to make sure we stay dimmed to keep continuity. - fadeOut(true); + PerformExit(skipTransition: true); }, }, }); @@ -316,10 +316,7 @@ namespace osu.Game.Screens.Play { if (!this.IsCurrentScreen()) return; - if (Restart(true)) - // The hotkey overlay dims the screen. - // If the operation succeeds, we want to make sure we stay dimmed to keep continuity. - fadeOut(true); + Restart(true); }, }, }); @@ -401,8 +398,20 @@ namespace osu.Game.Screens.Play protected virtual GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) => new MasterGameplayClockContainer(beatmap, gameplayStart); - private Drawable createUnderlayComponents() => - DimmableStoryboard = new DimmableStoryboard(GameplayState.Storyboard, GameplayState.Mods) { RelativeSizeAxes = Axes.Both }; + private Drawable createUnderlayComponents() + { + var container = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + DimmableStoryboard = new DimmableStoryboard(GameplayState.Storyboard, GameplayState.Mods) { RelativeSizeAxes = Axes.Both }, + new KiaiGameplayFountains(), + }, + }; + + return container; + } private Drawable createGameplayComponents(IWorkingBeatmap working) => new ScalingContainer(ScalingMode.Gameplay) { @@ -430,20 +439,11 @@ namespace osu.Game.Screens.Play Children = new[] { DimmableStoryboard.OverlayLayerContainer.CreateProxy(), - BreakOverlay = new BreakOverlay(working.Beatmap.BeatmapInfo.LetterboxInBreaks, ScoreProcessor) - { - Clock = DrawableRuleset.FrameStableClock, - ProcessCustomClock = false, - Breaks = working.Beatmap.Breaks - }, - // display the cursor above some HUD elements. - DrawableRuleset.Cursor?.CreateProxy() ?? new Container(), - DrawableRuleset.ResumeOverlay?.CreateProxy() ?? new Container(), HUDOverlay = new HUDOverlay(DrawableRuleset, GameplayState.Mods, Configuration.AlwaysShowLeaderboard) { HoldToQuit = { - Action = () => PerformExit(true), + Action = () => PerformExitWithConfirmation(), IsPaused = { BindTarget = GameplayClockContainer.IsPaused }, ReplayLoaded = { BindTarget = DrawableRuleset.HasReplayLoaded }, }, @@ -457,6 +457,14 @@ namespace osu.Game.Screens.Play Anchor = Anchor.Centre, Origin = Anchor.Centre }, + BreakOverlay = new BreakOverlay(working.Beatmap.BeatmapInfo.LetterboxInBreaks, ScoreProcessor) + { + Clock = DrawableRuleset.FrameStableClock, + ProcessCustomClock = false, + BreakTracker = breakTracker, + }, + // display the cursor above some HUD elements. + DrawableRuleset.Cursor?.CreateProxy() ?? new Container(), skipIntroOverlay = new SkipOverlay(DrawableRuleset.GameplayStartTime) { RequestSkip = performUserRequestedSkip @@ -466,12 +474,13 @@ namespace osu.Game.Screens.Play RequestSkip = () => progressToResults(false), Alpha = 0 }, + DrawableRuleset.ResumeOverlay?.CreateProxy() ?? new Container(), PauseOverlay = new PauseOverlay { OnResume = Resume, Retries = RestartCount, OnRetry = () => Restart(), - OnQuit = () => PerformExit(true), + OnQuit = () => PerformExitWithConfirmation(), }, }, }; @@ -494,9 +503,16 @@ namespace osu.Game.Screens.Play private void updateGameplayState() { - bool inGameplay = !DrawableRuleset.HasReplayLoaded.Value && !DrawableRuleset.IsPaused.Value && !breakTracker.IsBreakTime.Value && !GameplayState.HasFailed; - OverlayActivationMode.Value = inGameplay ? OverlayActivation.Disabled : OverlayActivation.UserTriggered; - localUserPlaying.Value = inGameplay; + bool inGameplay = !DrawableRuleset.HasReplayLoaded.Value && !GameplayState.HasPassed && !GameplayState.HasFailed; + bool inBreak = breakTracker.IsBreakTime.Value || DrawableRuleset.IsPaused.Value; + + if (inGameplay) + playingState.Value = inBreak ? LocalUserPlayingState.Break : LocalUserPlayingState.Playing; + else + playingState.Value = LocalUserPlayingState.NotPlaying; + + localUserPlaying.Value = playingState.Value == LocalUserPlayingState.Playing; + OverlayActivationMode.Value = playingState.Value == LocalUserPlayingState.Playing ? OverlayActivation.Disabled : OverlayActivation.UserTriggered; } private void updateSampleDisabledState() @@ -541,11 +557,8 @@ namespace osu.Game.Screens.Play } catch (BeatmapInvalidForRulesetException) { - // A playable beatmap may not be creatable with the user's preferred ruleset, so try using the beatmap's default ruleset - rulesetInfo = Beatmap.Value.BeatmapInfo.Ruleset; - ruleset = rulesetInfo.CreateInstance(); - - playable = Beatmap.Value.GetPlayableBeatmap(rulesetInfo, gameplayMods, cancellationToken); + Logger.Log($"The current beatmap is not playable in {ruleset.RulesetInfo.Name}!", level: LogLevel.Important); + return null; } if (playable.HitObjects.Count == 0) @@ -570,25 +583,24 @@ namespace osu.Game.Screens.Play } /// - /// Attempts to complete a user request to exit gameplay. + /// Attempts to complete a user request to exit gameplay, with confirmation. /// /// /// /// This should only be called in response to a user interaction. Exiting is not guaranteed. /// This will interrupt any pending progression to the results screen, even if the transition has begun. /// + /// + /// This method will show the pause or fail dialog before performing an exit. + /// If a dialog is not yet displayed, the exit will be blocked and the relevant dialog will display instead. /// - /// - /// Whether the pause or fail dialog should be shown before performing an exit. - /// If and a dialog is not yet displayed, the exit will be blocked and the relevant dialog will display instead. - /// /// Whether this call resulted in a final exit. - protected bool PerformExit(bool showDialogFirst) + protected bool PerformExitWithConfirmation() { bool pauseOrFailDialogVisible = PauseOverlay.State.Value == Visibility.Visible || FailOverlay.State.Value == Visibility.Visible; - if (showDialogFirst && !pauseOrFailDialogVisible) + if (!pauseOrFailDialogVisible) { // if the fail animation is currently in progress, accelerate it (it will show the pause dialog on completion). if (ValidForResume && GameplayState.HasFailed) @@ -607,6 +619,22 @@ namespace osu.Game.Screens.Play } } + return PerformExit(); + } + + /// + /// Attempts to complete a user request to exit gameplay. + /// + /// + /// + /// This should only be called in response to a user interaction. Exiting is not guaranteed. + /// This will interrupt any pending progression to the results screen, even if the transition has begun. + /// + /// + /// Whether the exit should perform without a transition, because the screen had faded to black already. + /// Whether this call resulted in a final exit. + protected bool PerformExit(bool skipTransition = false) + { // Matching osu!stable behaviour, if the results screen is pending and the user requests an exit, // show the results instead. if (GameplayState.HasPassed && !isRestarting) @@ -621,6 +649,8 @@ namespace osu.Game.Screens.Play // Screen may not be current if a restart has been performed. if (this.IsCurrentScreen()) { + skipExitTransition = skipTransition; + // The actual exit is performed if // - the pause / fail dialog was not requested // - the pause / fail dialog was requested but is already displayed (user showing intention to exit). @@ -689,9 +719,14 @@ namespace osu.Game.Screens.Play // stopping here is to ensure music doesn't become audible after exiting back to PlayerLoader. musicController.Stop(); - RestartRequested?.Invoke(quickRestart); + if (RestartRequested != null) + { + skipExitTransition = quickRestart; + RestartRequested?.Invoke(quickRestart); + return true; + } - return PerformExit(false); + return PerformExit(quickRestart); } /// @@ -987,14 +1022,14 @@ namespace osu.Game.Screens.Play /// /// The amount of gameplay time after which a second pause is allowed. /// - private const double pause_cooldown = 1000; + protected virtual double PauseCooldownDuration => 1000; protected PauseOverlay PauseOverlay { get; private set; } private double? lastPauseActionTime; protected bool PauseCooldownActive => - lastPauseActionTime.HasValue && GameplayClockContainer.CurrentTime < lastPauseActionTime + pause_cooldown; + lastPauseActionTime.HasValue && GameplayClockContainer.CurrentTime < lastPauseActionTime + PauseCooldownDuration; /// /// A set of conditionals which defines whether the current game state and configuration allows for @@ -1236,10 +1271,10 @@ namespace osu.Game.Screens.Play ShowUserStatistics = true, }; - private void fadeOut(bool instant = false) + private void fadeOut() { - float fadeOutDuration = instant ? 0 : 250; - this.FadeOut(fadeOutDuration); + if (!skipExitTransition) + this.FadeOut(250); if (this.IsCurrentScreen()) { @@ -1263,6 +1298,6 @@ namespace osu.Game.Screens.Play IBindable ISamplePlaybackDisabler.SamplePlaybackDisabled => samplePlaybackDisabled; - IBindable ILocalUserPlayInfo.IsPlaying => LocalUserPlaying; + public IBindable PlayingState => playingState; } } diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index 4f7e21dddf..aedc268d70 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -10,6 +10,7 @@ using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Transforms; using osu.Framework.Input; @@ -51,6 +52,8 @@ namespace osu.Game.Screens.Play public override bool? AllowGlobalTrackControl => false; + public override float BackgroundParallaxAmount => quickRestart ? 0 : 1; + // Here because IsHovered will not update unless we do so. public override bool HandlePositionalInput => true; @@ -86,9 +89,13 @@ namespace osu.Game.Screens.Play private SkinnableSound sampleRestart = null!; + private Box? quickRestartBlackLayer; + [Cached] private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + private const double quick_restart_initial_delay = 500; + protected bool BackgroundBrightnessReduction { set @@ -113,7 +120,7 @@ namespace osu.Game.Screens.Play // not ready if the user is hovering one of the panes (logo is excluded), unless they are idle. (IsHovered || osuLogo?.IsHovered == true || idleTracker.IsIdle.Value) // not ready if the user is dragging a slider or otherwise. - && inputManager.DraggedDrawable == null + && (inputManager.DraggedDrawable == null || inputManager.DraggedDrawable is OsuLogo) // not ready if a focused overlay is visible, like settings. && inputManager.FocusedDrawable == null; @@ -224,7 +231,7 @@ namespace osu.Game.Screens.Play } }, }, - idleTracker = new IdleTracker(750), + idleTracker = new IdleTracker(1500), sampleRestart = new SkinnableSound(new SampleInfo(@"Gameplay/restart", @"pause-retry-click")) }; @@ -249,7 +256,7 @@ namespace osu.Game.Screens.Play { base.LoadComplete(); - inputManager = GetContainingInputManager(); + inputManager = GetContainingInputManager()!; showStoryboards.BindValueChanged(val => epilepsyWarning?.FadeTo(val.NewValue ? 1 : 0, 250, Easing.OutQuint), true); epilepsyWarning?.FinishTransforms(true); @@ -305,6 +312,9 @@ namespace osu.Game.Screens.Play { base.OnSuspending(e); + quickRestartBlackLayer?.FadeOut(500, Easing.OutQuint).Expire(); + quickRestartBlackLayer = null; + BackgroundBrightnessReduction = false; // we're moving to player, so a period of silence is upcoming. @@ -321,6 +331,9 @@ namespace osu.Game.Screens.Play cancelLoad(); ContentOut(); + quickRestartBlackLayer?.FadeOut(100, Easing.OutQuint).Expire(); + quickRestartBlackLayer = null; + // Ensure the screen doesn't expire until all the outwards fade operations have completed. this.Delay(CONTENT_OUT_DURATION).FadeOut(); @@ -348,7 +361,14 @@ namespace osu.Game.Screens.Play if (!resuming) logo.MoveTo(new Vector2(0.5f), duration, Easing.OutQuint); logo.ScaleTo(new Vector2(0.15f), duration, Easing.OutQuint); - logo.FadeIn(350); + + if (quickRestart) + { + logo.Delay(quick_restart_initial_delay) + .FadeIn(350); + } + else + logo.FadeIn(350); Scheduler.AddDelayed(() => { @@ -387,7 +407,7 @@ namespace osu.Game.Screens.Play // We need to perform this check here rather than in OnHover as any number of children of VisualSettings // may also be handling the hover events. - if (inputManager.HoveredDrawables.Contains(VisualSettings)) + if (inputManager.HoveredDrawables.Contains(VisualSettings) || quickRestart) { // Preview user-defined background dim and blur when hovered on the visual settings panel. ApplyToBackground(b => @@ -454,16 +474,45 @@ namespace osu.Game.Screens.Play { MetadataInfo.Loading = true; - content.FadeInFromZero(500, Easing.OutQuint); - content.ScaleTo(1, 650, Easing.OutQuint).Then().Schedule(prepareNewPlayer); - - using (BeginDelayedSequence(delayBeforeSideDisplays)) + if (quickRestart) { - settingsScroll.FadeInFromZero(500, Easing.Out) - .MoveToX(0, 500, Easing.OutQuint); + // A quick restart starts by triggering a fade to black + AddInternal(quickRestartBlackLayer = new Box + { + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both, + Depth = float.MaxValue + }); - disclaimers.FadeInFromZero(500, Easing.Out) - .MoveToX(0, 500, Easing.OutQuint); + quickRestartBlackLayer + .Delay(50) + .FadeOut(5000, Easing.OutQuint); + + prepareNewPlayer(); + + content + .Delay(quick_restart_initial_delay) + .ScaleTo(1) + .FadeInFromZero(500, Easing.OutQuint); + } + else + { + content.FadeInFromZero(500, Easing.OutQuint); + + content + .ScaleTo(0.7f) + .ScaleTo(1, 650, Easing.OutQuint) + .Then() + .Schedule(prepareNewPlayer); + + using (BeginDelayedSequence(delayBeforeSideDisplays)) + { + settingsScroll.FadeInFromZero(500, Easing.Out) + .MoveToX(0, 500, Easing.OutQuint); + + disclaimers.FadeInFromZero(500, Easing.Out) + .MoveToX(0, 500, Easing.OutQuint); + } } AddRangeInternal(new[] @@ -527,33 +576,36 @@ namespace osu.Game.Screens.Play highPerformanceSession ??= highPerformanceSessionManager?.BeginSession(); scheduledPushPlayer = Scheduler.AddDelayed(() => - { - // ensure that once we have reached this "point of no return", readyForPush will be false for all future checks (until a new player instance is prepared). - var consumedPlayer = consumePlayer(); - - ContentOut(); - - TransformSequence pushSequence = this.Delay(0); - - // This goes hand-in-hand with the restoration of low pass filter in contentOut(). - this.TransformBindableTo(volumeAdjustment, 0, CONTENT_OUT_DURATION, Easing.OutCubic); - - pushSequence.Schedule(() => { - if (!this.IsCurrentScreen()) return; + // ensure that once we have reached this "point of no return", readyForPush will be false for all future checks (until a new player instance is prepared). + var consumedPlayer = consumePlayer(); - LoadTask = null; + ContentOut(); - // By default, we want to load the player and never be returned to. - // Note that this may change if the player we load requested a re-run. - ValidForResume = false; + TransformSequence pushSequence = this.Delay(0); - if (consumedPlayer.LoadedBeatmapSuccessfully) - this.Push(consumedPlayer); - else - this.Exit(); - }); - }, 500); + // This goes hand-in-hand with the restoration of low pass filter in contentOut(). + this.TransformBindableTo(volumeAdjustment, 0, CONTENT_OUT_DURATION, Easing.OutCubic); + + pushSequence.Schedule(() => + { + if (!this.IsCurrentScreen()) return; + + LoadTask = null; + + // By default, we want to load the player and never be returned to. + // Note that this may change if the player we load requested a re-run. + ValidForResume = false; + + if (consumedPlayer.LoadedBeatmapSuccessfully) + this.Push(consumedPlayer); + else + this.Exit(); + }); + }, + // When a quick restart is activated, the metadata content will display some time later if it's taking too long. + // To avoid it appearing too briefly, if it begins to fade in let's induce a standard delay. + quickRestart && content.Alpha == 0 ? 0 : 500); } private void cancelLoad() @@ -573,6 +625,9 @@ namespace osu.Game.Screens.Play // if the player never got pushed, we should explicitly dispose it. DisposalTask = LoadTask?.ContinueWith(_ => CurrentPlayer?.Dispose()); } + + highPerformanceSession?.Dispose(); + highPerformanceSession = null; } #endregion diff --git a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs index 9039604471..74b887481f 100644 --- a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs +++ b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs @@ -104,8 +104,6 @@ namespace osu.Game.Screens.Play.PlayerSettings { base.LoadComplete(); - ReferenceScore.BindValueChanged(scoreChanged, true); - beatmapOffsetSubscription = realm.SubscribeToPropertyChanged( r => r.Find(beatmap.Value.BeatmapInfo.ID)?.UserSettings, settings => settings.Offset, @@ -124,6 +122,7 @@ namespace osu.Game.Screens.Play.PlayerSettings }); Current.BindValueChanged(currentChanged); + ReferenceScore.BindValueChanged(scoreChanged, true); } private void currentChanged(ValueChangedEvent offset) @@ -196,7 +195,10 @@ namespace osu.Game.Screens.Play.PlayerSettings }, }; - if (hitEvents.Count < 10) + // affecting unstable rate here is used as a substitute of determining if a hit event represents a *timed* hit event, + // i.e. an user input that the user had to *time to the track*, + // i.e. one that it *makes sense to use* when doing anything with timing and offsets. + if (hitEvents.Count(HitEventExtensions.AffectsUnstableRate) < 10) { referenceScoreContainer.AddRange(new Drawable[] { @@ -228,7 +230,8 @@ namespace osu.Game.Screens.Play.PlayerSettings useAverageButton = new SettingsButton { Text = BeatmapOffsetControlStrings.CalibrateUsingLastPlay, - Action = () => Current.Value = lastPlayBeatmapOffset - lastPlayAverage + Action = () => Current.Value = lastPlayBeatmapOffset - lastPlayAverage, + Enabled = { Value = !Precision.AlmostEquals(lastPlayAverage, 0, Current.Precision / 2) } }, globalOffsetText = new LinkFlowContainer { diff --git a/osu.Game/Screens/Play/PlayerSettings/PlayerSliderBar.cs b/osu.Game/Screens/Play/PlayerSettings/PlayerSliderBar.cs index 88b778fafb..1fc1155c0b 100644 --- a/osu.Game/Screens/Play/PlayerSettings/PlayerSliderBar.cs +++ b/osu.Game/Screens/Play/PlayerSettings/PlayerSliderBar.cs @@ -1,7 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; +using System.Numerics; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Graphics; @@ -11,7 +11,7 @@ using osu.Game.Overlays.Settings; namespace osu.Game.Screens.Play.PlayerSettings { public partial class PlayerSliderBar : SettingsSlider - where T : struct, IEquatable, IComparable, IConvertible + where T : struct, INumber, IMinMaxValue { public RoundedSliderBar Bar => (RoundedSliderBar)Control; diff --git a/osu.Game/Screens/Play/ReplayPlayer.cs b/osu.Game/Screens/Play/ReplayPlayer.cs index ff60dbc0d0..0c125264a1 100644 --- a/osu.Game/Screens/Play/ReplayPlayer.cs +++ b/osu.Game/Screens/Play/ReplayPlayer.cs @@ -55,6 +55,16 @@ namespace osu.Game.Screens.Play this.createScore = createScore; } + /// + /// Add a settings group to the HUD overlay. Intended to be used by rulesets to add replay-specific settings. + /// + /// The settings group to be shown. + public void AddSettings(PlayerSettingsGroup settings) => Schedule(() => + { + settings.Expanded.Value = false; + HUDOverlay.PlayerSettingsOverlay.Add(settings); + }); + [BackgroundDependencyLoader] private void load(OsuConfigManager config) { diff --git a/osu.Game/Screens/Play/SkipOverlay.cs b/osu.Game/Screens/Play/SkipOverlay.cs index 29b2e5229b..362677ca5c 100644 --- a/osu.Game/Screens/Play/SkipOverlay.cs +++ b/osu.Game/Screens/Play/SkipOverlay.cs @@ -9,6 +9,7 @@ using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; +using osu.Framework.Audio.Track; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -16,6 +17,7 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Utils; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -26,7 +28,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.Play { - public partial class SkipOverlay : CompositeDrawable, IKeyBindingHandler + public partial class SkipOverlay : BeatSyncedContainer, IKeyBindingHandler { /// /// The total number of successful skips performed by this overlay. @@ -36,10 +38,9 @@ namespace osu.Game.Screens.Play private readonly double startTime; public Action RequestSkip; - private Button button; private ButtonContainer buttonContainer; - private Box remainingTimeBox; + private Circle remainingTimeBox; private FadeContainer fadeContainer; private double displayTime; @@ -51,7 +52,6 @@ namespace osu.Game.Screens.Play private IGameplayClock gameplayClock { get; set; } internal bool IsButtonVisible => fadeContainer.State == Visibility.Visible && buttonContainer.State.Value == Visibility.Visible; - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; /// @@ -87,13 +87,13 @@ namespace osu.Game.Screens.Play Anchor = Anchor.Centre, Origin = Anchor.Centre, }, - remainingTimeBox = new Box + remainingTimeBox = new Circle { Height = 5, - RelativeSizeAxes = Axes.X, - Colour = colours.Yellow, Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, + Colour = colours.Yellow, + RelativeSizeAxes = Axes.X } } } @@ -210,6 +210,18 @@ namespace osu.Game.Screens.Play { } + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + { + base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes); + + if (fadeOutBeginTime <= gameplayClock.CurrentTime) + return; + + float progress = (float)(gameplayClock.CurrentTime - displayTime) / (float)(fadeOutBeginTime - displayTime); + float newWidth = 1 - Math.Clamp(progress, 0, 1); + remainingTimeBox.ResizeWidthTo(newWidth, timingPoint.BeatLength * 2, Easing.OutQuint); + } + public partial class FadeContainer : Container, IStateful { [CanBeNull] diff --git a/osu.Game/Screens/Play/SoloSpectatorScreen.cs b/osu.Game/Screens/Play/SoloSpectatorScreen.cs index 2db751402c..269bc3bb92 100644 --- a/osu.Game/Screens/Play/SoloSpectatorScreen.cs +++ b/osu.Game/Screens/Play/SoloSpectatorScreen.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using osu.Framework.Allocation; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -13,11 +14,11 @@ using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables.Cards; using osu.Game.Configuration; +using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterfaceV2; -using osu.Game.Online.API; -using osu.Game.Online.API.Requests; +using osu.Game.Localisation; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Spectator; using osu.Game.Overlays; @@ -33,7 +34,7 @@ namespace osu.Game.Screens.Play public partial class SoloSpectatorScreen : SpectatorScreen, IPreviewTrackOwner { [Resolved] - private IAPIProvider api { get; set; } = null!; + private BeatmapLookupCache beatmapLookupCache { get; set; } = null!; [Resolved] private PreviewTrackManager previewTrackManager { get; set; } = null!; @@ -59,7 +60,7 @@ namespace osu.Game.Screens.Play /// private SpectatorGameplayState? immediateSpectatorGameplayState; - private GetBeatmapSetRequest? onlineBeatmapRequest; + private ScheduledDelegate? beatmapFetchCallback; private APIBeatmapSet? beatmapSet; @@ -138,7 +139,7 @@ namespace osu.Game.Screens.Play }, automaticDownload = new SettingsCheckbox { - LabelText = "Automatically download beatmaps", + LabelText = OnlineSettingsStrings.AutomaticallyDownloadMissingBeatmaps, Current = config.GetBindable(OsuSetting.AutomaticallyDownloadMissingBeatmaps), Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -209,7 +210,7 @@ namespace osu.Game.Screens.Play private void clearDisplay() { watchButton.Enabled.Value = false; - onlineBeatmapRequest?.Cancel(); + beatmapFetchCallback?.Cancel(); beatmapPanelContainer.Clear(); previewTrackManager.StopAnyPlaying(this); } @@ -243,15 +244,17 @@ namespace osu.Game.Screens.Play { Debug.Assert(state.BeatmapID != null); - onlineBeatmapRequest = new GetBeatmapSetRequest(state.BeatmapID.Value, BeatmapSetLookupType.BeatmapId); - onlineBeatmapRequest.Success += beatmapSet => Schedule(() => + beatmapLookupCache.GetBeatmapAsync(state.BeatmapID.Value).ContinueWith(t => beatmapFetchCallback = Schedule(() => { - this.beatmapSet = beatmapSet; - beatmapPanelContainer.Child = new BeatmapCardNormal(this.beatmapSet, allowExpansion: false); - checkForAutomaticDownload(); - }); + var beatmap = t.GetResultSafely(); - api.Queue(onlineBeatmapRequest); + if (beatmap?.BeatmapSet == null) + return; + + beatmapSet = beatmap.BeatmapSet; + beatmapPanelContainer.Child = new BeatmapCardNormal(beatmapSet, allowExpansion: false); + checkForAutomaticDownload(); + })); } private void checkForAutomaticDownload() diff --git a/osu.Game/Screens/Play/SubmittingPlayer.cs b/osu.Game/Screens/Play/SubmittingPlayer.cs index 6c5f7fab9e..24c5b2c3d4 100644 --- a/osu.Game/Screens/Play/SubmittingPlayer.cs +++ b/osu.Game/Screens/Play/SubmittingPlayer.cs @@ -234,9 +234,12 @@ namespace osu.Game.Screens.Play { if (LoadedBeatmapSuccessfully) { + // compare: https://github.com/ppy/osu/blob/ccf1acce56798497edfaf92d3ece933469edcf0a/osu.Game/Screens/Play/Player.cs#L848-L851 + var scoreCopy = Score.DeepClone(); + Task.Run(async () => { - await submitScore(Score.DeepClone()).ConfigureAwait(false); + await submitScore(scoreCopy).ConfigureAwait(false); spectatorClient.EndPlaying(GameplayState); }).FireAndForget(); } @@ -274,6 +277,16 @@ namespace osu.Game.Screens.Play return Task.CompletedTask; } + // if the user never hit anything, this score should not be counted in any way. + if (!score.ScoreInfo.Statistics.Any(s => s.Key.IsHit() && s.Value > 0)) + { + Logger.Log("No hits registered, skipping score submission"); + return Task.CompletedTask; + } + + // mind the timing of this. + // once `scoreSubmissionSource` is created, it is presumed that submission is taking place in the background, + // so all exceptional circumstances that would disallow submission must be handled above. lock (scoreSubmissionLock) { if (scoreSubmissionSource != null) @@ -282,10 +295,6 @@ namespace osu.Game.Screens.Play scoreSubmissionSource = new TaskCompletionSource(); } - // if the user never hit anything, this score should not be counted in any way. - if (!score.ScoreInfo.Statistics.Any(s => s.Key.IsHit() && s.Value > 0)) - return Task.CompletedTask; - Logger.Log($"Beginning score submission (token:{token.Value})..."); var request = CreateSubmissionRequest(score, token.Value); diff --git a/osu.Game/Screens/Ranking/CollectionButton.cs b/osu.Game/Screens/Ranking/CollectionButton.cs new file mode 100644 index 0000000000..869c6a7ff4 --- /dev/null +++ b/osu.Game/Screens/Ranking/CollectionButton.cs @@ -0,0 +1,81 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Beatmaps; +using osu.Game.Collections; +using osu.Game.Database; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osuTK; +using Realms; + +namespace osu.Game.Screens.Ranking +{ + public partial class CollectionButton : GrayButton, IHasPopover + { + private readonly BeatmapInfo beatmapInfo; + private readonly Bindable isInAnyCollection; + + [Resolved] + private RealmAccess realmAccess { get; set; } = null!; + + private IDisposable? collectionSubscription; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + public CollectionButton(BeatmapInfo beatmapInfo) + : base(FontAwesome.Solid.Book) + { + this.beatmapInfo = beatmapInfo; + isInAnyCollection = new Bindable(false); + + Size = new Vector2(75, 30); + + TooltipText = "collections"; + } + + [BackgroundDependencyLoader] + private void load() + { + Action = this.ShowPopover; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + collectionSubscription = realmAccess.RegisterForNotifications(r => r.All(), collectionsChanged); + + isInAnyCollection.BindValueChanged(_ => updateState(), true); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + collectionSubscription?.Dispose(); + } + + private void collectionsChanged(IRealmCollection sender, ChangeSet? changes) + { + isInAnyCollection.Value = sender.AsEnumerable().Any(c => c.BeatmapMD5Hashes.Contains(beatmapInfo.MD5Hash)); + } + + private void updateState() + { + Background.FadeColour(isInAnyCollection.Value ? colours.Green : colours.Gray4, 500, Easing.InOutExpo); + } + + public Popover GetPopover() => new CollectionPopover(beatmapInfo); + } +} diff --git a/osu.Game/Screens/Ranking/CollectionPopover.cs b/osu.Game/Screens/Ranking/CollectionPopover.cs new file mode 100644 index 0000000000..ffc448d7a9 --- /dev/null +++ b/osu.Game/Screens/Ranking/CollectionPopover.cs @@ -0,0 +1,68 @@ +// 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.Graphics; +using osu.Framework.Input.Events; +using osu.Game.Beatmaps; +using osu.Game.Collections; +using osu.Game.Database; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; + +namespace osu.Game.Screens.Ranking +{ + public partial class CollectionPopover : OsuPopover + { + private readonly BeatmapInfo beatmapInfo; + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + [Resolved] + private ManageCollectionsDialog? manageCollectionsDialog { get; set; } + + public CollectionPopover(BeatmapInfo beatmapInfo) + : base(false) + { + this.beatmapInfo = beatmapInfo; + + Body.CornerRadius = 4; + } + + [BackgroundDependencyLoader] + private void load() + { + Children = new[] + { + new OsuMenu(Direction.Vertical, true) + { + Items = items, + MaxHeight = 375, + }, + }; + } + + protected override void OnFocusLost(FocusLostEvent e) + { + base.OnFocusLost(e); + Hide(); + } + + private OsuMenuItem[] items + { + get + { + var collectionItems = realm.Realm.All() + .OrderBy(c => c.Name) + .AsEnumerable() + .Select(c => new CollectionToggleMenuItem(c.ToLive(realm), beatmapInfo)).Cast().ToList(); + + collectionItems.Add(new OsuMenuItem("Manage...", MenuItemType.Standard, () => manageCollectionsDialog?.Show())); + + return collectionItems.ToArray(); + } + } + } +} diff --git a/osu.Game/Screens/Ranking/Contracted/ContractedPanelTopContent.cs b/osu.Game/Screens/Ranking/Contracted/ContractedPanelTopContent.cs index 93bc7c41e1..06d127b972 100644 --- a/osu.Game/Screens/Ranking/Contracted/ContractedPanelTopContent.cs +++ b/osu.Game/Screens/Ranking/Contracted/ContractedPanelTopContent.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -16,7 +14,7 @@ namespace osu.Game.Screens.Ranking.Contracted { public readonly Bindable ScorePosition = new Bindable(); - private OsuSpriteText text; + private OsuSpriteText text = null!; public ContractedPanelTopContent() { diff --git a/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs b/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs index f04e4a6444..cebc54f490 100644 --- a/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs +++ b/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Linq; @@ -93,17 +91,17 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy private readonly ScoreInfo score; - private CircularProgress accuracyCircle; - private GradedCircles gradedCircles; - private Container badges; - private RankText rankText; + private CircularProgress accuracyCircle = null!; + private GradedCircles gradedCircles = null!; + private Container badges = null!; + private RankText rankText = null!; - private PoolableSkinnableSample scoreTickSound; - private PoolableSkinnableSample badgeTickSound; - private PoolableSkinnableSample badgeMaxSound; - private PoolableSkinnableSample swooshUpSound; - private PoolableSkinnableSample rankImpactSound; - private PoolableSkinnableSample rankApplauseSound; + private PoolableSkinnableSample? scoreTickSound; + private PoolableSkinnableSample? badgeTickSound; + private PoolableSkinnableSample? badgeMaxSound; + private PoolableSkinnableSample? swooshUpSound; + private PoolableSkinnableSample? rankImpactSound; + private PoolableSkinnableSample? rankApplauseSound; private readonly Bindable tickPlaybackRate = new Bindable(); @@ -119,7 +117,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy private readonly bool withFlair; private readonly bool isFailedSDueToMisses; - private RankText failedSRankText; + private RankText failedSRankText = null!; public AccuracyCircle(ScoreInfo score, bool withFlair = false) { @@ -229,8 +227,8 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy this.Delay(swoosh_pre_delay).Schedule(() => { - swooshUpSound.VolumeTo(swoosh_volume); - swooshUpSound.Play(); + swooshUpSound!.VolumeTo(swoosh_volume); + swooshUpSound!.Play(); }); } @@ -287,8 +285,8 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy this.TransformBindableTo(tickPlaybackRate, score_tick_debounce_rate_start); this.TransformBindableTo(tickPlaybackRate, score_tick_debounce_rate_end, ACCURACY_TRANSFORM_DURATION, Easing.OutSine); - scoreTickSound.FrequencyTo(1 + targetAccuracy, ACCURACY_TRANSFORM_DURATION, Easing.OutSine); - scoreTickSound.VolumeTo(score_tick_volume_start).Then().VolumeTo(score_tick_volume_end, ACCURACY_TRANSFORM_DURATION, Easing.OutSine); + scoreTickSound!.FrequencyTo(1 + targetAccuracy, ACCURACY_TRANSFORM_DURATION, Easing.OutSine); + scoreTickSound!.VolumeTo(score_tick_volume_start).Then().VolumeTo(score_tick_volume_end, ACCURACY_TRANSFORM_DURATION, Easing.OutSine); isTicking = true; }); @@ -314,8 +312,8 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy { var dink = badgeNum < badges.Count - 1 ? badgeTickSound : badgeMaxSound; - dink.FrequencyTo(1 + badgeNum++ * 0.05); - dink.Play(); + dink!.FrequencyTo(1 + badgeNum++ * 0.05); + dink!.Play(); }); } } @@ -331,7 +329,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy Schedule(() => { isTicking = false; - rankImpactSound.Play(); + rankImpactSound!.Play(); }); const double applause_pre_delay = 545f; @@ -341,8 +339,8 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy { Schedule(() => { - rankApplauseSound.VolumeTo(applause_volume); - rankApplauseSound.Play(); + rankApplauseSound!.VolumeTo(applause_volume); + rankApplauseSound!.Play(); }); } } diff --git a/osu.Game/Screens/Ranking/Expanded/Accuracy/RankBadge.cs b/osu.Game/Screens/Ranking/Expanded/Accuracy/RankBadge.cs index 8aea6045eb..0e798c7d6e 100644 --- a/osu.Game/Screens/Ranking/Expanded/Accuracy/RankBadge.cs +++ b/osu.Game/Screens/Ranking/Expanded/Accuracy/RankBadge.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; @@ -34,8 +32,8 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy public readonly ScoreRank Rank; - private Drawable rankContainer; - private Drawable overlay; + private Drawable rankContainer = null!; + private Drawable overlay = null!; /// /// Creates a new . diff --git a/osu.Game/Screens/Ranking/Expanded/Accuracy/RankText.cs b/osu.Game/Screens/Ranking/Expanded/Accuracy/RankText.cs index b7adcb032f..76e59b32b8 100644 --- a/osu.Game/Screens/Ranking/Expanded/Accuracy/RankText.cs +++ b/osu.Game/Screens/Ranking/Expanded/Accuracy/RankText.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -23,9 +21,9 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy { private readonly ScoreRank rank; - private BufferedContainer flash; - private BufferedContainer superFlash; - private GlowingSpriteText rankText; + private BufferedContainer flash = null!; + private BufferedContainer superFlash = null!; + private GlowingSpriteText rankText = null!; public RankText(ScoreRank rank) { diff --git a/osu.Game/Screens/Ranking/Expanded/Statistics/AccuracyStatistic.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/AccuracyStatistic.cs index f1f2c47e20..a4672a475c 100644 --- a/osu.Game/Screens/Ranking/Expanded/Statistics/AccuracyStatistic.cs +++ b/osu.Game/Screens/Ranking/Expanded/Statistics/AccuracyStatistic.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Graphics; @@ -22,7 +20,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Statistics { private readonly double accuracy; - private RollingCounter counter; + private RollingCounter counter = null!; /// /// Creates a new . diff --git a/osu.Game/Screens/Ranking/Expanded/Statistics/ComboStatistic.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/ComboStatistic.cs index 6290cee6da..7c91a37b77 100644 --- a/osu.Game/Screens/Ranking/Expanded/Statistics/ComboStatistic.cs +++ b/osu.Game/Screens/Ranking/Expanded/Statistics/ComboStatistic.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; @@ -22,7 +20,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Statistics { private readonly bool isPerfect; - private Drawable perfectText; + private Drawable perfectText = null!; /// /// Creates a new . diff --git a/osu.Game/Screens/Ranking/Expanded/Statistics/CounterStatistic.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/CounterStatistic.cs index 8528dac83b..4042724c75 100644 --- a/osu.Game/Screens/Ranking/Expanded/Statistics/CounterStatistic.cs +++ b/osu.Game/Screens/Ranking/Expanded/Statistics/CounterStatistic.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Localisation; @@ -21,7 +19,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Statistics private readonly int count; private readonly int? maxCount; - private RollingCounter counter; + private RollingCounter counter = null!; /// /// Creates a new . diff --git a/osu.Game/Screens/Ranking/Expanded/Statistics/PerformanceStatistic.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/PerformanceStatistic.cs index 8366f8d7ef..7d155e32b0 100644 --- a/osu.Game/Screens/Ranking/Expanded/Statistics/PerformanceStatistic.cs +++ b/osu.Game/Screens/Ranking/Expanded/Statistics/PerformanceStatistic.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Linq; @@ -32,7 +30,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Statistics private readonly CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(); - private RollingCounter counter; + private RollingCounter counter = null!; public PerformanceStatistic(ScoreInfo score) : base(BeatmapsetsStrings.ShowScoreboardHeaderspp) @@ -55,10 +53,10 @@ namespace osu.Game.Screens.Ranking.Expanded.Statistics var performanceCalculator = score.Ruleset.CreateInstance().CreatePerformanceCalculator(); // Performance calculation requires the beatmap and ruleset to be locally available. If not, return a default value. - if (attributes?.Attributes == null || performanceCalculator == null) + if (attributes?.DifficultyAttributes == null || performanceCalculator == null) return; - var result = await performanceCalculator.CalculateAsync(score, attributes.Value.Attributes, cancellationToken ?? default).ConfigureAwait(false); + var result = await performanceCalculator.CalculateAsync(score, attributes.Value.DifficultyAttributes, cancellationToken ?? default).ConfigureAwait(false); Schedule(() => setPerformanceValue(score, result.Total)); }, cancellationToken ?? default); @@ -107,7 +105,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Statistics protected override void Dispose(bool isDisposing) { - cancellationTokenSource?.Cancel(); + cancellationTokenSource.Cancel(); base.Dispose(isDisposing); } diff --git a/osu.Game/Screens/Ranking/Expanded/Statistics/StatisticDisplay.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/StatisticDisplay.cs index 686b6c7d47..9de60f013d 100644 --- a/osu.Game/Screens/Ranking/Expanded/Statistics/StatisticDisplay.cs +++ b/osu.Game/Screens/Ranking/Expanded/Statistics/StatisticDisplay.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.LocalisationExtensions; @@ -21,10 +19,10 @@ namespace osu.Game.Screens.Ranking.Expanded.Statistics /// public abstract partial class StatisticDisplay : CompositeDrawable { - protected SpriteText HeaderText { get; private set; } + protected SpriteText HeaderText { get; private set; } = null!; private readonly LocalisableString header; - private Drawable content; + private Drawable content = null!; /// /// Creates a new . diff --git a/osu.Game/Screens/Ranking/FavouriteButton.cs b/osu.Game/Screens/Ranking/FavouriteButton.cs new file mode 100644 index 0000000000..019b80dde9 --- /dev/null +++ b/osu.Game/Screens/Ranking/FavouriteButton.cs @@ -0,0 +1,156 @@ +// 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.Sprites; +using osu.Framework.Logging; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables.Cards; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Resources.Localisation.Web; +using osuTK; + +namespace osu.Game.Screens.Ranking +{ + public partial class FavouriteButton : GrayButton + { + public readonly BeatmapSetInfo BeatmapSetInfo; + private APIBeatmapSet? beatmapSet; + private readonly Bindable current; + + private PostBeatmapFavouriteRequest? favouriteRequest; + private LoadingLayer loading = null!; + + private readonly IBindable localUser = new Bindable(); + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + public FavouriteButton(BeatmapSetInfo beatmapSetInfo) + : base(FontAwesome.Regular.Heart) + { + BeatmapSetInfo = beatmapSetInfo; + current = new BindableWithCurrent(new BeatmapSetFavouriteState(false, 0)); + + Size = new Vector2(75, 30); + } + + [BackgroundDependencyLoader] + private void load() + { + Add(loading = new LoadingLayer(true, false)); + + Action = toggleFavouriteStatus; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + current.BindValueChanged(_ => updateState(), true); + + localUser.BindTo(api.LocalUser); + localUser.BindValueChanged(_ => updateUser(), true); + } + + private void getBeatmapSet() + { + GetBeatmapSetRequest beatmapSetRequest = new GetBeatmapSetRequest(BeatmapSetInfo.OnlineID); + + loading.Show(); + beatmapSetRequest.Success += beatmapSet => + { + this.beatmapSet = beatmapSet; + current.Value = new BeatmapSetFavouriteState(this.beatmapSet.HasFavourited, this.beatmapSet.FavouriteCount); + + loading.Hide(); + Enabled.Value = true; + }; + beatmapSetRequest.Failure += e => + { + Logger.Log($"Favourite button failed to fetch beatmap info: {e}", LoggingTarget.Network); + + Schedule(() => + { + loading.Hide(); + Enabled.Value = false; + TooltipText = "this beatmap cannot be favourited"; + }); + }; + api.Queue(beatmapSetRequest); + } + + private void toggleFavouriteStatus() + { + if (beatmapSet == null) + return; + + Enabled.Value = false; + loading.Show(); + + var actionType = current.Value.Favourited ? BeatmapFavouriteAction.UnFavourite : BeatmapFavouriteAction.Favourite; + + favouriteRequest?.Cancel(); + favouriteRequest = new PostBeatmapFavouriteRequest(beatmapSet.OnlineID, actionType); + + favouriteRequest.Success += () => + { + bool favourited = actionType == BeatmapFavouriteAction.Favourite; + + current.Value = new BeatmapSetFavouriteState(favourited, current.Value.FavouriteCount + (favourited ? 1 : -1)); + + Enabled.Value = true; + loading.Hide(); + }; + favouriteRequest.Failure += e => + { + Logger.Error(e, $"Failed to {actionType.ToString().ToLowerInvariant()} beatmap: {e.Message}"); + + Schedule(() => + { + Enabled.Value = true; + loading.Hide(); + }); + }; + + api.Queue(favouriteRequest); + } + + private void updateUser() + { + if (!(localUser.Value is GuestUser) && BeatmapSetInfo.OnlineID > 0) + getBeatmapSet(); + else + { + Enabled.Value = false; + current.Value = new BeatmapSetFavouriteState(false, 0); + updateState(); + TooltipText = BeatmapsetsStrings.ShowDetailsFavouriteLogin; + } + } + + private void updateState() + { + if (current.Value.Favourited) + { + Background.Colour = colours.Green; + Icon.Icon = FontAwesome.Solid.Heart; + TooltipText = BeatmapsetsStrings.ShowDetailsUnfavourite; + } + else + { + Background.Colour = colours.Gray4; + Icon.Icon = FontAwesome.Regular.Heart; + TooltipText = BeatmapsetsStrings.ShowDetailsFavourite; + } + } + } +} diff --git a/osu.Game/Screens/Ranking/ReplayDownloadButton.cs b/osu.Game/Screens/Ranking/ReplayDownloadButton.cs index df5f9c7a8a..5e2161c251 100644 --- a/osu.Game/Screens/Ranking/ReplayDownloadButton.cs +++ b/osu.Game/Screens/Ranking/ReplayDownloadButton.cs @@ -18,7 +18,7 @@ namespace osu.Game.Screens.Ranking { public partial class ReplayDownloadButton : CompositeDrawable, IKeyBindingHandler { - public readonly Bindable Score = new Bindable(); + public readonly Bindable Score = new Bindable(); protected readonly Bindable State = new Bindable(); @@ -44,7 +44,7 @@ namespace osu.Game.Screens.Ranking } } - public ReplayDownloadButton(ScoreInfo score) + public ReplayDownloadButton(ScoreInfo? score) { Score.Value = score; Size = new Vector2(50, 30); @@ -67,11 +67,11 @@ namespace osu.Game.Screens.Ranking switch (State.Value) { case DownloadState.LocallyAvailable: - game?.PresentScore(Score.Value, ScorePresentType.Gameplay); + game?.PresentScore(Score.Value!, ScorePresentType.Gameplay); break; case DownloadState.NotDownloaded: - scoreDownloader.Download(Score.Value); + scoreDownloader.Download(Score.Value!); break; case DownloadState.Importing: @@ -88,6 +88,8 @@ namespace osu.Game.Screens.Ranking State.ValueChanged -= exportWhenReady; downloadTracker?.RemoveAndDisposeImmediately(); + downloadTracker = null; + State.SetDefault(); if (score.NewValue != null) { @@ -147,7 +149,7 @@ namespace osu.Game.Screens.Ranking { if (state.NewValue != DownloadState.LocallyAvailable) return; - scoreManager.Export(Score.Value); + scoreManager.Export(Score.Value!); State.ValueChanged -= exportWhenReady; } diff --git a/osu.Game/Screens/Ranking/ResultsScreen.cs b/osu.Game/Screens/Ranking/ResultsScreen.cs index 1c3518909d..0209fbd39c 100644 --- a/osu.Game/Screens/Ranking/ResultsScreen.cs +++ b/osu.Game/Screens/Ranking/ResultsScreen.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -11,6 +12,7 @@ using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; @@ -75,7 +77,7 @@ namespace osu.Game.Screens.Ranking /// /// Whether the user's personal statistics should be shown on the extended statistics panel - /// after clicking the score panel associated with the being presented. + /// after clicking the score panel associated with the being presented. /// Requires to be present. /// public bool ShowUserStatistics { get; init; } @@ -96,73 +98,77 @@ namespace osu.Game.Screens.Ranking popInSample = audio.Samples.Get(@"UI/overlay-pop-in"); - InternalChild = new GridContainer + InternalChild = new PopoverContainer { RelativeSizeAxes = Axes.Both, - Content = new[] + Child = new GridContainer { - new Drawable[] + RelativeSizeAxes = Axes.Both, + Content = new[] { - VerticalScrollContent = new VerticalScrollContainer + new Drawable[] { - RelativeSizeAxes = Axes.Both, - ScrollbarVisible = false, - Child = new Container + VerticalScrollContent = new VerticalScrollContainer { RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - StatisticsPanel = createStatisticsPanel().With(panel => - { - panel.RelativeSizeAxes = Axes.Both; - panel.Score.BindTarget = SelectedScore; - }), - ScorePanelList = new ScorePanelList - { - RelativeSizeAxes = Axes.Both, - SelectedScore = { BindTarget = SelectedScore }, - PostExpandAction = () => StatisticsPanel.ToggleVisibility() - }, - detachedPanelContainer = new Container - { - RelativeSizeAxes = Axes.Both - }, - } - } - }, - }, - new[] - { - bottomPanel = new Container - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.X, - Height = TwoLayerButton.SIZE_EXTENDED.Y, - Alpha = 0, - Children = new Drawable[] - { - new Box + ScrollbarVisible = false, + Child = new Container { RelativeSizeAxes = Axes.Both, - Colour = Color4Extensions.FromHex("#333") - }, - buttons = new FillFlowContainer + Children = new Drawable[] + { + StatisticsPanel = createStatisticsPanel().With(panel => + { + panel.RelativeSizeAxes = Axes.Both; + panel.Score.BindTarget = SelectedScore; + }), + ScorePanelList = new ScorePanelList + { + RelativeSizeAxes = Axes.Both, + SelectedScore = { BindTarget = SelectedScore }, + PostExpandAction = () => StatisticsPanel.ToggleVisibility() + }, + detachedPanelContainer = new Container + { + RelativeSizeAxes = Axes.Both + }, + } + } + }, + }, + new[] + { + bottomPanel = new Container + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.X, + Height = TwoLayerButton.SIZE_EXTENDED.Y, + Alpha = 0, + Children = new Drawable[] { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - AutoSizeAxes = Axes.Both, - Spacing = new Vector2(5), - Direction = FillDirection.Horizontal + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex("#333") + }, + buttons = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(5), + Direction = FillDirection.Horizontal + }, } } } + }, + RowDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.AutoSize) } - }, - RowDimensions = new[] - { - new Dimension(), - new Dimension(GridSizeMode.AutoSize) } }; @@ -178,11 +184,11 @@ namespace osu.Game.Screens.Ranking Scheduler.AddDelayed(() => OverlayActivationMode.Value = OverlayActivation.All, shouldFlair ? AccuracyCircle.TOTAL_DURATION + 1000 : 0); } - if (SelectedScore.Value != null && AllowWatchingReplay) + if (AllowWatchingReplay) { buttons.Add(new ReplayDownloadButton(SelectedScore.Value) { - Score = { BindTarget = SelectedScore! }, + Score = { BindTarget = SelectedScore }, Width = 300 }); } @@ -201,6 +207,12 @@ namespace osu.Game.Screens.Ranking }, }); } + + if (Score?.BeatmapInfo != null) + buttons.Add(new CollectionButton(Score.BeatmapInfo)); + + if (Score?.BeatmapInfo?.BeatmapSet != null && Score.BeatmapInfo.BeatmapSet.OnlineID > 0) + buttons.Add(new FavouriteButton(Score.BeatmapInfo.BeatmapSet)); } protected override void LoadComplete() @@ -266,7 +278,8 @@ namespace osu.Game.Screens.Ranking foreach (var s in scores) addScore(s); - lastFetchCompleted = true; + // allow a frame for scroll container to adjust its dimensions with the added scores before fetching again. + Schedule(() => lastFetchCompleted = true); if (ScorePanelList.IsEmpty) { @@ -329,6 +342,7 @@ namespace osu.Game.Screens.Ranking { if (state.NewValue == Visibility.Visible) { + Debug.Assert(SelectedScore.Value != null); // Detach the panel in its original location, and move into the desired location in the local container. var expandedPanel = ScorePanelList.GetPanelForScore(SelectedScore.Value); var screenSpacePos = expandedPanel.ScreenSpaceDrawQuad.TopLeft; @@ -396,7 +410,8 @@ namespace osu.Game.Screens.Ranking break; case GlobalAction.Select: - StatisticsPanel.ToggleVisibility(); + if (SelectedScore.Value != null) + StatisticsPanel.ToggleVisibility(); return true; } diff --git a/osu.Game/Screens/Ranking/ScorePanel.cs b/osu.Game/Screens/Ranking/ScorePanel.cs index 1f7ba3692a..85da1afe7b 100644 --- a/osu.Game/Screens/Ranking/ScorePanel.cs +++ b/osu.Game/Screens/Ranking/ScorePanel.cs @@ -1,10 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; -using JetBrains.Annotations; using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -83,8 +80,7 @@ namespace osu.Game.Screens.Ranking private static readonly Color4 contracted_top_layer_colour = Color4Extensions.FromHex("#353535"); private static readonly Color4 contracted_middle_layer_colour = Color4Extensions.FromHex("#353535"); - [CanBeNull] - public event Action StateChanged; + public event Action? StateChanged; /// /// The position of the score in the rankings. @@ -94,28 +90,30 @@ namespace osu.Game.Screens.Ranking /// /// An action to be invoked if this is clicked while in an expanded state. /// - public Action PostExpandAction; + public Action? PostExpandAction; public readonly ScoreInfo Score; [Resolved] - private OsuGameBase game { get; set; } + private OsuGameBase game { get; set; } = null!; - private AudioContainer audioContent; + private AudioContainer audioContent = null!; private bool displayWithFlair; - private Container topLayerContainer; - private Drawable topLayerBackground; - private Container topLayerContentContainer; - private Drawable topLayerContent; + private Container topLayerContainer = null!; + private Drawable topLayerBackground = null!; + private Container topLayerContentContainer = null!; + private Drawable? topLayerContent; - private Container middleLayerContainer; - private Drawable middleLayerBackground; - private Container middleLayerContentContainer; - private Drawable middleLayerContent; + private Container middleLayerContainer = null!; + private Drawable middleLayerBackground = null!; + private Container middleLayerContentContainer = null!; + private Drawable? middleLayerContent; - private DrawableSample samplePanelFocus; + private ScorePanelTrackingContainer? trackingContainer; + + private DrawableSample? samplePanelFocus; public ScorePanel(ScoreInfo score, bool isNewLocalScore = false) { @@ -227,7 +225,7 @@ namespace osu.Game.Screens.Ranking protected override void Update() { base.Update(); - audioContent.Balance.Value = ((ScreenSpaceDrawQuad.Centre.X / game.ScreenSpaceDrawQuad.Width) * 2 - 1) * OsuGameBase.SFX_STEREO_STRENGTH; + audioContent.Balance.Value = (Math.Clamp(ScreenSpaceDrawQuad.Centre.X / game.ScreenSpaceDrawQuad.Width, -1, 1) * 2 - 1) * OsuGameBase.SFX_STEREO_STRENGTH; } private void playAppearSample() @@ -334,8 +332,6 @@ namespace osu.Game.Screens.Ranking || topLayerContainer.ReceivePositionalInputAt(screenSpacePos) || middleLayerContainer.ReceivePositionalInputAt(screenSpacePos); - private ScorePanelTrackingContainer trackingContainer; - /// /// Creates a which this can reside inside. /// The will track the size of this . diff --git a/osu.Game/Screens/Ranking/ScorePanelList.cs b/osu.Game/Screens/Ranking/ScorePanelList.cs index 95c90e35a0..e711bed729 100644 --- a/osu.Game/Screens/Ranking/ScorePanelList.cs +++ b/osu.Game/Screens/Ranking/ScorePanelList.cs @@ -1,14 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading; -using JetBrains.Annotations; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -64,14 +61,14 @@ namespace osu.Game.Screens.Ranking /// /// An action to be invoked if a is clicked while in an expanded state. /// - public Action PostExpandAction; + public Action? PostExpandAction; - public readonly Bindable SelectedScore = new Bindable(); + public readonly Bindable SelectedScore = new Bindable(); private readonly CancellationTokenSource loadCancellationSource = new CancellationTokenSource(); private readonly Flow flow; private readonly Scroll scroll; - private ScorePanel expandedPanel; + private ScorePanel? expandedPanel; /// /// Creates a new . @@ -174,7 +171,7 @@ namespace osu.Game.Screens.Ranking /// Brings a to the centre of the screen and expands it. /// /// The to present. - private void selectedScoreChanged(ValueChangedEvent score) + private void selectedScoreChanged(ValueChangedEvent score) { // avoid contracting panels unnecessarily when TriggerChange is fired manually. if (score.OldValue != null && !score.OldValue.Equals(score.NewValue)) @@ -317,7 +314,7 @@ namespace osu.Game.Screens.Ranking protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - loadCancellationSource?.Cancel(); + loadCancellationSource.Cancel(); } private partial class Flow : FillFlowContainer @@ -326,11 +323,9 @@ namespace osu.Game.Screens.Ranking public int GetPanelIndex(ScoreInfo score) => applySorting(Children).TakeWhile(s => !s.Panel.Score.Equals(score)).Count(); - [CanBeNull] - public ScoreInfo GetPreviousScore(ScoreInfo score) => applySorting(Children).TakeWhile(s => !s.Panel.Score.Equals(score)).LastOrDefault()?.Panel.Score; + public ScoreInfo? GetPreviousScore(ScoreInfo score) => applySorting(Children).TakeWhile(s => !s.Panel.Score.Equals(score)).LastOrDefault()?.Panel.Score; - [CanBeNull] - public ScoreInfo GetNextScore(ScoreInfo score) => applySorting(Children).SkipWhile(s => !s.Panel.Score.Equals(score)).ElementAtOrDefault(1)?.Panel.Score; + public ScoreInfo? GetNextScore(ScoreInfo score) => applySorting(Children).SkipWhile(s => !s.Panel.Score.Equals(score)).ElementAtOrDefault(1)?.Panel.Score; private IEnumerable applySorting(IEnumerable drawables) => drawables.OfType() .OrderByDescending(GetLayoutPosition) diff --git a/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs b/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs index 1260ec2339..a9b93e0ffc 100644 --- a/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs +++ b/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; -using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -59,7 +58,7 @@ namespace osu.Game.Screens.Ranking.Statistics /// The s to display the timing distribution of. public HitEventTimingDistributionGraph(IReadOnlyList hitEvents) { - this.hitEvents = hitEvents.Where(e => !(e.HitObject.HitWindows is HitWindows.EmptyHitWindows) && e.Result.IsHit()).ToList(); + this.hitEvents = hitEvents.Where(e => !(e.HitObject.HitWindows is HitWindows.EmptyHitWindows) && e.Result.IsBasic() && e.Result.IsHit()).ToList(); bins = Enumerable.Range(0, total_timing_distribution_bins).Select(_ => new Dictionary()).ToArray>(); } @@ -177,7 +176,7 @@ namespace osu.Game.Screens.Ranking.Statistics for (int i = 1; i <= axis_points; i++) { double axisValue = i * axisValueStep; - float position = (float)(axisValue / maxValue); + float position = maxValue == 0 ? 0 : (float)(axisValue / maxValue); float alpha = 1f - position * 0.8f; axisFlow.Add(new OsuSpriteText @@ -282,7 +281,7 @@ namespace osu.Game.Screens.Ranking.Statistics protected override bool OnInvalidate(Invalidation invalidation, InvalidationSource source) { - if (invalidation.HasFlagFast(Invalidation.DrawSize)) + if (invalidation.HasFlag(Invalidation.DrawSize)) { if (lastDrawHeight != null && lastDrawHeight != DrawHeight) Scheduler.AddOnce(updateMetrics, false); @@ -349,7 +348,7 @@ namespace osu.Game.Screens.Ranking.Statistics boxAdjustment.FadeTo(!hasAdjustment ? 0 : 1, duration, Easing.OutQuint); } - private float offsetForValue(float value) => (1 - minimum_height) * value / maxValue; + private float offsetForValue(float value) => maxValue == 0 ? 0 : (1 - minimum_height) * value / maxValue; private float heightForValue(float value) => minimum_height + offsetForValue(value); } diff --git a/osu.Game/Screens/Ranking/Statistics/PerformanceBreakdownChart.cs b/osu.Game/Screens/Ranking/Statistics/PerformanceBreakdownChart.cs index 8b13f0951c..f9c8c93dec 100644 --- a/osu.Game/Screens/Ranking/Statistics/PerformanceBreakdownChart.cs +++ b/osu.Game/Screens/Ranking/Statistics/PerformanceBreakdownChart.cs @@ -1,13 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Linq; using System.Threading; -using JetBrains.Annotations; +using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Extensions; using osu.Framework.Extensions.Color4Extensions; @@ -29,23 +27,21 @@ namespace osu.Game.Screens.Ranking.Statistics public partial class PerformanceBreakdownChart : Container { private readonly ScoreInfo score; - private readonly IBeatmap playableBeatmap; - private Drawable spinner; - private Drawable content; - private GridContainer chart; - private OsuSpriteText achievedPerformance; - private OsuSpriteText maximumPerformance; + private Drawable spinner = null!; + private Drawable content = null!; + private GridContainer chart = null!; + private OsuSpriteText achievedPerformance = null!; + private OsuSpriteText maximumPerformance = null!; private readonly CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(); [Resolved] - private BeatmapDifficultyCache difficultyCache { get; set; } + private BeatmapDifficultyCache difficultyCache { get; set; } = null!; public PerformanceBreakdownChart(ScoreInfo score, IBeatmap playableBeatmap) { this.score = score; - this.playableBeatmap = playableBeatmap; } [BackgroundDependencyLoader] @@ -145,12 +141,33 @@ namespace osu.Game.Screens.Ranking.Statistics spinner.Show(); - new PerformanceBreakdownCalculator(playableBeatmap, difficultyCache) - .CalculateAsync(score, cancellationTokenSource.Token) - .ContinueWith(t => Schedule(() => setPerformanceValue(t.GetResultSafely()))); + computePerformance(cancellationTokenSource.Token) + .ContinueWith(t => Schedule(() => + { + if (t.GetResultSafely() is PerformanceBreakdown breakdown) + setPerformance(breakdown); + }), TaskContinuationOptions.OnlyOnRanToCompletion); } - private void setPerformanceValue(PerformanceBreakdown breakdown) + private async Task computePerformance(CancellationToken token) + { + var performanceCalculator = score.Ruleset.CreateInstance().CreatePerformanceCalculator(); + if (performanceCalculator == null) + return null; + + var starsTask = difficultyCache.GetDifficultyAsync(score.BeatmapInfo!, score.Ruleset, score.Mods, token).ConfigureAwait(false); + if (await starsTask is not StarDifficulty stars) + return null; + + if (stars.DifficultyAttributes == null || stars.PerformanceAttributes == null) + return null; + + return new PerformanceBreakdown( + await performanceCalculator.CalculateAsync(score, stars.DifficultyAttributes, token).ConfigureAwait(false), + stars.PerformanceAttributes); + } + + private void setPerformance(PerformanceBreakdown breakdown) { spinner.Hide(); content.FadeIn(200); @@ -189,8 +206,7 @@ namespace osu.Game.Screens.Ranking.Statistics maximumPerformance.Text = Math.Round(perfectAttribute.Value, MidpointRounding.AwayFromZero).ToLocalisableString(); } - [CanBeNull] - private Drawable[] createAttributeRow(PerformanceDisplayAttribute attribute, PerformanceDisplayAttribute perfectAttribute) + private Drawable[]? createAttributeRow(PerformanceDisplayAttribute attribute, PerformanceDisplayAttribute perfectAttribute) { // Don't display the attribute if its maximum is 0 // For example, flashlight bonus would be zero if flashlight mod isn't on @@ -239,7 +255,9 @@ namespace osu.Game.Screens.Ranking.Statistics protected override void Dispose(bool isDisposing) { - cancellationTokenSource?.Cancel(); + cancellationTokenSource.Cancel(); + cancellationTokenSource.Dispose(); + base.Dispose(isDisposing); } } diff --git a/osu.Game/Screens/Ranking/Statistics/SimpleStatisticItem.cs b/osu.Game/Screens/Ranking/Statistics/SimpleStatisticItem.cs index 23ccc3d0b7..d8de1b07b5 100644 --- a/osu.Game/Screens/Ranking/Statistics/SimpleStatisticItem.cs +++ b/osu.Game/Screens/Ranking/Statistics/SimpleStatisticItem.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; @@ -61,7 +59,7 @@ namespace osu.Game.Screens.Ranking.Statistics /// public partial class SimpleStatisticItem : SimpleStatisticItem { - private TValue value; + private TValue value = default!; /// /// The statistic's value to be displayed. @@ -80,7 +78,7 @@ namespace osu.Game.Screens.Ranking.Statistics /// Used to convert to a text representation. /// Defaults to using . /// - protected virtual string DisplayValue(TValue value) => value.ToString(); + protected virtual string DisplayValue(TValue value) => value!.ToString() ?? string.Empty; public SimpleStatisticItem(string name) : base(name) diff --git a/osu.Game/Screens/Ranking/Statistics/SimpleStatisticTable.cs b/osu.Game/Screens/Ranking/Statistics/SimpleStatisticTable.cs index 4abf0007a7..da79fdb12b 100644 --- a/osu.Game/Screens/Ranking/Statistics/SimpleStatisticTable.cs +++ b/osu.Game/Screens/Ranking/Statistics/SimpleStatisticTable.cs @@ -1,12 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -24,14 +21,14 @@ namespace osu.Game.Screens.Ranking.Statistics private readonly SimpleStatisticItem[] items; private readonly int columnCount; - private FillFlowContainer[] columns; + private FillFlowContainer[] columns = null!; /// /// Creates a statistic row for the supplied s. /// /// The number of columns to layout the into. /// The s to display in this row. - public SimpleStatisticTable(int columnCount, [ItemNotNull] IEnumerable items) + public SimpleStatisticTable(int columnCount, IEnumerable items) { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(columnCount); diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs b/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs index 19bd0c4393..f9f5254bc2 100644 --- a/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs +++ b/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System.Collections.Generic; using System.Linq; using System.Threading; @@ -28,19 +26,20 @@ namespace osu.Game.Screens.Ranking.Statistics { public const float SIDE_PADDING = 30; - public readonly Bindable Score = new Bindable(); + public readonly Bindable Score = new Bindable(); protected override bool StartHidden => true; [Resolved] - private BeatmapManager beatmapManager { get; set; } + private BeatmapManager beatmapManager { get; set; } = null!; private readonly Container content; private readonly LoadingSpinner spinner; private bool wasOpened; - private Sample popInSample; - private Sample popOutSample; + private Sample? popInSample; + private Sample? popOutSample; + private CancellationTokenSource? loadCancellation; public StatisticsPanel() { @@ -71,9 +70,7 @@ namespace osu.Game.Screens.Ranking.Statistics popOutSample = audio.Samples.Get(@"Results/statistics-panel-pop-out"); } - private CancellationTokenSource loadCancellation; - - private void populateStatistics(ValueChangedEvent score) + private void populateStatistics(ValueChangedEvent score) { loadCancellation?.Cancel(); loadCancellation = null; @@ -187,7 +184,7 @@ namespace osu.Game.Screens.Ranking.Statistics LoadComponentAsync(container, d => { - if (!Score.Value.Equals(newScore)) + if (Score.Value?.Equals(newScore) != true) return; spinner.Hide(); diff --git a/osu.Game/Screens/Ranking/Statistics/UserStatisticsPanel.cs b/osu.Game/Screens/Ranking/Statistics/UserStatisticsPanel.cs index fa3bb1a375..4e9c07ab7b 100644 --- a/osu.Game/Screens/Ranking/Statistics/UserStatisticsPanel.cs +++ b/osu.Game/Screens/Ranking/Statistics/UserStatisticsPanel.cs @@ -37,7 +37,7 @@ namespace osu.Game.Screens.Ranking.Statistics { if (update.NewValue?.Score.MatchesOnlineID(achievedScore) == true) DisplayedUserStatisticsUpdate.Value = update.NewValue; - }); + }, true); } } diff --git a/osu.Game/Screens/Select/BeatmapCarousel.cs b/osu.Game/Screens/Select/BeatmapCarousel.cs index 32a1b5cb58..95268c35da 100644 --- a/osu.Game/Screens/Select/BeatmapCarousel.cs +++ b/osu.Game/Screens/Select/BeatmapCarousel.cs @@ -3,14 +3,15 @@ using System; using System.Collections.Generic; +using System.Collections.Specialized; using System.Diagnostics; using System.Linq; +using System.Threading; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Caching; -using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Pooling; @@ -22,8 +23,8 @@ using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Database; +using osu.Game.Extensions; using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Cursor; using osu.Game.Input.Bindings; using osu.Game.Screens.Select.Carousel; using osuTK; @@ -78,8 +79,6 @@ namespace osu.Game.Screens.Select private CarouselBeatmapSet? selectedBeatmapSet; - private List originalBeatmapSetsDetached = new List(); - /// /// Raised when the is changed. /// @@ -111,28 +110,40 @@ namespace osu.Game.Screens.Select [Cached] protected readonly CarouselScrollContainer Scroll; + [Resolved] + private RealmAccess realm { get; set; } = null!; + + [Resolved] + private DetachedBeatmapStore? detachedBeatmapStore { get; set; } + + private IBindableList? detachedBeatmapSets; + private readonly NoResultsPlaceholder noResultsPlaceholder; private IEnumerable beatmapSets => root.Items.OfType(); - // todo: only used for testing, maybe remove. - private bool loadedTestBeatmaps; - - public IEnumerable BeatmapSets + internal IEnumerable BeatmapSets { get => beatmapSets.Select(g => g.BeatmapSet); set { - loadedTestBeatmaps = true; - Schedule(() => loadBeatmapSets(value)); + if (LoadState != LoadState.NotLoaded) + throw new InvalidOperationException("If not using a realm source, beatmap sets must be set before load."); + + detachedBeatmapSets = new BindableList(value); + Schedule(loadNewRoot); } } - private void loadBeatmapSets(IEnumerable beatmapSets) + private void loadNewRoot() { - originalBeatmapSetsDetached = beatmapSets.Detach(); + beatmapsSplitOut = activeCriteria.SplitOutDifficulties; - if (selectedBeatmapSet != null && !originalBeatmapSetsDetached.Contains(selectedBeatmapSet.BeatmapSet)) + // Ensure no changes are made to the list while we are initialising items. + // We'll catch up on changes via subscriptions anyway. + BeatmapSetInfo[] loadableSets = detachedBeatmapSets!.ToArray(); + + if (selectedBeatmapSet != null && !loadableSets.Contains(selectedBeatmapSet.BeatmapSet, EqualityComparer.Default)) selectedBeatmapSet = null; var selectedBeatmapBefore = selectedBeatmap?.BeatmapInfo; @@ -141,7 +152,7 @@ namespace osu.Game.Screens.Select if (beatmapsSplitOut) { - var carouselBeatmapSets = originalBeatmapSetsDetached.SelectMany(s => s.Beatmaps).Select(b => + var carouselBeatmapSets = loadableSets.SelectMany(s => s.Beatmaps).Select(b => { return createCarouselSet(new BeatmapSetInfo(new[] { b }) { @@ -155,25 +166,18 @@ namespace osu.Game.Screens.Select } else { - var carouselBeatmapSets = originalBeatmapSetsDetached.Select(createCarouselSet).OfType(); + var carouselBeatmapSets = loadableSets.Select(createCarouselSet).OfType(); newRoot.AddItems(carouselBeatmapSets); } root = newRoot; + root.Filter(activeCriteria); Scroll.Clear(false); itemsCache.Invalidate(); ScrollToSelected(); - applyActiveCriteria(false); - - if (loadedTestBeatmaps) - { - invalidateAfterChange(); - BeatmapSetsLoaded = true; - } - // Restore selection if (selectedBeatmapBefore != null && newRoot.BeatmapSetsByID.TryGetValue(selectedBeatmapBefore.BeatmapSet!.ID, out var newSelectionCandidates)) { @@ -182,6 +186,12 @@ namespace osu.Game.Screens.Select if (found != null) found.State.Value = CarouselItemState.Selected; } + + Schedule(() => + { + invalidateAfterChange(); + BeatmapSetsLoaded = true; + }); } private readonly List visibleItems = new List(); @@ -197,10 +207,7 @@ namespace osu.Game.Screens.Select private CarouselRoot root; - private IDisposable? subscriptionSets; - private IDisposable? subscriptionDeletedSets; private IDisposable? subscriptionBeatmaps; - private IDisposable? subscriptionHiddenBeatmaps; private readonly DrawablePool setPool = new DrawablePool(100); @@ -209,18 +216,12 @@ namespace osu.Game.Screens.Select private int visibleSetsCount; - public BeatmapCarousel() + public BeatmapCarousel(FilterCriteria initialCriteria) { root = new CarouselRoot(this); - InternalChild = new OsuContextMenuContainer + InternalChild = new Container { RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding - { - // Avoid clash between scrollbar and osu! logo. - Top = 10, - Bottom = 100, - }, Children = new Drawable[] { setPool, @@ -231,10 +232,12 @@ namespace osu.Game.Screens.Select noResultsPlaceholder = new NoResultsPlaceholder() } }; + + activeCriteria = initialCriteria; } [BackgroundDependencyLoader] - private void load(OsuConfigManager config, AudioManager audio) + private void load(OsuConfigManager config, AudioManager audio, CancellationToken? cancellationToken) { spinSample = audio.Samples.Get("SongSelect/random-spin"); randomSelectSample = audio.Samples.Get(@"SongSelect/select-random"); @@ -245,159 +248,123 @@ namespace osu.Game.Screens.Select RightClickScrollingEnabled.ValueChanged += enabled => Scroll.RightMouseScrollbar = enabled.NewValue; RightClickScrollingEnabled.TriggerChange(); - if (!loadedTestBeatmaps) + if (detachedBeatmapStore != null && detachedBeatmapSets == null) { - realm.Run(r => loadBeatmapSets(getBeatmapSets(r))); + // This is performing an unnecessary second lookup on realm (in addition to the subscription), but for performance reasons + // we require it to be separate: the subscription's initial callback (with `ChangeSet` of `null`) will run on the update + // thread. If we attempt to detach beatmaps in this callback the game will fall over (it takes time). + detachedBeatmapSets = detachedBeatmapStore.GetDetachedBeatmaps(cancellationToken); + detachedBeatmapSets.BindCollectionChanged(beatmapSetsChanged); + loadNewRoot(); } } - [Resolved] - private RealmAccess realm { get; set; } = null!; - protected override void LoadComplete() { base.LoadComplete(); - subscriptionSets = realm.RegisterForNotifications(getBeatmapSets, beatmapSetsChanged); subscriptionBeatmaps = realm.RegisterForNotifications(r => r.All().Where(b => !b.Hidden), beatmapsChanged); - - // Can't use main subscriptions because we can't lookup deleted indices. - // https://github.com/realm/realm-dotnet/discussions/2634#discussioncomment-1605595. - subscriptionDeletedSets = realm.RegisterForNotifications(r => r.All().Where(s => s.DeletePending && !s.Protected), deletedBeatmapSetsChanged); - subscriptionHiddenBeatmaps = realm.RegisterForNotifications(r => r.All().Where(b => b.Hidden), beatmapsChanged); } - private void deletedBeatmapSetsChanged(IRealmCollection sender, ChangeSet? changes) + private readonly HashSet setsRequiringUpdate = new HashSet(); + private readonly HashSet setsRequiringRemoval = new HashSet(); + + private void beatmapSetsChanged(object? beatmaps, NotifyCollectionChangedEventArgs changed) { - // If loading test beatmaps, avoid overwriting with realm subscription callbacks. - if (loadedTestBeatmaps) - return; + IEnumerable? newBeatmapSets = changed.NewItems?.Cast(); - if (changes == null) - return; - - var removeableSets = changes.InsertedIndices.Select(i => sender[i].ID).ToHashSet(); - - // This schedule is required to retain selection of beatmaps over an ImportAsUpdate operation. - // This is covered by TestPlaySongSelect.TestSelectionRetainedOnBeatmapUpdate. - // - // In short, we have specialised logic in `beatmapSetsChanged` (directly below) to infer that an - // update operation has occurred. For this to work, we need to confirm the `DeletePending` flag - // of the current selection. - // - // If we don't schedule the following code, it is possible for the `deleteBeatmapSetsChanged` handler - // to be invoked before the `beatmapSetsChanged` handler (realm call order seems non-deterministic) - // which will lead to the currently selected beatmap changing via `CarouselGroupEagerSelect`. - // - // We need a better path forward here. A few ideas: - // - Avoid the necessity of having realm subscriptions on deleted/hidden items, maybe by storing all guids in realm - // to a local list so we can better look them up on receiving `DeletedIndices`. - // - Add a new property on `BeatmapSetInfo` to link to the pre-update set, and use that to handle the update case. - Schedule(() => + switch (changed.Action) { - foreach (var set in removeableSets) - removeBeatmapSet(set); + case NotifyCollectionChangedAction.Add: + HashSet newBeatmapSetIDs = newBeatmapSets!.Select(s => s.ID).ToHashSet(); - invalidateAfterChange(); - }); + setsRequiringRemoval.RemoveWhere(s => newBeatmapSetIDs.Contains(s.ID)); + setsRequiringUpdate.AddRange(newBeatmapSets!); + break; + + case NotifyCollectionChangedAction.Remove: + IEnumerable oldBeatmapSets = changed.OldItems!.Cast(); + HashSet oldBeatmapSetIDs = oldBeatmapSets.Select(s => s.ID).ToHashSet(); + + setsRequiringUpdate.RemoveWhere(s => oldBeatmapSetIDs.Contains(s.ID)); + setsRequiringRemoval.AddRange(oldBeatmapSets); + break; + + case NotifyCollectionChangedAction.Replace: + setsRequiringUpdate.AddRange(newBeatmapSets!); + break; + + case NotifyCollectionChangedAction.Move: + setsRequiringUpdate.AddRange(newBeatmapSets!); + break; + + case NotifyCollectionChangedAction.Reset: + setsRequiringRemoval.Clear(); + setsRequiringUpdate.Clear(); + loadNewRoot(); + break; + } + + Scheduler.AddOnce(processBeatmapChanges); } - private void beatmapSetsChanged(IRealmCollection sender, ChangeSet? changes) + // All local operations must be scheduled. + // + // If we don't schedule, beatmaps getting changed while song select is suspended (ie. last played being updated) + // will cause unexpected sounds and operations to occur in the background. + private void processBeatmapChanges() { - // If loading test beatmaps, avoid overwriting with realm subscription callbacks. - if (loadedTestBeatmaps) - return; - - var setsRequiringUpdate = new HashSet(); - var setsRequiringRemoval = new HashSet(); - - if (changes == null) + try { - // During initial population, we must manually account for the fact that our original query was done on an async thread. - // Since then, there may have been imports or deletions. - // Here we manually catch up on any changes. - var realmSets = new HashSet(); + // To handle the beatmap update flow, attempt to track selection changes across delete-insert transactions. + // When an update occurs, the previous beatmap set is either soft or hard deleted. + // Check if the current selection was potentially deleted by re-querying its validity. + bool selectedSetMarkedDeleted = SelectedBeatmapSet != null && fetchFromID(SelectedBeatmapSet.ID)?.DeletePending != false; - for (int i = 0; i < sender.Count; i++) - realmSets.Add(sender[i].ID); + foreach (var set in setsRequiringRemoval) removeBeatmapSet(set.ID); - foreach (var id in realmSets) + foreach (var set in setsRequiringUpdate) updateBeatmapSet(set); + + if (setsRequiringRemoval.Count > 0 && SelectedBeatmapInfo != null) { - if (!root.BeatmapSetsByID.ContainsKey(id)) - setsRequiringUpdate.Add(realm.Realm.Find(id)!.Detach()); - } + // If SelectedBeatmapInfo is non-null, the set should also be non-null. + Debug.Assert(SelectedBeatmapSet != null); - foreach (var id in root.BeatmapSetsByID.Keys) - { - if (!realmSets.Contains(id)) - setsRequiringRemoval.Add(id); - } - } - else - { - foreach (int i in changes.NewModifiedIndices) - setsRequiringUpdate.Add(sender[i].Detach()); - - foreach (int i in changes.InsertedIndices) - setsRequiringUpdate.Add(sender[i].Detach()); - } - - // All local operations must be scheduled. - // - // If we don't schedule, beatmaps getting changed while song select is suspended (ie. last played being updated) - // will cause unexpected sounds and operations to occur in the background. - Schedule(() => - { - try - { - foreach (var set in setsRequiringRemoval) - removeBeatmapSet(set); - - foreach (var set in setsRequiringUpdate) - updateBeatmapSet(set); - - if (changes?.DeletedIndices.Length > 0 && SelectedBeatmapInfo != null) + if (selectedSetMarkedDeleted && setsRequiringUpdate.Any()) { - // If SelectedBeatmapInfo is non-null, the set should also be non-null. - Debug.Assert(SelectedBeatmapSet != null); - - // To handle the beatmap update flow, attempt to track selection changes across delete-insert transactions. - // When an update occurs, the previous beatmap set is either soft or hard deleted. - // Check if the current selection was potentially deleted by re-querying its validity. - bool selectedSetMarkedDeleted = realm.Run(r => r.Find(SelectedBeatmapSet.ID)?.DeletePending != false); - - if (selectedSetMarkedDeleted && setsRequiringUpdate.Any()) + // If it is no longer valid, make the bold assumption that an updated version will be available in the modified/inserted indices. + // This relies on the full update operation being in a single transaction, so please don't change that. + foreach (var set in setsRequiringUpdate) { - // If it is no longer valid, make the bold assumption that an updated version will be available in the modified/inserted indices. - // This relies on the full update operation being in a single transaction, so please don't change that. - foreach (var set in setsRequiringUpdate) + foreach (var beatmapInfo in set.Beatmaps) { - foreach (var beatmapInfo in set.Beatmaps) - { - if (!((IBeatmapMetadataInfo)beatmapInfo.Metadata).Equals(SelectedBeatmapInfo.Metadata)) - continue; + if (!((IBeatmapMetadataInfo)beatmapInfo.Metadata).Equals(SelectedBeatmapInfo.Metadata)) continue; - // Best effort matching. We can't use ID because in the update flow a new version will get its own GUID. - if (beatmapInfo.DifficultyName == SelectedBeatmapInfo.DifficultyName) - { - SelectBeatmap(beatmapInfo); - return; - } + // Best effort matching. We can't use ID because in the update flow a new version will get its own GUID. + if (beatmapInfo.DifficultyName == SelectedBeatmapInfo.DifficultyName) + { + SelectBeatmap(beatmapInfo); + return; } } - - // If a direct selection couldn't be made, it's feasible that the difficulty name (or beatmap metadata) changed. - // Let's attempt to follow set-level selection anyway. - SelectBeatmap(setsRequiringUpdate.First().Beatmaps.First()); } + + // If a direct selection couldn't be made, it's feasible that the difficulty name (or beatmap metadata) changed. + // Let's attempt to follow set-level selection anyway. + SelectBeatmap(setsRequiringUpdate.First().Beatmaps.First()); } } - finally - { - BeatmapSetsLoaded = true; - invalidateAfterChange(); - } - }); + } + finally + { + BeatmapSetsLoaded = true; + invalidateAfterChange(); + } + + setsRequiringRemoval.Clear(); + setsRequiringUpdate.Clear(); + + BeatmapSetInfo? fetchFromID(Guid id) => realm.Realm.Find(id); } private void beatmapsChanged(IRealmCollection sender, ChangeSet? changes) @@ -429,8 +396,6 @@ namespace osu.Game.Screens.Select invalidateAfterChange(); } - private IQueryable getBeatmapSets(Realm realm) => realm.All().Where(s => !s.DeletePending && !s.Protected); - public void RemoveBeatmapSet(BeatmapSetInfo beatmapSet) => Schedule(() => { removeBeatmapSet(beatmapSet.ID); @@ -442,8 +407,6 @@ namespace osu.Game.Screens.Select if (!root.BeatmapSetsByID.TryGetValue(beatmapSetID, out var existingSets)) return; - originalBeatmapSetsDetached.RemoveAll(set => set.ID == beatmapSetID); - foreach (var set in existingSets) { foreach (var beatmap in set.Beatmaps) @@ -462,9 +425,6 @@ namespace osu.Game.Screens.Select private void updateBeatmapSet(BeatmapSetInfo beatmapSet) { - originalBeatmapSetsDetached.RemoveAll(set => set.ID == beatmapSet.ID); - originalBeatmapSetsDetached.Add(beatmapSet.Detach()); - var newSets = new List(); if (beatmapsSplitOut) @@ -693,7 +653,7 @@ namespace osu.Game.Screens.Select item.State.Value = CarouselItemState.Selected; } - private FilterCriteria activeCriteria = new FilterCriteria(); + private FilterCriteria activeCriteria; protected ScheduledDelegate? PendingFilter; @@ -730,17 +690,17 @@ namespace osu.Game.Screens.Select } } - public void Filter(FilterCriteria? newCriteria, bool debounce = true) + public void Filter(FilterCriteria? newCriteria) { if (newCriteria != null) activeCriteria = newCriteria; - applyActiveCriteria(debounce); + applyActiveCriteria(true); } private bool beatmapsSplitOut; - private void applyActiveCriteria(bool debounce, bool alwaysResetScrollPosition = true) + private void applyActiveCriteria(bool debounce) { PendingFilter?.Cancel(); PendingFilter = null; @@ -762,16 +722,14 @@ namespace osu.Game.Screens.Select if (activeCriteria.SplitOutDifficulties != beatmapsSplitOut) { - beatmapsSplitOut = activeCriteria.SplitOutDifficulties; - loadBeatmapSets(originalBeatmapSetsDetached); + loadNewRoot(); return; } root.Filter(activeCriteria); itemsCache.Invalidate(); - if (alwaysResetScrollPosition || !Scroll.UserScrolling) - ScrollToSelected(true); + ScrollToSelected(true); FilterApplied?.Invoke(); } @@ -828,7 +786,7 @@ namespace osu.Game.Screens.Select protected override bool OnInvalidate(Invalidation invalidation, InvalidationSource source) { // handles the vertical size of the carousel changing (ie. on window resize when aspect ratio has changed). - if (invalidation.HasFlagFast(Invalidation.DrawSize)) + if (invalidation.HasFlag(Invalidation.DrawSize)) itemsCache.Invalidate(); return base.OnInvalidate(invalidation, source); @@ -893,9 +851,9 @@ namespace osu.Game.Screens.Select // Add those items within the previously found index range that should be displayed. foreach (var item in toDisplay) { - var panel = setPool.Get(p => p.Item = item); + var panel = setPool.Get(); - panel.Depth = item.CarouselYPosition; + panel.Item = item; panel.Y = item.CarouselYPosition; Scroll.Add(panel); @@ -915,6 +873,8 @@ namespace osu.Game.Screens.Select { bool isSelected = item.Item.State.Value == CarouselItemState.Selected; + bool hasPassedSelection = item.Item.CarouselYPosition < selectedBeatmapSet?.CarouselYPosition; + // Cheap way of doing animations when entering / exiting song select. const double half_time = 50; const float panel_x_offset_when_inactive = 200; @@ -929,12 +889,14 @@ namespace osu.Game.Screens.Select item.Alpha = (float)Interpolation.DampContinuously(item.Alpha, 0, half_time, Clock.ElapsedFrameTime); item.X = (float)Interpolation.DampContinuously(item.X, panel_x_offset_when_inactive, half_time, Clock.ElapsedFrameTime); } + + Scroll.ChangeChildDepth(item, hasPassedSelection ? -item.Item.CarouselYPosition : item.Item.CarouselYPosition); } if (item is DrawableCarouselBeatmapSet set) { - foreach (var diff in set.DrawableBeatmaps) - updateItem(diff, item); + for (int i = 0; i < set.DrawableBeatmaps.Count; i++) + updateItem(set.DrawableBeatmaps[i], item); } } } @@ -1000,8 +962,6 @@ namespace osu.Game.Screens.Select return set; } - private const float panel_padding = 5; - /// /// Computes the target Y positions for every item in the carousel. /// @@ -1023,10 +983,18 @@ namespace osu.Game.Screens.Select { case CarouselBeatmapSet set: { + bool isSelected = item.State.Value == CarouselItemState.Selected; + + float padding = isSelected ? 5 : -5; + + if (isSelected) + // double padding because we want to cancel the negative padding from the last item. + currentY += padding * 2; + visibleItems.Add(set); set.CarouselYPosition = currentY; - if (item.State.Value == CarouselItemState.Selected) + if (isSelected) { // scroll position at currentY makes the set panel appear at the very top of the carousel's screen space // move down by half of visible height (height of the carousel's visible extent, including semi-transparent areas) @@ -1048,7 +1016,7 @@ namespace osu.Game.Screens.Select } } - currentY += set.TotalHeight + panel_padding; + currentY += set.TotalHeight + padding; break; } } @@ -1061,7 +1029,7 @@ namespace osu.Game.Screens.Select itemsCache.Validate(); // update and let external consumers know about selection loss. - if (BeatmapSetsLoaded) + if (BeatmapSetsLoaded && AllowSelection) { bool selectionLost = selectedBeatmapSet != null && selectedBeatmapSet.State.Value != CarouselItemState.Selected; @@ -1128,7 +1096,7 @@ namespace osu.Game.Screens.Select } /// - /// Update a item's x position and multiplicative alpha based on its y position and + /// Update an item's x position and multiplicative alpha based on its y position and /// the current scroll position. /// /// The item to be updated. @@ -1142,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 @@ -1279,7 +1242,7 @@ namespace osu.Game.Screens.Select { // we need to block right click absolute scrolling when hovering a carousel item so context menus can display. // this can be reconsidered when we have an alternative to right click scrolling. - if (GetContainingInputManager().HoveredDrawables.OfType().Any()) + if (GetContainingInputManager()!.HoveredDrawables.OfType().Any()) { rightMouseScrollBlocked = true; return false; @@ -1297,16 +1260,45 @@ 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) { base.Dispose(isDisposing); - subscriptionSets?.Dispose(); - subscriptionDeletedSets?.Dispose(); subscriptionBeatmaps?.Dispose(); - subscriptionHiddenBeatmaps?.Dispose(); } } } diff --git a/osu.Game/Screens/Select/BeatmapDeleteDialog.cs b/osu.Game/Screens/Select/BeatmapDeleteDialog.cs index e98af8cca2..16f0cbe65e 100644 --- a/osu.Game/Screens/Select/BeatmapDeleteDialog.cs +++ b/osu.Game/Screens/Select/BeatmapDeleteDialog.cs @@ -7,14 +7,14 @@ using osu.Game.Overlays.Dialog; namespace osu.Game.Screens.Select { - public partial class BeatmapDeleteDialog : DangerousActionDialog + public partial class BeatmapDeleteDialog : DeletionDialog { private readonly BeatmapSetInfo beatmapSet; public BeatmapDeleteDialog(BeatmapSetInfo beatmapSet) { this.beatmapSet = beatmapSet; - BodyText = $@"{beatmapSet.Metadata.Artist} - {beatmapSet.Metadata.Title}"; + BodyText = beatmapSet.Metadata.GetDisplayTitleRomanisable(false); } [BackgroundDependencyLoader] diff --git a/osu.Game/Screens/Select/BeatmapInfoWedge.cs b/osu.Game/Screens/Select/BeatmapInfoWedge.cs index 3cab4b67b6..3b0fdc3e47 100644 --- a/osu.Game/Screens/Select/BeatmapInfoWedge.cs +++ b/osu.Game/Screens/Select/BeatmapInfoWedge.cs @@ -402,9 +402,7 @@ namespace osu.Game.Screens.Select return; // this doesn't consider mods which apply variable rates, yet. - double rate = 1; - foreach (var mod in mods.Value.OfType()) - rate = mod.ApplyToRate(0, rate); + double rate = ModUtils.CalculateRateWithMods(mods.Value); int bpmMax = FormatUtils.RoundBPM(beatmap.ControlPointInfo.BPMMaximum, rate); int bpmMin = FormatUtils.RoundBPM(beatmap.ControlPointInfo.BPMMinimum, rate); @@ -492,7 +490,7 @@ namespace osu.Game.Screens.Select RelativeSizeAxes = Axes.Both, Colour = Color4Extensions.FromHex(@"f7dd55"), Icon = FontAwesome.Regular.Circle, - Size = new Vector2(0.8f) + Size = new Vector2(0.7f) }, statistic.CreateIcon().With(i => { @@ -500,7 +498,7 @@ namespace osu.Game.Screens.Select i.Origin = Anchor.Centre; i.RelativeSizeAxes = Axes.Both; i.Colour = Color4Extensions.FromHex(@"f7dd55"); - i.Size = new Vector2(0.64f); + i.Size = new Vector2(0.6f); }), } }, diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs index 8f38ae710c..3947cefb91 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs @@ -66,6 +66,8 @@ namespace osu.Game.Screens.Select.Carousel match &= !criteria.OverallDifficulty.HasFilter || criteria.OverallDifficulty.IsInRange(BeatmapInfo.Difficulty.OverallDifficulty); match &= !criteria.Length.HasFilter || criteria.Length.IsInRange(BeatmapInfo.Length); match &= !criteria.LastPlayed.HasFilter || criteria.LastPlayed.IsInRange(BeatmapInfo.LastPlayed ?? DateTimeOffset.MinValue); + match &= !criteria.DateRanked.HasFilter || (BeatmapInfo.BeatmapSet?.DateRanked != null && criteria.DateRanked.IsInRange(BeatmapInfo.BeatmapSet.DateRanked.Value)); + match &= !criteria.DateSubmitted.HasFilter || (BeatmapInfo.BeatmapSet?.DateSubmitted != null && criteria.DateSubmitted.IsInRange(BeatmapInfo.BeatmapSet.DateSubmitted.Value)); match &= !criteria.BPM.HasFilter || criteria.BPM.IsInRange(BeatmapInfo.BPM); match &= !criteria.BeatDivisor.HasFilter || criteria.BeatDivisor.IsInRange(BeatmapInfo.BeatDivisor); diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs index f725d98342..75c13c1be6 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs @@ -25,12 +25,15 @@ using osu.Game.Graphics; using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; using osu.Game.Overlays; using osu.Game.Resources.Localisation.Web; 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 { @@ -79,6 +82,12 @@ namespace osu.Game.Screens.Select.Carousel [Resolved] private IBindable> mods { get; set; } = null!; + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private OsuGame? game { get; set; } + private IBindable starDifficultyBindable = null!; private CancellationTokenSource? starDifficultyCancellationSource; @@ -288,8 +297,11 @@ 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(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 bd659d7423..996d9ea0ab 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -19,7 +20,10 @@ 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; namespace osu.Game.Screens.Select.Carousel { @@ -39,7 +43,16 @@ namespace osu.Game.Screens.Select.Carousel [Resolved] private RealmAccess realm { get; set; } = null!; - public IEnumerable DrawableBeatmaps => beatmapContainer?.IsLoaded != true ? Enumerable.Empty() : beatmapContainer.AliveChildren; + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private OsuGame? game { get; set; } + + [Resolved] + private IBindable ruleset { get; set; } = null!; + + public IReadOnlyList DrawableBeatmaps => beatmapContainer?.IsLoaded != true ? Array.Empty() : beatmapContainer; private Container? beatmapContainer; @@ -287,6 +300,9 @@ namespace osu.Game.Screens.Select.Carousel if (beatmapSet.Beatmaps.Any(b => b.Hidden)) items.Add(new OsuMenuItem("Restore all hidden", MenuItemType.Standard, () => restoreHiddenRequested(beatmapSet))); + if (beatmapSet.GetOnlineURL(api, ruleset.Value) is string 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)))); return items.ToArray(); diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselItem.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselItem.cs index 0c3de5848b..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(); @@ -144,9 +142,9 @@ namespace osu.Game.Screens.Select.Carousel } if (!Item.Visible) - this.FadeOut(300, Easing.OutQuint); + this.FadeOut(100, Easing.OutQuint); else - this.FadeIn(250); + this.FadeIn(400, Easing.OutQuint); } protected virtual void Selected() @@ -166,6 +164,8 @@ namespace osu.Game.Screens.Select.Carousel return true; } + protected override bool OnHover(HoverEvent e) => true; + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game/Screens/Select/Details/AdvancedStats.cs b/osu.Game/Screens/Select/Details/AdvancedStats.cs index cb820f4da9..b7086d2416 100644 --- a/osu.Game/Screens/Select/Details/AdvancedStats.cs +++ b/osu.Game/Screens/Select/Details/AdvancedStats.cs @@ -3,6 +3,7 @@ #nullable disable +using System; using osuTK.Graphics; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; @@ -27,6 +28,7 @@ using osu.Game.Configuration; using osu.Game.Resources.Localisation.Web; using osu.Game.Rulesets; using osu.Game.Overlays.Mods; +using osu.Game.Utils; namespace osu.Game.Screens.Select.Details { @@ -35,9 +37,6 @@ namespace osu.Game.Screens.Select.Details [Resolved] private BeatmapDifficultyCache difficultyCache { get; set; } - [Resolved] - private IBindable> mods { get; set; } - protected readonly StatisticRow FirstValue, HpDrain, Accuracy, ApproachRate; private readonly StatisticRow starDifficulty; @@ -68,6 +67,14 @@ namespace osu.Game.Screens.Select.Details /// public Bindable Ruleset { get; } = new Bindable(); + /// + /// Mods to be used for certain elements of display. + /// + /// + /// No checks are done as to whether the mods specified are valid for the current . + /// + public Bindable> Mods { get; } = new Bindable>(Array.Empty()); + public AdvancedStats(int columns = 1) { switch (columns) @@ -142,8 +149,7 @@ namespace osu.Game.Screens.Select.Details base.LoadComplete(); Ruleset.BindValueChanged(_ => updateStatistics()); - - mods.BindValueChanged(modsChanged, true); + Mods.BindValueChanged(modsChanged, true); } private ModSettingChangeTracker modSettingChangeTracker; @@ -172,16 +178,14 @@ namespace osu.Game.Screens.Select.Details { BeatmapDifficulty originalDifficulty = new BeatmapDifficulty(baseDifficulty); - foreach (var mod in mods.Value.OfType()) + foreach (var mod in Mods.Value.OfType()) mod.ApplyToDifficulty(originalDifficulty); adjustedDifficulty = originalDifficulty; if (Ruleset.Value != null) { - double rate = 1; - foreach (var mod in mods.Value.OfType()) - rate = mod.ApplyToRate(0, rate); + double rate = ModUtils.CalculateRateWithMods(Mods.Value); adjustedDifficulty = Ruleset.Value.CreateInstance().GetRateAdjustedDisplayDifficulty(originalDifficulty, rate); @@ -199,7 +203,7 @@ namespace osu.Game.Screens.Select.Details // For the time being, the key count is static no matter what, because: // a) The method doesn't have knowledge of the active keymods. Doing so may require considerations for filtering. // b) Using the difficulty adjustment mod to adjust OD doesn't have an effect on conversion. - int keyCount = baseDifficulty == null ? 0 : legacyRuleset.GetKeyCount(BeatmapInfo, mods.Value); + int keyCount = baseDifficulty == null ? 0 : legacyRuleset.GetKeyCount(BeatmapInfo, Mods.Value); FirstValue.Title = BeatmapsetsStrings.ShowStatsCsMania; FirstValue.Value = (keyCount, keyCount); @@ -237,7 +241,7 @@ namespace osu.Game.Screens.Select.Details starDifficultyCancellationSource = new CancellationTokenSource(); var normalStarDifficultyTask = difficultyCache.GetDifficultyAsync(BeatmapInfo, Ruleset.Value, null, starDifficultyCancellationSource.Token); - var moddedStarDifficultyTask = difficultyCache.GetDifficultyAsync(BeatmapInfo, Ruleset.Value, mods.Value, starDifficultyCancellationSource.Token); + var moddedStarDifficultyTask = difficultyCache.GetDifficultyAsync(BeatmapInfo, Ruleset.Value, Mods.Value, starDifficultyCancellationSource.Token); Task.WhenAll(normalStarDifficultyTask, moddedStarDifficultyTask).ContinueWith(_ => Schedule(() => { diff --git a/osu.Game/Screens/Select/FilterControl.cs b/osu.Game/Screens/Select/FilterControl.cs index 73c122dda6..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, }, @@ -245,7 +244,7 @@ namespace osu.Game.Screens.Select searchTextBox.ReadOnly = true; searchTextBox.HoldFocus = false; if (searchTextBox.HasFocus) - GetContainingInputManager().ChangeFocus(searchTextBox); + GetContainingFocusManager()!.ChangeFocus(searchTextBox); } public void Activate() diff --git a/osu.Game/Screens/Select/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs index 190efd0fb0..77d7ff0e9f 100644 --- a/osu.Game/Screens/Select/FilterCriteria.cs +++ b/osu.Game/Screens/Select/FilterCriteria.cs @@ -37,6 +37,8 @@ namespace osu.Game.Screens.Select public OptionalRange BeatDivisor; public OptionalSet OnlineStatus = new OptionalSet(); public OptionalRange LastPlayed; + public OptionalRange DateRanked; + public OptionalRange DateSubmitted; public OptionalTextFilter Creator; public OptionalTextFilter Artist; public OptionalTextFilter Title; diff --git a/osu.Game/Screens/Select/FilterQueryParser.cs b/osu.Game/Screens/Select/FilterQueryParser.cs index 4e49495f47..ccffd34dc2 100644 --- a/osu.Game/Screens/Select/FilterQueryParser.cs +++ b/osu.Game/Screens/Select/FilterQueryParser.cs @@ -40,6 +40,7 @@ namespace osu.Game.Screens.Select { case "star": case "stars": + case "sr": return TryUpdateCriteriaRange(ref criteria.StarDifficulty, op, value, 0.01d / 2); case "ar": @@ -61,10 +62,37 @@ namespace osu.Game.Screens.Select case "length": return tryUpdateLengthRange(criteria, op, value); - case "played": case "lastplayed": return tryUpdateDateAgoRange(ref criteria.LastPlayed, op, value); + case "ranked": + return tryUpdateRankedDateRange(ref criteria.DateRanked, op, value); + + case "submitted": + return tryUpdateRankedDateRange(ref criteria.DateSubmitted, op, value); + + case "played": + if (!tryParseBool(value, out bool played)) + return false; + + // Unplayed beatmaps are filtered on DateTimeOffset.MinValue. + + if (played) + { + criteria.LastPlayed.Min = DateTimeOffset.MinValue; + criteria.LastPlayed.Max = DateTimeOffset.MaxValue; + criteria.LastPlayed.IsLowerInclusive = false; + } + else + { + criteria.LastPlayed.Min = DateTimeOffset.MinValue; + criteria.LastPlayed.Max = DateTimeOffset.MinValue; + criteria.LastPlayed.IsLowerInclusive = true; + criteria.LastPlayed.IsUpperInclusive = true; + } + + return true; + case "divisor": return TryUpdateCriteriaRange(ref criteria.BeatDivisor, op, value, tryParseInt); @@ -132,6 +160,25 @@ namespace osu.Game.Screens.Select private static bool tryParseInt(string value, out int result) => int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out result); + private static bool tryParseBool(string value, out bool result) + { + switch (value) + { + case "1": + case "yes": + result = true; + return true; + + case "0": + case "no": + result = false; + return true; + + default: + return bool.TryParse(value, out result); + } + } + private static bool tryParseEnum(string value, out TEnum result) where TEnum : struct { // First try an exact match. @@ -551,5 +598,163 @@ namespace osu.Game.Screens.Select return tryUpdateCriteriaRange(ref dateRange, op, dateTimeOffset.Value); } + + /// + /// Helper function for building a UTC date from only the year, month and day. + /// UTC is used to keep consistent search results with osu!web. + /// + private static DateTimeOffset dateTimeOffsetFromDateOnly(int year, int month, int day) => + new DateTimeOffset(year, month, day, 0, 0, 0, TimeSpan.Zero); + + /// + /// Parses a string containing a ranked or submitted date filter. + /// Returns a boolean depending on whether parsing was successful or not. + /// Accepted dates are in the formats `yyyy`, `yyyy-mm` and `yyyy-mm-dd`. + /// Leading zeros are accepted. Numbers can be separated by `-`, `/`, or `.` + /// + /// The to store the parsed data into, if successful. + /// The operator of the filtering query + /// The string value to attempt parsing for. + private static bool tryUpdateRankedDateRange(ref FilterCriteria.OptionalRange dateRange, Operator op, string val) + { + GroupCollection? match = tryMatchRegex(val, @"^(?\d+)([-/.](?\d+)([-/.](?\d+))?)?$"); + + if (match == null) + return false; + + int? year = null; + int? month = null; + int? day = null; + + List keys = new List { @"year", @"month", @"day" }; + + foreach (string key in keys) + { + if (!match.TryGetValue(key, out var group) || !group.Success) + continue; + + if (group.Success) + { + if (!tryParseDoubleWithPoint(group.Value, out double value)) + return false; + + switch (key) + { + case @"year": + year = (int)value; + break; + + case @"month": + month = (int)value; + break; + + case @"day": + day = (int)value; + break; + } + } + } + + if (year == null) + { + return false; + } + + try + { + DateTimeOffset dateTimeOffset; + + switch (op) + { + case Operator.Less: + month ??= 1; + day ??= 1; + + dateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value); + return tryUpdateCriteriaRange(ref dateRange, op, dateTimeOffset); + + case Operator.LessOrEqual: + if (month == null) + { + month = 1; + day = 1; + dateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddYears(1); + return tryUpdateCriteriaRange(ref dateRange, Operator.Less, dateTimeOffset); + } + + if (day == null) + { + day = 1; + dateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddMonths(1); + return tryUpdateCriteriaRange(ref dateRange, Operator.Less, dateTimeOffset); + } + + dateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddDays(1); + return tryUpdateCriteriaRange(ref dateRange, Operator.Less, dateTimeOffset); + + case Operator.GreaterOrEqual: + month ??= 1; + day ??= 1; + + dateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value); + return tryUpdateCriteriaRange(ref dateRange, op, dateTimeOffset); + + case Operator.Greater: + if (month == null) + { + month = 1; + day = 1; + dateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddYears(1); + return tryUpdateCriteriaRange(ref dateRange, Operator.GreaterOrEqual, dateTimeOffset); + } + + if (day == null) + { + day = 1; + dateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddMonths(1); + return tryUpdateCriteriaRange(ref dateRange, Operator.GreaterOrEqual, dateTimeOffset); + } + + dateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddDays(1); + return tryUpdateCriteriaRange(ref dateRange, Operator.GreaterOrEqual, dateTimeOffset); + + case Operator.Equal: + + DateTimeOffset minDateTimeOffset; + DateTimeOffset maxDateTimeOffset; + + if (month == null) + { + month = 1; + day = 1; + minDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value); + maxDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddYears(1); + return tryUpdateCriteriaRange(ref dateRange, Operator.GreaterOrEqual, minDateTimeOffset) + && tryUpdateCriteriaRange(ref dateRange, Operator.Less, maxDateTimeOffset); + } + + if (day == null) + { + day = 1; + minDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value); + maxDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddMonths(1); + return tryUpdateCriteriaRange(ref dateRange, Operator.GreaterOrEqual, minDateTimeOffset) + && tryUpdateCriteriaRange(ref dateRange, Operator.Less, maxDateTimeOffset); + } + + minDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value); + maxDateTimeOffset = dateTimeOffsetFromDateOnly(year.Value, month.Value, day.Value).AddDays(1); + return tryUpdateCriteriaRange(ref dateRange, Operator.GreaterOrEqual, minDateTimeOffset) + && tryUpdateCriteriaRange(ref dateRange, Operator.Less, maxDateTimeOffset); + + default: + return false; + } + } + catch (ArgumentOutOfRangeException) + { + return false; + } + } } } diff --git a/osu.Game/Screens/Select/Footer.cs b/osu.Game/Screens/Select/Footer.cs index 933df2464a..1d05f644b7 100644 --- a/osu.Game/Screens/Select/Footer.cs +++ b/osu.Game/Screens/Select/Footer.cs @@ -6,12 +6,11 @@ using System.Collections.Generic; using System.Linq; using osuTK; -using osuTK.Graphics; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; +using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; namespace osu.Game.Screens.Select @@ -82,14 +81,15 @@ namespace osu.Game.Screens.Select { RelativeSizeAxes = Axes.Both, Size = Vector2.One, - Colour = Color4.Black.Opacity(0.5f), + Colour = OsuColour.Gray(0.1f), + Alpha = 0.96f, }, modeLight = new Box { RelativeSizeAxes = Axes.X, Height = 3, Position = new Vector2(0, -3), - Colour = Color4.Black, + Colour = OsuColour.Gray(0.1f), }, new FillFlowContainer { diff --git a/osu.Game/Screens/Select/FooterButtonMods.cs b/osu.Game/Screens/Select/FooterButtonMods.cs index 5685910c0a..a15d315f1b 100644 --- a/osu.Game/Screens/Select/FooterButtonMods.cs +++ b/osu.Game/Screens/Select/FooterButtonMods.cs @@ -59,8 +59,8 @@ namespace osu.Game.Screens.Select { SelectedColour = colours.Yellow; DeselectedColour = SelectedColour.Opacity(0.5f); - lowMultiplierColour = colours.Red; - highMultiplierColour = colours.Green; + lowMultiplierColour = colours.Green; + highMultiplierColour = colours.Red; Text = @"mods"; Hotkey = GlobalAction.ToggleModSelection; diff --git a/osu.Game/Screens/Select/FooterV2/FooterButtonModsV2.cs b/osu.Game/Screens/Select/FooterV2/FooterButtonModsV2.cs deleted file mode 100644 index b8c9f0b34b..0000000000 --- a/osu.Game/Screens/Select/FooterV2/FooterButtonModsV2.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics; - -namespace osu.Game.Screens.Select.FooterV2 -{ - public partial class FooterButtonModsV2 : FooterButtonV2 - { - [BackgroundDependencyLoader] - private void load(OsuColour colour) - { - Text = "Mods"; - Icon = FontAwesome.Solid.ExchangeAlt; - AccentColour = colour.Lime1; - } - } -} diff --git a/osu.Game/Screens/Select/FooterV2/FooterButtonOptionsV2.cs b/osu.Game/Screens/Select/FooterV2/FooterButtonOptionsV2.cs deleted file mode 100644 index a1559d32dc..0000000000 --- a/osu.Game/Screens/Select/FooterV2/FooterButtonOptionsV2.cs +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Extensions; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.UserInterface; -using osu.Game.Graphics; -using osu.Game.Input.Bindings; - -namespace osu.Game.Screens.Select.FooterV2 -{ - public partial class FooterButtonOptionsV2 : FooterButtonV2, IHasPopover - { - public readonly BindableBool IsActive = new BindableBool(); - - [BackgroundDependencyLoader] - private void load(OsuColour colour) - { - Text = "Options"; - Icon = FontAwesome.Solid.Cog; - AccentColour = colour.Purple1; - Hotkey = GlobalAction.ToggleBeatmapOptions; - - Action = () => IsActive.Toggle(); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - IsActive.BindValueChanged(active => - { - OverlayState.Value = active.NewValue ? Visibility.Visible : Visibility.Hidden; - }); - - OverlayState.BindValueChanged(state => - { - switch (state.NewValue) - { - case Visibility.Hidden: - this.HidePopover(); - break; - - case Visibility.Visible: - this.ShowPopover(); - break; - } - }); - } - - public Popover GetPopover() => new BeatmapOptionsPopover(this); - } -} diff --git a/osu.Game/Screens/Select/FooterV2/FooterButtonV2.cs b/osu.Game/Screens/Select/FooterV2/FooterButtonV2.cs deleted file mode 100644 index 2f5046d2bb..0000000000 --- a/osu.Game/Screens/Select/FooterV2/FooterButtonV2.cs +++ /dev/null @@ -1,211 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Effects; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Input.Bindings; -using osu.Framework.Input.Events; -using osu.Framework.Localisation; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; -using osu.Game.Input.Bindings; -using osu.Game.Overlays; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Screens.Select.FooterV2 -{ - public partial class FooterButtonV2 : OsuClickableContainer, IKeyBindingHandler - { - private const int button_height = 90; - private const int button_width = 140; - private const int corner_radius = 10; - private const int transition_length = 500; - - // This should be 12 by design, but an extra allowance is added due to the corner radius specification. - public const float SHEAR_WIDTH = 13.5f; - - public Bindable OverlayState = new Bindable(); - - protected static readonly Vector2 SHEAR = new Vector2(SHEAR_WIDTH / button_height, 0); - - [Resolved] - private OverlayColourProvider colourProvider { get; set; } = null!; - - private Colour4 buttonAccentColour; - - protected Colour4 AccentColour - { - set - { - buttonAccentColour = value; - bar.Colour = buttonAccentColour; - icon.Colour = buttonAccentColour; - } - } - - protected IconUsage Icon - { - set => icon.Icon = value; - } - - protected LocalisableString Text - { - set => text.Text = value; - } - - private readonly SpriteText text; - private readonly SpriteIcon icon; - - protected Container TextContainer; - private readonly Box bar; - private readonly Box backgroundBox; - - public FooterButtonV2() - { - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Shadow, - Radius = 4, - // Figma says 50% opacity, but it does not match up visually if taken at face value, and looks bad. - Colour = Colour4.Black.Opacity(0.25f), - Offset = new Vector2(0, 2), - }; - Shear = SHEAR; - Size = new Vector2(button_width, button_height); - Masking = true; - CornerRadius = corner_radius; - Children = new Drawable[] - { - backgroundBox = new Box - { - RelativeSizeAxes = Axes.Both - }, - - // For elements that should not be sheared. - new Container - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Shear = -SHEAR, - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - TextContainer = new Container - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Y = 42, - AutoSizeAxes = Axes.Both, - Child = text = new OsuSpriteText - { - // figma design says the size is 16, but due to the issues with font sizes 19 matches better - Font = OsuFont.TorusAlternate.With(size: 19), - AlwaysPresent = true - } - }, - icon = new SpriteIcon - { - Y = 12, - Size = new Vector2(20), - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre - }, - } - }, - new Container - { - Shear = -SHEAR, - Anchor = Anchor.BottomCentre, - Origin = Anchor.Centre, - Y = -corner_radius, - Size = new Vector2(120, 6), - Masking = true, - CornerRadius = 3, - Child = bar = new Box - { - RelativeSizeAxes = Axes.Both, - } - } - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - OverlayState.BindValueChanged(_ => updateDisplay()); - Enabled.BindValueChanged(_ => updateDisplay(), true); - - FinishTransforms(true); - } - - public GlobalAction? Hotkey; - - private bool handlingMouse; - - protected override bool OnHover(HoverEvent e) - { - updateDisplay(); - return true; - } - - protected override bool OnMouseDown(MouseDownEvent e) - { - handlingMouse = true; - updateDisplay(); - return base.OnMouseDown(e); - } - - protected override void OnMouseUp(MouseUpEvent e) - { - handlingMouse = false; - updateDisplay(); - base.OnMouseUp(e); - } - - protected override void OnHoverLost(HoverLostEvent e) => updateDisplay(); - - public virtual bool OnPressed(KeyBindingPressEvent e) - { - if (e.Action != Hotkey || e.Repeat) return false; - - TriggerClick(); - return true; - } - - public virtual void OnReleased(KeyBindingReleaseEvent e) { } - - private void updateDisplay() - { - Color4 backgroundColour = colourProvider.Background3; - - if (!Enabled.Value) - { - backgroundColour = colourProvider.Background3.Darken(0.4f); - } - else - { - if (OverlayState.Value == Visibility.Visible) - backgroundColour = buttonAccentColour.Darken(0.5f); - - if (IsHovered) - { - backgroundColour = backgroundColour.Lighten(0.3f); - - if (handlingMouse) - backgroundColour = backgroundColour.Lighten(0.3f); - } - } - - backgroundBox.FadeColour(backgroundColour, transition_length, Easing.OutQuint); - } - } -} diff --git a/osu.Game/Screens/Select/FooterV2/FooterV2.cs b/osu.Game/Screens/Select/FooterV2/FooterV2.cs deleted file mode 100644 index 0529f0d082..0000000000 --- a/osu.Game/Screens/Select/FooterV2/FooterV2.cs +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Collections.Generic; -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.UserInterface; -using osu.Game.Overlays; -using osuTK; - -namespace osu.Game.Screens.Select.FooterV2 -{ - public partial class FooterV2 : InputBlockingContainer - { - //Should be 60, setting to 50 for now for the sake of matching the current BackButton height. - private const int height = 50; - private const int padding = 80; - - private readonly List overlays = new List(); - - /// The button to be added. - /// The to be toggled by this button. - public void AddButton(FooterButtonV2 button, OverlayContainer? overlay = null) - { - if (overlay != null) - { - overlays.Add(overlay); - button.Action = () => showOverlay(overlay); - button.OverlayState.BindTo(overlay.State); - } - - buttons.Add(button); - } - - private void showOverlay(OverlayContainer overlay) - { - foreach (var o in overlays) - { - if (o == overlay) - o.ToggleVisibility(); - else - o.Hide(); - } - } - - private FillFlowContainer buttons = null!; - - public FooterV2() - { - RelativeSizeAxes = Axes.X; - Height = height; - Anchor = Anchor.BottomLeft; - Origin = Anchor.BottomLeft; - } - - [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) - { - InternalChildren = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = colourProvider.Background5 - }, - buttons = new FillFlowContainer - { - Position = new Vector2(TwoLayerButton.SIZE_EXTENDED.X + padding, 10), - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(-FooterButtonV2.SHEAR_WIDTH + 7, 0), - AutoSizeAxes = Axes.Both - } - }; - } - } -} diff --git a/osu.Game/Screens/Select/LocalScoreDeleteDialog.cs b/osu.Game/Screens/Select/LocalScoreDeleteDialog.cs index cd98872b65..ec2b8437e1 100644 --- a/osu.Game/Screens/Select/LocalScoreDeleteDialog.cs +++ b/osu.Game/Screens/Select/LocalScoreDeleteDialog.cs @@ -4,11 +4,10 @@ using osu.Framework.Allocation; using osu.Game.Overlays.Dialog; using osu.Game.Scoring; -using osu.Framework.Graphics.Sprites; namespace osu.Game.Screens.Select { - public partial class LocalScoreDeleteDialog : DangerousActionDialog + public partial class LocalScoreDeleteDialog : DeletionDialog { private readonly ScoreInfo score; @@ -21,8 +20,6 @@ namespace osu.Game.Screens.Select private void load(ScoreManager scoreManager) { BodyText = $"{score.User} ({score.DisplayAccuracy}, {score.Rank})"; - - Icon = FontAwesome.Regular.TrashAlt; DangerousAction = () => scoreManager.Delete(score); } } diff --git a/osu.Game/Screens/Select/ModSpeedHotkeyHandler.cs b/osu.Game/Screens/Select/ModSpeedHotkeyHandler.cs new file mode 100644 index 0000000000..c4cd44705e --- /dev/null +++ b/osu.Game/Screens/Select/ModSpeedHotkeyHandler.cs @@ -0,0 +1,115 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Utils; +using osu.Game.Configuration; +using osu.Game.Overlays; +using osu.Game.Overlays.OSD; +using osu.Game.Rulesets.Mods; +using osu.Game.Utils; + +namespace osu.Game.Screens.Select +{ + public partial class ModSpeedHotkeyHandler : Component + { + [Resolved] + private Bindable> selectedMods { get; set; } = null!; + + [Resolved] + private OsuConfigManager config { get; set; } = null!; + + [Resolved] + private OnScreenDisplay? onScreenDisplay { get; set; } + + private ModRateAdjust? lastActiveRateAdjustMod; + private ModSettingChangeTracker? settingChangeTracker; + + protected override void LoadComplete() + { + base.LoadComplete(); + + selectedMods.BindValueChanged(val => + { + storeLastActiveRateAdjustMod(); + + settingChangeTracker?.Dispose(); + settingChangeTracker = new ModSettingChangeTracker(val.NewValue); + settingChangeTracker.SettingChanged += _ => storeLastActiveRateAdjustMod(); + }, true); + } + + private void storeLastActiveRateAdjustMod() + { + lastActiveRateAdjustMod = (ModRateAdjust?)selectedMods.Value.OfType().SingleOrDefault()?.DeepClone() ?? lastActiveRateAdjustMod; + } + + public bool ChangeSpeed(double delta, IEnumerable availableMods) + { + double targetSpeed = (selectedMods.Value.OfType().SingleOrDefault()?.SpeedChange.Value ?? 1) + delta; + + if (Precision.AlmostEquals(targetSpeed, 1, 0.005)) + { + selectedMods.Value = selectedMods.Value.Where(m => m is not ModRateAdjust).ToList(); + onScreenDisplay?.Display(new SpeedChangeToast(config, targetSpeed)); + return true; + } + + ModRateAdjust? targetMod; + + if (lastActiveRateAdjustMod is ModDaycore || lastActiveRateAdjustMod is ModNightcore) + { + targetMod = targetSpeed < 1 + ? availableMods.OfType().SingleOrDefault() + : availableMods.OfType().SingleOrDefault(); + } + else + { + targetMod = targetSpeed < 1 + ? availableMods.OfType().SingleOrDefault() + : availableMods.OfType().SingleOrDefault(); + } + + if (targetMod == null) + return false; + + // preserve other settings from latest rate adjust mod instance seen + if (lastActiveRateAdjustMod != null) + { + foreach (var (_, sourceProperty) in lastActiveRateAdjustMod.GetSettingsSourceProperties()) + { + if (sourceProperty.Name == nameof(ModRateAdjust.SpeedChange)) + continue; + + var targetProperty = targetMod.GetType().GetProperty(sourceProperty.Name); + + if (targetProperty == null) + continue; + + var targetBindable = (IBindable)targetProperty.GetValue(targetMod)!; + var sourceBindable = (IBindable)sourceProperty.GetValue(lastActiveRateAdjustMod)!; + + if (targetBindable.GetType() != sourceBindable.GetType()) + continue; + + lastActiveRateAdjustMod.CopyAdjustedSetting(targetBindable, sourceBindable); + } + } + + targetMod.SpeedChange.Value = targetSpeed; + + var intendedMods = selectedMods.Value.Where(m => m is not ModRateAdjust).Append(targetMod).ToList(); + + if (!ModUtils.CheckCompatibleSet(intendedMods)) + return false; + + selectedMods.Value = intendedMods; + onScreenDisplay?.Display(new SpeedChangeToast(config, targetMod.SpeedChange.Value)); + return true; + } + } +} diff --git a/osu.Game/Screens/Select/PlaySongSelect.cs b/osu.Game/Screens/Select/PlaySongSelect.cs index 52f49ba56a..7b1479f392 100644 --- a/osu.Game/Screens/Select/PlaySongSelect.cs +++ b/osu.Game/Screens/Select/PlaySongSelect.cs @@ -95,7 +95,7 @@ namespace osu.Game.Screens.Select modsAtGameplayStart = Mods.Value; // Ctrl+Enter should start map with autoplay enabled. - if (GetContainingInputManager().CurrentState?.Keyboard.ControlPressed == true) + if (GetContainingInputManager()?.CurrentState?.Keyboard.ControlPressed == true) { var autoInstance = getAutoplayMod(); diff --git a/osu.Game/Screens/Select/SkinDeleteDialog.cs b/osu.Game/Screens/Select/SkinDeleteDialog.cs index 6612ae837a..cd14b5b6d2 100644 --- a/osu.Game/Screens/Select/SkinDeleteDialog.cs +++ b/osu.Game/Screens/Select/SkinDeleteDialog.cs @@ -7,7 +7,7 @@ using osu.Game.Overlays.Dialog; namespace osu.Game.Screens.Select { - public partial class SkinDeleteDialog : DangerousActionDialog + public partial class SkinDeleteDialog : DeletionDialog { private readonly Skin skin; diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs index 6225534e95..9f7a2c02ff 100644 --- a/osu.Game/Screens/Select/SongSelect.cs +++ b/osu.Game/Screens/Select/SongSelect.cs @@ -26,6 +26,7 @@ using osu.Game.Collections; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osu.Game.Overlays; @@ -39,6 +40,7 @@ using osu.Game.Screens.Play; using osu.Game.Screens.Select.Details; using osu.Game.Screens.Select.Options; using osu.Game.Skinning; +using osu.Game.Utils; using osuTK; using osuTK.Graphics; using osuTK.Input; @@ -60,19 +62,19 @@ namespace osu.Game.Screens.Select /// protected virtual bool ControlGlobalMusic => true; - protected virtual bool ShowFooter => true; + protected virtual bool ShowSongSelectFooter => true; public override bool? ApplyModTrackAdjustments => true; /// - /// Can be null if is false. + /// Can be null if is false. /// protected BeatmapOptionsOverlay BeatmapOptions { get; private set; } = null!; /// - /// Can be null if is false. + /// Can be null if is false. /// - protected Footer? Footer { get; private set; } + protected Footer? SongSelectFooter { get; private set; } /// /// Contains any panel which is triggered by a footer button. @@ -98,6 +100,9 @@ namespace osu.Game.Screens.Select new OsuMenuItem(@"Select", MenuItemType.Highlighted, () => FinaliseSelection(getBeatmap())) }; + [Resolved] + private OsuGameBase game { get; set; } = null!; + [Resolved] private Bindable> selectedMods { get; set; } = null!; @@ -122,6 +127,8 @@ namespace osu.Game.Screens.Select private Sample sampleChangeDifficulty = null!; private Sample sampleChangeBeatmap = null!; + private bool pendingFilterApplication; + private Container carouselContainer = null!; protected BeatmapDetailArea BeatmapDetails { get; private set; } = null!; @@ -133,6 +140,7 @@ namespace osu.Game.Screens.Select private double audioFeedbackLastPlaybackTime; private IDisposable? modSelectOverlayRegistration; + private ModSpeedHotkeyHandler modSpeedHotkeyHandler = null!; private AdvancedStats advancedStats = null!; @@ -156,20 +164,6 @@ namespace osu.Game.Screens.Select ApplyToBackground(applyBlurToBackground); }); - LoadComponentAsync(Carousel = new BeatmapCarousel - { - AllowSelection = false, // delay any selection until our bindables are ready to make a good choice. - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - RelativeSizeAxes = Axes.Both, - BleedTop = FilterControl.HEIGHT, - BleedBottom = Footer.HEIGHT, - SelectionChanged = updateSelectedBeatmap, - BeatmapSetsChanged = carouselBeatmapsLoaded, - FilterApplied = () => Scheduler.AddOnce(updateVisibleBeatmapCount), - GetRecommendedBeatmap = s => recommender?.GetRecommendedBeatmap(s), - }, c => carouselContainer.Child = c); - // initial value transfer is required for FilterControl (it uses our re-cached bindables in its async load for the initial filter). transferRulesetValue(); @@ -210,7 +204,7 @@ namespace osu.Game.Screens.Select Padding = new MarginPadding { Top = FilterControl.HEIGHT, - Bottom = Footer.HEIGHT + Bottom = Select.Footer.HEIGHT }, Child = new LoadingSpinner(true) { State = { Value = Visibility.Visible } } } @@ -221,7 +215,6 @@ namespace osu.Game.Screens.Select { RelativeSizeAxes = Axes.X, Height = FilterControl.HEIGHT, - FilterChanged = ApplyFilterToCarousel, }, new GridContainer // used for max width implementation { @@ -297,7 +290,7 @@ namespace osu.Game.Screens.Select RelativeSizeAxes = Axes.Both, Padding = new MarginPadding { - Bottom = Footer.HEIGHT, + Bottom = Select.Footer.HEIGHT, Top = WEDGE_HEIGHT + 70, Left = left_area_padding, Right = left_area_padding * 2, @@ -315,13 +308,44 @@ namespace osu.Game.Screens.Select } } }, - new SkinComponentsContainer(new SkinComponentsContainerLookup(SkinComponentsContainerLookup.TargetArea.SongSelect)) + new SkinnableContainer(new GlobalSkinnableContainerLookup(GlobalSkinnableContainers.SongSelect)) { RelativeSizeAxes = Axes.Both, }, + modSpeedHotkeyHandler = new ModSpeedHotkeyHandler(), }); - if (ShowFooter) + // Important to load this after the filter control is loaded (so we have initial filter criteria prepared). + LoadComponentAsync(Carousel = new BeatmapCarousel(FilterControl.CreateCriteria()) + { + AllowSelection = false, // delay any selection until our bindables are ready to make a good choice. + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.Both, + BleedTop = FilterControl.HEIGHT, + BleedBottom = Select.Footer.HEIGHT, + SelectionChanged = updateSelectedBeatmap, + BeatmapSetsChanged = carouselBeatmapsLoaded, + FilterApplied = () => Scheduler.AddOnce(updateVisibleBeatmapCount), + GetRecommendedBeatmap = s => recommender?.GetRecommendedBeatmap(s), + }, c => carouselContainer.Child = c); + + FilterControl.FilterChanged = criteria => + { + // If a filter operation is applied when we're in a state that doesn't allow selection, + // we might end up in an unexpected state. This is because currently carousel panels are in charge + // of updating the global selection (which is very hard to deal with). + // + // For now let's just avoid filtering when selection isn't allowed locally. + // This should be nuked from existence when we get around to fixing the complexity of song select <-> beatmap carousel. + // The debounce part of BeatmapCarousel's filtering should probably also be removed and handled locally. + if (Carousel.AllowSelection) + Carousel.Filter(criteria); + else + pendingFilterApplication = true; + }; + + if (ShowSongSelectFooter) { AddRangeInternal(new Drawable[] { @@ -330,13 +354,13 @@ namespace osu.Game.Screens.Select Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Bottom = Footer.HEIGHT }, + Padding = new MarginPadding { Bottom = Select.Footer.HEIGHT }, Children = new Drawable[] { BeatmapOptions = new BeatmapOptionsOverlay(), } }, - Footer = new Footer() + SongSelectFooter = new Footer() }); } @@ -344,10 +368,10 @@ namespace osu.Game.Screens.Select // therein it will be registered at the `OsuGame` level to properly function as a blocking overlay. LoadComponent(ModSelect = CreateModSelectOverlay()); - if (Footer != null) + if (SongSelectFooter != null) { - foreach (var (button, overlay) in CreateFooterButtons()) - Footer.AddButton(button, overlay); + foreach (var (button, overlay) in CreateSongSelectFooterButtons()) + SongSelectFooter.AddButton(button, overlay); BeatmapOptions.AddButton(@"Manage", @"collections", FontAwesome.Solid.Book, colours.Green, () => manageCollectionsDialog?.Show()); BeatmapOptions.AddButton(@"Delete", @"all difficulties", FontAwesome.Solid.Trash, colours.Pink, () => DeleteBeatmap(Beatmap.Value.BeatmapSetInfo)); @@ -381,7 +405,7 @@ namespace osu.Game.Screens.Select /// Creates the buttons to be displayed in the footer. /// /// A set of and an optional which the button opens when pressed. - protected virtual IEnumerable<(FooterButton, OverlayContainer?)> CreateFooterButtons() => new (FooterButton, OverlayContainer?)[] + protected virtual IEnumerable<(FooterButton, OverlayContainer?)> CreateSongSelectFooterButtons() => new (FooterButton, OverlayContainer?)[] { (new FooterButtonMods { Current = Mods }, ModSelect), (new FooterButtonRandom @@ -394,14 +418,6 @@ namespace osu.Game.Screens.Select protected virtual ModSelectOverlay CreateModSelectOverlay() => new SoloModSelectOverlay(); - protected virtual void ApplyFilterToCarousel(FilterCriteria criteria) - { - // if not the current screen, we want to get carousel in a good presentation state before displaying (resume or enter). - bool shouldDebounce = this.IsCurrentScreen(); - - Carousel.Filter(criteria, shouldDebounce); - } - private DependencyContainer dependencies = null!; protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) @@ -427,7 +443,8 @@ namespace osu.Game.Screens.Select // Forced refetch is important here to guarantee correct invalidation across all difficulties. Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmapInfo ?? beatmapInfoNoDebounce, true); - this.Push(new EditorLoader()); + + FinaliseSelection(customStartAction: () => this.Push(new EditorLoader())); } /// @@ -499,6 +516,13 @@ namespace osu.Game.Screens.Select var beatmap = e?.NewValue ?? Beatmap.Value; if (beatmap is DummyWorkingBeatmap || !this.IsCurrentScreen()) return; + if (beatmap.BeatmapSetInfo.Protected && e != null) + { + Logger.Log($"Denying working beatmap switch to protected beatmap {beatmap}"); + Beatmap.Value = e.OldValue; + return; + } + Logger.Log($"Song select working beatmap updated to {beatmap}"); if (!Carousel.SelectBeatmap(beatmap.BeatmapInfo, false)) @@ -586,17 +610,12 @@ namespace osu.Game.Screens.Select beatmapInfoPrevious = beatmap; } - // we can't run this in the debounced run due to the selected mods bindable not being debounced, - // since mods could be updated to the new ruleset instances while the decoupled bindable is held behind, - // therefore resulting in performing difficulty calculation with invalid states. - advancedStats.Ruleset.Value = ruleset; - void run() { // clear pending task immediately to track any potential nested debounce operation. selectionChangedDebounce = null; - Logger.Log($"Song select updating selection with beatmap:{beatmap?.ID.ToString() ?? "null"} ruleset:{ruleset?.ShortName ?? "null"}"); + Logger.Log($"Song select updating selection with beatmap: {beatmap} {beatmap?.ID.ToString() ?? "null"} ruleset:{ruleset?.ShortName ?? "null"}"); if (transferRulesetValue()) { @@ -724,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). @@ -845,9 +875,11 @@ namespace osu.Game.Screens.Select BeatmapDetails.Beatmap = beatmap; - ModSelect.Beatmap = beatmap; + ModSelect.Beatmap.Value = beatmap; advancedStats.BeatmapInfo = beatmap.BeatmapInfo; + advancedStats.Mods.Value = selectedMods.Value; + advancedStats.Ruleset.Value = Ruleset.Value; bool beatmapSelected = beatmap is not DummyWorkingBeatmap; @@ -960,6 +992,12 @@ namespace osu.Game.Screens.Select Beatmap.BindValueChanged(updateCarouselSelection); + selectedMods.BindValueChanged(_ => + { + if (decoupledRuleset.Value.Equals(rulesetNoDebounce)) + advancedStats.Mods.Value = selectedMods.Value; + }, true); + boundLocalBindables = true; } @@ -978,7 +1016,8 @@ namespace osu.Game.Screens.Select // if we have a pending filter operation, we want to run it now. // it could change selection (ie. if the ruleset has been changed). - Carousel.FlushPendingFilterOperations(); + if (IsLoaded) + Carousel.FlushPendingFilterOperations(); return true; } @@ -1007,11 +1046,20 @@ namespace osu.Game.Screens.Select public virtual bool OnPressed(KeyBindingPressEvent e) { + if (!this.IsCurrentScreen()) return false; + + switch (e.Action) + { + case GlobalAction.IncreaseModSpeed: + return modSpeedHotkeyHandler.ChangeSpeed(0.05, ModUtils.FlattenMods(game.AvailableMods.Value.SelectMany(kv => kv.Value))); + + case GlobalAction.DecreaseModSpeed: + return modSpeedHotkeyHandler.ChangeSpeed(-0.05, ModUtils.FlattenMods(game.AvailableMods.Value.SelectMany(kv => kv.Value))); + } + if (e.Repeat) return false; - if (!this.IsCurrentScreen()) return false; - switch (e.Action) { case GlobalAction.Select: @@ -1059,7 +1107,7 @@ namespace osu.Game.Screens.Select Anchor = Anchor.Centre; Origin = Anchor.Centre; Width = panel_overflow; // avoid horizontal masking so the panels don't clip when screen stack is pushed. - InternalChild = Content = new Container + InternalChild = Content = new OsuContextMenuContainer { RelativeSizeAxes = Axes.Both, Anchor = Anchor.Centre, diff --git a/osu.Game/Screens/Select/FooterV2/BeatmapOptionsPopover.cs b/osu.Game/Screens/SelectV2/Footer/BeatmapOptionsPopover.cs similarity index 84% rename from osu.Game/Screens/Select/FooterV2/BeatmapOptionsPopover.cs rename to osu.Game/Screens/SelectV2/Footer/BeatmapOptionsPopover.cs index f81036f745..fb2e32dfdc 100644 --- a/osu.Game/Screens/Select/FooterV2/BeatmapOptionsPopover.cs +++ b/osu.Game/Screens/SelectV2/Footer/BeatmapOptionsPopover.cs @@ -25,25 +25,29 @@ using osuTK.Graphics; using osuTK.Input; using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; -namespace osu.Game.Screens.Select.FooterV2 +namespace osu.Game.Screens.SelectV2.Footer { public partial class BeatmapOptionsPopover : OsuPopover { private FillFlowContainer buttonFlow = null!; - private readonly FooterButtonOptionsV2 footerButton; + private readonly ScreenFooterButtonOptions footerButton; + + [Cached] + private readonly OverlayColourProvider colourProvider; private WorkingBeatmap beatmapWhenOpening = null!; [Resolved] private IBindable beatmap { get; set; } = null!; - public BeatmapOptionsPopover(FooterButtonOptionsV2 footerButton) + public BeatmapOptionsPopover(ScreenFooterButtonOptions footerButton, OverlayColourProvider colourProvider) { this.footerButton = footerButton; + this.colourProvider = colourProvider; } [BackgroundDependencyLoader] - private void load(ManageCollectionsDialog? manageCollectionsDialog, SongSelect? songSelect, OsuColour colours, BeatmapManager? beatmapManager) + private void load(ManageCollectionsDialog? manageCollectionsDialog, OsuColour colours, BeatmapManager? beatmapManager) { Content.Padding = new MarginPadding(5); @@ -60,15 +64,15 @@ namespace osu.Game.Screens.Select.FooterV2 addButton(SongSelectStrings.ManageCollections, FontAwesome.Solid.Book, () => manageCollectionsDialog?.Show()); addHeader(SongSelectStrings.ForAllDifficulties, beatmapWhenOpening.BeatmapSetInfo.ToString()); - addButton(SongSelectStrings.DeleteBeatmap, FontAwesome.Solid.Trash, () => songSelect?.DeleteBeatmap(beatmapWhenOpening.BeatmapSetInfo), colours.Red1); + addButton(SongSelectStrings.DeleteBeatmap, FontAwesome.Solid.Trash, () => { }, colours.Red1); // songSelect?.DeleteBeatmap(beatmapWhenOpening.BeatmapSetInfo); addHeader(SongSelectStrings.ForSelectedDifficulty, beatmapWhenOpening.BeatmapInfo.DifficultyName); // TODO: make work, and make show "unplayed" or "played" based on status. addButton(SongSelectStrings.MarkAsPlayed, FontAwesome.Regular.TimesCircle, null); - addButton(SongSelectStrings.ClearAllLocalScores, FontAwesome.Solid.Eraser, () => songSelect?.ClearScores(beatmapWhenOpening.BeatmapInfo), colours.Red1); + addButton(SongSelectStrings.ClearAllLocalScores, FontAwesome.Solid.Eraser, () => { }, colours.Red1); // songSelect?.ClearScores(beatmapWhenOpening.BeatmapInfo); - if (songSelect != null && songSelect.AllowEditing) - addButton(SongSelectStrings.EditBeatmap, FontAwesome.Solid.PencilAlt, () => songSelect.Edit(beatmapWhenOpening.BeatmapInfo)); + // if (songSelect != null && songSelect.AllowEditing) + addButton(SongSelectStrings.EditBeatmap, FontAwesome.Solid.PencilAlt, () => { }); // songSelect.Edit(beatmapWhenOpening.BeatmapInfo); addButton(WebCommonStrings.ButtonsHide.ToSentence(), FontAwesome.Solid.Magic, () => beatmapManager?.Hide(beatmapWhenOpening.BeatmapInfo)); } @@ -77,14 +81,11 @@ namespace osu.Game.Screens.Select.FooterV2 { base.LoadComplete(); - ScheduleAfterChildren(() => GetContainingInputManager().ChangeFocus(this)); + ScheduleAfterChildren(() => GetContainingFocusManager()!.ChangeFocus(this)); beatmap.BindValueChanged(_ => Hide()); } - [Resolved] - private OverlayColourProvider overlayColourProvider { get; set; } = null!; - private void addHeader(LocalisableString text, string? context = null) { var textFlow = new OsuTextFlowContainer @@ -101,7 +102,7 @@ namespace osu.Game.Screens.Select.FooterV2 textFlow.NewLine(); textFlow.AddText(context, t => { - t.Colour = overlayColourProvider.Content2; + t.Colour = colourProvider.Content2; t.Font = t.Font.With(size: 13); }); } @@ -188,9 +189,7 @@ namespace osu.Game.Screens.Select.FooterV2 protected override void UpdateState(ValueChangedEvent state) { base.UpdateState(state); - - if (state.NewValue == Visibility.Hidden) - footerButton.IsActive.Value = false; + footerButton.OverlayState.Value = state.NewValue; } } } diff --git a/osu.Game/Screens/SelectV2/Footer/ScreenFooterButtonMods.cs b/osu.Game/Screens/SelectV2/Footer/ScreenFooterButtonMods.cs new file mode 100644 index 0000000000..0992203dbc --- /dev/null +++ b/osu.Game/Screens/SelectV2/Footer/ScreenFooterButtonMods.cs @@ -0,0 +1,343 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Localisation; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Localisation; +using osu.Game.Overlays; +using osu.Game.Overlays.Mods; +using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Footer; +using osu.Game.Screens.Play.HUD; +using osu.Game.Utils; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.SelectV2.Footer +{ + public partial class ScreenFooterButtonMods : ScreenFooterButton, IHasCurrentValue> + { + private const float bar_height = 30f; + private const float mod_display_portion = 0.65f; + + private readonly BindableWithCurrent> current = new BindableWithCurrent>(Array.Empty()); + + public Bindable> Current + { + get => current.Current; + set => current.Current = value; + } + + private Container modDisplayBar = null!; + + private Drawable unrankedBadge = null!; + + private ModDisplay modDisplay = null!; + private OsuSpriteText modCountText = null!; + + protected OsuSpriteText MultiplierText { get; private set; } = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public ScreenFooterButtonMods(ModSelectOverlay overlay) + : base(overlay) + { + } + + [BackgroundDependencyLoader] + private void load() + { + Text = "Mods"; + Icon = FontAwesome.Solid.ExchangeAlt; + AccentColour = colours.Lime1; + + AddRange(new[] + { + unrankedBadge = new UnrankedBadge(), + modDisplayBar = new Container + { + Y = -5f, + Depth = float.MaxValue, + Origin = Anchor.BottomLeft, + Shear = BUTTON_SHEAR, + CornerRadius = CORNER_RADIUS, + Size = new Vector2(BUTTON_WIDTH, bar_height), + Masking = true, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Radius = 4, + // Figma says 50% opacity, but it does not match up visually if taken at face value, and looks bad. + Colour = Colour4.Black.Opacity(0.25f), + Offset = new Vector2(0, 2), + }, + Children = new Drawable[] + { + new Box + { + Colour = colourProvider.Background4, + RelativeSizeAxes = Axes.Both, + }, + new Container + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.Both, + Width = 1f - mod_display_portion, + Masking = true, + Child = MultiplierText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Shear = -BUTTON_SHEAR, + UseFullGlyphHeight = false, + Font = OsuFont.Torus.With(size: 14f, weight: FontWeight.Bold) + } + }, + new Container + { + CornerRadius = CORNER_RADIUS, + RelativeSizeAxes = Axes.Both, + Width = mod_display_portion, + Masking = true, + Children = new Drawable[] + { + new Box + { + Colour = colourProvider.Background3, + RelativeSizeAxes = Axes.Both, + }, + modDisplay = new ModDisplay(showExtendedInformation: false) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Shear = -BUTTON_SHEAR, + Scale = new Vector2(0.5f), + Current = { BindTarget = Current }, + ExpansionMode = ExpansionMode.AlwaysContracted, + }, + modCountText = new ModCountText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Shear = -BUTTON_SHEAR, + Font = OsuFont.Torus.With(size: 14f, weight: FontWeight.Bold), + Mods = { BindTarget = Current }, + } + } + }, + } + }, + }); + } + + private ModSettingChangeTracker? modSettingChangeTracker; + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(m => + { + modSettingChangeTracker?.Dispose(); + + updateDisplay(); + + if (m.NewValue != null) + { + modSettingChangeTracker = new ModSettingChangeTracker(m.NewValue); + modSettingChangeTracker.SettingChanged += _ => updateDisplay(); + } + }, true); + + FinishTransforms(true); + } + + private const double duration = 240; + private const Easing easing = Easing.OutQuint; + + private void updateDisplay() + { + if (Current.Value.Count == 0) + { + modDisplayBar.MoveToY(20, duration, easing); + modDisplayBar.FadeOut(duration, easing); + modDisplay.FadeOut(duration, easing); + modCountText.FadeOut(duration, easing); + + unrankedBadge.MoveToY(20, duration, easing); + unrankedBadge.FadeOut(duration, easing); + + // add delay to let unranked indicator hide first before resizing the button back to its original width. + this.Delay(duration).ResizeWidthTo(BUTTON_WIDTH, duration, easing); + } + else + { + modDisplay.Hide(); + modCountText.Hide(); + + if (Current.Value.Count >= 5) + modCountText.Show(); + else + modDisplay.Show(); + + if (Current.Value.Any(m => !m.Ranked)) + { + unrankedBadge.MoveToX(0, duration, easing); + unrankedBadge.FadeIn(duration, easing); + + this.ResizeWidthTo(BUTTON_WIDTH + 5 + unrankedBadge.DrawWidth, duration, easing); + } + else + { + unrankedBadge.MoveToX(-unrankedBadge.DrawWidth, duration, easing); + unrankedBadge.FadeOut(duration, easing); + + this.ResizeWidthTo(BUTTON_WIDTH, duration, easing); + } + + modDisplayBar.MoveToY(-5, duration, Easing.OutQuint); + unrankedBadge.MoveToY(-5, duration, easing); + modDisplayBar.FadeIn(duration, easing); + } + + double multiplier = Current.Value?.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier) ?? 1; + MultiplierText.Text = ModUtils.FormatScoreMultiplier(multiplier); + + if (multiplier > 1) + MultiplierText.FadeColour(colours.Red1, duration, easing); + else if (multiplier < 1) + MultiplierText.FadeColour(colours.Lime1, duration, easing); + else + MultiplierText.FadeColour(Color4.White, duration, easing); + } + + private partial class ModCountText : OsuSpriteText, IHasCustomTooltip> + { + public readonly Bindable> Mods = new Bindable>(); + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + Mods.BindValueChanged(v => Text = ModSelectOverlayStrings.Mods(v.NewValue.Count).ToUpper(), true); + } + + public ITooltip> GetCustomTooltip() => new ModTooltip(colourProvider); + + public IReadOnlyList? TooltipContent => Mods.Value; + + public partial class ModTooltip : VisibilityContainer, ITooltip> + { + private ModDisplay extendedModDisplay = null!; + + [Cached] + private OverlayColourProvider colourProvider; + + public ModTooltip(OverlayColourProvider colourProvider) + { + this.colourProvider = colourProvider; + } + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + CornerRadius = CORNER_RADIUS; + Masking = true; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5, + }, + extendedModDisplay = new ModDisplay + { + Margin = new MarginPadding { Vertical = 2f, Horizontal = 10f }, + Scale = new Vector2(0.6f), + ExpansionMode = ExpansionMode.AlwaysExpanded, + }, + }; + } + + public void SetContent(IReadOnlyList content) + { + extendedModDisplay.Current.Value = content; + } + + public void Move(Vector2 pos) => Position = pos; + + protected override void PopIn() => this.FadeIn(240, Easing.OutQuint); + protected override void PopOut() => this.FadeOut(240, Easing.OutQuint); + } + } + + internal partial class UnrankedBadge : CompositeDrawable, IHasTooltip + { + public LocalisableString TooltipText { get; } + + public UnrankedBadge() + { + Margin = new MarginPadding { Left = BUTTON_WIDTH + 5f }; + Y = -5f; + Depth = float.MaxValue; + Origin = Anchor.BottomLeft; + Shear = BUTTON_SHEAR; + CornerRadius = CORNER_RADIUS; + AutoSizeAxes = Axes.X; + Height = bar_height; + Masking = true; + BorderColour = Color4.White; + BorderThickness = 2f; + TooltipText = ModSelectOverlayStrings.UnrankedExplanation; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + InternalChildren = new Drawable[] + { + new Box + { + Colour = colours.Orange2, + RelativeSizeAxes = Axes.Both, + }, + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Shear = -BUTTON_SHEAR, + Text = ModSelectOverlayStrings.Unranked.ToUpper(), + Margin = new MarginPadding { Horizontal = 15 }, + UseFullGlyphHeight = false, + Font = OsuFont.Torus.With(size: 14f, weight: FontWeight.Bold), + Colour = Color4.Black, + } + }; + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/Footer/ScreenFooterButtonOptions.cs b/osu.Game/Screens/SelectV2/Footer/ScreenFooterButtonOptions.cs new file mode 100644 index 0000000000..72409b5566 --- /dev/null +++ b/osu.Game/Screens/SelectV2/Footer/ScreenFooterButtonOptions.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics; +using osu.Game.Input.Bindings; +using osu.Game.Overlays; +using osu.Game.Screens.Footer; + +namespace osu.Game.Screens.SelectV2.Footer +{ + public partial class ScreenFooterButtonOptions : ScreenFooterButton, IHasPopover + { + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load(OsuColour colour) + { + Text = "Options"; + Icon = FontAwesome.Solid.Cog; + AccentColour = colour.Purple1; + Hotkey = GlobalAction.ToggleBeatmapOptions; + + Action = this.ShowPopover; + } + + public Popover GetPopover() => new BeatmapOptionsPopover(this, colourProvider); + } +} diff --git a/osu.Game/Screens/Select/FooterV2/FooterButtonRandomV2.cs b/osu.Game/Screens/SelectV2/Footer/ScreenFooterButtonRandom.cs similarity index 96% rename from osu.Game/Screens/Select/FooterV2/FooterButtonRandomV2.cs rename to osu.Game/Screens/SelectV2/Footer/ScreenFooterButtonRandom.cs index 70d1c0c19e..dbdb6fe79b 100644 --- a/osu.Game/Screens/Select/FooterV2/FooterButtonRandomV2.cs +++ b/osu.Game/Screens/SelectV2/Footer/ScreenFooterButtonRandom.cs @@ -10,12 +10,13 @@ using osu.Framework.Input.Events; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Input.Bindings; +using osu.Game.Screens.Footer; using osuTK; using osuTK.Input; -namespace osu.Game.Screens.Select.FooterV2 +namespace osu.Game.Screens.SelectV2.Footer { - public partial class FooterButtonRandomV2 : FooterButtonV2 + public partial class ScreenFooterButtonRandom : ScreenFooterButton { public Action? NextRandom { get; set; } public Action? PreviousRandom { get; set; } @@ -41,7 +42,7 @@ namespace osu.Game.Screens.Select.FooterV2 { randomSpriteText = new OsuSpriteText { - Font = OsuFont.TorusAlternate.With(size: 19), + Font = OsuFont.TorusAlternate.With(size: 16), AlwaysPresent = true, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, @@ -49,7 +50,7 @@ namespace osu.Game.Screens.Select.FooterV2 }, rewindSpriteText = new OsuSpriteText { - Font = OsuFont.TorusAlternate.With(size: 19), + Font = OsuFont.TorusAlternate.With(size: 16), AlwaysPresent = true, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, @@ -74,7 +75,7 @@ namespace osu.Game.Screens.Select.FooterV2 AlwaysPresent = true, // make sure the button is sized large enough to always show this Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, - Font = OsuFont.TorusAlternate.With(size: 19), + Font = OsuFont.TorusAlternate.With(size: 16), }); fallingRewind.FadeOutFromOne(fade_time, Easing.In); diff --git a/osu.Game/Screens/SelectV2/Leaderboards/LeaderboardScoreV2.cs b/osu.Game/Screens/SelectV2/Leaderboards/LeaderboardScoreV2.cs new file mode 100644 index 0000000000..732fb2cd8c --- /dev/null +++ b/osu.Game/Screens/SelectV2/Leaderboards/LeaderboardScoreV2.cs @@ -0,0 +1,792 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.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; +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; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; +using osu.Game.Scoring; +using osu.Game.Screens.Select; +using osu.Game.Users; +using osu.Game.Users.Drawables; +using osu.Game.Utils; +using osuTK; +using osuTK.Graphics; +using CommonStrings = osu.Game.Localisation.CommonStrings; + +namespace osu.Game.Screens.SelectV2.Leaderboards +{ + public partial class LeaderboardScoreV2 : OsuClickableContainer, IHasContextMenu, IHasCustomTooltip + { + public Bindable> SelectedMods = new Bindable>(); + + /// + /// A function determining whether each mod in the score can be selected. + /// A return value of means that the mod can be selected in the current context. + /// A return value of means that the mod cannot be selected in the current context. + /// + public Func IsValidMod { get; set; } = _ => true; + + public int? Rank { get; init; } + public bool IsPersonalBest { get; init; } + + private const float expanded_right_content_width = 210; + private const float grade_width = 40; + private const float username_min_width = 125; + private const float statistics_regular_min_width = 175; + private const float statistics_compact_min_width = 100; + private const float rank_label_width = 65; + + private readonly ScoreInfo score; + private readonly bool sheared; + + private const int height = 60; + private const int corner_radius = 10; + private const int transition_duration = 200; + + private Colour4 foregroundColour; + private Colour4 backgroundColour; + private ColourInfo totalScoreBackgroundGradient; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [Resolved] + private IDialogOverlay? dialogOverlay { get; set; } + + [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!; + + private Drawable avatar = null!; + private ClickableAvatar innerAvatar = null!; + + private OsuSpriteText nameLabel = null!; + private List statisticsLabels = null!; + + private Container rightContent = null!; + + protected Container RankContainer { get; private set; } = null!; + private FillFlowContainer flagBadgeAndDateContainer = null!; + private FillFlowContainer modsContainer = null!; + + private OsuSpriteText scoreText = null!; + private Drawable scoreRank = null!; + private Box totalScoreBackground = null!; + + private FillFlowContainer statisticsContainer = null!; + private RankLabel rankLabel = null!; + private Container rankLabelOverlay = null!; + + public ITooltip GetCustomTooltip() => new LeaderboardScoreTooltip(); + public virtual ScoreInfo TooltipContent => score; + + public LeaderboardScoreV2(ScoreInfo score, bool sheared = true) + { + this.score = score; + this.sheared = sheared; + + Shear = new Vector2(sheared ? OsuGame.SHEAR : 0, 0); + RelativeSizeAxes = Axes.X; + Height = height; + } + + [BackgroundDependencyLoader] + private void load() + { + var user = score.User; + + foregroundColour = IsPersonalBest ? colourProvider.Background1 : colourProvider.Background5; + backgroundColour = IsPersonalBest ? colourProvider.Background2 : colourProvider.Background4; + totalScoreBackgroundGradient = ColourInfo.GradientHorizontal(backgroundColour.Opacity(0), backgroundColour); + + statisticsLabels = GetStatistics(score).Select(s => new ScoreComponentLabel(s, score) + { + // ensure statistics container is the correct width when invalidating + AlwaysPresent = true, + }).ToList(); + + Child = content = new Container + { + Masking = true, + CornerRadius = corner_radius, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = backgroundColour + }, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new Drawable[] + { + new Container + { + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + Child = rankLabel = new RankLabel(Rank, sheared) + { + Width = rank_label_width, + RelativeSizeAxes = Axes.Y, + }, + }, + createCentreContent(user), + createRightContent() + } + } + } + } + }; + + innerAvatar.OnLoadComplete += d => d.FadeInFromZero(200); + } + + [Resolved] + private OsuConfigManager config { get; set; } = null!; + + private IBindable scoringMode { get; set; } = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + scoringMode = config.GetBindable(OsuSetting.ScoreDisplayMode); + scoringMode.BindValueChanged(s => + { + switch (s.NewValue) + { + case ScoringMode.Standardised: + rightContent.Width = 180f; + break; + + case ScoringMode.Classic: + rightContent.Width = expanded_right_content_width; + break; + } + + updateModDisplay(); + }, true); + } + + private void updateModDisplay() + { + int maxMods = scoringMode.Value == ScoringMode.Standardised ? 4 : 5; + + if (score.Mods.Length > 0) + { + modsContainer.Padding = new MarginPadding { Top = 4f }; + modsContainer.ChildrenEnumerable = score.Mods.AsOrdered().Take(Math.Min(maxMods, score.Mods.Length)).Select(mod => new ColouredModSwitchTiny(mod) + { + Scale = new Vector2(0.375f) + }); + + if (score.Mods.Length > maxMods) + { + modsContainer.Remove(modsContainer[^1], true); + modsContainer.Add(new MoreModSwitchTiny(score.Mods.Length - maxMods + 1) + { + Scale = new Vector2(0.375f), + }); + } + } + } + + private Container createCentreContent(APIUser user) => new Container + { + Name = @"Centre container", + Masking = true, + CornerRadius = corner_radius, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + foreground = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = foregroundColour + }, + new UserCoverBackground + { + RelativeSizeAxes = Axes.Both, + User = score.User, + Shear = new Vector2(sheared ? -OsuGame.SHEAR : 0, 0), + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Colour = ColourInfo.GradientHorizontal(Colour4.White.Opacity(0.5f), Colour4.FromHex(@"222A27").Opacity(1)), + }, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new Drawable[] + { + new Container + { + AutoSizeAxes = Axes.Both, + CornerRadius = corner_radius, + Masking = true, + Children = new[] + { + avatar = new DelayedLoadWrapper( + innerAvatar = new ClickableAvatar(user) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(1.1f), + Shear = new Vector2(sheared ? -OsuGame.SHEAR : 0, 0), + RelativeSizeAxes = Axes.Both, + }) + { + RelativeSizeAxes = Axes.None, + Size = new Vector2(height) + }, + rankLabelOverlay = new Container + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.Black.Opacity(0.5f), + }, + new RankLabel(Rank, sheared) + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + } + } + }, + }, + new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Padding = new MarginPadding { Horizontal = corner_radius }, + Children = new Drawable[] + { + flagBadgeAndDateContainer = new FillFlowContainer + { + Shear = new Vector2(sheared ? -OsuGame.SHEAR : 0, 0), + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + AutoSizeAxes = Axes.Both, + Masking = true, + Children = new Drawable[] + { + new UpdateableFlag(user.CountryCode) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(24, 16), + }, + new DateLabel(score.Date) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + UseFullGlyphHeight = false, + } + } + }, + nameLabel = new TruncatingSpriteText + { + RelativeSizeAxes = Axes.X, + Shear = new Vector2(sheared ? -OsuGame.SHEAR : 0, 0), + Text = user.Username, + Font = OsuFont.GetFont(size: 20, weight: FontWeight.SemiBold) + } + } + }, + new Container + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Child = statisticsContainer = new FillFlowContainer + { + Name = @"Statistics container", + Padding = new MarginPadding { Right = 40 }, + Spacing = new Vector2(25, 0), + Shear = new Vector2(sheared ? -OsuGame.SHEAR : 0, 0), + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Children = statisticsLabels, + Alpha = 0, + LayoutEasing = Easing.OutQuint, + LayoutDuration = transition_duration, + } + } + } + }, + }, + }, + }; + + private Container createRightContent() => rightContent = new Container + { + Name = @"Right content", + RelativeSizeAxes = Axes.Y, + Child = new Container + { + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Right = grade_width }, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientHorizontal(backgroundColour.Opacity(0), OsuColour.ForRank(score.Rank)), + }, + }, + new Box + { + RelativeSizeAxes = Axes.Y, + Width = grade_width, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Colour = OsuColour.ForRank(score.Rank), + }, + new TrianglesV2 + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + SpawnRatio = 2, + Velocity = 0.7f, + Colour = ColourInfo.GradientHorizontal(backgroundColour.Opacity(0), OsuColour.ForRank(score.Rank).Darken(0.2f)), + }, + RankContainer = new Container + { + Shear = new Vector2(sheared ? -OsuGame.SHEAR : 0, 0), + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.Y, + Width = grade_width, + Child = scoreRank = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Spacing = new Vector2(-2), + Colour = DrawableRank.GetRankNameColour(score.Rank), + Font = OsuFont.Numeric.With(size: 16), + Text = DrawableRank.GetRankName(score.Rank), + ShadowColour = Color4.Black.Opacity(0.3f), + ShadowOffset = new Vector2(0, 0.08f), + Shadow = true, + UseFullGlyphHeight = false, + }, + }, + new Container + { + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Padding = new MarginPadding { Right = grade_width }, + Child = new Container + { + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + Masking = true, + CornerRadius = corner_radius, + Children = new Drawable[] + { + totalScoreBackground = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = totalScoreBackgroundGradient, + }, + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientHorizontal(backgroundColour.Opacity(0), OsuColour.ForRank(score.Rank).Opacity(0.5f)), + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Direction = FillDirection.Vertical, + Padding = new MarginPadding { Horizontal = corner_radius }, + Children = new Drawable[] + { + scoreText = new OsuSpriteText + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + UseFullGlyphHeight = false, + Shear = new Vector2(sheared ? -OsuGame.SHEAR : 0, 0), + Current = scoreManager.GetBindableTotalScoreString(score), + Font = OsuFont.GetFont(size: 30, weight: FontWeight.Light), + }, + modsContainer = new FillFlowContainer + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Shear = new Vector2(sheared ? -OsuGame.SHEAR : 0, 0), + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(2f, 0f), + }, + } + } + } + } + } + } + }, + }; + + protected (CaseTransformableString, LocalisableString DisplayAccuracy)[] GetStatistics(ScoreInfo model) => new[] + { + (BeatmapsetsStrings.ShowScoreboardHeadersCombo.ToUpper(), model.MaxCombo.ToString().Insert(model.MaxCombo.ToString().Length, "x")), + (BeatmapsetsStrings.ShowScoreboardHeadersAccuracy.ToUpper(), model.DisplayAccuracy), + }; + + public override void Show() + { + foreach (var d in new[] { avatar, nameLabel, scoreText, scoreRank, flagBadgeAndDateContainer, modsContainer }.Concat(statisticsLabels)) + d.FadeOut(); + + Alpha = 0; + + content.MoveToY(75); + avatar.MoveToX(75); + nameLabel.MoveToX(150); + + this.FadeIn(200); + content.MoveToY(0, 800, Easing.OutQuint); + + using (BeginDelayedSequence(100)) + { + avatar.FadeIn(300, Easing.OutQuint); + nameLabel.FadeIn(350, Easing.OutQuint); + + avatar.MoveToX(0, 300, Easing.OutQuint); + nameLabel.MoveToX(0, 350, Easing.OutQuint); + + using (BeginDelayedSequence(250)) + { + scoreText.FadeIn(200); + scoreRank.FadeIn(200); + + using (BeginDelayedSequence(50)) + { + var drawables = new Drawable[] { flagBadgeAndDateContainer, modsContainer }.Concat(statisticsLabels).ToArray(); + for (int i = 0; i < drawables.Length; i++) + drawables[i].FadeIn(100 + i * 50); + } + } + } + } + + protected override bool OnHover(HoverEvent e) + { + updateState(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateState(); + base.OnHoverLost(e); + } + + private void updateState() + { + var lightenedGradient = ColourInfo.GradientHorizontal(backgroundColour.Opacity(0).Lighten(0.2f), backgroundColour.Lighten(0.2f)); + + foreground.FadeColour(IsHovered ? foregroundColour.Lighten(0.2f) : foregroundColour, transition_duration, Easing.OutQuint); + background.FadeColour(IsHovered ? backgroundColour.Lighten(0.2f) : backgroundColour, transition_duration, Easing.OutQuint); + totalScoreBackground.FadeColour(IsHovered ? lightenedGradient : totalScoreBackgroundGradient, transition_duration, Easing.OutQuint); + + if (IsHovered && currentMode != DisplayMode.Full) + rankLabelOverlay.FadeIn(transition_duration, Easing.OutQuint); + else + rankLabelOverlay.FadeOut(transition_duration, Easing.OutQuint); + } + + private DisplayMode? currentMode; + + protected override void Update() + { + base.Update(); + + DisplayMode mode = getCurrentDisplayMode(); + + if (currentMode != mode) + { + if (mode >= DisplayMode.Full) + rankLabel.FadeIn(transition_duration, Easing.OutQuint).MoveToX(0, transition_duration, Easing.OutQuint); + else + rankLabel.FadeOut(transition_duration, Easing.OutQuint).MoveToX(-rankLabel.DrawWidth, transition_duration, Easing.OutQuint); + + if (mode >= DisplayMode.Regular) + { + statisticsContainer.FadeIn(transition_duration, Easing.OutQuint).MoveToX(0, transition_duration, Easing.OutQuint); + statisticsContainer.Direction = FillDirection.Horizontal; + statisticsContainer.ScaleTo(1, transition_duration, Easing.OutQuint); + } + else if (mode >= DisplayMode.Compact) + { + statisticsContainer.FadeIn(transition_duration, Easing.OutQuint).MoveToX(0, transition_duration, Easing.OutQuint); + statisticsContainer.Direction = FillDirection.Vertical; + statisticsContainer.ScaleTo(0.8f, transition_duration, Easing.OutQuint); + } + else + statisticsContainer.FadeOut(transition_duration, Easing.OutQuint).MoveToX(statisticsContainer.DrawWidth, transition_duration, Easing.OutQuint); + + currentMode = mode; + } + } + + private DisplayMode getCurrentDisplayMode() + { + if (DrawWidth >= height + username_min_width + statistics_regular_min_width + expanded_right_content_width + rank_label_width) + return DisplayMode.Full; + + if (DrawWidth >= height + username_min_width + statistics_regular_min_width + expanded_right_content_width) + return DisplayMode.Regular; + + if (DrawWidth >= height + username_min_width + statistics_compact_min_width + expanded_right_content_width) + return DisplayMode.Compact; + + return DisplayMode.Minimal; + } + + #region Subclasses + + private enum DisplayMode + { + Minimal, + Compact, + Regular, + Full + } + + private partial class DateLabel : DrawableDate + { + public DateLabel(DateTimeOffset date) + : base(date) + { + Font = OsuFont.GetFont(size: 16, weight: FontWeight.Medium, italics: true); + } + + protected override string Format() => Date.ToShortRelativeTime(TimeSpan.FromSeconds(30)); + } + + private partial class ScoreComponentLabel : Container + { + private readonly (LocalisableString Name, LocalisableString Value) statisticInfo; + private readonly ScoreInfo score; + + private FillFlowContainer content = null!; + public override bool Contains(Vector2 screenSpacePos) => content.Contains(screenSpacePos); + + public ScoreComponentLabel((LocalisableString Name, LocalisableString Value) statisticInfo, ScoreInfo score) + { + this.statisticInfo = statisticInfo; + this.score = score; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours, OverlayColourProvider colourProvider) + { + AutoSizeAxes = Axes.Both; + OsuSpriteText value; + Child = content = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new OsuSpriteText + { + Colour = colourProvider.Content2, + Text = statisticInfo.Name, + Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), + }, + value = new OsuSpriteText + { + // We don't want the value setting the horizontal size, since it leads to wonky accuracy container length, + // since the accuracy is sometimes longer than its name. + BypassAutoSizeAxes = Axes.X, + Text = statisticInfo.Value, + Font = OsuFont.GetFont(size: 19, weight: FontWeight.Medium), + } + } + }; + + if (score.Combo != score.MaxCombo && statisticInfo.Name == BeatmapsetsStrings.ShowScoreboardHeadersCombo) + value.Colour = colours.Lime1; + } + } + + private partial class RankLabel : Container, IHasTooltip + { + public RankLabel(int? rank, bool sheared) + { + if (rank >= 1000) + TooltipText = $"#{rank:N0}"; + + Child = new OsuSpriteText + { + Shear = new Vector2(sheared ? -OsuGame.SHEAR : 0, 0), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.GetFont(size: 20, weight: FontWeight.SemiBold, italics: true), + Text = rank == null ? "-" : rank.Value.FormatRank().Insert(0, "#") + }; + } + + public LocalisableString TooltipText { get; } + } + + private sealed partial class ColouredModSwitchTiny : ModSwitchTiny, IHasTooltip + { + private readonly IMod mod; + + public ColouredModSwitchTiny(IMod mod) + : base(mod) + { + this.mod = mod; + Active.Value = true; + } + + public LocalisableString TooltipText => (mod as Mod)?.IconTooltip ?? mod.Name; + } + + private sealed partial class MoreModSwitchTiny : CompositeDrawable + { + private readonly int count; + + public MoreModSwitchTiny(int count) + { + this.count = count; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Size = new Vector2(ModSwitchTiny.WIDTH, ModSwitchTiny.DEFAULT_HEIGHT); + + InternalChild = new CircularContainer + { + Masking = true, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = OsuColour.Gray(0.2f), + }, + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Shadow = false, + Font = OsuFont.Numeric.With(size: 24, weight: FontWeight.Black), + Text = $"+{count}", + Colour = colours.Yellow, + Margin = new MarginPadding + { + Top = 4 + } + } + } + }; + } + } + + #endregion + + public MenuItem[] ContextMenuItems + { + get + { + List items = new List(); + + 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))); + items.Add(new OsuMenuItem(Resources.Localisation.Web.CommonStrings.ButtonsDelete, MenuItemType.Destructive, () => dialogOverlay?.Push(new LocalScoreDeleteDialog(score)))); + + return items.ToArray(); + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/SongSelectV2.cs b/osu.Game/Screens/SelectV2/SongSelectV2.cs new file mode 100644 index 0000000000..2f9667793f --- /dev/null +++ b/osu.Game/Screens/SelectV2/SongSelectV2.cs @@ -0,0 +1,151 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Screens; +using osu.Game.Overlays; +using osu.Game.Overlays.Mods; +using osu.Game.Screens.Footer; +using osu.Game.Screens.Menu; +using osu.Game.Screens.Play; +using osu.Game.Screens.SelectV2.Footer; + +namespace osu.Game.Screens.SelectV2 +{ + /// + /// This screen is intended to house all components introduced in the new song select design to add transitions and examine the overall look. + /// This will be gradually built upon and ultimately replace once everything is in place. + /// + public partial class SongSelectV2 : OsuScreen + { + private const float logo_scale = 0.4f; + + private readonly ModSelectOverlay modSelectOverlay = new SoloModSelectOverlay(); + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + + public override bool ShowFooter => true; + + [Resolved] + private OsuLogo? logo { get; set; } + + [BackgroundDependencyLoader] + private void load() + { + AddRangeInternal(new Drawable[] + { + modSelectOverlay, + }); + } + + public override IReadOnlyList CreateFooterButtons() => new ScreenFooterButton[] + { + new ScreenFooterButtonMods(modSelectOverlay) { Current = Mods }, + new ScreenFooterButtonRandom(), + new ScreenFooterButtonOptions(), + }; + + protected override void LoadComplete() + { + base.LoadComplete(); + + modSelectOverlay.State.BindValueChanged(v => + { + logo?.ScaleTo(v.NewValue == Visibility.Visible ? 0f : logo_scale, 400, Easing.OutQuint) + .FadeTo(v.NewValue == Visibility.Visible ? 0f : 1f, 200, Easing.OutQuint); + }, true); + } + + public override void OnEntering(ScreenTransitionEvent e) + { + this.FadeIn(); + + modSelectOverlay.SelectedMods.BindTo(Mods); + + base.OnEntering(e); + } + + public override void OnResuming(ScreenTransitionEvent e) + { + this.FadeIn(); + + // required due to https://github.com/ppy/osu-framework/issues/3218 + modSelectOverlay.SelectedMods.Disabled = false; + modSelectOverlay.SelectedMods.BindTo(Mods); + + base.OnResuming(e); + } + + public override void OnSuspending(ScreenTransitionEvent e) + { + this.Delay(400).FadeOut(); + + modSelectOverlay.SelectedMods.UnbindFrom(Mods); + + base.OnSuspending(e); + } + + public override bool OnExiting(ScreenExitEvent e) + { + this.Delay(400).FadeOut(); + return base.OnExiting(e); + } + + protected override void LogoArriving(OsuLogo logo, bool resuming) + { + base.LogoArriving(logo, resuming); + + if (logo.Alpha > 0.8f) + Footer?.StartTrackingLogo(logo, 400, Easing.OutQuint); + else + { + logo.Hide(); + logo.ScaleTo(0.2f); + Footer?.StartTrackingLogo(logo); + } + + logo.FadeIn(240, Easing.OutQuint); + logo.ScaleTo(logo_scale, 240, Easing.OutQuint); + + logo.Action = () => + { + this.Push(new PlayerLoaderV2(() => new SoloPlayer())); + return false; + }; + } + + protected override void LogoSuspending(OsuLogo logo) + { + base.LogoSuspending(logo); + Footer?.StopTrackingLogo(); + } + + protected override void LogoExiting(OsuLogo logo) + { + base.LogoExiting(logo); + Scheduler.AddDelayed(() => Footer?.StopTrackingLogo(), 120); + logo.ScaleTo(0.2f, 120, Easing.Out); + logo.FadeOut(120, Easing.Out); + } + + private partial class SoloModSelectOverlay : UserModSelectOverlay + { + protected override bool ShowPresets => true; + } + + private partial class PlayerLoaderV2 : PlayerLoader + { + public override bool ShowFooter => true; + + public PlayerLoaderV2(Func createPlayer) + : base(createPlayer) + { + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/Wedge/DifficultyNameContent.cs b/osu.Game/Screens/SelectV2/Wedge/DifficultyNameContent.cs new file mode 100644 index 0000000000..4a3dc34cf9 --- /dev/null +++ b/osu.Game/Screens/SelectV2/Wedge/DifficultyNameContent.cs @@ -0,0 +1,88 @@ +// 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; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Localisation; +using osu.Game.Overlays; + +namespace osu.Game.Screens.SelectV2.Wedge +{ + public abstract partial class DifficultyNameContent : CompositeDrawable + { + protected OsuSpriteText DifficultyName = null!; + private OsuSpriteText mappedByLabel = null!; + protected OsuHoverContainer MapperLink = null!; + protected OsuSpriteText MapperName = null!; + + protected DifficultyNameContent() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + DifficultyName = new TruncatingSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = OsuFont.GetFont(weight: FontWeight.SemiBold), + }, + mappedByLabel = new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + // TODO: better null display? beatmap carousel panels also just show this text currently. + Text = " mapped by ", + Font = OsuFont.GetFont(size: 14), + }, + // This is not a `LinkFlowContainer` as there are single-frame layout issues when Update() + // is being used for layout, see https://github.com/ppy/osu-framework/issues/3369. + MapperLink = new MapperLinkContainer + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + AutoSizeAxes = Axes.Both, + Child = MapperName = new OsuSpriteText + { + Font = OsuFont.GetFont(weight: FontWeight.SemiBold, size: 14), + } + }, + } + }; + } + + protected override void Update() + { + base.Update(); + + // truncate difficulty name when width exceeds bounds, prioritizing mapper name display + DifficultyName.MaxWidth = Math.Max(DrawWidth - mappedByLabel.DrawWidth + - MapperName.DrawWidth, 0); + } + + private partial class MapperLinkContainer : OsuHoverContainer + { + [BackgroundDependencyLoader] + private void load(OverlayColourProvider? overlayColourProvider, OsuColour colours) + { + TooltipText = ContextMenuStrings.ViewProfile; + IdleColour = overlayColourProvider?.Light2 ?? colours.Blue; + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/Wedge/LocalDifficultyNameContent.cs b/osu.Game/Screens/SelectV2/Wedge/LocalDifficultyNameContent.cs new file mode 100644 index 0000000000..66f8cb02b2 --- /dev/null +++ b/osu.Game/Screens/SelectV2/Wedge/LocalDifficultyNameContent.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Game.Beatmaps; +using osu.Game.Online; +using osu.Game.Online.Chat; + +namespace osu.Game.Screens.SelectV2.Wedge +{ + public partial class LocalDifficultyNameContent : DifficultyNameContent + { + [Resolved] + private IBindable beatmap { get; set; } = null!; + + [Resolved] + private ILinkHandler? linkHandler { get; set; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + beatmap.BindValueChanged(b => + { + DifficultyName.Text = b.NewValue.BeatmapInfo.DifficultyName; + + // TODO: should be the mapper of the guest difficulty, but that isn't stored correctly yet (see https://github.com/ppy/osu/issues/12965) + MapperName.Text = b.NewValue.Metadata.Author.Username; + MapperLink.Action = () => linkHandler?.HandleLink(new LinkDetails(LinkAction.OpenUserProfile, b.NewValue.Metadata.Author)); + }, true); + } + } +} diff --git a/osu.Game/Screens/Spectate/SpectatorScreen.cs b/osu.Game/Screens/Spectate/SpectatorScreen.cs index c4aef3c878..ddc638b7c5 100644 --- a/osu.Game/Screens/Spectate/SpectatorScreen.cs +++ b/osu.Game/Screens/Spectate/SpectatorScreen.cs @@ -135,6 +135,7 @@ namespace osu.Game.Screens.Spectate case SpectatedUserState.Passed: markReceivedAllFrames(userId); + PassGameplay(userId); break; case SpectatedUserState.Failed: @@ -233,6 +234,12 @@ namespace osu.Game.Screens.Spectate /// The gameplay state. protected abstract void StartGameplay(int userId, SpectatorGameplayState spectatorGameplayState); + /// + /// Fired when a user passes gameplay. + /// + /// The user which passed. + protected virtual void PassGameplay(int userId) { } + /// /// Quits gameplay for a user. /// Thread safety is not guaranteed – should be scheduled as required. diff --git a/osu.Game/Screens/Utility/SampleComponents/LatencySampleComponent.cs b/osu.Game/Screens/Utility/SampleComponents/LatencySampleComponent.cs index 690376cf52..922935f520 100644 --- a/osu.Game/Screens/Utility/SampleComponents/LatencySampleComponent.cs +++ b/osu.Game/Screens/Utility/SampleComponents/LatencySampleComponent.cs @@ -38,7 +38,7 @@ namespace osu.Game.Screens.Utility.SampleComponents { base.LoadComplete(); - inputManager = GetContainingInputManager(); + inputManager = GetContainingInputManager()!; IsActive.BindTo(latencyArea.IsActiveArea); } diff --git a/osu.Game/Skinning/ArgonSkin.cs b/osu.Game/Skinning/ArgonSkin.cs index 8fd393fcc5..771d10d73b 100644 --- a/osu.Game/Skinning/ArgonSkin.cs +++ b/osu.Game/Skinning/ArgonSkin.cs @@ -7,6 +7,7 @@ using JetBrains.Annotations; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Textures; using osu.Game.Audio; using osu.Game.Beatmaps.Formats; @@ -93,19 +94,12 @@ namespace osu.Game.Skinning // Temporary until default skin has a valid hit lighting. if ((lookup as SkinnableSprite.SpriteComponentLookup)?.LookupName == @"lighting") return Drawable.Empty(); - if (base.GetDrawableComponent(lookup) is Drawable c) - return c; - switch (lookup) { - case SkinComponentsContainerLookup containerLookup: - // Only handle global level defaults for now. - if (containerLookup.Ruleset != null) - return null; - - switch (containerLookup.Target) + case GlobalSkinnableContainerLookup containerLookup: + switch (containerLookup.Lookup) { - case SkinComponentsContainerLookup.TargetArea.SongSelect: + case GlobalSkinnableContainers.SongSelect: var songSelectComponents = new DefaultSkinComponentsContainer(_ => { // do stuff when we need to. @@ -113,8 +107,23 @@ namespace osu.Game.Skinning return songSelectComponents; - case SkinComponentsContainerLookup.TargetArea.MainHUDComponents: - var skinnableTargetWrapper = new DefaultSkinComponentsContainer(container => + case GlobalSkinnableContainers.MainHUDComponents: + if (containerLookup.Ruleset != null) + { + return new Container + { + RelativeSizeAxes = Axes.Both, + Child = new ArgonComboCounter + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Position = new Vector2(36, -66), + Scale = new Vector2(1.3f), + }, + }; + } + + var mainHUDComponents = new DefaultSkinComponentsContainer(container => { var health = container.OfType().FirstOrDefault(); var healthLine = container.OfType().FirstOrDefault(); @@ -122,7 +131,6 @@ namespace osu.Game.Skinning var score = container.OfType().FirstOrDefault(); var accuracy = container.OfType().FirstOrDefault(); var performancePoints = container.OfType().FirstOrDefault(); - var combo = container.OfType().FirstOrDefault(); var songProgress = container.OfType().FirstOrDefault(); var keyCounter = container.OfType().FirstOrDefault(); @@ -203,13 +211,6 @@ namespace osu.Game.Skinning keyCounter.Origin = Anchor.BottomRight; keyCounter.Position = new Vector2(-(hitError.Width + padding), -(padding * 2 + song_progress_offset_height)); } - - if (combo != null && hitError != null) - { - combo.Anchor = Anchor.BottomLeft; - combo.Origin = Anchor.BottomLeft; - combo.Position = new Vector2((hitError.Width + padding), -(padding * 2 + song_progress_offset_height)); - } } } }) @@ -239,10 +240,6 @@ namespace osu.Game.Skinning { Scale = new Vector2(0.8f), }, - new ArgonComboCounter - { - Scale = new Vector2(1.3f) - }, new BarHitErrorMeter(), new BarHitErrorMeter(), new ArgonSongProgress(), @@ -250,13 +247,13 @@ namespace osu.Game.Skinning } }; - return skinnableTargetWrapper; + return mainHUDComponents; } return null; } - return null; + return base.GetDrawableComponent(lookup); } public override IBindable? GetConfig(TLookup lookup) diff --git a/osu.Game/Skinning/BeatmapSkinProvidingContainer.cs b/osu.Game/Skinning/BeatmapSkinProvidingContainer.cs index 4486c8a9f0..41fa7fcc66 100644 --- a/osu.Game/Skinning/BeatmapSkinProvidingContainer.cs +++ b/osu.Game/Skinning/BeatmapSkinProvidingContainer.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -17,9 +15,9 @@ namespace osu.Game.Skinning /// public partial class BeatmapSkinProvidingContainer : SkinProvidingContainer { - private Bindable beatmapSkins; - private Bindable beatmapColours; - private Bindable beatmapHitsounds; + private Bindable beatmapSkins = null!; + private Bindable beatmapColours = null!; + private Bindable beatmapHitsounds = null!; protected override bool AllowConfigurationLookup { @@ -68,11 +66,15 @@ namespace osu.Game.Skinning } private readonly ISkin skin; + private readonly ISkin? classicFallback; - public BeatmapSkinProvidingContainer(ISkin skin) + private Bindable currentSkin = null!; + + public BeatmapSkinProvidingContainer(ISkin skin, ISkin? classicFallback = null) : base(skin) { this.skin = skin; + this.classicFallback = classicFallback; } protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) @@ -93,15 +95,27 @@ namespace osu.Game.Skinning beatmapColours.BindValueChanged(_ => TriggerSourceChanged()); beatmapHitsounds.BindValueChanged(_ => TriggerSourceChanged()); - // If the beatmap skin looks to have skinnable resources, add the default classic skin as a fallback opportunity. - if (skin is LegacySkinTransformer legacySkin && legacySkin.IsProvidingLegacyResources) + currentSkin = skins.CurrentSkin.GetBoundCopy(); + currentSkin.BindValueChanged(_ => { - SetSources(new[] - { - skin, - skins.DefaultClassicSkin - }); - } + bool userSkinIsLegacy = skins.CurrentSkin.Value is LegacySkin; + bool beatmapProvidingResources = skin is LegacySkinTransformer legacySkin && legacySkin.IsProvidingLegacyResources; + + // Some beatmaps provide a limited selection of skin elements to add some visual flair. + // In stable, these elements will take lookup priority over the selected skin (whether that be a user skin or default). + // + // To replicate this we need to pay special attention to the fallback order. + // If a user has a non-legacy skin (argon, triangles) selected, the game won't normally fall back to a legacy skin. + // In turn this can create an unexpected visual experience. + // + // So here, check what skin the user has selected. If it's already a legacy skin then we don't need to do anything special. + // If it isn't, we insert the classic default. Note that this is only done if the beatmap seems to be providing skin elements, + // as we only want to override the user's (non-legacy) skin choice when required for beatmap skin visuals. + if (!userSkinIsLegacy && beatmapProvidingResources && classicFallback != null) + SetSources(new[] { skin, classicFallback }); + else + SetSources(new[] { skin }); + }, true); } } } diff --git a/osu.Game/Skinning/Components/BeatmapAttributeText.cs b/osu.Game/Skinning/Components/BeatmapAttributeText.cs index 5c5e509fb2..79a1ed4d7c 100644 --- a/osu.Game/Skinning/Components/BeatmapAttributeText.cs +++ b/osu.Game/Skinning/Components/BeatmapAttributeText.cs @@ -3,8 +3,8 @@ using System; using System.Collections.Generic; -using System.Collections.Immutable; using System.Linq; +using System.Threading; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -20,6 +20,9 @@ using osu.Game.Graphics.Sprites; using osu.Game.Localisation; using osu.Game.Localisation.SkinComponents; using osu.Game.Resources.Localisation.Web; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Utils; namespace osu.Game.Skinning.Components { @@ -35,26 +38,20 @@ namespace osu.Game.Skinning.Components [Resolved] private IBindable beatmap { get; set; } = null!; - private readonly Dictionary valueDictionary = new Dictionary(); + [Resolved] + private IBindable> mods { get; set; } = null!; - private static readonly ImmutableDictionary label_dictionary = new Dictionary - { - [BeatmapAttribute.CircleSize] = BeatmapsetsStrings.ShowStatsCs, - [BeatmapAttribute.Accuracy] = BeatmapsetsStrings.ShowStatsAccuracy, - [BeatmapAttribute.HPDrain] = BeatmapsetsStrings.ShowStatsDrain, - [BeatmapAttribute.ApproachRate] = BeatmapsetsStrings.ShowStatsAr, - [BeatmapAttribute.StarRating] = BeatmapsetsStrings.ShowStatsStars, - [BeatmapAttribute.Title] = EditorSetupStrings.Title, - [BeatmapAttribute.Artist] = EditorSetupStrings.Artist, - [BeatmapAttribute.DifficultyName] = EditorSetupStrings.DifficultyHeader, - [BeatmapAttribute.Creator] = EditorSetupStrings.Creator, - [BeatmapAttribute.Source] = EditorSetupStrings.Source, - [BeatmapAttribute.Length] = ArtistStrings.TracklistLength.ToTitle(), - [BeatmapAttribute.RankedStatus] = BeatmapDiscussionsStrings.IndexFormBeatmapsetStatusDefault, - [BeatmapAttribute.BPM] = BeatmapsetsStrings.ShowStatsBpm, - }.ToImmutableDictionary(); + [Resolved] + private IBindable ruleset { get; set; } = null!; + + [Resolved] + private BeatmapDifficultyCache difficultyCache { get; set; } = null!; private readonly OsuSpriteText text; + private IBindable? difficultyBindable; + private CancellationTokenSource? difficultyCancellationSource; + private ModSettingChangeTracker? modSettingTracker; + private StarDifficulty? starDifficulty; public BeatmapAttributeText() { @@ -74,57 +71,205 @@ namespace osu.Game.Skinning.Components { base.LoadComplete(); - Attribute.BindValueChanged(_ => updateLabel()); - Template.BindValueChanged(_ => updateLabel()); + Attribute.BindValueChanged(_ => updateText()); + Template.BindValueChanged(_ => updateText()); + beatmap.BindValueChanged(b => { - updateBeatmapContent(b.NewValue); - updateLabel(); + difficultyCancellationSource?.Cancel(); + difficultyCancellationSource = new CancellationTokenSource(); + + difficultyBindable?.UnbindAll(); + difficultyBindable = difficultyCache.GetBindableDifficulty(b.NewValue.BeatmapInfo, difficultyCancellationSource.Token); + difficultyBindable.BindValueChanged(d => + { + starDifficulty = d.NewValue; + updateText(); + }); + + updateText(); }, true); + + mods.BindValueChanged(m => + { + modSettingTracker?.Dispose(); + modSettingTracker = new ModSettingChangeTracker(m.NewValue) + { + SettingChanged = _ => updateText() + }; + + updateText(); + }, true); + + ruleset.BindValueChanged(_ => updateText()); + + updateText(); } - private void updateBeatmapContent(WorkingBeatmap workingBeatmap) - { - valueDictionary[BeatmapAttribute.Title] = new RomanisableString(workingBeatmap.BeatmapInfo.Metadata.TitleUnicode, workingBeatmap.BeatmapInfo.Metadata.Title); - valueDictionary[BeatmapAttribute.Artist] = new RomanisableString(workingBeatmap.BeatmapInfo.Metadata.ArtistUnicode, workingBeatmap.BeatmapInfo.Metadata.Artist); - valueDictionary[BeatmapAttribute.DifficultyName] = workingBeatmap.BeatmapInfo.DifficultyName; - valueDictionary[BeatmapAttribute.Creator] = workingBeatmap.BeatmapInfo.Metadata.Author.Username; - valueDictionary[BeatmapAttribute.Source] = workingBeatmap.BeatmapInfo.Metadata.Source; - valueDictionary[BeatmapAttribute.Length] = TimeSpan.FromMilliseconds(workingBeatmap.BeatmapInfo.Length).ToFormattedDuration(); - valueDictionary[BeatmapAttribute.RankedStatus] = workingBeatmap.BeatmapInfo.Status.GetLocalisableDescription(); - valueDictionary[BeatmapAttribute.BPM] = workingBeatmap.BeatmapInfo.BPM.ToLocalisableString(@"F2"); - valueDictionary[BeatmapAttribute.CircleSize] = ((double)workingBeatmap.BeatmapInfo.Difficulty.CircleSize).ToLocalisableString(@"F2"); - valueDictionary[BeatmapAttribute.HPDrain] = ((double)workingBeatmap.BeatmapInfo.Difficulty.DrainRate).ToLocalisableString(@"F2"); - valueDictionary[BeatmapAttribute.Accuracy] = ((double)workingBeatmap.BeatmapInfo.Difficulty.OverallDifficulty).ToLocalisableString(@"F2"); - valueDictionary[BeatmapAttribute.ApproachRate] = ((double)workingBeatmap.BeatmapInfo.Difficulty.ApproachRate).ToLocalisableString(@"F2"); - valueDictionary[BeatmapAttribute.StarRating] = workingBeatmap.BeatmapInfo.StarRating.ToLocalisableString(@"F2"); - } - - private void updateLabel() + private void updateText() { string numberedTemplate = Template.Value .Replace("{", "{{") .Replace("}", "}}") .Replace(@"{{Label}}", "{0}") - .Replace(@"{{Value}}", $"{{{1 + (int)Attribute.Value}}}"); + .Replace(@"{{Value}}", "{1}"); - object?[] args = valueDictionary.OrderBy(pair => pair.Key) - .Select(pair => pair.Value) - .Prepend(label_dictionary[Attribute.Value]) - .Cast() - .ToArray(); + List values = new List + { + getLabelString(Attribute.Value), + getValueString(Attribute.Value) + }; foreach (var type in Enum.GetValues()) { - numberedTemplate = numberedTemplate.Replace($"{{{{{type}}}}}", $"{{{1 + (int)type}}}"); + string replaced = numberedTemplate.Replace($@"{{{{{type}}}}}", $@"{{{values.Count}}}"); + + if (numberedTemplate != replaced) + { + numberedTemplate = replaced; + values.Add(getValueString(type)); + } } - text.Text = LocalisableString.Format(numberedTemplate, args); + text.Text = LocalisableString.Format(numberedTemplate, values.ToArray()); + } + + private LocalisableString getLabelString(BeatmapAttribute attribute) + { + switch (attribute) + { + case BeatmapAttribute.CircleSize: + return BeatmapsetsStrings.ShowStatsCs; + + case BeatmapAttribute.Accuracy: + return BeatmapsetsStrings.ShowStatsAccuracy; + + case BeatmapAttribute.HPDrain: + return BeatmapsetsStrings.ShowStatsDrain; + + case BeatmapAttribute.ApproachRate: + return BeatmapsetsStrings.ShowStatsAr; + + case BeatmapAttribute.StarRating: + return BeatmapsetsStrings.ShowStatsStars; + + case BeatmapAttribute.Title: + return EditorSetupStrings.Title; + + case BeatmapAttribute.Artist: + return EditorSetupStrings.Artist; + + case BeatmapAttribute.DifficultyName: + return EditorSetupStrings.DifficultyHeader; + + case BeatmapAttribute.Creator: + return EditorSetupStrings.Creator; + + case BeatmapAttribute.Source: + return EditorSetupStrings.Source; + + case BeatmapAttribute.Length: + return ArtistStrings.TracklistLength.ToTitle(); + + case BeatmapAttribute.RankedStatus: + return BeatmapDiscussionsStrings.IndexFormBeatmapsetStatusDefault; + + case BeatmapAttribute.BPM: + return BeatmapsetsStrings.ShowStatsBpm; + + case BeatmapAttribute.MaxPP: + return BeatmapAttributeTextStrings.MaxPP; + + default: + return string.Empty; + } + } + + private LocalisableString getValueString(BeatmapAttribute attribute) + { + switch (attribute) + { + case BeatmapAttribute.Title: + return new RomanisableString(beatmap.Value.BeatmapInfo.Metadata.TitleUnicode, beatmap.Value.BeatmapInfo.Metadata.Title); + + case BeatmapAttribute.Artist: + return new RomanisableString(beatmap.Value.BeatmapInfo.Metadata.ArtistUnicode, beatmap.Value.BeatmapInfo.Metadata.Artist); + + case BeatmapAttribute.DifficultyName: + return beatmap.Value.BeatmapInfo.DifficultyName; + + case BeatmapAttribute.Creator: + return beatmap.Value.BeatmapInfo.Metadata.Author.Username; + + case BeatmapAttribute.Source: + return beatmap.Value.BeatmapInfo.Metadata.Source; + + case BeatmapAttribute.Length: + return Math.Round(beatmap.Value.BeatmapInfo.Length / ModUtils.CalculateRateWithMods(mods.Value)).ToFormattedDuration(); + + case BeatmapAttribute.RankedStatus: + return beatmap.Value.BeatmapInfo.Status.GetLocalisableDescription(); + + case BeatmapAttribute.BPM: + return FormatUtils.RoundBPM(beatmap.Value.BeatmapInfo.BPM, ModUtils.CalculateRateWithMods(mods.Value)).ToLocalisableString(@"F2"); + + case BeatmapAttribute.CircleSize: + return computeDifficulty().CircleSize.ToLocalisableString(@"F2"); + + case BeatmapAttribute.HPDrain: + return computeDifficulty().DrainRate.ToLocalisableString(@"F2"); + + case BeatmapAttribute.Accuracy: + return computeDifficulty().OverallDifficulty.ToLocalisableString(@"F2"); + + case BeatmapAttribute.ApproachRate: + return computeDifficulty().ApproachRate.ToLocalisableString(@"F2"); + + case BeatmapAttribute.StarRating: + return (starDifficulty?.Stars ?? 0).ToLocalisableString(@"F2"); + + case BeatmapAttribute.MaxPP: + return Math.Round(starDifficulty?.PerformanceAttributes?.Total ?? 0, MidpointRounding.AwayFromZero).ToLocalisableString(); + + default: + return string.Empty; + } + + BeatmapDifficulty computeDifficulty() + { + BeatmapDifficulty difficulty = new BeatmapDifficulty(beatmap.Value.BeatmapInfo.Difficulty); + + foreach (var mod in mods.Value.OfType()) + mod.ApplyToDifficulty(difficulty); + + if (ruleset.Value is RulesetInfo rulesetInfo) + { + double rate = ModUtils.CalculateRateWithMods(mods.Value); + difficulty = rulesetInfo.CreateInstance().GetRateAdjustedDisplayDifficulty(difficulty, rate); + } + + return difficulty; + } } protected override void SetFont(FontUsage font) => text.Font = font.With(size: 40); + + protected override void SetTextColour(Colour4 textColour) => text.Colour = textColour; + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + difficultyCancellationSource?.Cancel(); + difficultyCancellationSource?.Dispose(); + difficultyCancellationSource = null; + + modSettingTracker?.Dispose(); + } } + // WARNING: DO NOT ADD ANY VALUES TO THIS ENUM ANYWHERE ELSE THAN AT THE END. + // Doing so will break existing user skins. public enum BeatmapAttribute { CircleSize, @@ -134,11 +279,12 @@ namespace osu.Game.Skinning.Components StarRating, Title, Artist, - Source, DifficultyName, Creator, Length, RankedStatus, BPM, + Source, + MaxPP } } diff --git a/osu.Game/Skinning/Components/BoxElement.cs b/osu.Game/Skinning/Components/BoxElement.cs index 34d389728c..7f052a8523 100644 --- a/osu.Game/Skinning/Components/BoxElement.cs +++ b/osu.Game/Skinning/Components/BoxElement.cs @@ -27,6 +27,9 @@ namespace osu.Game.Skinning.Components Precision = 0.01f }; + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Colour), nameof(SkinnableComponentStrings.ColourDescription))] + public BindableColour4 AccentColour { get; } = new BindableColour4(Colour4.White); + public BoxElement() { Size = new Vector2(400, 80); @@ -43,6 +46,13 @@ namespace osu.Game.Skinning.Components Masking = true; } + protected override void LoadComplete() + { + base.LoadComplete(); + + AccentColour.BindValueChanged(_ => Colour = AccentColour.Value, true); + } + protected override void Update() { base.Update(); diff --git a/osu.Game/Skinning/Components/PlayerName.cs b/osu.Game/Skinning/Components/PlayerName.cs index 21bf615bc6..5b6ded0cc5 100644 --- a/osu.Game/Skinning/Components/PlayerName.cs +++ b/osu.Game/Skinning/Components/PlayerName.cs @@ -53,5 +53,7 @@ namespace osu.Game.Skinning.Components } protected override void SetFont(FontUsage font) => text.Font = font.With(size: 40); + + protected override void SetTextColour(Colour4 textColour) => text.Colour = textColour; } } diff --git a/osu.Game/Skinning/Components/TextElement.cs b/osu.Game/Skinning/Components/TextElement.cs index 936f6a529b..6e875c5590 100644 --- a/osu.Game/Skinning/Components/TextElement.cs +++ b/osu.Game/Skinning/Components/TextElement.cs @@ -36,5 +36,7 @@ namespace osu.Game.Skinning.Components } protected override void SetFont(FontUsage font) => text.Font = font.With(size: 40); + + protected override void SetTextColour(Colour4 textColour) => text.Colour = textColour; } } diff --git a/osu.Game/Skinning/FontAdjustableSkinComponent.cs b/osu.Game/Skinning/FontAdjustableSkinComponent.cs index 8f3a1d41c6..0821edf7fc 100644 --- a/osu.Game/Skinning/FontAdjustableSkinComponent.cs +++ b/osu.Game/Skinning/FontAdjustableSkinComponent.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Bindables; +using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Game.Configuration; @@ -20,11 +21,16 @@ namespace osu.Game.Skinning [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Font), nameof(SkinnableComponentStrings.FontDescription))] public Bindable Font { get; } = new Bindable(Typeface.Torus); + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.TextColour), nameof(SkinnableComponentStrings.TextColourDescription))] + public BindableColour4 TextColour { get; } = new BindableColour4(Colour4.White); + /// /// Implement to apply the user font selection to one or more components. /// protected abstract void SetFont(FontUsage font); + protected abstract void SetTextColour(Colour4 textColour); + protected override void LoadComplete() { base.LoadComplete(); @@ -37,6 +43,8 @@ namespace osu.Game.Skinning FontUsage f = OsuFont.GetFont(e.NewValue, weight: fontWeight); SetFont(f); }, true); + + TextColour.BindValueChanged(e => SetTextColour(e.NewValue), true); } } } diff --git a/osu.Game/Skinning/GameplaySkinComponentLookup.cs b/osu.Game/Skinning/GameplaySkinComponentLookup.cs deleted file mode 100644 index c317a17e21..0000000000 --- a/osu.Game/Skinning/GameplaySkinComponentLookup.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Scoring; - -namespace osu.Game.Skinning -{ - /// - /// A lookup type intended for use for skinnable gameplay components (not HUD level components). - /// - /// - /// The most common usage of this class is for ruleset-specific skinning implementations, but it can also be used directly - /// (see 's usage for ) where ruleset-agnostic elements are required. - /// - /// An enum lookup type. - public class GameplaySkinComponentLookup : ISkinComponentLookup - where T : Enum - { - public readonly T Component; - - public GameplaySkinComponentLookup(T component) - { - Component = component; - } - } -} diff --git a/osu.Game/Skinning/SkinComponentsContainerLookup.cs b/osu.Game/Skinning/GlobalSkinnableContainerLookup.cs similarity index 55% rename from osu.Game/Skinning/SkinComponentsContainerLookup.cs rename to osu.Game/Skinning/GlobalSkinnableContainerLookup.cs index 34358c3f06..6d78981f0a 100644 --- a/osu.Game/Skinning/SkinComponentsContainerLookup.cs +++ b/osu.Game/Skinning/GlobalSkinnableContainerLookup.cs @@ -2,21 +2,20 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.ComponentModel; using osu.Framework.Extensions; using osu.Game.Rulesets; namespace osu.Game.Skinning { /// - /// Represents a lookup of a collection of elements that make up a particular skinnable of the game. + /// Represents a lookup of a collection of elements that make up a particular skinnable of the game. /// - public class SkinComponentsContainerLookup : ISkinComponentLookup, IEquatable + public class GlobalSkinnableContainerLookup : ISkinComponentLookup, IEquatable { /// /// The target area / layer of the game for which skin components will be returned. /// - public readonly TargetArea Target; + public readonly GlobalSkinnableContainers Lookup; /// /// The ruleset for which skin components should be returned. @@ -24,25 +23,25 @@ namespace osu.Game.Skinning /// public readonly RulesetInfo? Ruleset; - public SkinComponentsContainerLookup(TargetArea target, RulesetInfo? ruleset = null) + public GlobalSkinnableContainerLookup(GlobalSkinnableContainers lookup, RulesetInfo? ruleset = null) { - Target = target; + Lookup = lookup; Ruleset = ruleset; } public override string ToString() { - if (Ruleset == null) return Target.GetDescription(); + if (Ruleset == null) return Lookup.GetDescription(); - return $"{Target.GetDescription()} (\"{Ruleset.Name}\" only)"; + return $"{Lookup.GetDescription()} (\"{Ruleset.Name}\" only)"; } - public bool Equals(SkinComponentsContainerLookup? other) + public bool Equals(GlobalSkinnableContainerLookup? other) { if (ReferenceEquals(null, other)) return false; if (ReferenceEquals(this, other)) return true; - return Target == other.Target && (ReferenceEquals(Ruleset, other.Ruleset) || Ruleset?.Equals(other.Ruleset) == true); + return Lookup == other.Lookup && (ReferenceEquals(Ruleset, other.Ruleset) || Ruleset?.Equals(other.Ruleset) == true); } public override bool Equals(object? obj) @@ -51,27 +50,12 @@ namespace osu.Game.Skinning if (ReferenceEquals(this, obj)) return true; if (obj.GetType() != GetType()) return false; - return Equals((SkinComponentsContainerLookup)obj); + return Equals((GlobalSkinnableContainerLookup)obj); } public override int GetHashCode() { - return HashCode.Combine((int)Target, Ruleset); - } - - /// - /// Represents a particular area or part of a game screen whose layout can be customised using the skin editor. - /// - public enum TargetArea - { - [Description("HUD")] - MainHUDComponents, - - [Description("Song select")] - SongSelect, - - [Description("Playfield")] - Playfield + return HashCode.Combine((int)Lookup, Ruleset); } } } diff --git a/osu.Game/Skinning/GlobalSkinnableContainers.cs b/osu.Game/Skinning/GlobalSkinnableContainers.cs new file mode 100644 index 0000000000..02f915895f --- /dev/null +++ b/osu.Game/Skinning/GlobalSkinnableContainers.cs @@ -0,0 +1,22 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.ComponentModel; + +namespace osu.Game.Skinning +{ + /// + /// Represents a particular area or part of a game screen whose layout can be customised using the skin editor. + /// + public enum GlobalSkinnableContainers + { + [Description("HUD")] + MainHUDComponents, + + [Description("Song select")] + SongSelect, + + [Description("Playfield")] + Playfield + } +} diff --git a/osu.Game/Skinning/ISkin.cs b/osu.Game/Skinning/ISkin.cs index fa04dda202..2af1eb8dd8 100644 --- a/osu.Game/Skinning/ISkin.cs +++ b/osu.Game/Skinning/ISkin.cs @@ -47,6 +47,9 @@ namespace osu.Game.Skinning /// /// Retrieve a configuration value. /// + /// + /// Note that while this returns a bindable value, it is not actually updated. + /// Until the API is fixed, just use the received bindable's immediately. /// The requested configuration value. /// A matching value boxed in an , or null if unavailable. IBindable? GetConfig(TLookup lookup) diff --git a/osu.Game/Skinning/ISkinComponentLookup.cs b/osu.Game/Skinning/ISkinComponentLookup.cs index 25ee086707..af2b512331 100644 --- a/osu.Game/Skinning/ISkinComponentLookup.cs +++ b/osu.Game/Skinning/ISkinComponentLookup.cs @@ -12,7 +12,7 @@ namespace osu.Game.Skinning /// to scope particular lookup variations. Using this, a ruleset or skin implementation could make its own lookup /// type to scope away from more global contexts. /// - /// More commonly, a ruleset could make use of to do a simple lookup based on + /// More commonly, a ruleset could make use of to do a simple lookup based on /// a provided enum. /// public interface ISkinComponentLookup diff --git a/osu.Game/Skinning/LegacyBeatmapSkin.cs b/osu.Game/Skinning/LegacyBeatmapSkin.cs index 9cd072b607..656c0e046f 100644 --- a/osu.Game/Skinning/LegacyBeatmapSkin.cs +++ b/osu.Game/Skinning/LegacyBeatmapSkin.cs @@ -50,11 +50,11 @@ namespace osu.Game.Skinning public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup) { - if (lookup is SkinComponentsContainerLookup containerLookup) + if (lookup is GlobalSkinnableContainerLookup containerLookup) { - switch (containerLookup.Target) + switch (containerLookup.Lookup) { - case SkinComponentsContainerLookup.TargetArea.MainHUDComponents: + case GlobalSkinnableContainers.MainHUDComponents: // this should exist in LegacySkin instead, but there isn't a fallback skin for LegacySkins yet. // therefore keep the check here until fallback default legacy skin is supported. if (!this.HasFont(LegacyFont.Score)) diff --git a/osu.Game/Skinning/LegacyComboCounter.cs b/osu.Game/Skinning/LegacyDefaultComboCounter.cs similarity index 93% rename from osu.Game/Skinning/LegacyComboCounter.cs rename to osu.Game/Skinning/LegacyDefaultComboCounter.cs index cd72055fce..7de4aee656 100644 --- a/osu.Game/Skinning/LegacyComboCounter.cs +++ b/osu.Game/Skinning/LegacyDefaultComboCounter.cs @@ -14,7 +14,7 @@ namespace osu.Game.Skinning /// /// Uses the 'x' symbol and has a pop-out effect while rolling over. /// - public partial class LegacyComboCounter : CompositeDrawable, ISerialisableDrawable + public partial class LegacyDefaultComboCounter : CompositeDrawable, ISerialisableDrawable { public Bindable Current { get; } = new BindableInt { MinValue = 0 }; @@ -43,21 +43,9 @@ namespace osu.Game.Skinning private readonly Container counterContainer; - /// - /// Hides the combo counter internally without affecting its . - /// - /// - /// This is used for rulesets that provide their own combo counter and don't want this HUD one to be visible, - /// without potentially affecting the user's selected skin. - /// - public bool HiddenByRulesetImplementation - { - set => counterContainer.Alpha = value ? 1 : 0; - } - public bool UsesFixedAnchor { get; set; } - public LegacyComboCounter() + public LegacyDefaultComboCounter() { AutoSizeAxes = Axes.Both; diff --git a/osu.Game/Skinning/LegacyKeyCounter.cs b/osu.Game/Skinning/LegacyKeyCounter.cs new file mode 100644 index 0000000000..609e21b9ff --- /dev/null +++ b/osu.Game/Skinning/LegacyKeyCounter.cs @@ -0,0 +1,101 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Screens.Play.HUD; + +namespace osu.Game.Skinning +{ + public partial class LegacyKeyCounter : KeyCounter + { + private const float transition_duration = 160; + + public Colour4 ActiveColour { get; set; } + + private Colour4 textColour; + + public Colour4 TextColour + { + get => textColour; + set + { + textColour = value; + overlayKeyText.Colour = value; + } + } + + private readonly Container keyContainer; + private readonly OsuSpriteText overlayKeyText; + private readonly Sprite keySprite; + + public LegacyKeyCounter(InputTrigger trigger) + : base(trigger) + { + Origin = Anchor.Centre; + Anchor = Anchor.Centre; + Child = keyContainer = new Container + { + AutoSizeAxes = Axes.Both, + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Children = new Drawable[] + { + keySprite = new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new UprightAspectMaintainingContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Child = overlayKeyText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = trigger.Name, + Colour = textColour, + Font = OsuFont.GetFont(weight: FontWeight.SemiBold), + }, + }, + } + }; + + // matches longest dimension of default skin asset + Height = Width = 46; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource source) + { + Texture? keyTexture = source.GetTexture(@"inputoverlay-key"); + + if (keyTexture != null) + keySprite.Texture = keyTexture; + } + + protected override void Activate(bool forwardPlayback = true) + { + base.Activate(forwardPlayback); + keyContainer.ScaleTo(0.75f, transition_duration, Easing.Out); + keySprite.Colour = ActiveColour; + overlayKeyText.Text = CountPresses.Value.ToString(); + overlayKeyText.Font = overlayKeyText.Font.With(weight: FontWeight.SemiBold); + } + + protected override void Deactivate(bool forwardPlayback = true) + { + base.Deactivate(forwardPlayback); + keyContainer.ScaleTo(1f, transition_duration, Easing.Out); + keySprite.Colour = Colour4.White; + } + } +} diff --git a/osu.Game/Skinning/LegacyKeyCounterDisplay.cs b/osu.Game/Skinning/LegacyKeyCounterDisplay.cs new file mode 100644 index 0000000000..fdbd3570f5 --- /dev/null +++ b/osu.Game/Skinning/LegacyKeyCounterDisplay.cs @@ -0,0 +1,92 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Screens.Play.HUD; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Skinning +{ + public partial class LegacyKeyCounterDisplay : KeyCounterDisplay + { + private static readonly Colour4 active_colour_top = Colour4.FromHex(@"#ffde00"); + private static readonly Colour4 active_colour_bottom = Colour4.FromHex(@"#f8009e"); + + protected override FillFlowContainer KeyFlow { get; } + + private readonly Sprite backgroundSprite; + + public LegacyKeyCounterDisplay() + { + AutoSizeAxes = Axes.Both; + + AddRange(new Drawable[] + { + backgroundSprite = new Sprite + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopLeft, + Scale = new Vector2(1.05f, 1), + Rotation = 90, + }, + KeyFlow = new FillFlowContainer + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + X = -1.5f, + Y = 7, + Spacing = new Vector2(1.8f), + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Both, + }, + }); + } + + [Resolved] + private ISkinSource source { get; set; } = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + KeyTextColor = source.GetConfig(new SkinCustomColourLookup(SkinConfiguration.LegacySetting.InputOverlayText))?.Value ?? Color4.Black; + + Texture? backgroundTexture = source.GetTexture(@"inputoverlay-background"); + + if (backgroundTexture != null) + backgroundSprite.Texture = backgroundTexture; + + for (int i = 0; i < KeyFlow.Count; ++i) + { + ((LegacyKeyCounter)KeyFlow[i]).ActiveColour = i < 2 ? active_colour_top : active_colour_bottom; + } + } + + protected override KeyCounter CreateCounter(InputTrigger trigger) => new LegacyKeyCounter(trigger) + { + TextColour = keyTextColor, + }; + + private Colour4 keyTextColor = Colour4.White; + + public Colour4 KeyTextColor + { + get => keyTextColor; + set + { + if (value != keyTextColor) + { + keyTextColor = value; + foreach (var child in KeyFlow.Cast()) + child.TextColour = value; + } + } + } + } +} diff --git a/osu.Game/Skinning/LegacyManiaSkinConfiguration.cs b/osu.Game/Skinning/LegacyManiaSkinConfiguration.cs index 042836984a..db1f216b6e 100644 --- a/osu.Game/Skinning/LegacyManiaSkinConfiguration.cs +++ b/osu.Game/Skinning/LegacyManiaSkinConfiguration.cs @@ -39,6 +39,7 @@ namespace osu.Game.Skinning public float HitPosition = DEFAULT_HIT_POSITION; public float LightPosition = (480 - 413) * POSITION_SCALE_FACTOR; + public float ComboPosition = 111 * POSITION_SCALE_FACTOR; public float ScorePosition = 300 * POSITION_SCALE_FACTOR; public bool ShowJudgementLine = true; public bool KeysUnderNotes; diff --git a/osu.Game/Skinning/LegacyManiaSkinConfigurationLookup.cs b/osu.Game/Skinning/LegacyManiaSkinConfigurationLookup.cs index cacca0de23..ee354de68b 100644 --- a/osu.Game/Skinning/LegacyManiaSkinConfigurationLookup.cs +++ b/osu.Game/Skinning/LegacyManiaSkinConfigurationLookup.cs @@ -42,6 +42,7 @@ namespace osu.Game.Skinning LeftLineWidth, RightLineWidth, HitPosition, + ComboPosition, ScorePosition, LightPosition, StagePaddingTop, @@ -63,11 +64,15 @@ namespace osu.Game.Skinning JudgementLineColour, ColumnBackgroundColour, ColumnLightColour, + ComboBreakColour, MinimumColumnWidth, LeftStageImage, RightStageImage, BottomStageImage, + + // ReSharper disable once InconsistentNaming Hit300g, + Hit300, Hit200, Hit100, diff --git a/osu.Game/Skinning/LegacyManiaSkinDecoder.cs b/osu.Game/Skinning/LegacyManiaSkinDecoder.cs index ff6e7fc38e..09866ef237 100644 --- a/osu.Game/Skinning/LegacyManiaSkinDecoder.cs +++ b/osu.Game/Skinning/LegacyManiaSkinDecoder.cs @@ -94,6 +94,10 @@ namespace osu.Game.Skinning currentConfig.LightPosition = (480 - float.Parse(pair.Value, CultureInfo.InvariantCulture)) * LegacyManiaSkinConfiguration.POSITION_SCALE_FACTOR; break; + case "ComboPosition": + currentConfig.ComboPosition = (float.Parse(pair.Value, CultureInfo.InvariantCulture)) * LegacyManiaSkinConfiguration.POSITION_SCALE_FACTOR; + break; + case "ScorePosition": currentConfig.ScorePosition = (float.Parse(pair.Value, CultureInfo.InvariantCulture)) * LegacyManiaSkinConfiguration.POSITION_SCALE_FACTOR; break; diff --git a/osu.Game/Skinning/LegacyRankDisplay.cs b/osu.Game/Skinning/LegacyRankDisplay.cs new file mode 100644 index 0000000000..70b5ed0278 --- /dev/null +++ b/osu.Game/Skinning/LegacyRankDisplay.cs @@ -0,0 +1,63 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Rulesets.Scoring; +using osuTK; + +namespace osu.Game.Skinning +{ + public partial class LegacyRankDisplay : CompositeDrawable, ISerialisableDrawable + { + public bool UsesFixedAnchor { get; set; } + + [Resolved] + private ScoreProcessor scoreProcessor { get; set; } = null!; + + [Resolved] + private ISkinSource source { get; set; } = null!; + + private readonly Sprite rank; + + public LegacyRankDisplay() + { + AutoSizeAxes = Axes.Both; + + AddInternal(rank = new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + } + + protected override void LoadComplete() + { + scoreProcessor.Rank.BindValueChanged(v => + { + var texture = source.GetTexture($"ranking-{v.NewValue}-small"); + + rank.Texture = texture; + + if (texture != null) + { + var transientRank = new Sprite + { + Texture = texture, + Blending = BlendingParameters.Additive, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + BypassAutoSizeAxes = Axes.Both, + }; + AddInternal(transientRank); + transientRank.FadeOutFromOne(500, Easing.Out) + .ScaleTo(new Vector2(1.625f), 500, Easing.Out) + .Expire(); + } + }, true); + FinishTransforms(true); + } + } +} diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs index 816cfc0a2d..6faadfba9b 100644 --- a/osu.Game/Skinning/LegacySkin.cs +++ b/osu.Game/Skinning/LegacySkin.cs @@ -155,6 +155,9 @@ namespace osu.Game.Skinning case LegacyManiaSkinConfigurationLookups.HitPosition: return SkinUtils.As(new Bindable(existing.HitPosition)); + case LegacyManiaSkinConfigurationLookups.ComboPosition: + return SkinUtils.As(new Bindable(existing.ComboPosition)); + case LegacyManiaSkinConfigurationLookups.ScorePosition: return SkinUtils.As(new Bindable(existing.ScorePosition)); @@ -192,6 +195,9 @@ namespace osu.Game.Skinning Debug.Assert(maniaLookup.ColumnIndex != null); return SkinUtils.As(getCustomColour(existing, $"ColourLight{maniaLookup.ColumnIndex + 1}")); + case LegacyManiaSkinConfigurationLookups.ComboBreakColour: + return SkinUtils.As(getCustomColour(existing, "ColourBreak")); + case LegacyManiaSkinConfigurationLookups.MinimumColumnWidth: return SkinUtils.As(new Bindable(existing.MinimumColumnWidth)); @@ -310,6 +316,9 @@ namespace osu.Game.Skinning case SkinConfiguration.LegacySetting.Version: return SkinUtils.As(new Bindable(Configuration.LegacyVersion ?? SkinConfiguration.LATEST_VERSION)); + case SkinConfiguration.LegacySetting.InputOverlayText: + return SkinUtils.As(new Bindable(Configuration.CustomColours.TryGetValue(@"InputOverlayText", out var colour) ? colour : Colour4.Black)); + default: return genericLookup(legacySetting); } @@ -347,19 +356,30 @@ namespace osu.Game.Skinning public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup) { - if (base.GetDrawableComponent(lookup) is Drawable c) - return c; - switch (lookup) { - case SkinComponentsContainerLookup containerLookup: - // Only handle global level defaults for now. - if (containerLookup.Ruleset != null) - return null; - - switch (containerLookup.Target) + case GlobalSkinnableContainerLookup containerLookup: + switch (containerLookup.Lookup) { - case SkinComponentsContainerLookup.TargetArea.MainHUDComponents: + case GlobalSkinnableContainers.MainHUDComponents: + if (containerLookup.Ruleset != null) + { + return new DefaultSkinComponentsContainer(container => + { + var combo = container.OfType().FirstOrDefault(); + + if (combo != null) + { + combo.Anchor = Anchor.BottomLeft; + combo.Origin = Anchor.BottomLeft; + combo.Scale = new Vector2(1.28f); + } + }) + { + new LegacyDefaultComboCounter() + }; + } + return new DefaultSkinComponentsContainer(container => { var score = container.OfType().FirstOrDefault(); @@ -381,41 +401,29 @@ namespace osu.Game.Skinning } var hitError = container.OfType().FirstOrDefault(); - var keyCounter = container.OfType().FirstOrDefault(); if (hitError != null) { hitError.Anchor = Anchor.BottomCentre; hitError.Origin = Anchor.CentreLeft; hitError.Rotation = -90; - - if (keyCounter != null) - { - const float padding = 10; - - keyCounter.Anchor = Anchor.BottomRight; - keyCounter.Origin = Anchor.BottomRight; - keyCounter.Position = new Vector2(-padding, -(padding + hitError.Width)); - } } }) { Children = new Drawable[] { - new LegacyComboCounter(), new LegacyScoreCounter(), new LegacyAccuracyCounter(), new LegacySongProgress(), new LegacyHealthDisplay(), new BarHitErrorMeter(), - new DefaultKeyCounterDisplay() } }; } return null; - case GameplaySkinComponentLookup resultComponent: + case SkinComponentLookup resultComponent: // kind of wasteful that we throw this away, but should do for now. if (getJudgementAnimation(resultComponent.Component) != null) @@ -434,7 +442,7 @@ namespace osu.Game.Skinning return null; } - return null; + return base.GetDrawableComponent(lookup); } private Texture? getParticleTexture(HitResult result) diff --git a/osu.Game/Skinning/LegacySpriteText.cs b/osu.Game/Skinning/LegacySpriteText.cs index fdd8716d5a..1028b5bb9d 100644 --- a/osu.Game/Skinning/LegacySpriteText.cs +++ b/osu.Game/Skinning/LegacySpriteText.cs @@ -96,7 +96,7 @@ namespace osu.Game.Skinning if (maxSize != null) texture = texture.WithMaximumSize(maxSize.Value); - glyph = new TexturedCharacterGlyph(new CharacterGlyph(character, 0, 0, texture.Width, texture.Height, null), texture, 1f / texture.ScaleAdjust); + glyph = new TexturedCharacterGlyph(new CharacterGlyph(character, 0, 0, texture.Width, 0, null), texture, 1f / texture.ScaleAdjust); } cache[character] = glyph; diff --git a/osu.Game/Skinning/RealmBackedResourceStore.cs b/osu.Game/Skinning/RealmBackedResourceStore.cs index cce099a268..f41bd89b7a 100644 --- a/osu.Game/Skinning/RealmBackedResourceStore.cs +++ b/osu.Game/Skinning/RealmBackedResourceStore.cs @@ -29,7 +29,10 @@ namespace osu.Game.Skinning invalidateCache(); Debug.Assert(fileToStoragePathMapping != null); - realmSubscription = realm?.RegisterForNotifications(r => r.All().Where(s => s.ID == source.ID), skinChanged); + // Required local for iOS. Will cause runtime crash if inlined. + Guid id = source.ID; + + realmSubscription = realm?.RegisterForNotifications(r => r.All().Where(s => s.ID == id), skinChanged); } protected override void Dispose(bool disposing) diff --git a/osu.Game/Skinning/RulesetSkinProvidingContainer.cs b/osu.Game/Skinning/RulesetSkinProvidingContainer.cs index 07e238243b..d736f4cdb5 100644 --- a/osu.Game/Skinning/RulesetSkinProvidingContainer.cs +++ b/osu.Game/Skinning/RulesetSkinProvidingContainer.cs @@ -28,25 +28,33 @@ namespace osu.Game.Skinning protected readonly Ruleset Ruleset; protected readonly IBeatmap Beatmap; + [CanBeNull] + private readonly ISkin beatmapSkin; + /// /// This container already re-exposes all parent sources in a ruleset-usable form. /// Therefore disallow falling back to any parent any further. /// protected override bool AllowFallingBackToParent => false; - protected override Container Content { get; } + protected override Container Content { get; } = new Container + { + RelativeSizeAxes = Axes.Both, + }; public RulesetSkinProvidingContainer(Ruleset ruleset, IBeatmap beatmap, [CanBeNull] ISkin beatmapSkin) { Ruleset = ruleset; Beatmap = beatmap; + this.beatmapSkin = beatmapSkin; + } - InternalChild = new BeatmapSkinProvidingContainer(GetRulesetTransformedSkin(beatmapSkin)) + [BackgroundDependencyLoader] + private void load(SkinManager skinManager) + { + InternalChild = new BeatmapSkinProvidingContainer(GetRulesetTransformedSkin(beatmapSkin), GetRulesetTransformedSkin(skinManager.DefaultClassicSkin)) { - Child = Content = new Container - { - RelativeSizeAxes = Axes.Both, - } + Child = Content, }; } diff --git a/osu.Game/Skinning/SerialisableDrawableExtensions.cs b/osu.Game/Skinning/SerialisableDrawableExtensions.cs index 97c4cc8f73..a0488492ae 100644 --- a/osu.Game/Skinning/SerialisableDrawableExtensions.cs +++ b/osu.Game/Skinning/SerialisableDrawableExtensions.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Bindables; -using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Configuration; @@ -19,9 +18,9 @@ namespace osu.Game.Skinning // todo: can probably make this better via deserialisation directly using a common interface. component.Position = drawableInfo.Position; component.Rotation = drawableInfo.Rotation; - if (drawableInfo.Width is float width && width != 0 && (component as CompositeDrawable)?.AutoSizeAxes.HasFlagFast(Axes.X) != true) + if (drawableInfo.Width is float width && width != 0 && (component as CompositeDrawable)?.AutoSizeAxes.HasFlag(Axes.X) != true) component.Width = width; - if (drawableInfo.Height is float height && height != 0 && (component as CompositeDrawable)?.AutoSizeAxes.HasFlagFast(Axes.Y) != true) + if (drawableInfo.Height is float height && height != 0 && (component as CompositeDrawable)?.AutoSizeAxes.HasFlag(Axes.Y) != true) component.Height = height; component.Scale = drawableInfo.Scale; component.Anchor = drawableInfo.Anchor; diff --git a/osu.Game/Skinning/SerialisedDrawableInfo.cs b/osu.Game/Skinning/SerialisedDrawableInfo.cs index ac1aa80d29..b4be5745d1 100644 --- a/osu.Game/Skinning/SerialisedDrawableInfo.cs +++ b/osu.Game/Skinning/SerialisedDrawableInfo.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using System.Linq; using Newtonsoft.Json; using osu.Framework.Bindables; -using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Logging; @@ -68,10 +67,10 @@ namespace osu.Game.Skinning Rotation = component.Rotation; Scale = component.Scale; - if ((component as CompositeDrawable)?.AutoSizeAxes.HasFlagFast(Axes.X) != true) + if ((component as CompositeDrawable)?.AutoSizeAxes.HasFlag(Axes.X) != true) Width = component.Width; - if ((component as CompositeDrawable)?.AutoSizeAxes.HasFlagFast(Axes.Y) != true) + if ((component as CompositeDrawable)?.AutoSizeAxes.HasFlag(Axes.Y) != true) Height = component.Height; Anchor = component.Anchor; diff --git a/osu.Game/Skinning/Skin.cs b/osu.Game/Skinning/Skin.cs index e4ca908d90..e93a10d50b 100644 --- a/osu.Game/Skinning/Skin.cs +++ b/osu.Game/Skinning/Skin.cs @@ -21,11 +21,15 @@ using osu.Framework.Logging; using osu.Game.Audio; using osu.Game.Database; using osu.Game.IO; +using osu.Game.Rulesets; +using osu.Game.Screens.Play.HUD; namespace osu.Game.Skinning { public abstract class Skin : IDisposable, ISkin { + private readonly IStorageResourceProvider? resources; + /// /// A texture store which can be used to perform user file lookups for this skin. /// @@ -40,10 +44,10 @@ namespace osu.Game.Skinning public SkinConfiguration Configuration { get; set; } - public IDictionary LayoutInfos => layoutInfos; + public IDictionary LayoutInfos => layoutInfos; - private readonly Dictionary layoutInfos = - new Dictionary(); + private readonly Dictionary layoutInfos = + new Dictionary(); public abstract ISample? GetSample(ISampleInfo sampleInfo); @@ -68,6 +72,8 @@ namespace osu.Game.Skinning /// An optional filename to read the skin configuration from. If not provided, the configuration will be retrieved from the storage using "skin.ini". protected Skin(SkinInfo skin, IStorageResourceProvider? resources, IResourceStore? fallbackStore = null, string configurationFilename = @"skin.ini") { + this.resources = resources; + Name = skin.Name; if (resources != null) @@ -118,7 +124,7 @@ namespace osu.Game.Skinning } // skininfo files may be null for default skin. - foreach (SkinComponentsContainerLookup.TargetArea skinnableTarget in Enum.GetValues()) + foreach (GlobalSkinnableContainers skinnableTarget in Enum.GetValues()) { string filename = $"{skinnableTarget}.json"; @@ -131,40 +137,9 @@ namespace osu.Game.Skinning { string jsonContent = Encoding.UTF8.GetString(bytes); - SkinLayoutInfo? layoutInfo = null; - - // handle namespace changes... - jsonContent = jsonContent.Replace(@"osu.Game.Screens.Play.SongProgress", @"osu.Game.Screens.Play.HUD.DefaultSongProgress"); - jsonContent = jsonContent.Replace(@"osu.Game.Screens.Play.HUD.LegacyComboCounter", @"osu.Game.Skinning.LegacyComboCounter"); - jsonContent = jsonContent.Replace(@"osu.Game.Screens.Play.HUD.PerformancePointsCounter", @"osu.Game.Skinning.Triangles.TrianglesPerformancePointsCounter"); - - try - { - // First attempt to deserialise using the new SkinLayoutInfo format - layoutInfo = JsonConvert.DeserializeObject(jsonContent); - } - catch - { - } - - // Of note, the migration code below runs on read of skins, but there's nothing to - // force a rewrite after migration. Let's not remove these migration rules until we - // have something in place to ensure we don't end up breaking skins of users that haven't - // manually saved their skin since a change was implemented. - - // If deserialisation using SkinLayoutInfo fails, attempt to deserialise using the old naked list. + var layoutInfo = parseLayoutInfo(jsonContent, skinnableTarget); if (layoutInfo == null) - { - var deserializedContent = JsonConvert.DeserializeObject>(jsonContent); - - if (deserializedContent == null) - continue; - - layoutInfo = new SkinLayoutInfo(); - layoutInfo.Update(null, deserializedContent.ToArray()); - - Logger.Log($"Ferrying {deserializedContent.Count()} components in {skinnableTarget} to global section of new {nameof(SkinLayoutInfo)} format"); - } + continue; LayoutInfos[skinnableTarget] = layoutInfo; } @@ -188,19 +163,19 @@ namespace osu.Game.Skinning /// Remove all stored customisations for the provided target. /// /// The target container to reset. - public void ResetDrawableTarget(SkinComponentsContainer targetContainer) + public void ResetDrawableTarget(SkinnableContainer targetContainer) { - LayoutInfos.Remove(targetContainer.Lookup.Target); + LayoutInfos.Remove(targetContainer.Lookup.Lookup); } /// /// Update serialised information for the provided target. /// /// The target container to serialise to this skin. - public void UpdateDrawableTarget(SkinComponentsContainer targetContainer) + public void UpdateDrawableTarget(SkinnableContainer targetContainer) { - if (!LayoutInfos.TryGetValue(targetContainer.Lookup.Target, out var layoutInfo)) - layoutInfos[targetContainer.Lookup.Target] = layoutInfo = new SkinLayoutInfo(); + if (!LayoutInfos.TryGetValue(targetContainer.Lookup.Lookup, out var layoutInfo)) + layoutInfos[targetContainer.Lookup.Lookup] = layoutInfo = new SkinLayoutInfo(); layoutInfo.Update(targetContainer.Lookup.Ruleset, ((ISerialisableDrawableContainer)targetContainer).CreateSerialisedInfo().ToArray()); } @@ -213,23 +188,130 @@ namespace osu.Game.Skinning case SkinnableSprite.SpriteComponentLookup sprite: return this.GetAnimation(sprite.LookupName, false, false, maxSize: sprite.MaxSize); - case SkinComponentsContainerLookup containerLookup: - - // It is important to return null if the user has not configured this yet. - // This allows skin transformers the opportunity to provide default components. - if (!LayoutInfos.TryGetValue(containerLookup.Target, out var layoutInfo)) return null; - if (!layoutInfo.TryGetDrawableInfo(containerLookup.Ruleset, out var drawableInfos)) return null; - - return new Container + case UserSkinComponentLookup userLookup: + switch (userLookup.Component) { - RelativeSizeAxes = Axes.Both, - ChildrenEnumerable = drawableInfos.Select(i => i.CreateInstance()) - }; + case GlobalSkinnableContainerLookup containerLookup: + // It is important to return null if the user has not configured this yet. + // This allows skin transformers the opportunity to provide default components. + if (!LayoutInfos.TryGetValue(containerLookup.Lookup, out var layoutInfo)) return null; + if (!layoutInfo.TryGetDrawableInfo(containerLookup.Ruleset, out var drawableInfos)) return null; + + return new Container + { + RelativeSizeAxes = Axes.Both, + ChildrenEnumerable = drawableInfos.Select(i => i.CreateInstance()) + }; + } + + break; } return null; } + #region Deserialisation & Migration + + private SkinLayoutInfo? parseLayoutInfo(string jsonContent, GlobalSkinnableContainers target) + { + SkinLayoutInfo? layout = null; + + // handle namespace changes... + jsonContent = jsonContent.Replace(@"osu.Game.Screens.Play.SongProgress", @"osu.Game.Screens.Play.HUD.DefaultSongProgress"); + jsonContent = jsonContent.Replace(@"osu.Game.Screens.Play.HUD.LegacyComboCounter", @"osu.Game.Skinning.LegacyComboCounter"); + jsonContent = jsonContent.Replace(@"osu.Game.Skinning.LegacyComboCounter", @"osu.Game.Skinning.LegacyDefaultComboCounter"); + jsonContent = jsonContent.Replace(@"osu.Game.Screens.Play.HUD.PerformancePointsCounter", @"osu.Game.Skinning.Triangles.TrianglesPerformancePointsCounter"); + + try + { + // First attempt to deserialise using the new SkinLayoutInfo format + layout = JsonConvert.DeserializeObject(jsonContent); + } + catch + { + } + + // If deserialisation using SkinLayoutInfo fails, attempt to deserialise using the old naked list. + if (layout == null) + { + var deserializedContent = JsonConvert.DeserializeObject>(jsonContent); + if (deserializedContent == null) + return null; + + layout = new SkinLayoutInfo { Version = 0 }; + layout.Update(null, deserializedContent.ToArray()); + + Logger.Log($"Ferrying {deserializedContent.Count()} components in {target} to global section of new {nameof(SkinLayoutInfo)} format"); + } + + for (int i = layout.Version + 1; i <= SkinLayoutInfo.LATEST_VERSION; i++) + applyMigration(layout, target, i); + + layout.Version = SkinLayoutInfo.LATEST_VERSION; + + foreach (var kvp in layout.DrawableInfo.ToArray()) + { + foreach (var di in kvp.Value) + { + if (!isValidDrawable(di)) + layout.DrawableInfo[kvp.Key] = kvp.Value.Where(i => i.Type != di.Type).ToArray(); + } + } + + return layout; + } + + private bool isValidDrawable(SerialisedDrawableInfo di) + { + if (!typeof(ISerialisableDrawable).IsAssignableFrom(di.Type)) + return false; + + foreach (var child in di.Children) + { + if (!isValidDrawable(child)) + return false; + } + + return true; + } + + private void applyMigration(SkinLayoutInfo layout, GlobalSkinnableContainers target, int version) + { + switch (version) + { + case 1: + { + // Combo counters were moved out of the global HUD components into per-ruleset. + // This is to allow some rulesets to customise further (ie. mania and catch moving the combo to within their play area). + if (target != GlobalSkinnableContainers.MainHUDComponents || + !layout.TryGetDrawableInfo(null, out var globalHUDComponents) || + resources == null) + break; + + var comboCounters = globalHUDComponents.Where(c => + c.Type.Name == nameof(LegacyDefaultComboCounter) || + c.Type.Name == nameof(DefaultComboCounter) || + c.Type.Name == nameof(ArgonComboCounter)).ToArray(); + + layout.Update(null, globalHUDComponents.Except(comboCounters).ToArray()); + + resources.RealmAccess.Run(r => + { + foreach (var ruleset in r.All()) + { + layout.Update(ruleset, layout.TryGetDrawableInfo(ruleset, out var rulesetHUDComponents) + ? rulesetHUDComponents.Concat(comboCounters).ToArray() + : comboCounters); + } + }); + + break; + } + } + } + + #endregion + #region Disposal ~Skin() diff --git a/osu.Game/Skinning/SkinComponentLookup.cs b/osu.Game/Skinning/SkinComponentLookup.cs new file mode 100644 index 0000000000..4da6bb0c08 --- /dev/null +++ b/osu.Game/Skinning/SkinComponentLookup.cs @@ -0,0 +1,22 @@ +// 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.Skinning +{ + /// + /// A lookup type intended for use for skinnable components. + /// + /// An enum lookup type. + public class SkinComponentLookup : ISkinComponentLookup + where T : Enum + { + public readonly T Component; + + public SkinComponentLookup(T component) + { + Component = component; + } + } +} diff --git a/osu.Game/Skinning/SkinConfiguration.cs b/osu.Game/Skinning/SkinConfiguration.cs index 937cca0aeb..a657a667eb 100644 --- a/osu.Game/Skinning/SkinConfiguration.cs +++ b/osu.Game/Skinning/SkinConfiguration.cs @@ -38,6 +38,7 @@ namespace osu.Game.Skinning AnimationFramerate, LayeredHitSounds, AllowSliderBallTint, + InputOverlayText, } public static List DefaultComboColours { get; } = new List diff --git a/osu.Game/Skinning/SkinLayoutInfo.cs b/osu.Game/Skinning/SkinLayoutInfo.cs index 115d59b9d0..bf6c693621 100644 --- a/osu.Game/Skinning/SkinLayoutInfo.cs +++ b/osu.Game/Skinning/SkinLayoutInfo.cs @@ -11,20 +11,34 @@ using osu.Game.Rulesets; namespace osu.Game.Skinning { /// - /// A serialisable model describing layout of a . - /// May contain multiple configurations for different rulesets, each of which should manifest their own as required. + /// A serialisable model describing layout of a . + /// May contain multiple configurations for different rulesets, each of which should manifest their own as required. /// [Serializable] public class SkinLayoutInfo { private const string global_identifier = @"global"; - [JsonIgnore] - public IEnumerable AllDrawables => DrawableInfo.Values.SelectMany(v => v); + /// + /// Latest version representing the schema of the skin layout. + /// + /// + /// + /// 0: Initial version of all skin layouts. + /// 1: Moves existing combo counters from global to per-ruleset HUD targets. + /// + /// + public const int LATEST_VERSION = 1; + + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)] + public int Version = LATEST_VERSION; [JsonProperty] public Dictionary DrawableInfo { get; set; } = new Dictionary(); + [JsonIgnore] + public IEnumerable AllDrawables => DrawableInfo.Values.SelectMany(v => v); + public bool TryGetDrawableInfo(RulesetInfo? ruleset, [NotNullWhen(true)] out SerialisedDrawableInfo[]? components) => DrawableInfo.TryGetValue(ruleset?.ShortName ?? global_identifier, out components); diff --git a/osu.Game/Skinning/SkinManager.cs b/osu.Game/Skinning/SkinManager.cs index 59c2a8bca0..9018c2e2c3 100644 --- a/osu.Game/Skinning/SkinManager.cs +++ b/osu.Game/Skinning/SkinManager.cs @@ -131,9 +131,12 @@ namespace osu.Game.Skinning { Realm.Run(r => { + // Required local for iOS. Will cause runtime crash if inlined. + Guid currentSkinId = CurrentSkinInfo.Value.ID; + // choose from only user skins, removing the current selection to ensure a new one is chosen. var randomChoices = r.All() - .Where(s => !s.DeletePending && s.ID != CurrentSkinInfo.Value.ID) + .Where(s => !s.DeletePending && s.ID != currentSkinId) .ToArray(); if (randomChoices.Length == 0) @@ -312,6 +315,8 @@ namespace osu.Game.Skinning public Task> ImportAsUpdate(ProgressNotification notification, ImportTask task, SkinInfo original) => skinImporter.ImportAsUpdate(notification, task, original); + public Task> BeginExternalEditing(SkinInfo model) => skinImporter.BeginExternalEditing(model); + public Task> Import(ImportTask task, ImportParameters parameters = default, CancellationToken cancellationToken = default) => skinImporter.Import(task, parameters, cancellationToken); diff --git a/osu.Game/Skinning/SkinProvidingContainer.cs b/osu.Game/Skinning/SkinProvidingContainer.cs index acb15da80e..9aff187c9c 100644 --- a/osu.Game/Skinning/SkinProvidingContainer.cs +++ b/osu.Game/Skinning/SkinProvidingContainer.cs @@ -7,6 +7,7 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -201,7 +202,10 @@ namespace osu.Game.Skinning source.SourceChanged -= TriggerSourceChanged; } - skinSources = sources.Select(skin => (skin, new DisableableSkinSource(skin, this))).ToArray(); + skinSources = sources + // Shouldn't be required after NRT is applied to all calling sources. + .Where(skin => skin.IsNotNull()) + .Select(skin => (skin, new DisableableSkinSource(skin, this))).ToArray(); foreach (var skin in skinSources) { diff --git a/osu.Game/Skinning/SkinComponentsContainer.cs b/osu.Game/Skinning/SkinnableContainer.cs similarity index 89% rename from osu.Game/Skinning/SkinComponentsContainer.cs rename to osu.Game/Skinning/SkinnableContainer.cs index 02ba43fd39..aad95ca779 100644 --- a/osu.Game/Skinning/SkinComponentsContainer.cs +++ b/osu.Game/Skinning/SkinnableContainer.cs @@ -16,17 +16,17 @@ namespace osu.Game.Skinning /// /// /// This is currently used as a means of serialising skin layouts to files. - /// Currently, one json file in a skin will represent one , containing + /// Currently, one json file in a skin will represent one , containing /// the output of . /// - public partial class SkinComponentsContainer : SkinReloadableDrawable, ISerialisableDrawableContainer + public partial class SkinnableContainer : SkinReloadableDrawable, ISerialisableDrawableContainer { private Container? content; /// /// The lookup criteria which will be used to retrieve components from the active skin. /// - public SkinComponentsContainerLookup Lookup { get; } + public GlobalSkinnableContainerLookup Lookup { get; } public IBindableList Components => components; @@ -38,12 +38,15 @@ namespace osu.Game.Skinning private CancellationTokenSource? cancellationSource; - public SkinComponentsContainer(SkinComponentsContainerLookup lookup) + public SkinnableContainer(GlobalSkinnableContainerLookup lookup) { Lookup = lookup; } - public void Reload() => Reload(CurrentSkin.GetDrawableComponent(Lookup) as Container); + public void Reload() => Reload(( + CurrentSkin.GetDrawableComponent(new UserSkinComponentLookup(Lookup)) + ?? CurrentSkin.GetDrawableComponent(Lookup)) + as Container); public void Reload(Container? componentsContainer) { diff --git a/osu.Game/Skinning/SkinnableSound.cs b/osu.Game/Skinning/SkinnableSound.cs index f153f4f8d3..be9212da9e 100644 --- a/osu.Game/Skinning/SkinnableSound.cs +++ b/osu.Game/Skinning/SkinnableSound.cs @@ -154,6 +154,9 @@ namespace osu.Game.Skinning { bool wasPlaying = IsPlaying; + if (wasPlaying && Looping) + Stop(); + // Remove all pooled samples (return them to the pool), and dispose the rest. samplesContainer.RemoveAll(s => s.IsInPool, false); samplesContainer.Clear(); diff --git a/osu.Game/Skinning/TrianglesSkin.cs b/osu.Game/Skinning/TrianglesSkin.cs index 6158d4c7bf..d562fd3256 100644 --- a/osu.Game/Skinning/TrianglesSkin.cs +++ b/osu.Game/Skinning/TrianglesSkin.cs @@ -64,19 +64,16 @@ namespace osu.Game.Skinning // Temporary until default skin has a valid hit lighting. if ((lookup as SkinnableSprite.SpriteComponentLookup)?.LookupName == @"lighting") return Drawable.Empty(); - if (base.GetDrawableComponent(lookup) is Drawable c) - return c; - switch (lookup) { - case SkinComponentsContainerLookup containerLookup: + case GlobalSkinnableContainerLookup containerLookup: // Only handle global level defaults for now. if (containerLookup.Ruleset != null) return null; - switch (containerLookup.Target) + switch (containerLookup.Lookup) { - case SkinComponentsContainerLookup.TargetArea.SongSelect: + case GlobalSkinnableContainers.SongSelect: var songSelectComponents = new DefaultSkinComponentsContainer(_ => { // do stuff when we need to. @@ -84,7 +81,7 @@ namespace osu.Game.Skinning return songSelectComponents; - case SkinComponentsContainerLookup.TargetArea.MainHUDComponents: + case GlobalSkinnableContainers.MainHUDComponents: var skinnableTargetWrapper = new DefaultSkinComponentsContainer(container => { var score = container.OfType().FirstOrDefault(); @@ -178,7 +175,7 @@ namespace osu.Game.Skinning return null; } - return null; + return base.GetDrawableComponent(lookup); } public override IBindable? GetConfig(TLookup lookup) diff --git a/osu.Game/Skinning/UserSkinComponentLookup.cs b/osu.Game/Skinning/UserSkinComponentLookup.cs new file mode 100644 index 0000000000..1ecdc96b38 --- /dev/null +++ b/osu.Game/Skinning/UserSkinComponentLookup.cs @@ -0,0 +1,18 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Skinning +{ + /// + /// A lookup class which is only for internal use, and explicitly to get a user-level configuration. + /// + internal class UserSkinComponentLookup : ISkinComponentLookup + { + public readonly ISkinComponentLookup Component; + + public UserSkinComponentLookup(ISkinComponentLookup component) + { + Component = component; + } + } +} diff --git a/osu.Game/Storyboards/CommandLoop.cs b/osu.Game/Storyboards/CommandLoop.cs deleted file mode 100644 index 480d69c12f..0000000000 --- a/osu.Game/Storyboards/CommandLoop.cs +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; - -namespace osu.Game.Storyboards -{ - public class CommandLoop : CommandTimelineGroup - { - public double LoopStartTime; - - /// - /// The total number of times this loop is played back. Always greater than zero. - /// - public readonly int TotalIterations; - - public override double StartTime => LoopStartTime + CommandsStartTime; - - public override double EndTime => - // In an ideal world, we would multiply the command duration by TotalIterations here. - // Unfortunately this would clash with how stable handled end times, and results in some storyboards playing outro - // sequences for minutes or hours. - StartTime + CommandsDuration; - - /// - /// Construct a new command loop. - /// - /// The start time of the loop. - /// The number of times the loop should repeat. Should be greater than zero. Zero means a single playback. - public CommandLoop(double startTime, int repeatCount) - { - if (repeatCount < 0) throw new ArgumentException("Repeat count must be zero or above.", nameof(repeatCount)); - - LoopStartTime = startTime; - TotalIterations = repeatCount + 1; - } - - public override IEnumerable.TypedCommand> GetCommands(CommandTimelineSelector timelineSelector, double offset = 0) - { - for (int loop = 0; loop < TotalIterations; loop++) - { - double loopOffset = LoopStartTime + loop * CommandsDuration; - foreach (var command in base.GetCommands(timelineSelector, offset + loopOffset)) - yield return command; - } - } - - public override string ToString() - => $"{LoopStartTime} x{TotalIterations}"; - } -} diff --git a/osu.Game/Storyboards/CommandTimeline.cs b/osu.Game/Storyboards/CommandTimeline.cs deleted file mode 100644 index 0650c97165..0000000000 --- a/osu.Game/Storyboards/CommandTimeline.cs +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using osu.Framework.Graphics; -using System; -using System.Collections.Generic; -using System.Linq; - -namespace osu.Game.Storyboards -{ - public class CommandTimeline : ICommandTimeline - { - private readonly List commands = new List(); - - public IEnumerable Commands => commands.OrderBy(c => c.StartTime); - - public bool HasCommands => commands.Count > 0; - - public double StartTime { get; private set; } = double.MaxValue; - public double EndTime { get; private set; } = double.MinValue; - - public T StartValue { get; private set; } - public T EndValue { get; private set; } - - public void Add(Easing easing, double startTime, double endTime, T startValue, T endValue) - { - if (endTime < startTime) - { - endTime = startTime; - } - - commands.Add(new TypedCommand { Easing = easing, StartTime = startTime, EndTime = endTime, StartValue = startValue, EndValue = endValue }); - - if (startTime < StartTime) - { - StartValue = startValue; - StartTime = startTime; - } - - if (endTime > EndTime) - { - EndValue = endValue; - EndTime = endTime; - } - } - - public override string ToString() - => $"{commands.Count} command(s)"; - - public class TypedCommand : ICommand - { - public Easing Easing { get; set; } - public double StartTime { get; set; } - public double EndTime { get; set; } - public double Duration => EndTime - StartTime; - - public T StartValue; - public T EndValue; - - public int CompareTo(ICommand other) - { - int result = StartTime.CompareTo(other.StartTime); - if (result != 0) return result; - - return EndTime.CompareTo(other.EndTime); - } - - public override string ToString() - => $"{StartTime} -> {EndTime}, {StartValue} -> {EndValue} {Easing}"; - } - } - - public interface ICommandTimeline - { - double StartTime { get; } - double EndTime { get; } - bool HasCommands { get; } - } - - public interface ICommand : IComparable - { - Easing Easing { get; set; } - double StartTime { get; set; } - double EndTime { get; set; } - double Duration { get; } - } -} diff --git a/osu.Game/Storyboards/CommandTimelineGroup.cs b/osu.Game/Storyboards/CommandTimelineGroup.cs deleted file mode 100644 index 0b96db6861..0000000000 --- a/osu.Game/Storyboards/CommandTimelineGroup.cs +++ /dev/null @@ -1,118 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using osuTK; -using osuTK.Graphics; -using osu.Framework.Graphics; -using System.Collections.Generic; -using System.Linq; -using Newtonsoft.Json; - -namespace osu.Game.Storyboards -{ - public delegate CommandTimeline CommandTimelineSelector(CommandTimelineGroup commandTimelineGroup); - - public class CommandTimelineGroup - { - public CommandTimeline X = new CommandTimeline(); - public CommandTimeline Y = new CommandTimeline(); - public CommandTimeline Scale = new CommandTimeline(); - public CommandTimeline VectorScale = new CommandTimeline(); - public CommandTimeline Rotation = new CommandTimeline(); - public CommandTimeline Colour = new CommandTimeline(); - public CommandTimeline Alpha = new CommandTimeline(); - public CommandTimeline BlendingParameters = new CommandTimeline(); - public CommandTimeline FlipH = new CommandTimeline(); - public CommandTimeline FlipV = new CommandTimeline(); - - private readonly ICommandTimeline[] timelines; - - public CommandTimelineGroup() - { - timelines = new ICommandTimeline[] - { - X, - Y, - Scale, - VectorScale, - Rotation, - Colour, - Alpha, - BlendingParameters, - FlipH, - FlipV - }; - } - - [JsonIgnore] - public double CommandsStartTime - { - get - { - double min = double.MaxValue; - - for (int i = 0; i < timelines.Length; i++) - min = Math.Min(min, timelines[i].StartTime); - - return min; - } - } - - [JsonIgnore] - public double CommandsEndTime - { - get - { - double max = double.MinValue; - - for (int i = 0; i < timelines.Length; i++) - max = Math.Max(max, timelines[i].EndTime); - - return max; - } - } - - [JsonIgnore] - public double CommandsDuration => CommandsEndTime - CommandsStartTime; - - [JsonIgnore] - public virtual double StartTime => CommandsStartTime; - - [JsonIgnore] - public virtual double EndTime => CommandsEndTime; - - [JsonIgnore] - public bool HasCommands - { - get - { - for (int i = 0; i < timelines.Length; i++) - { - if (timelines[i].HasCommands) - return true; - } - - return false; - } - } - - public virtual IEnumerable.TypedCommand> GetCommands(CommandTimelineSelector timelineSelector, double offset = 0) - { - if (offset != 0) - { - return timelineSelector(this).Commands.Select(command => - new CommandTimeline.TypedCommand - { - Easing = command.Easing, - StartTime = offset + command.StartTime, - EndTime = offset + command.EndTime, - StartValue = command.StartValue, - EndValue = command.EndValue, - }); - } - - return timelineSelector(this).Commands; - } - } -} diff --git a/osu.Game/Storyboards/Commands/IStoryboardCommand.cs b/osu.Game/Storyboards/Commands/IStoryboardCommand.cs new file mode 100644 index 0000000000..ea14f5fa40 --- /dev/null +++ b/osu.Game/Storyboards/Commands/IStoryboardCommand.cs @@ -0,0 +1,46 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Transforms; +using osu.Game.Storyboards.Drawables; + +namespace osu.Game.Storyboards.Commands +{ + public interface IStoryboardCommand + { + /// + /// The start time of the storyboard command. + /// + double StartTime { get; } + + /// + /// The end time of the storyboard command. + /// + double EndTime { get; } + + /// + /// The name of the property affected by this storyboard command. + /// Used to apply initial property values based on the list of commands given in . + /// + string PropertyName { get; } + + /// + /// Sets the value of the corresponding property in to the start value of this command. + /// + /// + /// Parameter commands (e.g. / / ) only apply the start value if they have zero duration, i.e. take "permanent" effect regardless of time. + /// + /// The target drawable. + void ApplyInitialValue(TDrawable d) + where TDrawable : Drawable, IFlippable, IVectorScalable; + + /// + /// Applies the transforms described by this storyboard command to the target drawable. + /// + /// The target drawable. + /// The sequence of transforms applied to the target drawable. + TransformSequence ApplyTransforms(TDrawable d) + where TDrawable : Drawable, IFlippable, IVectorScalable; + } +} diff --git a/osu.Game/Storyboards/Commands/StoryboardAlphaCommand.cs b/osu.Game/Storyboards/Commands/StoryboardAlphaCommand.cs new file mode 100644 index 0000000000..1c17da7592 --- /dev/null +++ b/osu.Game/Storyboards/Commands/StoryboardAlphaCommand.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Transforms; + +namespace osu.Game.Storyboards.Commands +{ + public class StoryboardAlphaCommand : StoryboardCommand + { + public StoryboardAlphaCommand(Easing easing, double startTime, double endTime, float startValue, float endValue) + : base(easing, startTime, endTime, startValue, endValue) + { + } + + public override string PropertyName => nameof(Drawable.Alpha); + + public override void ApplyInitialValue(TDrawable d) => d.Alpha = StartValue; + + public override TransformSequence ApplyTransforms(TDrawable d) + => d.FadeTo(StartValue).Then().FadeTo(EndValue, Duration, Easing); + } +} diff --git a/osu.Game/Storyboards/Commands/StoryboardBlendingParametersCommand.cs b/osu.Game/Storyboards/Commands/StoryboardBlendingParametersCommand.cs new file mode 100644 index 0000000000..cf9cadf1a7 --- /dev/null +++ b/osu.Game/Storyboards/Commands/StoryboardBlendingParametersCommand.cs @@ -0,0 +1,28 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Transforms; + +namespace osu.Game.Storyboards.Commands +{ + public class StoryboardBlendingParametersCommand : StoryboardCommand + { + public StoryboardBlendingParametersCommand(Easing easing, double startTime, double endTime, BlendingParameters startValue, BlendingParameters endValue) + : base(easing, startTime, endTime, startValue, endValue) + { + } + + public override string PropertyName => nameof(Drawable.Blending); + + public override void ApplyInitialValue(TDrawable d) + { + if (StartTime == EndTime) + d.Blending = StartValue; + } + + public override TransformSequence ApplyTransforms(TDrawable d) + => d.TransformTo(nameof(d.Blending), StartValue).Delay(Duration) + .TransformTo(nameof(d.Blending), EndValue); + } +} diff --git a/osu.Game/Storyboards/Commands/StoryboardColourCommand.cs b/osu.Game/Storyboards/Commands/StoryboardColourCommand.cs new file mode 100644 index 0000000000..da8a20647c --- /dev/null +++ b/osu.Game/Storyboards/Commands/StoryboardColourCommand.cs @@ -0,0 +1,24 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Transforms; +using osuTK.Graphics; + +namespace osu.Game.Storyboards.Commands +{ + public class StoryboardColourCommand : StoryboardCommand + { + public StoryboardColourCommand(Easing easing, double startTime, double endTime, Color4 startValue, Color4 endValue) + : base(easing, startTime, endTime, startValue, endValue) + { + } + + public override string PropertyName => nameof(Drawable.Colour); + + public override void ApplyInitialValue(TDrawable d) => d.Colour = StartValue; + + public override TransformSequence ApplyTransforms(TDrawable d) + => d.FadeColour(StartValue).Then().FadeColour(EndValue, Duration, Easing); + } +} diff --git a/osu.Game/Storyboards/Commands/StoryboardCommand.cs b/osu.Game/Storyboards/Commands/StoryboardCommand.cs new file mode 100644 index 0000000000..60c28e7833 --- /dev/null +++ b/osu.Game/Storyboards/Commands/StoryboardCommand.cs @@ -0,0 +1,56 @@ +// 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.Graphics; +using osu.Framework.Graphics.Transforms; +using osu.Game.Storyboards.Drawables; + +namespace osu.Game.Storyboards.Commands +{ + public abstract class StoryboardCommand : IStoryboardCommand, IComparable> + { + public double StartTime { get; } + public double EndTime { get; } + + public T StartValue { get; } + public T EndValue { get; } + public Easing Easing { get; } + + public double Duration => EndTime - StartTime; + + protected StoryboardCommand(Easing easing, double startTime, double endTime, T startValue, T endValue) + { + if (endTime < startTime) + endTime = startTime; + + StartTime = startTime; + StartValue = startValue; + EndTime = endTime; + EndValue = endValue; + Easing = easing; + } + + public abstract string PropertyName { get; } + + public abstract void ApplyInitialValue(TDrawable d) + where TDrawable : Drawable, IFlippable, IVectorScalable; + + public abstract TransformSequence ApplyTransforms(TDrawable d) + where TDrawable : Drawable, IFlippable, IVectorScalable; + + public int CompareTo(StoryboardCommand? other) + { + if (other == null) + return 1; + + int result = StartTime.CompareTo(other.StartTime); + if (result != 0) + return result; + + return EndTime.CompareTo(other.EndTime); + } + + public override string ToString() => $"{StartTime} -> {EndTime}, {StartValue} -> {EndValue} {Easing}"; + } +} diff --git a/osu.Game/Storyboards/Commands/StoryboardCommandGroup.cs b/osu.Game/Storyboards/Commands/StoryboardCommandGroup.cs new file mode 100644 index 0000000000..0925231412 --- /dev/null +++ b/osu.Game/Storyboards/Commands/StoryboardCommandGroup.cs @@ -0,0 +1,130 @@ +// 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 Newtonsoft.Json; +using osu.Framework.Graphics; +using osu.Framework.Lists; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Storyboards.Commands +{ + public class StoryboardCommandGroup + { + private readonly SortedList> x = new SortedList>(); + + public IReadOnlyList> X => x; + + private readonly SortedList> y = new SortedList>(); + + public IReadOnlyList> Y => y; + + private readonly SortedList> scale = new SortedList>(); + + public IReadOnlyList> Scale => scale; + + private readonly SortedList> vectorScale = new SortedList>(); + + public IReadOnlyList> VectorScale => vectorScale; + + private readonly SortedList> rotation = new SortedList>(); + + public IReadOnlyList> Rotation => rotation; + + private readonly SortedList> colour = new SortedList>(); + + public IReadOnlyList> Colour => colour; + + private readonly SortedList> alpha = new SortedList>(); + + public IReadOnlyList> Alpha => alpha; + + private readonly SortedList> blendingParameters = new SortedList>(); + + public IReadOnlyList> BlendingParameters => blendingParameters; + + private readonly SortedList> flipH = new SortedList>(); + + public IReadOnlyList> FlipH => flipH; + + private readonly SortedList> flipV = new SortedList>(); + + public IReadOnlyList> FlipV => flipV; + + /// + /// Returns the earliest start time of the commands added to this group. + /// + [JsonIgnore] + public double StartTime { get; private set; } = double.MaxValue; + + /// + /// Returns the latest end time of the commands added to this group. + /// + [JsonIgnore] + public double EndTime { get; private set; } = double.MinValue; + + [JsonIgnore] + public double Duration => EndTime - StartTime; + + [JsonIgnore] + public bool HasCommands { get; private set; } + + private readonly IReadOnlyList[] lists; + + public IEnumerable AllCommands => lists.SelectMany(g => g); + + public StoryboardCommandGroup() + { + lists = new IReadOnlyList[] { X, Y, Scale, VectorScale, Rotation, Colour, Alpha, BlendingParameters, FlipH, FlipV }; + } + + public void AddX(Easing easing, double startTime, double endTime, float startValue, float endValue) + => AddCommand(x, new StoryboardXCommand(easing, startTime, endTime, startValue, endValue)); + + public void AddY(Easing easing, double startTime, double endTime, float startValue, float endValue) + => AddCommand(y, new StoryboardYCommand(easing, startTime, endTime, startValue, endValue)); + + public void AddScale(Easing easing, double startTime, double endTime, float startValue, float endValue) + => AddCommand(scale, new StoryboardScaleCommand(easing, startTime, endTime, startValue, endValue)); + + public void AddVectorScale(Easing easing, double startTime, double endTime, Vector2 startValue, Vector2 endValue) + => AddCommand(vectorScale, new StoryboardVectorScaleCommand(easing, startTime, endTime, startValue, endValue)); + + public void AddRotation(Easing easing, double startTime, double endTime, float startValue, float endValue) + => AddCommand(rotation, new StoryboardRotationCommand(easing, startTime, endTime, startValue, endValue)); + + public void AddColour(Easing easing, double startTime, double endTime, Color4 startValue, Color4 endValue) + => AddCommand(colour, new StoryboardColourCommand(easing, startTime, endTime, startValue, endValue)); + + public void AddAlpha(Easing easing, double startTime, double endTime, float startValue, float endValue) + => AddCommand(alpha, new StoryboardAlphaCommand(easing, startTime, endTime, startValue, endValue)); + + public void AddBlendingParameters(Easing easing, double startTime, double endTime, BlendingParameters startValue, BlendingParameters endValue) + => AddCommand(blendingParameters, new StoryboardBlendingParametersCommand(easing, startTime, endTime, startValue, endValue)); + + public void AddFlipH(Easing easing, double startTime, double endTime, bool startValue, bool endValue) + => AddCommand(flipH, new StoryboardFlipHCommand(easing, startTime, endTime, startValue, endValue)); + + public void AddFlipV(Easing easing, double startTime, double endTime, bool startValue, bool endValue) + => AddCommand(flipV, new StoryboardFlipVCommand(easing, startTime, endTime, startValue, endValue)); + + /// + /// Adds the given storyboard to the target . + /// Can be overriden to apply custom effects to the given command before adding it to the list (e.g. looping or time offsets). + /// + /// The value type of the target property affected by this storyboard command. + protected virtual void AddCommand(ICollection> list, StoryboardCommand command) + { + list.Add(command); + HasCommands = true; + + if (command.StartTime < StartTime) + StartTime = command.StartTime; + + if (command.EndTime > EndTime) + EndTime = command.EndTime; + } + } +} diff --git a/osu.Game/Storyboards/Commands/StoryboardFlipHCommand.cs b/osu.Game/Storyboards/Commands/StoryboardFlipHCommand.cs new file mode 100644 index 0000000000..fbf7295f15 --- /dev/null +++ b/osu.Game/Storyboards/Commands/StoryboardFlipHCommand.cs @@ -0,0 +1,29 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Transforms; +using osu.Game.Storyboards.Drawables; + +namespace osu.Game.Storyboards.Commands +{ + public class StoryboardFlipHCommand : StoryboardCommand + { + public StoryboardFlipHCommand(Easing easing, double startTime, double endTime, bool startValue, bool endValue) + : base(easing, startTime, endTime, startValue, endValue) + { + } + + public override string PropertyName => nameof(IFlippable.FlipH); + + public override void ApplyInitialValue(TDrawable d) + { + if (StartTime == EndTime) + d.FlipH = StartValue; + } + + public override TransformSequence ApplyTransforms(TDrawable d) + => d.TransformTo(nameof(IFlippable.FlipH), StartValue).Delay(Duration) + .TransformTo(nameof(IFlippable.FlipH), EndValue); + } +} diff --git a/osu.Game/Storyboards/Commands/StoryboardFlipVCommand.cs b/osu.Game/Storyboards/Commands/StoryboardFlipVCommand.cs new file mode 100644 index 0000000000..136bd52f1f --- /dev/null +++ b/osu.Game/Storyboards/Commands/StoryboardFlipVCommand.cs @@ -0,0 +1,29 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Transforms; +using osu.Game.Storyboards.Drawables; + +namespace osu.Game.Storyboards.Commands +{ + public class StoryboardFlipVCommand : StoryboardCommand + { + public StoryboardFlipVCommand(Easing easing, double startTime, double endTime, bool startValue, bool endValue) + : base(easing, startTime, endTime, startValue, endValue) + { + } + + public override string PropertyName => nameof(IFlippable.FlipV); + + public override void ApplyInitialValue(TDrawable d) + { + if (StartTime == EndTime) + d.FlipV = StartValue; + } + + public override TransformSequence ApplyTransforms(TDrawable d) + => d.TransformTo(nameof(IFlippable.FlipV), StartValue).Delay(Duration) + .TransformTo(nameof(IFlippable.FlipV), EndValue); + } +} diff --git a/osu.Game/Storyboards/Commands/StoryboardLoopingGroup.cs b/osu.Game/Storyboards/Commands/StoryboardLoopingGroup.cs new file mode 100644 index 0000000000..fe334ad608 --- /dev/null +++ b/osu.Game/Storyboards/Commands/StoryboardLoopingGroup.cs @@ -0,0 +1,66 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using osu.Framework.Graphics.Transforms; + +namespace osu.Game.Storyboards.Commands +{ + public class StoryboardLoopingGroup : StoryboardCommandGroup + { + private readonly double loopStartTime; + + /// + /// The total number of times this loop is played back. Always greater than zero. + /// + public readonly int TotalIterations; + + /// + /// Construct a new command loop. + /// + /// The start time of the loop. + /// The number of times the loop should repeat. Should be greater than zero. Zero means a single playback. + public StoryboardLoopingGroup(double startTime, int repeatCount) + { + ArgumentOutOfRangeException.ThrowIfNegative(repeatCount); + + loopStartTime = startTime; + TotalIterations = repeatCount + 1; + } + + protected override void AddCommand(ICollection> list, StoryboardCommand command) + => base.AddCommand(list, new StoryboardLoopingCommand(command, this)); + + public override string ToString() => $"{loopStartTime} x{TotalIterations}"; + + private class StoryboardLoopingCommand : StoryboardCommand + { + private readonly StoryboardCommand command; + private readonly StoryboardLoopingGroup loopingGroup; + + public StoryboardLoopingCommand(StoryboardCommand command, StoryboardLoopingGroup loopingGroup) + // In an ideal world, we would multiply the command duration by TotalIterations in command end time. + // Unfortunately this would clash with how stable handled end times, and results in some storyboards playing outro + // sequences for minutes or hours. + : base(command.Easing, loopingGroup.loopStartTime + command.StartTime, loopingGroup.loopStartTime + command.EndTime, command.StartValue, command.EndValue) + { + this.command = command; + this.loopingGroup = loopingGroup; + } + + public override string PropertyName => command.PropertyName; + + public override void ApplyInitialValue(TDrawable d) => command.ApplyInitialValue(d); + + public override TransformSequence ApplyTransforms(TDrawable d) + { + if (loopingGroup.TotalIterations == 0) + return command.ApplyTransforms(d); + + double loopingGroupDuration = loopingGroup.Duration; + return command.ApplyTransforms(d).Loop(loopingGroupDuration - Duration, loopingGroup.TotalIterations); + } + } + } +} diff --git a/osu.Game/Storyboards/Commands/StoryboardRotationCommand.cs b/osu.Game/Storyboards/Commands/StoryboardRotationCommand.cs new file mode 100644 index 0000000000..7e097fce25 --- /dev/null +++ b/osu.Game/Storyboards/Commands/StoryboardRotationCommand.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Transforms; + +namespace osu.Game.Storyboards.Commands +{ + public class StoryboardRotationCommand : StoryboardCommand + { + public StoryboardRotationCommand(Easing easing, double startTime, double endTime, float startValue, float endValue) + : base(easing, startTime, endTime, startValue, endValue) + { + } + + public override string PropertyName => nameof(Drawable.Rotation); + + public override void ApplyInitialValue(TDrawable d) => d.Rotation = StartValue; + + public override TransformSequence ApplyTransforms(TDrawable d) + => d.RotateTo(StartValue).Then().RotateTo(EndValue, Duration, Easing); + } +} diff --git a/osu.Game/Storyboards/Commands/StoryboardScaleCommand.cs b/osu.Game/Storyboards/Commands/StoryboardScaleCommand.cs new file mode 100644 index 0000000000..832533af5e --- /dev/null +++ b/osu.Game/Storyboards/Commands/StoryboardScaleCommand.cs @@ -0,0 +1,24 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Transforms; +using osuTK; + +namespace osu.Game.Storyboards.Commands +{ + public class StoryboardScaleCommand : StoryboardCommand + { + public StoryboardScaleCommand(Easing easing, double startTime, double endTime, float startValue, float endValue) + : base(easing, startTime, endTime, startValue, endValue) + { + } + + public override string PropertyName => nameof(Drawable.Scale); + + public override void ApplyInitialValue(TDrawable d) => d.Scale = new Vector2(StartValue); + + public override TransformSequence ApplyTransforms(TDrawable d) + => d.ScaleTo(StartValue).Then().ScaleTo(EndValue, Duration, Easing); + } +} diff --git a/osu.Game/Storyboards/CommandTrigger.cs b/osu.Game/Storyboards/Commands/StoryboardTriggerGroup.cs similarity index 74% rename from osu.Game/Storyboards/CommandTrigger.cs rename to osu.Game/Storyboards/Commands/StoryboardTriggerGroup.cs index 011f345df2..89a68e9ec0 100644 --- a/osu.Game/Storyboards/CommandTrigger.cs +++ b/osu.Game/Storyboards/Commands/StoryboardTriggerGroup.cs @@ -1,16 +1,16 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -namespace osu.Game.Storyboards +namespace osu.Game.Storyboards.Commands { - public class CommandTrigger : CommandTimelineGroup + public class StoryboardTriggerGroup : StoryboardCommandGroup { public string TriggerName; public double TriggerStartTime; public double TriggerEndTime; public int GroupNumber; - public CommandTrigger(string triggerName, double startTime, double endTime, int groupNumber) + public StoryboardTriggerGroup(string triggerName, double startTime, double endTime, int groupNumber) { TriggerName = triggerName; TriggerStartTime = startTime; diff --git a/osu.Game/Storyboards/Commands/StoryboardVectorScaleCommand.cs b/osu.Game/Storyboards/Commands/StoryboardVectorScaleCommand.cs new file mode 100644 index 0000000000..06983a1590 --- /dev/null +++ b/osu.Game/Storyboards/Commands/StoryboardVectorScaleCommand.cs @@ -0,0 +1,26 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Transforms; +using osu.Game.Storyboards.Drawables; +using osuTK; + +namespace osu.Game.Storyboards.Commands +{ + public class StoryboardVectorScaleCommand : StoryboardCommand + { + public StoryboardVectorScaleCommand(Easing easing, double startTime, double endTime, Vector2 startValue, Vector2 endValue) + : base(easing, startTime, endTime, startValue, endValue) + { + } + + public override string PropertyName => nameof(IVectorScalable.VectorScale); + + public override void ApplyInitialValue(TDrawable d) => d.VectorScale = StartValue; + + public override TransformSequence ApplyTransforms(TDrawable d) + => d.TransformTo(nameof(d.VectorScale), StartValue).Then() + .TransformTo(nameof(d.VectorScale), EndValue, Duration, Easing); + } +} diff --git a/osu.Game/Storyboards/Commands/StoryboardXCommand.cs b/osu.Game/Storyboards/Commands/StoryboardXCommand.cs new file mode 100644 index 0000000000..d52e9c8a05 --- /dev/null +++ b/osu.Game/Storyboards/Commands/StoryboardXCommand.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Transforms; + +namespace osu.Game.Storyboards.Commands +{ + public class StoryboardXCommand : StoryboardCommand + { + public StoryboardXCommand(Easing easing, double startTime, double endTime, float startValue, float endValue) + : base(easing, startTime, endTime, startValue, endValue) + { + } + + public override string PropertyName => nameof(Drawable.X); + + public override void ApplyInitialValue(TDrawable d) => d.X = StartValue; + + public override TransformSequence ApplyTransforms(TDrawable d) + => d.MoveToX(StartValue).Then().MoveToX(EndValue, Duration, Easing); + } +} diff --git a/osu.Game/Storyboards/Commands/StoryboardYCommand.cs b/osu.Game/Storyboards/Commands/StoryboardYCommand.cs new file mode 100644 index 0000000000..90dfe4d995 --- /dev/null +++ b/osu.Game/Storyboards/Commands/StoryboardYCommand.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Transforms; + +namespace osu.Game.Storyboards.Commands +{ + public class StoryboardYCommand : StoryboardCommand + { + public StoryboardYCommand(Easing easing, double startTime, double endTime, float startValue, float endValue) + : base(easing, startTime, endTime, startValue, endValue) + { + } + + public override string PropertyName => nameof(Drawable.Y); + + public override void ApplyInitialValue(TDrawable d) => d.Y = StartValue; + + public override TransformSequence ApplyTransforms(TDrawable d) + => d.MoveToY(StartValue).Then().MoveToY(EndValue, Duration, Easing); + } +} diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs index fc5ef12fb8..8e7b3feacf 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs @@ -37,20 +37,6 @@ namespace osu.Game.Storyboards.Drawables protected override Vector2 DrawScale => new Vector2(Parent!.DrawHeight / 480); - private bool passing = true; - - public bool Passing - { - get => passing; - set - { - if (passing == value) return; - - passing = value; - updateLayerVisibility(); - } - } - public override bool RemoveCompletedTransforms => false; private double? lastEventEndTime; @@ -66,6 +52,9 @@ namespace osu.Game.Storyboards.Drawables private DependencyContainer dependencies = null!; + private BindableNumber health = null!; + private readonly BindableBool passing = new BindableBool(true); + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); @@ -91,8 +80,8 @@ namespace osu.Game.Storyboards.Drawables }); } - [BackgroundDependencyLoader(true)] - private void load(IGameplayClock? clock, CancellationToken? cancellationToken) + [BackgroundDependencyLoader] + private void load(IGameplayClock? clock, CancellationToken? cancellationToken, GameplayState? gameplayState) { if (clock != null) Clock = clock; @@ -110,6 +99,16 @@ namespace osu.Game.Storyboards.Drawables } lastEventEndTime = Storyboard.LatestEventTime; + + health = gameplayState?.HealthProcessor.Health.GetBoundCopy() ?? new BindableDouble(1); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + health.BindValueChanged(val => passing.Value = val.NewValue >= 0.5, true); + passing.BindValueChanged(_ => updateLayerVisibility(), true); } protected virtual IResourceStore CreateResourceLookupStore() => new StoryboardResourceLookupStore(Storyboard, realm, host); @@ -125,7 +124,7 @@ namespace osu.Game.Storyboards.Drawables private void updateLayerVisibility() { foreach (var layer in Children) - layer.Enabled = passing ? layer.Layer.VisibleWhenPassing : layer.Layer.VisibleWhenFailing; + layer.Enabled = passing.Value ? layer.Layer.VisibleWhenPassing : layer.Layer.VisibleWhenFailing; } private class StoryboardResourceLookupStore : IResourceStore diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs index fae9ec7f2e..f66f84af7a 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs @@ -83,6 +83,7 @@ namespace osu.Game.Storyboards.Drawables Origin = animation.Origin; Position = animation.InitialPosition; Loop = animation.LoopType == AnimationLoopType.LoopForever; + Name = animation.Path; LifetimeStart = animation.StartTime; LifetimeEnd = animation.EndTimeForDisplay; diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs index ec875219b6..e25c915d8b 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs @@ -85,6 +85,7 @@ namespace osu.Game.Storyboards.Drawables Sprite = sprite; Origin = sprite.Origin; Position = sprite.InitialPosition; + Name = sprite.Path; LifetimeStart = sprite.StartTime; LifetimeEnd = sprite.EndTimeForDisplay; @@ -99,14 +100,15 @@ namespace osu.Game.Storyboards.Drawables skinSourceChanged(); } else - Texture = textureStore.Get(Sprite.Path); + Texture = textureStore.Get(Sprite.Path, WrapMode.ClampToEdge, WrapMode.ClampToEdge); Sprite.ApplyTransforms(this); } private void skinSourceChanged() { - Texture = skin.GetTexture(Sprite.Path) ?? textureStore.Get(Sprite.Path); + Texture = skin.GetTexture(Sprite.Path, WrapMode.ClampToEdge, WrapMode.ClampToEdge) ?? + textureStore.Get(Sprite.Path, WrapMode.ClampToEdge, WrapMode.ClampToEdge); // Setting texture will only update the size if it's zero. // So let's force an explicit update. diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardVideo.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardVideo.cs index f2454be190..329564a345 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardVideo.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardVideo.cs @@ -1,11 +1,16 @@ // 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.IO; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Textures; using osu.Framework.Graphics.Video; +using osu.Framework.Utils; +using osuTK; namespace osu.Game.Storyboards.Drawables { @@ -13,7 +18,7 @@ namespace osu.Game.Storyboards.Drawables { public readonly StoryboardVideo Video; - private Video? drawableVideo; + private DrawableVideo? drawableVideo; public override bool RemoveWhenNotAlive => false; @@ -25,7 +30,7 @@ namespace osu.Game.Storyboards.Drawables // This allows scaling based on the video's absolute size. // // If not specified we take up the full available space. - bool useRelative = !video.TimelineGroup.Scale.HasCommands; + bool useRelative = !video.Commands.Scale.Any(); RelativeSizeAxes = useRelative ? Axes.Both : Axes.None; AutoSizeAxes = useRelative ? Axes.None : Axes.Both; @@ -42,7 +47,7 @@ namespace osu.Game.Storyboards.Drawables if (stream == null) return; - InternalChild = drawableVideo = new Video(stream, false) + InternalChild = drawableVideo = new DrawableVideo(stream, false) { RelativeSizeAxes = RelativeSizeAxes, FillMode = FillMode.Fill, @@ -70,5 +75,65 @@ namespace osu.Game.Storyboards.Drawables drawableVideo.FadeOut(500); } } + + private partial class DrawableVideo : Video, IFlippable, IVectorScalable + { + private bool flipH; + + public bool FlipH + { + get => flipH; + set + { + if (flipH == value) + return; + + flipH = value; + Invalidate(Invalidation.MiscGeometry); + } + } + + private bool flipV; + + public bool FlipV + { + get => flipV; + set + { + if (flipV == value) + return; + + flipV = value; + Invalidate(Invalidation.MiscGeometry); + } + } + + private Vector2 vectorScale = Vector2.One; + + public Vector2 VectorScale + { + get => vectorScale; + set + { + if (vectorScale == value) + return; + + if (!Validation.IsFinite(value)) throw new ArgumentException($@"{nameof(VectorScale)} must be finite, but is {value}."); + + vectorScale = value; + Invalidate(Invalidation.MiscGeometry); + } + } + + protected override Vector2 DrawScale + => new Vector2(FlipH ? -base.DrawScale.X : base.DrawScale.X, FlipV ? -base.DrawScale.Y : base.DrawScale.Y) * VectorScale; + + public override Anchor Origin => StoryboardExtensions.AdjustOrigin(base.Origin, VectorScale, FlipH, FlipV); + + public DrawableVideo(Stream stream, bool startAtCurrentTime = true) + : base(stream, startAtCurrentTime) + { + } + } } } diff --git a/osu.Game/Storyboards/Drawables/DrawablesExtensions.cs b/osu.Game/Storyboards/Drawables/DrawablesExtensions.cs deleted file mode 100644 index bbc55a336d..0000000000 --- a/osu.Game/Storyboards/Drawables/DrawablesExtensions.cs +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Graphics; -using osu.Framework.Graphics.Transforms; - -namespace osu.Game.Storyboards.Drawables -{ - public static class DrawablesExtensions - { - /// - /// Adjusts after a delay. - /// - /// A to which further transforms can be added. - public static TransformSequence TransformBlendingMode(this T drawable, BlendingParameters newValue, double delay = 0) - where T : Drawable - => drawable.TransformTo(drawable.PopulateTransform(new TransformBlendingParameters(), newValue, delay)); - } - - public class TransformBlendingParameters : Transform - { - private BlendingParameters valueAt(double time) - => time < EndTime ? StartValue : EndValue; - - public override string TargetMember => nameof(Drawable.Blending); - - protected override void Apply(Drawable d, double time) => d.Blending = valueAt(time); - protected override void ReadIntoStartValue(Drawable d) => StartValue = d.Blending; - } -} diff --git a/osu.Game/Storyboards/Drawables/IFlippable.cs b/osu.Game/Storyboards/Drawables/IFlippable.cs index 165b3d97cc..79f98ea6ef 100644 --- a/osu.Game/Storyboards/Drawables/IFlippable.cs +++ b/osu.Game/Storyboards/Drawables/IFlippable.cs @@ -1,55 +1,13 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics; -using osu.Framework.Graphics.Transforms; namespace osu.Game.Storyboards.Drawables { - internal interface IFlippable : ITransformable + public interface IFlippable : IDrawable { bool FlipH { get; set; } bool FlipV { get; set; } } - - internal class TransformFlipH : Transform - { - private bool valueAt(double time) - => time < EndTime ? StartValue : EndValue; - - public override string TargetMember => nameof(IFlippable.FlipH); - - protected override void Apply(IFlippable d, double time) => d.FlipH = valueAt(time); - protected override void ReadIntoStartValue(IFlippable d) => StartValue = d.FlipH; - } - - internal class TransformFlipV : Transform - { - private bool valueAt(double time) - => time < EndTime ? StartValue : EndValue; - - public override string TargetMember => nameof(IFlippable.FlipV); - - protected override void Apply(IFlippable d, double time) => d.FlipV = valueAt(time); - protected override void ReadIntoStartValue(IFlippable d) => StartValue = d.FlipV; - } - - internal static class FlippableExtensions - { - /// - /// Adjusts after a delay. - /// - /// A to which further transforms can be added. - public static TransformSequence TransformFlipH(this T flippable, bool newValue, double delay = 0) - where T : class, IFlippable - => flippable.TransformTo(flippable.PopulateTransform(new TransformFlipH(), newValue, delay)); - - /// - /// Adjusts after a delay. - /// - /// A to which further transforms can be added. - public static TransformSequence TransformFlipV(this T flippable, bool newValue, double delay = 0) - where T : class, IFlippable - => flippable.TransformTo(flippable.PopulateTransform(new TransformFlipV(), newValue, delay)); - } } diff --git a/osu.Game/Storyboards/Drawables/IVectorScalable.cs b/osu.Game/Storyboards/Drawables/IVectorScalable.cs index 60a297e126..ce6047c8f6 100644 --- a/osu.Game/Storyboards/Drawables/IVectorScalable.cs +++ b/osu.Game/Storyboards/Drawables/IVectorScalable.cs @@ -1,21 +1,13 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics; -using osu.Framework.Graphics.Transforms; using osuTK; namespace osu.Game.Storyboards.Drawables { - internal interface IVectorScalable : ITransformable + public interface IVectorScalable : IDrawable { Vector2 VectorScale { get; set; } } - - internal static class VectorScalableExtensions - { - public static TransformSequence VectorScaleTo(this T target, Vector2 newVectorScale, double duration = 0, Easing easing = Easing.None) - where T : class, IVectorScalable - => target.TransformTo(nameof(IVectorScalable.VectorScale), newVectorScale, duration, easing); - } } diff --git a/osu.Game/Storyboards/StoryboardAnimation.cs b/osu.Game/Storyboards/StoryboardAnimation.cs index 1a4b6bb923..0b714633c9 100644 --- a/osu.Game/Storyboards/StoryboardAnimation.cs +++ b/osu.Game/Storyboards/StoryboardAnimation.cs @@ -21,8 +21,7 @@ namespace osu.Game.Storyboards LoopType = loopType; } - public override Drawable CreateDrawable() - => new DrawableStoryboardAnimation(this); + public override Drawable CreateDrawable() => new DrawableStoryboardAnimation(this); } public enum AnimationLoopType diff --git a/osu.Game/Storyboards/StoryboardExtensions.cs b/osu.Game/Storyboards/StoryboardExtensions.cs index 04c7196315..110af73cca 100644 --- a/osu.Game/Storyboards/StoryboardExtensions.cs +++ b/osu.Game/Storyboards/StoryboardExtensions.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 osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osuTK; @@ -22,18 +21,18 @@ namespace osu.Game.Storyboards // Either flip horizontally or negative X scale, but not both. if (flipH ^ (vectorScale.X < 0)) { - if (origin.HasFlagFast(Anchor.x0)) + if (origin.HasFlag(Anchor.x0)) origin = Anchor.x2 | (origin & (Anchor.y0 | Anchor.y1 | Anchor.y2)); - else if (origin.HasFlagFast(Anchor.x2)) + else if (origin.HasFlag(Anchor.x2)) origin = Anchor.x0 | (origin & (Anchor.y0 | Anchor.y1 | Anchor.y2)); } // Either flip vertically or negative Y scale, but not both. if (flipV ^ (vectorScale.Y < 0)) { - if (origin.HasFlagFast(Anchor.y0)) + if (origin.HasFlag(Anchor.y0)) origin = Anchor.y2 | (origin & (Anchor.x0 | Anchor.x1 | Anchor.x2)); - else if (origin.HasFlagFast(Anchor.y2)) + else if (origin.HasFlag(Anchor.y2)) origin = Anchor.y0 | (origin & (Anchor.x0 | Anchor.x1 | Anchor.x2)); } diff --git a/osu.Game/Storyboards/StoryboardSprite.cs b/osu.Game/Storyboards/StoryboardSprite.cs index 982185d51b..42426c8c85 100644 --- a/osu.Game/Storyboards/StoryboardSprite.cs +++ b/osu.Game/Storyboards/StoryboardSprite.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Graphics; +using osu.Game.Storyboards.Commands; using osu.Game.Storyboards.Drawables; using osuTK; @@ -12,8 +13,8 @@ namespace osu.Game.Storyboards { public class StoryboardSprite : IStoryboardElementWithDuration { - private readonly List loops = new List(); - private readonly List triggers = new List(); + private readonly List loopingGroups = new List(); + private readonly List triggerGroups = new List(); public string Path { get; } public bool IsDrawable => HasCommands; @@ -21,7 +22,7 @@ namespace osu.Game.Storyboards public Anchor Origin; public Vector2 InitialPosition; - public readonly CommandTimelineGroup TimelineGroup = new CommandTimelineGroup(); + public readonly StoryboardCommandGroup Commands = new StoryboardCommandGroup(); public double StartTime { @@ -34,13 +35,13 @@ namespace osu.Game.Storyboards // anything before that point can be ignored (the sprite is not visible after all). var alphaCommands = new List<(double startTime, bool isZeroStartValue)>(); - var command = TimelineGroup.Alpha.Commands.FirstOrDefault(); + var command = Commands.Alpha.FirstOrDefault(); if (command != null) alphaCommands.Add((command.StartTime, command.StartValue == 0)); - foreach (var loop in loops) + foreach (var loop in loopingGroups) { - command = loop.Alpha.Commands.FirstOrDefault(); - if (command != null) alphaCommands.Add((command.StartTime + loop.LoopStartTime, command.StartValue == 0)); + command = loop.Alpha.FirstOrDefault(); + if (command != null) alphaCommands.Add((command.StartTime, command.StartValue == 0)); } if (alphaCommands.Count > 0) @@ -61,8 +62,8 @@ namespace osu.Game.Storyboards { // If we got to this point, either no alpha commands were present, or the earliest had a non-zero start value. // The sprite's StartTime will be determined by the earliest command, regardless of type. - double earliestStartTime = TimelineGroup.StartTime; - foreach (var l in loops) + double earliestStartTime = Commands.StartTime; + foreach (var l in loopingGroups) earliestStartTime = Math.Min(earliestStartTime, l.StartTime); return earliestStartTime; } @@ -72,9 +73,9 @@ namespace osu.Game.Storyboards { get { - double latestEndTime = TimelineGroup.EndTime; + double latestEndTime = Commands.EndTime; - foreach (var l in loops) + foreach (var l in loopingGroups) latestEndTime = Math.Max(latestEndTime, l.EndTime); return latestEndTime; @@ -85,20 +86,16 @@ namespace osu.Game.Storyboards { get { - double latestEndTime = TimelineGroup.EndTime; + double latestEndTime = Commands.EndTime; - foreach (var l in loops) - latestEndTime = Math.Max(latestEndTime, l.StartTime + l.CommandsDuration * l.TotalIterations); + foreach (var l in loopingGroups) + latestEndTime = Math.Max(latestEndTime, l.StartTime + l.Duration * l.TotalIterations); return latestEndTime; } } - public bool HasCommands => TimelineGroup.HasCommands || loops.Any(l => l.HasCommands); - - private delegate void DrawablePropertyInitializer(Drawable drawable, T value); - - private delegate void DrawableTransformer(Drawable drawable, T value, double duration, Easing easing); + public bool HasCommands => Commands.HasCommands || loopingGroups.Any(l => l.HasCommands); public StoryboardSprite(string path, Anchor origin, Vector2 initialPosition) { @@ -107,127 +104,39 @@ namespace osu.Game.Storyboards InitialPosition = initialPosition; } - public CommandLoop AddLoop(double startTime, int repeatCount) + public virtual Drawable CreateDrawable() => new DrawableStoryboardSprite(this); + + public StoryboardLoopingGroup AddLoopingGroup(double loopStartTime, int repeatCount) { - var loop = new CommandLoop(startTime, repeatCount); - loops.Add(loop); + var loop = new StoryboardLoopingGroup(loopStartTime, repeatCount); + loopingGroups.Add(loop); return loop; } - public CommandTrigger AddTrigger(string triggerName, double startTime, double endTime, int groupNumber) + public StoryboardTriggerGroup AddTriggerGroup(string triggerName, double startTime, double endTime, int groupNumber) { - var trigger = new CommandTrigger(triggerName, startTime, endTime, groupNumber); - triggers.Add(trigger); + var trigger = new StoryboardTriggerGroup(triggerName, startTime, endTime, groupNumber); + triggerGroups.Add(trigger); return trigger; } - public virtual Drawable CreateDrawable() - => new DrawableStoryboardSprite(this); - - public void ApplyTransforms(Drawable drawable, IEnumerable>? triggeredGroups = null) + public void ApplyTransforms(TDrawable drawable) + where TDrawable : Drawable, IFlippable, IVectorScalable { - // For performance reasons, we need to apply the commands in order by start time. Not doing so will cause many functions to be interleaved, resulting in O(n^2) complexity. - // To achieve this, commands are "generated" as pairs of (command, initFunc, transformFunc) and batched into a contiguous list - // The list is then stably-sorted (to preserve command order), and applied to the drawable sequentially. + HashSet appliedProperties = new HashSet(); - List generated = new List(); + // For performance reasons, we need to apply the commands in chronological order. + // Not doing so will cause many functions to be interleaved, resulting in O(n^2) complexity. + IEnumerable commands = Commands.AllCommands; + commands = commands.Concat(loopingGroups.SelectMany(l => l.AllCommands)); - generateCommands(generated, getCommands(g => g.X, triggeredGroups), (d, value) => d.X = value, (d, value, duration, easing) => d.MoveToX(value, duration, easing)); - generateCommands(generated, getCommands(g => g.Y, triggeredGroups), (d, value) => d.Y = value, (d, value, duration, easing) => d.MoveToY(value, duration, easing)); - generateCommands(generated, getCommands(g => g.Scale, triggeredGroups), (d, value) => d.Scale = new Vector2(value), (d, value, duration, easing) => d.ScaleTo(value, duration, easing)); - generateCommands(generated, getCommands(g => g.Rotation, triggeredGroups), (d, value) => d.Rotation = value, (d, value, duration, easing) => d.RotateTo(value, duration, easing)); - generateCommands(generated, getCommands(g => g.Colour, triggeredGroups), (d, value) => d.Colour = value, (d, value, duration, easing) => d.FadeColour(value, duration, easing)); - generateCommands(generated, getCommands(g => g.Alpha, triggeredGroups), (d, value) => d.Alpha = value, (d, value, duration, easing) => d.FadeTo(value, duration, easing)); - generateCommands(generated, getCommands(g => g.BlendingParameters, triggeredGroups), (d, value) => d.Blending = value, (d, value, duration, _) => d.TransformBlendingMode(value, duration), - false); - - if (drawable is IVectorScalable vectorScalable) + foreach (var command in commands.OrderBy(c => c.StartTime)) { - generateCommands(generated, getCommands(g => g.VectorScale, triggeredGroups), (_, value) => vectorScalable.VectorScale = value, - (_, value, duration, easing) => vectorScalable.VectorScaleTo(value, duration, easing)); - } - - if (drawable is IFlippable flippable) - { - generateCommands(generated, getCommands(g => g.FlipH, triggeredGroups), (_, value) => flippable.FlipH = value, (_, value, duration, _) => flippable.TransformFlipH(value, duration), - false); - generateCommands(generated, getCommands(g => g.FlipV, triggeredGroups), (_, value) => flippable.FlipV = value, (_, value, duration, _) => flippable.TransformFlipV(value, duration), - false); - } - - foreach (var command in generated.OrderBy(g => g.StartTime)) - command.ApplyTo(drawable); - } - - private void generateCommands(List resultList, IEnumerable.TypedCommand> commands, - DrawablePropertyInitializer initializeProperty, DrawableTransformer transform, bool alwaysInitialize = true) - { - bool initialized = false; - - foreach (var command in commands) - { - DrawablePropertyInitializer? initFunc = null; - - if (!initialized) - { - if (alwaysInitialize || command.StartTime == command.EndTime) - initFunc = initializeProperty; - initialized = true; - } - - resultList.Add(new GeneratedCommand(command, initFunc, transform)); - } - } - - private IEnumerable.TypedCommand> getCommands(CommandTimelineSelector timelineSelector, IEnumerable>? triggeredGroups) - { - var commands = TimelineGroup.GetCommands(timelineSelector); - foreach (var loop in loops) - commands = commands.Concat(loop.GetCommands(timelineSelector)); - - if (triggeredGroups != null) - { - foreach (var pair in triggeredGroups) - commands = commands.Concat(pair.Item1.GetCommands(timelineSelector, pair.Item2)); - } - - return commands; - } - - public override string ToString() - => $"{Path}, {Origin}, {InitialPosition}"; - - private interface IGeneratedCommand - { - double StartTime { get; } - - void ApplyTo(Drawable drawable); - } - - private readonly struct GeneratedCommand : IGeneratedCommand - { - public double StartTime => command.StartTime; - - private readonly DrawablePropertyInitializer? initializeProperty; - private readonly DrawableTransformer transform; - private readonly CommandTimeline.TypedCommand command; - - public GeneratedCommand(CommandTimeline.TypedCommand command, DrawablePropertyInitializer? initializeProperty, DrawableTransformer transform) - { - this.command = command; - this.initializeProperty = initializeProperty; - this.transform = transform; - } - - public void ApplyTo(Drawable drawable) - { - initializeProperty?.Invoke(drawable, command.StartValue); + if (appliedProperties.Add(command.PropertyName)) + command.ApplyInitialValue(drawable); using (drawable.BeginAbsoluteSequence(command.StartTime)) - { - transform(drawable, command.StartValue, 0, Easing.None); - transform(drawable, command.EndValue, command.Duration, command.Easing); - } + command.ApplyTransforms(drawable); } } } diff --git a/osu.Game/Storyboards/StoryboardVideo.cs b/osu.Game/Storyboards/StoryboardVideo.cs index 5573162d26..14189a1a6c 100644 --- a/osu.Game/Storyboards/StoryboardVideo.cs +++ b/osu.Game/Storyboards/StoryboardVideo.cs @@ -14,7 +14,7 @@ namespace osu.Game.Storyboards { // This is just required to get a valid StartTime based on the incoming offset. // Actual fades are handled inside DrawableStoryboardVideo for now. - TimelineGroup.Alpha.Add(Easing.None, offset, offset, 0, 0); + Commands.AddAlpha(Easing.None, offset, offset, 0, 0); } public override Drawable CreateDrawable() => new DrawableStoryboardVideo(this); diff --git a/osu.Game/Tests/Beatmaps/TestBeatmap.cs b/osu.Game/Tests/Beatmaps/TestBeatmap.cs index ff670e1232..31ad2de62e 100644 --- a/osu.Game/Tests/Beatmaps/TestBeatmap.cs +++ b/osu.Game/Tests/Beatmaps/TestBeatmap.cs @@ -26,10 +26,13 @@ namespace osu.Game.Tests.Beatmaps BeatmapInfo = baseBeatmap.BeatmapInfo; ControlPointInfo = baseBeatmap.ControlPointInfo; - Breaks = baseBeatmap.Breaks; + UnhandledEventLines = baseBeatmap.UnhandledEventLines; if (withHitObjects) + { HitObjects = baseBeatmap.HitObjects; + Breaks = baseBeatmap.Breaks; + } BeatmapInfo.Ruleset = ruleset; BeatmapInfo.Length = 75000; diff --git a/osu.Game/Tests/Gameplay/TestGameplayState.cs b/osu.Game/Tests/Gameplay/TestGameplayState.cs index bb82335543..8fad6d1e23 100644 --- a/osu.Game/Tests/Gameplay/TestGameplayState.cs +++ b/osu.Game/Tests/Gameplay/TestGameplayState.cs @@ -27,7 +27,9 @@ namespace osu.Game.Tests.Gameplay var scoreProcessor = ruleset.CreateScoreProcessor(); scoreProcessor.ApplyBeatmap(playableBeatmap); - return new GameplayState(playableBeatmap, ruleset, mods, score, scoreProcessor); + var healthProcessor = ruleset.CreateHealthProcessor(beatmap.HitObjects[0].StartTime); + + return new GameplayState(playableBeatmap, ruleset, mods, score, scoreProcessor, healthProcessor); } } } diff --git a/osu.Game/Tests/PollingChatClient.cs b/osu.Game/Tests/PollingChatClient.cs index eb29b35c1d..75975c716b 100644 --- a/osu.Game/Tests/PollingChatClient.cs +++ b/osu.Game/Tests/PollingChatClient.cs @@ -48,7 +48,7 @@ namespace osu.Game.Tests fetchReq.Success += updates => { - if (updates?.Presence != null) + if (updates.Presence != null) { foreach (var channel in updates.Presence) handleChannelJoined(channel); diff --git a/osu.Game/Tests/Visual/Gameplay/ScoringTestScene.cs b/osu.Game/Tests/Visual/Gameplay/ScoringTestScene.cs index e7053e4202..6908f7f1b4 100644 --- a/osu.Game/Tests/Visual/Gameplay/ScoringTestScene.cs +++ b/osu.Game/Tests/Visual/Gameplay/ScoringTestScene.cs @@ -658,7 +658,6 @@ namespace osu.Game.Tests.Visual.Gameplay private partial class TestModSelectOverlay : UserModSelectOverlay { - protected override bool ShowModEffects => true; protected override bool ShowPresets => false; public TestModSelectOverlay() diff --git a/osu.Game/Tests/Visual/LegacySkinPlayerTestScene.cs b/osu.Game/Tests/Visual/LegacySkinPlayerTestScene.cs index 2e254f5b95..0e1776be8e 100644 --- a/osu.Game/Tests/Visual/LegacySkinPlayerTestScene.cs +++ b/osu.Game/Tests/Visual/LegacySkinPlayerTestScene.cs @@ -45,13 +45,13 @@ namespace osu.Game.Tests.Visual private void addResetTargetsStep() { - AddStep("reset targets", () => this.ChildrenOfType().ForEach(t => + AddStep("reset targets", () => this.ChildrenOfType().ForEach(t => { LegacySkin.ResetDrawableTarget(t); t.Reload(); })); - AddUntilStep("wait for components to load", () => this.ChildrenOfType().All(t => t.ComponentsLoaded)); + AddUntilStep("wait for components to load", () => this.ChildrenOfType().All(t => t.ComponentsLoaded)); } public partial class SkinProvidingPlayer : TestPlayer diff --git a/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs b/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs index 16cbf879df..4a862750bc 100644 --- a/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs +++ b/osu.Game/Tests/Visual/Metadata/TestMetadataClient.cs @@ -13,7 +13,8 @@ namespace osu.Game.Tests.Visual.Metadata { public partial class TestMetadataClient : MetadataClient { - public override IBindable IsConnected => new BindableBool(true); + public override IBindable IsConnected => isConnected; + private readonly BindableBool isConnected = new BindableBool(true); public override IBindable IsWatchingUserPresence => isWatchingUserPresence; private readonly BindableBool isWatchingUserPresence = new BindableBool(); @@ -21,6 +22,9 @@ namespace osu.Game.Tests.Visual.Metadata public override IBindableDictionary UserStates => userStates; private readonly BindableDictionary userStates = new BindableDictionary(); + public override Bindable DailyChallengeInfo => dailyChallengeInfo; + private readonly Bindable dailyChallengeInfo = new Bindable(); + [Resolved] private IAPIProvider api { get; set; } = null!; @@ -77,5 +81,34 @@ namespace osu.Game.Tests.Visual.Metadata => Task.FromResult(new BeatmapUpdates(Array.Empty(), queueId)); public override Task BeatmapSetsUpdated(BeatmapUpdates updates) => Task.CompletedTask; + + public override Task DailyChallengeUpdated(DailyChallengeInfo? info) + { + dailyChallengeInfo.Value = info; + return Task.CompletedTask; + } + + public override Task BeginWatchingMultiplayerRoom(long id) + { + var stats = new MultiplayerPlaylistItemStats[MultiplayerPlaylistItemStats.TOTAL_SCORE_DISTRIBUTION_BINS]; + + for (int i = 0; i < stats.Length; i++) + stats[i] = new MultiplayerPlaylistItemStats { PlaylistItemID = i }; + + return Task.FromResult(stats); + } + + public override Task EndWatchingMultiplayerRoom(long id) => Task.CompletedTask; + + public void Disconnect() + { + isConnected.Value = false; + dailyChallengeInfo.Value = null; + } + + public void Reconnect() + { + isConnected.Value = true; + } } } diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index 4c3deac1d7..efa9dc4990 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -208,6 +208,9 @@ namespace osu.Game.Tests.Visual.Multiplayer protected override async Task JoinRoom(long roomId, string? password = null) { + if (RoomJoined || ServerAPIRoom != null) + throw new InvalidOperationException("Already joined a room"); + roomId = clone(roomId); password = clone(password); @@ -260,6 +263,8 @@ namespace osu.Game.Tests.Visual.Multiplayer protected override Task LeaveRoomInternal() { RoomJoined = false; + ServerAPIRoom = null; + ServerRoom = null; return Task.CompletedTask; } diff --git a/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs b/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs index ef4539ba56..ec89f81597 100644 --- a/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs +++ b/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs @@ -9,6 +9,7 @@ using System.Diagnostics; using System.Linq; using Newtonsoft.Json; using osu.Game.Beatmaps; +using osu.Game.Database; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; @@ -18,6 +19,7 @@ using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Tests.Beatmaps; +using osu.Game.Utils; namespace osu.Game.Tests.Visual.OnlinePlay { @@ -99,6 +101,55 @@ namespace osu.Game.Tests.Visual.OnlinePlay }); return true; + case IndexPlaylistScoresRequest roomLeaderboardRequest: + roomLeaderboardRequest.TriggerSuccess(new IndexedMultiplayerScores + { + Scores = + { + new MultiplayerScore + { + ID = currentScoreId++, + Accuracy = 1, + Position = 1, + EndedAt = DateTimeOffset.Now, + Passed = true, + Rank = ScoreRank.S, + MaxCombo = 1000, + TotalScore = 1000000, + User = new APIUser { Username = "best user" }, + Mods = [new APIMod { Acronym = @"DT" }], + Statistics = new Dictionary() + }, + new MultiplayerScore + { + ID = currentScoreId++, + Accuracy = 0.7, + Position = 2, + EndedAt = DateTimeOffset.Now, + Passed = true, + Rank = ScoreRank.B, + MaxCombo = 100, + TotalScore = 200000, + User = new APIUser { Username = "worst user" }, + Statistics = new Dictionary() + }, + }, + UserScore = new MultiplayerScore + { + ID = currentScoreId++, + Accuracy = 0.91, + Position = 4, + EndedAt = DateTimeOffset.Now, + Passed = true, + Rank = ScoreRank.A, + MaxCombo = 100, + TotalScore = 800000, + User = localUser, + Statistics = new Dictionary() + }, + }); + return true; + case PartRoomRequest partRoomRequest: partRoomRequest.TriggerSuccess(); return true; @@ -137,7 +188,7 @@ namespace osu.Game.Tests.Visual.OnlinePlay case GetBeatmapRequest getBeatmapRequest: { - getBeatmapRequest.TriggerSuccess(createResponseBeatmaps(getBeatmapRequest.BeatmapInfo.OnlineID).Single()); + getBeatmapRequest.TriggerSuccess(createResponseBeatmaps(getBeatmapRequest.OnlineID).Single()); return true; } @@ -151,7 +202,7 @@ namespace osu.Game.Tests.Visual.OnlinePlay { var baseBeatmap = getBeatmapSetRequest.Type == BeatmapSetLookupType.BeatmapId ? beatmapManager.QueryBeatmap(b => b.OnlineID == getBeatmapSetRequest.ID) - : beatmapManager.QueryBeatmap(b => b.BeatmapSet.OnlineID == getBeatmapSetRequest.ID); + : beatmapManager.QueryBeatmapSet(s => s.OnlineID == getBeatmapSetRequest.ID)?.PerformRead(s => s.Beatmaps.First().Detach()); if (baseBeatmap == null) { @@ -163,6 +214,22 @@ namespace osu.Game.Tests.Visual.OnlinePlay getBeatmapSetRequest.TriggerSuccess(OsuTestScene.CreateAPIBeatmapSet(baseBeatmap)); return true; } + + case GetUsersRequest getUsersRequest: + { + getUsersRequest.TriggerSuccess(new GetUsersResponse + { + Users = getUsersRequest.UserIds.Select(id => id == TestUserLookupCache.UNRESOLVED_USER_ID + ? null + : new APIUser + { + Id = id, + Username = $"User {id}" + }) + .Where(u => u != null).ToList(), + }); + return true; + } } List createResponseBeatmaps(params int[] beatmapIds) @@ -228,11 +295,18 @@ namespace osu.Game.Tests.Visual.OnlinePlay var result = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(source)); Debug.Assert(result != null); - // Playlist item IDs aren't serialised. + // Playlist item IDs and beatmaps aren't serialised. if (source.CurrentPlaylistItem.Value != null) + { + result.CurrentPlaylistItem.Value = result.CurrentPlaylistItem.Value.With(new Optional(source.CurrentPlaylistItem.Value.Beatmap)); result.CurrentPlaylistItem.Value.ID = source.CurrentPlaylistItem.Value.ID; + } + for (int i = 0; i < source.Playlist.Count; i++) + { + result.Playlist[i] = result.Playlist[i].With(new Optional(source.Playlist[i].Beatmap)); result.Playlist[i].ID = source.Playlist[i].ID; + } return result; } diff --git a/osu.Game/Tests/Visual/OsuTestScene.cs b/osu.Game/Tests/Visual/OsuTestScene.cs index 2b4c64dca8..09cfe5ecad 100644 --- a/osu.Game/Tests/Visual/OsuTestScene.cs +++ b/osu.Game/Tests/Visual/OsuTestScene.cs @@ -306,7 +306,9 @@ namespace osu.Game.Tests.Visual StarRating = original.StarRating, DifficultyName = original.DifficultyName, } - } + }, + HasFavourited = false, + FavouriteCount = 0, }; foreach (var beatmap in result.Beatmaps) diff --git a/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs b/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs index 0027e03492..aa8aff3adc 100644 --- a/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs +++ b/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs @@ -3,9 +3,11 @@ #nullable disable +using System; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; @@ -19,11 +21,15 @@ namespace osu.Game.Tests.Visual public abstract partial class PlacementBlueprintTestScene : OsuManualInputManagerTestScene, IPlacementHandler { protected readonly Container HitObjectContainer; - protected PlacementBlueprint CurrentBlueprint { get; private set; } + protected HitObjectPlacementBlueprint CurrentBlueprint { get; private set; } protected PlacementBlueprintTestScene() { base.Content.Add(HitObjectContainer = CreateHitObjectContainer().With(c => c.Clock = new FramedClock(new StopwatchClock()))); + base.Content.Add(new MouseMovementInterceptor + { + MouseMoved = updatePlacementTimeAndPosition, + }); } protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) @@ -53,20 +59,20 @@ namespace osu.Game.Tests.Visual protected override void LoadComplete() { base.LoadComplete(); - ResetPlacement(); } - public void BeginPlacement(HitObject hitObject) + public void ShowPlacement(HitObject hitObject) { } - public void EndPlacement(HitObject hitObject, bool commit) + public void HidePlacement() { - if (commit) - AddHitObject(CreateHitObject(hitObject)); + } - ResetPlacement(); + public void CommitPlacement(HitObject hitObject) + { + AddHitObject(CreateHitObject(hitObject)); } protected void ResetPlacement() @@ -84,17 +90,22 @@ namespace osu.Game.Tests.Visual { base.Update(); - CurrentBlueprint.UpdateTimeAndPosition(SnapForBlueprint(CurrentBlueprint)); + if (CurrentBlueprint.PlacementActive == PlacementBlueprint.PlacementState.Finished) + ResetPlacement(); + + updatePlacementTimeAndPosition(); } - protected virtual SnapResult SnapForBlueprint(PlacementBlueprint blueprint) => + private void updatePlacementTimeAndPosition() => CurrentBlueprint.UpdateTimeAndPosition(SnapForBlueprint(CurrentBlueprint)); + + protected virtual SnapResult SnapForBlueprint(HitObjectPlacementBlueprint blueprint) => new SnapResult(InputManager.CurrentState.Mouse.Position, null); public override void Add(Drawable drawable) { base.Add(drawable); - if (drawable is PlacementBlueprint blueprint) + if (drawable is HitObjectPlacementBlueprint blueprint) { blueprint.Show(); blueprint.UpdateTimeAndPosition(SnapForBlueprint(blueprint)); @@ -106,6 +117,23 @@ namespace osu.Game.Tests.Visual protected virtual Container CreateHitObjectContainer() => new Container { RelativeSizeAxes = Axes.Both }; protected abstract DrawableHitObject CreateHitObject(HitObject hitObject); - protected abstract PlacementBlueprint CreateBlueprint(); + protected abstract HitObjectPlacementBlueprint CreateBlueprint(); + + private partial class MouseMovementInterceptor : Drawable + { + public Action MouseMoved; + + public MouseMovementInterceptor() + { + RelativeSizeAxes = Axes.Both; + Depth = float.MinValue; + } + + protected override bool OnMouseMove(MouseMoveEvent e) + { + MouseMoved?.Invoke(); + return base.OnMouseMove(e); + } + } } } diff --git a/osu.Game/Tests/Visual/ScreenTestScene.cs b/osu.Game/Tests/Visual/ScreenTestScene.cs index 3cca1e59cc..f780b1a8f8 100644 --- a/osu.Game/Tests/Visual/ScreenTestScene.cs +++ b/osu.Game/Tests/Visual/ScreenTestScene.cs @@ -12,6 +12,7 @@ using osu.Framework.Testing; using osu.Game.Graphics; using osu.Game.Overlays; using osu.Game.Screens; +using osu.Game.Screens.Footer; namespace osu.Game.Tests.Visual { @@ -30,6 +31,9 @@ namespace osu.Game.Tests.Visual [Cached(typeof(IDialogOverlay))] protected DialogOverlay DialogOverlay { get; private set; } + [Cached] + private ScreenFooter footer; + protected ScreenTestScene() { base.Content.AddRange(new Drawable[] @@ -44,7 +48,8 @@ namespace osu.Game.Tests.Visual { RelativeSizeAxes = Axes.Both, Child = DialogOverlay = new DialogOverlay() - } + }, + footer = new ScreenFooter(), }); Stack.ScreenPushed += (_, newScreen) => Logger.Log($"{nameof(ScreenTestScene)} screen changed → {newScreen}"); diff --git a/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs b/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs index 5aef85fa13..c27e7f15ca 100644 --- a/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs +++ b/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs @@ -13,6 +13,8 @@ using osu.Game.Online.API; using osu.Game.Online.Spectator; using osu.Game.Replays.Legacy; using osu.Game.Rulesets; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; @@ -99,13 +101,24 @@ namespace osu.Game.Tests.Visual.Spectator /// The user to send frames for. /// The total number of frames to send. /// The time to start gameplay frames from. - public void SendFramesFromUser(int userId, int count, double startTime = 0) + /// Add a number of misses to frame header data for testing purposes. + public void SendFramesFromUser(int userId, int count, double startTime = 0, int initialResultCount = 0) { var frames = new List(); int currentFrameIndex = userNextFrameDictionary[userId]; int lastFrameIndex = currentFrameIndex + count - 1; + var scoreProcessor = new ScoreProcessor(rulesetStore.GetRuleset(0)!.CreateInstance()); + + for (int i = 0; i < initialResultCount; i++) + { + scoreProcessor.ApplyResult(new JudgementResult(new HitObject(), new Judgement()) + { + Type = HitResult.Miss, + }); + } + for (; currentFrameIndex <= lastFrameIndex; currentFrameIndex++) { // This is done in the next frame so that currentFrameIndex is updated to the correct value. @@ -130,7 +143,16 @@ namespace osu.Game.Tests.Visual.Spectator Combo = currentFrameIndex, TotalScore = (long)(currentFrameIndex * 123478 * RNG.NextDouble(0.99, 1.01)), Accuracy = RNG.NextDouble(0.98, 1), - }, new ScoreProcessor(rulesetStore.GetRuleset(0)!.CreateInstance()), frames.ToArray()); + Statistics = scoreProcessor.Statistics.ToDictionary(), + }, scoreProcessor, frames.ToArray()); + + if (initialResultCount > 0) + { + foreach (var f in frames) + f.Header = bundle.Header; + } + + scoreProcessor.ResetFromReplayFrame(frames.Last()); ((ISpectatorClient)this).UserSentFrames(userId, bundle); frames.Clear(); diff --git a/osu.Game/Updater/SimpleUpdateManager.cs b/osu.Game/Updater/MobileUpdateNotifier.cs similarity index 81% rename from osu.Game/Updater/SimpleUpdateManager.cs rename to osu.Game/Updater/MobileUpdateNotifier.cs index 0f9d5b929f..04b54df3c0 100644 --- a/osu.Game/Updater/SimpleUpdateManager.cs +++ b/osu.Game/Updater/MobileUpdateNotifier.cs @@ -4,7 +4,6 @@ using System; using System.Diagnostics.CodeAnalysis; using System.Linq; -using System.Runtime.InteropServices; using System.Threading.Tasks; using osu.Framework; using osu.Framework.Allocation; @@ -16,10 +15,10 @@ using osu.Game.Overlays.Notifications; namespace osu.Game.Updater { /// - /// An update manager that shows notifications if a newer release is detected. + /// An update manager that shows notifications if a newer release is detected for mobile platforms. /// Installation is left up to the user. /// - public partial class SimpleUpdateManager : UpdateManager + public partial class MobileUpdateNotifier : UpdateManager { private string version = null!; @@ -80,19 +79,6 @@ namespace osu.Game.Updater switch (RuntimeInfo.OS) { - case RuntimeInfo.Platform.Windows: - bestAsset = release.Assets?.Find(f => f.Name.EndsWith(".exe", StringComparison.Ordinal)); - break; - - case RuntimeInfo.Platform.macOS: - string arch = RuntimeInformation.OSArchitecture == Architecture.Arm64 ? "Apple.Silicon" : "Intel"; - bestAsset = release.Assets?.Find(f => f.Name.EndsWith($".app.{arch}.zip", StringComparison.Ordinal)); - break; - - case RuntimeInfo.Platform.Linux: - bestAsset = release.Assets?.Find(f => f.Name.EndsWith(".AppImage", StringComparison.Ordinal)); - break; - case RuntimeInfo.Platform.iOS: if (release.Assets?.Exists(f => f.Name.EndsWith(".ipa", StringComparison.Ordinal)) == true) // iOS releases are available via testflight. this link seems to work well enough for now. diff --git a/osu.Game/Updater/UpdateManager.cs b/osu.Game/Updater/UpdateManager.cs index bcb28d8b14..c114e3a8d0 100644 --- a/osu.Game/Updater/UpdateManager.cs +++ b/osu.Game/Updater/UpdateManager.cs @@ -176,12 +176,6 @@ namespace osu.Game.Updater Text = NotificationsStrings.DownloadingUpdate; } - public void StartInstall() - { - Progress = 0; - Text = NotificationsStrings.InstallingUpdate; - } - public void FailDownload() { State = ProgressNotificationState.Cancelled; diff --git a/osu.Game/Users/CountryCode.cs b/osu.Game/Users/CountryCode.cs index edaa1562c7..59fcd5d625 100644 --- a/osu.Game/Users/CountryCode.cs +++ b/osu.Game/Users/CountryCode.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using JetBrains.Annotations; using Newtonsoft.Json; using Newtonsoft.Json.Converters; @@ -12,6 +13,7 @@ namespace osu.Game.Users /// Matches `osu_countries` database table. /// [JsonConverter(typeof(StringEnumConverter))] + [SuppressMessage("ReSharper", "InconsistentNaming")] [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] public enum CountryCode { diff --git a/osu.Game/Users/Drawables/UpdateableFlag.cs b/osu.Game/Users/Drawables/UpdateableFlag.cs index 8f8d7052e5..6a587212a3 100644 --- a/osu.Game/Users/Drawables/UpdateableFlag.cs +++ b/osu.Game/Users/Drawables/UpdateableFlag.cs @@ -3,9 +3,11 @@ using System; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; +using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; @@ -13,33 +15,43 @@ namespace osu.Game.Users.Drawables { public partial class UpdateableFlag : ModelBackedDrawable { + private CountryCode countryCode; + public CountryCode CountryCode { - get => Model; - set => Model = value; + get => countryCode; + set + { + countryCode = value; + updateModel(); + } } - /// - /// Whether to show a place holder on unknown country. - /// - public bool ShowPlaceholderOnUnknown = true; - /// /// Perform an action in addition to showing the country ranking. /// This should be used to perform auxiliary tasks and not as a primary action for clicking a flag (to maintain a consistent UX). /// public Action? Action; + private readonly Bindable hideFlags = new BindableBool(); + + [Resolved] + private RankingsOverlay? rankingsOverlay { get; set; } + public UpdateableFlag(CountryCode countryCode = CountryCode.Unknown) { CountryCode = countryCode; + hideFlags.BindValueChanged(_ => updateModel()); } - protected override Drawable? CreateDrawable(CountryCode countryCode) + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) { - if (countryCode == CountryCode.Unknown && !ShowPlaceholderOnUnknown) - return null; + config.BindWith(OsuSetting.HideCountryFlags, hideFlags); + } + protected override Drawable CreateDrawable(CountryCode countryCode) + { return new Container { RelativeSizeAxes = Axes.Both, @@ -54,14 +66,13 @@ namespace osu.Game.Users.Drawables }; } - [Resolved] - private RankingsOverlay? rankingsOverlay { get; set; } - protected override bool OnClick(ClickEvent e) { Action?.Invoke(); rankingsOverlay?.ShowCountry(CountryCode); return true; } + + private void updateModel() => Model = hideFlags.Value ? CountryCode.Unknown : countryCode; } } diff --git a/osu.Game/Users/UserPanel.cs b/osu.Game/Users/UserPanel.cs index b88619c8b7..0d3ea52611 100644 --- a/osu.Game/Users/UserPanel.cs +++ b/osu.Game/Users/UserPanel.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; @@ -13,6 +14,7 @@ using osu.Game.Overlays; using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics.UserInterface; using osu.Framework.Graphics.Cursor; +using osu.Framework.Localisation; using osu.Framework.Screens; using osu.Game.Graphics.Containers; using osu.Game.Online.API; @@ -28,7 +30,7 @@ using osuTK; namespace osu.Game.Users { - public abstract partial class UserPanel : OsuClickableContainer, IHasContextMenu + public abstract partial class UserPanel : OsuClickableContainer, IHasContextMenu, IFilterable { public readonly APIUser User; @@ -162,5 +164,20 @@ namespace osu.Game.Users return items.ToArray(); } } + + public IEnumerable FilterTerms => [User.Username]; + + public bool MatchingFilter + { + set + { + if (value) + Show(); + else + Hide(); + } + } + + public bool FilteringActive { get; set; } } } diff --git a/osu.Game/Users/UserRankPanel.cs b/osu.Game/Users/UserRankPanel.cs index 0d57b7bb7d..6d8d7dd143 100644 --- a/osu.Game/Users/UserRankPanel.cs +++ b/osu.Game/Users/UserRankPanel.cs @@ -152,7 +152,7 @@ namespace osu.Game.Users Margin = new MarginPadding { Top = main_content_height }, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Left = 80, Vertical = padding }, + Padding = new MarginPadding(padding), ColumnDimensions = new[] { new Dimension(), diff --git a/osu.Game/Utils/BindableValueAccessor.cs b/osu.Game/Utils/BindableValueAccessor.cs new file mode 100644 index 0000000000..a4cd356339 --- /dev/null +++ b/osu.Game/Utils/BindableValueAccessor.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; +using System.Linq; +using System.Reflection; +using osu.Framework.Bindables; +using osu.Framework.Extensions.TypeExtensions; + +namespace osu.Game.Utils +{ + internal static class BindableValueAccessor + { + private static readonly MethodInfo get_method = typeof(BindableValueAccessor).GetMethod(nameof(getValue), BindingFlags.Static | BindingFlags.NonPublic)!; + private static readonly MethodInfo set_method = typeof(BindableValueAccessor).GetMethod(nameof(setValue), BindingFlags.Static | BindingFlags.NonPublic)!; + + public static object GetValue(IBindable bindable) + { + Type? bindableWithValueType = bindable.GetType().GetInterfaces().FirstOrDefault(t => t.IsGenericType && t.GetGenericTypeDefinition() == typeof(IBindable<>)); + if (bindableWithValueType == null) + return bindable; + + return get_method.MakeGenericMethod(bindableWithValueType.GenericTypeArguments[0]).Invoke(null, [bindable])!; + } + + public static void SetValue(IBindable bindable, object value) + { + Type? bindableWithValueType = bindable.GetType().EnumerateBaseTypes().SingleOrDefault(t => t.IsGenericType && t.GetGenericTypeDefinition() == typeof(Bindable<>)); + if (bindableWithValueType == null) + return; + + set_method.MakeGenericMethod(bindableWithValueType.GenericTypeArguments[0]).Invoke(null, [bindable, value]); + } + + private static object getValue(object bindable) => ((IBindable)bindable).Value!; + + private static object setValue(object bindable, object value) => ((Bindable)bindable).Value = (T)value; + } +} diff --git a/osu.Game/Utils/GeometryUtils.cs b/osu.Game/Utils/GeometryUtils.cs index dbeba4dfc1..eac86a9c02 100644 --- a/osu.Game/Utils/GeometryUtils.cs +++ b/osu.Game/Utils/GeometryUtils.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Graphics; using osu.Framework.Graphics.Primitives; +using osu.Framework.Utils; using osu.Game.Rulesets.Objects.Types; using osuTK; @@ -26,9 +27,7 @@ namespace osu.Game.Utils point.X -= origin.X; point.Y -= origin.Y; - Vector2 ret; - ret.X = point.X * MathF.Cos(float.DegreesToRadians(angle)) + point.Y * MathF.Sin(float.DegreesToRadians(angle)); - ret.Y = point.X * -MathF.Sin(float.DegreesToRadians(angle)) + point.Y * MathF.Cos(float.DegreesToRadians(angle)); + Vector2 ret = RotateVector(point, angle); ret.X += origin.X; ret.Y += origin.Y; @@ -36,10 +35,26 @@ namespace osu.Game.Utils return ret; } + /// + /// Rotate a vector around the origin. + /// + /// The vector. + /// The angle to rotate (in degrees). + public static Vector2 RotateVector(Vector2 vector, float angle) + { + return new Vector2( + vector.X * MathF.Cos(float.DegreesToRadians(angle)) + vector.Y * MathF.Sin(float.DegreesToRadians(angle)), + vector.X * -MathF.Sin(float.DegreesToRadians(angle)) + vector.Y * MathF.Cos(float.DegreesToRadians(angle)) + ); + } + /// /// Given a flip direction, a surrounding quad for all selected objects, and a position, /// will return the flipped position in screen space coordinates. /// + /// The direction to flip towards. + /// The quad surrounding all selected objects. The center of this determines the position of the axis. + /// The position to flip. public static Vector2 GetFlippedPosition(Direction direction, Quad quad, Vector2 position) { var centre = quad.Centre; @@ -58,6 +73,20 @@ namespace osu.Game.Utils return position; } + /// + /// Given a flip axis vector, a surrounding quad for all selected objects, and a position, + /// will return the flipped position in screen space coordinates. + /// + /// The vector indicating the direction to flip towards. This is perpendicular to the mirroring axis. + /// The quad surrounding all selected objects. The center of this determines the position of the axis. + /// The position to flip. + public static Vector2 GetFlippedPosition(Vector2 axis, Quad quad, Vector2 position) + { + var centre = quad.Centre; + + return position - 2 * Vector2.Dot(position - centre, axis) * axis; + } + /// /// Given a scale vector, a surrounding quad for all selected objects, and a position, /// will return the scaled position in screen space coordinates. @@ -78,6 +107,15 @@ namespace osu.Game.Utils return position; } + /// + /// Given a scale multiplier, an origin, and a position, + /// will return the scaled position in screen space coordinates. + /// + public static Vector2 GetScaledPosition(Vector2 scale, Vector2 origin, Vector2 position, float axisRotation = 0) + { + return origin + RotateVector(RotateVector(position - origin, axisRotation) * scale, -axisRotation); + } + /// /// Returns a quad surrounding the provided points. /// @@ -107,7 +145,67 @@ namespace osu.Game.Utils /// /// The hit objects to calculate a quad for. public static Quad GetSurroundingQuad(IEnumerable hitObjects) => - GetSurroundingQuad(hitObjects.SelectMany(h => + GetSurroundingQuad(enumerateStartAndEndPositions(hitObjects)); + + /// + /// Returns the points that make up the convex hull of the provided points. + /// + /// The points to calculate a convex hull. + public static List GetConvexHull(IEnumerable points) + { + var pointsList = points.OrderBy(p => p.X).ThenBy(p => p.Y).ToList(); + + if (pointsList.Count < 3) + return pointsList; + + var convexHullLower = new List + { + pointsList[0], + pointsList[1] + }; + var convexHullUpper = new List + { + pointsList[^1], + pointsList[^2] + }; + + // Build the lower hull. + for (int i = 2; i < pointsList.Count; i++) + { + Vector2 c = pointsList[i]; + while (convexHullLower.Count > 1 && isClockwise(convexHullLower[^2], convexHullLower[^1], c)) + convexHullLower.RemoveAt(convexHullLower.Count - 1); + + convexHullLower.Add(c); + } + + // Build the upper hull. + for (int i = pointsList.Count - 3; i >= 0; i--) + { + Vector2 c = pointsList[i]; + while (convexHullUpper.Count > 1 && isClockwise(convexHullUpper[^2], convexHullUpper[^1], c)) + convexHullUpper.RemoveAt(convexHullUpper.Count - 1); + + convexHullUpper.Add(c); + } + + convexHullLower.RemoveAt(convexHullLower.Count - 1); + convexHullUpper.RemoveAt(convexHullUpper.Count - 1); + + convexHullLower.AddRange(convexHullUpper); + + return convexHullLower; + + float crossProduct(Vector2 v1, Vector2 v2) => v1.X * v2.Y - v1.Y * v2.X; + + bool isClockwise(Vector2 a, Vector2 b, Vector2 c) => crossProduct(b - a, c - a) >= 0; + } + + public static List GetConvexHull(IEnumerable hitObjects) => + GetConvexHull(enumerateStartAndEndPositions(hitObjects)); + + private static IEnumerable enumerateStartAndEndPositions(IEnumerable hitObjects) => + hitObjects.SelectMany(h => { if (h is IHasPath path) { @@ -120,6 +218,159 @@ namespace osu.Game.Utils } return new[] { h.Position }; - })); + }); + + #region Welzl helpers + + // Function to check whether a point lies inside or on the boundaries of the circle + private static bool isInside((Vector2 Centre, float Radius) c, Vector2 p) + { + return Precision.AlmostBigger(c.Radius, Vector2.Distance(c.Centre, p)); + } + + // Function to return a unique circle that intersects three points + private static (Vector2, float) circleFrom(Vector2 a, Vector2 b, Vector2 c) + { + if (Precision.AlmostEquals(0, (b.Y - a.Y) * (c.X - a.X) - (b.X - a.X) * (c.Y - a.Y))) + return circleFrom(a, b); + + // See: https://en.wikipedia.org/wiki/Circumcircle#Cartesian_coordinates + float d = 2 * (a.X * (b - c).Y + b.X * (c - a).Y + c.X * (a - b).Y); + float aSq = a.LengthSquared; + float bSq = b.LengthSquared; + float cSq = c.LengthSquared; + + var centre = new Vector2( + aSq * (b - c).Y + bSq * (c - a).Y + cSq * (a - b).Y, + aSq * (c - b).X + bSq * (a - c).X + cSq * (b - a).X) / d; + + return (centre, Vector2.Distance(a, centre)); + } + + // Function to return the smallest circle that intersects 2 points + private static (Vector2, float) circleFrom(Vector2 a, Vector2 b) + { + var centre = (a + b) / 2.0f; + return (centre, Vector2.Distance(a, b) / 2.0f); + } + + // Function to check whether a circle encloses the given points + private static bool isValidCircle((Vector2, float) c, List points) + { + // Iterating through all the points to check whether the points lie inside the circle or not + foreach (Vector2 p in points) + { + if (!isInside(c, p)) return false; + } + + return true; + } + + // Function to return the minimum enclosing circle for N <= 3 + private static (Vector2, float) minCircleTrivial(List points) + { + if (points.Count > 3) + throw new ArgumentException("Number of points must be at most 3", nameof(points)); + + switch (points.Count) + { + case 0: + return (new Vector2(0, 0), 0); + + case 1: + return (points[0], 0); + + case 2: + return circleFrom(points[0], points[1]); + } + + // To check if MEC can be determined by 2 points only + for (int i = 0; i < 3; i++) + { + for (int j = i + 1; j < 3; j++) + { + var c = circleFrom(points[i], points[j]); + + if (isValidCircle(c, points)) + return c; + } + } + + return circleFrom(points[0], points[1], points[2]); + } + + #endregion + + /// + /// Function to find the minimum enclosing circle for a collection of points. + /// + /// A tuple containing the circle centre and radius. + public static (Vector2, float) MinimumEnclosingCircle(IEnumerable points) + { + // Using Welzl's algorithm to find the minimum enclosing circle + // https://www.geeksforgeeks.org/minimum-enclosing-circle-using-welzls-algorithm/ + List p = points.ToList(); + + var stack = new Stack<(Vector2?, int)>(); + var r = new List(3); + (Vector2, float) d = (Vector2.Zero, 0); + + stack.Push((null, p.Count)); + + while (stack.Count > 0) + { + // `n` represents the number of points in P that are not yet processed. + // `point` represents the point that was randomly picked to process. + (Vector2? point, int n) = stack.Pop(); + + if (!point.HasValue) + { + // Base case when all points processed or |R| = 3 + if (n == 0 || r.Count == 3) + { + d = minCircleTrivial(r); + continue; + } + + // Pick a random point randomly + int idx = RNG.Next(n); + point = p[idx]; + + // Put the picked point at the end of P since it's more efficient than + // deleting from the middle of the list + (p[idx], p[n - 1]) = (p[n - 1], p[idx]); + + // Schedule processing of p after we get the MEC circle d from the set of points P - {p} + stack.Push((point, n)); + // Get the MEC circle d from the set of points P - {p} + stack.Push((null, n - 1)); + } + else + { + // If d contains p, return d + if (isInside(d, point.Value)) + continue; + + // Remove points from R that were added in a deeper recursion + // |R| = |P| - |stack| - n + int removeCount = r.Count - (p.Count - stack.Count - n); + r.RemoveRange(r.Count - removeCount, removeCount); + + // Otherwise, must be on the boundary of the MEC + r.Add(point.Value); + // Return the MEC for P - {p} and R U {p} + stack.Push((null, n - 1)); + } + } + + return d; + } + + /// + /// Function to find the minimum enclosing circle for a collection of hit objects. + /// + /// A tuple containing the circle centre and radius. + public static (Vector2, float) MinimumEnclosingCircle(IEnumerable hitObjects) => + MinimumEnclosingCircle(enumerateStartAndEndPositions(hitObjects)); } } diff --git a/osu.Game/Utils/ModUtils.cs b/osu.Game/Utils/ModUtils.cs index 2c9eef41e3..f901f15388 100644 --- a/osu.Game/Utils/ModUtils.cs +++ b/osu.Game/Utils/ModUtils.cs @@ -276,5 +276,20 @@ namespace osu.Game.Utils return scoreMultiplier.ToLocalisableString("0.00x"); } + + /// + /// Calculate the rate for the song with the selected mods. + /// + /// The list of selected mods. + /// The rate with mods. + public static double CalculateRateWithMods(IEnumerable mods) + { + double rate = 1; + + foreach (var mod in mods.OfType()) + rate = mod.ApplyToRate(0, rate); + + return rate; + } } } diff --git a/osu.Game/Utils/Optional.cs b/osu.Game/Utils/Optional.cs index 301767ba08..f5749a513f 100644 --- a/osu.Game/Utils/Optional.cs +++ b/osu.Game/Utils/Optional.cs @@ -22,7 +22,7 @@ namespace osu.Game.Utils /// public readonly bool HasValue; - private Optional(T value) + public Optional(T value) { Value = value; HasValue = true; diff --git a/osu.Game/Utils/PeriodTracker.cs b/osu.Game/Utils/PeriodTracker.cs index ba77702247..2c62684ac4 100644 --- a/osu.Game/Utils/PeriodTracker.cs +++ b/osu.Game/Utils/PeriodTracker.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; namespace osu.Game.Utils @@ -24,8 +25,17 @@ namespace osu.Game.Utils /// Whether the provided time is in any of the added periods. /// /// The time value to check. - public bool IsInAny(double time) + public bool IsInAny(double time) => IsInAny(time, out _); + + /// + /// Whether the provided time is in any of the added periods. + /// + /// The time value to check. + /// The period which matched. + public bool IsInAny(double time, [NotNullWhen(true)] out Period? period) { + period = null; + if (periods.Count == 0) return false; @@ -41,7 +51,15 @@ namespace osu.Game.Utils } var nearest = periods[nearestIndex]; - return time >= nearest.Start && time <= nearest.End; + bool isInAny = time >= nearest.Start && time <= nearest.End; + + if (isInAny) + { + period = nearest; + return true; + } + + return false; } } @@ -57,6 +75,8 @@ namespace osu.Game.Utils /// public readonly double End; + public double Duration => End - Start; + public Period(double start, double end) { if (start >= end) diff --git a/osu.Game/Utils/SpecialFunctions.cs b/osu.Game/Utils/SpecialFunctions.cs new file mode 100644 index 0000000000..0b0f0598bb --- /dev/null +++ b/osu.Game/Utils/SpecialFunctions.cs @@ -0,0 +1,694 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +// All code is referenced from the following: +// https://github.com/mathnet/mathnet-numerics/blob/master/src/Numerics/SpecialFunctions/Erf.cs +// https://github.com/mathnet/mathnet-numerics/blob/master/src/Numerics/Optimization/NelderMeadSimplex.cs + +/* + Copyright (c) 2002-2022 Math.NET +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +using System; + +namespace osu.Game.Utils +{ + public class SpecialFunctions + { + private const double sqrt2_pi = 2.5066282746310005024157652848110452530069867406099d; + + /// + /// ************************************** + /// COEFFICIENTS FOR METHOD ErfImp * + /// ************************************** + /// + /// Polynomial coefficients for a numerator of ErfImp + /// calculation for Erf(x) in the interval [1e-10, 0.5]. + /// + private static readonly double[] erf_imp_an = { 0.00337916709551257388990745, -0.00073695653048167948530905, -0.374732337392919607868241, 0.0817442448733587196071743, -0.0421089319936548595203468, 0.0070165709512095756344528, -0.00495091255982435110337458, 0.000871646599037922480317225 }; + + /// Polynomial coefficients for a denominator of ErfImp + /// calculation for Erf(x) in the interval [1e-10, 0.5]. + /// + private static readonly double[] erf_imp_ad = { 1, -0.218088218087924645390535, 0.412542972725442099083918, -0.0841891147873106755410271, 0.0655338856400241519690695, -0.0120019604454941768171266, 0.00408165558926174048329689, -0.000615900721557769691924509 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [0.5, 0.75]. + /// + private static readonly double[] erf_imp_bn = { -0.0361790390718262471360258, 0.292251883444882683221149, 0.281447041797604512774415, 0.125610208862766947294894, 0.0274135028268930549240776, 0.00250839672168065762786937 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [0.5, 0.75]. + /// + private static readonly double[] erf_imp_bd = { 1, 1.8545005897903486499845, 1.43575803037831418074962, 0.582827658753036572454135, 0.124810476932949746447682, 0.0113724176546353285778481 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [0.75, 1.25]. + /// + private static readonly double[] erf_imp_cn = { -0.0397876892611136856954425, 0.153165212467878293257683, 0.191260295600936245503129, 0.10276327061989304213645, 0.029637090615738836726027, 0.0046093486780275489468812, 0.000307607820348680180548455 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [0.75, 1.25]. + /// + private static readonly double[] erf_imp_cd = { 1, 1.95520072987627704987886, 1.64762317199384860109595, 0.768238607022126250082483, 0.209793185936509782784315, 0.0319569316899913392596356, 0.00213363160895785378615014 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [1.25, 2.25]. + /// + private static readonly double[] erf_imp_dn = { -0.0300838560557949717328341, 0.0538578829844454508530552, 0.0726211541651914182692959, 0.0367628469888049348429018, 0.00964629015572527529605267, 0.00133453480075291076745275, 0.778087599782504251917881e-4 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [1.25, 2.25]. + /// + private static readonly double[] erf_imp_dd = { 1, 1.75967098147167528287343, 1.32883571437961120556307, 0.552528596508757581287907, 0.133793056941332861912279, 0.0179509645176280768640766, 0.00104712440019937356634038, -0.106640381820357337177643e-7 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [2.25, 3.5]. + /// + private static readonly double[] erf_imp_en = { -0.0117907570137227847827732, 0.014262132090538809896674, 0.0202234435902960820020765, 0.00930668299990432009042239, 0.00213357802422065994322516, 0.00025022987386460102395382, 0.120534912219588189822126e-4 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [2.25, 3.5]. + /// + private static readonly double[] erf_imp_ed = { 1, 1.50376225203620482047419, 0.965397786204462896346934, 0.339265230476796681555511, 0.0689740649541569716897427, 0.00771060262491768307365526, 0.000371421101531069302990367 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [3.5, 5.25]. + /// + private static readonly double[] erf_imp_fn = { -0.00546954795538729307482955, 0.00404190278731707110245394, 0.0054963369553161170521356, 0.00212616472603945399437862, 0.000394984014495083900689956, 0.365565477064442377259271e-4, 0.135485897109932323253786e-5 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [3.5, 5.25]. + /// + private static readonly double[] erf_imp_fd = { 1, 1.21019697773630784832251, 0.620914668221143886601045, 0.173038430661142762569515, 0.0276550813773432047594539, 0.00240625974424309709745382, 0.891811817251336577241006e-4, -0.465528836283382684461025e-11 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [5.25, 8]. + /// + private static readonly double[] erf_imp_gn = { -0.00270722535905778347999196, 0.0013187563425029400461378, 0.00119925933261002333923989, 0.00027849619811344664248235, 0.267822988218331849989363e-4, 0.923043672315028197865066e-6 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [5.25, 8]. + /// + private static readonly double[] erf_imp_gd = { 1, 0.814632808543141591118279, 0.268901665856299542168425, 0.0449877216103041118694989, 0.00381759663320248459168994, 0.000131571897888596914350697, 0.404815359675764138445257e-11 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [8, 11.5]. + /// + private static readonly double[] erf_imp_hn = { -0.00109946720691742196814323, 0.000406425442750422675169153, 0.000274499489416900707787024, 0.465293770646659383436343e-4, 0.320955425395767463401993e-5, 0.778286018145020892261936e-7 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [8, 11.5]. + /// + private static readonly double[] erf_imp_hd = { 1, 0.588173710611846046373373, 0.139363331289409746077541, 0.0166329340417083678763028, 0.00100023921310234908642639, 0.24254837521587225125068e-4 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [11.5, 17]. + /// + private static readonly double[] erf_imp_in = { -0.00056907993601094962855594, 0.000169498540373762264416984, 0.518472354581100890120501e-4, 0.382819312231928859704678e-5, 0.824989931281894431781794e-7 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [11.5, 17]. + /// + private static readonly double[] erf_imp_id = { 1, 0.339637250051139347430323, 0.043472647870310663055044, 0.00248549335224637114641629, 0.535633305337152900549536e-4, -0.117490944405459578783846e-12 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [17, 24]. + /// + private static readonly double[] erf_imp_jn = { -0.000241313599483991337479091, 0.574224975202501512365975e-4, 0.115998962927383778460557e-4, 0.581762134402593739370875e-6, 0.853971555085673614607418e-8 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [17, 24]. + /// + private static readonly double[] erf_imp_jd = { 1, 0.233044138299687841018015, 0.0204186940546440312625597, 0.000797185647564398289151125, 0.117019281670172327758019e-4 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [24, 38]. + /// + private static readonly double[] erf_imp_kn = { -0.000146674699277760365803642, 0.162666552112280519955647e-4, 0.269116248509165239294897e-5, 0.979584479468091935086972e-7, 0.101994647625723465722285e-8 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [24, 38]. + /// + private static readonly double[] erf_imp_kd = { 1, 0.165907812944847226546036, 0.0103361716191505884359634, 0.000286593026373868366935721, 0.298401570840900340874568e-5 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [38, 60]. + /// + private static readonly double[] erf_imp_ln = { -0.583905797629771786720406e-4, 0.412510325105496173512992e-5, 0.431790922420250949096906e-6, 0.993365155590013193345569e-8, 0.653480510020104699270084e-10 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [38, 60]. + /// + private static readonly double[] erf_imp_ld = { 1, 0.105077086072039915406159, 0.00414278428675475620830226, 0.726338754644523769144108e-4, 0.477818471047398785369849e-6 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [60, 85]. + /// + private static readonly double[] erf_imp_mn = { -0.196457797609229579459841e-4, 0.157243887666800692441195e-5, 0.543902511192700878690335e-7, 0.317472492369117710852685e-9 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [60, 85]. + /// + private static readonly double[] erf_imp_md = { 1, 0.052803989240957632204885, 0.000926876069151753290378112, 0.541011723226630257077328e-5, 0.535093845803642394908747e-15 }; + + /// Polynomial coefficients for a numerator in ErfImp + /// calculation for Erfc(x) in the interval [85, 110]. + /// + private static readonly double[] erf_imp_nn = { -0.789224703978722689089794e-5, 0.622088451660986955124162e-6, 0.145728445676882396797184e-7, 0.603715505542715364529243e-10 }; + + /// Polynomial coefficients for a denominator in ErfImp + /// calculation for Erfc(x) in the interval [85, 110]. + /// + private static readonly double[] erf_imp_nd = { 1, 0.0375328846356293715248719, 0.000467919535974625308126054, 0.193847039275845656900547e-5 }; + + /// + /// ************************************** + /// COEFFICIENTS FOR METHOD ErfInvImp * + /// ************************************** + /// + /// Polynomial coefficients for a numerator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0, 0.5]. + /// + private static readonly double[] erv_inv_imp_an = { -0.000508781949658280665617, -0.00836874819741736770379, 0.0334806625409744615033, -0.0126926147662974029034, -0.0365637971411762664006, 0.0219878681111168899165, 0.00822687874676915743155, -0.00538772965071242932965 }; + + /// Polynomial coefficients for a denominator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0, 0.5]. + /// + private static readonly double[] erv_inv_imp_ad = { 1, -0.970005043303290640362, -1.56574558234175846809, 1.56221558398423026363, 0.662328840472002992063, -0.71228902341542847553, -0.0527396382340099713954, 0.0795283687341571680018, -0.00233393759374190016776, 0.000886216390456424707504 }; + + /// Polynomial coefficients for a numerator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0.5, 0.75]. + /// + private static readonly double[] erv_inv_imp_bn = { -0.202433508355938759655, 0.105264680699391713268, 8.37050328343119927838, 17.6447298408374015486, -18.8510648058714251895, -44.6382324441786960818, 17.445385985570866523, 21.1294655448340526258, -3.67192254707729348546 }; + + /// Polynomial coefficients for a denominator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0.5, 0.75]. + /// + private static readonly double[] erv_inv_imp_bd = { 1, 6.24264124854247537712, 3.9713437953343869095, -28.6608180499800029974, -20.1432634680485188801, 48.5609213108739935468, 10.8268667355460159008, -22.6436933413139721736, 1.72114765761200282724 }; + + /// Polynomial coefficients for a numerator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0.75, 1] with x less than 3. + /// + private static readonly double[] erv_inv_imp_cn = { -0.131102781679951906451, -0.163794047193317060787, 0.117030156341995252019, 0.387079738972604337464, 0.337785538912035898924, 0.142869534408157156766, 0.0290157910005329060432, 0.00214558995388805277169, -0.679465575181126350155e-6, 0.285225331782217055858e-7, -0.681149956853776992068e-9 }; + + /// Polynomial coefficients for a denominator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0.75, 1] with x less than 3. + /// + private static readonly double[] erv_inv_imp_cd = { 1, 3.46625407242567245975, 5.38168345707006855425, 4.77846592945843778382, 2.59301921623620271374, 0.848854343457902036425, 0.152264338295331783612, 0.01105924229346489121 }; + + /// Polynomial coefficients for a numerator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0.75, 1] with x between 3 and 6. + /// + private static readonly double[] erv_inv_imp_dn = { -0.0350353787183177984712, -0.00222426529213447927281, 0.0185573306514231072324, 0.00950804701325919603619, 0.00187123492819559223345, 0.000157544617424960554631, 0.460469890584317994083e-5, -0.230404776911882601748e-9, 0.266339227425782031962e-11 }; + + /// Polynomial coefficients for a denominator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0.75, 1] with x between 3 and 6. + /// + private static readonly double[] erv_inv_imp_dd = { 1, 1.3653349817554063097, 0.762059164553623404043, 0.220091105764131249824, 0.0341589143670947727934, 0.00263861676657015992959, 0.764675292302794483503e-4 }; + + /// Polynomial coefficients for a numerator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0.75, 1] with x between 6 and 18. + /// + private static readonly double[] erv_inv_imp_en = { -0.0167431005076633737133, -0.00112951438745580278863, 0.00105628862152492910091, 0.000209386317487588078668, 0.149624783758342370182e-4, 0.449696789927706453732e-6, 0.462596163522878599135e-8, -0.281128735628831791805e-13, 0.99055709973310326855e-16 }; + + /// Polynomial coefficients for a denominator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0.75, 1] with x between 6 and 18. + /// + private static readonly double[] erv_inv_imp_ed = { 1, 0.591429344886417493481, 0.138151865749083321638, 0.0160746087093676504695, 0.000964011807005165528527, 0.275335474764726041141e-4, 0.282243172016108031869e-6 }; + + /// Polynomial coefficients for a numerator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0.75, 1] with x between 18 and 44. + /// + private static readonly double[] erv_inv_imp_fn = { -0.0024978212791898131227, -0.779190719229053954292e-5, 0.254723037413027451751e-4, 0.162397777342510920873e-5, 0.396341011304801168516e-7, 0.411632831190944208473e-9, 0.145596286718675035587e-11, -0.116765012397184275695e-17 }; + + /// Polynomial coefficients for a denominator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0.75, 1] with x between 18 and 44. + /// + private static readonly double[] erv_inv_imp_fd = { 1, 0.207123112214422517181, 0.0169410838120975906478, 0.000690538265622684595676, 0.145007359818232637924e-4, 0.144437756628144157666e-6, 0.509761276599778486139e-9 }; + + /// Polynomial coefficients for a numerator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0.75, 1] with x greater than 44. + /// + private static readonly double[] erv_inv_imp_gn = { -0.000539042911019078575891, -0.28398759004727721098e-6, 0.899465114892291446442e-6, 0.229345859265920864296e-7, 0.225561444863500149219e-9, 0.947846627503022684216e-12, 0.135880130108924861008e-14, -0.348890393399948882918e-21 }; + + /// Polynomial coefficients for a denominator of ErfInvImp + /// calculation for Erf^-1(z) in the interval [0.75, 1] with x greater than 44. + /// + private static readonly double[] erv_inv_imp_gd = { 1, 0.0845746234001899436914, 0.00282092984726264681981, 0.468292921940894236786e-4, 0.399968812193862100054e-6, 0.161809290887904476097e-8, 0.231558608310259605225e-11 }; + + /// Calculates the error function. + /// The value to evaluate. + /// the error function evaluated at given value. + /// + /// + /// returns 1 if x == double.PositiveInfinity. + /// returns -1 if x == double.NegativeInfinity. + /// + /// + public static double Erf(double x) + { + if (x == 0) + { + return 0; + } + + if (double.IsPositiveInfinity(x)) + { + return 1; + } + + if (double.IsNegativeInfinity(x)) + { + return -1; + } + + if (double.IsNaN(x)) + { + return double.NaN; + } + + return erfImp(x, false); + } + + /// Calculates the complementary error function. + /// The value to evaluate. + /// the complementary error function evaluated at given value. + /// + /// + /// returns 0 if x == double.PositiveInfinity. + /// returns 2 if x == double.NegativeInfinity. + /// + /// + public static double Erfc(double x) + { + if (x == 0) + { + return 1; + } + + if (double.IsPositiveInfinity(x)) + { + return 0; + } + + if (double.IsNegativeInfinity(x)) + { + return 2; + } + + if (double.IsNaN(x)) + { + return double.NaN; + } + + return erfImp(x, true); + } + + /// Calculates the inverse error function evaluated at z. + /// The inverse error function evaluated at given value. + /// + /// + /// returns double.PositiveInfinity if z >= 1.0. + /// returns double.NegativeInfinity if z <= -1.0. + /// + /// + /// Calculates the inverse error function evaluated at z. + /// value to evaluate. + /// the inverse error function evaluated at Z. + public static double ErfInv(double z) + { + if (z == 0.0) + { + return 0.0; + } + + if (z >= 1.0) + { + return double.PositiveInfinity; + } + + if (z <= -1.0) + { + return double.NegativeInfinity; + } + + double p, q, s; + + if (z < 0) + { + p = -z; + q = 1 - p; + s = -1; + } + else + { + p = z; + q = 1 - z; + s = 1; + } + + return erfInvImpl(p, q, s); + } + + /// + /// Implementation of the error function. + /// + /// Where to evaluate the error function. + /// Whether to compute 1 - the error function. + /// the error function. + private static double erfImp(double z, bool invert) + { + if (z < 0) + { + if (!invert) + { + return -erfImp(-z, false); + } + + if (z < -0.5) + { + return 2 - erfImp(-z, true); + } + + return 1 + erfImp(-z, false); + } + + double result; + + // Big bunch of selection statements now to pick which + // implementation to use, try to put most likely options + // first: + if (z < 0.5) + { + // We're going to calculate erf: + if (z < 1e-10) + { + result = (z * 1.125) + (z * 0.003379167095512573896158903121545171688); + } + else + { + // Worst case absolute error found: 6.688618532e-21 + result = (z * 1.125) + (z * evaluatePolynomial(z, erf_imp_an) / evaluatePolynomial(z, erf_imp_ad)); + } + } + else if (z < 110) + { + // We'll be calculating erfc: + invert = !invert; + double r, b; + + if (z < 0.75) + { + // Worst case absolute error found: 5.582813374e-21 + r = evaluatePolynomial(z - 0.5, erf_imp_bn) / evaluatePolynomial(z - 0.5, erf_imp_bd); + b = 0.3440242112F; + } + else if (z < 1.25) + { + // Worst case absolute error found: 4.01854729e-21 + r = evaluatePolynomial(z - 0.75, erf_imp_cn) / evaluatePolynomial(z - 0.75, erf_imp_cd); + b = 0.419990927F; + } + else if (z < 2.25) + { + // Worst case absolute error found: 2.866005373e-21 + r = evaluatePolynomial(z - 1.25, erf_imp_dn) / evaluatePolynomial(z - 1.25, erf_imp_dd); + b = 0.4898625016F; + } + else if (z < 3.5) + { + // Worst case absolute error found: 1.045355789e-21 + r = evaluatePolynomial(z - 2.25, erf_imp_en) / evaluatePolynomial(z - 2.25, erf_imp_ed); + b = 0.5317370892F; + } + else if (z < 5.25) + { + // Worst case absolute error found: 8.300028706e-22 + r = evaluatePolynomial(z - 3.5, erf_imp_fn) / evaluatePolynomial(z - 3.5, erf_imp_fd); + b = 0.5489973426F; + } + else if (z < 8) + { + // Worst case absolute error found: 1.700157534e-21 + r = evaluatePolynomial(z - 5.25, erf_imp_gn) / evaluatePolynomial(z - 5.25, erf_imp_gd); + b = 0.5571740866F; + } + else if (z < 11.5) + { + // Worst case absolute error found: 3.002278011e-22 + r = evaluatePolynomial(z - 8, erf_imp_hn) / evaluatePolynomial(z - 8, erf_imp_hd); + b = 0.5609807968F; + } + else if (z < 17) + { + // Worst case absolute error found: 6.741114695e-21 + r = evaluatePolynomial(z - 11.5, erf_imp_in) / evaluatePolynomial(z - 11.5, erf_imp_id); + b = 0.5626493692F; + } + else if (z < 24) + { + // Worst case absolute error found: 7.802346984e-22 + r = evaluatePolynomial(z - 17, erf_imp_jn) / evaluatePolynomial(z - 17, erf_imp_jd); + b = 0.5634598136F; + } + else if (z < 38) + { + // Worst case absolute error found: 2.414228989e-22 + r = evaluatePolynomial(z - 24, erf_imp_kn) / evaluatePolynomial(z - 24, erf_imp_kd); + b = 0.5638477802F; + } + else if (z < 60) + { + // Worst case absolute error found: 5.896543869e-24 + r = evaluatePolynomial(z - 38, erf_imp_ln) / evaluatePolynomial(z - 38, erf_imp_ld); + b = 0.5640528202F; + } + else if (z < 85) + { + // Worst case absolute error found: 3.080612264e-21 + r = evaluatePolynomial(z - 60, erf_imp_mn) / evaluatePolynomial(z - 60, erf_imp_md); + b = 0.5641309023F; + } + else + { + // Worst case absolute error found: 8.094633491e-22 + r = evaluatePolynomial(z - 85, erf_imp_nn) / evaluatePolynomial(z - 85, erf_imp_nd); + b = 0.5641584396F; + } + + double g = Math.Exp(-z * z) / z; + result = (g * b) + (g * r); + } + else + { + // Any value of z larger than 28 will underflow to zero: + result = 0; + invert = !invert; + } + + if (invert) + { + result = 1 - result; + } + + return result; + } + + /// Calculates the complementary inverse error function evaluated at z. + /// The complementary inverse error function evaluated at given value. + /// We have tested this implementation against the arbitrary precision mpmath library + /// and found cases where we can only guarantee 9 significant figures correct. + /// + /// returns double.PositiveInfinity if z <= 0.0. + /// returns double.NegativeInfinity if z >= 2.0. + /// + /// + /// calculates the complementary inverse error function evaluated at z. + /// value to evaluate. + /// the complementary inverse error function evaluated at Z. + public static double ErfcInv(double z) + { + if (z <= 0.0) + { + return double.PositiveInfinity; + } + + if (z >= 2.0) + { + return double.NegativeInfinity; + } + + double p, q, s; + + if (z > 1) + { + q = 2 - z; + p = 1 - q; + s = -1; + } + else + { + p = 1 - z; + q = z; + s = 1; + } + + return erfInvImpl(p, q, s); + } + + /// + /// The implementation of the inverse error function. + /// + /// First intermediate parameter. + /// Second intermediate parameter. + /// Third intermediate parameter. + /// the inverse error function. + private static double erfInvImpl(double p, double q, double s) + { + double result; + + if (p <= 0.5) + { + // Evaluate inverse erf using the rational approximation: + // + // x = p(p+10)(Y+R(p)) + // + // Where Y is a constant, and R(p) is optimized for a low + // absolute error compared to |Y|. + // + // double: Max error found: 2.001849e-18 + // long double: Max error found: 1.017064e-20 + // Maximum Deviation Found (actual error term at infinite precision) 8.030e-21 + const float y = 0.0891314744949340820313f; + double g = p * (p + 10); + double r = evaluatePolynomial(p, erv_inv_imp_an) / evaluatePolynomial(p, erv_inv_imp_ad); + result = (g * y) + (g * r); + } + else if (q >= 0.25) + { + // Rational approximation for 0.5 > q >= 0.25 + // + // x = sqrt(-2*log(q)) / (Y + R(q)) + // + // Where Y is a constant, and R(q) is optimized for a low + // absolute error compared to Y. + // + // double : Max error found: 7.403372e-17 + // long double : Max error found: 6.084616e-20 + // Maximum Deviation Found (error term) 4.811e-20 + const float y = 2.249481201171875f; + double g = Math.Sqrt(-2 * Math.Log(q)); + double xs = q - 0.25; + double r = evaluatePolynomial(xs, erv_inv_imp_bn) / evaluatePolynomial(xs, erv_inv_imp_bd); + result = g / (y + r); + } + else + { + // For q < 0.25 we have a series of rational approximations all + // of the general form: + // + // let: x = sqrt(-log(q)) + // + // Then the result is given by: + // + // x(Y+R(x-B)) + // + // where Y is a constant, B is the lowest value of x for which + // the approximation is valid, and R(x-B) is optimized for a low + // absolute error compared to Y. + // + // Note that almost all code will really go through the first + // or maybe second approximation. After than we're dealing with very + // small input values indeed: 80 and 128 bit long double's go all the + // way down to ~ 1e-5000 so the "tail" is rather long... + double x = Math.Sqrt(-Math.Log(q)); + + if (x < 3) + { + // Max error found: 1.089051e-20 + const float y = 0.807220458984375f; + double xs = x - 1.125; + double r = evaluatePolynomial(xs, erv_inv_imp_cn) / evaluatePolynomial(xs, erv_inv_imp_cd); + result = (y * x) + (r * x); + } + else if (x < 6) + { + // Max error found: 8.389174e-21 + const float y = 0.93995571136474609375f; + double xs = x - 3; + double r = evaluatePolynomial(xs, erv_inv_imp_dn) / evaluatePolynomial(xs, erv_inv_imp_dd); + result = (y * x) + (r * x); + } + else if (x < 18) + { + // Max error found: 1.481312e-19 + const float y = 0.98362827301025390625f; + double xs = x - 6; + double r = evaluatePolynomial(xs, erv_inv_imp_en) / evaluatePolynomial(xs, erv_inv_imp_ed); + result = (y * x) + (r * x); + } + else if (x < 44) + { + // Max error found: 5.697761e-20 + const float y = 0.99714565277099609375f; + double xs = x - 18; + double r = evaluatePolynomial(xs, erv_inv_imp_fn) / evaluatePolynomial(xs, erv_inv_imp_fd); + result = (y * x) + (r * x); + } + else + { + // Max error found: 1.279746e-20 + const float y = 0.99941349029541015625f; + double xs = x - 44; + double r = evaluatePolynomial(xs, erv_inv_imp_gn) / evaluatePolynomial(xs, erv_inv_imp_gd); + result = (y * x) + (r * x); + } + } + + return s * result; + } + + /// + /// Evaluate a polynomial at point x. + /// Coefficients are ordered ascending by power with power k at index k. + /// Example: coefficients [3,-1,2] represent y=2x^2-x+3. + /// + /// The location where to evaluate the polynomial at. + /// The coefficients of the polynomial, coefficient for power k at index k. + /// + /// is a null reference. + /// + private static double evaluatePolynomial(double z, params double[] coefficients) + { + // 2020-10-07 jbialogrodzki #730 Since this is public API we should probably + // handle null arguments? It doesn't seem to have been done consistently in this class though. + if (coefficients == null) + { + throw new ArgumentNullException(nameof(coefficients)); + } + + // 2020-10-07 jbialogrodzki #730 Zero polynomials need explicit handling. + // Without this check, we attempted to peek coefficients at negative indices! + int n = coefficients.Length; + + if (n == 0) + { + return 0; + } + + double sum = coefficients[n - 1]; + + for (int i = n - 2; i >= 0; --i) + { + sum *= z; + sum += coefficients[i]; + } + + return sum; + } + } +} diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index a336dff977..71e6fee78e 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -18,30 +18,30 @@ - + - + - - - - - - + + + + + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + - + - + diff --git a/osu.iOS.props b/osu.iOS.props index 6389172fe7..d4ef9a263f 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -16,13 +16,7 @@ false -all - - ios-arm64 - - - iossimulator-x64 - - + diff --git a/osu.iOS/Assets.xcassets/AppIcon.appiconset/300076680-5cbe0121-ed68-414f-9ddc-dd993ac97e62.png b/osu.iOS/Assets.xcassets/AppIcon.appiconset/300076680-5cbe0121-ed68-414f-9ddc-dd993ac97e62.png index 9287a71040..7b62835cdc 100644 Binary files a/osu.iOS/Assets.xcassets/AppIcon.appiconset/300076680-5cbe0121-ed68-414f-9ddc-dd993ac97e62.png and b/osu.iOS/Assets.xcassets/AppIcon.appiconset/300076680-5cbe0121-ed68-414f-9ddc-dd993ac97e62.png differ diff --git a/osu.iOS/OsuGameIOS.cs b/osu.iOS/OsuGameIOS.cs index 502f302157..2a4f9b87ac 100644 --- a/osu.iOS/OsuGameIOS.cs +++ b/osu.iOS/OsuGameIOS.cs @@ -15,7 +15,7 @@ namespace osu.iOS { public override Version AssemblyVersion => new Version(NSBundle.MainBundle.InfoDictionary["CFBundleVersion"].ToString()); - protected override UpdateManager CreateUpdateManager() => new SimpleUpdateManager(); + protected override UpdateManager CreateUpdateManager() => new MobileUpdateNotifier(); protected override BatteryInfo CreateBatteryInfo() => new IOSBatteryInfo(); diff --git a/osu.sln b/osu.sln index aeec0843be..829e43fc65 100644 --- a/osu.sln +++ b/osu.sln @@ -98,445 +98,157 @@ EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU - Debug|iPhone = Debug|iPhone - Debug|iPhoneSimulator = Debug|iPhoneSimulator Release|Any CPU = Release|Any CPU - Release|iPhone = Release|iPhone - Release|iPhoneSimulator = Release|iPhoneSimulator EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Debug|iPhone.Build.0 = Debug|Any CPU - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Release|Any CPU.ActiveCfg = Release|Any CPU {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Release|Any CPU.Build.0 = Release|Any CPU - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Release|iPhone.ActiveCfg = Release|Any CPU - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Release|iPhone.Build.0 = Release|Any CPU - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {C92A607B-1FDD-4954-9F92-03FF547D9080}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C92A607B-1FDD-4954-9F92-03FF547D9080}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C92A607B-1FDD-4954-9F92-03FF547D9080}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {C92A607B-1FDD-4954-9F92-03FF547D9080}.Debug|iPhone.Build.0 = Debug|Any CPU - {C92A607B-1FDD-4954-9F92-03FF547D9080}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {C92A607B-1FDD-4954-9F92-03FF547D9080}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {C92A607B-1FDD-4954-9F92-03FF547D9080}.Release|Any CPU.ActiveCfg = Release|Any CPU {C92A607B-1FDD-4954-9F92-03FF547D9080}.Release|Any CPU.Build.0 = Release|Any CPU - {C92A607B-1FDD-4954-9F92-03FF547D9080}.Release|iPhone.ActiveCfg = Release|Any CPU - {C92A607B-1FDD-4954-9F92-03FF547D9080}.Release|iPhone.Build.0 = Release|Any CPU - {C92A607B-1FDD-4954-9F92-03FF547D9080}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {C92A607B-1FDD-4954-9F92-03FF547D9080}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Debug|iPhone.Build.0 = Debug|Any CPU - {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Release|Any CPU.ActiveCfg = Release|Any CPU {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Release|Any CPU.Build.0 = Release|Any CPU - {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Release|iPhone.ActiveCfg = Release|Any CPU - {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Release|iPhone.Build.0 = Release|Any CPU - {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {F167E17A-7DE6-4AF5-B920-A5112296C695}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F167E17A-7DE6-4AF5-B920-A5112296C695}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F167E17A-7DE6-4AF5-B920-A5112296C695}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {F167E17A-7DE6-4AF5-B920-A5112296C695}.Debug|iPhone.Build.0 = Debug|Any CPU - {F167E17A-7DE6-4AF5-B920-A5112296C695}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {F167E17A-7DE6-4AF5-B920-A5112296C695}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {F167E17A-7DE6-4AF5-B920-A5112296C695}.Release|Any CPU.ActiveCfg = Release|Any CPU {F167E17A-7DE6-4AF5-B920-A5112296C695}.Release|Any CPU.Build.0 = Release|Any CPU - {F167E17A-7DE6-4AF5-B920-A5112296C695}.Release|iPhone.ActiveCfg = Release|Any CPU - {F167E17A-7DE6-4AF5-B920-A5112296C695}.Release|iPhone.Build.0 = Release|Any CPU - {F167E17A-7DE6-4AF5-B920-A5112296C695}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {F167E17A-7DE6-4AF5-B920-A5112296C695}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {48F4582B-7687-4621-9CBE-5C24197CB536}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {48F4582B-7687-4621-9CBE-5C24197CB536}.Debug|Any CPU.Build.0 = Debug|Any CPU - {48F4582B-7687-4621-9CBE-5C24197CB536}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {48F4582B-7687-4621-9CBE-5C24197CB536}.Debug|iPhone.Build.0 = Debug|Any CPU - {48F4582B-7687-4621-9CBE-5C24197CB536}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {48F4582B-7687-4621-9CBE-5C24197CB536}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {48F4582B-7687-4621-9CBE-5C24197CB536}.Release|Any CPU.ActiveCfg = Release|Any CPU {48F4582B-7687-4621-9CBE-5C24197CB536}.Release|Any CPU.Build.0 = Release|Any CPU - {48F4582B-7687-4621-9CBE-5C24197CB536}.Release|iPhone.ActiveCfg = Release|Any CPU - {48F4582B-7687-4621-9CBE-5C24197CB536}.Release|iPhone.Build.0 = Release|Any CPU - {48F4582B-7687-4621-9CBE-5C24197CB536}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {48F4582B-7687-4621-9CBE-5C24197CB536}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {54377672-20B1-40AF-8087-5CF73BF3953A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {54377672-20B1-40AF-8087-5CF73BF3953A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {54377672-20B1-40AF-8087-5CF73BF3953A}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {54377672-20B1-40AF-8087-5CF73BF3953A}.Debug|iPhone.Build.0 = Debug|Any CPU - {54377672-20B1-40AF-8087-5CF73BF3953A}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {54377672-20B1-40AF-8087-5CF73BF3953A}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {54377672-20B1-40AF-8087-5CF73BF3953A}.Release|Any CPU.ActiveCfg = Release|Any CPU {54377672-20B1-40AF-8087-5CF73BF3953A}.Release|Any CPU.Build.0 = Release|Any CPU - {54377672-20B1-40AF-8087-5CF73BF3953A}.Release|iPhone.ActiveCfg = Release|Any CPU - {54377672-20B1-40AF-8087-5CF73BF3953A}.Release|iPhone.Build.0 = Release|Any CPU - {54377672-20B1-40AF-8087-5CF73BF3953A}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {54377672-20B1-40AF-8087-5CF73BF3953A}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {419659FD-72EA-4678-9EB8-B22A746CED70}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {419659FD-72EA-4678-9EB8-B22A746CED70}.Debug|Any CPU.Build.0 = Debug|Any CPU - {419659FD-72EA-4678-9EB8-B22A746CED70}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {419659FD-72EA-4678-9EB8-B22A746CED70}.Debug|iPhone.Build.0 = Debug|Any CPU - {419659FD-72EA-4678-9EB8-B22A746CED70}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {419659FD-72EA-4678-9EB8-B22A746CED70}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {419659FD-72EA-4678-9EB8-B22A746CED70}.Release|Any CPU.ActiveCfg = Release|Any CPU {419659FD-72EA-4678-9EB8-B22A746CED70}.Release|Any CPU.Build.0 = Release|Any CPU - {419659FD-72EA-4678-9EB8-B22A746CED70}.Release|iPhone.ActiveCfg = Release|Any CPU - {419659FD-72EA-4678-9EB8-B22A746CED70}.Release|iPhone.Build.0 = Release|Any CPU - {419659FD-72EA-4678-9EB8-B22A746CED70}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {419659FD-72EA-4678-9EB8-B22A746CED70}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {3AD63355-D6B1-4365-8D31-5652C989BEF1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3AD63355-D6B1-4365-8D31-5652C989BEF1}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3AD63355-D6B1-4365-8D31-5652C989BEF1}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {3AD63355-D6B1-4365-8D31-5652C989BEF1}.Debug|iPhone.Build.0 = Debug|Any CPU - {3AD63355-D6B1-4365-8D31-5652C989BEF1}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {3AD63355-D6B1-4365-8D31-5652C989BEF1}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {3AD63355-D6B1-4365-8D31-5652C989BEF1}.Release|Any CPU.ActiveCfg = Release|Any CPU {3AD63355-D6B1-4365-8D31-5652C989BEF1}.Release|Any CPU.Build.0 = Release|Any CPU - {3AD63355-D6B1-4365-8D31-5652C989BEF1}.Release|iPhone.ActiveCfg = Release|Any CPU - {3AD63355-D6B1-4365-8D31-5652C989BEF1}.Release|iPhone.Build.0 = Release|Any CPU - {3AD63355-D6B1-4365-8D31-5652C989BEF1}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {3AD63355-D6B1-4365-8D31-5652C989BEF1}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {7E9E9C34-B204-406B-82E2-E01E900699CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7E9E9C34-B204-406B-82E2-E01E900699CD}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7E9E9C34-B204-406B-82E2-E01E900699CD}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {7E9E9C34-B204-406B-82E2-E01E900699CD}.Debug|iPhone.Build.0 = Debug|Any CPU - {7E9E9C34-B204-406B-82E2-E01E900699CD}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {7E9E9C34-B204-406B-82E2-E01E900699CD}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {7E9E9C34-B204-406B-82E2-E01E900699CD}.Release|Any CPU.ActiveCfg = Release|Any CPU {7E9E9C34-B204-406B-82E2-E01E900699CD}.Release|Any CPU.Build.0 = Release|Any CPU - {7E9E9C34-B204-406B-82E2-E01E900699CD}.Release|iPhone.ActiveCfg = Release|Any CPU - {7E9E9C34-B204-406B-82E2-E01E900699CD}.Release|iPhone.Build.0 = Release|Any CPU - {7E9E9C34-B204-406B-82E2-E01E900699CD}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {7E9E9C34-B204-406B-82E2-E01E900699CD}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {B698561F-FB28-46B1-857E-3CA7B92F9D70}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B698561F-FB28-46B1-857E-3CA7B92F9D70}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B698561F-FB28-46B1-857E-3CA7B92F9D70}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {B698561F-FB28-46B1-857E-3CA7B92F9D70}.Debug|iPhone.Build.0 = Debug|Any CPU - {B698561F-FB28-46B1-857E-3CA7B92F9D70}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {B698561F-FB28-46B1-857E-3CA7B92F9D70}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {B698561F-FB28-46B1-857E-3CA7B92F9D70}.Release|Any CPU.ActiveCfg = Release|Any CPU {B698561F-FB28-46B1-857E-3CA7B92F9D70}.Release|Any CPU.Build.0 = Release|Any CPU - {B698561F-FB28-46B1-857E-3CA7B92F9D70}.Release|iPhone.ActiveCfg = Release|Any CPU - {B698561F-FB28-46B1-857E-3CA7B92F9D70}.Release|iPhone.Build.0 = Release|Any CPU - {B698561F-FB28-46B1-857E-3CA7B92F9D70}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {B698561F-FB28-46B1-857E-3CA7B92F9D70}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {6A2D5D58-0261-4A75-BE84-2BE8B076B7C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6A2D5D58-0261-4A75-BE84-2BE8B076B7C2}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6A2D5D58-0261-4A75-BE84-2BE8B076B7C2}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {6A2D5D58-0261-4A75-BE84-2BE8B076B7C2}.Debug|iPhone.Build.0 = Debug|Any CPU - {6A2D5D58-0261-4A75-BE84-2BE8B076B7C2}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {6A2D5D58-0261-4A75-BE84-2BE8B076B7C2}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {6A2D5D58-0261-4A75-BE84-2BE8B076B7C2}.Release|Any CPU.ActiveCfg = Release|Any CPU {6A2D5D58-0261-4A75-BE84-2BE8B076B7C2}.Release|Any CPU.Build.0 = Release|Any CPU - {6A2D5D58-0261-4A75-BE84-2BE8B076B7C2}.Release|iPhone.ActiveCfg = Release|Any CPU - {6A2D5D58-0261-4A75-BE84-2BE8B076B7C2}.Release|iPhone.Build.0 = Release|Any CPU - {6A2D5D58-0261-4A75-BE84-2BE8B076B7C2}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {6A2D5D58-0261-4A75-BE84-2BE8B076B7C2}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {5672CA4D-1B37-425B-A118-A8DA26E78938}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5672CA4D-1B37-425B-A118-A8DA26E78938}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5672CA4D-1B37-425B-A118-A8DA26E78938}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {5672CA4D-1B37-425B-A118-A8DA26E78938}.Debug|iPhone.Build.0 = Debug|Any CPU - {5672CA4D-1B37-425B-A118-A8DA26E78938}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {5672CA4D-1B37-425B-A118-A8DA26E78938}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {5672CA4D-1B37-425B-A118-A8DA26E78938}.Release|Any CPU.ActiveCfg = Release|Any CPU {5672CA4D-1B37-425B-A118-A8DA26E78938}.Release|Any CPU.Build.0 = Release|Any CPU - {5672CA4D-1B37-425B-A118-A8DA26E78938}.Release|iPhone.ActiveCfg = Release|Any CPU - {5672CA4D-1B37-425B-A118-A8DA26E78938}.Release|iPhone.Build.0 = Release|Any CPU - {5672CA4D-1B37-425B-A118-A8DA26E78938}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {5672CA4D-1B37-425B-A118-A8DA26E78938}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {5789E78D-38F9-4072-AB7B-978F34B2C17F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5789E78D-38F9-4072-AB7B-978F34B2C17F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5789E78D-38F9-4072-AB7B-978F34B2C17F}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {5789E78D-38F9-4072-AB7B-978F34B2C17F}.Debug|iPhone.Build.0 = Debug|Any CPU - {5789E78D-38F9-4072-AB7B-978F34B2C17F}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {5789E78D-38F9-4072-AB7B-978F34B2C17F}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {5789E78D-38F9-4072-AB7B-978F34B2C17F}.Release|Any CPU.ActiveCfg = Release|Any CPU {5789E78D-38F9-4072-AB7B-978F34B2C17F}.Release|Any CPU.Build.0 = Release|Any CPU - {5789E78D-38F9-4072-AB7B-978F34B2C17F}.Release|iPhone.ActiveCfg = Release|Any CPU - {5789E78D-38F9-4072-AB7B-978F34B2C17F}.Release|iPhone.Build.0 = Release|Any CPU - {5789E78D-38F9-4072-AB7B-978F34B2C17F}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {5789E78D-38F9-4072-AB7B-978F34B2C17F}.Release|iPhoneSimulator.Build.0 = Release|Any CPU - {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Debug|Any CPU.ActiveCfg = Debug|iPhoneSimulator - {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Debug|iPhone.ActiveCfg = Debug|iPhone - {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Debug|iPhone.Build.0 = Debug|iPhone - {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Debug|iPhoneSimulator.ActiveCfg = Debug|iPhoneSimulator - {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Debug|iPhoneSimulator.Build.0 = Debug|iPhoneSimulator - {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Release|Any CPU.ActiveCfg = Release|iPhone - {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Release|iPhone.ActiveCfg = Release|iPhone - {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Release|iPhone.Build.0 = Release|iPhone - {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Release|iPhoneSimulator.ActiveCfg = Release|iPhoneSimulator - {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Release|iPhoneSimulator.Build.0 = Release|iPhoneSimulator - {65FF8E19-6934-469B-B690-23C6D6E56A17}.Debug|Any CPU.ActiveCfg = Debug|iPhoneSimulator - {65FF8E19-6934-469B-B690-23C6D6E56A17}.Debug|iPhone.ActiveCfg = Debug|iPhone - {65FF8E19-6934-469B-B690-23C6D6E56A17}.Debug|iPhone.Build.0 = Debug|iPhone - {65FF8E19-6934-469B-B690-23C6D6E56A17}.Debug|iPhoneSimulator.ActiveCfg = Debug|iPhoneSimulator - {65FF8E19-6934-469B-B690-23C6D6E56A17}.Debug|iPhoneSimulator.Build.0 = Debug|iPhoneSimulator - {65FF8E19-6934-469B-B690-23C6D6E56A17}.Release|Any CPU.ActiveCfg = Release|iPhone - {65FF8E19-6934-469B-B690-23C6D6E56A17}.Release|iPhone.ActiveCfg = Release|iPhone - {65FF8E19-6934-469B-B690-23C6D6E56A17}.Release|iPhone.Build.0 = Release|iPhone - {65FF8E19-6934-469B-B690-23C6D6E56A17}.Release|iPhoneSimulator.ActiveCfg = Release|iPhoneSimulator - {65FF8E19-6934-469B-B690-23C6D6E56A17}.Release|iPhoneSimulator.Build.0 = Release|iPhoneSimulator - {7E408809-66AC-49D1-AF4D-98834F9B979A}.Debug|Any CPU.ActiveCfg = Debug|iPhoneSimulator - {7E408809-66AC-49D1-AF4D-98834F9B979A}.Debug|iPhone.ActiveCfg = Debug|iPhone - {7E408809-66AC-49D1-AF4D-98834F9B979A}.Debug|iPhone.Build.0 = Debug|iPhone - {7E408809-66AC-49D1-AF4D-98834F9B979A}.Debug|iPhoneSimulator.ActiveCfg = Debug|iPhoneSimulator - {7E408809-66AC-49D1-AF4D-98834F9B979A}.Debug|iPhoneSimulator.Build.0 = Debug|iPhoneSimulator - {7E408809-66AC-49D1-AF4D-98834F9B979A}.Release|Any CPU.ActiveCfg = Release|iPhone - {7E408809-66AC-49D1-AF4D-98834F9B979A}.Release|iPhone.ActiveCfg = Release|iPhone - {7E408809-66AC-49D1-AF4D-98834F9B979A}.Release|iPhone.Build.0 = Release|iPhone - {7E408809-66AC-49D1-AF4D-98834F9B979A}.Release|iPhoneSimulator.ActiveCfg = Release|iPhoneSimulator - {7E408809-66AC-49D1-AF4D-98834F9B979A}.Release|iPhoneSimulator.Build.0 = Release|iPhoneSimulator - {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Debug|Any CPU.ActiveCfg = Debug|iPhoneSimulator - {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Debug|iPhone.ActiveCfg = Debug|iPhone - {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Debug|iPhone.Build.0 = Debug|iPhone - {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Debug|iPhoneSimulator.ActiveCfg = Debug|iPhoneSimulator - {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Debug|iPhoneSimulator.Build.0 = Debug|iPhoneSimulator - {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Release|Any CPU.ActiveCfg = Release|iPhone - {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Release|iPhone.ActiveCfg = Release|iPhone - {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Release|iPhone.Build.0 = Release|iPhone - {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Release|iPhoneSimulator.ActiveCfg = Release|iPhoneSimulator - {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Release|iPhoneSimulator.Build.0 = Release|iPhoneSimulator - {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Debug|Any CPU.ActiveCfg = Debug|iPhoneSimulator - {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Debug|iPhone.ActiveCfg = Debug|iPhone - {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Debug|iPhone.Build.0 = Debug|iPhone - {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Debug|iPhoneSimulator.ActiveCfg = Debug|iPhoneSimulator - {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Debug|iPhoneSimulator.Build.0 = Debug|iPhoneSimulator - {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Release|Any CPU.ActiveCfg = Release|iPhone - {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Release|iPhone.ActiveCfg = Release|iPhone - {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Release|iPhone.Build.0 = Release|iPhone - {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Release|iPhoneSimulator.ActiveCfg = Release|iPhoneSimulator - {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Release|iPhoneSimulator.Build.0 = Release|iPhoneSimulator - {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Debug|Any CPU.ActiveCfg = Debug|iPhoneSimulator - {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Debug|iPhone.ActiveCfg = Debug|iPhone - {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Debug|iPhone.Build.0 = Debug|iPhone - {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Debug|iPhoneSimulator.ActiveCfg = Debug|iPhoneSimulator - {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Debug|iPhoneSimulator.Build.0 = Debug|iPhoneSimulator - {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Release|Any CPU.ActiveCfg = Release|iPhone - {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Release|iPhone.ActiveCfg = Release|iPhone - {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Release|iPhone.Build.0 = Release|iPhone - {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Release|iPhoneSimulator.ActiveCfg = Release|iPhoneSimulator - {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Release|iPhoneSimulator.Build.0 = Release|iPhoneSimulator + {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Release|Any CPU.Build.0 = Release|Any CPU + {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {65FF8E19-6934-469B-B690-23C6D6E56A17}.Release|Any CPU.ActiveCfg = Release|Any CPU + {65FF8E19-6934-469B-B690-23C6D6E56A17}.Release|Any CPU.Build.0 = Release|Any CPU + {65FF8E19-6934-469B-B690-23C6D6E56A17}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {65FF8E19-6934-469B-B690-23C6D6E56A17}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7E408809-66AC-49D1-AF4D-98834F9B979A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7E408809-66AC-49D1-AF4D-98834F9B979A}.Release|Any CPU.Build.0 = Release|Any CPU + {7E408809-66AC-49D1-AF4D-98834F9B979A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7E408809-66AC-49D1-AF4D-98834F9B979A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Release|Any CPU.Build.0 = Release|Any CPU + {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Debug|Any CPU.Build.0 = Debug|Any CPU + {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Release|Any CPU.Build.0 = Release|Any CPU + {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Release|Any CPU.Build.0 = Release|Any CPU + {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Debug|Any CPU.Build.0 = Debug|Any CPU {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Debug|Any CPU.Build.0 = Debug|Any CPU {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Debug|Any CPU.Deploy.0 = Debug|Any CPU - {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Debug|iPhone.Build.0 = Debug|Any CPU - {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Debug|iPhone.Deploy.0 = Debug|Any CPU - {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU - {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Debug|iPhoneSimulator.Deploy.0 = Debug|Any CPU {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Release|Any CPU.ActiveCfg = Release|Any CPU {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Release|Any CPU.Build.0 = Release|Any CPU {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Release|Any CPU.Deploy.0 = Release|Any CPU - {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Release|iPhone.ActiveCfg = Release|Any CPU - {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Release|iPhone.Build.0 = Release|Any CPU - {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Release|iPhone.Deploy.0 = Release|Any CPU - {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Release|iPhoneSimulator.Build.0 = Release|Any CPU - {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Release|iPhoneSimulator.Deploy.0 = Release|Any CPU {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Debug|Any CPU.Build.0 = Debug|Any CPU {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Debug|Any CPU.Deploy.0 = Debug|Any CPU - {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Debug|iPhone.Build.0 = Debug|Any CPU - {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Debug|iPhone.Deploy.0 = Debug|Any CPU - {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU - {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Debug|iPhoneSimulator.Deploy.0 = Debug|Any CPU {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Release|Any CPU.ActiveCfg = Release|Any CPU {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Release|Any CPU.Build.0 = Release|Any CPU {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Release|Any CPU.Deploy.0 = Release|Any CPU - {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Release|iPhone.ActiveCfg = Release|Any CPU - {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Release|iPhone.Build.0 = Release|Any CPU - {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Release|iPhone.Deploy.0 = Release|Any CPU - {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Release|iPhoneSimulator.Build.0 = Release|Any CPU - {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Release|iPhoneSimulator.Deploy.0 = Release|Any CPU {531F1092-DB27-445D-AA33-2A77C7187C99}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {531F1092-DB27-445D-AA33-2A77C7187C99}.Debug|Any CPU.Build.0 = Debug|Any CPU {531F1092-DB27-445D-AA33-2A77C7187C99}.Debug|Any CPU.Deploy.0 = Debug|Any CPU - {531F1092-DB27-445D-AA33-2A77C7187C99}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {531F1092-DB27-445D-AA33-2A77C7187C99}.Debug|iPhone.Build.0 = Debug|Any CPU - {531F1092-DB27-445D-AA33-2A77C7187C99}.Debug|iPhone.Deploy.0 = Debug|Any CPU - {531F1092-DB27-445D-AA33-2A77C7187C99}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {531F1092-DB27-445D-AA33-2A77C7187C99}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU - {531F1092-DB27-445D-AA33-2A77C7187C99}.Debug|iPhoneSimulator.Deploy.0 = Debug|Any CPU {531F1092-DB27-445D-AA33-2A77C7187C99}.Release|Any CPU.ActiveCfg = Release|Any CPU {531F1092-DB27-445D-AA33-2A77C7187C99}.Release|Any CPU.Build.0 = Release|Any CPU {531F1092-DB27-445D-AA33-2A77C7187C99}.Release|Any CPU.Deploy.0 = Release|Any CPU - {531F1092-DB27-445D-AA33-2A77C7187C99}.Release|iPhone.ActiveCfg = Release|Any CPU - {531F1092-DB27-445D-AA33-2A77C7187C99}.Release|iPhone.Build.0 = Release|Any CPU - {531F1092-DB27-445D-AA33-2A77C7187C99}.Release|iPhone.Deploy.0 = Release|Any CPU - {531F1092-DB27-445D-AA33-2A77C7187C99}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {531F1092-DB27-445D-AA33-2A77C7187C99}.Release|iPhoneSimulator.Build.0 = Release|Any CPU - {531F1092-DB27-445D-AA33-2A77C7187C99}.Release|iPhoneSimulator.Deploy.0 = Release|Any CPU {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Debug|Any CPU.Build.0 = Debug|Any CPU {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Debug|Any CPU.Deploy.0 = Debug|Any CPU - {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Debug|iPhone.Build.0 = Debug|Any CPU - {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Debug|iPhone.Deploy.0 = Debug|Any CPU - {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU - {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Debug|iPhoneSimulator.Deploy.0 = Debug|Any CPU {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Release|Any CPU.ActiveCfg = Release|Any CPU {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Release|Any CPU.Build.0 = Release|Any CPU {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Release|Any CPU.Deploy.0 = Release|Any CPU - {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Release|iPhone.ActiveCfg = Release|Any CPU - {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Release|iPhone.Build.0 = Release|Any CPU - {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Release|iPhone.Deploy.0 = Release|Any CPU - {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Release|iPhoneSimulator.Build.0 = Release|Any CPU - {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Release|iPhoneSimulator.Deploy.0 = Release|Any CPU {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Debug|Any CPU.Build.0 = Debug|Any CPU {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Debug|Any CPU.Deploy.0 = Debug|Any CPU - {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Debug|iPhone.Build.0 = Debug|Any CPU - {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Debug|iPhone.Deploy.0 = Debug|Any CPU - {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU - {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Debug|iPhoneSimulator.Deploy.0 = Debug|Any CPU {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Release|Any CPU.ActiveCfg = Release|Any CPU {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Release|Any CPU.Build.0 = Release|Any CPU {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Release|Any CPU.Deploy.0 = Release|Any CPU - {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Release|iPhone.ActiveCfg = Release|Any CPU - {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Release|iPhone.Build.0 = Release|Any CPU - {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Release|iPhone.Deploy.0 = Release|Any CPU - {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Release|iPhoneSimulator.Build.0 = Release|Any CPU - {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Release|iPhoneSimulator.Deploy.0 = Release|Any CPU {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Debug|Any CPU.Build.0 = Debug|Any CPU {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Debug|Any CPU.Deploy.0 = Debug|Any CPU - {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Debug|iPhone.Build.0 = Debug|Any CPU - {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Debug|iPhone.Deploy.0 = Debug|Any CPU - {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU - {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Debug|iPhoneSimulator.Deploy.0 = Debug|Any CPU {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Release|Any CPU.ActiveCfg = Release|Any CPU {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Release|Any CPU.Build.0 = Release|Any CPU {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Release|Any CPU.Deploy.0 = Release|Any CPU - {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Release|iPhone.ActiveCfg = Release|Any CPU - {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Release|iPhone.Build.0 = Release|Any CPU - {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Release|iPhone.Deploy.0 = Release|Any CPU - {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Release|iPhoneSimulator.Build.0 = Release|Any CPU - {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Release|iPhoneSimulator.Deploy.0 = Release|Any CPU {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Debug|Any CPU.Build.0 = Debug|Any CPU - {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Debug|iPhone.Build.0 = Debug|Any CPU - {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Release|Any CPU.ActiveCfg = Release|Any CPU {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Release|Any CPU.Build.0 = Release|Any CPU - {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Release|iPhone.ActiveCfg = Release|Any CPU - {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Release|iPhone.Build.0 = Release|Any CPU - {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {9014CA66-5217-49F6-8C1E-3430FD08EF61}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9014CA66-5217-49F6-8C1E-3430FD08EF61}.Debug|Any CPU.Build.0 = Debug|Any CPU - {9014CA66-5217-49F6-8C1E-3430FD08EF61}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {9014CA66-5217-49F6-8C1E-3430FD08EF61}.Debug|iPhone.Build.0 = Debug|Any CPU - {9014CA66-5217-49F6-8C1E-3430FD08EF61}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {9014CA66-5217-49F6-8C1E-3430FD08EF61}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {9014CA66-5217-49F6-8C1E-3430FD08EF61}.Release|Any CPU.ActiveCfg = Release|Any CPU {9014CA66-5217-49F6-8C1E-3430FD08EF61}.Release|Any CPU.Build.0 = Release|Any CPU - {9014CA66-5217-49F6-8C1E-3430FD08EF61}.Release|iPhone.ActiveCfg = Release|Any CPU - {9014CA66-5217-49F6-8C1E-3430FD08EF61}.Release|iPhone.Build.0 = Release|Any CPU - {9014CA66-5217-49F6-8C1E-3430FD08EF61}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {9014CA66-5217-49F6-8C1E-3430FD08EF61}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {561DFD5E-5896-40D1-9708-4D692F5BAE66}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {561DFD5E-5896-40D1-9708-4D692F5BAE66}.Debug|Any CPU.Build.0 = Debug|Any CPU - {561DFD5E-5896-40D1-9708-4D692F5BAE66}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {561DFD5E-5896-40D1-9708-4D692F5BAE66}.Debug|iPhone.Build.0 = Debug|Any CPU - {561DFD5E-5896-40D1-9708-4D692F5BAE66}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {561DFD5E-5896-40D1-9708-4D692F5BAE66}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {561DFD5E-5896-40D1-9708-4D692F5BAE66}.Release|Any CPU.ActiveCfg = Release|Any CPU {561DFD5E-5896-40D1-9708-4D692F5BAE66}.Release|Any CPU.Build.0 = Release|Any CPU - {561DFD5E-5896-40D1-9708-4D692F5BAE66}.Release|iPhone.ActiveCfg = Release|Any CPU - {561DFD5E-5896-40D1-9708-4D692F5BAE66}.Release|iPhone.Build.0 = Release|Any CPU - {561DFD5E-5896-40D1-9708-4D692F5BAE66}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {561DFD5E-5896-40D1-9708-4D692F5BAE66}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {B325271C-85E7-4DB3-8BBB-B70F242954F8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B325271C-85E7-4DB3-8BBB-B70F242954F8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B325271C-85E7-4DB3-8BBB-B70F242954F8}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {B325271C-85E7-4DB3-8BBB-B70F242954F8}.Debug|iPhone.Build.0 = Debug|Any CPU - {B325271C-85E7-4DB3-8BBB-B70F242954F8}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {B325271C-85E7-4DB3-8BBB-B70F242954F8}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {B325271C-85E7-4DB3-8BBB-B70F242954F8}.Release|Any CPU.ActiveCfg = Release|Any CPU {B325271C-85E7-4DB3-8BBB-B70F242954F8}.Release|Any CPU.Build.0 = Release|Any CPU - {B325271C-85E7-4DB3-8BBB-B70F242954F8}.Release|iPhone.ActiveCfg = Release|Any CPU - {B325271C-85E7-4DB3-8BBB-B70F242954F8}.Release|iPhone.Build.0 = Release|Any CPU - {B325271C-85E7-4DB3-8BBB-B70F242954F8}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {B325271C-85E7-4DB3-8BBB-B70F242954F8}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {4C834F7F-07CA-46C7-8C7B-F10A1B3BC738}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4C834F7F-07CA-46C7-8C7B-F10A1B3BC738}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4C834F7F-07CA-46C7-8C7B-F10A1B3BC738}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {4C834F7F-07CA-46C7-8C7B-F10A1B3BC738}.Debug|iPhone.Build.0 = Debug|Any CPU - {4C834F7F-07CA-46C7-8C7B-F10A1B3BC738}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {4C834F7F-07CA-46C7-8C7B-F10A1B3BC738}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {4C834F7F-07CA-46C7-8C7B-F10A1B3BC738}.Release|Any CPU.ActiveCfg = Release|Any CPU {4C834F7F-07CA-46C7-8C7B-F10A1B3BC738}.Release|Any CPU.Build.0 = Release|Any CPU - {4C834F7F-07CA-46C7-8C7B-F10A1B3BC738}.Release|iPhone.ActiveCfg = Release|Any CPU - {4C834F7F-07CA-46C7-8C7B-F10A1B3BC738}.Release|iPhone.Build.0 = Release|Any CPU - {4C834F7F-07CA-46C7-8C7B-F10A1B3BC738}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {4C834F7F-07CA-46C7-8C7B-F10A1B3BC738}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {AD923016-F318-49B7-B08B-89DED6DC2422}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AD923016-F318-49B7-B08B-89DED6DC2422}.Debug|Any CPU.Build.0 = Debug|Any CPU - {AD923016-F318-49B7-B08B-89DED6DC2422}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {AD923016-F318-49B7-B08B-89DED6DC2422}.Debug|iPhone.Build.0 = Debug|Any CPU - {AD923016-F318-49B7-B08B-89DED6DC2422}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {AD923016-F318-49B7-B08B-89DED6DC2422}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {AD923016-F318-49B7-B08B-89DED6DC2422}.Release|Any CPU.ActiveCfg = Release|Any CPU {AD923016-F318-49B7-B08B-89DED6DC2422}.Release|Any CPU.Build.0 = Release|Any CPU - {AD923016-F318-49B7-B08B-89DED6DC2422}.Release|iPhone.ActiveCfg = Release|Any CPU - {AD923016-F318-49B7-B08B-89DED6DC2422}.Release|iPhone.Build.0 = Release|Any CPU - {AD923016-F318-49B7-B08B-89DED6DC2422}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {AD923016-F318-49B7-B08B-89DED6DC2422}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {B9B92246-02EB-4118-9C6F-85A0D726AA70}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B9B92246-02EB-4118-9C6F-85A0D726AA70}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B9B92246-02EB-4118-9C6F-85A0D726AA70}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {B9B92246-02EB-4118-9C6F-85A0D726AA70}.Debug|iPhone.Build.0 = Debug|Any CPU - {B9B92246-02EB-4118-9C6F-85A0D726AA70}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {B9B92246-02EB-4118-9C6F-85A0D726AA70}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {B9B92246-02EB-4118-9C6F-85A0D726AA70}.Release|Any CPU.ActiveCfg = Release|Any CPU {B9B92246-02EB-4118-9C6F-85A0D726AA70}.Release|Any CPU.Build.0 = Release|Any CPU - {B9B92246-02EB-4118-9C6F-85A0D726AA70}.Release|iPhone.ActiveCfg = Release|Any CPU - {B9B92246-02EB-4118-9C6F-85A0D726AA70}.Release|iPhone.Build.0 = Release|Any CPU - {B9B92246-02EB-4118-9C6F-85A0D726AA70}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {B9B92246-02EB-4118-9C6F-85A0D726AA70}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {B9022390-8184-4548-9DB1-50EB8878D20A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B9022390-8184-4548-9DB1-50EB8878D20A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B9022390-8184-4548-9DB1-50EB8878D20A}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {B9022390-8184-4548-9DB1-50EB8878D20A}.Debug|iPhone.Build.0 = Debug|Any CPU - {B9022390-8184-4548-9DB1-50EB8878D20A}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {B9022390-8184-4548-9DB1-50EB8878D20A}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {B9022390-8184-4548-9DB1-50EB8878D20A}.Release|Any CPU.ActiveCfg = Release|Any CPU {B9022390-8184-4548-9DB1-50EB8878D20A}.Release|Any CPU.Build.0 = Release|Any CPU - {B9022390-8184-4548-9DB1-50EB8878D20A}.Release|iPhone.ActiveCfg = Release|Any CPU - {B9022390-8184-4548-9DB1-50EB8878D20A}.Release|iPhone.Build.0 = Release|Any CPU - {B9022390-8184-4548-9DB1-50EB8878D20A}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {B9022390-8184-4548-9DB1-50EB8878D20A}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {1743BF7C-E6AE-4A06-BAD9-166D62894303}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1743BF7C-E6AE-4A06-BAD9-166D62894303}.Debug|Any CPU.Build.0 = Debug|Any CPU - {1743BF7C-E6AE-4A06-BAD9-166D62894303}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {1743BF7C-E6AE-4A06-BAD9-166D62894303}.Debug|iPhone.Build.0 = Debug|Any CPU - {1743BF7C-E6AE-4A06-BAD9-166D62894303}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {1743BF7C-E6AE-4A06-BAD9-166D62894303}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {1743BF7C-E6AE-4A06-BAD9-166D62894303}.Release|Any CPU.ActiveCfg = Release|Any CPU {1743BF7C-E6AE-4A06-BAD9-166D62894303}.Release|Any CPU.Build.0 = Release|Any CPU - {1743BF7C-E6AE-4A06-BAD9-166D62894303}.Release|iPhone.ActiveCfg = Release|Any CPU - {1743BF7C-E6AE-4A06-BAD9-166D62894303}.Release|iPhone.Build.0 = Release|Any CPU - {1743BF7C-E6AE-4A06-BAD9-166D62894303}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {1743BF7C-E6AE-4A06-BAD9-166D62894303}.Release|iPhoneSimulator.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/osu.sln.DotSettings b/osu.sln.DotSettings index dd71744bf0..ccd6db354b 100644 --- a/osu.sln.DotSettings +++ b/osu.sln.DotSettings @@ -20,6 +20,7 @@ WARNING WARNING True + DO_NOT_SHOW WARNING WARNING HINT @@ -68,7 +69,7 @@ DO_NOT_SHOW HINT WARNING - WARNING + HINT WARNING WARNING DO_NOT_SHOW @@ -82,7 +83,7 @@ WARNING WARNING HINT - HINT + DO_NOT_SHOW WARNING HINT DO_NOT_SHOW @@ -254,7 +255,7 @@ HINT DO_NOT_SHOW WARNING - HINT + DO_NOT_SHOW WARNING WARNING WARNING @@ -340,12 +341,14 @@ API ARGB BPM + DDKK EF FPS GC GL GLSL HID + HP HSL HSPA HSV @@ -357,6 +360,8 @@ IP IPC JIT + KDDK + KKDD LTRB MD5 NS @@ -375,6 +380,7 @@ QAT BNG UI + WIP False HINT <?xml version="1.0" encoding="utf-16"?> @@ -839,6 +845,7 @@ See the LICENCE file in the repository root for full licence text. True True True + True True True True @@ -846,6 +853,7 @@ See the LICENCE file in the repository root for full licence text. True True True + True True True True @@ -1053,5 +1061,6 @@ private void load() True True True + True True True