Compare commits
371 Commits
@@ -1,206 +1,365 @@
|
||||
# Listens for new PR comments containing !pp check [id], and runs a diffcalc comparison against master.
|
||||
# Usage:
|
||||
# !pp check 0 | Runs only the osu! ruleset.
|
||||
# !pp check 0 2 | Runs only the osu! and catch rulesets.
|
||||
# ## Description
|
||||
#
|
||||
# Uses [diffcalc-sheet-generator](https://github.com/smoogipoo/diffcalc-sheet-generator) to run two builds of osu and generate an SR/PP/Score comparison spreadsheet.
|
||||
#
|
||||
# ## Requirements
|
||||
#
|
||||
# Self-hosted runner with installed:
|
||||
# - `docker >= 20.10.16`
|
||||
# - `docker-compose >= 2.5.1`
|
||||
# - `lbzip2`
|
||||
# - `jq`
|
||||
#
|
||||
# ## Usage
|
||||
#
|
||||
# The workflow can be run in two ways:
|
||||
# 1. Via workflow dispatch.
|
||||
# 2. By an owner of the repository posting a pull request or issue comment containing `!diffcalc`.
|
||||
# For pull requests, the workflow will assume the pull request as the target to compare against (i.e. the `OSU_B` variable).
|
||||
# Any lines in the comment of the form `KEY=VALUE` are treated as variables for the generator.
|
||||
#
|
||||
# ## Google Service Account
|
||||
#
|
||||
# Spreadsheets are uploaded to a Google Service Account, and exposed with read-only permissions to the wider audience.
|
||||
#
|
||||
# 1. Create a project at https://console.cloud.google.com
|
||||
# 2. Enable the `Google Sheets` and `Google Drive` APIs.
|
||||
# 3. Create a Service Account
|
||||
# 4. Generate a key in the JSON format.
|
||||
# 5. Encode the key as base64 and store as an **actions secret** with name **`DIFFCALC_GOOGLE_CREDENTIALS`**
|
||||
#
|
||||
# ## Environment variables
|
||||
#
|
||||
# The default environment may be configured via **actions variables**.
|
||||
#
|
||||
# Refer to [the sample environment](https://github.com/smoogipoo/diffcalc-sheet-generator/blob/master/.env.sample), and prefix each variable with `DIFFCALC_` (e.g. `DIFFCALC_THREADS`, `DIFFCALC_INNODB_BUFFER_SIZE`, etc...).
|
||||
|
||||
name: Run difficulty calculation comparison
|
||||
|
||||
run-name: "${{ github.event_name == 'workflow_dispatch' && format('Manual run: {0}', inputs.osu-b) || 'Automatic comment trigger' }}"
|
||||
|
||||
name: Difficulty Calculation
|
||||
on:
|
||||
issue_comment:
|
||||
types: [ created ]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
osu-b:
|
||||
description: "The target build of ppy/osu"
|
||||
type: string
|
||||
required: true
|
||||
ruleset:
|
||||
description: "The ruleset to process"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- osu
|
||||
- taiko
|
||||
- catch
|
||||
- mania
|
||||
converts:
|
||||
description: "Include converted beatmaps"
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
ranked-only:
|
||||
description: "Only ranked beatmaps"
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
generators:
|
||||
description: "Comma-separated list of generators (available: [sr, pp, score])"
|
||||
type: string
|
||||
required: false
|
||||
default: 'pp,sr'
|
||||
osu-a:
|
||||
description: "The source build of ppy/osu"
|
||||
type: string
|
||||
required: false
|
||||
default: 'latest'
|
||||
difficulty-calculator-a:
|
||||
description: "The source build of ppy/osu-difficulty-calculator"
|
||||
type: string
|
||||
required: false
|
||||
default: 'latest'
|
||||
difficulty-calculator-b:
|
||||
description: "The target build of ppy/osu-difficulty-calculator"
|
||||
type: string
|
||||
required: false
|
||||
default: 'latest'
|
||||
score-processor-a:
|
||||
description: "The source build of ppy/osu-queue-score-statistics"
|
||||
type: string
|
||||
required: false
|
||||
default: 'latest'
|
||||
score-processor-b:
|
||||
description: "The target build of ppy/osu-queue-score-statistics"
|
||||
type: string
|
||||
required: false
|
||||
default: 'latest'
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
CONCURRENCY: 4
|
||||
ALLOW_DOWNLOAD: 1
|
||||
SAVE_DOWNLOADED: 1
|
||||
SKIP_INSERT_ATTRIBUTES: 1
|
||||
COMMENT_TAG: execution-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}
|
||||
|
||||
jobs:
|
||||
metadata:
|
||||
name: Check for requests
|
||||
wait-for-queue:
|
||||
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
|
||||
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:
|
||||
matrix: ${{ steps.generate-matrix.outputs.matrix }}
|
||||
continue: ${{ steps.generate-matrix.outputs.continue }}
|
||||
GENERATOR_DIR: ${{ steps.set-outputs.outputs.GENERATOR_DIR }}
|
||||
GENERATOR_ENV: ${{ steps.set-outputs.outputs.GENERATOR_ENV }}
|
||||
GOOGLE_CREDS_FILE: ${{ steps.set-outputs.outputs.GOOGLE_CREDS_FILE }}
|
||||
steps:
|
||||
- name: Construct build matrix
|
||||
id: generate-matrix
|
||||
- name: Checkout
|
||||
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: |
|
||||
if [[ "${{ github.event.comment.body }}" =~ "osu" ]] ; then
|
||||
MATRIX_PROJECTS_JSON+='{ "name": "osu", "id": 0 },'
|
||||
fi
|
||||
if [[ "${{ github.event.comment.body }}" =~ "taiko" ]] ; then
|
||||
MATRIX_PROJECTS_JSON+='{ "name": "taiko", "id": 1 },'
|
||||
fi
|
||||
if [[ "${{ github.event.comment.body }}" =~ "catch" ]] ; then
|
||||
MATRIX_PROJECTS_JSON+='{ "name": "catch", "id": 2 },'
|
||||
fi
|
||||
if [[ "${{ github.event.comment.body }}" =~ "mania" ]] ; then
|
||||
MATRIX_PROJECTS_JSON+='{ "name": "mania", "id": 3 },'
|
||||
fi
|
||||
echo "GENERATOR_DIR=${{ github.workspace }}/diffcalc-sheet-generator" >> "${GITHUB_OUTPUT}"
|
||||
echo "GENERATOR_ENV=${{ github.workspace }}/diffcalc-sheet-generator/.env" >> "${GITHUB_OUTPUT}"
|
||||
echo "GOOGLE_CREDS_FILE=${{ github.workspace }}/diffcalc-sheet-generator/google-credentials.json" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
if [[ "${MATRIX_PROJECTS_JSON}" != "" ]]; then
|
||||
MATRIX_JSON="{ \"ruleset\": [ ${MATRIX_PROJECTS_JSON} ] }"
|
||||
echo "${MATRIX_JSON}"
|
||||
CONTINUE="yes"
|
||||
else
|
||||
CONTINUE="no"
|
||||
fi
|
||||
|
||||
echo "continue=${CONTINUE}" >> $GITHUB_OUTPUT
|
||||
echo "matrix=${MATRIX_JSON}" >> $GITHUB_OUTPUT
|
||||
diffcalc:
|
||||
name: Run
|
||||
environment:
|
||||
name: Setup environment
|
||||
needs: directory
|
||||
runs-on: self-hosted
|
||||
timeout-minutes: 1440
|
||||
if: needs.metadata.outputs.continue == 'yes'
|
||||
needs: metadata
|
||||
strategy:
|
||||
matrix: ${{ fromJson(needs.metadata.outputs.matrix) }}
|
||||
if: ${{ !cancelled() && needs.directory.result == 'success' }}
|
||||
env:
|
||||
VARS_JSON: ${{ toJSON(vars) }}
|
||||
steps:
|
||||
- name: Verify MySQL connection from host
|
||||
- name: Add base environment
|
||||
run: |
|
||||
mysql -e "SHOW DATABASES"
|
||||
# Required by diffcalc-sheet-generator
|
||||
cp '${{ github.workspace }}/diffcalc-sheet-generator/.env.sample' "${{ needs.directory.outputs.GENERATOR_ENV }}"
|
||||
|
||||
- name: Drop previous databases
|
||||
run: |
|
||||
for db in osu_master osu_pr
|
||||
do
|
||||
mysql -e "DROP DATABASE IF EXISTS $db"
|
||||
# Add Google credentials
|
||||
echo '${{ secrets.DIFFCALC_GOOGLE_CREDENTIALS }}' | base64 -d > "${{ needs.directory.outputs.GOOGLE_CREDS_FILE }}"
|
||||
|
||||
# Add repository variables
|
||||
echo "${VARS_JSON}" | jq -c '. | to_entries | .[]' | while read -r line; do
|
||||
opt=$(jq -r '.key' <<< ${line})
|
||||
val=$(jq -r '.value' <<< ${line})
|
||||
|
||||
if [[ "${opt}" =~ ^DIFFCALC_ ]]; then
|
||||
optNoPrefix=$(echo "${opt}" | cut -d '_' -f2-)
|
||||
sed -i "s;^${optNoPrefix}=.*$;${optNoPrefix}=${val};" "${{ needs.directory.outputs.GENERATOR_ENV }}"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Create directory structure
|
||||
- name: Add pull-request environment
|
||||
if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request }}
|
||||
run: |
|
||||
mkdir -p $GITHUB_WORKSPACE/master/
|
||||
mkdir -p $GITHUB_WORKSPACE/pr/
|
||||
sed -i "s;^OSU_B=.*$;OSU_B=${{ github.event.issue.pull_request.url }};" "${{ needs.directory.outputs.GENERATOR_ENV }}"
|
||||
|
||||
- name: Get upstream branch # https://akaimo.hatenablog.jp/entry/2020/05/16/101251
|
||||
id: upstreambranch
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Add comment environment
|
||||
if: ${{ github.event_name == 'issue_comment' }}
|
||||
run: |
|
||||
echo "branchname=$(curl -H "Authorization: token ${GITHUB_TOKEN}" ${{ github.event.issue.pull_request.url }} | jq '.head.ref' | sed 's/\"//g')" >> $GITHUB_OUTPUT
|
||||
echo "repo=$(curl -H "Authorization: token ${GITHUB_TOKEN}" ${{ github.event.issue.pull_request.url }} | jq '.head.repo.full_name' | sed 's/\"//g')" >> $GITHUB_OUTPUT
|
||||
|
||||
# Checkout osu
|
||||
- name: Checkout osu (master)
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
path: 'master/osu'
|
||||
- name: Checkout osu (pr)
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
path: 'pr/osu'
|
||||
repository: ${{ steps.upstreambranch.outputs.repo }}
|
||||
ref: ${{ steps.upstreambranch.outputs.branchname }}
|
||||
|
||||
- name: Checkout osu-difficulty-calculator (master)
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
repository: ppy/osu-difficulty-calculator
|
||||
path: 'master/osu-difficulty-calculator'
|
||||
- name: Checkout osu-difficulty-calculator (pr)
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
repository: ppy/osu-difficulty-calculator
|
||||
path: 'pr/osu-difficulty-calculator'
|
||||
|
||||
- name: Install .NET 5.0.x
|
||||
uses: actions/setup-dotnet@v3
|
||||
with:
|
||||
dotnet-version: "5.0.x"
|
||||
|
||||
# Sanity checks to make sure diffcalc is not run when incompatible.
|
||||
- name: Build diffcalc (master)
|
||||
run: |
|
||||
cd $GITHUB_WORKSPACE/master/osu-difficulty-calculator
|
||||
./UseLocalOsu.sh
|
||||
dotnet build
|
||||
- name: Build diffcalc (pr)
|
||||
run: |
|
||||
cd $GITHUB_WORKSPACE/pr/osu-difficulty-calculator
|
||||
./UseLocalOsu.sh
|
||||
dotnet build
|
||||
|
||||
- name: Download + import data
|
||||
run: |
|
||||
PERFORMANCE_DATA_NAME=$(curl https://data.ppy.sh/ | grep performance_${{ matrix.ruleset.name }}_top_1000 | tail -1 | awk -F "\"" '{print $2}' | sed 's/\.tar\.bz2//g')
|
||||
BEATMAPS_DATA_NAME=$(curl https://data.ppy.sh/ | grep osu_files | tail -1 | awk -F "\"" '{print $2}' | sed 's/\.tar\.bz2//g')
|
||||
|
||||
# Set env variable for further steps.
|
||||
echo "BEATMAPS_PATH=$GITHUB_WORKSPACE/$BEATMAPS_DATA_NAME" >> $GITHUB_ENV
|
||||
|
||||
cd $GITHUB_WORKSPACE
|
||||
|
||||
echo "Downloading database dump $PERFORMANCE_DATA_NAME.."
|
||||
wget -q -nc https://data.ppy.sh/$PERFORMANCE_DATA_NAME.tar.bz2
|
||||
echo "Extracting.."
|
||||
tar -xf $PERFORMANCE_DATA_NAME.tar.bz2
|
||||
|
||||
echo "Downloading beatmap dump $BEATMAPS_DATA_NAME.."
|
||||
wget -q -nc https://data.ppy.sh/$BEATMAPS_DATA_NAME.tar.bz2
|
||||
echo "Extracting.."
|
||||
tar -xf $BEATMAPS_DATA_NAME.tar.bz2
|
||||
|
||||
cd $PERFORMANCE_DATA_NAME
|
||||
|
||||
for db in osu_master osu_pr
|
||||
do
|
||||
echo "Setting up database $db.."
|
||||
|
||||
mysql -e "CREATE DATABASE $db"
|
||||
|
||||
echo "Importing beatmaps.."
|
||||
cat osu_beatmaps.sql | mysql $db
|
||||
echo "Importing beatmapsets.."
|
||||
cat osu_beatmapsets.sql | mysql $db
|
||||
|
||||
echo "Creating table structure.."
|
||||
mysql $db -e 'CREATE TABLE `osu_beatmap_difficulty` (
|
||||
`beatmap_id` int unsigned NOT NULL,
|
||||
`mode` tinyint NOT NULL DEFAULT 0,
|
||||
`mods` int unsigned NOT NULL,
|
||||
`diff_unified` float NOT NULL,
|
||||
`last_update` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`beatmap_id`,`mode`,`mods`),
|
||||
KEY `diff_sort` (`mode`,`mods`,`diff_unified`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;'
|
||||
# Add comment environment
|
||||
echo '${{ github.event.comment.body }}' | sed -r 's/\r$//' | grep -E '^\w+=' | while read -r line; do
|
||||
opt=$(echo ${line} | cut -d '=' -f1)
|
||||
sed -i "s;^${opt}=.*$;${line};" "${{ needs.directory.outputs.GENERATOR_ENV }}"
|
||||
done
|
||||
|
||||
- name: Run diffcalc (master)
|
||||
env:
|
||||
DB_NAME: osu_master
|
||||
- name: Add dispatch environment
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
run: |
|
||||
cd $GITHUB_WORKSPACE/master/osu-difficulty-calculator/osu.Server.DifficultyCalculator
|
||||
dotnet run -c:Release -- all -m ${{ matrix.ruleset.id }} -ac -c ${{ env.CONCURRENCY }}
|
||||
- name: Run diffcalc (pr)
|
||||
env:
|
||||
DB_NAME: osu_pr
|
||||
run: |
|
||||
cd $GITHUB_WORKSPACE/pr/osu-difficulty-calculator/osu.Server.DifficultyCalculator
|
||||
dotnet run -c:Release -- all -m ${{ matrix.ruleset.id }} -ac -c ${{ env.CONCURRENCY }}
|
||||
sed -i 's;^OSU_B=.*$;OSU_B=${{ inputs.osu-b }};' "${{ needs.directory.outputs.GENERATOR_ENV }}"
|
||||
sed -i 's/^RULESET=.*$/RULESET=${{ inputs.ruleset }}/' "${{ needs.directory.outputs.GENERATOR_ENV }}"
|
||||
sed -i 's/^GENERATORS=.*$/GENERATORS=${{ inputs.generators }}/' "${{ needs.directory.outputs.GENERATOR_ENV }}"
|
||||
|
||||
- name: Print diffs
|
||||
run: |
|
||||
mysql -e "
|
||||
SELECT
|
||||
m.beatmap_id,
|
||||
m.mods,
|
||||
b.filename,
|
||||
m.diff_unified as 'sr_master',
|
||||
p.diff_unified as 'sr_pr',
|
||||
(p.diff_unified - m.diff_unified) as 'diff'
|
||||
FROM osu_master.osu_beatmap_difficulty m
|
||||
JOIN osu_pr.osu_beatmap_difficulty p
|
||||
ON m.beatmap_id = p.beatmap_id
|
||||
AND m.mode = p.mode
|
||||
AND m.mods = p.mods
|
||||
JOIN osu_pr.osu_beatmaps b
|
||||
ON b.beatmap_id = p.beatmap_id
|
||||
WHERE abs(m.diff_unified - p.diff_unified) > 0.1
|
||||
ORDER BY abs(m.diff_unified - p.diff_unified)
|
||||
DESC
|
||||
LIMIT 10000;"
|
||||
if [[ '${{ inputs.osu-a }}' != 'latest' ]]; then
|
||||
sed -i 's;^OSU_A=.*$;OSU_A=${{ inputs.osu-a }};' "${{ needs.directory.outputs.GENERATOR_ENV }}"
|
||||
fi
|
||||
|
||||
# Todo: Run ppcalc
|
||||
if [[ '${{ inputs.difficulty-calculator-a }}' != 'latest' ]]; then
|
||||
sed -i 's;^DIFFICULTY_CALCULATOR_A=.*$;DIFFICULTY_CALCULATOR_A=${{ inputs.difficulty-calculator-a }};' "${{ needs.directory.outputs.GENERATOR_ENV }}"
|
||||
fi
|
||||
|
||||
if [[ '${{ inputs.difficulty-calculator-b }}' != 'latest' ]]; then
|
||||
sed -i 's;^DIFFICULTY_CALCULATOR_B=.*$;DIFFICULTY_CALCULATOR_B=${{ inputs.difficulty-calculator-b }};' "${{ needs.directory.outputs.GENERATOR_ENV }}"
|
||||
fi
|
||||
|
||||
if [[ '${{ inputs.score-processor-a }}' != 'latest' ]]; then
|
||||
sed -i 's;^SCORE_PROCESSOR_A=.*$;SCORE_PROCESSOR_A=${{ inputs.score-processor-a }};' "${{ needs.directory.outputs.GENERATOR_ENV }}"
|
||||
fi
|
||||
|
||||
if [[ '${{ inputs.score-processor-b }}' != 'latest' ]]; then
|
||||
sed -i 's;^SCORE_PROCESSOR_B=.*$;SCORE_PROCESSOR_B=${{ inputs.score-processor-b }};' "${{ needs.directory.outputs.GENERATOR_ENV }}"
|
||||
fi
|
||||
|
||||
if [[ '${{ inputs.converts }}' == 'true' ]]; then
|
||||
sed -i 's/^NO_CONVERTS=.*$/NO_CONVERTS=0/' "${{ needs.directory.outputs.GENERATOR_ENV }}"
|
||||
else
|
||||
sed -i 's/^NO_CONVERTS=.*$/NO_CONVERTS=1/' "${{ needs.directory.outputs.GENERATOR_ENV }}"
|
||||
fi
|
||||
|
||||
if [[ '${{ inputs.ranked-only }}' == 'true' ]]; then
|
||||
sed -i 's/^RANKED_ONLY=.*$/RANKED_ONLY=1/' "${{ needs.directory.outputs.GENERATOR_ENV }}"
|
||||
else
|
||||
sed -i 's/^RANKED_ONLY=.*$/RANKED_ONLY=0/' "${{ needs.directory.outputs.GENERATOR_ENV }}"
|
||||
fi
|
||||
|
||||
scores:
|
||||
name: Setup scores
|
||||
needs: [ directory, environment ]
|
||||
runs-on: self-hosted
|
||||
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 }}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ppy.osu.Framework.Android" Version="2023.922.0" />
|
||||
<PackageReference Include="ppy.osu.Framework.Android" Version="2023.1012.0" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup>
|
||||
<!-- Fody does not handle Android build well, and warns when unchanged.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BenchmarkDotNet" Version="0.13.8" />
|
||||
<PackageReference Include="BenchmarkDotNet" Version="0.13.9" />
|
||||
<PackageReference Include="nunit" Version="3.13.3" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
@@ -41,7 +41,6 @@ namespace osu.Game.Rulesets.Catch.Beatmaps
|
||||
X = xPositionData?.X ?? 0,
|
||||
NewCombo = comboData?.NewCombo ?? false,
|
||||
ComboOffset = comboData?.ComboOffset ?? 0,
|
||||
LegacyLastTickOffset = (obj as IHasLegacyLastTickOffset)?.LegacyLastTickOffset ?? 0,
|
||||
LegacyConvertedY = yPositionData?.Y ?? CatchHitObject.DEFAULT_LEGACY_CONVERT_Y,
|
||||
SliderVelocityMultiplier = sliderVelocityData?.SliderVelocityMultiplier ?? 1
|
||||
}.Yield();
|
||||
|
||||
@@ -25,6 +25,7 @@ using osu.Game.Rulesets.Edit;
|
||||
using osu.Game.Rulesets.Mods;
|
||||
using osu.Game.Rulesets.Replays.Types;
|
||||
using osu.Game.Rulesets.Scoring;
|
||||
using osu.Game.Rulesets.Scoring.Legacy;
|
||||
using osu.Game.Rulesets.UI;
|
||||
using osu.Game.Scoring;
|
||||
using osu.Game.Screens.Ranking.Statistics;
|
||||
|
||||
@@ -25,12 +25,9 @@ namespace osu.Game.Rulesets.Catch.Difficulty
|
||||
|
||||
public override int Version => 20220701;
|
||||
|
||||
private readonly IWorkingBeatmap workingBeatmap;
|
||||
|
||||
public CatchDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
|
||||
: base(ruleset, beatmap)
|
||||
{
|
||||
workingBeatmap = beatmap;
|
||||
}
|
||||
|
||||
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
|
||||
@@ -49,15 +46,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty
|
||||
MaxCombo = beatmap.HitObjects.Count(h => h is Fruit) + beatmap.HitObjects.OfType<JuiceStream>().SelectMany(j => j.NestedHitObjects).Count(h => !(h is TinyDroplet)),
|
||||
};
|
||||
|
||||
if (ComputeLegacyScoringValues)
|
||||
{
|
||||
CatchLegacyScoreSimulator sv1Simulator = new CatchLegacyScoreSimulator();
|
||||
sv1Simulator.Simulate(workingBeatmap, beatmap, mods);
|
||||
attributes.LegacyAccuracyScore = sv1Simulator.AccuracyScore;
|
||||
attributes.LegacyComboScore = sv1Simulator.ComboScore;
|
||||
attributes.LegacyBonusScoreRatio = sv1Simulator.BonusScoreRatio;
|
||||
}
|
||||
|
||||
return attributes;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,30 +5,26 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Rulesets.Catch.Mods;
|
||||
using osu.Game.Rulesets.Catch.Objects;
|
||||
using osu.Game.Rulesets.Judgements;
|
||||
using osu.Game.Rulesets.Mods;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
using osu.Game.Rulesets.Objects.Types;
|
||||
using osu.Game.Rulesets.Scoring;
|
||||
using osu.Game.Rulesets.Scoring.Legacy;
|
||||
|
||||
namespace osu.Game.Rulesets.Catch.Difficulty
|
||||
{
|
||||
internal class CatchLegacyScoreSimulator : ILegacyScoreSimulator
|
||||
{
|
||||
public int AccuracyScore { get; private set; }
|
||||
|
||||
public int ComboScore { get; private set; }
|
||||
|
||||
public double BonusScoreRatio => legacyBonusScore == 0 ? 0 : (double)modernBonusScore / legacyBonusScore;
|
||||
|
||||
private int legacyBonusScore;
|
||||
private int modernBonusScore;
|
||||
private int standardisedBonusScore;
|
||||
private int combo;
|
||||
|
||||
private double scoreMultiplier;
|
||||
|
||||
public void Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap, IReadOnlyList<Mod> mods)
|
||||
public LegacyScoreAttributes Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap)
|
||||
{
|
||||
IBeatmap baseBeatmap = workingBeatmap.Beatmap;
|
||||
|
||||
@@ -70,13 +66,19 @@ namespace osu.Game.Rulesets.Catch.Difficulty
|
||||
+ baseBeatmap.Difficulty.CircleSize
|
||||
+ Math.Clamp((float)objectCount / drainLength * 8, 0, 16)) / 38 * 5);
|
||||
|
||||
scoreMultiplier = difficultyPeppyStars * mods.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier);
|
||||
scoreMultiplier = difficultyPeppyStars;
|
||||
|
||||
LegacyScoreAttributes attributes = new LegacyScoreAttributes();
|
||||
|
||||
foreach (var obj in playableBeatmap.HitObjects)
|
||||
simulateHit(obj);
|
||||
simulateHit(obj, ref attributes);
|
||||
|
||||
attributes.BonusScoreRatio = legacyBonusScore == 0 ? 0 : (double)standardisedBonusScore / legacyBonusScore;
|
||||
|
||||
return attributes;
|
||||
}
|
||||
|
||||
private void simulateHit(HitObject hitObject)
|
||||
private void simulateHit(HitObject hitObject, ref LegacyScoreAttributes attributes)
|
||||
{
|
||||
bool increaseCombo = true;
|
||||
bool addScoreComboMultiplier = false;
|
||||
@@ -112,31 +114,79 @@ namespace osu.Game.Rulesets.Catch.Difficulty
|
||||
|
||||
case JuiceStream:
|
||||
foreach (var nested in hitObject.NestedHitObjects)
|
||||
simulateHit(nested);
|
||||
simulateHit(nested, ref attributes);
|
||||
return;
|
||||
|
||||
case BananaShower:
|
||||
foreach (var nested in hitObject.NestedHitObjects)
|
||||
simulateHit(nested);
|
||||
simulateHit(nested, ref attributes);
|
||||
return;
|
||||
}
|
||||
|
||||
if (addScoreComboMultiplier)
|
||||
{
|
||||
// ReSharper disable once PossibleLossOfFraction (intentional to match osu-stable...)
|
||||
ComboScore += (int)(Math.Max(0, combo - 1) * (scoreIncrease / 25 * scoreMultiplier));
|
||||
attributes.ComboScore += (int)(Math.Max(0, combo - 1) * (scoreIncrease / 25 * scoreMultiplier));
|
||||
}
|
||||
|
||||
if (isBonus)
|
||||
{
|
||||
legacyBonusScore += scoreIncrease;
|
||||
modernBonusScore += Judgement.ToNumericResult(bonusResult);
|
||||
standardisedBonusScore += Judgement.ToNumericResult(bonusResult);
|
||||
}
|
||||
else
|
||||
AccuracyScore += scoreIncrease;
|
||||
attributes.AccuracyScore += scoreIncrease;
|
||||
|
||||
if (increaseCombo)
|
||||
combo++;
|
||||
}
|
||||
|
||||
public double GetLegacyScoreMultiplier(IReadOnlyList<Mod> mods, LegacyBeatmapConversionDifficultyInfo difficulty)
|
||||
{
|
||||
bool scoreV2 = mods.Any(m => m is ModScoreV2);
|
||||
|
||||
double multiplier = 1.0;
|
||||
|
||||
foreach (var mod in mods)
|
||||
{
|
||||
switch (mod)
|
||||
{
|
||||
case CatchModNoFail:
|
||||
multiplier *= scoreV2 ? 1.0 : 0.5;
|
||||
break;
|
||||
|
||||
case CatchModEasy:
|
||||
multiplier *= 0.5;
|
||||
break;
|
||||
|
||||
case CatchModHalfTime:
|
||||
case CatchModDaycore:
|
||||
multiplier *= 0.3;
|
||||
break;
|
||||
|
||||
case CatchModHidden:
|
||||
multiplier *= scoreV2 ? 1.0 : 1.06;
|
||||
break;
|
||||
|
||||
case CatchModHardRock:
|
||||
multiplier *= 1.12;
|
||||
break;
|
||||
|
||||
case CatchModDoubleTime:
|
||||
case CatchModNightcore:
|
||||
multiplier *= 1.06;
|
||||
break;
|
||||
|
||||
case CatchModFlashlight:
|
||||
multiplier *= 1.12;
|
||||
break;
|
||||
|
||||
case CatchModRelax:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
return multiplier;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ namespace osu.Game.Rulesets.Catch.Objects
|
||||
int nodeIndex = 0;
|
||||
SliderEventDescriptor? lastEvent = null;
|
||||
|
||||
foreach (var e in SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), LegacyLastTickOffset, cancellationToken))
|
||||
foreach (var e in SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), cancellationToken))
|
||||
{
|
||||
// generate tiny droplets since the last point
|
||||
if (lastEvent != null)
|
||||
@@ -104,8 +104,8 @@ namespace osu.Game.Rulesets.Catch.Objects
|
||||
}
|
||||
}
|
||||
|
||||
// this also includes LegacyLastTick and this is used for TinyDroplet generation above.
|
||||
// this means that the final segment of TinyDroplets are increasingly mistimed where LegacyLastTickOffset is being applied.
|
||||
// this also includes LastTick and this is used for TinyDroplet generation above.
|
||||
// this means that the final segment of TinyDroplets are increasingly mistimed where LastTick is being applied.
|
||||
lastEvent = e;
|
||||
|
||||
switch (e.Type)
|
||||
@@ -162,7 +162,5 @@ namespace osu.Game.Rulesets.Catch.Objects
|
||||
public double Distance => Path.Distance;
|
||||
|
||||
public IList<IList<HitSampleInfo>> NodeSamples { get; set; } = new List<IList<HitSampleInfo>>();
|
||||
|
||||
public double? LegacyLastTickOffset { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,11 @@ namespace osu.Game.Rulesets.Catch.Skinning.Argon
|
||||
[BackgroundDependencyLoader]
|
||||
private void load()
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both;
|
||||
Anchor = Anchor.TopCentre;
|
||||
Origin = Anchor.Centre;
|
||||
|
||||
RelativeSizeAxes = Axes.X;
|
||||
AutoSizeAxes = Axes.Y;
|
||||
|
||||
InternalChildren = new Drawable[]
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@ using osu.Framework.Graphics.Containers;
|
||||
using osu.Framework.Graphics.Sprites;
|
||||
using osu.Framework.Graphics.Textures;
|
||||
using osu.Game.Rulesets.Catch.UI;
|
||||
using osuTK;
|
||||
|
||||
namespace osu.Game.Rulesets.Catch.Skinning.Default
|
||||
{
|
||||
@@ -22,6 +23,7 @@ namespace osu.Game.Rulesets.Catch.Skinning.Default
|
||||
|
||||
public DefaultCatcher()
|
||||
{
|
||||
Anchor = Anchor.TopCentre;
|
||||
RelativeSizeAxes = Axes.Both;
|
||||
InternalChild = sprite = new Sprite
|
||||
{
|
||||
@@ -32,6 +34,15 @@ namespace osu.Game.Rulesets.Catch.Skinning.Default
|
||||
};
|
||||
}
|
||||
|
||||
protected override void Update()
|
||||
{
|
||||
base.Update();
|
||||
|
||||
// matches stable's origin position since we're using the same catcher sprite.
|
||||
// see LegacyCatcher for more information.
|
||||
OriginPosition = new Vector2(DrawWidth / 2, 16f);
|
||||
}
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load(TextureStore store, Bindable<CatcherAnimationState> currentState)
|
||||
{
|
||||
|
||||
@@ -2,17 +2,21 @@
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using osu.Framework.Graphics.Textures;
|
||||
using osu.Game.Skinning;
|
||||
using osuTK;
|
||||
|
||||
namespace osu.Game.Rulesets.Catch.Skinning.Legacy
|
||||
{
|
||||
public partial class LegacyBananaPiece : LegacyCatchHitObjectPiece
|
||||
{
|
||||
private static readonly Vector2 banana_max_size = new Vector2(160);
|
||||
|
||||
protected override void LoadComplete()
|
||||
{
|
||||
base.LoadComplete();
|
||||
|
||||
Texture? texture = Skin.GetTexture("fruit-bananas");
|
||||
Texture? overlayTexture = Skin.GetTexture("fruit-bananas-overlay");
|
||||
Texture? texture = Skin.GetTexture("fruit-bananas")?.WithMaximumSize(banana_max_size);
|
||||
Texture? overlayTexture = Skin.GetTexture("fruit-bananas-overlay")?.WithMaximumSize(banana_max_size);
|
||||
|
||||
SetTexture(texture, overlayTexture);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osuTK;
|
||||
|
||||
namespace osu.Game.Rulesets.Catch.Skinning.Legacy
|
||||
{
|
||||
public abstract partial class LegacyCatcher : CompositeDrawable
|
||||
{
|
||||
protected LegacyCatcher()
|
||||
{
|
||||
Anchor = Anchor.TopCentre;
|
||||
Origin = Anchor.TopCentre;
|
||||
|
||||
// in stable, catcher sprites are displayed in their raw size. stable also has catcher sprites displayed with the following scale factors applied:
|
||||
// 1. 0.5x, affecting all sprites in the playfield, computed here based on lazer's catch playfield dimensions (see WIDTH/HEIGHT constants in CatchPlayfield),
|
||||
// source: https://github.com/peppy/osu-stable-reference/blob/1531237b63392e82c003c712faa028406073aa8f/osu!/GameplayElements/HitObjectManager.cs#L483-L494
|
||||
// 2. 0.7x, a constant scale applied to all catcher sprites on construction.
|
||||
AutoSizeAxes = Axes.Both;
|
||||
Scale = new Vector2(0.5f * 0.7f);
|
||||
}
|
||||
|
||||
protected override void Update()
|
||||
{
|
||||
base.Update();
|
||||
|
||||
// stable sets the Y origin position of the catcher to 16px in order for the catching range and OD scaling to align with the top of the catcher's plate in the default skin.
|
||||
OriginPosition = new Vector2(DrawWidth / 2, 16f);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,12 @@ using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Animations;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Game.Rulesets.Catch.UI;
|
||||
using osu.Game.Skinning;
|
||||
using osuTK;
|
||||
|
||||
namespace osu.Game.Rulesets.Catch.Skinning.Legacy
|
||||
{
|
||||
public partial class LegacyCatcherNew : CompositeDrawable
|
||||
public partial class LegacyCatcherNew : LegacyCatcher
|
||||
{
|
||||
[Resolved]
|
||||
private Bindable<CatcherAnimationState> currentState { get; set; } = null!;
|
||||
@@ -23,25 +21,12 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy
|
||||
|
||||
private Drawable currentDrawable = null!;
|
||||
|
||||
public LegacyCatcherNew()
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both;
|
||||
}
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load(ISkinSource skin)
|
||||
{
|
||||
foreach (var state in Enum.GetValues<CatcherAnimationState>())
|
||||
{
|
||||
AddInternal(drawables[state] = getDrawableFor(state).With(d =>
|
||||
{
|
||||
d.Anchor = Anchor.TopCentre;
|
||||
d.Origin = Anchor.TopCentre;
|
||||
d.RelativeSizeAxes = Axes.Both;
|
||||
d.Size = Vector2.One;
|
||||
d.FillMode = FillMode.Fit;
|
||||
d.Alpha = 0;
|
||||
}));
|
||||
AddInternal(drawables[state] = getDrawableFor(state).With(d => d.Alpha = 0));
|
||||
}
|
||||
|
||||
currentDrawable = drawables[CatcherAnimationState.Idle];
|
||||
|
||||
@@ -3,30 +3,21 @@
|
||||
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Game.Skinning;
|
||||
using osuTK;
|
||||
|
||||
namespace osu.Game.Rulesets.Catch.Skinning.Legacy
|
||||
{
|
||||
public partial class LegacyCatcherOld : CompositeDrawable
|
||||
public partial class LegacyCatcherOld : LegacyCatcher
|
||||
{
|
||||
public LegacyCatcherOld()
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both;
|
||||
AutoSizeAxes = Axes.Both;
|
||||
}
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load(ISkinSource skin)
|
||||
{
|
||||
InternalChild = (skin.GetAnimation(@"fruit-ryuuta", true, true, true) ?? Empty()).With(d =>
|
||||
{
|
||||
d.Anchor = Anchor.TopCentre;
|
||||
d.Origin = Anchor.TopCentre;
|
||||
d.RelativeSizeAxes = Axes.Both;
|
||||
d.Size = Vector2.One;
|
||||
d.FillMode = FillMode.Fit;
|
||||
});
|
||||
InternalChild = skin.GetAnimation(@"fruit-ryuuta", true, true, true) ?? Empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using osu.Framework.Graphics.Textures;
|
||||
using osu.Game.Skinning;
|
||||
using osuTK;
|
||||
|
||||
namespace osu.Game.Rulesets.Catch.Skinning.Legacy
|
||||
{
|
||||
public partial class LegacyDropletPiece : LegacyCatchHitObjectPiece
|
||||
{
|
||||
private static readonly Vector2 droplet_max_size = new Vector2(160);
|
||||
|
||||
public LegacyDropletPiece()
|
||||
{
|
||||
Scale = new Vector2(0.8f);
|
||||
@@ -17,8 +20,8 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy
|
||||
{
|
||||
base.LoadComplete();
|
||||
|
||||
Texture? texture = Skin.GetTexture("fruit-drop");
|
||||
Texture? overlayTexture = Skin.GetTexture("fruit-drop-overlay");
|
||||
Texture? texture = Skin.GetTexture("fruit-drop")?.WithMaximumSize(droplet_max_size);
|
||||
Texture? overlayTexture = Skin.GetTexture("fruit-drop-overlay")?.WithMaximumSize(droplet_max_size);
|
||||
|
||||
SetTexture(texture, overlayTexture);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using osu.Game.Rulesets.Catch.Objects;
|
||||
using osu.Game.Skinning;
|
||||
using osuTK;
|
||||
|
||||
namespace osu.Game.Rulesets.Catch.Skinning.Legacy
|
||||
{
|
||||
internal partial class LegacyFruitPiece : LegacyCatchHitObjectPiece
|
||||
{
|
||||
private static readonly Vector2 fruit_max_size = new Vector2(160);
|
||||
|
||||
protected override void LoadComplete()
|
||||
{
|
||||
base.LoadComplete();
|
||||
@@ -22,21 +26,26 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy
|
||||
switch (visualRepresentation)
|
||||
{
|
||||
case FruitVisualRepresentation.Pear:
|
||||
SetTexture(Skin.GetTexture("fruit-pear"), Skin.GetTexture("fruit-pear-overlay"));
|
||||
setTextures("pear");
|
||||
break;
|
||||
|
||||
case FruitVisualRepresentation.Grape:
|
||||
SetTexture(Skin.GetTexture("fruit-grapes"), Skin.GetTexture("fruit-grapes-overlay"));
|
||||
setTextures("grapes");
|
||||
break;
|
||||
|
||||
case FruitVisualRepresentation.Pineapple:
|
||||
SetTexture(Skin.GetTexture("fruit-apple"), Skin.GetTexture("fruit-apple-overlay"));
|
||||
setTextures("apple");
|
||||
break;
|
||||
|
||||
case FruitVisualRepresentation.Raspberry:
|
||||
SetTexture(Skin.GetTexture("fruit-orange"), Skin.GetTexture("fruit-orange-overlay"));
|
||||
setTextures("orange");
|
||||
break;
|
||||
}
|
||||
|
||||
void setTextures(string fruitName) => SetTexture(
|
||||
Skin.GetTexture($"fruit-{fruitName}")?.WithMaximumSize(fruit_max_size),
|
||||
Skin.GetTexture($"fruit-{fruitName}-overlay")?.WithMaximumSize(fruit_max_size)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,12 +17,13 @@ namespace osu.Game.Rulesets.Catch.UI
|
||||
|
||||
public CatchPlayfieldAdjustmentContainer()
|
||||
{
|
||||
// because we are using centre anchor/origin, we will need to limit visibility in the future
|
||||
// to ensure tall windows do not get a readability advantage.
|
||||
// it may be possible to bake the catch-specific offsets (-100..340 mentioned below) into new values
|
||||
// which are compatible with TopCentre alignment.
|
||||
Anchor = Anchor.Centre;
|
||||
Origin = Anchor.Centre;
|
||||
Anchor = Anchor.TopCentre;
|
||||
Origin = Anchor.TopCentre;
|
||||
|
||||
// playfields in stable are positioned vertically at three fourths the difference between the playfield height and the window height in stable.
|
||||
// we can match that in lazer by using relative coordinates for Y and considering window height to be 1, and playfield height to be 0.8.
|
||||
RelativePositionAxes = Axes.Y;
|
||||
Y = (1 - playfield_size_adjust) / 4 * 3;
|
||||
|
||||
Size = new Vector2(playfield_size_adjust);
|
||||
|
||||
@@ -42,18 +43,28 @@ namespace osu.Game.Rulesets.Catch.UI
|
||||
/// </summary>
|
||||
private partial class ScalingContainer : Container
|
||||
{
|
||||
public ScalingContainer()
|
||||
{
|
||||
Anchor = Anchor.BottomCentre;
|
||||
Origin = Anchor.BottomCentre;
|
||||
}
|
||||
|
||||
protected override void Update()
|
||||
{
|
||||
base.Update();
|
||||
|
||||
// in stable, fruit fall vertically from -100 to 340.
|
||||
// to emulate this, we want to make our playfield 440 gameplay pixels high.
|
||||
// we then offset it -100 vertically in the position set below.
|
||||
const float stable_v_offset_ratio = 440 / 384f;
|
||||
// in stable, fruit fall vertically from 100 pixels above the playfield top down to the catcher's Y position (i.e. -100 to 340),
|
||||
// see: https://github.com/peppy/osu-stable-reference/blob/1531237b63392e82c003c712faa028406073aa8f/osu!/GameplayElements/HitObjects/Fruits/HitCircleFruits.cs#L65
|
||||
// we already have the playfield positioned similar to stable (see CatchPlayfieldAdjustmentContainer constructor),
|
||||
// so we only need to increase this container's height 100 pixels above the playfield, and offset it to have the bottom at 340 rather than 384.
|
||||
const float stable_fruit_start_position = -100;
|
||||
const float stable_catcher_y_position = 340;
|
||||
const float playfield_v_size_adjustment = (stable_catcher_y_position - stable_fruit_start_position) / CatchPlayfield.HEIGHT;
|
||||
const float playfield_v_catcher_offset = stable_catcher_y_position - CatchPlayfield.HEIGHT;
|
||||
|
||||
Scale = new Vector2(Parent.ChildSize.X / CatchPlayfield.WIDTH);
|
||||
Position = new Vector2(0, -100 * stable_v_offset_ratio + Scale.X);
|
||||
Size = Vector2.Divide(new Vector2(1, stable_v_offset_ratio), Scale);
|
||||
Scale = new Vector2(Parent!.ChildSize.X / CatchPlayfield.WIDTH);
|
||||
Position = new Vector2(0f, playfield_v_catcher_offset * Scale.Y);
|
||||
Size = Vector2.Divide(new Vector2(1, playfield_v_size_adjustment), Scale);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,13 @@ namespace osu.Game.Rulesets.Catch.UI
|
||||
/// <summary>
|
||||
/// The size of the catcher at 1x scale.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is mainly used to compute catching range, the actual catcher size may differ based on skin implementation and sprite textures.
|
||||
/// This is also equivalent to the "catcherWidth" property in osu-stable when the game field and beatmap difficulty are set to default values.
|
||||
/// </remarks>
|
||||
/// <seealso cref="CatchPlayfield.WIDTH"/>
|
||||
/// <seealso cref="CatchPlayfield.HEIGHT"/>
|
||||
/// <seealso cref="IBeatmapDifficultyInfo.DEFAULT_DIFFICULTY"/>
|
||||
public const float BASE_SIZE = 106.75f;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -6,7 +6,6 @@ using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Game.Rulesets.Catch.Skinning.Default;
|
||||
using osu.Game.Skinning;
|
||||
using osuTK;
|
||||
|
||||
namespace osu.Game.Rulesets.Catch.UI
|
||||
{
|
||||
@@ -26,8 +25,8 @@ namespace osu.Game.Rulesets.Catch.UI
|
||||
: base(new CatchSkinComponentLookup(CatchSkinComponents.Catcher), _ => new DefaultCatcher())
|
||||
{
|
||||
Anchor = Anchor.TopCentre;
|
||||
// Sets the origin roughly to the centre of the catcher's plate to allow for correct scaling.
|
||||
OriginPosition = new Vector2(0.5f, 0.06f) * Catcher.BASE_SIZE;
|
||||
Origin = Anchor.TopCentre;
|
||||
CentreComponent = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning
|
||||
{
|
||||
c.Add(hitExplosionPools[poolIndex].Get(e =>
|
||||
{
|
||||
e.Apply(new JudgementResult(new HitObject(), runCount % 6 == 0 ? new HoldNoteTickJudgement() : new ManiaJudgement()));
|
||||
e.Apply(new JudgementResult(new HitObject(), new ManiaJudgement()));
|
||||
|
||||
e.Anchor = Anchor.Centre;
|
||||
e.Origin = Anchor.Centre;
|
||||
|
||||
@@ -54,7 +54,6 @@ namespace osu.Game.Rulesets.Mania.Tests
|
||||
});
|
||||
|
||||
assertHeadJudgement(HitResult.Miss);
|
||||
assertTickJudgement(HitResult.LargeTickMiss);
|
||||
assertTailJudgement(HitResult.Miss);
|
||||
assertNoteJudgement(HitResult.IgnoreMiss);
|
||||
}
|
||||
@@ -73,7 +72,6 @@ namespace osu.Game.Rulesets.Mania.Tests
|
||||
});
|
||||
|
||||
assertHeadJudgement(HitResult.Perfect);
|
||||
assertTickJudgement(HitResult.LargeTickHit);
|
||||
assertTailJudgement(HitResult.Perfect);
|
||||
assertNoteJudgement(HitResult.IgnoreHit);
|
||||
}
|
||||
@@ -92,7 +90,6 @@ namespace osu.Game.Rulesets.Mania.Tests
|
||||
});
|
||||
|
||||
assertHeadJudgement(HitResult.Perfect);
|
||||
assertTickJudgement(HitResult.LargeTickHit);
|
||||
assertTailJudgement(HitResult.Miss);
|
||||
assertNoteJudgement(HitResult.IgnoreMiss);
|
||||
}
|
||||
@@ -111,7 +108,6 @@ namespace osu.Game.Rulesets.Mania.Tests
|
||||
});
|
||||
|
||||
assertHeadJudgement(HitResult.Miss);
|
||||
assertTickJudgement(HitResult.LargeTickMiss);
|
||||
assertTailJudgement(HitResult.Miss);
|
||||
}
|
||||
|
||||
@@ -129,7 +125,6 @@ namespace osu.Game.Rulesets.Mania.Tests
|
||||
});
|
||||
|
||||
assertHeadJudgement(HitResult.Miss);
|
||||
assertTickJudgement(HitResult.LargeTickMiss);
|
||||
assertTailJudgement(HitResult.Miss);
|
||||
}
|
||||
|
||||
@@ -149,7 +144,6 @@ namespace osu.Game.Rulesets.Mania.Tests
|
||||
});
|
||||
|
||||
assertHeadJudgement(HitResult.Perfect);
|
||||
assertTickJudgement(HitResult.LargeTickHit);
|
||||
assertTailJudgement(HitResult.Miss);
|
||||
}
|
||||
|
||||
@@ -169,7 +163,6 @@ namespace osu.Game.Rulesets.Mania.Tests
|
||||
});
|
||||
|
||||
assertHeadJudgement(HitResult.Perfect);
|
||||
assertTickJudgement(HitResult.LargeTickHit);
|
||||
assertTailJudgement(HitResult.Perfect);
|
||||
}
|
||||
|
||||
@@ -188,10 +181,31 @@ namespace osu.Game.Rulesets.Mania.Tests
|
||||
});
|
||||
|
||||
assertHeadJudgement(HitResult.Perfect);
|
||||
assertTickJudgement(HitResult.LargeTickMiss);
|
||||
assertTailJudgement(HitResult.Miss);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// -----[ ]-----
|
||||
/// xox o
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void TestPressAtStartThenReleaseAndImmediatelyRepress()
|
||||
{
|
||||
performTest(new List<ReplayFrame>
|
||||
{
|
||||
new ManiaReplayFrame(time_head, ManiaAction.Key1),
|
||||
new ManiaReplayFrame(time_head + 1),
|
||||
new ManiaReplayFrame(time_head + 2, ManiaAction.Key1),
|
||||
new ManiaReplayFrame(time_tail),
|
||||
});
|
||||
|
||||
assertHeadJudgement(HitResult.Perfect);
|
||||
assertComboAtJudgement(0, 1);
|
||||
assertTailJudgement(HitResult.Meh);
|
||||
assertComboAtJudgement(1, 0);
|
||||
assertComboAtJudgement(2, 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// -----[ ]-----
|
||||
/// xo x o
|
||||
@@ -208,7 +222,6 @@ namespace osu.Game.Rulesets.Mania.Tests
|
||||
});
|
||||
|
||||
assertHeadJudgement(HitResult.Perfect);
|
||||
assertTickJudgement(HitResult.LargeTickHit);
|
||||
assertTailJudgement(HitResult.Miss);
|
||||
}
|
||||
|
||||
@@ -228,7 +241,6 @@ namespace osu.Game.Rulesets.Mania.Tests
|
||||
});
|
||||
|
||||
assertHeadJudgement(HitResult.Perfect);
|
||||
assertTickJudgement(HitResult.LargeTickHit);
|
||||
assertTailJudgement(HitResult.Meh);
|
||||
}
|
||||
|
||||
@@ -246,7 +258,6 @@ namespace osu.Game.Rulesets.Mania.Tests
|
||||
});
|
||||
|
||||
assertHeadJudgement(HitResult.Miss);
|
||||
assertTickJudgement(HitResult.LargeTickHit);
|
||||
assertTailJudgement(HitResult.Miss);
|
||||
}
|
||||
|
||||
@@ -264,7 +275,6 @@ namespace osu.Game.Rulesets.Mania.Tests
|
||||
});
|
||||
|
||||
assertHeadJudgement(HitResult.Miss);
|
||||
assertTickJudgement(HitResult.LargeTickHit);
|
||||
assertTailJudgement(HitResult.Meh);
|
||||
}
|
||||
|
||||
@@ -358,7 +368,6 @@ namespace osu.Game.Rulesets.Mania.Tests
|
||||
}, beatmap);
|
||||
|
||||
assertHeadJudgement(HitResult.Miss);
|
||||
assertTickJudgement(HitResult.LargeTickMiss);
|
||||
assertTailJudgement(HitResult.Miss);
|
||||
|
||||
assertHitObjectJudgement(note, HitResult.Good);
|
||||
@@ -405,7 +414,6 @@ namespace osu.Game.Rulesets.Mania.Tests
|
||||
}, beatmap);
|
||||
|
||||
assertHeadJudgement(HitResult.Miss);
|
||||
assertTickJudgement(HitResult.LargeTickMiss);
|
||||
assertTailJudgement(HitResult.Miss);
|
||||
|
||||
assertHitObjectJudgement(note, HitResult.Great);
|
||||
@@ -425,7 +433,6 @@ namespace osu.Game.Rulesets.Mania.Tests
|
||||
});
|
||||
|
||||
assertHeadJudgement(HitResult.Miss);
|
||||
assertTickJudgement(HitResult.LargeTickMiss);
|
||||
assertTailJudgement(HitResult.Meh);
|
||||
}
|
||||
|
||||
@@ -476,42 +483,6 @@ namespace osu.Game.Rulesets.Mania.Tests
|
||||
.All(j => j.Type.IsHit()));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestHitTailBeforeLastTick()
|
||||
{
|
||||
const int tick_rate = 8;
|
||||
const double tick_spacing = TimingControlPoint.DEFAULT_BEAT_LENGTH / tick_rate;
|
||||
const double time_last_tick = time_head + tick_spacing * (int)((time_tail - time_head) / tick_spacing - 1);
|
||||
|
||||
var beatmap = new Beatmap<ManiaHitObject>
|
||||
{
|
||||
HitObjects =
|
||||
{
|
||||
new HoldNote
|
||||
{
|
||||
StartTime = time_head,
|
||||
Duration = time_tail - time_head,
|
||||
Column = 0,
|
||||
}
|
||||
},
|
||||
BeatmapInfo =
|
||||
{
|
||||
Difficulty = new BeatmapDifficulty { SliderTickRate = tick_rate },
|
||||
Ruleset = new ManiaRuleset().RulesetInfo
|
||||
},
|
||||
};
|
||||
|
||||
performTest(new List<ReplayFrame>
|
||||
{
|
||||
new ManiaReplayFrame(time_head, ManiaAction.Key1),
|
||||
new ManiaReplayFrame(time_last_tick - 5)
|
||||
}, beatmap);
|
||||
|
||||
assertHeadJudgement(HitResult.Perfect);
|
||||
assertLastTickJudgement(HitResult.LargeTickMiss);
|
||||
assertTailJudgement(HitResult.Ok);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestZeroLength()
|
||||
{
|
||||
@@ -551,11 +522,8 @@ namespace osu.Game.Rulesets.Mania.Tests
|
||||
private void assertNoteJudgement(HitResult result)
|
||||
=> AddAssert($"hold note judged as {result}", () => judgementResults.Single(j => j.HitObject is HoldNote).Type, () => Is.EqualTo(result));
|
||||
|
||||
private void assertTickJudgement(HitResult result)
|
||||
=> AddAssert($"any tick judged as {result}", () => judgementResults.Where(j => j.HitObject is HoldNoteTick).Select(j => j.Type), () => Does.Contain(result));
|
||||
|
||||
private void assertLastTickJudgement(HitResult result)
|
||||
=> AddAssert($"last tick judged as {result}", () => judgementResults.Last(j => j.HitObject is HoldNoteTick).Type, () => Is.EqualTo(result));
|
||||
private void assertComboAtJudgement(int judgementIndex, int combo)
|
||||
=> AddAssert($"combo at judgement {judgementIndex} is {combo}", () => judgementResults.ElementAt(judgementIndex).ComboAfterJudgement, () => Is.EqualTo(combo));
|
||||
|
||||
private ScoreAccessibleReplayPlayer currentPlayer = null!;
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ using osu.Game.Rulesets.Objects;
|
||||
using osu.Game.Rulesets.Objects.Types;
|
||||
using osu.Game.Rulesets.Mania.Beatmaps.Patterns;
|
||||
using osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy;
|
||||
using osu.Game.Rulesets.Scoring.Legacy;
|
||||
using osu.Game.Utils;
|
||||
using osuTK;
|
||||
|
||||
@@ -43,39 +44,41 @@ namespace osu.Game.Rulesets.Mania.Beatmaps
|
||||
: base(beatmap, ruleset)
|
||||
{
|
||||
IsForCurrentRuleset = beatmap.BeatmapInfo.Ruleset.Equals(ruleset.RulesetInfo);
|
||||
TargetColumns = GetColumnCount(LegacyBeatmapConversionDifficultyInfo.FromBeatmap(beatmap));
|
||||
|
||||
double roundedCircleSize = Math.Round(beatmap.Difficulty.CircleSize);
|
||||
double roundedOverallDifficulty = Math.Round(beatmap.Difficulty.OverallDifficulty);
|
||||
|
||||
if (IsForCurrentRuleset)
|
||||
if (IsForCurrentRuleset && TargetColumns > ManiaRuleset.MAX_STAGE_KEYS)
|
||||
{
|
||||
TargetColumns = GetColumnCountForNonConvert(beatmap.BeatmapInfo);
|
||||
|
||||
if (TargetColumns > ManiaRuleset.MAX_STAGE_KEYS)
|
||||
{
|
||||
TargetColumns /= 2;
|
||||
Dual = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
float percentSliderOrSpinner = (float)beatmap.HitObjects.Count(h => h is IHasDuration) / beatmap.HitObjects.Count;
|
||||
if (percentSliderOrSpinner < 0.2)
|
||||
TargetColumns = 7;
|
||||
else if (percentSliderOrSpinner < 0.3 || roundedCircleSize >= 5)
|
||||
TargetColumns = roundedOverallDifficulty > 5 ? 7 : 6;
|
||||
else if (percentSliderOrSpinner > 0.6)
|
||||
TargetColumns = roundedOverallDifficulty > 4 ? 5 : 4;
|
||||
else
|
||||
TargetColumns = Math.Max(4, Math.Min((int)roundedOverallDifficulty + 1, 7));
|
||||
TargetColumns /= 2;
|
||||
Dual = true;
|
||||
}
|
||||
|
||||
originalTargetColumns = TargetColumns;
|
||||
}
|
||||
|
||||
public static int GetColumnCountForNonConvert(BeatmapInfo beatmapInfo)
|
||||
public static int GetColumnCount(LegacyBeatmapConversionDifficultyInfo difficulty)
|
||||
{
|
||||
double roundedCircleSize = Math.Round(beatmapInfo.Difficulty.CircleSize);
|
||||
if (new ManiaRuleset().RulesetInfo.Equals(difficulty.SourceRuleset))
|
||||
return GetColumnCountForNonConvert(difficulty);
|
||||
|
||||
double roundedCircleSize = Math.Round(difficulty.CircleSize);
|
||||
double roundedOverallDifficulty = Math.Round(difficulty.OverallDifficulty);
|
||||
|
||||
int countSliderOrSpinner = difficulty.TotalObjectCount - difficulty.CircleCount;
|
||||
float percentSpecialObjects = (float)countSliderOrSpinner / difficulty.TotalObjectCount;
|
||||
|
||||
if (percentSpecialObjects < 0.2)
|
||||
return 7;
|
||||
if (percentSpecialObjects < 0.3 || roundedCircleSize >= 5)
|
||||
return roundedOverallDifficulty > 5 ? 7 : 6;
|
||||
if (percentSpecialObjects > 0.6)
|
||||
return roundedOverallDifficulty > 4 ? 5 : 4;
|
||||
|
||||
return Math.Max(4, Math.Min((int)roundedOverallDifficulty + 1, 7));
|
||||
}
|
||||
|
||||
public static int GetColumnCountForNonConvert(IBeatmapDifficultyInfo difficulty)
|
||||
{
|
||||
double roundedCircleSize = Math.Round(difficulty.CircleSize);
|
||||
return (int)Math.Max(1, roundedCircleSize);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,13 +31,9 @@ namespace osu.Game.Rulesets.Mania.Difficulty
|
||||
|
||||
public override int Version => 20220902;
|
||||
|
||||
private readonly IWorkingBeatmap workingBeatmap;
|
||||
|
||||
public ManiaDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
|
||||
: base(ruleset, beatmap)
|
||||
{
|
||||
workingBeatmap = beatmap;
|
||||
|
||||
isForCurrentRuleset = beatmap.BeatmapInfo.Ruleset.MatchesOnlineID(ruleset);
|
||||
originalOverallDifficulty = beatmap.BeatmapInfo.Difficulty.OverallDifficulty;
|
||||
}
|
||||
@@ -60,15 +56,6 @@ namespace osu.Game.Rulesets.Mania.Difficulty
|
||||
MaxCombo = beatmap.HitObjects.Sum(maxComboForObject),
|
||||
};
|
||||
|
||||
if (ComputeLegacyScoringValues)
|
||||
{
|
||||
ManiaLegacyScoreSimulator sv1Simulator = new ManiaLegacyScoreSimulator();
|
||||
sv1Simulator.Simulate(workingBeatmap, beatmap, mods);
|
||||
attributes.LegacyAccuracyScore = sv1Simulator.AccuracyScore;
|
||||
attributes.LegacyComboScore = sv1Simulator.ComboScore;
|
||||
attributes.LegacyBonusScoreRatio = sv1Simulator.BonusScoreRatio;
|
||||
}
|
||||
|
||||
return attributes;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,25 +4,63 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Rulesets.Mania.Beatmaps;
|
||||
using osu.Game.Rulesets.Mania.Mods;
|
||||
using osu.Game.Rulesets.Mods;
|
||||
using osu.Game.Rulesets.Scoring;
|
||||
using osu.Game.Rulesets.Scoring.Legacy;
|
||||
|
||||
namespace osu.Game.Rulesets.Mania.Difficulty
|
||||
{
|
||||
internal class ManiaLegacyScoreSimulator : ILegacyScoreSimulator
|
||||
{
|
||||
public int AccuracyScore => 0;
|
||||
public int ComboScore { get; private set; }
|
||||
public double BonusScoreRatio => 0;
|
||||
|
||||
public void Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap, IReadOnlyList<Mod> mods)
|
||||
public LegacyScoreAttributes Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap)
|
||||
{
|
||||
double multiplier = mods.Where(m => m is not (ModHidden or ModHardRock or ModDoubleTime or ModFlashlight or ManiaModFadeIn))
|
||||
.Select(m => m.ScoreMultiplier)
|
||||
.Aggregate(1.0, (c, n) => c * n);
|
||||
return new LegacyScoreAttributes { ComboScore = 1000000 };
|
||||
}
|
||||
|
||||
ComboScore = (int)(1000000 * multiplier);
|
||||
public double GetLegacyScoreMultiplier(IReadOnlyList<Mod> mods, LegacyBeatmapConversionDifficultyInfo difficulty)
|
||||
{
|
||||
bool scoreV2 = mods.Any(m => m is ModScoreV2);
|
||||
|
||||
double multiplier = 1.0;
|
||||
|
||||
foreach (var mod in mods)
|
||||
{
|
||||
switch (mod)
|
||||
{
|
||||
case ManiaModNoFail:
|
||||
multiplier *= scoreV2 ? 1.0 : 0.5;
|
||||
break;
|
||||
|
||||
case ManiaModEasy:
|
||||
multiplier *= 0.5;
|
||||
break;
|
||||
|
||||
case ManiaModHalfTime:
|
||||
case ManiaModDaycore:
|
||||
multiplier *= 0.5;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (new ManiaRuleset().RulesetInfo.Equals(difficulty.SourceRuleset))
|
||||
return multiplier;
|
||||
|
||||
// Apply key mod multipliers.
|
||||
|
||||
int originalColumns = ManiaBeatmapConverter.GetColumnCount(difficulty);
|
||||
int actualColumns = originalColumns;
|
||||
|
||||
actualColumns = mods.OfType<ManiaKeyMod>().SingleOrDefault()?.KeyCount ?? actualColumns;
|
||||
if (mods.Any(m => m is ManiaModDualStages))
|
||||
actualColumns *= 2;
|
||||
|
||||
if (actualColumns > originalColumns)
|
||||
multiplier *= 0.9;
|
||||
else if (actualColumns < originalColumns)
|
||||
multiplier *= 0.9 - 0.04 * (originalColumns - actualColumns);
|
||||
|
||||
return multiplier;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace osu.Game.Rulesets.Mania.Edit
|
||||
{
|
||||
public partial class DrawableManiaEditorRuleset : DrawableManiaRuleset, ISupportConstantAlgorithmToggle
|
||||
{
|
||||
public BindableBool ShowSpeedChanges { get; set; } = new BindableBool();
|
||||
public BindableBool ShowSpeedChanges { get; } = new BindableBool();
|
||||
|
||||
public new IScrollingInfo ScrollingInfo => base.ScrollingInfo;
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Caching;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Framework.Graphics.Pooling;
|
||||
using osu.Framework.Graphics.Shapes;
|
||||
using osu.Game.Graphics;
|
||||
using osu.Game.Rulesets.Edit;
|
||||
@@ -23,7 +25,7 @@ namespace osu.Game.Rulesets.Mania.Edit
|
||||
/// <summary>
|
||||
/// A grid which displays coloured beat divisor lines in proximity to the selection or placement cursor.
|
||||
/// </summary>
|
||||
public partial class ManiaBeatSnapGrid : Component
|
||||
public partial class ManiaBeatSnapGrid : CompositeComponent
|
||||
{
|
||||
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 DrawablePool<DrawableGridLine> linesPool = new DrawablePool<DrawableGridLine>(50);
|
||||
|
||||
private readonly Cached lineCache = new Cached();
|
||||
|
||||
private (double start, double end)? selectionTimeRange;
|
||||
@@ -60,6 +64,8 @@ namespace osu.Game.Rulesets.Mania.Edit
|
||||
[BackgroundDependencyLoader]
|
||||
private void load(HitObjectComposer composer)
|
||||
{
|
||||
AddInternal(linesPool);
|
||||
|
||||
foreach (var stage in ((ManiaPlayfield)composer.Playfield).Stages)
|
||||
{
|
||||
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()
|
||||
{
|
||||
foreach (var grid in grids)
|
||||
{
|
||||
foreach (var line in grid.Objects.OfType<DrawableGridLine>())
|
||||
availableLines.Push(line);
|
||||
|
||||
grid.Clear();
|
||||
}
|
||||
|
||||
if (selectionTimeRange == null)
|
||||
return;
|
||||
@@ -131,10 +130,13 @@ namespace osu.Game.Rulesets.Mania.Edit
|
||||
|
||||
foreach (var grid in grids)
|
||||
{
|
||||
if (!availableLines.TryPop(out var line))
|
||||
line = new DrawableGridLine();
|
||||
var line = linesPool.Get();
|
||||
|
||||
line.Apply(new HitObject
|
||||
{
|
||||
StartTime = time
|
||||
});
|
||||
|
||||
line.HitObject.StartTime = time;
|
||||
line.Colour = colour;
|
||||
|
||||
grid.Add(line);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using osu.Game.Rulesets.Scoring;
|
||||
|
||||
namespace osu.Game.Rulesets.Mania.Judgements
|
||||
{
|
||||
public class HoldNoteBodyJudgement : ManiaJudgement
|
||||
{
|
||||
public override HitResult MaxResult => HitResult.IgnoreHit;
|
||||
public override HitResult MinResult => HitResult.ComboBreak;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using osu.Game.Rulesets.Scoring;
|
||||
|
||||
namespace osu.Game.Rulesets.Mania.Judgements
|
||||
{
|
||||
public class HoldNoteTickJudgement : ManiaJudgement
|
||||
{
|
||||
public override HitResult MaxResult => HitResult.LargeTickHit;
|
||||
}
|
||||
}
|
||||
@@ -12,12 +12,6 @@ namespace osu.Game.Rulesets.Mania.Judgements
|
||||
{
|
||||
switch (result)
|
||||
{
|
||||
case HitResult.LargeTickHit:
|
||||
return DEFAULT_MAX_HEALTH_INCREASE * 0.1;
|
||||
|
||||
case HitResult.LargeTickMiss:
|
||||
return -DEFAULT_MAX_HEALTH_INCREASE * 0.1;
|
||||
|
||||
case HitResult.Meh:
|
||||
return -DEFAULT_MAX_HEALTH_INCREASE * 0.5;
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace osu.Game.Rulesets.Mania
|
||||
|
||||
public bool Matches(BeatmapInfo beatmapInfo)
|
||||
{
|
||||
return !keys.HasFilter || (beatmapInfo.Ruleset.OnlineID == new ManiaRuleset().LegacyID && keys.IsInRange(ManiaBeatmapConverter.GetColumnCountForNonConvert(beatmapInfo)));
|
||||
return !keys.HasFilter || (beatmapInfo.Ruleset.OnlineID == new ManiaRuleset().LegacyID && keys.IsInRange(ManiaBeatmapConverter.GetColumnCountForNonConvert(beatmapInfo.Difficulty)));
|
||||
}
|
||||
|
||||
public bool TryParseCustomKeywordCriteria(string key, Operator op, string value)
|
||||
|
||||
@@ -33,6 +33,7 @@ using osu.Game.Rulesets.Mania.UI;
|
||||
using osu.Game.Rulesets.Mods;
|
||||
using osu.Game.Rulesets.Replays.Types;
|
||||
using osu.Game.Rulesets.Scoring;
|
||||
using osu.Game.Rulesets.Scoring.Legacy;
|
||||
using osu.Game.Rulesets.UI;
|
||||
using osu.Game.Scoring;
|
||||
using osu.Game.Screens.Edit.Setup;
|
||||
@@ -384,22 +385,9 @@ namespace osu.Game.Rulesets.Mania
|
||||
HitResult.Good,
|
||||
HitResult.Ok,
|
||||
HitResult.Meh,
|
||||
|
||||
HitResult.LargeTickHit,
|
||||
};
|
||||
}
|
||||
|
||||
public override LocalisableString GetDisplayNameForHitResult(HitResult result)
|
||||
{
|
||||
switch (result)
|
||||
{
|
||||
case HitResult.LargeTickHit:
|
||||
return "hold tick";
|
||||
}
|
||||
|
||||
return base.GetDisplayNameForHitResult(result);
|
||||
}
|
||||
|
||||
public override StatisticItem[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => new[]
|
||||
{
|
||||
new StatisticItem("Performance Breakdown", () => new PerformanceBreakdownChart(score, playableBeatmap)
|
||||
|
||||
@@ -41,6 +41,7 @@ namespace osu.Game.Rulesets.Mania
|
||||
},
|
||||
new SettingsCheckbox
|
||||
{
|
||||
Keywords = new[] { "color" },
|
||||
LabelText = RulesetSettingsStrings.TimingBasedColouring,
|
||||
Current = config.GetBindable<bool>(ManiaRulesetSetting.TimingBasedNoteColouring),
|
||||
}
|
||||
|
||||
@@ -35,10 +35,11 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
|
||||
|
||||
public DrawableHoldNoteHead Head => headContainer.Child;
|
||||
public DrawableHoldNoteTail Tail => tailContainer.Child;
|
||||
public DrawableHoldNoteBody Body => bodyContainer.Child;
|
||||
|
||||
private Container<DrawableHoldNoteHead> headContainer;
|
||||
private Container<DrawableHoldNoteTail> tailContainer;
|
||||
private Container<DrawableHoldNoteTick> tickContainer;
|
||||
private Container<DrawableHoldNoteBody> bodyContainer;
|
||||
|
||||
private PausableSkinnableSound slidingSample;
|
||||
|
||||
@@ -60,12 +61,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
|
||||
public double? HoldStartTime { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Time at which the hold note has been broken, i.e. released too early, resulting in a reduced score.
|
||||
/// </summary>
|
||||
public double? HoldBrokenTime { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the hold note has been released potentially without having caused a break.
|
||||
/// Used to decide whether to visually clamp the hold note to the judgement line.
|
||||
/// </summary>
|
||||
private double? releaseTime;
|
||||
|
||||
@@ -103,6 +99,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
|
||||
headContainer = new Container<DrawableHoldNoteHead> { RelativeSizeAxes = Axes.Both }
|
||||
}
|
||||
},
|
||||
bodyContainer = new Container<DrawableHoldNoteBody> { RelativeSizeAxes = Axes.Both },
|
||||
bodyPiece = new SkinnableDrawable(new ManiaSkinComponentLookup(ManiaSkinComponents.HoldNoteBody), _ => new DefaultBodyPiece
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
@@ -110,7 +107,6 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
|
||||
{
|
||||
RelativeSizeAxes = Axes.X
|
||||
},
|
||||
tickContainer = new Container<DrawableHoldNoteTick> { RelativeSizeAxes = Axes.Both },
|
||||
tailContainer = new Container<DrawableHoldNoteTail> { RelativeSizeAxes = Axes.Both },
|
||||
slidingSample = new PausableSkinnableSound { Looping = true }
|
||||
});
|
||||
@@ -118,7 +114,6 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
|
||||
maskedContents.AddRange(new[]
|
||||
{
|
||||
bodyPiece.CreateProxy(),
|
||||
tickContainer.CreateProxy(),
|
||||
tailContainer.CreateProxy(),
|
||||
});
|
||||
}
|
||||
@@ -136,7 +131,6 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
|
||||
|
||||
sizingContainer.Size = Vector2.One;
|
||||
HoldStartTime = null;
|
||||
HoldBrokenTime = null;
|
||||
releaseTime = null;
|
||||
}
|
||||
|
||||
@@ -154,8 +148,8 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
|
||||
tailContainer.Child = tail;
|
||||
break;
|
||||
|
||||
case DrawableHoldNoteTick tick:
|
||||
tickContainer.Add(tick);
|
||||
case DrawableHoldNoteBody body:
|
||||
bodyContainer.Child = body;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -165,7 +159,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
|
||||
base.ClearNestedHitObjects();
|
||||
headContainer.Clear(false);
|
||||
tailContainer.Clear(false);
|
||||
tickContainer.Clear(false);
|
||||
bodyContainer.Clear(false);
|
||||
}
|
||||
|
||||
protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject)
|
||||
@@ -178,8 +172,8 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
|
||||
case HeadNote head:
|
||||
return new DrawableHoldNoteHead(head);
|
||||
|
||||
case HoldNoteTick tick:
|
||||
return new DrawableHoldNoteTick(tick);
|
||||
case HoldNoteBody body:
|
||||
return new DrawableHoldNoteBody(body);
|
||||
}
|
||||
|
||||
return base.CreateNestedHitObject(hitObject);
|
||||
@@ -266,20 +260,15 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
|
||||
{
|
||||
if (Tail.AllJudged)
|
||||
{
|
||||
foreach (var tick in tickContainer)
|
||||
{
|
||||
if (!tick.Judged)
|
||||
tick.MissForcefully();
|
||||
}
|
||||
|
||||
if (Tail.IsHit)
|
||||
ApplyResult(r => r.Type = r.Judgement.MaxResult);
|
||||
else
|
||||
MissForcefully();
|
||||
}
|
||||
|
||||
if (Tail.Judged && !Tail.IsHit)
|
||||
HoldBrokenTime = Time.Current;
|
||||
// Make sure that the hold note is fully judged by giving the body a judgement.
|
||||
if (Tail.AllJudged && !Body.AllJudged)
|
||||
Body.TriggerResult(Tail.IsHit);
|
||||
}
|
||||
|
||||
public override void MissForcefully()
|
||||
@@ -333,22 +322,22 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
|
||||
if (e.Action != Action.Value)
|
||||
return;
|
||||
|
||||
// Make sure a hold was started
|
||||
if (HoldStartTime == null)
|
||||
return;
|
||||
|
||||
// do not run any of this logic when rewinding, as it inverts order of presses/releases.
|
||||
if ((Clock as IGameplayClock)?.IsRewinding == true)
|
||||
return;
|
||||
|
||||
Tail.UpdateResult();
|
||||
endHold();
|
||||
// When our action is released and we are in the middle of a hold, there's a chance that
|
||||
// the user has released too early (before the tail).
|
||||
//
|
||||
// In such a case, we want to record this against the DrawableHoldNoteBody.
|
||||
if (HoldStartTime != null)
|
||||
{
|
||||
Tail.UpdateResult();
|
||||
Body.TriggerResult(Tail.IsHit);
|
||||
|
||||
// If the key has been released too early, the user should not receive full score for the release
|
||||
if (!Tail.IsHit)
|
||||
HoldBrokenTime = Time.Current;
|
||||
|
||||
releaseTime = Time.Current;
|
||||
endHold();
|
||||
releaseTime = Time.Current;
|
||||
}
|
||||
}
|
||||
|
||||
private void endHold()
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace osu.Game.Rulesets.Mania.Objects.Drawables
|
||||
{
|
||||
public partial class DrawableHoldNoteBody : DrawableManiaHitObject<HoldNoteBody>
|
||||
{
|
||||
public bool HasHoldBreak => AllJudged && !IsHit;
|
||||
|
||||
public override bool DisplayResult => false;
|
||||
|
||||
public DrawableHoldNoteBody()
|
||||
: this(null)
|
||||
{
|
||||
}
|
||||
|
||||
public DrawableHoldNoteBody(HoldNoteBody hitObject)
|
||||
: base(hitObject)
|
||||
{
|
||||
}
|
||||
|
||||
internal void TriggerResult(bool hit)
|
||||
{
|
||||
if (AllJudged) return;
|
||||
|
||||
ApplyResult(r => r.Type = hit ? r.Judgement.MaxResult : r.Judgement.MinResult);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,9 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
|
||||
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))
|
||||
bool hasComboBreak = !HoldNote.Head.IsHit || HoldNote.Body.HasHoldBreak;
|
||||
|
||||
if (result > HitResult.Meh && hasComboBreak)
|
||||
result = HitResult.Meh;
|
||||
|
||||
r.Type = result;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@
|
||||
|
||||
namespace osu.Game.Rulesets.Mania.Objects
|
||||
{
|
||||
/// <summary>
|
||||
/// The head note of a <see cref="HoldNote"/>.
|
||||
/// </summary>
|
||||
public class HeadNote : Note
|
||||
{
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using osu.Game.Audio;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Beatmaps.ControlPoints;
|
||||
using osu.Game.Rulesets.Judgements;
|
||||
using osu.Game.Rulesets.Objects.Types;
|
||||
using osu.Game.Rulesets.Scoring;
|
||||
@@ -81,27 +79,18 @@ namespace osu.Game.Rulesets.Mania.Objects
|
||||
/// </summary>
|
||||
public TailNote Tail { get; private set; }
|
||||
|
||||
public override double MaximumJudgementOffset => Tail.MaximumJudgementOffset;
|
||||
|
||||
/// <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>
|
||||
private double tickSpacing = 50;
|
||||
public HoldNoteBody Body { get; private set; }
|
||||
|
||||
protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty)
|
||||
{
|
||||
base.ApplyDefaultsToSelf(controlPointInfo, difficulty);
|
||||
|
||||
TimingControlPoint timingPoint = controlPointInfo.TimingPointAt(StartTime);
|
||||
tickSpacing = timingPoint.BeatLength / difficulty.SliderTickRate;
|
||||
}
|
||||
public override double MaximumJudgementOffset => Tail.MaximumJudgementOffset;
|
||||
|
||||
protected override void CreateNestedHitObjects(CancellationToken cancellationToken)
|
||||
{
|
||||
base.CreateNestedHitObjects(cancellationToken);
|
||||
|
||||
createTicks(cancellationToken);
|
||||
|
||||
AddNested(Head = new HeadNote
|
||||
{
|
||||
StartTime = StartTime,
|
||||
@@ -115,23 +104,12 @@ namespace osu.Game.Rulesets.Mania.Objects
|
||||
Column = Column,
|
||||
Samples = GetNodeSamples((NodeSamples?.Count - 1) ?? 1),
|
||||
});
|
||||
}
|
||||
|
||||
private void createTicks(CancellationToken cancellationToken)
|
||||
{
|
||||
if (tickSpacing == 0)
|
||||
return;
|
||||
|
||||
for (double t = StartTime + tickSpacing; t <= EndTime - tickSpacing; t += tickSpacing)
|
||||
AddNested(Body = new HoldNoteBody
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
AddNested(new HoldNoteTick
|
||||
{
|
||||
StartTime = t,
|
||||
Column = Column
|
||||
});
|
||||
}
|
||||
StartTime = StartTime,
|
||||
Column = Column
|
||||
});
|
||||
}
|
||||
|
||||
public override Judgement CreateJudgement() => new IgnoreJudgement();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@ using osu.Game.Rulesets.Mania.Judgements;
|
||||
|
||||
namespace osu.Game.Rulesets.Mania.Objects
|
||||
{
|
||||
/// <summary>
|
||||
/// The tail note of a <see cref="HoldNote"/>.
|
||||
/// </summary>
|
||||
public class TailNote : Note
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Colour;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Framework.Graphics.Shapes;
|
||||
using osu.Game.Rulesets.Mania.Objects.Drawables;
|
||||
@@ -25,33 +26,42 @@ namespace osu.Game.Rulesets.Mania.Skinning.Default
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both;
|
||||
|
||||
// Avoid flickering due to no anti-aliasing of boxes by default.
|
||||
var edgeSmoothness = new Vector2(0.3f);
|
||||
|
||||
AddInternal(mainLine = new Box
|
||||
{
|
||||
Name = "Bar line",
|
||||
EdgeSmoothness = edgeSmoothness,
|
||||
Anchor = Anchor.BottomCentre,
|
||||
Origin = Anchor.BottomCentre,
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
});
|
||||
|
||||
Vector2 size = new Vector2(22, 6);
|
||||
const float line_offset = 4;
|
||||
const float major_extension = 10;
|
||||
|
||||
AddInternal(leftAnchor = new Circle
|
||||
AddInternal(leftAnchor = new Box
|
||||
{
|
||||
Name = "Left anchor",
|
||||
EdgeSmoothness = edgeSmoothness,
|
||||
Blending = BlendingParameters.Additive,
|
||||
Anchor = Anchor.CentreLeft,
|
||||
Origin = Anchor.CentreRight,
|
||||
Size = size,
|
||||
X = -line_offset,
|
||||
Width = major_extension,
|
||||
RelativeSizeAxes = Axes.Y,
|
||||
Colour = ColourInfo.GradientHorizontal(Colour4.Transparent, Colour4.White),
|
||||
});
|
||||
|
||||
AddInternal(rightAnchor = new Circle
|
||||
AddInternal(rightAnchor = new Box
|
||||
{
|
||||
Name = "Right anchor",
|
||||
EdgeSmoothness = edgeSmoothness,
|
||||
Blending = BlendingParameters.Additive,
|
||||
Anchor = Anchor.CentreRight,
|
||||
Origin = Anchor.CentreLeft,
|
||||
Size = size,
|
||||
X = line_offset,
|
||||
Width = major_extension,
|
||||
RelativeSizeAxes = Axes.Y,
|
||||
Colour = ColourInfo.GradientHorizontal(Colour4.White, Colour4.Transparent),
|
||||
});
|
||||
|
||||
major = ((DrawableBarLine)drawableHitObject).Major.GetBoundCopy();
|
||||
@@ -66,7 +76,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Default
|
||||
private void updateMajor(ValueChangedEvent<bool> major)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,7 +209,9 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
|
||||
protected override void 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);
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Animations;
|
||||
using osu.Game.Rulesets.Judgements;
|
||||
using osu.Game.Rulesets.Mania.Judgements;
|
||||
using osu.Game.Rulesets.Mania.UI;
|
||||
using osu.Game.Rulesets.UI.Scrolling;
|
||||
using osu.Game.Skinning;
|
||||
@@ -69,9 +68,6 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
|
||||
|
||||
public void Animate(JudgementResult result)
|
||||
{
|
||||
if (result.Judgement is HoldNoteTickJudgement)
|
||||
return;
|
||||
|
||||
(explosion as IFramedAnimation)?.GotoFrame(0);
|
||||
|
||||
explosion?.FadeInFromZero(FADE_IN_DURATION)
|
||||
|
||||
@@ -112,7 +112,7 @@ namespace osu.Game.Rulesets.Mania.UI
|
||||
RegisterPool<HoldNote, DrawableHoldNote>(10, 50);
|
||||
RegisterPool<HeadNote, DrawableHoldNoteHead>(10, 50);
|
||||
RegisterPool<TailNote, DrawableHoldNoteTail>(10, 50);
|
||||
RegisterPool<HoldNoteTick, DrawableHoldNoteTick>(50, 250);
|
||||
RegisterPool<HoldNoteBody, DrawableHoldNoteBody>(10, 50);
|
||||
}
|
||||
|
||||
private void onSourceChanged()
|
||||
|
||||
@@ -11,7 +11,6 @@ using osu.Framework.Graphics.Containers;
|
||||
using osu.Framework.Graphics.Effects;
|
||||
using osu.Framework.Utils;
|
||||
using osu.Game.Rulesets.Judgements;
|
||||
using osu.Game.Rulesets.Mania.Judgements;
|
||||
using osu.Game.Rulesets.Mania.Skinning.Default;
|
||||
using osu.Game.Rulesets.UI.Scrolling;
|
||||
using osuTK;
|
||||
@@ -150,9 +149,6 @@ namespace osu.Game.Rulesets.Mania.UI
|
||||
// scale roughly in-line with visual appearance of notes
|
||||
Vector2 scale = new Vector2(1, 0.6f);
|
||||
|
||||
if (result.Judgement is HoldNoteTickJudgement)
|
||||
scale *= 0.5f;
|
||||
|
||||
this.ScaleTo(scale);
|
||||
|
||||
largeFaint
|
||||
|
||||
@@ -195,10 +195,6 @@ namespace osu.Game.Rulesets.Mania.UI
|
||||
if (!judgedObject.DisplayResult || !DisplayJudgements.Value)
|
||||
return;
|
||||
|
||||
// Tick judgements should not display text.
|
||||
if (judgedObject is DrawableHoldNoteTick)
|
||||
return;
|
||||
|
||||
judgements.Clear(false);
|
||||
judgements.Add(judgementPool.Get(j =>
|
||||
{
|
||||
|
||||
@@ -163,7 +163,6 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
|
||||
slider = new Slider
|
||||
{
|
||||
Position = new Vector2(0, 50),
|
||||
LegacyLastTickOffset = 36, // This is necessary for undo to retain the sample control point
|
||||
Path = new SliderPath(new[]
|
||||
{
|
||||
new PathControlPoint(Vector2.Zero, PathType.PerfectCurve),
|
||||
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -43,7 +43,8 @@ namespace osu.Game.Rulesets.Osu.Tests
|
||||
AddUntilStep("Pitch starts low", () => getSpinningSample().Frequency.Value < 0.8);
|
||||
AddUntilStep("Pitch increases", () => getSpinningSample().Frequency.Value > 0.8);
|
||||
|
||||
PausableSkinnableSound getSpinningSample() => drawableSpinner.ChildrenOfType<PausableSkinnableSound>().FirstOrDefault(s => s.Samples.Any(i => i.LookupNames.Any(l => l.Contains("spinnerspin"))));
|
||||
PausableSkinnableSound getSpinningSample() =>
|
||||
drawableSpinner.ChildrenOfType<PausableSkinnableSound>().FirstOrDefault(s => s.Samples.Any(i => i.LookupNames.Any(l => l.Contains("spinnerspin"))));
|
||||
}
|
||||
|
||||
[TestCase(false)]
|
||||
@@ -64,6 +65,39 @@ namespace osu.Game.Rulesets.Osu.Tests
|
||||
AddUntilStep("Short spinner implicitly completes", () => drawableSpinner.Progress == 1);
|
||||
}
|
||||
|
||||
[TestCase(0, 4, 6)]
|
||||
[TestCase(5, 7, 10)]
|
||||
[TestCase(10, 11, 8)]
|
||||
public void TestSpinnerSpinRequirements(int od, int normalTicks, int bonusTicks)
|
||||
{
|
||||
Spinner spinner = null;
|
||||
|
||||
AddStep("add spinner", () => SetContents(_ =>
|
||||
{
|
||||
spinner = new Spinner
|
||||
{
|
||||
StartTime = Time.Current,
|
||||
EndTime = Time.Current + 3000,
|
||||
Samples = new List<HitSampleInfo>
|
||||
{
|
||||
new HitSampleInfo(HitSampleInfo.HIT_NORMAL)
|
||||
}
|
||||
};
|
||||
|
||||
spinner.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { OverallDifficulty = od });
|
||||
|
||||
return drawableSpinner = new TestDrawableSpinner(spinner, true)
|
||||
{
|
||||
Anchor = Anchor.Centre,
|
||||
Depth = depthIndex++,
|
||||
Scale = new Vector2(0.75f)
|
||||
};
|
||||
}));
|
||||
|
||||
AddAssert("number of normal ticks matches", () => spinner.SpinsRequired, () => Is.EqualTo(normalTicks));
|
||||
AddAssert("number of bonus ticks matches", () => spinner.MaximumBonusSpins, () => Is.EqualTo(bonusTicks));
|
||||
}
|
||||
|
||||
private Drawable testSingle(float circleSize, bool auto = false, double length = 3000)
|
||||
{
|
||||
const double delay = 2000;
|
||||
|
||||
@@ -44,7 +44,6 @@ namespace osu.Game.Rulesets.Osu.Beatmaps
|
||||
Position = positionData?.Position ?? Vector2.Zero,
|
||||
NewCombo = comboData?.NewCombo ?? false,
|
||||
ComboOffset = comboData?.ComboOffset ?? 0,
|
||||
LegacyLastTickOffset = (original as IHasLegacyLastTickOffset)?.LegacyLastTickOffset,
|
||||
// prior to v8, speed multipliers don't adjust for how many ticks are generated over the same distance.
|
||||
// this results in more (or less) ticks being generated in <v8 maps for the same time duration.
|
||||
TickDistanceMultiplier = beatmap.BeatmapInfo.BeatmapVersion < 8 ? 1f / ((LegacyControlPointInfo)beatmap.ControlPointInfo).DifficultyPointAt(original.StartTime).SliderVelocity : 1,
|
||||
|
||||
@@ -26,12 +26,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty
|
||||
|
||||
public override int Version => 20220902;
|
||||
|
||||
private readonly IWorkingBeatmap workingBeatmap;
|
||||
|
||||
public OsuDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
|
||||
: base(ruleset, beatmap)
|
||||
{
|
||||
workingBeatmap = beatmap;
|
||||
}
|
||||
|
||||
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
|
||||
@@ -109,15 +106,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty
|
||||
SpinnerCount = spinnerCount,
|
||||
};
|
||||
|
||||
if (ComputeLegacyScoringValues)
|
||||
{
|
||||
OsuLegacyScoreSimulator sv1Simulator = new OsuLegacyScoreSimulator();
|
||||
sv1Simulator.Simulate(workingBeatmap, beatmap, mods);
|
||||
attributes.LegacyAccuracyScore = sv1Simulator.AccuracyScore;
|
||||
attributes.LegacyComboScore = sv1Simulator.ComboScore;
|
||||
attributes.LegacyBonusScoreRatio = sv1Simulator.BonusScoreRatio;
|
||||
}
|
||||
|
||||
return attributes;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,30 +9,23 @@ using osu.Game.Rulesets.Judgements;
|
||||
using osu.Game.Rulesets.Mods;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
using osu.Game.Rulesets.Objects.Types;
|
||||
using osu.Game.Rulesets.Osu.Mods;
|
||||
using osu.Game.Rulesets.Osu.Objects;
|
||||
using osu.Game.Rulesets.Scoring;
|
||||
using osu.Game.Rulesets.Scoring.Legacy;
|
||||
|
||||
namespace osu.Game.Rulesets.Osu.Difficulty
|
||||
{
|
||||
internal class OsuLegacyScoreSimulator : ILegacyScoreSimulator
|
||||
{
|
||||
public int AccuracyScore { get; private set; }
|
||||
|
||||
public int ComboScore { get; private set; }
|
||||
|
||||
public double BonusScoreRatio => legacyBonusScore == 0 ? 0 : (double)modernBonusScore / legacyBonusScore;
|
||||
|
||||
private int legacyBonusScore;
|
||||
private int modernBonusScore;
|
||||
private int standardisedBonusScore;
|
||||
private int combo;
|
||||
|
||||
private double scoreMultiplier;
|
||||
private IBeatmap playableBeatmap = null!;
|
||||
|
||||
public void Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap, IReadOnlyList<Mod> mods)
|
||||
public LegacyScoreAttributes Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap)
|
||||
{
|
||||
this.playableBeatmap = playableBeatmap;
|
||||
|
||||
IBeatmap baseBeatmap = workingBeatmap.Beatmap;
|
||||
|
||||
int countNormal = 0;
|
||||
@@ -73,13 +66,19 @@ namespace osu.Game.Rulesets.Osu.Difficulty
|
||||
+ baseBeatmap.Difficulty.CircleSize
|
||||
+ Math.Clamp((float)objectCount / drainLength * 8, 0, 16)) / 38 * 5);
|
||||
|
||||
scoreMultiplier = difficultyPeppyStars * mods.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier);
|
||||
scoreMultiplier = difficultyPeppyStars;
|
||||
|
||||
LegacyScoreAttributes attributes = new LegacyScoreAttributes();
|
||||
|
||||
foreach (var obj in playableBeatmap.HitObjects)
|
||||
simulateHit(obj);
|
||||
simulateHit(obj, ref attributes);
|
||||
|
||||
attributes.BonusScoreRatio = legacyBonusScore == 0 ? 0 : (double)standardisedBonusScore / legacyBonusScore;
|
||||
|
||||
return attributes;
|
||||
}
|
||||
|
||||
private void simulateHit(HitObject hitObject)
|
||||
private void simulateHit(HitObject hitObject, ref LegacyScoreAttributes attributes)
|
||||
{
|
||||
bool increaseCombo = true;
|
||||
bool addScoreComboMultiplier = false;
|
||||
@@ -122,7 +121,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
|
||||
|
||||
case Slider:
|
||||
foreach (var nested in hitObject.NestedHitObjects)
|
||||
simulateHit(nested);
|
||||
simulateHit(nested, ref attributes);
|
||||
|
||||
scoreIncrease = 300;
|
||||
increaseCombo = false;
|
||||
@@ -133,22 +132,27 @@ namespace osu.Game.Rulesets.Osu.Difficulty
|
||||
// The spinner object applies a lenience because gameplay mechanics differ from osu-stable.
|
||||
// We'll redo the calculations to match osu-stable here...
|
||||
const double maximum_rotations_per_second = 477.0 / 60;
|
||||
double minimumRotationsPerSecond = IBeatmapDifficultyInfo.DifficultyRange(playableBeatmap.Difficulty.OverallDifficulty, 3, 5, 7.5);
|
||||
|
||||
// Normally, this value depends on the final overall difficulty. For simplicity, we'll only consider the worst case that maximises bonus score.
|
||||
// As we're primarily concerned with computing the maximum theoretical final score,
|
||||
// this will have the final effect of slightly underestimating bonus score achieved on stable when converting from score V1.
|
||||
const double minimum_rotations_per_second = 3;
|
||||
|
||||
double secondsDuration = spinner.Duration / 1000;
|
||||
|
||||
// The total amount of half spins possible for the entire spinner.
|
||||
int totalHalfSpinsPossible = (int)(secondsDuration * maximum_rotations_per_second * 2);
|
||||
// The amount of half spins that are required to successfully complete the spinner (i.e. get a 300).
|
||||
int halfSpinsRequiredForCompletion = (int)(secondsDuration * minimumRotationsPerSecond);
|
||||
int halfSpinsRequiredForCompletion = (int)(secondsDuration * minimum_rotations_per_second);
|
||||
// To be able to receive bonus points, the spinner must be rotated another 1.5 times.
|
||||
int halfSpinsRequiredBeforeBonus = halfSpinsRequiredForCompletion + 3;
|
||||
|
||||
for (int i = 0; i <= totalHalfSpinsPossible; i++)
|
||||
{
|
||||
if (i > halfSpinsRequiredBeforeBonus && (i - halfSpinsRequiredBeforeBonus) % 2 == 0)
|
||||
simulateHit(new SpinnerBonusTick());
|
||||
simulateHit(new SpinnerBonusTick(), ref attributes);
|
||||
else if (i > 1 && i % 2 == 0)
|
||||
simulateHit(new SpinnerTick());
|
||||
simulateHit(new SpinnerTick(), ref attributes);
|
||||
}
|
||||
|
||||
scoreIncrease = 300;
|
||||
@@ -159,19 +163,72 @@ namespace osu.Game.Rulesets.Osu.Difficulty
|
||||
if (addScoreComboMultiplier)
|
||||
{
|
||||
// ReSharper disable once PossibleLossOfFraction (intentional to match osu-stable...)
|
||||
ComboScore += (int)(Math.Max(0, combo - 1) * (scoreIncrease / 25 * scoreMultiplier));
|
||||
attributes.ComboScore += (int)(Math.Max(0, combo - 1) * (scoreIncrease / 25 * scoreMultiplier));
|
||||
}
|
||||
|
||||
if (isBonus)
|
||||
{
|
||||
legacyBonusScore += scoreIncrease;
|
||||
modernBonusScore += Judgement.ToNumericResult(bonusResult);
|
||||
standardisedBonusScore += Judgement.ToNumericResult(bonusResult);
|
||||
}
|
||||
else
|
||||
AccuracyScore += scoreIncrease;
|
||||
attributes.AccuracyScore += scoreIncrease;
|
||||
|
||||
if (increaseCombo)
|
||||
combo++;
|
||||
}
|
||||
|
||||
public double GetLegacyScoreMultiplier(IReadOnlyList<Mod> mods, LegacyBeatmapConversionDifficultyInfo difficulty)
|
||||
{
|
||||
bool scoreV2 = mods.Any(m => m is ModScoreV2);
|
||||
|
||||
double multiplier = 1.0;
|
||||
|
||||
foreach (var mod in mods)
|
||||
{
|
||||
switch (mod)
|
||||
{
|
||||
case OsuModNoFail:
|
||||
multiplier *= scoreV2 ? 1.0 : 0.5;
|
||||
break;
|
||||
|
||||
case OsuModEasy:
|
||||
multiplier *= 0.5;
|
||||
break;
|
||||
|
||||
case OsuModHalfTime:
|
||||
case OsuModDaycore:
|
||||
multiplier *= 0.3;
|
||||
break;
|
||||
|
||||
case OsuModHidden:
|
||||
multiplier *= 1.06;
|
||||
break;
|
||||
|
||||
case OsuModHardRock:
|
||||
multiplier *= scoreV2 ? 1.10 : 1.06;
|
||||
break;
|
||||
|
||||
case OsuModDoubleTime:
|
||||
case OsuModNightcore:
|
||||
multiplier *= scoreV2 ? 1.20 : 1.12;
|
||||
break;
|
||||
|
||||
case OsuModFlashlight:
|
||||
multiplier *= 1.12;
|
||||
break;
|
||||
|
||||
case OsuModSpunOut:
|
||||
multiplier *= 0.9;
|
||||
break;
|
||||
|
||||
case OsuModRelax:
|
||||
case OsuModAutopilot:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
return multiplier;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components
|
||||
{
|
||||
Origin = Anchor.Centre;
|
||||
|
||||
Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2);
|
||||
Size = OsuHitObject.OBJECT_DIMENSIONS;
|
||||
|
||||
InternalChild = content = new Container
|
||||
{
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components
|
||||
{
|
||||
Origin = Anchor.Centre;
|
||||
|
||||
Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2);
|
||||
Size = OsuHitObject.OBJECT_DIMENSIONS;
|
||||
|
||||
CornerRadius = Size.X / 2;
|
||||
CornerExponent = 2;
|
||||
|
||||
@@ -315,7 +315,6 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
|
||||
StartTime = HitObject.StartTime,
|
||||
Position = HitObject.Position + splitControlPoints[0].Position,
|
||||
NewCombo = HitObject.NewCombo,
|
||||
LegacyLastTickOffset = HitObject.LegacyLastTickOffset,
|
||||
Samples = HitObject.Samples.Select(s => s.With()).ToList(),
|
||||
RepeatCount = HitObject.RepeatCount,
|
||||
NodeSamples = HitObject.NodeSamples.Select(n => (IList<HitSampleInfo>)n.Select(s => s.With()).ToList()).ToList(),
|
||||
|
||||
@@ -61,10 +61,12 @@ namespace osu.Game.Rulesets.Osu.Mods
|
||||
|
||||
public void ApplyToDrawableRuleset(DrawableRuleset<OsuHitObject> drawableRuleset)
|
||||
{
|
||||
OsuHitObject firstObject = drawableRuleset.Beatmap.HitObjects.First();
|
||||
|
||||
// Multiplying by 2 results in an initial size that is too large, hence 1.90 has been chosen
|
||||
// Also avoids the HitObject bleeding around the edges of the bubble drawable at minimum size
|
||||
bubbleSize = (float)(drawableRuleset.Beatmap.HitObjects.OfType<HitCircle>().First().Radius * 1.90f);
|
||||
bubbleFade = drawableRuleset.Beatmap.HitObjects.OfType<HitCircle>().First().TimePreempt * 2;
|
||||
bubbleSize = (float)firstObject.Radius * 1.90f;
|
||||
bubbleFade = firstObject.TimePreempt * 2;
|
||||
|
||||
// We want to hide the judgements since they are obscured by the BubbleDrawable (due to layering)
|
||||
drawableRuleset.Playfield.DisplayJudgements.Value = false;
|
||||
|
||||
@@ -96,14 +96,13 @@ namespace osu.Game.Rulesets.Osu.Mods
|
||||
Position = original.Position;
|
||||
NewCombo = original.NewCombo;
|
||||
ComboOffset = original.ComboOffset;
|
||||
LegacyLastTickOffset = original.LegacyLastTickOffset;
|
||||
TickDistanceMultiplier = original.TickDistanceMultiplier;
|
||||
SliderVelocityMultiplier = original.SliderVelocityMultiplier;
|
||||
}
|
||||
|
||||
protected override void CreateNestedHitObjects(CancellationToken cancellationToken)
|
||||
{
|
||||
var sliderEvents = SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), LegacyLastTickOffset, cancellationToken);
|
||||
var sliderEvents = SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), cancellationToken);
|
||||
|
||||
foreach (var e in sliderEvents)
|
||||
{
|
||||
@@ -130,7 +129,7 @@ namespace osu.Game.Rulesets.Osu.Mods
|
||||
});
|
||||
break;
|
||||
|
||||
case SliderEventType.LegacyLastTick:
|
||||
case SliderEventType.LastTick:
|
||||
AddNested(TailCircle = new StrictTrackingSliderTailCircle(this)
|
||||
{
|
||||
RepeatIndex = e.SpanIndex,
|
||||
|
||||
@@ -36,7 +36,7 @@ namespace osu.Game.Rulesets.Osu.Mods
|
||||
|
||||
d.HitObjectApplied += _ =>
|
||||
{
|
||||
// slider tails are a painful edge case, as their start time is offset 36ms back (see `LegacyLastTick`).
|
||||
// slider tails are a painful edge case, as their start time is offset 36ms back (see `LastTick`).
|
||||
// to work around this, look up the slider tail's parenting slider's end time instead to ensure proper snap.
|
||||
double snapTime = d is DrawableSliderTail tail
|
||||
? tail.Slider.GetEndTime()
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#nullable disable
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using JetBrains.Annotations;
|
||||
using osu.Framework.Allocation;
|
||||
@@ -34,6 +35,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
|
||||
public HitReceptor HitArea { get; private set; }
|
||||
public SkinnableDrawable CirclePiece { get; private set; }
|
||||
|
||||
protected override IEnumerable<Drawable> DimmablePieces => new[]
|
||||
{
|
||||
CirclePiece,
|
||||
};
|
||||
|
||||
Drawable IHasApproachCircle.ApproachCircle => ApproachCircle;
|
||||
|
||||
private Container scaleContainer;
|
||||
@@ -191,7 +197,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
|
||||
|
||||
CirclePiece.FadeInFromZero(HitObject.TimeFadeIn);
|
||||
|
||||
ApproachCircle.FadeIn(Math.Min(HitObject.TimeFadeIn * 2, HitObject.TimePreempt));
|
||||
ApproachCircle.FadeTo(0.9f, Math.Min(HitObject.TimeFadeIn * 2, HitObject.TimePreempt));
|
||||
ApproachCircle.ScaleTo(1f, HitObject.TimePreempt);
|
||||
ApproachCircle.Expire(true);
|
||||
}
|
||||
@@ -244,7 +250,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
|
||||
|
||||
public HitReceptor()
|
||||
{
|
||||
Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2);
|
||||
Size = OsuHitObject.OBJECT_DIMENSIONS;
|
||||
|
||||
Anchor = Anchor.Centre;
|
||||
Origin = Anchor.Centre;
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
#nullable disable
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics;
|
||||
@@ -71,20 +73,17 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
|
||||
ScaleBindable.UnbindFrom(HitObject.ScaleBindable);
|
||||
}
|
||||
|
||||
protected virtual IEnumerable<Drawable> DimmablePieces => Enumerable.Empty<Drawable>();
|
||||
|
||||
protected override void UpdateInitialTransforms()
|
||||
{
|
||||
base.UpdateInitialTransforms();
|
||||
|
||||
// Dim should only be applied at a top level, as it will be implicitly applied to nested objects.
|
||||
if (ParentHitObject == null)
|
||||
foreach (var piece in DimmablePieces)
|
||||
{
|
||||
// Of note, no one noticed this was missing for years, but it definitely feels like it should still exist.
|
||||
// For now this is applied across all skins, and matches stable.
|
||||
// For simplicity, dim colour is applied to the DrawableHitObject itself.
|
||||
// We may need to make a nested container setup if this even causes a usage conflict (ie. with a mod).
|
||||
this.FadeColour(new Color4(195, 195, 195, 255));
|
||||
using (BeginDelayedSequence(InitialLifetimeOffset - OsuHitWindows.MISS_WINDOW))
|
||||
this.FadeColour(Color4.White, 100);
|
||||
piece.FadeColour(new Color4(195, 195, 195, 255));
|
||||
using (piece.BeginDelayedSequence(InitialLifetimeOffset - OsuHitWindows.MISS_WINDOW))
|
||||
piece.FadeColour(Color4.White, 100);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#nullable disable
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using JetBrains.Annotations;
|
||||
using osu.Framework.Allocation;
|
||||
@@ -35,6 +36,13 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
|
||||
|
||||
private ShakeContainer shakeContainer;
|
||||
|
||||
protected override IEnumerable<Drawable> DimmablePieces => new Drawable[]
|
||||
{
|
||||
HeadCircle,
|
||||
TailCircle,
|
||||
Body,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// A target container which can be used to add top level elements to the slider's display.
|
||||
/// Intended to be used for proxy purposes only.
|
||||
@@ -288,7 +296,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
|
||||
public override void PlaySamples()
|
||||
{
|
||||
// rather than doing it this way, we should probably attach the sample to the tail circle.
|
||||
// this can only be done after we stop using LegacyLastTick.
|
||||
// this can only be done if we stop using LastTick.
|
||||
if (!TailCircle.SamplePlaysOnlyOnHit || TailCircle.IsHit)
|
||||
base.PlaySamples();
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
|
||||
|
||||
Origin = Anchor.Centre;
|
||||
|
||||
Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2);
|
||||
Size = OsuHitObject.OBJECT_DIMENSIONS;
|
||||
|
||||
Children = new[]
|
||||
{
|
||||
|
||||
@@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
|
||||
|
||||
public SkinnableDrawable CirclePiece { get; private set; }
|
||||
|
||||
public ReverseArrowPiece Arrow { get; private set; }
|
||||
public SkinnableDrawable Arrow { get; private set; }
|
||||
|
||||
private Drawable scaleContainer;
|
||||
|
||||
@@ -50,7 +50,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
|
||||
private void load()
|
||||
{
|
||||
Origin = Anchor.Centre;
|
||||
Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2);
|
||||
Size = OsuHitObject.OBJECT_DIMENSIONS;
|
||||
|
||||
AddInternal(scaleContainer = new Container
|
||||
{
|
||||
@@ -65,7 +65,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
},
|
||||
Arrow = new ReverseArrowPiece(),
|
||||
Arrow = new SkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.ReverseArrow), _ => new DefaultReverseArrow())
|
||||
{
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
|
||||
private void load()
|
||||
{
|
||||
Origin = Anchor.Centre;
|
||||
Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2);
|
||||
Size = OsuHitObject.OBJECT_DIMENSIONS;
|
||||
|
||||
AddRangeInternal(new Drawable[]
|
||||
{
|
||||
|
||||
@@ -41,7 +41,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
|
||||
[BackgroundDependencyLoader]
|
||||
private void load()
|
||||
{
|
||||
Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2);
|
||||
Size = OsuHitObject.OBJECT_DIMENSIONS;
|
||||
Origin = Anchor.Centre;
|
||||
|
||||
AddInternal(scaleContainer = new SkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.SliderScorePoint), _ => new CircularContainer
|
||||
|
||||
@@ -21,6 +21,11 @@ namespace osu.Game.Rulesets.Osu.Objects
|
||||
/// </summary>
|
||||
public const float OBJECT_RADIUS = 64;
|
||||
|
||||
/// <summary>
|
||||
/// The width and height any element participating in display of a hitcircle (or similarly sized object) should be.
|
||||
/// </summary>
|
||||
public static readonly Vector2 OBJECT_DIMENSIONS = new Vector2(OBJECT_RADIUS * 2);
|
||||
|
||||
/// <summary>
|
||||
/// Scoring distance with a speed-adjusted beat length of 1 second (ie. the speed slider balls move through their track).
|
||||
/// </summary>
|
||||
|
||||
@@ -71,8 +71,6 @@ namespace osu.Game.Rulesets.Osu.Objects
|
||||
}
|
||||
}
|
||||
|
||||
public double? LegacyLastTickOffset { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The position of the cursor at the point of completion of this <see cref="Slider"/> if it was hit
|
||||
/// with as few movements as possible. This is set and used by difficulty calculation.
|
||||
@@ -179,7 +177,7 @@ namespace osu.Game.Rulesets.Osu.Objects
|
||||
{
|
||||
base.CreateNestedHitObjects(cancellationToken);
|
||||
|
||||
var sliderEvents = SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), LegacyLastTickOffset, cancellationToken);
|
||||
var sliderEvents = SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), cancellationToken);
|
||||
|
||||
foreach (var e in sliderEvents)
|
||||
{
|
||||
@@ -206,10 +204,11 @@ namespace osu.Game.Rulesets.Osu.Objects
|
||||
});
|
||||
break;
|
||||
|
||||
case SliderEventType.LegacyLastTick:
|
||||
// we need to use the LegacyLastTick here for compatibility reasons (difficulty).
|
||||
// it is *okay* to use this because the TailCircle is not used for any meaningful purpose in gameplay.
|
||||
// if this is to change, we should revisit this.
|
||||
case SliderEventType.LastTick:
|
||||
// Of note, we are directly mapping LastTick (instead of `SliderEventType.Tail`) to SliderTailCircle.
|
||||
// It is required as difficulty calculation and gameplay relies on reading this value.
|
||||
// (although it is displayed in classic skins, which may be a concern).
|
||||
// If this is to change, we should revisit this.
|
||||
AddNested(TailCircle = new SliderTailCircle(this)
|
||||
{
|
||||
RepeatIndex = e.SpanIndex,
|
||||
@@ -264,7 +263,9 @@ namespace osu.Game.Rulesets.Osu.Objects
|
||||
if (HeadCircle != null)
|
||||
HeadCircle.Samples = this.GetNodeSamples(0);
|
||||
|
||||
// The samples should be attached to the slider tail, however this can only be done after LegacyLastTick is removed otherwise they would play earlier than they're intended to.
|
||||
// The samples should be attached to the slider tail, however this can only be done if LastTick is removed otherwise they would play earlier than they're intended to.
|
||||
// (see mapping logic in `CreateNestedHitObjects` above)
|
||||
//
|
||||
// For now, the samples are played by the slider itself at the correct end time.
|
||||
TailSamples = this.GetNodeSamples(repeatCount + 1);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace osu.Game.Rulesets.Osu.Objects
|
||||
{
|
||||
/// <summary>
|
||||
/// Note that this should not be used for timing correctness.
|
||||
/// See <see cref="SliderEventType.LegacyLastTick"/> usage in <see cref="Slider"/> for more information.
|
||||
/// See <see cref="SliderEventType.LastTick"/> usage in <see cref="Slider"/> for more information.
|
||||
/// </summary>
|
||||
public class SliderTailCircle : SliderEndCircle
|
||||
{
|
||||
|
||||
@@ -18,6 +18,16 @@ namespace osu.Game.Rulesets.Osu.Objects
|
||||
{
|
||||
public class Spinner : OsuHitObject, IHasDuration
|
||||
{
|
||||
/// <summary>
|
||||
/// The RPM required to clear the spinner at ODs [ 0, 5, 10 ].
|
||||
/// </summary>
|
||||
private static readonly (int min, int mid, int max) clear_rpm_range = (90, 150, 225);
|
||||
|
||||
/// <summary>
|
||||
/// The RPM required to complete the spinner and receive full score at ODs [ 0, 5, 10 ].
|
||||
/// </summary>
|
||||
private static readonly (int min, int mid, int max) complete_rpm_range = (250, 380, 430);
|
||||
|
||||
public double EndTime
|
||||
{
|
||||
get => StartTime + Duration;
|
||||
@@ -52,13 +62,19 @@ namespace osu.Game.Rulesets.Osu.Objects
|
||||
{
|
||||
base.ApplyDefaultsToSelf(controlPointInfo, difficulty);
|
||||
|
||||
const double maximum_rotations_per_second = 477f / 60f;
|
||||
// The average RPS required over the length of the spinner to clear the spinner.
|
||||
double minRps = IBeatmapDifficultyInfo.DifficultyRange(difficulty.OverallDifficulty, clear_rpm_range) / 60;
|
||||
|
||||
// The RPS required over the length of the spinner to receive full score (all normal + bonus ticks).
|
||||
double maxRps = IBeatmapDifficultyInfo.DifficultyRange(difficulty.OverallDifficulty, complete_rpm_range) / 60;
|
||||
|
||||
double secondsDuration = Duration / 1000;
|
||||
double minimumRotationsPerSecond = IBeatmapDifficultyInfo.DifficultyRange(difficulty.OverallDifficulty, 1.5, 2.5, 3.75);
|
||||
|
||||
SpinsRequired = (int)(secondsDuration * minimumRotationsPerSecond);
|
||||
MaximumBonusSpins = (int)((maximum_rotations_per_second - minimumRotationsPerSecond) * secondsDuration) - bonus_spins_gap;
|
||||
// Allow a 0.1ms floating point precision error in the calculation of the duration.
|
||||
const double duration_error = 0.0001;
|
||||
|
||||
SpinsRequired = (int)(minRps * secondsDuration + duration_error);
|
||||
MaximumBonusSpins = Math.Max(0, (int)(maxRps * secondsDuration + duration_error) - SpinsRequired - bonus_spins_gap);
|
||||
}
|
||||
|
||||
protected override void CreateNestedHitObjects(CancellationToken cancellationToken)
|
||||
|
||||
@@ -33,6 +33,7 @@ using osu.Game.Rulesets.Osu.Statistics;
|
||||
using osu.Game.Rulesets.Osu.UI;
|
||||
using osu.Game.Rulesets.Replays.Types;
|
||||
using osu.Game.Rulesets.Scoring;
|
||||
using osu.Game.Rulesets.Scoring.Legacy;
|
||||
using osu.Game.Rulesets.UI;
|
||||
using osu.Game.Scoring;
|
||||
using osu.Game.Screens.Edit.Setup;
|
||||
|
||||
@@ -48,7 +48,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon
|
||||
|
||||
private Bindable<bool> configHitLighting = null!;
|
||||
|
||||
private static readonly Vector2 circle_size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2);
|
||||
private static readonly Vector2 circle_size = OsuHitObject.OBJECT_DIMENSIONS;
|
||||
|
||||
[Resolved]
|
||||
private DrawableHitObject drawableObject { get; set; } = null!;
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Extensions.Color4Extensions;
|
||||
using osu.Framework.Extensions.ObjectExtensions;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Framework.Graphics.Shapes;
|
||||
using osu.Framework.Graphics.Sprites;
|
||||
using osu.Framework.Graphics.Textures;
|
||||
using osu.Game.Rulesets.Objects.Drawables;
|
||||
using osu.Game.Rulesets.Osu.Objects;
|
||||
using osuTK;
|
||||
@@ -17,38 +19,92 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon
|
||||
{
|
||||
public partial class ArgonReverseArrow : CompositeDrawable
|
||||
{
|
||||
[Resolved]
|
||||
private DrawableHitObject drawableObject { get; set; } = null!;
|
||||
|
||||
private Bindable<Color4> accentColour = null!;
|
||||
|
||||
private SpriteIcon icon = null!;
|
||||
private Container main = null!;
|
||||
private Sprite side = null!;
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load(DrawableHitObject hitObject)
|
||||
private void load(TextureStore textures)
|
||||
{
|
||||
Anchor = Anchor.Centre;
|
||||
Origin = Anchor.Centre;
|
||||
|
||||
Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2);
|
||||
Size = OsuHitObject.OBJECT_DIMENSIONS;
|
||||
|
||||
InternalChildren = new Drawable[]
|
||||
{
|
||||
new Circle
|
||||
main = new Container
|
||||
{
|
||||
Size = new Vector2(40, 20),
|
||||
Colour = Color4.White,
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
Children = new Drawable[]
|
||||
{
|
||||
new Circle
|
||||
{
|
||||
Size = new Vector2(40, 20),
|
||||
Colour = Color4.White,
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
},
|
||||
icon = new SpriteIcon
|
||||
{
|
||||
Icon = FontAwesome.Solid.AngleDoubleRight,
|
||||
Size = new Vector2(16),
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
},
|
||||
}
|
||||
},
|
||||
icon = new SpriteIcon
|
||||
side = new Sprite
|
||||
{
|
||||
Icon = FontAwesome.Solid.AngleDoubleRight,
|
||||
Size = new Vector2(16),
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
},
|
||||
Texture = textures.Get("Gameplay/osu/repeat-edge-piece"),
|
||||
Size = new Vector2(ArgonMainCirclePiece.OUTER_GRADIENT_SIZE),
|
||||
}
|
||||
};
|
||||
|
||||
accentColour = hitObject.AccentColour.GetBoundCopy();
|
||||
accentColour = drawableObject.AccentColour.GetBoundCopy();
|
||||
accentColour.BindValueChanged(accent => icon.Colour = accent.NewValue.Darken(4), true);
|
||||
|
||||
drawableObject.ApplyCustomUpdateState += updateStateTransforms;
|
||||
}
|
||||
|
||||
private void updateStateTransforms(DrawableHitObject hitObject, ArmedState state)
|
||||
{
|
||||
const float move_distance = -12;
|
||||
const double move_out_duration = 35;
|
||||
const double move_in_duration = 250;
|
||||
const double total = 300;
|
||||
|
||||
switch (state)
|
||||
{
|
||||
case ArmedState.Idle:
|
||||
main.ScaleTo(1.3f, move_out_duration, Easing.Out)
|
||||
.Then()
|
||||
.ScaleTo(1f, move_in_duration, Easing.Out)
|
||||
.Loop(total - (move_in_duration + move_out_duration));
|
||||
side
|
||||
.MoveToX(move_distance, move_out_duration, Easing.Out)
|
||||
.Then()
|
||||
.MoveToX(0, move_in_duration, Easing.Out)
|
||||
.Loop(total - (move_in_duration + move_out_duration));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void Dispose(bool isDisposing)
|
||||
{
|
||||
base.Dispose(isDisposing);
|
||||
|
||||
if (drawableObject.IsNotNull())
|
||||
drawableObject.ApplyCustomUpdateState -= updateStateTransforms;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ using osu.Framework.Graphics.Sprites;
|
||||
using osu.Framework.Graphics.Textures;
|
||||
using osu.Game.Rulesets.Objects.Drawables;
|
||||
using osu.Game.Rulesets.Osu.Objects;
|
||||
using osuTK;
|
||||
|
||||
namespace osu.Game.Rulesets.Osu.Skinning.Default
|
||||
{
|
||||
@@ -22,7 +21,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
|
||||
|
||||
public CirclePiece()
|
||||
{
|
||||
Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2);
|
||||
Size = OsuHitObject.OBJECT_DIMENSIONS;
|
||||
Masking = true;
|
||||
|
||||
CornerRadius = Size.X / 2;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Extensions.ObjectExtensions;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Framework.Graphics.Sprites;
|
||||
using osu.Game.Rulesets.Objects.Drawables;
|
||||
using osu.Game.Rulesets.Osu.Objects;
|
||||
using osuTK;
|
||||
|
||||
namespace osu.Game.Rulesets.Osu.Skinning.Default
|
||||
{
|
||||
public partial class DefaultReverseArrow : CompositeDrawable
|
||||
{
|
||||
[Resolved]
|
||||
private DrawableHitObject drawableObject { get; set; } = null!;
|
||||
|
||||
public DefaultReverseArrow()
|
||||
{
|
||||
Anchor = Anchor.Centre;
|
||||
Origin = Anchor.Centre;
|
||||
|
||||
Size = OsuHitObject.OBJECT_DIMENSIONS;
|
||||
|
||||
InternalChild = new SpriteIcon
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Blending = BlendingParameters.Additive,
|
||||
Icon = FontAwesome.Solid.ChevronRight,
|
||||
Size = new Vector2(0.35f),
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
};
|
||||
}
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load()
|
||||
{
|
||||
drawableObject.ApplyCustomUpdateState += updateStateTransforms;
|
||||
}
|
||||
|
||||
private void updateStateTransforms(DrawableHitObject hitObject, ArmedState state)
|
||||
{
|
||||
const double move_out_duration = 35;
|
||||
const double move_in_duration = 250;
|
||||
const double total = 300;
|
||||
|
||||
switch (state)
|
||||
{
|
||||
case ArmedState.Idle:
|
||||
InternalChild.ScaleTo(1.3f, move_out_duration, Easing.Out)
|
||||
.Then()
|
||||
.ScaleTo(1f, move_in_duration, Easing.Out)
|
||||
.Loop(total - (move_in_duration + move_out_duration));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void Dispose(bool isDisposing)
|
||||
{
|
||||
base.Dispose(isDisposing);
|
||||
|
||||
if (drawableObject.IsNotNull())
|
||||
drawableObject.ApplyCustomUpdateState -= updateStateTransforms;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Game.Rulesets.Objects.Drawables;
|
||||
using osu.Game.Rulesets.Osu.Objects;
|
||||
using osuTK;
|
||||
|
||||
namespace osu.Game.Rulesets.Osu.Skinning.Default
|
||||
{
|
||||
@@ -20,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
|
||||
|
||||
public ExplodePiece()
|
||||
{
|
||||
Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2);
|
||||
Size = OsuHitObject.OBJECT_DIMENSIONS;
|
||||
|
||||
Anchor = Anchor.Centre;
|
||||
Origin = Anchor.Centre;
|
||||
|
||||
@@ -5,7 +5,6 @@ using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Framework.Graphics.Shapes;
|
||||
using osu.Game.Rulesets.Osu.Objects;
|
||||
using osuTK;
|
||||
|
||||
namespace osu.Game.Rulesets.Osu.Skinning.Default
|
||||
{
|
||||
@@ -13,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
|
||||
{
|
||||
public FlashPiece()
|
||||
{
|
||||
Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2);
|
||||
Size = OsuHitObject.OBJECT_DIMENSIONS;
|
||||
|
||||
Anchor = Anchor.Centre;
|
||||
Origin = Anchor.Centre;
|
||||
|
||||
@@ -9,7 +9,6 @@ using osu.Framework.Graphics.Containers;
|
||||
using osu.Game.Rulesets.Objects.Drawables;
|
||||
using osu.Game.Rulesets.Osu.Objects;
|
||||
using osu.Game.Rulesets.Osu.Objects.Drawables;
|
||||
using osuTK;
|
||||
using osuTK.Graphics;
|
||||
|
||||
namespace osu.Game.Rulesets.Osu.Skinning.Default
|
||||
@@ -25,7 +24,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
|
||||
|
||||
public MainCirclePiece()
|
||||
{
|
||||
Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2);
|
||||
Size = OsuHitObject.OBJECT_DIMENSIONS;
|
||||
|
||||
Anchor = Anchor.Centre;
|
||||
Origin = Anchor.Centre;
|
||||
|
||||
@@ -1,51 +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.Framework.Allocation;
|
||||
using osu.Framework.Audio.Track;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Sprites;
|
||||
using osu.Game.Beatmaps.ControlPoints;
|
||||
using osu.Game.Graphics.Containers;
|
||||
using osu.Game.Rulesets.Objects.Drawables;
|
||||
using osu.Game.Rulesets.Osu.Objects;
|
||||
using osu.Game.Skinning;
|
||||
using osuTK;
|
||||
|
||||
namespace osu.Game.Rulesets.Osu.Skinning.Default
|
||||
{
|
||||
public partial class ReverseArrowPiece : BeatSyncedContainer
|
||||
{
|
||||
[Resolved]
|
||||
private DrawableHitObject drawableRepeat { get; set; } = null!;
|
||||
|
||||
public ReverseArrowPiece()
|
||||
{
|
||||
Divisor = 2;
|
||||
MinimumBeatLength = 200;
|
||||
|
||||
Anchor = Anchor.Centre;
|
||||
Origin = Anchor.Centre;
|
||||
|
||||
Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2);
|
||||
|
||||
Child = new SkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.ReverseArrow), _ => new SpriteIcon
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Blending = BlendingParameters.Additive,
|
||||
Icon = FontAwesome.Solid.ChevronRight,
|
||||
Size = new Vector2(0.35f)
|
||||
})
|
||||
{
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
};
|
||||
}
|
||||
|
||||
protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes)
|
||||
{
|
||||
if (!drawableRepeat.IsHit)
|
||||
Child.ScaleTo(1.3f).ScaleTo(1f, timingPoint.BeatLength, Easing.Out);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Framework.Graphics.Shapes;
|
||||
using osu.Game.Rulesets.Osu.Objects;
|
||||
using osuTK;
|
||||
using osuTK.Graphics;
|
||||
|
||||
namespace osu.Game.Rulesets.Osu.Skinning.Default
|
||||
@@ -14,7 +13,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
|
||||
{
|
||||
public RingPiece(float thickness = 9)
|
||||
{
|
||||
Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2);
|
||||
Size = OsuHitObject.OBJECT_DIMENSIONS;
|
||||
|
||||
Anchor = Anchor.Centre;
|
||||
Origin = Anchor.Centre;
|
||||
|
||||
@@ -5,12 +5,14 @@ using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Game.Rulesets.Objects.Drawables;
|
||||
using osu.Game.Rulesets.Osu.Objects;
|
||||
using osu.Game.Skinning;
|
||||
using osuTK;
|
||||
using osuTK.Graphics;
|
||||
|
||||
namespace osu.Game.Rulesets.Osu.Skinning.Legacy
|
||||
{
|
||||
// todo: this should probably not be a SkinnableSprite, as this is always created for legacy skins and is recreated on skin change.
|
||||
public partial class LegacyApproachCircle : SkinnableSprite
|
||||
{
|
||||
private readonly IBindable<Color4> accentColour = new Bindable<Color4>();
|
||||
@@ -19,7 +21,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
|
||||
private DrawableHitObject drawableObject { get; set; } = null!;
|
||||
|
||||
public LegacyApproachCircle()
|
||||
: base("Gameplay/osu/approachcircle")
|
||||
: base("Gameplay/osu/approachcircle", OsuHitObject.OBJECT_DIMENSIONS * 2)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ using osu.Game.Rulesets.Objects.Drawables;
|
||||
using osu.Game.Rulesets.Osu.Objects;
|
||||
using osu.Game.Rulesets.Osu.Objects.Drawables;
|
||||
using osu.Game.Skinning;
|
||||
using osuTK;
|
||||
using osuTK.Graphics;
|
||||
|
||||
namespace osu.Game.Rulesets.Osu.Skinning.Legacy
|
||||
@@ -51,7 +50,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
|
||||
this.priorityLookupPrefix = priorityLookupPrefix;
|
||||
this.hasNumber = hasNumber;
|
||||
|
||||
Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2);
|
||||
Size = OsuHitObject.OBJECT_DIMENSIONS;
|
||||
}
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
@@ -68,7 +67,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
|
||||
// expected behaviour in this scenario is not showing the overlay, rather than using hitcircleoverlay.png.
|
||||
InternalChildren = new[]
|
||||
{
|
||||
CircleSprite = new LegacyKiaiFlashingDrawable(() => new Sprite { Texture = skin.GetTexture(circleName) })
|
||||
CircleSprite = new LegacyKiaiFlashingDrawable(() => new Sprite { Texture = skin.GetTexture(circleName)?.WithMaximumSize(OsuHitObject.OBJECT_DIMENSIONS * 2) })
|
||||
{
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
@@ -77,7 +76,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
|
||||
{
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
Child = OverlaySprite = new LegacyKiaiFlashingDrawable(() => skin.GetAnimation(@$"{circleName}overlay", true, true, frameLength: 1000 / 2d))
|
||||
Child = OverlaySprite = new LegacyKiaiFlashingDrawable(() => skin.GetAnimation(@$"{circleName}overlay", true, true, frameLength: 1000 / 2d, maxSize: OsuHitObject.OBJECT_DIMENSIONS * 2))
|
||||
{
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
using System.Diagnostics;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Extensions.ObjectExtensions;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Game.Rulesets.Objects.Drawables;
|
||||
using osu.Game.Rulesets.Osu.Objects;
|
||||
using osu.Game.Rulesets.Osu.Objects.Drawables;
|
||||
using osu.Game.Skinning;
|
||||
using osuTK.Graphics;
|
||||
@@ -15,8 +17,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
|
||||
{
|
||||
public partial class LegacyReverseArrow : CompositeDrawable
|
||||
{
|
||||
[Resolved(canBeNull: true)]
|
||||
private DrawableHitObject? drawableHitObject { get; set; }
|
||||
[Resolved]
|
||||
private DrawableHitObject drawableObject { get; set; } = null!;
|
||||
|
||||
private Drawable proxy = null!;
|
||||
|
||||
@@ -26,6 +28,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
|
||||
|
||||
private Drawable arrow = null!;
|
||||
|
||||
private bool shouldRotate;
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load(ISkinSource skinSource)
|
||||
{
|
||||
@@ -35,8 +39,17 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
|
||||
|
||||
var skin = skinSource.FindProvider(s => s.GetTexture(lookupName) != null);
|
||||
|
||||
InternalChild = arrow = (skin?.GetAnimation(lookupName, true, true) ?? Empty());
|
||||
InternalChild = arrow = (skin?.GetAnimation(lookupName, true, true, maxSize: OsuHitObject.OBJECT_DIMENSIONS * 2) ?? Empty()).With(d =>
|
||||
{
|
||||
d.Anchor = Anchor.Centre;
|
||||
d.Origin = Anchor.Centre;
|
||||
});
|
||||
|
||||
textureIsDefaultSkin = skin is ISkinTransformer transformer && transformer.Skin is DefaultLegacySkin;
|
||||
|
||||
drawableObject.ApplyCustomUpdateState += updateStateTransforms;
|
||||
|
||||
shouldRotate = skinSource.GetConfig<SkinConfiguration.LegacySetting, decimal>(SkinConfiguration.LegacySetting.Version)?.Value <= 1;
|
||||
}
|
||||
|
||||
protected override void LoadComplete()
|
||||
@@ -45,17 +58,14 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
|
||||
|
||||
proxy = CreateProxy();
|
||||
|
||||
if (drawableHitObject != null)
|
||||
{
|
||||
drawableHitObject.HitObjectApplied += onHitObjectApplied;
|
||||
onHitObjectApplied(drawableHitObject);
|
||||
drawableObject.HitObjectApplied += onHitObjectApplied;
|
||||
onHitObjectApplied(drawableObject);
|
||||
|
||||
accentColour = drawableHitObject.AccentColour.GetBoundCopy();
|
||||
accentColour.BindValueChanged(c =>
|
||||
{
|
||||
arrow.Colour = textureIsDefaultSkin && c.NewValue.R + c.NewValue.G + c.NewValue.B > (600 / 255f) ? Color4.Black : Color4.White;
|
||||
}, true);
|
||||
}
|
||||
accentColour = drawableObject.AccentColour.GetBoundCopy();
|
||||
accentColour.BindValueChanged(c =>
|
||||
{
|
||||
arrow.Colour = textureIsDefaultSkin && c.NewValue.R + c.NewValue.G + c.NewValue.B > (600 / 255f) ? Color4.Black : Color4.White;
|
||||
}, true);
|
||||
}
|
||||
|
||||
private void onHitObjectApplied(DrawableHitObject drawableObject)
|
||||
@@ -67,11 +77,43 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
|
||||
.OverlayElementContainer.Add(proxy);
|
||||
}
|
||||
|
||||
private void updateStateTransforms(DrawableHitObject hitObject, ArmedState state)
|
||||
{
|
||||
const double duration = 300;
|
||||
const float rotation = 5.625f;
|
||||
|
||||
switch (state)
|
||||
{
|
||||
case ArmedState.Idle:
|
||||
if (shouldRotate)
|
||||
{
|
||||
InternalChild.ScaleTo(1.3f)
|
||||
.RotateTo(rotation)
|
||||
.Then()
|
||||
.ScaleTo(1f, duration)
|
||||
.RotateTo(-rotation, duration)
|
||||
.Loop();
|
||||
}
|
||||
else
|
||||
{
|
||||
InternalChild.ScaleTo(1.3f).Then()
|
||||
.ScaleTo(1f, duration, Easing.Out)
|
||||
.Loop();
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void Dispose(bool isDisposing)
|
||||
{
|
||||
base.Dispose(isDisposing);
|
||||
if (drawableHitObject != null)
|
||||
drawableHitObject.HitObjectApplied -= onHitObjectApplied;
|
||||
|
||||
if (drawableObject.IsNotNull())
|
||||
{
|
||||
drawableObject.HitObjectApplied -= onHitObjectApplied;
|
||||
drawableObject.ApplyCustomUpdateState -= updateStateTransforms;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
|
||||
{
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
Texture = skin.GetTexture("sliderb-nd"),
|
||||
Texture = skin.GetTexture("sliderb-nd")?.WithMaximumSize(OsuLegacySkinTransformer.MAX_FOLLOW_CIRCLE_AREA_SIZE),
|
||||
Colour = new Color4(5, 5, 5, 255),
|
||||
},
|
||||
LegacyColourCompatibility.ApplyWithDoubledAlpha(animationContent.With(d =>
|
||||
@@ -58,7 +58,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
|
||||
{
|
||||
Anchor = Anchor.Centre,
|
||||
Origin = Anchor.Centre,
|
||||
Texture = skin.GetTexture("sliderb-spec"),
|
||||
Texture = skin.GetTexture("sliderb-spec")?.WithMaximumSize(OsuLegacySkinTransformer.MAX_FOLLOW_CIRCLE_AREA_SIZE),
|
||||
Blending = BlendingParameters.Additive,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
using System;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Game.Rulesets.Osu.Objects;
|
||||
using osu.Game.Skinning;
|
||||
using osuTK;
|
||||
|
||||
@@ -20,7 +21,17 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
|
||||
/// Their hittable area is 128px, but the actual circle portion is 118px.
|
||||
/// We must account for some gameplay elements such as slider bodies, where this padding is not present.
|
||||
/// </summary>
|
||||
public const float LEGACY_CIRCLE_RADIUS = 64 - 5;
|
||||
public const float LEGACY_CIRCLE_RADIUS = OsuHitObject.OBJECT_RADIUS - 5;
|
||||
|
||||
/// <summary>
|
||||
/// The maximum allowed size of sprites that reside in the follow circle area of a slider.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The reason this is extracted out to a constant, rather than be inlined in the follow circle sprite retrieval,
|
||||
/// is that some skins will use `sliderb` elements to emulate a slider follow circle with slightly different visual effects applied
|
||||
/// (`sliderb` is always shown and doesn't pulsate; `sliderfollowcircle` isn't always shown and pulsates).
|
||||
/// </remarks>
|
||||
public static readonly Vector2 MAX_FOLLOW_CIRCLE_AREA_SIZE = OsuHitObject.OBJECT_DIMENSIONS * 3;
|
||||
|
||||
public OsuLegacySkinTransformer(ISkin skin)
|
||||
: base(skin)
|
||||
@@ -41,14 +52,14 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
|
||||
return this.GetAnimation("sliderscorepoint", false, false);
|
||||
|
||||
case OsuSkinComponents.SliderFollowCircle:
|
||||
var followCircleContent = this.GetAnimation("sliderfollowcircle", true, true, true);
|
||||
var followCircleContent = this.GetAnimation("sliderfollowcircle", true, true, true, maxSize: MAX_FOLLOW_CIRCLE_AREA_SIZE);
|
||||
if (followCircleContent != null)
|
||||
return new LegacyFollowCircle(followCircleContent);
|
||||
|
||||
return null;
|
||||
|
||||
case OsuSkinComponents.SliderBall:
|
||||
var sliderBallContent = this.GetAnimation("sliderb", true, true, animationSeparator: "");
|
||||
var sliderBallContent = this.GetAnimation("sliderb", true, true, animationSeparator: "", maxSize: MAX_FOLLOW_CIRCLE_AREA_SIZE);
|
||||
|
||||
// todo: slider ball has a custom frame delay based on velocity
|
||||
// Math.Max((150 / Velocity) * GameBase.SIXTY_FRAME_TIME, GameBase.SIXTY_FRAME_TIME);
|
||||
@@ -138,10 +149,11 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
|
||||
if (!this.HasFont(LegacyFont.HitCircle))
|
||||
return null;
|
||||
|
||||
return new LegacySpriteText(LegacyFont.HitCircle)
|
||||
const float hitcircle_text_scale = 0.8f;
|
||||
return new LegacySpriteText(LegacyFont.HitCircle, OsuHitObject.OBJECT_DIMENSIONS * 2 / hitcircle_text_scale)
|
||||
{
|
||||
// stable applies a blanket 0.8x scale to hitcircle fonts
|
||||
Scale = new Vector2(0.8f),
|
||||
Scale = new Vector2(hitcircle_text_scale),
|
||||
};
|
||||
|
||||
case OsuSkinComponents.SpinnerBody:
|
||||
|
||||
@@ -95,7 +95,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
|
||||
{
|
||||
new RingPiece(3)
|
||||
{
|
||||
Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2),
|
||||
Size = OsuHitObject.OBJECT_DIMENSIONS,
|
||||
Alpha = 0.1f,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -115,6 +115,48 @@ namespace osu.Game.Rulesets.Taiko.Tests.Judgements
|
||||
AddAssert("all tick offsets are 0", () => JudgementResults.Where(r => r.HitObject is SwellTick).All(r => r.TimeOffset == 0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestAtMostOneSwellTickJudgedPerFrame()
|
||||
{
|
||||
const double swell_time = 1000;
|
||||
|
||||
Swell swell = new Swell
|
||||
{
|
||||
StartTime = swell_time,
|
||||
Duration = 1000,
|
||||
RequiredHits = 10
|
||||
};
|
||||
|
||||
List<ReplayFrame> frames = new List<ReplayFrame>
|
||||
{
|
||||
new TaikoReplayFrame(1000),
|
||||
new TaikoReplayFrame(1250, TaikoAction.LeftCentre, TaikoAction.LeftRim),
|
||||
new TaikoReplayFrame(1251),
|
||||
new TaikoReplayFrame(1500, TaikoAction.LeftCentre, TaikoAction.LeftRim, TaikoAction.RightCentre, TaikoAction.RightRim),
|
||||
new TaikoReplayFrame(1501),
|
||||
new TaikoReplayFrame(2000),
|
||||
};
|
||||
|
||||
PerformTest(frames, CreateBeatmap(swell));
|
||||
|
||||
AssertJudgementCount(11);
|
||||
|
||||
// this is a charitable interpretation of the inputs.
|
||||
//
|
||||
// for the frame at time 1250, we only count either one of the input actions - simple.
|
||||
//
|
||||
// for the frame at time 1500, we give the user the benefit of the doubt,
|
||||
// and we ignore actions that wouldn't otherwise cause a hit due to not alternating,
|
||||
// but we still count one (just one) of the actions that _would_ normally cause a hit.
|
||||
// this is done as a courtesy to avoid stuff like key chattering after press blocking legitimate inputs.
|
||||
for (int i = 0; i < 2; i++)
|
||||
AssertResult<SwellTick>(i, HitResult.IgnoreHit);
|
||||
for (int i = 2; i < swell.RequiredHits; i++)
|
||||
AssertResult<SwellTick>(i, HitResult.IgnoreMiss);
|
||||
|
||||
AssertResult<Swell>(0, HitResult.IgnoreMiss);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensure input is correctly sent to subsequent hits if a swell is fully completed.
|
||||
/// </summary>
|
||||
|
||||
@@ -38,7 +38,7 @@ namespace osu.Game.Rulesets.Taiko.Tests
|
||||
[SetUp]
|
||||
public void SetUp() => Schedule(() =>
|
||||
{
|
||||
gameplayClock = new GameplayClockContainer(manualClock)
|
||||
gameplayClock = new GameplayClockContainer(manualClock, false, false)
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Children = new Drawable[]
|
||||
|
||||
@@ -25,12 +25,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
|
||||
|
||||
public override int Version => 20220902;
|
||||
|
||||
private readonly IWorkingBeatmap workingBeatmap;
|
||||
|
||||
public TaikoDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
|
||||
: base(ruleset, beatmap)
|
||||
{
|
||||
workingBeatmap = beatmap;
|
||||
}
|
||||
|
||||
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate)
|
||||
@@ -99,15 +96,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
|
||||
MaxCombo = beatmap.HitObjects.Count(h => h is Hit),
|
||||
};
|
||||
|
||||
if (ComputeLegacyScoringValues)
|
||||
{
|
||||
TaikoLegacyScoreSimulator sv1Simulator = new TaikoLegacyScoreSimulator();
|
||||
sv1Simulator.Simulate(workingBeatmap, beatmap, mods);
|
||||
attributes.LegacyAccuracyScore = sv1Simulator.AccuracyScore;
|
||||
attributes.LegacyComboScore = sv1Simulator.ComboScore;
|
||||
attributes.LegacyBonusScoreRatio = sv1Simulator.BonusScoreRatio;
|
||||
}
|
||||
|
||||
return attributes;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,31 +10,24 @@ using osu.Game.Rulesets.Mods;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
using osu.Game.Rulesets.Objects.Types;
|
||||
using osu.Game.Rulesets.Scoring;
|
||||
using osu.Game.Rulesets.Scoring.Legacy;
|
||||
using osu.Game.Rulesets.Taiko.Mods;
|
||||
using osu.Game.Rulesets.Taiko.Objects;
|
||||
|
||||
namespace osu.Game.Rulesets.Taiko.Difficulty
|
||||
{
|
||||
internal class TaikoLegacyScoreSimulator : ILegacyScoreSimulator
|
||||
{
|
||||
public int AccuracyScore { get; private set; }
|
||||
|
||||
public int ComboScore { get; private set; }
|
||||
|
||||
public double BonusScoreRatio => legacyBonusScore == 0 ? 0 : (double)modernBonusScore / legacyBonusScore;
|
||||
|
||||
private int legacyBonusScore;
|
||||
private int modernBonusScore;
|
||||
private int standardisedBonusScore;
|
||||
private int combo;
|
||||
|
||||
private double modMultiplier;
|
||||
private int difficultyPeppyStars;
|
||||
private IBeatmap playableBeatmap = null!;
|
||||
private IReadOnlyList<Mod> mods = null!;
|
||||
|
||||
public void Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap, IReadOnlyList<Mod> mods)
|
||||
public LegacyScoreAttributes Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap)
|
||||
{
|
||||
this.playableBeatmap = playableBeatmap;
|
||||
this.mods = mods;
|
||||
|
||||
IBeatmap baseBeatmap = workingBeatmap.Beatmap;
|
||||
|
||||
@@ -76,13 +69,17 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
|
||||
+ baseBeatmap.Difficulty.CircleSize
|
||||
+ Math.Clamp((float)objectCount / drainLength * 8, 0, 16)) / 38 * 5);
|
||||
|
||||
modMultiplier = mods.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier);
|
||||
LegacyScoreAttributes attributes = new LegacyScoreAttributes();
|
||||
|
||||
foreach (var obj in playableBeatmap.HitObjects)
|
||||
simulateHit(obj);
|
||||
simulateHit(obj, ref attributes);
|
||||
|
||||
attributes.BonusScoreRatio = legacyBonusScore == 0 ? 0 : (double)standardisedBonusScore / legacyBonusScore;
|
||||
|
||||
return attributes;
|
||||
}
|
||||
|
||||
private void simulateHit(HitObject hitObject)
|
||||
private void simulateHit(HitObject hitObject, ref LegacyScoreAttributes attributes)
|
||||
{
|
||||
bool increaseCombo = true;
|
||||
bool addScoreComboMultiplier = false;
|
||||
@@ -109,21 +106,24 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
|
||||
case Swell swell:
|
||||
// The taiko swell generally does not match the osu-stable implementation in any way.
|
||||
// We'll redo the calculations to match osu-stable here...
|
||||
double minimumRotationsPerSecond = IBeatmapDifficultyInfo.DifficultyRange(playableBeatmap.Difficulty.OverallDifficulty, 3, 5, 7.5);
|
||||
double secondsDuration = swell.Duration / 1000;
|
||||
|
||||
// Normally, this value depends on the final overall difficulty. For simplicity, we'll only consider the worst case that maximises rotations.
|
||||
const double minimum_rotations_per_second = 7.5;
|
||||
|
||||
// The amount of half spins that are required to successfully complete the spinner (i.e. get a 300).
|
||||
int halfSpinsRequiredForCompletion = (int)(secondsDuration * minimumRotationsPerSecond);
|
||||
|
||||
int halfSpinsRequiredForCompletion = (int)(swell.Duration / 1000 * minimum_rotations_per_second);
|
||||
halfSpinsRequiredForCompletion = (int)Math.Max(1, halfSpinsRequiredForCompletion * 1.65f);
|
||||
|
||||
if (mods.Any(m => m is ModDoubleTime))
|
||||
halfSpinsRequiredForCompletion = Math.Max(1, (int)(halfSpinsRequiredForCompletion * 0.75f));
|
||||
if (mods.Any(m => m is ModHalfTime))
|
||||
halfSpinsRequiredForCompletion = Math.Max(1, (int)(halfSpinsRequiredForCompletion * 1.5f));
|
||||
//
|
||||
// Normally, this multiplier depends on the active mods (DT = 0.75, HT = 1.5). For simplicity, we'll only consider the worst case that maximises rotations.
|
||||
// This way, scores remain beatable at the cost of the conversion being slightly inaccurate.
|
||||
// - A perfect DT/NM score will have less than 1M total score (excluding bonus).
|
||||
// - A perfect HT score will have 1M total score (excluding bonus).
|
||||
//
|
||||
halfSpinsRequiredForCompletion = Math.Max(1, (int)(halfSpinsRequiredForCompletion * 1.5f));
|
||||
|
||||
for (int i = 0; i <= halfSpinsRequiredForCompletion; i++)
|
||||
simulateHit(new SwellTick());
|
||||
simulateHit(new SwellTick(), ref attributes);
|
||||
|
||||
scoreIncrease = 300;
|
||||
addScoreComboMultiplier = true;
|
||||
@@ -139,7 +139,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
|
||||
|
||||
case DrumRoll:
|
||||
foreach (var nested in hitObject.NestedHitObjects)
|
||||
simulateHit(nested);
|
||||
simulateHit(nested, ref attributes);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -159,8 +159,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
|
||||
{
|
||||
int oldScoreIncrease = scoreIncrease;
|
||||
|
||||
// ReSharper disable once PossibleLossOfFraction (intentional to match osu-stable...)
|
||||
scoreIncrease += (int)(scoreIncrease / 35 * 2 * (difficultyPeppyStars + 1) * modMultiplier) * (Math.Min(100, combo) / 10);
|
||||
scoreIncrease += scoreIncrease / 35 * 2 * (difficultyPeppyStars + 1) * (Math.Min(100, combo) / 10);
|
||||
|
||||
if (hitObject is Swell)
|
||||
{
|
||||
@@ -185,18 +184,60 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
|
||||
scoreIncrease -= comboScoreIncrease;
|
||||
|
||||
if (addScoreComboMultiplier)
|
||||
ComboScore += comboScoreIncrease;
|
||||
attributes.ComboScore += comboScoreIncrease;
|
||||
|
||||
if (isBonus)
|
||||
{
|
||||
legacyBonusScore += scoreIncrease;
|
||||
modernBonusScore += Judgement.ToNumericResult(bonusResult);
|
||||
standardisedBonusScore += Judgement.ToNumericResult(bonusResult);
|
||||
}
|
||||
else
|
||||
AccuracyScore += scoreIncrease;
|
||||
attributes.AccuracyScore += scoreIncrease;
|
||||
|
||||
if (increaseCombo)
|
||||
combo++;
|
||||
}
|
||||
|
||||
public double GetLegacyScoreMultiplier(IReadOnlyList<Mod> mods, LegacyBeatmapConversionDifficultyInfo difficulty)
|
||||
{
|
||||
bool scoreV2 = mods.Any(m => m is ModScoreV2);
|
||||
|
||||
double multiplier = 1.0;
|
||||
|
||||
foreach (var mod in mods)
|
||||
{
|
||||
switch (mod)
|
||||
{
|
||||
case TaikoModNoFail:
|
||||
multiplier *= scoreV2 ? 1.0 : 0.5;
|
||||
break;
|
||||
|
||||
case TaikoModEasy:
|
||||
multiplier *= 0.5;
|
||||
break;
|
||||
|
||||
case TaikoModHalfTime:
|
||||
case TaikoModDaycore:
|
||||
multiplier *= 0.3;
|
||||
break;
|
||||
|
||||
case TaikoModHidden:
|
||||
case TaikoModHardRock:
|
||||
multiplier *= 1.06;
|
||||
break;
|
||||
|
||||
case TaikoModDoubleTime:
|
||||
case TaikoModNightcore:
|
||||
case TaikoModFlashlight:
|
||||
multiplier *= 1.12;
|
||||
break;
|
||||
|
||||
case TaikoModRelax:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
return multiplier;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace osu.Game.Rulesets.Taiko.Edit
|
||||
{
|
||||
public partial class DrawableTaikoEditorRuleset : DrawableTaikoRuleset, ISupportConstantAlgorithmToggle
|
||||
{
|
||||
public BindableBool ShowSpeedChanges { get; set; } = new BindableBool();
|
||||
public BindableBool ShowSpeedChanges { get; } = new BindableBool();
|
||||
|
||||
public DrawableTaikoEditorRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod> mods)
|
||||
: base(ruleset, beatmap, mods)
|
||||
|
||||
@@ -17,6 +17,7 @@ using osu.Framework.Graphics.Shapes;
|
||||
using osu.Framework.Input.Events;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
using osu.Game.Rulesets.Taiko.Skinning.Default;
|
||||
using osu.Game.Screens.Play;
|
||||
using osu.Game.Skinning;
|
||||
|
||||
namespace osu.Game.Rulesets.Taiko.Objects.Drawables
|
||||
@@ -38,6 +39,8 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
|
||||
private readonly CircularContainer targetRing;
|
||||
private readonly CircularContainer expandingRing;
|
||||
|
||||
private double? lastPressHandleTime;
|
||||
|
||||
public override bool DisplayResult => false;
|
||||
|
||||
public DrawableSwell()
|
||||
@@ -140,6 +143,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
|
||||
UnproxyContent();
|
||||
|
||||
lastWasCentre = null;
|
||||
lastPressHandleTime = null;
|
||||
}
|
||||
|
||||
protected override void AddNestedHitObject(DrawableHitObject hitObject)
|
||||
@@ -266,6 +270,9 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
|
||||
ProxyContent();
|
||||
else
|
||||
UnproxyContent();
|
||||
|
||||
if ((Clock as IGameplayClock)?.IsRewinding == true)
|
||||
lastPressHandleTime = null;
|
||||
}
|
||||
|
||||
private bool? lastWasCentre;
|
||||
@@ -285,7 +292,14 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
|
||||
if (lastWasCentre == isCentre)
|
||||
return false;
|
||||
|
||||
// If we've already successfully judged a tick this frame, do not judge more.
|
||||
// Note that the ordering is important here - this is intentionally placed after the alternating check.
|
||||
// That is done to prevent accidental double inputs blocking simultaneous but legitimate hits from registering.
|
||||
if (lastPressHandleTime == Time.Current)
|
||||
return true;
|
||||
|
||||
lastWasCentre = isCentre;
|
||||
lastPressHandleTime = Time.Current;
|
||||
|
||||
UpdateResult(true);
|
||||
|
||||
|
||||