1
0
mirror of https://github.com/ppy/osu.git synced 2025-01-18 05:42:56 +08:00

Merge branch 'master' into music-player-queue-by-results

This commit is contained in:
Wezwery 2024-11-15 23:58:33 +02:00 committed by GitHub
commit e8197bf25f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
141 changed files with 2219 additions and 1405 deletions

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 }}"

View File

@ -103,27 +103,11 @@ permissions:
env:
EXECUTION_ID: execution-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}
defaults:
run:
shell: bash -euo pipefail {0}
jobs:
master-environment:
name: Save master environment
runs-on: ubuntu-latest
if: ${{ github.event_name == 'workflow_dispatch' || contains(github.event.comment.body, '!diffcalc') }}
outputs:
HEAD: ${{ steps.get-head.outputs.HEAD }}
steps:
- name: Checkout osu
uses: actions/checkout@v4
with:
ref: master
sparse-checkout: |
README.md
- name: Get HEAD ref
id: get-head
run: |
ref=$(git log -1 --format='%H')
echo "HEAD=https://github.com/${{ github.repository }}/commit/${ref}" >> "${GITHUB_OUTPUT}"
check-permissions:
name: Check permissions
runs-on: ubuntu-latest
@ -139,9 +123,23 @@ jobs:
done
exit 1
run-diffcalc:
name: Run spreadsheet generator
needs: check-permissions
uses: ./.github/workflows/_diffcalc_processor.yml
with:
# Can't reference env... Why GitHub, WHY?
id: execution-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}
head-sha: https://github.com/${{ github.repository }}/commit/${{ github.event.pull_request.head.sha || github.sha }}
pr-url: ${{ github.event.issue.pull_request.html_url || '' }}
pr-text: ${{ github.event.comment.body || '' }}
dispatch-inputs: ${{ (github.event.type == 'workflow_dispatch' && toJSON(inputs)) || '' }}
secrets:
DIFFCALC_GOOGLE_CREDENTIALS: ${{ secrets.DIFFCALC_GOOGLE_CREDENTIALS }}
create-comment:
name: Create PR comment
needs: [ master-environment, check-permissions ]
needs: check-permissions
runs-on: ubuntu-latest
if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request }}
steps:
@ -154,249 +152,34 @@ jobs:
*This comment will update on completion*
directory:
name: Prepare directory
needs: check-permissions
runs-on: self-hosted
outputs:
GENERATOR_DIR: ${{ steps.set-outputs.outputs.GENERATOR_DIR }}
GENERATOR_ENV: ${{ steps.set-outputs.outputs.GENERATOR_ENV }}
GOOGLE_CREDS_FILE: ${{ steps.set-outputs.outputs.GOOGLE_CREDS_FILE }}
steps:
- name: Checkout diffcalc-sheet-generator
uses: actions/checkout@v4
with:
path: ${{ env.EXECUTION_ID }}
repository: 'smoogipoo/diffcalc-sheet-generator'
- name: Set outputs
id: set-outputs
run: |
echo "GENERATOR_DIR=${{ github.workspace }}/${{ env.EXECUTION_ID }}" >> "${GITHUB_OUTPUT}"
echo "GENERATOR_ENV=${{ github.workspace }}/${{ env.EXECUTION_ID }}/.env" >> "${GITHUB_OUTPUT}"
echo "GOOGLE_CREDS_FILE=${{ github.workspace }}/${{ env.EXECUTION_ID }}/google-credentials.json" >> "${GITHUB_OUTPUT}"
environment:
name: Setup environment
needs: [ master-environment, directory ]
runs-on: self-hosted
env:
VARS_JSON: ${{ toJSON(vars) }}
steps:
- name: Add base environment
run: |
# Required by diffcalc-sheet-generator
cp '${{ needs.directory.outputs.GENERATOR_DIR }}/.env.sample' "${{ needs.directory.outputs.GENERATOR_ENV }}"
# Add Google credentials
echo '${{ secrets.DIFFCALC_GOOGLE_CREDENTIALS }}' | base64 -d > "${{ needs.directory.outputs.GOOGLE_CREDS_FILE }}"
# Add repository variables
echo "${VARS_JSON}" | jq -c '. | to_entries | .[]' | while read -r line; do
opt=$(jq -r '.key' <<< ${line})
val=$(jq -r '.value' <<< ${line})
if [[ "${opt}" =~ ^DIFFCALC_ ]]; then
optNoPrefix=$(echo "${opt}" | cut -d '_' -f2-)
sed -i "s;^${optNoPrefix}=.*$;${optNoPrefix}=${val};" "${{ needs.directory.outputs.GENERATOR_ENV }}"
fi
done
- name: Add master environment
run: |
sed -i "s;^OSU_A=.*$;OSU_A=${{ needs.master-environment.outputs.HEAD }};" "${{ needs.directory.outputs.GENERATOR_ENV }}"
- name: Add pull-request environment
if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request }}
run: |
sed -i "s;^OSU_B=.*$;OSU_B=${{ github.event.issue.pull_request.html_url }};" "${{ needs.directory.outputs.GENERATOR_ENV }}"
- name: Add comment environment
if: ${{ github.event_name == 'issue_comment' }}
env:
COMMENT_BODY: ${{ github.event.comment.body }}
run: |
# Add comment environment
echo "$COMMENT_BODY" | sed -r 's/\r$//' | grep -E '^\w+=' | while read -r line; do
opt=$(echo "${line}" | cut -d '=' -f1)
sed -i "s;^${opt}=.*$;${line};" "${{ needs.directory.outputs.GENERATOR_ENV }}"
done
- name: Add dispatch environment
if: ${{ github.event_name == 'workflow_dispatch' }}
run: |
sed -i 's;^OSU_B=.*$;OSU_B=${{ inputs.osu-b }};' "${{ needs.directory.outputs.GENERATOR_ENV }}"
sed -i 's/^RULESET=.*$/RULESET=${{ inputs.ruleset }}/' "${{ needs.directory.outputs.GENERATOR_ENV }}"
sed -i 's/^GENERATORS=.*$/GENERATORS=${{ inputs.generators }}/' "${{ needs.directory.outputs.GENERATOR_ENV }}"
if [[ '${{ inputs.osu-a }}' != 'latest' ]]; then
sed -i 's;^OSU_A=.*$;OSU_A=${{ inputs.osu-a }};' "${{ needs.directory.outputs.GENERATOR_ENV }}"
fi
if [[ '${{ inputs.difficulty-calculator-a }}' != 'latest' ]]; then
sed -i 's;^DIFFICULTY_CALCULATOR_A=.*$;DIFFICULTY_CALCULATOR_A=${{ inputs.difficulty-calculator-a }};' "${{ needs.directory.outputs.GENERATOR_ENV }}"
fi
if [[ '${{ inputs.difficulty-calculator-b }}' != 'latest' ]]; then
sed -i 's;^DIFFICULTY_CALCULATOR_B=.*$;DIFFICULTY_CALCULATOR_B=${{ inputs.difficulty-calculator-b }};' "${{ needs.directory.outputs.GENERATOR_ENV }}"
fi
if [[ '${{ inputs.score-processor-a }}' != 'latest' ]]; then
sed -i 's;^SCORE_PROCESSOR_A=.*$;SCORE_PROCESSOR_A=${{ inputs.score-processor-a }};' "${{ needs.directory.outputs.GENERATOR_ENV }}"
fi
if [[ '${{ inputs.score-processor-b }}' != 'latest' ]]; then
sed -i 's;^SCORE_PROCESSOR_B=.*$;SCORE_PROCESSOR_B=${{ inputs.score-processor-b }};' "${{ needs.directory.outputs.GENERATOR_ENV }}"
fi
if [[ '${{ inputs.converts }}' == 'true' ]]; then
sed -i 's/^NO_CONVERTS=.*$/NO_CONVERTS=0/' "${{ needs.directory.outputs.GENERATOR_ENV }}"
else
sed -i 's/^NO_CONVERTS=.*$/NO_CONVERTS=1/' "${{ needs.directory.outputs.GENERATOR_ENV }}"
fi
if [[ '${{ inputs.ranked-only }}' == 'true' ]]; then
sed -i 's/^RANKED_ONLY=.*$/RANKED_ONLY=1/' "${{ needs.directory.outputs.GENERATOR_ENV }}"
else
sed -i 's/^RANKED_ONLY=.*$/RANKED_ONLY=0/' "${{ needs.directory.outputs.GENERATOR_ENV }}"
fi
scores:
name: Setup scores
needs: [ directory, environment ]
runs-on: self-hosted
steps:
- name: Query latest data
id: query
run: |
ruleset=$(cat ${{ needs.directory.outputs.GENERATOR_ENV }} | grep -E '^RULESET=' | cut -d '=' -f2-)
performance_data_name=$(curl -s "https://data.ppy.sh/" | grep "performance_${ruleset}_top_1000\b" | tail -1 | awk -F "'" '{print $2}' | sed 's/\.tar\.bz2//g')
echo "TARGET_DIR=${{ needs.directory.outputs.GENERATOR_DIR }}/sql/${ruleset}" >> "${GITHUB_OUTPUT}"
echo "DATA_NAME=${performance_data_name}" >> "${GITHUB_OUTPUT}"
echo "DATA_PKG=${performance_data_name}.tar.bz2" >> "${GITHUB_OUTPUT}"
- name: Restore cache
id: restore-cache
uses: maxnowack/local-cache@720e69c948191660a90aa1cf6a42fc4d2dacdf30 # v2
with:
path: ${{ steps.query.outputs.DATA_PKG }}
key: ${{ steps.query.outputs.DATA_NAME }}
- name: Download
if: steps.restore-cache.outputs.cache-hit != 'true'
run: |
wget -q -O "${{ steps.query.outputs.DATA_PKG }}" "https://data.ppy.sh/${{ steps.query.outputs.DATA_PKG }}"
- name: Extract
run: |
tar -I lbzip2 -xf "${{ steps.query.outputs.DATA_PKG }}"
rm -r "${{ steps.query.outputs.TARGET_DIR }}"
mv "${{ steps.query.outputs.DATA_NAME }}" "${{ steps.query.outputs.TARGET_DIR }}"
beatmaps:
name: Setup beatmaps
needs: directory
runs-on: self-hosted
steps:
- name: Query latest data
id: query
run: |
beatmaps_data_name=$(curl -s "https://data.ppy.sh/" | grep "osu_files" | tail -1 | awk -F "'" '{print $2}' | sed 's/\.tar\.bz2//g')
echo "TARGET_DIR=${{ needs.directory.outputs.GENERATOR_DIR }}/beatmaps" >> "${GITHUB_OUTPUT}"
echo "DATA_NAME=${beatmaps_data_name}" >> "${GITHUB_OUTPUT}"
echo "DATA_PKG=${beatmaps_data_name}.tar.bz2" >> "${GITHUB_OUTPUT}"
- name: Restore cache
id: restore-cache
uses: maxnowack/local-cache@720e69c948191660a90aa1cf6a42fc4d2dacdf30 # v2
with:
path: ${{ steps.query.outputs.DATA_PKG }}
key: ${{ steps.query.outputs.DATA_NAME }}
- name: Download
if: steps.restore-cache.outputs.cache-hit != 'true'
run: |
wget -q -O "${{ steps.query.outputs.DATA_PKG }}" "https://data.ppy.sh/${{ steps.query.outputs.DATA_PKG }}"
- name: Extract
run: |
tar -I lbzip2 -xf "${{ steps.query.outputs.DATA_PKG }}"
rm -r "${{ steps.query.outputs.TARGET_DIR }}"
mv "${{ steps.query.outputs.DATA_NAME }}" "${{ steps.query.outputs.TARGET_DIR }}"
generator:
name: Run generator
needs: [ directory, environment, scores, beatmaps ]
runs-on: self-hosted
timeout-minutes: 720
outputs:
TARGET: ${{ steps.run.outputs.TARGET }}
SPREADSHEET_LINK: ${{ steps.run.outputs.SPREADSHEET_LINK }}
steps:
- name: Run
id: run
run: |
# Add the GitHub token. This needs to be done here because it's unique per-job.
sed -i 's/^GH_TOKEN=.*$/GH_TOKEN=${{ github.token }}/' "${{ needs.directory.outputs.GENERATOR_ENV }}"
cd "${{ needs.directory.outputs.GENERATOR_DIR }}"
docker compose up --build --detach
docker compose logs --follow &
docker compose wait generator
link=$(docker compose logs --tail 10 generator | grep 'http' | sed -E 's/^.*(http.*)$/\1/')
target=$(cat "${{ needs.directory.outputs.GENERATOR_ENV }}" | grep -E '^OSU_B=' | cut -d '=' -f2-)
echo "TARGET=${target}" >> "${GITHUB_OUTPUT}"
echo "SPREADSHEET_LINK=${link}" >> "${GITHUB_OUTPUT}"
- name: Shutdown
if: ${{ always() }}
run: |
cd "${{ needs.directory.outputs.GENERATOR_DIR }}"
docker compose down --volumes
output-cli:
name: Output info
needs: generator
name: Info
needs: run-diffcalc
runs-on: ubuntu-latest
steps:
- name: Output info
run: |
echo "Target: ${{ needs.generator.outputs.TARGET }}"
echo "Spreadsheet: ${{ needs.generator.outputs.SPREADSHEET_LINK }}"
cleanup:
name: Cleanup
needs: [ directory, generator ]
if: ${{ always() && needs.directory.result == 'success' }}
runs-on: self-hosted
steps:
- name: Cleanup
run: |
rm -rf "${{ needs.directory.outputs.GENERATOR_DIR }}"
echo "Target: ${{ needs.run-diffcalc.outputs.target }}"
echo "Spreadsheet: ${{ needs.run-diffcalc.outputs.sheet }}"
update-comment:
name: Update PR comment
needs: [ create-comment, generator ]
needs: [ create-comment, run-diffcalc ]
runs-on: ubuntu-latest
if: ${{ always() && needs.create-comment.result == 'success' }}
steps:
- name: Update comment on success
if: ${{ needs.generator.result == 'success' }}
if: ${{ needs.run-diffcalc.result == 'success' }}
uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0
with:
comment_tag: ${{ env.EXECUTION_ID }}
mode: recreate
message: |
Target: ${{ needs.generator.outputs.TARGET }}
Spreadsheet: ${{ needs.generator.outputs.SPREADSHEET_LINK }}
Target: ${{ needs.run-diffcalc.outputs.target }}
Spreadsheet: ${{ needs.run-diffcalc.outputs.sheet }}
- name: Update comment on failure
if: ${{ needs.generator.result == 'failure' }}
if: ${{ needs.run-diffcalc.result == 'failure' }}
uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0
with:
comment_tag: ${{ env.EXECUTION_ID }}
@ -405,7 +188,7 @@ jobs:
Difficulty calculation failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- name: Update comment on cancellation
if: ${{ needs.generator.result == 'cancelled' }}
if: ${{ needs.run-diffcalc.result == 'cancelled' }}
uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0
with:
comment_tag: ${{ env.EXECUTION_ID }}

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'

View File

@ -10,7 +10,7 @@
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Framework.Android" Version="2024.1025.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2024.1112.0" />
</ItemGroup>
<PropertyGroup>
<!-- Fody does not handle Android build well, and warns when unchanged.

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
}
}

View File

@ -5,6 +5,7 @@ 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;
@ -106,7 +107,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
AddStep("select everything", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects));
AddStep("start drag", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<Column>().First());
InputManager.MoveMouseTo(this.ChildrenOfType<NoteSelectionBlueprint>().Single(blueprint => blueprint.IsSelected && blueprint.HitObject.StartTime == 0));
InputManager.PressButton(MouseButton.Left);
});
AddStep("end drag", () =>

View File

@ -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);

View File

@ -3,21 +3,39 @@
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
using osu.Game.Rulesets.Mania.Skinning.Default;
namespace osu.Game.Rulesets.Mania.Edit.Blueprints.Components
{
public partial class EditBodyPiece : DefaultBodyPiece
public partial class EditBodyPiece : CompositeDrawable
{
private readonly Container border;
public EditBodyPiece()
{
InternalChildren = new Drawable[]
{
border = new Container
{
RelativeSizeAxes = Axes.Both,
Masking = true,
BorderThickness = 3,
Child = new Box
{
RelativeSizeAxes = Axes.Both,
Alpha = 0,
AlwaysPresent = true,
},
},
};
}
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
AccentColour.Value = colours.Yellow;
Background.Alpha = 0.5f;
border.BorderColour = colours.YellowDarker;
}
protected override Drawable CreateForeground() => base.CreateForeground().With(d => d.Alpha = 0);
}
}

View File

@ -4,6 +4,7 @@
using System;
using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Input.Events;
using osu.Game.Graphics;
@ -26,10 +27,11 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints.Components
{
Height = DefaultNotePiece.NOTE_HEIGHT;
CornerRadius = 5;
Masking = true;
InternalChild = new DefaultNotePiece();
InternalChild = new EditNotePiece
{
RelativeSizeAxes = Axes.Both,
Height = 1,
};
}
protected override void LoadComplete()
@ -60,19 +62,23 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints.Components
{
base.OnDrag(e);
Dragging?.Invoke(e.ScreenSpaceMousePosition);
updateState();
}
protected override void OnDragEnd(DragEndEvent e)
{
base.OnDragEnd(e);
DragEnded?.Invoke();
updateState();
}
private void updateState()
{
InternalChild.Colour = Colour4.White;
var colour = colours.Yellow;
if (IsHovered)
if (IsHovered || IsDragged)
colour = colour.Lighten(1);
Colour = colour;

View File

@ -2,28 +2,63 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
using osu.Game.Rulesets.Mania.Skinning.Default;
using osu.Game.Rulesets.Mania.UI;
using osu.Game.Rulesets.UI.Scrolling;
using osuTK;
namespace osu.Game.Rulesets.Mania.Edit.Blueprints.Components
{
public partial class EditNotePiece : CompositeDrawable
{
private readonly Container border;
private readonly Box box;
[Resolved]
private Column? column { get; set; }
public EditNotePiece()
{
Height = DefaultNotePiece.NOTE_HEIGHT;
CornerRadius = 5;
Masking = true;
InternalChild = new DefaultNotePiece();
InternalChildren = new Drawable[]
{
border = new Container
{
RelativeSizeAxes = Axes.Both,
Masking = true,
BorderThickness = 3,
Child = new Box
{
RelativeSizeAxes = Axes.Both,
Alpha = 0,
AlwaysPresent = true,
},
},
box = new Box
{
RelativeSizeAxes = Axes.X,
Height = 3,
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
},
};
}
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
Colour = colours.Yellow;
border.BorderColour = colours.YellowDark;
box.Colour = colours.YellowLight;
}
protected override void Update()
{
base.Update();
if (column != null)
Scale = new Vector2(1, column.ScrollingInfo.Direction.Value == ScrollingDirection.Down ? 1 : -1);
}
}
}

View File

@ -4,8 +4,10 @@
using System;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Framework.Utils;
using osu.Game.Graphics;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Mania.Edit.Blueprints.Components;
using osu.Game.Rulesets.Mania.Objects;
@ -17,9 +19,9 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
{
public partial class HoldNotePlacementBlueprint : ManiaPlacementBlueprint<HoldNote>
{
private readonly EditBodyPiece bodyPiece;
private readonly EditNotePiece headPiece;
private readonly EditNotePiece tailPiece;
private EditBodyPiece bodyPiece = null!;
private Circle headPiece = null!;
private Circle tailPiece = null!;
[Resolved]
private IScrollingInfo scrollingInfo { get; set; } = null!;
@ -28,14 +30,29 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
public HoldNotePlacementBlueprint()
: base(new HoldNote())
{
}
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
RelativeSizeAxes = Axes.Both;
InternalChildren = new Drawable[]
{
bodyPiece = new EditBodyPiece { Origin = Anchor.TopCentre },
headPiece = new EditNotePiece { Origin = Anchor.Centre },
tailPiece = new EditNotePiece { Origin = Anchor.Centre }
headPiece = new Circle
{
Origin = Anchor.Centre,
Colour = colours.Yellow,
Height = 10
},
tailPiece = new Circle
{
Origin = Anchor.Centre,
Colour = colours.Yellow,
Height = 10
},
};
}

View File

@ -2,14 +2,14 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Mania.Edit.Blueprints.Components;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Objects.Drawables;
using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Screens.Edit;
using osuTK;
@ -17,9 +17,6 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
{
public partial class HoldNoteSelectionBlueprint : ManiaSelectionBlueprint<HoldNote>
{
[Resolved]
private OsuColour colours { get; set; } = null!;
[Resolved]
private IEditorChangeHandler? changeHandler { get; set; }
@ -29,9 +26,12 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
[Resolved]
private IPositionSnapProvider? positionSnapProvider { get; set; }
private EditBodyPiece body = null!;
private EditHoldNoteEndPiece head = null!;
private EditHoldNoteEndPiece tail = null!;
protected new DrawableHoldNote DrawableObject => (DrawableHoldNote)base.DrawableObject;
public HoldNoteSelectionBlueprint(HoldNote hold)
: base(hold)
{
@ -42,9 +42,17 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
{
InternalChildren = new Drawable[]
{
body = new EditBodyPiece
{
RelativeSizeAxes = Axes.Both,
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
},
head = new EditHoldNoteEndPiece
{
RelativeSizeAxes = Axes.X,
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
DragStarted = () => changeHandler?.BeginChange(),
Dragging = pos =>
{
@ -64,6 +72,8 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
tail = new EditHoldNoteEndPiece
{
RelativeSizeAxes = Axes.X,
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
DragStarted = () => changeHandler?.BeginChange(),
Dragging = pos =>
{
@ -79,19 +89,6 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
},
DragEnded = () => changeHandler?.EndChange(),
},
new Container
{
RelativeSizeAxes = Axes.Both,
Masking = true,
BorderThickness = 1,
BorderColour = colours.Yellow,
Child = new Box
{
RelativeSizeAxes = Axes.Both,
Alpha = 0,
AlwaysPresent = true,
}
}
};
}
@ -99,11 +96,23 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
{
base.Update();
head.Height = DrawableObject.Head.DrawHeight;
head.Y = HitObjectContainer.PositionAtTime(HitObject.Head.StartTime, HitObject.StartTime);
tail.Height = DrawableObject.Tail.DrawHeight;
tail.Y = HitObjectContainer.PositionAtTime(HitObject.Tail.StartTime, HitObject.StartTime);
Height = HitObjectContainer.LengthAtTime(HitObject.StartTime, HitObject.EndTime) + tail.DrawHeight;
}
protected override void OnDirectionChanged(ValueChangedEvent<ScrollingDirection> direction)
{
Origin = direction.NewValue == ScrollingDirection.Down ? Anchor.BottomCentre : Anchor.TopCentre;
foreach (var child in InternalChildren)
child.Anchor = Origin;
head.Scale = tail.Scale = body.Scale = new Vector2(1, direction.NewValue == ScrollingDirection.Down ? 1 : -1);
}
public override Quad SelectionQuad => ScreenSpaceDrawQuad;
public override Vector2 ScreenSpaceSelectionPoint => head.ScreenSpaceDrawQuad.Centre;

View File

@ -37,16 +37,10 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
protected override void LoadComplete()
{
base.LoadComplete();
directionBindable.BindValueChanged(onDirectionChanged, true);
directionBindable.BindValueChanged(OnDirectionChanged, true);
}
private void onDirectionChanged(ValueChangedEvent<ScrollingDirection> direction)
{
var anchor = direction.NewValue == ScrollingDirection.Up ? Anchor.TopCentre : Anchor.BottomCentre;
Anchor = Origin = anchor;
foreach (var child in InternalChildren)
child.Anchor = child.Origin = anchor;
}
protected abstract void OnDirectionChanged(ValueChangedEvent<ScrollingDirection> direction);
protected override void Update()
{

View File

@ -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 osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Game.Graphics;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Mania.Edit.Blueprints.Components;
using osu.Game.Rulesets.Mania.Objects;
using osuTK.Input;
@ -12,14 +14,25 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
{
public partial class NotePlacementBlueprint : ManiaPlacementBlueprint<Note>
{
private readonly EditNotePiece piece;
private Circle piece = null!;
public NotePlacementBlueprint()
: base(new Note())
{
RelativeSizeAxes = Axes.Both;
}
InternalChild = piece = new EditNotePiece { Origin = Anchor.Centre };
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
RelativeSizeAxes = Axes.Both;
Masking = true;
InternalChild = piece = new Circle
{
Origin = Anchor.Centre,
Colour = colours.Yellow,
Height = 10
};
}
public override void UpdateTimeAndPosition(SnapResult result)

View File

@ -1,18 +1,42 @@
// 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.Bindables;
using osu.Framework.Graphics;
using osu.Game.Rulesets.Mania.Edit.Blueprints.Components;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.UI.Scrolling;
using osuTK;
namespace osu.Game.Rulesets.Mania.Edit.Blueprints
{
public partial class NoteSelectionBlueprint : ManiaSelectionBlueprint<Note>
{
private readonly EditNotePiece notePiece;
public NoteSelectionBlueprint(Note note)
: base(note)
{
AddInternal(new EditNotePiece { RelativeSizeAxes = Axes.X });
Anchor = Anchor.BottomCentre;
Origin = Anchor.BottomCentre;
AddInternal(notePiece = new EditNotePiece
{
RelativeSizeAxes = Axes.X,
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
});
}
protected override void Update()
{
base.Update();
notePiece.Height = DrawableObject.DrawHeight;
}
protected override void OnDirectionChanged(ValueChangedEvent<ScrollingDirection> direction)
{
notePiece.Scale = new Vector2(1, direction.NewValue == ScrollingDirection.Down ? 1 : -1);
}
}
}

View File

@ -92,7 +92,7 @@ namespace osu.Game.Rulesets.Mania.Edit
base.Update();
if (screenWithTimeline?.TimelineArea.Timeline != null)
drawableRuleset.TimelineTimeRange = EditorClock.TrackLength / screenWithTimeline.TimelineArea.Timeline.CurrentZoom / 2;
drawableRuleset.TimelineTimeRange = EditorClock.TrackLength / screenWithTimeline.TimelineArea.Timeline.CurrentZoom.Value / 2;
}
}
}

View File

@ -54,7 +54,8 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
},
columnBackgrounds = new ColumnFlow<Drawable>(stageDefinition)
{
RelativeSizeAxes = Axes.Y
RelativeSizeAxes = Axes.Y,
Masking = false,
},
new HitTargetInsetContainer
{
@ -126,8 +127,8 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
},
new Container
{
X = isLastColumn ? -0.16f : 0,
Anchor = Anchor.TopRight,
Origin = Anchor.TopRight,
RelativeSizeAxes = Axes.Y,
Width = rightLineWidth,
Scale = new Vector2(0.740f, 1),

View File

@ -28,6 +28,12 @@ namespace osu.Game.Rulesets.Mania.UI
private readonly FillFlowContainer<Container<TContent>> columns;
private readonly StageDefinition stageDefinition;
public new bool Masking
{
get => base.Masking;
set => base.Masking = value;
}
public ColumnFlow(StageDefinition stageDefinition)
{
this.stageDefinition = stageDefinition;

View File

@ -8,6 +8,7 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Input;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.UI;
using osu.Game.Screens.Edit.Components.RadioButtons;
@ -24,38 +25,57 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false);
[Test]
public void TestTouchInputAfterTouchingComposeArea()
public void TestTouchInputPlaceHitCircleDirectly()
{
AddStep("tap circle", () => tap(this.ChildrenOfType<EditorRadioButton>().Single(b => b.Button.Label == "HitCircle")));
// this input is just for interacting with compose area
AddStep("tap playfield", () => tap(this.ChildrenOfType<Playfield>().Single()));
AddStep("move current time", () => InputManager.Key(Key.Right));
AddStep("tap to place circle", () => tap(this.ChildrenOfType<Playfield>().Single().ToScreenSpace(new Vector2(10, 10))));
AddStep("tap to place circle", () => tap(this.ChildrenOfType<Playfield>().Single()));
AddAssert("circle placed correctly", () =>
{
var circle = (HitCircle)EditorBeatmap.HitObjects.Single(h => h.StartTime == EditorClock.CurrentTimeAccurate);
Assert.Multiple(() =>
{
Assert.That(circle.Position.X, Is.EqualTo(10f).Within(0.01f));
Assert.That(circle.Position.Y, Is.EqualTo(10f).Within(0.01f));
Assert.That(circle.Position.X, Is.EqualTo(256f).Within(0.01f));
Assert.That(circle.Position.Y, Is.EqualTo(192f).Within(0.01f));
});
return true;
});
}
AddStep("tap slider", () => tap(this.ChildrenOfType<EditorRadioButton>().Single(b => b.Button.Label == "Slider")));
[Test]
public void TestTouchInputPlaceCircleAfterTouchingComposeArea()
{
AddStep("tap circle", () => tap(this.ChildrenOfType<EditorRadioButton>().Single(b => b.Button.Label == "HitCircle")));
// this input is just for interacting with compose area
AddStep("tap playfield", () => tap(this.ChildrenOfType<Playfield>().Single()));
AddAssert("circle placed", () => EditorBeatmap.HitObjects.Single(h => h.StartTime == EditorClock.CurrentTimeAccurate) is HitCircle);
AddStep("move current time", () => InputManager.Key(Key.Right));
AddStep("move forward", () => InputManager.Key(Key.Right));
AddStep("tap to place circle", () => tap(this.ChildrenOfType<Playfield>().Single()));
AddAssert("circle placed correctly", () =>
{
var circle = (HitCircle)EditorBeatmap.HitObjects.Single(h => h.StartTime == EditorClock.CurrentTimeAccurate);
Assert.Multiple(() =>
{
Assert.That(circle.Position.X, Is.EqualTo(256f).Within(0.01f));
Assert.That(circle.Position.Y, Is.EqualTo(192f).Within(0.01f));
});
return true;
});
}
[Test]
public void TestTouchInputPlaceSliderDirectly()
{
AddStep("tap slider", () => tap(this.ChildrenOfType<EditorRadioButton>().Single(b => b.Button.Label == "Slider")));
AddStep("hold to draw slider", () => InputManager.BeginTouch(new Touch(TouchSource.Touch1, this.ChildrenOfType<Playfield>().Single().ToScreenSpace(new Vector2(50, 20)))));
AddStep("drag to draw", () => InputManager.MoveTouchTo(new Touch(TouchSource.Touch1, this.ChildrenOfType<Playfield>().Single().ToScreenSpace(new Vector2(200, 50)))));
AddAssert("selection not initiated", () => this.ChildrenOfType<DragBox>().All(d => d.State == Visibility.Hidden));
AddAssert("blueprint visible", () => this.ChildrenOfType<SliderPlacementBlueprint>().Single().Alpha > 0);
AddStep("end", () => InputManager.EndTouch(new Touch(TouchSource.Touch1, InputManager.CurrentState.Touch.GetTouchPosition(TouchSource.Touch1)!.Value)));
AddAssert("slider placed correctly", () =>
{
@ -76,12 +96,55 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
});
}
private void tap(Drawable drawable) => tap(drawable.ScreenSpaceDrawQuad.Centre);
[Test]
public void TestTouchInputPlaceSliderAfterTouchingComposeArea()
{
AddStep("tap slider", () => tap(this.ChildrenOfType<EditorRadioButton>().Single(b => b.Button.Label == "Slider")));
AddStep("tap playfield", () => tap(this.ChildrenOfType<Playfield>().Single()));
AddStep("tap and hold another spot", () => hold(this.ChildrenOfType<Playfield>().Single(), new Vector2(50, 0)));
AddUntilStep("wait for slider placement", () => EditorBeatmap.HitObjects.SingleOrDefault(h => h.StartTime == EditorClock.CurrentTimeAccurate) is Slider);
AddStep("end", () => InputManager.EndTouch(new Touch(TouchSource.Touch1, InputManager.CurrentState.Touch.GetTouchPosition(TouchSource.Touch1)!.Value)));
AddStep("move forward", () => InputManager.Key(Key.Right));
AddStep("hold to draw slider", () => InputManager.BeginTouch(new Touch(TouchSource.Touch1, this.ChildrenOfType<Playfield>().Single().ToScreenSpace(new Vector2(50, 20)))));
AddStep("drag to draw", () => InputManager.MoveTouchTo(new Touch(TouchSource.Touch1, this.ChildrenOfType<Playfield>().Single().ToScreenSpace(new Vector2(200, 50)))));
AddAssert("selection not initiated", () => this.ChildrenOfType<DragBox>().All(d => d.State == Visibility.Hidden));
AddAssert("blueprint visible", () => this.ChildrenOfType<SliderPlacementBlueprint>().Single().IsPresent);
AddStep("end", () => InputManager.EndTouch(new Touch(TouchSource.Touch1, InputManager.CurrentState.Touch.GetTouchPosition(TouchSource.Touch1)!.Value)));
AddAssert("slider placed correctly", () =>
{
var slider = (Slider)EditorBeatmap.HitObjects.Single(h => h.StartTime == EditorClock.CurrentTimeAccurate);
Assert.Multiple(() =>
{
Assert.That(slider.Position.X, Is.EqualTo(50f).Within(0.01f));
Assert.That(slider.Position.Y, Is.EqualTo(20f).Within(0.01f));
Assert.That(slider.Path.ControlPoints.Count, Is.EqualTo(2));
Assert.That(slider.Path.ControlPoints[0].Position, Is.EqualTo(Vector2.Zero));
// the final position may be slightly off from the mouse position when drawing, account for that.
Assert.That(slider.Path.ControlPoints[1].Position.X, Is.EqualTo(150).Within(5));
Assert.That(slider.Path.ControlPoints[1].Position.Y, Is.EqualTo(30).Within(5));
});
return true;
});
}
private void tap(Drawable drawable, Vector2 offset = default) => tap(drawable.ToScreenSpace(drawable.LayoutRectangle.Centre + offset));
private void tap(Vector2 position)
{
InputManager.BeginTouch(new Touch(TouchSource.Touch1, position));
hold(position);
InputManager.EndTouch(new Touch(TouchSource.Touch1, position));
}
private void hold(Drawable drawable, Vector2 offset = default) => hold(drawable.ToScreenSpace(drawable.LayoutRectangle.Centre + offset));
private void hold(Vector2 position)
{
InputManager.BeginTouch(new Touch(TouchSource.Touch1, position));
}
}
}

View File

@ -3,6 +3,7 @@
using System;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Objects;
@ -33,6 +34,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
var osuLastObj = (OsuDifficultyHitObject)current.Previous(0);
var osuLastLastObj = (OsuDifficultyHitObject)current.Previous(1);
const int radius = OsuDifficultyHitObject.NORMALISED_RADIUS;
const int diameter = OsuDifficultyHitObject.NORMALISED_DIAMETER;
// Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle.
double currVelocity = osuCurrObj.LazyJumpDistance / osuCurrObj.StrainTime;
@ -77,14 +81,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
wideAngleBonus = calcWideAngleBonus(currAngle);
acuteAngleBonus = calcAcuteAngleBonus(currAngle);
if (osuCurrObj.StrainTime > 100) // Only buff deltaTime exceeding 300 bpm 1/2.
if (DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.StrainTime, 2) < 300) // Only buff deltaTime exceeding 300 bpm 1/2.
acuteAngleBonus = 0;
else
{
acuteAngleBonus *= calcAcuteAngleBonus(lastAngle) // Multiply by previous angle, we don't want to buff unless this is a wiggle type pattern.
* Math.Min(angleBonus, 125 / osuCurrObj.StrainTime) // The maximum velocity we buff is equal to 125 / strainTime
* Math.Min(angleBonus, diameter * 1.25 / osuCurrObj.StrainTime) // The maximum velocity we buff is equal to 125 / strainTime
* Math.Pow(Math.Sin(Math.PI / 2 * Math.Min(1, (100 - osuCurrObj.StrainTime) / 25)), 2) // scale buff from 150 bpm 1/4 to 200 bpm 1/4
* Math.Pow(Math.Sin(Math.PI / 2 * (Math.Clamp(osuCurrObj.LazyJumpDistance, 50, 100) - 50) / 50), 2); // Buff distance exceeding 50 (radius) up to 100 (diameter).
* Math.Pow(Math.Sin(Math.PI / 2 * (Math.Clamp(osuCurrObj.LazyJumpDistance, radius, diameter) - radius) / radius), 2); // Buff distance exceeding radius up to diameter.
}
// Penalize wide angles if they're repeated, reducing the penalty as the lastAngle gets more acute.
@ -104,7 +108,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
double distRatio = Math.Pow(Math.Sin(Math.PI / 2 * Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity)), 2);
// Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing.
double overlapVelocityBuff = Math.Min(125 / Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime), Math.Abs(prevVelocity - currVelocity));
double overlapVelocityBuff = Math.Min(diameter * 1.25 / Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime), Math.Abs(prevVelocity - currVelocity));
velocityChangeBonus = overlapVelocityBuff * distRatio;

View File

@ -5,6 +5,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Objects;
@ -120,7 +121,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
islandCount.Count++;
// repeated island (ex: triplet -> triplet)
double power = logistic(island.Delta, 2.75, 0.24, 14);
double power = DifficultyCalculationUtils.Logistic(island.Delta, maxValue: 2.75, multiplier: 0.24, midpointOffset: 58.33);
effectiveRatio *= Math.Min(3.0 / islandCount.Count, Math.Pow(1.0 / islandCount.Count, power));
islandCounts[countIndex] = (islandCount.Island, islandCount.Count);
@ -172,8 +173,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
return Math.Sqrt(4 + rhythmComplexitySum * rhythm_overall_multiplier) / 2.0; // produces multiplier that can be applied to strain. range [1, infinity) (not really though)
}
private static double logistic(double x, double maxValue, double multiplier, double offset) => (maxValue / (1 + Math.Pow(Math.E, offset - (multiplier * x))));
private class Island : IEquatable<Island>
{
private readonly double deltaDifferenceEpsilon;

View File

@ -3,6 +3,7 @@
using System;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Objects;
@ -10,8 +11,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
{
public static class SpeedEvaluator
{
private const double single_spacing_threshold = 125; // 1.25 circles distance between centers
private const double min_speed_bonus = 75; // ~200BPM
private const double single_spacing_threshold = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25; // 1.25 circles distance between centers
private const double min_speed_bonus = 200; // 200 BPM 1/4th
private const double speed_balancing_factor = 40;
private const double distance_multiplier = 0.94;
@ -43,8 +44,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
double speedBonus = 0.0;
// Add additional scaling bonus for streams/bursts higher than 200bpm
if (strainTime < min_speed_bonus)
speedBonus = 0.75 * Math.Pow((min_speed_bonus - strainTime) / speed_balancing_factor, 2);
if (DifficultyCalculationUtils.MillisecondsToBPM(strainTime) > min_speed_bonus)
speedBonus = 0.75 * Math.Pow((DifficultyCalculationUtils.BPMToMilliseconds(min_speed_bonus) - strainTime) / speed_balancing_factor, 2);
double travelDistance = osuPrevObj?.TravelDistance ?? 0;
double distance = travelDistance + osuCurrObj.MinimumJumpDistance;

View File

@ -48,8 +48,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty
double sliderFactor = aimRating > 0 ? aimRatingNoSliders / aimRating : 1;
double aimDifficultyStrainCount = ((OsuStrainSkill)skills[0]).CountDifficultStrains();
double speedDifficultyStrainCount = ((OsuStrainSkill)skills[2]).CountDifficultStrains();
double aimDifficultyStrainCount = ((OsuStrainSkill)skills[0]).CountTopWeightedStrains();
double speedDifficultyStrainCount = ((OsuStrainSkill)skills[2]).CountTopWeightedStrains();
if (mods.Any(m => m is OsuModTouchDevice))
{

View File

@ -180,7 +180,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
{
// We add tick misses here since they too mean that the player didn't follow the slider properly
// We however aren't adding misses here because missing slider heads has a harsh penalty by itself and doesn't mean that the rest of the slider wasn't followed properly
estimateImproperlyFollowedDifficultSliders = Math.Min(countSliderEndsDropped + countSliderTickMiss, estimateDifficultSliders);
estimateImproperlyFollowedDifficultSliders = Math.Clamp(countSliderEndsDropped + countSliderTickMiss, 0, estimateDifficultSliders);
}
double sliderNerfFactor = (1 - attributes.SliderFactor) * Math.Pow(1 - estimateImproperlyFollowedDifficultSliders / estimateDifficultSliders, 3) + attributes.SliderFactor;

View File

@ -20,6 +20,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
/// </summary>
public const int NORMALISED_RADIUS = 50; // Change radius to 50 to make 100 the diameter. Easier for mental maths.
public const int NORMALISED_DIAMETER = NORMALISED_RADIUS * 2;
public const int MIN_DELTA_TIME = 25;
private const float maximum_slider_radius = NORMALISED_RADIUS * 2.4f;

View File

@ -34,7 +34,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
{
currentStrain *= strainDecay(current.DeltaTime);
currentStrain += AimEvaluator.EvaluateDifficultyOf(current, withSliders) * skillMultiplier;
ObjectStrains.Add(currentStrain);
return currentStrain;
}

View File

@ -23,9 +23,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
/// </summary>
protected virtual double ReducedStrainBaseline => 0.75;
protected List<double> ObjectStrains = new List<double>();
protected double Difficulty;
protected OsuStrainSkill(Mod[] mods)
: base(mods)
{
@ -33,7 +30,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
public override double DifficultyValue()
{
Difficulty = 0;
double difficulty = 0;
double weight = 1;
// Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871).
@ -53,25 +50,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
// We're sorting from highest to lowest strain.
foreach (double strain in strains.OrderDescending())
{
Difficulty += strain * weight;
difficulty += strain * weight;
weight *= DecayWeight;
}
return Difficulty;
}
/// <summary>
/// Returns the number of strains weighted against the top strain.
/// The result is scaled by clock rate as it affects the total number of strains.
/// </summary>
public double CountDifficultStrains()
{
if (Difficulty == 0)
return 0.0;
double consistentTopStrain = Difficulty / 10; // What would the top strain be if all strain values were identical
// Use a weighted sum of all strains. Constants are arbitrary and give nice values
return ObjectStrains.Sum(s => 1.1 / (1 + Math.Exp(-10 * (s / consistentTopStrain - 0.88))));
return difficulty;
}
public static double DifficultyToPerformance(double difficulty) => Math.Pow(5.0 * Math.Max(1.0, difficulty / 0.0675) - 4.0, 3.0) / 100000.0;

View File

@ -40,7 +40,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
currentRhythm = RhythmEvaluator.EvaluateDifficultyOf(current);
double totalStrain = currentStrain * currentRhythm;
ObjectStrains.Add(totalStrain);
return totalStrain;
}

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Graphics.Containers;
using osu.Framework.Input.Events;
using osu.Game.Rulesets.Edit;
using osuTK;
@ -31,12 +32,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints
public override void EndPlacement(bool commit)
{
if (!commit && PlacementActive != PlacementState.Finished)
{
gridToolboxGroup.StartPosition.Value = originalOrigin;
gridToolboxGroup.Spacing.Value = originalSpacing;
if (!gridToolboxGroup.GridLinesRotation.Disabled)
gridToolboxGroup.GridLinesRotation.Value = originalRotation;
}
resetGridState();
base.EndPlacement(commit);
@ -103,6 +99,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints
public override void UpdateTimeAndPosition(SnapResult result)
{
if (State.Value == Visibility.Hidden)
return;
var pos = ToLocalSpace(result.ScreenSpacePosition);
if (PlacementActive != PlacementState.Active)
@ -122,5 +121,19 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints
}
}
}
protected override void PopOut()
{
base.PopOut();
resetGridState();
}
private void resetGridState()
{
gridToolboxGroup.StartPosition.Value = originalOrigin;
gridToolboxGroup.Spacing.Value = originalSpacing;
if (!gridToolboxGroup.GridLinesRotation.Disabled)
gridToolboxGroup.GridLinesRotation.Value = originalRotation;
}
}
}

View File

@ -156,6 +156,13 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
return true;
}
// this allows sliders to be drawn outside compose area (after starting from a point within the compose area).
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => base.ReceivePositionalInputAt(screenSpacePos) || PlacementActive == PlacementState.Active;
// ReceivePositionalInputAtSubTree generally always returns true when masking is disabled, but we don't want that,
// otherwise a slider path tooltip will be displayed anywhere in the editor (outside compose area).
protected override bool ReceivePositionalInputAtSubTree(Vector2 screenSpacePos) => ReceivePositionalInputAt(screenSpacePos);
private void beginNewSegment(PathControlPoint lastPoint)
{
segmentStart = lastPoint;

View File

@ -50,9 +50,14 @@ namespace osu.Game.Rulesets.Osu.Edit
[Resolved]
private HitObjectComposer composer { get; set; } = null!;
private Bindable<TernaryState> newComboState = null!;
[BackgroundDependencyLoader]
private void load()
{
var selectionHandler = (EditorSelectionHandler)composer.BlueprintContainer.SelectionHandler;
newComboState = selectionHandler.SelectionNewComboState.GetBoundCopy();
AllowableAnchors = new[] { Anchor.CentreLeft, Anchor.CentreRight };
Child = new FillFlowContainer
@ -120,10 +125,11 @@ namespace osu.Game.Rulesets.Osu.Edit
changeHandler?.BeginChange();
began = true;
distanceSnapInput.Current.BindValueChanged(_ => tryCreatePolygon());
offsetAngleInput.Current.BindValueChanged(_ => tryCreatePolygon());
repeatCountInput.Current.BindValueChanged(_ => tryCreatePolygon());
pointInput.Current.BindValueChanged(_ => tryCreatePolygon());
distanceSnapInput.Current.BindValueChanged(_ => Scheduler.AddOnce(tryCreatePolygon));
offsetAngleInput.Current.BindValueChanged(_ => Scheduler.AddOnce(tryCreatePolygon));
repeatCountInput.Current.BindValueChanged(_ => Scheduler.AddOnce(tryCreatePolygon));
pointInput.Current.BindValueChanged(_ => Scheduler.AddOnce(tryCreatePolygon));
newComboState.BindValueChanged(_ => Scheduler.AddOnce(tryCreatePolygon));
tryCreatePolygon();
}
@ -138,39 +144,69 @@ namespace osu.Game.Rulesets.Osu.Edit
double length = distanceSnapInput.Current.Value * velocity * timeSpacing;
float polygonRadius = (float)(length / (2 * Math.Sin(double.Pi / pointInput.Current.Value)));
editorBeatmap.RemoveRange(insertedCircles);
insertedCircles.Clear();
int totalPoints = pointInput.Current.Value * repeatCountInput.Current.Value;
var selectionHandler = (EditorSelectionHandler)composer.BlueprintContainer.SelectionHandler;
bool first = true;
for (int i = 1; i <= pointInput.Current.Value * repeatCountInput.Current.Value; ++i)
if (insertedCircles.Count > totalPoints)
{
float angle = float.DegreesToRadians(offsetAngleInput.Current.Value) + i * (2 * float.Pi / pointInput.Current.Value);
var position = OsuPlayfield.BASE_SIZE / 2 + new Vector2(polygonRadius * float.Cos(angle), polygonRadius * float.Sin(angle));
editorBeatmap.RemoveRange(insertedCircles.GetRange(totalPoints, insertedCircles.Count - totalPoints));
insertedCircles.RemoveRange(totalPoints, insertedCircles.Count - totalPoints);
}
var circle = new HitCircle
var newlyAdded = new List<HitCircle>();
for (int i = 0; i < totalPoints; ++i)
{
float angle = float.DegreesToRadians(offsetAngleInput.Current.Value) + (i + 1) * (2 * float.Pi / pointInput.Current.Value);
var position = OsuPlayfield.BASE_SIZE / 2 + new Vector2(polygonRadius * float.Cos(angle), polygonRadius * float.Sin(angle));
bool newCombo = i == 0 && newComboState.Value == TernaryState.True;
HitCircle circle;
if (i < insertedCircles.Count)
{
Position = position,
StartTime = startTime,
NewCombo = first && selectionHandler.SelectionNewComboState.Value == TernaryState.True,
};
// TODO: probably ensure samples also follow current ternary status (not trivial)
circle.Samples.Add(circle.CreateHitSampleInfo());
circle = insertedCircles[i];
circle.Position = position;
circle.StartTime = startTime;
circle.NewCombo = newCombo;
editorBeatmap.Update(circle);
}
else
{
circle = new HitCircle
{
Position = position,
StartTime = startTime,
NewCombo = newCombo,
};
newlyAdded.Add(circle);
// TODO: probably ensure samples also follow current ternary status (not trivial)
circle.Samples.Add(circle.CreateHitSampleInfo());
}
if (position.X < 0 || position.Y < 0 || position.X > OsuPlayfield.BASE_SIZE.X || position.Y > OsuPlayfield.BASE_SIZE.Y)
{
commitButton.Enabled.Value = false;
editorBeatmap.RemoveRange(insertedCircles);
insertedCircles.Clear();
return;
}
insertedCircles.Add(circle);
startTime = beatSnapProvider.SnapTime(startTime + timeSpacing);
first = false;
}
editorBeatmap.AddRange(insertedCircles);
var previousNewComboState = newComboState.Value;
insertedCircles.AddRange(newlyAdded);
editorBeatmap.AddRange(newlyAdded);
// When adding new hitObjects, newCombo state will get reset to false when no objects are selected.
// Since this is the case when this popover is showing, we need to restore the previous newCombo state
newComboState.Value = previousNewComboState;
commitButton.Enabled.Value = true;
}

View File

@ -0,0 +1,85 @@
// 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.Bindables;
using osu.Framework.Localisation;
using osu.Framework.Utils;
using osu.Game.Configuration;
using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays.Settings;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.UI.Cursor;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI;
using osu.Game.Scoring;
using osu.Game.Screens.Play;
namespace osu.Game.Rulesets.Osu.Mods
{
public class OsuModBloom : Mod, IApplicableToScoreProcessor, IUpdatableByPlayfield, IApplicableToPlayer
{
public override string Name => "Bloom";
public override string Acronym => "BM";
public override ModType Type => ModType.Fun;
public override LocalisableString Description => "The cursor blooms into.. a larger cursor!";
public override double ScoreMultiplier => 1;
protected const float MIN_SIZE = 1;
protected const float TRANSITION_DURATION = 100;
public override Type[] IncompatibleMods => new[] { typeof(OsuModFlashlight), typeof(OsuModNoScope), typeof(ModTouchDevice) };
protected readonly BindableNumber<int> CurrentCombo = new BindableInt();
protected readonly IBindable<bool> IsBreakTime = new Bindable<bool>();
private float currentSize;
[SettingSource(
"Max size at combo",
"The combo count at which the cursor reaches its maximum size",
SettingControlType = typeof(SettingsSlider<int, RoundedSliderBar<int>>)
)]
public BindableInt MaxSizeComboCount { get; } = new BindableInt(50)
{
MinValue = 5,
MaxValue = 100,
};
[SettingSource(
"Final size multiplier",
"The multiplier applied to cursor size when combo reaches maximum",
SettingControlType = typeof(SettingsSlider<float, RoundedSliderBar<float>>)
)]
public BindableFloat MaxCursorSize { get; } = new BindableFloat(10f)
{
MinValue = 5f,
MaxValue = 15f,
Precision = 0.5f,
};
public ScoreRank AdjustRank(ScoreRank rank, double accuracy) => rank;
public void ApplyToPlayer(Player player)
{
IsBreakTime.BindTo(player.IsBreakTime);
}
public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor)
{
CurrentCombo.BindTo(scoreProcessor.Combo);
CurrentCombo.BindValueChanged(combo =>
{
currentSize = Math.Clamp(MaxCursorSize.Value * ((float)combo.NewValue / MaxSizeComboCount.Value), MIN_SIZE, MaxCursorSize.Value);
}, true);
}
public void Update(Playfield playfield)
{
OsuCursor cursor = (OsuCursor)(playfield.Cursor!.ActiveCursor);
if (IsBreakTime.Value)
cursor.ModScaleAdjust.Value = 1;
else
cursor.ModScaleAdjust.Value = (float)Interpolation.Lerp(cursor.ModScaleAdjust.Value, currentSize, Math.Clamp(cursor.Time.Elapsed / TRANSITION_DURATION, 0, 1));
}
}
}

View File

@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Osu.Mods
public partial class OsuModFlashlight : ModFlashlight<OsuHitObject>, IApplicableToDrawableHitObject
{
public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.12 : 1;
public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(OsuModBlinds)).ToArray();
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModBloom), typeof(OsuModBlinds) }).ToArray();
private const double default_follow_delay = 120;

View File

@ -20,6 +20,8 @@ namespace osu.Game.Rulesets.Osu.Mods
{
public override LocalisableString Description => "Where's the cursor?";
public override Type[] IncompatibleMods => new[] { typeof(OsuModBloom) };
private PeriodTracker spinnerPeriods = null!;
public override BindableInt HiddenComboCount { get; } = new BindableInt(10)

View File

@ -9,7 +9,7 @@ namespace osu.Game.Rulesets.Osu.Mods
{
public class OsuModTouchDevice : ModTouchDevice
{
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAutopilot) }).ToArray();
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAutopilot), typeof(OsuModBloom) }).ToArray();
public override bool Ranked => UsesDefaultConfiguration;
}
}

View File

@ -214,7 +214,8 @@ namespace osu.Game.Rulesets.Osu
new OsuModFreezeFrame(),
new OsuModBubbles(),
new OsuModSynesthesia(),
new OsuModDepth()
new OsuModDepth(),
new OsuModBloom()
};
case ModType.System:

View File

@ -14,6 +14,7 @@ using osu.Framework.Graphics.Rendering.Vertices;
using osu.Framework.Graphics.Shaders;
using osu.Framework.Graphics.Shaders.Types;
using osu.Framework.Graphics.Textures;
using osu.Framework.Graphics.Visualisation;
using osu.Framework.Input;
using osu.Framework.Input.Events;
using osu.Framework.Timing;
@ -23,6 +24,7 @@ using osuTK.Graphics.ES30;
namespace osu.Game.Rulesets.Osu.UI.Cursor
{
[DrawVisualiserHidden]
public partial class CursorTrail : Drawable, IRequireHighFrequencyMousePosition
{
private const int max_sprites = 2048;

View File

@ -38,6 +38,11 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
public IBindable<float> CursorScale => cursorScale;
/// <summary>
/// Mods which want to adjust cursor size should do so via this bindable.
/// </summary>
public readonly Bindable<float> ModScaleAdjust = new Bindable<float>(1);
private readonly Bindable<float> cursorScale = new BindableFloat(1);
private Bindable<float> userCursorScale = null!;
@ -67,6 +72,8 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
autoCursorScale = config.GetBindable<bool>(OsuSetting.AutoCursorSize);
autoCursorScale.ValueChanged += _ => cursorScale.Value = CalculateCursorScale();
ModScaleAdjust.ValueChanged += _ => cursorScale.Value = CalculateCursorScale();
cursorScale.BindValueChanged(e => cursorScaleContainer.Scale = new Vector2(e.NewValue), true);
}
@ -90,7 +97,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
protected virtual float CalculateCursorScale()
{
float scale = userCursorScale.Value;
float scale = userCursorScale.Value * ModScaleAdjust.Value;
if (autoCursorScale.Value && state != null)
{

View File

@ -3,6 +3,7 @@
using System;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour.Data;
@ -11,26 +12,12 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators
{
public class ColourEvaluator
{
/// <summary>
/// A sigmoid function. It gives a value between (middle - height/2) and (middle + height/2).
/// </summary>
/// <param name="val">The input value.</param>
/// <param name="center">The center of the sigmoid, where the largest gradient occurs and value is equal to middle.</param>
/// <param name="width">The radius of the sigmoid, outside of which values are near the minimum/maximum.</param>
/// <param name="middle">The middle of the sigmoid output.</param>
/// <param name="height">The height of the sigmoid output. This will be equal to max value - min value.</param>
private static double sigmoid(double val, double center, double width, double middle, double height)
{
double sigmoid = Math.Tanh(Math.E * -(val - center) / width);
return sigmoid * (height / 2) + middle;
}
/// <summary>
/// Evaluate the difficulty of the first note of a <see cref="MonoStreak"/>.
/// </summary>
public static double EvaluateDifficultyOf(MonoStreak monoStreak)
{
return sigmoid(monoStreak.Index, 2, 2, 0.5, 1) * EvaluateDifficultyOf(monoStreak.Parent) * 0.5;
return DifficultyCalculationUtils.Logistic(exponent: Math.E * monoStreak.Index - 2 * Math.E) * EvaluateDifficultyOf(monoStreak.Parent) * 0.5;
}
/// <summary>
@ -38,7 +25,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators
/// </summary>
public static double EvaluateDifficultyOf(AlternatingMonoPattern alternatingMonoPattern)
{
return sigmoid(alternatingMonoPattern.Index, 2, 2, 0.5, 1) * EvaluateDifficultyOf(alternatingMonoPattern.Parent);
return DifficultyCalculationUtils.Logistic(exponent: Math.E * alternatingMonoPattern.Index - 2 * Math.E) * EvaluateDifficultyOf(alternatingMonoPattern.Parent);
}
/// <summary>
@ -46,7 +33,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators
/// </summary>
public static double EvaluateDifficultyOf(RepeatingHitPatterns repeatingHitPattern)
{
return 2 * (1 - sigmoid(repeatingHitPattern.RepetitionInterval, 2, 2, 0.5, 1));
return 2 * (1 - DifficultyCalculationUtils.Logistic(exponent: Math.E * repeatingHitPattern.RepetitionInterval - 2 * Math.E));
}
public static double EvaluateDifficultyOf(DifficultyHitObject hitObject)

View File

@ -54,17 +54,17 @@ namespace osu.Game.Rulesets.Taiko.Edit
public void SetStrongState(bool state)
{
if (SelectedItems.OfType<Hit>().All(h => h.IsStrong == state))
if (SelectedItems.OfType<TaikoStrongableHitObject>().All(h => h.IsStrong == state))
return;
EditorBeatmap.PerformOnSelection(h =>
{
if (!(h is Hit taikoHit)) return;
if (h is not TaikoStrongableHitObject strongable) return;
if (taikoHit.IsStrong != state)
if (strongable.IsStrong != state)
{
taikoHit.IsStrong = state;
EditorBeatmap.Update(taikoHit);
strongable.IsStrong = state;
EditorBeatmap.Update(strongable);
}
});
}

View File

@ -81,6 +81,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
{
base.RecreatePieces();
updateColour();
Height = HitObject.IsStrong ? TaikoStrongableHitObject.DEFAULT_STRONG_SIZE : TaikoHitObject.DEFAULT_SIZE;
}
protected override void OnFree()

View File

@ -12,6 +12,7 @@ using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Taiko.Skinning.Default;
using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Rulesets.Taiko.Objects.Drawables
{
@ -44,6 +45,12 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
IsFirstTick.Value = HitObject.FirstTick;
}
protected override void RecreatePieces()
{
base.RecreatePieces();
Size = new Vector2(HitObject.IsStrong ? TaikoStrongableHitObject.DEFAULT_STRONG_SIZE : TaikoHitObject.DEFAULT_SIZE);
}
protected override void CheckForResult(bool userTriggered, double timeOffset)
{
if (!userTriggered)

View File

@ -14,6 +14,7 @@ using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Taiko.Skinning.Default;
using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Rulesets.Taiko.Objects.Drawables
{
@ -63,6 +64,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
{
updateActionsFromType();
base.RecreatePieces();
Size = new Vector2(HitObject.IsStrong ? TaikoStrongableHitObject.DEFAULT_STRONG_SIZE : TaikoHitObject.DEFAULT_SIZE);
}
protected override void OnFree()

View File

@ -19,6 +19,7 @@ using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Taiko.Skinning.Default;
using osu.Game.Screens.Play;
using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Rulesets.Taiko.Objects.Drawables
{
@ -34,6 +35,8 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
/// </summary>
private const double ring_appear_offset = 100;
private Vector2 baseSize;
private readonly Container<DrawableSwellTick> ticks;
private readonly Container bodyContainer;
private readonly CircularContainer targetRing;
@ -141,6 +144,12 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
Origin = Anchor.Centre,
});
protected override void RecreatePieces()
{
base.RecreatePieces();
Size = baseSize = new Vector2(TaikoHitObject.DEFAULT_SIZE);
}
protected override void OnFree()
{
base.OnFree();
@ -269,7 +278,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
{
base.Update();
Size = BaseSize * Parent!.RelativeChildSize;
Size = baseSize * Parent!.RelativeChildSize;
// Make the swell stop at the hit target
X = Math.Max(0, X);

View File

@ -130,7 +130,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
public new TObject HitObject => (TObject)base.HitObject;
protected Vector2 BaseSize;
protected SkinnableDrawable MainPiece;
protected DrawableTaikoHitObject([CanBeNull] TObject hitObject)
@ -152,8 +151,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
protected virtual void RecreatePieces()
{
Size = BaseSize = new Vector2(TaikoHitObject.DEFAULT_SIZE);
if (MainPiece != null)
Content.Remove(MainPiece, true);

View File

@ -8,7 +8,6 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics.Containers;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osuTK;
namespace osu.Game.Rulesets.Taiko.Objects.Drawables
{
@ -44,13 +43,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
isStrong.UnbindEvents();
}
protected override void RecreatePieces()
{
base.RecreatePieces();
if (HitObject.IsStrong)
Size = BaseSize = new Vector2(TaikoStrongableHitObject.DEFAULT_STRONG_SIZE);
}
protected override void AddNestedHitObject(DrawableHitObject hitObject)
{
base.AddNestedHitObject(hitObject);

View File

@ -84,8 +84,11 @@ namespace osu.Game.Rulesets.Taiko.UI
protected virtual double ComputeTimeRange()
{
// Adjust when we're using constant algorithm to not be sluggish.
double multiplier = VisualisationMethod == ScrollVisualisationMethod.Constant ? 4 * Beatmap.Difficulty.SliderMultiplier : 1;
// Using the constant algorithm results in a sluggish scroll speed that's equal to 60 BPM.
// We need to adjust it to the expected default scroll speed (BPM * base SV multiplier).
double multiplier = VisualisationMethod == ScrollVisualisationMethod.Constant
? (Beatmap.BeatmapInfo.BPM * Beatmap.Difficulty.SliderMultiplier) / 60
: 1;
return PlayfieldAdjustmentContainer.ComputeTimeRange() / multiplier;
}

View File

@ -0,0 +1,63 @@
// 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.IO;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Extensions;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Tests.Resources;
using osu.Game.Tests.Visual;
using MemoryStream = System.IO.MemoryStream;
namespace osu.Game.Tests.Beatmaps.IO
{
[HeadlessTest]
public partial class LegacyBeatmapExporterTest : OsuTestScene
{
[Resolved]
private BeatmapManager beatmaps { get; set; } = null!;
[Test]
public void TestObjectsSnappedAfterTruncatingExport()
{
IWorkingBeatmap beatmap = null!;
MemoryStream outStream = null!;
// Ensure importer encoding is correct
AddStep("import beatmap", () => beatmap = importBeatmapFromArchives(@"decimal-timing-beatmap.olz"));
AddAssert("timing point has decimal offset", () => beatmap.Beatmap.ControlPointInfo.TimingPoints[0].Time, () => Is.EqualTo(284.725).Within(0.001));
AddAssert("kiai has decimal offset", () => beatmap.Beatmap.ControlPointInfo.EffectPoints[0].Time, () => Is.EqualTo(28520.019).Within(0.001));
AddAssert("hit object has decimal offset", () => beatmap.Beatmap.HitObjects[0].StartTime, () => Is.EqualTo(28520.019).Within(0.001));
// Ensure exporter legacy conversion is correct
AddStep("export", () =>
{
outStream = new MemoryStream();
new LegacyBeatmapExporter(LocalStorage)
.ExportToStream((BeatmapSetInfo)beatmap.BeatmapInfo.BeatmapSet!, outStream, null);
});
AddStep("import beatmap again", () => beatmap = importBeatmapFromStream(outStream));
AddAssert("timing point has truncated offset", () => beatmap.Beatmap.ControlPointInfo.TimingPoints[0].Time, () => Is.EqualTo(284).Within(0.001));
AddAssert("kiai is snapped", () => beatmap.Beatmap.ControlPointInfo.EffectPoints[0].Time, () => Is.EqualTo(28519).Within(0.001));
AddAssert("hit object is snapped", () => beatmap.Beatmap.HitObjects[0].StartTime, () => Is.EqualTo(28519).Within(0.001));
}
private IWorkingBeatmap importBeatmapFromStream(Stream stream)
{
var imported = beatmaps.Import(new ImportTask(stream, "filename.osz")).GetResultSafely();
return imported.AsNonNull().PerformRead(s => beatmaps.GetWorkingBeatmap(s.Beatmaps[0]));
}
private IWorkingBeatmap importBeatmapFromArchives(string filename)
{
var imported = beatmaps.Import(new ImportTask(TestResources.OpenResource($@"Archives/{filename}"), filename)).GetResultSafely();
return imported.AsNonNull().PerformRead(s => beatmaps.GetWorkingBeatmap(s.Beatmaps[0]));
}
}
}

View File

@ -0,0 +1,161 @@
// 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 System.Linq;
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Screens.Edit.Timing;
namespace osu.Game.Tests.Editing
{
[TestFixture]
public class TimingSectionAdjustmentsTest
{
[Test]
public void TestOffsetAdjustment()
{
var controlPoints = new ControlPointInfo();
controlPoints.Add(100, new TimingControlPoint { BeatLength = 100 });
controlPoints.Add(50_000, new TimingControlPoint { BeatLength = 200 });
controlPoints.Add(100_000, new TimingControlPoint { BeatLength = 50 });
var beatmap = new Beatmap
{
ControlPointInfo = controlPoints,
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0 },
new HitCircle { StartTime = 200 },
new HitCircle { StartTime = 49_900 },
new HitCircle { StartTime = 50_000 },
new HitCircle { StartTime = 50_200 },
new HitCircle { StartTime = 99_800 },
new HitCircle { StartTime = 100_000 },
new HitCircle { StartTime = 100_050 },
new HitCircle { StartTime = 100_550 },
}
};
moveTimingPoint(beatmap, 100, -50);
Assert.Multiple(() =>
{
Assert.That(beatmap.HitObjects[0].StartTime, Is.EqualTo(-50));
Assert.That(beatmap.HitObjects[1].StartTime, Is.EqualTo(150));
Assert.That(beatmap.HitObjects[2].StartTime, Is.EqualTo(49_850));
Assert.That(beatmap.HitObjects[3].StartTime, Is.EqualTo(50_000));
});
moveTimingPoint(beatmap, 50_000, 1_000);
Assert.Multiple(() =>
{
Assert.That(beatmap.HitObjects[2].StartTime, Is.EqualTo(49_850));
Assert.That(beatmap.HitObjects[3].StartTime, Is.EqualTo(51_000));
Assert.That(beatmap.HitObjects[4].StartTime, Is.EqualTo(51_200));
Assert.That(beatmap.HitObjects[5].StartTime, Is.EqualTo(100_800));
Assert.That(beatmap.HitObjects[6].StartTime, Is.EqualTo(100_000));
});
moveTimingPoint(beatmap, 100_000, 10_000);
Assert.Multiple(() =>
{
Assert.That(beatmap.HitObjects[4].StartTime, Is.EqualTo(51_200));
Assert.That(beatmap.HitObjects[5].StartTime, Is.EqualTo(110_800));
Assert.That(beatmap.HitObjects[6].StartTime, Is.EqualTo(110_000));
Assert.That(beatmap.HitObjects[7].StartTime, Is.EqualTo(110_050));
Assert.That(beatmap.HitObjects[8].StartTime, Is.EqualTo(110_550));
});
}
[Test]
public void TestBPMAdjustment()
{
var controlPoints = new ControlPointInfo();
controlPoints.Add(100, new TimingControlPoint { BeatLength = 100 });
controlPoints.Add(50_000, new TimingControlPoint { BeatLength = 200 });
controlPoints.Add(100_000, new TimingControlPoint { BeatLength = 50 });
var beatmap = new Beatmap
{
ControlPointInfo = controlPoints,
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0 },
new HitCircle { StartTime = 200 },
new Spinner { StartTime = 500, EndTime = 1000 },
new HitCircle { StartTime = 49_900 },
new HitCircle { StartTime = 50_000 },
new HitCircle { StartTime = 50_200 },
new HitCircle { StartTime = 99_800 },
new HitCircle { StartTime = 100_000 },
new HitCircle { StartTime = 100_050 },
new HitCircle { StartTime = 100_550 },
}
};
adjustBeatLength(beatmap, 100, 50);
Assert.Multiple(() =>
{
Assert.That(beatmap.HitObjects[0].StartTime, Is.EqualTo(50));
Assert.That(beatmap.HitObjects[1].StartTime, Is.EqualTo(150));
Assert.That(beatmap.HitObjects[2].StartTime, Is.EqualTo(300));
Assert.That(beatmap.HitObjects[2].GetEndTime(), Is.EqualTo(550));
Assert.That(beatmap.HitObjects[3].StartTime, Is.EqualTo(25_000));
Assert.That(beatmap.HitObjects[4].StartTime, Is.EqualTo(50_000));
});
adjustBeatLength(beatmap, 50_000, 400);
Assert.Multiple(() =>
{
Assert.That(beatmap.HitObjects[2].StartTime, Is.EqualTo(300));
Assert.That(beatmap.HitObjects[2].GetEndTime(), Is.EqualTo(550));
Assert.That(beatmap.HitObjects[3].StartTime, Is.EqualTo(25_000));
Assert.That(beatmap.HitObjects[4].StartTime, Is.EqualTo(50_000));
Assert.That(beatmap.HitObjects[5].StartTime, Is.EqualTo(50_400));
Assert.That(beatmap.HitObjects[6].StartTime, Is.EqualTo(149_600));
Assert.That(beatmap.HitObjects[7].StartTime, Is.EqualTo(100_000));
});
adjustBeatLength(beatmap, 100_000, 100);
Assert.Multiple(() =>
{
Assert.That(beatmap.HitObjects[5].StartTime, Is.EqualTo(50_400));
Assert.That(beatmap.HitObjects[6].StartTime, Is.EqualTo(199_200));
Assert.That(beatmap.HitObjects[7].StartTime, Is.EqualTo(100_000));
Assert.That(beatmap.HitObjects[8].StartTime, Is.EqualTo(100_100));
Assert.That(beatmap.HitObjects[9].StartTime, Is.EqualTo(101_100));
});
}
private static void moveTimingPoint(IBeatmap beatmap, double originalTime, double adjustment)
{
var controlPoints = beatmap.ControlPointInfo;
var controlPointGroup = controlPoints.GroupAt(originalTime);
var timingPoint = controlPointGroup.ControlPoints.OfType<TimingControlPoint>().Single();
controlPoints.RemoveGroup(controlPointGroup);
TimingSectionAdjustments.AdjustHitObjectOffset(beatmap, timingPoint, adjustment);
controlPoints.Add(originalTime - adjustment, timingPoint);
}
private static void adjustBeatLength(IBeatmap beatmap, double groupTime, double newBeatLength)
{
var controlPoints = beatmap.ControlPointInfo;
var controlPointGroup = controlPoints.GroupAt(groupTime);
var timingPoint = controlPointGroup.ControlPoints.OfType<TimingControlPoint>().Single();
double oldBeatLength = timingPoint.BeatLength;
timingPoint.BeatLength = newBeatLength;
TimingSectionAdjustments.SetHitObjectBPM(beatmap, timingPoint, oldBeatLength);
}
}
}

View File

@ -205,7 +205,9 @@ namespace osu.Game.Tests.Visual.Collections
AddStep("click first delete button", () =>
{
InputManager.MoveMouseTo(dialog.ChildrenOfType<DrawableCollectionListItem.DeleteButton>().First(), new Vector2(5, 0));
InputManager.MoveMouseTo(dialog
.ChildrenOfType<DrawableCollectionListItem>().Single(i => i.Model.Value.Name == "1")
.ChildrenOfType<DrawableCollectionListItem.DeleteButton>().Single(), new Vector2(5, 0));
InputManager.Click(MouseButton.Left);
});
@ -213,9 +215,11 @@ namespace osu.Game.Tests.Visual.Collections
assertCollectionCount(1);
assertCollectionName(0, "2");
AddStep("click first delete button", () =>
AddStep("click second delete button", () =>
{
InputManager.MoveMouseTo(dialog.ChildrenOfType<DrawableCollectionListItem.DeleteButton>().First(), new Vector2(5, 0));
InputManager.MoveMouseTo(dialog
.ChildrenOfType<DrawableCollectionListItem>().Single(i => i.Model.Value.Name == "2")
.ChildrenOfType<DrawableCollectionListItem.DeleteButton>().Single(), new Vector2(5, 0));
InputManager.Click(MouseButton.Left);
});
@ -261,6 +265,7 @@ namespace osu.Game.Tests.Visual.Collections
}
[Test]
[Solo]
public void TestCollectionRenamedExternal()
{
BeatmapCollection first = null!;
@ -310,7 +315,7 @@ namespace osu.Game.Tests.Visual.Collections
AddStep("focus first collection", () =>
{
InputManager.MoveMouseTo(firstItem = dialog.ChildrenOfType<DrawableCollectionListItem>().First());
InputManager.MoveMouseTo(firstItem = dialog.ChildrenOfType<DrawableCollectionListItem>().Single(i => i.Model.Value.Name == "1"));
InputManager.Click(MouseButton.Left);
});
@ -337,6 +342,6 @@ namespace osu.Game.Tests.Visual.Collections
=> AddUntilStep($"{count} collections shown", () => dialog.ChildrenOfType<DrawableCollectionListItem>().Count() == count + 1); // +1 for placeholder
private void assertCollectionName(int index, string name)
=> AddUntilStep($"item {index + 1} has correct name", () => dialog.ChildrenOfType<DrawableCollectionListItem>().ElementAt(index).ChildrenOfType<TextBox>().First().Text == name);
=> AddUntilStep($"item {index + 1} has correct name", () => dialog.ChildrenOfType<DrawableCollectionList>().Single().OrderedItems.ElementAt(index).ChildrenOfType<TextBox>().First().Text == name);
}
}

View File

@ -38,7 +38,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge
Add(metadataClient);
// add button to observe for daily challenge changes and perform its logic.
Add(new DailyChallengeButton(@"button-default-select", new Color4(102, 68, 204, 255), _ => { }, 0, Key.D));
Add(new DailyChallengeButton(@"button-default-select", new Color4(102, 68, 204, 255), (_, _) => { }, 0, Key.D));
}
[Test]

View File

@ -19,6 +19,7 @@ using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Edit;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.UI;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Components.RadioButtons;
using osu.Game.Screens.Edit.Components.TernaryButtons;
@ -82,6 +83,45 @@ namespace osu.Game.Tests.Visual.Editing
});
}
[Test]
public void TestPlacementOutsideComposeScreen()
{
AddStep("clear all control points and hitobjects", () =>
{
editorBeatmap.ControlPointInfo.Clear();
editorBeatmap.Clear();
});
AddStep("Add timing point", () => editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint()));
AddStep("select circle", () => hitObjectComposer.ChildrenOfType<EditorRadioButton>().First(d => d.Button.Label == "HitCircle").TriggerClick());
AddStep("move mouse to compose", () => InputManager.MoveMouseTo(hitObjectComposer.ChildrenOfType<HitObjectContainer>().Single()));
AddStep("click", () => InputManager.Click(MouseButton.Left));
AddAssert("circle placed", () => editorBeatmap.HitObjects.Count == 1);
AddStep("move mouse outside compose", () => InputManager.MoveMouseTo(hitObjectComposer.ChildrenOfType<HitObjectContainer>().Single().ScreenSpaceDrawQuad.TopLeft - new Vector2(0f, 20f)));
AddStep("click", () => InputManager.Click(MouseButton.Left));
AddAssert("no circle placed", () => editorBeatmap.HitObjects.Count == 1);
}
[Test]
public void TestDragSliderOutsideComposeScreen()
{
AddStep("clear all control points and hitobjects", () =>
{
editorBeatmap.ControlPointInfo.Clear();
editorBeatmap.Clear();
});
AddStep("Add timing point", () => editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint()));
AddStep("select slider", () => hitObjectComposer.ChildrenOfType<EditorRadioButton>().First(d => d.Button.Label == "Slider").TriggerClick());
AddStep("move mouse to compose", () => InputManager.MoveMouseTo(hitObjectComposer.ChildrenOfType<HitObjectContainer>().Single()));
AddStep("hold", () => InputManager.PressButton(MouseButton.Left));
AddStep("move mouse outside compose", () => InputManager.MoveMouseTo(hitObjectComposer.ChildrenOfType<HitObjectContainer>().Single().ScreenSpaceDrawQuad.TopLeft - new Vector2(0f, 80f)));
AddStep("release", () => InputManager.ReleaseButton(MouseButton.Left));
AddAssert("slider placed", () => editorBeatmap.HitObjects.Count == 1);
}
[Test]
public void TestPlacementOnlyWorksWithTiming()
{

View File

@ -1,14 +1,18 @@
// 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 System.Linq;
using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Overlays.Settings;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Screens.Play.PlayerSettings;
using osu.Game.Tests.Resources;
@ -52,6 +56,39 @@ namespace osu.Game.Tests.Visual.Gameplay
AddAssert("No calibration button", () => !offsetControl.ChildrenOfType<SettingsButton>().Any());
}
[Test]
public void TestNotEnoughTimedHitEvents()
{
AddStep("Set short reference score", () =>
{
List<HitEvent> hitEvents =
[
// 10 events total. one of them (head circle) being timed / having hitwindows, rest having no hitwindows
new HitEvent(30, 1, HitResult.LargeTickHit, new SliderHeadCircle { ClassicSliderBehaviour = true }, null, null),
new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null),
new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null),
new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null),
new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null),
new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null),
new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null),
new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null),
new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null),
new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null),
];
foreach (var ev in hitEvents)
ev.HitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
offsetControl.ReferenceScore.Value = new ScoreInfo
{
HitEvents = hitEvents,
BeatmapInfo = Beatmap.Value.BeatmapInfo,
};
});
AddAssert("No calibration button", () => !offsetControl.ChildrenOfType<SettingsButton>().Any());
}
[Test]
public void TestScoreFromDifferentBeatmap()
{

View File

@ -523,7 +523,7 @@ namespace osu.Game.Tests.Visual.Gameplay
AddUntilStep("restart completed", () => getCurrentPlayer() != null && getCurrentPlayer() != previousPlayer);
AddStep("release quick retry key", () => InputManager.ReleaseKey(Key.Tilde));
AddUntilStep("wait for player", () => getCurrentPlayer()?.LoadState == LoadState.Ready);
AddUntilStep("wait for player", () => getCurrentPlayer()?.LoadState >= LoadState.Ready);
AddUntilStep("time reached zero", () => getCurrentPlayer()?.GameplayClockContainer.CurrentTime > 0);
AddUntilStep("skip button not visible", () => !checkSkipButtonVisible());

View File

@ -223,7 +223,7 @@ namespace osu.Game.Tests.Visual.Gameplay
protected partial class OutroPlayer : TestPlayer
{
public void ExitViaPause() => PerformExit(true);
public void ExitViaPause() => PerformExitWithConfirmation();
public new FailOverlay FailOverlay => base.FailOverlay;

View File

@ -6,6 +6,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using JetBrains.Annotations;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio;
@ -15,6 +16,7 @@ using osu.Framework.Platform;
using osu.Framework.Screens;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Database;
using osu.Game.Online.Rooms;
using osu.Game.Overlays.Mods;
@ -42,6 +44,9 @@ namespace osu.Game.Tests.Visual.Multiplayer
private Live<BeatmapSetInfo> importedBeatmapSet;
[Resolved]
private OsuConfigManager configManager { get; set; }
[BackgroundDependencyLoader]
private void load(GameHost host, AudioManager audio)
{
@ -57,10 +62,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
Add(detachedBeatmapStore);
}
public override void SetUpSteps()
private void setUp()
{
base.SetUpSteps();
AddStep("reset", () =>
{
Ruleset.Value = new OsuRuleset().RulesetInfo;
@ -75,6 +78,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestSelectFreeMods()
{
setUp();
AddStep("set some freemods", () => songSelect.FreeMods.Value = new OsuRuleset().GetModsFor(ModType.Fun).ToArray());
AddStep("set all freemods", () => songSelect.FreeMods.Value = new OsuRuleset().CreateAllMods().ToArray());
AddStep("set no freemods", () => songSelect.FreeMods.Value = Array.Empty<Mod>());
@ -85,6 +90,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
BeatmapInfo selectedBeatmap = null;
setUp();
AddStep("change ruleset", () => Ruleset.Value = new TaikoRuleset().RulesetInfo);
AddStep("select beatmap",
() => songSelect.Carousel.SelectBeatmap(selectedBeatmap = beatmaps.First(beatmap => beatmap.Ruleset.OnlineID == new TaikoRuleset().LegacyID)));
@ -107,6 +114,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
[TestCase(typeof(OsuModHidden), typeof(OsuModTraceable))] // Incompatible.
public void TestAllowedModDeselectedWhenRequired(Type allowedMod, Type requiredMod)
{
setUp();
AddStep("change ruleset", () => Ruleset.Value = new OsuRuleset().RulesetInfo);
AddStep($"select {allowedMod.ReadableName()} as allowed", () => songSelect.FreeMods.Value = new[] { (Mod)Activator.CreateInstance(allowedMod) });
AddStep($"select {requiredMod.ReadableName()} as required", () => songSelect.Mods.Value = new[] { (Mod)Activator.CreateInstance(requiredMod) });
@ -120,6 +129,30 @@ namespace osu.Game.Tests.Visual.Multiplayer
assertFreeModNotShown(requiredMod);
}
[Test]
public void TestChangeRulesetImmediatelyAfterLoadComplete()
{
AddStep("reset", () =>
{
configManager.SetValue(OsuSetting.ShowConvertedBeatmaps, false);
Beatmap.SetDefault();
SelectedMods.SetDefault();
});
AddStep("create song select", () =>
{
SelectedRoom.Value.Playlist.Single().RulesetID = 2;
songSelect = new TestMultiplayerMatchSongSelect(SelectedRoom.Value, SelectedRoom.Value.Playlist.Single());
songSelect.OnLoadComplete += _ => Ruleset.Value = new TaikoRuleset().RulesetInfo;
LoadScreen(songSelect);
});
AddUntilStep("wait for present", () => songSelect.IsCurrentScreen() && songSelect.BeatmapSetsLoaded);
AddStep("confirm selection", () => songSelect.FinaliseSelection());
AddAssert("beatmap is taiko", () => Beatmap.Value.BeatmapInfo.Ruleset.OnlineID, () => Is.EqualTo(1));
AddAssert("ruleset is taiko", () => Ruleset.Value.OnlineID, () => Is.EqualTo(1));
}
private void assertFreeModNotShown(Type type)
{
AddAssert($"{type.ReadableName()} not displayed in freemod overlay",
@ -138,8 +171,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
public new BeatmapCarousel Carousel => base.Carousel;
public TestMultiplayerMatchSongSelect(Room room)
: base(room)
public TestMultiplayerMatchSongSelect(Room room, [CanBeNull] PlaylistItem itemToEdit = null)
: base(room, itemToEdit)
{
}
}

View File

@ -55,21 +55,21 @@ namespace osu.Game.Tests.Visual.Online
{
Username = @"flyte",
Id = 3103765,
CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c6.jpg"
CoverUrl = @"https://assets.ppy.sh/user-cover-presets/1/df28696b58541a9e67f6755918951d542d93bdf1da41720fcca2fd2c1ea8cf51.jpeg",
}),
new UserBrickPanel(new APIUser
{
Username = @"peppy",
Id = 2,
Colour = "99EB47",
CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg",
CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg",
}),
new UserGridPanel(new APIUser
{
Username = @"flyte",
Id = 3103765,
CountryCode = CountryCode.JP,
CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c6.jpg",
CoverUrl = @"https://assets.ppy.sh/user-cover-presets/1/df28696b58541a9e67f6755918951d542d93bdf1da41720fcca2fd2c1ea8cf51.jpeg",
Status = { Value = UserStatus.Online }
}) { Width = 300 },
boundPanel1 = new UserGridPanel(new APIUser
@ -77,7 +77,7 @@ namespace osu.Game.Tests.Visual.Online
Username = @"peppy",
Id = 2,
CountryCode = CountryCode.AU,
CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg",
CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg",
IsSupporter = true,
SupportLevel = 3,
}) { Width = 300 },
@ -95,7 +95,7 @@ namespace osu.Game.Tests.Visual.Online
Username = @"flyte",
Id = 3103765,
CountryCode = CountryCode.JP,
CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c6.jpg",
CoverUrl = @"https://assets.ppy.sh/user-cover-presets/1/df28696b58541a9e67f6755918951d542d93bdf1da41720fcca2fd2c1ea8cf51.jpeg",
Statistics = new UserStatistics { GlobalRank = 12345, CountryRank = 1234 }
}) { Width = 300 },
new UserRankPanel(new APIUser
@ -104,7 +104,7 @@ namespace osu.Game.Tests.Visual.Online
Id = 2,
Colour = "99EB47",
CountryCode = CountryCode.AU,
CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg",
CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg",
Statistics = new UserStatistics { GlobalRank = null, CountryRank = null }
}) { Width = 300 }
}
@ -168,6 +168,14 @@ namespace osu.Game.Tests.Visual.Online
CountryRank = RNG.Next(100000)
});
});
AddStep("set statistics to something big", () =>
{
API.UpdateStatistics(new UserStatistics
{
GlobalRank = RNG.Next(1_000_000, 100_000_000),
CountryRank = RNG.Next(1_000_000, 100_000_000)
});
});
AddStep("set statistics to empty", () =>
{
API.UpdateStatistics(new UserStatistics());

View File

@ -40,6 +40,13 @@ namespace osu.Game.Tests.Visual.Ranking
AddSliderStep("height", 0.0f, 1000.0f, height.Value, height.Set);
}
[Test]
public void TestZeroEvents()
{
createTest(new List<HitEvent>());
AddStep("update offset", () => graph.UpdateOffset(10));
}
[Test]
public void TestManyDistributedEventsOffset()
{

View File

@ -1,12 +1,9 @@
// 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.Linq;
using JetBrains.Annotations;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
@ -23,7 +20,6 @@ using osu.Game.Rulesets.Catch;
using osu.Game.Rulesets.Mania;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Taiko;
@ -35,15 +31,11 @@ namespace osu.Game.Tests.Visual.SongSelect
[TestFixture]
public partial class TestSceneBeatmapInfoWedge : OsuTestScene
{
private RulesetStore rulesets;
private TestBeatmapInfoWedge infoWedge;
private readonly List<IBeatmap> beatmaps = new List<IBeatmap>();
[Resolved]
private RulesetStore rulesets { get; set; } = null!;
[BackgroundDependencyLoader]
private void load(RulesetStore rulesets)
{
this.rulesets = rulesets;
}
private TestBeatmapInfoWedge infoWedge = null!;
private readonly List<IBeatmap> beatmaps = new List<IBeatmap>();
protected override void LoadComplete()
{
@ -156,7 +148,7 @@ namespace osu.Game.Tests.Visual.SongSelect
IBeatmap beatmap = createTestBeatmap(new OsuRuleset().RulesetInfo);
beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 60 * 1000 / bpm });
OsuModDoubleTime doubleTime = null;
OsuModDoubleTime doubleTime = null!;
selectBeatmap(beatmap);
checkDisplayedBPM($"{bpm}");
@ -173,7 +165,7 @@ namespace osu.Game.Tests.Visual.SongSelect
[TestCase(120, 120.4, null, "120")]
[TestCase(120, 120.6, "DT", "180-182 (mostly 180)")]
[TestCase(120, 120.4, "DT", "180")]
public void TestVaryingBPM(double commonBpm, double otherBpm, string mod, string expectedDisplay)
public void TestVaryingBPM(double commonBpm, double otherBpm, string? mod, string expectedDisplay)
{
IBeatmap beatmap = createTestBeatmap(new OsuRuleset().RulesetInfo);
beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 60 * 1000 / commonBpm });
@ -203,7 +195,7 @@ namespace osu.Game.Tests.Visual.SongSelect
double drain = beatmap.CalculateDrainLength();
beatmap.BeatmapInfo.Length = drain;
OsuModDoubleTime doubleTime = null;
OsuModDoubleTime doubleTime = null!;
selectBeatmap(beatmap);
checkDisplayedLength(drain);
@ -221,14 +213,15 @@ namespace osu.Game.Tests.Visual.SongSelect
AddUntilStep($"check map drain ({displayedLength})", () =>
{
var label = infoWedge.DisplayedContent.ChildrenOfType<BeatmapInfoWedge.WedgeInfoText.InfoLabel>().Single(l => l.Statistic.Name == BeatmapsetsStrings.ShowStatsTotalLength(displayedLength));
var label = infoWedge.DisplayedContent.ChildrenOfType<BeatmapInfoWedge.WedgeInfoText.InfoLabel>()
.Single(l => l.Statistic.Name == BeatmapsetsStrings.ShowStatsTotalLength(displayedLength));
return label.Statistic.Content == displayedLength.ToString();
});
}
private void setRuleset(RulesetInfo rulesetInfo)
{
Container containerBefore = null;
Container? containerBefore = null;
AddStep("set ruleset", () =>
{
@ -242,9 +235,9 @@ namespace osu.Game.Tests.Visual.SongSelect
AddUntilStep("wait for async load", () => infoWedge.DisplayedContent != containerBefore);
}
private void selectBeatmap([CanBeNull] IBeatmap b)
private void selectBeatmap(IBeatmap? b)
{
Container containerBefore = null;
Container? containerBefore = null;
AddStep($"select {b?.Metadata.Title ?? "null"} beatmap", () =>
{
@ -307,11 +300,6 @@ namespace osu.Game.Tests.Visual.SongSelect
public new WedgeInfoText Info => base.Info;
}
private class TestHitObject : ConvertHitObject, IHasPosition
{
public float X => 0;
public float Y => 0;
public Vector2 Position { get; } = Vector2.Zero;
}
private class TestHitObject : ConvertHitObject;
}
}

View File

@ -14,9 +14,7 @@ using osu.Game.Beatmaps;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Screens.Select;
using osuTK;
namespace osu.Game.Tests.Visual.SongSelectV2
{
@ -209,11 +207,6 @@ namespace osu.Game.Tests.Visual.SongSelectV2
public new WedgeInfoText? Info => base.Info;
}
private class TestHitObject : ConvertHitObject, IHasPosition
{
public float X => 0;
public float Y => 0;
public Vector2 Position { get; } = Vector2.Zero;
}
private class TestHitObject : ConvertHitObject;
}
}

View File

@ -159,6 +159,23 @@ namespace osu.Game.Tests.Visual.UserInterface
AddUntilStep("check star rating is 2", getText, () => Is.EqualTo("Star Rating: 2.00"));
}
[Test]
public void TestMaxPp()
{
AddStep("set test ruleset", () => Ruleset.Value = new TestRuleset().RulesetInfo);
AddStep("set max pp attribute", () => text.Attribute.Value = BeatmapAttribute.MaxPP);
AddAssert("check max pp is 0", getText, () => Is.EqualTo("Max PP: 0"));
// Adding mod
TestMod mod = null!;
AddStep("add mod with pp 1", () => SelectedMods.Value = new[] { mod = new TestMod { Performance = { Value = 1 } } });
AddUntilStep("check max pp is 1", getText, () => Is.EqualTo("Max PP: 1"));
// Changing mod setting
AddStep("change mod pp to 2", () => mod.Performance.Value = 2);
AddUntilStep("check max pp is 2", getText, () => Is.EqualTo("Max PP: 2"));
}
private string getText() => text.ChildrenOfType<SpriteText>().Single().Text.ToString();
private class TestRuleset : Ruleset

View File

@ -110,7 +110,7 @@ namespace osu.Game.Tests.Visual.UserInterface
break;
case Key.Q:
buttons.OnExit = action;
buttons.OnExit = _ => action();
break;
case Key.O:

View File

@ -30,7 +30,7 @@ namespace osu.Game.Tests.Visual.UserInterface
public void TestStandardButton()
{
AddStep("add button", () => Child = new MainMenuButton(
ButtonSystemStrings.Solo, @"button-default-select", OsuIcon.Player, new Color4(102, 68, 204, 255), _ => { }, 0, Key.P)
ButtonSystemStrings.Solo, @"button-default-select", OsuIcon.Player, new Color4(102, 68, 204, 255), (_, _) => { }, 0, Key.P)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
@ -87,7 +87,7 @@ namespace osu.Game.Tests.Visual.UserInterface
Origin = Anchor.Centre,
AutoSizeAxes = Axes.Both,
CachedDependencies = [(typeof(INotificationOverlay), notificationOverlay)],
Child = new DailyChallengeButton(@"button-default-select", new Color4(102, 68, 204, 255), _ => { }, 0, Key.D)
Child = new DailyChallengeButton(@"button-default-select", new Color4(102, 68, 204, 255), (_, _) => { }, 0, Key.D)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
@ -161,7 +161,7 @@ namespace osu.Game.Tests.Visual.UserInterface
Origin = Anchor.Centre,
AutoSizeAxes = Axes.Both,
CachedDependencies = [(typeof(INotificationOverlay), notificationOverlay)],
Child = new DailyChallengeButton(@"button-default-select", new Color4(102, 68, 204, 255), _ => { }, 0, Key.D)
Child = new DailyChallengeButton(@"button-default-select", new Color4(102, 68, 204, 255), (_, _) => { }, 0, Key.D)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,

View File

@ -4,12 +4,15 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Audio.Track;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Graphics.Textures;
using osu.Framework.Lists;
using osu.Framework.Logging;
using osu.Framework.Threading;
@ -18,7 +21,11 @@ using osu.Game.Database;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI;
using osu.Game.Scoring;
using osu.Game.Skinning;
using osu.Game.Storyboards;
namespace osu.Game.Beatmaps
{
@ -237,10 +244,37 @@ namespace osu.Game.Beatmaps
var ruleset = rulesetInfo.CreateInstance();
Debug.Assert(ruleset != null);
var calculator = ruleset.CreateDifficultyCalculator(beatmapManager.GetWorkingBeatmap(key.BeatmapInfo));
var attributes = calculator.Calculate(key.OrderedMods, cancellationToken);
PlayableCachedWorkingBeatmap workingBeatmap = new PlayableCachedWorkingBeatmap(beatmapManager.GetWorkingBeatmap(key.BeatmapInfo));
IBeatmap playableBeatmap = workingBeatmap.GetPlayableBeatmap(ruleset.RulesetInfo, key.OrderedMods, cancellationToken);
return new StarDifficulty(attributes);
var difficulty = ruleset.CreateDifficultyCalculator(workingBeatmap).Calculate(key.OrderedMods, cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
var performanceCalculator = ruleset.CreatePerformanceCalculator();
if (performanceCalculator == null)
return new StarDifficulty(difficulty, new PerformanceAttributes());
ScoreProcessor scoreProcessor = ruleset.CreateScoreProcessor();
scoreProcessor.Mods.Value = key.OrderedMods;
scoreProcessor.ApplyBeatmap(playableBeatmap);
cancellationToken.ThrowIfCancellationRequested();
ScoreInfo perfectScore = new ScoreInfo(key.BeatmapInfo, ruleset.RulesetInfo)
{
Passed = true,
Accuracy = 1,
Mods = key.OrderedMods,
MaxCombo = scoreProcessor.MaximumCombo,
Combo = scoreProcessor.MaximumCombo,
TotalScore = scoreProcessor.MaximumTotalScore,
Statistics = scoreProcessor.MaximumStatistics,
MaximumStatistics = scoreProcessor.MaximumStatistics
};
var performance = performanceCalculator.Calculate(perfectScore, difficulty);
cancellationToken.ThrowIfCancellationRequested();
return new StarDifficulty(difficulty, performance);
}
catch (OperationCanceledException)
{
@ -276,7 +310,6 @@ namespace osu.Game.Beatmaps
{
public readonly BeatmapInfo BeatmapInfo;
public readonly RulesetInfo Ruleset;
public readonly Mod[] OrderedMods;
public DifficultyCacheLookup(BeatmapInfo beatmapInfo, RulesetInfo? ruleset, IEnumerable<Mod>? mods)
@ -317,5 +350,42 @@ namespace osu.Game.Beatmaps
CancellationToken = cancellationToken;
}
}
/// <summary>
/// A working beatmap that caches its playable representation.
/// This is intended as single-use for when it is guaranteed that the playable beatmap can be reused.
/// </summary>
private class PlayableCachedWorkingBeatmap : IWorkingBeatmap
{
private readonly IWorkingBeatmap working;
private IBeatmap? playable;
public PlayableCachedWorkingBeatmap(IWorkingBeatmap working)
{
this.working = working;
}
public IBeatmap GetPlayableBeatmap(IRulesetInfo ruleset, IReadOnlyList<Mod> mods)
=> playable ??= working.GetPlayableBeatmap(ruleset, mods);
public IBeatmap GetPlayableBeatmap(IRulesetInfo ruleset, IReadOnlyList<Mod> mods, CancellationToken cancellationToken)
=> playable ??= working.GetPlayableBeatmap(ruleset, mods, cancellationToken);
IBeatmapInfo IWorkingBeatmap.BeatmapInfo => working.BeatmapInfo;
bool IWorkingBeatmap.BeatmapLoaded => working.BeatmapLoaded;
bool IWorkingBeatmap.TrackLoaded => working.TrackLoaded;
IBeatmap IWorkingBeatmap.Beatmap => working.Beatmap;
Texture IWorkingBeatmap.GetBackground() => working.GetBackground();
Texture IWorkingBeatmap.GetPanelBackground() => working.GetPanelBackground();
Waveform IWorkingBeatmap.Waveform => working.Waveform;
Storyboard IWorkingBeatmap.Storyboard => working.Storyboard;
ISkin IWorkingBeatmap.Skin => working.Skin;
Track IWorkingBeatmap.Track => working.Track;
Track IWorkingBeatmap.LoadTrack() => working.LoadTrack();
Stream IWorkingBeatmap.GetStream(string storagePath) => working.GetStream(storagePath);
void IWorkingBeatmap.BeginAsyncLoad() => working.BeginAsyncLoad();
void IWorkingBeatmap.CancelAsyncLoad() => working.CancelAsyncLoad();
void IWorkingBeatmap.PrepareTrackForPreview(bool looping, double offsetFromPreviewPoint) => working.PrepareTrackForPreview(looping, offsetFromPreviewPoint);
}
}
}

View File

@ -9,6 +9,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Shapes;
using osu.Game.Extensions;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Rulesets;
@ -146,7 +147,8 @@ namespace osu.Game.Beatmaps.Drawables
approachRate.Text = @" AR: " + adjustedDifficulty.ApproachRate.ToString(@"0.##");
overallDifficulty.Text = @" OD: " + adjustedDifficulty.OverallDifficulty.ToString(@"0.##");
length.Text = "Length: " + TimeSpan.FromMilliseconds(displayedContent.BeatmapInfo.Length / rate).ToString(@"mm\:ss");
TimeSpan lengthTimeSpan = TimeSpan.FromMilliseconds(displayedContent.BeatmapInfo.Length / rate);
length.Text = "Length: " + lengthTimeSpan.ToFormattedDuration();
bpm.Text = " BPM: " + Math.Round(bpmAdjusted, 0);
}

View File

@ -38,8 +38,7 @@ namespace osu.Game.Beatmaps.Formats
internal static RulesetStore? RulesetStore;
private Beatmap beatmap = null!;
private ConvertHitObjectParser? parser;
private ConvertHitObjectParser parser = null!;
private LegacySampleBank defaultSampleBank;
private int defaultSampleVolume = 100;
@ -80,6 +79,7 @@ namespace osu.Game.Beatmaps.Formats
{
this.beatmap = beatmap;
this.beatmap.BeatmapInfo.BeatmapVersion = FormatVersion;
parser = new ConvertHitObjectParser(getOffsetTime(), FormatVersion);
applyLegacyDefaults(this.beatmap.BeatmapInfo);
@ -162,7 +162,8 @@ namespace osu.Game.Beatmaps.Formats
{
if (hitObject is IHasRepeats hasRepeats)
{
SampleControlPoint sampleControlPoint = (beatmap.ControlPointInfo as LegacyControlPointInfo)?.SamplePointAt(hitObject.StartTime + CONTROL_POINT_LENIENCY + 1) ?? SampleControlPoint.DEFAULT;
SampleControlPoint sampleControlPoint = (beatmap.ControlPointInfo as LegacyControlPointInfo)?.SamplePointAt(hitObject.StartTime + CONTROL_POINT_LENIENCY + 1)
?? SampleControlPoint.DEFAULT;
hitObject.Samples = hitObject.Samples.Select(o => sampleControlPoint.ApplyTo(o)).ToList();
for (int i = 0; i < hasRepeats.NodeSamples.Count; i++)
@ -175,7 +176,8 @@ namespace osu.Game.Beatmaps.Formats
}
else
{
SampleControlPoint sampleControlPoint = (beatmap.ControlPointInfo as LegacyControlPointInfo)?.SamplePointAt(hitObject.GetEndTime() + CONTROL_POINT_LENIENCY) ?? SampleControlPoint.DEFAULT;
SampleControlPoint sampleControlPoint = (beatmap.ControlPointInfo as LegacyControlPointInfo)?.SamplePointAt(hitObject.GetEndTime() + CONTROL_POINT_LENIENCY)
?? SampleControlPoint.DEFAULT;
hitObject.Samples = hitObject.Samples.Select(o => sampleControlPoint.ApplyTo(o)).ToList();
}
}
@ -263,29 +265,7 @@ namespace osu.Game.Beatmaps.Formats
break;
case @"Mode":
int rulesetID = Parsing.ParseInt(pair.Value);
beatmap.BeatmapInfo.Ruleset = RulesetStore?.GetRuleset(rulesetID) ?? throw new ArgumentException("Ruleset is not available locally.");
switch (rulesetID)
{
case 0:
parser = new Rulesets.Objects.Legacy.Osu.ConvertHitObjectParser(getOffsetTime(), FormatVersion);
break;
case 1:
parser = new Rulesets.Objects.Legacy.Taiko.ConvertHitObjectParser(getOffsetTime(), FormatVersion);
break;
case 2:
parser = new Rulesets.Objects.Legacy.Catch.ConvertHitObjectParser(getOffsetTime(), FormatVersion);
break;
case 3:
parser = new Rulesets.Objects.Legacy.Mania.ConvertHitObjectParser(getOffsetTime(), FormatVersion);
break;
}
beatmap.BeatmapInfo.Ruleset = RulesetStore?.GetRuleset(Parsing.ParseInt(pair.Value)) ?? throw new ArgumentException("Ruleset is not available locally.");
break;
case @"LetterboxInBreaks":
@ -617,17 +597,10 @@ namespace osu.Game.Beatmaps.Formats
private void handleHitObject(string line)
{
// If the ruleset wasn't specified, assume the osu!standard ruleset.
parser ??= new Rulesets.Objects.Legacy.Osu.ConvertHitObjectParser(getOffsetTime(), FormatVersion);
var obj = parser.Parse(line);
obj.ApplyDefaults(beatmap.ControlPointInfo, beatmap.Difficulty);
if (obj != null)
{
obj.ApplyDefaults(beatmap.ControlPointInfo, beatmap.Difficulty);
beatmap.HitObjects.Add(obj);
}
beatmap.HitObjects.Add(obj);
}
private int getOffsetTime(int time) => time + (ApplyOffsets ? offset : 0);

View File

@ -6,7 +6,7 @@ using System;
namespace osu.Game.Beatmaps.Legacy
{
[Flags]
internal enum LegacyHitObjectType
public enum LegacyHitObjectType
{
Circle = 1,
Slider = 1 << 1,

View File

@ -1,9 +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 JetBrains.Annotations;
using osu.Framework.Utils;
using osu.Game.Rulesets.Difficulty;
@ -25,30 +22,34 @@ namespace osu.Game.Beatmaps
/// The difficulty attributes computed for the given beatmap.
/// Might not be available if the star difficulty is associated with a beatmap that's not locally available.
/// </summary>
[CanBeNull]
public readonly DifficultyAttributes Attributes;
public readonly DifficultyAttributes? DifficultyAttributes;
/// <summary>
/// Creates a <see cref="StarDifficulty"/> structure based on <see cref="DifficultyAttributes"/> computed
/// by a <see cref="DifficultyCalculator"/>.
/// The performance attributes computed for a perfect score on the given beatmap.
/// Might not be available if the star difficulty is associated with a beatmap that's not locally available.
/// </summary>
public StarDifficulty([NotNull] DifficultyAttributes attributes)
public readonly PerformanceAttributes? PerformanceAttributes;
/// <summary>
/// Creates a <see cref="StarDifficulty"/> structure.
/// </summary>
public StarDifficulty(DifficultyAttributes difficulty, PerformanceAttributes performance)
{
Stars = double.IsFinite(attributes.StarRating) ? attributes.StarRating : 0;
MaxCombo = attributes.MaxCombo;
Attributes = attributes;
Stars = double.IsFinite(difficulty.StarRating) ? difficulty.StarRating : 0;
MaxCombo = difficulty.MaxCombo;
DifficultyAttributes = difficulty;
PerformanceAttributes = performance;
// Todo: Add more members (BeatmapInfo.DifficultyRating? Attributes? Etc...)
}
/// <summary>
/// Creates a <see cref="StarDifficulty"/> structure with a pre-populated star difficulty and max combo
/// in scenarios where computing <see cref="DifficultyAttributes"/> is not feasible (i.e. when working with online sources).
/// in scenarios where computing <see cref="Rulesets.Difficulty.DifficultyAttributes"/> is not feasible (i.e. when working with online sources).
/// </summary>
public StarDifficulty(double starDifficulty, int maxCombo)
{
Stars = double.IsFinite(starDifficulty) ? starDifficulty : 0;
MaxCombo = maxCombo;
Attributes = null;
}
public DifficultyRating DifficultyRating => GetDifficultyRating(Stars);

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using osu.Framework.Allocation;
@ -29,7 +30,11 @@ namespace osu.Game.Collections
private IDisposable? realmSubscription;
protected override FillFlowContainer<RearrangeableListItem<Live<BeatmapCollection>>> CreateListFillFlowContainer() => new Flow
private Flow flow = null!;
public IEnumerable<Drawable> OrderedItems => flow.FlowingChildren;
protected override FillFlowContainer<RearrangeableListItem<Live<BeatmapCollection>>> CreateListFillFlowContainer() => flow = new Flow
{
DragActive = { BindTarget = DragActive }
};
@ -43,8 +48,25 @@ namespace osu.Game.Collections
private void collectionsChanged(IRealmCollection<BeatmapCollection> collections, ChangeSet? changes)
{
Items.Clear();
Items.AddRange(collections.AsEnumerable().Select(c => c.ToLive(realm)));
if (changes == null)
{
Items.AddRange(collections.AsEnumerable().Select(c => c.ToLive(realm)));
return;
}
foreach (int i in changes.DeletedIndices.OrderDescending())
Items.RemoveAt(i);
foreach (int i in changes.InsertedIndices)
Items.Insert(i, collections[i].ToLive(realm));
foreach (int i in changes.NewModifiedIndices)
{
var updatedItem = collections[i];
Items.RemoveAt(i);
Items.Insert(i, updatedItem.ToLive(realm));
}
}
protected override OsuRearrangeableListItem<Live<BeatmapCollection>> CreateOsuDrawable(Live<BeatmapCollection> item)
@ -123,12 +145,37 @@ namespace osu.Game.Collections
var previous = PlaceholderItem;
placeholderContainer.Clear(false);
placeholderContainer.Add(PlaceholderItem = new DrawableCollectionListItem(new BeatmapCollection().ToLiveUnmanaged(), false));
placeholderContainer.Add(PlaceholderItem = new NewCollectionEntryItem());
return previous;
}
}
private partial class NewCollectionEntryItem : DrawableCollectionListItem
{
[Resolved]
private RealmAccess realm { get; set; } = null!;
public NewCollectionEntryItem()
: base(new BeatmapCollection().ToLiveUnmanaged(), false)
{
}
protected override void LoadComplete()
{
base.LoadComplete();
TextBox.OnCommit += (sender, newText) =>
{
if (string.IsNullOrEmpty(TextBox.Text))
return;
realm.Write(r => r.Add(new BeatmapCollection(TextBox.Text)));
TextBox.Text = string.Empty;
};
}
}
/// <summary>
/// The flow of <see cref="DrawableCollectionListItem"/>. Disables layout easing unless a drag is in progress.
/// </summary>

View File

@ -28,6 +28,10 @@ namespace osu.Game.Collections
private const float item_height = 35;
private const float button_width = item_height * 0.75f;
protected TextBox TextBox => content.TextBox;
private ItemContent content = null!;
/// <summary>
/// Creates a new <see cref="DrawableCollectionListItem"/>.
/// </summary>
@ -48,7 +52,7 @@ namespace osu.Game.Collections
CornerRadius = item_height / 2;
}
protected override Drawable CreateContent() => new ItemContent(Model);
protected override Drawable CreateContent() => content = new ItemContent(Model);
/// <summary>
/// The main content of the <see cref="DrawableCollectionListItem"/>.
@ -57,10 +61,7 @@ namespace osu.Game.Collections
{
private readonly Live<BeatmapCollection> collection;
private ItemTextBox textBox = null!;
[Resolved]
private RealmAccess realm { get; set; } = null!;
public ItemTextBox TextBox { get; private set; } = null!;
public ItemContent(Live<BeatmapCollection> collection)
{
@ -80,7 +81,7 @@ namespace osu.Game.Collections
{
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
IsTextBoxHovered = v => textBox.ReceivePositionalInputAt(v)
IsTextBoxHovered = v => TextBox.ReceivePositionalInputAt(v)
}
: Empty(),
new Container
@ -89,7 +90,7 @@ namespace osu.Game.Collections
Padding = new MarginPadding { Right = collection.IsManaged ? button_width : 0 },
Children = new Drawable[]
{
textBox = new ItemTextBox
TextBox = new ItemTextBox
{
RelativeSizeAxes = Axes.Both,
Size = Vector2.One,
@ -107,18 +108,14 @@ namespace osu.Game.Collections
base.LoadComplete();
// Bind late, as the collection name may change externally while still loading.
textBox.Current.Value = collection.PerformRead(c => c.IsValid ? c.Name : string.Empty);
textBox.OnCommit += onCommit;
TextBox.Current.Value = collection.PerformRead(c => c.IsValid ? c.Name : string.Empty);
TextBox.OnCommit += onCommit;
}
private void onCommit(TextBox sender, bool newText)
{
if (collection.IsManaged)
collection.PerformWrite(c => c.Name = textBox.Current.Value);
else if (!string.IsNullOrEmpty(textBox.Current.Value))
realm.Write(r => r.Add(new BeatmapCollection(textBox.Current.Value)));
textBox.Text = string.Empty;
if (collection.IsManaged && collection.Value.Name != TextBox.Current.Value)
collection.PerformWrite(c => c.Name = TextBox.Current.Value);
}
}

View File

@ -196,6 +196,7 @@ namespace osu.Game.Configuration
SetDefault(OsuSetting.EditorShowSpeedChanges, false);
SetDefault(OsuSetting.EditorScaleOrigin, EditorOrigin.GridCentre);
SetDefault(OsuSetting.EditorRotationOrigin, EditorOrigin.GridCentre);
SetDefault(OsuSetting.EditorAdjustExistingObjectsOnTimingChanges, true);
SetDefault(OsuSetting.HideCountryFlags, false);
@ -442,5 +443,6 @@ namespace osu.Game.Configuration
EditorScaleOrigin,
EditorRotationOrigin,
EditorTimelineShowBreaks,
EditorAdjustExistingObjectsOnTimingChanges,
}
}

View File

@ -59,7 +59,25 @@ namespace osu.Game.Database
};
// Convert beatmap elements to be compatible with legacy format
// So we truncate time and position values to integers, and convert paths with multiple segments to bezier curves
// So we truncate time and position values to integers, and convert paths with multiple segments to Bézier curves
// We must first truncate all timing points and move all objects in the timing section with it to ensure everything stays snapped
for (int i = 0; i < playableBeatmap.ControlPointInfo.TimingPoints.Count; i++)
{
var timingPoint = playableBeatmap.ControlPointInfo.TimingPoints[i];
double offset = Math.Floor(timingPoint.Time) - timingPoint.Time;
double nextTimingPointTime = i + 1 < playableBeatmap.ControlPointInfo.TimingPoints.Count
? playableBeatmap.ControlPointInfo.TimingPoints[i + 1].Time
: double.PositiveInfinity;
// Offset all control points in the timing section (including the current one)
foreach (var controlPoint in playableBeatmap.ControlPointInfo.AllControlPoints.Where(o => o.Time >= timingPoint.Time && o.Time < nextTimingPointTime))
controlPoint.Time += offset;
// Offset all hit objects in the timing section
foreach (var hitObject in playableBeatmap.HitObjects.Where(o => o.StartTime >= timingPoint.Time && o.StartTime < nextTimingPointTime))
hitObject.StartTime += offset;
}
foreach (var controlPoint in playableBeatmap.ControlPointInfo.AllControlPoints)
controlPoint.Time = Math.Floor(controlPoint.Time);

View File

@ -39,6 +39,11 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString SetPreviewPointToCurrent => new TranslatableString(getKey(@"set_preview_point_to_current"), @"Set preview point to current time");
/// <summary>
/// "Move already placed objects when changing timing"
/// </summary>
public static LocalisableString AdjustExistingObjectsOnTimingChanges => new TranslatableString(getKey(@"adjust_existing_objects_on_timing_changes"), @"Move already placed objects when changing timing");
/// <summary>
/// "For editing (.olz)"
/// </summary>

View File

@ -12,23 +12,28 @@ namespace osu.Game.Localisation.SkinComponents
/// <summary>
/// "Attribute"
/// </summary>
public static LocalisableString Attribute => new TranslatableString(getKey(@"attribute"), "Attribute");
public static LocalisableString Attribute => new TranslatableString(getKey(@"attribute"), @"Attribute");
/// <summary>
/// "The attribute to be displayed."
/// </summary>
public static LocalisableString AttributeDescription => new TranslatableString(getKey(@"attribute_description"), "The attribute to be displayed.");
public static LocalisableString AttributeDescription => new TranslatableString(getKey(@"attribute_description"), @"The attribute to be displayed.");
/// <summary>
/// "Template"
/// </summary>
public static LocalisableString Template => new TranslatableString(getKey(@"template"), "Template");
public static LocalisableString Template => new TranslatableString(getKey(@"template"), @"Template");
/// <summary>
/// "Supports {{Label}} and {{Value}}, but also including arbitrary attributes like {{StarRating}} (see attribute list for supported values)."
/// </summary>
public static LocalisableString TemplateDescription => new TranslatableString(getKey(@"template_description"), @"Supports {{Label}} and {{Value}}, but also including arbitrary attributes like {{StarRating}} (see attribute list for supported values).");
private static string getKey(string key) => $"{prefix}:{key}";
/// <summary>
/// "Max PP"
/// </summary>
public static LocalisableString MaxPP => new TranslatableString(getKey(@"max_pp"), @"Max PP");
private static string getKey(string key) => $@"{prefix}:{key}";
}
}

View File

@ -1,121 +0,0 @@
// 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 System.Threading;
using System.Threading.Tasks;
using JetBrains.Annotations;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
namespace osu.Game.Rulesets.Difficulty
{
public class PerformanceBreakdownCalculator
{
private readonly IBeatmap playableBeatmap;
private readonly BeatmapDifficultyCache difficultyCache;
public PerformanceBreakdownCalculator(IBeatmap playableBeatmap, BeatmapDifficultyCache difficultyCache)
{
this.playableBeatmap = playableBeatmap;
this.difficultyCache = difficultyCache;
}
[ItemCanBeNull]
public async Task<PerformanceBreakdown> CalculateAsync(ScoreInfo score, CancellationToken cancellationToken = default)
{
var attributes = await difficultyCache.GetDifficultyAsync(score.BeatmapInfo!, score.Ruleset, score.Mods, cancellationToken).ConfigureAwait(false);
var performanceCalculator = score.Ruleset.CreateInstance().CreatePerformanceCalculator();
// Performance calculation requires the beatmap and ruleset to be locally available. If not, return a default value.
if (attributes?.Attributes == null || performanceCalculator == null)
return null;
cancellationToken.ThrowIfCancellationRequested();
PerformanceAttributes[] performanceArray = await Task.WhenAll(
// compute actual performance
performanceCalculator.CalculateAsync(score, attributes.Value.Attributes, cancellationToken),
// compute performance for perfect play
getPerfectPerformance(score, cancellationToken)
).ConfigureAwait(false);
return new PerformanceBreakdown(performanceArray[0] ?? new PerformanceAttributes(), performanceArray[1] ?? new PerformanceAttributes());
}
[ItemCanBeNull]
private Task<PerformanceAttributes> getPerfectPerformance(ScoreInfo score, CancellationToken cancellationToken = default)
{
return Task.Run(async () =>
{
Ruleset ruleset = score.Ruleset.CreateInstance();
ScoreInfo perfectPlay = score.DeepClone();
perfectPlay.Accuracy = 1;
perfectPlay.Passed = true;
// calculate max combo
// todo: Get max combo from difficulty calculator instead when diffcalc properly supports lazer-first scores
perfectPlay.MaxCombo = calculateMaxCombo(playableBeatmap);
// create statistics assuming all hit objects have perfect hit result
var statistics = playableBeatmap.HitObjects
.SelectMany(getPerfectHitResults)
.GroupBy(hr => hr, (hr, list) => (hitResult: hr, count: list.Count()))
.ToDictionary(pair => pair.hitResult, pair => pair.count);
perfectPlay.Statistics = statistics;
perfectPlay.MaximumStatistics = statistics;
// calculate total score
ScoreProcessor scoreProcessor = ruleset.CreateScoreProcessor();
scoreProcessor.Mods.Value = perfectPlay.Mods;
scoreProcessor.ApplyBeatmap(playableBeatmap);
perfectPlay.TotalScore = scoreProcessor.MaximumTotalScore;
// compute rank achieved
// default to SS, then adjust the rank with mods
perfectPlay.Rank = ScoreRank.X;
foreach (IApplicableToScoreProcessor mod in perfectPlay.Mods.OfType<IApplicableToScoreProcessor>())
{
perfectPlay.Rank = mod.AdjustRank(perfectPlay.Rank, 1);
}
// calculate performance for this perfect score
var difficulty = await difficultyCache.GetDifficultyAsync(
playableBeatmap.BeatmapInfo,
score.Ruleset,
score.Mods,
cancellationToken
).ConfigureAwait(false);
var performanceCalculator = ruleset.CreatePerformanceCalculator();
if (performanceCalculator == null || difficulty == null)
return null;
return await performanceCalculator.CalculateAsync(perfectPlay, difficulty.Value.Attributes.AsNonNull(), cancellationToken).ConfigureAwait(false);
}, cancellationToken);
}
private int calculateMaxCombo(IBeatmap beatmap)
{
return beatmap.HitObjects.SelectMany(getPerfectHitResults).Count(r => r.AffectsCombo());
}
private IEnumerable<HitResult> getPerfectHitResults(HitObject hitObject)
{
foreach (HitObject nested in hitObject.NestedHitObjects)
yield return nested.Judgement.MaxResult;
yield return hitObject.Judgement.MaxResult;
}
}
}

View File

@ -26,10 +26,10 @@ namespace osu.Game.Rulesets.Difficulty.Skills
protected virtual int SectionLength => 400;
private double currentSectionPeak; // We also keep track of the peak strain level in the current section.
private double currentSectionEnd;
private readonly List<double> strainPeaks = new List<double>();
protected readonly List<double> ObjectStrains = new List<double>(); // Store individual strains
protected StrainSkill(Mod[] mods)
: base(mods)
@ -57,7 +57,29 @@ namespace osu.Game.Rulesets.Difficulty.Skills
currentSectionEnd += SectionLength;
}
currentSectionPeak = Math.Max(StrainValueAt(current), currentSectionPeak);
double strain = StrainValueAt(current);
currentSectionPeak = Math.Max(strain, currentSectionPeak);
// Store the strain value for the object
ObjectStrains.Add(strain);
}
/// <summary>
/// Calculates the number of strains weighted against the top strain.
/// The result is scaled by clock rate as it affects the total number of strains.
/// </summary>
public virtual double CountTopWeightedStrains()
{
if (ObjectStrains.Count == 0)
return 0.0;
double consistentTopStrain = DifficultyValue() / 10; // What would the top strain be if all strain values were identical
if (consistentTopStrain == 0)
return ObjectStrains.Count;
// Use a weighted sum of all strains. Constants are arbitrary and give nice values
return ObjectStrains.Sum(s => 1.1 / (1 + Math.Exp(-10 * (s / consistentTopStrain - 0.88))));
}
/// <summary>

View File

@ -0,0 +1,50 @@
// 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;
namespace osu.Game.Rulesets.Difficulty.Utils
{
public static class DifficultyCalculationUtils
{
/// <summary>
/// Converts BPM value into milliseconds
/// </summary>
/// <param name="bpm">Beats per minute</param>
/// <param name="delimiter">Which rhythm delimiter to use, default is 1/4</param>
/// <returns>BPM conveted to milliseconds</returns>
public static double BPMToMilliseconds(double bpm, int delimiter = 4)
{
return 60000.0 / delimiter / bpm;
}
/// <summary>
/// Converts milliseconds value into a BPM value
/// </summary>
/// <param name="ms">Milliseconds</param>
/// <param name="delimiter">Which rhythm delimiter to use, default is 1/4</param>
/// <returns>Milliseconds conveted to beats per minute</returns>
public static double MillisecondsToBPM(double ms, int delimiter = 4)
{
return 60000.0 / (ms * delimiter);
}
/// <summary>
/// Calculates a S-shaped logistic function (https://en.wikipedia.org/wiki/Logistic_function)
/// </summary>
/// <param name="x">Value to calculate the function for</param>
/// <param name="maxValue">Maximum value returnable by the function</param>
/// <param name="multiplier">Growth rate of the function</param>
/// <param name="midpointOffset">How much the function midpoint is offset from zero <paramref name="x"/></param>
/// <returns>The output of logistic function of <paramref name="x"/></returns>
public static double Logistic(double x, double midpointOffset, double multiplier, double maxValue = 1) => maxValue / (1 + Math.Exp(multiplier * (midpointOffset - x)));
/// <summary>
/// Calculates a S-shaped logistic function (https://en.wikipedia.org/wiki/Logistic_function)
/// </summary>
/// <param name="maxValue">Maximum value returnable by the function</param>
/// <param name="exponent">Exponent</param>
/// <returns>The output of logistic function</returns>
public static double Logistic(double exponent, double maxValue = 1) => maxValue / (1 + Math.Exp(exponent));
}
}

View File

@ -537,22 +537,23 @@ namespace osu.Game.Rulesets.Edit
#region IPlacementHandler
public void BeginPlacement(HitObject hitObject)
public void ShowPlacement(HitObject hitObject)
{
EditorBeatmap.PlacementObject.Value = hitObject;
}
public void EndPlacement(HitObject hitObject, bool commit)
public void HidePlacement()
{
EditorBeatmap.PlacementObject.Value = null;
}
if (commit)
{
EditorBeatmap.Add(hitObject);
public void CommitPlacement(HitObject hitObject)
{
EditorBeatmap.PlacementObject.Value = null;
EditorBeatmap.Add(hitObject);
if (autoSeekOnPlacement.Value && EditorClock.CurrentTime < hitObject.StartTime)
EditorClock.SeekSmoothlyTo(hitObject.StartTime);
}
if (autoSeekOnPlacement.Value && EditorClock.CurrentTime < hitObject.StartTime)
EditorClock.SeekSmoothlyTo(hitObject.StartTime);
}
public void Delete(HitObject hitObject) => EditorBeatmap.Remove(hitObject);

View File

@ -5,6 +5,7 @@ using System.Linq;
using System.Threading;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics.Containers;
using osu.Game.Audio;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
@ -63,18 +64,26 @@ namespace osu.Game.Rulesets.Edit
startTimeBindable.BindValueChanged(_ => ApplyDefaultsToHitObject(), true);
}
private bool placementBegun;
protected override void BeginPlacement(bool commitStart = false)
{
base.BeginPlacement(commitStart);
placementHandler.BeginPlacement(HitObject);
if (State.Value == Visibility.Visible)
placementHandler.ShowPlacement(HitObject);
placementBegun = true;
}
public override void EndPlacement(bool commit)
{
base.EndPlacement(commit);
placementHandler.EndPlacement(HitObject, IsValidForPlacement && commit);
if (IsValidForPlacement && commit)
placementHandler.CommitPlacement(HitObject);
else
placementHandler.HidePlacement();
}
/// <summary>
@ -127,5 +136,19 @@ namespace osu.Game.Rulesets.Edit
/// refreshing <see cref="Objects.HitObject.NestedHitObjects"/> and parameters for the <see cref="HitObject"/>.
/// </summary>
protected void ApplyDefaultsToHitObject() => HitObject.ApplyDefaults(beatmap.ControlPointInfo, beatmap.Difficulty);
protected override void PopIn()
{
base.PopIn();
if (placementBegun)
placementHandler.ShowPlacement(HitObject);
}
protected override void PopOut()
{
base.PopOut();
placementHandler.HidePlacement();
}
}
}

View File

@ -7,7 +7,6 @@ using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Game.Input.Bindings;
using osu.Game.Rulesets.Objects;
using osuTK;
using osuTK.Input;
namespace osu.Game.Rulesets.Edit
@ -15,7 +14,7 @@ namespace osu.Game.Rulesets.Edit
/// <summary>
/// A blueprint which governs the placement of something.
/// </summary>
public abstract partial class PlacementBlueprint : CompositeDrawable, IKeyBindingHandler<GlobalAction>
public abstract partial class PlacementBlueprint : VisibilityContainer, IKeyBindingHandler<GlobalAction>
{
/// <summary>
/// Whether the <see cref="HitObject"/> is currently mid-placement, but has not necessarily finished being placed.
@ -31,12 +30,17 @@ namespace osu.Game.Rulesets.Edit
/// </remarks>
protected virtual bool IsValidForPlacement => true;
// the blueprint should still be considered for input even if it is hidden,
// especially when such input is the reason for making the blueprint become visible.
public override bool PropagatePositionalInputSubTree => true;
public override bool PropagateNonPositionalInputSubTree => true;
protected PlacementBlueprint()
{
RelativeSizeAxes = Axes.Both;
// This is required to allow the blueprint's position to be updated via OnMouseMove/Handle
// on the same frame it is made visible via a PlacementState change.
// the blueprint should still be considered for input even if it is hidden,
// especially when such input is the reason for making the blueprint become visible.
AlwaysPresent = true;
}
@ -104,8 +108,6 @@ namespace osu.Game.Rulesets.Edit
{
}
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true;
protected override bool Handle(UIEvent e)
{
base.Handle(e);
@ -127,6 +129,9 @@ namespace osu.Game.Rulesets.Edit
}
}
protected override void PopIn() => this.FadeIn();
protected override void PopOut() => this.FadeOut();
public enum PlacementState
{
Waiting,

View File

@ -1,20 +0,0 @@
// 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.Game.Rulesets.Objects.Types;
using osuTK;
namespace osu.Game.Rulesets.Objects.Legacy.Catch
{
/// <summary>
/// Legacy osu!catch Hit-type, used for parsing Beatmaps.
/// </summary>
internal sealed class ConvertHit : ConvertHitObject, IHasPosition
{
public float X => Position.X;
public float Y => Position.Y;
public Vector2 Position { get; set; }
}
}

View File

@ -1,63 +0,0 @@
// 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 osuTK;
using osu.Game.Audio;
using System.Collections.Generic;
namespace osu.Game.Rulesets.Objects.Legacy.Catch
{
/// <summary>
/// A HitObjectParser to parse legacy osu!catch Beatmaps.
/// </summary>
public class ConvertHitObjectParser : Legacy.ConvertHitObjectParser
{
private ConvertHitObject lastObject;
public ConvertHitObjectParser(double offset, int formatVersion)
: base(offset, formatVersion)
{
}
protected override HitObject CreateHit(Vector2 position, bool newCombo, int comboOffset)
{
return lastObject = new ConvertHit
{
Position = position,
NewCombo = FirstObject || lastObject is ConvertSpinner || newCombo,
ComboOffset = newCombo ? comboOffset : 0
};
}
protected override HitObject CreateSlider(Vector2 position, bool newCombo, int comboOffset, PathControlPoint[] controlPoints, double? length, int repeatCount,
IList<IList<HitSampleInfo>> nodeSamples)
{
return lastObject = new ConvertSlider
{
Position = position,
NewCombo = FirstObject || lastObject is ConvertSpinner || newCombo,
ComboOffset = newCombo ? comboOffset : 0,
Path = new SliderPath(controlPoints, length),
NodeSamples = nodeSamples,
RepeatCount = repeatCount
};
}
protected override HitObject CreateSpinner(Vector2 position, bool newCombo, int comboOffset, double duration)
{
return lastObject = new ConvertSpinner
{
Duration = duration,
NewCombo = newCombo
// Spinners cannot have combo offset.
};
}
protected override HitObject CreateHold(Vector2 position, bool newCombo, int comboOffset, double duration)
{
return lastObject = null;
}
}
}

View File

@ -1,20 +0,0 @@
// 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.Game.Rulesets.Objects.Types;
using osuTK;
namespace osu.Game.Rulesets.Objects.Legacy.Catch
{
/// <summary>
/// Legacy osu!catch Slider-type, used for parsing Beatmaps.
/// </summary>
internal sealed class ConvertSlider : Legacy.ConvertSlider, IHasPosition
{
public float X => Position.X;
public float Y => Position.Y;
public Vector2 Position { get; set; }
}
}

View File

@ -0,0 +1,13 @@
// 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.
namespace osu.Game.Rulesets.Objects.Legacy
{
/// <summary>
/// Legacy "HitCircle" hit object type.
/// </summary>
/// <remarks>
/// Only used for parsing beatmaps and not gameplay.
/// </remarks>
internal sealed class ConvertHitCircle : ConvertHitObject;
}

View File

@ -1,21 +1,34 @@
// 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.Game.Beatmaps.Legacy;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Scoring;
using osuTK;
namespace osu.Game.Rulesets.Objects.Legacy
{
/// <summary>
/// A hit object only used for conversion, not actual gameplay.
/// Represents a legacy hit object.
/// </summary>
internal abstract class ConvertHitObject : HitObject, IHasCombo
/// <remarks>
/// Only used for parsing beatmaps and not gameplay.
/// </remarks>
internal abstract class ConvertHitObject : HitObject, IHasCombo, IHasPosition, IHasLegacyHitObjectType
{
public bool NewCombo { get; set; }
public int ComboOffset { get; set; }
public float X => Position.X;
public float Y => Position.Y;
public Vector2 Position { get; set; }
public LegacyHitObjectType LegacyType { get; set; }
public override Judgement CreateJudgement() => new IgnoreJudgement();
protected override HitWindows CreateHitWindows() => HitWindows.Empty;

View File

@ -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 osuTK;
using osu.Game.Rulesets.Objects.Types;
using System;
@ -11,7 +9,6 @@ using System.IO;
using osu.Game.Beatmaps.Formats;
using osu.Game.Audio;
using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Utils;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Beatmaps.Legacy;
@ -24,24 +21,32 @@ namespace osu.Game.Rulesets.Objects.Legacy
/// <summary>
/// A HitObjectParser to parse legacy Beatmaps.
/// </summary>
public abstract class ConvertHitObjectParser : HitObjectParser
public class ConvertHitObjectParser : HitObjectParser
{
/// <summary>
/// The offset to apply to all time values.
/// </summary>
protected readonly double Offset;
private readonly double offset;
/// <summary>
/// The .osu format (beatmap) version.
/// </summary>
protected readonly int FormatVersion;
private readonly int formatVersion;
protected bool FirstObject { get; private set; } = true;
/// <summary>
/// Whether the current hitobject is the first hitobject in the beatmap.
/// </summary>
private bool firstObject = true;
protected ConvertHitObjectParser(double offset, int formatVersion)
/// <summary>
/// The last parsed hitobject.
/// </summary>
private ConvertHitObject? lastObject;
internal ConvertHitObjectParser(double offset, int formatVersion)
{
Offset = offset;
FormatVersion = formatVersion;
this.offset = offset;
this.formatVersion = formatVersion;
}
public override HitObject Parse(string text)
@ -49,11 +54,11 @@ namespace osu.Game.Rulesets.Objects.Legacy
string[] split = text.Split(',');
Vector2 pos =
FormatVersion >= LegacyBeatmapEncoder.FIRST_LAZER_VERSION
formatVersion >= LegacyBeatmapEncoder.FIRST_LAZER_VERSION
? new Vector2(Parsing.ParseFloat(split[0], Parsing.MAX_COORDINATE_VALUE), Parsing.ParseFloat(split[1], Parsing.MAX_COORDINATE_VALUE))
: new Vector2((int)Parsing.ParseFloat(split[0], Parsing.MAX_COORDINATE_VALUE), (int)Parsing.ParseFloat(split[1], Parsing.MAX_COORDINATE_VALUE));
double startTime = Parsing.ParseDouble(split[2]) + Offset;
double startTime = Parsing.ParseDouble(split[2]) + offset;
LegacyHitObjectType type = (LegacyHitObjectType)Parsing.ParseInt(split[3]);
@ -66,11 +71,11 @@ namespace osu.Game.Rulesets.Objects.Legacy
var soundType = (LegacyHitSoundType)Parsing.ParseInt(split[4]);
var bankInfo = new SampleBankInfo();
HitObject result = null;
ConvertHitObject? result = null;
if (type.HasFlag(LegacyHitObjectType.Circle))
{
result = CreateHit(pos, combo, comboOffset);
result = createHitCircle(pos, combo, comboOffset);
if (split.Length > 5)
readCustomSampleBanks(split[5], bankInfo);
@ -145,13 +150,13 @@ namespace osu.Game.Rulesets.Objects.Legacy
for (int i = 0; i < nodes; i++)
nodeSamples.Add(convertSoundType(nodeSoundTypes[i], nodeBankInfos[i]));
result = CreateSlider(pos, combo, comboOffset, convertPathString(split[5], pos), length, repeatCount, nodeSamples);
result = createSlider(pos, combo, comboOffset, convertPathString(split[5], pos), length, repeatCount, nodeSamples);
}
else if (type.HasFlag(LegacyHitObjectType.Spinner))
{
double duration = Math.Max(0, Parsing.ParseDouble(split[5]) + Offset - startTime);
double duration = Math.Max(0, Parsing.ParseDouble(split[5]) + offset - startTime);
result = CreateSpinner(new Vector2(512, 384) / 2, combo, comboOffset, duration);
result = createSpinner(new Vector2(512, 384) / 2, combo, duration);
if (split.Length > 6)
readCustomSampleBanks(split[6], bankInfo);
@ -169,18 +174,19 @@ namespace osu.Game.Rulesets.Objects.Legacy
readCustomSampleBanks(string.Join(':', ss.Skip(1)), bankInfo);
}
result = CreateHold(pos, combo, comboOffset, endTime + Offset - startTime);
result = createHold(pos, endTime + offset - startTime);
}
if (result == null)
throw new InvalidDataException($"Unknown hit object type: {split[3]}");
result.StartTime = startTime;
result.LegacyType = type;
if (result.Samples.Count == 0)
result.Samples = convertSoundType(soundType, bankInfo);
FirstObject = false;
firstObject = false;
return result;
}
@ -200,10 +206,11 @@ namespace osu.Game.Rulesets.Objects.Legacy
if (!Enum.IsDefined(addBank))
addBank = LegacySampleBank.Normal;
string stringBank = bank.ToString().ToLowerInvariant();
string? stringBank = bank.ToString().ToLowerInvariant();
string? stringAddBank = addBank.ToString().ToLowerInvariant();
if (stringBank == @"none")
stringBank = null;
string stringAddBank = addBank.ToString().ToLowerInvariant();
if (stringAddBank == @"none")
{
@ -357,7 +364,7 @@ namespace osu.Game.Rulesets.Objects.Legacy
{
int endPointLength = endPoint == null ? 0 : 1;
if (FormatVersion < LegacyBeatmapEncoder.FIRST_LAZER_VERSION)
if (formatVersion < LegacyBeatmapEncoder.FIRST_LAZER_VERSION)
{
if (vertices.Length + endPointLength != 3)
type = PathType.BEZIER;
@ -393,7 +400,7 @@ namespace osu.Game.Rulesets.Objects.Legacy
// Legacy CATMULL sliders don't support multiple segments, so adjacent CATMULL segments should be treated as a single one.
// Importantly, this is not applied to the first control point, which may duplicate the slider path's position
// resulting in a duplicate (0,0) control point in the resultant list.
if (type == PathType.CATMULL && endIndex > 1 && FormatVersion < LegacyBeatmapEncoder.FIRST_LAZER_VERSION)
if (type == PathType.CATMULL && endIndex > 1 && formatVersion < LegacyBeatmapEncoder.FIRST_LAZER_VERSION)
continue;
// The last control point of each segment is not allowed to start a new implicit segment.
@ -442,7 +449,15 @@ namespace osu.Game.Rulesets.Objects.Legacy
/// <param name="newCombo">Whether the hit object creates a new combo.</param>
/// <param name="comboOffset">When starting a new combo, the offset of the new combo relative to the current one.</param>
/// <returns>The hit object.</returns>
protected abstract HitObject CreateHit(Vector2 position, bool newCombo, int comboOffset);
private ConvertHitObject createHitCircle(Vector2 position, bool newCombo, int comboOffset)
{
return lastObject = new ConvertHitCircle
{
Position = position,
NewCombo = firstObject || lastObject is ConvertSpinner || newCombo,
ComboOffset = newCombo ? comboOffset : 0
};
}
/// <summary>
/// Creats a legacy Slider-type hit object.
@ -455,27 +470,51 @@ namespace osu.Game.Rulesets.Objects.Legacy
/// <param name="repeatCount">The slider repeat count.</param>
/// <param name="nodeSamples">The samples to be played when the slider nodes are hit. This includes the head and tail of the slider.</param>
/// <returns>The hit object.</returns>
protected abstract HitObject CreateSlider(Vector2 position, bool newCombo, int comboOffset, PathControlPoint[] controlPoints, double? length, int repeatCount,
IList<IList<HitSampleInfo>> nodeSamples);
private ConvertHitObject createSlider(Vector2 position, bool newCombo, int comboOffset, PathControlPoint[] controlPoints, double? length, int repeatCount,
IList<IList<HitSampleInfo>> nodeSamples)
{
return lastObject = new ConvertSlider
{
Position = position,
NewCombo = firstObject || lastObject is ConvertSpinner || newCombo,
ComboOffset = newCombo ? comboOffset : 0,
Path = new SliderPath(controlPoints, length),
NodeSamples = nodeSamples,
RepeatCount = repeatCount
};
}
/// <summary>
/// Creates a legacy Spinner-type hit object.
/// </summary>
/// <param name="position">The position of the hit object.</param>
/// <param name="newCombo">Whether the hit object creates a new combo.</param>
/// <param name="comboOffset">When starting a new combo, the offset of the new combo relative to the current one.</param>
/// <param name="duration">The spinner duration.</param>
/// <returns>The hit object.</returns>
protected abstract HitObject CreateSpinner(Vector2 position, bool newCombo, int comboOffset, double duration);
private ConvertHitObject createSpinner(Vector2 position, bool newCombo, double duration)
{
return lastObject = new ConvertSpinner
{
Position = position,
Duration = duration,
NewCombo = newCombo
// Spinners cannot have combo offset.
};
}
/// <summary>
/// Creates a legacy Hold-type hit object.
/// </summary>
/// <param name="position">The position of the hit object.</param>
/// <param name="newCombo">Whether the hit object creates a new combo.</param>
/// <param name="comboOffset">When starting a new combo, the offset of the new combo relative to the current one.</param>
/// <param name="duration">The hold duration.</param>
protected abstract HitObject CreateHold(Vector2 position, bool newCombo, int comboOffset, double duration);
private ConvertHitObject createHold(Vector2 position, double duration)
{
return lastObject = new ConvertHold
{
Position = position,
Duration = duration
};
}
private List<HitSampleInfo> convertSoundType(LegacyHitSoundType type, SampleBankInfo bankInfo)
{
@ -511,21 +550,19 @@ namespace osu.Game.Rulesets.Objects.Legacy
/// <summary>
/// An optional overriding filename which causes all bank/sample specifications to be ignored.
/// </summary>
public string Filename;
public string? Filename;
/// <summary>
/// The bank identifier to use for the base ("hitnormal") sample.
/// Transferred to <see cref="HitSampleInfo.Bank"/> when appropriate.
/// </summary>
[CanBeNull]
public string BankForNormal;
public string? BankForNormal;
/// <summary>
/// The bank identifier to use for additions ("hitwhistle", "hitfinish", "hitclap").
/// Transferred to <see cref="HitSampleInfo.Bank"/> when appropriate.
/// </summary>
[CanBeNull]
public string BankForAdditions;
public string? BankForAdditions;
/// <summary>
/// Hit sample volume (0-100).
@ -548,8 +585,6 @@ namespace osu.Game.Rulesets.Objects.Legacy
public SampleBankInfo Clone() => (SampleBankInfo)MemberwiseClone();
}
#nullable enable
public class LegacyHitSampleInfo : HitSampleInfo, IEquatable<LegacyHitSampleInfo>
{
public readonly int CustomSampleBank;
@ -577,13 +612,14 @@ namespace osu.Game.Rulesets.Objects.Legacy
IsLayered = isLayered;
}
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)
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)
=> With(newName, newBank, newVolume, newEditorAutoBank);
public virtual LegacyHitSampleInfo With(Optional<string> newName = default, Optional<string> newBank = default, Optional<int> newVolume = default, Optional<bool> newEditorAutoBank = default,
Optional<int> newCustomSampleBank = default,
Optional<bool> newIsLayered = default)
=> new LegacyHitSampleInfo(newName.GetOr(Name), newBank.GetOr(Bank), newVolume.GetOr(Volume), newEditorAutoBank.GetOr(EditorAutoBank), newCustomSampleBank.GetOr(CustomSampleBank), newIsLayered.GetOr(IsLayered));
public virtual LegacyHitSampleInfo With(Optional<string> newName = default, Optional<string> newBank = default, Optional<int> newVolume = default,
Optional<bool> newEditorAutoBank = default, Optional<int> newCustomSampleBank = default, Optional<bool> newIsLayered = default)
=> new LegacyHitSampleInfo(newName.GetOr(Name), newBank.GetOr(Bank), newVolume.GetOr(Volume), newEditorAutoBank.GetOr(EditorAutoBank), newCustomSampleBank.GetOr(CustomSampleBank),
newIsLayered.GetOr(IsLayered));
public bool Equals(LegacyHitSampleInfo? other)
// The additions to equality checks here are *required* to ensure that pooling works correctly.
@ -615,9 +651,8 @@ namespace osu.Game.Rulesets.Objects.Legacy
Path.ChangeExtension(Filename, null)
};
public sealed override LegacyHitSampleInfo With(Optional<string> newName = default, Optional<string> newBank = default, Optional<int> newVolume = default, Optional<bool> newEditorAutoBank = default,
Optional<int> newCustomSampleBank = default,
Optional<bool> newIsLayered = default)
public sealed override LegacyHitSampleInfo With(Optional<string> newName = default, Optional<string> newBank = default, Optional<int> newVolume = default,
Optional<bool> newEditorAutoBank = default, Optional<int> newCustomSampleBank = default, Optional<bool> newIsLayered = default)
=> new FileHitSampleInfo(Filename, newVolume.GetOr(Volume));
public bool Equals(FileHitSampleInfo? other)

View File

@ -3,17 +3,18 @@
using osu.Game.Rulesets.Objects.Types;
namespace osu.Game.Rulesets.Objects.Legacy.Catch
namespace osu.Game.Rulesets.Objects.Legacy
{
/// <summary>
/// Legacy osu!catch Spinner-type, used for parsing Beatmaps.
/// Legacy "Hold" hit object type. Generally only valid in the mania ruleset.
/// </summary>
internal sealed class ConvertSpinner : ConvertHitObject, IHasDuration, IHasXPosition
/// <remarks>
/// Only used for parsing beatmaps and not gameplay.
/// </remarks>
internal sealed class ConvertHold : ConvertHitObject, IHasDuration
{
public double EndTime => StartTime + Duration;
public double Duration { get; set; }
public float X => 256; // Required for CatchBeatmapConverter
public double EndTime => StartTime + Duration;
}
}

View File

@ -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 osu.Game.Rulesets.Objects.Types;
using System.Collections.Generic;
using Newtonsoft.Json;
@ -13,7 +11,13 @@ using osu.Game.Beatmaps.ControlPoints;
namespace osu.Game.Rulesets.Objects.Legacy
{
internal abstract class ConvertSlider : ConvertHitObject, IHasPathWithRepeats, IHasSliderVelocity
/// <summary>
/// Legacy "Slider" hit object type.
/// </summary>
/// <remarks>
/// Only used for parsing beatmaps and not gameplay.
/// </remarks>
internal class ConvertSlider : ConvertHitObject, IHasPathWithRepeats, IHasSliderVelocity, IHasGenerateTicks
{
/// <summary>
/// Scoring distance with a speed-adjusted beat length of 1 second.
@ -50,6 +54,8 @@ namespace osu.Game.Rulesets.Objects.Legacy
set => SliderVelocityMultiplierBindable.Value = value;
}
public bool GenerateTicks { get; set; } = true;
protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty)
{
base.ApplyDefaultsToSelf(controlPointInfo, difficulty);

View File

@ -3,11 +3,14 @@
using osu.Game.Rulesets.Objects.Types;
namespace osu.Game.Rulesets.Objects.Legacy.Taiko
namespace osu.Game.Rulesets.Objects.Legacy
{
/// <summary>
/// Legacy osu!taiko Spinner-type, used for parsing Beatmaps.
/// Legacy "Spinner" hit object type.
/// </summary>
/// <remarks>
/// Only used for parsing beatmaps and not gameplay.
/// </remarks>
internal sealed class ConvertSpinner : ConvertHitObject, IHasDuration
{
public double Duration { get; set; }

View File

@ -0,0 +1,18 @@
// 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.Game.Beatmaps.Legacy;
namespace osu.Game.Rulesets.Objects.Legacy
{
/// <summary>
/// A hit object from a legacy beatmap representation.
/// </summary>
public interface IHasLegacyHitObjectType
{
/// <summary>
/// The hit object type.
/// </summary>
LegacyHitObjectType LegacyType { get; }
}
}

View File

@ -1,15 +0,0 @@
// 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.Game.Rulesets.Objects.Types;
namespace osu.Game.Rulesets.Objects.Legacy.Mania
{
/// <summary>
/// Legacy osu!mania Hit-type, used for parsing Beatmaps.
/// </summary>
internal sealed class ConvertHit : ConvertHitObject, IHasXPosition
{
public float X { get; set; }
}
}

View File

@ -1,58 +0,0 @@
// 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 osuTK;
using osu.Game.Audio;
using System.Collections.Generic;
namespace osu.Game.Rulesets.Objects.Legacy.Mania
{
/// <summary>
/// A HitObjectParser to parse legacy osu!mania Beatmaps.
/// </summary>
public class ConvertHitObjectParser : Legacy.ConvertHitObjectParser
{
public ConvertHitObjectParser(double offset, int formatVersion)
: base(offset, formatVersion)
{
}
protected override HitObject CreateHit(Vector2 position, bool newCombo, int comboOffset)
{
return new ConvertHit
{
X = position.X
};
}
protected override HitObject CreateSlider(Vector2 position, bool newCombo, int comboOffset, PathControlPoint[] controlPoints, double? length, int repeatCount,
IList<IList<HitSampleInfo>> nodeSamples)
{
return new ConvertSlider
{
X = position.X,
Path = new SliderPath(controlPoints, length),
NodeSamples = nodeSamples,
RepeatCount = repeatCount
};
}
protected override HitObject CreateSpinner(Vector2 position, bool newCombo, int comboOffset, double duration)
{
return new ConvertSpinner
{
X = position.X,
Duration = duration
};
}
protected override HitObject CreateHold(Vector2 position, bool newCombo, int comboOffset, double duration)
{
return new ConvertHold
{
X = position.X,
Duration = duration
};
}
}
}

View File

@ -1,16 +0,0 @@
// 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.Game.Rulesets.Objects.Types;
namespace osu.Game.Rulesets.Objects.Legacy.Mania
{
internal sealed class ConvertHold : ConvertHitObject, IHasXPosition, IHasDuration
{
public float X { get; set; }
public double Duration { get; set; }
public double EndTime => StartTime + Duration;
}
}

View File

@ -1,15 +0,0 @@
// 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.Game.Rulesets.Objects.Types;
namespace osu.Game.Rulesets.Objects.Legacy.Mania
{
/// <summary>
/// Legacy osu!mania Slider-type, used for parsing Beatmaps.
/// </summary>
internal sealed class ConvertSlider : Legacy.ConvertSlider, IHasXPosition
{
public float X { get; set; }
}
}

View File

@ -1,19 +0,0 @@
// 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.Game.Rulesets.Objects.Types;
namespace osu.Game.Rulesets.Objects.Legacy.Mania
{
/// <summary>
/// Legacy osu!mania Spinner-type, used for parsing Beatmaps.
/// </summary>
internal sealed class ConvertSpinner : ConvertHitObject, IHasDuration, IHasXPosition
{
public double Duration { get; set; }
public double EndTime => StartTime + Duration;
public float X { get; set; }
}
}

View File

@ -1,20 +0,0 @@
// 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.Game.Rulesets.Objects.Types;
using osuTK;
namespace osu.Game.Rulesets.Objects.Legacy.Osu
{
/// <summary>
/// Legacy osu! Hit-type, used for parsing Beatmaps.
/// </summary>
internal sealed class ConvertHit : ConvertHitObject, IHasPosition
{
public Vector2 Position { get; set; }
public float X => Position.X;
public float Y => Position.Y;
}
}

View File

@ -1,64 +0,0 @@
// 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 osuTK;
using System.Collections.Generic;
using osu.Game.Audio;
namespace osu.Game.Rulesets.Objects.Legacy.Osu
{
/// <summary>
/// A HitObjectParser to parse legacy osu! Beatmaps.
/// </summary>
public class ConvertHitObjectParser : Legacy.ConvertHitObjectParser
{
private ConvertHitObject lastObject;
public ConvertHitObjectParser(double offset, int formatVersion)
: base(offset, formatVersion)
{
}
protected override HitObject CreateHit(Vector2 position, bool newCombo, int comboOffset)
{
return lastObject = new ConvertHit
{
Position = position,
NewCombo = FirstObject || lastObject is ConvertSpinner || newCombo,
ComboOffset = newCombo ? comboOffset : 0
};
}
protected override HitObject CreateSlider(Vector2 position, bool newCombo, int comboOffset, PathControlPoint[] controlPoints, double? length, int repeatCount,
IList<IList<HitSampleInfo>> nodeSamples)
{
return lastObject = new ConvertSlider
{
Position = position,
NewCombo = FirstObject || lastObject is ConvertSpinner || newCombo,
ComboOffset = newCombo ? comboOffset : 0,
Path = new SliderPath(controlPoints, length),
NodeSamples = nodeSamples,
RepeatCount = repeatCount
};
}
protected override HitObject CreateSpinner(Vector2 position, bool newCombo, int comboOffset, double duration)
{
return lastObject = new ConvertSpinner
{
Position = position,
Duration = duration,
NewCombo = newCombo
// Spinners cannot have combo offset.
};
}
protected override HitObject CreateHold(Vector2 position, bool newCombo, int comboOffset, double duration)
{
return lastObject = null;
}
}
}

View File

@ -1,22 +0,0 @@
// 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.Game.Rulesets.Objects.Types;
using osuTK;
namespace osu.Game.Rulesets.Objects.Legacy.Osu
{
/// <summary>
/// Legacy osu! Slider-type, used for parsing Beatmaps.
/// </summary>
internal sealed class ConvertSlider : Legacy.ConvertSlider, IHasPosition, IHasGenerateTicks
{
public Vector2 Position { get; set; }
public float X => Position.X;
public float Y => Position.Y;
public bool GenerateTicks { get; set; } = true;
}
}

View File

@ -1,24 +0,0 @@
// 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.Game.Rulesets.Objects.Types;
using osuTK;
namespace osu.Game.Rulesets.Objects.Legacy.Osu
{
/// <summary>
/// Legacy osu! Spinner-type, used for parsing Beatmaps.
/// </summary>
internal sealed class ConvertSpinner : ConvertHitObject, IHasDuration, IHasPosition
{
public double Duration { get; set; }
public double EndTime => StartTime + Duration;
public Vector2 Position { get; set; }
public float X => Position.X;
public float Y => Position.Y;
}
}

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