1
0
mirror of https://github.com/ppy/osu.git synced 2025-01-12 17:43:05 +08:00

Merge branch 'master' into pitch_change

This commit is contained in:
Dean Herbert 2023-10-18 16:33:03 +09:00
commit eabfcfbf2a
No known key found for this signature in database
577 changed files with 12337 additions and 3928 deletions

View File

@ -1,206 +1,365 @@
# Listens for new PR comments containing !pp check [id], and runs a diffcalc comparison against master. # ## Description
# Usage:
# !pp check 0 | Runs only the osu! ruleset.
# !pp check 0 2 | Runs only the osu! and catch rulesets.
# #
# 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: on:
issue_comment: issue_comment:
types: [ created ] 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: env:
CONCURRENCY: 4 COMMENT_TAG: execution-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}
ALLOW_DOWNLOAD: 1
SAVE_DOWNLOADED: 1
SKIP_INSERT_ATTRIBUTES: 1
jobs: jobs:
metadata: wait-for-queue:
name: Check for requests name: "Wait for previous workflows"
runs-on: ubuntu-latest
if: ${{ !cancelled() && (github.event_name == 'workflow_dispatch' || contains(github.event.comment.body, '!diffcalc') && github.event.comment.author_association == 'OWNER') }}
timeout-minutes: 50400 # 35 days, the maximum for jobs.
steps:
- uses: ahmadnassri/action-workflow-queue@v1
with:
timeout: 2147483647 # Around 24 days, maximum supported.
delay: 120000 # Poll every 2 minutes. API seems fairly low on this one.
create-comment:
name: Create PR comment
runs-on: ubuntu-latest
if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(github.event.comment.body, '!diffcalc') && github.event.comment.author_association == 'OWNER' }}
steps:
- name: Create comment
uses: thollander/actions-comment-pull-request@v2
with:
comment_tag: ${{ env.COMMENT_TAG }}
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: wait-for-queue
runs-on: self-hosted 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') if: ${{ !cancelled() && (github.event_name == 'workflow_dispatch' || contains(github.event.comment.body, '!diffcalc') && github.event.comment.author_association == 'OWNER') }}
outputs: outputs:
matrix: ${{ steps.generate-matrix.outputs.matrix }} GENERATOR_DIR: ${{ steps.set-outputs.outputs.GENERATOR_DIR }}
continue: ${{ steps.generate-matrix.outputs.continue }} GENERATOR_ENV: ${{ steps.set-outputs.outputs.GENERATOR_ENV }}
GOOGLE_CREDS_FILE: ${{ steps.set-outputs.outputs.GOOGLE_CREDS_FILE }}
steps: steps:
- name: Construct build matrix - name: Checkout
id: generate-matrix uses: actions/checkout@v3
- name: Checkout diffcalc-sheet-generator
uses: actions/checkout@v3
with:
path: 'diffcalc-sheet-generator'
repository: 'smoogipoo/diffcalc-sheet-generator'
- name: Set outputs
id: set-outputs
run: | run: |
if [[ "${{ github.event.comment.body }}" =~ "osu" ]] ; then echo "GENERATOR_DIR=${{ github.workspace }}/diffcalc-sheet-generator" >> "${GITHUB_OUTPUT}"
MATRIX_PROJECTS_JSON+='{ "name": "osu", "id": 0 },' echo "GENERATOR_ENV=${{ github.workspace }}/diffcalc-sheet-generator/.env" >> "${GITHUB_OUTPUT}"
fi echo "GOOGLE_CREDS_FILE=${{ github.workspace }}/diffcalc-sheet-generator/google-credentials.json" >> "${GITHUB_OUTPUT}"
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
if [[ "${MATRIX_PROJECTS_JSON}" != "" ]]; then environment:
MATRIX_JSON="{ \"ruleset\": [ ${MATRIX_PROJECTS_JSON} ] }" name: Setup environment
echo "${MATRIX_JSON}" needs: directory
CONTINUE="yes"
else
CONTINUE="no"
fi
echo "continue=${CONTINUE}" >> $GITHUB_OUTPUT
echo "matrix=${MATRIX_JSON}" >> $GITHUB_OUTPUT
diffcalc:
name: Run
runs-on: self-hosted runs-on: self-hosted
timeout-minutes: 1440 if: ${{ !cancelled() && needs.directory.result == 'success' }}
if: needs.metadata.outputs.continue == 'yes' env:
needs: metadata VARS_JSON: ${{ toJSON(vars) }}
strategy:
matrix: ${{ fromJson(needs.metadata.outputs.matrix) }}
steps: steps:
- name: Verify MySQL connection from host - name: Add base environment
run: | run: |
mysql -e "SHOW DATABASES" # Required by diffcalc-sheet-generator
cp '${{ github.workspace }}/diffcalc-sheet-generator/.env.sample' "${{ needs.directory.outputs.GENERATOR_ENV }}"
- name: Drop previous databases # Add Google credentials
run: | echo '${{ secrets.DIFFCALC_GOOGLE_CREDENTIALS }}' | base64 -d > "${{ needs.directory.outputs.GOOGLE_CREDS_FILE }}"
for db in osu_master osu_pr
do # Add repository variables
mysql -e "DROP DATABASE IF EXISTS $db" 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 done
- name: Create directory structure - name: Add pull-request environment
if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request }}
run: | run: |
mkdir -p $GITHUB_WORKSPACE/master/ sed -i "s;^OSU_B=.*$;OSU_B=${{ github.event.issue.pull_request.url }};" "${{ needs.directory.outputs.GENERATOR_ENV }}"
mkdir -p $GITHUB_WORKSPACE/pr/
- name: Get upstream branch # https://akaimo.hatenablog.jp/entry/2020/05/16/101251 - name: Add comment environment
id: upstreambranch if: ${{ github.event_name == 'issue_comment' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
echo "branchname=$(curl -H "Authorization: token ${GITHUB_TOKEN}" ${{ github.event.issue.pull_request.url }} | jq '.head.ref' | sed 's/\"//g')" >> $GITHUB_OUTPUT # Add comment environment
echo "repo=$(curl -H "Authorization: token ${GITHUB_TOKEN}" ${{ github.event.issue.pull_request.url }} | jq '.head.repo.full_name' | sed 's/\"//g')" >> $GITHUB_OUTPUT echo '${{ github.event.comment.body }}' | sed -r 's/\r$//' | grep -E '^\w+=' | while read -r line; do
opt=$(echo ${line} | cut -d '=' -f1)
# Checkout osu sed -i "s;^${opt}=.*$;${line};" "${{ needs.directory.outputs.GENERATOR_ENV }}"
- 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;'
done done
- name: Run diffcalc (master) - name: Add dispatch environment
env: if: ${{ github.event_name == 'workflow_dispatch' }}
DB_NAME: osu_master
run: | run: |
cd $GITHUB_WORKSPACE/master/osu-difficulty-calculator/osu.Server.DifficultyCalculator sed -i 's;^OSU_B=.*$;OSU_B=${{ inputs.osu-b }};' "${{ needs.directory.outputs.GENERATOR_ENV }}"
dotnet run -c:Release -- all -m ${{ matrix.ruleset.id }} -ac -c ${{ env.CONCURRENCY }} sed -i 's/^RULESET=.*$/RULESET=${{ inputs.ruleset }}/' "${{ needs.directory.outputs.GENERATOR_ENV }}"
- name: Run diffcalc (pr) sed -i 's/^GENERATORS=.*$/GENERATORS=${{ inputs.generators }}/' "${{ needs.directory.outputs.GENERATOR_ENV }}"
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 }}
- name: Print diffs if [[ '${{ inputs.osu-a }}' != 'latest' ]]; then
run: | sed -i 's;^OSU_A=.*$;OSU_A=${{ inputs.osu-a }};' "${{ needs.directory.outputs.GENERATOR_ENV }}"
mysql -e " fi
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;"
# 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
if: ${{ !cancelled() && needs.environment.result == 'success' }}
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@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
if: ${{ !cancelled() && needs.directory.result == 'success' }}
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@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
if: ${{ !cancelled() && needs.scores.result == 'success' && needs.beatmaps.result == 'success' }}
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
- name: Output info
if: ${{ success() }}
run: |
echo "Target: ${{ steps.run.outputs.TARGET }}"
echo "Spreadsheet: ${{ steps.run.outputs.SPREADSHEET_LINK }}"
update-comment:
name: Update PR comment
needs: [ create-comment, generator ]
runs-on: ubuntu-latest
if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(github.event.comment.body, '!diffcalc') && github.event.comment.author_association == 'OWNER' }}
steps:
- name: Update comment on success
if: ${{ needs.generator.result == 'success' }}
uses: thollander/actions-comment-pull-request@v2
with:
comment_tag: ${{ env.COMMENT_TAG }}
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@v2
with:
comment_tag: ${{ env.COMMENT_TAG }}
mode: upsert
create_if_not_exists: false
message: |
Difficulty calculation failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}

View File

@ -12,45 +12,48 @@
A free-to-win rhythm game. Rhythm is just a *click* away! 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 ## 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. A few resources are available as starting points to getting involved and understanding the project:
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:
- Detailed release changelogs are available on the [official osu! site](https://osu.ppy.sh/home/changelog/lazer). - 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). - 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! ## 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) | | [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. 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 ## 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). You can see some examples of custom rulesets by visiting the [custom ruleset directory](https://github.com/ppy/osu/discussions/13096).
## Developing osu! ## Developing osu!
### Prerequisites
Please make sure you have the following prerequisites: Please make sure you have the following prerequisites:
- A desktop platform with the [.NET 6.0 SDK](https://dotnet.microsoft.com/download) installed. - A desktop platform with the [.NET 6.0 SDK](https://dotnet.microsoft.com/download) installed.
When working with the codebase, we recommend using an IDE with intelligent code completion and syntax highlighting, such as the latest version of [Visual Studio](https://visualstudio.microsoft.com/vs/), [JetBrains Rider](https://www.jetbrains.com/rider/) or [Visual Studio Code](https://code.visualstudio.com/). When working with the codebase, we recommend using an IDE with intelligent code completion and syntax highlighting, such as the latest version of [Visual Studio](https://visualstudio.microsoft.com/vs/), [JetBrains Rider](https://www.jetbrains.com/rider/), or [Visual Studio Code](https://code.visualstudio.com/) with the [EditorConfig](https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig) and [C#](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csharp) plugin installed.
### Downloading the source code ### Downloading the source code
@ -69,9 +72,19 @@ git pull
### Building ### 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: 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 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`. 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 ### 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: 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:

View File

@ -9,9 +9,9 @@
<GenerateProgramFile>false</GenerateProgramFile> <GenerateProgramFile>false</GenerateProgramFile>
</PropertyGroup> </PropertyGroup>
<ItemGroup Label="Package References"> <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="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.3.1" /> <PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\osu.Game.Rulesets.EmptyFreeform\osu.Game.Rulesets.EmptyFreeform.csproj" /> <ProjectReference Include="..\osu.Game.Rulesets.EmptyFreeform\osu.Game.Rulesets.EmptyFreeform.csproj" />

View File

@ -9,9 +9,9 @@
<GenerateProgramFile>false</GenerateProgramFile> <GenerateProgramFile>false</GenerateProgramFile>
</PropertyGroup> </PropertyGroup>
<ItemGroup Label="Package References"> <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="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.3.1" /> <PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj" /> <ProjectReference Include="..\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj" />

View File

@ -9,9 +9,9 @@
<GenerateProgramFile>false</GenerateProgramFile> <GenerateProgramFile>false</GenerateProgramFile>
</PropertyGroup> </PropertyGroup>
<ItemGroup Label="Package References"> <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="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.3.1" /> <PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\osu.Game.Rulesets.EmptyScrolling\osu.Game.Rulesets.EmptyScrolling.csproj" /> <ProjectReference Include="..\osu.Game.Rulesets.EmptyScrolling\osu.Game.Rulesets.EmptyScrolling.csproj" />

View File

@ -9,9 +9,9 @@
<GenerateProgramFile>false</GenerateProgramFile> <GenerateProgramFile>false</GenerateProgramFile>
</PropertyGroup> </PropertyGroup>
<ItemGroup Label="Package References"> <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="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.3.1" /> <PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj" /> <ProjectReference Include="..\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj" />

View File

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

View File

@ -26,7 +26,7 @@
<PackageReference Include="Clowd.Squirrel" Version="2.9.42" /> <PackageReference Include="Clowd.Squirrel" Version="2.9.42" />
<PackageReference Include="Mono.Posix.NETStandard" Version="1.0.0" /> <PackageReference Include="Mono.Posix.NETStandard" Version="1.0.0" />
<PackageReference Include="System.IO.Packaging" Version="7.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>
<ItemGroup Label="Resources"> <ItemGroup Label="Resources">
<EmbeddedResource Include="lazer.ico" /> <EmbeddedResource Include="lazer.ico" />

View File

@ -7,9 +7,9 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.13.4" /> <PackageReference Include="BenchmarkDotNet" Version="0.13.9" />
<PackageReference Include="nunit" Version="3.13.3" /> <PackageReference Include="nunit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.3.1" /> <PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -45,7 +45,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
AddAssert("end time is correct", () => Precision.AlmostEquals(lastObject.EndTime, times[1])); AddAssert("end time is correct", () => Precision.AlmostEquals(lastObject.EndTime, times[1]));
AddAssert("start position is correct", () => Precision.AlmostEquals(lastObject.OriginalX, positions[0])); AddAssert("start position is correct", () => Precision.AlmostEquals(lastObject.OriginalX, positions[0]));
AddAssert("end position is correct", () => Precision.AlmostEquals(lastObject.EndX, positions[1])); 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] [Test]
@ -76,7 +76,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
addPlacementSteps(times, positions); addPlacementSteps(times, positions);
addPathCheckStep(times, positions); addPathCheckStep(times, positions);
AddAssert("slider velocity changed", () => !lastObject.SliderVelocityBindable.IsDefault); AddAssert("slider velocity changed", () => !lastObject.SliderVelocityMultiplierBindable.IsDefault);
} }
[Test] [Test]

View File

@ -108,11 +108,11 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
double[] times = { 100, 300 }; double[] times = { 100, 300 };
float[] positions = { 200, 300 }; float[] positions = { 200, 300 };
addBlueprintStep(times, positions); addBlueprintStep(times, positions);
AddAssert("default slider velocity", () => hitObject.SliderVelocityBindable.IsDefault); AddAssert("default slider velocity", () => hitObject.SliderVelocityMultiplierBindable.IsDefault);
addDragStartStep(times[1], positions[1]); addDragStartStep(times[1], positions[1]);
AddMouseMoveStep(times[1], 400); AddMouseMoveStep(times[1], 400);
AddAssert("slider velocity changed", () => !hitObject.SliderVelocityBindable.IsDefault); AddAssert("slider velocity changed", () => !hitObject.SliderVelocityMultiplierBindable.IsDefault);
} }
[Test] [Test]

View File

@ -73,7 +73,7 @@ namespace osu.Game.Rulesets.Catch.Tests
private float getCaughtObjectPosition(Fruit fruit) private float getCaughtObjectPosition(Fruit fruit)
{ {
var caughtObject = catcher.ChildrenOfType<CaughtObject>().Single(c => c.HitObject == 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) private void catchFruit(Fruit fruit, float x)

View File

@ -0,0 +1,157 @@
// 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 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.Scoring;
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() => new ScoreV1 { ScoreMultiplier = { BindTarget = scoreMultiplier } };
protected override IScoringAlgorithm CreateScoreV2(int maxCombo) => new ScoreV2(maxCombo);
protected override ProcessorBasedScoringAlgorithm CreateScoreAlgorithm(IBeatmap beatmap, ScoringMode mode) => new CatchProcessorBasedScoringAlgorithm(beatmap, mode);
[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 int currentCombo;
public BindableDouble ScoreMultiplier { get; } = new BindableDouble();
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));
currentCombo++;
}
public long TotalScore { get; private set; }
}
private class ScoreV2 : IScoringAlgorithm
{
private int currentCombo;
private double comboPortion;
private readonly double comboPortionMax;
private const double combo_base = 4;
private const int combo_cap = 200;
public ScoreV2(int maxCombo)
{
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); // vast simplification, as we're not doing ticks here.
}
private class CatchProcessorBasedScoringAlgorithm : ProcessorBasedScoringAlgorithm
{
public CatchProcessorBasedScoringAlgorithm(IBeatmap beatmap, ScoringMode mode)
: base(beatmap, mode)
{
}
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 };
}
}
}

View File

@ -1,9 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\osu.TestProject.props" /> <Import Project="..\osu.TestProject.props" />
<ItemGroup Label="Package References"> <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="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.3.1" /> <PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
</ItemGroup> </ItemGroup>
<PropertyGroup Label="Project"> <PropertyGroup Label="Project">
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>

View File

@ -41,9 +41,8 @@ namespace osu.Game.Rulesets.Catch.Beatmaps
X = xPositionData?.X ?? 0, X = xPositionData?.X ?? 0,
NewCombo = comboData?.NewCombo ?? false, NewCombo = comboData?.NewCombo ?? false,
ComboOffset = comboData?.ComboOffset ?? 0, ComboOffset = comboData?.ComboOffset ?? 0,
LegacyLastTickOffset = (obj as IHasLegacyLastTickOffset)?.LegacyLastTickOffset ?? 0,
LegacyConvertedY = yPositionData?.Y ?? CatchHitObject.DEFAULT_LEGACY_CONVERT_Y, LegacyConvertedY = yPositionData?.Y ?? CatchHitObject.DEFAULT_LEGACY_CONVERT_Y,
SliderVelocity = sliderVelocityData?.SliderVelocity ?? 1 SliderVelocityMultiplier = sliderVelocityData?.SliderVelocityMultiplier ?? 1
}.Yield(); }.Yield();
case IHasDuration endTime: case IHasDuration endTime:

View File

@ -25,6 +25,7 @@ using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Replays.Types; using osu.Game.Rulesets.Replays.Types;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Scoring.Legacy;
using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI;
using osu.Game.Scoring; using osu.Game.Scoring;
using osu.Game.Screens.Ranking.Statistics; using osu.Game.Screens.Ranking.Statistics;
@ -53,7 +54,7 @@ namespace osu.Game.Rulesets.Catch
new KeyBinding(InputKey.X, CatchAction.MoveRight), new KeyBinding(InputKey.X, CatchAction.MoveRight),
new KeyBinding(InputKey.Right, CatchAction.MoveRight), new KeyBinding(InputKey.Right, CatchAction.MoveRight),
new KeyBinding(InputKey.Shift, CatchAction.Dash), 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) public override IEnumerable<Mod> ConvertFromLegacyMods(LegacyMods mods)

View File

@ -25,12 +25,9 @@ namespace osu.Game.Rulesets.Catch.Difficulty
public override int Version => 20220701; public override int Version => 20220701;
private readonly IWorkingBeatmap workingBeatmap;
public CatchDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) public CatchDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap) : base(ruleset, beatmap)
{ {
workingBeatmap = beatmap;
} }
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) 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)), 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; return attributes;
} }

View File

@ -5,30 +5,26 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Rulesets.Catch.Mods;
using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Scoring.Legacy;
namespace osu.Game.Rulesets.Catch.Difficulty namespace osu.Game.Rulesets.Catch.Difficulty
{ {
internal class CatchLegacyScoreSimulator : ILegacyScoreSimulator 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 legacyBonusScore;
private int modernBonusScore; private int standardisedBonusScore;
private int combo; private int combo;
private double scoreMultiplier; private double scoreMultiplier;
public void Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap, IReadOnlyList<Mod> mods) public LegacyScoreAttributes Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap)
{ {
IBeatmap baseBeatmap = workingBeatmap.Beatmap; IBeatmap baseBeatmap = workingBeatmap.Beatmap;
@ -70,13 +66,19 @@ namespace osu.Game.Rulesets.Catch.Difficulty
+ baseBeatmap.Difficulty.CircleSize + baseBeatmap.Difficulty.CircleSize
+ Math.Clamp((float)objectCount / drainLength * 8, 0, 16)) / 38 * 5); + 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) 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 increaseCombo = true;
bool addScoreComboMultiplier = false; bool addScoreComboMultiplier = false;
@ -112,31 +114,79 @@ namespace osu.Game.Rulesets.Catch.Difficulty
case JuiceStream: case JuiceStream:
foreach (var nested in hitObject.NestedHitObjects) foreach (var nested in hitObject.NestedHitObjects)
simulateHit(nested); simulateHit(nested, ref attributes);
return; return;
case BananaShower: case BananaShower:
foreach (var nested in hitObject.NestedHitObjects) foreach (var nested in hitObject.NestedHitObjects)
simulateHit(nested); simulateHit(nested, ref attributes);
return; return;
} }
if (addScoreComboMultiplier) if (addScoreComboMultiplier)
{ {
// ReSharper disable once PossibleLossOfFraction (intentional to match osu-stable...) // 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) if (isBonus)
{ {
legacyBonusScore += scoreIncrease; legacyBonusScore += scoreIncrease;
modernBonusScore += Judgement.ToNumericResult(bonusResult); standardisedBonusScore += Judgement.ToNumericResult(bonusResult);
} }
else else
AccuracyScore += scoreIncrease; attributes.AccuracyScore += scoreIncrease;
if (increaseCombo) if (increaseCombo)
combo++; 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;
}
} }
} }

View File

@ -91,7 +91,7 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
public void UpdateHitObjectFromPath(JuiceStream hitObject) public void UpdateHitObjectFromPath(JuiceStream hitObject)
{ {
// The SV setting may need to be changed for the current path. // 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 svToVelocityFactor = hitObject.Velocity / svBindable.Value;
double requiredVelocity = path.ComputeRequiredVelocity(); double requiredVelocity = path.ComputeRequiredVelocity();

View File

@ -41,7 +41,7 @@ namespace osu.Game.Rulesets.Catch.Edit
{ {
base.Update(); 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; Height = 1 / Scale.Y;
} }
} }

View File

@ -25,6 +25,7 @@ using osuTK;
namespace osu.Game.Rulesets.Catch.Edit namespace osu.Game.Rulesets.Catch.Edit
{ {
// we're also a ScrollingHitObjectComposer candidate, but can't be everything can we?
public partial class CatchHitObjectComposer : DistancedHitObjectComposer<CatchHitObject> public partial class CatchHitObjectComposer : DistancedHitObjectComposer<CatchHitObject>
{ {
private const float distance_snap_radius = 50; private const float distance_snap_radius = 50;
@ -140,7 +141,7 @@ namespace osu.Game.Rulesets.Catch.Edit
return base.OnPressed(e); return base.OnPressed(e);
} }
protected override DrawableRuleset<CatchHitObject> CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod>? mods = null) => protected override DrawableRuleset<CatchHitObject> CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod> mods) =>
new DrawableCatchEditorRuleset(ruleset, beatmap, mods) new DrawableCatchEditorRuleset(ruleset, beatmap, mods)
{ {
TimeRangeMultiplier = { BindTarget = timeRangeMultiplier, } TimeRangeMultiplier = { BindTarget = timeRangeMultiplier, }

View File

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
using System;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Rulesets.Catch.Beatmaps; using osu.Game.Rulesets.Catch.Beatmaps;
@ -16,5 +17,13 @@ namespace osu.Game.Rulesets.Catch.Mods
var catchProcessor = (CatchBeatmapProcessor)beatmapProcessor; var catchProcessor = (CatchBeatmapProcessor)beatmapProcessor;
catchProcessor.HardRockOffsets = true; 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);
}
} }
} }

View File

@ -28,17 +28,17 @@ namespace osu.Game.Rulesets.Catch.Objects
public int RepeatCount { get; set; } public int RepeatCount { get; set; }
public BindableNumber<double> SliderVelocityBindable { get; } = new BindableDouble(1) public BindableNumber<double> SliderVelocityMultiplierBindable { get; } = new BindableDouble(1)
{ {
Precision = 0.01, Precision = 0.01,
MinValue = 0.1, MinValue = 0.1,
MaxValue = 10 MaxValue = 10
}; };
public double SliderVelocity public double SliderVelocityMultiplier
{ {
get => SliderVelocityBindable.Value; get => SliderVelocityMultiplierBindable.Value;
set => SliderVelocityBindable.Value = value; set => SliderVelocityMultiplierBindable.Value = value;
} }
[JsonIgnore] [JsonIgnore]
@ -48,10 +48,10 @@ namespace osu.Game.Rulesets.Catch.Objects
private double tickDistanceFactor; private double tickDistanceFactor;
[JsonIgnore] [JsonIgnore]
public double Velocity => velocityFactor * SliderVelocity; public double Velocity => velocityFactor * SliderVelocityMultiplier;
[JsonIgnore] [JsonIgnore]
public double TickDistance => tickDistanceFactor * SliderVelocity; public double TickDistance => tickDistanceFactor * SliderVelocityMultiplier;
/// <summary> /// <summary>
/// The length of one span of this <see cref="JuiceStream"/>. /// The length of one span of this <see cref="JuiceStream"/>.
@ -77,7 +77,7 @@ namespace osu.Game.Rulesets.Catch.Objects
int nodeIndex = 0; int nodeIndex = 0;
SliderEventDescriptor? lastEvent = null; 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 // generate tiny droplets since the last point
if (lastEvent != null) 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 also includes LastTick 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 means that the final segment of TinyDroplets are increasingly mistimed where LastTick is being applied.
lastEvent = e; lastEvent = e;
switch (e.Type) switch (e.Type)
@ -162,7 +162,5 @@ namespace osu.Game.Rulesets.Catch.Objects
public double Distance => Path.Distance; public double Distance => Path.Distance;
public IList<IList<HitSampleInfo>> NodeSamples { get; set; } = new List<IList<HitSampleInfo>>(); public IList<IList<HitSampleInfo>> NodeSamples { get; set; } = new List<IList<HitSampleInfo>>();
public double? LegacyLastTickOffset { get; set; }
} }
} }

View File

@ -15,7 +15,11 @@ namespace osu.Game.Rulesets.Catch.Skinning.Argon
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load() private void load()
{ {
RelativeSizeAxes = Axes.Both; Anchor = Anchor.TopCentre;
Origin = Anchor.Centre;
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
InternalChildren = new Drawable[] InternalChildren = new Drawable[]
{ {

View File

@ -9,6 +9,7 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures; using osu.Framework.Graphics.Textures;
using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Catch.UI;
using osuTK;
namespace osu.Game.Rulesets.Catch.Skinning.Default namespace osu.Game.Rulesets.Catch.Skinning.Default
{ {
@ -22,6 +23,7 @@ namespace osu.Game.Rulesets.Catch.Skinning.Default
public DefaultCatcher() public DefaultCatcher()
{ {
Anchor = Anchor.TopCentre;
RelativeSizeAxes = Axes.Both; RelativeSizeAxes = Axes.Both;
InternalChild = sprite = new Sprite 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] [BackgroundDependencyLoader]
private void load(TextureStore store, Bindable<CatcherAnimationState> currentState) private void load(TextureStore store, Bindable<CatcherAnimationState> currentState)
{ {

View File

@ -2,17 +2,21 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics.Textures; using osu.Framework.Graphics.Textures;
using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Rulesets.Catch.Skinning.Legacy namespace osu.Game.Rulesets.Catch.Skinning.Legacy
{ {
public partial class LegacyBananaPiece : LegacyCatchHitObjectPiece public partial class LegacyBananaPiece : LegacyCatchHitObjectPiece
{ {
private static readonly Vector2 banana_max_size = new Vector2(160);
protected override void LoadComplete() protected override void LoadComplete()
{ {
base.LoadComplete(); base.LoadComplete();
Texture? texture = Skin.GetTexture("fruit-bananas"); Texture? texture = Skin.GetTexture("fruit-bananas")?.WithMaximumSize(banana_max_size);
Texture? overlayTexture = Skin.GetTexture("fruit-bananas-overlay"); Texture? overlayTexture = Skin.GetTexture("fruit-bananas-overlay")?.WithMaximumSize(banana_max_size);
SetTexture(texture, overlayTexture); SetTexture(texture, overlayTexture);
} }

View File

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

View File

@ -7,14 +7,12 @@ using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Animations; using osu.Framework.Graphics.Animations;
using osu.Framework.Graphics.Containers;
using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Catch.UI;
using osu.Game.Skinning; using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Rulesets.Catch.Skinning.Legacy namespace osu.Game.Rulesets.Catch.Skinning.Legacy
{ {
public partial class LegacyCatcherNew : CompositeDrawable public partial class LegacyCatcherNew : LegacyCatcher
{ {
[Resolved] [Resolved]
private Bindable<CatcherAnimationState> currentState { get; set; } = null!; private Bindable<CatcherAnimationState> currentState { get; set; } = null!;
@ -23,25 +21,12 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy
private Drawable currentDrawable = null!; private Drawable currentDrawable = null!;
public LegacyCatcherNew()
{
RelativeSizeAxes = Axes.Both;
}
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(ISkinSource skin) private void load(ISkinSource skin)
{ {
foreach (var state in Enum.GetValues<CatcherAnimationState>()) foreach (var state in Enum.GetValues<CatcherAnimationState>())
{ {
AddInternal(drawables[state] = getDrawableFor(state).With(d => AddInternal(drawables[state] = getDrawableFor(state).With(d => d.Alpha = 0));
{
d.Anchor = Anchor.TopCentre;
d.Origin = Anchor.TopCentre;
d.RelativeSizeAxes = Axes.Both;
d.Size = Vector2.One;
d.FillMode = FillMode.Fit;
d.Alpha = 0;
}));
} }
currentDrawable = drawables[CatcherAnimationState.Idle]; currentDrawable = drawables[CatcherAnimationState.Idle];

View File

@ -3,30 +3,21 @@
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Skinning; using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Rulesets.Catch.Skinning.Legacy namespace osu.Game.Rulesets.Catch.Skinning.Legacy
{ {
public partial class LegacyCatcherOld : CompositeDrawable public partial class LegacyCatcherOld : LegacyCatcher
{ {
public LegacyCatcherOld() public LegacyCatcherOld()
{ {
RelativeSizeAxes = Axes.Both; AutoSizeAxes = Axes.Both;
} }
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(ISkinSource skin) private void load(ISkinSource skin)
{ {
InternalChild = (skin.GetAnimation(@"fruit-ryuuta", true, true, true) ?? Empty()).With(d => InternalChild = skin.GetAnimation(@"fruit-ryuuta", true, true, true) ?? Empty();
{
d.Anchor = Anchor.TopCentre;
d.Origin = Anchor.TopCentre;
d.RelativeSizeAxes = Axes.Both;
d.Size = Vector2.One;
d.FillMode = FillMode.Fit;
});
} }
} }
} }

View File

@ -2,12 +2,15 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics.Textures; using osu.Framework.Graphics.Textures;
using osu.Game.Skinning;
using osuTK; using osuTK;
namespace osu.Game.Rulesets.Catch.Skinning.Legacy namespace osu.Game.Rulesets.Catch.Skinning.Legacy
{ {
public partial class LegacyDropletPiece : LegacyCatchHitObjectPiece public partial class LegacyDropletPiece : LegacyCatchHitObjectPiece
{ {
private static readonly Vector2 droplet_max_size = new Vector2(160);
public LegacyDropletPiece() public LegacyDropletPiece()
{ {
Scale = new Vector2(0.8f); Scale = new Vector2(0.8f);
@ -17,8 +20,8 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy
{ {
base.LoadComplete(); base.LoadComplete();
Texture? texture = Skin.GetTexture("fruit-drop"); Texture? texture = Skin.GetTexture("fruit-drop")?.WithMaximumSize(droplet_max_size);
Texture? overlayTexture = Skin.GetTexture("fruit-drop-overlay"); Texture? overlayTexture = Skin.GetTexture("fruit-drop-overlay")?.WithMaximumSize(droplet_max_size);
SetTexture(texture, overlayTexture); SetTexture(texture, overlayTexture);
} }

View File

@ -2,11 +2,15 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Rulesets.Catch.Skinning.Legacy namespace osu.Game.Rulesets.Catch.Skinning.Legacy
{ {
internal partial class LegacyFruitPiece : LegacyCatchHitObjectPiece internal partial class LegacyFruitPiece : LegacyCatchHitObjectPiece
{ {
private static readonly Vector2 fruit_max_size = new Vector2(160);
protected override void LoadComplete() protected override void LoadComplete()
{ {
base.LoadComplete(); base.LoadComplete();
@ -22,21 +26,26 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy
switch (visualRepresentation) switch (visualRepresentation)
{ {
case FruitVisualRepresentation.Pear: case FruitVisualRepresentation.Pear:
SetTexture(Skin.GetTexture("fruit-pear"), Skin.GetTexture("fruit-pear-overlay")); setTextures("pear");
break; break;
case FruitVisualRepresentation.Grape: case FruitVisualRepresentation.Grape:
SetTexture(Skin.GetTexture("fruit-grapes"), Skin.GetTexture("fruit-grapes-overlay")); setTextures("grapes");
break; break;
case FruitVisualRepresentation.Pineapple: case FruitVisualRepresentation.Pineapple:
SetTexture(Skin.GetTexture("fruit-apple"), Skin.GetTexture("fruit-apple-overlay")); setTextures("apple");
break; break;
case FruitVisualRepresentation.Raspberry: case FruitVisualRepresentation.Raspberry:
SetTexture(Skin.GetTexture("fruit-orange"), Skin.GetTexture("fruit-orange-overlay")); setTextures("orange");
break; break;
} }
void setTextures(string fruitName) => SetTexture(
Skin.GetTexture($"fruit-{fruitName}")?.WithMaximumSize(fruit_max_size),
Skin.GetTexture($"fruit-{fruitName}-overlay")?.WithMaximumSize(fruit_max_size)
);
} }
} }
} }

View File

@ -17,12 +17,13 @@ namespace osu.Game.Rulesets.Catch.UI
public CatchPlayfieldAdjustmentContainer() public CatchPlayfieldAdjustmentContainer()
{ {
// because we are using centre anchor/origin, we will need to limit visibility in the future Anchor = Anchor.TopCentre;
// to ensure tall windows do not get a readability advantage. Origin = Anchor.TopCentre;
// it may be possible to bake the catch-specific offsets (-100..340 mentioned below) into new values
// which are compatible with TopCentre alignment. // playfields in stable are positioned vertically at three fourths the difference between the playfield height and the window height in stable.
Anchor = Anchor.Centre; // 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.
Origin = Anchor.Centre; RelativePositionAxes = Axes.Y;
Y = (1 - playfield_size_adjust) / 4 * 3;
Size = new Vector2(playfield_size_adjust); Size = new Vector2(playfield_size_adjust);
@ -42,18 +43,28 @@ namespace osu.Game.Rulesets.Catch.UI
/// </summary> /// </summary>
private partial class ScalingContainer : Container private partial class ScalingContainer : Container
{ {
public ScalingContainer()
{
Anchor = Anchor.BottomCentre;
Origin = Anchor.BottomCentre;
}
protected override void Update() protected override void Update()
{ {
base.Update(); base.Update();
// in stable, fruit fall vertically from -100 to 340. // in stable, fruit fall vertically from 100 pixels above the playfield top down to the catcher's Y position (i.e. -100 to 340),
// to emulate this, we want to make our playfield 440 gameplay pixels high. // see: https://github.com/peppy/osu-stable-reference/blob/1531237b63392e82c003c712faa028406073aa8f/osu!/GameplayElements/HitObjects/Fruits/HitCircleFruits.cs#L65
// we then offset it -100 vertically in the position set below. // we already have the playfield positioned similar to stable (see CatchPlayfieldAdjustmentContainer constructor),
const float stable_v_offset_ratio = 440 / 384f; // 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); Scale = new Vector2(Parent!.ChildSize.X / CatchPlayfield.WIDTH);
Position = new Vector2(0, -100 * stable_v_offset_ratio + Scale.X); Position = new Vector2(0f, playfield_v_catcher_offset * Scale.Y);
Size = Vector2.Divide(new Vector2(1, stable_v_offset_ratio), Scale); Size = Vector2.Divide(new Vector2(1, playfield_v_size_adjustment), Scale);
} }
} }
} }

View File

@ -29,6 +29,13 @@ namespace osu.Game.Rulesets.Catch.UI
/// <summary> /// <summary>
/// The size of the catcher at 1x scale. /// The size of the catcher at 1x scale.
/// </summary> /// </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; public const float BASE_SIZE = 106.75f;
/// <summary> /// <summary>

View File

@ -21,8 +21,6 @@ namespace osu.Game.Rulesets.Catch.UI
{ {
public partial class DrawableCatchRuleset : DrawableScrollingRuleset<CatchHitObject> public partial class DrawableCatchRuleset : DrawableScrollingRuleset<CatchHitObject>
{ {
protected override ScrollVisualisationMethod VisualisationMethod => ScrollVisualisationMethod.Constant;
protected override bool UserScrollSpeedAdjustment => false; protected override bool UserScrollSpeedAdjustment => false;
public DrawableCatchRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod>? mods = null) public DrawableCatchRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod>? mods = null)
@ -30,6 +28,7 @@ namespace osu.Game.Rulesets.Catch.UI
{ {
Direction.Value = ScrollingDirection.Down; Direction.Value = ScrollingDirection.Down;
TimeRange.Value = GetTimeRange(beatmap.Difficulty.ApproachRate); TimeRange.Value = GetTimeRange(beatmap.Difficulty.ApproachRate);
VisualisationMethod = ScrollVisualisationMethod.Constant;
} }
[BackgroundDependencyLoader] [BackgroundDependencyLoader]

View File

@ -6,7 +6,6 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Game.Rulesets.Catch.Skinning.Default; using osu.Game.Rulesets.Catch.Skinning.Default;
using osu.Game.Skinning; using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Rulesets.Catch.UI namespace osu.Game.Rulesets.Catch.UI
{ {
@ -26,8 +25,8 @@ namespace osu.Game.Rulesets.Catch.UI
: base(new CatchSkinComponentLookup(CatchSkinComponents.Catcher), _ => new DefaultCatcher()) : base(new CatchSkinComponentLookup(CatchSkinComponents.Catcher), _ => new DefaultCatcher())
{ {
Anchor = Anchor.TopCentre; Anchor = Anchor.TopCentre;
// Sets the origin roughly to the centre of the catcher's plate to allow for correct scaling. Origin = Anchor.TopCentre;
OriginPosition = new Vector2(0.5f, 0.06f) * Catcher.BASE_SIZE; CentreComponent = false;
} }
} }
} }

View File

@ -24,7 +24,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods
PassCondition = () => PassCondition = () =>
{ {
var hitObject = Player.ChildrenOfType<DrawableManiaHitObject>().FirstOrDefault(); var hitObject = Player.ChildrenOfType<DrawableManiaHitObject>().FirstOrDefault();
return hitObject?.Dependencies.Get<IScrollingInfo>().Algorithm is ConstantScrollAlgorithm; return hitObject?.Dependencies.Get<IScrollingInfo>().Algorithm.Value is ConstantScrollAlgorithm;
} }
}); });
} }

View File

@ -0,0 +1,74 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mania.Mods;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Replays;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Replays;
using osu.Game.Tests.Visual;
namespace osu.Game.Rulesets.Mania.Tests.Mods
{
public partial class TestSceneManiaModDoubleTime : ModTestScene
{
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
&& Player.ScoreProcessor.TotalScore.Value == 1_000_000,
Autoplay = false,
Beatmap = new Beatmap
{
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)
}
});
[Test]
public void TestHitWindowWithDoubleTime()
{
var doubleTime = new ManiaModDoubleTime();
CreateModTest(new ModTestData
{
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
{
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)
}
});
}
}
}

View File

@ -58,7 +58,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning
IBindable<ScrollingDirection> IScrollingInfo.Direction => Direction; IBindable<ScrollingDirection> IScrollingInfo.Direction => Direction;
IBindable<double> IScrollingInfo.TimeRange { get; } = new Bindable<double>(5000); 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());
} }
} }
} }

View File

@ -39,7 +39,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning
{ {
c.Add(hitExplosionPools[poolIndex].Get(e => 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.Anchor = Anchor.Centre;
e.Origin = Anchor.Centre; e.Origin = Anchor.Centre;

View File

@ -54,7 +54,6 @@ namespace osu.Game.Rulesets.Mania.Tests
}); });
assertHeadJudgement(HitResult.Miss); assertHeadJudgement(HitResult.Miss);
assertTickJudgement(HitResult.LargeTickMiss);
assertTailJudgement(HitResult.Miss); assertTailJudgement(HitResult.Miss);
assertNoteJudgement(HitResult.IgnoreMiss); assertNoteJudgement(HitResult.IgnoreMiss);
} }
@ -73,7 +72,6 @@ namespace osu.Game.Rulesets.Mania.Tests
}); });
assertHeadJudgement(HitResult.Perfect); assertHeadJudgement(HitResult.Perfect);
assertTickJudgement(HitResult.LargeTickHit);
assertTailJudgement(HitResult.Perfect); assertTailJudgement(HitResult.Perfect);
assertNoteJudgement(HitResult.IgnoreHit); assertNoteJudgement(HitResult.IgnoreHit);
} }
@ -92,7 +90,6 @@ namespace osu.Game.Rulesets.Mania.Tests
}); });
assertHeadJudgement(HitResult.Perfect); assertHeadJudgement(HitResult.Perfect);
assertTickJudgement(HitResult.LargeTickHit);
assertTailJudgement(HitResult.Miss); assertTailJudgement(HitResult.Miss);
assertNoteJudgement(HitResult.IgnoreMiss); assertNoteJudgement(HitResult.IgnoreMiss);
} }
@ -111,7 +108,6 @@ namespace osu.Game.Rulesets.Mania.Tests
}); });
assertHeadJudgement(HitResult.Miss); assertHeadJudgement(HitResult.Miss);
assertTickJudgement(HitResult.LargeTickMiss);
assertTailJudgement(HitResult.Miss); assertTailJudgement(HitResult.Miss);
} }
@ -129,7 +125,6 @@ namespace osu.Game.Rulesets.Mania.Tests
}); });
assertHeadJudgement(HitResult.Miss); assertHeadJudgement(HitResult.Miss);
assertTickJudgement(HitResult.LargeTickMiss);
assertTailJudgement(HitResult.Miss); assertTailJudgement(HitResult.Miss);
} }
@ -149,7 +144,6 @@ namespace osu.Game.Rulesets.Mania.Tests
}); });
assertHeadJudgement(HitResult.Perfect); assertHeadJudgement(HitResult.Perfect);
assertTickJudgement(HitResult.LargeTickHit);
assertTailJudgement(HitResult.Miss); assertTailJudgement(HitResult.Miss);
} }
@ -169,7 +163,6 @@ namespace osu.Game.Rulesets.Mania.Tests
}); });
assertHeadJudgement(HitResult.Perfect); assertHeadJudgement(HitResult.Perfect);
assertTickJudgement(HitResult.LargeTickHit);
assertTailJudgement(HitResult.Perfect); assertTailJudgement(HitResult.Perfect);
} }
@ -188,10 +181,33 @@ namespace osu.Game.Rulesets.Mania.Tests
}); });
assertHeadJudgement(HitResult.Perfect); assertHeadJudgement(HitResult.Perfect);
assertTickJudgement(HitResult.LargeTickMiss);
assertTailJudgement(HitResult.Miss); 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> /// <summary>
/// -----[ ]----- /// -----[ ]-----
/// xo x o /// xo x o
@ -208,7 +224,6 @@ namespace osu.Game.Rulesets.Mania.Tests
}); });
assertHeadJudgement(HitResult.Perfect); assertHeadJudgement(HitResult.Perfect);
assertTickJudgement(HitResult.LargeTickHit);
assertTailJudgement(HitResult.Miss); assertTailJudgement(HitResult.Miss);
} }
@ -228,7 +243,6 @@ namespace osu.Game.Rulesets.Mania.Tests
}); });
assertHeadJudgement(HitResult.Perfect); assertHeadJudgement(HitResult.Perfect);
assertTickJudgement(HitResult.LargeTickHit);
assertTailJudgement(HitResult.Meh); assertTailJudgement(HitResult.Meh);
} }
@ -246,7 +260,6 @@ namespace osu.Game.Rulesets.Mania.Tests
}); });
assertHeadJudgement(HitResult.Miss); assertHeadJudgement(HitResult.Miss);
assertTickJudgement(HitResult.LargeTickHit);
assertTailJudgement(HitResult.Miss); assertTailJudgement(HitResult.Miss);
} }
@ -264,7 +277,6 @@ namespace osu.Game.Rulesets.Mania.Tests
}); });
assertHeadJudgement(HitResult.Miss); assertHeadJudgement(HitResult.Miss);
assertTickJudgement(HitResult.LargeTickHit);
assertTailJudgement(HitResult.Meh); assertTailJudgement(HitResult.Meh);
} }
@ -358,7 +370,6 @@ namespace osu.Game.Rulesets.Mania.Tests
}, beatmap); }, beatmap);
assertHeadJudgement(HitResult.Miss); assertHeadJudgement(HitResult.Miss);
assertTickJudgement(HitResult.LargeTickMiss);
assertTailJudgement(HitResult.Miss); assertTailJudgement(HitResult.Miss);
assertHitObjectJudgement(note, HitResult.Good); assertHitObjectJudgement(note, HitResult.Good);
@ -371,7 +382,8 @@ namespace osu.Game.Rulesets.Mania.Tests
[Test] [Test]
public void TestPressAndReleaseJustAfterTailWithNearbyNote() public void TestPressAndReleaseJustAfterTailWithNearbyNote()
{ {
Note note; // Next note within tail lenience
Note note = new Note { StartTime = time_tail + 50 };
var beatmap = new Beatmap<ManiaHitObject> var beatmap = new Beatmap<ManiaHitObject>
{ {
@ -383,13 +395,7 @@ namespace osu.Game.Rulesets.Mania.Tests
Duration = time_tail - time_head, Duration = time_tail - time_head,
Column = 0, Column = 0,
}, },
{ note
// Next note within tail lenience
note = new Note
{
StartTime = time_tail + 50
}
}
}, },
BeatmapInfo = BeatmapInfo =
{ {
@ -405,7 +411,6 @@ namespace osu.Game.Rulesets.Mania.Tests
}, beatmap); }, beatmap);
assertHeadJudgement(HitResult.Miss); assertHeadJudgement(HitResult.Miss);
assertTickJudgement(HitResult.LargeTickMiss);
assertTailJudgement(HitResult.Miss); assertTailJudgement(HitResult.Miss);
assertHitObjectJudgement(note, HitResult.Great); assertHitObjectJudgement(note, HitResult.Great);
@ -425,7 +430,6 @@ namespace osu.Game.Rulesets.Mania.Tests
}); });
assertHeadJudgement(HitResult.Miss); assertHeadJudgement(HitResult.Miss);
assertTickJudgement(HitResult.LargeTickMiss);
assertTailJudgement(HitResult.Meh); assertTailJudgement(HitResult.Meh);
} }
@ -476,42 +480,6 @@ namespace osu.Game.Rulesets.Mania.Tests
.All(j => j.Type.IsHit())); .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] [Test]
public void TestZeroLength() public void TestZeroLength()
{ {
@ -551,11 +519,8 @@ namespace osu.Game.Rulesets.Mania.Tests
private void assertNoteJudgement(HitResult result) private void assertNoteJudgement(HitResult result)
=> AddAssert($"hold note judged as {result}", () => judgementResults.Single(j => j.HitObject is HoldNote).Type, () => Is.EqualTo(result)); => AddAssert($"hold note judged as {result}", () => judgementResults.Single(j => j.HitObject is HoldNote).Type, () => Is.EqualTo(result));
private void assertTickJudgement(HitResult result) private void assertComboAtJudgement(int judgementIndex, int combo)
=> AddAssert($"any tick judged as {result}", () => judgementResults.Where(j => j.HitObject is HoldNoteTick).Select(j => j.Type), () => Does.Contain(result)); => AddAssert($"combo at judgement {judgementIndex} is {combo}", () => judgementResults.ElementAt(judgementIndex).ComboAfterJudgement, () => Is.EqualTo(combo));
private void assertLastTickJudgement(HitResult result)
=> AddAssert($"last tick judged as {result}", () => judgementResults.Last(j => j.HitObject is HoldNoteTick).Type, () => Is.EqualTo(result));
private ScoreAccessibleReplayPlayer currentPlayer = null!; private ScoreAccessibleReplayPlayer currentPlayer = null!;

View File

@ -54,7 +54,7 @@ namespace osu.Game.Rulesets.Mania.Tests
AddAssert("all objects perfectly judged", AddAssert("all objects perfectly judged",
() => judgementResults.Select(result => result.Type), () => judgementResults.Select(result => result.Type),
() => Is.EquivalentTo(judgementResults.Select(result => result.Judgement.MaxResult))); () => 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] [Test]
@ -87,7 +87,7 @@ namespace osu.Game.Rulesets.Mania.Tests
AddAssert("all objects perfectly judged", AddAssert("all objects perfectly judged",
() => judgementResults.Select(result => result.Type), () => judgementResults.Select(result => result.Type),
() => Is.EquivalentTo(judgementResults.Select(result => result.Judgement.MaxResult))); () => 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) private void performTest(List<ManiaHitObject> hitObjects, List<ReplayFrame> frames)

View File

@ -0,0 +1,175 @@
// 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 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.Scoring;
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() => new ScoreV1(MaxCombo.Value);
protected override IScoringAlgorithm CreateScoreV2(int maxCombo) => new ScoreV2(maxCombo);
protected override ProcessorBasedScoringAlgorithm CreateScoreAlgorithm(IBeatmap beatmap, ScoringMode mode) => new ManiaProcessorBasedScoringAlgorithm(beatmap, mode);
[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)
{
scoreMultiplier = 500000d / maxCombo;
}
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 const double combo_base = 4;
public ScoreV2(int maxCombo)
{
this.maxCombo = maxCombo;
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)
);
}
}
}
private class ManiaProcessorBasedScoringAlgorithm : ProcessorBasedScoringAlgorithm
{
public ManiaProcessorBasedScoringAlgorithm(IBeatmap beatmap, ScoringMode mode)
: base(beatmap, mode)
{
}
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 };
}
}
}

View File

@ -1,9 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\osu.TestProject.props" /> <Import Project="..\osu.TestProject.props" />
<ItemGroup Label="Package References"> <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="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.3.1" /> <PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
</ItemGroup> </ItemGroup>
<PropertyGroup Label="Project"> <PropertyGroup Label="Project">
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>

View File

@ -14,6 +14,7 @@ using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Mania.Beatmaps.Patterns; using osu.Game.Rulesets.Mania.Beatmaps.Patterns;
using osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy; using osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy;
using osu.Game.Rulesets.Scoring.Legacy;
using osu.Game.Utils; using osu.Game.Utils;
using osuTK; using osuTK;
@ -43,39 +44,41 @@ namespace osu.Game.Rulesets.Mania.Beatmaps
: base(beatmap, ruleset) : base(beatmap, ruleset)
{ {
IsForCurrentRuleset = beatmap.BeatmapInfo.Ruleset.Equals(ruleset.RulesetInfo); IsForCurrentRuleset = beatmap.BeatmapInfo.Ruleset.Equals(ruleset.RulesetInfo);
TargetColumns = GetColumnCount(LegacyBeatmapConversionDifficultyInfo.FromBeatmap(beatmap));
double roundedCircleSize = Math.Round(beatmap.Difficulty.CircleSize); if (IsForCurrentRuleset && TargetColumns > ManiaRuleset.MAX_STAGE_KEYS)
double roundedOverallDifficulty = Math.Round(beatmap.Difficulty.OverallDifficulty);
if (IsForCurrentRuleset)
{ {
TargetColumns = GetColumnCountForNonConvert(beatmap.BeatmapInfo); TargetColumns /= 2;
Dual = true;
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));
} }
originalTargetColumns = TargetColumns; 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); return (int)Math.Max(1, roundedCircleSize);
} }

View File

@ -10,10 +10,11 @@ using System.Linq;
using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Extensions.EnumExtensions;
using osu.Game.Audio; using osu.Game.Audio;
using osu.Game.Beatmaps; 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.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; using osu.Game.Utils;
namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy 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); TimingControlPoint timingPoint = beatmap.ControlPointInfo.TimingPointAt(hitObject.StartTime);
double beatLength; double beatLength;
if (hitObject.LegacyBpmMultiplier.HasValue)
beatLength = timingPoint.BeatLength * hitObject.LegacyBpmMultiplier.Value; if (hitObject is IHasSliderVelocity hasSliderVelocity)
else if (hitObject is IHasSliderVelocity hasSliderVelocity) beatLength = LegacyRulesetExtensions.GetPrecisionAdjustedBeatLength(hasSliderVelocity, timingPoint, ManiaRuleset.SHORT_NAME);
beatLength = timingPoint.BeatLength / hasSliderVelocity.SliderVelocity;
else else
beatLength = timingPoint.BeatLength; beatLength = timingPoint.BeatLength;

View File

@ -31,13 +31,9 @@ namespace osu.Game.Rulesets.Mania.Difficulty
public override int Version => 20220902; public override int Version => 20220902;
private readonly IWorkingBeatmap workingBeatmap;
public ManiaDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) public ManiaDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap) : base(ruleset, beatmap)
{ {
workingBeatmap = beatmap;
isForCurrentRuleset = beatmap.BeatmapInfo.Ruleset.MatchesOnlineID(ruleset); isForCurrentRuleset = beatmap.BeatmapInfo.Ruleset.MatchesOnlineID(ruleset);
originalOverallDifficulty = beatmap.BeatmapInfo.Difficulty.OverallDifficulty; originalOverallDifficulty = beatmap.BeatmapInfo.Difficulty.OverallDifficulty;
} }
@ -60,15 +56,6 @@ namespace osu.Game.Rulesets.Mania.Difficulty
MaxCombo = beatmap.HitObjects.Sum(maxComboForObject), 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; return attributes;
} }

View File

@ -4,25 +4,63 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.Mods; using osu.Game.Rulesets.Mania.Mods;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring.Legacy;
namespace osu.Game.Rulesets.Mania.Difficulty namespace osu.Game.Rulesets.Mania.Difficulty
{ {
internal class ManiaLegacyScoreSimulator : ILegacyScoreSimulator internal class ManiaLegacyScoreSimulator : ILegacyScoreSimulator
{ {
public int AccuracyScore => 0; public LegacyScoreAttributes Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap)
public int ComboScore { get; private set; }
public double BonusScoreRatio => 0;
public void Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap, IReadOnlyList<Mod> mods)
{ {
double multiplier = mods.Where(m => m is not (ModHidden or ModHardRock or ModDoubleTime or ModFlashlight or ManiaModFadeIn)) return new LegacyScoreAttributes { ComboScore = 1000000 };
.Select(m => m.ScoreMultiplier) }
.Aggregate(1.0, (c, n) => c * n);
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;
} }
} }
} }

View File

@ -44,8 +44,8 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
if (Column != null) if (Column != null)
{ {
headPiece.Y = Parent.ToLocalSpace(Column.ScreenSpacePositionAtTime(HitObject.StartTime)).Y; headPiece.Y = Parent!.ToLocalSpace(Column.ScreenSpacePositionAtTime(HitObject.StartTime)).Y;
tailPiece.Y = Parent.ToLocalSpace(Column.ScreenSpacePositionAtTime(HitObject.EndTime)).Y; tailPiece.Y = Parent!.ToLocalSpace(Column.ScreenSpacePositionAtTime(HitObject.EndTime)).Y;
switch (scrollingInfo.Direction.Value) switch (scrollingInfo.Direction.Value)
{ {

View File

@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
foreach (var child in InternalChildren) foreach (var child in InternalChildren)
child.Anchor = child.Origin = anchor; child.Anchor = child.Origin = anchor;
Position = Parent.ToLocalSpace(HitObjectContainer.ScreenSpacePositionAtTime(HitObject.StartTime)) - AnchorPosition; Position = Parent!.ToLocalSpace(HitObjectContainer.ScreenSpacePositionAtTime(HitObject.StartTime)) - AnchorPosition;
Width = HitObjectContainer.DrawWidth; Width = HitObjectContainer.DrawWidth;
} }
} }

View File

@ -2,18 +2,22 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic; using System.Collections.Generic;
using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osuTK;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Rulesets.Mania.UI; using osu.Game.Rulesets.Mania.UI;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI;
using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Rulesets.UI.Scrolling;
using osuTK;
namespace osu.Game.Rulesets.Mania.Edit 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 new IScrollingInfo ScrollingInfo => base.ScrollingInfo;
public DrawableManiaEditorRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod>? mods) 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) protected override Playfield CreatePlayfield() => new ManiaEditorPlayfield(Beatmap.Stages)
{ {
Anchor = Anchor.Centre, Anchor = Anchor.Centre,

View File

@ -8,6 +8,8 @@ using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Caching; using osu.Framework.Caching;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Pooling;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit;
@ -23,7 +25,7 @@ namespace osu.Game.Rulesets.Mania.Edit
/// <summary> /// <summary>
/// A grid which displays coloured beat divisor lines in proximity to the selection or placement cursor. /// A grid which displays coloured beat divisor lines in proximity to the selection or placement cursor.
/// </summary> /// </summary>
public partial class ManiaBeatSnapGrid : Component public partial class ManiaBeatSnapGrid : CompositeComponent
{ {
private const double visible_range = 750; private const double visible_range = 750;
@ -53,6 +55,8 @@ namespace osu.Game.Rulesets.Mania.Edit
private readonly List<ScrollingHitObjectContainer> grids = new List<ScrollingHitObjectContainer>(); private readonly List<ScrollingHitObjectContainer> grids = new List<ScrollingHitObjectContainer>();
private readonly DrawablePool<DrawableGridLine> linesPool = new DrawablePool<DrawableGridLine>(50);
private readonly Cached lineCache = new Cached(); private readonly Cached lineCache = new Cached();
private (double start, double end)? selectionTimeRange; private (double start, double end)? selectionTimeRange;
@ -60,6 +64,8 @@ namespace osu.Game.Rulesets.Mania.Edit
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(HitObjectComposer composer) private void load(HitObjectComposer composer)
{ {
AddInternal(linesPool);
foreach (var stage in ((ManiaPlayfield)composer.Playfield).Stages) foreach (var stage in ((ManiaPlayfield)composer.Playfield).Stages)
{ {
foreach (var column in stage.Columns) foreach (var column in stage.Columns)
@ -85,17 +91,10 @@ namespace osu.Game.Rulesets.Mania.Edit
} }
} }
private readonly Stack<DrawableGridLine> availableLines = new Stack<DrawableGridLine>();
private void createLines() private void createLines()
{ {
foreach (var grid in grids) foreach (var grid in grids)
{
foreach (var line in grid.Objects.OfType<DrawableGridLine>())
availableLines.Push(line);
grid.Clear(); grid.Clear();
}
if (selectionTimeRange == null) if (selectionTimeRange == null)
return; return;
@ -131,10 +130,13 @@ namespace osu.Game.Rulesets.Mania.Edit
foreach (var grid in grids) foreach (var grid in grids)
{ {
if (!availableLines.TryPop(out var line)) var line = linesPool.Get();
line = new DrawableGridLine();
line.Apply(new HitObject
{
StartTime = time
});
line.HitObject.StartTime = time;
line.Colour = colour; line.Colour = colour;
grid.Add(line); grid.Add(line);

View File

@ -21,7 +21,7 @@ using osuTK;
namespace osu.Game.Rulesets.Mania.Edit namespace osu.Game.Rulesets.Mania.Edit
{ {
public partial class ManiaHitObjectComposer : HitObjectComposer<ManiaHitObject> public partial class ManiaHitObjectComposer : ScrollingHitObjectComposer<ManiaHitObject>
{ {
private DrawableManiaEditorRuleset drawableRuleset; private DrawableManiaEditorRuleset drawableRuleset;
private ManiaBeatSnapGrid beatSnapGrid; private ManiaBeatSnapGrid beatSnapGrid;
@ -57,7 +57,7 @@ namespace osu.Game.Rulesets.Mania.Edit
protected override Playfield PlayfieldAtScreenSpacePosition(Vector2 screenSpacePosition) => protected override Playfield PlayfieldAtScreenSpacePosition(Vector2 screenSpacePosition) =>
Playfield.GetColumnByPosition(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); drawableRuleset = new DrawableManiaEditorRuleset(ruleset, beatmap, mods);

View File

@ -0,0 +1,13 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
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;
}
}

View File

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

View File

@ -12,12 +12,6 @@ namespace osu.Game.Rulesets.Mania.Judgements
{ {
switch (result) 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: case HitResult.Meh:
return -DEFAULT_MAX_HEALTH_INCREASE * 0.5; return -DEFAULT_MAX_HEALTH_INCREASE * 0.5;

View File

@ -15,7 +15,7 @@ namespace osu.Game.Rulesets.Mania
public bool Matches(BeatmapInfo beatmapInfo) 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) public bool TryParseCustomKeywordCriteria(string key, Operator op, string value)

View File

@ -33,6 +33,7 @@ using osu.Game.Rulesets.Mania.UI;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Replays.Types; using osu.Game.Rulesets.Replays.Types;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Scoring.Legacy;
using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI;
using osu.Game.Scoring; using osu.Game.Scoring;
using osu.Game.Screens.Edit.Setup; using osu.Game.Screens.Edit.Setup;
@ -385,21 +386,11 @@ namespace osu.Game.Rulesets.Mania
HitResult.Ok, HitResult.Ok,
HitResult.Meh, 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[] public override StatisticItem[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => new[]
{ {
new StatisticItem("Performance Breakdown", () => new PerformanceBreakdownChart(score, playableBeatmap) new StatisticItem("Performance Breakdown", () => new PerformanceBreakdownChart(score, playableBeatmap)

View File

@ -41,6 +41,7 @@ namespace osu.Game.Rulesets.Mania
}, },
new SettingsCheckbox new SettingsCheckbox
{ {
Keywords = new[] { "color" },
LabelText = RulesetSettingsStrings.TimingBasedColouring, LabelText = RulesetSettingsStrings.TimingBasedColouring,
Current = config.GetBindable<bool>(ManiaRulesetSetting.TimingBasedNoteColouring), Current = config.GetBindable<bool>(ManiaRulesetSetting.TimingBasedNoteColouring),
} }

View File

@ -0,0 +1,47 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Bindables;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Scoring;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.Mods
{
/// <summary>
/// May be attached to rate-adjustment mods to adjust hit windows adjust relative to gameplay rate.
/// </summary>
/// <remarks>
/// Historically, in osu!mania, hit windows are expected to adjust relative to the gameplay rate such that the real-world hit window remains the same.
/// </remarks>
public interface IManiaRateAdjustmentMod : IApplicableToDifficulty, IApplicableToHitObject
{
BindableNumber<double> SpeedChange { get; }
HitWindows HitWindows { get; set; }
void IApplicableToDifficulty.ApplyToDifficulty(BeatmapDifficulty difficulty)
{
HitWindows = new ManiaHitWindows(SpeedChange.Value);
HitWindows.SetDifficulty(difficulty.OverallDifficulty);
}
void IApplicableToHitObject.ApplyToHitObject(HitObject hitObject)
{
switch (hitObject)
{
case Note:
hitObject.HitWindows = HitWindows;
break;
case HoldNote hold:
hold.Head.HitWindows = HitWindows;
hold.Tail.HitWindows = HitWindows;
break;
}
}
}
}

View File

@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Mania.Mods
public void ApplyToDrawableRuleset(DrawableRuleset<ManiaHitObject> drawableRuleset) public void ApplyToDrawableRuleset(DrawableRuleset<ManiaHitObject> drawableRuleset)
{ {
var maniaRuleset = (DrawableManiaRuleset)drawableRuleset; var maniaRuleset = (DrawableManiaRuleset)drawableRuleset;
maniaRuleset.ScrollMethod = ScrollVisualisationMethod.Constant; maniaRuleset.VisualisationMethod = ScrollVisualisationMethod.Constant;
} }
} }
} }

View File

@ -1,11 +1,14 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
using osu.Game.Rulesets.Mania.Scoring;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.Mods namespace osu.Game.Rulesets.Mania.Mods
{ {
public class ManiaModDaycore : ModDaycore public class ManiaModDaycore : ModDaycore, IManiaRateAdjustmentMod
{ {
public HitWindows HitWindows { get; set; } = new ManiaHitWindows();
} }
} }

View File

@ -1,11 +1,14 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
using osu.Game.Rulesets.Mania.Scoring;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.Mods namespace osu.Game.Rulesets.Mania.Mods
{ {
public class ManiaModDoubleTime : ModDoubleTime public class ManiaModDoubleTime : ModDoubleTime, IManiaRateAdjustmentMod
{ {
public HitWindows HitWindows { get; set; } = new ManiaHitWindows();
} }
} }

View File

@ -1,11 +1,14 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
using osu.Game.Rulesets.Mania.Scoring;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.Mods namespace osu.Game.Rulesets.Mania.Mods
{ {
public class ManiaModHalfTime : ModHalfTime public class ManiaModHalfTime : ModHalfTime, IManiaRateAdjustmentMod
{ {
public HitWindows HitWindows { get; set; } = new ManiaHitWindows();
} }
} }

View File

@ -2,11 +2,14 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Scoring;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.Mods namespace osu.Game.Rulesets.Mania.Mods
{ {
public class ManiaModNightcore : ModNightcore<ManiaHitObject> public class ManiaModNightcore : ModNightcore<ManiaHitObject>, IManiaRateAdjustmentMod
{ {
public HitWindows HitWindows { get; set; } = new ManiaHitWindows();
} }
} }

View File

@ -34,7 +34,7 @@ namespace osu.Game.Rulesets.Mania.Mods
foreach (Column column in maniaPlayfield.Stages.SelectMany(stage => stage.Columns)) foreach (Column column in maniaPlayfield.Stages.SelectMany(stage => stage.Columns))
{ {
HitObjectContainer hoc = column.HitObjectArea.HitObjectContainer; HitObjectContainer hoc = column.HitObjectArea.HitObjectContainer;
Container hocParent = (Container)hoc.Parent; Container hocParent = (Container)hoc.Parent!;
hocParent.Remove(hoc, false); hocParent.Remove(hoc, false);
hocParent.Add(new PlayfieldCoveringWrapper(hoc).With(c => hocParent.Add(new PlayfieldCoveringWrapper(hoc).With(c =>

View File

@ -35,10 +35,11 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
public DrawableHoldNoteHead Head => headContainer.Child; public DrawableHoldNoteHead Head => headContainer.Child;
public DrawableHoldNoteTail Tail => tailContainer.Child; public DrawableHoldNoteTail Tail => tailContainer.Child;
public DrawableHoldNoteBody Body => bodyContainer.Child;
private Container<DrawableHoldNoteHead> headContainer; private Container<DrawableHoldNoteHead> headContainer;
private Container<DrawableHoldNoteTail> tailContainer; private Container<DrawableHoldNoteTail> tailContainer;
private Container<DrawableHoldNoteTick> tickContainer; private Container<DrawableHoldNoteBody> bodyContainer;
private PausableSkinnableSound slidingSample; private PausableSkinnableSound slidingSample;
@ -60,12 +61,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
public double? HoldStartTime { get; private set; } public double? HoldStartTime { get; private set; }
/// <summary> /// <summary>
/// Time at which the hold note has been broken, i.e. released too early, resulting in a reduced score. /// Used to decide whether to visually clamp the hold note to the judgement line.
/// </summary>
public double? HoldBrokenTime { get; private set; }
/// <summary>
/// Whether the hold note has been released potentially without having caused a break.
/// </summary> /// </summary>
private double? releaseTime; private double? releaseTime;
@ -103,6 +99,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
headContainer = new Container<DrawableHoldNoteHead> { RelativeSizeAxes = Axes.Both } headContainer = new Container<DrawableHoldNoteHead> { RelativeSizeAxes = Axes.Both }
} }
}, },
bodyContainer = new Container<DrawableHoldNoteBody> { RelativeSizeAxes = Axes.Both },
bodyPiece = new SkinnableDrawable(new ManiaSkinComponentLookup(ManiaSkinComponents.HoldNoteBody), _ => new DefaultBodyPiece bodyPiece = new SkinnableDrawable(new ManiaSkinComponentLookup(ManiaSkinComponents.HoldNoteBody), _ => new DefaultBodyPiece
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
@ -110,7 +107,6 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
{ {
RelativeSizeAxes = Axes.X RelativeSizeAxes = Axes.X
}, },
tickContainer = new Container<DrawableHoldNoteTick> { RelativeSizeAxes = Axes.Both },
tailContainer = new Container<DrawableHoldNoteTail> { RelativeSizeAxes = Axes.Both }, tailContainer = new Container<DrawableHoldNoteTail> { RelativeSizeAxes = Axes.Both },
slidingSample = new PausableSkinnableSound { Looping = true } slidingSample = new PausableSkinnableSound { Looping = true }
}); });
@ -118,7 +114,6 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
maskedContents.AddRange(new[] maskedContents.AddRange(new[]
{ {
bodyPiece.CreateProxy(), bodyPiece.CreateProxy(),
tickContainer.CreateProxy(),
tailContainer.CreateProxy(), tailContainer.CreateProxy(),
}); });
} }
@ -136,7 +131,6 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
sizingContainer.Size = Vector2.One; sizingContainer.Size = Vector2.One;
HoldStartTime = null; HoldStartTime = null;
HoldBrokenTime = null;
releaseTime = null; releaseTime = null;
} }
@ -154,8 +148,8 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
tailContainer.Child = tail; tailContainer.Child = tail;
break; break;
case DrawableHoldNoteTick tick: case DrawableHoldNoteBody body:
tickContainer.Add(tick); bodyContainer.Child = body;
break; break;
} }
} }
@ -165,7 +159,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
base.ClearNestedHitObjects(); base.ClearNestedHitObjects();
headContainer.Clear(false); headContainer.Clear(false);
tailContainer.Clear(false); tailContainer.Clear(false);
tickContainer.Clear(false); bodyContainer.Clear(false);
} }
protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject) protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject)
@ -178,8 +172,8 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
case HeadNote head: case HeadNote head:
return new DrawableHoldNoteHead(head); return new DrawableHoldNoteHead(head);
case HoldNoteTick tick: case HoldNoteBody body:
return new DrawableHoldNoteTick(tick); return new DrawableHoldNoteBody(body);
} }
return base.CreateNestedHitObject(hitObject); return base.CreateNestedHitObject(hitObject);
@ -266,20 +260,15 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
{ {
if (Tail.AllJudged) if (Tail.AllJudged)
{ {
foreach (var tick in tickContainer)
{
if (!tick.Judged)
tick.MissForcefully();
}
if (Tail.IsHit) if (Tail.IsHit)
ApplyResult(r => r.Type = r.Judgement.MaxResult); ApplyResult(r => r.Type = r.Judgement.MaxResult);
else else
MissForcefully(); MissForcefully();
} }
if (Tail.Judged && !Tail.IsHit) // Make sure that the hold note is fully judged by giving the body a judgement.
HoldBrokenTime = Time.Current; if (Tail.AllJudged && !Body.AllJudged)
Body.TriggerResult(Tail.IsHit);
} }
public override void MissForcefully() public override void MissForcefully()
@ -333,22 +322,22 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
if (e.Action != Action.Value) if (e.Action != Action.Value)
return; 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. // do not run any of this logic when rewinding, as it inverts order of presses/releases.
if ((Clock as IGameplayClock)?.IsRewinding == true) if ((Clock as IGameplayClock)?.IsRewinding == true)
return; return;
Tail.UpdateResult(); // When our action is released and we are in the middle of a hold, there's a chance that
endHold(); // 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 endHold();
if (!Tail.IsHit) releaseTime = Time.Current;
HoldBrokenTime = Time.Current; }
releaseTime = Time.Current;
} }
private void endHold() private void endHold()

View File

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

View File

@ -3,7 +3,6 @@
#nullable disable #nullable disable
using System.Diagnostics;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
@ -33,33 +32,19 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
public void UpdateResult() => base.UpdateResult(true); public void UpdateResult() => base.UpdateResult(true);
protected override void CheckForResult(bool userTriggered, double timeOffset) protected override void CheckForResult(bool userTriggered, double timeOffset) =>
{
Debug.Assert(HitObject.HitWindows != null);
// Factor in the release lenience // Factor in the release lenience
timeOffset /= TailNote.RELEASE_WINDOW_LENIENCE; base.CheckForResult(userTriggered, timeOffset / TailNote.RELEASE_WINDOW_LENIENCE);
if (!userTriggered) protected override HitResult GetCappedResult(HitResult result)
{ {
if (!HitObject.HitWindows.CanBeHit(timeOffset)) // If the head wasn't hit or the hold note was broken, cap the max score to Meh.
ApplyResult(r => r.Type = r.Judgement.MinResult); bool hasComboBreak = !HoldNote.Head.IsHit || HoldNote.Body.HasHoldBreak;
return; if (result > HitResult.Meh && hasComboBreak)
} return HitResult.Meh;
var result = HitObject.HitWindows.ResultFor(timeOffset); return result;
if (result == HitResult.None)
return;
ApplyResult(r =>
{
// If the head wasn't hit or the hold note was broken, cap the max score to Meh.
if (result > HitResult.Meh && (!HoldNote.Head.IsHit || HoldNote.HoldBrokenTime != null))
result = HitResult.Meh;
r.Type = result;
});
} }
public override bool OnPressed(KeyBindingPressEvent<ManiaAction> e) => false; // Handled by the hold note public override bool OnPressed(KeyBindingPressEvent<ManiaAction> e) => false; // Handled by the hold note

View File

@ -1,110 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using System;
using System.Diagnostics;
using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Effects;
using osu.Framework.Graphics.Shapes;
namespace osu.Game.Rulesets.Mania.Objects.Drawables
{
/// <summary>
/// Visualises a <see cref="HoldNoteTick"/> hit object.
/// </summary>
public partial class DrawableHoldNoteTick : DrawableManiaHitObject<HoldNoteTick>
{
/// <summary>
/// References the time at which the user started holding the hold note.
/// </summary>
private Func<double?> holdStartTime;
private Container glowContainer;
public DrawableHoldNoteTick()
: this(null)
{
}
public DrawableHoldNoteTick(HoldNoteTick hitObject)
: base(hitObject)
{
Anchor = Anchor.TopCentre;
Origin = Anchor.TopCentre;
RelativeSizeAxes = Axes.X;
}
[BackgroundDependencyLoader]
private void load()
{
AddInternal(glowContainer = new CircularContainer
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
RelativeSizeAxes = Axes.Both,
Masking = true,
Children = new[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Alpha = 0,
AlwaysPresent = true
}
}
});
}
protected override void LoadComplete()
{
base.LoadComplete();
AccentColour.BindValueChanged(colour =>
{
glowContainer.EdgeEffect = new EdgeEffectParameters
{
Type = EdgeEffectType.Glow,
Radius = 2f,
Roundness = 15f,
Colour = colour.NewValue.Opacity(0.3f)
};
}, true);
}
protected override void OnApply()
{
base.OnApply();
Debug.Assert(ParentHitObject != null);
var holdNote = (DrawableHoldNote)ParentHitObject;
holdStartTime = () => holdNote.HoldStartTime;
}
protected override void OnFree()
{
base.OnFree();
holdStartTime = null;
}
protected override void CheckForResult(bool userTriggered, double timeOffset)
{
if (Time.Current < HitObject.StartTime)
return;
double? startTime = holdStartTime?.Invoke();
if (startTime == null || startTime > HitObject.StartTime)
ApplyResult(r => r.Type = r.Judgement.MinResult);
else
ApplyResult(r => r.Type = r.Judgement.MaxResult);
}
}
}

View File

@ -13,6 +13,8 @@ using osu.Game.Beatmaps;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Rulesets.Mania.Configuration; using osu.Game.Rulesets.Mania.Configuration;
using osu.Game.Rulesets.Mania.Skinning.Default; using osu.Game.Rulesets.Mania.Skinning.Default;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Screens.Edit; using osu.Game.Screens.Edit;
@ -38,6 +40,8 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
private Drawable headPiece; private Drawable headPiece;
private DrawableNotePerfectBonus perfectBonus;
public DrawableNote() public DrawableNote()
: this(null) : this(null)
{ {
@ -89,7 +93,11 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
if (!userTriggered) if (!userTriggered)
{ {
if (!HitObject.HitWindows.CanBeHit(timeOffset)) if (!HitObject.HitWindows.CanBeHit(timeOffset))
{
perfectBonus.TriggerResult(false);
ApplyResult(r => r.Type = r.Judgement.MinResult); ApplyResult(r => r.Type = r.Judgement.MinResult);
}
return; return;
} }
@ -97,9 +105,23 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
if (result == HitResult.None) if (result == HitResult.None)
return; return;
result = GetCappedResult(result);
perfectBonus.TriggerResult(result == HitResult.Perfect);
ApplyResult(r => r.Type = result); ApplyResult(r => r.Type = result);
} }
public override void MissForcefully()
{
perfectBonus.TriggerResult(false);
base.MissForcefully();
}
/// <summary>
/// Some objects in mania may want to limit the max result.
/// </summary>
protected virtual HitResult GetCappedResult(HitResult result) => result;
public virtual bool OnPressed(KeyBindingPressEvent<ManiaAction> e) public virtual bool OnPressed(KeyBindingPressEvent<ManiaAction> e)
{ {
if (e.Action != Action.Value) if (e.Action != Action.Value)
@ -115,6 +137,32 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
{ {
} }
protected override void AddNestedHitObject(DrawableHitObject hitObject)
{
switch (hitObject)
{
case DrawableNotePerfectBonus bonus:
AddInternal(perfectBonus = bonus);
break;
}
}
protected override void ClearNestedHitObjects()
{
RemoveInternal(perfectBonus, false);
}
protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject)
{
switch (hitObject)
{
case NotePerfectBonus bonus:
return new DrawableNotePerfectBonus(bonus);
}
return base.CreateNestedHitObject(hitObject);
}
private void updateSnapColour() private void updateSnapColour()
{ {
if (beatmap == null || HitObject == null) return; if (beatmap == null || HitObject == null) return;

View File

@ -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.
namespace osu.Game.Rulesets.Mania.Objects.Drawables
{
public partial class DrawableNotePerfectBonus : DrawableManiaHitObject<NotePerfectBonus>
{
public override bool DisplayResult => false;
public DrawableNotePerfectBonus()
: this(null!)
{
}
public DrawableNotePerfectBonus(NotePerfectBonus hitObject)
: base(hitObject)
{
}
/// <summary>
/// Apply a judgement result.
/// </summary>
/// <param name="hit">Whether this tick was reached.</param>
internal void TriggerResult(bool hit) => ApplyResult(r => r.Type = hit ? r.Judgement.MaxResult : r.Judgement.MinResult);
}
}

View File

@ -3,6 +3,9 @@
namespace osu.Game.Rulesets.Mania.Objects namespace osu.Game.Rulesets.Mania.Objects
{ {
/// <summary>
/// The head note of a <see cref="HoldNote"/>.
/// </summary>
public class HeadNote : Note public class HeadNote : Note
{ {
} }

View File

@ -6,8 +6,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading; using System.Threading;
using osu.Game.Audio; using osu.Game.Audio;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
@ -81,27 +79,18 @@ namespace osu.Game.Rulesets.Mania.Objects
/// </summary> /// </summary>
public TailNote Tail { get; private set; } public TailNote Tail { get; private set; }
public override double MaximumJudgementOffset => Tail.MaximumJudgementOffset;
/// <summary> /// <summary>
/// The time between ticks of this hold. /// The body of the hold.
/// This is an invisible and silent object that tracks the holding state of the <see cref="HoldNote"/>.
/// </summary> /// </summary>
private double tickSpacing = 50; public HoldNoteBody Body { get; private set; }
protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty) public override double MaximumJudgementOffset => Tail.MaximumJudgementOffset;
{
base.ApplyDefaultsToSelf(controlPointInfo, difficulty);
TimingControlPoint timingPoint = controlPointInfo.TimingPointAt(StartTime);
tickSpacing = timingPoint.BeatLength / difficulty.SliderTickRate;
}
protected override void CreateNestedHitObjects(CancellationToken cancellationToken) protected override void CreateNestedHitObjects(CancellationToken cancellationToken)
{ {
base.CreateNestedHitObjects(cancellationToken); base.CreateNestedHitObjects(cancellationToken);
createTicks(cancellationToken);
AddNested(Head = new HeadNote AddNested(Head = new HeadNote
{ {
StartTime = StartTime, StartTime = StartTime,
@ -115,23 +104,12 @@ namespace osu.Game.Rulesets.Mania.Objects
Column = Column, Column = Column,
Samples = GetNodeSamples((NodeSamples?.Count - 1) ?? 1), Samples = GetNodeSamples((NodeSamples?.Count - 1) ?? 1),
}); });
}
private void createTicks(CancellationToken cancellationToken) AddNested(Body = new HoldNoteBody
{
if (tickSpacing == 0)
return;
for (double t = StartTime + tickSpacing; t <= EndTime - tickSpacing; t += tickSpacing)
{ {
cancellationToken.ThrowIfCancellationRequested(); StartTime = StartTime,
Column = Column
AddNested(new HoldNoteTick });
{
StartTime = t,
Column = Column
});
}
} }
public override Judgement CreateJudgement() => new IgnoreJudgement(); public override Judgement CreateJudgement() => new IgnoreJudgement();

View File

@ -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 osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mania.Judgements;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.Objects
{
/// <summary>
/// The body of a <see cref="HoldNote"/>.
/// Mostly a dummy hitobject that provides the judgement for the "holding" state.<br />
/// On hit - the hold note was held correctly for the full duration.<br />
/// On miss - the hold note was released at some point during its judgement period.
/// </summary>
public class HoldNoteBody : ManiaHitObject
{
public override Judgement CreateJudgement() => new HoldNoteBodyJudgement();
protected override HitWindows CreateHitWindows() => HitWindows.Empty;
}
}

View File

@ -1,19 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mania.Judgements;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.Objects
{
/// <summary>
/// A scoring tick of a hold note.
/// </summary>
public class HoldNoteTick : ManiaHitObject
{
public override Judgement CreateJudgement() => new HoldNoteTickJudgement();
protected override HitWindows CreateHitWindows() => HitWindows.Empty;
}
}

View File

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
using System.Threading;
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mania.Judgements; using osu.Game.Rulesets.Mania.Judgements;
@ -12,5 +13,12 @@ namespace osu.Game.Rulesets.Mania.Objects
public class Note : ManiaHitObject public class Note : ManiaHitObject
{ {
public override Judgement CreateJudgement() => new ManiaJudgement(); public override Judgement CreateJudgement() => new ManiaJudgement();
protected override void CreateNestedHitObjects(CancellationToken cancellationToken)
{
base.CreateNestedHitObjects(cancellationToken);
AddNested(new NotePerfectBonus { StartTime = StartTime });
}
} }
} }

View File

@ -0,0 +1,20 @@
// 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.Judgements;
using osu.Game.Rulesets.Mania.Judgements;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.Objects
{
public class NotePerfectBonus : ManiaHitObject
{
public override Judgement CreateJudgement() => new NotePerfectBonusJudgement();
protected override HitWindows CreateHitWindows() => HitWindows.Empty;
public class NotePerfectBonusJudgement : ManiaJudgement
{
public override HitResult MaxResult => HitResult.SmallBonus;
}
}
}

View File

@ -6,6 +6,9 @@ using osu.Game.Rulesets.Mania.Judgements;
namespace osu.Game.Rulesets.Mania.Objects namespace osu.Game.Rulesets.Mania.Objects
{ {
/// <summary>
/// The tail note of a <see cref="HoldNote"/>.
/// </summary>
public class TailNote : Note public class TailNote : Note
{ {
/// <summary> /// <summary>

View File

@ -1,12 +1,25 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
using System.Linq;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.Scoring namespace osu.Game.Rulesets.Mania.Scoring
{ {
public class ManiaHitWindows : HitWindows public class ManiaHitWindows : HitWindows
{ {
private readonly double multiplier;
public ManiaHitWindows()
: this(1)
{
}
public ManiaHitWindows(double multiplier)
{
this.multiplier = multiplier;
}
public override bool IsHitResultAllowed(HitResult result) public override bool IsHitResultAllowed(HitResult result)
{ {
switch (result) switch (result)
@ -22,5 +35,12 @@ namespace osu.Game.Rulesets.Mania.Scoring
return false; return false;
} }
protected override DifficultyRange[] GetRanges() => base.GetRanges().Select(r =>
new DifficultyRange(
r.Result,
r.Min * multiplier,
r.Average * multiplier,
r.Max * multiplier)).ToArray();
} }
} }

View File

@ -4,6 +4,7 @@
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
using osu.Game.Rulesets.Mania.Objects.Drawables; using osu.Game.Rulesets.Mania.Objects.Drawables;
@ -25,33 +26,42 @@ namespace osu.Game.Rulesets.Mania.Skinning.Default
{ {
RelativeSizeAxes = Axes.Both; RelativeSizeAxes = Axes.Both;
// Avoid flickering due to no anti-aliasing of boxes by default.
var edgeSmoothness = new Vector2(0.3f);
AddInternal(mainLine = new Box AddInternal(mainLine = new Box
{ {
Name = "Bar line", Name = "Bar line",
EdgeSmoothness = edgeSmoothness,
Anchor = Anchor.BottomCentre, Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre, Origin = Anchor.BottomCentre,
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
}); });
Vector2 size = new Vector2(22, 6); const float major_extension = 10;
const float line_offset = 4;
AddInternal(leftAnchor = new Circle AddInternal(leftAnchor = new Box
{ {
Name = "Left anchor", Name = "Left anchor",
EdgeSmoothness = edgeSmoothness,
Blending = BlendingParameters.Additive,
Anchor = Anchor.CentreLeft, Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreRight, Origin = Anchor.CentreRight,
Size = size, Width = major_extension,
X = -line_offset, RelativeSizeAxes = Axes.Y,
Colour = ColourInfo.GradientHorizontal(Colour4.Transparent, Colour4.White),
}); });
AddInternal(rightAnchor = new Circle AddInternal(rightAnchor = new Box
{ {
Name = "Right anchor", Name = "Right anchor",
EdgeSmoothness = edgeSmoothness,
Blending = BlendingParameters.Additive,
Anchor = Anchor.CentreRight, Anchor = Anchor.CentreRight,
Origin = Anchor.CentreLeft, Origin = Anchor.CentreLeft,
Size = size, Width = major_extension,
X = line_offset, RelativeSizeAxes = Axes.Y,
Colour = ColourInfo.GradientHorizontal(Colour4.White, Colour4.Transparent),
}); });
major = ((DrawableBarLine)drawableHitObject).Major.GetBoundCopy(); major = ((DrawableBarLine)drawableHitObject).Major.GetBoundCopy();
@ -66,7 +76,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Default
private void updateMajor(ValueChangedEvent<bool> major) private void updateMajor(ValueChangedEvent<bool> major)
{ {
mainLine.Alpha = major.NewValue ? 0.5f : 0.2f; mainLine.Alpha = major.NewValue ? 0.5f : 0.2f;
leftAnchor.Alpha = rightAnchor.Alpha = major.NewValue ? 1 : 0; leftAnchor.Alpha = rightAnchor.Alpha = major.NewValue ? mainLine.Alpha * 0.3f : 0;
} }
} }
} }

View File

@ -123,9 +123,18 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
private void applyCustomUpdateState(DrawableHitObject hitObject, ArmedState state) private void applyCustomUpdateState(DrawableHitObject hitObject, ArmedState state)
{ {
// ensure that the hold note is also faded out when the head/tail/any tick is missed. switch (hitObject)
if (state == ArmedState.Miss) {
missFadeTime.Value ??= hitObject.HitStateUpdateTime; // Ensure that the hold note is also faded out when the head/tail/body is missed.
// Importantly, we filter out unrelated objects like DrawableNotePerfectBonus.
case DrawableHoldNoteTail:
case DrawableHoldNoteHead:
case DrawableHoldNoteBody:
if (state == ArmedState.Miss)
missFadeTime.Value ??= hitObject.HitStateUpdateTime;
break;
}
} }
private void onIsHittingChanged(ValueChangedEvent<bool> isHitting) private void onIsHittingChanged(ValueChangedEvent<bool> isHitting)
@ -209,7 +218,9 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
protected override void Update() protected override void Update()
{ {
base.Update(); base.Update();
missFadeTime.Value ??= holdNote.HoldBrokenTime;
if (holdNote.Body.HasHoldBreak)
missFadeTime.Value = holdNote.Body.Result.TimeAbsolute;
int scaleDirection = (direction.Value == ScrollingDirection.Down ? 1 : -1); int scaleDirection = (direction.Value == ScrollingDirection.Down ? 1 : -1);

View File

@ -7,7 +7,6 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Animations; using osu.Framework.Graphics.Animations;
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mania.Judgements;
using osu.Game.Rulesets.Mania.UI; using osu.Game.Rulesets.Mania.UI;
using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Skinning; using osu.Game.Skinning;
@ -69,9 +68,6 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
public void Animate(JudgementResult result) public void Animate(JudgementResult result)
{ {
if (result.Judgement is HoldNoteTickJudgement)
return;
(explosion as IFramedAnimation)?.GotoFrame(0); (explosion as IFramedAnimation)?.GotoFrame(0);
explosion?.FadeInFromZero(FADE_IN_DURATION) explosion?.FadeInFromZero(FADE_IN_DURATION)

View File

@ -30,5 +30,7 @@ namespace osu.Game.Rulesets.Mania.Skinning
Lookup = lookup; Lookup = lookup;
ColumnIndex = columnIndex; ColumnIndex = columnIndex;
} }
public override string ToString() => $"[{nameof(ManiaSkinConfigurationLookup)} lookup:{Lookup} col:{ColumnIndex}]";
} }
} }

View File

@ -109,10 +109,11 @@ namespace osu.Game.Rulesets.Mania.UI
TopLevelContainer.Add(HitObjectArea.Explosions.CreateProxy()); TopLevelContainer.Add(HitObjectArea.Explosions.CreateProxy());
RegisterPool<Note, DrawableNote>(10, 50); RegisterPool<Note, DrawableNote>(10, 50);
RegisterPool<NotePerfectBonus, DrawableNotePerfectBonus>(10, 50);
RegisterPool<HoldNote, DrawableHoldNote>(10, 50); RegisterPool<HoldNote, DrawableHoldNote>(10, 50);
RegisterPool<HeadNote, DrawableHoldNoteHead>(10, 50); RegisterPool<HeadNote, DrawableHoldNoteHead>(10, 50);
RegisterPool<TailNote, DrawableHoldNoteTail>(10, 50); RegisterPool<TailNote, DrawableHoldNoteTail>(10, 50);
RegisterPool<HoldNoteTick, DrawableHoldNoteTick>(50, 250); RegisterPool<HoldNoteBody, DrawableHoldNoteBody>(10, 50);
} }
private void onSourceChanged() private void onSourceChanged()

View File

@ -11,7 +11,6 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Effects;
using osu.Framework.Utils; using osu.Framework.Utils;
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mania.Judgements;
using osu.Game.Rulesets.Mania.Skinning.Default; using osu.Game.Rulesets.Mania.Skinning.Default;
using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Rulesets.UI.Scrolling;
using osuTK; using osuTK;
@ -150,9 +149,6 @@ namespace osu.Game.Rulesets.Mania.UI
// scale roughly in-line with visual appearance of notes // scale roughly in-line with visual appearance of notes
Vector2 scale = new Vector2(1, 0.6f); Vector2 scale = new Vector2(1, 0.6f);
if (result.Judgement is HoldNoteTickJudgement)
scale *= 0.5f;
this.ScaleTo(scale); this.ScaleTo(scale);
largeFaint largeFaint

View File

@ -3,7 +3,6 @@
#nullable disable #nullable disable
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using osu.Framework.Allocation; using osu.Framework.Allocation;
@ -14,7 +13,6 @@ using osu.Framework.Graphics;
using osu.Framework.Input; using osu.Framework.Input;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Configuration;
using osu.Game.Input.Handlers; using osu.Game.Input.Handlers;
using osu.Game.Replays; using osu.Game.Replays;
using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Beatmaps;
@ -52,22 +50,6 @@ namespace osu.Game.Rulesets.Mania.UI
protected new ManiaRulesetConfigManager Config => (ManiaRulesetConfigManager)base.Config; protected new ManiaRulesetConfigManager Config => (ManiaRulesetConfigManager)base.Config;
public ScrollVisualisationMethod ScrollMethod
{
get => scrollMethod;
set
{
if (IsLoaded)
throw new InvalidOperationException($"Can't alter {nameof(ScrollMethod)} after ruleset is already loaded");
scrollMethod = value;
}
}
private ScrollVisualisationMethod scrollMethod = ScrollVisualisationMethod.Sequential;
protected override ScrollVisualisationMethod VisualisationMethod => scrollMethod;
private readonly Bindable<ManiaScrollingDirection> configDirection = new Bindable<ManiaScrollingDirection>(); private readonly Bindable<ManiaScrollingDirection> configDirection = new Bindable<ManiaScrollingDirection>();
private readonly BindableInt configScrollSpeed = new BindableInt(); private readonly BindableInt configScrollSpeed = new BindableInt();
private double smoothTimeRange; private double smoothTimeRange;

View File

@ -195,10 +195,6 @@ namespace osu.Game.Rulesets.Mania.UI
if (!judgedObject.DisplayResult || !DisplayJudgements.Value) if (!judgedObject.DisplayResult || !DisplayJudgements.Value)
return; return;
// Tick judgements should not display text.
if (judgedObject is DrawableHoldNoteTick)
return;
judgements.Clear(false); judgements.Clear(false);
judgements.Add(judgementPool.Get(j => judgements.Add(judgementPool.Get(j =>
{ {

View File

@ -34,7 +34,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
}); });
moveMouseToHitObject(1); moveMouseToHitObject(1);
AddAssert("merge option available", () => selectionHandler.ContextMenuItems.Any(o => o.Text.Value == "Merge selection")); AddAssert("merge option available", () => selectionHandler.ContextMenuItems?.Any(o => o.Text.Value == "Merge selection") == true);
mergeSelection(); mergeSelection();
@ -198,7 +198,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
}); });
moveMouseToHitObject(1); moveMouseToHitObject(1);
AddAssert("merge option not available", () => selectionHandler.ContextMenuItems.Length > 0 && selectionHandler.ContextMenuItems.All(o => o.Text.Value != "Merge selection")); AddAssert("merge option not available", () => selectionHandler.ContextMenuItems?.Length > 0 && selectionHandler.ContextMenuItems.All(o => o.Text.Value != "Merge selection"));
mergeSelection(); mergeSelection();
AddAssert("circles not merged", () => circle1 is not null && circle2 is not null AddAssert("circles not merged", () => circle1 is not null && circle2 is not null
&& EditorBeatmap.HitObjects.Contains(circle1) && EditorBeatmap.HitObjects.Contains(circle2)); && EditorBeatmap.HitObjects.Contains(circle1) && EditorBeatmap.HitObjects.Contains(circle2));
@ -222,7 +222,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
}); });
moveMouseToHitObject(1); moveMouseToHitObject(1);
AddAssert("merge option available", () => selectionHandler.ContextMenuItems.Any(o => o.Text.Value == "Merge selection")); AddAssert("merge option available", () => selectionHandler.ContextMenuItems?.Any(o => o.Text.Value == "Merge selection") == true);
mergeSelection(); mergeSelection();

View File

@ -169,7 +169,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
AddStep($"move mouse to control point {index}", () => AddStep($"move mouse to control point {index}", () =>
{ {
Vector2 position = slider.Path.ControlPoints[index].Position; Vector2 position = slider.Path.ControlPoints[index].Position;
InputManager.MoveMouseTo(visualiser.Pieces[0].Parent.ToScreenSpace(position)); InputManager.MoveMouseTo(visualiser.Pieces[0].Parent!.ToScreenSpace(position));
}); });
} }

View File

@ -169,7 +169,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
AddStep($"move mouse to {relativePosition}", () => AddStep($"move mouse to {relativePosition}", () =>
{ {
Vector2 position = slider.Position + relativePosition; Vector2 position = slider.Position + relativePosition;
InputManager.MoveMouseTo(drawableObject.Parent.ToScreenSpace(position)); InputManager.MoveMouseTo(drawableObject.Parent!.ToScreenSpace(position));
}); });
[Test] [Test]
@ -331,7 +331,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
AddStep($"move mouse to {relativePosition}", () => AddStep($"move mouse to {relativePosition}", () =>
{ {
Vector2 position = slider.Position + relativePosition; Vector2 position = slider.Position + relativePosition;
InputManager.MoveMouseTo(drawableObject.Parent.ToScreenSpace(position)); InputManager.MoveMouseTo(drawableObject.Parent!.ToScreenSpace(position));
}); });
} }
@ -340,7 +340,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
AddStep($"move mouse to control point {index}", () => AddStep($"move mouse to control point {index}", () =>
{ {
Vector2 position = slider.Position + slider.Path.ControlPoints[index].Position; Vector2 position = slider.Position + slider.Path.ControlPoints[index].Position;
InputManager.MoveMouseTo(drawableObject.Parent.ToScreenSpace(position)); InputManager.MoveMouseTo(drawableObject.Parent!.ToScreenSpace(position));
}); });
} }

View File

@ -187,7 +187,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
AddStep($"move mouse to control point {index}", () => AddStep($"move mouse to control point {index}", () =>
{ {
Vector2 position = slider.Position + slider.Path.ControlPoints[index].Position; Vector2 position = slider.Position + slider.Path.ControlPoints[index].Position;
InputManager.MoveMouseTo(drawableObject.Parent.ToScreenSpace(position)); InputManager.MoveMouseTo(drawableObject.Parent!.ToScreenSpace(position));
}); });
} }

View File

@ -101,7 +101,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
{ {
var firstPiece = this.ChildrenOfType<PathControlPointPiece<Slider>>().Single(piece => piece.ControlPoint == slider.Path.ControlPoints[0]); var firstPiece = this.ChildrenOfType<PathControlPointPiece<Slider>>().Single(piece => piece.ControlPoint == slider.Path.ControlPoints[0]);
var pos = slider.Path.PositionAt(0.25d) + slider.Position; var pos = slider.Path.PositionAt(0.25d) + slider.Position;
InputManager.MoveMouseTo(firstPiece.Parent.ToScreenSpace(pos)); InputManager.MoveMouseTo(firstPiece.Parent!.ToScreenSpace(pos));
}); });
AddStep("move slider end", () => AddStep("move slider end", () =>
{ {

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