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

Compare commits

..

1752 Commits

900 changed files with 21644 additions and 6927 deletions
+2 -2
View File
@@ -21,10 +21,10 @@
]
},
"ppy.localisationanalyser.tools": {
"version": "2023.712.0",
"version": "2023.1117.0",
"commands": [
"localisation"
]
}
}
}
}
+8 -12
View File
@@ -108,6 +108,12 @@ jobs:
- name: Checkout
uses: actions/checkout@v3
- name: Setup JDK 11
uses: actions/setup-java@v3
with:
distribution: microsoft
java-version: 11
- name: Install .NET 6.0.x
uses: actions/setup-dotnet@v3
with:
@@ -121,24 +127,14 @@ jobs:
build-only-ios:
name: Build only (iOS)
# `macos-13` is required, because Xcode 14.3 is required (see below).
# TODO: can be changed to `macos-latest` once `macos-13` becomes latest (currently in beta)
# `macos-13` is required, because the newest Microsoft.iOS.Sdk versions require Xcode 14.3.
# TODO: can be changed to `macos-latest` once `macos-13` becomes latest (currently in beta: https://github.com/actions/runner-images/tree/main#available-images)
runs-on: macos-13
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v3
# newest Microsoft.iOS.Sdk versions require Xcode 14.3.
# 14.3 is currently not the default Xcode version (https://github.com/actions/runner-images/blob/main/images/macos/macos-13-Readme.md#xcode),
# so set it manually.
# TODO: remove when 14.3 becomes the default Xcode version.
- name: Set Xcode version
shell: bash
run: |
sudo xcode-select -s "/Applications/Xcode_14.3.app"
echo "MD_APPLE_SDK_ROOT=/Applications/Xcode_14.3.app" >> $GITHUB_ENV
- name: Install .NET 6.0.x
uses: actions/setup-dotnet@v3
with:
+353 -177
View File
@@ -1,206 +1,382 @@
# Listens for new PR comments containing !pp check [id], and runs a diffcalc comparison against master.
# Usage:
# !pp check 0 | Runs only the osu! ruleset.
# !pp check 0 2 | Runs only the osu! and catch rulesets.
# ## Description
#
# Uses [diffcalc-sheet-generator](https://github.com/smoogipoo/diffcalc-sheet-generator) to run two builds of osu and generate an SR/PP/Score comparison spreadsheet.
#
# ## Requirements
#
# Self-hosted runner with installed:
# - `docker >= 20.10.16`
# - `docker-compose >= 2.5.1`
# - `lbzip2`
# - `jq`
#
# ## Usage
#
# The workflow can be run in two ways:
# 1. Via workflow dispatch.
# 2. By an owner of the repository posting a pull request or issue comment containing `!diffcalc`.
# For pull requests, the workflow will assume the pull request as the target to compare against (i.e. the `OSU_B` variable).
# Any lines in the comment of the form `KEY=VALUE` are treated as variables for the generator.
#
# ## Google Service Account
#
# Spreadsheets are uploaded to a Google Service Account, and exposed with read-only permissions to the wider audience.
#
# 1. Create a project at https://console.cloud.google.com
# 2. Enable the `Google Sheets` and `Google Drive` APIs.
# 3. Create a Service Account
# 4. Generate a key in the JSON format.
# 5. Encode the key as base64 and store as an **actions secret** with name **`DIFFCALC_GOOGLE_CREDENTIALS`**
#
# ## Environment variables
#
# The default environment may be configured via **actions variables**.
#
# Refer to [the sample environment](https://github.com/smoogipoo/diffcalc-sheet-generator/blob/master/.env.sample), and prefix each variable with `DIFFCALC_` (e.g. `DIFFCALC_THREADS`, `DIFFCALC_INNODB_BUFFER_SIZE`, etc...).
name: Run difficulty calculation comparison
run-name: "${{ github.event_name == 'workflow_dispatch' && format('Manual run: {0}', inputs.osu-b) || 'Automatic comment trigger' }}"
name: Difficulty Calculation
on:
issue_comment:
types: [ created ]
workflow_dispatch:
inputs:
osu-b:
description: "The target build of ppy/osu"
type: string
required: true
ruleset:
description: "The ruleset to process"
type: choice
required: true
options:
- osu
- taiko
- catch
- mania
converts:
description: "Include converted beatmaps"
type: boolean
required: false
default: true
ranked-only:
description: "Only ranked beatmaps"
type: boolean
required: false
default: true
generators:
description: "Comma-separated list of generators (available: [sr, pp, score])"
type: string
required: false
default: 'pp,sr'
osu-a:
description: "The source build of ppy/osu"
type: string
required: false
default: 'latest'
difficulty-calculator-a:
description: "The source build of ppy/osu-difficulty-calculator"
type: string
required: false
default: 'latest'
difficulty-calculator-b:
description: "The target build of ppy/osu-difficulty-calculator"
type: string
required: false
default: 'latest'
score-processor-a:
description: "The source build of ppy/osu-queue-score-statistics"
type: string
required: false
default: 'latest'
score-processor-b:
description: "The target build of ppy/osu-queue-score-statistics"
type: string
required: false
default: 'latest'
permissions:
pull-requests: write
env:
CONCURRENCY: 4
ALLOW_DOWNLOAD: 1
SAVE_DOWNLOADED: 1
SKIP_INSERT_ATTRIBUTES: 1
EXECUTION_ID: execution-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}
jobs:
metadata:
name: Check for requests
check-permissions:
name: Check permissions
runs-on: ubuntu-latest
if: ${{ github.event_name == 'workflow_dispatch' || contains(github.event.comment.body, '!diffcalc') }}
steps:
- name: Check permissions
if: ${{ github.event_name != 'workflow_dispatch' }}
uses: actions-cool/check-user-permission@a0668c9aec87f3875fc56170b6452a453e9dd819 # v2.2.0
with:
require: 'write'
create-comment:
name: Create PR comment
needs: check-permissions
runs-on: ubuntu-latest
if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request }}
steps:
- name: Create comment
uses: thollander/actions-comment-pull-request@363c6f6eae92cc5c3a66e95ba016fc771bb38943 # v2.4.2
with:
comment_tag: ${{ env.EXECUTION_ID }}
message: |
Difficulty calculation queued -- please wait! (${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
*This comment will update on completion*
directory:
name: Prepare directory
needs: check-permissions
runs-on: self-hosted
if: github.event.issue.pull_request && contains(github.event.comment.body, '!pp check') && (github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'OWNER')
outputs:
matrix: ${{ steps.generate-matrix.outputs.matrix }}
continue: ${{ steps.generate-matrix.outputs.continue }}
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: Construct build matrix
id: generate-matrix
- name: Checkout diffcalc-sheet-generator
uses: actions/checkout@v3
with:
path: ${{ env.EXECUTION_ID }}
repository: 'smoogipoo/diffcalc-sheet-generator'
- name: Set outputs
id: set-outputs
run: |
if [[ "${{ github.event.comment.body }}" =~ "osu" ]] ; then
MATRIX_PROJECTS_JSON+='{ "name": "osu", "id": 0 },'
fi
if [[ "${{ github.event.comment.body }}" =~ "taiko" ]] ; then
MATRIX_PROJECTS_JSON+='{ "name": "taiko", "id": 1 },'
fi
if [[ "${{ github.event.comment.body }}" =~ "catch" ]] ; then
MATRIX_PROJECTS_JSON+='{ "name": "catch", "id": 2 },'
fi
if [[ "${{ github.event.comment.body }}" =~ "mania" ]] ; then
MATRIX_PROJECTS_JSON+='{ "name": "mania", "id": 3 },'
fi
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}"
if [[ "${MATRIX_PROJECTS_JSON}" != "" ]]; then
MATRIX_JSON="{ \"ruleset\": [ ${MATRIX_PROJECTS_JSON} ] }"
echo "${MATRIX_JSON}"
CONTINUE="yes"
else
CONTINUE="no"
fi
echo "continue=${CONTINUE}" >> $GITHUB_OUTPUT
echo "matrix=${MATRIX_JSON}" >> $GITHUB_OUTPUT
diffcalc:
name: Run
environment:
name: Setup environment
needs: directory
runs-on: self-hosted
timeout-minutes: 1440
if: needs.metadata.outputs.continue == 'yes'
needs: metadata
strategy:
matrix: ${{ fromJson(needs.metadata.outputs.matrix) }}
env:
VARS_JSON: ${{ toJSON(vars) }}
steps:
- name: Verify MySQL connection from host
- name: Add base environment
run: |
mysql -e "SHOW DATABASES"
# Required by diffcalc-sheet-generator
cp '${{ needs.directory.outputs.GENERATOR_DIR }}/.env.sample' "${{ needs.directory.outputs.GENERATOR_ENV }}"
- name: Drop previous databases
run: |
for db in osu_master osu_pr
do
mysql -e "DROP DATABASE IF EXISTS $db"
# 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: Create directory structure
- name: Add pull-request environment
if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request }}
run: |
mkdir -p $GITHUB_WORKSPACE/master/
mkdir -p $GITHUB_WORKSPACE/pr/
sed -i "s;^OSU_B=.*$;OSU_B=${{ github.event.issue.pull_request.html_url }};" "${{ needs.directory.outputs.GENERATOR_ENV }}"
- name: Get upstream branch # https://akaimo.hatenablog.jp/entry/2020/05/16/101251
id: upstreambranch
- name: Add comment environment
if: ${{ github.event_name == 'issue_comment' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
COMMENT_BODY: ${{ github.event.comment.body }}
run: |
echo "branchname=$(curl -H "Authorization: token ${GITHUB_TOKEN}" ${{ github.event.issue.pull_request.url }} | jq '.head.ref' | sed 's/\"//g')" >> $GITHUB_OUTPUT
echo "repo=$(curl -H "Authorization: token ${GITHUB_TOKEN}" ${{ github.event.issue.pull_request.url }} | jq '.head.repo.full_name' | sed 's/\"//g')" >> $GITHUB_OUTPUT
# Checkout osu
- name: Checkout osu (master)
uses: actions/checkout@v3
with:
path: 'master/osu'
- name: Checkout osu (pr)
uses: actions/checkout@v3
with:
path: 'pr/osu'
repository: ${{ steps.upstreambranch.outputs.repo }}
ref: ${{ steps.upstreambranch.outputs.branchname }}
- name: Checkout osu-difficulty-calculator (master)
uses: actions/checkout@v3
with:
repository: ppy/osu-difficulty-calculator
path: 'master/osu-difficulty-calculator'
- name: Checkout osu-difficulty-calculator (pr)
uses: actions/checkout@v3
with:
repository: ppy/osu-difficulty-calculator
path: 'pr/osu-difficulty-calculator'
- name: Install .NET 5.0.x
uses: actions/setup-dotnet@v3
with:
dotnet-version: "5.0.x"
# Sanity checks to make sure diffcalc is not run when incompatible.
- name: Build diffcalc (master)
run: |
cd $GITHUB_WORKSPACE/master/osu-difficulty-calculator
./UseLocalOsu.sh
dotnet build
- name: Build diffcalc (pr)
run: |
cd $GITHUB_WORKSPACE/pr/osu-difficulty-calculator
./UseLocalOsu.sh
dotnet build
- name: Download + import data
run: |
PERFORMANCE_DATA_NAME=$(curl https://data.ppy.sh/ | grep performance_${{ matrix.ruleset.name }}_top_1000 | tail -1 | awk -F "\"" '{print $2}' | sed 's/\.tar\.bz2//g')
BEATMAPS_DATA_NAME=$(curl https://data.ppy.sh/ | grep osu_files | tail -1 | awk -F "\"" '{print $2}' | sed 's/\.tar\.bz2//g')
# Set env variable for further steps.
echo "BEATMAPS_PATH=$GITHUB_WORKSPACE/$BEATMAPS_DATA_NAME" >> $GITHUB_ENV
cd $GITHUB_WORKSPACE
echo "Downloading database dump $PERFORMANCE_DATA_NAME.."
wget -q -nc https://data.ppy.sh/$PERFORMANCE_DATA_NAME.tar.bz2
echo "Extracting.."
tar -xf $PERFORMANCE_DATA_NAME.tar.bz2
echo "Downloading beatmap dump $BEATMAPS_DATA_NAME.."
wget -q -nc https://data.ppy.sh/$BEATMAPS_DATA_NAME.tar.bz2
echo "Extracting.."
tar -xf $BEATMAPS_DATA_NAME.tar.bz2
cd $PERFORMANCE_DATA_NAME
for db in osu_master osu_pr
do
echo "Setting up database $db.."
mysql -e "CREATE DATABASE $db"
echo "Importing beatmaps.."
cat osu_beatmaps.sql | mysql $db
echo "Importing beatmapsets.."
cat osu_beatmapsets.sql | mysql $db
echo "Creating table structure.."
mysql $db -e 'CREATE TABLE `osu_beatmap_difficulty` (
`beatmap_id` int unsigned NOT NULL,
`mode` tinyint NOT NULL DEFAULT 0,
`mods` int unsigned NOT NULL,
`diff_unified` float NOT NULL,
`last_update` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`beatmap_id`,`mode`,`mods`),
KEY `diff_sort` (`mode`,`mods`,`diff_unified`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;'
# 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: Run diffcalc (master)
env:
DB_NAME: osu_master
- name: Add dispatch environment
if: ${{ github.event_name == 'workflow_dispatch' }}
run: |
cd $GITHUB_WORKSPACE/master/osu-difficulty-calculator/osu.Server.DifficultyCalculator
dotnet run -c:Release -- all -m ${{ matrix.ruleset.id }} -ac -c ${{ env.CONCURRENCY }}
- name: Run diffcalc (pr)
env:
DB_NAME: osu_pr
run: |
cd $GITHUB_WORKSPACE/pr/osu-difficulty-calculator/osu.Server.DifficultyCalculator
dotnet run -c:Release -- all -m ${{ matrix.ruleset.id }} -ac -c ${{ env.CONCURRENCY }}
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 }}"
- name: Print diffs
run: |
mysql -e "
SELECT
m.beatmap_id,
m.mods,
b.filename,
m.diff_unified as 'sr_master',
p.diff_unified as 'sr_pr',
(p.diff_unified - m.diff_unified) as 'diff'
FROM osu_master.osu_beatmap_difficulty m
JOIN osu_pr.osu_beatmap_difficulty p
ON m.beatmap_id = p.beatmap_id
AND m.mode = p.mode
AND m.mods = p.mods
JOIN osu_pr.osu_beatmaps b
ON b.beatmap_id = p.beatmap_id
WHERE abs(m.diff_unified - p.diff_unified) > 0.1
ORDER BY abs(m.diff_unified - p.diff_unified)
DESC
LIMIT 10000;"
if [[ '${{ inputs.osu-a }}' != 'latest' ]]; then
sed -i 's;^OSU_A=.*$;OSU_A=${{ inputs.osu-a }};' "${{ needs.directory.outputs.GENERATOR_ENV }}"
fi
# Todo: Run ppcalc
if [[ '${{ inputs.difficulty-calculator-a }}' != 'latest' ]]; then
sed -i 's;^DIFFICULTY_CALCULATOR_A=.*$;DIFFICULTY_CALCULATOR_A=${{ inputs.difficulty-calculator-a }};' "${{ needs.directory.outputs.GENERATOR_ENV }}"
fi
if [[ '${{ inputs.difficulty-calculator-b }}' != 'latest' ]]; then
sed -i 's;^DIFFICULTY_CALCULATOR_B=.*$;DIFFICULTY_CALCULATOR_B=${{ inputs.difficulty-calculator-b }};' "${{ needs.directory.outputs.GENERATOR_ENV }}"
fi
if [[ '${{ inputs.score-processor-a }}' != 'latest' ]]; then
sed -i 's;^SCORE_PROCESSOR_A=.*$;SCORE_PROCESSOR_A=${{ inputs.score-processor-a }};' "${{ needs.directory.outputs.GENERATOR_ENV }}"
fi
if [[ '${{ inputs.score-processor-b }}' != 'latest' ]]; then
sed -i 's;^SCORE_PROCESSOR_B=.*$;SCORE_PROCESSOR_B=${{ inputs.score-processor-b }};' "${{ needs.directory.outputs.GENERATOR_ENV }}"
fi
if [[ '${{ inputs.converts }}' == 'true' ]]; then
sed -i 's/^NO_CONVERTS=.*$/NO_CONVERTS=0/' "${{ needs.directory.outputs.GENERATOR_ENV }}"
else
sed -i 's/^NO_CONVERTS=.*$/NO_CONVERTS=1/' "${{ needs.directory.outputs.GENERATOR_ENV }}"
fi
if [[ '${{ inputs.ranked-only }}' == 'true' ]]; then
sed -i 's/^RANKED_ONLY=.*$/RANKED_ONLY=1/' "${{ needs.directory.outputs.GENERATOR_ENV }}"
else
sed -i 's/^RANKED_ONLY=.*$/RANKED_ONLY=0/' "${{ needs.directory.outputs.GENERATOR_ENV }}"
fi
scores:
name: Setup scores
needs: [ directory, environment ]
runs-on: self-hosted
steps:
- name: Query latest data
id: query
run: |
ruleset=$(cat ${{ needs.directory.outputs.GENERATOR_ENV }} | grep -E '^RULESET=' | cut -d '=' -f2-)
performance_data_name=$(curl -s "https://data.ppy.sh/" | grep "performance_${ruleset}_top_1000\b" | tail -1 | awk -F "'" '{print $2}' | sed 's/\.tar\.bz2//g')
echo "TARGET_DIR=${{ needs.directory.outputs.GENERATOR_DIR }}/sql/${ruleset}" >> "${GITHUB_OUTPUT}"
echo "DATA_NAME=${performance_data_name}" >> "${GITHUB_OUTPUT}"
- name: Restore cache
id: restore-cache
uses: maxnowack/local-cache@038cc090b52e4f205fbc468bf5b0756df6f68775 # v1
with:
path: ${{ steps.query.outputs.DATA_NAME }}.tar.bz2
key: ${{ steps.query.outputs.DATA_NAME }}
- name: Download
if: steps.restore-cache.outputs.cache-hit != 'true'
run: |
wget -q -nc "https://data.ppy.sh/${{ steps.query.outputs.DATA_NAME }}.tar.bz2"
- name: Extract
run: |
tar -I lbzip2 -xf "${{ steps.query.outputs.DATA_NAME }}.tar.bz2"
rm -r "${{ steps.query.outputs.TARGET_DIR }}"
mv "${{ steps.query.outputs.DATA_NAME }}" "${{ steps.query.outputs.TARGET_DIR }}"
beatmaps:
name: Setup beatmaps
needs: directory
runs-on: self-hosted
steps:
- name: Query latest data
id: query
run: |
beatmaps_data_name=$(curl -s "https://data.ppy.sh/" | grep "osu_files" | tail -1 | awk -F "'" '{print $2}' | sed 's/\.tar\.bz2//g')
echo "TARGET_DIR=${{ needs.directory.outputs.GENERATOR_DIR }}/beatmaps" >> "${GITHUB_OUTPUT}"
echo "DATA_NAME=${beatmaps_data_name}" >> "${GITHUB_OUTPUT}"
- name: Restore cache
id: restore-cache
uses: maxnowack/local-cache@038cc090b52e4f205fbc468bf5b0756df6f68775 # v1
with:
path: ${{ steps.query.outputs.DATA_NAME }}.tar.bz2
key: ${{ steps.query.outputs.DATA_NAME }}
- name: Download
if: steps.restore-cache.outputs.cache-hit != 'true'
run: |
wget -q -nc "https://data.ppy.sh/${{ steps.query.outputs.DATA_NAME }}.tar.bz2"
- name: Extract
run: |
tar -I lbzip2 -xf "${{ steps.query.outputs.DATA_NAME }}.tar.bz2"
rm -r "${{ steps.query.outputs.TARGET_DIR }}"
mv "${{ steps.query.outputs.DATA_NAME }}" "${{ steps.query.outputs.TARGET_DIR }}"
generator:
name: Run generator
needs: [ directory, environment, scores, beatmaps ]
runs-on: self-hosted
timeout-minutes: 720
outputs:
TARGET: ${{ steps.run.outputs.TARGET }}
SPREADSHEET_LINK: ${{ steps.run.outputs.SPREADSHEET_LINK }}
steps:
- name: Run
id: run
run: |
# Add the GitHub token. This needs to be done here because it's unique per-job.
sed -i 's/^GH_TOKEN=.*$/GH_TOKEN=${{ github.token }}/' "${{ needs.directory.outputs.GENERATOR_ENV }}"
cd "${{ needs.directory.outputs.GENERATOR_DIR }}"
docker-compose up --build generator
link=$(docker-compose logs generator -n 10 | grep 'http' | sed -E 's/^.*(http.*)$/\1/')
target=$(cat "${{ needs.directory.outputs.GENERATOR_ENV }}" | grep -E '^OSU_B=' | cut -d '=' -f2-)
echo "TARGET=${target}" >> "${GITHUB_OUTPUT}"
echo "SPREADSHEET_LINK=${link}" >> "${GITHUB_OUTPUT}"
- name: Shutdown
if: ${{ always() }}
run: |
cd "${{ needs.directory.outputs.GENERATOR_DIR }}"
docker-compose down -v
output-cli:
name: Output info
needs: generator
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 }}"
update-comment:
name: Update PR comment
needs: [ create-comment, generator ]
runs-on: ubuntu-latest
if: ${{ always() && needs.create-comment.result == 'success' }}
steps:
- name: Update comment on success
if: ${{ needs.generator.result == 'success' }}
uses: thollander/actions-comment-pull-request@363c6f6eae92cc5c3a66e95ba016fc771bb38943 # v2.4.2
with:
comment_tag: ${{ env.EXECUTION_ID }}
mode: upsert
create_if_not_exists: false
message: |
Target: ${{ needs.generator.outputs.TARGET }}
Spreadsheet: ${{ needs.generator.outputs.SPREADSHEET_LINK }}
- name: Update comment on failure
if: ${{ needs.generator.result == 'failure' }}
uses: thollander/actions-comment-pull-request@363c6f6eae92cc5c3a66e95ba016fc771bb38943 # v2.4.2
with:
comment_tag: ${{ env.EXECUTION_ID }}
mode: upsert
create_if_not_exists: false
message: |
Difficulty calculation failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- name: Update comment on cancellation
if: ${{ needs.generator.result == 'cancelled' }}
uses: thollander/actions-comment-pull-request@363c6f6eae92cc5c3a66e95ba016fc771bb38943 # v2.4.2
with:
comment_tag: ${{ env.EXECUTION_ID }}
mode: delete
message: '.' # Appears to be required by this action for non-error status code.
+2 -2
View File
@@ -59,7 +59,7 @@ The [issue tracker](https://github.com/ppy/osu/issues) should provide plenty of
In the case of simple issues, a direct PR is okay. However, if you decide to work on an existing issue which doesn't seem trivial, **please ask us first**. This way we can try to estimate if it is a good fit for you and provide the correct direction on how to address it. In addition, note that while we do not rule out external contributors from working on roadmapped issues, we will generally prefer to handle them ourselves unless they're not very time sensitive.
If you'd like to propose a subjective change to one of the visual aspects of the game, or there is a bigger task you'd like to work on, but there is no corresponding issue or discussion thread yet for it, **please open a discussion or issue first** to avoid wasted effort. This in particular applies if you want to work on [one of the available designs from the osu! public Figma library](https://www.figma.com/file/6m10GiGEncVFWmgOoSyakH/osu!-Figma-Library).
If you'd like to propose a subjective change to one of the visual aspects of the game, or there is a bigger task you'd like to work on, but there is no corresponding issue or discussion thread yet for it, **please open a discussion or issue first** to avoid wasted effort. This in particular applies if you want to work on [one of the available designs from the osu! Figma master library](https://www.figma.com/file/VIkXMYNPMtQem2RJg9k2iQ/Master-Library).
Aside from the above, below is a brief checklist of things to watch out when you're preparing your code changes:
@@ -85,4 +85,4 @@ If you're uncertain about some part of the codebase or some inner workings of th
- [Development roadmap](https://github.com/orgs/ppy/projects/7/views/6): What the core team is currently working on
- [`ppy/osu-framework` wiki](https://github.com/ppy/osu-framework/wiki): Contains introductory information about osu!framework, the bespoke 2D game framework we use for the game
- [`ppy/osu` wiki](https://github.com/ppy/osu/wiki): Contains articles about various technical aspects of the game
- [Public Figma library](https://www.figma.com/file/6m10GiGEncVFWmgOoSyakH/osu!-Figma-Library): Contains finished and draft designs for osu!
- [Figma master library](https://www.figma.com/file/VIkXMYNPMtQem2RJg9k2iQ/Master-Library): Contains finished and draft designs for osu!
+25 -14
View File
@@ -12,40 +12,43 @@
A free-to-win rhythm game. Rhythm is just a *click* away!
The future of [osu!](https://osu.ppy.sh) and the beginning of an open era! Currently known by and released under the release codename "*lazer*". As in sharper than cutting-edge.
This is the future and final iteration of the [osu!](https://osu.ppy.sh) game client which marks the beginning of an open era! Currently known by and released under the release codename "*lazer*". As in sharper than cutting-edge.
## Status
This project is under constant development, but we aim to keep things in a stable state. Users are encouraged to try it out and keep it installed alongside the stable *osu!* client. It will continue to evolve to the point of eventually replacing the existing stable client as an update.
This project is under constant development, but we do our best to keep things in a stable state. Players are encouraged to install from a release alongside their stable *osu!* client. This project will continue to evolve until we eventually reach the point where most users prefer it over the previous "osu!stable" release.
**IMPORTANT:** Gameplay mechanics (and other features which you may have come to know and love) are in a constant state of flux. Game balance and final quality-of-life passes come at the end of development, preceded by experimentation and changes which may potentially **reduce playability or usability**. This is done in order to allow us to move forward as developers and designers more efficiently. If this offends you, please consider sticking to a [stable release](https://osu.ppy.sh/home/download) of osu!. We are not yet open to heated discussion over game mechanics and will not be using github as a forum for such discussions just yet.
We are accepting bug reports (please report with as much detail as possible and follow the existing issue templates). Feature requests are also welcome, but understand that our focus is on completing the game to feature parity before adding new features. A few resources are available as starting points to getting involved and understanding the project:
A few resources are available as starting points to getting involved and understanding the project:
- Detailed release changelogs are available on the [official osu! site](https://osu.ppy.sh/home/changelog/lazer).
- You can learn more about our approach to [project management](https://github.com/ppy/osu/wiki/Project-management).
- Track our current efforts [towards full "ranked play" support](https://github.com/orgs/ppy/projects/13?query=is%3Aopen+sort%3Aupdated-desc).
## Running osu!
If you are looking to install or test osu! without setting up a development environment, you can consume our [releases](https://github.com/ppy/osu/releases). You can also generally download a version for your current device from the [osu! site](https://osu.ppy.sh/home/download). Failing that, you may use the links below to download the latest version for your operating system of choice:
If you are just looking to give the game a whirl, you can grab the latest release for your platform:
**Latest release:**
### Latest release:
| [Windows 8.1+ (x64)](https://github.com/ppy/osu/releases/latest/download/install.exe) | macOS 10.15+ ([Intel](https://github.com/ppy/osu/releases/latest/download/osu.app.Intel.zip), [Apple Silicon](https://github.com/ppy/osu/releases/latest/download/osu.app.Apple.Silicon.zip)) | [Linux (x64)](https://github.com/ppy/osu/releases/latest/download/osu.AppImage) | [iOS 13.4+](https://osu.ppy.sh/home/testflight) | [Android 5+](https://github.com/ppy/osu/releases/latest/download/sh.ppy.osulazer.apk) |
| ------------- | ------------- | ------------- | ------------- | ------------- |
- The iOS testflight link may fill up (Apple has a hard limit of 10,000 users). We reset it occasionally when this happens. Please do not ask about this. Check back regularly for link resets or follow [peppy](https://twitter.com/ppy) on twitter for announcements of link resets.
You can also generally download a version for your current device from the [osu! site](https://osu.ppy.sh/home/download).
If your platform is not listed above, there is still a chance you can manually build it by following the instructions below.
**For iOS/iPadOS users**: The iOS testflight link fills up very fast (Apple has a hard limit of 10,000 users). We reset it occasionally. Please do not ask about this. Check back regularly for link resets or follow [peppy](https://twitter.com/ppy) on twitter for announcements. Our goal is to get the game on mobile app stores in early 2024.
## Developing a custom ruleset
osu! is designed to have extensible modular gameplay modes, called "rulesets". Building one of these allows a developer to harness the power of osu! for their own game style. To get started working on a ruleset, we have some templates available [here](https://github.com/ppy/osu/tree/master/Templates).
osu! is designed to allow user-created gameplay variations, called "rulesets". Building one of these allows a developer to harness the power of the osu! beatmap library, game engine, and general UX for a new style of gameplay. To get started working on a ruleset, we have some templates available [here](https://github.com/ppy/osu/tree/master/Templates).
You can see some examples of custom rulesets by visiting the [custom ruleset directory](https://github.com/ppy/osu/discussions/13096).
## Developing osu!
### Prerequisites
Please make sure you have the following prerequisites:
- A desktop platform with the [.NET 6.0 SDK](https://dotnet.microsoft.com/download) installed.
@@ -69,9 +72,19 @@ git pull
### Building
Build configurations for the recommended IDEs (listed above) are included. You should use the provided Build/Run functionality of your IDE to get things going. When testing or building new components, it's highly encouraged you use the `VisualTests` project/configuration. More information on this is provided [below](#contributing).
#### From an IDE
- Visual Studio / Rider users should load the project via one of the platform-specific `.slnf` files, rather than the main `.sln`. This will allow access to template run configurations.
You should load the solution via one of the platform-specific `.slnf` files, rather than the main `.sln`. This will reduce dependencies and hide platforms that you don't care about. Valid `.slnf` files are:
- `osu.Desktop.slnf` (most common)
- `osu.Android.slnf`
- `osu.iOS.slnf`
Run configurations for the recommended IDEs (listed above) are included. You should use the provided Build/Run functionality of your IDE to get things going. When testing or building new components, it's highly encouraged you use the `osu! (Tests)` project/configuration. More information on this is provided [below](#contributing).
To build for mobile platforms, you will likely need to run `sudo dotnet workload restore` if you haven't done so previously. This will install Android/iOS tooling required to complete the build.
#### From CLI
You can also build and run *osu!* from the command-line with a single command:
@@ -79,12 +92,10 @@ You can also build and run *osu!* from the command-line with a single command:
dotnet run --project osu.Desktop
```
If you are not interested in debugging *osu!*, you can add `-c Release` to gain performance. In this case, you must replace `Debug` with `Release` in any commands mentioned in this document.
When running locally to do any kind of performance testing, make sure to add `-c Release` to the build command, as the overhead of running with the default `Debug` configuration can be large (especially when testing with local framework modifications as below).
If the build fails, try to restore NuGet packages with `dotnet restore`.
_Due to a historical feature gap between .NET Core and Xamarin, running `dotnet` CLI from the root directory will not work for most commands. This can be resolved by specifying a target `.csproj` or the helper project at `build/Desktop.proj`. Configurations have been provided to work around this issue for all supported IDEs mentioned above._
### Testing with resource/framework modifications
Sometimes it may be necessary to cross-test changes in [osu-resources](https://github.com/ppy/osu-resources) or [osu-framework](https://github.com/ppy/osu-framework). This can be quickly achieved using included commands:
+1 -1
View File
@@ -7,7 +7,7 @@ Templates for use when creating osu! dependent projects. Create a fully-testable
```bash
# install (or update) templates package.
# this only needs to be done once
dotnet new -i ppy.osu.Game.Templates
dotnet new install ppy.osu.Game.Templates
# create an empty freeform ruleset
dotnet new ruleset -n MyCoolRuleset
@@ -9,9 +9,9 @@
<GenerateProgramFile>false</GenerateProgramFile>
</PropertyGroup>
<ItemGroup Label="Package References">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.3.1" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\osu.Game.Rulesets.EmptyFreeform\osu.Game.Rulesets.EmptyFreeform.csproj" />
@@ -9,9 +9,9 @@
<GenerateProgramFile>false</GenerateProgramFile>
</PropertyGroup>
<ItemGroup Label="Package References">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.3.1" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj" />
@@ -9,9 +9,9 @@
<GenerateProgramFile>false</GenerateProgramFile>
</PropertyGroup>
<ItemGroup Label="Package References">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.3.1" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\osu.Game.Rulesets.EmptyScrolling\osu.Game.Rulesets.EmptyScrolling.csproj" />
@@ -9,9 +9,9 @@
<GenerateProgramFile>false</GenerateProgramFile>
</PropertyGroup>
<ItemGroup Label="Package References">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.3.1" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj" />
+1 -1
View File
@@ -10,7 +10,7 @@
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Framework.Android" Version="2023.904.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2023.1127.0" />
</ItemGroup>
<PropertyGroup>
<!-- Fody does not handle Android build well, and warns when unchanged.
+1 -1
View File
@@ -70,7 +70,7 @@ namespace osu.Android
},
new SettingsCheckbox
{
LabelText = MouseSettingsStrings.DisableMouseButtons,
LabelText = MouseSettingsStrings.DisableClicksDuringGameplay,
Current = osuConfig.GetBindable<bool>(OsuSetting.MouseDisableButtons),
},
});
+4
View File
@@ -11,6 +11,7 @@ using osu.Framework.Input.Handlers;
using osu.Framework.Platform;
using osu.Game;
using osu.Game.Overlays.Settings;
using osu.Game.Overlays.Settings.Sections.Input;
using osu.Game.Updater;
using osu.Game.Utils;
@@ -97,6 +98,9 @@ namespace osu.Android
case AndroidJoystickHandler jh:
return new AndroidJoystickSettings(jh);
case AndroidTouchHandler th:
return new TouchSettings(th);
default:
return base.CreateSettingsSubsectionFor(handler);
}
-4
View File
@@ -30,10 +30,6 @@ namespace osu.Desktop
[STAThread]
public static void Main(string[] args)
{
// Too many performance issues reported. We're going to have to do more testing before pushing this out.
// See https://github.com/ppy/osu/issues/24751.
Environment.SetEnvironmentVariable("OSU_GRAPHICS_NO_SSBO", "1");
// run Squirrel first, as the app may exit after these run
if (OperatingSystem.IsWindows())
{
+2 -2
View File
@@ -10,8 +10,8 @@ using osu.Game;
using osu.Game.Overlays;
using osu.Game.Overlays.Notifications;
using osu.Game.Screens.Play;
using Squirrel;
using Squirrel.SimpleSplat;
using Squirrel.Sources;
using LogLevel = Squirrel.SimpleSplat.LogLevel;
using UpdateManager = osu.Game.Updater.UpdateManager;
@@ -63,7 +63,7 @@ namespace osu.Desktop.Updater
if (localUserInfo?.IsPlaying.Value == true)
return false;
updateManager ??= new GithubUpdateManager(@"https://github.com/ppy/osu", false, github_token, @"osulazer");
updateManager ??= new Squirrel.UpdateManager(new GithubSource(@"https://github.com/ppy/osu", github_token, false), @"osulazer");
var info = await updateManager.CheckForUpdate(!useDeltaPatching).ConfigureAwait(false);
+2 -2
View File
@@ -23,10 +23,10 @@
<ProjectReference Include="..\osu.Game.Rulesets.Taiko\osu.Game.Rulesets.Taiko.csproj" />
</ItemGroup>
<ItemGroup Label="Package References">
<PackageReference Include="Clowd.Squirrel" Version="2.9.42" />
<PackageReference Include="Clowd.Squirrel" Version="2.10.2" />
<PackageReference Include="Mono.Posix.NETStandard" Version="1.0.0" />
<PackageReference Include="System.IO.Packaging" Version="7.0.0" />
<PackageReference Include="DiscordRichPresence" Version="1.1.4.20" />
<PackageReference Include="DiscordRichPresence" Version="1.2.1.24" />
</ItemGroup>
<ItemGroup Label="Resources">
<EmbeddedResource Include="lazer.ico" />
@@ -7,9 +7,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.13.4" />
<PackageReference Include="BenchmarkDotNet" Version="0.13.9" />
<PackageReference Include="nunit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.3.1" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
</ItemGroup>
<ItemGroup>
@@ -28,9 +28,9 @@ namespace osu.Game.Rulesets.Catch.Tests
private class TestLegacySkin : LegacySkin
{
public TestLegacySkin(SkinInfo skin, IResourceStore<byte[]> storage)
public TestLegacySkin(SkinInfo skin, IResourceStore<byte[]> fallbackStore)
// Bypass LegacySkinResourceStore to avoid returning null for retrieving files due to bad skin info (SkinInfo.Files = null).
: base(skin, null, storage)
: base(skin, null, fallbackStore)
{
}
}
@@ -45,7 +45,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
AddAssert("end time is correct", () => Precision.AlmostEquals(lastObject.EndTime, times[1]));
AddAssert("start position is correct", () => Precision.AlmostEquals(lastObject.OriginalX, positions[0]));
AddAssert("end position is correct", () => Precision.AlmostEquals(lastObject.EndX, positions[1]));
AddAssert("default slider velocity", () => lastObject.SliderVelocityBindable.IsDefault);
AddAssert("default slider velocity", () => lastObject.SliderVelocityMultiplierBindable.IsDefault);
}
[Test]
@@ -76,7 +76,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
addPlacementSteps(times, positions);
addPathCheckStep(times, positions);
AddAssert("slider velocity changed", () => !lastObject.SliderVelocityBindable.IsDefault);
AddAssert("slider velocity changed", () => !lastObject.SliderVelocityMultiplierBindable.IsDefault);
}
[Test]
@@ -108,11 +108,11 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
double[] times = { 100, 300 };
float[] positions = { 200, 300 };
addBlueprintStep(times, positions);
AddAssert("default slider velocity", () => hitObject.SliderVelocityBindable.IsDefault);
AddAssert("default slider velocity", () => hitObject.SliderVelocityMultiplierBindable.IsDefault);
addDragStartStep(times[1], positions[1]);
AddMouseMoveStep(times[1], 400);
AddAssert("slider velocity changed", () => !hitObject.SliderVelocityBindable.IsDefault);
AddAssert("slider velocity changed", () => !hitObject.SliderVelocityMultiplierBindable.IsDefault);
}
[Test]
@@ -140,7 +140,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
AddStep("update hit object path", () =>
{
hitObject.Path = new SliderPath(PathType.PerfectCurve, new[]
hitObject.Path = new SliderPath(PathType.PERFECT_CURVE, new[]
{
Vector2.Zero,
new Vector2(100, 100),
@@ -190,16 +190,16 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
[Test]
public void TestVertexResampling()
{
addBlueprintStep(100, 100, new SliderPath(PathType.PerfectCurve, new[]
addBlueprintStep(100, 100, new SliderPath(PathType.PERFECT_CURVE, new[]
{
Vector2.Zero,
new Vector2(100, 100),
new Vector2(50, 200),
}), 0.5);
AddAssert("1 vertex per 1 nested HO", () => getVertices().Count == hitObject.NestedHitObjects.Count);
AddAssert("slider path not yet changed", () => hitObject.Path.ControlPoints[0].Type == PathType.PerfectCurve);
AddAssert("slider path not yet changed", () => hitObject.Path.ControlPoints[0].Type == PathType.PERFECT_CURVE);
addAddVertexSteps(150, 150);
AddAssert("slider path change to linear", () => hitObject.Path.ControlPoints[0].Type == PathType.Linear);
AddAssert("slider path change to linear", () => hitObject.Path.ControlPoints[0].Type == PathType.LINEAR);
}
private void addBlueprintStep(double time, float x, SliderPath sliderPath, double velocity) => AddStep("add selection blueprint", () =>
@@ -154,7 +154,7 @@ namespace osu.Game.Rulesets.Catch.Tests
} while (rng.Next(2) != 0);
int length = sliderPath.ControlPoints.Count - start + 1;
sliderPath.ControlPoints[start].Type = length <= 2 ? PathType.Linear : length == 3 ? PathType.PerfectCurve : PathType.Bezier;
sliderPath.ControlPoints[start].Type = length <= 2 ? PathType.LINEAR : length == 3 ? PathType.PERFECT_CURVE : PathType.BEZIER;
} while (rng.Next(3) != 0);
if (rng.Next(5) == 0)
@@ -215,7 +215,7 @@ namespace osu.Game.Rulesets.Catch.Tests
foreach (var point in sliderPath.ControlPoints)
{
Assert.That(point.Type, Is.EqualTo(PathType.Linear).Or.Null);
Assert.That(point.Type, Is.EqualTo(PathType.LINEAR).Or.Null);
Assert.That(sliderStartY + point.Position.Y, Is.InRange(0, JuiceStreamPath.OSU_PLAYFIELD_HEIGHT));
}
@@ -0,0 +1,21 @@
// 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 NUnit.Framework;
using osu.Game.Rulesets.Catch.Mods;
using osu.Game.Tests.Visual;
namespace osu.Game.Rulesets.Catch.Tests.Mods
{
public partial class TestSceneCatchModFloatingFruits : ModTestScene
{
protected override Ruleset CreatePlayerRuleset() => new CatchRuleset();
[Test]
public void TestFloating() => CreateModTest(new ModTestData
{
Mod = new CatchModFloatingFruits(),
PassCondition = () => true
});
}
}
@@ -35,7 +35,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Mods
var stream = new JuiceStream
{
StartTime = 1000,
Path = new SliderPath(PathType.Linear, new[]
Path = new SliderPath(PathType.LINEAR, new[]
{
Vector2.Zero,
new Vector2(100, 0),
@@ -51,7 +51,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Mods
{
X = CatchPlayfield.CENTER_X,
StartTime = 3000,
Path = new SliderPath(PathType.Linear, new[] { Vector2.Zero, Vector2.UnitY * 200 })
Path = new SliderPath(PathType.LINEAR, new[] { Vector2.Zero, Vector2.UnitY * 200 })
}
}
}
@@ -34,7 +34,7 @@ namespace osu.Game.Rulesets.Catch.Tests
beatmap.HitObjects.Add(new JuiceStream
{
X = CatchPlayfield.CENTER_X - width / 2,
Path = new SliderPath(PathType.Linear, new[]
Path = new SliderPath(PathType.LINEAR, new[]
{
Vector2.Zero,
new Vector2(width, 0)
@@ -31,7 +31,7 @@ namespace osu.Game.Rulesets.Catch.Tests
new JuiceStream
{
StartTime = 1000,
Path = new SliderPath(PathType.Linear, new[] { Vector2.Zero, new Vector2(0, -192) }),
Path = new SliderPath(PathType.LINEAR, new[] { Vector2.Zero, new Vector2(0, -192) }),
X = CatchPlayfield.WIDTH / 2
}
}
@@ -73,7 +73,7 @@ namespace osu.Game.Rulesets.Catch.Tests
private float getCaughtObjectPosition(Fruit fruit)
{
var caughtObject = catcher.ChildrenOfType<CaughtObject>().Single(c => c.HitObject == fruit);
return caughtObject.Parent.ToSpaceOfOtherDrawable(caughtObject.Position, catcher).X;
return caughtObject.Parent!.ToSpaceOfOtherDrawable(caughtObject.Position, catcher).X;
}
private void catchFruit(Fruit fruit, float x)
@@ -126,7 +126,7 @@ namespace osu.Game.Rulesets.Catch.Tests
{
X = xCoords,
StartTime = playfieldTime + 1000,
Path = new SliderPath(PathType.Linear, new[]
Path = new SliderPath(PathType.LINEAR, new[]
{
Vector2.Zero,
new Vector2(0, 200)
@@ -32,7 +32,7 @@ namespace osu.Game.Rulesets.Catch.Tests
new JuiceStream
{
X = CatchPlayfield.CENTER_X,
Path = new SliderPath(PathType.Linear, new[]
Path = new SliderPath(PathType.LINEAR, new[]
{
Vector2.Zero,
new Vector2(0, 100)
@@ -0,0 +1,185 @@
// 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 System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Bindables;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Catch.Beatmaps;
using osu.Game.Rulesets.Catch.Judgements;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Catch.Scoring;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Scoring.Legacy;
using osu.Game.Tests.Visual.Gameplay;
namespace osu.Game.Rulesets.Catch.Tests
{
[TestFixture]
public partial class TestSceneScoring : ScoringTestScene
{
public TestSceneScoring()
: base(supportsNonPerfectJudgements: false)
{
}
private Bindable<double> scoreMultiplier { get; } = new BindableDouble
{
Default = 4,
Value = 4
};
protected override IBeatmap CreateBeatmap(int maxCombo)
{
var beatmap = new CatchBeatmap();
for (int i = 0; i < maxCombo; ++i)
beatmap.HitObjects.Add(new Fruit());
return beatmap;
}
protected override IScoringAlgorithm CreateScoreV1(IReadOnlyList<Mod> selectedMods)
=> new ScoreV1(selectedMods) { ScoreMultiplier = { BindTarget = scoreMultiplier } };
protected override IScoringAlgorithm CreateScoreV2(int maxCombo, IReadOnlyList<Mod> selectedMods)
=> new ScoreV2(maxCombo, selectedMods);
protected override ProcessorBasedScoringAlgorithm CreateScoreAlgorithm(IBeatmap beatmap, ScoringMode mode, IReadOnlyList<Mod> selectedMods)
=> new CatchProcessorBasedScoringAlgorithm(beatmap, mode, selectedMods);
[Test]
public void TestBasicScenarios()
{
AddStep("set up score multiplier", () =>
{
scoreMultiplier.BindValueChanged(_ => Rerun());
});
AddStep("set max combo to 100", () => MaxCombo.Value = 100);
AddStep("set perfect score", () =>
{
NonPerfectLocations.Clear();
MissLocations.Clear();
});
AddStep("set score with misses", () =>
{
NonPerfectLocations.Clear();
MissLocations.Clear();
MissLocations.AddRange(new[] { 24d, 49 });
});
AddSliderStep("adjust score multiplier", 0, 10, (int)scoreMultiplier.Default, multiplier => scoreMultiplier.Value = multiplier);
}
private const int base_great = 300;
private class ScoreV1 : IScoringAlgorithm
{
private readonly double modMultiplier;
public BindableDouble ScoreMultiplier { get; } = new BindableDouble();
private int currentCombo;
public ScoreV1(IReadOnlyList<Mod> selectedMods)
{
var ruleset = new CatchRuleset();
modMultiplier = ruleset.CreateLegacyScoreSimulator().GetLegacyScoreMultiplier(selectedMods, new LegacyBeatmapConversionDifficultyInfo
{
SourceRuleset = ruleset.RulesetInfo
});
}
public void ApplyHit() => applyHitV1(base_great);
public void ApplyNonPerfect() => throw new NotSupportedException("catch does not have \"non-perfect\" judgements.");
public void ApplyMiss() => applyHitV1(0);
private void applyHitV1(int baseScore)
{
if (baseScore == 0)
{
currentCombo = 0;
return;
}
TotalScore += baseScore;
// combo multiplier
// ReSharper disable once PossibleLossOfFraction
TotalScore += (int)(Math.Max(0, currentCombo - 1) * (baseScore / 25 * (ScoreMultiplier.Value * modMultiplier)));
currentCombo++;
}
public long TotalScore { get; private set; }
}
private class ScoreV2 : IScoringAlgorithm
{
private int currentCombo;
private double comboPortion;
private readonly double modMultiplier;
private readonly double comboPortionMax;
private const double combo_base = 4;
private const int combo_cap = 200;
public ScoreV2(int maxCombo, IReadOnlyList<Mod> selectedMods)
{
var ruleset = new CatchRuleset();
modMultiplier = ruleset.CreateLegacyScoreSimulator().GetLegacyScoreMultiplier(
selectedMods.Append(new ModScoreV2()).ToList(),
new LegacyBeatmapConversionDifficultyInfo
{
SourceRuleset = ruleset.RulesetInfo
});
for (int i = 0; i < maxCombo; i++)
ApplyHit();
comboPortionMax = comboPortion;
currentCombo = 0;
comboPortion = 0;
}
public void ApplyHit() => applyHitV2(base_great);
public void ApplyNonPerfect() => throw new NotSupportedException("catch does not have \"non-perfect\" judgements.");
private void applyHitV2(int baseScore)
{
comboPortion += baseScore * Math.Min(Math.Max(0.5, Math.Log(++currentCombo, combo_base)), Math.Log(combo_cap, combo_base));
}
public void ApplyMiss()
{
currentCombo = 0;
}
public long TotalScore
=> (int)Math.Round((1000000 * comboPortion / comboPortionMax) * modMultiplier); // vast simplification, as we're not doing ticks here.
}
private class CatchProcessorBasedScoringAlgorithm : ProcessorBasedScoringAlgorithm
{
public CatchProcessorBasedScoringAlgorithm(IBeatmap beatmap, ScoringMode mode, IReadOnlyList<Mod> selectedMods)
: base(beatmap, mode, selectedMods)
{
}
protected override ScoreProcessor CreateScoreProcessor() => new CatchScoreProcessor();
protected override JudgementResult CreatePerfectJudgementResult() => new CatchJudgementResult(new Fruit(), new CatchJudgement()) { Type = HitResult.Great };
protected override JudgementResult CreateNonPerfectJudgementResult() => throw new NotSupportedException("catch does not have \"non-perfect\" judgements.");
protected override JudgementResult CreateMissJudgementResult() => new CatchJudgementResult(new Fruit(), new CatchJudgement()) { Type = HitResult.Miss };
}
}
}
@@ -1,9 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\osu.TestProject.props" />
<ItemGroup Label="Package References">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.3.1" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
</ItemGroup>
<PropertyGroup Label="Project">
<OutputType>WinExe</OutputType>
@@ -41,9 +41,8 @@ namespace osu.Game.Rulesets.Catch.Beatmaps
X = xPositionData?.X ?? 0,
NewCombo = comboData?.NewCombo ?? false,
ComboOffset = comboData?.ComboOffset ?? 0,
LegacyLastTickOffset = (obj as IHasLegacyLastTickOffset)?.LegacyLastTickOffset ?? 0,
LegacyConvertedY = yPositionData?.Y ?? CatchHitObject.DEFAULT_LEGACY_CONVERT_Y,
SliderVelocity = sliderVelocityData?.SliderVelocity ?? 1
SliderVelocityMultiplier = sliderVelocityData?.SliderVelocityMultiplier ?? 1
}.Yield();
case IHasDuration endTime:
@@ -23,6 +23,22 @@ namespace osu.Game.Rulesets.Catch.Beatmaps
{
}
public override void PreProcess()
{
IHasComboInformation? lastObj = null;
// For sanity, ensures that both the first hitobject and the first hitobject after a banana shower start a new combo.
// This is normally enforced by the legacy decoder, but is not enforced by the editor.
foreach (var obj in Beatmap.HitObjects.OfType<IHasComboInformation>())
{
if (obj is not BananaShower && (lastObj == null || lastObj is BananaShower))
obj.NewCombo = true;
lastObj = obj;
}
base.PreProcess();
}
public override void PostProcess()
{
base.PostProcess();
+4 -1
View File
@@ -25,6 +25,7 @@ using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Replays.Types;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Scoring.Legacy;
using osu.Game.Rulesets.UI;
using osu.Game.Scoring;
using osu.Game.Screens.Ranking.Statistics;
@@ -38,6 +39,8 @@ namespace osu.Game.Rulesets.Catch
public override ScoreProcessor CreateScoreProcessor() => new CatchScoreProcessor();
public override HealthProcessor CreateHealthProcessor(double drainStartTime) => new CatchHealthProcessor(drainStartTime);
public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => new CatchBeatmapConverter(beatmap, this);
public override IBeatmapProcessor CreateBeatmapProcessor(IBeatmap beatmap) => new CatchBeatmapProcessor(beatmap);
@@ -53,7 +56,7 @@ namespace osu.Game.Rulesets.Catch
new KeyBinding(InputKey.X, CatchAction.MoveRight),
new KeyBinding(InputKey.Right, CatchAction.MoveRight),
new KeyBinding(InputKey.Shift, CatchAction.Dash),
new KeyBinding(InputKey.Shift, CatchAction.Dash),
new KeyBinding(InputKey.MouseLeft, CatchAction.Dash),
};
public override IEnumerable<Mod> ConvertFromLegacyMods(LegacyMods mods)
@@ -25,12 +25,9 @@ namespace osu.Game.Rulesets.Catch.Difficulty
public override int Version => 20220701;
private readonly IWorkingBeatmap workingBeatmap;
public CatchDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap)
{
workingBeatmap = beatmap;
}
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
@@ -49,15 +46,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty
MaxCombo = beatmap.HitObjects.Count(h => h is Fruit) + beatmap.HitObjects.OfType<JuiceStream>().SelectMany(j => j.NestedHitObjects).Count(h => !(h is TinyDroplet)),
};
if (ComputeLegacyScoringValues)
{
CatchLegacyScoreSimulator sv1Simulator = new CatchLegacyScoreSimulator();
sv1Simulator.Simulate(workingBeatmap, beatmap, mods);
attributes.LegacyAccuracyScore = sv1Simulator.AccuracyScore;
attributes.LegacyComboScore = sv1Simulator.ComboScore;
attributes.LegacyBonusScoreRatio = sv1Simulator.BonusScoreRatio;
}
return attributes;
}
@@ -5,30 +5,26 @@ using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Catch.Mods;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Scoring.Legacy;
namespace osu.Game.Rulesets.Catch.Difficulty
{
internal class CatchLegacyScoreSimulator : ILegacyScoreSimulator
{
public int AccuracyScore { get; private set; }
public int ComboScore { get; private set; }
public double BonusScoreRatio => legacyBonusScore == 0 ? 0 : (double)modernBonusScore / legacyBonusScore;
private int legacyBonusScore;
private int modernBonusScore;
private int standardisedBonusScore;
private int combo;
private double scoreMultiplier;
public void Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap, IReadOnlyList<Mod> mods)
public LegacyScoreAttributes Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap)
{
IBeatmap baseBeatmap = workingBeatmap.Beatmap;
@@ -70,13 +66,19 @@ namespace osu.Game.Rulesets.Catch.Difficulty
+ baseBeatmap.Difficulty.CircleSize
+ Math.Clamp((float)objectCount / drainLength * 8, 0, 16)) / 38 * 5);
scoreMultiplier = difficultyPeppyStars * mods.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier);
scoreMultiplier = difficultyPeppyStars;
LegacyScoreAttributes attributes = new LegacyScoreAttributes();
foreach (var obj in playableBeatmap.HitObjects)
simulateHit(obj);
simulateHit(obj, ref attributes);
attributes.BonusScoreRatio = legacyBonusScore == 0 ? 0 : (double)standardisedBonusScore / legacyBonusScore;
return attributes;
}
private void simulateHit(HitObject hitObject)
private void simulateHit(HitObject hitObject, ref LegacyScoreAttributes attributes)
{
bool increaseCombo = true;
bool addScoreComboMultiplier = false;
@@ -112,31 +114,79 @@ namespace osu.Game.Rulesets.Catch.Difficulty
case JuiceStream:
foreach (var nested in hitObject.NestedHitObjects)
simulateHit(nested);
simulateHit(nested, ref attributes);
return;
case BananaShower:
foreach (var nested in hitObject.NestedHitObjects)
simulateHit(nested);
simulateHit(nested, ref attributes);
return;
}
if (addScoreComboMultiplier)
{
// ReSharper disable once PossibleLossOfFraction (intentional to match osu-stable...)
ComboScore += (int)(Math.Max(0, combo - 1) * (scoreIncrease / 25 * scoreMultiplier));
attributes.ComboScore += (int)(Math.Max(0, combo - 1) * (scoreIncrease / 25 * scoreMultiplier));
}
if (isBonus)
{
legacyBonusScore += scoreIncrease;
modernBonusScore += Judgement.ToNumericResult(bonusResult);
standardisedBonusScore += Judgement.ToNumericResult(bonusResult);
}
else
AccuracyScore += scoreIncrease;
attributes.AccuracyScore += scoreIncrease;
if (increaseCombo)
combo++;
}
public double GetLegacyScoreMultiplier(IReadOnlyList<Mod> mods, LegacyBeatmapConversionDifficultyInfo difficulty)
{
bool scoreV2 = mods.Any(m => m is ModScoreV2);
double multiplier = 1.0;
foreach (var mod in mods)
{
switch (mod)
{
case CatchModNoFail:
multiplier *= scoreV2 ? 1.0 : 0.5;
break;
case CatchModEasy:
multiplier *= 0.5;
break;
case CatchModHalfTime:
case CatchModDaycore:
multiplier *= 0.3;
break;
case CatchModHidden:
multiplier *= scoreV2 ? 1.0 : 1.06;
break;
case CatchModHardRock:
multiplier *= 1.12;
break;
case CatchModDoubleTime:
case CatchModNightcore:
multiplier *= 1.06;
break;
case CatchModFlashlight:
multiplier *= 1.12;
break;
case CatchModRelax:
return 0;
}
}
return multiplier;
}
}
}
@@ -74,7 +74,7 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
path.ConvertFromSliderPath(sliderPath, hitObject.Velocity);
// If the original slider path has non-linear type segments, resample the vertices at nested hit object times to reduce the number of vertices.
if (sliderPath.ControlPoints.Any(p => p.Type != null && p.Type != PathType.Linear))
if (sliderPath.ControlPoints.Any(p => p.Type != null && p.Type != PathType.LINEAR))
{
path.ResampleVertices(hitObject.NestedHitObjects
.Skip(1).TakeWhile(h => !(h is Fruit)) // Only droplets in the first span are used.
@@ -91,7 +91,7 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
public void UpdateHitObjectFromPath(JuiceStream hitObject)
{
// The SV setting may need to be changed for the current path.
var svBindable = hitObject.SliderVelocityBindable;
var svBindable = hitObject.SliderVelocityMultiplierBindable;
double svToVelocityFactor = hitObject.Velocity / svBindable.Value;
double requiredVelocity = path.ComputeRequiredVelocity();
@@ -1,180 +1,19 @@
// 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 System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Caching;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Rulesets.Catch.UI;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Screens.Edit;
using osuTK.Graphics;
using osu.Game.Screens.Edit.Compose.Components;
namespace osu.Game.Rulesets.Catch.Edit
{
/// <summary>
/// A grid which displays coloured beat divisor lines in proximity to the selection or placement cursor.
/// </summary>
/// <remarks>
/// This class heavily borrows from osu!mania's implementation (ManiaBeatSnapGrid).
/// If further changes are to be made, they should also be applied there.
/// If the scale of the changes are large enough, abstracting may be a good path.
/// </remarks>
public partial class CatchBeatSnapGrid : Component
public partial class CatchBeatSnapGrid : BeatSnapGrid
{
private const double visible_range = 750;
/// <summary>
/// The range of time values of the current selection.
/// </summary>
public (double start, double end)? SelectionTimeRange
protected override IEnumerable<Container> GetTargetContainers(HitObjectComposer composer) => new[]
{
set
{
if (value == selectionTimeRange)
return;
selectionTimeRange = value;
lineCache.Invalidate();
}
}
[Resolved]
private EditorBeatmap beatmap { get; set; } = null!;
[Resolved]
private OsuColour colours { get; set; } = null!;
[Resolved]
private BindableBeatDivisor beatDivisor { get; set; } = null!;
private readonly Cached lineCache = new Cached();
private (double start, double end)? selectionTimeRange;
private ScrollingHitObjectContainer lineContainer = null!;
[BackgroundDependencyLoader]
private void load(HitObjectComposer composer)
{
lineContainer = new ScrollingHitObjectContainer();
((CatchPlayfield)composer.Playfield).UnderlayElements.Add(lineContainer);
beatDivisor.BindValueChanged(_ => createLines(), true);
}
protected override void Update()
{
base.Update();
if (!lineCache.IsValid)
{
lineCache.Validate();
createLines();
}
}
private readonly Stack<DrawableGridLine> availableLines = new Stack<DrawableGridLine>();
private void createLines()
{
foreach (var line in lineContainer.Objects.OfType<DrawableGridLine>())
availableLines.Push(line);
lineContainer.Clear();
if (selectionTimeRange == null)
return;
var range = selectionTimeRange.Value;
var timingPoint = beatmap.ControlPointInfo.TimingPointAt(range.start - visible_range);
double time = timingPoint.Time;
int beat = 0;
// progress time until in the visible range.
while (time < range.start - visible_range)
{
time += timingPoint.BeatLength / beatDivisor.Value;
beat++;
}
while (time < range.end + visible_range)
{
var nextTimingPoint = beatmap.ControlPointInfo.TimingPointAt(time);
// switch to the next timing point if we have reached it.
if (nextTimingPoint.Time > timingPoint.Time)
{
beat = 0;
time = nextTimingPoint.Time;
timingPoint = nextTimingPoint;
}
Color4 colour = BindableBeatDivisor.GetColourFor(
BindableBeatDivisor.GetDivisorForBeatIndex(beat, beatDivisor.Value), colours);
if (!availableLines.TryPop(out var line))
line = new DrawableGridLine();
line.HitObject.StartTime = time;
line.Colour = colour;
lineContainer.Add(line);
beat++;
time += timingPoint.BeatLength / beatDivisor.Value;
}
// required to update ScrollingHitObjectContainer's cache.
lineContainer.UpdateSubTree();
foreach (var line in lineContainer.Objects.OfType<DrawableGridLine>())
{
time = line.HitObject.StartTime;
if (time >= range.start && time <= range.end)
line.Alpha = 1;
else
{
double timeSeparation = time < range.start ? range.start - time : time - range.end;
line.Alpha = (float)Math.Max(0, 1 - timeSeparation / visible_range);
}
}
}
private partial class DrawableGridLine : DrawableHitObject
{
public DrawableGridLine()
: base(new HitObject())
{
RelativeSizeAxes = Axes.X;
Height = 2;
AddInternal(new Box { RelativeSizeAxes = Axes.Both });
}
[BackgroundDependencyLoader]
private void load()
{
Origin = Anchor.BottomLeft;
Anchor = Anchor.BottomLeft;
}
protected override void UpdateInitialTransforms()
{
// don't perform any fading we are handling that ourselves.
LifetimeEnd = HitObject.StartTime + visible_range;
}
}
((CatchPlayfield)composer.Playfield).UnderlayElements
};
}
}
@@ -0,0 +1,26 @@
// 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.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
namespace osu.Game.Rulesets.Catch.Edit
{
public partial class CatchDistanceSnapProvider : ComposerDistanceSnapProvider
{
protected override double ReadCurrentDistanceSnap(HitObject before, HitObject after)
{
// osu!catch's distance snap implementation is limited, in that a custom spacing cannot be specified.
// Therefore this functionality is not currently used.
//
// The implementation below is probably correct but should be checked if/when exposed via controls.
float expectedDistance = DurationToDistance(before, after.StartTime - before.GetEndTime());
float actualDistance = Math.Abs(((CatchHitObject)before).EffectiveX - ((CatchHitObject)after).EffectiveX);
return actualDistance / expectedDistance;
}
}
}
@@ -41,7 +41,7 @@ namespace osu.Game.Rulesets.Catch.Edit
{
base.Update();
Scale = new Vector2(Math.Min(Parent.ChildSize.X / CatchPlayfield.WIDTH, Parent.ChildSize.Y / CatchPlayfield.HEIGHT));
Scale = new Vector2(Math.Min(Parent!.ChildSize.X / CatchPlayfield.WIDTH, Parent!.ChildSize.Y / CatchPlayfield.HEIGHT));
Height = 1 / Scale.Y;
}
}
@@ -1,14 +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.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.EnumExtensions;
using osu.Framework.Graphics;
using osu.Framework.Input;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Game.Beatmaps;
using osu.Game.Graphics.UserInterface;
@@ -20,27 +19,27 @@ using osu.Game.Rulesets.Edit.Tools;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.UI;
using osu.Game.Screens.Edit.Components.TernaryButtons;
using osu.Game.Screens.Edit.Compose.Components;
using osuTK;
namespace osu.Game.Rulesets.Catch.Edit
{
public partial class CatchHitObjectComposer : DistancedHitObjectComposer<CatchHitObject>
public partial class CatchHitObjectComposer : ScrollingHitObjectComposer<CatchHitObject>, IKeyBindingHandler<GlobalAction>
{
private const float distance_snap_radius = 50;
private CatchDistanceSnapGrid distanceSnapGrid = null!;
private InputManager inputManager = null!;
private CatchBeatSnapGrid beatSnapGrid = null!;
private readonly BindableDouble timeRangeMultiplier = new BindableDouble(1)
{
MinValue = 1,
MaxValue = 10,
};
[Cached(typeof(IDistanceSnapProvider))]
protected readonly CatchDistanceSnapProvider DistanceSnapProvider = new CatchDistanceSnapProvider();
public CatchHitObjectComposer(CatchRuleset ruleset)
: base(ruleset)
{
@@ -49,8 +48,11 @@ namespace osu.Game.Rulesets.Catch.Edit
[BackgroundDependencyLoader]
private void load()
{
AddInternal(DistanceSnapProvider);
DistanceSnapProvider.AttachToToolbox(RightToolbox);
// todo: enable distance spacing once catch supports applying it to its existing distance snap grid implementation.
DistanceSpacingMultiplier.Disabled = true;
DistanceSnapProvider.DistanceSpacingMultiplier.Disabled = true;
LayerBelowRuleset.Add(new PlayfieldBorder
{
@@ -67,61 +69,30 @@ namespace osu.Game.Rulesets.Catch.Edit
Catcher.BASE_DASH_SPEED, -Catcher.BASE_DASH_SPEED,
Catcher.BASE_WALK_SPEED, -Catcher.BASE_WALK_SPEED,
}));
AddInternal(beatSnapGrid = new CatchBeatSnapGrid());
}
protected override void LoadComplete()
{
base.LoadComplete();
protected override IEnumerable<TernaryButton> CreateTernaryButtons()
=> base.CreateTernaryButtons()
.Concat(DistanceSnapProvider.CreateTernaryButtons());
inputManager = GetContainingInputManager();
}
protected override void UpdateAfterChildren()
{
base.UpdateAfterChildren();
if (BlueprintContainer.CurrentTool is SelectTool)
protected override DrawableRuleset<CatchHitObject> CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod> mods) =>
new DrawableCatchEditorRuleset(ruleset, beatmap, mods)
{
if (EditorBeatmap.SelectedHitObjects.Any())
{
beatSnapGrid.SelectionTimeRange = (EditorBeatmap.SelectedHitObjects.Min(h => h.StartTime), EditorBeatmap.SelectedHitObjects.Max(h => h.GetEndTime()));
}
else
beatSnapGrid.SelectionTimeRange = null;
}
else
{
var result = FindSnappedPositionAndTime(inputManager.CurrentState.Mouse.Position);
if (result.Time is double time)
beatSnapGrid.SelectionTimeRange = (time, time);
else
beatSnapGrid.SelectionTimeRange = null;
}
}
TimeRangeMultiplier = { BindTarget = timeRangeMultiplier, }
};
protected override double ReadCurrentDistanceSnap(HitObject before, HitObject after)
protected override ComposeBlueprintContainer CreateBlueprintContainer() => new CatchBlueprintContainer(this);
protected override BeatSnapGrid CreateBeatSnapGrid() => new CatchBeatSnapGrid();
protected override IReadOnlyList<HitObjectCompositionTool> CompositionTools => new HitObjectCompositionTool[]
{
// osu!catch's distance snap implementation is limited, in that a custom spacing cannot be specified.
// Therefore this functionality is not currently used.
//
// The implementation below is probably correct but should be checked if/when exposed via controls.
new FruitCompositionTool(),
new JuiceStreamCompositionTool(),
new BananaShowerCompositionTool()
};
float expectedDistance = DurationToDistance(before, after.StartTime - before.GetEndTime());
float actualDistance = Math.Abs(((CatchHitObject)before).EffectiveX - ((CatchHitObject)after).EffectiveX);
return actualDistance / expectedDistance;
}
protected override void Update()
{
base.Update();
updateDistanceSnapGrid();
}
public override bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
public bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
{
switch (e.Action)
{
@@ -130,28 +101,19 @@ namespace osu.Game.Rulesets.Catch.Edit
// May be worth considering standardising "zoom" behaviour with what the timeline uses (ie. alt-wheel) but that may cause new conflicts.
case GlobalAction.IncreaseScrollSpeed:
this.TransformBindableTo(timeRangeMultiplier, timeRangeMultiplier.Value - 1, 200, Easing.OutQuint);
break;
return true;
case GlobalAction.DecreaseScrollSpeed:
this.TransformBindableTo(timeRangeMultiplier, timeRangeMultiplier.Value + 1, 200, Easing.OutQuint);
break;
return true;
}
return base.OnPressed(e);
return false;
}
protected override DrawableRuleset<CatchHitObject> CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod>? mods = null) =>
new DrawableCatchEditorRuleset(ruleset, beatmap, mods)
{
TimeRangeMultiplier = { BindTarget = timeRangeMultiplier, }
};
protected override IReadOnlyList<HitObjectCompositionTool> CompositionTools => new HitObjectCompositionTool[]
public void OnReleased(KeyBindingReleaseEvent<GlobalAction> e)
{
new FruitCompositionTool(),
new JuiceStreamCompositionTool(),
new BananaShowerCompositionTool()
};
}
public override SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All)
{
@@ -171,8 +133,6 @@ namespace osu.Game.Rulesets.Catch.Edit
return result;
}
protected override ComposeBlueprintContainer CreateBlueprintContainer() => new CatchBlueprintContainer(this);
private PalpableCatchHitObject? getLastSnappableHitObject(double time)
{
var hitObject = EditorBeatmap.HitObjects.OfType<CatchHitObject>().LastOrDefault(h => h.GetEndTime() < time && !(h is BananaShower));
@@ -213,7 +173,7 @@ namespace osu.Game.Rulesets.Catch.Edit
return null;
}
double timeAtCursor = ((CatchPlayfield)Playfield).TimeAtScreenSpacePosition(inputManager.CurrentState.Mouse.Position);
double timeAtCursor = ((CatchPlayfield)Playfield).TimeAtScreenSpacePosition(InputManager.CurrentState.Mouse.Position);
return getLastSnappableHitObject(timeAtCursor);
default:
@@ -221,9 +181,16 @@ namespace osu.Game.Rulesets.Catch.Edit
}
}
protected override void Update()
{
base.Update();
updateDistanceSnapGrid();
}
private void updateDistanceSnapGrid()
{
if (DistanceSnapToggle.Value != TernaryState.True)
if (DistanceSnapProvider.DistanceSnapToggle.Value != TernaryState.True)
{
distanceSnapGrid.Hide();
return;
@@ -1,7 +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.
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Rulesets.Catch.Objects;
@@ -21,10 +20,8 @@ namespace osu.Game.Rulesets.Catch.Mods
public void ApplyToDrawableRuleset(DrawableRuleset<CatchHitObject> drawableRuleset)
{
drawableRuleset.PlayfieldAdjustmentContainer.Anchor = Anchor.Centre;
drawableRuleset.PlayfieldAdjustmentContainer.Origin = Anchor.Centre;
drawableRuleset.PlayfieldAdjustmentContainer.Scale = new Vector2(1, -1);
drawableRuleset.PlayfieldAdjustmentContainer.Y = 1 - drawableRuleset.PlayfieldAdjustmentContainer.Y;
}
}
}
@@ -1,6 +1,7 @@
// 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.Game.Rulesets.Mods;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Catch.Beatmaps;
@@ -16,5 +17,13 @@ namespace osu.Game.Rulesets.Catch.Mods
var catchProcessor = (CatchBeatmapProcessor)beatmapProcessor;
catchProcessor.HardRockOffsets = true;
}
public override void ApplyToDifficulty(BeatmapDifficulty difficulty)
{
base.ApplyToDifficulty(difficulty);
difficulty.CircleSize = Math.Min(difficulty.CircleSize * 1.3f, 10.0f); // CS uses a custom 1.3 ratio.
difficulty.ApproachRate = Math.Min(difficulty.ApproachRate * ADJUST_RATIO, 10.0f);
}
}
}
@@ -8,6 +8,7 @@ using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Catch.UI;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Scoring;
using osuTK;
@@ -151,7 +152,34 @@ namespace osu.Game.Rulesets.Catch.Objects
TimePreempt = (float)IBeatmapDifficultyInfo.DifficultyRange(difficulty.ApproachRate, 1800, 1200, 450);
Scale = (1.0f - 0.7f * (difficulty.CircleSize - 5) / 5) / 2;
Scale = LegacyRulesetExtensions.CalculateScaleFromCircleSize(difficulty.CircleSize);
}
public void UpdateComboInformation(IHasComboInformation? lastObj)
{
// Note that this implementation is shared with the osu! ruleset's implementation.
// If a change is made here, OsuHitObject.cs should also be updated.
ComboIndex = lastObj?.ComboIndex ?? 0;
ComboIndexWithOffsets = lastObj?.ComboIndexWithOffsets ?? 0;
IndexInCurrentCombo = (lastObj?.IndexInCurrentCombo + 1) ?? 0;
if (this is BananaShower)
{
// For the purpose of combo colours, spinners never start a new combo even if they are flagged as doing so.
return;
}
// At decode time, the first hitobject in the beatmap and the first hitobject after a banana shower are both enforced to be a new combo,
// but this isn't directly enforced by the editor so the extra checks against the last hitobject are duplicated here.
if (NewCombo || lastObj == null || lastObj is BananaShower)
{
IndexInCurrentCombo = 0;
ComboIndex++;
ComboIndexWithOffsets += ComboOffset + 1;
if (lastObj != null)
lastObj.LastInCombo = true;
}
}
protected override HitWindows CreateHitWindows() => HitWindows.Empty;
+9 -11
View File
@@ -28,17 +28,17 @@ namespace osu.Game.Rulesets.Catch.Objects
public int RepeatCount { get; set; }
public BindableNumber<double> SliderVelocityBindable { get; } = new BindableDouble(1)
public BindableNumber<double> SliderVelocityMultiplierBindable { get; } = new BindableDouble(1)
{
Precision = 0.01,
MinValue = 0.1,
MaxValue = 10
};
public double SliderVelocity
public double SliderVelocityMultiplier
{
get => SliderVelocityBindable.Value;
set => SliderVelocityBindable.Value = value;
get => SliderVelocityMultiplierBindable.Value;
set => SliderVelocityMultiplierBindable.Value = value;
}
[JsonIgnore]
@@ -48,10 +48,10 @@ namespace osu.Game.Rulesets.Catch.Objects
private double tickDistanceFactor;
[JsonIgnore]
public double Velocity => velocityFactor * SliderVelocity;
public double Velocity => velocityFactor * SliderVelocityMultiplier;
[JsonIgnore]
public double TickDistance => tickDistanceFactor * SliderVelocity;
public double TickDistance => tickDistanceFactor * SliderVelocityMultiplier;
/// <summary>
/// The length of one span of this <see cref="JuiceStream"/>.
@@ -77,7 +77,7 @@ namespace osu.Game.Rulesets.Catch.Objects
int nodeIndex = 0;
SliderEventDescriptor? lastEvent = null;
foreach (var e in SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), LegacyLastTickOffset, cancellationToken))
foreach (var e in SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), cancellationToken))
{
// generate tiny droplets since the last point
if (lastEvent != null)
@@ -104,8 +104,8 @@ namespace osu.Game.Rulesets.Catch.Objects
}
}
// this also includes LegacyLastTick and this is used for TinyDroplet generation above.
// this means that the final segment of TinyDroplets are increasingly mistimed where LegacyLastTickOffset is being applied.
// this also includes LastTick and this is used for TinyDroplet generation above.
// this means that the final segment of TinyDroplets are increasingly mistimed where LastTick is being applied.
lastEvent = e;
switch (e.Type)
@@ -162,7 +162,5 @@ namespace osu.Game.Rulesets.Catch.Objects
public double Distance => Path.Distance;
public IList<IList<HitSampleInfo>> NodeSamples { get; set; } = new List<IList<HitSampleInfo>>();
public double? LegacyLastTickOffset { get; set; }
}
}
@@ -236,7 +236,7 @@ namespace osu.Game.Rulesets.Catch.Objects
for (int i = 1; i < vertices.Count; i++)
{
sliderPath.ControlPoints[^1].Type = PathType.Linear;
sliderPath.ControlPoints[^1].Type = PathType.LINEAR;
float deltaX = vertices[i].X - lastPosition.X;
double length = (vertices[i].Time - currentTime) * velocity;
@@ -0,0 +1,57 @@
// 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 osu.Game.Beatmaps;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Catch.Scoring
{
public partial class CatchHealthProcessor : LegacyDrainingHealthProcessor
{
public CatchHealthProcessor(double drainStartTime)
: base(drainStartTime)
{
}
protected override IEnumerable<HitObject> EnumerateTopLevelHitObjects() => EnumerateHitObjects(Beatmap).Where(h => h is Fruit || h is Droplet || h is Banana);
protected override IEnumerable<HitObject> EnumerateNestedHitObjects(HitObject hitObject) => Enumerable.Empty<HitObject>();
protected override double GetHealthIncreaseFor(HitObject hitObject, HitResult result)
{
double increase = 0;
switch (result)
{
case HitResult.SmallTickMiss:
return 0;
case HitResult.LargeTickMiss:
case HitResult.Miss:
return IBeatmapDifficultyInfo.DifficultyRange(Beatmap.Difficulty.DrainRate, -0.03, -0.125, -0.2);
case HitResult.SmallTickHit:
increase = 0.0015;
break;
case HitResult.LargeTickHit:
increase = 0.015;
break;
case HitResult.Great:
increase = 0.03;
break;
case HitResult.LargeBonus:
increase = 0.0025;
break;
}
return HpMultiplierNormal * increase;
}
}
}
@@ -15,7 +15,11 @@ namespace osu.Game.Rulesets.Catch.Skinning.Argon
[BackgroundDependencyLoader]
private void load()
{
RelativeSizeAxes = Axes.Both;
Anchor = Anchor.TopCentre;
Origin = Anchor.Centre;
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
InternalChildren = new Drawable[]
{
@@ -9,6 +9,7 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Game.Rulesets.Catch.UI;
using osuTK;
namespace osu.Game.Rulesets.Catch.Skinning.Default
{
@@ -22,6 +23,7 @@ namespace osu.Game.Rulesets.Catch.Skinning.Default
public DefaultCatcher()
{
Anchor = Anchor.TopCentre;
RelativeSizeAxes = Axes.Both;
InternalChild = sprite = new Sprite
{
@@ -32,6 +34,15 @@ namespace osu.Game.Rulesets.Catch.Skinning.Default
};
}
protected override void Update()
{
base.Update();
// matches stable's origin position since we're using the same catcher sprite.
// see LegacyCatcher for more information.
OriginPosition = new Vector2(DrawWidth / 2, 16f);
}
[BackgroundDependencyLoader]
private void load(TextureStore store, Bindable<CatcherAnimationState> currentState)
{
@@ -2,17 +2,21 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics.Textures;
using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Rulesets.Catch.Skinning.Legacy
{
public partial class LegacyBananaPiece : LegacyCatchHitObjectPiece
{
private static readonly Vector2 banana_max_size = new Vector2(160);
protected override void LoadComplete()
{
base.LoadComplete();
Texture? texture = Skin.GetTexture("fruit-bananas");
Texture? overlayTexture = Skin.GetTexture("fruit-bananas-overlay");
Texture? texture = Skin.GetTexture("fruit-bananas")?.WithMaximumSize(banana_max_size);
Texture? overlayTexture = Skin.GetTexture("fruit-bananas-overlay")?.WithMaximumSize(banana_max_size);
SetTexture(texture, overlayTexture);
}
@@ -0,0 +1,33 @@
// 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.Graphics;
using osu.Framework.Graphics.Containers;
using osuTK;
namespace osu.Game.Rulesets.Catch.Skinning.Legacy
{
public abstract partial class LegacyCatcher : CompositeDrawable
{
protected LegacyCatcher()
{
Anchor = Anchor.TopCentre;
Origin = Anchor.TopCentre;
// in stable, catcher sprites are displayed in their raw size. stable also has catcher sprites displayed with the following scale factors applied:
// 1. 0.5x, affecting all sprites in the playfield, computed here based on lazer's catch playfield dimensions (see WIDTH/HEIGHT constants in CatchPlayfield),
// source: https://github.com/peppy/osu-stable-reference/blob/1531237b63392e82c003c712faa028406073aa8f/osu!/GameplayElements/HitObjectManager.cs#L483-L494
// 2. 0.7x, a constant scale applied to all catcher sprites on construction.
AutoSizeAxes = Axes.Both;
Scale = new Vector2(0.5f * 0.7f);
}
protected override void Update()
{
base.Update();
// stable sets the Y origin position of the catcher to 16px in order for the catching range and OD scaling to align with the top of the catcher's plate in the default skin.
OriginPosition = new Vector2(DrawWidth / 2, 16f);
}
}
}
@@ -7,14 +7,12 @@ using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Animations;
using osu.Framework.Graphics.Containers;
using osu.Game.Rulesets.Catch.UI;
using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Rulesets.Catch.Skinning.Legacy
{
public partial class LegacyCatcherNew : CompositeDrawable
public partial class LegacyCatcherNew : LegacyCatcher
{
[Resolved]
private Bindable<CatcherAnimationState> currentState { get; set; } = null!;
@@ -23,25 +21,12 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy
private Drawable currentDrawable = null!;
public LegacyCatcherNew()
{
RelativeSizeAxes = Axes.Both;
}
[BackgroundDependencyLoader]
private void load(ISkinSource skin)
{
foreach (var state in Enum.GetValues<CatcherAnimationState>())
{
AddInternal(drawables[state] = getDrawableFor(state).With(d =>
{
d.Anchor = Anchor.TopCentre;
d.Origin = Anchor.TopCentre;
d.RelativeSizeAxes = Axes.Both;
d.Size = Vector2.One;
d.FillMode = FillMode.Fit;
d.Alpha = 0;
}));
AddInternal(drawables[state] = getDrawableFor(state).With(d => d.Alpha = 0));
}
currentDrawable = drawables[CatcherAnimationState.Idle];
@@ -3,30 +3,21 @@
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Rulesets.Catch.Skinning.Legacy
{
public partial class LegacyCatcherOld : CompositeDrawable
public partial class LegacyCatcherOld : LegacyCatcher
{
public LegacyCatcherOld()
{
RelativeSizeAxes = Axes.Both;
AutoSizeAxes = Axes.Both;
}
[BackgroundDependencyLoader]
private void load(ISkinSource skin)
{
InternalChild = (skin.GetAnimation(@"fruit-ryuuta", true, true, true) ?? Empty()).With(d =>
{
d.Anchor = Anchor.TopCentre;
d.Origin = Anchor.TopCentre;
d.RelativeSizeAxes = Axes.Both;
d.Size = Vector2.One;
d.FillMode = FillMode.Fit;
});
InternalChild = skin.GetAnimation(@"fruit-ryuuta", true, true, true) ?? Empty();
}
}
}
@@ -2,12 +2,15 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics.Textures;
using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Rulesets.Catch.Skinning.Legacy
{
public partial class LegacyDropletPiece : LegacyCatchHitObjectPiece
{
private static readonly Vector2 droplet_max_size = new Vector2(160);
public LegacyDropletPiece()
{
Scale = new Vector2(0.8f);
@@ -17,8 +20,8 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy
{
base.LoadComplete();
Texture? texture = Skin.GetTexture("fruit-drop");
Texture? overlayTexture = Skin.GetTexture("fruit-drop-overlay");
Texture? texture = Skin.GetTexture("fruit-drop")?.WithMaximumSize(droplet_max_size);
Texture? overlayTexture = Skin.GetTexture("fruit-drop-overlay")?.WithMaximumSize(droplet_max_size);
SetTexture(texture, overlayTexture);
}
@@ -2,11 +2,15 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Rulesets.Catch.Skinning.Legacy
{
internal partial class LegacyFruitPiece : LegacyCatchHitObjectPiece
{
private static readonly Vector2 fruit_max_size = new Vector2(160);
protected override void LoadComplete()
{
base.LoadComplete();
@@ -22,21 +26,26 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy
switch (visualRepresentation)
{
case FruitVisualRepresentation.Pear:
SetTexture(Skin.GetTexture("fruit-pear"), Skin.GetTexture("fruit-pear-overlay"));
setTextures("pear");
break;
case FruitVisualRepresentation.Grape:
SetTexture(Skin.GetTexture("fruit-grapes"), Skin.GetTexture("fruit-grapes-overlay"));
setTextures("grapes");
break;
case FruitVisualRepresentation.Pineapple:
SetTexture(Skin.GetTexture("fruit-apple"), Skin.GetTexture("fruit-apple-overlay"));
setTextures("apple");
break;
case FruitVisualRepresentation.Raspberry:
SetTexture(Skin.GetTexture("fruit-orange"), Skin.GetTexture("fruit-orange-overlay"));
setTextures("orange");
break;
}
void setTextures(string fruitName) => SetTexture(
Skin.GetTexture($"fruit-{fruitName}")?.WithMaximumSize(fruit_max_size),
Skin.GetTexture($"fruit-{fruitName}-overlay")?.WithMaximumSize(fruit_max_size)
);
}
}
}
@@ -17,12 +17,13 @@ namespace osu.Game.Rulesets.Catch.UI
public CatchPlayfieldAdjustmentContainer()
{
// because we are using centre anchor/origin, we will need to limit visibility in the future
// to ensure tall windows do not get a readability advantage.
// it may be possible to bake the catch-specific offsets (-100..340 mentioned below) into new values
// which are compatible with TopCentre alignment.
Anchor = Anchor.Centre;
Origin = Anchor.Centre;
Anchor = Anchor.TopCentre;
Origin = Anchor.TopCentre;
// playfields in stable are positioned vertically at three fourths the difference between the playfield height and the window height in stable.
// we can match that in lazer by using relative coordinates for Y and considering window height to be 1, and playfield height to be 0.8.
RelativePositionAxes = Axes.Y;
Y = (1 - playfield_size_adjust) / 4 * 3;
Size = new Vector2(playfield_size_adjust);
@@ -42,18 +43,28 @@ namespace osu.Game.Rulesets.Catch.UI
/// </summary>
private partial class ScalingContainer : Container
{
public ScalingContainer()
{
Anchor = Anchor.BottomCentre;
Origin = Anchor.BottomCentre;
}
protected override void Update()
{
base.Update();
// in stable, fruit fall vertically from -100 to 340.
// to emulate this, we want to make our playfield 440 gameplay pixels high.
// we then offset it -100 vertically in the position set below.
const float stable_v_offset_ratio = 440 / 384f;
// in stable, fruit fall vertically from 100 pixels above the playfield top down to the catcher's Y position (i.e. -100 to 340),
// see: https://github.com/peppy/osu-stable-reference/blob/1531237b63392e82c003c712faa028406073aa8f/osu!/GameplayElements/HitObjects/Fruits/HitCircleFruits.cs#L65
// we already have the playfield positioned similar to stable (see CatchPlayfieldAdjustmentContainer constructor),
// so we only need to increase this container's height 100 pixels above the playfield, and offset it to have the bottom at 340 rather than 384.
const float stable_fruit_start_position = -100;
const float stable_catcher_y_position = 340;
const float playfield_v_size_adjustment = (stable_catcher_y_position - stable_fruit_start_position) / CatchPlayfield.HEIGHT;
const float playfield_v_catcher_offset = stable_catcher_y_position - CatchPlayfield.HEIGHT;
Scale = new Vector2(Parent.ChildSize.X / CatchPlayfield.WIDTH);
Position = new Vector2(0, -100 * stable_v_offset_ratio + Scale.X);
Size = Vector2.Divide(new Vector2(1, stable_v_offset_ratio), Scale);
Scale = new Vector2(Parent!.ChildSize.X / CatchPlayfield.WIDTH);
Position = new Vector2(0f, playfield_v_catcher_offset * Scale.Y);
Size = Vector2.Divide(new Vector2(1, playfield_v_size_adjustment), Scale);
}
}
}
+13 -5
View File
@@ -17,6 +17,7 @@ using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Catch.Objects.Drawables;
using osu.Game.Rulesets.Catch.Skinning;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Skinning;
using osuTK;
using osuTK.Graphics;
@@ -29,6 +30,13 @@ namespace osu.Game.Rulesets.Catch.UI
/// <summary>
/// The size of the catcher at 1x scale.
/// </summary>
/// <remarks>
/// This is mainly used to compute catching range, the actual catcher size may differ based on skin implementation and sprite textures.
/// This is also equivalent to the "catcherWidth" property in osu-stable when the game field and beatmap difficulty are set to default values.
/// </remarks>
/// <seealso cref="CatchPlayfield.WIDTH"/>
/// <seealso cref="CatchPlayfield.HEIGHT"/>
/// <seealso cref="IBeatmapDifficultyInfo.DEFAULT_DIFFICULTY"/>
public const float BASE_SIZE = 106.75f;
/// <summary>
@@ -175,11 +183,6 @@ namespace osu.Game.Rulesets.Catch.UI
/// </summary>
public Drawable CreateProxiedContent() => caughtObjectContainer.CreateProxy();
/// <summary>
/// Calculates the scale of the catcher based off the provided beatmap difficulty.
/// </summary>
private static Vector2 calculateScale(IBeatmapDifficultyInfo difficulty) => new Vector2(1.0f - 0.7f * (difficulty.CircleSize - 5) / 5);
/// <summary>
/// Calculates the width of the area used for attempting catches in gameplay.
/// </summary>
@@ -464,6 +467,11 @@ namespace osu.Game.Rulesets.Catch.UI
d.Expire();
}
/// <summary>
/// Calculates the scale of the catcher based off the provided beatmap difficulty.
/// </summary>
private static Vector2 calculateScale(IBeatmapDifficultyInfo difficulty) => new Vector2(LegacyRulesetExtensions.CalculateScaleFromCircleSize(difficulty.CircleSize) * 2);
private enum DroppedObjectAnimation
{
Drop,
@@ -21,8 +21,6 @@ namespace osu.Game.Rulesets.Catch.UI
{
public partial class DrawableCatchRuleset : DrawableScrollingRuleset<CatchHitObject>
{
protected override ScrollVisualisationMethod VisualisationMethod => ScrollVisualisationMethod.Constant;
protected override bool UserScrollSpeedAdjustment => false;
public DrawableCatchRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod>? mods = null)
@@ -30,6 +28,7 @@ namespace osu.Game.Rulesets.Catch.UI
{
Direction.Value = ScrollingDirection.Down;
TimeRange.Value = GetTimeRange(beatmap.Difficulty.ApproachRate);
VisualisationMethod = ScrollVisualisationMethod.Constant;
}
[BackgroundDependencyLoader]
@@ -6,7 +6,6 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Game.Rulesets.Catch.Skinning.Default;
using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Rulesets.Catch.UI
{
@@ -26,8 +25,8 @@ namespace osu.Game.Rulesets.Catch.UI
: base(new CatchSkinComponentLookup(CatchSkinComponents.Catcher), _ => new DefaultCatcher())
{
Anchor = Anchor.TopCentre;
// Sets the origin roughly to the centre of the catcher's plate to allow for correct scaling.
OriginPosition = new Vector2(0.5f, 0.06f) * Catcher.BASE_SIZE;
Origin = Anchor.TopCentre;
CentreComponent = false;
}
}
}
@@ -0,0 +1,94 @@
// 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.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Objects;
using osu.Game.Tests.Visual;
namespace osu.Game.Rulesets.Mania.Tests.Editor
{
public partial class TestSceneOpenEditorTimestampInMania : EditorTestScene
{
protected override Ruleset CreateEditorRuleset() => new ManiaRuleset();
[Test]
public void TestNormalSelection()
{
addStepClickLink("00:05:920 (5920|3,6623|3,6857|2,7326|1)");
AddAssert("selected group", () => checkSnapAndSelectColumn(5_920, new List<(int, int)>
{ (5_920, 3), (6_623, 3), (6_857, 2), (7_326, 1) }
));
addReset();
addStepClickLink("00:42:716 (42716|3,43420|2,44123|0,44357|1,45295|1)");
AddAssert("selected ungrouped", () => checkSnapAndSelectColumn(42_716, new List<(int, int)>
{ (42_716, 3), (43_420, 2), (44_123, 0), (44_357, 1), (45_295, 1) }
));
addReset();
AddStep("add notes to row", () =>
{
if (EditorBeatmap.HitObjects.Any(x => x is ManiaHitObject m && m.StartTime == 11_545 && m.Column is 1 or 2 or 3))
return;
ManiaHitObject first = (ManiaHitObject)EditorBeatmap.HitObjects.First(x => x is ManiaHitObject m && m.StartTime == 11_545 && m.Column == 0);
ManiaHitObject second = new Note { Column = 1, StartTime = first.StartTime };
ManiaHitObject third = new Note { Column = 2, StartTime = first.StartTime };
ManiaHitObject forth = new Note { Column = 3, StartTime = first.StartTime };
EditorBeatmap.AddRange(new[] { second, third, forth });
});
addStepClickLink("00:11:545 (11545|0,11545|1,11545|2,11545|3)");
AddAssert("selected in row", () => checkSnapAndSelectColumn(11_545, new List<(int, int)>
{ (11_545, 0), (11_545, 1), (11_545, 2), (11_545, 3) }
));
addReset();
addStepClickLink("01:36:623 (96623|1,97560|1,97677|1,97795|1,98966|1)");
AddAssert("selected in column", () => checkSnapAndSelectColumn(96_623, new List<(int, int)>
{ (96_623, 1), (97_560, 1), (97_677, 1), (97_795, 1), (98_966, 1) }
));
}
[Test]
public void TestUnusualSelection()
{
addStepClickLink("00:00:000 (0|1)", "wrong offset");
AddAssert("snap to 1, select none", () => checkSnapAndSelectColumn(2_170));
addReset();
addStepClickLink("00:00:000 (2)", "std link");
AddAssert("snap to 1, select none", () => checkSnapAndSelectColumn(2_170));
addReset();
addStepClickLink("00:00:000 (1,2)", "std link");
AddAssert("snap to 1, select none", () => checkSnapAndSelectColumn(2_170));
}
private void addStepClickLink(string timestamp, string step = "", bool displayTimestamp = true)
{
AddStep(displayTimestamp ? $"{step} {timestamp}" : step, () => Editor.HandleTimestamp(timestamp));
AddUntilStep("wait for seek", () => EditorClock.SeekingOrStopped.Value);
}
private void addReset() => addStepClickLink("00:00:000", "reset", false);
private bool checkSnapAndSelectColumn(double startTime, IReadOnlyCollection<(int, int)>? columnPairs = null)
{
bool checkColumns = columnPairs != null
? EditorBeatmap.SelectedHitObjects.All(x => columnPairs.Any(col => isNoteAt(x, col.Item1, col.Item2)))
: !EditorBeatmap.SelectedHitObjects.Any();
return EditorClock.CurrentTime == startTime
&& EditorBeatmap.SelectedHitObjects.Count == (columnPairs?.Count ?? 0)
&& checkColumns;
}
private bool isNoteAt(HitObject hitObject, double time, int column) =>
hitObject is ManiaHitObject maniaHitObject
&& maniaHitObject.StartTime == time
&& maniaHitObject.Column == column;
}
}
@@ -24,7 +24,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods
PassCondition = () =>
{
var hitObject = Player.ChildrenOfType<DrawableManiaHitObject>().FirstOrDefault();
return hitObject?.Dependencies.Get<IScrollingInfo>().Algorithm is ConstantScrollAlgorithm;
return hitObject?.Dependencies.Get<IScrollingInfo>().Algorithm.Value is ConstantScrollAlgorithm;
}
});
}
@@ -17,12 +17,16 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods
{
private const double offset = 18;
protected override bool AllowFail => true;
protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset();
[Test]
public void TestHitWindowWithoutDoubleTime() => CreateModTest(new ModTestData
{
PassCondition = () => Player.ScoreProcessor.JudgedHits > 0 && Player.ScoreProcessor.Accuracy.Value != 1,
PassCondition = () => Player.ScoreProcessor.JudgedHits > 0
&& Player.ScoreProcessor.Accuracy.Value == 1
&& Player.ScoreProcessor.TotalScore.Value == 1_000_000,
Autoplay = false,
Beatmap = new Beatmap
{
@@ -40,24 +44,31 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods
});
[Test]
public void TestHitWindowWithDoubleTime() => CreateModTest(new ModTestData
public void TestHitWindowWithDoubleTime()
{
Mod = new ManiaModDoubleTime(),
PassCondition = () => Player.ScoreProcessor.JudgedHits > 0 && Player.ScoreProcessor.Accuracy.Value == 1,
Autoplay = false,
Beatmap = new Beatmap
var doubleTime = new ManiaModDoubleTime();
CreateModTest(new ModTestData
{
BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo },
Difficulty = { OverallDifficulty = 10 },
HitObjects = new List<HitObject>
Mod = doubleTime,
PassCondition = () => Player.ScoreProcessor.JudgedHits > 0
&& Player.ScoreProcessor.Accuracy.Value == 1
&& Player.ScoreProcessor.TotalScore.Value == (long)(1_000_010 * doubleTime.ScoreMultiplier),
Autoplay = false,
Beatmap = new Beatmap
{
new Note { StartTime = 1000 }
BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo },
Difficulty = { OverallDifficulty = 10 },
HitObjects = new List<HitObject>
{
new Note { StartTime = 1000 }
},
},
},
ReplayFrames = new List<ReplayFrame>
{
new ManiaReplayFrame(1000 + offset, ManiaAction.Key1)
}
});
ReplayFrames = new List<ReplayFrame>
{
new ManiaReplayFrame(1000 + offset, ManiaAction.Key1)
}
});
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

@@ -4,6 +4,7 @@ Version: 2.5
[Mania]
Keys: 4
ColumnLineWidth: 3,1,3,1,1
LightFramePerSecond: 15
// some skins found in the wild had configuration keys where the @2x suffix was included in the values.
// the expected compatibility behaviour is that the presence of the @2x suffix shouldn't change anything
// if @2x assets are present.
@@ -15,5 +16,6 @@ Hit300: mania/hit300@2x
Hit300g: mania/hit300g@2x
StageLeft: mania/stage-left
StageRight: mania/stage-right
StageLight: mania/stage-light
NoteImage0L: LongNoteTailWang
NoteImage1L: LongNoteTailWang
@@ -58,7 +58,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning
IBindable<ScrollingDirection> IScrollingInfo.Direction => Direction;
IBindable<double> IScrollingInfo.TimeRange { get; } = new Bindable<double>(5000);
IScrollAlgorithm IScrollingInfo.Algorithm { get; } = new ConstantScrollAlgorithm();
IBindable<IScrollAlgorithm> IScrollingInfo.Algorithm { get; } = new Bindable<IScrollAlgorithm>(new ConstantScrollAlgorithm());
}
}
}
@@ -39,7 +39,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning
{
c.Add(hitExplosionPools[poolIndex].Get(e =>
{
e.Apply(new JudgementResult(new HitObject(), runCount % 6 == 0 ? new HoldNoteTickJudgement() : new ManiaJudgement()));
e.Apply(new JudgementResult(new HitObject(), new ManiaJudgement()));
e.Anchor = Anchor.Centre;
e.Origin = Anchor.Centre;
@@ -54,7 +54,6 @@ namespace osu.Game.Rulesets.Mania.Tests
});
assertHeadJudgement(HitResult.Miss);
assertTickJudgement(HitResult.LargeTickMiss);
assertTailJudgement(HitResult.Miss);
assertNoteJudgement(HitResult.IgnoreMiss);
}
@@ -73,7 +72,6 @@ namespace osu.Game.Rulesets.Mania.Tests
});
assertHeadJudgement(HitResult.Perfect);
assertTickJudgement(HitResult.LargeTickHit);
assertTailJudgement(HitResult.Perfect);
assertNoteJudgement(HitResult.IgnoreHit);
}
@@ -92,7 +90,6 @@ namespace osu.Game.Rulesets.Mania.Tests
});
assertHeadJudgement(HitResult.Perfect);
assertTickJudgement(HitResult.LargeTickHit);
assertTailJudgement(HitResult.Miss);
assertNoteJudgement(HitResult.IgnoreMiss);
}
@@ -111,7 +108,6 @@ namespace osu.Game.Rulesets.Mania.Tests
});
assertHeadJudgement(HitResult.Miss);
assertTickJudgement(HitResult.LargeTickMiss);
assertTailJudgement(HitResult.Miss);
}
@@ -129,7 +125,6 @@ namespace osu.Game.Rulesets.Mania.Tests
});
assertHeadJudgement(HitResult.Miss);
assertTickJudgement(HitResult.LargeTickMiss);
assertTailJudgement(HitResult.Miss);
}
@@ -149,7 +144,6 @@ namespace osu.Game.Rulesets.Mania.Tests
});
assertHeadJudgement(HitResult.Perfect);
assertTickJudgement(HitResult.LargeTickHit);
assertTailJudgement(HitResult.Miss);
}
@@ -169,7 +163,6 @@ namespace osu.Game.Rulesets.Mania.Tests
});
assertHeadJudgement(HitResult.Perfect);
assertTickJudgement(HitResult.LargeTickHit);
assertTailJudgement(HitResult.Perfect);
}
@@ -188,10 +181,33 @@ namespace osu.Game.Rulesets.Mania.Tests
});
assertHeadJudgement(HitResult.Perfect);
assertTickJudgement(HitResult.LargeTickMiss);
assertTailJudgement(HitResult.Miss);
}
/// <summary>
/// -----[ ]-----
/// xox o
/// </summary>
[Test]
public void TestPressAtStartThenReleaseAndImmediatelyRepress()
{
performTest(new List<ReplayFrame>
{
new ManiaReplayFrame(time_head, ManiaAction.Key1),
new ManiaReplayFrame(time_head + 1),
new ManiaReplayFrame(time_head + 2, ManiaAction.Key1),
new ManiaReplayFrame(time_tail),
});
assertHeadJudgement(HitResult.Perfect);
// judgement combo offset by perfect bonus judgement. see logic in DrawableNote.CheckForResult.
assertComboAtJudgement(1, 1);
assertTailJudgement(HitResult.Meh);
assertComboAtJudgement(2, 0);
// judgement combo offset by perfect bonus judgement. see logic in DrawableNote.CheckForResult.
assertComboAtJudgement(4, 1);
}
/// <summary>
/// -----[ ]-----
/// xo x o
@@ -208,7 +224,6 @@ namespace osu.Game.Rulesets.Mania.Tests
});
assertHeadJudgement(HitResult.Perfect);
assertTickJudgement(HitResult.LargeTickHit);
assertTailJudgement(HitResult.Miss);
}
@@ -228,7 +243,6 @@ namespace osu.Game.Rulesets.Mania.Tests
});
assertHeadJudgement(HitResult.Perfect);
assertTickJudgement(HitResult.LargeTickHit);
assertTailJudgement(HitResult.Meh);
}
@@ -246,7 +260,6 @@ namespace osu.Game.Rulesets.Mania.Tests
});
assertHeadJudgement(HitResult.Miss);
assertTickJudgement(HitResult.LargeTickHit);
assertTailJudgement(HitResult.Miss);
}
@@ -264,7 +277,6 @@ namespace osu.Game.Rulesets.Mania.Tests
});
assertHeadJudgement(HitResult.Miss);
assertTickJudgement(HitResult.LargeTickHit);
assertTailJudgement(HitResult.Meh);
}
@@ -358,7 +370,6 @@ namespace osu.Game.Rulesets.Mania.Tests
}, beatmap);
assertHeadJudgement(HitResult.Miss);
assertTickJudgement(HitResult.LargeTickMiss);
assertTailJudgement(HitResult.Miss);
assertHitObjectJudgement(note, HitResult.Good);
@@ -371,7 +382,8 @@ namespace osu.Game.Rulesets.Mania.Tests
[Test]
public void TestPressAndReleaseJustAfterTailWithNearbyNote()
{
Note note;
// Next note within tail lenience
Note note = new Note { StartTime = time_tail + 50 };
var beatmap = new Beatmap<ManiaHitObject>
{
@@ -383,13 +395,7 @@ namespace osu.Game.Rulesets.Mania.Tests
Duration = time_tail - time_head,
Column = 0,
},
{
// Next note within tail lenience
note = new Note
{
StartTime = time_tail + 50
}
}
note
},
BeatmapInfo =
{
@@ -405,7 +411,6 @@ namespace osu.Game.Rulesets.Mania.Tests
}, beatmap);
assertHeadJudgement(HitResult.Miss);
assertTickJudgement(HitResult.LargeTickMiss);
assertTailJudgement(HitResult.Miss);
assertHitObjectJudgement(note, HitResult.Great);
@@ -425,7 +430,6 @@ namespace osu.Game.Rulesets.Mania.Tests
});
assertHeadJudgement(HitResult.Miss);
assertTickJudgement(HitResult.LargeTickMiss);
assertTailJudgement(HitResult.Meh);
}
@@ -476,42 +480,6 @@ namespace osu.Game.Rulesets.Mania.Tests
.All(j => j.Type.IsHit()));
}
[Test]
public void TestHitTailBeforeLastTick()
{
const int tick_rate = 8;
const double tick_spacing = TimingControlPoint.DEFAULT_BEAT_LENGTH / tick_rate;
const double time_last_tick = time_head + tick_spacing * (int)((time_tail - time_head) / tick_spacing - 1);
var beatmap = new Beatmap<ManiaHitObject>
{
HitObjects =
{
new HoldNote
{
StartTime = time_head,
Duration = time_tail - time_head,
Column = 0,
}
},
BeatmapInfo =
{
Difficulty = new BeatmapDifficulty { SliderTickRate = tick_rate },
Ruleset = new ManiaRuleset().RulesetInfo
},
};
performTest(new List<ReplayFrame>
{
new ManiaReplayFrame(time_head, ManiaAction.Key1),
new ManiaReplayFrame(time_last_tick - 5)
}, beatmap);
assertHeadJudgement(HitResult.Perfect);
assertLastTickJudgement(HitResult.LargeTickMiss);
assertTailJudgement(HitResult.Ok);
}
[Test]
public void TestZeroLength()
{
@@ -551,11 +519,8 @@ namespace osu.Game.Rulesets.Mania.Tests
private void assertNoteJudgement(HitResult result)
=> AddAssert($"hold note judged as {result}", () => judgementResults.Single(j => j.HitObject is HoldNote).Type, () => Is.EqualTo(result));
private void assertTickJudgement(HitResult result)
=> AddAssert($"any tick judged as {result}", () => judgementResults.Where(j => j.HitObject is HoldNoteTick).Select(j => j.Type), () => Does.Contain(result));
private void assertLastTickJudgement(HitResult result)
=> AddAssert($"last tick judged as {result}", () => judgementResults.Last(j => j.HitObject is HoldNoteTick).Type, () => Is.EqualTo(result));
private void assertComboAtJudgement(int judgementIndex, int combo)
=> AddAssert($"combo at judgement {judgementIndex} is {combo}", () => judgementResults.ElementAt(judgementIndex).ComboAfterJudgement, () => Is.EqualTo(combo));
private ScoreAccessibleReplayPlayer currentPlayer = null!;
@@ -54,7 +54,7 @@ namespace osu.Game.Rulesets.Mania.Tests
AddAssert("all objects perfectly judged",
() => judgementResults.Select(result => result.Type),
() => Is.EquivalentTo(judgementResults.Select(result => result.Judgement.MaxResult)));
AddAssert("score is 1 million", () => currentPlayer.ScoreProcessor.TotalScore.Value, () => Is.EqualTo(1_000_000));
AddAssert("score is correct", () => currentPlayer.ScoreProcessor.TotalScore.Value, () => Is.EqualTo(1_000_030));
}
[Test]
@@ -87,7 +87,7 @@ namespace osu.Game.Rulesets.Mania.Tests
AddAssert("all objects perfectly judged",
() => judgementResults.Select(result => result.Type),
() => Is.EquivalentTo(judgementResults.Select(result => result.Judgement.MaxResult)));
AddAssert("score is 1 million", () => currentPlayer.ScoreProcessor.TotalScore.Value, () => Is.EqualTo(1_000_000));
AddAssert("score is correct", () => currentPlayer.ScoreProcessor.TotalScore.Value, () => Is.EqualTo(1_000_040));
}
private void performTest(List<ManiaHitObject> hitObjects, List<ReplayFrame> frames)
@@ -0,0 +1,196 @@
// 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 System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.Judgements;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Scoring;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Scoring.Legacy;
using osu.Game.Tests.Visual.Gameplay;
namespace osu.Game.Rulesets.Mania.Tests
{
[TestFixture]
public partial class TestSceneScoring : ScoringTestScene
{
protected override IBeatmap CreateBeatmap(int maxCombo)
{
var beatmap = new ManiaBeatmap(new StageDefinition(5));
for (int i = 0; i < maxCombo; ++i)
beatmap.HitObjects.Add(new Note());
return beatmap;
}
protected override IScoringAlgorithm CreateScoreV1(IReadOnlyList<Mod> selectedMods) => new ScoreV1(MaxCombo.Value, selectedMods);
protected override IScoringAlgorithm CreateScoreV2(int maxCombo, IReadOnlyList<Mod> selectedMods) => new ScoreV2(maxCombo, selectedMods);
protected override ProcessorBasedScoringAlgorithm CreateScoreAlgorithm(IBeatmap beatmap, ScoringMode mode, IReadOnlyList<Mod> selectedMods)
=> new ManiaProcessorBasedScoringAlgorithm(beatmap, mode, selectedMods);
[Test]
public void TestBasicScenarios()
{
AddStep("set max combo to 100", () => MaxCombo.Value = 100);
AddStep("set perfect score", () =>
{
NonPerfectLocations.Clear();
MissLocations.Clear();
});
AddStep("set score with misses", () =>
{
NonPerfectLocations.Clear();
MissLocations.Clear();
MissLocations.AddRange(new[] { 24d, 49 });
});
AddStep("set score with misses and OKs", () =>
{
NonPerfectLocations.Clear();
MissLocations.Clear();
NonPerfectLocations.AddRange(new[] { 9d, 19, 29, 39, 59, 69, 79, 89, 99 });
MissLocations.AddRange(new[] { 24d, 49 });
});
}
private class ScoreV1 : IScoringAlgorithm
{
private int currentCombo;
private double comboAddition = 100;
private double totalScoreDouble;
private readonly double scoreMultiplier;
public ScoreV1(int maxCombo, IReadOnlyList<Mod> selectedMods)
{
var ruleset = new ManiaRuleset();
scoreMultiplier = 500000d / maxCombo * ruleset.CreateLegacyScoreSimulator().GetLegacyScoreMultiplier(selectedMods, new LegacyBeatmapConversionDifficultyInfo
{
SourceRuleset = ruleset.RulesetInfo
});
}
public void ApplyHit() => applyHitV1(320, add => add + 2, 32);
public void ApplyNonPerfect() => applyHitV1(100, add => add - 24, 8);
public void ApplyMiss() => applyHitV1(0, _ => -56, 0);
private void applyHitV1(int scoreIncrease, Func<double, double> comboAdditionFunc, int delta)
{
comboAddition = comboAdditionFunc(comboAddition);
if (currentCombo != 0 && currentCombo % 384 == 0)
comboAddition = 100;
comboAddition = Math.Max(0, Math.Min(comboAddition, 100));
double scoreIncreaseD = Math.Sqrt(comboAddition) * delta * scoreMultiplier / 320;
TotalScore = (long)totalScoreDouble;
scoreIncreaseD += scoreIncrease * scoreMultiplier / 320;
scoreIncrease = (int)scoreIncreaseD;
TotalScore += scoreIncrease;
totalScoreDouble += scoreIncreaseD;
if (scoreIncrease > 0)
currentCombo++;
}
public long TotalScore { get; private set; }
}
private class ScoreV2 : IScoringAlgorithm
{
private int currentCombo;
private double comboPortion;
private double currentBaseScore;
private double maxBaseScore;
private int currentHits;
private readonly double comboPortionMax;
private readonly int maxCombo;
private readonly double modMultiplier;
private const double combo_base = 4;
public ScoreV2(int maxCombo, IReadOnlyList<Mod> selectedMods)
{
this.maxCombo = maxCombo;
var ruleset = new ManiaRuleset();
modMultiplier = new ManiaRuleset().CreateLegacyScoreSimulator().GetLegacyScoreMultiplier(
selectedMods.Append(new ModScoreV2()).ToArray(),
new LegacyBeatmapConversionDifficultyInfo
{
SourceRuleset = ruleset.RulesetInfo
});
for (int i = 0; i < this.maxCombo; i++)
ApplyHit();
comboPortionMax = comboPortion;
currentCombo = 0;
comboPortion = 0;
currentBaseScore = 0;
maxBaseScore = 0;
currentHits = 0;
}
public void ApplyHit() => applyHitV2(305, 300);
public void ApplyNonPerfect() => applyHitV2(100, 100);
private void applyHitV2(int hitValue, int baseHitValue)
{
maxBaseScore += 305;
currentBaseScore += hitValue;
comboPortion += baseHitValue * Math.Min(Math.Max(0.5, Math.Log(++currentCombo, combo_base)), Math.Log(400, combo_base));
currentHits++;
}
public void ApplyMiss()
{
currentHits++;
maxBaseScore += 305;
currentCombo = 0;
}
public long TotalScore
{
get
{
float accuracy = (float)(currentBaseScore / maxBaseScore);
return (int)Math.Round
((
200000 * comboPortion / comboPortionMax +
800000 * Math.Pow(accuracy, 2 + 2 * accuracy) * ((double)currentHits / maxCombo)
) * modMultiplier);
}
}
}
private class ManiaProcessorBasedScoringAlgorithm : ProcessorBasedScoringAlgorithm
{
public ManiaProcessorBasedScoringAlgorithm(IBeatmap beatmap, ScoringMode mode, IReadOnlyList<Mod> selectedMods)
: base(beatmap, mode, selectedMods)
{
}
protected override ScoreProcessor CreateScoreProcessor() => new ManiaScoreProcessor();
protected override JudgementResult CreatePerfectJudgementResult() => new JudgementResult(new Note(), new ManiaJudgement()) { Type = HitResult.Perfect };
protected override JudgementResult CreateNonPerfectJudgementResult() => new JudgementResult(new Note(), new ManiaJudgement()) { Type = HitResult.Ok };
protected override JudgementResult CreateMissJudgementResult() => new JudgementResult(new Note(), new ManiaJudgement()) { Type = HitResult.Miss };
}
}
}
@@ -1,9 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\osu.TestProject.props" />
<ItemGroup Label="Package References">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.3.1" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
</ItemGroup>
<PropertyGroup Label="Project">
<OutputType>WinExe</OutputType>
@@ -14,6 +14,7 @@ using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Mania.Beatmaps.Patterns;
using osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy;
using osu.Game.Rulesets.Scoring.Legacy;
using osu.Game.Utils;
using osuTK;
@@ -43,39 +44,41 @@ namespace osu.Game.Rulesets.Mania.Beatmaps
: base(beatmap, ruleset)
{
IsForCurrentRuleset = beatmap.BeatmapInfo.Ruleset.Equals(ruleset.RulesetInfo);
TargetColumns = GetColumnCount(LegacyBeatmapConversionDifficultyInfo.FromBeatmap(beatmap));
double roundedCircleSize = Math.Round(beatmap.Difficulty.CircleSize);
double roundedOverallDifficulty = Math.Round(beatmap.Difficulty.OverallDifficulty);
if (IsForCurrentRuleset)
if (IsForCurrentRuleset && TargetColumns > ManiaRuleset.MAX_STAGE_KEYS)
{
TargetColumns = GetColumnCountForNonConvert(beatmap.BeatmapInfo);
if (TargetColumns > ManiaRuleset.MAX_STAGE_KEYS)
{
TargetColumns /= 2;
Dual = true;
}
}
else
{
float percentSliderOrSpinner = (float)beatmap.HitObjects.Count(h => h is IHasDuration) / beatmap.HitObjects.Count;
if (percentSliderOrSpinner < 0.2)
TargetColumns = 7;
else if (percentSliderOrSpinner < 0.3 || roundedCircleSize >= 5)
TargetColumns = roundedOverallDifficulty > 5 ? 7 : 6;
else if (percentSliderOrSpinner > 0.6)
TargetColumns = roundedOverallDifficulty > 4 ? 5 : 4;
else
TargetColumns = Math.Max(4, Math.Min((int)roundedOverallDifficulty + 1, 7));
TargetColumns /= 2;
Dual = true;
}
originalTargetColumns = TargetColumns;
}
public static int GetColumnCountForNonConvert(BeatmapInfo beatmapInfo)
public static int GetColumnCount(LegacyBeatmapConversionDifficultyInfo difficulty)
{
double roundedCircleSize = Math.Round(beatmapInfo.Difficulty.CircleSize);
if (new ManiaRuleset().RulesetInfo.Equals(difficulty.SourceRuleset))
return GetColumnCountForNonConvert(difficulty);
double roundedCircleSize = Math.Round(difficulty.CircleSize);
double roundedOverallDifficulty = Math.Round(difficulty.OverallDifficulty);
int countSliderOrSpinner = difficulty.TotalObjectCount - difficulty.CircleCount;
float percentSpecialObjects = (float)countSliderOrSpinner / difficulty.TotalObjectCount;
if (percentSpecialObjects < 0.2)
return 7;
if (percentSpecialObjects < 0.3 || roundedCircleSize >= 5)
return roundedOverallDifficulty > 5 ? 7 : 6;
if (percentSpecialObjects > 0.6)
return roundedOverallDifficulty > 4 ? 5 : 4;
return Math.Max(4, Math.Min((int)roundedOverallDifficulty + 1, 7));
}
public static int GetColumnCountForNonConvert(IBeatmapDifficultyInfo difficulty)
{
double roundedCircleSize = Math.Round(difficulty.CircleSize);
return (int)Math.Max(1, roundedCircleSize);
}
@@ -10,10 +10,11 @@ using System.Linq;
using osu.Framework.Extensions.EnumExtensions;
using osu.Game.Audio;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Utils;
namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy
@@ -50,10 +51,9 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy
TimingControlPoint timingPoint = beatmap.ControlPointInfo.TimingPointAt(hitObject.StartTime);
double beatLength;
if (hitObject.LegacyBpmMultiplier.HasValue)
beatLength = timingPoint.BeatLength * hitObject.LegacyBpmMultiplier.Value;
else if (hitObject is IHasSliderVelocity hasSliderVelocity)
beatLength = timingPoint.BeatLength / hasSliderVelocity.SliderVelocity;
if (hitObject is IHasSliderVelocity hasSliderVelocity)
beatLength = LegacyRulesetExtensions.GetPrecisionAdjustedBeatLength(hasSliderVelocity, timingPoint, ManiaRuleset.SHORT_NAME);
else
beatLength = timingPoint.BeatLength;
@@ -31,13 +31,9 @@ namespace osu.Game.Rulesets.Mania.Difficulty
public override int Version => 20220902;
private readonly IWorkingBeatmap workingBeatmap;
public ManiaDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap)
{
workingBeatmap = beatmap;
isForCurrentRuleset = beatmap.BeatmapInfo.Ruleset.MatchesOnlineID(ruleset);
originalOverallDifficulty = beatmap.BeatmapInfo.Difficulty.OverallDifficulty;
}
@@ -60,15 +56,6 @@ namespace osu.Game.Rulesets.Mania.Difficulty
MaxCombo = beatmap.HitObjects.Sum(maxComboForObject),
};
if (ComputeLegacyScoringValues)
{
ManiaLegacyScoreSimulator sv1Simulator = new ManiaLegacyScoreSimulator();
sv1Simulator.Simulate(workingBeatmap, beatmap, mods);
attributes.LegacyAccuracyScore = sv1Simulator.AccuracyScore;
attributes.LegacyComboScore = sv1Simulator.ComboScore;
attributes.LegacyBonusScoreRatio = sv1Simulator.BonusScoreRatio;
}
return attributes;
}
@@ -4,25 +4,63 @@
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.Mods;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Scoring.Legacy;
namespace osu.Game.Rulesets.Mania.Difficulty
{
internal class ManiaLegacyScoreSimulator : ILegacyScoreSimulator
{
public int AccuracyScore => 0;
public int ComboScore { get; private set; }
public double BonusScoreRatio => 0;
public void Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap, IReadOnlyList<Mod> mods)
public LegacyScoreAttributes Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap)
{
double multiplier = mods.Where(m => m is not (ModHidden or ModHardRock or ModDoubleTime or ModFlashlight or ManiaModFadeIn))
.Select(m => m.ScoreMultiplier)
.Aggregate(1.0, (c, n) => c * n);
return new LegacyScoreAttributes { ComboScore = 1000000 };
}
ComboScore = (int)(1000000 * multiplier);
public double GetLegacyScoreMultiplier(IReadOnlyList<Mod> mods, LegacyBeatmapConversionDifficultyInfo difficulty)
{
bool scoreV2 = mods.Any(m => m is ModScoreV2);
double multiplier = 1.0;
foreach (var mod in mods)
{
switch (mod)
{
case ManiaModNoFail:
multiplier *= scoreV2 ? 1.0 : 0.5;
break;
case ManiaModEasy:
multiplier *= 0.5;
break;
case ManiaModHalfTime:
case ManiaModDaycore:
multiplier *= 0.5;
break;
}
}
if (new ManiaRuleset().RulesetInfo.Equals(difficulty.SourceRuleset))
return multiplier;
// Apply key mod multipliers.
int originalColumns = ManiaBeatmapConverter.GetColumnCount(difficulty);
int actualColumns = originalColumns;
actualColumns = mods.OfType<ManiaKeyMod>().SingleOrDefault()?.KeyCount ?? actualColumns;
if (mods.Any(m => m is ManiaModDualStages))
actualColumns *= 2;
if (actualColumns > originalColumns)
multiplier *= 0.9;
else if (actualColumns < originalColumns)
multiplier *= 0.9 - 0.04 * (originalColumns - actualColumns);
return multiplier;
}
}
}
@@ -44,8 +44,8 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
if (Column != null)
{
headPiece.Y = Parent.ToLocalSpace(Column.ScreenSpacePositionAtTime(HitObject.StartTime)).Y;
tailPiece.Y = Parent.ToLocalSpace(Column.ScreenSpacePositionAtTime(HitObject.EndTime)).Y;
headPiece.Y = Parent!.ToLocalSpace(Column.ScreenSpacePositionAtTime(HitObject.StartTime)).Y;
tailPiece.Y = Parent!.ToLocalSpace(Column.ScreenSpacePositionAtTime(HitObject.EndTime)).Y;
switch (scrollingInfo.Direction.Value)
{
@@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
foreach (var child in InternalChildren)
child.Anchor = child.Origin = anchor;
Position = Parent.ToLocalSpace(HitObjectContainer.ScreenSpacePositionAtTime(HitObject.StartTime)) - AnchorPosition;
Position = Parent!.ToLocalSpace(HitObjectContainer.ScreenSpacePositionAtTime(HitObject.StartTime)) - AnchorPosition;
Width = HitObjectContainer.DrawWidth;
}
}
@@ -2,18 +2,22 @@
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osuTK;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Rulesets.Mania.UI;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.UI;
using osu.Game.Rulesets.UI.Scrolling;
using osuTK;
namespace osu.Game.Rulesets.Mania.Edit
{
public partial class DrawableManiaEditorRuleset : DrawableManiaRuleset
public partial class DrawableManiaEditorRuleset : DrawableManiaRuleset, ISupportConstantAlgorithmToggle
{
public BindableBool ShowSpeedChanges { get; } = new BindableBool();
public new IScrollingInfo ScrollingInfo => base.ScrollingInfo;
public DrawableManiaEditorRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod>? mods)
@@ -21,6 +25,13 @@ namespace osu.Game.Rulesets.Mania.Edit
{
}
protected override void LoadComplete()
{
base.LoadComplete();
ShowSpeedChanges.BindValueChanged(showChanges => VisualisationMethod = showChanges.NewValue ? ScrollVisualisationMethod.Sequential : ScrollVisualisationMethod.Constant, true);
}
protected override Playfield CreatePlayfield() => new ManiaEditorPlayfield(Beatmap.Stages)
{
Anchor = Anchor.Centre,
@@ -1,204 +1,23 @@
// 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 System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Caching;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Mania.UI;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Screens.Edit;
using osuTK.Graphics;
using osu.Game.Screens.Edit.Compose.Components;
namespace osu.Game.Rulesets.Mania.Edit
{
/// <summary>
/// A grid which displays coloured beat divisor lines in proximity to the selection or placement cursor.
/// </summary>
public partial class ManiaBeatSnapGrid : Component
public partial class ManiaBeatSnapGrid : BeatSnapGrid
{
private const double visible_range = 750;
/// <summary>
/// The range of time values of the current selection.
/// </summary>
public (double start, double end)? SelectionTimeRange
protected override IEnumerable<Container> GetTargetContainers(HitObjectComposer composer)
{
set
{
if (value == selectionTimeRange)
return;
selectionTimeRange = value;
lineCache.Invalidate();
}
}
[Resolved]
private EditorBeatmap beatmap { get; set; } = null!;
[Resolved]
private OsuColour colours { get; set; } = null!;
[Resolved]
private BindableBeatDivisor beatDivisor { get; set; } = null!;
private readonly List<ScrollingHitObjectContainer> grids = new List<ScrollingHitObjectContainer>();
private readonly Cached lineCache = new Cached();
private (double start, double end)? selectionTimeRange;
[BackgroundDependencyLoader]
private void load(HitObjectComposer composer)
{
foreach (var stage in ((ManiaPlayfield)composer.Playfield).Stages)
{
foreach (var column in stage.Columns)
{
var lineContainer = new ScrollingHitObjectContainer();
grids.Add(lineContainer);
column.UnderlayElements.Add(lineContainer);
}
}
beatDivisor.BindValueChanged(_ => createLines(), true);
}
protected override void Update()
{
base.Update();
if (!lineCache.IsValid)
{
lineCache.Validate();
createLines();
}
}
private readonly Stack<DrawableGridLine> availableLines = new Stack<DrawableGridLine>();
private void createLines()
{
foreach (var grid in grids)
{
foreach (var line in grid.Objects.OfType<DrawableGridLine>())
availableLines.Push(line);
grid.Clear();
}
if (selectionTimeRange == null)
return;
var range = selectionTimeRange.Value;
var timingPoint = beatmap.ControlPointInfo.TimingPointAt(range.start - visible_range);
double time = timingPoint.Time;
int beat = 0;
// progress time until in the visible range.
while (time < range.start - visible_range)
{
time += timingPoint.BeatLength / beatDivisor.Value;
beat++;
}
while (time < range.end + visible_range)
{
var nextTimingPoint = beatmap.ControlPointInfo.TimingPointAt(time);
// switch to the next timing point if we have reached it.
if (nextTimingPoint.Time > timingPoint.Time)
{
beat = 0;
time = nextTimingPoint.Time;
timingPoint = nextTimingPoint;
}
Color4 colour = BindableBeatDivisor.GetColourFor(
BindableBeatDivisor.GetDivisorForBeatIndex(beat, beatDivisor.Value), colours);
foreach (var grid in grids)
{
if (!availableLines.TryPop(out var line))
line = new DrawableGridLine();
line.HitObject.StartTime = time;
line.Colour = colour;
grid.Add(line);
}
beat++;
time += timingPoint.BeatLength / beatDivisor.Value;
}
foreach (var grid in grids)
{
// required to update ScrollingHitObjectContainer's cache.
grid.UpdateSubTree();
foreach (var line in grid.Objects.OfType<DrawableGridLine>())
{
time = line.HitObject.StartTime;
if (time >= range.start && time <= range.end)
line.Alpha = 1;
else
{
double timeSeparation = time < range.start ? range.start - time : time - range.end;
line.Alpha = (float)Math.Max(0, 1 - timeSeparation / visible_range);
}
}
}
}
private partial class DrawableGridLine : DrawableHitObject
{
[Resolved]
private IScrollingInfo scrollingInfo { get; set; } = null!;
private readonly IBindable<ScrollingDirection> direction = new Bindable<ScrollingDirection>();
public DrawableGridLine()
: base(new HitObject())
{
RelativeSizeAxes = Axes.X;
Height = 2;
AddInternal(new Box { RelativeSizeAxes = Axes.Both });
}
[BackgroundDependencyLoader]
private void load()
{
direction.BindTo(scrollingInfo.Direction);
direction.BindValueChanged(onDirectionChanged, true);
}
private void onDirectionChanged(ValueChangedEvent<ScrollingDirection> direction)
{
Origin = Anchor = direction.NewValue == ScrollingDirection.Up
? Anchor.TopLeft
: Anchor.BottomLeft;
}
protected override void UpdateInitialTransforms()
{
// don't perform any fading we are handling that ourselves.
LifetimeEnd = HitObject.StartTime + visible_range;
}
return ((ManiaPlayfield)composer.Playfield)
.Stages
.SelectMany(stage => stage.Columns)
.Select(column => column.UnderlayElements);
}
}
}
@@ -5,15 +5,13 @@
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Input;
using System.Text.RegularExpressions;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Tools;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.UI;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.UI;
using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Screens.Edit.Compose.Components;
@@ -21,35 +19,15 @@ using osuTK;
namespace osu.Game.Rulesets.Mania.Edit
{
public partial class ManiaHitObjectComposer : HitObjectComposer<ManiaHitObject>
public partial class ManiaHitObjectComposer : ScrollingHitObjectComposer<ManiaHitObject>
{
private DrawableManiaEditorRuleset drawableRuleset;
private ManiaBeatSnapGrid beatSnapGrid;
private InputManager inputManager;
public ManiaHitObjectComposer(Ruleset ruleset)
: base(ruleset)
{
}
[BackgroundDependencyLoader]
private void load()
{
AddInternal(beatSnapGrid = new ManiaBeatSnapGrid());
}
protected override void LoadComplete()
{
base.LoadComplete();
inputManager = GetContainingInputManager();
}
private DependencyContainer dependencies;
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent)
=> dependencies = new DependencyContainer(base.CreateChildDependencies(parent));
public new ManiaPlayfield Playfield => ((ManiaPlayfield)drawableRuleset.Playfield);
public IScrollingInfo ScrollingInfo => drawableRuleset.ScrollingInfo;
@@ -57,49 +35,53 @@ namespace osu.Game.Rulesets.Mania.Edit
protected override Playfield PlayfieldAtScreenSpacePosition(Vector2 screenSpacePosition) =>
Playfield.GetColumnByPosition(screenSpacePosition);
protected override DrawableRuleset<ManiaHitObject> CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod> mods = null)
{
protected override DrawableRuleset<ManiaHitObject> CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod> mods) =>
drawableRuleset = new DrawableManiaEditorRuleset(ruleset, beatmap, mods);
// This is the earliest we can cache the scrolling info to ourselves, before masks are added to the hierarchy and inject it
dependencies.CacheAs(drawableRuleset.ScrollingInfo);
return drawableRuleset;
}
protected override ComposeBlueprintContainer CreateBlueprintContainer()
=> new ManiaBlueprintContainer(this);
protected override BeatSnapGrid CreateBeatSnapGrid() => new ManiaBeatSnapGrid();
protected override IReadOnlyList<HitObjectCompositionTool> CompositionTools => new HitObjectCompositionTool[]
{
new NoteCompositionTool(),
new HoldNoteCompositionTool()
};
protected override void UpdateAfterChildren()
{
base.UpdateAfterChildren();
if (BlueprintContainer.CurrentTool is SelectTool)
{
if (EditorBeatmap.SelectedHitObjects.Any())
{
beatSnapGrid.SelectionTimeRange = (EditorBeatmap.SelectedHitObjects.Min(h => h.StartTime), EditorBeatmap.SelectedHitObjects.Max(h => h.GetEndTime()));
}
else
beatSnapGrid.SelectionTimeRange = null;
}
else
{
var result = FindSnappedPositionAndTime(inputManager.CurrentState.Mouse.Position);
if (result.Time is double time)
beatSnapGrid.SelectionTimeRange = (time, time);
else
beatSnapGrid.SelectionTimeRange = null;
}
}
public override string ConvertSelectionToString()
=> string.Join(',', EditorBeatmap.SelectedHitObjects.Cast<ManiaHitObject>().OrderBy(h => h.StartTime).Select(h => $"{h.StartTime}|{h.Column}"));
// 123|0,456|1,789|2 ...
private static readonly Regex selection_regex = new Regex(@"^\d+\|\d+(,\d+\|\d+)*$", RegexOptions.Compiled);
public override void SelectFromTimestamp(double timestamp, string objectDescription)
{
if (!selection_regex.IsMatch(objectDescription))
return;
List<ManiaHitObject> remainingHitObjects = EditorBeatmap.HitObjects.Cast<ManiaHitObject>().Where(h => h.StartTime >= timestamp).ToList();
string[] objectDescriptions = objectDescription.Split(',').ToArray();
for (int i = 0; i < objectDescriptions.Length; i++)
{
string[] split = objectDescriptions[i].Split('|').ToArray();
if (split.Length != 2)
continue;
if (!double.TryParse(split[0], out double time) || !int.TryParse(split[1], out int column))
continue;
ManiaHitObject current = remainingHitObjects.FirstOrDefault(h => h.StartTime == time && h.Column == column);
if (current == null)
continue;
EditorBeatmap.SelectedHitObjects.Add(current);
if (i < objectDescriptions.Length - 1)
remainingHitObjects = remainingHitObjects.Where(h => h != current && h.StartTime >= current.StartTime).ToList();
}
}
}
}
@@ -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.
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.Judgements
{
public class HoldNoteBodyJudgement : ManiaJudgement
{
public override HitResult MaxResult => HitResult.IgnoreHit;
public override HitResult MinResult => HitResult.ComboBreak;
}
}
@@ -1,12 +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.Scoring;
namespace osu.Game.Rulesets.Mania.Judgements
{
public class HoldNoteTickJudgement : ManiaJudgement
{
public override HitResult MaxResult => HitResult.LargeTickHit;
}
}
@@ -12,12 +12,6 @@ namespace osu.Game.Rulesets.Mania.Judgements
{
switch (result)
{
case HitResult.LargeTickHit:
return DEFAULT_MAX_HEALTH_INCREASE * 0.1;
case HitResult.LargeTickMiss:
return -DEFAULT_MAX_HEALTH_INCREASE * 0.1;
case HitResult.Meh:
return -DEFAULT_MAX_HEALTH_INCREASE * 0.5;
@@ -15,7 +15,7 @@ namespace osu.Game.Rulesets.Mania
public bool Matches(BeatmapInfo beatmapInfo)
{
return !keys.HasFilter || (beatmapInfo.Ruleset.OnlineID == new ManiaRuleset().LegacyID && keys.IsInRange(ManiaBeatmapConverter.GetColumnCountForNonConvert(beatmapInfo)));
return !keys.HasFilter || (beatmapInfo.Ruleset.OnlineID == new ManiaRuleset().LegacyID && keys.IsInRange(ManiaBeatmapConverter.GetColumnCountForNonConvert(beatmapInfo.Difficulty)));
}
public bool TryParseCustomKeywordCriteria(string key, Operator op, string value)
+16 -23
View File
@@ -33,6 +33,7 @@ using osu.Game.Rulesets.Mania.UI;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Replays.Types;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Scoring.Legacy;
using osu.Game.Rulesets.UI;
using osu.Game.Scoring;
using osu.Game.Screens.Edit.Setup;
@@ -254,16 +255,6 @@ namespace osu.Game.Rulesets.Mania
case ModType.Conversion:
return new Mod[]
{
new MultiMod(new ManiaModKey4(),
new ManiaModKey5(),
new ManiaModKey6(),
new ManiaModKey7(),
new ManiaModKey8(),
new ManiaModKey9(),
new ManiaModKey10(),
new ManiaModKey1(),
new ManiaModKey2(),
new ManiaModKey3()),
new ManiaModRandom(),
new ManiaModDualStages(),
new ManiaModMirror(),
@@ -271,7 +262,19 @@ namespace osu.Game.Rulesets.Mania
new ManiaModClassic(),
new ManiaModInvert(),
new ManiaModConstantSpeed(),
new ManiaModHoldOff()
new ManiaModHoldOff(),
new MultiMod(
new ManiaModKey1(),
new ManiaModKey2(),
new ManiaModKey3(),
new ManiaModKey4(),
new ManiaModKey5(),
new ManiaModKey6(),
new ManiaModKey7(),
new ManiaModKey8(),
new ManiaModKey9(),
new ManiaModKey10()
),
};
case ModType.Automation:
@@ -385,21 +388,11 @@ namespace osu.Game.Rulesets.Mania
HitResult.Ok,
HitResult.Meh,
HitResult.LargeTickHit,
// HitResult.SmallBonus is used for awarding perfect bonus score but is not included here as
// it would be a bit redundant to show this to the user.
};
}
public override LocalisableString GetDisplayNameForHitResult(HitResult result)
{
switch (result)
{
case HitResult.LargeTickHit:
return "hold tick";
}
return base.GetDisplayNameForHitResult(result);
}
public override StatisticItem[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => new[]
{
new StatisticItem("Performance Breakdown", () => new PerformanceBreakdownChart(score, playableBeatmap)
@@ -41,6 +41,7 @@ namespace osu.Game.Rulesets.Mania
},
new SettingsCheckbox
{
Keywords = new[] { "color" },
LabelText = RulesetSettingsStrings.TimingBasedColouring,
Current = config.GetBindable<bool>(ManiaRulesetSetting.TimingBasedNoteColouring),
}
@@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Mania.Mods
public void ApplyToDrawableRuleset(DrawableRuleset<ManiaHitObject> drawableRuleset)
{
var maniaRuleset = (DrawableManiaRuleset)drawableRuleset;
maniaRuleset.ScrollMethod = ScrollVisualisationMethod.Constant;
maniaRuleset.VisualisationMethod = ScrollVisualisationMethod.Constant;
}
}
}
@@ -34,7 +34,7 @@ namespace osu.Game.Rulesets.Mania.Mods
foreach (Column column in maniaPlayfield.Stages.SelectMany(stage => stage.Columns))
{
HitObjectContainer hoc = column.HitObjectArea.HitObjectContainer;
Container hocParent = (Container)hoc.Parent;
Container hocParent = (Container)hoc.Parent!;
hocParent.Remove(hoc, false);
hocParent.Add(new PlayfieldCoveringWrapper(hoc).With(c =>
@@ -35,10 +35,11 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
public DrawableHoldNoteHead Head => headContainer.Child;
public DrawableHoldNoteTail Tail => tailContainer.Child;
public DrawableHoldNoteBody Body => bodyContainer.Child;
private Container<DrawableHoldNoteHead> headContainer;
private Container<DrawableHoldNoteTail> tailContainer;
private Container<DrawableHoldNoteTick> tickContainer;
private Container<DrawableHoldNoteBody> bodyContainer;
private PausableSkinnableSound slidingSample;
@@ -60,12 +61,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
public double? HoldStartTime { get; private set; }
/// <summary>
/// Time at which the hold note has been broken, i.e. released too early, resulting in a reduced score.
/// </summary>
public double? HoldBrokenTime { get; private set; }
/// <summary>
/// Whether the hold note has been released potentially without having caused a break.
/// Used to decide whether to visually clamp the hold note to the judgement line.
/// </summary>
private double? releaseTime;
@@ -103,6 +99,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
headContainer = new Container<DrawableHoldNoteHead> { RelativeSizeAxes = Axes.Both }
}
},
bodyContainer = new Container<DrawableHoldNoteBody> { RelativeSizeAxes = Axes.Both },
bodyPiece = new SkinnableDrawable(new ManiaSkinComponentLookup(ManiaSkinComponents.HoldNoteBody), _ => new DefaultBodyPiece
{
RelativeSizeAxes = Axes.Both,
@@ -110,15 +107,17 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
{
RelativeSizeAxes = Axes.X
},
tickContainer = new Container<DrawableHoldNoteTick> { RelativeSizeAxes = Axes.Both },
tailContainer = new Container<DrawableHoldNoteTail> { RelativeSizeAxes = Axes.Both },
slidingSample = new PausableSkinnableSound { Looping = true }
slidingSample = new PausableSkinnableSound
{
Looping = true,
MinimumSampleVolume = MINIMUM_SAMPLE_VOLUME,
}
});
maskedContents.AddRange(new[]
{
bodyPiece.CreateProxy(),
tickContainer.CreateProxy(),
tailContainer.CreateProxy(),
});
}
@@ -136,7 +135,6 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
sizingContainer.Size = Vector2.One;
HoldStartTime = null;
HoldBrokenTime = null;
releaseTime = null;
}
@@ -154,8 +152,8 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
tailContainer.Child = tail;
break;
case DrawableHoldNoteTick tick:
tickContainer.Add(tick);
case DrawableHoldNoteBody body:
bodyContainer.Child = body;
break;
}
}
@@ -165,7 +163,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
base.ClearNestedHitObjects();
headContainer.Clear(false);
tailContainer.Clear(false);
tickContainer.Clear(false);
bodyContainer.Clear(false);
}
protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject)
@@ -178,8 +176,8 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
case HeadNote head:
return new DrawableHoldNoteHead(head);
case HoldNoteTick tick:
return new DrawableHoldNoteTick(tick);
case HoldNoteBody body:
return new DrawableHoldNoteBody(body);
}
return base.CreateNestedHitObject(hitObject);
@@ -266,20 +264,15 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
{
if (Tail.AllJudged)
{
foreach (var tick in tickContainer)
{
if (!tick.Judged)
tick.MissForcefully();
}
if (Tail.IsHit)
ApplyResult(r => r.Type = r.Judgement.MaxResult);
else
MissForcefully();
}
if (Tail.Judged && !Tail.IsHit)
HoldBrokenTime = Time.Current;
// Make sure that the hold note is fully judged by giving the body a judgement.
if (Tail.AllJudged && !Body.AllJudged)
Body.TriggerResult(Tail.IsHit);
}
public override void MissForcefully()
@@ -333,22 +326,22 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
if (e.Action != Action.Value)
return;
// Make sure a hold was started
if (HoldStartTime == null)
return;
// do not run any of this logic when rewinding, as it inverts order of presses/releases.
if ((Clock as IGameplayClock)?.IsRewinding == true)
return;
Tail.UpdateResult();
endHold();
// When our action is released and we are in the middle of a hold, there's a chance that
// the user has released too early (before the tail).
//
// In such a case, we want to record this against the DrawableHoldNoteBody.
if (HoldStartTime != null)
{
Tail.UpdateResult();
Body.TriggerResult(Tail.IsHit);
// If the key has been released too early, the user should not receive full score for the release
if (!Tail.IsHit)
HoldBrokenTime = Time.Current;
releaseTime = Time.Current;
endHold();
releaseTime = Time.Current;
}
}
private void endHold()
@@ -0,0 +1,31 @@
// 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
namespace osu.Game.Rulesets.Mania.Objects.Drawables
{
public partial class DrawableHoldNoteBody : DrawableManiaHitObject<HoldNoteBody>
{
public bool HasHoldBreak => AllJudged && !IsHit;
public override bool DisplayResult => false;
public DrawableHoldNoteBody()
: this(null)
{
}
public DrawableHoldNoteBody(HoldNoteBody hitObject)
: base(hitObject)
{
}
internal void TriggerResult(bool hit)
{
if (AllJudged) return;
ApplyResult(r => r.Type = hit ? r.Judgement.MaxResult : r.Judgement.MinResult);
}
}
}

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