1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-18 18:29:58 +08:00

Compare commits

..

2130 Commits

1096 changed files with 30772 additions and 9835 deletions
+1 -1
View File
@@ -9,7 +9,7 @@
]
},
"nvika": {
"version": "3.0.0",
"version": "4.0.0",
"commands": [
"nvika"
]
+228
View File
@@ -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 }}"
+7 -4
View File
@@ -88,7 +88,7 @@ jobs:
# Attempt to upload results even if test fails.
# https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#always
- name: Upload Test Results
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: ${{ always() }}
with:
name: osu-test-results-${{matrix.os.prettyname}}-${{matrix.threadingMode}}
@@ -96,7 +96,7 @@ jobs:
build-only-android:
name: Build only (Android)
runs-on: windows-latest
runs-on: windows-2019
timeout-minutes: 60
steps:
- name: Checkout
@@ -114,7 +114,10 @@ jobs:
dotnet-version: "8.0.x"
- name: Install .NET workloads
run: dotnet workload install maui-android
# since windows image 20241113.3.0, not specifying a version here
# installs the .NET 7 version of android workload for very unknown reasons.
# revisit once we upgrade to .NET 9, it's probably fixed there.
run: dotnet workload install android --version (dotnet --version)
- name: Compile
run: dotnet build -c Debug osu.Android.slnf
@@ -133,7 +136,7 @@ jobs:
dotnet-version: "8.0.x"
- name: Install .NET Workloads
run: dotnet workload install maui-ios
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
+31 -221
View File
@@ -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 frenzibyte)
ALLOWED_USERS=(smoogipoo peppy bdach frenzibyte tsunyoku stanriders)
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 }}
+22 -15
View File
@@ -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'
-57
View File
@@ -1,57 +0,0 @@
# .NET Code Style
# IDE styles reference: https://docs.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/
# IDE0001: Simplify names
dotnet_diagnostic.IDE0001.severity = warning
# IDE0002: Simplify member access
dotnet_diagnostic.IDE0002.severity = warning
# IDE0003: Remove qualification
dotnet_diagnostic.IDE0003.severity = warning
# IDE0004: Remove unnecessary cast
dotnet_diagnostic.IDE0004.severity = warning
# IDE0005: Remove unnecessary imports
dotnet_diagnostic.IDE0005.severity = warning
# IDE0034: Simplify default literal
dotnet_diagnostic.IDE0034.severity = warning
# IDE0036: Sort modifiers
dotnet_diagnostic.IDE0036.severity = warning
# IDE0040: Add accessibility modifier
dotnet_diagnostic.IDE0040.severity = warning
# IDE0049: Use keyword for type name
dotnet_diagnostic.IDE0040.severity = warning
# IDE0055: Fix formatting
dotnet_diagnostic.IDE0055.severity = warning
# IDE0051: Private method is unused
dotnet_diagnostic.IDE0051.severity = silent
# IDE0052: Private member is unused
dotnet_diagnostic.IDE0052.severity = silent
# IDE0073: File header
dotnet_diagnostic.IDE0073.severity = warning
# IDE0130: Namespace mismatch with folder
dotnet_diagnostic.IDE0130.severity = warning
# IDE1006: Naming style
dotnet_diagnostic.IDE1006.severity = warning
#Disable operator overloads requiring alternate named methods
dotnet_diagnostic.CA2225.severity = none
# Banned APIs
dotnet_diagnostic.RS0030.severity = error
# Temporarily disable analysing CanBeNull = true in NRT contexts due to mobile issues.
# See: https://github.com/ppy/osu/pull/19677
dotnet_diagnostic.OSUF001.severity = none
+2 -1
View File
@@ -1,5 +1,6 @@
{
"recommendations": [
"ms-dotnettools.csharp"
"editorconfig.editorconfig",
"ms-dotnettools.csdevkit"
]
}
-4
View File
@@ -14,10 +14,6 @@ M:Realms.CollectionExtensions.SubscribeForNotifications`1(System.Collections.Gen
M:System.Threading.Tasks.Task.Wait();Don't use Task.Wait. Use Task.WaitSafely() to ensure we avoid deadlocks.
P:System.Threading.Tasks.Task`1.Result;Don't use Task.Result. Use Task.GetResultSafely() to ensure we avoid deadlocks.
M:System.Threading.ManualResetEventSlim.Wait();Specify a timeout to avoid waiting forever.
M:System.Char.ToLower(System.Char);char.ToLower() changes behaviour depending on CultureInfo.CurrentCulture. Use char.ToLowerInvariant() instead. If wanting culture-sensitive behaviour, explicitly provide CultureInfo.CurrentCulture.
M:System.Char.ToUpper(System.Char);char.ToUpper() changes behaviour depending on CultureInfo.CurrentCulture. Use char.ToUpperInvariant() instead. If wanting culture-sensitive behaviour, explicitly provide CultureInfo.CurrentCulture.
M:System.String.ToLower();string.ToLower() changes behaviour depending on CultureInfo.CurrentCulture. Use string.ToLowerInvariant() instead. If wanting culture-sensitive behaviour, explicitly provide CultureInfo.CurrentCulture or use LocalisableString.
M:System.String.ToUpper();string.ToUpper() changes behaviour depending on CultureInfo.CurrentCulture. Use string.ToUpperInvariant() instead. If wanting culture-sensitive behaviour, explicitly provide CultureInfo.CurrentCulture or use LocalisableString.
M:Humanizer.InflectorExtensions.Pascalize(System.String);Humanizer's .Pascalize() extension method changes behaviour depending on CultureInfo.CurrentCulture. Use StringDehumanizeExtensions.ToPascalCase() instead.
M:Humanizer.InflectorExtensions.Camelize(System.String);Humanizer's .Camelize() extension method changes behaviour depending on CultureInfo.CurrentCulture. Use StringDehumanizeExtensions.ToCamelCase() instead.
M:Humanizer.InflectorExtensions.Underscore(System.String);Humanizer's .Underscore() extension method changes behaviour depending on CultureInfo.CurrentCulture. Use StringDehumanizeExtensions.ToSnakeCase() instead.
+112
View File
@@ -0,0 +1,112 @@
# .NET Code Style
# IDE styles reference: https://docs.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/
is_global = true
# IDE0001: Simplify names
dotnet_diagnostic.IDE0001.severity = warning
# IDE0002: Simplify member access
dotnet_diagnostic.IDE0002.severity = warning
# IDE0003: Remove qualification
dotnet_diagnostic.IDE0003.severity = warning
# IDE0004: Remove unnecessary cast
dotnet_diagnostic.IDE0004.severity = warning
# IDE0005: Remove unnecessary imports
dotnet_diagnostic.IDE0005.severity = warning
# IDE0034: Simplify default literal
dotnet_diagnostic.IDE0034.severity = warning
# IDE0036: Sort modifiers
dotnet_diagnostic.IDE0036.severity = warning
# IDE0040: Add accessibility modifier
dotnet_diagnostic.IDE0040.severity = warning
# IDE0049: Use keyword for type name
dotnet_diagnostic.IDE0040.severity = warning
# IDE0055: Fix formatting
dotnet_diagnostic.IDE0055.severity = warning
# IDE0051: Private method is unused
dotnet_diagnostic.IDE0051.severity = silent
# IDE0052: Private member is unused
dotnet_diagnostic.IDE0052.severity = silent
# IDE0073: File header
dotnet_diagnostic.IDE0073.severity = warning
# IDE0130: Namespace mismatch with folder
dotnet_diagnostic.IDE0130.severity = warning
# IDE1006: Naming style
dotnet_diagnostic.IDE1006.severity = warning
# CA1305: Specify IFormatProvider
# Too many noisy warnings for parsing/formatting numbers
dotnet_diagnostic.CA1305.severity = none
# messagepack complains about "osu" not being title cased due to reserved words
dotnet_diagnostic.CS8981.severity = none
# CA1507: Use nameof to express symbol names
# Flags serialization name attributes
dotnet_diagnostic.CA1507.severity = suggestion
# CA1806: Do not ignore method results
# The usages for numeric parsing are explicitly optional
dotnet_diagnostic.CA1806.severity = suggestion
# CA1822: Mark members as static
# Potential false positive around reflection/too much noise
dotnet_diagnostic.CA1822.severity = none
# CA1826: Do not use Enumerable method on indexable collections
dotnet_diagnostic.CA1826.severity = suggestion
# CA1859: Use concrete types when possible for improved performance
# Involves design considerations
dotnet_diagnostic.CA1859.severity = suggestion
# CA1860: Avoid using 'Enumerable.Any()' extension method
dotnet_diagnostic.CA1860.severity = suggestion
# CA1861: Avoid constant arrays as arguments
# Outdated with collection expressions
dotnet_diagnostic.CA1861.severity = suggestion
# CA2007: Consider calling ConfigureAwait on the awaited task
dotnet_diagnostic.CA2007.severity = warning
# CA2016: Forward the 'CancellationToken' parameter to methods
# Some overloads are having special handling for debugger
dotnet_diagnostic.CA2016.severity = suggestion
# CA2021: Do not call Enumerable.Cast<T> or Enumerable.OfType<T> with incompatible types
# Causing a lot of false positives with generics
dotnet_diagnostic.CA2021.severity = none
# CA2101: Specify marshaling for P/Invoke string arguments
# Reports warning for all non-UTF16 usages on DllImport; consider migrating to LibraryImport
dotnet_diagnostic.CA2101.severity = none
# CA2201: Do not raise reserved exception types
dotnet_diagnostic.CA2201.severity = warning
# CA2208: Instantiate argument exceptions correctly
dotnet_diagnostic.CA2208.severity = suggestion
# CA2242: Test for NaN correctly
dotnet_diagnostic.CA2242.severity = warning
# Banned APIs
dotnet_diagnostic.RS0030.severity = error
# Temporarily disable analysing CanBeNull = true in NRT contexts due to mobile issues.
# See: https://github.com/ppy/osu/pull/19677
dotnet_diagnostic.OSUF001.severity = none
-58
View File
@@ -1,58 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<RuleSet Name="osu! Rule Set" Description=" " ToolsVersion="16.0">
<Rules AnalyzerId="Microsoft.CodeQuality.Analyzers" RuleNamespace="Microsoft.CodeQuality.Analyzers">
<Rule Id="CA1016" Action="None" />
<Rule Id="CA1028" Action="None" />
<Rule Id="CA1031" Action="None" />
<Rule Id="CA1034" Action="None" />
<Rule Id="CA1036" Action="None" />
<Rule Id="CA1040" Action="None" />
<Rule Id="CA1044" Action="None" />
<Rule Id="CA1051" Action="None" />
<Rule Id="CA1054" Action="None" />
<Rule Id="CA1056" Action="None" />
<Rule Id="CA1062" Action="None" />
<Rule Id="CA1063" Action="None" />
<Rule Id="CA1067" Action="None" />
<Rule Id="CA1707" Action="None" />
<Rule Id="CA1710" Action="None" />
<Rule Id="CA1714" Action="None" />
<Rule Id="CA1716" Action="None" />
<Rule Id="CA1717" Action="None" />
<Rule Id="CA1720" Action="None" />
<Rule Id="CA1721" Action="None" />
<Rule Id="CA1724" Action="None" />
<Rule Id="CA1801" Action="None" />
<Rule Id="CA1806" Action="None" />
<Rule Id="CA1812" Action="None" />
<Rule Id="CA1814" Action="None" />
<Rule Id="CA1815" Action="None" />
<Rule Id="CA1819" Action="None" />
<Rule Id="CA1822" Action="None" />
<Rule Id="CA1823" Action="None" />
<Rule Id="CA2007" Action="Warning" />
<Rule Id="CA2214" Action="None" />
<Rule Id="CA2227" Action="None" />
</Rules>
<Rules AnalyzerId="Microsoft.CodeQuality.CSharp.Analyzers" RuleNamespace="Microsoft.CodeQuality.CSharp.Analyzers">
<Rule Id="CA1001" Action="None" />
<Rule Id="CA1032" Action="None" />
</Rules>
<Rules AnalyzerId="Microsoft.NetCore.Analyzers" RuleNamespace="Microsoft.NetCore.Analyzers">
<Rule Id="CA1303" Action="None" />
<Rule Id="CA1304" Action="None" />
<Rule Id="CA1305" Action="None" />
<Rule Id="CA1307" Action="None" />
<Rule Id="CA1308" Action="None" />
<Rule Id="CA1816" Action="None" />
<Rule Id="CA1826" Action="None" />
<Rule Id="CA2000" Action="None" />
<Rule Id="CA2008" Action="None" />
<Rule Id="CA2213" Action="None" />
<Rule Id="CA2235" Action="None" />
</Rules>
<Rules AnalyzerId="Microsoft.NetCore.CSharp.Analyzers" RuleNamespace="Microsoft.NetCore.CSharp.Analyzers">
<Rule Id="CA1309" Action="Warning" />
<Rule Id="CA2201" Action="Warning" />
</Rules>
</RuleSet>
+13 -1
View File
@@ -18,9 +18,21 @@
<ItemGroup Label="Code Analysis">
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4" PrivateAssets="All" />
<AdditionalFiles Include="$(MSBuildThisFileDirectory)CodeAnalysis\BannedSymbols.txt" />
<!-- Rider compatibility: .globalconfig needs to be explicitly referenced instead of using the global file name. -->
<GlobalAnalyzerConfigFiles Include="$(MSBuildThisFileDirectory)CodeAnalysis\osu.globalconfig" />
</ItemGroup>
<PropertyGroup Label="Code Analysis">
<CodeAnalysisRuleSet>$(MSBuildThisFileDirectory)CodeAnalysis\osu.ruleset</CodeAnalysisRuleSet>
<AnalysisMode>Default</AnalysisMode>
<AnalysisModeDesign>Default</AnalysisModeDesign>
<AnalysisModeDocumentation>Recommended</AnalysisModeDocumentation>
<AnalysisModeGlobalization>Recommended</AnalysisModeGlobalization>
<AnalysisModeInteroperability>Recommended</AnalysisModeInteroperability>
<AnalysisModeMaintainability>Recommended</AnalysisModeMaintainability>
<AnalysisModeNaming>Default</AnalysisModeNaming>
<AnalysisModePerformance>Minimum</AnalysisModePerformance>
<AnalysisModeReliability>Recommended</AnalysisModeReliability>
<AnalysisModeSecurity>Default</AnalysisModeSecurity>
<AnalysisModeUsage>Default</AnalysisModeUsage>
</PropertyGroup>
<PropertyGroup Label="Documentation">
<GenerateDocumentationFile>true</GenerateDocumentationFile>
+2 -2
View File
@@ -37,7 +37,7 @@ You can also generally download a version for your current device from the [osu!
If your platform is unsupported or not listed above, there is still a chance you can run the release or manually build it by following the instructions below.
**For iOS/iPadOS users**: The iOS testflight link fills up very fast (Apple has a hard limit of 10,000 users). We reset it occasionally. Please do not ask about this. Check back regularly for link resets or follow [peppy](https://twitter.com/ppy) on twitter for announcements. Our goal is to get the game on mobile app stores in early 2024.
**For iOS/iPadOS users**: The iOS testflight link fills up very fast (Apple has a hard limit of 10,000 users). We reset it occasionally. Please do not ask about this. Check back regularly for link resets or follow [peppy](https://twitter.com/ppy) on twitter for announcements. Our goal is to get the game on mobile app stores very soon so we don't have to live with this limitation.
## Developing a custom ruleset
@@ -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
@@ -9,9 +9,9 @@
<GenerateProgramFile>false</GenerateProgramFile>
</PropertyGroup>
<ItemGroup Label="Package References">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="NUnit" Version="3.14.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\osu.Game.Rulesets.EmptyFreeform\osu.Game.Rulesets.EmptyFreeform.csproj" />
@@ -14,7 +14,16 @@ namespace osu.Game.Rulesets.EmptyFreeform.Objects
public Vector2 Position { get; set; }
public float X => Position.X;
public float Y => Position.Y;
public float X
{
get => Position.X;
set => Position = new Vector2(value, Y);
}
public float Y
{
get => Position.Y;
set => Position = new Vector2(X, value);
}
}
}
@@ -9,9 +9,9 @@
<GenerateProgramFile>false</GenerateProgramFile>
</PropertyGroup>
<ItemGroup Label="Package References">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="NUnit" Version="3.14.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj" />
@@ -14,7 +14,16 @@ namespace osu.Game.Rulesets.Pippidon.Objects
public Vector2 Position { get; set; }
public float X => Position.X;
public float Y => Position.Y;
public float X
{
get => Position.X;
set => Position = new Vector2(value, Y);
}
public float Y
{
get => Position.Y;
set => Position = new Vector2(X, value);
}
}
}
@@ -9,9 +9,9 @@
<GenerateProgramFile>false</GenerateProgramFile>
</PropertyGroup>
<ItemGroup Label="Package References">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="NUnit" Version="3.14.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\osu.Game.Rulesets.EmptyScrolling\osu.Game.Rulesets.EmptyScrolling.csproj" />
@@ -9,9 +9,9 @@
<GenerateProgramFile>false</GenerateProgramFile>
</PropertyGroup>
<ItemGroup Label="Package References">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="NUnit" Version="3.14.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj" />
+1 -1
View File
@@ -10,7 +10,7 @@
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Framework.Android" Version="2024.904.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2025.115.0" />
</ItemGroup>
<PropertyGroup>
<!-- Fody does not handle Android build well, and warns when unchanged.
+6 -6
View File
@@ -5,29 +5,29 @@ using Android.Content.PM;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Game;
using osu.Game.Screens.Play;
namespace osu.Android
{
public partial class GameplayScreenRotationLocker : Component
{
private Bindable<bool> localUserPlaying = null!;
private IBindable<LocalUserPlayingState> localUserPlaying = null!;
[Resolved]
private OsuGameActivity gameActivity { get; set; } = null!;
[BackgroundDependencyLoader]
private void load(OsuGame game)
private void load(ILocalUserPlayInfo localUserPlayInfo)
{
localUserPlaying = game.LocalUserPlaying.GetBoundCopy();
localUserPlaying = localUserPlayInfo.PlayingState.GetBoundCopy();
localUserPlaying.BindValueChanged(updateLock, true);
}
private void updateLock(ValueChangedEvent<bool> userPlaying)
private void updateLock(ValueChangedEvent<LocalUserPlayingState> userPlaying)
{
gameActivity.RunOnUiThread(() =>
{
gameActivity.RequestedOrientation = userPlaying.NewValue ? ScreenOrientation.Locked : gameActivity.DefaultOrientation;
gameActivity.RequestedOrientation = userPlaying.NewValue == LocalUserPlayingState.Playing ? ScreenOrientation.Locked : gameActivity.DefaultOrientation;
});
}
}
+36 -10
View File
@@ -13,7 +13,6 @@ using Android.Graphics;
using Android.OS;
using Android.Views;
using osu.Framework.Android;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Game.Database;
using Debug = System.Diagnostics.Debug;
using Uri = Android.Net.Uri;
@@ -50,9 +49,23 @@ namespace osu.Android
/// <remarks>Adjusted on startup to match expected UX for the current device type (phone/tablet).</remarks>
public ScreenOrientation DefaultOrientation = ScreenOrientation.Unspecified;
private OsuGameAndroid game = null!;
private readonly OsuGameAndroid game;
protected override Framework.Game CreateGame() => game = new OsuGameAndroid(this);
private bool gameCreated;
protected override Framework.Game CreateGame()
{
if (gameCreated)
throw new InvalidOperationException("Framework tried to create a game twice.");
gameCreated = true;
return game;
}
public OsuGameActivity()
{
game = new OsuGameAndroid(this);
}
protected override void OnCreate(Bundle? savedInstanceState)
{
@@ -95,25 +108,38 @@ namespace osu.Android
private void handleIntent(Intent? intent)
{
switch (intent?.Action)
if (intent == null)
return;
switch (intent.Action)
{
case Intent.ActionDefault:
if (intent.Scheme == ContentResolver.SchemeContent)
handleImportFromUris(intent.Data.AsNonNull());
{
if (intent.Data != null)
handleImportFromUris(intent.Data);
}
else if (osu_url_schemes.Contains(intent.Scheme))
game.HandleLink(intent.DataString);
{
if (intent.DataString != null)
game.HandleLink(intent.DataString);
}
break;
case Intent.ActionSend:
case Intent.ActionSendMultiple:
{
if (intent.ClipData == null)
break;
var uris = new List<Uri>();
for (int i = 0; i < intent.ClipData?.ItemCount; i++)
for (int i = 0; i < intent.ClipData.ItemCount; i++)
{
var content = intent.ClipData?.GetItemAt(i);
if (content != null)
uris.Add(content.Uri.AsNonNull());
var item = intent.ClipData.GetItemAt(i);
if (item?.Uri != null)
uris.Add(item.Uri);
}
handleImportFromUris(uris.ToArray());
+34 -46
View File
@@ -15,6 +15,7 @@ using osu.Framework.Threading;
using osu.Game;
using osu.Game.Configuration;
using osu.Game.Extensions;
using osu.Game.Online;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
@@ -48,11 +49,11 @@ namespace osu.Desktop
private MultiplayerClient multiplayerClient { get; set; } = null!;
[Resolved]
private OsuConfigManager config { get; set; } = null!;
private LocalUserStatisticsProvider statisticsProvider { get; set; } = null!;
private readonly IBindable<UserStatus?> status = new Bindable<UserStatus?>();
private readonly IBindable<UserActivity> activity = new Bindable<UserActivity>();
private readonly Bindable<DiscordRichPresenceMode> privacyMode = new Bindable<DiscordRichPresenceMode>();
private IBindable<DiscordRichPresenceMode> privacyMode = null!;
private IBindable<UserStatus> userStatus = null!;
private IBindable<UserActivity?> userActivity = null!;
private readonly RichPresence presence = new RichPresence
{
@@ -67,8 +68,12 @@ namespace osu.Desktop
private IBindable<APIUser>? user;
[BackgroundDependencyLoader]
private void load()
private void load(OsuConfigManager config, SessionStatics session)
{
privacyMode = config.GetBindable<DiscordRichPresenceMode>(OsuSetting.DiscordRichPresence);
userStatus = config.GetBindable<UserStatus>(OsuSetting.UserOnlineStatus);
userActivity = session.GetBindable<UserActivity?>(Static.UserOnlineActivity);
client = new DiscordRpcClient(client_id)
{
// SkipIdenticalPresence allows us to fire SetPresence at any point and leave it to the underlying implementation
@@ -77,7 +82,7 @@ namespace osu.Desktop
};
client.OnReady += onReady;
client.OnError += (_, e) => Logger.Log($"An error occurred with Discord RPC Client: {e.Message} ({e.Code})", LoggingTarget.Network, LogLevel.Error);
client.OnError += (_, e) => Logger.Log($"An error occurred with Discord RPC Client: {e.Message} ({e.Code})", LoggingTarget.Network);
try
{
@@ -101,23 +106,15 @@ namespace osu.Desktop
{
base.LoadComplete();
config.BindWith(OsuSetting.DiscordRichPresence, privacyMode);
user = api.LocalUser.GetBoundCopy();
user.BindValueChanged(u =>
{
status.UnbindBindings();
status.BindTo(u.NewValue.Status);
activity.UnbindBindings();
activity.BindTo(u.NewValue.Activity);
}, true);
ruleset.BindValueChanged(_ => schedulePresenceUpdate());
status.BindValueChanged(_ => schedulePresenceUpdate());
activity.BindValueChanged(_ => schedulePresenceUpdate());
userStatus.BindValueChanged(_ => schedulePresenceUpdate());
userActivity.BindValueChanged(_ => schedulePresenceUpdate());
privacyMode.BindValueChanged(_ => schedulePresenceUpdate());
multiplayerClient.RoomUpdated += onRoomUpdated;
statisticsProvider.StatisticsUpdated += onStatisticsUpdated;
}
private void onReady(object _, ReadyMessage __)
@@ -133,6 +130,8 @@ namespace osu.Desktop
private void onRoomUpdated() => schedulePresenceUpdate();
private void onStatisticsUpdated(UserStatisticsUpdate _) => schedulePresenceUpdate();
private ScheduledDelegate? presenceUpdateDelegate;
private void schedulePresenceUpdate()
@@ -143,13 +142,13 @@ namespace osu.Desktop
if (!client.IsInitialized)
return;
if (status.Value == UserStatus.Offline || privacyMode.Value == DiscordRichPresenceMode.Off)
if (!api.IsLoggedIn || userStatus.Value == UserStatus.Offline || privacyMode.Value == DiscordRichPresenceMode.Off)
{
client.ClearPresence();
return;
}
bool hideIdentifiableInformation = privacyMode.Value == DiscordRichPresenceMode.Limited || status.Value == UserStatus.DoNotDisturb;
bool hideIdentifiableInformation = privacyMode.Value == DiscordRichPresenceMode.Limited || userStatus.Value == UserStatus.DoNotDisturb;
updatePresence(hideIdentifiableInformation);
client.SetPresence(presence);
@@ -162,12 +161,12 @@ namespace osu.Desktop
return;
// user activity
if (activity.Value != null)
if (userActivity.Value != null)
{
presence.State = clampLength(activity.Value.GetStatus(hideIdentifiableInformation));
presence.Details = clampLength(activity.Value.GetDetails(hideIdentifiableInformation) ?? string.Empty);
presence.State = clampLength(userActivity.Value.GetStatus(hideIdentifiableInformation));
presence.Details = clampLength(userActivity.Value.GetDetails(hideIdentifiableInformation) ?? string.Empty);
if (getBeatmapID(activity.Value) is int beatmapId && beatmapId > 0)
if (userActivity.Value.GetBeatmapID(hideIdentifiableInformation) is int beatmapId && beatmapId > 0)
{
presence.Buttons = new[]
{
@@ -229,10 +228,8 @@ namespace osu.Desktop
presence.Assets.LargeImageText = string.Empty;
else
{
if (user.Value.RulesetsStatistics != null && user.Value.RulesetsStatistics.TryGetValue(ruleset.Value.ShortName, out UserStatistics? statistics))
presence.Assets.LargeImageText = $"{user.Value.Username}" + (statistics.GlobalRank > 0 ? $" (rank #{statistics.GlobalRank:N0})" : string.Empty);
else
presence.Assets.LargeImageText = $"{user.Value.Username}" + (user.Value.Statistics?.GlobalRank > 0 ? $" (rank #{user.Value.Statistics.GlobalRank:N0})" : string.Empty);
var statistics = statisticsProvider.GetStatisticsFor(ruleset.Value);
presence.Assets.LargeImageText = $"{user.Value.Username}" + (statistics?.GlobalRank > 0 ? $" (rank #{statistics.GlobalRank:N0})" : string.Empty);
}
// small image
@@ -279,10 +276,12 @@ namespace osu.Desktop
// As above, discord decides that *non-empty* strings shorter than 2 characters cannot possibly be valid input, because... reasons?
// And yes, that is two *characters*, or *codepoints*, not *bytes* as further down below (as determined by empirical testing).
// That seems very questionable, and isn't even documented anywhere. So to *make it* accept such valid input,
// just tack on enough of U+200B ZERO WIDTH SPACEs at the end.
if (str.Length < 2)
return str.PadRight(2, '\u200B');
// Also, spaces don't count. Because reasons, clearly.
// That all seems very questionable, and isn't even documented anywhere. So to *make it* accept such valid input,
// just tack on enough of U+200B ZERO WIDTH SPACEs at the end. After making sure to trim whitespace.
string trimmed = str.Trim();
if (trimmed.Length < 2)
return trimmed.PadRight(2, '\u200B');
if (Encoding.UTF8.GetByteCount(str) <= 128)
return str;
@@ -325,25 +324,14 @@ namespace osu.Desktop
return true;
}
private static int? getBeatmapID(UserActivity activity)
{
switch (activity)
{
case UserActivity.InGame game:
return game.BeatmapID;
case UserActivity.EditingBeatmap edit:
return edit.BeatmapID;
}
return null;
}
protected override void Dispose(bool isDisposing)
{
if (multiplayerClient.IsNotNull())
multiplayerClient.RoomUpdated -= onRoomUpdated;
if (statisticsProvider.IsNotNull())
statisticsProvider.StatisticsUpdated -= onStatisticsUpdated;
client.Dispose();
base.Dispose(isDisposing);
}
+32 -22
View File
@@ -141,12 +141,12 @@ namespace osu.Desktop
// Make sure that this is a laptop.
IntPtr[] gpus = new IntPtr[64];
if (checkError(EnumPhysicalGPUs(gpus, out int gpuCount)))
if (checkError(EnumPhysicalGPUs(gpus, out int gpuCount), nameof(EnumPhysicalGPUs)))
return false;
for (int i = 0; i < gpuCount; i++)
{
if (checkError(GetSystemType(gpus[i], out var type)))
if (checkError(GetSystemType(gpus[i], out var type), nameof(GetSystemType)))
return false;
if (type == NvSystemType.LAPTOP)
@@ -182,7 +182,7 @@ namespace osu.Desktop
bool success = setSetting(NvSettingID.OGL_THREAD_CONTROL_ID, (uint)value);
Logger.Log(success ? $"Threaded optimizations set to \"{value}\"!" : "Threaded optimizations set failed!");
Logger.Log(success ? $"[NVAPI] Threaded optimizations set to \"{value}\"!" : "[NVAPI] Threaded optimizations set failed!");
}
}
@@ -205,7 +205,7 @@ namespace osu.Desktop
uint numApps = profile.NumOfApps;
if (checkError(EnumApplications(sessionHandle, profileHandle, 0, ref numApps, applications)))
if (checkError(EnumApplications(sessionHandle, profileHandle, 0, ref numApps, applications), nameof(EnumApplications)))
return false;
for (uint i = 0; i < numApps; i++)
@@ -236,10 +236,10 @@ namespace osu.Desktop
isApplicationSpecific = true;
if (checkError(FindApplicationByName(sessionHandle, osu_filename, out profileHandle, ref application)))
if (checkError(FindApplicationByName(sessionHandle, osu_filename, out profileHandle, ref application), nameof(FindApplicationByName)))
{
isApplicationSpecific = false;
if (checkError(GetCurrentGlobalProfile(sessionHandle, out profileHandle)))
if (checkError(GetCurrentGlobalProfile(sessionHandle, out profileHandle), nameof(GetCurrentGlobalProfile)))
return false;
}
@@ -258,12 +258,10 @@ namespace osu.Desktop
Version = NvProfile.Stride,
IsPredefined = 0,
ProfileName = PROFILE_NAME,
GPUSupport = new uint[32]
GpuSupport = NvDrsGpuSupport.Geforce
};
newProfile.GPUSupport[0] = 1;
if (checkError(CreateProfile(sessionHandle, ref newProfile, out profileHandle)))
if (checkError(CreateProfile(sessionHandle, ref newProfile, out profileHandle), nameof(CreateProfile)))
return false;
return true;
@@ -284,7 +282,7 @@ namespace osu.Desktop
SettingID = settingId
};
if (checkError(GetSetting(sessionHandle, profileHandle, settingId, ref setting)))
if (checkError(GetSetting(sessionHandle, profileHandle, settingId, ref setting), nameof(GetSetting)))
return false;
return true;
@@ -313,7 +311,7 @@ namespace osu.Desktop
};
// Set the thread state
if (checkError(SetSetting(sessionHandle, profileHandle, ref newSetting)))
if (checkError(SetSetting(sessionHandle, profileHandle, ref newSetting), nameof(SetSetting)))
return false;
// Get the profile (needed to check app count)
@@ -321,7 +319,7 @@ namespace osu.Desktop
{
Version = NvProfile.Stride
};
if (checkError(GetProfileInfo(sessionHandle, profileHandle, ref profile)))
if (checkError(GetProfileInfo(sessionHandle, profileHandle, ref profile), nameof(GetProfileInfo)))
return false;
if (!containsApplication(profileHandle, profile, out application))
@@ -332,12 +330,12 @@ namespace osu.Desktop
application.AppName = osu_filename;
application.UserFriendlyName = APPLICATION_NAME;
if (checkError(CreateApplication(sessionHandle, profileHandle, ref application)))
if (checkError(CreateApplication(sessionHandle, profileHandle, ref application), nameof(CreateApplication)))
return false;
}
// Save!
return !checkError(SaveSettings(sessionHandle));
return !checkError(SaveSettings(sessionHandle), nameof(SaveSettings));
}
/// <summary>
@@ -346,20 +344,25 @@ namespace osu.Desktop
/// <returns>If the operation succeeded.</returns>
private static bool createSession()
{
if (checkError(CreateSession(out sessionHandle)))
if (checkError(CreateSession(out sessionHandle), nameof(CreateSession)))
return false;
// Load settings into session
if (checkError(LoadSettings(sessionHandle)))
if (checkError(LoadSettings(sessionHandle), nameof(LoadSettings)))
return false;
return true;
}
private static bool checkError(NvStatus status)
private static bool checkError(NvStatus status, string caller)
{
Status = status;
return status != NvStatus.OK;
bool hasError = status != NvStatus.OK;
if (hasError)
Logger.Log($"[NVAPI] {caller} call failed with status code {status}");
return hasError;
}
static NVAPI()
@@ -458,9 +461,7 @@ namespace osu.Desktop
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = NVAPI.UNICODE_STRING_MAX)]
public string ProfileName;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 32)]
public uint[] GPUSupport;
public NvDrsGpuSupport GpuSupport;
public uint IsPredefined;
public uint NumOfApps;
public uint NumOfSettings;
@@ -606,6 +607,7 @@ namespace osu.Desktop
SYNC_NOT_ACTIVE = -194, // The requested action cannot be performed without Sync being enabled.
SYNC_MASTER_NOT_FOUND = -195, // The requested action cannot be performed without Sync Master being enabled.
INVALID_SYNC_TOPOLOGY = -196, // Invalid displays passed in the NV_GSYNC_DISPLAY pointer.
ECID_SIGN_ALGO_UNSUPPORTED = -197, // The specified signing algorithm is not supported. Either an incorrect value was entered or the current installed driver/hardware does not support the input value.
ECID_KEY_VERIFICATION_FAILED = -198, // The encrypted public key verification has failed.
FIRMWARE_OUT_OF_DATE = -199, // The device's firmware is out of date.
@@ -744,4 +746,12 @@ namespace osu.Desktop
OGL_THREAD_CONTROL_NUM_VALUES = 2,
OGL_THREAD_CONTROL_DEFAULT = 0
}
[Flags]
internal enum NvDrsGpuSupport : uint
{
Geforce = 1 << 0,
Quadro = 1 << 1,
Nvs = 1 << 2
}
}
+11 -7
View File
@@ -67,7 +67,12 @@ namespace osu.Desktop
{
try
{
stableInstallPath = getStableInstallPathFromRegistry();
stableInstallPath = getStableInstallPathFromRegistry("osustable.File.osz");
if (!string.IsNullOrEmpty(stableInstallPath) && checkExists(stableInstallPath))
return stableInstallPath;
stableInstallPath = getStableInstallPathFromRegistry("osu!");
if (!string.IsNullOrEmpty(stableInstallPath) && checkExists(stableInstallPath))
return stableInstallPath;
@@ -89,17 +94,17 @@ namespace osu.Desktop
}
[SupportedOSPlatform("windows")]
private string? getStableInstallPathFromRegistry()
private string? getStableInstallPathFromRegistry(string progId)
{
using (RegistryKey? key = Registry.ClassesRoot.OpenSubKey("osu!"))
using (RegistryKey? key = Registry.ClassesRoot.OpenSubKey(progId))
return key?.OpenSubKey(WindowsAssociationManager.SHELL_OPEN_COMMAND)?.GetValue(string.Empty)?.ToString()?.Split('"')[1].Replace("osu!.exe", "");
}
public static bool IsPackageManaged => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("OSU_EXTERNAL_UPDATE_PROVIDER"));
protected override UpdateManager CreateUpdateManager()
{
string? packageManaged = Environment.GetEnvironmentVariable("OSU_EXTERNAL_UPDATE_PROVIDER");
if (!string.IsNullOrEmpty(packageManaged))
if (IsPackageManaged)
return new NoActionUpdateManager();
return new VelopackUpdateManager();
@@ -134,7 +139,6 @@ namespace osu.Desktop
if (iconStream != null)
host.Window.SetIconFromStream(iconStream);
host.Window.CursorState |= CursorState.Hidden;
host.Window.Title = Name;
}
+22 -7
View File
@@ -3,6 +3,7 @@
using System;
using System.IO;
using System.Runtime.Versioning;
using osu.Desktop.LegacyIpc;
using osu.Desktop.Windows;
using osu.Framework;
@@ -98,7 +99,7 @@ namespace osu.Desktop
var hostOptions = new HostOptions
{
IPCPort = !tournamentClient ? OsuGame.IPC_PORT : null,
IPCPipeName = !tournamentClient ? OsuGame.IPC_PIPE_NAME : null,
FriendlyGameName = OsuGameBase.GAME_NAME,
};
@@ -168,12 +169,26 @@ namespace osu.Desktop
private static void setupVelopack()
{
VelopackApp
.Build()
.WithFirstRun(v =>
{
if (OperatingSystem.IsWindows()) WindowsAssociationManager.InstallAssociations();
}).Run();
if (OsuGameDesktop.IsPackageManaged)
{
Logger.Log("Updates are being managed by an external provider. Skipping Velopack setup.");
return;
}
var app = VelopackApp.Build();
if (OperatingSystem.IsWindows())
configureWindows(app);
app.Run();
}
[SupportedOSPlatform("windows")]
private static void configureWindows(VelopackApp app)
{
app.WithFirstRun(_ => WindowsAssociationManager.InstallAssociations());
app.WithAfterUpdateFastCallback(_ => WindowsAssociationManager.UpdateAssociations());
app.WithBeforeUninstallFastCallback(_ => WindowsAssociationManager.UninstallAssociations());
}
}
}
+41 -26
View File
@@ -25,6 +25,8 @@ namespace osu.Desktop.Updater
[Resolved]
private ILocalUserPlayInfo? localUserInfo { get; set; }
private bool isInGameplay => localUserInfo?.PlayingState.Value != LocalUserPlayingState.NotPlaying;
private UpdateInfo? pendingUpdate;
public VelopackUpdateManager()
@@ -43,16 +45,19 @@ namespace osu.Desktop.Updater
protected override async Task<bool> PerformUpdateCheck() => await checkForUpdateAsync().ConfigureAwait(false);
private async Task<bool> checkForUpdateAsync(UpdateProgressNotification? notification = null)
private async Task<bool> checkForUpdateAsync()
{
// should we schedule a retry on completion of this check?
bool scheduleRecheck = true;
// whether to check again in 30 minutes. generally only if there's an error or no update was found (yet).
bool scheduleRecheck = false;
try
{
// Avoid any kind of update checking while gameplay is running.
if (localUserInfo?.IsPlaying.Value == true)
return false;
if (isInGameplay)
{
scheduleRecheck = true;
return true;
}
// TODO: we should probably be checking if there's a more recent update, rather than shortcutting here.
// Velopack does support this scenario (see https://github.com/ppy/osu/pull/28743#discussion_r1743495975).
@@ -63,42 +68,45 @@ namespace osu.Desktop.Updater
{
Activated = () =>
{
restartToApplyUpdate();
Task.Run(restartToApplyUpdate);
return true;
}
});
return true;
}
pendingUpdate = await updateManager.CheckForUpdatesAsync().ConfigureAwait(false);
// Handle no updates available.
// No update is available. We'll check again later.
if (pendingUpdate == null)
return false;
scheduleRecheck = false;
if (notification == null)
{
notification = new UpdateProgressNotification
{
CompletionClickAction = restartToApplyUpdate,
};
Schedule(() => notificationOverlay.Post(notification));
scheduleRecheck = true;
return false;
}
// An update is found, let's notify the user and start downloading it.
UpdateProgressNotification notification = new UpdateProgressNotification
{
CompletionClickAction = () =>
{
Task.Run(restartToApplyUpdate);
return true;
},
};
runOutsideOfGameplay(() => notificationOverlay.Post(notification));
notification.StartDownload();
try
{
await updateManager.DownloadUpdatesAsync(pendingUpdate, p => notification.Progress = p / 100f).ConfigureAwait(false);
notification.State = ProgressNotificationState.Completed;
runOutsideOfGameplay(() => notification.State = ProgressNotificationState.Completed);
}
catch (Exception e)
{
// In the case of an error, a separate notification will be displayed.
scheduleRecheck = true;
notification.FailDownload();
Logger.Error(e, @"update failed!");
}
@@ -113,7 +121,6 @@ namespace osu.Desktop.Updater
{
if (scheduleRecheck)
{
// check again in 30 minutes.
Scheduler.AddDelayed(() => Task.Run(async () => await checkForUpdateAsync().ConfigureAwait(false)), 60000 * 30);
}
}
@@ -121,13 +128,21 @@ namespace osu.Desktop.Updater
return true;
}
private bool restartToApplyUpdate()
private void runOutsideOfGameplay(Action action)
{
// TODO: Migrate this to async flow whenever available (see https://github.com/ppy/osu/pull/28743#discussion_r1740505665).
// Currently there's an internal Thread.Sleep(300) which will cause a stutter when the user clicks to restart.
updateManager.WaitExitThenApplyUpdates(pendingUpdate?.TargetFullRelease);
if (isInGameplay)
{
Scheduler.AddDelayed(() => runOutsideOfGameplay(action), 1000);
return;
}
action();
}
private async Task restartToApplyUpdate()
{
await updateManager.WaitExitThenApplyUpdatesAsync(pendingUpdate?.TargetFullRelease).ConfigureAwait(false);
Schedule(() => game.AttemptExit());
return true;
}
}
}
+3 -3
View File
@@ -13,7 +13,7 @@ namespace osu.Desktop.Windows
public partial class GameplayWinKeyBlocker : Component
{
private Bindable<bool> disableWinKey = null!;
private IBindable<bool> localUserPlaying = null!;
private IBindable<LocalUserPlayingState> localUserPlaying = null!;
private IBindable<bool> isActive = null!;
[Resolved]
@@ -22,7 +22,7 @@ namespace osu.Desktop.Windows
[BackgroundDependencyLoader]
private void load(ILocalUserPlayInfo localUserInfo, OsuConfigManager config)
{
localUserPlaying = localUserInfo.IsPlaying.GetBoundCopy();
localUserPlaying = localUserInfo.PlayingState.GetBoundCopy();
localUserPlaying.BindValueChanged(_ => updateBlocking());
isActive = host.IsActive.GetBoundCopy();
@@ -34,7 +34,7 @@ namespace osu.Desktop.Windows
private void updateBlocking()
{
bool shouldDisable = isActive.Value && disableWinKey.Value && localUserPlaying.Value;
bool shouldDisable = isActive.Value && disableWinKey.Value && localUserPlaying.Value == LocalUserPlayingState.Playing;
if (shouldDisable)
host.InputThread.Scheduler.Add(WindowsKey.Disable);
+2
View File
@@ -13,5 +13,7 @@ namespace osu.Desktop.Windows
private static readonly string icon_directory = Path.GetDirectoryName(typeof(Icons).Assembly.Location)!;
public static string Lazer => Path.Join(icon_directory, "lazer.ico");
public static string Beatmap => Path.Join(icon_directory, "beatmap.ico");
}
}
+153 -58
View File
@@ -17,6 +17,7 @@ namespace osu.Desktop.Windows
public static class WindowsAssociationManager
{
private const string software_classes = @"Software\Classes";
private const string software_registered_applications = @"Software\RegisteredApplications";
/// <summary>
/// Sub key for setting the icon.
@@ -36,14 +37,18 @@ namespace osu.Desktop.Windows
/// Program ID prefix used for file associations. Should be relatively short since the full program ID has a 39 character limit,
/// see https://learn.microsoft.com/en-us/windows/win32/com/-progid--key.
/// </summary>
private const string program_id_prefix = "osu.File";
private const string program_id_file_prefix = "osu.File";
private const string program_id_protocol_prefix = "osu.Uri";
private static readonly ApplicationCapability application_capability = new ApplicationCapability(@"osu", @"Software\ppy\osu\Capabilities", "osu!(lazer)");
private static readonly FileAssociation[] file_associations =
{
new FileAssociation(@".osz", WindowsAssociationManagerStrings.OsuBeatmap, Icons.Lazer),
new FileAssociation(@".olz", WindowsAssociationManagerStrings.OsuBeatmap, Icons.Lazer),
new FileAssociation(@".osr", WindowsAssociationManagerStrings.OsuReplay, Icons.Lazer),
new FileAssociation(@".osk", WindowsAssociationManagerStrings.OsuSkin, Icons.Lazer),
new FileAssociation(@".osz", WindowsAssociationManagerStrings.OsuBeatmap, Icons.Beatmap),
new FileAssociation(@".olz", WindowsAssociationManagerStrings.OsuBeatmap, Icons.Beatmap),
new FileAssociation(@".osr", WindowsAssociationManagerStrings.OsuReplay, Icons.Beatmap),
new FileAssociation(@".osk", WindowsAssociationManagerStrings.OsuSkin, Icons.Beatmap),
};
private static readonly UriAssociation[] uri_associations =
@@ -56,14 +61,13 @@ namespace osu.Desktop.Windows
/// Installs file and URI associations.
/// </summary>
/// <remarks>
/// Call <see cref="UpdateDescriptions"/> in a timely fashion to keep descriptions up-to-date and localised.
/// Call <see cref="LocaliseDescriptions"/> in a timely fashion to keep descriptions up-to-date and localised.
/// </remarks>
public static void InstallAssociations()
{
try
{
updateAssociations();
updateDescriptions(null); // write default descriptions in case `UpdateDescriptions()` is not called.
NotifyShellUpdate();
}
catch (Exception e)
@@ -76,17 +80,13 @@ namespace osu.Desktop.Windows
/// Updates associations with latest definitions.
/// </summary>
/// <remarks>
/// Call <see cref="UpdateDescriptions"/> in a timely fashion to keep descriptions up-to-date and localised.
/// Call <see cref="LocaliseDescriptions"/> in a timely fashion to keep descriptions up-to-date and localised.
/// </remarks>
public static void UpdateAssociations()
{
try
{
updateAssociations();
// TODO: Remove once UpdateDescriptions() is called as specified in the xmldoc.
updateDescriptions(null); // always write default descriptions, in case of updating from an older version in which file associations were not implemented/installed
NotifyShellUpdate();
}
catch (Exception e)
@@ -95,11 +95,19 @@ namespace osu.Desktop.Windows
}
}
public static void UpdateDescriptions(LocalisationManager localisationManager)
// TODO: call this sometime.
public static void LocaliseDescriptions(LocalisationManager localisationManager)
{
try
{
updateDescriptions(localisationManager);
application_capability.LocaliseDescription(localisationManager);
foreach (var association in file_associations)
association.LocaliseDescription(localisationManager);
foreach (var association in uri_associations)
association.LocaliseDescription(localisationManager);
NotifyShellUpdate();
}
catch (Exception e)
@@ -112,6 +120,8 @@ namespace osu.Desktop.Windows
{
try
{
application_capability.Uninstall();
foreach (var association in file_associations)
association.Uninstall();
@@ -133,30 +143,16 @@ namespace osu.Desktop.Windows
/// </summary>
private static void updateAssociations()
{
application_capability.Install();
foreach (var association in file_associations)
association.Install();
foreach (var association in uri_associations)
association.Install();
}
private static void updateDescriptions(LocalisationManager? localisation)
{
foreach (var association in file_associations)
association.UpdateDescription(getLocalisedString(association.Description));
foreach (var association in uri_associations)
association.UpdateDescription(getLocalisedString(association.Description));
string getLocalisedString(LocalisableString s)
{
if (localisation == null)
return s.ToString();
var b = localisation.GetLocalisedBindableString(s);
b.UnbindAll();
return b.Value;
}
application_capability.RegisterFileAssociations(file_associations);
application_capability.RegisterUriAssociations(uri_associations);
}
#region Native interop
@@ -182,9 +178,87 @@ namespace osu.Desktop.Windows
#endregion
private record FileAssociation(string Extension, LocalisableString Description, string IconPath)
private class ApplicationCapability
{
private string programId => $@"{program_id_prefix}{Extension}";
private string uniqueName { get; }
private string capabilityPath { get; }
private LocalisableString description { get; }
public ApplicationCapability(string uniqueName, string capabilityPath, LocalisableString description)
{
this.uniqueName = uniqueName;
this.capabilityPath = capabilityPath;
this.description = description;
}
/// <summary>
/// Registers an application capability according to <see href="https://learn.microsoft.com/en-us/windows/win32/shell/default-programs#registering-an-application-for-use-with-default-programs">
/// Registering an Application for Use with Default Programs</see>.
/// </summary>
public void Install()
{
using (var capability = Registry.CurrentUser.CreateSubKey(capabilityPath))
{
capability.SetValue(@"ApplicationDescription", description.ToString());
}
using (var registeredApplications = Registry.CurrentUser.OpenSubKey(software_registered_applications, true))
registeredApplications?.SetValue(uniqueName, capabilityPath);
}
public void RegisterFileAssociations(FileAssociation[] associations)
{
using var capability = Registry.CurrentUser.OpenSubKey(capabilityPath, true);
if (capability == null) return;
using var fileAssociations = capability.CreateSubKey(@"FileAssociations");
foreach (var association in associations)
fileAssociations.SetValue(association.Extension, association.ProgramId);
}
public void RegisterUriAssociations(UriAssociation[] associations)
{
using var capability = Registry.CurrentUser.OpenSubKey(capabilityPath, true);
if (capability == null) return;
using var urlAssociations = capability.CreateSubKey(@"UrlAssociations");
foreach (var association in associations)
urlAssociations.SetValue(association.Protocol, association.ProgramId);
}
public void LocaliseDescription(LocalisationManager localisationManager)
{
using (var capability = Registry.CurrentUser.OpenSubKey(capabilityPath, true))
{
capability?.SetValue(@"ApplicationDescription", localisationManager.GetLocalisedString(description));
}
}
public void Uninstall()
{
using (var registeredApplications = Registry.CurrentUser.OpenSubKey(software_registered_applications, true))
registeredApplications?.DeleteValue(uniqueName, throwOnMissingValue: false);
Registry.CurrentUser.DeleteSubKeyTree(capabilityPath, throwOnMissingSubKey: false);
}
}
private class FileAssociation
{
public string ProgramId => $@"{program_id_file_prefix}{Extension}";
public string Extension { get; }
private LocalisableString description { get; }
private string iconPath { get; }
public FileAssociation(string extension, LocalisableString description, string iconPath)
{
Extension = extension;
this.description = description;
this.iconPath = iconPath;
}
/// <summary>
/// Installs a file extension association in accordance with https://learn.microsoft.com/en-us/windows/win32/com/-progid--key
@@ -195,10 +269,12 @@ namespace osu.Desktop.Windows
if (classes == null) return;
// register a program id for the given extension
using (var programKey = classes.CreateSubKey(programId))
using (var programKey = classes.CreateSubKey(ProgramId))
{
programKey.SetValue(null, description.ToString());
using (var defaultIconKey = programKey.CreateSubKey(default_icon))
defaultIconKey.SetValue(null, IconPath);
defaultIconKey.SetValue(null, iconPath);
using (var openCommandKey = programKey.CreateSubKey(SHELL_OPEN_COMMAND))
openCommandKey.SetValue(null, $@"""{exe_path}"" ""%1""");
@@ -206,23 +282,25 @@ namespace osu.Desktop.Windows
using (var extensionKey = classes.CreateSubKey(Extension))
{
// set ourselves as the default program
extensionKey.SetValue(null, programId);
// Clear out our existing default ProgramID. Default programs in Windows are handled internally by Explorer,
// so having it here is just confusing and may override user preferences.
if (extensionKey.GetValue(null) is string s && s == ProgramId)
extensionKey.SetValue(null, string.Empty);
// add to the open with dialog
// https://learn.microsoft.com/en-us/windows/win32/shell/how-to-include-an-application-on-the-open-with-dialog-box
using (var openWithKey = extensionKey.CreateSubKey(@"OpenWithProgIds"))
openWithKey.SetValue(programId, string.Empty);
openWithKey.SetValue(ProgramId, string.Empty);
}
}
public void UpdateDescription(string description)
public void LocaliseDescription(LocalisationManager localisationManager)
{
using var classes = Registry.CurrentUser.OpenSubKey(software_classes, true);
if (classes == null) return;
using (var programKey = classes.OpenSubKey(programId, true))
programKey?.SetValue(null, description);
using (var programKey = classes.OpenSubKey(ProgramId, true))
programKey?.SetValue(null, localisationManager.GetLocalisedString(description));
}
/// <summary>
@@ -235,26 +313,34 @@ namespace osu.Desktop.Windows
using (var extensionKey = classes.OpenSubKey(Extension, true))
{
// clear our default association so that Explorer doesn't show the raw programId to users
// the null/(Default) entry is used for both ProdID association and as a fallback friendly name, for legacy reasons
if (extensionKey?.GetValue(null) is string s && s == programId)
extensionKey.SetValue(null, string.Empty);
using (var openWithKey = extensionKey?.CreateSubKey(@"OpenWithProgIds"))
openWithKey?.DeleteValue(programId, throwOnMissingValue: false);
openWithKey?.DeleteValue(ProgramId, throwOnMissingValue: false);
}
classes.DeleteSubKeyTree(programId, throwOnMissingSubKey: false);
classes.DeleteSubKeyTree(ProgramId, throwOnMissingSubKey: false);
}
}
private record UriAssociation(string Protocol, LocalisableString Description, string IconPath)
private class UriAssociation
{
/// <summary>
/// "The <c>URL Protocol</c> string value indicates that this key declares a custom pluggable protocol handler."
/// See https://learn.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/aa767914(v=vs.85).
/// </summary>
public const string URL_PROTOCOL = @"URL Protocol";
private const string url_protocol = @"URL Protocol";
public string Protocol { get; }
private LocalisableString description { get; }
private string iconPath { get; }
public UriAssociation(string protocol, LocalisableString description, string iconPath)
{
Protocol = protocol;
this.description = description;
this.iconPath = iconPath;
}
public string ProgramId => $@"{program_id_protocol_prefix}.{Protocol}";
/// <summary>
/// Registers an URI protocol handler in accordance with https://learn.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/aa767914(v=vs.85).
@@ -266,29 +352,38 @@ namespace osu.Desktop.Windows
using (var protocolKey = classes.CreateSubKey(Protocol))
{
protocolKey.SetValue(URL_PROTOCOL, string.Empty);
protocolKey.SetValue(null, $@"URL:{description}");
protocolKey.SetValue(url_protocol, string.Empty);
using (var defaultIconKey = protocolKey.CreateSubKey(default_icon))
defaultIconKey.SetValue(null, IconPath);
// clear out old data
protocolKey.DeleteSubKeyTree(default_icon, throwOnMissingSubKey: false);
protocolKey.DeleteSubKeyTree(@"Shell", throwOnMissingSubKey: false);
}
using (var openCommandKey = protocolKey.CreateSubKey(SHELL_OPEN_COMMAND))
// register a program id for the given protocol
using (var programKey = classes.CreateSubKey(ProgramId))
{
using (var defaultIconKey = programKey.CreateSubKey(default_icon))
defaultIconKey.SetValue(null, iconPath);
using (var openCommandKey = programKey.CreateSubKey(SHELL_OPEN_COMMAND))
openCommandKey.SetValue(null, $@"""{exe_path}"" ""%1""");
}
}
public void UpdateDescription(string description)
public void LocaliseDescription(LocalisationManager localisationManager)
{
using var classes = Registry.CurrentUser.OpenSubKey(software_classes, true);
if (classes == null) return;
using (var protocolKey = classes.OpenSubKey(Protocol, true))
protocolKey?.SetValue(null, $@"URL:{description}");
protocolKey?.SetValue(null, $@"URL:{localisationManager.GetLocalisedString(description)}");
}
public void Uninstall()
{
using var classes = Registry.CurrentUser.OpenSubKey(software_classes, true);
classes?.DeleteSubKeyTree(Protocol, throwOnMissingSubKey: false);
classes?.DeleteSubKeyTree(ProgramId, throwOnMissingSubKey: false);
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

+3 -2
View File
@@ -5,6 +5,7 @@
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Description>A free-to-win rhythm game. Rhythm is just a *click* away!</Description>
<AssemblyName>osu!</AssemblyName>
<AssemblyTitle>osu!(lazer)</AssemblyTitle>
<Title>osu!</Title>
<Product>osu!(lazer)</Product>
<ApplicationIcon>lazer.ico</ApplicationIcon>
@@ -23,9 +24,9 @@
<ProjectReference Include="..\osu.Game.Rulesets.Taiko\osu.Game.Rulesets.Taiko.csproj" />
</ItemGroup>
<ItemGroup Label="Package References">
<PackageReference Include="System.IO.Packaging" Version="8.0.0" />
<PackageReference Include="System.IO.Packaging" Version="9.0.0" />
<PackageReference Include="DiscordRichPresence" Version="1.2.1.24" />
<PackageReference Include="Velopack" Version="0.0.598-g933b2ab" />
<PackageReference Include="Velopack" Version="0.0.1053" />
</ItemGroup>
<ItemGroup Label="Resources">
<EmbeddedResource Include="lazer.ico" />
@@ -0,0 +1,29 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using BenchmarkDotNet.Attributes;
using osu.Framework.Utils;
using osu.Game.Utils;
using osuTK;
namespace osu.Game.Benchmarks
{
public class BenchmarkGeometryUtils : BenchmarkTest
{
[Params(100, 1000, 2000, 4000, 8000, 10000)]
public int N;
private Vector2[] points = null!;
public override void SetUp()
{
points = new Vector2[N];
for (int i = 0; i < points.Length; ++i)
points[i] = new Vector2(RNG.Next(512), RNG.Next(384));
}
[Benchmark]
public void MinimumEnclosingCircle() => GeometryUtils.MinimumEnclosingCircle(points);
}
}
+32 -6
View File
@@ -4,28 +4,54 @@
using System.Collections.Generic;
using BenchmarkDotNet.Attributes;
using osu.Framework.Utils;
using osu.Game.Rulesets.Objects;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Benchmarks
{
public class BenchmarkUnstableRate : BenchmarkTest
{
private List<HitEvent> events = null!;
private readonly List<List<HitEvent>> incrementalEventLists = new List<List<HitEvent>>();
public override void SetUp()
{
base.SetUp();
events = new List<HitEvent>();
for (int i = 0; i < 1000; i++)
events.Add(new HitEvent(RNG.NextDouble(-200.0, 200.0), RNG.NextDouble(1.0, 2.0), HitResult.Great, new HitObject(), null, null));
var events = new List<HitEvent>();
for (int i = 0; i < 2048; i++)
{
// Ensure the object has hit windows populated.
var hitObject = new HitCircle();
hitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
events.Add(new HitEvent(RNG.NextDouble(-200.0, 200.0), RNG.NextDouble(1.0, 2.0), HitResult.Great, hitObject, null, null));
incrementalEventLists.Add(new List<HitEvent>(events));
}
}
[Benchmark]
public void CalculateUnstableRate()
{
_ = events.CalculateUnstableRate();
for (int i = 0; i < 2048; i++)
{
var events = incrementalEventLists[i];
_ = events.CalculateUnstableRate();
}
}
[Benchmark]
public void CalculateUnstableRateUsingIncrementalCalculation()
{
HitEventExtensions.UnstableRateCalculationResult? last = null;
for (int i = 0; i < 2048; i++)
{
var events = incrementalEventLists[i];
last = events.CalculateUnstableRate(last);
}
}
}
}
@@ -7,9 +7,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.13.12" />
<PackageReference Include="BenchmarkDotNet" Version="0.14.0" />
<PackageReference Include="nunit" Version="3.14.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,15 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using Foundation;
using osu.Framework.iOS;
using osu.Game.Tests;
namespace osu.Game.Rulesets.Catch.Tests.iOS
{
[Register("AppDelegate")]
public class AppDelegate : GameApplicationDelegate
{
protected override Framework.Game CreateGame() => new OsuTestBrowser();
}
}
@@ -1,16 +1,15 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.iOS;
using osu.Game.Tests;
using UIKit;
namespace osu.Game.Rulesets.Catch.Tests.iOS
{
public static class Application
public static class Program
{
public static void Main(string[] args)
{
GameApplication.Main(new OsuTestBrowser());
UIApplication.Main(args, null, typeof(AppDelegate));
}
}
}
@@ -71,7 +71,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
contentContainer.Playfield.HitObjectContainer.Add(hitObject);
}
protected override SnapResult SnapForBlueprint(PlacementBlueprint blueprint)
protected override SnapResult SnapForBlueprint(HitObjectPlacementBlueprint blueprint)
{
var result = base.SnapForBlueprint(blueprint);
result.Time = Math.Round(HitObjectContainer.TimeAtScreenSpacePosition(result.ScreenSpacePosition) / TIME_SNAP) * TIME_SNAP;
@@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
{
protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableBananaShower((BananaShower)hitObject);
protected override PlacementBlueprint CreateBlueprint() => new BananaShowerPlacementBlueprint();
protected override HitObjectPlacementBlueprint CreateBlueprint() => new BananaShowerPlacementBlueprint();
protected override void AddHitObject(DrawableHitObject hitObject)
{
@@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
{
protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableFruit((Fruit)hitObject);
protected override PlacementBlueprint CreateBlueprint() => new FruitPlacementBlueprint();
protected override HitObjectPlacementBlueprint CreateBlueprint() => new FruitPlacementBlueprint();
[Test]
public void TestFruitPlacementPosition()
@@ -114,7 +114,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableJuiceStream((JuiceStream)hitObject);
protected override PlacementBlueprint CreateBlueprint() => new JuiceStreamPlacementBlueprint();
protected override HitObjectPlacementBlueprint CreateBlueprint() => new JuiceStreamPlacementBlueprint();
private void addMoveAndClickSteps(double time, float position, bool end = false)
{
@@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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;
@@ -21,7 +19,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
{
public partial class TestSceneJuiceStreamSelectionBlueprint : CatchSelectionBlueprintTestScene
{
private JuiceStream hitObject;
private JuiceStream hitObject = null!;
private readonly ManualClock manualClock = new ManualClock();
@@ -193,6 +191,17 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
addVertexCheckStep(1, 0, times[0], positions[0]);
}
[Test]
public void TestDeletingSecondVertexDeletesEntireJuiceStream()
{
double[] times = { 100, 400 };
float[] positions = { 100, 150 };
addBlueprintStep(times, positions);
addDeleteVertexSteps(times[1], positions[1]);
AddAssert("juice stream deleted", () => EditorBeatmap.HitObjects, () => Is.Empty);
}
[Test]
public void TestVertexResampling()
{
@@ -29,7 +29,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Mods
},
Autoplay = true,
PassCondition = () => Player.ScoreProcessor.Combo.Value == 2,
Beatmap = new Beatmap
CreateBeatmap = () => new Beatmap
{
HitObjects = new List<HitObject>
{
@@ -59,7 +59,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Mods
},
Autoplay = true,
PassCondition = () => true,
Beatmap = new Beatmap
CreateBeatmap = () => new Beatmap
{
HitObjects = new List<HitObject>
{
@@ -99,7 +99,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Mods
},
Autoplay = true,
PassCondition = () => true,
Beatmap = new Beatmap
CreateBeatmap = () => new Beatmap
{
HitObjects = new List<HitObject>
{
@@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Mods
Mod = new CatchModRelax(),
Autoplay = false,
PassCondition = passCondition,
Beatmap = new Beatmap
CreateBeatmap = () => new Beatmap
{
HitObjects = new List<HitObject>
{
@@ -24,7 +24,7 @@ namespace osu.Game.Rulesets.Catch.Tests
{
CreateModTest(new ModTestData
{
Beatmap = new Beatmap
CreateBeatmap = () => new Beatmap
{
HitObjects = new List<HitObject>
{
@@ -1,9 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\osu.TestProject.props" />
<ItemGroup Label="Package References">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="NUnit" Version="3.14.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
</ItemGroup>
<PropertyGroup Label="Project">
<OutputType>WinExe</OutputType>
+22 -2
View File
@@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Bindings;
using osu.Framework.Localisation;
@@ -31,6 +32,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.Catch
{
@@ -223,10 +225,28 @@ namespace osu.Game.Rulesets.Catch
public override HitObjectComposer CreateHitObjectComposer() => new CatchHitObjectComposer(this);
public override IEnumerable<SetupSection> CreateEditorSetupSections() =>
public override IEnumerable<Drawable> CreateEditorSetupSections() =>
[
new MetadataSection(),
new DifficultySection(),
new ColoursSection(),
new FillFlowContainer
{
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
Spacing = new Vector2(SetupScreen.SPACING),
Children = new Drawable[]
{
new ResourcesSection
{
RelativeSizeAxes = Axes.X,
},
new ColoursSection
{
RelativeSizeAxes = Axes.X,
}
}
},
new DesignSection(),
];
public override IBeatmapVerifier CreateBeatmapVerifier() => new CatchBeatmapVerifier();
@@ -3,7 +3,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Catch.Beatmaps;
using osu.Game.Rulesets.Catch.Difficulty.Preprocessing;
@@ -20,7 +19,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty
{
public class CatchDifficultyCalculator : DifficultyCalculator
{
private const double star_scaling_factor = 0.153;
private const double difficulty_multiplier = 4.59;
private float halfCatcherWidth;
@@ -41,10 +40,10 @@ namespace osu.Game.Rulesets.Catch.Difficulty
CatchDifficultyAttributes attributes = new CatchDifficultyAttributes
{
StarRating = Math.Sqrt(skills[0].DifficultyValue()) * star_scaling_factor,
StarRating = Math.Sqrt(skills[0].DifficultyValue()) * difficulty_multiplier,
Mods = mods,
ApproachRate = preempt > 1200.0 ? -(preempt - 1800.0) / 120.0 : -(preempt - 1200.0) / 150.0 + 5.0,
MaxCombo = beatmap.HitObjects.Count(h => h is Fruit) + beatmap.HitObjects.OfType<JuiceStream>().SelectMany(j => j.NestedHitObjects).Count(h => !(h is TinyDroplet)),
MaxCombo = beatmap.GetMaxCombo(),
};
return attributes;
@@ -15,7 +15,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills
private const float normalized_hitobject_radius = 41.0f;
private const double direction_change_bonus = 21.0;
protected override double SkillMultiplier => 900;
protected override double SkillMultiplier => 1;
protected override double StrainDecayBase => 0.2;
protected override double DecayWeight => 0.94;
@@ -10,7 +10,7 @@ using osu.Game.Rulesets.Edit.Tools;
namespace osu.Game.Rulesets.Catch.Edit
{
public class BananaShowerCompositionTool : HitObjectCompositionTool
public class BananaShowerCompositionTool : CompositionTool
{
public BananaShowerCompositionTool()
: base(nameof(BananaShower))
@@ -19,6 +19,6 @@ namespace osu.Game.Rulesets.Catch.Edit
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Spinners);
public override PlacementBlueprint CreatePlacementBlueprint() => new BananaShowerPlacementBlueprint();
public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new BananaShowerPlacementBlueprint();
}
}
@@ -9,7 +9,7 @@ using osu.Game.Rulesets.UI.Scrolling;
namespace osu.Game.Rulesets.Catch.Edit.Blueprints
{
public partial class CatchPlacementBlueprint<THitObject> : PlacementBlueprint
public partial class CatchPlacementBlueprint<THitObject> : HitObjectPlacementBlueprint
where THitObject : CatchHitObject, new()
{
protected new THitObject HitObject => (THitObject)base.HitObject;
@@ -92,7 +92,7 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
}));
}
public void UpdateHitObjectFromPath(JuiceStream hitObject)
public virtual void UpdateHitObjectFromPath(JuiceStream hitObject)
{
// The SV setting may need to be changed for the current path.
var svBindable = hitObject.SliderVelocityMultiplierBindable;
@@ -138,5 +138,13 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
EditorBeatmap?.EndChange();
}
public override void UpdateHitObjectFromPath(JuiceStream hitObject)
{
base.UpdateHitObjectFromPath(hitObject);
if (hitObject.Path.ControlPoints.Count <= 1 || !hitObject.Path.HasValidLength)
EditorBeatmap?.Remove(hitObject);
}
}
}
@@ -88,10 +88,9 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints
switch (PlacementActive)
{
case PlacementState.Waiting:
if (!(result.Time is double snappedTime)) return;
HitObject.OriginalX = ToLocalSpace(result.ScreenSpacePosition).X;
HitObject.StartTime = snappedTime;
if (result.Time is double snappedTime)
HitObject.StartTime = snappedTime;
break;
case PlacementState.Active:
@@ -107,21 +106,13 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints
Vector2 startPosition = CatchHitObjectUtils.GetStartPosition(HitObjectContainer, HitObject);
editablePath.Position = nestedOutlineContainer.Position = scrollingPath.Position = startPosition;
updateHitObjectFromPath();
}
if (lastEditablePathId != editablePath.PathId)
editablePath.UpdateHitObjectFromPath(HitObject);
lastEditablePathId = editablePath.PathId;
private void updateHitObjectFromPath()
{
if (lastEditablePathId == editablePath.PathId)
return;
editablePath.UpdateHitObjectFromPath(HitObject);
ApplyDefaultsToHitObject();
scrollingPath.UpdatePathFrom(HitObjectContainer, HitObject);
nestedOutlineContainer.UpdateNestedObjectsFrom(HitObjectContainer, HitObject);
lastEditablePathId = editablePath.PathId;
}
private double positionToTime(float relativeYPosition)
@@ -8,11 +8,14 @@ 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.Graphics.UserInterface;
using osu.Game.Rulesets.Catch.Edit.Blueprints.Components;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Screens.Edit;
using osuTK;
using osuTK.Input;
@@ -53,6 +56,12 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints
[Resolved]
private EditorBeatmap? editorBeatmap { get; set; }
[Resolved]
private IEditorChangeHandler? changeHandler { get; set; }
[Resolved]
private BindableBeatDivisor? beatDivisor { get; set; }
public JuiceStreamSelectionBlueprint(JuiceStream hitObject)
: base(hitObject)
{
@@ -118,6 +127,20 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints
return base.OnMouseDown(e);
}
protected override bool OnKeyDown(KeyDownEvent e)
{
if (!IsSelected)
return false;
if (e.Key == Key.F && e.ControlPressed && e.ShiftPressed)
{
convertToStream();
return true;
}
return false;
}
private void onDefaultsApplied(HitObject _)
{
computeObjectBounds();
@@ -167,12 +190,64 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints
lastSliderPathVersion = HitObject.Path.Version.Value;
}
// duplicated in `SliderSelectionBlueprint.convertToStream()`
// consider extracting common helper when applying changes here
private void convertToStream()
{
if (editorBeatmap == null || beatDivisor == null)
return;
var timingPoint = editorBeatmap.ControlPointInfo.TimingPointAt(HitObject.StartTime);
double streamSpacing = timingPoint.BeatLength / beatDivisor.Value;
changeHandler?.BeginChange();
int i = 0;
double time = HitObject.StartTime;
while (!Precision.DefinitelyBigger(time, HitObject.GetEndTime(), 1))
{
// positionWithRepeats is a fractional number in the range of [0, HitObject.SpanCount()]
// and indicates how many fractional spans of a slider have passed up to time.
double positionWithRepeats = (time - HitObject.StartTime) / HitObject.Duration * HitObject.SpanCount();
double pathPosition = positionWithRepeats - (int)positionWithRepeats;
// every second span is in the reverse direction - need to reverse the path position.
if (positionWithRepeats % 2 >= 1)
pathPosition = 1 - pathPosition;
float fruitXValue = HitObject.OriginalX + HitObject.Path.PositionAt(pathPosition).X;
editorBeatmap.Add(new Fruit
{
StartTime = time,
OriginalX = fruitXValue,
NewCombo = i == 0 && HitObject.NewCombo,
Samples = HitObject.Samples.Select(s => s.With()).ToList()
});
i += 1;
time = HitObject.StartTime + i * streamSpacing;
}
editorBeatmap.Remove(HitObject);
changeHandler?.EndChange();
}
private IEnumerable<MenuItem> getContextMenuItems()
{
yield return new OsuMenuItem("Add vertex", MenuItemType.Standard, () =>
{
editablePath.AddVertex(rightMouseDownPosition);
});
})
{
Hotkey = new Hotkey(new KeyCombination(InputKey.Control, InputKey.MouseLeft))
};
yield return new OsuMenuItem("Convert to stream", MenuItemType.Destructive, convertToStream)
{
Hotkey = new Hotkey(new KeyCombination(InputKey.Control, InputKey.Shift, InputKey.F))
};
}
protected override void Dispose(bool isDisposing)
@@ -10,7 +10,7 @@ namespace osu.Game.Rulesets.Catch.Edit
{
public partial class CatchDistanceSnapProvider : ComposerDistanceSnapProvider
{
protected override double ReadCurrentDistanceSnap(HitObject before, HitObject after)
public override double ReadCurrentDistanceSnap(HitObject before, HitObject after)
{
// osu!catch's distance snap implementation is limited, in that a custom spacing cannot be specified.
// Therefore this functionality is not currently used.
@@ -8,7 +8,6 @@ namespace osu.Game.Rulesets.Catch.Edit
{
public partial class CatchEditorPlayfield : CatchPlayfield
{
// TODO fixme: the size of the catcher is not changed when circle size is changed in setup screen.
public CatchEditorPlayfield(IBeatmapDifficultyInfo difficulty)
: base(difficulty)
{
@@ -18,7 +18,6 @@ using osu.Game.Rulesets.Edit.Tools;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.UI;
using osu.Game.Screens.Edit.Components.TernaryButtons;
using osu.Game.Screens.Edit.Compose.Components;
using osuTK;
@@ -70,7 +69,9 @@ namespace osu.Game.Rulesets.Catch.Edit
}));
}
protected override IEnumerable<TernaryButton> CreateTernaryButtons()
protected override Drawable CreateHitObjectInspector() => new CatchHitObjectInspector(DistanceSnapProvider);
protected override IEnumerable<Drawable> CreateTernaryButtons()
=> base.CreateTernaryButtons()
.Concat(DistanceSnapProvider.CreateTernaryButtons());
@@ -84,7 +85,7 @@ namespace osu.Game.Rulesets.Catch.Edit
protected override BeatSnapGrid CreateBeatSnapGrid() => new CatchBeatSnapGrid();
protected override IReadOnlyList<HitObjectCompositionTool> CompositionTools => new HitObjectCompositionTool[]
protected override IReadOnlyList<CompositionTool> CompositionTools => new CompositionTool[]
{
new FruitCompositionTool(),
new JuiceStreamCompositionTool(),
@@ -114,6 +115,26 @@ namespace osu.Game.Rulesets.Catch.Edit
{
}
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)
{
DistanceSnapProvider.HandleToggleViaKey(key);
}
public override SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All)
{
var result = base.FindSnappedPositionAndTime(screenSpacePosition, snapType);
@@ -0,0 +1,41 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Objects;
using osu.Game.Screens.Edit.Compose.Components;
namespace osu.Game.Rulesets.Catch.Edit
{
public partial class CatchHitObjectInspector(CatchDistanceSnapProvider snapProvider) : HitObjectInspector
{
protected override void AddInspectorValues(HitObject[] objects)
{
base.AddInspectorValues(objects);
if (objects.Length > 0)
{
HitObject firstSelectedHitObject = objects.MinBy(ho => ho.StartTime)!;
HitObject lastSelectedHitObject = objects.MaxBy(ho => ho.GetEndTime())!;
HitObject? precedingObject = EditorBeatmap.HitObjects.LastOrDefault(ho => ho.GetEndTime() < firstSelectedHitObject.StartTime);
HitObject? nextObject = EditorBeatmap.HitObjects.FirstOrDefault(ho => ho.StartTime > lastSelectedHitObject.GetEndTime());
if (precedingObject != null && precedingObject is not BananaShower)
{
double previousSnap = snapProvider.ReadCurrentDistanceSnap(precedingObject, firstSelectedHitObject);
AddHeader("To previous");
AddValue($"{previousSnap:#,0.##}x");
}
if (nextObject != null && nextObject is not BananaShower)
{
double nextSnap = snapProvider.ReadCurrentDistanceSnap(lastSelectedHitObject, nextObject);
AddHeader("To next");
AddValue($"{nextSnap:#,0.##}x");
}
}
}
}
}
@@ -2,16 +2,22 @@
// 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.Extensions.ObjectExtensions;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Catch.UI;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.UI;
using osu.Game.Screens.Edit;
namespace osu.Game.Rulesets.Catch.Edit
{
public partial class DrawableCatchEditorRuleset : DrawableCatchRuleset
{
[Resolved]
private EditorBeatmap editorBeatmap { get; set; } = null!;
public readonly BindableDouble TimeRangeMultiplier = new BindableDouble(1);
public DrawableCatchEditorRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod>? mods = null)
@@ -28,6 +34,30 @@ namespace osu.Game.Rulesets.Catch.Edit
TimeRange.Value = gamePlayTimeRange * TimeRangeMultiplier.Value * playfieldStretch;
}
protected override void LoadComplete()
{
base.LoadComplete();
editorBeatmap.BeatmapReprocessed += onBeatmapReprocessed;
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (editorBeatmap.IsNotNull())
editorBeatmap.BeatmapReprocessed -= onBeatmapReprocessed;
}
private void onBeatmapReprocessed()
{
if (Playfield is CatchEditorPlayfield catchPlayfield)
{
catchPlayfield.Catcher.ApplyDifficulty(editorBeatmap.Difficulty);
catchPlayfield.CatcherArea.CatcherTrails.UpdateCatcherTrailsScale(catchPlayfield.Catcher.BodyScale);
}
}
protected override Playfield CreatePlayfield() => new CatchEditorPlayfield(Beatmap.Difficulty);
public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new CatchEditorPlayfieldAdjustmentContainer();
@@ -10,7 +10,7 @@ using osu.Game.Rulesets.Edit.Tools;
namespace osu.Game.Rulesets.Catch.Edit
{
public class FruitCompositionTool : HitObjectCompositionTool
public class FruitCompositionTool : CompositionTool
{
public FruitCompositionTool()
: base(nameof(Fruit))
@@ -19,6 +19,6 @@ namespace osu.Game.Rulesets.Catch.Edit
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles);
public override PlacementBlueprint CreatePlacementBlueprint() => new FruitPlacementBlueprint();
public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new FruitPlacementBlueprint();
}
}
@@ -10,7 +10,7 @@ using osu.Game.Rulesets.Edit.Tools;
namespace osu.Game.Rulesets.Catch.Edit
{
public class JuiceStreamCompositionTool : HitObjectCompositionTool
public class JuiceStreamCompositionTool : CompositionTool
{
public JuiceStreamCompositionTool()
: base(nameof(JuiceStream))
@@ -19,6 +19,6 @@ namespace osu.Game.Rulesets.Catch.Edit
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders);
public override PlacementBlueprint CreatePlacementBlueprint() => new JuiceStreamPlacementBlueprint();
public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new JuiceStreamPlacementBlueprint();
}
}
+1 -1
View File
@@ -58,7 +58,7 @@ namespace osu.Game.Rulesets.Catch.Objects
{
}
public sealed override HitSampleInfo With(Optional<string> newName = default, Optional<string> newBank = default, Optional<string?> newSuffix = default, Optional<int> newVolume = default)
public sealed override HitSampleInfo With(Optional<string> newName = default, Optional<string> newBank = default, Optional<string?> newSuffix = default, Optional<int> newVolume = default, Optional<bool> newEditorAutoBank = default)
=> new BananaHitSampleInfo(newVolume.GetOr(Volume));
public bool Equals(BananaHitSampleInfo? other)
@@ -159,27 +159,26 @@ namespace osu.Game.Rulesets.Catch.Objects
{
// Note that this implementation is shared with the osu! ruleset's implementation.
// If a change is made here, OsuHitObject.cs should also be updated.
ComboIndex = lastObj?.ComboIndex ?? 0;
ComboIndexWithOffsets = lastObj?.ComboIndexWithOffsets ?? 0;
IndexInCurrentCombo = (lastObj?.IndexInCurrentCombo + 1) ?? 0;
int index = lastObj?.ComboIndex ?? 0;
int indexWithOffsets = lastObj?.ComboIndexWithOffsets ?? 0;
int inCurrentCombo = (lastObj?.IndexInCurrentCombo + 1) ?? 0;
if (this is BananaShower)
// - For the purpose of combo colours, spinners never start a new combo even if they are flagged as doing so.
// - At decode time, the first hitobject in the beatmap and the first hitobject after a banana shower are both enforced to be a new combo,
// but this isn't directly enforced by the editor so the extra checks against the last hitobject are duplicated here.
if (this is not BananaShower && (NewCombo || lastObj == null || lastObj is BananaShower))
{
// For the purpose of combo colours, spinners never start a new combo even if they are flagged as doing so.
return;
}
// At decode time, the first hitobject in the beatmap and the first hitobject after a banana shower are both enforced to be a new combo,
// but this isn't directly enforced by the editor so the extra checks against the last hitobject are duplicated here.
if (NewCombo || lastObj == null || lastObj is BananaShower)
{
IndexInCurrentCombo = 0;
ComboIndex++;
ComboIndexWithOffsets += ComboOffset + 1;
inCurrentCombo = 0;
index++;
indexWithOffsets += ComboOffset + 1;
if (lastObj != null)
lastObj.LastInCombo = true;
}
ComboIndex = index;
ComboIndexWithOffsets = indexWithOffsets;
IndexInCurrentCombo = inCurrentCombo;
}
protected override HitWindows CreateHitWindows() => HitWindows.Empty;
@@ -210,11 +209,27 @@ namespace osu.Game.Rulesets.Catch.Objects
/// </summary>
public float LegacyConvertedY { get; set; } = DEFAULT_LEGACY_CONVERT_Y;
float IHasXPosition.X => OriginalX;
float IHasXPosition.X
{
get => OriginalX;
set => OriginalX = value;
}
float IHasYPosition.Y => LegacyConvertedY;
float IHasYPosition.Y
{
get => LegacyConvertedY;
set => LegacyConvertedY = value;
}
Vector2 IHasPosition.Position => new Vector2(OriginalX, LegacyConvertedY);
Vector2 IHasPosition.Position
{
get => new Vector2(OriginalX, LegacyConvertedY);
set
{
((IHasXPosition)this).X = value.X;
((IHasYPosition)this).Y = value.Y;
}
}
#endregion
}
@@ -1,10 +1,12 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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.Utils;
using osu.Game.Rulesets.Catch.Skinning.Default;
using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Rulesets.Catch.Objects.Drawables
{
@@ -36,23 +38,43 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables
StartTimeBindable.BindValueChanged(_ => UpdateComboColour());
}
private float startScale;
private float endScale;
private float startAngle;
private float endAngle;
protected override void UpdateInitialTransforms()
{
base.UpdateInitialTransforms();
// Important to have this in UpdateInitialTransforms() to it is re-triggered by RefreshStateTransforms().
const float end_scale = 0.6f;
const float random_scale_range = 1.6f;
ScalingContainer.ScaleTo(HitObject.Scale * (end_scale + random_scale_range * RandomSingle(3)))
.Then().ScaleTo(HitObject.Scale * end_scale, HitObject.TimePreempt);
startScale = end_scale + random_scale_range * RandomSingle(3);
endScale = end_scale;
ScalingContainer.RotateTo(getRandomAngle(1))
.Then()
.RotateTo(getRandomAngle(2), HitObject.TimePreempt);
startAngle = getRandomAngle(1);
endAngle = getRandomAngle(2);
float getRandomAngle(int series) => 180 * (RandomSingle(series) * 2 - 1);
}
protected override void Update()
{
base.Update();
double preemptProgress = (Time.Current - (HitObject.StartTime - InitialLifetimeOffset)) / HitObject.TimePreempt;
// Clamp scale and rotation at the point of bananas being caught, else let them freely extrapolate.
if (Result.IsHit)
preemptProgress = Math.Min(1, preemptProgress);
ScalingContainer.Scale = new Vector2(HitObject.Scale * (float)Interpolation.Lerp(startScale, endScale, preemptProgress));
ScalingContainer.Rotation = (float)Interpolation.Lerp(startAngle, endAngle, preemptProgress);
}
public override void PlaySamples()
{
base.PlaySamples();
@@ -2,7 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Utils;
using osu.Game.Rulesets.Catch.Skinning.Default;
using osu.Game.Skinning;
@@ -28,15 +28,24 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables
_ => new DropletPiece());
}
private float startRotation;
protected override void UpdateInitialTransforms()
{
base.UpdateInitialTransforms();
// roughly matches osu-stable
float startRotation = RandomSingle(1) * 20;
double duration = HitObject.TimePreempt + 2000;
// Important to have this in UpdateInitialTransforms() to it is re-triggered by RefreshStateTransforms().
startRotation = RandomSingle(1) * 20;
}
ScalingContainer.RotateTo(startRotation).RotateTo(startRotation + 720, duration);
protected override void Update()
{
base.Update();
// No clamping for droplets. They should be considered indefinitely spinning regardless of time.
// They also never end up on the plate, so they shouldn't stop spinning when caught.
double preemptProgress = (Time.Current - (HitObject.StartTime - InitialLifetimeOffset)) / (HitObject.TimePreempt + 2000);
ScalingContainer.Rotation = (float)Interpolation.Lerp(startRotation, startRotation + 720, preemptProgress);
}
}
}
@@ -2,7 +2,6 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Game.Rulesets.Catch.Skinning.Default;
using osu.Game.Skinning;
@@ -32,7 +31,8 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables
{
base.UpdateInitialTransforms();
ScalingContainer.RotateTo((RandomSingle(1) - 0.5f) * 40);
// Important to have this in UpdateInitialTransforms() to it is re-triggered by RefreshStateTransforms().
ScalingContainer.Rotation = (RandomSingle(1) - 0.5f) * 40;
}
}
}
+13 -5
View File
@@ -116,7 +116,7 @@ namespace osu.Game.Rulesets.Catch.UI
/// <summary>
/// Width of the area that can be used to attempt catches during gameplay.
/// </summary>
public readonly float CatchWidth;
public float CatchWidth { get; private set; }
private readonly SkinnableCatcher body;
@@ -142,10 +142,7 @@ namespace osu.Game.Rulesets.Catch.UI
Size = new Vector2(BASE_SIZE);
if (difficulty != null)
Scale = calculateScale(difficulty);
CatchWidth = CalculateCatchWidth(Scale);
ApplyDifficulty(difficulty);
InternalChildren = new Drawable[]
{
@@ -312,6 +309,17 @@ namespace osu.Game.Rulesets.Catch.UI
}
}
/// <summary>
/// Set the scale and catch width.
/// </summary>
public void ApplyDifficulty(IBeatmapDifficultyInfo? difficulty)
{
if (difficulty != null)
Scale = calculateScale(difficulty);
CatchWidth = CalculateCatchWidth(Scale);
}
/// <summary>
/// Drop any fruit off the plate.
/// </summary>
+4 -4
View File
@@ -32,7 +32,7 @@ namespace osu.Game.Rulesets.Catch.UI
private readonly CatchComboDisplay comboDisplay;
private readonly CatcherTrailDisplay catcherTrails;
public readonly CatcherTrailDisplay CatcherTrails;
private Catcher catcher = null!;
@@ -55,7 +55,7 @@ namespace osu.Game.Rulesets.Catch.UI
Children = new Drawable[]
{
catcherContainer = new Container<Catcher> { RelativeSizeAxes = Axes.Both },
catcherTrails = new CatcherTrailDisplay(),
CatcherTrails = new CatcherTrailDisplay(),
comboDisplay = new CatchComboDisplay
{
RelativeSizeAxes = Axes.None,
@@ -112,7 +112,7 @@ namespace osu.Game.Rulesets.Catch.UI
{
const double trail_generation_interval = 16;
if (Time.Current - catcherTrails.LastDashTrailTime >= trail_generation_interval)
if (Time.Current - CatcherTrails.LastDashTrailTime >= trail_generation_interval)
displayCatcherTrail(Catcher.HyperDashing ? CatcherTrailAnimation.HyperDashing : CatcherTrailAnimation.Dashing);
}
@@ -170,6 +170,6 @@ namespace osu.Game.Rulesets.Catch.UI
}
}
private void displayCatcherTrail(CatcherTrailAnimation animation) => catcherTrails.Add(new CatcherTrailEntry(Time.Current, Catcher.CurrentState, Catcher.X, Catcher.BodyScale, animation));
private void displayCatcherTrail(CatcherTrailAnimation animation) => CatcherTrails.Add(new CatcherTrailEntry(Time.Current, Catcher.CurrentState, Catcher.X, Catcher.BodyScale, animation));
}
}
@@ -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.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
@@ -10,6 +11,7 @@ using osu.Framework.Graphics.Pooling;
using osu.Game.Rulesets.Catch.Skinning;
using osu.Game.Rulesets.Objects.Pooling;
using osu.Game.Skinning;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Catch.UI
@@ -55,6 +57,25 @@ namespace osu.Game.Rulesets.Catch.UI
};
}
/// <summary>
/// Update the scale of all trails.
/// </summary>
/// <param name="scale">The new body scale of the Catcher</param>
public void UpdateCatcherTrailsScale(Vector2 scale)
{
var oldEntries = Entries.ToList();
Clear();
foreach (var oldEntry in oldEntries)
{
// use magnitude of the new scale while preserving the sign of the old one in the X direction.
// the end effect is preserving the direction in which the trail sprites face, which is important.
var targetScale = new Vector2(Math.Abs(scale.X) * Math.Sign(oldEntry.Scale.X), Math.Abs(scale.Y));
Add(new CatcherTrailEntry(oldEntry.LifetimeStart, oldEntry.CatcherState, oldEntry.Position, targetScale, oldEntry.Animation));
}
}
protected override void LoadComplete()
{
base.LoadComplete();
@@ -0,0 +1,15 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using Foundation;
using osu.Framework.iOS;
using osu.Game.Tests;
namespace osu.Game.Rulesets.Mania.Tests.iOS
{
[Register("AppDelegate")]
public class AppDelegate : GameApplicationDelegate
{
protected override Framework.Game CreateGame() => new OsuTestBrowser();
}
}
@@ -1,16 +1,15 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.iOS;
using osu.Game.Tests;
using UIKit;
namespace osu.Game.Rulesets.Mania.Tests.iOS
{
public static class Application
public static class Program
{
public static void Main(string[] args)
{
GameApplication.Main(new OsuTestBrowser());
UIApplication.Main(args, null, typeof(AppDelegate));
}
}
}
@@ -47,7 +47,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
});
}
protected override SnapResult SnapForBlueprint(PlacementBlueprint blueprint)
protected override SnapResult SnapForBlueprint(HitObjectPlacementBlueprint blueprint)
{
double time = column.TimeAtScreenSpacePosition(InputManager.CurrentState.Mouse.Position);
var pos = column.ScreenSpacePositionAtTime(time);
@@ -13,6 +13,6 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
public partial class TestSceneHoldNotePlacementBlueprint : ManiaPlacementBlueprintTestScene
{
protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableHoldNote((HoldNote)hitObject);
protected override PlacementBlueprint CreateBlueprint() => new HoldNotePlacementBlueprint();
protected override HitObjectPlacementBlueprint CreateBlueprint() => new HoldNotePlacementBlueprint();
}
}
@@ -20,10 +20,10 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
[Test]
public void TestKeyCountChange()
{
LabelledSliderBar<float> keyCount = null!;
FormSliderBar<float> keyCount = null!;
AddStep("go to setup screen", () => InputManager.Key(Key.F4));
AddUntilStep("retrieve key count slider", () => keyCount = Editor.ChildrenOfType<SetupScreen>().Single().ChildrenOfType<LabelledSliderBar<float>>().First(), () => Is.Not.Null);
AddUntilStep("retrieve key count slider", () => keyCount = Editor.ChildrenOfType<SetupScreen>().Single().ChildrenOfType<FormSliderBar<float>>().First(), () => Is.Not.Null);
AddAssert("key count is 5", () => keyCount.Current.Value, () => Is.EqualTo(5));
AddStep("change key count to 8", () =>
{
@@ -5,7 +5,9 @@ using System.Linq;
using NUnit.Framework;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mania.Edit.Blueprints;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.UI;
using osu.Game.Screens.Edit.Compose.Components;
using osu.Game.Tests.Beatmaps;
using osu.Game.Tests.Visual;
@@ -92,5 +94,70 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
AddAssert("second object flipped", () => second.StartTime, () => Is.EqualTo(250));
AddAssert("third object flipped", () => third.StartTime, () => Is.EqualTo(1250));
}
[Test]
public void TestOffScreenObjectsRemainSelectedOnColumnChange()
{
AddStep("create objects", () =>
{
for (int i = 0; i < 20; ++i)
EditorBeatmap.Add(new Note { StartTime = 1000 * i, Column = 0 });
});
AddStep("select everything", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects));
AddStep("start drag", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<NoteSelectionBlueprint>().Single(blueprint => blueprint.IsSelected && blueprint.HitObject.StartTime == 0));
InputManager.PressButton(MouseButton.Left);
});
AddStep("end drag", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<Column>().Last());
InputManager.ReleaseButton(MouseButton.Left);
});
AddAssert("all objects in last column", () => EditorBeatmap.HitObjects.All(ho => ((ManiaHitObject)ho).Column == 3));
AddAssert("all objects remain selected", () => EditorBeatmap.SelectedHitObjects.SequenceEqual(EditorBeatmap.HitObjects));
}
[Test]
public void TestOffScreenObjectsRemainSelectedOnHorizontalFlip()
{
AddStep("create objects", () =>
{
for (int i = 0; i < 20; ++i)
EditorBeatmap.Add(new Note { StartTime = 1000 * i, Column = i % 4 });
});
AddStep("select everything", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects));
AddStep("flip", () =>
{
InputManager.PressKey(Key.ControlLeft);
InputManager.Key(Key.H);
InputManager.ReleaseKey(Key.ControlLeft);
});
AddAssert("all objects remain selected", () => EditorBeatmap.SelectedHitObjects.SequenceEqual(EditorBeatmap.HitObjects));
}
[Test]
public void TestOffScreenObjectsRemainSelectedOnVerticalFlip()
{
AddStep("create objects", () =>
{
for (int i = 0; i < 20; ++i)
EditorBeatmap.Add(new Note { StartTime = 1000 * i, Column = i % 4 });
});
AddStep("select everything", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects));
AddStep("flip", () =>
{
InputManager.PressKey(Key.ControlLeft);
InputManager.Key(Key.J);
InputManager.ReleaseKey(Key.ControlLeft);
});
AddAssert("all objects remain selected", () => EditorBeatmap.SelectedHitObjects.SequenceEqual(EditorBeatmap.HitObjects.Reverse()));
}
}
}
@@ -64,6 +64,6 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
private Note getNote() => this.ChildrenOfType<DrawableNote>().FirstOrDefault()?.HitObject;
protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableNote((Note)hitObject);
protected override PlacementBlueprint CreateBlueprint() => new NotePlacementBlueprint();
protected override HitObjectPlacementBlueprint CreateBlueprint() => new NotePlacementBlueprint();
}
}
@@ -22,9 +22,11 @@ namespace osu.Game.Rulesets.Mania.Tests
[TestCase("basic")]
[TestCase("zero-length-slider")]
[TestCase("mania-specific-spinner")]
[TestCase("20544")]
[TestCase("100374")]
[TestCase("1450162")]
[TestCase("4869637")]
public void Test(string name) => base.Test(name);
protected override IEnumerable<ConvertValue> CreateConvertValue(HitObject hitObject)
@@ -0,0 +1,39 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using NUnit.Framework;
using osu.Game.Rulesets.Mania.Scoring;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
namespace osu.Game.Rulesets.Mania.Tests
{
[TestFixture]
public class ManiaScoreProcessorTest
{
[TestCase(ScoreRank.X, 1, HitResult.Perfect)]
[TestCase(ScoreRank.X, 0.99, HitResult.Great)]
[TestCase(ScoreRank.D, 0.1, HitResult.Great)]
[TestCase(ScoreRank.X, 0.99, HitResult.Perfect, HitResult.Great)]
[TestCase(ScoreRank.X, 0.99, HitResult.Great, HitResult.Great)]
[TestCase(ScoreRank.S, 0.99, HitResult.Perfect, HitResult.Good)]
[TestCase(ScoreRank.S, 0.99, HitResult.Perfect, HitResult.Ok)]
[TestCase(ScoreRank.S, 0.99, HitResult.Perfect, HitResult.Meh)]
[TestCase(ScoreRank.S, 0.99, HitResult.Perfect, HitResult.Miss)]
[TestCase(ScoreRank.S, 0.99, HitResult.Great, HitResult.Good)]
[TestCase(ScoreRank.S, 0.99, HitResult.Great, HitResult.Ok)]
[TestCase(ScoreRank.S, 0.99, HitResult.Great, HitResult.Meh)]
[TestCase(ScoreRank.S, 0.99, HitResult.Great, HitResult.Miss)]
public void TestRanks(ScoreRank expected, double accuracy, params HitResult[] results)
{
var scoreProcessor = new ManiaScoreProcessor();
Dictionary<HitResult, int> resultsDict = new Dictionary<HitResult, int>();
foreach (var result in results)
resultsDict[result] = resultsDict.GetValueOrDefault(result) + 1;
Assert.That(scoreProcessor.RankFromScore(accuracy, resultsDict), Is.EqualTo(expected));
}
}
}
@@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods
CreateModTest(new ModTestData
{
Autoplay = true,
Beatmap = new ManiaBeatmap(new StageDefinition(1))
CreateBeatmap = () => new ManiaBeatmap(new StageDefinition(1))
{
HitObjects = new List<ManiaHitObject>
{
@@ -29,7 +29,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods
&& Precision.AlmostEquals(Player.ScoreProcessor.Accuracy.Value, 0.9836, 0.01)
&& Player.ScoreProcessor.TotalScore.Value == 946_049,
Autoplay = false,
Beatmap = new Beatmap
CreateBeatmap = () => new Beatmap
{
BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo },
Difficulty = { OverallDifficulty = 10 },
@@ -56,7 +56,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods
&& Player.ScoreProcessor.Accuracy.Value == 1
&& Player.ScoreProcessor.TotalScore.Value == (long)(1_000_000 * doubleTime.ScoreMultiplier),
Autoplay = false,
Beatmap = new Beatmap
CreateBeatmap = () => new Beatmap
{
BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo },
Difficulty = { OverallDifficulty = 10 },
@@ -74,7 +74,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods
CreateModTest(new ModTestData
{
Mod = new ManiaModHidden(),
Beatmap = new Beatmap
CreateBeatmap = () => new Beatmap
{
HitObjects = Enumerable.Range(1, 100).Select(i => (HitObject)new Note { StartTime = 1000 + 200 * i }).ToList(),
Breaks = { new BreakPeriod(2000, 28000) }
@@ -74,7 +74,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods
CreateModTest(new ModTestData
{
Mod = new ManiaModHidden(),
Beatmap = new Beatmap
CreateBeatmap = () => new Beatmap
{
HitObjects = Enumerable.Range(1, 100).Select(i => (HitObject)new Note { StartTime = 1000 + 200 * i }).ToList(),
Breaks = { new BreakPeriod(2000, 28000) }
@@ -36,7 +36,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods
Mod = new ManiaModPerfect(),
PassCondition = () => ((ModFailConditionTestPlayer)Player).CheckFailed(false),
Autoplay = false,
Beatmap = new Beatmap
CreateBeatmap = () => new Beatmap
{
HitObjects = new List<HitObject>
{
@@ -59,7 +59,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods
Mod = new ManiaModPerfect(),
PassCondition = () => ((ModFailConditionTestPlayer)Player).CheckFailed(true) && Player.Results.Count == 2,
Autoplay = false,
Beatmap = new Beatmap
CreateBeatmap = () => new Beatmap
{
HitObjects = new List<HitObject>
{
@@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods
Mod = new ManiaModSuddenDeath(),
PassCondition = () => ((ModFailConditionTestPlayer)Player).CheckFailed(false),
Autoplay = false,
Beatmap = new Beatmap
CreateBeatmap = () => new Beatmap
{
HitObjects = new List<HitObject>
{
@@ -51,7 +51,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods
Mod = new ManiaModSuddenDeath(),
PassCondition = () => ((ModFailConditionTestPlayer)Player).CheckFailed(true) && Player.Results.Count == 2,
Autoplay = false,
Beatmap = new Beatmap
CreateBeatmap = () => new Beatmap
{
HitObjects = new List<HitObject>
{
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,60 @@
{
"Mappings": [
{
"RandomW": 273071671,
"RandomX": 842502087,
"RandomY": 3579807591,
"RandomZ": 273326509,
"StartTime": 11783.0,
"Objects": [
{
"StartTime": 11783.0,
"EndTime": 15116.0,
"Column": 0
}
]
},
{
"RandomW": 2659271247,
"RandomX": 3579807591,
"RandomY": 273326509,
"RandomZ": 273071671,
"StartTime": 91545.0,
"Objects": [
{
"StartTime": 91545.0,
"EndTime": 92735.0,
"Column": 0
}
]
},
{
"RandomW": 3083635271,
"RandomX": 273326509,
"RandomY": 273071671,
"RandomZ": 2659271247,
"StartTime": 152497.0,
"Objects": [
{
"StartTime": 152497.0,
"EndTime": 153687.0,
"Column": 1
}
]
},
{
"RandomW": 4073591514,
"RandomX": 273071671,
"RandomY": 2659271247,
"RandomZ": 3083635271,
"StartTime": 231545.0,
"Objects": [
{
"StartTime": 231545.0,
"EndTime": 232974.0,
"Column": 3
}
]
}
]
}
@@ -0,0 +1,27 @@
osu file format v14
[General]
Mode: 3
[Difficulty]
HPDrainRate:5
CircleSize:4
OverallDifficulty:5
ApproachRate:0
SliderMultiplier:2.6
SliderTickRate:1
[TimingPoints]
355,476.190476190476,4,2,1,60,1,0
60652,-100,4,2,1,60,0,1
92735,-100,4,2,1,60,0,0
121485,-100,4,2,1,60,0,1
153688,-100,4,2,1,60,0,0
182497,-100,4,2,1,60,0,1
213688,-100,4,2,1,60,0,0
[HitObjects]
256,192,11783,12,0,15116,0:0:0:0:
256,192,91545,12,0,92735,0:0:0:0:
256,192,152497,12,0,153687,0:0:0:0:
256,192,231545,12,0,232974,0:0:0:0:
@@ -1,9 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\osu.TestProject.props" />
<ItemGroup Label="Package References">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="NUnit" Version="3.14.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
</ItemGroup>
<PropertyGroup Label="Project">
<OutputType>WinExe</OutputType>
@@ -7,11 +7,13 @@ using System.Linq;
using System.Collections.Generic;
using System.Threading;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Legacy;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Mania.Beatmaps.Patterns;
using osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Rulesets.Scoring.Legacy;
using osu.Game.Utils;
using osuTK;
@@ -124,16 +126,109 @@ namespace osu.Game.Rulesets.Mania.Beatmaps
protected override IEnumerable<ManiaHitObject> ConvertHitObject(HitObject original, IBeatmap beatmap, CancellationToken cancellationToken)
{
if (original is ManiaHitObject maniaOriginal)
{
yield return maniaOriginal;
LegacyHitObjectType legacyType;
yield break;
switch (original)
{
case ManiaHitObject maniaObj:
{
yield return maniaObj;
yield break;
}
case IHasLegacyHitObjectType legacy:
legacyType = legacy.LegacyType & LegacyHitObjectType.ObjectTypes;
break;
case IHasPath:
legacyType = LegacyHitObjectType.Slider;
break;
case IHasDuration:
legacyType = LegacyHitObjectType.Hold;
break;
default:
legacyType = LegacyHitObjectType.Circle;
break;
}
var objects = IsForCurrentRuleset ? generateSpecific(original, beatmap) : generateConverted(original, beatmap);
foreach (ManiaHitObject obj in objects)
yield return obj;
double startTime = original.StartTime;
double endTime = (original as IHasDuration)?.EndTime ?? startTime;
Vector2 position = (original as IHasPosition)?.Position ?? Vector2.Zero;
PatternGenerator conversion;
switch (legacyType)
{
case LegacyHitObjectType.Circle:
if (IsForCurrentRuleset)
{
conversion = new PassThroughPatternGenerator(Random, original, beatmap, TotalColumns, lastPattern);
recordNote(startTime, position);
}
else
{
// Note: The density is used during the pattern generator constructor, and intentionally computed first.
computeDensity(startTime);
conversion = new HitCirclePatternGenerator(Random, original, beatmap, TotalColumns, lastPattern, lastTime, lastPosition, density, lastStair);
recordNote(startTime, position);
}
break;
case LegacyHitObjectType.Slider:
if (IsForCurrentRuleset)
{
conversion = new PassThroughPatternGenerator(Random, original, beatmap, TotalColumns, lastPattern);
recordNote(original.StartTime, position);
}
else
{
var generator = new SliderPatternGenerator(Random, original, beatmap, TotalColumns, lastPattern);
conversion = generator;
for (int i = 0; i <= generator.SpanCount; i++)
{
double time = original.StartTime + generator.SegmentDuration * i;
recordNote(time, position);
computeDensity(time);
}
}
break;
case LegacyHitObjectType.Spinner:
// Note: Some older mania-specific beatmaps can have spinners that are converted rather than passed through.
// Newer beatmaps will usually use the "hold" hitobject type below.
conversion = new SpinnerPatternGenerator(Random, original, beatmap, TotalColumns, lastPattern);
recordNote(endTime, new Vector2(256, 192));
computeDensity(endTime);
break;
case LegacyHitObjectType.Hold:
conversion = new PassThroughPatternGenerator(Random, original, beatmap, TotalColumns, lastPattern);
recordNote(endTime, position);
computeDensity(endTime);
break;
default:
throw new ArgumentException($"Invalid legacy object type: {legacyType}", nameof(original));
}
foreach (var newPattern in conversion.Generate())
{
if (conversion is HitCirclePatternGenerator circleGenerator)
lastStair = circleGenerator.StairType;
if (conversion is HitCirclePatternGenerator || conversion is SliderPatternGenerator)
lastPattern = newPattern;
foreach (var obj in newPattern.HitObjects)
yield return obj;
}
}
private readonly LimitedCapacityQueue<double> prevNoteTimes = new LimitedCapacityQueue<double>(max_notes_for_density);
@@ -156,135 +251,5 @@ namespace osu.Game.Rulesets.Mania.Beatmaps
lastTime = time;
lastPosition = position;
}
/// <summary>
/// Method that generates hit objects for osu!mania specific beatmaps.
/// </summary>
/// <param name="original">The original hit object.</param>
/// <param name="originalBeatmap">The original beatmap. This is used to look-up any values dependent on a fully-loaded beatmap.</param>
/// <returns>The hit objects generated.</returns>
private IEnumerable<ManiaHitObject> generateSpecific(HitObject original, IBeatmap originalBeatmap)
{
var generator = new SpecificBeatmapPatternGenerator(Random, original, originalBeatmap, TotalColumns, lastPattern);
foreach (var newPattern in generator.Generate())
{
lastPattern = newPattern;
foreach (var obj in newPattern.HitObjects)
yield return obj;
}
}
/// <summary>
/// Method that generates hit objects for non-osu!mania beatmaps.
/// </summary>
/// <param name="original">The original hit object.</param>
/// <param name="originalBeatmap">The original beatmap. This is used to look-up any values dependent on a fully-loaded beatmap.</param>
/// <returns>The hit objects generated.</returns>
private IEnumerable<ManiaHitObject> generateConverted(HitObject original, IBeatmap originalBeatmap)
{
Patterns.PatternGenerator? conversion = null;
switch (original)
{
case IHasPath:
{
var generator = new PathObjectPatternGenerator(Random, original, originalBeatmap, TotalColumns, lastPattern);
conversion = generator;
var positionData = original as IHasPosition;
for (int i = 0; i <= generator.SpanCount; i++)
{
double time = original.StartTime + generator.SegmentDuration * i;
recordNote(time, positionData?.Position ?? Vector2.Zero);
computeDensity(time);
}
break;
}
case IHasDuration endTimeData:
{
conversion = new EndTimeObjectPatternGenerator(Random, original, originalBeatmap, TotalColumns, lastPattern);
recordNote(endTimeData.EndTime, new Vector2(256, 192));
computeDensity(endTimeData.EndTime);
break;
}
case IHasPosition positionData:
{
computeDensity(original.StartTime);
conversion = new HitObjectPatternGenerator(Random, original, originalBeatmap, TotalColumns, lastPattern, lastTime, lastPosition, density, lastStair);
recordNote(original.StartTime, positionData.Position);
break;
}
}
if (conversion == null)
yield break;
foreach (var newPattern in conversion.Generate())
{
lastPattern = conversion is EndTimeObjectPatternGenerator ? lastPattern : newPattern;
lastStair = (conversion as HitObjectPatternGenerator)?.StairType ?? lastStair;
foreach (var obj in newPattern.HitObjects)
yield return obj;
}
}
/// <summary>
/// A pattern generator for osu!mania-specific beatmaps.
/// </summary>
private class SpecificBeatmapPatternGenerator : Patterns.Legacy.PatternGenerator
{
public SpecificBeatmapPatternGenerator(LegacyRandom random, HitObject hitObject, IBeatmap beatmap, int totalColumns, Pattern previousPattern)
: base(random, hitObject, beatmap, previousPattern, totalColumns)
{
}
public override IEnumerable<Pattern> Generate()
{
yield return generate();
}
private Pattern generate()
{
var positionData = HitObject as IHasXPosition;
int column = GetColumn(positionData?.X ?? 0);
var pattern = new Pattern();
if (HitObject is IHasDuration endTimeData)
{
pattern.Add(new HoldNote
{
StartTime = HitObject.StartTime,
Duration = endTimeData.Duration,
Column = column,
Samples = HitObject.Samples,
NodeSamples = (HitObject as IHasRepeats)?.NodeSamples ?? HoldNote.CreateDefaultNodeSamples(HitObject)
});
}
else if (HitObject is IHasXPosition)
{
pattern.Add(new Note
{
StartTime = HitObject.StartTime,
Samples = HitObject.Samples,
Column = column
});
}
return pattern;
}
}
}
}
@@ -16,13 +16,16 @@ using osu.Game.Utils;
namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy
{
internal class HitObjectPatternGenerator : PatternGenerator
/// <summary>
/// Converter for legacy "HitCircle" hit objects.
/// </summary>
internal class HitCirclePatternGenerator : LegacyPatternGenerator
{
public PatternType StairType { get; private set; }
private readonly PatternType convertType;
public HitObjectPatternGenerator(LegacyRandom random, HitObject hitObject, IBeatmap beatmap, int totalColumns, Pattern previousPattern, double previousTime, Vector2 previousPosition,
public HitCirclePatternGenerator(LegacyRandom random, HitObject hitObject, IBeatmap beatmap, int totalColumns, Pattern previousPattern, double previousTime, Vector2 previousPosition,
double density, PatternType lastStair)
: base(random, hitObject, beatmap, previousPattern, totalColumns)
{
@@ -114,10 +117,10 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy
}
if (convertType.HasFlag(PatternType.Cycle) && PreviousPattern.HitObjects.Count() == 1
// If we convert to 7K + 1, let's not overload the special key
&& (TotalColumns != 8 || lastColumn != 0)
// Make sure the last column was not the centre column
&& (TotalColumns % 2 == 0 || lastColumn != TotalColumns / 2))
// If we convert to 7K + 1, let's not overload the special key
&& (TotalColumns != 8 || lastColumn != 0)
// Make sure the last column was not the centre column
&& (TotalColumns % 2 == 0 || lastColumn != TotalColumns / 2))
{
// Generate a new pattern by cycling backwards (similar to Reverse but for only one hit object)
int column = RandomStart + TotalColumns - lastColumn - 1;
@@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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 JetBrains.Annotations;
@@ -15,7 +13,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy
/// <summary>
/// A pattern generator for legacy hit objects.
/// </summary>
internal abstract class PatternGenerator : Patterns.PatternGenerator
internal abstract class LegacyPatternGenerator : PatternGenerator
{
/// <summary>
/// The column index at which to start generating random notes.
@@ -27,7 +25,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy
/// </summary>
protected readonly LegacyRandom Random;
protected PatternGenerator(LegacyRandom random, HitObject hitObject, IBeatmap beatmap, Pattern previousPattern, int totalColumns)
protected LegacyPatternGenerator(LegacyRandom random, HitObject hitObject, IBeatmap beatmap, Pattern previousPattern, int totalColumns)
: base(hitObject, beatmap, totalColumns, previousPattern)
{
ArgumentNullException.ThrowIfNull(random);
@@ -96,8 +94,8 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy
if (conversionDifficulty != null)
return conversionDifficulty.Value;
HitObject lastObject = Beatmap.HitObjects.LastOrDefault();
HitObject firstObject = Beatmap.HitObjects.FirstOrDefault();
HitObject? lastObject = Beatmap.HitObjects.LastOrDefault();
HitObject? firstObject = Beatmap.HitObjects.FirstOrDefault();
// Drain time in seconds
int drainTime = (int)(((lastObject?.StartTime ?? 0) - (firstObject?.StartTime ?? 0) - Beatmap.TotalBreakTime) / 1000);
@@ -132,13 +130,13 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy
/// <param name="nextColumn">A function to retrieve the next column. If null, a randomisation scheme will be used.</param>
/// <param name="validation">A function to perform additional validation checks to determine if a column is a valid candidate for a <see cref="HitObject"/>.</param>
/// <param name="lowerBound">The minimum column index. If null, <see cref="RandomStart"/> is used.</param>
/// <param name="upperBound">The maximum column index. If null, <see cref="Patterns.PatternGenerator.TotalColumns">TotalColumns</see> is used.</param>
/// <param name="upperBound">The maximum column index. If null, <see cref="PatternGenerator.TotalColumns">TotalColumns</see> is used.</param>
/// <param name="patterns">A list of patterns for which the validity of a column should be checked against.
/// A column is not a valid candidate if a <see cref="HitObject"/> occupies the same column in any of the patterns.</param>
/// <returns>A column which has passed the <paramref name="validation"/> check and for which there are no
/// <see cref="HitObject"/>s in any of <paramref name="patterns"/> occupying the same column.</returns>
/// <exception cref="NotEnoughColumnsException">If there are no valid candidate columns.</exception>
protected int FindAvailableColumn(int initialColumn, int? lowerBound = null, int? upperBound = null, Func<int, int> nextColumn = null, [InstantHandle] Func<int, bool> validation = null,
protected int FindAvailableColumn(int initialColumn, int? lowerBound = null, int? upperBound = null, Func<int, int>? nextColumn = null, [InstantHandle] Func<int, bool>? validation = null,
params Pattern[] patterns)
{
lowerBound ??= RandomStart;
@@ -189,7 +187,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy
/// Returns a random column index in the range [<paramref name="lowerBound"/>, <paramref name="upperBound"/>).
/// </summary>
/// <param name="lowerBound">The minimum column index. If null, <see cref="RandomStart"/> is used.</param>
/// <param name="upperBound">The maximum column index. If null, <see cref="Patterns.PatternGenerator.TotalColumns"/> is used.</param>
/// <param name="upperBound">The maximum column index. If null, <see cref="PatternGenerator.TotalColumns"/> is used.</param>
protected int GetRandomColumn(int? lowerBound = null, int? upperBound = null) => Random.Next(lowerBound ?? RandomStart, upperBound ?? TotalColumns);
/// <summary>
@@ -0,0 +1,55 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Utils;
namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy
{
/// <summary>
/// A simple generator which, for any object, if the hitobject has an end time
/// it becomes a <see cref="HoldNote"/> or otherwise a <see cref="Note"/>.
/// </summary>
internal class PassThroughPatternGenerator : LegacyPatternGenerator
{
public PassThroughPatternGenerator(LegacyRandom random, HitObject hitObject, IBeatmap beatmap, int totalColumns, Pattern previousPattern)
: base(random, hitObject, beatmap, previousPattern, totalColumns)
{
}
public override IEnumerable<Pattern> Generate()
{
var positionData = HitObject as IHasXPosition;
int column = GetColumn(positionData?.X ?? 0);
var pattern = new Pattern();
if (HitObject is IHasDuration endTimeData)
{
pattern.Add(new HoldNote
{
StartTime = HitObject.StartTime,
Duration = endTimeData.Duration,
Column = column,
Samples = HitObject.Samples,
NodeSamples = (HitObject as IHasRepeats)?.NodeSamples ?? HoldNote.CreateDefaultNodeSamples(HitObject)
});
}
else
{
pattern.Add(new Note
{
StartTime = HitObject.StartTime,
Samples = HitObject.Samples,
Column = column
});
}
yield return pattern;
}
}
}
@@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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;
@@ -19,9 +17,9 @@ using osu.Game.Utils;
namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy
{
/// <summary>
/// A pattern generator for IHasDistance hit objects.
/// Converter for legacy "Slider" hit objects.
/// </summary>
internal class PathObjectPatternGenerator : PatternGenerator
internal class SliderPatternGenerator : LegacyPatternGenerator
{
public readonly int StartTime;
public readonly int EndTime;
@@ -30,7 +28,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy
private PatternType convertType;
public PathObjectPatternGenerator(LegacyRandom random, HitObject hitObject, IBeatmap beatmap, int totalColumns, Pattern previousPattern)
public SliderPatternGenerator(LegacyRandom random, HitObject hitObject, IBeatmap beatmap, int totalColumns, Pattern previousPattern)
: base(random, hitObject, beatmap, previousPattern, totalColumns)
{
convertType = PatternType.None;
@@ -484,9 +482,9 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy
/// Retrieves the list of node samples that occur at time greater than or equal to <paramref name="time"/>.
/// </summary>
/// <param name="time">The time to retrieve node samples at.</param>
private IList<IList<HitSampleInfo>> nodeSamplesAt(int time)
private IList<IList<HitSampleInfo>>? nodeSamplesAt(int time)
{
if (!(HitObject is IHasPathWithRepeats curveData))
if (HitObject is not IHasPathWithRepeats curveData)
return null;
int index = SegmentDuration == 0 ? 0 : (time - StartTime) / SegmentDuration;
@@ -12,12 +12,15 @@ using osu.Game.Utils;
namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy
{
internal class EndTimeObjectPatternGenerator : PatternGenerator
/// <summary>
/// Converter for legacy "Spinner" hit objects.
/// </summary>
internal class SpinnerPatternGenerator : LegacyPatternGenerator
{
private readonly int endTime;
private readonly PatternType convertType;
public EndTimeObjectPatternGenerator(LegacyRandom random, HitObject hitObject, IBeatmap beatmap, int totalColumns, Pattern previousPattern)
public SpinnerPatternGenerator(LegacyRandom random, HitObject hitObject, IBeatmap beatmap, int totalColumns, Pattern previousPattern)
: base(random, hitObject, beatmap, previousPattern, totalColumns)
{
endTime = (int)((HitObject as IHasDuration)?.EndTime ?? 0);
@@ -1,9 +1,8 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using osu.Game.Rulesets.Mania.Objects;
@@ -14,8 +13,8 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns
/// </summary>
internal class Pattern
{
private List<ManiaHitObject> hitObjects;
private HashSet<int> containedColumns;
private List<ManiaHitObject>? hitObjects;
private HashSet<int>? containedColumns;
/// <summary>
/// All the hit objects contained in this pattern.
@@ -72,6 +71,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns
containedColumns?.Clear();
}
[MemberNotNull(nameof(hitObjects), nameof(containedColumns))]
private void prepareStorage()
{
hitObjects ??= new List<ManiaHitObject>();
@@ -21,7 +21,7 @@ namespace osu.Game.Rulesets.Mania.Configuration
{
base.InitialiseDefaults();
SetDefault(ManiaRulesetSetting.ScrollSpeed, 8, 1, 40);
SetDefault(ManiaRulesetSetting.ScrollSpeed, 8.0, 1.0, 40.0, 0.1);
SetDefault(ManiaRulesetSetting.ScrollDirection, ManiaScrollingDirection.Down);
SetDefault(ManiaRulesetSetting.TimingBasedNoteColouring, false);
@@ -31,7 +31,7 @@ namespace osu.Game.Rulesets.Mania.Configuration
if (Get<double?>(ManiaRulesetSetting.ScrollTime) is double scrollTime)
{
SetValue(ManiaRulesetSetting.ScrollSpeed, (int)Math.Round(DrawableManiaRuleset.MAX_TIME_RANGE / scrollTime));
SetValue(ManiaRulesetSetting.ScrollSpeed, Math.Round(DrawableManiaRuleset.MAX_TIME_RANGE / scrollTime));
SetValue<double?>(ManiaRulesetSetting.ScrollTime, null);
}
#pragma warning restore CS0618
@@ -39,7 +39,7 @@ namespace osu.Game.Rulesets.Mania.Configuration
public override TrackedSettings CreateTrackedSettings() => new TrackedSettings
{
new TrackedSetting<int>(ManiaRulesetSetting.ScrollSpeed,
new TrackedSetting<double>(ManiaRulesetSetting.ScrollSpeed,
speed => new SettingDescription(
rawValue: speed,
name: RulesetSettingsStrings.ScrollSpeed,
@@ -24,12 +24,12 @@ namespace osu.Game.Rulesets.Mania.Difficulty
{
public class ManiaDifficultyCalculator : DifficultyCalculator
{
private const double star_scaling_factor = 0.018;
private const double difficulty_multiplier = 0.018;
private readonly bool isForCurrentRuleset;
private readonly double originalOverallDifficulty;
public override int Version => 20230817;
public override int Version => 20241007;
public ManiaDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap)
@@ -48,7 +48,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty
ManiaDifficultyAttributes attributes = new ManiaDifficultyAttributes
{
StarRating = skills[0].DifficultyValue() * star_scaling_factor,
StarRating = skills[0].DifficultyValue() * difficulty_multiplier,
Mods = mods,
// In osu-stable mania, rate-adjustment mods don't affect the hit window.
// This is done the way it is to introduce fractional differences in order to match osu-stable for the time being.
@@ -38,9 +38,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty
countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss);
scoreAccuracy = calculateCustomAccuracy();
// Arbitrary initial value for scaling pp in order to standardize distributions across game modes.
// The specific number has no intrinsic meaning and can be adjusted as needed.
double multiplier = 8.0;
double multiplier = 1.0;
if (score.Mods.Any(m => m is ModNoFail))
multiplier *= 0.75;
@@ -59,9 +57,9 @@ namespace osu.Game.Rulesets.Mania.Difficulty
private double computeDifficultyValue(ManiaDifficultyAttributes attributes)
{
double difficultyValue = Math.Pow(Math.Max(attributes.StarRating - 0.15, 0.05), 2.2) // Star rating to pp curve
* Math.Max(0, 5 * scoreAccuracy - 4) // From 80% accuracy, 1/20th of total pp is awarded per additional 1% accuracy
* (1 + 0.1 * Math.Min(1, totalHits / 1500)); // Length bonus, capped at 1500 notes
double difficultyValue = 8.0 * Math.Pow(Math.Max(attributes.StarRating - 0.15, 0.05), 2.2) // Star rating to pp curve
* Math.Max(0, 5 * scoreAccuracy - 4) // From 80% accuracy, 1/20th of total pp is awarded per additional 1% accuracy
* (1 + 0.1 * Math.Min(1, totalHits / 1500)); // Length bonus, capped at 1500 notes
return difficultyValue;
}
@@ -5,6 +5,7 @@ using System;
using osu.Framework.Utils;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mania.Difficulty.Preprocessing;
using osu.Game.Rulesets.Mods;
@@ -73,7 +74,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills
// 0.0 +--------+-+---------------> Release Difference / ms
// release_threshold
if (isOverlapping)
holdAddition = 1 / (1 + Math.Exp(0.27 * (release_threshold - closestEndTime)));
holdAddition = DifficultyCalculationUtils.Logistic(x: closestEndTime, multiplier: 0.27, midpointOffset: release_threshold);
// Decay and increase individualStrains in own column
individualStrains[column] = applyDecay(individualStrains[column], startTime - startTimes[column], individual_decay_base);

Some files were not shown because too many files have changed in this diff Show More