【对齐】重置提交数

This commit is contained in:
LA
2026-01-21 23:20:42 +08:00
parent ae1402c9cd
commit b80c98a056
341 changed files with 50267 additions and 1508 deletions

2
.github/FUNDING.yml vendored
View File

@@ -1,2 +0,0 @@
github: ppy
custom: https://osu.ppy.sh/home/support

View File

@@ -1,75 +0,0 @@
name: Bug report
description: Report a very clearly broken issue.
body:
- type: markdown
attributes:
value: |
# osu! bug report
Important to note that your issue may have already been reported before. Please check:
- Pinned issues, at the top of https://github.com/ppy/osu/issues.
- Current open `priority:0` issues, filterable [here](https://github.com/ppy/osu/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc+label%3Apriority%3A0).
- And most importantly, search for your issue both in the [issue listing](https://github.com/ppy/osu/issues) and the [Q&A discussion listing](https://github.com/ppy/osu/discussions/categories/q-a). If you find that it already exists, respond with a reaction or add any further information that may be helpful.
# ATTENTION LINUX USERS
If you are having an issue and it is hardware related, **please open a [q&a discussion](https://github.com/ppy/osu/discussions/categories/q-a)** instead of an issue. There's a high chance your issue is due to your system configuration, and not our software.
- type: dropdown
attributes:
label: Type
options:
- Crash to desktop
- Game behaviour
- Performance
- Cosmetic
- Other
validations:
required: true
- type: textarea
attributes:
label: Bug description
description: How did you find the bug? Any additional details that might help?
validations:
required: true
- type: textarea
attributes:
label: Screenshots or videos
description: Add screenshots or videos that show the bug here.
placeholder: Drag and drop the screenshots/videos into this box.
validations:
required: false
- type: input
attributes:
label: Version
description: The version you encountered this bug on. This is shown at the end of the settings overlay.
validations:
required: true
- type: markdown
attributes:
value: |
## Logs
Attaching log files is required for **every** issue, regardless of whether you deem them required or not. See instructions below on how to find them.
### Desktop platforms
If the game has not yet been closed since you found the bug:
1. Head on to game settings and click on "Export logs"
2. Click the notification to locate the file
3. Drag the generated `.zip` files into the github issue window
![export logs button](https://github.com/ppy/osu/assets/191335/cbfa5550-b7ed-4c5c-8dd0-8b87cc90ad9b)
### Mobile platforms
The places to find the logs on mobile platforms are as follows:
- *On Android*, navigate to `Android/data/sh.ppy.osulazer/files/logs` using a file browser app.
- *On iOS*, connect your device to a PC and copy the `logs` directory from the app's document storage using iTunes. (https://support.apple.com/en-us/HT201301#copy-to-computer)
- type: textarea
attributes:
label: Logs
placeholder: Drag and drop the log files into this box.
validations:
required: true

View File

@@ -1,12 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Help
url: https://github.com/ppy/osu/discussions/categories/q-a
about: osu! not working or performing as you'd expect? Not sure it's a bug? Check the Q&A section!
- name: Suggestions or feature request
url: https://github.com/ppy/osu/discussions/categories/ideas
about: Got something you think should change or be added? Search for or start a new discussion!
- name: osu!stable issues
url: https://github.com/ppy/osu-stable-issues
about: For osu!(stable) - ie. the current "live" game version, check out the dedicated repository. Note that this is for serious bug reports only, not tech support.

View File

@@ -1,46 +0,0 @@
version: 2
updates:
- package-ecosystem: nuget
directory: "/"
schedule:
interval: monthly
time: "17:00"
open-pull-requests-limit: 0 # disabled until https://github.com/dependabot/dependabot-core/issues/369 is resolved.
ignore:
- dependency-name: Microsoft.EntityFrameworkCore.Design
versions:
- "> 2.2.6"
- dependency-name: Microsoft.EntityFrameworkCore.Sqlite
versions:
- "> 2.2.6"
- dependency-name: Microsoft.EntityFrameworkCore.Sqlite.Core
versions:
- "> 2.2.6"
- dependency-name: Microsoft.Extensions.DependencyInjection
versions:
- ">= 5.a, < 6"
- dependency-name: NUnit3TestAdapter
versions:
- ">= 3.16.a, < 3.17"
- dependency-name: Microsoft.NET.Test.Sdk
versions:
- 16.9.1
- dependency-name: Microsoft.Extensions.DependencyInjection
versions:
- 3.1.11
- 3.1.12
- dependency-name: Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson
versions:
- 3.1.11
- dependency-name: Microsoft.NETCore.Targets
versions:
- 5.0.0
- dependency-name: Microsoft.AspNetCore.SignalR.Protocols.MessagePack
versions:
- 5.0.2
- dependency-name: NUnit
versions:
- 3.13.1
- dependency-name: Microsoft.AspNetCore.SignalR.Client
versions:
- 3.1.11

View File

@@ -1,228 +0,0 @@
name: "🔒diffcalc (do not use)"
on:
workflow_call:
inputs:
id:
type: string
head-sha:
type: string
pr-url:
type: string
pr-text:
type: string
dispatch-inputs:
type: string
outputs:
target:
description: The comparison target.
value: ${{ jobs.generator.outputs.target }}
sheet:
description: The comparison spreadsheet.
value: ${{ jobs.generator.outputs.sheet }}
secrets:
DIFFCALC_GOOGLE_CREDENTIALS:
required: true
env:
GENERATOR_DIR: ${{ github.workspace }}/${{ inputs.id }}
GENERATOR_ENV: ${{ github.workspace }}/${{ inputs.id }}/.env
defaults:
run:
shell: bash -euo pipefail {0}
jobs:
generator:
name: Run
runs-on: self-hosted
timeout-minutes: 1440
outputs:
target: ${{ steps.run.outputs.target }}
sheet: ${{ steps.run.outputs.sheet }}
steps:
- name: Checkout diffcalc-sheet-generator
uses: actions/checkout@v4
with:
path: ${{ inputs.id }}
repository: 'smoogipoo/diffcalc-sheet-generator'
- name: Add base environment
env:
GOOGLE_CREDS_FILE: ${{ github.workspace }}/${{ inputs.id }}/google-credentials.json
VARS_JSON: ${{ (vars != null && toJSON(vars)) || '' }}
run: |
# Required by diffcalc-sheet-generator
cp '${{ env.GENERATOR_DIR }}/.env.sample' "${{ env.GENERATOR_ENV }}"
# Add Google credentials
echo '${{ secrets.DIFFCALC_GOOGLE_CREDENTIALS }}' | base64 -d > "${{ env.GOOGLE_CREDS_FILE }}"
# Add repository variables
echo "${VARS_JSON}" | jq -c '. | to_entries | .[]' | while read -r line; do
opt=$(jq -r '.key' <<< ${line})
val=$(jq -r '.value' <<< ${line})
if [[ "${opt}" =~ ^DIFFCALC_ ]]; then
optNoPrefix=$(echo "${opt}" | cut -d '_' -f2-)
sed -i "s;^${optNoPrefix}=.*$;${optNoPrefix}=${val};" "${{ env.GENERATOR_ENV }}"
fi
done
- name: Add HEAD environment
run: |
sed -i "s;^OSU_A=.*$;OSU_A=${{ inputs.head-sha }};" "${{ env.GENERATOR_ENV }}"
- name: Add pull-request environment
if: ${{ inputs.pr-url != '' }}
run: |
sed -i "s;^OSU_B=.*$;OSU_B=${{ inputs.pr-url }};" "${{ env.GENERATOR_ENV }}"
- name: Add comment environment
if: ${{ inputs.pr-text != '' }}
env:
PR_TEXT: ${{ inputs.pr-text }}
run: |
# Add comment environment
echo "${PR_TEXT}" | sed -r 's/\r$//' | { grep -E '^\w+=' || true; } | while read -r line; do
opt=$(echo "${line}" | cut -d '=' -f1)
sed -i "s;^${opt}=.*$;${line};" "${{ env.GENERATOR_ENV }}"
done
- name: Add dispatch environment
if: ${{ inputs.dispatch-inputs != '' }}
env:
DISPATCH_INPUTS_JSON: ${{ inputs.dispatch-inputs }}
run: |
function get_input() {
echo "${DISPATCH_INPUTS_JSON}" | jq -r ".\"$1\""
}
osu_a=$(get_input 'osu-a')
osu_b=$(get_input 'osu-b')
ruleset=$(get_input 'ruleset')
generators=$(get_input 'generators')
difficulty_calculator_a=$(get_input 'difficulty-calculator-a')
difficulty_calculator_b=$(get_input 'difficulty-calculator-b')
score_processor_a=$(get_input 'score-processor-a')
score_processor_b=$(get_input 'score-processor-b')
converts=$(get_input 'converts')
ranked_only=$(get_input 'ranked-only')
sed -i "s;^OSU_B=.*$;OSU_B=${osu_b};" "${{ env.GENERATOR_ENV }}"
sed -i "s/^RULESET=.*$/RULESET=${ruleset}/" "${{ env.GENERATOR_ENV }}"
sed -i "s/^GENERATORS=.*$/GENERATORS=${generators}/" "${{ env.GENERATOR_ENV }}"
if [[ "${osu_a}" != 'latest' ]]; then
sed -i "s;^OSU_A=.*$;OSU_A=${osu_a};" "${{ env.GENERATOR_ENV }}"
fi
if [[ "${difficulty_calculator_a}" != 'latest' ]]; then
sed -i "s;^DIFFICULTY_CALCULATOR_A=.*$;DIFFICULTY_CALCULATOR_A=${difficulty_calculator_a};" "${{ env.GENERATOR_ENV }}"
fi
if [[ "${difficulty_calculator_b}" != 'latest' ]]; then
sed -i "s;^DIFFICULTY_CALCULATOR_B=.*$;DIFFICULTY_CALCULATOR_B=${difficulty_calculator_b};" "${{ env.GENERATOR_ENV }}"
fi
if [[ "${score_processor_a}" != 'latest' ]]; then
sed -i "s;^SCORE_PROCESSOR_A=.*$;SCORE_PROCESSOR_A=${score_processor_a};" "${{ env.GENERATOR_ENV }}"
fi
if [[ "${score_processor_b}" != 'latest' ]]; then
sed -i "s;^SCORE_PROCESSOR_B=.*$;SCORE_PROCESSOR_B=${score_processor_b};" "${{ env.GENERATOR_ENV }}"
fi
if [[ "${converts}" == 'true' ]]; then
sed -i 's/^NO_CONVERTS=.*$/NO_CONVERTS=0/' "${{ env.GENERATOR_ENV }}"
else
sed -i 's/^NO_CONVERTS=.*$/NO_CONVERTS=1/' "${{ env.GENERATOR_ENV }}"
fi
if [[ "${ranked_only}" == 'true' ]]; then
sed -i 's/^RANKED_ONLY=.*$/RANKED_ONLY=1/' "${{ env.GENERATOR_ENV }}"
else
sed -i 's/^RANKED_ONLY=.*$/RANKED_ONLY=0/' "${{ env.GENERATOR_ENV }}"
fi
- name: Query latest scores
id: query-scores
run: |
ruleset=$(cat ${{ env.GENERATOR_ENV }} | grep -E '^RULESET=' | cut -d '=' -f2-)
performance_data_name=$(curl -s "https://data.ppy.sh/" | grep "performance_${ruleset}_top_1000\b" | tail -1 | awk -F "'" '{print $2}' | sed 's/\.tar\.bz2//g')
echo "TARGET_DIR=${{ env.GENERATOR_DIR }}/sql/${ruleset}" >> "${GITHUB_OUTPUT}"
echo "DATA_NAME=${performance_data_name}" >> "${GITHUB_OUTPUT}"
echo "DATA_PKG=${performance_data_name}.tar.bz2" >> "${GITHUB_OUTPUT}"
- name: Restore score cache
id: restore-score-cache
uses: maxnowack/local-cache@720e69c948191660a90aa1cf6a42fc4d2dacdf30 # v2
with:
path: ${{ steps.query-scores.outputs.DATA_PKG }}
key: ${{ steps.query-scores.outputs.DATA_NAME }}
- name: Download scores
if: steps.restore-score-cache.outputs.cache-hit != 'true'
run: |
wget -q -O "${{ steps.query-scores.outputs.DATA_PKG }}" "https://data.ppy.sh/${{ steps.query-scores.outputs.DATA_PKG }}"
- name: Extract scores
run: |
tar -I lbzip2 -xf "${{ steps.query-scores.outputs.DATA_PKG }}"
rm -r "${{ steps.query-scores.outputs.TARGET_DIR }}"
mv "${{ steps.query-scores.outputs.DATA_NAME }}" "${{ steps.query-scores.outputs.TARGET_DIR }}"
- name: Query latest beatmaps
id: query-beatmaps
run: |
beatmaps_data_name=$(curl -s "https://data.ppy.sh/" | grep "osu_files" | tail -1 | awk -F "'" '{print $2}' | sed 's/\.tar\.bz2//g')
echo "TARGET_DIR=${{ env.GENERATOR_DIR }}/beatmaps" >> "${GITHUB_OUTPUT}"
echo "DATA_NAME=${beatmaps_data_name}" >> "${GITHUB_OUTPUT}"
echo "DATA_PKG=${beatmaps_data_name}.tar.bz2" >> "${GITHUB_OUTPUT}"
- name: Restore beatmap cache
id: restore-beatmap-cache
uses: maxnowack/local-cache@720e69c948191660a90aa1cf6a42fc4d2dacdf30 # v2
with:
path: ${{ steps.query-beatmaps.outputs.DATA_PKG }}
key: ${{ steps.query-beatmaps.outputs.DATA_NAME }}
- name: Download beatmap
if: steps.restore-beatmap-cache.outputs.cache-hit != 'true'
run: |
wget -q -O "${{ steps.query-beatmaps.outputs.DATA_PKG }}" "https://data.ppy.sh/${{ steps.query-beatmaps.outputs.DATA_PKG }}"
- name: Extract beatmap
run: |
tar -I lbzip2 -xf "${{ steps.query-beatmaps.outputs.DATA_PKG }}"
rm -r "${{ steps.query-beatmaps.outputs.TARGET_DIR }}"
mv "${{ steps.query-beatmaps.outputs.DATA_NAME }}" "${{ steps.query-beatmaps.outputs.TARGET_DIR }}"
- name: Run
id: run
run: |
# Add the GitHub token. This needs to be done here because it's unique per-job.
sed -i 's/^GH_TOKEN=.*$/GH_TOKEN=${{ github.token }}/' "${{ env.GENERATOR_ENV }}"
cd "${{ env.GENERATOR_DIR }}"
docker compose up --build --detach
docker compose logs --follow &
docker compose wait generator
link=$(docker compose logs --tail 10 generator | grep 'http' | sed -E 's/^.*(http.*)$/\1/')
target=$(cat "${{ env.GENERATOR_ENV }}" | grep -E '^OSU_B=' | cut -d '=' -f2-)
echo "target=${target}" >> "${GITHUB_OUTPUT}"
echo "sheet=${link}" >> "${GITHUB_OUTPUT}"
- name: Shutdown
if: ${{ always() }}
run: |
cd "${{ env.GENERATOR_DIR }}"
docker compose down --volumes
rm -rf "${{ env.GENERATOR_DIR }}"

312
.github/workflows/auto-release.yml vendored Normal file
View File

@@ -0,0 +1,312 @@
name: Ez2Lazer Auto Release (manual build + package + release)
on:
workflow_dispatch:
inputs:
tag:
description: '发布使用的 tag格式 YYYY-M-D年为四位月禁止前导0 如 1-12日为1-31例如 2026-1-14必填。工作流会在你选择的分支最新提交上创建该 tag如果远程不存在。'
required: true
branch:
description: '主仓要构建的分支(默认 locmain'
required: true
default: 'locmain'
framework_branch:
description: '可选:用于 checkout osu-framework 的分支(留空则使用上面的 branch'
required: false
default: ''
resources_branch:
description: '可选:用于 checkout osu-resources 的分支(留空则使用上面的 branch'
required: false
default: ''
create_zip:
description: '是否在 workflow 中创建 zip 包true/false。默认 false'
required: false
default: 'false'
upload_assets:
description: '是否在 workflow 完成后创建 GitHub Release 并上传 ziptrue/false。默认 false'
required: false
default: 'false'
jobs:
build-and-package:
runs-on: windows-latest
steps:
- name: Checkout main repo into osu/
uses: actions/checkout@v4
with:
fetch-depth: 0
path: osu
ref: refs/heads/${{ github.event.inputs.branch }}
- name: Checkout osu-framework (deps) as sibling
uses: actions/checkout@v4
with:
repository: SK-la/osu-framework
path: osu-framework
# Use framework_branch if provided, otherwise fall back to the main `branch` input
ref: refs/heads/${{ github.event.inputs.framework_branch || github.event.inputs.branch }}
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Validate osu-framework checkout (Windows)
shell: pwsh
run: |
if (-not (Test-Path 'osu-framework\.git')) {
Write-Output 'actions/checkout did not populate osu-framework; attempting HTTPS clone fallback'
git clone https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/SK-la/osu-framework.git osu-framework
Push-Location osu-framework
$FrameworkBranch = '${{ github.event.inputs.framework_branch || github.event.inputs.branch }}'
git checkout $FrameworkBranch || Write-Output "checkout $FrameworkBranch failed, continuing"
Pop-Location
} else {
Write-Output 'osu-framework already present'
}
- name: Checkout osu-resources (resources) as sibling
uses: actions/checkout@v4
with:
repository: SK-la/osu-resources
path: osu-resources
ref: refs/heads/${{ github.event.inputs.resources_branch || github.event.inputs.branch }}
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Validate osu-resources checkout (Windows)
shell: pwsh
run: |
if (-not (Test-Path 'osu-resources\.git')) {
Write-Output 'actions/checkout did not populate osu-resources; attempting HTTPS clone fallback'
git clone https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/SK-la/osu-resources.git osu-resources
Push-Location osu-resources
$ResourcesBranch = '${{ github.event.inputs.resources_branch || github.event.inputs.branch }}'
git checkout $ResourcesBranch || Write-Output "checkout $ResourcesBranch failed, continuing"
Pop-Location
} else {
Write-Output 'osu-resources already present'
}
- name: Setup .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Cache NuGet packages (Windows)
if: runner.os == 'Windows'
uses: actions/cache@v4
with:
path: C:\\Users\\runneradmin\\.nuget\\packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/Directory.Packages.props','**/*.csproj','**/packages.lock.json') }}
restore-keys: |
nuget-${{ runner.os }}-
- name: Cache MSBuild outputs (obj/bin)
if: runner.os == 'Windows'
uses: actions/cache@v4
with:
path: |
**/obj
**/bin
key: msbuild-${{ runner.os }}-${{ hashFiles('**/*.csproj','**/Directory.Packages.props') }}
restore-keys: |
msbuild-${{ runner.os }}-
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Dotnet restore (desktop project)
working-directory: osu
shell: pwsh
run: |
dotnet --info
dotnet restore ./osu.Desktop/osu.Desktop.csproj --packages $env:USERPROFILE\\.nuget\\packages
- name: Dotnet build (desktop project)
working-directory: osu
shell: pwsh
run: |
dotnet build ./osu.Desktop/osu.Desktop.csproj -c Release -p:GenerateFullPaths=true --no-restore --verbosity minimal
- name: Prepare release tag and environment
shell: pwsh
id: export
run: |
$inputTag = '${{ github.event.inputs.tag }}'
if (-not $inputTag) { Write-Error 'Input tag is required and must follow YYYY-M-D format'; exit 1 }
# validate strict: 4-digit year, month 1-12 without leading zero, day 1-31 without leading zero
if ($inputTag -notmatch '^[0-9]{4}-(?:[1-9]|1[0-2])-(?:[1-9]|[12][0-9]|3[01])$') { Write-Error 'Tag must match YYYY-M-D with no leading zero in month (e.g. 2026-1-14)'; exit 1 }
Write-Output "RELEASE_TAG=$inputTag" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
# export branch for clarity (branch chosen when triggering the workflow)
Write-Output "TARGET_BRANCH=${{ github.event.inputs.branch }}" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
- name: Run publish script (pack & create release)
working-directory: osu
shell: pwsh
env:
GITHUB_WORKSPACE: ${{ github.workspace }}
PYTHONUTF8: '1'
PYTHONIOENCODING: 'utf-8'
run: |
# Use the RELEASE_TAG prepared by prior step; fail if not set to avoid fallback behavior
if (-not $env:RELEASE_TAG) { Write-Error 'RELEASE_TAG is not set; aborting to avoid fallback tag generation' ; exit 1 }
$tag = $env:RELEASE_TAG
Write-Output "Using TAG=$tag"
# Explicitly pass project, workdir, and outroot so artifacts land under osu/artifacts
$project = Join-Path (Get-Location) 'osu.Desktop\osu.Desktop.csproj'
$workdir = (Get-Location).Path
# outroot should be the working directory; publish.py will append /artifacts
$outroot = $workdir
$create_zip = '${{ github.event.inputs.create_zip }}'
if ($create_zip -eq 'true') {
python ./publish.py --project "$project" --workdir "$workdir" --outroot "$outroot" --deps-source none --resources-path "${{ github.workspace }}\osu-resources\osu.Game.Resources\Resources" --tag "$tag"
} else {
Write-Output 'create_zip is false -> running publish.py with --no-zip to avoid creating zip files'
python ./publish.py --project "$project" --workdir "$workdir" --outroot "$outroot" --deps-source none --resources-path "${{ github.workspace }}\osu-resources\osu.Game.Resources\Resources" --tag "$tag" --no-zip
}
- name: Debug publish environment (list files + --help)
working-directory: osu
shell: pwsh
continue-on-error: true
run: |
Write-Output "PWD: $(Get-Location)"
Write-Output "Listing osu/ (first 200 entries)"
Get-ChildItem -Recurse -Force -ErrorAction SilentlyContinue | Select-Object -First 200 | ForEach-Object { Write-Output $_.FullName }
Write-Output "Python version:"
python --version || Write-Output 'python --version failed'
Write-Output "Dotnet info:"
dotnet --info || Write-Output 'dotnet --info failed'
Write-Output "Show the first 200 lines of publish.py"
Get-Content publish.py -TotalCount 200 -ErrorAction SilentlyContinue || Write-Output 'cat publish.py failed'
Write-Output "Running publish.py --help"
python ./publish.py --help || Write-Output 'publish.py --help failed'
- name: List artifacts directories for diagnostics
shell: pwsh
run: |
Write-Output 'Listing $GITHUB_WORKSPACE/artifacts:'
if (Test-Path "$env:GITHUB_WORKSPACE\artifacts") { Get-ChildItem -Path "$env:GITHUB_WORKSPACE\artifacts" -Recurse -Force | Select-Object FullName, Length | ForEach-Object { Write-Output "$($_.FullName) - $($_.Length)" } } else { Write-Output 'No root artifacts dir' }
Write-Output 'Listing $GITHUB_WORKSPACE/osu/artifacts:'
if (Test-Path "$env:GITHUB_WORKSPACE\osu\artifacts") { Get-ChildItem -Path "$env:GITHUB_WORKSPACE\osu\artifacts" -Recurse -Force | Select-Object FullName, Length | ForEach-Object { Write-Output "$($_.FullName) - $($_.Length)" } } else { Write-Output 'No osu artifacts dir' }
- name: Find produced zip artifact
if: ${{ github.event.inputs.create_zip == 'true' }}
shell: pwsh
id: list_zips
run: |
Write-Output "Searching for zip files under $env:GITHUB_WORKSPACE and $env:GITHUB_WORKSPACE\osu\artifacts"
$repoRoot = $env:GITHUB_WORKSPACE
$paths = @()
$paths += $repoRoot
$paths += (Join-Path $repoRoot 'osu\artifacts')
$found = $null
foreach ($p in $paths) {
if (Test-Path $p) {
Write-Output "Searching in: $p"
$z = Get-ChildItem -Path $p -Filter *.zip -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.Length -gt 0 } | ForEach-Object { $_.FullName }
if ($z -and $z.Count -gt 0) { $found = $z; break }
}
}
if ($found) {
Write-Output "Found zip(s): $found"
$first = $found[0]
$name = Split-Path -Path $first -Leaf
Write-Output "zip_path=$first" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
Write-Output "zip_name=$name" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
} else {
Write-Output "No zip files found in searched locations."
exit 1
}
- name: Normalize artifact names to fixed asset names
if: ${{ github.event.inputs.create_zip == 'true' }}
id: prepare_assets
shell: pwsh
run: |
$ws = $env:GITHUB_WORKSPACE
$artifacts_dir = Join-Path $ws 'artifacts'
if (-not (Test-Path $artifacts_dir)) { New-Item -ItemType Directory -Path $artifacts_dir | Out-Null }
$release_pattern = 'Ez2Lazer_release_x64*.zip'
$debug_pattern = 'Ez2Lazer_debug_x64*.zip'
function find-first([string]$base, [string]$pattern) {
$f = Get-ChildItem -Path $base -Filter $pattern -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.Length -gt 0 } | Select-Object -First 1
return $f
}
$rel = find-first $ws $release_pattern
if (-not $rel) { $rel = find-first (Join-Path $ws 'osu\artifacts') $release_pattern }
$dbg = find-first $ws $debug_pattern
if (-not $dbg) { $dbg = find-first (Join-Path $ws 'osu\artifacts') $debug_pattern }
$out_rel = ''
$out_dbg = ''
if ($rel) {
$out_rel = Join-Path $artifacts_dir 'Ez2Lazer_release_x64.zip'
Copy-Item -Path $rel.FullName -Destination $out_rel -Force
Write-Output "Prepared release asset: $out_rel"
} else { Write-Output 'Release zip not found' }
if ($dbg) {
$out_dbg = Join-Path $artifacts_dir 'Ez2Lazer_debug_x64.zip'
Copy-Item -Path $dbg.FullName -Destination $out_dbg -Force
Write-Output "Prepared debug asset: $out_dbg"
} else { Write-Output 'Debug zip not found' }
Write-Output "release_asset_path=$out_rel" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
Write-Output "debug_asset_path=$out_dbg" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
- name: Check remote tag existence and create if missing
shell: pwsh
run: |
$tag = '${{ env.RELEASE_TAG }}'
Write-Output "Checking remote for tag $tag..."
$found = git ls-remote --tags https://github.com/${{ github.repository }}.git refs/tags/$tag | Select-String -Pattern $tag
if ($found) { Write-Output "Tag $tag exists remotely"; exit 0 }
Write-Output "Tag $tag not found remotely - creating on branch tip"
# determine commit to tag: use the branch selected when triggering the workflow
$targetBranch = '${{ github.event.inputs.branch }}'
Write-Output "Resolving latest commit of branch $targetBranch"
$commitLine = git ls-remote https://github.com/${{ github.repository }}.git refs/heads/$targetBranch
if (-not $commitLine) { Write-Error "Could not resolve branch $targetBranch"; exit 1 }
$commit = $commitLine.Split()[0]
Write-Output "Tagging commit $commit"
git -C osu config user.name "github-actions[bot]"
git -C osu config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git -C osu tag $tag $commit
git -C osu push https://${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git refs/tags/$tag
- name: Create or get GitHub Release (create tag if needed)
if: ${{ github.event.inputs.upload_assets == 'true' }}
id: create_release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.RELEASE_TAG }}
name: ${{ env.RELEASE_TAG }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload release asset (release)
if: ${{ github.event.inputs.upload_assets == 'true' && steps.prepare_assets.outputs.release_asset_path != '' }}
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ${{ steps.prepare_assets.outputs.release_asset_path }}
asset_name: Ez2Lazer_release_x64.zip
asset_content_type: application/zip
- name: Upload release asset (debug)
if: ${{ github.event.inputs.upload_assets == 'true' && steps.prepare_assets.outputs.debug_asset_path != '' }}
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ${{ steps.prepare_assets.outputs.debug_asset_path }}
asset_name: Ez2Lazer_debug_x64.zip
asset_content_type: application/zip

View File

@@ -1,154 +0,0 @@
on: [push, pull_request]
name: Continuous Integration
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read # to fetch code (actions/checkout)
jobs:
inspect-code:
name: Code Quality
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install .NET 8.0.x
uses: actions/setup-dotnet@v4
with:
dotnet-version: "8.0.x"
- name: Restore Tools
run: dotnet tool restore
- name: Restore Packages
run: dotnet restore osu.Desktop.slnf
- name: Restore inspectcode cache
uses: actions/cache@v4
with:
path: ${{ github.workspace }}/inspectcode
key: inspectcode-${{ hashFiles('.config/dotnet-tools.json', '.github/workflows/ci.yml', 'osu.sln*', 'osu*.slnf', '.editorconfig', '.globalconfig', 'CodeAnalysis/*', '**/*.csproj', '**/*.props') }}
- name: Dotnet code style
run: dotnet build -c Debug -warnaserror osu.Desktop.slnf -p:EnforceCodeStyleInBuild=true
- name: CodeFileSanity
run: |
# TODO: Add ignore filters and GitHub Workflow Command Reporting in CFS. That way we don't have to do this workaround.
# FIXME: Suppress warnings from templates project
exit_code=0
while read -r line; do
if [[ ! -z "$line" ]]; then
echo "::error::$line"
exit_code=1
fi
done <<< $(dotnet codefilesanity)
exit $exit_code
- name: InspectCode
run: dotnet jb inspectcode $(pwd)/osu.Desktop.slnf --no-build --output="inspectcodereport.xml" --caches-home="inspectcode" --verbosity=WARN
- name: NVika
run: dotnet nvika parsereport "${{github.workspace}}/inspectcodereport.xml" --treatwarningsaserrors
test:
name: Test
runs-on: ${{matrix.os.fullname}}
env:
OSU_EXECUTION_MODE: ${{matrix.threadingMode}}
strategy:
fail-fast: false
matrix:
os:
- { prettyname: Windows, fullname: windows-latest }
# macOS runner performance has gotten unbearably slow so let's turn them off temporarily.
# - { prettyname: macOS, fullname: macos-latest }
- { prettyname: Linux, fullname: ubuntu-latest }
threadingMode: ['SingleThread', 'MultiThreaded']
timeout-minutes: 120
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install .NET 8.0.x
uses: actions/setup-dotnet@v4
with:
dotnet-version: "8.0.x"
- name: Compile
run: dotnet build -c Debug -warnaserror osu.Desktop.slnf
- name: Test
run: >
dotnet test
osu.Game.Tests/bin/Debug/**/osu.Game.Tests.dll
osu.Game.Rulesets.Osu.Tests/bin/Debug/**/osu.Game.Rulesets.Osu.Tests.dll
osu.Game.Rulesets.Taiko.Tests/bin/Debug/**/osu.Game.Rulesets.Taiko.Tests.dll
osu.Game.Rulesets.Catch.Tests/bin/Debug/**/osu.Game.Rulesets.Catch.Tests.dll
osu.Game.Rulesets.Mania.Tests/bin/Debug/**/osu.Game.Rulesets.Mania.Tests.dll
osu.Game.Tournament.Tests/bin/Debug/**/osu.Game.Tournament.Tests.dll
Templates/**/*.Tests/bin/Debug/**/*.Tests.dll
--logger "trx;LogFileName=TestResults-${{matrix.os.prettyname}}-${{matrix.threadingMode}}.trx"
--
NUnit.ConsoleOut=0
# Attempt to upload results even if test fails.
# https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#always
- name: Upload Test Results
uses: actions/upload-artifact@v4
if: ${{ always() }}
with:
name: osu-test-results-${{matrix.os.prettyname}}-${{matrix.threadingMode}}
path: ${{github.workspace}}/TestResults/TestResults-${{matrix.os.prettyname}}-${{matrix.threadingMode}}.trx
build-only-android:
name: Build only (Android)
runs-on: windows-latest
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup JDK 11
uses: actions/setup-java@v4
with:
distribution: microsoft
java-version: 11
- name: Install .NET 8.0.x
uses: actions/setup-dotnet@v4
with:
dotnet-version: "8.0.x"
- name: Install .NET workloads
run: dotnet workload install android
- name: Compile
run: dotnet build -c Debug osu.Android.slnf
build-only-ios:
name: Build only (iOS)
runs-on: macos-15
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install .NET 8.0.x
uses: actions/setup-dotnet@v4
with:
dotnet-version: "8.0.x"
- name: Install .NET Workloads
run: dotnet workload install ios
# https://github.com/dotnet/macios/issues/19157
# https://github.com/actions/runner-images/issues/12758
- name: Use Xcode 16.4
run: sudo xcode-select -switch /Applications/Xcode_16.4.app
- name: Build
run: dotnet build -c Debug osu.iOS.slnf

View File

@@ -1,87 +0,0 @@
name: Pack and nuget
on:
push:
tags:
- '*'
jobs:
notify_pending_production_deploy:
runs-on: ubuntu-latest
steps:
- name: Submit pending deployment notification
run: |
export TITLE="Pending osu Production Deployment: $GITHUB_REF_NAME"
export URL="https://github.com/ppy/osu/actions/runs/$GITHUB_RUN_ID"
export DESCRIPTION="Awaiting approval for building NuGet packages for tag $GITHUB_REF_NAME:
[View Workflow Run]($URL)"
export ACTOR_ICON="https://avatars.githubusercontent.com/u/$GITHUB_ACTOR_ID"
BODY="$(jq --null-input '{
"embeds": [
{
"title": env.TITLE,
"color": 15098112,
"description": env.DESCRIPTION,
"url": env.URL,
"author": {
"name": env.GITHUB_ACTOR,
"icon_url": env.ACTOR_ICON
}
}
]
}')"
curl \
-H "Content-Type: application/json" \
-d "$BODY" \
"${{ secrets.DISCORD_INFRA_WEBHOOK_URL }}"
pack:
name: Pack
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set artifacts directory
id: artifactsPath
run: echo "::set-output name=nuget_artifacts::${{github.workspace}}/artifacts"
- name: Install .NET 8.0.x
uses: actions/setup-dotnet@v4
with:
dotnet-version: "8.0.x"
- name: Pack
run: |
# Replace project references in templates with package reference, because they're included as source files.
dotnet remove Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/osu.Game.Rulesets.EmptyFreeform.csproj reference osu.Game/osu.Game.csproj
dotnet remove Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj reference osu.Game/osu.Game.csproj
dotnet remove Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/osu.Game.Rulesets.EmptyScrolling.csproj reference osu.Game/osu.Game.csproj
dotnet remove Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj reference osu.Game/osu.Game.csproj
dotnet add Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/osu.Game.Rulesets.EmptyFreeform.csproj package ppy.osu.Game -n -v ${{ github.ref_name }}
dotnet add Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj package ppy.osu.Game -n -v ${{ github.ref_name }}
dotnet add Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/osu.Game.Rulesets.EmptyScrolling.csproj package ppy.osu.Game -n -v ${{ github.ref_name }}
dotnet add Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj package ppy.osu.Game -n -v ${{ github.ref_name }}
# Pack
dotnet pack -c Release osu.Game /p:Version=${{ github.ref_name }} /p:GenerateDocumentationFile=true /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg -o ${{steps.artifactsPath.outputs.nuget_artifacts}}
dotnet pack -c Release osu.Game.Rulesets.Osu /p:Version=${{ github.ref_name }} /p:GenerateDocumentationFile=true /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg -o ${{steps.artifactsPath.outputs.nuget_artifacts}}
dotnet pack -c Release osu.Game.Rulesets.Taiko /p:Version=${{ github.ref_name }} /p:GenerateDocumentationFile=true /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg -o ${{steps.artifactsPath.outputs.nuget_artifacts}}
dotnet pack -c Release osu.Game.Rulesets.Catch /p:Version=${{ github.ref_name }} /p:GenerateDocumentationFile=true /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg -o ${{steps.artifactsPath.outputs.nuget_artifacts}}
dotnet pack -c Release osu.Game.Rulesets.Mania /p:Version=${{ github.ref_name }} /p:GenerateDocumentationFile=true /p:IncludeSymbols=true /p:SymbolPackageFormat=snupkg -o ${{steps.artifactsPath.outputs.nuget_artifacts}}
dotnet pack -c Release Templates /p:Version=${{ github.ref_name }} -o ${{steps.artifactsPath.outputs.nuget_artifacts}}
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: osu
path: |
${{steps.artifactsPath.outputs.nuget_artifacts}}/*.nupkg
${{steps.artifactsPath.outputs.nuget_artifacts}}/*.snupkg
- name: Publish packages to nuget.org
run: dotnet nuget push ${{steps.artifactsPath.outputs.nuget_artifacts}}/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json

View File

@@ -1,196 +0,0 @@
# ## 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' }}"
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:
EXECUTION_ID: execution-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}
defaults:
run:
shell: bash -euo pipefail {0}
jobs:
check-permissions:
name: Check permissions
runs-on: ubuntu-latest
if: ${{ github.event_name == 'workflow_dispatch' || contains(github.event.comment.body, '!diffcalc') }}
steps:
- name: Check permissions
run: |
ALLOWED_USERS=(smoogipoo peppy bdach frenzibyte tsunyoku stanriders)
for i in "${ALLOWED_USERS[@]}"; do
if [[ "${{ github.actor }}" == "$i" ]]; then
exit 0
fi
done
exit 1
run-diffcalc:
name: Run spreadsheet generator
needs: check-permissions
uses: ./.github/workflows/_diffcalc_processor.yml
with:
# Can't reference env... Why GitHub, WHY?
id: execution-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}
head-sha: https://github.com/${{ github.repository }}/commit/${{ github.event.pull_request.head.sha || github.sha }}
pr-url: ${{ github.event.issue.pull_request.html_url || '' }}
pr-text: ${{ github.event.comment.body || '' }}
dispatch-inputs: ${{ (github.event.type == 'workflow_dispatch' && toJSON(inputs)) || '' }}
secrets:
DIFFCALC_GOOGLE_CREDENTIALS: ${{ secrets.DIFFCALC_GOOGLE_CREDENTIALS }}
create-comment:
name: Create PR comment
needs: check-permissions
runs-on: ubuntu-latest
if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request }}
steps:
- name: Create comment
uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0
with:
comment_tag: ${{ env.EXECUTION_ID }}
message: |
Difficulty calculation queued -- please wait! (${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
*This comment will update on completion*
output-cli:
name: Info
needs: run-diffcalc
runs-on: ubuntu-latest
steps:
- name: Output info
run: |
echo "Target: ${{ needs.run-diffcalc.outputs.target }}"
echo "Spreadsheet: ${{ needs.run-diffcalc.outputs.sheet }}"
update-comment:
name: Update PR comment
needs: [ create-comment, run-diffcalc ]
runs-on: ubuntu-latest
if: ${{ always() && needs.create-comment.result == 'success' }}
steps:
- name: Update comment on success
if: ${{ needs.run-diffcalc.result == 'success' }}
uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0
with:
comment_tag: ${{ env.EXECUTION_ID }}
mode: recreate
message: |
Target: ${{ needs.run-diffcalc.outputs.target }}
Spreadsheet: ${{ needs.run-diffcalc.outputs.sheet }}
- name: Update comment on failure
if: ${{ needs.run-diffcalc.result == 'failure' }}
uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0
with:
comment_tag: ${{ env.EXECUTION_ID }}
mode: recreate
message: |
Difficulty calculation failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- name: Update comment on cancellation
if: ${{ needs.run-diffcalc.result == 'cancelled' }}
uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0
with:
comment_tag: ${{ env.EXECUTION_ID }}
mode: delete
message: '.' # Appears to be required by this action for non-error status code.

View File

@@ -0,0 +1,28 @@
name: Qodana
on:
workflow_dispatch:
pull_request:
push:
branches: # Specify your branches here
- locmain # The 'locmain' branch
- 'releases/*' # The release branches
jobs:
qodana:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
checks: write
steps:
- uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.sha }} # to check out the actual pull request commit, not the merge commit
fetch-depth: 0 # a full history is required for pull request analysis
- name: 'Qodana Scan'
uses: JetBrains/qodana-action@v2025.2
with:
pr-mode: false
env:
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN_1172857956 }}
QODANA_ENDPOINT: 'https://qodana.cloud'

View File

@@ -1,45 +0,0 @@
# This is a workaround to allow PRs to report their coverage. This will run inside the base repository.
# See:
# * https://github.com/dorny/test-reporter#recommended-setup-for-public-repositories
# * https://docs.github.com/en/actions/reference/authentication-in-a-workflow#permissions-for-the-github_token
name: Annotate CI run with test results
on:
workflow_run:
workflows: [ "Continuous Integration" ]
types:
- completed
permissions:
contents: read
actions: read
checks: write
jobs:
annotate:
name: Annotate CI run with test results
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion != 'cancelled' }}
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@v4
with:
repository: ${{ github.event.workflow_run.repository.full_name }}
ref: ${{ github.event.workflow_run.head_sha }}
- name: Download results
uses: actions/download-artifact@v4
with:
pattern: osu-test-results-*
merge-multiple: true
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
- name: Annotate CI run with test results
uses: dorny/test-reporter@v1.8.0
with:
name: Results
path: "*.trx"
reporter: dotnet-trx
list-suites: 'failed'
list-tests: 'failed'

View File

@@ -1,29 +0,0 @@
name: Add Release to Sentry
on:
push:
tags:
- '*'
permissions:
contents: read # to fetch code (actions/checkout)
jobs:
sentry_release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Create Sentry release
uses: getsentry/action-release@v1
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ppy
SENTRY_PROJECT: osu
SENTRY_URL: https://sentry.ppy.sh/
with:
environment: production
version: osu@${{ github.ref_name }}

View File

@@ -1,53 +0,0 @@
name: Update osu-web mod definitions
on:
push:
tags:
- '*'
permissions:
contents: read # to fetch code (actions/checkout)
jobs:
update-mod-definitions:
name: Update osu-web mod definitions
runs-on: ubuntu-latest
steps:
- name: Install .NET 8.0.x
uses: actions/setup-dotnet@v4
with:
dotnet-version: "8.0.x"
- name: Checkout ppy/osu
uses: actions/checkout@v4
with:
path: osu
- name: Checkout ppy/osu-tools
uses: actions/checkout@v4
with:
repository: ppy/osu-tools
path: osu-tools
- name: Checkout ppy/osu-web
uses: actions/checkout@v4
with:
repository: ppy/osu-web
path: osu-web
- name: Setup local game checkout for tools
run: ./UseLocalOsu.sh
working-directory: ./osu-tools
- name: Regenerate mod definitions
run: dotnet run --project PerformanceCalculator -- mods > ../osu-web/database/mods.json
working-directory: ./osu-tools
- name: Create pull request with changes
uses: peter-evans/create-pull-request@v6
with:
title: Update mod definitions
body: "This PR has been auto-generated to update the mod definitions to match ppy/osu@${{ github.ref_name }}."
branch: update-mod-definitions
commit-message: Update mod definitions
path: osu-web
token: ${{ secrets.OSU_WEB_PULL_REQUEST_PAT }}

5
.gitignore vendored
View File

@@ -344,3 +344,8 @@ FodyWeavers.xsd
.idea/.idea.osu.Desktop/.idea/misc.xml
.idea/.idea.osu.Android/.idea/deploymentTargetDropDown.xml
# MacOS literally shits anywhere lmao
*.DS_Store
.github/

View File

@@ -12,5 +12,7 @@
</component>
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$/../osu-framework" vcs="Git" />
<mapping directory="$PROJECT_DIR$/../osu-resources" vcs="Git" />
</component>
</project>

6
.vscode/launch.json vendored
View File

@@ -7,7 +7,7 @@
"request": "launch",
"program": "dotnet",
"args": [
"${workspaceRoot}/osu.Desktop/bin/Debug/net8.0/osu!.dll"
"${workspaceRoot}/osu.Desktop/bin/Debug/net8.0/Ez2osu!.dll"
],
"cwd": "${workspaceRoot}",
"preLaunchTask": "Build osu! (Debug)",
@@ -19,7 +19,7 @@
"request": "launch",
"program": "dotnet",
"args": [
"${workspaceRoot}/osu.Desktop/bin/Release/net8.0/osu!.dll"
"${workspaceRoot}/osu.Desktop/bin/Release/net8.0/Ez2osu!.dll"
],
"cwd": "${workspaceRoot}",
"preLaunchTask": "Build osu! (Release)",
@@ -55,7 +55,7 @@
"request": "launch",
"program": "dotnet",
"args": [
"${workspaceRoot}/osu.Desktop/bin/Debug/net8.0/osu!.dll",
"${workspaceRoot}/osu.Desktop/bin/Debug/net8.0/Ez2osu!.dll",
"--tournament"
],
"cwd": "${workspaceRoot}",

1
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1 @@
{}

View File

@@ -1,90 +1,57 @@
# Contributing Guidelines
Thank you for showing interest in the development of osu!. We aim to provide a good collaborating environment for everyone involved, and as such have decided to list some of the most important things to keep in mind in the process. The guidelines below have been chosen based on past experience.
感谢你对本项目的关注!再开始之前,请先阅读以下指南及代码的行为守则。在收到补丁后,我会默认你已经阅读了以下内容,并接受我会提出的审阅意见。
## Table of contents
## 第三方发行版说明
1. [Reporting bugs](#reporting-bugs)
2. [Providing general feedback](#providing-general-feedback)
3. [Issue or discussion?](#issue-or-discussion)
4. [Submitting pull requests](#submitting-pull-requests)
5. [Resources](#resources)
当前仓库基于由 [ppy](https://github.com/ppy) 的 [osu!](https://github.com/ppy/osu) 项目修改。
- 由于本人能力有限,无法保证所有功能在每个版本均能正常使用。(我无法每次都进行完整的功能测试)
- 可能存在与原版不同的行为。
- 原则上可以与官方osu使用统一的数据库不干扰配置文件。我使用client_XX.realm文件存储数据官方使用client.realm但建议自行备份原文件以防数据不兼容、文件损坏等
- 原则上可以与官方osu使用同一账号release版可以登录并连接官方服务器可以正常下图、下载排行榜、保存到处成绩等
- 绝对不支持向官方服务器做出任何上传行为(上传谱面、成绩等)和多人游戏。
- 可以使用第三方服务器(如有),我只能做有限支持,不保证每个版本的可用性。
- 大部分精力会放在mania模式的功能性开发上接受其他模式功能性补丁。
- 我非常乐意接受任何反馈和建议通过任何渠道GitHub issues、邮件、QQ等但不保证每个建议都能被采纳。
## Reporting bugs
如果你想提交补丁我希望你能通过某些方式在发布PR前与我联系这能够节省我大量的时间和精力。
- 在你提交PR前请先通过邮件或QQ与我联系说明你想做的改动。
- 我需要先确认我本地的代码是否上传到了GitHub。否则我需要花费大量精力解决冲突问题
A **bug** is a situation in which there is something clearly *and objectively* wrong with the game. Examples of applicable bug reports are:
## 代码的行为守则
- The game crashes to desktop when I start a beatmap
- Friends appear twice in the friend listing
- The game slows down a lot when I play this specific map
- A piece of text is overlapping another piece of text on the screen
- 所有新创建的文件必须包含ppy的版权声明。新建文件时项目应该会自动添加。
- 遵循现有的代码风格和惯例,在提交前经过测试。
- 新建文件尽可能放在独立文件夹中,在我提供的唯一一级分类文件夹下(如osu.Game.Rulesets.Mania.LAsEzMania)创建带有byID字样的二级文件夹
- 尽量不要出现硬编码字符串,尽量不要使用反射,除非别无选择。
- 除using外尽量不要到处拉屎比如一个地方改一行然后到处修改
- 尽量不要增加新的第三方依赖,除非功能很棒且没有更好的替代品。
- 本地化支持统一使用EzLocalizationManager及相关继承类进行本地化字符串管理。
To track bug reports, we primarily use GitHub **issues**. When opening an issue, please keep in mind the following:
# English Version
- Before opening the issue, please search for any similar existing issues using the text search bar and the issue labels. This includes both open and closed issues (we may have already fixed something, but the fix hasn't yet been released).
- When opening the issue, please fill out as much of the issue template as you can. In particular, please make sure to include logs and screenshots as much as possible. The instructions on how to find the log files are included in the issue template.
- We may ask you for follow-up information to reproduce or debug the problem. Please look out for this and provide follow-up info if we request it.
## Third-Party Distribution Instructions
If we cannot reproduce the issue, it is deemed low priority, or it is deemed to be specific to your setup in some way, the issue may be downgraded to a discussion. This will be done by a maintainer for you.
The current repository is based on [ppy](https://github.com/ppy)'s [osu!](https://github.com/ppy/osu) Project modifications.
- Due to my limited ability, I cannot guarantee that all functions will work properly in each version. (I can't do a full functional test every time)
- There may be different behavior from the original.
- In principle, it is possible to use a unified database with the official OSU without interfering with the configuration file. I use client_XX.realm file to store data, and the official uses client.realm (but it is recommended to back up the original file yourself in case of data incompatibility, file corruption, etc.).
- In principle, you can use the same account as the official OSU, the release version can log in and connect to the official server, you can download the picture normally, download the leaderboard, save the results everywhere, etc.
- Any uploads to official servers (uploading beatmaps, scores, etc.) and multiplayer games are absolutely not supported.
- Third-party servers (if any) are available, I can only do limited support and do not guarantee the availability of each version.
- Most of the effort will be focused on the functional development of the mania mode, accepting functional patches for other modes.
- I am more than happy to accept any feedback and suggestions through any channel (GitHub issues, email, QQ, etc.), but there is no guarantee that every suggestion will be adopted.
## Providing general feedback
If you want to submit a patch, I would like you to contact me before publishing a PR in some way, which will save me a lot of time and effort.
- Before you submit a PR, please contact me via email or QQ with the changes you want to make.
- I need to check if my local code is uploaded to GitHub. (Otherwise I would have to spend a lot of energy resolving conflict issues)
If you wish to:
## Code of Conduct for Code
- provide *subjective* feedback on the game (about how the UI looks, about how the default skin works, about game mechanics, about how the PP and scoring systems work, etc.),
- suggest a new feature to be added to the game,
- report a non-specific problem with the game that you think may be connected to your hardware or operating system specifically,
then it is generally best to start with a **discussion** first. Discussions are a good avenue to group subjective feedback on a single topic, or gauge interest in a particular feature request.
When opening a discussion, please keep in mind the following:
- Use the search function to see if your idea has been proposed before, or if there is already a thread about a particular issue you wish to raise.
- If proposing a feature, please try to explain the feature in as much detail as possible.
- If you're reporting a non-specific problem, please provide applicable logs, screenshots, or video that illustrate the issue.
If a discussion gathers enough traction, then it may be converted into an issue. This will be done by a maintainer for you.
## Issue or discussion?
We realise that the line between an issue and a discussion may be fuzzy, so while we ask you to use your best judgement based on the description above, please don't think about it too hard either. Feedback in a slightly wrong place is better than no feedback at all.
When in doubt, it's probably best to start with a discussion first. We will escalate to issues as needed.
## Submitting pull requests
While pull requests from unaffiliated contributors are welcome, please note that due to significant community interest and limited review throughput, the core team's primary focus is on the issues which are currently [on the roadmap](https://github.com/orgs/ppy/projects/7/views/6). Reviewing PRs that fall outside of the scope of the roadmap is done on a best-effort basis, so please be aware that it may take a while before a core maintainer gets around to review your change.
The [issue tracker](https://github.com/ppy/osu/issues) should provide plenty of issues to start with. In the case of simple issues, a direct PR is okay. However, if you decide to work on an existing issue which doesn't seem trivial, **please ask us first**. This way we can try to estimate if it is a good fit for you and provide the correct direction on how to address it. In addition, note that while we do not rule out external contributors from working on roadmapped issues, we will generally prefer to handle them ourselves unless they're not very time sensitive.
If you'd like to propose a subjective change to one of the visual aspects of the game, or there is a bigger task you'd like to work on, but there is no corresponding issue or discussion thread yet for it, **please open a discussion or issue first** to avoid wasted effort. This in particular applies if you want to work on [one of the available designs from the osu! Figma master library](https://www.figma.com/file/VIkXMYNPMtQem2RJg9k2iQ/Master-Library).
Aside from the above, below is a brief checklist of things to watch out when you're preparing your code changes:
- Make sure you're comfortable with the principles of object-oriented programming, the syntax of C\# and your development environment.
- Make sure you are familiar with [git](https://git-scm.com/) and [the pull request workflow](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/proposing-changes-to-your-work-with-pull-requests).
- Please do not make code changes via the GitHub web interface.
- Please add tests for your changes. We expect most new features and bugfixes to have test coverage, unless the effort of adding them is prohibitive. The visual testing methodology we use is described in more detail [here](https://github.com/ppy/osu-framework/wiki/Development-and-Testing).
- Please run tests and code style analysis (via `InspectCode.{ps1,sh}` scripts in the root of this repository) before opening the PR. This is particularly important if you're a first-time contributor, as CI will not run for your PR until we allow it to do so.
- **Do not run the game in release configuration at any point during your testing** (the sole exception to this being benchmarks). Using release is an unnecessary and harmful practice, and can even lead to you losing your local realm database if you start making changes to the schema. The debug configuration has a completely separated full-stack environment, including a development website instance at https://dev.ppy.sh/. It is permitted to register an account on that development instance for testing purposes and not worry about multi-accounting infractions.
After you're done with your changes and you wish to open the PR, please observe the following recommendations:
- Please submit the pull request from a [topic branch](https://git-scm.com/book/en/v2/Git-Branching-Branching-Workflows#_topic_branch) (not `master`), and keep the *Allow edits from maintainers* check box selected, so that we can push fixes to your PR if necessary.
- Please pick the following target branch for your pull request:
- `pp-dev`, if the change impacts star rating or performance points calculations for any of the rulesets,
- `master`, otherwise.
- Please avoid pushing untested or incomplete code.
- Please do not force-push or rebase unless we ask you to.
- Please do not merge `master` continually if there are no conflicts to resolve. We will do this for you when the change is ready for merge.
We are highly committed to quality when it comes to the osu! project. This means that contributions from less experienced community members can take multiple rounds of review to get to a mergeable state. We try our utmost best to never conflate a person with the code they authored, and to keep the discussion focused on the code at all times. Please consider our comments and requests a learning experience.
If you're uncertain about some part of the codebase or some inner workings of the game and framework, please reach out either by leaving a comment in the relevant issue, discussion, or PR thread, or by posting a message in the [development Discord server](https://discord.gg/ppy). We will try to help you as much as we can.
## Resources
- [Development roadmap](https://github.com/orgs/ppy/projects/7/views/6): What the core team is currently working on
- [`ppy/osu-framework` wiki](https://github.com/ppy/osu-framework/wiki): Contains introductory information about osu!framework, the bespoke 2D game framework we use for the game
- [`ppy/osu` wiki](https://github.com/ppy/osu/wiki): Contains articles about various technical aspects of the game
- [Figma master library](https://www.figma.com/file/VIkXMYNPMtQem2RJg9k2iQ/Master-Library): Contains finished and draft designs for osu!
- All newly created files must include a copyright notice from PPY. When you create a new file, the project should be added automatically.
- Follow existing code styles and conventions, tested before committing.
- Place new files in a separate folder as much as possible, under the only first-level classification folder I provide (e.g. osu. Game.Rulesets.Mania.LAsEzMania), creating a secondary folder with the word byID
- Try not to appear hardcoded strings and try not to use reflections unless there is no other choice.
- Try not to shit everywhere except using (like changing a line in one place and then changing it everywhere).
- Try not to add new third-party dependencies unless the functionality is great and there are no better alternatives.
- Localization support: EzLocalizationManager and related inheritance classes are used for localized string management.

195
README.md
View File

@@ -2,141 +2,144 @@
<img width="500" alt="osu! logo" src="assets/lazer.png">
</p>
# osu!
# Ez2Lazer!
[![Build status](https://github.com/ppy/osu/actions/workflows/ci.yml/badge.svg?branch=master&event=push)](https://github.com/ppy/osu/actions/workflows/ci.yml)
[![GitHub release](https://img.shields.io/github/release/ppy/osu.svg)](https://github.com/ppy/osu/releases/latest)
[![CodeFactor](https://www.codefactor.io/repository/github/ppy/osu/badge)](https://www.codefactor.io/repository/github/ppy/osu)
[![dev chat](https://discordapp.com/api/guilds/188630481301012481/widget.png?style=shield)](https://discord.gg/ppy)
[![Crowdin](https://d322cqt584bo4o.cloudfront.net/osu-web/localized.svg)](https://crowdin.com/project/osu-web)
This is always a pre-release version, maintained by me personally
A free-to-win rhythm game. Rhythm is just a *click* away!
## Latest release: [Windows 10+ (x64)](https://github.com/SK-la/Ez2Lazer/releases)
- **Setup [EzResources](https://la1225-my.sharepoint.com/:f:/g/personal/la_la1225_onmicrosoft_com/EiosAbw_1C9ErYCNRD1PQvkBaYvhflOkt8G9ZKHNYuppLg?e=DWY1kn) Pack to osu datebase path.**
This is the future and final iteration of the [osu!](https://osu.ppy.sh) game client which marks the beginning of an open era! Currently known by and released under the release codename "*lazer*". As in sharper than cutting-edge.
- A desktop platform with the [.NET 8.0 RunTime](https://dotnet.microsoft.com/download) installed.
## Status
This project is under constant development, but we do our best to keep things in a stable state. Players are encouraged to install from a release alongside their stable *osu!* client. This project will continue to evolve until we eventually reach the point where most users prefer it over the previous "osu!stable" release.
A few resources are available as starting points to getting involved and understanding the project:
- Detailed release changelogs are available on the [official osu! site](https://osu.ppy.sh/home/changelog/lazer).
- You can learn more about our approach to [project management](https://github.com/ppy/osu/wiki/Project-management).
- Track our current efforts [towards improving the game](https://github.com/orgs/ppy/projects/7/views/6).
## Running osu!
If you are just looking to give the game a whirl, you can grab the latest release for your platform:
### Latest release:
| [Windows 10+ (x64)](https://github.com/ppy/osu/releases/latest/download/install.exe) | macOS 12+ ([Intel](https://github.com/ppy/osu/releases/latest/download/osu.app.Intel.zip), [Apple Silicon](https://github.com/ppy/osu/releases/latest/download/osu.app.Apple.Silicon.zip)) | [Linux (x64)](https://github.com/ppy/osu/releases/latest/download/osu.AppImage) | [iOS 13.4+](https://osu.ppy.sh/home/testflight) | [Android 5+](https://github.com/ppy/osu/releases/latest/download/sh.ppy.osulazer.apk) |
|--------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| ------------- | ------------- | ------------- |
You can also generally download a version for your current device from the [osu! site](https://osu.ppy.sh/home/download).
If your platform is unsupported or not listed above, there is still a chance you can run the release or manually build it by following the instructions below.
**For iOS/iPadOS users**: The iOS testflight link fills up very fast (Apple has a hard limit of 10,000 users). We reset it occasionally. Please do not ask about this. Check back regularly for link resets or follow [peppy](https://twitter.com/ppy) on twitter for announcements. Our goal is to get the game on mobile app stores very soon so we don't have to live with this limitation.
## Developing a custom ruleset
osu! is designed to allow user-created gameplay variations, called "rulesets". Building one of these allows a developer to harness the power of the osu! beatmap library, game engine, and general UX for a new style of gameplay. To get started working on a ruleset, we have some templates available [here](https://github.com/ppy/osu/tree/master/Templates).
You can see some examples of custom rulesets by visiting the [custom ruleset directory](https://github.com/ppy/osu/discussions/13096).
## Developing osu!
### Prerequisites
Please make sure you have the following prerequisites:
- A desktop platform with the [.NET 8.0 SDK](https://dotnet.microsoft.com/download) installed.
- Develop modifications using Rider + VS Code
When working with the codebase, we recommend using an IDE with intelligent code completion and syntax highlighting, such as the latest version of [Visual Studio](https://visualstudio.microsoft.com/vs/), [JetBrains Rider](https://www.jetbrains.com/rider/), or [Visual Studio Code](https://code.visualstudio.com/) with the [EditorConfig](https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig) and [C# Dev Kit](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csdevkit) plugin installed.
### Downloading the source code
## Build Instructions
- Clone the repository
```bash
Clone the repository:
git clone SK-la/Ez2Lazer
git clone SK-la/osu-framework
git clone SK-la/osu-resources
// There is a lack of special texture resources in Resource, so it is recommended that you use the DLL in the release package to replace it after building
```shell
git clone https://github.com/ppy/osu
cd osu
build Ez2Lazer
```
To update the source code to the latest commit, run the following command inside the `osu` directory:
## Feature support
(It's not always updated here)
```shell
git pull
```
### Vedio Main Background
- Support vedio as main background (.webm)
<img width="3440" height="1440" alt="img_10" src="https://github.com/user-attachments/assets/f0277860-8db5-4244-8dd0-e6eb8ac9fcea" />
<img width="1039" height="156" alt="img_13" src="https://github.com/user-attachments/assets/18da55c5-a996-48ba-be45-7071d9c71922" />
### Building
#### From an IDE
### SongSelect Ez to Filter
- Keys Filter (One\Multi)
- Notes by column
- Avg\Max KPS
<img width="1524" height="637" alt="img_12" src="https://github.com/user-attachments/assets/8caae7a3-74d0-42fa-a9de-a15385541ca7" />
You should load the solution via one of the platform-specific `.slnf` files, rather than the main `.sln`. This will reduce dependencies and hide platforms that you don't care about. Valid `.slnf` files are:
- `osu.Desktop.slnf` (most common)
- `osu.Android.slnf`
- `osu.iOS.slnf`
### Freedom Speed Adjust System
Run configurations for the recommended IDEs (listed above) are included. You should use the provided Build/Run functionality of your IDE to get things going. When testing or building new components, it's highly encouraged you use the `osu! (Tests)` project/configuration. More information on this is provided [below](#contributing).
| - | Speed | + |
|--|-----------|--|
| ← | Base Speed | → |
| 0 | Scroll Speed | 401 |
To build for mobile platforms, you will likely need to run `sudo dotnet workload restore` if you haven't done so previously. This will install Android/iOS tooling required to complete the build.
Base Speed - Setting Speed(0~401) * MPS(Gaming ±Speed)
#### From CLI
<img width="1055" height="677" alt="img_11" src="https://github.com/user-attachments/assets/f2878062-5e12-40a3-9f51-90637f617053" />
You can also build and run *osu!* from the command-line with a single command:
```shell
dotnet run --project osu.Desktop
```
When running locally to do any kind of performance testing, make sure to add `-c Release` to the build command, as the overhead of running with the default `Debug` configuration can be large (especially when testing with local framework modifications as below).
### New Skin System
- Ez Pro SKin System
- New Ez Style SKin Sprites - 全新Ez风格皮肤素材
- New Dynamic real-time preview SKin Options - 全新动态实时预览皮肤选项
- Built-in skin.ini settings - 内置skin.ini设置
- New color settings, column type setting system - 全新颜色设置、列类型设置系统
If the build fails, try to restore NuGet packages with `dotnet restore`.
<img width="3440" height="1440" alt="img_5" src="https://github.com/user-attachments/assets/89cb4ea0-3a03-4252-8378-91e15789e229" />
<img width="3440" height="1440" alt="img_4" src="https://github.com/user-attachments/assets/246bbc63-f6a5-47e1-a05c-d5f7606bdae2" />
### Testing with resource/framework modifications
- Preload skin resources when entering the game interface to reduce lag in the early stages of the game
- Change to the Smart Subfolder drop-down list
<img width="1167" height="759" alt="Snipaste_2025-12-07_21-37-22" src="https://github.com/user-attachments/assets/6485aa3f-f153-4cbf-be57-d5bb7f85a615" />
Sometimes it may be necessary to cross-test changes in [osu-resources](https://github.com/ppy/osu-resources) or [osu-framework](https://github.com/ppy/osu-framework). This can be quickly achieved using included commands:
- Mania Playfield Support Blur and Dim Effect
Windows:
<img width="1129" height="1131" alt="Snipaste_2025-12-07_21-29-40" src="https://github.com/user-attachments/assets/d1959f40-a90a-4803-9d0a-3cd36663b8dd" />
```ps
UseLocalFramework.ps1
UseLocalResources.ps1
```
- HUD Components
- <img width="443" height="974" alt="img_16" src="https://github.com/user-attachments/assets/71dd717c-b4c6-43ec-90b3-c5d974575e80" />
macOS / Linux:
```ps
UseLocalFramework.sh
UseLocalResources.sh
```
### Pool Judgment (Empty Judgment)
Note that these commands assume you have the relevant project(s) checked out in adjacent directories:
- Pool判定不影响ACC、Combo仅严格扣血连续的Pool判将累加扣血幅度.
- The pool hit result does not affect ACC and Combo, only strict blood deduction, and continuous pools will accumulate the blood deduction amplitude.
> -500 < -Pool < miss < +Pool < +150
>
> <img width="629" height="77" alt="img_9" src="https://github.com/user-attachments/assets/523d62d3-9796-4657-b1a8-359586c7ab83" />
```
|- osu // this repository
|- osu-framework
|- osu-resources
```
### Code analysis
### New Judgment Mode
Before committing your code, please run a code formatter. This can be achieved by running `dotnet format` in the command line, or using the `Format code` command in your IDE.
> For the time being, only the settings are implemented, and the actual parameters will be matched in the future
>
> 暂时仅实现设置,未来匹配实际参数
We have adopted some cross-platform, compiler integrated analyzers. They can provide warnings when you are editing, building inside IDE or from command line, as-if they are provided by the compiler itself.
<img width="1041" height="585" alt="img_14" src="https://github.com/user-attachments/assets/d4264792-db76-478a-9351-31527a030368" />
JetBrains ReSharper InspectCode is also used for wider rule sets. You can run it from PowerShell with `.\InspectCode.ps1`. Alternatively, you can install ReSharper or use Rider to get inline support in your IDE of choice.
- Ez2AC: LN-NoRelease (Press and hold LN-tail to perfect)
> { 18.0, 32.0, 64.0, 80.0, 100.0, 120.0 }
## Contributing
- O2Jam: None-Press is miss
> coolRange = 7500.0 / bpm;
> goodRange = 22500.0 / bpm;
> badRange = 31250.0 / bpm;
When it comes to contributing to the project, the two main things you can do to help out are reporting issues and submitting pull requests. Please refer to the [contributing guidelines](CONTRIBUTING.md) to understand how to help in the most effective way possible.
- IIDX (instant): LN-NoRelease
> { 20.0, 40.0, 60.0, 80.0, 100.0, 120.0 }
- Malody (instant): LN-NoRelease
> { 20.0, 40.0, 60.0, 80.0, 100.0, 120.0 }
Audio System
- 增加采样打断重放防止全key音谱多音轨重叠变成噪音
- Added sampling interruption playback (to prevent overlapping multiple tracks of the full key note spectrum from becoming noise)
- 选歌界面增加预览keysound和故事板背景音乐
- Added preview keysound and storyboard background music to the song selection interface
### Static Score
- Space Graph
<img width="2511" height="464" alt="img_7" src="https://github.com/user-attachments/assets/681064a3-d632-41cf-a575-984d6f7e3c10" />
- Column One by One
<img width="2560" height="889" alt="img_8" src="https://github.com/user-attachments/assets/d245f649-c64a-4e4b-ad43-365f657ef155" />
### Other
- Scale Only Mode
<img width="1023" height="162" alt="img_15" src="https://github.com/user-attachments/assets/fd6f26e6-ffce-421a-930e-7b29bb7c6281" />
## Mod
<img width="1136" height="932" alt="img_1" src="https://github.com/user-attachments/assets/f09e8c19-6459-4431-a40d-bfb3700fd24f" />
## Special Thanks
- [osu!](https://github.com/ppy/osu): The original game and framework. The code is very strong and elegant.
- [YuLiangSSS](https://osu.ppy.sh/users/15889644): Many fun mods contributed.
If you wish to help with localisation efforts, head over to [crowdin](https://crowdin.com/project/osu-web).
We love to reward quality contributions. If you have made a large contribution, or are a regular contributor, you are welcome to [submit an expense via opencollective](https://opencollective.com/ppy/expenses/new). If you have any questions, feel free to [reach out to peppy](mailto:pe@ppy.sh) before doing so.
## Licence

View File

@@ -0,0 +1,96 @@
# 需要修改的文件列表
以下是实现皮肤脚本系统所需修改的所有文件列表,包括修改内容的简要说明。
## 修改的现有文件
### 1. `osu.Game/Skinning/Skin.cs`
**主要修改:**
- 添加了 `Scripts` 属性,用于存储加载的脚本
- 添加了 `LoadComplete()``LoadScripts()` 方法,用于加载皮肤脚本
- 添加了 `GetScriptFiles()``GetStream()` 虚拟方法,供子类实现
- 修改了 `Dispose()` 方法,确保脚本资源被正确释放
### 2. `osu.Game/Skinning/LegacySkin.cs`
**主要修改:**
- 重写了 `GetScriptFiles()` 方法,实现从皮肤文件中查找 .lua 脚本文件
### 3. `osu.Game/Skinning/SkinManager.cs`
**主要修改:**
- 添加了 `scriptManager` 字段,用于存储脚本管理器实例
- 在构造函数中初始化脚本管理器并注册到 RealmAccess
- 确保皮肤切换时脚本也得到更新
### 4. `osu.Game/Skinning/SkinnableDrawable.cs`
**主要修改:**
- 添加了 `[Resolved]` 依赖注入 `SkinScriptManager`
- 添加了 `LoadComplete()` 方法,在组件加载完成后通知脚本
- 添加了 `NotifyScriptsOfComponentLoad()` 方法,用于通知脚本管理器组件已加载
## 新增的文件
### 1. `osu.Game/Skinning/Scripting/ISkinScriptHost.cs`
提供脚本与游戏交互的主机接口,定义了脚本可以访问的资源和功能。
### 2. `osu.Game/Skinning/Scripting/SkinScript.cs`
表示单个Lua脚本包含加载、执行脚本及处理各种事件的逻辑。
### 3. `osu.Game/Skinning/Scripting/SkinScriptInterface.cs`
为Lua脚本提供API封装对游戏系统的调用确保安全且受控的访问。
### 4. `osu.Game/Skinning/Scripting/SkinScriptManager.cs`
管理所有活跃的皮肤脚本,协调脚本加载和事件分发。
### 5. `osu.Game/Skinning/Scripting/SkinScriptingConfig.cs`
管理脚本配置设置,包括启用/禁用脚本和权限列表。
### 6. `osu.Game/Skinning/Scripting/Overlays/SkinScriptingSettingsSection.cs`
提供脚本设置用户界面,允许用户启用/禁用脚本并导入新脚本。
### 7. `osu.Game/Skinning/Scripting/SkinScriptingOverlayRegistration.cs`
在游戏启动时注册脚本设置到设置界面。
### 8. `osu.Game/Overlays/Dialog/FileImportFaultDialog.cs`
用于显示文件导入错误的对话框。
### 9. `osu.Game/Rulesets/Mania/Skinning/Scripting/ManiaSkinScriptExtensions.cs`
为Mania模式提供特定的脚本扩展允许脚本访问和修改Mania特有的元素。
## 示例文件
### 1. `ExampleSkinScript.lua`
通用皮肤脚本示例演示基本功能和API用法。
### 2. `ExampleManiaSkinScript.lua`
Mania模式特定皮肤脚本示例演示如何使用Mania特有的API。
## 需要安装的依赖
- MoonSharp.Interpreter (2.0.0) - Lua脚本引擎
## 实施步骤
1. 安装必要的NuGet包
2. 添加新文件到项目中
3. 按照列表修改现有文件
4. 编译并测试基本功能
5. 测试示例脚本
## 对现有代码的影响
修改尽量保持最小化主要通过添加方法和属性来扩展现有类而不是修改核心逻辑。所有脚本执行都在try-catch块中确保脚本错误不会影响游戏稳定性。

View File

@@ -0,0 +1 @@
MoonSharp.Interpreter 2.0.0

View File

@@ -0,0 +1,160 @@
-- Mania-specific skin script example
-- This script shows how to customize Mania mode skin components
-- Script description and metadata
SCRIPT_DESCRIPTION = "Mania模式特定的皮肤脚本示例展示如何自定义下落式键盘模式的外观和行为"
SCRIPT_VERSION = "1.0"
SCRIPT_AUTHOR = "osu!team"
-- Cache for column information
local columnData = {}
-- Called when the script is first loaded
function onLoad()
osu.Log("Mania skin script loaded!", "info")
osu.SubscribeToEvent("ManiaColumnHit")
osu.SubscribeToEvent("ManiaHoldActivated")
-- Initialize column data if we're in mania mode
if osu.GetRulesetName() == "mania" then
local columnCount = mania.GetColumnCount()
osu.Log("Mania mode detected with " .. columnCount .. " columns", "info")
-- Store information about each column
for i = 0, columnCount - 1 do
columnData[i] = {
binding = mania.GetColumnBinding(i),
width = mania.GetColumnWidth(i),
lastHitTime = 0,
isHolding = false
}
osu.Log("Column " .. i .. " has binding " .. columnData[i].binding, "debug")
}
end
end
-- Called when a component is loaded
function onComponentLoaded(component)
if component.Type and component.Type.Name == "ManiaStageComponent" then
osu.Log("Mania stage component loaded", "info")
-- Here you could modify the appearance of the mania stage
-- For example, change colors, sizes, etc.
elseif component.Type and component.Type.Name == "ManiaNote" then
osu.Log("Mania note component loaded", "debug")
-- You could customize individual notes here
-- For example, change the color based on the column
local note = component
if note.Column ~= nil then
local columnIndex = mania.GetNoteColumn(note)
-- Example: Apply different styling to different columns
if columnIndex % 2 == 0 then
-- Even columns get one style
note.Colour = {R = 0.9, G = 0.4, B = 0.4, A = 1.0}
else
-- Odd columns get another style
note.Colour = {R = 0.4, G = 0.4, B = 0.9, A = 1.0}
end
end
end
end
-- Called when a game event occurs
function onGameEvent(eventName, data)
if eventName == "ManiaColumnHit" then
local columnIndex = data.ColumnIndex
if columnData[columnIndex] then
columnData[columnIndex].lastHitTime = osu.GetCurrentTime()
-- Example: Create a visual effect when a column is hit
-- This would require a custom component to be defined elsewhere
osu.Log("Hit on column " .. columnIndex, "debug")
end
elseif eventName == "ManiaHoldActivated" then
local columnIndex = data.ColumnIndex
if columnData[columnIndex] then
columnData[columnIndex].isHolding = true
-- Example: Apply a continuous effect while holding
osu.Log("Hold started on column " .. columnIndex, "debug")
end
elseif eventName == "ManiaHoldReleased" then
local columnIndex = data.ColumnIndex
if columnData[columnIndex] then
columnData[columnIndex].isHolding = false
-- Example: End continuous effects when holding stops
osu.Log("Hold released on column " .. columnIndex, "debug")
end
end
end
-- Called when a judgement result is received
function onJudgement(result)
if result.HitObject and result.HitObject.Column ~= nil then
local columnIndex = result.HitObject.Column
-- Example: Play different sounds based on column and hit result
if result.Type == "Perfect" then
osu.Log("Perfect hit on column " .. columnIndex, "info")
-- Example: Custom sound per column
if columnIndex % 2 == 0 then
osu.PlaySample("normal-hitnormal")
else
osu.PlaySample("normal-hitwhistle")
end
end
end
end
-- Called when an input event occurs
function onInputEvent(event)
-- Example: Map keyboard events to column effects
if event.Key then
-- Check if the key corresponds to a column binding
for i = 0, #columnData do
if columnData[i] and columnData[i].binding == tostring(event.Key) then
osu.Log("Input detected for column " .. i, "debug")
-- Here you could create custom input visualizations
-- This is especially useful for key overlay effects
end
end
end
end
-- Called every frame for continuous effects
function update()
local currentTime = osu.GetCurrentTime()
-- Example: Create pulsing effects on recently hit columns
for i = 0, #columnData do
if columnData[i] then
local timeSinceHit = currentTime - columnData[i].lastHitTime
if timeSinceHit < 500 then -- 500ms of effect
-- Calculate a decay effect (1.0 -> 0.0 over 500ms)
local intensity = 1.0 - (timeSinceHit / 500)
-- Here you would apply the effect to column visualizations
-- Example: column.Glow = intensity
end
-- Apply continuous effects to held columns
if columnData[i].isHolding then
-- Example: Create pulsing or glowing effects while holding
-- local pulseAmount = math.sin(currentTime / 100) * 0.2 + 0.8
-- column.HoldEffectIntensity = pulseAmount
end
end
end
end
-- Return true to indicate the script loaded successfully
return true

View File

@@ -0,0 +1,66 @@
-- Example Skin Script for osu!
-- This script shows how to customize skin components with Lua scripting
-- Script description and metadata
SCRIPT_DESCRIPTION = "基础皮肤脚本示例,展示如何自定义皮肤组件外观和行为"
SCRIPT_VERSION = "1.0"
SCRIPT_AUTHOR = "osu!team"
-- Called when the script is first loaded
-- This is where you can set up any initial state or subscribe to events
function onLoad()
osu.Log("Skin script loaded!", "info")
osu.SubscribeToEvent("HitEvent")
osu.SubscribeToEvent("InputEvent")
end
-- Called when a skinnable component is loaded
-- You can modify components or react to their creation
function onComponentLoaded(component)
osu.Log("Component loaded: " .. tostring(component), "debug")
-- Example: Make combo counter text larger if it's a DefaultComboCounter
if component.Type and component.Type.Name == "DefaultComboCounter" then
if component.CountDisplay then
component.CountDisplay.Scale = {X = 1.5, Y = 1.5}
osu.Log("Modified combo counter size", "info")
end
end
end
-- Called when a game event occurs
-- Events include things like hit events, misses, combo breaks, etc.
function onGameEvent(eventName, data)
if eventName == "HitEvent" then
osu.Log("Hit event received!", "debug")
-- You can trigger sound effects or visual effects here
if data.Result and data.Result.Type == "Great" then
osu.PlaySample("applause")
end
end
end
-- Called when a judgement result is received
-- This includes hit results, misses, etc.
function onJudgement(result)
-- Example: Play a custom sound on perfect hits
if result.Type == "Perfect" then
osu.Log("Perfect hit!", "info")
end
end
-- Called when an input event occurs
-- This includes key presses, mouse clicks, etc.
function onInputEvent(event)
osu.Log("Input event: " .. tostring(event), "debug")
end
-- Called every frame
-- Use this for continuous animations or effects
function update()
-- Example: Create pulsing effects or continuous animations
-- Note: Be careful with performance in this function
end
-- Return true to indicate the script loaded successfully
return true

View File

@@ -0,0 +1,169 @@
# 皮肤脚本系统 (Skin Scripting System)
这个实现添加了对外部Lua脚本的支持允许皮肤制作者通过脚本定制皮肤的行为和外观。
## 实现概述
这个系统使用MoonSharp作为Lua脚本引擎并通过以下关键组件实现
1. **脚本接口** - 为皮肤脚本提供与游戏交互的API
2. **脚本管理器** - 负责加载、执行和管理皮肤脚本
3. **对现有代码的修改** - 在关键点调用脚本回调函数
## 安装和使用
### 安装
1. 安装MoonSharp NuGet包:
```
dotnet add package MoonSharp.Interpreter --version 2.0.0
```
2. 将`SkinScriptingImplementation`文件夹中的所有文件复制到对应的项目文件夹中,保持相同的目录结构。
### 创建皮肤脚本
1. 创建一个`.lua`扩展名的文件
2. 将该文件放入你的皮肤文件夹中
3. 当皮肤加载时,脚本会自动被加载和执行
### 管理脚本
皮肤脚本系统提供了用户界面来管理皮肤脚本:
1. 转到`设置 -> 皮肤 -> 皮肤脚本`部分
2. 使用`启用皮肤脚本`选项来全局启用或禁用脚本功能
3. 使用`从文件导入脚本`按钮将新脚本添加到当前皮肤
4. 在`可用脚本`列表中,可以单独启用或禁用每个脚本
### 脚本元数据
脚本可以包含以下元数据变量:
```lua
-- 脚本描述信息,将显示在设置中
SCRIPT_DESCRIPTION = "这个脚本的功能描述"
SCRIPT_VERSION = "1.0"
SCRIPT_AUTHOR = "作者名称"
```
## Lua脚本API
脚本可以实现以下回调函数:
```lua
-- 脚本加载时调用
function onLoad()
-- 初始化工作,订阅事件等
end
-- 当皮肤组件被加载时调用
function onComponentLoaded(component)
-- 你可以修改组件或对其创建做出反应
end
-- 当游戏事件发生时调用
function onGameEvent(eventName, data)
-- 处理游戏事件
end
-- 当判定结果产生时调用
function onJudgement(result)
-- 根据判定结果创建效果
end
-- 当输入事件发生时调用
function onInputEvent(event)
-- 对输入事件做出反应
end
-- 每帧调用,用于连续动画或效果
function update()
-- 创建连续动画或效果
end
```
### 全局API
所有脚本都可以访问通过`osu`对象的以下功能:
```lua
-- 获取当前谱面标题
osu.GetBeatmapTitle()
-- 获取当前谱面艺术家
osu.GetBeatmapArtist()
-- 获取当前规则集名称
osu.GetRulesetName()
-- 创建新组件
osu.CreateComponent(componentType)
-- 获取纹理
osu.GetTexture(name)
-- 获取音频样本
osu.GetSample(name)
-- 播放音频样本
osu.PlaySample(name)
-- 订阅游戏事件
osu.SubscribeToEvent(eventName)
-- 记录日志
osu.Log(message, level) -- level可以是"debug", "info", "warning", "error"
```
### Mania模式特定API
在Mania模式下脚本还可以通过`mania`对象访问以下功能:
```lua
-- 获取列数
mania.GetColumnCount()
-- 获取音符所在的列
mania.GetNoteColumn(note)
-- 获取列绑定
mania.GetColumnBinding(column)
-- 获取列宽度
mania.GetColumnWidth(column)
```
## 示例脚本
请参考提供的示例脚本:
- [ExampleSkinScript.lua](ExampleSkinScript.lua) - 通用皮肤脚本示例
- [ExampleManiaSkinScript.lua](ExampleManiaSkinScript.lua) - Mania模式特定皮肤脚本示例
## 修改说明
以下文件已被修改以支持皮肤脚本系统:
1. `osu.Game/Skinning/Skin.cs` - 添加了脚本加载和管理功能
2. `osu.Game/Skinning/LegacySkin.cs` - 实现了脚本文件查找
3. `osu.Game/Skinning/SkinManager.cs` - 初始化脚本管理器
4. `osu.Game/Skinning/SkinnableDrawable.cs` - 添加了组件加载通知
新增的文件:
1. `osu.Game/Skinning/Scripting/*.cs` - 脚本系统核心类
2. `osu.Game/Rulesets/Mania/Skinning/Scripting/*.cs` - Mania模式特定脚本扩展
## 限制和注意事项
1. 脚本在沙箱环境中运行,访问权限有限
2. 过于复杂的脚本可能会影响性能
3. 脚本API可能会随着游戏更新而变化
## 故障排除
如果脚本无法正常工作:
1. 检查游戏日志中的脚本错误信息
2. 确保脚本文件格式正确UTF-8编码无BOM
3. 确保脚本没有语法错误

View File

@@ -0,0 +1,17 @@
using osu.Framework.Allocation;
using osu.Game.Skinning.Scripting;
namespace osu.Game
{
public partial class OsuGame
{
private SkinScriptingOverlayRegistration scriptingRegistration;
[BackgroundDependencyLoader]
private void loadSkinScripting()
{
// 添加皮肤脚本设置注册组件
Add(scriptingRegistration = new SkinScriptingOverlayRegistration());
}
}
}

View File

@@ -0,0 +1,31 @@
using System;
using osu.Framework.Graphics.Sprites;
using osu.Game.Overlays.Dialog;
namespace osu.Game.Overlays.Dialog
{
/// <summary>
/// 文件导入失败时显示的对话框。
/// </summary>
public partial class FileImportFaultDialog : PopupDialog
{
/// <summary>
/// 初始化 <see cref="FileImportFaultDialog"/> 类的新实例。
/// </summary>
/// <param name="errorMessage">错误信息。</param>
public FileImportFaultDialog(string errorMessage)
{
Icon = FontAwesome.Regular.TimesCircle;
HeaderText = "导入失败";
BodyText = errorMessage;
Buttons = new PopupDialogButton[]
{
new PopupDialogOkButton
{
Text = "确定",
}
};
}
}
}

View File

@@ -0,0 +1,316 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using System;
using System.Collections.Generic;
using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Localisation;
using osu.Framework.Logging;
using osu.Game.Database;
using osu.Game.Graphics;
using osu.Game.Graphics.UserInterface;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Localisation;
using osu.Game.Overlays.SkinEditor;
using osu.Game.Screens.Select;
using osu.Game.Skinning;
using osuTK;
using Realms;
using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings;
using osu.Game.Skinning.Scripting;
using osu.Game.Skinning.Scripting.Overlays;
namespace osu.Game.Overlays.Settings.Sections
{
public partial class SkinSection : SettingsSection
{
private SkinSettingsDropdown skinDropdown;
private SkinScriptingSettingsSection scriptingSection;
public override LocalisableString Header => SkinSettingsStrings.SkinSectionHeader;
public override Drawable CreateIcon() => new SpriteIcon
{
Icon = OsuIcon.SkinB
};
private static readonly Live<SkinInfo> random_skin_info = new SkinInfo
{
ID = SkinInfo.RANDOM_SKIN,
Name = "<Random Skin>",
}.ToLiveUnmanaged();
private readonly List<Live<SkinInfo>> dropdownItems = new List<Live<SkinInfo>>();
[Resolved]
private SkinManager skins { get; set; }
[Resolved]
private RealmAccess realm { get; set; }
private IDisposable realmSubscription; [BackgroundDependencyLoader(permitNulls: true)]
private void load([CanBeNull] SkinEditorOverlay skinEditor)
{
Children = new Drawable[]
{
skinDropdown = new SkinSettingsDropdown
{
AlwaysShowSearchBar = true,
AllowNonContiguousMatching = true,
LabelText = SkinSettingsStrings.CurrentSkin,
Current = skins.CurrentSkinInfo,
Keywords = new[] { @"skins" },
},
new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(5, 0),
Padding = new MarginPadding { Left = SettingsPanel.CONTENT_MARGINS, Right = SettingsPanel.CONTENT_MARGINS },
Children = new Drawable[]
{
// This is all super-temporary until we move skin settings to their own panel / overlay.
new RenameSkinButton { Padding = new MarginPadding(), RelativeSizeAxes = Axes.None, Width = 120 },
new ExportSkinButton { Padding = new MarginPadding(), RelativeSizeAxes = Axes.None, Width = 120 },
new DeleteSkinButton { Padding = new MarginPadding(), RelativeSizeAxes = Axes.None, Width = 110 },
}
},
new SettingsButton
{
Text = SkinSettingsStrings.SkinLayoutEditor,
Action = () => skinEditor?.ToggleVisibility(),
},
scriptingSection = new SkinScriptingSettingsSection(),
};
};
}
protected override void LoadComplete()
{
base.LoadComplete();
realmSubscription = realm.RegisterForNotifications(_ => realm.Realm.All<SkinInfo>()
.Where(s => !s.DeletePending)
.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase), skinsChanged);
skinDropdown.Current.BindValueChanged(skin =>
{
if (skin.NewValue == random_skin_info)
{
// before selecting random, set the skin back to the previous selection.
// this is done because at this point it will be random_skin_info, and would
// cause SelectRandomSkin to be unable to skip the previous selection.
skins.CurrentSkinInfo.Value = skin.OldValue;
skins.SelectRandomSkin();
}
});
}
private void skinsChanged(IRealmCollection<SkinInfo> sender, ChangeSet changes)
{
// This can only mean that realm is recycling, else we would see the protected skins.
// Because we are using `Live<>` in this class, we don't need to worry about this scenario too much.
if (!sender.Any())
return;
// For simplicity repopulate the full list.
// In the future we should change this to properly handle ChangeSet events.
dropdownItems.Clear();
dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.EZ2_SKIN).ToLive(realm));
dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.SBI_SKIN).ToLive(realm));
dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.ARGON_SKIN).ToLive(realm));
dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.ARGON_PRO_SKIN).ToLive(realm));
dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.TRIANGLES_SKIN).ToLive(realm));
dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.CLASSIC_SKIN).ToLive(realm));
dropdownItems.Add(random_skin_info);
foreach (var skin in sender.Where(s => !s.Protected))
dropdownItems.Add(skin.ToLive(realm));
Schedule(() => skinDropdown.Items = dropdownItems);
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
realmSubscription?.Dispose();
}
private partial class SkinSettingsDropdown : SettingsDropdown<Live<SkinInfo>>
{
protected override OsuDropdown<Live<SkinInfo>> CreateDropdown() => new SkinDropdownControl();
private partial class SkinDropdownControl : DropdownControl
{
protected override LocalisableString GenerateItemText(Live<SkinInfo> item) => item.ToString();
}
}
public partial class RenameSkinButton : SettingsButton, IHasPopover
{
[Resolved]
private SkinManager skins { get; set; }
private Bindable<Skin> currentSkin;
[BackgroundDependencyLoader]
private void load()
{
Text = CommonStrings.Rename;
Action = this.ShowPopover;
}
protected override void LoadComplete()
{
base.LoadComplete();
currentSkin = skins.CurrentSkin.GetBoundCopy();
currentSkin.BindValueChanged(skin => Enabled.Value = skin.NewValue.SkinInfo.PerformRead(s => !s.Protected), true);
}
public Popover GetPopover()
{
return new RenameSkinPopover();
}
}
public partial class ExportSkinButton : SettingsButton
{
[Resolved]
private SkinManager skins { get; set; }
private Bindable<Skin> currentSkin;
[BackgroundDependencyLoader]
private void load()
{
Text = CommonStrings.Export;
Action = export;
}
protected override void LoadComplete()
{
base.LoadComplete();
currentSkin = skins.CurrentSkin.GetBoundCopy();
currentSkin.BindValueChanged(skin => Enabled.Value = skin.NewValue.SkinInfo.PerformRead(s => !s.Protected), true);
}
private void export()
{
try
{
skins.ExportCurrentSkin();
}
catch (Exception e)
{
Logger.Log($"Could not export current skin: {e.Message}", level: LogLevel.Error);
}
}
}
public partial class DeleteSkinButton : DangerousSettingsButton
{
[Resolved]
private SkinManager skins { get; set; }
[Resolved(CanBeNull = true)]
private IDialogOverlay dialogOverlay { get; set; }
private Bindable<Skin> currentSkin;
[BackgroundDependencyLoader]
private void load()
{
Text = WebCommonStrings.ButtonsDelete;
Action = delete;
}
protected override void LoadComplete()
{
base.LoadComplete();
currentSkin = skins.CurrentSkin.GetBoundCopy();
currentSkin.BindValueChanged(skin => Enabled.Value = skin.NewValue.SkinInfo.PerformRead(s => !s.Protected), true);
}
private void delete()
{
dialogOverlay?.Push(new SkinDeleteDialog(currentSkin.Value));
}
}
public partial class RenameSkinPopover : OsuPopover
{
[Resolved]
private SkinManager skins { get; set; }
private readonly FocusedTextBox textBox;
public RenameSkinPopover()
{
AutoSizeAxes = Axes.Both;
Origin = Anchor.TopCentre;
RoundedButton renameButton;
Child = new FillFlowContainer
{
Direction = FillDirection.Vertical,
AutoSizeAxes = Axes.Y,
Width = 250,
Spacing = new Vector2(10f),
Children = new Drawable[]
{
textBox = new FocusedTextBox
{
PlaceholderText = @"Skin name",
FontSize = OsuFont.DEFAULT_FONT_SIZE,
RelativeSizeAxes = Axes.X,
SelectAllOnFocus = true,
},
renameButton = new RoundedButton
{
Height = 40,
RelativeSizeAxes = Axes.X,
MatchingFilter = true,
Text = "Save",
}
}
};
renameButton.Action += rename;
textBox.OnCommit += (_, _) => rename();
}
protected override void PopIn()
{
textBox.Text = skins.CurrentSkinInfo.Value.Value.Name;
textBox.TakeFocus();
base.PopIn();
}
private void rename() => skins.CurrentSkinInfo.Value.PerformWrite(skin =>
{
skin.Name = textBox.Text;
PopOut();
});
}
}
}

View File

@@ -0,0 +1,81 @@
using MoonSharp.Interpreter;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.UI;
namespace osu.Game.Rulesets.Mania.Skinning.Scripting
{
/// <summary>
/// Provides mania-specific extensions for skin scripts.
/// </summary>
[MoonSharpUserData]
public class ManiaSkinScriptExtensions
{
private readonly ManiaAction[] columnBindings;
private readonly StageDefinition stageDefinition;
/// <summary>
/// Initializes a new instance of the <see cref="ManiaSkinScriptExtensions"/> class.
/// </summary>
/// <param name="stage">The stage this extension is for.</param>
public ManiaSkinScriptExtensions(Stage stage)
{
stageDefinition = stage.Definition;
// Store column bindings
columnBindings = new ManiaAction[stageDefinition.Columns];
for (int i = 0; i < stageDefinition.Columns; i++)
{
columnBindings[i] = stageDefinition.GetActionForColumn(i);
}
}
/// <summary>
/// Gets the number of columns in the stage.
/// </summary>
/// <returns>The number of columns.</returns>
[MoonSharpVisible(true)]
public int GetColumnCount()
{
return stageDefinition.Columns;
}
/// <summary>
/// Gets the column index for a specific note.
/// </summary>
/// <param name="note">The note.</param>
/// <returns>The column index.</returns>
[MoonSharpVisible(true)]
public int GetNoteColumn(Note note)
{
return note.Column;
}
/// <summary>
/// Gets the binding (action) for a specific column.
/// </summary>
/// <param name="column">The column index.</param>
/// <returns>The binding action as a string.</returns>
[MoonSharpVisible(true)]
public string GetColumnBinding(int column)
{
if (column < 0 || column >= columnBindings.Length)
return "Invalid";
return columnBindings[column].ToString();
}
/// <summary>
/// Gets the width of a specific column.
/// </summary>
/// <param name="column">The column index.</param>
/// <returns>The column width.</returns>
[MoonSharpVisible(true)]
public float GetColumnWidth(int column)
{
if (column < 0 || column >= stageDefinition.Columns)
return 0;
return stageDefinition.ColumnWidths[column];
}
}
}

View File

@@ -0,0 +1,369 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Audio.Sample;
using osu.Framework.Bindables;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Textures;
using osu.Framework.IO.Stores;
using osu.Game.Audio;
using osu.Game.Beatmaps.Formats;
using osu.Game.Extensions;
using osu.Game.IO;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.Play.HUD;
using osu.Game.Screens.Play.HUD.HitErrorMeters;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Skinning
{
public class LegacySkin : Skin
{
protected virtual bool AllowManiaConfigLookups => true;
/// <summary>
/// Whether this skin can use samples with a custom bank (custom sample set in stable terminology).
/// Added in order to match sample lookup logic from stable (in stable, only the beatmap skin could use samples with a custom sample bank).
/// </summary>
protected virtual bool UseCustomSampleBanks => false;
private readonly Dictionary<int, LegacyManiaSkinConfiguration> maniaConfigurations = new Dictionary<int, LegacyManiaSkinConfiguration>();
[UsedImplicitly(ImplicitUseKindFlags.InstantiatedWithFixedConstructorSignature)]
public LegacySkin(SkinInfo skin, IStorageResourceProvider resources)
: this(skin, resources, null)
{
}
/// <summary>
/// Construct a new legacy skin instance.
/// </summary>
/// <param name="skin">The model for this skin.</param>
/// <param name="resources">Access to raw game resources.</param>
/// <param name="fallbackStore">An optional fallback store which will be used for file lookups that are not serviced by realm user storage.</param>
/// <param name="configurationFilename">The user-facing filename of the configuration file to be parsed. Can accept an .osu or skin.ini file.</param>
protected LegacySkin(SkinInfo skin, IStorageResourceProvider? resources, IResourceStore<byte[]>? fallbackStore, string configurationFilename = @"skin.ini")
: base(skin, resources, fallbackStore, configurationFilename)
{
}
protected override IResourceStore<TextureUpload> CreateTextureLoaderStore(IStorageResourceProvider resources, IResourceStore<byte[]> storage)
=> new LegacyTextureLoaderStore(base.CreateTextureLoaderStore(resources, storage));
protected override void ParseConfigurationStream(Stream stream)
{
base.ParseConfigurationStream(stream);
stream.Seek(0, SeekOrigin.Begin);
using (LineBufferedReader reader = new LineBufferedReader(stream))
{
var maniaList = new LegacyManiaSkinDecoder().Decode(reader);
foreach (var config in maniaList)
maniaConfigurations[config.Keys] = config;
}
}
/// <summary>
/// Gets a list of script files in the skin.
/// </summary>
/// <returns>A list of script file names.</returns>
protected override IEnumerable<string> GetScriptFiles()
{
// Look for .lua script files in the skin
return SkinInfo.Files.Where(f => f.Filename.EndsWith(".lua", StringComparison.OrdinalIgnoreCase))
.Select(f => f.Filename);
}
[SuppressMessage("ReSharper", "RedundantAssignment")] // for `wasHit` assignments used in `finally` debug logic
public override IBindable<TValue>? GetConfig<TLookup, TValue>(TLookup lookup)
{
bool wasHit = true;
try
{
switch (lookup)
{
case GlobalSkinColours colour:
switch (colour)
{
case GlobalSkinColours.ComboColours:
var comboColours = Configuration.ComboColours;
if (comboColours != null)
return SkinUtils.As<TValue>(new Bindable<IReadOnlyList<Color4>>(comboColours));
break;
default:
return SkinUtils.As<TValue>(getCustomColour(Configuration, colour.ToString()));
}
break;
case SkinConfiguration.LegacySetting setting:
switch (setting)
{
case SkinConfiguration.LegacySetting.Version:
return SkinUtils.As<TValue>(new Bindable<decimal>(Configuration.LegacyVersion ?? LegacySkinConfiguration.LATEST_VERSION));
}
break;
// handled by ruleset-specific skin classes.
case LegacyManiaSkinConfigurationLookup maniaLookup:
wasHit = false;
break;
case SkinCustomColourLookup customColour:
return SkinUtils.As<TValue>(getCustomColour(Configuration, customColour.Lookup.ToString()));
case LegacySkinHitCircleLookup legacyHitCircleLookup:
switch (legacyHitCircleLookup.Detail)
{
case LegacySkinHitCircleLookup.DetailType.HitCircleNormalPathTint:
return SkinUtils.As<TValue>(new Bindable<Color4>(Configuration.HitCircleNormalPathTint ?? Color4.White));
case LegacySkinHitCircleLookup.DetailType.HitCircleHoverPathTint:
return SkinUtils.As<TValue>(new Bindable<Color4>(Configuration.HitCircleHoverPathTint ?? Color4.White));
case LegacySkinHitCircleLookup.DetailType.Count:
wasHit = false;
break;
}
break;
case LegacySkinNoteSheetLookup legacyNoteSheetLookup:
return SkinUtils.As<TValue>(new Bindable<float>(Configuration.NoteBodyWidth ?? 128));
case SkinConfigurationLookup skinLookup:
return handleLegacySkinLookup(skinLookup);
}
wasHit = false;
return null;
}
finally
{
LogLookupDebug(this, lookup, wasHit ? LookupDebugType.Hit : LookupDebugType.Miss);
}
}
public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup)
{
if (base.GetDrawableComponent(lookup) is Drawable d)
return d;
switch (lookup)
{
case SkinnableSprite.SpriteComponentLookup sprite:
return this.GetAnimation(sprite.LookupName, false, false, maxSize: sprite.MaxSize);
case SkinComponentsContainerLookup _:
return null;
case GameplaySkinComponentLookup<HitResult> resultComponent:
return getResult(resultComponent.Component);
case GameplaySkinComponentLookup<BarHitErrorMeter> bhe:
if (Configuration.LegacyVersion < 2.2m)
return null;
break;
case JudgementLineStyleLookup judgementLine:
return findProvider(nameof(JudgementLineStyleLookup.Type), judgementLine.Type);
default:
return findProvider(nameof(ISkinComponentLookup.Lookup), lookup.Lookup);
}
return null;
}
private Drawable? findProvider(string lookupName, object lookupValue)
{
var providedType = GetType().Assembly.GetTypes()
.Where(t => !t.IsInterface && !t.IsAbstract)
.FirstOrDefault(t =>
{
var interfaces = t.GetInterfaces();
return interfaces.Any(i => i.IsGenericType &&
i.GetGenericTypeDefinition() == typeof(ILegacySkinComponentProvider<,>) &&
i.GenericTypeArguments[1].GetProperty(lookupName)?.PropertyType == lookupValue.GetType());
});
if (providedType == null)
return null;
var constructor = providedType.GetConstructor(new[] { typeof(LegacySkinConfiguration), typeof(ISkin) });
if (constructor == null)
return null;
var instance = constructor.Invoke(new object[] { Configuration, this });
var interfaceType = instance.GetType().GetInterfaces()
.First(i => i.IsGenericType &&
i.GetGenericTypeDefinition() == typeof(ILegacySkinComponentProvider<,>) &&
i.GenericTypeArguments[1].GetProperty(lookupName)?.PropertyType == lookupValue.GetType());
var providerType = interfaceType.GetGenericTypeDefinition().MakeGenericType(interfaceType.GenericTypeArguments[0], lookupValue.GetType());
var methodInfo = providerType.GetMethod(nameof(ILegacySkinComponentProvider<Drawable, object>.GetDrawableComponent),
new[] { lookupValue.GetType() });
var component = methodInfo?.Invoke(instance, new[] { lookupValue }) as Drawable;
return component;
}
private IBindable<TValue>? handleLegacySkinLookup<TValue>(SkinConfigurationLookup lookup)
{
switch (lookup.Lookup)
{
case SkinConfiguration.SliderStyle:
{
var style = Configuration.SliderStyle ?? (Configuration.Version < 2.0m ? SliderStyle.Segmented : SliderStyle.Gradient);
return SkinUtils.As<TValue>(new Bindable<SliderStyle>(style));
}
case SkinConfiguration.ScoringVisible:
return SkinUtils.As<TValue>(new Bindable<bool>(Configuration.ScoringVisible ?? true));
case SkinConfiguration.ComboPerformed:
return SkinUtils.As<TValue>(new Bindable<bool>(Configuration.ComboPerformed ?? true));
case SkinConfiguration.ComboTaskbarPopover:
return SkinUtils.As<TValue>(new Bindable<bool>(Configuration.ComboTaskbarPopover ?? true));
case SkinConfiguration.HitErrorStyle:
return SkinUtils.As<TValue>(new Bindable<HitErrorStyle>(Configuration.HitErrorStyle ?? HitErrorStyle.Bottom));
case SkinConfiguration.MainHUDLayoutMode:
return SkinUtils.As<TValue>(new Bindable<HUDLayoutMode>(Configuration.MainHUDLayoutMode ?? HUDLayoutMode.New));
case SkinConfiguration.InputOverlayMode:
return SkinUtils.As<TValue>(new Bindable<InputOverlayMode>(Configuration.InputOverlayMode ?? InputOverlayMode.Bottom));
case SkinConfiguration.SongMetadataView:
return SkinUtils.As<TValue>(new Bindable<SongMetadataView>(Configuration.SongMetadataView ?? SongMetadataView.Default));
}
return null;
}
private IBindable<Color4>? getCustomColour(LegacySkinConfiguration configuration, string lookup)
{
if (configuration.CustomColours != null &&
configuration.CustomColours.TryGetValue(lookup, out Color4 col))
return new Bindable<Color4>(col);
return null;
}
[CanBeNull]
protected virtual Drawable? getResult(HitResult result)
{
return null;
}
public override Texture? GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT)
{
float ratio = 2;
var texture = Textures?.Get($"{componentName}@2x", wrapModeS, wrapModeT);
if (texture == null)
{
ratio = 1;
texture = Textures?.Get(componentName, wrapModeS, wrapModeT);
}
if (texture == null && !componentName.EndsWith(@"@2x", StringComparison.Ordinal))
{
componentName = componentName.Replace(@"@2x", string.Empty);
string twoTimesFilename = $"{Path.ChangeExtension(componentName, null)}@2x{Path.GetExtension(componentName)}";
texture = Textures?.Get(twoTimesFilename, wrapModeS, wrapModeT);
if (texture != null)
ratio = 2;
}
texture ??= Textures?.Get(componentName, wrapModeS, wrapModeT);
if (texture != null)
texture.ScaleAdjust = ratio;
return texture;
}
public override ISample? GetSample(ISampleInfo sampleInfo)
{
IEnumerable<string> lookupNames;
if (sampleInfo is HitSampleInfo hitSample)
lookupNames = getLegacyLookupNames(hitSample);
else
{
lookupNames = sampleInfo.LookupNames.SelectMany(getFallbackSampleNames);
}
foreach (string lookup in lookupNames)
{
var sample = Samples?.Get(lookup);
if (sample != null)
{
return sample;
}
}
return null;
}
private IEnumerable<string> getLegacyLookupNames(HitSampleInfo hitSample)
{
var lookupNames = hitSample.LookupNames.SelectMany(getFallbackSampleNames);
if (!UseCustomSampleBanks && !string.IsNullOrEmpty(hitSample.Suffix))
{
// for compatibility with stable, exclude the lookup names with the custom sample bank suffix, if they are not valid for use in this skin.
// using .EndsWith() is intentional as it ensures parity in all edge cases
// (see LegacyTaikoSampleInfo for an example of one - prioritising the taiko prefix should still apply, but the sample bank should not).
lookupNames = lookupNames.Where(name => !name.EndsWith(hitSample.Suffix, StringComparison.Ordinal));
}
foreach (string l in lookupNames)
yield return l;
// also for compatibility, try falling back to non-bank samples (so-called "universal" samples) as the last resort.
// going forward specifying banks shall always be required, even for elements that wouldn't require it on stable,
// which is why this is done locally here.
yield return hitSample.Name;
}
private IEnumerable<string> getFallbackSampleNames(string name)
{
// May be something like "Gameplay/normal-hitnormal" from lazer.
yield return name;
// Fall back to using the last piece for components coming from lazer (e.g. "Gameplay/normal-hitnormal" -> "normal-hitnormal").
yield return name.Split('/').Last();
}
}
}

View File

@@ -0,0 +1,82 @@
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Textures;
using osu.Framework.Input.Events;
using osu.Game.Beatmaps;
using osu.Game.Rulesets;
using osu.Game.Scoring;
namespace osu.Game.Skinning.Scripting
{
/// <summary>
/// Interface for communication between the game and skin scripts.
/// </summary>
public interface ISkinScriptHost
{
/// <summary>
/// Gets the current beatmap.
/// </summary>
IBeatmap CurrentBeatmap { get; }
/// <summary>
/// Gets the audio manager for sound playback.
/// </summary>
IAudioManager AudioManager { get; }
/// <summary>
/// Gets the current skin.
/// </summary>
ISkin CurrentSkin { get; }
/// <summary>
/// Gets the current ruleset info.
/// </summary>
IRulesetInfo CurrentRuleset { get; }
/// <summary>
/// Creates a new drawable component of the specified type.
/// </summary>
/// <param name="componentType">The type of component to create.</param>
/// <returns>The created component.</returns>
Drawable CreateComponent(string componentType);
/// <summary>
/// Gets a texture from the current skin.
/// </summary>
/// <param name="name">The name of the texture.</param>
/// <returns>The texture, or null if not found.</returns>
Texture GetTexture(string name);
/// <summary>
/// Gets a sample from the current skin.
/// </summary>
/// <param name="name">The name of the sample.</param>
/// <returns>The sample, or null if not found.</returns>
ISample GetSample(string name);
/// <summary>
/// Subscribe to a game event.
/// </summary>
/// <param name="eventName">The name of the event to subscribe to.</param>
void SubscribeToEvent(string eventName);
/// <summary>
/// Log a message to the osu! log.
/// </summary>
/// <param name="message">The message to log.</param>
/// <param name="level">The log level.</param>
void Log(string message, LogLevel level = LogLevel.Information);
}
/// <summary>
/// Log levels for skin script messages.
/// </summary>
public enum LogLevel
{
Debug,
Information,
Warning,
Error
}
}

View File

@@ -0,0 +1,300 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays;
using osu.Game.Overlays.Settings;
using osuTK;
namespace osu.Game.Skinning.Scripting.Overlays
{
public class SkinScriptingSettingsSection : SettingsSection
{
protected override string Header => "皮肤脚本";
[Resolved]
private SkinManager skinManager { get; set; }
[Resolved]
private DialogOverlay dialogOverlay { get; set; }
[Resolved]
private GameHost host { get; set; }
[Resolved]
private Storage storage { get; set; }
[Resolved(CanBeNull = true)]
private SkinScriptingConfig scriptConfig { get; set; }
private readonly Bindable<bool> scriptingEnabled = new Bindable<bool>(true);
private readonly BindableList<string> allowedScripts = new BindableList<string>();
private readonly BindableList<string> blockedScripts = new BindableList<string>();
private FillFlowContainer scriptListFlow;
private OsuButton importButton;
[BackgroundDependencyLoader] private void load()
{
if (scriptConfig != null)
{
scriptConfig.BindWith(SkinScriptingSettings.ScriptingEnabled, scriptingEnabled);
scriptConfig.BindWith(SkinScriptingSettings.AllowedScripts, allowedScripts);
scriptConfig.BindWith(SkinScriptingSettings.BlockedScripts, blockedScripts);
}
Children = new Drawable[]
{
new SettingsCheckbox
{
LabelText = "启用皮肤脚本",
TooltipText = "允许皮肤使用Lua脚本来自定义外观和行为",
Current = scriptingEnabled
},
new SettingsButton
{
Text = "从文件导入脚本",
Action = ImportScriptFromFile
},
new OsuSpriteText
{
Text = "可用脚本",
Font = OsuFont.GetFont(size: 20, weight: FontWeight.Bold),
Margin = new MarginPadding { Top = 20, Bottom = 10 }
},
new OsuScrollContainer
{
RelativeSizeAxes = Axes.X,
Height = 300,
Child = scriptListFlow = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
Spacing = new Vector2(0, 5)
}
}
};
// 监听皮肤变化
skinManager.CurrentSkin.BindValueChanged(_ => RefreshScriptList(), true);
}
private void RefreshScriptList()
{
scriptListFlow.Clear();
if (skinManager.CurrentSkin.Value is LegacySkin skin)
{
var scripts = skin.Scripts;
if (scripts.Count == 0)
{
scriptListFlow.Add(new OsuSpriteText
{
Text = "当前皮肤没有可用的脚本",
Font = OsuFont.GetFont(size: 16),
Colour = Colours.Gray9
});
}
else
{
foreach (var script in scripts)
{
scriptListFlow.Add(new ScriptListItem(script, allowedScripts, blockedScripts));
}
}
}
else
{
scriptListFlow.Add(new OsuSpriteText
{
Text = "当前皮肤不支持脚本",
Font = OsuFont.GetFont(size: 16),
Colour = Colours.Gray9
});
}
}
private void ImportScriptFromFile()
{
Task.Run(async () =>
{
try
{
string[] paths = await host.PickFilesAsync(new FilePickerOptions
{
Title = "选择Lua脚本文件",
FileTypes = new[] { ".lua" }
}).ConfigureAwait(false);
if (paths == null || paths.Length == 0)
return;
Schedule(() =>
{
foreach (string path in paths)
{
try
{
// 获取目标路径(当前皮肤文件夹)
if (skinManager.CurrentSkin.Value is not LegacySkin skin || skin.SkinInfo.Files == null)
{
dialogOverlay.Push(new FileImportFaultDialog("当前皮肤不支持脚本导入"));
return;
}
string fileName = Path.GetFileName(path);
string destPath = Path.Combine(storage.GetFullPath($"skins/{skin.SkinInfo.ID}/{fileName}"));
// 复制文件
File.Copy(path, destPath, true);
// 刷新皮肤(重新加载脚本)
skinManager.RefreshCurrentSkin();
// 刷新脚本列表
RefreshScriptList();
}
catch (Exception ex)
{
Logger.Error(ex, $"导入脚本失败: {ex.Message}");
dialogOverlay.Push(new FileImportFaultDialog(ex.Message));
}
}
});
}
catch (Exception ex)
{
Logger.Error(ex, "选择脚本文件失败");
Schedule(() => dialogOverlay.Push(new FileImportFaultDialog(ex.Message)));
}
});
}
private class ScriptListItem : CompositeDrawable
{
private readonly SkinScript script;
private readonly BindableList<string> allowedScripts;
private readonly BindableList<string> blockedScripts;
private readonly BindableBool isEnabled = new BindableBool(true);
public ScriptListItem(SkinScript script, BindableList<string> allowedScripts, BindableList<string> blockedScripts)
{
this.script = script;
this.allowedScripts = allowedScripts;
this.blockedScripts = blockedScripts;
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
// 根据配置设置初始状态
string scriptName = script.ScriptName;
if (blockedScripts.Contains(scriptName))
isEnabled.Value = false;
else if (allowedScripts.Count > 0 && !allowedScripts.Contains(scriptName))
isEnabled.Value = false;
else
isEnabled.Value = true;
isEnabled.ValueChanged += e =>
{
if (e.NewValue)
{
// 启用脚本
blockedScripts.Remove(scriptName);
if (allowedScripts.Count > 0)
allowedScripts.Add(scriptName);
}
else
{
// 禁用脚本
blockedScripts.Add(scriptName);
allowedScripts.Remove(scriptName);
}
};
}
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
InternalChildren = new Drawable[]
{
new Container
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Padding = new MarginPadding { Horizontal = 10, Vertical = 5 },
Children = new Drawable[]
{
new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(10, 0),
Children = new Drawable[]
{
new OsuCheckbox
{
Current = isEnabled,
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
},
new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
Spacing = new Vector2(0, 2),
Width = 0.9f,
Children = new Drawable[]
{
new OsuSpriteText
{
Text = script.ScriptName,
Font = OsuFont.GetFont(size: 16, weight: FontWeight.Bold)
},
new OsuSpriteText
{
Text = $"脚本描述: {script.Description ?? ""}",
Font = OsuFont.GetFont(size: 14),
Colour = colours.Gray9
}
}
}
}
}
}
}
};
}
protected override bool OnHover(HoverEvent e)
{
this.FadeColour(Colour4.LightGray, 200);
return base.OnHover(e);
}
protected override void OnHoverLost(HoverLostEvent e)
{
this.FadeColour(Colour4.White, 200);
base.OnHoverLost(e);
}
}
}
}

View File

@@ -0,0 +1,191 @@
using System;
using System.IO;
using MoonSharp.Interpreter;
using osu.Framework.Graphics;
using osu.Framework.Input.Events;
using osu.Game.Scoring;
namespace osu.Game.Skinning.Scripting
{
/// <summary>
/// Represents a Lua script that can customize skin behavior.
/// </summary>
public class SkinScript : IDisposable
{
private readonly Script luaScript;
private readonly ISkinScriptHost host;
/// <summary>
/// Gets the name of the script (usually the filename).
/// </summary>
public string ScriptName { get; }
/// <summary>
/// Gets the description of the script, if provided.
/// </summary>
public string Description { get; private set; }
/// <summary>
/// Gets a value indicating whether the script is enabled.
/// </summary>
public bool IsEnabled { get; set; } = true;
/// <summary>
/// Initializes a new instance of the <see cref="SkinScript"/> class.
/// </summary>
/// <param name="scriptContent">The Lua script content.</param>
/// <param name="scriptName">The name of the script (usually the filename).</param>
/// <param name="host">The host interface for the script.</param>
public SkinScript(string scriptContent, string scriptName, ISkinScriptHost host)
{
this.ScriptName = scriptName;
this.host = host;
// Configure MoonSharp for maximum safety
luaScript = new Script(CoreModules.Preset_SoftSandbox);
// Register our host API with MoonSharp
var scriptInterface = new SkinScriptInterface(host);
UserData.RegisterType<SkinScriptInterface>();
luaScript.Globals["osu"] = scriptInterface; try
{
// Execute the script
luaScript.DoString(scriptContent);
// Extract script description if available
if (luaScript.Globals.Get("SCRIPT_DESCRIPTION").Type != DataType.Nil)
Description = luaScript.Globals.Get("SCRIPT_DESCRIPTION").String;
else
Description = "No description provided";
// Call onLoad function if it exists
if (luaScript.Globals.Get("onLoad").Type != DataType.Nil)
{
try
{
luaScript.Call(luaScript.Globals.Get("onLoad"));
}
catch (Exception ex)
{
host.Log($"Error in {ScriptName}.onLoad: {ex.Message}", LogLevel.Error);
}
}
}
catch (Exception ex)
{
host.Log($"Error loading script {ScriptName}: {ex.Message}", LogLevel.Error);
}
} /// <summary>
/// Creates a new skin script from a file.
/// </summary>
/// <param name="filePath">The path to the Lua script file.</param>
/// <param name="host">The host interface for the script.</param>
/// <returns>A new instance of the <see cref="SkinScript"/> class.</returns>
public static SkinScript FromFile(string filePath, ISkinScriptHost host)
{
string scriptContent = File.ReadAllText(filePath);
string scriptName = Path.GetFileName(filePath);
return new SkinScript(scriptContent, scriptName, host);
} /// <summary>
/// Notifies the script that a component has been loaded.
/// </summary>
/// <param name="component">The loaded component.</param>
public void NotifyComponentLoaded(Drawable component)
{
if (!IsEnabled)
return;
try
{
DynValue result = luaScript.Call(luaScript.Globals.Get("onComponentLoaded"), component);
}
catch (Exception ex)
{
host.Log($"Error in {ScriptName}.onComponentLoaded: {ex.Message}", LogLevel.Error);
}
}
/// <summary>
/// Notifies the script of a game event.
/// </summary>
/// <param name="eventName">The name of the event.</param>
/// <param name="data">The event data.</param>
public void NotifyGameEvent(string eventName, object data)
{
if (!IsEnabled)
return;
try
{
DynValue result = luaScript.Call(luaScript.Globals.Get("onGameEvent"), eventName, data);
}
catch (Exception ex)
{
host.Log($"Error in {ScriptName}.onGameEvent: {ex.Message}", LogLevel.Error);
}
} /// <summary>
/// Notifies the script of a judgement result.
/// </summary>
/// <param name="result">The judgement result.</param>
public void NotifyJudgement(JudgementResult result)
{
if (!IsEnabled)
return;
try
{
DynValue dynResult = luaScript.Call(luaScript.Globals.Get("onJudgement"), result);
}
catch (Exception ex)
{
host.Log($"Error in {ScriptName}.onJudgement: {ex.Message}", LogLevel.Error);
}
}
/// <summary>
/// Notifies the script of an input event.
/// </summary>
/// <param name="inputEvent">The input event.</param>
public void NotifyInputEvent(InputEvent inputEvent)
{
if (!IsEnabled)
return;
try
{
DynValue result = luaScript.Call(luaScript.Globals.Get("onInputEvent"), inputEvent);
}
catch (Exception ex)
{
host.Log($"Error in {ScriptName}.onInputEvent: {ex.Message}", LogLevel.Error);
}
}
/// <summary>
/// Updates the script.
/// </summary>
public void Update()
{
if (!IsEnabled)
return;
try
{
DynValue result = luaScript.Call(luaScript.Globals.Get("update"));
}
catch (Exception ex)
{
host.Log($"Error in {ScriptName}.update: {ex.Message}", LogLevel.Error);
}
}
/// <summary>
/// Releases all resources used by the script.
/// </summary>
public void Dispose()
{
// Release any resources held by the script
luaScript.Globals.Clear();
}
}
}

View File

@@ -0,0 +1,139 @@
using System;
using System.Collections.Generic;
using MoonSharp.Interpreter;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Textures;
namespace osu.Game.Skinning.Scripting
{
/// <summary>
/// Provides an interface for Lua scripts to interact with the game.
/// </summary>
[MoonSharpUserData]
public class SkinScriptInterface
{
private readonly ISkinScriptHost host;
private readonly Dictionary<string, object> eventHandlers = new Dictionary<string, object>();
/// <summary>
/// Initializes a new instance of the <see cref="SkinScriptInterface"/> class.
/// </summary>
/// <param name="host">The host interface for the script.</param>
public SkinScriptInterface(ISkinScriptHost host)
{
this.host = host;
}
/// <summary>
/// Gets the beatmap's title.
/// </summary>
/// <returns>The beatmap's title.</returns>
[MoonSharpVisible(true)]
public string GetBeatmapTitle()
{
return host.CurrentBeatmap?.Metadata?.Title ?? "Unknown";
}
/// <summary>
/// Gets the beatmap's artist.
/// </summary>
/// <returns>The beatmap's artist.</returns>
[MoonSharpVisible(true)]
public string GetBeatmapArtist()
{
return host.CurrentBeatmap?.Metadata?.Artist ?? "Unknown";
}
/// <summary>
/// Gets the current ruleset's name.
/// </summary>
/// <returns>The ruleset's name.</returns>
[MoonSharpVisible(true)]
public string GetRulesetName()
{
return host.CurrentRuleset?.Name ?? "Unknown";
}
/// <summary>
/// Creates a new component of the specified type.
/// </summary>
/// <param name="componentType">The component type name.</param>
/// <returns>The created component.</returns>
[MoonSharpVisible(true)]
public object CreateComponent(string componentType)
{
return host.CreateComponent(componentType);
}
/// <summary>
/// Gets a texture from the current skin.
/// </summary>
/// <param name="name">The name of the texture.</param>
/// <returns>The texture, or null if not found.</returns>
[MoonSharpVisible(true)]
public object GetTexture(string name)
{
return host.GetTexture(name);
}
/// <summary>
/// Gets a sample from the current skin.
/// </summary>
/// <param name="name">The name of the sample.</param>
/// <returns>The sample, or null if not found.</returns>
[MoonSharpVisible(true)]
public object GetSample(string name)
{
return host.GetSample(name);
}
/// <summary>
/// Plays a sample.
/// </summary>
/// <param name="name">The name of the sample.</param>
[MoonSharpVisible(true)]
public void PlaySample(string name)
{
var sample = host.GetSample(name);
sample?.Play();
}
/// <summary>
/// Subscribes to a game event.
/// </summary>
/// <param name="eventName">The name of the event to subscribe to.</param>
[MoonSharpVisible(true)]
public void SubscribeToEvent(string eventName)
{
host.SubscribeToEvent(eventName);
}
/// <summary>
/// Logs a message to the osu! log.
/// </summary>
/// <param name="message">The message to log.</param>
/// <param name="level">The log level (debug, info, warning, error).</param>
[MoonSharpVisible(true)]
public void Log(string message, string level = "info")
{
LogLevel logLevel = LogLevel.Information;
switch (level.ToLower())
{
case "debug":
logLevel = LogLevel.Debug;
break;
case "warning":
logLevel = LogLevel.Warning;
break;
case "error":
logLevel = LogLevel.Error;
break;
}
host.Log(message, logLevel);
}
}
}

View File

@@ -0,0 +1,301 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Textures;
using osu.Framework.Input.Events;
using osu.Framework.Platform;
using osu.Game.Beatmaps;
using osu.Game.Rulesets;
using osu.Game.Scoring;
namespace osu.Game.Skinning.Scripting
{
/// <summary>
/// Manages skin scripts for the current skin.
/// </summary>
[Cached]
public class SkinScriptManager : Component, ISkinScriptHost
{
private readonly List<SkinScript> activeScripts = new List<SkinScript>();
[Resolved]
private AudioManager audioManager { get; set; }
[Resolved]
private SkinManager skinManager { get; set; }
[Resolved]
private IBindable<WorkingBeatmap> beatmap { get; set; }
[Resolved]
private IBindable<RulesetInfo> ruleset { get; set; }
[Resolved]
private Storage storage { get; set; }
private SkinScriptingConfig scriptingConfig;
private Bindable<bool> scriptingEnabled;
private BindableList<string> allowedScripts;
private BindableList<string> blockedScripts; /// <summary>
/// Gets the current beatmap.
/// </summary>
public IBeatmap CurrentBeatmap => beatmap.Value?.Beatmap;
/// <summary>
/// Gets the audio manager for sound playback.
/// </summary>
public IAudioManager AudioManager => audioManager;
/// <summary>
/// Gets the current skin.
/// </summary>
public ISkin CurrentSkin => skinManager.CurrentSkin.Value;
/// <summary>
/// Gets the current ruleset info.
/// </summary>
public IRulesetInfo CurrentRuleset => ruleset.Value;
[BackgroundDependencyLoader]
private void load()
{
// Initialize scripting configuration
scriptingConfig = new SkinScriptingConfig(storage);
scriptingEnabled = scriptingConfig.GetBindable<bool>(SkinScriptingSettings.ScriptingEnabled);
allowedScripts = scriptingConfig.GetBindable<List<string>>(SkinScriptingSettings.AllowedScripts).GetBoundCopy();
blockedScripts = scriptingConfig.GetBindable<List<string>>(SkinScriptingSettings.BlockedScripts).GetBoundCopy();
}
protected override void LoadComplete()
{
base.LoadComplete();
// Subscribe to skin changes
skinManager.CurrentSkinInfo.BindValueChanged(skinChanged);
// Subscribe to scripting configuration changes
scriptingEnabled.BindValueChanged(_ => updateScriptStates(), true);
allowedScripts.BindCollectionChanged((_, __) => updateScriptStates(), true);
blockedScripts.BindCollectionChanged((_, __) => updateScriptStates(), true);
}
private void updateScriptStates()
{
if (!scriptingEnabled.Value)
{
// Disable all scripts when scripting is disabled
foreach (var script in activeScripts)
script.IsEnabled = false;
return;
}
foreach (var script in activeScripts)
{
string scriptName = script.ScriptName;
if (blockedScripts.Contains(scriptName))
script.IsEnabled = false;
else if (allowedScripts.Count > 0)
script.IsEnabled = allowedScripts.Contains(scriptName);
else
script.IsEnabled = true;
}
}
private void skinChanged(ValueChangedEvent<SkinInfo> skin)
{
// Clear existing scripts
foreach (var script in activeScripts)
script.Dispose();
activeScripts.Clear();
if (scriptingEnabled.Value)
{
// Load scripts from the new skin
loadScriptsFromSkin(skinManager.CurrentSkin.Value);
}
} private void loadScriptsFromSkin(ISkin skin)
{
if (skin is Skin skinWithFiles)
{
// Look for Lua script files
foreach (var file in skinWithFiles.Files.Where(f => Path.GetExtension(f.Filename).Equals(".lua", StringComparison.OrdinalIgnoreCase)))
{
try
{
using (Stream stream = skinWithFiles.GetStream(file.Filename))
using (StreamReader reader = new StreamReader(stream))
{
string scriptContent = reader.ReadToEnd();
SkinScript script = new SkinScript(scriptContent, file.Filename, this);
// 设置脚本的启用状态
string scriptName = file.Filename;
if (blockedScripts.Contains(scriptName))
script.IsEnabled = false;
else if (allowedScripts.Count > 0)
script.IsEnabled = allowedScripts.Contains(scriptName);
else
script.IsEnabled = true;
activeScripts.Add(script);
Log($"Loaded skin script: {file.Filename}", LogLevel.Information);
}
}
catch (Exception ex)
{
Log($"Failed to load skin script {file.Filename}: {ex.Message}", LogLevel.Error);
}
}
}
}
/// <summary>
/// Notifies scripts that a component has been loaded.
/// </summary>
/// <param name="component">The loaded component.</param>
public void NotifyComponentLoaded(Drawable component)
{
foreach (var script in activeScripts)
script.NotifyComponentLoaded(component);
}
/// <summary>
/// Notifies scripts of a game event.
/// </summary>
/// <param name="eventName">The name of the event.</param>
/// <param name="data">The event data.</param>
public void NotifyGameEvent(string eventName, object data)
{
foreach (var script in activeScripts)
script.NotifyGameEvent(eventName, data);
}
/// <summary>
/// Notifies scripts of a judgement result.
/// </summary>
/// <param name="result">The judgement result.</param>
public void NotifyJudgement(JudgementResult result)
{
foreach (var script in activeScripts)
script.NotifyJudgement(result);
}
/// <summary>
/// Notifies scripts of an input event.
/// </summary>
/// <param name="inputEvent">The input event.</param>
public void NotifyInputEvent(InputEvent inputEvent)
{
foreach (var script in activeScripts)
script.NotifyInputEvent(inputEvent);
}
/// <summary>
/// Updates all scripts.
/// </summary>
protected override void Update()
{
base.Update();
foreach (var script in activeScripts)
script.Update();
}
#region ISkinScriptHost Implementation
/// <summary>
/// Creates a new drawable component of the specified type.
/// </summary>
/// <param name="componentType">The type of component to create.</param>
/// <returns>The created component.</returns>
public Drawable CreateComponent(string componentType)
{
// This would need to be expanded with actual component types
switch (componentType)
{
case "Container":
return new Container();
// Add more component types as needed
default:
Log($"Unknown component type: {componentType}", LogLevel.Warning);
return new Container();
}
}
/// <summary>
/// Gets a texture from the current skin.
/// </summary>
/// <param name="name">The name of the texture.</param>
/// <returns>The texture, or null if not found.</returns>
public Texture GetTexture(string name)
{
return skinManager.CurrentSkin.Value.GetTexture(name);
}
/// <summary>
/// Gets a sample from the current skin.
/// </summary>
/// <param name="name">The name of the sample.</param>
/// <returns>The sample, or null if not found.</returns>
public ISample GetSample(string name)
{
return skinManager.CurrentSkin.Value.GetSample(name);
}
/// <summary>
/// Subscribe to a game event.
/// </summary>
/// <param name="eventName">The name of the event to subscribe to.</param>
public void SubscribeToEvent(string eventName)
{
// Implementation would depend on available events
Log($"Script subscribed to event: {eventName}", LogLevel.Debug);
}
/// <summary>
/// Log a message to the osu! log.
/// </summary>
/// <param name="message">The message to log.</param>
/// <param name="level">The log level.</param>
public void Log(string message, LogLevel level = LogLevel.Information)
{
switch (level)
{
case LogLevel.Debug:
Logger.Log(message, level: LogLevel.Debug);
break;
case LogLevel.Information:
Logger.Log(message);
break;
case LogLevel.Warning:
Logger.Log(message, level: Framework.Logging.LogLevel.Important);
break;
case LogLevel.Error:
Logger.Error(message);
break;
}
}
#endregion
protected override void Dispose(bool isDisposing)
{
foreach (var script in activeScripts)
script.Dispose();
activeScripts.Clear();
base.Dispose(isDisposing);
}
}
}

View File

@@ -0,0 +1,35 @@
using System;
using System.Collections.Generic;
using osu.Framework.Bindables;
using osu.Game.Configuration;
namespace osu.Game.Skinning.Scripting
{
public class SkinScriptingConfig : IniConfigManager<SkinScriptingSettings>
{
public SkinScriptingConfig(Storage storage) : base(storage)
{
}
protected override void InitialiseDefaults()
{
base.InitialiseDefaults();
Set(SkinScriptingSettings.ScriptingEnabled, true);
Set(SkinScriptingSettings.AllowedScripts, new List<string>());
Set(SkinScriptingSettings.BlockedScripts, new List<string>());
}
}
public enum SkinScriptingSettings
{
// 全局启用/禁用脚本功能
ScriptingEnabled,
// 允许的脚本列表
AllowedScripts,
// 禁止的脚本列表
BlockedScripts
}
}

View File

@@ -0,0 +1,25 @@
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Game.Overlays;
using osu.Game.Overlays.Settings;
using osu.Game.Overlays.Settings.Sections;
using osu.Game.Skinning.Scripting.Overlays;
namespace osu.Game.Skinning.Scripting
{
/// <summary>
/// 负责注册皮肤脚本设置到设置界面。
/// </summary>
public class SkinScriptingOverlayRegistration : Component
{
[Resolved]
private SettingsOverlay settingsOverlay { get; set; }
[BackgroundDependencyLoader]
private void load()
{
// 皮肤脚本设置部分已经在SkinSection中集成无需额外操作
}
}
}

View File

@@ -0,0 +1,460 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
using Newtonsoft.Json;
using osu.Framework.Audio.Sample;
using osu.Framework.Bindables;
using osu.Framework.Extensions.TypeExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Textures;
using osu.Framework.IO.Stores;
using osu.Framework.Logging;
using osu.Game.Audio;
using osu.Game.Database;
using osu.Game.IO;
using osu.Game.Rulesets;
using osu.Game.Screens.Play.HUD;
using osu.Game.Skinning.Scripting;
namespace osu.Game.Skinning
{
public abstract class Skin : IDisposable, ISkin
{
private readonly IStorageResourceProvider? resources;
/// <summary>
/// A texture store which can be used to perform user file lookups for this skin.
/// </summary>
protected TextureStore? Textures { get; }
/// <summary>
/// A sample store which can be used to perform user file lookups for this skin.
/// </summary>
protected ISampleStore? Samples { get; }
public readonly Live<SkinInfo> SkinInfo;
public SkinConfiguration Configuration { get; set; }
public IDictionary<GlobalSkinnableContainers, SkinLayoutInfo> LayoutInfos => layoutInfos;
private readonly Dictionary<GlobalSkinnableContainers, SkinLayoutInfo> layoutInfos =
new Dictionary<GlobalSkinnableContainers, SkinLayoutInfo>();
/// <summary>
/// The list of loaded scripts for this skin.
/// </summary>
public List<SkinScript> Scripts { get; private set; } = new List<SkinScript>();
public abstract ISample? GetSample(ISampleInfo sampleInfo);
public Texture? GetTexture(string componentName) => GetTexture(componentName, default, default);
public abstract Texture? GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT);
public abstract IBindable<TValue>? GetConfig<TLookup, TValue>(TLookup lookup)
where TLookup : notnull
where TValue : notnull;
private readonly ResourceStore<byte[]> store = new ResourceStore<byte[]>();
public string Name { get; }
/// <summary>
/// Construct a new skin.
/// </summary>
/// <param name="skin">The skin's metadata. Usually a live realm object.</param>
/// <param name="resources">Access to game-wide resources.</param>
/// <param name="fallbackStore">An optional fallback store which will be used for file lookups that are not serviced by realm user storage.</param>
/// <param name="configurationFilename">An optional filename to read the skin configuration from. If not provided, the configuration will be retrieved from the storage using "skin.ini".</param>
protected Skin(SkinInfo skin, IStorageResourceProvider? resources, IResourceStore<byte[]>? fallbackStore = null, string configurationFilename = @"skin.ini")
{
this.resources = resources;
Name = skin.Name;
if (resources != null)
{
SkinInfo = skin.ToLive(resources.RealmAccess);
store.AddStore(new RealmBackedResourceStore<SkinInfo>(SkinInfo, resources.Files, resources.RealmAccess));
var samples = resources.AudioManager?.GetSampleStore(store);
if (samples != null)
{
samples.PlaybackConcurrency = OsuGameBase.SAMPLE_CONCURRENCY;
// osu-stable performs audio lookups in order of wav -> mp3 -> ogg.
// The GetSampleStore() call above internally adds wav and mp3, so ogg is added at the end to ensure expected ordering.
samples.AddExtension(@"ogg");
}
Samples = samples;
Textures = new TextureStore(resources.Renderer, CreateTextureLoaderStore(resources, store));
}
else
{
// Generally only used for tests.
SkinInfo = skin.ToLiveUnmanaged();
}
if (fallbackStore != null)
store.AddStore(fallbackStore);
var configurationStream = store.GetStream(configurationFilename);
if (configurationStream != null)
{
// stream will be closed after use by LineBufferedReader.
ParseConfigurationStream(configurationStream);
Debug.Assert(Configuration != null);
}
else
{
Configuration = new SkinConfiguration
{
// generally won't be hit as we always write a `skin.ini` on import, but best be safe than sorry.
// see https://github.com/peppy/osu-stable-reference/blob/1531237b63392e82c003c712faa028406073aa8f/osu!/Graphics/Skinning/SkinManager.cs#L297-L298
LegacyVersion = SkinConfiguration.LATEST_VERSION,
};
}
// skininfo files may be null for default skin.
foreach (GlobalSkinnableContainers skinnableTarget in Enum.GetValues<GlobalSkinnableContainers>())
{
string filename = $"{skinnableTarget}.json";
byte[]? bytes = store?.Get(filename);
if (bytes == null)
continue;
try
{
string jsonContent = Encoding.UTF8.GetString(bytes);
var layoutInfo = parseLayoutInfo(jsonContent, skinnableTarget);
if (layoutInfo == null)
continue;
LayoutInfos[skinnableTarget] = layoutInfo;
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to load skin configuration.");
}
}
}
/// <summary>
/// Called when skin resources have been loaded.
/// This is the place to load any script files.
/// </summary>
protected virtual void LoadComplete()
{
// Load skin scripts if any
LoadScripts();
}
/// <summary>
/// Loads any script files associated with this skin.
/// </summary>
protected virtual void LoadScripts()
{
if (!(resources?.RealmAccess?.Realm.Find<SkinScriptManager>() is SkinScriptManager scriptManager))
return;
foreach (var file in GetScriptFiles())
{
try
{
using (Stream stream = GetStream(file))
using (StreamReader reader = new StreamReader(stream))
{
string scriptContent = reader.ReadToEnd();
SkinScript script = new SkinScript(scriptContent, file, scriptManager);
Scripts.Add(script);
Logger.Log($"Loaded skin script: {file}", LogLevel.Information);
}
}
catch (Exception ex)
{
Logger.Error(ex, $"Failed to load skin script {file}");
}
}
}
/// <summary>
/// Gets a list of script files in the skin.
/// </summary>
/// <returns>A list of script file names.</returns>
protected virtual IEnumerable<string> GetScriptFiles()
{
return new string[0];
}
/// <summary>
/// Gets a stream for the specified file.
/// </summary>
/// <param name="filename">The name of the file.</param>
/// <returns>The stream, or null if the file was not found.</returns>
protected virtual Stream GetStream(string filename)
{
return store.GetStream(filename);
}
protected virtual IResourceStore<TextureUpload> CreateTextureLoaderStore(IStorageResourceProvider resources, IResourceStore<byte[]> storage)
=> new MaxDimensionLimitedTextureLoaderStore(resources.CreateTextureLoaderStore(storage));
protected virtual void ParseConfigurationStream(Stream stream)
{
using (LineBufferedReader reader = new LineBufferedReader(stream, true))
Configuration = new LegacySkinDecoder().Decode(reader);
}
/// <summary>
/// Remove all stored customisations for the provided target.
/// </summary>
/// <param name="targetContainer">The target container to reset.</param>
public void ResetDrawableTarget(SkinnableContainer targetContainer)
{
LayoutInfos.Remove(targetContainer.Lookup.Lookup);
}
/// <summary>
/// Update serialised information for the provided target.
/// </summary>
/// <param name="targetContainer">The target container to serialise to this skin.</param>
public void UpdateDrawableTarget(SkinnableContainer targetContainer)
{
if (!LayoutInfos.TryGetValue(targetContainer.Lookup.Lookup, out var layoutInfo))
layoutInfos[targetContainer.Lookup.Lookup] = layoutInfo = new SkinLayoutInfo();
layoutInfo.Update(targetContainer.Lookup.Ruleset, ((ISerialisableDrawableContainer)targetContainer).CreateSerialisedInfo().ToArray());
}
public virtual Drawable? GetDrawableComponent(ISkinComponentLookup lookup)
{
switch (lookup)
{
// This fallback is important for user skins which use SkinnableSprites.
case SkinnableSprite.SpriteComponentLookup sprite:
return this.GetAnimation(sprite.LookupName, false, false, maxSize: sprite.MaxSize);
case UserSkinComponentLookup userLookup:
switch (userLookup.Component)
{
case GlobalSkinnableContainerLookup containerLookup:
// It is important to return null if the user has not configured this yet.
// This allows skin transformers the opportunity to provide default components.
if (!LayoutInfos.TryGetValue(containerLookup.Lookup, out var layoutInfo)) return null;
if (!layoutInfo.TryGetDrawableInfo(containerLookup.Ruleset, out var drawableInfos)) return null;
return new Container
{
RelativeSizeAxes = Axes.Both,
ChildrenEnumerable = drawableInfos.Select(i => i.CreateInstance())
};
}
break;
}
return null;
}
#region Deserialisation & Migration
private SkinLayoutInfo? parseLayoutInfo(string jsonContent, GlobalSkinnableContainers target)
{
SkinLayoutInfo? layout = null;
// handle namespace changes...
jsonContent = jsonContent.Replace(@"osu.Game.Screens.Play.SongProgress", @"osu.Game.Screens.Play.HUD.DefaultSongProgress");
jsonContent = jsonContent.Replace(@"osu.Game.Screens.Play.HUD.LegacyComboCounter", @"osu.Game.Skinning.LegacyComboCounter");
jsonContent = jsonContent.Replace(@"osu.Game.Skinning.LegacyComboCounter", @"osu.Game.Skinning.LegacyDefaultComboCounter");
jsonContent = jsonContent.Replace(@"osu.Game.Screens.Play.HUD.PerformancePointsCounter", @"osu.Game.Skinning.Triangles.TrianglesPerformancePointsCounter");
try
{
// First attempt to deserialise using the new SkinLayoutInfo format
layout = JsonConvert.DeserializeObject<SkinLayoutInfo>(jsonContent);
}
catch
{
}
// If deserialisation using SkinLayoutInfo fails, attempt to deserialise using the old naked list.
if (layout == null)
{
var deserializedContent = JsonConvert.DeserializeObject<IEnumerable<SerialisedDrawableInfo>>(jsonContent);
if (deserializedContent == null)
return null;
layout = new SkinLayoutInfo { Version = 0 };
layout.Update(null, deserializedContent.ToArray());
Logger.Log($"Ferrying {deserializedContent.Count()} components in {target} to global section of new {nameof(SkinLayoutInfo)} format");
}
for (int i = layout.Version + 1; i <= SkinLayoutInfo.LATEST_VERSION; i++)
applyMigration(layout, target, i);
layout.Version = SkinLayoutInfo.LATEST_VERSION;
foreach (var kvp in layout.DrawableInfo.ToArray())
{
foreach (var di in kvp.Value)
{
if (!isValidDrawable(di))
layout.DrawableInfo[kvp.Key] = kvp.Value.Where(i => i.Type != di.Type).ToArray();
}
}
return layout;
}
private bool isValidDrawable(SerialisedDrawableInfo di)
{
if (!typeof(ISerialisableDrawable).IsAssignableFrom(di.Type))
return false;
foreach (var child in di.Children)
{
if (!isValidDrawable(child))
return false;
}
return true;
}
private void applyMigration(SkinLayoutInfo layout, GlobalSkinnableContainers target, int version)
{
switch (version)
{
case 1:
{
// Combo counters were moved out of the global HUD components into per-ruleset.
// This is to allow some rulesets to customise further (ie. mania and catch moving the combo to within their play area).
if (target != GlobalSkinnableContainers.MainHUDComponents ||
!layout.TryGetDrawableInfo(null, out var globalHUDComponents) ||
resources == null)
break;
var comboCounters = globalHUDComponents.Where(c =>
c.Type.Name == nameof(LegacyDefaultComboCounter) ||
c.Type.Name == nameof(DefaultComboCounter) ||
c.Type.Name == nameof(ArgonComboCounter)).ToArray();
layout.Update(null, globalHUDComponents.Except(comboCounters).ToArray());
resources.RealmAccess.Run(r =>
{
foreach (var ruleset in r.All<RulesetInfo>())
{
layout.Update(ruleset, layout.TryGetDrawableInfo(ruleset, out var rulesetHUDComponents)
? rulesetHUDComponents.Concat(comboCounters).ToArray()
: comboCounters);
}
});
break;
}
}
}
#endregion
#region Disposal
~Skin()
{
// required to potentially clean up sample store from audio hierarchy.
Dispose(false);
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
private bool isDisposed;
protected virtual void Dispose(bool isDisposing)
{
if (isDisposed)
return;
isDisposed = true;
foreach (var script in Scripts)
script.Dispose();
Scripts.Clear();
Textures?.Dispose();
Samples?.Dispose();
store.Dispose();
}
#endregion
public override string ToString() => $"{GetType().ReadableName()} {{ Name: {Name} }}";
private static readonly ThreadLocal<int> nested_level = new ThreadLocal<int>(() => 0);
[Conditional("SKIN_LOOKUP_DEBUG")]
internal static void LogLookupDebug(object callingClass, object lookup, LookupDebugType type, [CallerMemberName] string callerMethod = "")
{
string icon = string.Empty;
int level = nested_level.Value;
switch (type)
{
case LookupDebugType.Hit:
icon = "🟢 hit";
break;
case LookupDebugType.Miss:
icon = "🔴 miss";
break;
case LookupDebugType.Enter:
nested_level.Value++;
break;
case LookupDebugType.Exit:
nested_level.Value--;
if (nested_level.Value == 0)
Logger.Log(string.Empty);
return;
}
string lookupString = lookup.ToString() ?? string.Empty;
string callingClassString = callingClass.ToString() ?? string.Empty;
Logger.Log($"{string.Join(null, Enumerable.Repeat("|-", level))}{callingClassString}.{callerMethod}(lookup: {lookupString}) {icon}");
}
internal enum LookupDebugType
{
Hit,
Miss,
Enter,
Exit
}
}
}

View File

@@ -0,0 +1,504 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;
using JetBrains.Annotations;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Rendering;
using osu.Framework.Graphics.Textures;
using osu.Framework.IO.Stores;
using osu.Framework.Platform;
using osu.Framework.Threading;
using osu.Framework.Utils;
using osu.Game.Audio;
using osu.Game.Database;
using osu.Game.IO;
using osu.Game.Overlays.Notifications;
using osu.Game.Skinning.Scripting;
using osu.Game.Utils;
namespace osu.Game.Skinning
{
/// <summary>
/// Handles the storage and retrieval of <see cref="Skin"/>s.
/// </summary>
/// <remarks>
/// This is also exposed and cached as <see cref="ISkinSource"/> to allow for any component to potentially have skinning support.
/// For gameplay components, see <see cref="RulesetSkinProvidingContainer"/> which adds extra legacy and toggle logic that may affect the lookup process.
/// </remarks>
public class SkinManager : ModelManager<SkinInfo>, ISkinSource, IStorageResourceProvider, IModelImporter<SkinInfo>
{
/// <summary>
/// The default "classic" skin.
/// </summary>
public Skin DefaultClassicSkin { get; }
private readonly AudioManager audio;
private readonly Scheduler scheduler;
private readonly GameHost host;
private readonly IResourceStore<byte[]> resources;
public readonly Bindable<Skin> CurrentSkin = new Bindable<Skin>();
public readonly Bindable<Live<SkinInfo>> CurrentSkinInfo = new Bindable<Live<SkinInfo>>(ArgonSkin.CreateInfo().ToLiveUnmanaged());
private readonly SkinImporter skinImporter;
private readonly LegacySkinExporter skinExporter;
private readonly IResourceStore<byte[]> userFiles;
private Skin argonSkin { get; }
private Skin trianglesSkin { get; }
private SkinScriptManager scriptManager;
public override bool PauseImports
{
get => base.PauseImports;
set
{
base.PauseImports = value;
skinImporter.PauseImports = value;
}
}
public SkinManager(Storage storage, RealmAccess realm, GameHost host, IResourceStore<byte[]> resources, AudioManager audio, Scheduler scheduler)
: base(storage, realm)
{
this.audio = audio;
this.scheduler = scheduler;
this.host = host;
this.resources = resources;
userFiles = new StorageBackedResourceStore(storage.GetStorageForDirectory("files"));
skinImporter = new SkinImporter(storage, realm, this)
{
PostNotification = obj => PostNotification?.Invoke(obj),
};
var defaultSkins = new[]
{
DefaultClassicSkin = new DefaultLegacySkin(this),
trianglesSkin = new TrianglesSkin(this),
argonSkin = new ArgonSkin(this),
new ArgonProSkin(this),
new Ez2Skin(this),
new SbISkin(this),
};
skinExporter = new LegacySkinExporter(storage)
{
PostNotification = obj => PostNotification?.Invoke(obj)
};
// Initialize the script manager
scriptManager = new SkinScriptManager();
realm.RegisterCustomObject(scriptManager);
CurrentSkinInfo.ValueChanged += skin =>
{
CurrentSkin.Value = getSkin(skin.NewValue);
CurrentSkinInfoChanged?.Invoke();
};
try
{
// Start with non-user skins to ensure they are present.
foreach (var skin in defaultSkins)
{
if (skin.SkinInfo.ID != SkinInfo.ARGON_SKIN && skin.SkinInfo.ID != SkinInfo.TRIANGLES_SKIN)
continue;
// if the user has a modified copy of the default, use it instead.
var existingSkin = realm.Run(r => r.All<SkinInfo>().FirstOrDefault(s => s.ID == skin.SkinInfo.ID));
if (existingSkin != null)
continue;
realm.Write(r =>
{
skin.SkinInfo.Protected = true;
r.Add(new SkinInfo
{
ID = skin.SkinInfo.ID,
Name = skin.SkinInfo.Name,
Creator = skin.SkinInfo.Creator,
Protected = true,
InstantiationInfo = skin.SkinInfo.InstantiationInfo
});
});
}
}
catch
{
// May fail due to incomplete or breaking migrations.
}
}
/// <summary>
/// Returns a list of all usable <see cref="SkinInfo"/>s. Includes the special default and random skins.
/// </summary>
/// <returns>A list of available <see cref="SkinInfo"/>s.</returns>
public List<Live<SkinInfo>> GetAllUsableSkins()
{
return Realm.Run(r =>
{
// First display all skins.
var instances = r.All<SkinInfo>()
.Where(s => !s.DeletePending)
.OrderBy(s => s.Protected)
.ThenBy(s => s.Name)
.ToList();
// Then add all default skin entries.
var defaultSkins = r.All<SkinInfo>()
.Where(s => s.ID == SkinInfo.ARGON_SKIN || s.ID == SkinInfo.TRIANGLES_SKIN)
.ToList();
foreach (var s in defaultSkins)
instances.Insert(instances.FindIndex(s2 => s2.Protected) + defaultSkins.IndexOf(s), s);
return instances.Distinct().Select(s => r.Find<SkinInfo>(s.ID)?.ToLive(Realm.Realm)).Where(s => s != null).ToList();
});
}
public event Action CurrentSkinInfoChanged;
public Skin CurrentSkinInfo { get; private set; }
public void RefreshCurrentSkin() => CurrentSkinInfo.TriggerChange();
private Skin getSkin(Live<SkinInfo> skinInfo)
{
if (skinInfo == null)
return null;
Skin skin;
try
{
switch (skinInfo.ID)
{
case SkinInfo.ARGON_SKIN:
skin = argonSkin;
break;
case SkinInfo.TRIANGLES_SKIN:
skin = trianglesSkin;
break;
default:
skin = skinInfo.CreateInstance(this);
break;
}
}
catch (Exception ex)
{
Logger.Error(ex, $"Unable to load skin \"{skinInfo.ToString()}\"");
return DefaultClassicSkin;
}
return skin;
}
public Drawable GetDrawableComponent(ISkinComponentLookup lookup) => GetDrawableComponent(CurrentSkin.Value, lookup);
public Drawable GetDrawableComponent(ISkin skin, ISkinComponentLookup lookup)
{
return skin?.GetDrawableComponent(lookup);
}
public ISample GetSample(ISampleInfo sampleInfo) => GetSample(CurrentSkin.Value, sampleInfo);
public ISample GetSample(ISkin skin, ISampleInfo sampleInfo)
{
IEnumerable<Skin.LookupDebugType> lookupDebug = null;
if (DebugDisplay.Value || (lookupDebug ??= GetIpcData<List<Skin.LookupDebugType>>("Debug:SkinLookupTypes"))?.Contains(Skin.LookupDebugType.None) != true)
Skin.LogLookupDebug(this, sampleInfo, Skin.LookupDebugType.Enter);
try
{
var sample = skin?.GetSample(sampleInfo);
if (sample != null)
return sample;
foreach (var skSource in AllSources)
{
sample = skSource.GetSample(sampleInfo);
if (sample != null)
return sample;
}
return null;
}
finally
{
Skin.LogLookupDebug(this, sampleInfo, Skin.LookupDebugType.Exit);
}
}
public Texture GetTexture(string componentName) => GetTexture(CurrentSkin.Value, componentName);
public Texture GetTexture(ISkin skin, string componentName)
{
Skin.LogLookupDebug(this, componentName, Skin.LookupDebugType.Enter);
try
{
var texture = skin?.GetTexture(componentName);
if (texture != null)
return texture;
foreach (var skSource in AllSources)
{
texture = skSource.GetTexture(componentName);
if (texture != null)
return texture;
}
return null;
}
finally
{
Skin.LogLookupDebug(this, componentName, Skin.LookupDebugType.Exit);
}
}
public IBindable<TValue> GetConfig<TLookup, TValue>(TLookup lookup) => GetConfig<TLookup, TValue>(CurrentSkin.Value, lookup);
public IBindable<TValue> GetConfig<TLookup, TValue>(ISkin skin, TLookup lookup)
where TLookup : notnull
where TValue : notnull
{
Skin.LogLookupDebug(this, lookup, Skin.LookupDebugType.Enter);
try
{
var bindable = skin?.GetConfig<TLookup, TValue>(lookup);
if (bindable != null)
return bindable;
foreach (var source in AllSources)
{
bindable = source.GetConfig<TLookup, TValue>(lookup);
if (bindable != null)
return bindable;
}
return null;
}
finally
{
Skin.LogLookupDebug(this, lookup, Skin.LookupDebugType.Exit);
}
}
public IEnumerable<ISkin> AllSources
{
get
{
yield return DefaultClassicSkin;
}
}
public IEnumerable<TValue> GetAllConfigs<TLookup, TValue>(TLookup lookup)
where TLookup : notnull
where TValue : notnull
{
var sources = new List<ISkin>();
var items = new List<TValue>();
addFromSource(CurrentSkin.Value, this);
// This is not sane.
foreach (var s in AllSources)
addFromSource(s, default);
return items;
void addFromSource(ISkin source, object lookupFunction)
{
if (source == null) return;
if (sources.Contains(source)) return;
sources.Add(source);
if (lookupFunction != null)
Skin.LogLookupDebug(this, lookupFunction, Skin.LookupDebugType.Enter);
try
{
// check for direct value
if (source.GetConfig<TLookup, TValue>(lookup)?.Value is TValue val)
items.Add(val);
}
finally
{
if (lookupFunction != null)
Skin.LogLookupDebug(this, lookupFunction, Skin.LookupDebugType.Exit);
}
}
}
public IBindable<TValue> FindConfig<TLookup, TValue>(params Func<ISkin, IBindable<TValue>>[] lookups)
where TValue : notnull
{
Skin.LogLookupDebug(this, lookups, Skin.LookupDebugType.Enter);
try
{
return FindConfig(CurrentSkin.Value, lookups) ?? FindConfig(AllSources, lookups);
}
finally
{
Skin.LogLookupDebug(this, lookups, Skin.LookupDebugType.Exit);
}
}
public IBindable<TValue> FindConfig<TLookup, TValue>(ISkin skin, params Func<ISkin, IBindable<TValue>>[] lookups)
where TValue : notnull
{
Skin.LogLookupDebug(this, lookups, Skin.LookupDebugType.Enter);
try
{
if (skin == null)
return null;
foreach (var l in lookups)
{
var bindable = l(skin);
if (bindable != null)
return bindable;
}
return null;
}
finally
{
Skin.LogLookupDebug(this, lookups, Skin.LookupDebugType.Exit);
}
}
public IBindable<TValue> FindConfig<TLookup, TValue>(IEnumerable<ISkin> allSources, params Func<ISkin, IBindable<TValue>>[] lookups)
where TValue : notnull
{
Skin.LogLookupDebug(this, lookups, Skin.LookupDebugType.Enter);
try
{
foreach (var source in allSources)
{
var bindable = FindConfig(source, lookups);
if (bindable != null)
return bindable;
}
return null;
}
finally
{
Skin.LogLookupDebug(this, lookups, Skin.LookupDebugType.Exit);
}
}
#region IResourceStorageProvider
IRenderer IStorageResourceProvider.Renderer => host.Renderer;
AudioManager IStorageResourceProvider.AudioManager => audio;
IResourceStore<byte[]> IStorageResourceProvider.Resources => resources;
IResourceStore<byte[]> IStorageResourceProvider.Files => userFiles;
RealmAccess IStorageResourceProvider.RealmAccess => Realm;
IResourceStore<TextureUpload> IStorageResourceProvider.CreateTextureLoaderStore(IResourceStore<byte[]> underlyingStore) => host.CreateTextureLoaderStore(underlyingStore);
#endregion
#region Implementation of IModelImporter<SkinInfo>
public Action<IEnumerable<Live<SkinInfo>>> PresentImport
{
set => skinImporter.PresentImport = value;
}
public Task Import(params string[] paths) => skinImporter.Import(paths);
public Task Import(ImportTask[] imports, ImportParameters parameters = default) => skinImporter.Import(imports, parameters);
public IEnumerable<string> HandledExtensions => skinImporter.HandledExtensions;
public Task<IEnumerable<Live<SkinInfo>>> Import(ProgressNotification notification, ImportTask[] tasks, ImportParameters parameters = default) =>
skinImporter.Import(notification, tasks, parameters);
public Task<Live<SkinInfo>> ImportAsUpdate(ProgressNotification notification, ImportTask task, SkinInfo original) =>
skinImporter.ImportAsUpdate(notification, task, original);
public Task<ExternalEditOperation<SkinInfo>> BeginExternalEditing(SkinInfo model) => skinImporter.BeginExternalEditing(model);
public Task<Live<SkinInfo>> Import(ImportTask task, ImportParameters parameters = default, CancellationToken cancellationToken = default) =>
skinImporter.Import(task, parameters, cancellationToken);
public Task ExportCurrentSkin() => ExportSkin(CurrentSkinInfo.Value);
public Task ExportSkin(Live<SkinInfo> skin) => skinExporter.ExportAsync(skin);
#endregion
public void Delete([CanBeNull] Expression<Func<SkinInfo, bool>> filter = null, bool silent = false)
{
Realm.Run(r =>
{
var items = r.All<SkinInfo>()
.Where(s => !s.Protected && !s.DeletePending);
if (filter != null)
items = items.Where(filter);
// check the removed skin is not the current user choice. if it is, switch back to default.
Guid currentUserSkin = CurrentSkinInfo.Value.ID;
if (items.Any(s => s.ID == currentUserSkin))
scheduler.Add(() => CurrentSkinInfo.Value = ArgonSkin.CreateInfo().ToLiveUnmanaged());
Delete(items.ToList(), silent);
});
}
public void SetSkinFromConfiguration(string guidString)
{
Live<SkinInfo> skinInfo = null;
if (Guid.TryParse(guidString, out var guid))
skinInfo = Query(s => s.ID == guid);
if (skinInfo == null)
{
if (guid == SkinInfo.CLASSIC_SKIN)
skinInfo = DefaultClassicSkin.SkinInfo;
}
CurrentSkinInfo.Value = skinInfo ?? trianglesSkin.SkinInfo;
}
}
}

View File

@@ -0,0 +1,157 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Allocation;
using osu.Framework.Caching;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Animations;
using osu.Game.Skinning.Scripting;
using osuTK;
namespace osu.Game.Skinning
{
/// <summary>
/// A drawable which can be skinned via an <see cref="ISkinSource"/>.
/// </summary>
public partial class SkinnableDrawable : SkinReloadableDrawable
{
/// <summary>
/// The displayed component.
/// </summary>
public Drawable Drawable { get; private set; } = null!;
/// <summary>
/// Whether the drawable component should be centered in available space.
/// Defaults to true.
/// </summary>
public bool CentreComponent = true;
public new Axes AutoSizeAxes
{
get => base.AutoSizeAxes;
set => base.AutoSizeAxes = value;
}
protected readonly ISkinComponentLookup ComponentLookup;
private readonly ConfineMode confineMode;
[Resolved(CanBeNull = true)]
private SkinScriptManager scriptManager { get; set; }
/// <summary>
/// Create a new skinnable drawable.
/// </summary>
/// <param name="lookup">The namespace-complete resource name for this skinnable element.</param>
/// <param name="defaultImplementation">A function to create the default skin implementation of this element.</param>
/// <param name="confineMode">How (if at all) the <see cref="Drawable"/> should be resize to fit within our own bounds.</param>
public SkinnableDrawable(ISkinComponentLookup lookup, Func<ISkinComponentLookup, Drawable>? defaultImplementation = null, ConfineMode confineMode = ConfineMode.NoScaling)
: this(lookup, confineMode)
{
createDefault = defaultImplementation;
}
protected SkinnableDrawable(ISkinComponentLookup lookup, ConfineMode confineMode = ConfineMode.NoScaling)
{
ComponentLookup = lookup;
this.confineMode = confineMode;
RelativeSizeAxes = Axes.Both;
}
/// <summary>
/// Seeks to the 0-th frame if the content of this <see cref="SkinnableDrawable"/> is an <see cref="IFramedAnimation"/>.
/// </summary>
public void ResetAnimation() => (Drawable as IFramedAnimation)?.GotoFrame(0);
private readonly Func<ISkinComponentLookup, Drawable>? createDefault;
private readonly Cached scaling = new Cached();
private bool isDefault;
protected virtual Drawable CreateDefault(ISkinComponentLookup lookup) => createDefault?.Invoke(lookup) ?? Empty();
/// <summary>
/// Whether to apply size restrictions (specified via <see cref="confineMode"/>) to the default implementation.
/// </summary>
protected virtual bool ApplySizeRestrictionsToDefault => false;
protected override void SkinChanged(ISkinSource skin)
{
var retrieved = skin.GetDrawableComponent(ComponentLookup);
if (retrieved == null)
{
Drawable = CreateDefault(ComponentLookup);
isDefault = true;
}
else
{
Drawable = retrieved;
isDefault = false;
}
scaling.Invalidate();
if (CentreComponent)
{
Drawable.Origin = Anchor.Centre;
Drawable.Anchor = Anchor.Centre;
}
InternalChild = Drawable;
}
protected override void LoadComplete()
{
base.LoadComplete();
// Notify scripts that a component has been loaded
NotifyScriptsOfComponentLoad();
}
private void NotifyScriptsOfComponentLoad()
{
// Notify the script manager about the component being loaded
scriptManager?.NotifyComponentLoaded(Drawable);
}
protected override void Update()
{
base.Update();
if (!scaling.IsValid)
{
try
{
if (isDefault && !ApplySizeRestrictionsToDefault) return;
switch (confineMode)
{
case ConfineMode.ScaleToFit:
Drawable.RelativeSizeAxes = Axes.Both;
Drawable.Size = Vector2.One;
Drawable.Scale = Vector2.One;
Drawable.FillMode = FillMode.Fit;
break;
}
}
finally
{
scaling.Validate();
}
}
}
}
public enum ConfineMode
{
/// <summary>
/// Don't apply any scaling. This allows the user element to be of any size, exceeding specified bounds.
/// </summary>
NoScaling,
ScaleToFit,
}
}

32
appveyor.yml Normal file
View File

@@ -0,0 +1,32 @@
clone_depth: 1
version: '{branch}-{build}'
image: Visual Studio 2022
cache:
- '%LOCALAPPDATA%\NuGet\v3-cache -> appveyor.yml'
dotnet_csproj:
patch: true
file: 'osu.Game\osu.Game.csproj' # Use wildcard when it's able to exclude Xamarin projects
version: '0.0.{build}'
before_build:
- cmd: dotnet --info # Useful when version mismatch between CI and local
- cmd: dotnet workload install maui-android # Change to `dotnet workload restore` once there's no old projects
- cmd: dotnet workload install maui-ios # Change to `dotnet workload restore` once there's no old projects
- cmd: nuget restore -verbosity quiet # Only nuget.exe knows both new (.NET Core) and old (Xamarin) projects
build:
project: osu.sln
parallel: true
verbosity: minimal
publish_nuget: true
after_build:
- ps: .\InspectCode.ps1
test:
assemblies:
except:
- '**\*Android*'
- '**\*iOS*'
- 'build\**\*'

86
appveyor_deploy.yml Normal file
View File

@@ -0,0 +1,86 @@
clone_depth: 1
version: '{build}'
image: Visual Studio 2022
test: off
skip_non_tags: true
configuration: Release
environment:
matrix:
- job_name: osu-game
- job_name: osu-ruleset
job_depends_on: osu-game
- job_name: taiko-ruleset
job_depends_on: osu-game
- job_name: catch-ruleset
job_depends_on: osu-game
- job_name: mania-ruleset
job_depends_on: osu-game
- job_name: templates
job_depends_on: osu-game
nuget:
project_feed: true
for:
-
matrix:
only:
- job_name: osu-game
build_script:
- cmd: dotnet pack osu.Game\osu.Game.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME%
-
matrix:
only:
- job_name: osu-ruleset
build_script:
- cmd: dotnet remove osu.Game.Rulesets.Osu\osu.Game.Rulesets.Osu.csproj reference osu.Game\osu.Game.csproj
- cmd: dotnet add osu.Game.Rulesets.Osu\osu.Game.Rulesets.Osu.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME%
- cmd: dotnet pack osu.Game.Rulesets.Osu\osu.Game.Rulesets.Osu.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME%
-
matrix:
only:
- job_name: taiko-ruleset
build_script:
- cmd: dotnet remove osu.Game.Rulesets.Taiko\osu.Game.Rulesets.Taiko.csproj reference osu.Game\osu.Game.csproj
- cmd: dotnet add osu.Game.Rulesets.Taiko\osu.Game.Rulesets.Taiko.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME%
- cmd: dotnet pack osu.Game.Rulesets.Taiko\osu.Game.Rulesets.Taiko.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME%
-
matrix:
only:
- job_name: catch-ruleset
build_script:
- cmd: dotnet remove osu.Game.Rulesets.Catch\osu.Game.Rulesets.Catch.csproj reference osu.Game\osu.Game.csproj
- cmd: dotnet add osu.Game.Rulesets.Catch\osu.Game.Rulesets.Catch.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME%
- cmd: dotnet pack osu.Game.Rulesets.Catch\osu.Game.Rulesets.Catch.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME%
-
matrix:
only:
- job_name: mania-ruleset
build_script:
- cmd: dotnet remove osu.Game.Rulesets.Mania\osu.Game.Rulesets.Mania.csproj reference osu.Game\osu.Game.csproj
- cmd: dotnet add osu.Game.Rulesets.Mania\osu.Game.Rulesets.Mania.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME%
- cmd: dotnet pack osu.Game.Rulesets.Mania\osu.Game.Rulesets.Mania.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME%
-
matrix:
only:
- job_name: templates
build_script:
- cmd: dotnet remove Templates\Rulesets\ruleset-empty\osu.Game.Rulesets.EmptyFreeform\osu.Game.Rulesets.EmptyFreeform.csproj reference osu.Game\osu.Game.csproj
- cmd: dotnet remove Templates\Rulesets\ruleset-example\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj reference osu.Game\osu.Game.csproj
- cmd: dotnet remove Templates\Rulesets\ruleset-scrolling-empty\osu.Game.Rulesets.EmptyScrolling\osu.Game.Rulesets.EmptyScrolling.csproj reference osu.Game\osu.Game.csproj
- cmd: dotnet remove Templates\Rulesets\ruleset-scrolling-example\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj reference osu.Game\osu.Game.csproj
- cmd: dotnet add Templates\Rulesets\ruleset-empty\osu.Game.Rulesets.EmptyFreeform\osu.Game.Rulesets.EmptyFreeform.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME%
- cmd: dotnet add Templates\Rulesets\ruleset-example\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME%
- cmd: dotnet add Templates\Rulesets\ruleset-scrolling-empty\osu.Game.Rulesets.EmptyScrolling\osu.Game.Rulesets.EmptyScrolling.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME%
- cmd: dotnet add Templates\Rulesets\ruleset-scrolling-example\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME%
- cmd: dotnet pack Templates\osu.Game.Templates.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME%
artifacts:
- path: '**\*.nupkg'
deploy:
- provider: Environment
name: nuget

View File

@@ -1,19 +1,17 @@
{
{
"solution": {
"path": "osu.sln",
"projects": [
"..\\osu-framework\\osu.Framework\\osu.Framework.csproj",
"..\\osu-resources\\osu.Game.Resources\\osu.Game.Resources.csproj",
"osu.Desktop\\osu.Desktop.csproj",
"osu.Game.Benchmarks\\osu.Game.Benchmarks.csproj",
"osu.Game.Rulesets.Catch.Tests\\osu.Game.Rulesets.Catch.Tests.csproj",
"osu.Game.Rulesets.Catch\\osu.Game.Rulesets.Catch.csproj",
"osu.Game.Rulesets.Mania.Tests\\osu.Game.Rulesets.Mania.Tests.csproj",
"osu.Game.Rulesets.Mania\\osu.Game.Rulesets.Mania.csproj",
"osu.Game.Rulesets.Osu.Tests\\osu.Game.Rulesets.Osu.Tests.csproj",
"osu.Game.Rulesets.Osu\\osu.Game.Rulesets.Osu.csproj",
"osu.Game.Rulesets.Taiko.Tests\\osu.Game.Rulesets.Taiko.Tests.csproj",
"osu.Game.Rulesets.Taiko\\osu.Game.Rulesets.Taiko.csproj",
"osu.Game.Tests\\osu.Game.Tests.csproj",
"osu.Game.Tournament.Tests\\osu.Game.Tournament.Tests.csproj",
"osu.Game.Tournament\\osu.Game.Tournament.csproj",
"osu.Game\\osu.Game.csproj",
"Templates\\Rulesets\\ruleset-empty\\osu.Game.Rulesets.EmptyFreeform.Tests\\osu.Game.Rulesets.EmptyFreeform.Tests.csproj",

View File

@@ -0,0 +1,66 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Runtime.Versioning;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Input.Events;
using osu.Framework.Platform;
using osuTK.Input;
using osu.Game.Screens.Play;
using osu.Game.LAsEzExtensions.Configuration;
namespace osu.Desktop.EzMacOS
{
[SupportedOSPlatform("macos")]
public partial class GameplaySpotlightBlocker : Drawable
{
private Bindable<bool> disableCmdSpace = null!;
private IBindable<LocalUserPlayingState> localUserPlaying = null!;
private IBindable<bool> isActive = null!;
[Resolved]
private GameHost host { get; set; } = null!;
[BackgroundDependencyLoader]
private void load(ILocalUserPlayInfo localUserInfo, Ez2ConfigManager ezConfig)
{
RelativeSizeAxes = Axes.Both;
AlwaysPresent = true;
localUserPlaying = localUserInfo.PlayingState.GetBoundCopy();
localUserPlaying.BindValueChanged(_ => updateBlocking());
isActive = host.IsActive.GetBoundCopy();
isActive.BindValueChanged(_ => updateBlocking());
disableCmdSpace = ezConfig.GetBindable<bool>(Ez2Setting.GameplayDisableCmdSpace);
disableCmdSpace.BindValueChanged(_ => updateBlocking(), true);
}
private void updateBlocking()
{
// Block during active gameplay, including breaks; only allow when NotPlaying.
bool shouldDisable = isActive.Value && disableCmdSpace.Value && localUserPlaying.Value != LocalUserPlayingState.NotPlaying;
if (shouldDisable)
host.InputThread.Scheduler.Add(SpotlightKey.Disable);
else
host.InputThread.Scheduler.Add(SpotlightKey.Enable);
}
public override bool HandleNonPositionalInput => true;
protected override bool OnKeyDown(KeyDownEvent e)
{
// As a fallback for "read input" only, swallow Space when Command is held
// to avoid triggering in-game actions while Spotlight is opening.
bool shouldDisable = isActive.Value && disableCmdSpace.Value && localUserPlaying.Value != LocalUserPlayingState.NotPlaying;
if (shouldDisable && e.Key == Key.Space && e.SuperPressed)
return true; // handled: don't propagate Space to game
return base.OnKeyDown(e);
}
}
}

View File

@@ -0,0 +1,184 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using osu.Framework.Logging;
namespace osu.Desktop.EzMacOS
{
[SupportedOSPlatform("macos")]
public static class SpotlightKey
{
private static IntPtr eventTap;
private static IntPtr runLoopSource;
private static IntPtr runLoopMode;
private static bool isDisabled;
private static EventTapCallback? callbackDelegate;
private const int kVK_Space = 49;
private const ulong kCGEventFlagMaskCommand = 1UL << 20;
public static void Disable()
{
if (isDisabled)
return;
try
{
Logger.Log("Attempting to disable Cmd+Space (Spotlight) via Accessibility tap...", LoggingTarget.Runtime, LogLevel.Debug);
ulong mask = CGEventMaskBit(CGEventType.KeyDown);
callbackDelegate = OnEventTap;
eventTap = CGEventTapCreate(
kCGHIDEventTap,
kCGHeadInsertEventTap,
kCGEventTapOptionDefault,
mask,
callbackDelegate,
IntPtr.Zero);
if (eventTap == IntPtr.Zero)
{
Logger.Log("Failed to create event tap. Ensure Accessibility permission is granted (System Settings → Privacy & Security → Accessibility).", LoggingTarget.Runtime, LogLevel.Error);
return;
}
runLoopSource = CFMachPortCreateRunLoopSource(IntPtr.Zero, eventTap, 0);
IntPtr runLoop = CFRunLoopGetMain();
runLoopMode = CFStringCreateWithCString(IntPtr.Zero, "kCFRunLoopDefaultMode", kCFStringEncodingUTF8);
CFRunLoopAddSource(runLoop, runLoopSource, runLoopMode);
CGEventTapEnable(eventTap, true);
isDisabled = true;
Logger.Log("Cmd+Space (Spotlight) blocking enabled during gameplay.", LoggingTarget.Runtime, LogLevel.Verbose);
}
catch (Exception ex)
{
Logger.Log($"Failed to disable Cmd+Space: {ex.Message}\n{ex.StackTrace}", LoggingTarget.Runtime, LogLevel.Error);
}
}
public static void Enable()
{
if (!isDisabled)
return;
try
{
Logger.Log("Re-enabling Cmd+Space (Spotlight)...", LoggingTarget.Runtime, LogLevel.Debug);
if (eventTap != IntPtr.Zero)
{
CGEventTapEnable(eventTap, false);
CFMachPortInvalidate(eventTap);
}
if (runLoopSource != IntPtr.Zero)
{
IntPtr runLoop = CFRunLoopGetMain();
CFRunLoopRemoveSource(runLoop, runLoopSource, runLoopMode);
CFRelease(runLoopSource);
}
if (runLoopMode != IntPtr.Zero)
CFRelease(runLoopMode);
eventTap = IntPtr.Zero;
runLoopSource = IntPtr.Zero;
runLoopMode = IntPtr.Zero;
callbackDelegate = null;
isDisabled = false;
Logger.Log("Cmd+Space (Spotlight) blocking disabled.", LoggingTarget.Runtime, LogLevel.Verbose);
}
catch (Exception ex)
{
Logger.Log($"Failed to enable Cmd+Space: {ex.Message}\n{ex.StackTrace}", LoggingTarget.Runtime, LogLevel.Error);
}
}
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate IntPtr EventTapCallback(IntPtr proxy, CGEventType type, IntPtr @event, IntPtr userInfo);
private static IntPtr OnEventTap(IntPtr proxy, CGEventType type, IntPtr @event, IntPtr userInfo)
{
try
{
if (type == CGEventType.KeyDown)
{
ulong flags = CGEventGetFlags(@event);
long keyCode = CGEventGetIntegerValueField(@event, kCGKeyboardEventKeycode);
if ((flags & kCGEventFlagMaskCommand) != 0 && keyCode == kVK_Space)
{
Logger.Log("Blocked Cmd+Space via Accessibility tap", LoggingTarget.Runtime, LogLevel.Debug);
return IntPtr.Zero; // swallow event
}
}
}
catch (Exception ex)
{
Logger.Log($"Error in event tap: {ex.Message}", LoggingTarget.Runtime, LogLevel.Error);
}
return @event;
}
private static ulong CGEventMaskBit(CGEventType type) => 1UL << (int)type;
private const int kCGHIDEventTap = 0; // HID system-wide events
private const int kCGHeadInsertEventTap = 0; // Insert at head
private const int kCGEventTapOptionDefault = 0; // Listen-only/active
private const int kCGKeyboardEventKeycode = 9; // Field for keycode
private const uint kCFStringEncodingUTF8 = 0x08000100;
#region Native Methods
[DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")]
private static extern IntPtr CGEventTapCreate(int tap, int place, int options, ulong eventsOfInterest, EventTapCallback callback, IntPtr userInfo);
[DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")]
private static extern void CGEventTapEnable(IntPtr tap, bool enable);
[DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")]
private static extern ulong CGEventGetFlags(IntPtr eventRef);
[DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")]
private static extern long CGEventGetIntegerValueField(IntPtr eventRef, int field);
[DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")]
private static extern void CFMachPortInvalidate(IntPtr port);
[DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")]
private static extern IntPtr CFMachPortCreateRunLoopSource(IntPtr allocator, IntPtr port, Int32 order);
[DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")]
private static extern IntPtr CFRunLoopGetMain();
[DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")]
private static extern void CFRunLoopAddSource(IntPtr rl, IntPtr source, IntPtr mode);
[DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")]
private static extern void CFRunLoopRemoveSource(IntPtr rl, IntPtr source, IntPtr mode);
[DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")]
private static extern IntPtr CFStringCreateWithCString(IntPtr alloc, string cStr, uint encoding);
[DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")]
private static extern void CFRelease(IntPtr cf);
#endregion
private enum CGEventType
{
KeyDown = 10,
KeyUp = 11
}
}
}

View File

@@ -16,6 +16,7 @@ using osu.Framework;
using osu.Framework.Logging;
using osu.Game.Updater;
using osu.Desktop.Windows;
using osu.Desktop.EzMacOS;
using osu.Framework.Allocation;
using osu.Game.Configuration;
using osu.Game.IO;
@@ -136,6 +137,9 @@ namespace osu.Desktop
if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows)
LoadComponentAsync(new GameplayWinKeyBlocker(), Add);
if (OperatingSystem.IsMacOS())
LoadComponentAsync(new GameplaySpotlightBlocker(), Add);
LoadComponentAsync(new ElevatedPrivilegesChecker(), Add);
osuSchemeLinkIPCChannel = new OsuSchemeLinkIPCChannel(Host, this);

View File

@@ -21,9 +21,9 @@ namespace osu.Desktop
public static class Program
{
#if DEBUG
private const string base_game_name = @"osu-development";
private const string base_game_name = @"osu-Ez2Lazer-development";
#else
private const string base_game_name = @"osu";
private const string base_game_name = @"osu-Ez2Lazer";
#endif
private static LegacyTcpIpcProvider? legacyIpc;

View File

@@ -1,7 +1,10 @@
{
"profiles": {
"osu! Desktop": {
"commandName": "Project"
"commandName": "Project",
"environmentVariables": {
"OSU_SDL3": "1"
}
},
"osu! Tournament": {
"commandName": "Project",

View File

@@ -4,10 +4,10 @@
<OutputType>WinExe</OutputType>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Description>A free-to-win rhythm game. Rhythm is just a *click* away!</Description>
<AssemblyName>osu!</AssemblyName>
<AssemblyTitle>osu!(lazer)</AssemblyTitle>
<Title>osu!</Title>
<Product>osu!(lazer)</Product>
<AssemblyName>Ez2osu!</AssemblyName>
<AssemblyTitle>osu!(Ez2lazer)</AssemblyTitle>
<Title>Ez2lazer!</Title>
<Product>osu!(Ez2lazer)</Product>
<ApplicationIcon>lazer.ico</ApplicationIcon>
<Version>0.0.0</Version>
<FileVersion>0.0.0</FileVersion>

View File

@@ -0,0 +1,87 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using System;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.Objects;
namespace osu.Game.Rulesets.Mania.Tests.Analysis
{
public static class HalfHoldBeatmapSets
{
public static IBeatmap[] CreateTen(int columns = 4, int notesPerColumn = 32)
{
// use fixed seeds to produce deterministic but different beatmaps
return Enumerable.Range(0, 10).Select(i => HalfHoldBeatmapFactory.Create(columns, notesPerColumn, 1000 + i)).ToArray();
}
}
public static class HalfHoldBeatmapFactory
{
/// <summary>
/// Create a Mania beatmap with the specified number of columns and notes per column.
/// Approximately half of the hit objects will be long notes (HoldNote) and half simple notes.
/// </summary>
public static IBeatmap Create(int columns = 4, int notesPerColumn = 32, int seed = 1234)
{
var beatmap = new ManiaBeatmap(new StageDefinition(columns))
{
BeatmapInfo = new BeatmapInfo
{
Ruleset = new ManiaRuleset().RulesetInfo,
Difficulty = new BeatmapDifficulty
{
DrainRate = 6,
OverallDifficulty = 6,
ApproachRate = 6,
CircleSize = columns
}
},
ControlPointInfo = new ControlPointInfo()
};
var rnd = new Random(seed);
for (int col = 0; col < columns; col++)
{
for (int i = 0; i < notesPerColumn; i++)
{
// spread notes in time with a base spacing and random jitter
double baseTime = col * 2000 + i * 300;
double jitter = rnd.NextDouble() * 200 - 100; // ±100ms
double startTime = baseTime + jitter;
// randomly decide whether this object is a hold note, target ~50%
bool isHold = (i % 2 == 0);
if (isHold)
{
var hold = new HoldNote
{
StartTime = startTime,
Column = col,
Duration = 200 + rnd.Next(0, 800) // 200-1000ms
};
beatmap.HitObjects.Add(hold);
}
else
{
var note = new Note
{
StartTime = startTime,
Column = col
};
beatmap.HitObjects.Add(note);
}
}
}
return beatmap;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,259 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Formats;
using osu.Game.IO;
using osu.Game.Graphics.Sprites;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.LAsEZMania.Analysis;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Objects;
using osu.Game.Tests.Visual;
using osuTK;
namespace osu.Game.Rulesets.Mania.Tests.Analysis
{
public partial class TestSceneSRTune : OsuTestScene
{
private FillFlowContainer listContainer = null!;
private Dictionary<string, double> last = new Dictionary<string, double>();
private Dictionary<string, double> current = new Dictionary<string, double>();
// exposed tuning parameters (initialized from Tunables defaults)
private double ln_weight = SRCalculatorTunable.Tunables.FinalLNToNotesFactor; // 0.0 - 1.0
private double ln_len_cap = SRCalculatorTunable.Tunables.FinalLNLenCap; // ms, 100 - 2000
private double totalnotes_offset = SRCalculatorTunable.Tunables.TotalNotesOffset; // 0 - 500
private double pbar_ln_coeff = SRCalculatorTunable.Tunables.PBarLnMultiplier; // 0.0 - 0.02
private double jack_multiplier = SRCalculatorTunable.Tunables.JackPenaltyMultiplier; // 10 - 40
private double final_scale = SRCalculatorTunable.Tunables.FinalScale; // 0.9 - 1.05
private IBeatmap[] sampleBeatmaps = Array.Empty<IBeatmap>();
// storage for last/two-setting snapshots
private (double lnWeight, double lnLenCap, double offset, double pbarCoeff, double jackMult, double scale) lastSettings;
public TestSceneSRTune()
{
Child = new FillFlowContainer
{
RelativeSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Spacing = new Vector2(6f),
Padding = new MarginPadding(10),
Children = new[]
{
// control row is exposed as test steps rather than placed into the scene hierarchy
listContainer = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
Direction = FillDirection.Vertical,
Spacing = new Vector2(4f),
AutoSizeAxes = Axes.Y
}
}
};
loadSampleBeatmaps();
// register test steps for sliders and button controls (appear in the test steps UI)
AddSliderStep("LN Weight", 0, 1, SRCalculatorTunable.Tunables.FinalLNToNotesFactor, v => SRCalculatorTunable.Tunables.FinalLNToNotesFactor = v);
AddSliderStep("LN Len Cap", 100, 2000, SRCalculatorTunable.Tunables.FinalLNLenCap, v => SRCalculatorTunable.Tunables.FinalLNLenCap = v);
AddSliderStep("Offset", 0, 500, SRCalculatorTunable.Tunables.TotalNotesOffset, v => SRCalculatorTunable.Tunables.TotalNotesOffset = v);
AddSliderStep("pBar LN coeff", 0, 0.02, SRCalculatorTunable.Tunables.PBarLnMultiplier, v => SRCalculatorTunable.Tunables.PBarLnMultiplier = v);
AddSliderStep("Jack mult", 10, 40, SRCalculatorTunable.Tunables.JackPenaltyMultiplier, v => SRCalculatorTunable.Tunables.JackPenaltyMultiplier = v);
AddSliderStep("Final scale", 0.9, 1.05, SRCalculatorTunable.Tunables.FinalScale, v => SRCalculatorTunable.Tunables.FinalScale = v);
// save current settings to "last" snapshot
AddStep("Save as last settings", () =>
{
lastSettings = (SRCalculatorTunable.Tunables.FinalLNToNotesFactor, SRCalculatorTunable.Tunables.FinalLNLenCap, SRCalculatorTunable.Tunables.TotalNotesOffset, SRCalculatorTunable.Tunables.PBarLnMultiplier, SRCalculatorTunable.Tunables.JackPenaltyMultiplier, SRCalculatorTunable.Tunables.FinalScale);
// also snapshot last SR values from current
foreach (string k in current.Keys)
last[k] = current[k];
});
// update every 50ms
Scheduler.AddDelayed(updateAllSR, 50, true);
}
private IBeatmap[] createSyntheticSamples()
{
var list = new List<IBeatmap>();
for (int i = 0; i < 8; i++)
{
var bm = new ManiaBeatmap(new StageDefinition(4));
bm.BeatmapInfo = new BeatmapInfo { Metadata = new BeatmapMetadata { Title = $"Sample {i + 1}" } };
for (int t = 0; t < 2000; t += 250)
{
// alternate between simple notes and hold notes to exercise LN codepaths
if (i % 2 == 0)
{
var note = new Note { StartTime = t + i * 10, Column = i % 4 };
bm.HitObjects.Add(note);
}
else
{
var hold = new HoldNote { StartTime = t + i * 10, Column = i % 4 };
hold.Duration = 400 + i * 50;
bm.HitObjects.Add(hold);
}
}
list.Add(bm);
}
return list.ToArray();
}
private Drawable buildControlRow()
{
// Controls are exposed via test steps (AddSliderStep / AddStep) instead of being placed in the scene.
return new Container { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y };
}
private void loadSampleBeatmaps()
{
// try load canonical test beatmap resource first
try
{
string resourcePath = @"Resources/Testing/Beatmaps/4869637.osu";
// prefer a set of deterministic half-hold beatmaps for side-by-side tuning
sampleBeatmaps = HalfHoldBeatmapSets.CreateTen(4, 32);
}
catch
{
sampleBeatmaps = createSyntheticSamples();
}
foreach (var bm in sampleBeatmaps)
{
string id = ((BeatmapInfo)bm.BeatmapInfo).Metadata.Title!;
last[id] = 0;
current[id] = 0;
var row = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(10f),
Children = new Drawable[]
{
new OsuSpriteText { Text = id, Width = 180, Font = new FontUsage(size:20) },
new OsuSpriteText { Text = "Last: 0", Name = "last_" + id, Font = new FontUsage(size:20) },
new OsuSpriteText { Text = "Cur: 0", Name = "cur_" + id, Font = new FontUsage(size:20) },
// place holders for settings display under the SRs
new OsuSpriteText { Text = "Last settings: -", Name = "last_set_" + id, Font = new FontUsage(size:20) },
new OsuSpriteText { Text = "Cur settings: -", Name = "cur_set_" + id, Font = new FontUsage(size:20) }
}
};
listContainer.Add(row);
}
}
private void updateAllSR()
{
// read slider values
var sliders = this.ChildrenOfType<BasicSliderBar<double>>();
foreach (var s in sliders)
{
// decide by Width positions we set earlier
if (s.Width == 0.28f) ln_weight = s.Current.Value;
else if (s.Width == 0.2f) ln_len_cap = s.Current.Value;
else if (s.Width == 0.16f) totalnotes_offset = s.Current.Value;
else if (s.Width == 0.22f) pbar_ln_coeff = s.Current.Value;
else if (s.Width == 0.18f) jack_multiplier = s.Current.Value;
else if (s.Width == 0.12f) final_scale = s.Current.Value;
}
// propagate slider values into SRCalculatorTunable.Tunables
TunableSyncFromSliders();
for (int i = 0; i < sampleBeatmaps.Length; i++)
{
var bm = sampleBeatmaps[i];
string id = bm.BeatmapInfo.Metadata.Title;
double sr = SRCalculatorTunable.CalculateSR(bm);
current[id] = sr;
var row = listContainer.Children[i] as FillFlowContainer;
var curText = row?.Children[2] as SpriteText;
if (row?.Children[1] is SpriteText lastText) lastText.Text = $"Last: {last[id]:F2}";
if (curText != null) curText.Text = $"Cur: {sr:F2}";
// update settings display
var lastSetText = row?.Children[3] as SpriteText;
var curSetText = row?.Children[4] as SpriteText;
if (lastSetText != null)
lastSetText.Text = $"Last settings: ln_w={lastSettings.lnWeight:F3}, ln_cap={lastSettings.lnLenCap:F0}, off={lastSettings.offset:F0}, pbar={lastSettings.pbarCoeff:F4}, jack={lastSettings.jackMult:F1}, scale={lastSettings.scale:F3}";
if (curSetText != null)
curSetText.Text = $"Cur settings: ln_w={SRCalculatorTunable.Tunables.FinalLNToNotesFactor:F3}, ln_cap={SRCalculatorTunable.Tunables.FinalLNLenCap:F0}, off={SRCalculatorTunable.Tunables.TotalNotesOffset:F0}, pbar={SRCalculatorTunable.Tunables.PBarLnMultiplier:F4}, jack={SRCalculatorTunable.Tunables.JackPenaltyMultiplier:F1}, scale={SRCalculatorTunable.Tunables.FinalScale:F3}";
}
}
private void TunableSyncFromSliders()
{
var sliders = this.ChildrenOfType<BasicSliderBar<double>>();
foreach (var s in sliders)
{
if (s.Width == 0.28f) SRCalculatorTunable.Tunables.FinalLNToNotesFactor = s.Current.Value;
else if (s.Width == 0.2f) SRCalculatorTunable.Tunables.FinalLNLenCap = s.Current.Value;
else if (s.Width == 0.16f) SRCalculatorTunable.Tunables.TotalNotesOffset = s.Current.Value;
else if (s.Width == 0.22f) SRCalculatorTunable.Tunables.PBarLnMultiplier = s.Current.Value;
else if (s.Width == 0.18f) SRCalculatorTunable.Tunables.JackPenaltyMultiplier = s.Current.Value;
else if (s.Width == 0.12f) SRCalculatorTunable.Tunables.FinalScale = s.Current.Value;
}
}
// isolated compute method that uses exposed parameters
private double HotComputeSR(IBeatmap beatmap, double lnWeight, int lnLenCap, int offset, double pbarCoeff, double jackMult, double scale)
{
var mania = (ManiaBeatmap)beatmap;
int headCount = 0;
var lnLens = new List<int>();
foreach (var ho in mania.HitObjects)
{
headCount++;
int tail = (int)ho.GetEndTime();
int len = Math.Max(0, Math.Min(tail - (int)ho.StartTime, lnLenCap));
if (len > 0) lnLens.Add(len);
}
double baseDifficulty = headCount * 0.1;
double lnContribution = 0;
foreach (int len in lnLens)
{
// combine ln weight and pbar coefficient
lnContribution += lnWeight * (len / 200.0) * 0.5 * (1.0 + pbarCoeff * 100.0);
}
double totalNotes = headCount + lnContribution;
// simplistic tail-based penalty: more LN tails -> slightly reduce sr, scaled by jackMult
double tailPenalty = 1.0 + lnLens.Count * 0.01 * (jackMult / 35.0);
double sr = baseDifficulty * (totalNotes / (totalNotes + offset)) / tailPenalty;
sr *= scale;
return Math.Max(0, sr);
}
}
}

View File

@@ -0,0 +1,142 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mania.Mods.LAsMods;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Objects;
using osu.Game.Tests.Visual;
namespace osu.Game.Rulesets.Mania.Tests.Mods
{
public partial class TestSceneManiaModSpaceBody : ModTestScene
{
protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset();
[Test]
public void TestDefaultSettings()
{
var mod = new ManiaModSpaceBody();
CreateModTest(new ModTestData
{
Mod = mod,
CreateBeatmap = () => new Beatmap
{
BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo },
HitObjects = new List<HitObject>
{
new Note { StartTime = 1000, Column = 0 },
new Note { StartTime = 2000, Column = 0 },
new Note { StartTime = 3000, Column = 0 },
new Note { StartTime = 1000, Column = 1 },
new Note { StartTime = 2500, Column = 1 },
new Note { StartTime = 1000, Column = 2 },
new Note { StartTime = 3500, Column = 2 }
}
},
PassCondition = () => Player.DrawableRuleset?.Objects.Any() == true
});
}
[Test]
public void TestWithSmallSpaceBeat()
{
var mod = new ManiaModSpaceBody
{
SpaceBeat = { Value = 2.0 }
};
CreateModTest(new ModTestData
{
Mod = mod,
CreateBeatmap = () => new Beatmap
{
BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo },
HitObjects = new List<HitObject>
{
new Note { StartTime = 1000, Column = 0 },
new Note { StartTime = 2000, Column = 0 },
new Note { StartTime = 3000, Column = 0 }
}
},
PassCondition = () => Player.DrawableRuleset?.Objects.Any() == true
});
}
[Test]
public void TestWithLargeSpaceBeat()
{
var mod = new ManiaModSpaceBody
{
SpaceBeat = { Value = 8.0 }
};
CreateModTest(new ModTestData
{
Mod = mod,
CreateBeatmap = () => new Beatmap
{
BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo },
HitObjects = new List<HitObject>
{
new Note { StartTime = 1000, Column = 0 },
new Note { StartTime = 2000, Column = 0 },
new Note { StartTime = 3000, Column = 0 }
}
},
PassCondition = () => Player.DrawableRuleset?.Objects.Any() == true
});
}
[Test]
public void TestWithShieldEnabled()
{
var mod = new ManiaModSpaceBody
{
Shield = { Value = true }
};
CreateModTest(new ModTestData
{
Mod = mod,
CreateBeatmap = () => new Beatmap
{
BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo },
HitObjects = new List<HitObject>
{
new Note { StartTime = 1000, Column = 0 },
new Note { StartTime = 2000, Column = 0 },
new Note { StartTime = 3000, Column = 0 }
}
},
PassCondition = () => Player.DrawableRuleset?.Objects.Any() == true
});
}
[Test]
public void TestWithHoldNotes()
{
var mod = new ManiaModSpaceBody();
CreateModTest(new ModTestData
{
Mod = mod,
CreateBeatmap = () => new Beatmap
{
BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo },
HitObjects = new List<HitObject>
{
new Note { StartTime = 1000, Column = 0 },
new HoldNote { StartTime = 2000, Duration = 500, Column = 0 },
new Note { StartTime = 3000, Column = 0 }
}
},
PassCondition = () => Player.DrawableRuleset?.Objects.Any() == true
});
}
}
}

View File

@@ -0,0 +1,442 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.LAsEzMania.Analysis;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Scoring;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Tests.Visual;
using osuTK;
namespace osu.Game.Rulesets.Mania.Tests
{
[TestFixture]
public partial class TestSceneEzHitEventHeatmapGraph : OsuTestScene
{
private ScoreInfo testScore;
private IBeatmap testBeatmap;
private const int RANDOM_SEED = 1234; // Fixed seed for consistent random data
private const int COLUMNS = 7; // 7k
private const int NOTES_PER_COLUMN = 50; // 50 notes per column
[SetUp]
public void SetUp()
{
// Create a test beatmap: 7k with 50 notes per column (350 total)
testBeatmap = createTestBeatmap();
// Create a test score with specific hit event distribution
testScore = createTestScore(testBeatmap);
}
[Test]
public void TestEmptyScore()
{
EzManiaScoreGraph graph = null;
AddStep("Create graph with empty score", () =>
{
var emptyScore = new ScoreInfo
{
BeatmapInfo = testBeatmap.BeatmapInfo,
Ruleset = testBeatmap.BeatmapInfo.Ruleset,
HitEvents = new List<HitEvent>(),
Accuracy = 1.0,
TotalScore = 0,
Mods = Array.Empty<Mod>()
};
Child = graph = new EzManiaScoreGraph(emptyScore, testBeatmap)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(800, 400),
};
});
AddAssert("Graph created successfully", () => graph != null);
AddAssert("Graph is visible", () => graph.IsPresent);
}
[Test]
public void TestPerfectScore()
{
EzManiaScoreGraph graph = null;
AddStep("Create graph with perfect score", () =>
{
var perfectScore = createPerfectScore(testBeatmap);
Child = graph = new EzManiaScoreGraph(perfectScore, testBeatmap)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(800, 400),
};
});
AddAssert("Graph created successfully", () => graph != null);
AddAssert("Graph is visible", () => graph.IsPresent);
AddAssert("Graph has hit events", () => graph != null && graph.DrawWidth > 0);
}
[Test]
public void TestMixedResults()
{
EzManiaScoreGraph graph = null;
AddStep("Create graph with test score", () =>
{
Child = graph = new EzManiaScoreGraph(testScore, testBeatmap)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(800, 400),
};
});
AddAssert("Graph created successfully", () => graph != null);
AddAssert("Graph is visible", () => graph.IsPresent);
AddAssert("Graph height is reasonable", () => graph != null && graph.DrawHeight > 0);
}
/// <summary>
/// Creates a 7k beatmap with 50 notes per column using fixed seed randomization.
/// Total: 7 columns * 50 notes = 350 notes
/// </summary>
private IBeatmap createTestBeatmap()
{
var beatmap = new ManiaBeatmap(new StageDefinition(COLUMNS))
{
BeatmapInfo = new BeatmapInfo
{
Ruleset = new ManiaRuleset().RulesetInfo,
Difficulty = new BeatmapDifficulty
{
DrainRate = 8,
OverallDifficulty = 8,
ApproachRate = 8,
CircleSize = 4,
}
},
ControlPointInfo = new ControlPointInfo()
};
var random = new Random(RANDOM_SEED);
// Create 50 notes per column
for (int column = 0; column < COLUMNS; column++)
{
for (int i = 0; i < NOTES_PER_COLUMN; i++)
{
// Use fixed seed random for note timing within each column
double timeOffset = random.NextDouble() * 500; // Random offset between 0-500ms for each note
beatmap.HitObjects.Add(new Note
{
StartTime = column * 500 + i * 200 + timeOffset,
Column = column
});
}
}
return beatmap;
}
/// <summary>
/// Creates a test score with segmented symmetric normal distribution of timing offsets.
/// - [-40, 40]ms: 200 hits centered at 0ms (σ=20ms)
/// - [40, 100] and [-100, -40]ms: 50 hits centered at ±40ms (σ=30ms)
/// - [100, 150] and [-150, -100]ms: 20 hits centered at ±100ms (σ=25ms)
/// - [150, 200] and [-200, -150]ms: 10 hits centered at ±150ms (σ=25ms)
/// </summary>
private ScoreInfo createTestScore(IBeatmap beatmap)
{
var hitEvents = new List<HitEvent>();
var random = new Random(RANDOM_SEED);
// Initialize hit windows based on beatmap difficulty
var hitWindows = new ManiaHitWindows();
hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty);
// Collect all offsets with their results
var allOffsetsWithResults = new List<(double offset, HitResult result)>();
// Segment 1: [-40, 40]ms with 200 hits
// Normal distribution centered at 0ms with σ=20ms (symmetric around 0)
for (int i = 0; i < 200; i++)
{
double offset = GenerateNormalOffset(random, 0, 20);
// Clamp to [-40, 40] range
offset = Math.Max(-40, Math.Min(40, offset));
HitResult result = hitWindows.ResultFor(offset);
if (result == HitResult.None)
result = HitResult.Miss;
allOffsetsWithResults.Add((offset, result));
}
// Segment 2: [40, 100]ms and [-100, -40]ms with 50 hits
// Normal distribution centered at ±40ms with σ=30ms
for (int i = 0; i < 25; i++)
{
// Positive side [40, 100]
double offset = GenerateNormalOffset(random, 40, 30);
offset = Math.Max(40, Math.Min(100, offset));
HitResult result = hitWindows.ResultFor(offset);
if (result == HitResult.None)
result = HitResult.Miss;
allOffsetsWithResults.Add((offset, result));
}
for (int i = 0; i < 25; i++)
{
// Negative side [-100, -40]
double offset = GenerateNormalOffset(random, -40, 30);
offset = Math.Max(-100, Math.Min(-40, offset));
HitResult result = hitWindows.ResultFor(offset);
if (result == HitResult.None)
result = HitResult.Miss;
allOffsetsWithResults.Add((offset, result));
}
// Segment 3: [100, 150]ms and [-150, -100]ms with 20 hits
// Normal distribution centered at ±100ms with σ=25ms
for (int i = 0; i < 10; i++)
{
// Positive side [100, 150]
double offset = GenerateNormalOffset(random, 100, 25);
offset = Math.Max(100, Math.Min(150, offset));
HitResult result = hitWindows.ResultFor(offset);
if (result == HitResult.None)
result = HitResult.Miss;
allOffsetsWithResults.Add((offset, result));
}
for (int i = 0; i < 10; i++)
{
// Negative side [-150, -100]
double offset = GenerateNormalOffset(random, -100, 25);
offset = Math.Max(-150, Math.Min(-100, offset));
HitResult result = hitWindows.ResultFor(offset);
if (result == HitResult.None)
result = HitResult.Miss;
allOffsetsWithResults.Add((offset, result));
}
// Segment 4: [150, 200]ms and [-200, -150]ms with 10 hits
// Normal distribution centered at ±150ms with σ=25ms
for (int i = 0; i < 5; i++)
{
// Positive side [150, 200]
double offset = GenerateNormalOffset(random, 150, 25);
offset = Math.Max(150, Math.Min(200, offset));
HitResult result = hitWindows.ResultFor(offset);
if (result == HitResult.None)
result = HitResult.Miss;
allOffsetsWithResults.Add((offset, result));
}
for (int i = 0; i < 5; i++)
{
// Negative side [-200, -150]
double offset = GenerateNormalOffset(random, -150, 25);
offset = Math.Max(-200, Math.Min(-150, offset));
HitResult result = hitWindows.ResultFor(offset);
if (result == HitResult.None)
result = HitResult.Miss;
allOffsetsWithResults.Add((offset, result));
}
// Now we have 280 hit events, need 70 more to reach 350
// Fill remaining with random distribution across all segments
while (allOffsetsWithResults.Count < beatmap.HitObjects.Count)
{
int segment = random.Next(4);
double offset = 0;
bool isNegative = random.Next(2) == 0; // Randomly choose positive or negative side
switch (segment)
{
case 0: // [-40, 40]ms
offset = GenerateNormalOffset(random, 0, 20);
offset = Math.Max(-40, Math.Min(40, offset));
break;
case 1: // [±40, ±100]ms
offset = GenerateNormalOffset(random, isNegative ? -40 : 40, 30);
if (isNegative)
offset = Math.Max(-100, Math.Min(-40, offset));
else
offset = Math.Max(40, Math.Min(100, offset));
break;
case 2: // [±100, ±150]ms
offset = GenerateNormalOffset(random, isNegative ? -100 : 100, 25);
if (isNegative)
offset = Math.Max(-150, Math.Min(-100, offset));
else
offset = Math.Max(100, Math.Min(150, offset));
break;
case 3: // [±150, ±200]ms
offset = GenerateNormalOffset(random, isNegative ? -150 : 150, 25);
if (isNegative)
offset = Math.Max(-200, Math.Min(-150, offset));
else
offset = Math.Max(150, Math.Min(200, offset));
break;
}
HitResult result = hitWindows.ResultFor(offset);
if (result == HitResult.None)
result = HitResult.Miss;
allOffsetsWithResults.Add((offset, result));
}
// Trim to exact count
allOffsetsWithResults = allOffsetsWithResults.Take(beatmap.HitObjects.Count).ToList();
// Shuffle to randomize distribution while maintaining segments
allOffsetsWithResults = allOffsetsWithResults.OrderBy(_ => random.Next()).ToList();
// Create HitEvents
for (int i = 0; i < allOffsetsWithResults.Count && i < beatmap.HitObjects.Count; i++)
{
double timeOffset = allOffsetsWithResults[i].offset;
HitResult result = allOffsetsWithResults[i].result;
hitEvents.Add(new HitEvent(timeOffset, null, result, beatmap.HitObjects[i], null, null));
}
double accuracy = CalculateAccuracy(hitEvents);
return new ScoreInfo
{
BeatmapInfo = beatmap.BeatmapInfo,
Ruleset = beatmap.BeatmapInfo.Ruleset,
HitEvents = hitEvents,
Accuracy = accuracy,
TotalScore = 424000,
MaxCombo = hitEvents.Count,
Mods = Array.Empty<Mod>()
};
}
/// <summary>
/// Generate a random value from normal distribution using Box-Muller transform.
/// </summary>
private double GenerateNormalOffset(Random random, double mean, double stdDev)
{
double u1 = random.NextDouble();
double u2 = random.NextDouble();
double z0 = Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Cos(2.0 * Math.PI * u2);
return mean + z0 * stdDev;
}
/// <summary>
/// Calculate accuracy based on hit event results.
/// </summary>
private double CalculateAccuracy(List<HitEvent> hitEvents)
{
double totalPoints = 0;
double maxPoints = 0;
foreach (var hitEvent in hitEvents)
{
maxPoints += 305; // Maximum points per note in Mania
switch (hitEvent.Result)
{
case HitResult.Perfect:
totalPoints += 305;
break;
case HitResult.Great:
totalPoints += 300;
break;
case HitResult.Good:
totalPoints += 200;
break;
case HitResult.Ok:
totalPoints += 100;
break;
case HitResult.Meh:
totalPoints += 50;
break;
default:
totalPoints += 0;
break;
}
}
return maxPoints > 0 ? totalPoints / maxPoints : 0;
}
private ScoreInfo createPerfectScore(IBeatmap beatmap)
{
var hitEvents = new List<HitEvent>();
// Initialize hit windows based on beatmap difficulty
var hitWindows = new ManiaHitWindows();
hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty);
foreach (var hitObject in beatmap.HitObjects)
{
// Perfect timing offset (0ms)
double timeOffset = 0;
// Calculate the correct hit result based on hit windows at perfect timing
HitResult result = hitWindows.ResultFor(timeOffset);
if (result == HitResult.None)
result = HitResult.Miss;
hitEvents.Add(new HitEvent(timeOffset, null, result, hitObject, null, null));
}
return new ScoreInfo
{
BeatmapInfo = beatmap.BeatmapInfo,
Ruleset = beatmap.BeatmapInfo.Ruleset,
HitEvents = hitEvents,
Accuracy = 1.0,
TotalScore = 1000000,
MaxCombo = hitEvents.Count,
Mods = Array.Empty<Mod>()
};
}
}
}

View File

@@ -8,10 +8,13 @@ using System.Collections.Generic;
using System.Threading;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Legacy;
using osu.Game.LAsEzExtensions.Background;
using osu.Game.LAsEzExtensions.Configuration;
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.Mania.Objects.EzCurrentHitObject;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Rulesets.Scoring.Legacy;
@@ -25,7 +28,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps
/// <summary>
/// Maximum number of previous notes to consider for density calculation.
/// </summary>
private const int max_notes_for_density = 7;
private const int max_notes_for_density = 24;
/// <summary>
/// The total number of columns.
@@ -47,6 +50,11 @@ namespace osu.Game.Rulesets.Mania.Beatmaps
/// </summary>
public readonly bool IsForCurrentRuleset;
/// <summary>
/// The current hit mode for mania judgement system.
/// </summary>
public static EzMUGHitMode CurrentHitMode { get; set; }
// Internal for testing purposes
internal readonly LegacyRandom Random;
@@ -60,6 +68,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps
private ManiaBeatmapConverter(IBeatmap? beatmap, LegacyBeatmapConversionDifficultyInfo difficulty, Ruleset ruleset)
: base(beatmap!, ruleset)
{
CurrentHitMode = GlobalConfigStore.EzConfig?.Get<EzMUGHitMode>(Ez2Setting.HitMode) ?? EzMUGHitMode.Lazer;
IsForCurrentRuleset = difficulty.SourceRuleset.Equals(ruleset.RulesetInfo);
Random = new LegacyRandom((int)MathF.Round(difficulty.DrainRate + difficulty.CircleSize) * 20 + (int)(difficulty.OverallDifficulty * 41.2) + (int)MathF.Round(difficulty.ApproachRate));
TargetColumns = getColumnCount(difficulty);
@@ -132,7 +141,21 @@ namespace osu.Game.Rulesets.Mania.Beatmaps
{
case ManiaHitObject maniaObj:
{
yield return maniaObj;
if (maniaObj is HoldNote hold && CurrentHitMode != EzMUGHitMode.Lazer)
{
yield return CurrentHitMode switch
{
EzMUGHitMode.EZ2AC => new Ez2AcHoldNote(hold),
EzMUGHitMode.Malody => new NoJudgmentHoldNote(hold),
EzMUGHitMode.O2Jam => new O2HoldNote(hold),
EzMUGHitMode.IIDX_HD => new Ez2AcHoldNote(hold),
_ => hold
};
}
else
{
yield return maniaObj;
}
yield break;
}
@@ -227,7 +250,21 @@ namespace osu.Game.Rulesets.Mania.Beatmaps
lastPattern = newPattern;
foreach (var obj in newPattern.HitObjects)
yield return obj;
if (obj is HoldNote hold && CurrentHitMode != EzMUGHitMode.Lazer)
{
yield return CurrentHitMode switch
{
EzMUGHitMode.EZ2AC => new Ez2AcHoldNote(hold),
EzMUGHitMode.Malody => new NoJudgmentHoldNote(hold),
EzMUGHitMode.O2Jam => new O2HoldNote(hold),
EzMUGHitMode.IIDX_HD => new Ez2AcHoldNote(hold),
_ => hold
};
}
else
{
yield return obj;
}
}
}

View File

@@ -3,8 +3,10 @@
using osu.Framework.Configuration.Tracking;
using osu.Game.Configuration;
using osu.Game.LAsEzExtensions.Configuration;
using osu.Game.Localisation;
using osu.Game.Rulesets.Configuration;
using osu.Game.Rulesets.Mania.LAsEZMania;
using osu.Game.Rulesets.Mania.UI;
namespace osu.Game.Rulesets.Mania.Configuration
@@ -17,11 +19,17 @@ namespace osu.Game.Rulesets.Mania.Configuration
Migrate();
}
private const double current_scroll_speed_precision = 1.0;
protected override void InitialiseDefaults()
{
base.InitialiseDefaults();
SetDefault(ManiaRulesetSetting.ScrollSpeed, 8.0, 1.0, 40.0, 0.1);
SetDefault(ManiaRulesetSetting.ScrollBaseSpeed, 500, 100, 1000, 1.0);
SetDefault(ManiaRulesetSetting.ScrollTimePerSpeed, 5, 1.0, 40, 1.0);
SetDefault(ManiaRulesetSetting.ScrollStyle, EzManiaScrollingStyle.ScrollTimeStyleFixed);
SetDefault(ManiaRulesetSetting.ScrollSpeed, 200, 1.0, 401.0, current_scroll_speed_precision);
SetDefault(ManiaRulesetSetting.ScrollDirection, ManiaScrollingDirection.Down);
SetDefault(ManiaRulesetSetting.TimingBasedNoteColouring, false);
SetDefault(ManiaRulesetSetting.MobileLayout, ManiaMobileLayout.Portrait);
@@ -47,14 +55,24 @@ namespace osu.Game.Rulesets.Mania.Configuration
speed => new SettingDescription(
rawValue: speed,
name: RulesetSettingsStrings.ScrollSpeed,
value: RulesetSettingsStrings.ScrollSpeedTooltip((int)DrawableManiaRuleset.ComputeScrollTime(speed), speed)
value: RulesetSettingsStrings.ScrollSpeedTooltip(
(int)DrawableManiaRuleset.ComputeScrollTime(speed, Get<double>(ManiaRulesetSetting.ScrollBaseSpeed), Get<double>(ManiaRulesetSetting.ScrollTimePerSpeed)),
speed
)
)
)
),
};
}
// TODO: 未来应考虑完全迁移到Ez2Setting中
public enum ManiaRulesetSetting
{
ScrollStyle,
ScrollTime,
ScrollBaseSpeed,
ScrollTimePerSpeed,
//官方设置
ScrollSpeed,
ScrollDirection,
TimingBasedNoteColouring,

View File

@@ -6,14 +6,18 @@ using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Extensions;
using osu.Game.LAsEzExtensions.Background;
using osu.Game.LAsEzExtensions.Configuration;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.Difficulty.Preprocessing;
using osu.Game.Rulesets.Mania.Difficulty.Skills;
using osu.Game.Rulesets.Mania.LAsEZMania.Analysis;
using osu.Game.Rulesets.Mania.MathUtils;
using osu.Game.Rulesets.Mania.Mods;
using osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Scoring;
using osu.Game.Rulesets.Mods;
@@ -41,12 +45,18 @@ namespace osu.Game.Rulesets.Mania.Difficulty
if (beatmap.HitObjects.Count == 0)
return new ManiaDifficultyAttributes { Mods = mods };
HitWindows hitWindows = new ManiaHitWindows();
var hitWindows = new ManiaHitWindows();
hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty);
if (beatmap.BeatmapInfo.BPM > 0) hitWindows.BPM = beatmap.BeatmapInfo.BPM;
double sr = skills[0].DifficultyValue() * difficulty_multiplier;
sr = AdditionalMethod(beatmap, mods, skills, clockRate, sr);
ManiaDifficultyAttributes attributes = new ManiaDifficultyAttributes
{
StarRating = skills.OfType<Strain>().Single().DifficultyValue() * difficulty_multiplier,
StarRating = sr > 0
? sr
: skills.OfType<Strain>().Single().DifficultyValue() * difficulty_multiplier,
Mods = mods,
MaxCombo = beatmap.HitObjects.Sum(maxComboForObject),
};
@@ -54,6 +64,22 @@ namespace osu.Game.Rulesets.Mania.Difficulty
return attributes;
}
public double AdditionalMethod(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate, double originalValue)
{
double sr = originalValue;
if (mods.Any(m => m is ModStarRatingRebirth))
{
var xxySRFilter = GlobalConfigStore.EzConfig?.GetBindable<bool>(Ez2Setting.XxySRFilter);
sr = xxySRFilter != null && xxySRFilter.Value
? SRCalculator.CalculateSR(beatmap, clockRate)
: skills.OfType<Strain>().Single().DifficultyValue() * difficulty_multiplier;
}
return sr;
}
private static int maxComboForObject(HitObject hitObject)
{
if (hitObject is HoldNote hold)

View File

@@ -44,7 +44,8 @@ namespace osu.Game.Rulesets.Mania.Edit
protected override void Update()
{
TargetTimeRange = TimelineTimeRange == null || ShowSpeedChanges.Value ? ComputeScrollTime(Config.Get<double>(ManiaRulesetSetting.ScrollSpeed)) : TimelineTimeRange.Value;
// 使用ez2lazer特色调速系统
TargetTimeRange = TimelineTimeRange == null || ShowSpeedChanges.Value ? ComputeScrollTime(Config.Get<double>(ManiaRulesetSetting.ScrollSpeed), Config.Get<double>(ManiaRulesetSetting.ScrollBaseSpeed), Config.Get<double>(ManiaRulesetSetting.ScrollTimePerSpeed)) : TimelineTimeRange.Value;
base.Update();
}
}

View File

@@ -0,0 +1,77 @@
using System;
using System.Collections.Generic;
namespace osu.Game.Rulesets.Mania.LAsEZMania.Analysis
{
/// <summary>
/// 交叉矩阵提供者用于SR计算中的列间 权重矩阵
/// </summary>
public static class CrossMatrixProvider
{
/// <summary>
/// 默认交叉矩阵数据,表示各键位两侧的权重分布
/// 索引0对应K=1索引1对应K=2以此类推
/// null表示不支持该键数
/// </summary>
// TODO: 未来考虑支持在游戏内配置这些矩阵动态调试对比
private static readonly double[][] default_cross_matrices =
[
[-1], // CS=0
[0.075, 0.075],
[0.125, 0.05, 0.125],
[0.125, 0.125, 0.125, 0.125],
[0.175, 0.25, 0.05, 0.25, 0.175],
[0.175, 0.25, 0.175, 0.175, 0.25, 0.175],
[0.225, 0.35, 0.25, 0.05, 0.25, 0.35, 0.225],
[0.225, 0.35, 0.25, 0.225, 0.225, 0.25, 0.35, 0.225],
[0.275, 0.45, 0.35, 0.25, 0.05, 0.25, 0.35, 0.45, 0.275],
[0.275, 0.45, 0.35, 0.25, 0.275, 0.275, 0.25, 0.35, 0.45, 0.275],
[0.325, 0.55, 0.45, 0.35, 0.25, 0.05, 0.25, 0.35, 0.45, 0.55, 0.325], // 10key
// Inferred matrices for K=11 to 18 based on user-specified patterns
[-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], // K=11 (odd, unsupported)
// 更高K的矩阵没有经过严格验证仅提供占位
[0.8, 0.8, 0.8, 0.6, 0.4, 0.2, 0.05, 0.2, 0.4, 0.6, 0.8, 0.8, 0.8], // K=12 (even, sides 3 columns higher)
[-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], // K=13 (odd, unsupported)
[0.4, 0.4, 0.2, 0.2, 0.3, 0.3, 0.1, 0.1, 0.3, 0.3, 0.2, 0.2, 0.4, 0.4, 0.4], // K=14 (wave: low-low-high-high-low-low-high-high)
[-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], // K=15 (odd, unsupported)
[0.4, 0.4, 0.2, 0.2, 0.4, 0.4, 0.2, 0.1, 0.1, 0.2, 0.4, 0.4, 0.2, 0.2, 0.4, 0.4, 0.4], // K=16 (wave: low-low-high-high-low-low-high-high-low-low-high-high)
[-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], // K=17 (odd, unsupported)
[0.4, 0.4, 0.2, 0.4, 0.2, 0.4, 0.2, 0.3, 0.1, 0.1, 0.3, 0.2, 0.4, 0.2, 0.4, 0.2, 0.4, 0.4, 0.4] // K=18 (wave: low-low-high-low-high-low-high-low-low-high-low-high-low-high)
];
/// <summary>
/// 自定义交叉矩阵,用于覆盖默认数据
/// </summary>
private static readonly Dictionary<int, double[]> custom_matrices = new Dictionary<int, double[]>();
/// <summary>
/// 设置自定义交叉矩阵
/// </summary>
/// <param name="k">键数</param>
/// <param name="matrix">自定义矩阵数组如果为null则清除自定义矩阵</param>
public static void SetCustomMatrix(int k, double[]? matrix)
{
if (k < 1 || k > default_cross_matrices.Length)
throw new ArgumentOutOfRangeException(nameof(k), $"不支持的键数: {k},支持范围: 1-{default_cross_matrices.Length}");
if (matrix == null)
custom_matrices.Remove(k);
else
custom_matrices[k] = matrix;
}
/// <summary>
/// 获取指定键数(K)的交叉矩阵
/// K表示键数从1开始索引
/// </summary>
/// <param name="k">键数</param>
/// <returns>交叉矩阵数组如果不支持返回null</returns>
public static double[]? GetMatrix(int k)
{
if (k < 1 || k > default_cross_matrices.Length)
return null;
return custom_matrices.TryGetValue(k, out double[]? customMatrix) ? customMatrix : default_cross_matrices[k];
}
}
}

View File

@@ -0,0 +1,320 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Beatmaps;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.LAsEzExtensions.Analysis;
using osu.Game.LAsEzExtensions.Configuration;
using osu.Game.Rulesets.Mania.Scoring;
using osu.Game.Rulesets.Mania.LAsEZMania.Helper;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Mania.LAsEzMania.Analysis
{
/// <summary>
/// Mania-specific implementation of score graph that extends BaseEzScoreGraph.
/// Provides LN (Long Note) aware scoring calculation for Classic mode.
/// </summary>
public partial class EzManiaScoreGraph : BaseEzScoreGraph
{
private readonly ManiaHitWindows maniaHitWindows = new ManiaHitWindows();
private readonly CustomHitWindowsHelper hitWindows1;
private readonly CustomHitWindowsHelper hitWindows2;
private Bindable<EzMUGHitMode> hitModeBindable = null!;
[Resolved]
private Ez2ConfigManager ezConfig { get; set; } = null!;
public EzManiaScoreGraph(ScoreInfo score, IBeatmap beatmap)
: base(score, beatmap, new ManiaHitWindows())
{
maniaHitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty);
// Initialize helpers here (after base ctor has run and static OD/HP have been set).
hitWindows1 = new CustomHitWindowsHelper { OverallDifficulty = OD };
hitWindows2 = new CustomHitWindowsHelper { OverallDifficulty = OD };
}
protected override IReadOnlyList<HitEvent> FilterHitEvents()
{
return Score.HitEvents.Where(e => maniaHitWindows.IsHitResultAllowed(e.Result)).ToList();
}
protected override double UpdateBoundary(HitResult result)
{
return maniaHitWindows.WindowFor(result);
}
[BackgroundDependencyLoader]
private void load()
{
// Bind to the global hit mode setting so that switching hit modes updates our helpers and redraws.
hitModeBindable = ezConfig.GetBindable<EzMUGHitMode>(Ez2Setting.HitMode);
hitModeBindable.BindValueChanged(v =>
{
hitWindows1.HitMode = v.NewValue;
hitWindows2.HitMode = v.NewValue;
// Ensure mania windows re-evaluate based on global config and difficulty.
maniaHitWindows.ResetRange();
maniaHitWindows.SetDifficulty(Beatmap.Difficulty.OverallDifficulty);
// Recalculate and redraw.
Refresh();
}, true);
}
protected override HitResult RecalculateV1Result(HitEvent hitEvent)
{
return hitWindows1.ResultFor(hitEvent.TimeOffset);
}
protected override HitResult RecalculateV2Result(HitEvent hitEvent)
{
return maniaHitWindows.ResultFor(hitEvent.TimeOffset);
}
protected override void UpdateText()
{
double scAcc = Score.Accuracy * 100;
long scScore = Score.TotalScore;
AddInternal(new GridContainer
{
Anchor = Anchor.TopLeft,
Origin = Anchor.TopLeft,
Position = Vector2.Zero,
RowDimensions = new[]
{
new Dimension(GridSizeMode.AutoSize),
new Dimension(GridSizeMode.AutoSize),
new Dimension(GridSizeMode.AutoSize),
new Dimension(GridSizeMode.AutoSize),
new Dimension(GridSizeMode.AutoSize),
new Dimension(GridSizeMode.AutoSize),
new Dimension(GridSizeMode.AutoSize),
new Dimension(GridSizeMode.AutoSize),
new Dimension(GridSizeMode.AutoSize),
new Dimension(GridSizeMode.AutoSize),
new Dimension(GridSizeMode.AutoSize),
new Dimension(GridSizeMode.AutoSize),
new Dimension(GridSizeMode.AutoSize),
},
ColumnDimensions = new[]
{
new Dimension(GridSizeMode.AutoSize),
new Dimension(GridSizeMode.AutoSize),
},
Content = new[]
{
new Drawable[]
{
new OsuSpriteText
{
Text = "Acc org",
Font = OsuFont.GetFont(size: 14),
Colour = Color4.White,
},
new OsuSpriteText
{
Text = $" : {scAcc:F1}%",
Font = OsuFont.GetFont(size: 14),
Colour = Color4.White,
},
},
new Drawable[]
{
new OsuSpriteText
{
Text = "Acc v2",
Font = OsuFont.GetFont(size: 14),
Colour = Color4.White,
},
new OsuSpriteText
{
Text = $" : {V2Accuracy * 100:F1}%",
Font = OsuFont.GetFont(size: 14),
Colour = Color4.White,
},
},
new Drawable[]
{
new OsuSpriteText
{
Text = "Acc v1",
Font = OsuFont.GetFont(size: 14),
Colour = Color4.White,
},
new OsuSpriteText
{
Text = $" : {V1Accuracy * 100:F1}%",
Font = OsuFont.GetFont(size: 14),
Colour = Color4.White,
},
},
new Drawable[]
{
new OsuSpriteText
{
Text = "Scr org",
Font = OsuFont.GetFont(size: 14),
Colour = Color4.White,
},
new OsuSpriteText
{
Text = $" : {scScore / 1000.0:F0}k",
Font = OsuFont.GetFont(size: 14),
Colour = Color4.White,
},
},
new Drawable[]
{
new OsuSpriteText
{
Text = "Scr v2",
Font = OsuFont.GetFont(size: 14),
Colour = Color4.White,
},
new OsuSpriteText
{
Text = $" : {V2Score / 1000.0:F0}k",
Font = OsuFont.GetFont(size: 14),
Colour = Color4.White,
},
},
new Drawable[]
{
new OsuSpriteText
{
Text = "Scr v1",
Font = OsuFont.GetFont(size: 14),
Colour = Color4.White,
},
new OsuSpriteText
{
Text = $" : {V1Score / 1000.0:F0}k",
Font = OsuFont.GetFont(size: 14),
Colour = Color4.White,
},
},
new Drawable[]
{
new OsuSpriteText
{
Text = "Pauses",
Font = OsuFont.GetFont(size: 14),
Colour = Color4.White,
},
new OsuSpriteText
{
Text = $" : {Score.Pauses.Count}",
Font = OsuFont.GetFont(size: 14),
Colour = Color4.White,
},
},
new Drawable[]
{
new OsuSpriteText
{
Text = "PERFECT",
Font = OsuFont.GetFont(size: 14),
Colour = Color4.White,
},
new OsuSpriteText
{
Text = $" : {V2Counts.GetValueOrDefault(HitResult.Perfect, 0)}\\{V1Counts.GetValueOrDefault(HitResult.Perfect, 0)}",
Font = OsuFont.GetFont(size: 14),
Colour = Color4.White,
},
},
new Drawable[]
{
new OsuSpriteText
{
Text = "GREAT",
Font = OsuFont.GetFont(size: 14),
Colour = Color4.White,
},
new OsuSpriteText
{
Text = $" : {V2Counts.GetValueOrDefault(HitResult.Great, 0)}\\{V1Counts.GetValueOrDefault(HitResult.Great, 0)}",
Font = OsuFont.GetFont(size: 14),
Colour = Color4.White,
},
},
new Drawable[]
{
new OsuSpriteText
{
Text = "GOOD",
Font = OsuFont.GetFont(size: 14),
Colour = Color4.White,
},
new OsuSpriteText
{
Text = $" : {V2Counts.GetValueOrDefault(HitResult.Good, 0)}\\{V1Counts.GetValueOrDefault(HitResult.Good, 0)}",
Font = OsuFont.GetFont(size: 14),
Colour = Color4.White,
},
},
new Drawable[]
{
new OsuSpriteText
{
Text = "OK",
Font = OsuFont.GetFont(size: 14),
Colour = Color4.White,
},
new OsuSpriteText
{
Text = $" : {V2Counts.GetValueOrDefault(HitResult.Ok, 0)}\\{V1Counts.GetValueOrDefault(HitResult.Ok, 0)}",
Font = OsuFont.GetFont(size: 14),
Colour = Color4.White,
},
},
new Drawable[]
{
new OsuSpriteText
{
Text = "MEH",
Font = OsuFont.GetFont(size: 14),
Colour = Color4.White,
},
new OsuSpriteText
{
Text = $" : {V2Counts.GetValueOrDefault(HitResult.Meh, 0)}\\{V1Counts.GetValueOrDefault(HitResult.Meh, 0)}",
Font = OsuFont.GetFont(size: 14),
Colour = Color4.White,
},
},
new Drawable[]
{
new OsuSpriteText
{
Text = "MISS",
Font = OsuFont.GetFont(size: 14),
Colour = Color4.White,
},
new OsuSpriteText
{
Text = $" : {V2Counts.GetValueOrDefault(HitResult.Miss, 0)}\\{V1Counts.GetValueOrDefault(HitResult.Miss, 0)}",
Font = OsuFont.GetFont(size: 14),
Colour = Color4.White,
},
},
}
});
}
}
}

View File

@@ -0,0 +1,244 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using osu.Game.Beatmaps;
using osu.Game.LAsEzExtensions.Analysis;
using osu.Game.Replays;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Replays;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Screens.Ranking.Statistics;
using osu.Game.Utils;
namespace osu.Game.Rulesets.Mania.LAsEzMania.Analysis
{
/// <summary>
/// Generates <see cref="HitEvent"/>s for mania scores by re-evaluating a score's replay input against a provided playable beatmap.
/// This is intended for results/statistics usage where <see cref="ScoreInfo.HitEvents"/> are not persisted.
/// </summary>
public sealed class ManiaScoreHitEventGenerator : IHitEventGenerator
{
public static ManiaScoreHitEventGenerator Instance { get; } = new ManiaScoreHitEventGenerator();
/// <summary>
/// Instance implementation of generator.
/// </summary>
public List<HitEvent>? Generate(Score score, IBeatmap playableBeatmap, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (score.ScoreInfo.Ruleset.OnlineID != 3)
return null;
Replay replay = score.Replay;
// Legacy decoding should have produced mania frames.
if (replay?.Frames == null || replay.Frames.Count == 0)
return null;
if (replay.Frames.Any(f => f is not ManiaReplayFrame))
return null;
var frames = replay.Frames.Cast<ManiaReplayFrame>().OrderBy(f => f.Time).ToList();
// Build per-column input transitions.
var pressTimesByColumn = new List<double>[32];
var releaseTimesByColumn = new List<double>[32];
for (int i = 0; i < pressTimesByColumn.Length; i++)
{
pressTimesByColumn[i] = new List<double>();
releaseTimesByColumn[i] = new List<double>();
}
HashSet<ManiaAction> last = new HashSet<ManiaAction>();
foreach (var frame in frames)
{
cancellationToken.ThrowIfCancellationRequested();
var current = new HashSet<ManiaAction>(frame.Actions);
foreach (var action in current)
{
if (last.Contains(action))
continue;
int column = (int)action;
if (column >= 0 && column < pressTimesByColumn.Length)
pressTimesByColumn[column].Add(frame.Time);
}
foreach (var action in last)
{
if (current.Contains(action))
continue;
int column = (int)action;
if (column >= 0 && column < releaseTimesByColumn.Length)
releaseTimesByColumn[column].Add(frame.Time);
}
last = current;
}
// If keys are still held at the end of replay, treat them as released at the last frame time.
if (last.Count > 0)
{
double endTime = frames[^1].Time;
foreach (var action in last)
{
int column = (int)action;
if (column >= 0 && column < releaseTimesByColumn.Length)
releaseTimesByColumn[column].Add(endTime);
}
}
// Map tail -> head to support capping (combo-break conditions).
var headByTail = new Dictionary<TailNote, HeadNote>();
foreach (var hitObject in playableBeatmap.HitObjects)
{
cancellationToken.ThrowIfCancellationRequested();
if (hitObject is HoldNote hold)
headByTail[hold.Tail] = hold.Head;
}
var targets = new List<HitObject>();
foreach (var hitObject in playableBeatmap.HitObjects)
{
cancellationToken.ThrowIfCancellationRequested();
collectJudgementTargets(hitObject, targets, cancellationToken);
}
// Ensure deterministic ordering.
targets.Sort((a, b) =>
{
int timeComparison = a.StartTime.CompareTo(b.StartTime);
if (timeComparison != 0)
return timeComparison;
int colA = (a as IHasColumn)?.Column ?? 0;
int colB = (b as IHasColumn)?.Column ?? 0;
return colA.CompareTo(colB);
});
double gameplayRate = ModUtils.CalculateRateWithMods(score.ScoreInfo.Mods);
var hitEvents = new List<HitEvent>(targets.Count);
HitObject? lastHitObject = null;
// Track head hit results for later tail capping.
var headWasHit = new Dictionary<HeadNote, bool>();
foreach (var target in targets)
{
cancellationToken.ThrowIfCancellationRequested();
if (target.HitWindows == null || ReferenceEquals(target.HitWindows, HitWindows.Empty))
continue;
if (target is not IHasColumn hasColumn)
continue;
int column = hasColumn.Column;
if (column < 0 || column >= pressTimesByColumn.Length)
continue;
bool isTail = target is TailNote;
double lenienceFactor = isTail ? TailNote.RELEASE_WINDOW_LENIENCE : 1;
// We treat judgement windows as symmetrical around StartTime.
double missWindow = target.HitWindows.WindowFor(HitResult.Miss) * lenienceFactor;
List<double> times = isTail ? releaseTimesByColumn[column] : pressTimesByColumn[column];
int idx = times.FindIndex(t => t >= target.StartTime - missWindow && t <= target.StartTime + missWindow);
double timeOffsetForJudgement;
HitResult result;
bool holdBreak = false;
if (idx >= 0)
{
double eventTime = times[idx];
times.RemoveAt(idx);
double rawOffset = eventTime - target.StartTime;
if (isTail && rawOffset < 0)
holdBreak = true;
timeOffsetForJudgement = rawOffset / lenienceFactor;
// Use the ruleset-provided mapping, but coerce outside-of-window to Miss (ResultFor() would return None).
if (Math.Abs(rawOffset) > missWindow)
result = HitResult.Miss;
else
result = target.HitWindows.ResultFor(timeOffsetForJudgement);
if (result == HitResult.None)
result = HitResult.Miss;
if (target is HeadNote head)
headWasHit[head] = result.IsHit();
if (target is TailNote tail && headByTail.TryGetValue(tail, out var headNote))
{
bool headHit = headWasHit.TryGetValue(headNote, out bool wasHit) && wasHit;
if (result > HitResult.Meh && (!headHit || holdBreak))
result = HitResult.Meh;
}
}
else
{
// No matching input event. Treat as a miss.
timeOffsetForJudgement = 0;
result = HitResult.Miss;
if (target is HeadNote head)
headWasHit[head] = false;
}
hitEvents.Add(new HitEvent(timeOffsetForJudgement, gameplayRate, result, target, lastHitObject, null));
lastHitObject = target;
}
return hitEvents;
}
static ManiaScoreHitEventGenerator()
{
try
{
ScoreHitEventGeneratorBridge.Register(ManiaRuleset.SHORT_NAME, Instance);
ScoreHitEventGeneratorBridge.Register("3", Instance);
}
catch
{
}
}
private static void collectJudgementTargets(HitObject hitObject, List<HitObject> targets, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (hitObject.HitWindows != null && !ReferenceEquals(hitObject.HitWindows, HitWindows.Empty) && hitObject.Judgement.MaxResult != HitResult.IgnoreHit)
targets.Add(hitObject);
foreach (var nested in hitObject.NestedHitObjects)
collectJudgementTargets(nested, targets, cancellationToken);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
using System.Collections.Generic;
namespace osu.Game.Rulesets.Mania.LAsEZMania.Analysis
{
/// <summary>
/// SR计算错误代码定义
/// </summary>
public static class SRErrorCodes
{
/// <summary>
/// 错误代码与消息映射
/// </summary>
public static readonly Dictionary<double, string> ERROR_MESSAGES = new Dictionary<double, string>
{
[-2.0] = "路径字符串无效",
[-3.0] = "文件打开失败",
[-4.0] = "解析失败",
[-5.0] = "数据非法",
[-6.0] = "SR计算失败",
[-7.0] = "SR计算panic"
};
/// <summary>
/// 获取错误消息
/// </summary>
/// <param name="code">错误码</param>
/// <returns>错误消息</returns>
public static string GetErrorMessage(double code)
{
return ERROR_MESSAGES.GetValueOrDefault(code, "未知错误");
}
}
}

View File

@@ -0,0 +1,27 @@
// 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;
namespace osu.Game.Rulesets.Mania.LAsEZMania
{
public partial class CustomVisibilityContainer : VisibilityContainer
{
public CustomVisibilityContainer()
{
AutoSizeAxes = Axes.Y;
RelativeSizeAxes = Axes.X;
}
protected override void PopIn()
{
this.FadeIn();
}
protected override void PopOut()
{
this.FadeOut();
}
}
}

View File

@@ -0,0 +1,57 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Graphics;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Mania.LAsEZMania
{
public static class EzEffectHelper
{
public static void ApplyScaleAnimation(Drawable target, bool wasIncrease, bool wasMiss, float increaseFactor, float decreaseFactor, float increaseDuration, float decreaseDuration)
{
float newScaleValue = Math.Clamp(target.Scale.X * (wasIncrease ? increaseFactor : decreaseFactor), 0.5f, 3f);
Vector2 newScale = new Vector2(newScaleValue);
target
.ScaleTo(newScale, increaseDuration, Easing.OutQuint)
.Then()
.ScaleTo(Vector2.One, decreaseDuration, Easing.OutQuint);
if (wasMiss)
target.FlashColour(Color4.Red, decreaseDuration, Easing.OutQuint);
}
public static void ApplyBounceAnimation(Drawable target, bool wasIncrease, bool wasMiss, float increaseFactor, float decreaseFactor, float increaseDuration, float decreaseDuration)
{
float factor = Math.Clamp(wasIncrease ? 10 * increaseFactor : -10 * decreaseFactor, -100f, 100f);
target
.MoveToY(factor, increaseDuration / 2, Easing.OutBounce)
.Then()
.MoveToY(0, decreaseDuration, Easing.OutBounce);
if (wasMiss)
target.FlashColour(Color4.Red, decreaseDuration, Easing.OutQuint);
}
}
}
// float factor = 0;
//
// switch (AnimationOrigin.Value)
// {
// case OriginOptions.TopCentre:
// factor = Math.Clamp(wasIncrease ? 10 * IncreaseScale.Value : -50, -100f, 100f); // 向下跳
// break;
//
// case OriginOptions.BottomCentre:
// factor = Math.Clamp(wasIncrease ? -10 * IncreaseScale.Value : 50, -100f, 100f); // 向上跳
// break;
//
// case OriginOptions.Centre:
// factor = Math.Clamp(wasIncrease ? 10 * IncreaseScale.Value : -10 * IncreaseScale.Value, -100f, 100f); // 上下跳
// break;
// }

View File

@@ -0,0 +1,207 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.LAsEzExtensions.Analysis;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.Ranking.Statistics;
namespace osu.Game.Rulesets.Mania.LAsEZMania
{
/// <summary>
/// A graph which displays the distribution of hit timing for each column in a series of <see cref="HitEvent"/>s.
/// </summary>
public partial class EzHitTimingGraphByColumn : CompositeDrawable
{
/// <summary>
/// The currently displayed hit events.
/// </summary>
private readonly IReadOnlyList<HitEvent> hitEvents;
/// <summary>
/// The number of columns in the beatmap.
/// </summary>
private readonly int columnCount;
/// <summary>
/// Creates a new <see cref="EzHitTimingGraphByColumn"/>.
/// </summary>
/// <param name="hitEvents">The <see cref="HitEvent"/>s to display the timing distribution of.</param>
/// <param name="columnCount">The number of columns in the beatmap.</param>
public EzHitTimingGraphByColumn(IReadOnlyList<HitEvent> hitEvents, int columnCount)
{
this.hitEvents = hitEvents;
this.columnCount = columnCount;
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
}
[BackgroundDependencyLoader]
private void load()
{
if (hitEvents.Count == 0)
return;
var columnGraphs = new List<Drawable>();
for (int i = 0; i < columnCount; i++)
{
var columnEvents = hitEvents.Where(h => ((ManiaHitObject)h.HitObject).Column == i).ToList();
if (columnEvents.Count == 0)
continue;
int perfect = columnEvents.Count(h => h.Result == HitResult.Perfect);
int great = columnEvents.Count(h => h.Result == HitResult.Great);
int good = columnEvents.Count(h => h.Result == HitResult.Good);
int ok = columnEvents.Count(h => h.Result == HitResult.Ok);
int meh = columnEvents.Count(h => h.Result == HitResult.Meh);
int miss = columnEvents.Count(h => h.Result == HitResult.Miss);
int total = perfect + great + good + ok + meh + miss;
int perfectLate = columnEvents.Count(h => h.Result == HitResult.Perfect && h.TimeOffset > 0);
int greatLate = columnEvents.Count(h => h.Result == HitResult.Great && h.TimeOffset > 0);
int goodLate = columnEvents.Count(h => h.Result == HitResult.Good && h.TimeOffset > 0);
int okLate = columnEvents.Count(h => h.Result == HitResult.Ok && h.TimeOffset > 0);
int mehLate = columnEvents.Count(h => h.Result == HitResult.Meh && h.TimeOffset > 0);
int missLate = columnEvents.Count(h => h.Result == HitResult.Miss && h.TimeOffset > 0);
int totalLate = perfectLate + greatLate + goodLate + okLate + mehLate + missLate;
int perfectEarly = columnEvents.Count(h => h.Result == HitResult.Perfect && h.TimeOffset <= 0);
int greatEarly = columnEvents.Count(h => h.Result == HitResult.Great && h.TimeOffset <= 0);
int goodEarly = columnEvents.Count(h => h.Result == HitResult.Good && h.TimeOffset <= 0);
int okEarly = columnEvents.Count(h => h.Result == HitResult.Ok && h.TimeOffset <= 0);
int mehEarly = columnEvents.Count(h => h.Result == HitResult.Meh && h.TimeOffset <= 0);
int missEarly = columnEvents.Count(h => h.Result == HitResult.Miss && h.TimeOffset <= 0);
int totalEarly = perfectEarly + greatEarly + goodEarly + okEarly + mehEarly + missEarly;
var totalColor = Color4Extensions.FromHex(@"ff8c00");
var perfectColor = Color4Extensions.FromHex(@"99eeff");
var greatColor = Color4Extensions.FromHex(@"66ccff");
var goodColor = Color4Extensions.FromHex(@"b3d944");
var okColor = Color4Extensions.FromHex(@"88b300");
var mehColor = Color4Extensions.FromHex(@"ffcc22");
var missColor = Color4Extensions.FromHex(@"ed1121");
var columnContainer = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical
};
columnContainer.Add(new HitEventTimingDistributionGraph(columnEvents)
{
RelativeSizeAxes = Axes.X,
Height = 100,
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Scale = new osuTK.Vector2(0.96f),
Margin = new MarginPadding { Top = 5, Bottom = 10 }
});
columnContainer.Add(new OsuSpriteText
{
Text = $"Column {i + 1}",
Font = OsuFont.GetFont(size: StatisticItem.FONT_SIZE, weight: FontWeight.Bold),
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre
});
columnContainer.Add(new AverageHitError(columnEvents)
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Scale = new osuTK.Vector2(0.96f)
});
columnContainer.Add(new UnstableRate(columnEvents)
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Scale = new osuTK.Vector2(0.96f)
});
columnContainer.Add(new SimpleStatisticTable(3, new[]
{
new EzJudgementsItem(total.ToString(), "Total", totalColor),
new EzJudgementsItem(totalLate.ToString(), "Total (Late)", ColourInfo.GradientVertical(Colour4.White, totalColor)),
new EzJudgementsItem(totalEarly.ToString(), "Total (Early)", ColourInfo.GradientVertical(totalColor, Colour4.White)),
new EzJudgementsItem(perfect.ToString(), "Perfect", perfectColor),
new EzJudgementsItem(perfectLate.ToString(), "Perfect (Late)", ColourInfo.GradientVertical(Colour4.White, perfectColor)),
new EzJudgementsItem(perfectEarly.ToString(), "Perfect (Early)", ColourInfo.GradientVertical(perfectColor, Colour4.White)),
new EzJudgementsItem(great.ToString(), "Great", greatColor),
new EzJudgementsItem(greatLate.ToString(), "Great (Late)", ColourInfo.GradientVertical(Colour4.White, greatColor)),
new EzJudgementsItem(greatEarly.ToString(), "Great (Early)", ColourInfo.GradientVertical(greatColor, Colour4.White)),
new EzJudgementsItem(good.ToString(), "Good", goodColor),
new EzJudgementsItem(goodLate.ToString(), "Good (Late)", ColourInfo.GradientVertical(Colour4.White, goodColor)),
new EzJudgementsItem(goodEarly.ToString(), "Good (Early)", ColourInfo.GradientVertical(goodColor, Colour4.White)),
new EzJudgementsItem(ok.ToString(), "Ok", okColor),
new EzJudgementsItem(okLate.ToString(), "Ok (Late)", ColourInfo.GradientVertical(Colour4.White, okColor)),
new EzJudgementsItem(okEarly.ToString(), "Ok (Early)", ColourInfo.GradientVertical(okColor, Colour4.White)),
new EzJudgementsItem(meh.ToString(), "Meh", mehColor),
new EzJudgementsItem(mehLate.ToString(), "Meh (Late)", ColourInfo.GradientVertical(Colour4.White, mehColor)),
new EzJudgementsItem(mehEarly.ToString(), "Meh (Early)", ColourInfo.GradientVertical(mehColor, Colour4.White)),
new EzJudgementsItem(miss.ToString(), "Miss", missColor),
new EzJudgementsItem(missLate.ToString(), "Miss (Late)", ColourInfo.GradientVertical(Colour4.White, missColor)),
new EzJudgementsItem(missEarly.ToString(), "Miss (Early)", ColourInfo.GradientVertical(missColor, Colour4.White))
})
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Scale = new osuTK.Vector2(0.96f)
});
columnGraphs.Add(columnContainer);
}
const int columns_per_row = 2;
int rowCount = (columnGraphs.Count + columns_per_row - 1) / columns_per_row; // 向上取整得到行数
var gridContent = new Drawable[rowCount][];
for (int i = 0; i < rowCount; i++)
{
gridContent[i] = new Drawable[columns_per_row];
for (int j = 0; j < columns_per_row; j++)
{
int index = i * columns_per_row + j;
if (index < columnGraphs.Count)
{
gridContent[i][j] = columnGraphs[index];
if (columnCount % 2 == 1 && i == rowCount - 1 && j == 0)
{
var position = gridContent[i][j].Position;
position.X += 228;
gridContent[i][j].Position = position;
}
}
else
gridContent[i][j] = Empty();
}
}
InternalChild = new GridContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
RowDimensions = Enumerable.Range(0, rowCount).Select(_ => new Dimension(GridSizeMode.AutoSize)).ToArray(),
ColumnDimensions = Enumerable.Range(0, columns_per_row).Select(_ => new Dimension()).ToArray(),
Content = gridContent
};
}
}
}

View File

@@ -0,0 +1,44 @@
// 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;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Game.Graphics;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.LAsEZMania
{
public abstract partial class EzJudgementText : CompositeDrawable //, ISerialisableDrawable
{
protected readonly HitResult Result;
protected SpriteText JudgementText { get; private set; } = null!;
[Resolved]
private OsuColour colours { get; set; } = null!;
// public bool UsesFixedAnchor { get; set; }
protected EzJudgementText(HitResult result)
{
Result = result;
}
protected EzJudgementText()
{
}
[BackgroundDependencyLoader]
private void load()
{
AddInternal(JudgementText = CreateJudgementText());
JudgementText.Colour = colours.ForHitResult(Result);
JudgementText.Text = Result.GetDescription().ToUpperInvariant();
}
protected abstract SpriteText CreateJudgementText();
}
}

View File

@@ -0,0 +1,111 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Screens.Play.HUD;
namespace osu.Game.Rulesets.Mania.LAsEZMania
{
public partial class EzKeyCounter : KeyCounter
{
private Circle inputIndicator = null!;
private OsuSpriteText keyNameText = null!;
private OsuSpriteText countText = null!;
private const float line_height = 3;
private const float name_font_size = 10;
private const float count_font_size = 14;
private const float scale_factor = 1.5f;
private const float indicator_press_offset = 4;
[Resolved]
private OsuColour colours { get; set; } = null!;
public bool ShowKeyName { get; set; } = false;
// private readonly string keyDisplayName;
public EzKeyCounter(InputTrigger trigger)
: base(trigger)
{
// this.keyDisplayName = keyDisplayName;
}
[BackgroundDependencyLoader]
private void load()
{
Children = new Drawable[]
{
inputIndicator = new Circle
{
RelativeSizeAxes = Axes.X,
Height = line_height * scale_factor,
Alpha = 0.5f
},
new Container
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding { Top = line_height * scale_factor + indicator_press_offset },
Children = new Drawable[]
{
keyNameText = new OsuSpriteText
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Font = OsuFont.Torus.With(size: name_font_size * scale_factor, weight: FontWeight.Bold),
Colour = colours.Blue0,
// Text = ShowKeyName ? keyDisplayName : Trigger.Name
Text = Trigger.Name
},
countText = new OsuSpriteText
{
Anchor = Anchor.BottomCentre,
Origin = Anchor.BottomCentre,
Font = OsuFont.Torus.With(size: count_font_size * scale_factor, weight: FontWeight.Bold),
},
}
},
};
Height = 30 * scale_factor;
}
protected override void LoadComplete()
{
base.LoadComplete();
CountPresses.BindValueChanged(e => countText.Text = e.NewValue.ToString(@"#,0"), true);
}
protected override void Activate(bool forwardPlayback = true)
{
base.Activate(forwardPlayback);
keyNameText
.FadeColour(Colour4.White, 10, Easing.OutQuint);
inputIndicator
.FadeIn(10, Easing.OutQuint)
.MoveToY(0)
.Then()
.MoveToY(indicator_press_offset, 60, Easing.OutQuint);
}
protected override void Deactivate(bool forwardPlayback = true)
{
base.Deactivate(forwardPlayback);
keyNameText
.FadeColour(colours.Blue0, 200, Easing.OutQuart);
inputIndicator
.MoveToY(0, 250, Easing.OutQuart)
.FadeTo(0.5f, 250, Easing.OutQuart);
}
}
}

View File

@@ -0,0 +1,59 @@
using osu.Framework.Bindables;
using osu.Framework.Graphics.Containers;
using osu.Game.Screens.Play.HUD;
namespace osu.Game.Rulesets.Mania.LAsEZMania
{
public partial class ManiaActionInputTrigger : InputTrigger
{
public ManiaActionInputTrigger(string actionName)
: base(actionName)
{
}
}
public partial class EzKeyCounterPro : Container
{
public readonly InputTrigger Trigger;
public IBindable<int> CountPresses => Trigger.ActivationCount;
public IBindable<bool> IsActive => isActive;
private readonly Bindable<bool> isActive = new BindableBool();
public EzKeyCounterPro(ManiaAction action)
{
// 将 ManiaAction 转换为 InputTrigger
Trigger = new ManiaActionInputTrigger(action.ToString());
Trigger.OnActivate += Activate;
Trigger.OnDeactivate += Deactivate;
}
protected override void LoadComplete()
{
base.LoadComplete();
if (Trigger.IsActive)
Activate();
}
protected virtual void Activate(bool forwardPlayback = true)
{
isActive.Value = true;
}
protected virtual void Deactivate(bool forwardPlayback = true)
{
isActive.Value = false;
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
Trigger.OnActivate -= Activate;
Trigger.OnDeactivate -= Deactivate;
}
}
}

View File

@@ -0,0 +1,56 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.ComponentModel;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.LAsEZMania
{
public enum EzManiaScrollingStyle
{
// [LocalisableDescription(typeof(RulesetSettingsStrings), nameof(RulesetSettingsStrings.ScrollingDirectionUp))]
[Description("40速 通配速度风格(不可用)")]
ScrollSpeedStyle = 0,
// [LocalisableDescription(typeof(RulesetSettingsStrings), nameof(RulesetSettingsStrings.ScrollingDirectionDown))]
[Description("(ms) For Default Judgement Line")]
ScrollTimeStyle = 1,
[Description("(ms) For Real Judgement Line")]
ScrollTimeForRealJudgement = 2,
[Description("(ms) For Screen Bottom")]
ScrollTimeStyleFixed = 3,
// [Obsolete("Renamed to ScrollTimeStyleFixed. Kept for backward compatibility with stored settings.")]
// ScrollTimeStyle = ScrollTimeStyleFixed,
//
// [Obsolete("Renamed to ScrollTimeStyleFixed. Kept for backward compatibility with stored settings.")]
// ScrollTimeStyleFixed = ScrollTimeStyleFixed,
}
public enum ManiaHitResult
{
[Description("Pool")]
Pool,
[Description("Miss")]
Miss,
[Description("50")]
Meh,
[Description("100")]
Ok,
[Description("200")]
Good,
[Description("300")]
Great,
[Description("Perfect")]
Perfect
}
}

View File

@@ -0,0 +1,98 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Localisation;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Overlays.Settings.Sections.Gameplay;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.Configuration;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Objects.Drawables;
using osu.Game.Rulesets.Mania.Objects.EzCurrentHitObject;
using osu.Game.Rulesets.Mania.Scoring;
using osu.Game.Rulesets.Mania.UI;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI;
namespace osu.Game.Rulesets.Mania.LAsEZMania
{
public class HitWindowTemplate
{
public double TemplatePerfect { get; set; }
public double TemplateGreat { get; set; }
public double TemplateGood { get; set; }
public double TemplateOk { get; set; }
public double TemplateMeh { get; set; }
public double TemplateMiss { get; set; }
}
public static class HitWindowTemplateDictionary
{
private static readonly Dictionary<string, HitWindowTemplate> templates = new Dictionary<string, HitWindowTemplate>
{
["EZ2AC"] = new HitWindowTemplate
{
TemplatePerfect = 22,
TemplateGreat = 32,
TemplateGood = 64,
TemplateOk = 80,
TemplateMeh = 100,
TemplateMiss = 120
},
["IIDX"] = new HitWindowTemplate
{
TemplatePerfect = 20,
TemplateGreat = 40,
TemplateGood = 60,
TemplateOk = 80,
TemplateMeh = 100,
TemplateMiss = 120
},
["Melody"] = new HitWindowTemplate
{
TemplatePerfect = 20,
TemplateGreat = 40,
TemplateGood = 60,
TemplateOk = 80,
TemplateMeh = 100,
TemplateMiss = 120
}
};
public static HitWindowTemplate GetTemplate(string mode)
{
return templates.TryGetValue(mode, out var template)
? template
: throw new InvalidOperationException($"Hit window template for mode '{mode}' is not defined.");
}
public static readonly HitWindowTemplate EASY = new HitWindowTemplate
{
TemplatePerfect = 50,
TemplateGreat = 100,
TemplateGood = 150,
TemplateOk = 200,
TemplateMeh = 250,
TemplateMiss = 300
};
public static readonly HitWindowTemplate HARD = new HitWindowTemplate
{
TemplatePerfect = 20,
TemplateGreat = 40,
TemplateGood = 60,
TemplateOk = 80,
TemplateMeh = 100,
TemplateMiss = 120
};
}
}

View File

@@ -0,0 +1,19 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.LAsEZMania
{
public class EzManiaHitTimingInfo
{
public double HitTime { get; set; }
public HitResult Result { get; set; }
public EzManiaHitTimingInfo(double hitTime, HitResult result)
{
HitTime = hitTime;
Result = result;
}
}
}

View File

@@ -0,0 +1,382 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Reflection;
using osu.Framework.Localisation;
using osu.Game.LAsEzExtensions.Configuration;
namespace osu.Game.Rulesets.Mania.LAsEZMania
{
public class EzManiaModStrings : EzLocalizationManager
{
static EzManiaModStrings()
{
// 使用反射为未设置英文的属性自动生成英文属性名替换_为空格
var fields = typeof(EzManiaModStrings).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy);
foreach (var field in fields)
{
if (field.FieldType == typeof(EzLocalisableString))
{
if (field.GetValue(null) is EzLocalisableString instance && instance.English == null)
{
instance.English = field.Name.Replace("_", " ");
}
}
}
}
// 本地化字符串类,直接持有中文和英文
public new class EzLocalisableString : EzLocalizationManager.EzLocalisableString
{
public EzLocalisableString(string chinese, string? english = null)
: base(chinese, english) { }
// 便捷构造函数:如果不提供英文,则稍后通过反射从属性名生成
public EzLocalisableString(string chinese)
: base(chinese) { }
}
// ====================================================================================================
// LAsMods - Mod Descriptions
// ====================================================================================================
public static readonly LocalisableString Ez2Settings_Description = new EzLocalisableString("按固定模版,移除盘子和踏板", "Remove Scratch, Panel.");
public static readonly LocalisableString NiceBPM_Description = new EzLocalisableString("自由调整BPM或速度", "Free BPM or Speed");
public static readonly LocalisableString SpaceBody_Description = new EzLocalisableString("全LN面海可调面缝", "Full LN, adjustable gaps");
public static readonly LocalisableString LoopPlayClip_Description = new EzLocalisableString("将谱面切割成片段用于循环练习。原版是YuLiangSSS的Duplicate Mod",
"Cut the beatmap into a clip for loop practice. (The original is YuLiangSSS's Duplicate Mod)");
// ====================================================================================================
// LAsMods - SettingSource Labels & Descriptions
// ====================================================================================================
// Ez2Settings
public static readonly LocalisableString NoScratch_Label = new EzLocalisableString("无盘", "No Scratch");
public static readonly LocalisableString NoScratch_Description = new EzLocalisableString("按固定模版去除Ez街机谱面中的盘子. 用于: 6-9k L-S; 12\\14\\16k LR-S", "No (EZ)Scratch. For: 6-9k L-S; 12\\14\\16k LR-S");
public static readonly LocalisableString NoPanel_Label = new EzLocalisableString("无踏板", "No Panel");
public static readonly LocalisableString NoPanel_Description = new EzLocalisableString("按固定模版去除Ez街机谱面中的脚踏. 用于: 7\\14\\18k", "No (EZ)Panel. For: 7\\14\\18k");
public static readonly LocalisableString HealthyScratch_Label = new EzLocalisableString("健康盘子", "Healthy Scratch");
public static readonly LocalisableString HealthyScratch_Description = new EzLocalisableString("优化盘子密度,通过特定模版将过快的盘子移动到其他列", "Healthy (EZ)Scratch. Move the fast Scratch to the other columns");
public static readonly LocalisableString MaxBeat_Label = new EzLocalisableString("最大节拍", "Max Beat");
public static readonly LocalisableString MaxBeat_Description = new EzLocalisableString("盘子密度的最大节拍间隔, 1/? 拍", "Scratch MAX Beat Space, MAX 1/? Beat");
// SpaceBody
public static readonly LocalisableString SpaceBody_Label = new EzLocalisableString("全反键缝隙", "Space Body");
public static readonly LocalisableString SpaceBodyGap_Description = new EzLocalisableString("调整前后两个面之间的间隔缝隙", "Full LN, adjustable gaps");
public static readonly LocalisableString AddShield_Label = new EzLocalisableString("添加盾型", "Add Shield");
public static readonly LocalisableString AddShield_Description = new EzLocalisableString("将每个面尾添加盾牌键型", "Add shield notes in the sea");
// LoopPlayClip
public static readonly LocalisableString LoopCount_Label = new EzLocalisableString("循环次数", "Loop Count");
public static readonly LocalisableString LoopCount_Description = new EzLocalisableString("切片循环次数", "Loop Clip Count.");
public static readonly LocalisableString SpeedChange_Label = new EzLocalisableString("改变倍速", "Speed Change");
public static readonly LocalisableString SpeedChange_Description = new EzLocalisableString("改变倍速。不允许叠加其他变速mod。", "Speed Change. The actual decrease to apply. Don't add other rate-mod.");
public static readonly LocalisableString AdjustPitch_Label = new EzLocalisableString("调整音调", "Adjust pitch");
public static readonly LocalisableString AdjustPitch_Description = new EzLocalisableString("速度改变时是否调整音调。(变速又变调)", "Adjust pitch. Should pitch be adjusted with speed.(变速又变调)");
public static readonly LocalisableString ConstantSpeed_Label = new EzLocalisableString("无SV变速", "Constant Speed");
public static readonly LocalisableString ConstantSpeed_Description = new EzLocalisableString("去除SV变速。恒定速度/忽略谱面中的变速)", "Constant Speed. No more tricky speed changes.(恒定速度/忽略谱面中的变速)");
public static readonly LocalisableString CutStartTime_Label = new EzLocalisableString("切片开始时间", "Cut Start Time");
public static readonly LocalisableString CutStartTime_Description = new EzLocalisableString("切片开始时间, 默认是秒。推荐通过谱面编辑器A-B控件设置可自动输入", "Cut StartTime. Default is second.");
public static readonly LocalisableString CutEndTime_Label = new EzLocalisableString("切片结束时间", "Cut End Time");
public static readonly LocalisableString CutEndTime_Description = new EzLocalisableString("切片结束时间, 默认是秒。推荐通过谱面编辑器A-B控件设置可自动输入", "Cut EndTime. Default is second.");
public static readonly LocalisableString UseMillisecond_Label = new EzLocalisableString("使用毫秒", "Use Millisecond");
public static readonly LocalisableString UseMillisecond_Description = new EzLocalisableString("改为使用ms单位", "Use millisecond(ms).");
public static readonly LocalisableString UseGlobalABRange_Label = new EzLocalisableString("使用全局A-B范围", "Use Global A-B Range");
public static readonly LocalisableString UseGlobalABRange_Description = new EzLocalisableString("始终使用谱面编辑器中A/B空间设置的范围毫秒。推荐保持开启",
"Use global A-B range. Always use the editor A/B range stored for this session (ms).");
public static readonly LocalisableString BreakTime_Label = new EzLocalisableString("休息时间", "Break Time");
public static readonly LocalisableString BreakTime_Description = new EzLocalisableString("设置两个切片循环之间的休息时间(秒)", "Break Time. If you need break(second).");
public static readonly LocalisableString Random_Label = new EzLocalisableString("随机", "Random");
public static readonly LocalisableString Random_Description = new EzLocalisableString("在切片每次重复时进行随机", "Random. Do a Random on every duplicate.");
public static readonly LocalisableString Mirror_Label = new EzLocalisableString("镜像", "Mirror");
public static readonly LocalisableString Mirror_Description = new EzLocalisableString("在切片每次重复时进行镜像", "Mirror. Mirror next part.");
public static readonly LocalisableString InfiniteLoop_Label = new EzLocalisableString("无限循环", "Infinite Loop");
public static readonly LocalisableString InfiniteLoop_Description = new EzLocalisableString("启用无限循环播放。游戏中必须使用Esc退出才能结束无法获得成绩结算。",
"Infinite Loop. Enable infinite loop playback. You must use Esc to exit in the game to end, and you cannot get score settlement.");
public static readonly LocalisableString MirrorTime_Label = new EzLocalisableString("镜像时间", "Mirror Time");
public static readonly LocalisableString MirrorTime_Description = new EzLocalisableString("每隔多少次循环做一次镜像", "Mirror Time. Every next time part will be mirrored.");
public static readonly LocalisableString Seed_Label = new EzLocalisableString("种子", "Seed");
public static readonly LocalisableString Seed_Description = new EzLocalisableString("使用自定义种子而不是随机种子", "Seed. Use a custom seed instead of a random one");
// Additional Adjust mod settings
public static readonly LocalisableString RandomMirror_Label = new EzLocalisableString("随机镜像", "Random Mirror");
public static readonly LocalisableString RandomMirror_Description = new EzLocalisableString("随机决定是否镜像音符", "Random Mirror. Mirror or not mirror notes by random.");
public static readonly LocalisableString NoFail_Label = new EzLocalisableString("无失败", "No Fail");
public static readonly LocalisableString NoFail_Description = new EzLocalisableString("无论如何都不会失败", "No Fail. You can't fail, no matter what.");
public static readonly LocalisableString Restart_Label = new EzLocalisableString("失败重启", "Restart on fail");
public static readonly LocalisableString Restart_Description = new EzLocalisableString("失败时自动重启", "Restart on fail. Automatically restarts when failed.");
public static readonly LocalisableString RandomSelect_Label = new EzLocalisableString("随机选择", "Random");
public static readonly LocalisableString RandomSelect_Description = new EzLocalisableString("随机排列按键", "Random. Shuffle around the keys.");
public static readonly LocalisableString TrueRandom_Label = new EzLocalisableString("真随机", "True Random");
public static readonly LocalisableString TrueRandom_Description = new EzLocalisableString("随机排列所有音符使用NoLNLN转换器等级-3否则可能会重叠",
"True Random. Shuffle all notes(Use NoLN(LN Transformer Level -3), or you will get overlapping notes otherwise).");
// ====================================================================================================
// YuLiangSSSMods - Mod Descriptions
// ====================================================================================================
public static readonly LocalisableString ChangeSpeedByAccuracy_Description = new EzLocalisableString("根据准确度调整游戏速度", "Adapt the speed of the game based on the accuracy.");
public static readonly LocalisableString Adjust_Description = new EzLocalisableString("凉雨Mod一卡通", "Set your settings.");
public static readonly LocalisableString LN_Description = new EzLocalisableString("LN转换器", "LN Transformer");
public static readonly LocalisableString Cleaner_Description = new EzLocalisableString("清理谱面中的子弹或其他音符(例如重叠音符)", "Clean bullet or other notes on map(e.g. Overlap note).");
public static readonly LocalisableString LNJudgementAdjust_Description = new EzLocalisableString("调整LN的判定", "Adjust the judgement of LN.");
public static readonly LocalisableString LNSimplify_Description = new EzLocalisableString("通过转换简化节奏", "Simplifies rhythms by converting.");
public static readonly LocalisableString LNTransformer_Description = new EzLocalisableString("LN转换", "LN Transformer");
public static readonly LocalisableString NewJudgement_Description = new EzLocalisableString("根据歌曲BPM设置新的判定", "New judgement set by BPM of the song.");
public static readonly LocalisableString NtoM_Description = new EzLocalisableString("转换为更高的按键数模式", "Convert to upper Keys mode.");
public static readonly LocalisableString NtoMAnother_Description = new EzLocalisableString("转Key来自krrcream的工具有一些bug请使用Clean设置来清理",
"From krrcream's Tool (It has some bugs, please use Clean settings to clean it.)");
public static readonly LocalisableString Gracer_Description = new EzLocalisableString("转换为grace", "Convert to grace.");
public static readonly LocalisableString O2Judgement_Description = new EzLocalisableString("为O2JAM玩家设计的判定系统", "Judgement System for O2JAM players.");
public static readonly LocalisableString PlayfieldTransformation_Description = new EzLocalisableString("根据连击数调整游戏区域缩放", "Adjusts playfield scale based on combo.");
public static readonly LocalisableString O2Health_Description = new EzLocalisableString("为O2JAM玩家设计的生命值系统", "Health system for O2JAM players.");
public static readonly LocalisableString Remedy_Description = new EzLocalisableString("修复较低的判定", "Remedy lower judgement.");
public static readonly LocalisableString StarRatingRebirth_Description = new EzLocalisableString("sunnyxxy的星级算法替换官方星级标记", "New algorithm by sunnyxxy.");
public static readonly LocalisableString ReleaseAdjust_Description = new EzLocalisableString("不再需要计时长按音符的结尾", "No more timing the end of hold notes.");
public static readonly LocalisableString NoteAdjust_Description = new EzLocalisableString("制作更多或更少的音符", "To make more or less note.");
public static readonly LocalisableString LNLongShortAddition_Description = new EzLocalisableString("LN转换器附加版本", "LN Transformer additional version.");
public static readonly LocalisableString MalodyStyleLN_Description = new EzLocalisableString("像Malody一样播放LN", "Play LN like Malody!");
public static readonly LocalisableString LNDoubleDistribution_Description = new EzLocalisableString("LN转换器另一个版本", "LN Transformer another version.");
public static readonly LocalisableString JudgmentsAdjust_Description = new EzLocalisableString("修改你的判定", "Modify your judgement.");
public static readonly LocalisableString JackAdjust_Description = new EzLocalisableString("Jack的模式", "Pattern of Jack");
public static readonly LocalisableString CleanColumn_Description = new EzLocalisableString("清理Column, 推荐搭配Column Type使用",
"Clean Column, use with Column Type.");
// CleanColumn
public static readonly LocalisableString DeleteSColumn_Label = new EzLocalisableString("删除S列", "Delete S Column Type");
public static readonly LocalisableString DeleteSColumn_Description = new EzLocalisableString("开启时删除标记了S Column Type的列", "Delete columns marked with S column type when enabled");
public static readonly LocalisableString DeletePColumn_Label = new EzLocalisableString("删除P列", "Delete P Column Type");
public static readonly LocalisableString DeletePColumn_Description = new EzLocalisableString("开启时删除标记了P Column Type的列", "Delete columns marked with P column type when enabled");
public static readonly LocalisableString DeleteEColumn_Label = new EzLocalisableString("删除E列", "Delete E Column Type");
public static readonly LocalisableString DeleteEColumn_Description = new EzLocalisableString("开启时删除标记了E Column Type的列", "Delete columns marked with E column type when enabled");
public static readonly LocalisableString EnableCustomDelete_Label = new EzLocalisableString("自定义删除列", "Enable Custom Delete");
public static readonly LocalisableString EnableCustomDelete_Description = new EzLocalisableString("开启后启用自定义删除列功能", "Enable custom column deletion when enabled");
public static readonly LocalisableString CustomDeleteColumn_Label = new EzLocalisableString("自定义删除列序号", "Custom Delete Column Index");
public static readonly LocalisableString CustomDeleteColumn_Description = new EzLocalisableString("按照输入的序号,删除谱面中对应编号的列", "Delete the column with the specified index");
// ====================================================================================================
// YuLiangSSSMods - SettingSource Labels & Descriptions
// ====================================================================================================
// ChangeSpeedByAccuracy
public static readonly LocalisableString ChangeSpeedAccuracy_Label = new EzLocalisableString("准确度", "Accuracy");
public static readonly LocalisableString ChangeSpeedAccuracy_Description = new EzLocalisableString("应用速度变化的准确度", "Accuracy. Accuracy for speed change to be applied.");
public static readonly LocalisableString MaxSpeed_Label = new EzLocalisableString("最大速度", "Max Speed");
public static readonly LocalisableString MaxSpeed_Description = new EzLocalisableString("最大速度", "Max Speed");
public static readonly LocalisableString MinSpeed_Label = new EzLocalisableString("最小速度", "Min Speed");
public static readonly LocalisableString MinSpeed_Description = new EzLocalisableString("最小速度", "Min Speed");
// NiceBPM
public static readonly LocalisableString InitialRate_Label = new EzLocalisableString("初始速度", "Initial rate");
public static readonly LocalisableString InitialRate_Description = new EzLocalisableString("轨道的起始速度", "Initial rate. The starting speed of the track");
// Gracer
public static readonly LocalisableString Bias_Label = new EzLocalisableString("偏差", "Bias");
public static readonly LocalisableString Bias_Description = new EzLocalisableString("原始时机的偏差", "Bias. The bias of original timing.");
public static readonly LocalisableString Interval_Label = new EzLocalisableString("间隔", "Interval");
public static readonly LocalisableString Interval_Description = new EzLocalisableString("音符的最小间隔(防止重叠)", "Interval. The minimum interval of note(To prevent overlap).");
public static readonly LocalisableString Probability_Label = new EzLocalisableString("概率", "Probability");
public static readonly LocalisableString Probability_Description = new EzLocalisableString("转换概率", "Probability. The Probability of convertion.");
// NtoM, Gracer, JackAdjust
public static readonly LocalisableString Key_Label = new EzLocalisableString("按键数", "Key");
public static readonly LocalisableString Key_Description = new EzLocalisableString("目标按键数(只能从低按键数转换为高按键数)", "Key. To Keys(Can only convert lower keys to higher keys.)");
public static readonly LocalisableString BlankColumn_Label = new EzLocalisableString("空白列", "Blank Column");
public static readonly LocalisableString BlankColumn_Description = new EzLocalisableString("要添加的空白列数。(注意:如果按键数-圆形大小小于空白列数,则不会添加。)",
"Number of blank columns to add. (Notice: If the number of Key - CircleSize is less than the number of blank columns, it won't be added.)");
public static readonly LocalisableString NtoMGap_Label = new EzLocalisableString("间隙", "Gap");
public static readonly LocalisableString NtoMGap_Description = new EzLocalisableString("在每个区域重新排列音符。(间隙越大,音符分布越广。)",
"Rearrange the notes in every area. (If Gap is bigger, the notes will be more spread out.)");
public static readonly LocalisableString Clean_Label = new EzLocalisableString("清理", "Clean");
public static readonly LocalisableString Clean_Description = new EzLocalisableString("尝试清理谱面中的一些音符。", "Try to clean some notes in the map.");
public static readonly LocalisableString CleanDivide_Label = new EzLocalisableString("清理分割", "Clean Divide");
public static readonly LocalisableString CleanDivide_Description = new EzLocalisableString("选择清理的分割0表示不分割清理4推荐用于流8推荐用于Jack如果清理为false此设置不会被使用。",
"Choose the divide(0 For no Divide Clean, 4 is Recommended for Stream, 8 is Recommended for Jack) of cleaning. (If Clean is false, this setting won't be used.)");
public static readonly LocalisableString Adjust4Jack_Label = new EzLocalisableString("1/4 Jack", "1/4 Jack");
public static readonly LocalisableString Adjust4Jack_Description = new EzLocalisableString("如100+ BPM 1/4 Jack清理分割 * 1/2用于1/4 Jack避免清理1/4 Jack。",
"(Like 100+ BPM 1/4 Jack)Clean Divide * 1/2, for 1/4 Jack, avoiding cleaning 1/4 Jack.");
public static readonly LocalisableString Adjust4Speed_Label = new EzLocalisableString("1/4 Speed", "1/4 Speed");
public static readonly LocalisableString Adjust4Speed_Description = new EzLocalisableString("如300+ BPM 1/4 Speed清理分割 * 2用于1/4 Speed避免额外的1/2 Jack。",
"(Like 300+ BPM 1/4 Speed)Clean Divide * 2, for 1/4 Speed, avoiding additional 1/2 Jack.");
// JackAdjust
public static readonly LocalisableString ToStream_Label = new EzLocalisableString("转换为流", "To Stream");
public static readonly LocalisableString ToStream_Description = new EzLocalisableString("尽可能作为JumpJack推荐使用中等概率50~80",
"To Stream. As Jumpjack as possible(Recommend to use a medium(50~80) probability).");
public static readonly LocalisableString Line_Label = new EzLocalisableString("线", "Line");
public static readonly LocalisableString Line_Description = new EzLocalisableString("Jack的线", "Line. Line for Jack.");
public static readonly LocalisableString Alignment_Label = new EzLocalisableString("对齐", "Alignment");
public static readonly LocalisableString Alignment_Description = new EzLocalisableString("最后一行false或第一行truetrue会得到一些子弹false会得到许多长jack",
"Alignment. Last line(false) or first line(true), true will get some bullet, false will get many long jack.");
// NtoM, Gracer, JackAdjust, LNJudgementAdjust
public static readonly LocalisableString ApplyOrder_Label = new EzLocalisableString("应用顺序", "Apply Order");
public static readonly LocalisableString ApplyOrder_Description = new EzLocalisableString("此mod在谱面转换后应用的顺序。数字越小越先运行。",
"Apply Order. Order in which this mod is applied after beatmap conversion. Lower runs earlier.");
// JudgmentsAdjust
public static readonly LocalisableString CustomHitRange_Label = new EzLocalisableString("自定义打击范围", "Custom Hit Range");
public static readonly LocalisableString CustomHitRange_Description = new EzLocalisableString("调整音符的打击范围", "Custom Hit Range. Adjust the hit range of notes.");
public static readonly LocalisableString CustomProportionScore_Label = new EzLocalisableString("自定义比例分数", "Custom Proportion Score");
public static readonly LocalisableString CustomProportionScore_Description = new EzLocalisableString("自定义比例分数", "Custom Proportion Score");
// LNJudgementAdjust
public static readonly LocalisableString BodyJudgementSwitch_Label = new EzLocalisableString("主体判定开关", "Body Judgement Switch");
public static readonly LocalisableString BodyJudgementSwitch_Description = new EzLocalisableString("开启/关闭主体判定", "Turn on/off body judgement.");
public static readonly LocalisableString TailJudgementSwitch_Label = new EzLocalisableString("尾部判定开关", "Tail Judgement Switch");
public static readonly LocalisableString TailJudgementSwitch_Description = new EzLocalisableString("开启/关闭尾部判定", "Turn on/off tail judgement.");
// O2Judgement
public static readonly LocalisableString PillSwitch_Label = new EzLocalisableString("药丸开关", "Pill Switch");
public static readonly LocalisableString PillSwitch_Description = new EzLocalisableString("使用O2JAM药丸功能", "Use O2JAM pill function.");
// Cleaner
public static readonly LocalisableString Style_Label = new EzLocalisableString("样式", "Style");
public static readonly LocalisableString Style_Description = new EzLocalisableString("选择你的样式", "Choose your style.");
public static readonly LocalisableString LNInterval_Label = new EzLocalisableString("LN间隔", "LN Interval");
public static readonly LocalisableString LNInterval_Description = new EzLocalisableString("你决定的释放和按压速度", "The release & press speed you decide.");
// LN
public static readonly LocalisableString Divide_Label = new EzLocalisableString("分割", "Divide");
public static readonly LocalisableString Divide_Description = new EzLocalisableString("使用1/?", "Use 1/?");
public static readonly LocalisableString Percentage_Label = new EzLocalisableString("百分比", "Percentage");
public static readonly LocalisableString Percentage_Description = new EzLocalisableString("LN内容", "LN Content");
public static readonly LocalisableString OriginalLN_Label = new EzLocalisableString("原始LN", "Original LN");
public static readonly LocalisableString OriginalLN_Description = new EzLocalisableString("原始LN不会被转换", "Original LN won't be converted.");
public static readonly LocalisableString ColumnNum_Label = new EzLocalisableString("列数", "Column Num");
public static readonly LocalisableString ColumnNum_Description = new EzLocalisableString("选择要转换的列数", "Select the number of column to transform.");
public static readonly LocalisableString Gap_Label = new EzLocalisableString("间隙", "Gap");
public static readonly LocalisableString Gap_Description = new EzLocalisableString("转换后改变随机列的音符数量间隙", "For changing random columns after transforming the gap's number of notes.");
public static readonly LocalisableString LineSpacing_Label = new EzLocalisableString("行间距", "Line Spacing");
public static readonly LocalisableString LineSpacing_Description = new EzLocalisableString("设置为0时转换每一行", "Transform every line when set to 0.");
public static readonly LocalisableString InvertLineSpacing_Label = new EzLocalisableString("反转行间距", "Invert Line Spacing");
public static readonly LocalisableString InvertLineSpacing_Description = new EzLocalisableString("反转行间距", "Invert the Line Spacing.");
public static readonly LocalisableString DurationLimit_Label = new EzLocalisableString("持续时间限制", "Duration Limit");
public static readonly LocalisableString DurationLimit_Description = new EzLocalisableString("LN的最大持续时间设置为0时无限制", "The max duration(second) of a LN.(No limit when set to 0)");
// LNSimplify
public static readonly LocalisableString LimitDivide_Label = new EzLocalisableString("限制分割", "Limit Divide");
public static readonly LocalisableString LimitDivide_Description = new EzLocalisableString("选择限制", "Select limit.");
public static readonly LocalisableString EasierDivide_Label = new EzLocalisableString("简化分割", "Easier Divide");
public static readonly LocalisableString EasierDivide_Description = new EzLocalisableString("选择复杂度", "Select complexity.");
public static readonly LocalisableString LongestLN_Label = new EzLocalisableString("最长LN", "Longest LN");
public static readonly LocalisableString LongestLN_Description = new EzLocalisableString("最长LN", "Longest LN.");
public static readonly LocalisableString ShortestLN_Label = new EzLocalisableString("最短LN", "Shortest LN");
public static readonly LocalisableString ShortestLN_Description = new EzLocalisableString("最短LN", "Shortest LN.");
// O2Health
public static readonly LocalisableString Difficulty_Label = new EzLocalisableString("难度", "Difficulty");
public static readonly LocalisableString Difficulty_Description = new EzLocalisableString("1: 简单 2: 普通 3: 困难", "1: Easy 2: Normal 3: Hard");
// DoublePlay
public static readonly LocalisableString DoublePlayStyle_Label = new EzLocalisableString("样式", "Style");
public static readonly LocalisableString DoublePlayStyle_Description = new EzLocalisableString(
"1: NM+NM 2: MR+MR 3: NM+MR 4: MR+NM 5: Bracket NM+NM 6: Bracket MR 7: Wide Bracket 8: Wide Bracket MR",
"1: NM+NM 2: MR+MR 3: NM+MR 4: MR+NM 5: Bracket NM+NM 6: Bracket MR 7: Wide Bracket 8: Wide Bracket MR");
// PlayfieldTransformation
public static readonly LocalisableString MinimumScale_Label = new EzLocalisableString("最小缩放", "Minimum scale");
public static readonly LocalisableString MinimumScale_Description = new EzLocalisableString("游戏区域的最小缩放", "The minimum scale of the playfield.");
// ModStarRatingRebirth
public static readonly LocalisableString UseOriginalOD_Label = new EzLocalisableString("使用原始OD", "Use original OD");
public static readonly LocalisableString UseOriginalOD_Description = new EzLocalisableString("高优先级", "High Priority");
public static readonly LocalisableString UseCustomOD_Label = new EzLocalisableString("使用自定义OD", "Use custom OD");
public static readonly LocalisableString UseCustomOD_Description = new EzLocalisableString("低优先级", "Low Priority");
public static readonly LocalisableString OD_Label = new EzLocalisableString("OD", "OD");
public static readonly LocalisableString OD_Description = new EzLocalisableString("选择要重新计算的OD", "Choose the OD you want to recalculate.");
// Adjust
public static readonly LocalisableString ScoreMultiplier_Label = new EzLocalisableString("分数倍数", "Score Multiplier");
public static readonly LocalisableString HPDrain_Label = new EzLocalisableString("HP消耗", "HP Drain");
public static readonly LocalisableString HPDrain_Description = new EzLocalisableString("覆盖谱面的HP设置", "Override a beatmap's set HP.");
public static readonly LocalisableString AdjustAccuracy_Label = new EzLocalisableString("准确度", "Accuracy");
public static readonly LocalisableString AdjustAccuracy_Description = new EzLocalisableString("覆盖谱面的OD设置", "Override a beatmap's set OD.");
public static readonly LocalisableString ReleaseLenience_Label = new EzLocalisableString("释放宽容度", "Release Lenience");
public static readonly LocalisableString ReleaseLenience_Description = new EzLocalisableString("调整LN尾部释放窗口宽容度。Score v2中的尾部默认有1.5倍打击窗口)",
"Adjust LN tail release window lenience.(Tail in Score v2 has default 1.5x hit window)");
public static readonly LocalisableString CustomHP_Label = new EzLocalisableString("自定义HP", "Custom HP");
public static readonly LocalisableString CustomOD_Label = new EzLocalisableString("自定义OD", "Custom OD");
public static readonly LocalisableString CustomRelease_Label = new EzLocalisableString("自定义释放", "Custom Release");
public static readonly LocalisableString ExtendedLimits_Label = new EzLocalisableString("扩展限制", "Extended Limits");
public static readonly LocalisableString ExtendedLimits_Description = new EzLocalisableString("调整难度超出合理限制", "Adjust difficulty beyond sane limits.");
public static readonly LocalisableString AdjustConstantSpeed_Label = new EzLocalisableString("恒定速度", "Constant Speed");
public static readonly LocalisableString AdjustConstantSpeed_Description = new EzLocalisableString("不再有棘手的速度变化", "No more tricky speed changes.");
// NoteAdjust
public static readonly LocalisableString NoteAdjustStyle_Label = new EzLocalisableString("样式", "Style");
public static readonly LocalisableString NoteAdjustStyle_Description = new EzLocalisableString("1: 适用于Jack模式。2&3: 适用于Stream模式。4&5: 适用于Speed模式无Jack。6: DIY将使用↓↓↓所有选项1~5将仅使用↓种子选项",
"1: Applicable to Jack Pattern. 2&3: Applicable to Stream Pattern. 4&5: Applicable to Speed Pattern(No Jack). 6: DIY(Will use ↓↓↓ all options) (1~5 will only use ↓ seed option).");
public static readonly LocalisableString NoteAdjustProbability_Label = new EzLocalisableString("概率", "Probability");
public static readonly LocalisableString NoteAdjustProbability_Description = new EzLocalisableString("增加音符的概率", "The Probability of increasing note.");
public static readonly LocalisableString Extremum_Label = new EzLocalisableString("极值", "Extremum");
public static readonly LocalisableString Extremum_Description = new EzLocalisableString("取决于你在一行上保留多少音符(可用最大音符或最小音符)",
"Depending on how many notes on one line you keep(Available maximum note or minimum note).");
public static readonly LocalisableString ComparisonStyle_Label = new EzLocalisableString("比较样式", "Comparison Style");
public static readonly LocalisableString ComparisonStyle_Description = new EzLocalisableString("1: 当此行的音符数量>=上一行和下一行时处理一行。2: 当此行的音符数量<=上一行和下一行时处理一行",
"1: Dispose a line when this line's note quantity >= Last&Next line. 2: Dispose a line when this line's note quantity <= Last&Next line.");
public static readonly LocalisableString NoteAdjustLine_Label = new EzLocalisableString("线", "Line");
public static readonly LocalisableString NoteAdjustLine_Description = new EzLocalisableString("取决于这张图的难度0推荐用于Jack1推荐用于Jump/Hand/Etc.Stream2推荐用于Speed",
"Depending on how heavy about this map(0 is recommended for Jack, 1 is recommended for (Jump/Hand/Etc.)Stream, 2 is recommended for Speed).");
public static readonly LocalisableString Step_Label = new EzLocalisableString("步长", "Step");
public static readonly LocalisableString Step_Description = new EzLocalisableString("在一行上成功转换时跳过\"Step\"行", "Skip \"Step\" line when converting successfully on a line.");
public static readonly LocalisableString IgnoreComparison_Label = new EzLocalisableString("忽略比较", "Ignore Comparison");
public static readonly LocalisableString IgnoreComparison_Description = new EzLocalisableString("忽略比较条件", "Ignore condition of Comparison.");
public static readonly LocalisableString IgnoreInterval_Label = new EzLocalisableString("忽略间隔", "Ignore Interval");
public static readonly LocalisableString IgnoreInterval_Description = new EzLocalisableString("忽略音符间隔", "Ignore interval of note.");
// LNLongShortAddition
public static readonly LocalisableString LongShortPercent_Label = new EzLocalisableString("长/短百分比", "Long / Short %");
public static readonly LocalisableString LongShortPercent_Description = new EzLocalisableString("形状", "The Shape");
// LNDoubleDistribution
public static readonly LocalisableString Divide1_Label = new EzLocalisableString("分割1", "Divide 1");
public static readonly LocalisableString Divide1_Description = new EzLocalisableString("使用1/?", "Use 1/?");
public static readonly LocalisableString Divide2_Label = new EzLocalisableString("分割2", "Divide 2");
public static readonly LocalisableString Divide2_Description = new EzLocalisableString("使用1/?", "Use 1/?");
public static readonly LocalisableString Mu1_Label = new EzLocalisableString("μ1", "Mu 1");
public static readonly LocalisableString Mu1_Description = new EzLocalisableString("分布中的μ(百分比)", "Mu in distribution (Percentage).");
public static readonly LocalisableString Mu2_Label = new EzLocalisableString("μ2", "Mu 2");
public static readonly LocalisableString Mu2_Description = new EzLocalisableString("分布中的μ(百分比)", "Mu in distribution (Percentage).");
public static readonly LocalisableString MuRatio_Label = new EzLocalisableString("μ1/μ2", "Mu 1 / Mu 2");
public static readonly LocalisableString MuRatio_Description = new EzLocalisableString("百分比", "Percentage");
public static readonly LocalisableString SigmaInteger_Label = new EzLocalisableString("σ整数部分", "Sigma Integer Part");
public static readonly LocalisableString SigmaInteger_Description = new EzLocalisableString("σ除数(不是σ)", "Sigma Divisor (not sigma).");
public static readonly LocalisableString SigmaDecimal_Label = new EzLocalisableString("σ小数部分", "Sigma Decimal Part");
public static readonly LocalisableString SigmaDecimal_Description = new EzLocalisableString("σ除数(不是σ)", "Sigma Divisor (not sigma).");
}
}

View File

@@ -0,0 +1,44 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Reflection;
using osu.Game.LAsEzExtensions.Configuration;
namespace osu.Game.Rulesets.Mania.LAsEZMania
{
public class EzManiaStrings : EzLocalizationManager
{
static EzManiaStrings()
{
// 使用反射为未设置英文的属性自动生成英文属性名替换_为空格
var fields = typeof(EzManiaStrings).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy);
foreach (var field in fields)
{
if (field.FieldType == typeof(EzLocalisableString))
{
if (field.GetValue(null) is EzLocalisableString instance && instance.English == null)
{
instance.English = field.Name.Replace("_", " ");
}
}
}
}
// 本地化字符串类,直接持有中文和英文
public new class EzLocalisableString : EzLocalizationManager.EzLocalisableString
{
public EzLocalisableString(string chinese, string? english = null)
: base(chinese, english) { }
// 便捷构造函数:如果不提供英文,则稍后通过反射从属性名生成
public EzLocalisableString(string chinese)
: base(chinese) { }
}
// 公共属性定义本地化字符串,直接指定中文和英文
public static readonly EzLocalisableString Mania_Specific_Key = new EzLocalisableString("Mania特定中文");
// 添加更多属性...
}
}

View File

@@ -0,0 +1,174 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Game.Rulesets.Mania.Beatmaps;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Mania.LAsEZMania
{
public static class EzStageDefinitionExtensions
{
public static bool EzIsSpecialColumn(this StageDefinition stage, int columnIndex)
{
if (columnIndex < 0 || columnIndex >= stage.Columns)
return false;
return stage.Columns switch
{
7 when columnIndex is 3 => true,
9 when columnIndex is 4 => true,
12 when columnIndex is 0 or 11 => true,
14 when columnIndex is 0 or 12 => true,
16 when columnIndex is 0 or 15 => true,
_ => false
};
}
public static Color4 EzGetColumnColor(this StageDefinition stage, int columnIndex)
{
if (columnIndex < 0 || columnIndex >= stage.Columns)
return colour_column;
return stage.Columns switch
{
12 when columnIndex is 0 or 11 => colour_scratch,
14 when columnIndex is 0 or 12 => colour_scratch,
14 when columnIndex is 13 => colour_alpha,
14 when columnIndex is 6 => colour_panel,
16 when columnIndex is 0 or 15 => colour_scratch,
16 when columnIndex is 6 or 7 or 8 or 9 => colour_scratch,
_ => colour_column
};
}
private static readonly Color4 colour_column = new Color4(4, 4, 4, 255);
private static readonly Color4 colour_scratch = new Color4(20, 0, 0, 255);
private static readonly Color4 colour_panel = new Color4(0, 20, 0, 255);
private static readonly Color4 colour_alpha = new Color4(0, 0, 0, 0);
// 颜色定义
private static readonly Color4 colour_special = new Color4(206, 6, 3, 255);
private static readonly Color4 colour_green = new Color4(100, 192, 92, 255);
private static readonly Color4 colour_red = new Color4(206, 6, 3, 255);
private static readonly Color4 colour_withe = new Color4(222, 222, 222, 255);
private static readonly Color4 colour_blue = new Color4(55, 155, 255, 255);
private const int total_colours = 3;
private static readonly Color4 colour_cyan = new Color4(72, 198, 255, 255);
private static readonly Color4 colour_pink = new Color4(213, 35, 90, 255);
private static readonly Color4 colour_purple = new Color4(203, 60, 236, 255);
public static Color4 GetColourForLayout(this StageDefinition stage, int columnIndex)
{
columnIndex %= stage.Columns;
switch (stage.Columns)
{
case 4:
return columnIndex switch
{
0 => colour_green,
1 => colour_red,
2 => colour_blue,
3 => colour_cyan,
_ => throw new ArgumentOutOfRangeException()
};
case 5:
return columnIndex switch
{
0 => colour_green,
1 => colour_blue,
2 => colour_red,
3 => colour_cyan,
4 => colour_purple,
_ => throw new ArgumentOutOfRangeException()
};
case 7:
return columnIndex switch
{
1 or 5 => colour_withe,
0 or 2 or 4 or 6 => colour_blue,
3 => colour_green,
_ => throw new ArgumentOutOfRangeException()
};
case 8:
return columnIndex switch
{
0 or 4 => colour_red,
2 or 6 => colour_withe,
1 or 3 or 5 or 7 => colour_blue,
_ => throw new ArgumentOutOfRangeException()
};
case 9:
return columnIndex switch
{
0 or 6 or 7 => colour_red,
2 or 4 => colour_withe,
1 or 3 or 5 => colour_blue,
8 => colour_green,
_ => throw new ArgumentOutOfRangeException()
};
case 10:
return columnIndex switch
{
0 or 9 => colour_green,
2 or 4 or 5 or 7 => colour_withe,
1 or 3 or 6 or 8 => colour_blue,
_ => throw new ArgumentOutOfRangeException()
};
case 12:
return columnIndex switch
{
0 or 11 => colour_red,
1 or 3 or 5 or 6 or 8 or 10 => colour_withe,
2 or 4 or 7 or 9 => colour_blue,
_ => throw new ArgumentOutOfRangeException()
};
case 14:
return columnIndex switch
{
0 or 12 or 13 => colour_red,
1 or 3 or 5 or 7 or 9 or 11 => colour_withe,
2 or 4 or 8 or 10 => colour_blue,
6 => colour_green,
_ => throw new ArgumentOutOfRangeException()
};
case 16:
return columnIndex switch
{
0 or 6 or 7 or 8 or 9 or 15 => colour_red,
1 or 3 or 5 or 10 or 12 or 14 => colour_withe,
2 or 4 or 11 or 13 => colour_blue,
_ => throw new ArgumentOutOfRangeException()
};
}
// 后备逻辑保持不变
if (stage.EzIsSpecialColumn(columnIndex))
return colour_special;
switch (columnIndex % total_colours)
{
case 0: return colour_cyan;
case 1: return colour_pink;
case 2: return colour_purple;
default: throw new ArgumentOutOfRangeException();
}
}
}
}

View File

@@ -0,0 +1,261 @@
// 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.Localisation;
namespace osu.Game.Rulesets.Mania.LAsEZMania
{
/// <summary>
/// 供 FastSlowDisplay HUD组件使用的本地化字符串。
/// 代码文件来自于 YuLiangSSS。
/// </summary>
public static class FastSlowDisplayStrings
{
private const string prefix = @"osu.Game.Rulesets.Mania.LAsEZMania.FastSlowDisplay";
/// <summary>
/// "Perfect"
/// </summary>
public static LocalisableString Perfect => "Perfect";
/// <summary>
/// "Great"
/// </summary>
public static LocalisableString Great => "Great";
/// <summary>
/// "Good"
/// </summary>
public static LocalisableString Good => "Good";
/// <summary>
/// "Ok"
/// </summary>
public static LocalisableString Ok => "Ok";
/// <summary>
/// "Meh"
/// </summary>
public static LocalisableString Meh => "Meh";
/// <summary>
/// "Miss"
/// </summary>
public static LocalisableString Miss => "Miss";
/// <summary>
/// "Gap"
/// </summary>
public static LocalisableString Gap => "Gap";
/// <summary>
/// "Font Size"
/// </summary>
public static LocalisableString FontSize => "Font Size";
/// <summary>
/// "The size of the text."
/// </summary>
public static LocalisableString FontSizeDescription => "The size of the text.";
/// <summary>
/// "LN Switch"
/// </summary>
public static LocalisableString LNSwitch => "LN Switch";
/// <summary>
/// "Display LN tail individually."
/// </summary>
public static LocalisableString LNSwitchDescription => "Display LN tail individually.";
/// <summary>
/// "Fast Text"
/// </summary>
public static LocalisableString FastText => "Fast Text";
/// <summary>
/// "Fast Text LN"
/// </summary>
public static LocalisableString FastTextLN => "Fast Text LN";
/// <summary>
/// "Slow Text"
/// </summary>
public static LocalisableString SlowText => "Slow Text";
/// <summary>
/// "Slow Text LN"
/// </summary>
public static LocalisableString SlowTextLN => "Slow Text LN";
/// <summary>
/// "The text to be displayed."
/// </summary>
public static LocalisableString TextDescription => "The text to be displayed.";
/// <summary>
/// "Fast Colour"
/// </summary>
public static LocalisableString FastColour => "Fast Colour";
/// <summary>
/// "Fast Colour Style"
/// </summary>
public static LocalisableString FastColourStyle => "Fast Colour Style";
/// <summary>
/// "The style of the fast colour."
/// </summary>
public static LocalisableString FastColourStyleDescription => "The style of the fast colour.";
/// <summary>
/// "Slow Colour"
/// </summary>
public static LocalisableString SlowColour => "Slow Colour";
/// <summary>
/// "Slow Colour Style"
/// </summary>
public static LocalisableString SlowColourStyle => "Slow Colour Style";
/// <summary>
/// "The style of the slow colour."
/// </summary>
public static LocalisableString SlowColourStyleDescription => "The style of the slow colour.";
/// <summary>
/// "The colour of the text."
/// </summary>
public static LocalisableString TextColourDescription => "The colour of the text.";
/// <summary>
/// "The gap between fast and slow."
/// </summary>
public static LocalisableString GapDescription => "The gap between fast and slow.";
/// <summary>
/// "Fade Duration"
/// </summary>
public static LocalisableString FadeDuration => "Fade Duration";
/// <summary>
/// "The duration of the fade out effect."
/// </summary>
public static LocalisableString FadeDurationDescription => "The duration of the fade out effect.";
/// <summary>
/// "Show Judgement"
/// </summary>
public static LocalisableString ShowJudgement => "Judgement";
/// <summary>
/// "Fade before first judgement."
/// </summary>
public static LocalisableString FadeBeforeFirstJudgement => "Fade before first judgement";
/// <summary>
/// "See if your SS missed."
/// </summary>
public static LocalisableString FadeBeforeFirstJudgementDescription => "See if your SS missed.";
/// <summary>
/// "How to show fast/slow."
/// </summary>
public static LocalisableString ShowStyleDescription => "When to show fast/slow.";
/// <summary>
/// "Horizontal / Vertical Display"
/// </summary>
public static LocalisableString DisplayStyle => "Horizontal / Vertical";
/// <summary>
/// "Display the text horizontally or vertically."
/// </summary>
public static LocalisableString DisplayStyleDescription => "Display the text horizontally or vertically.";
/// <summary>
/// "Test"
/// </summary>
public static LocalisableString Test => "Test";
/// <summary>
/// "Preview the display of fast/slow."
/// </summary>
public static LocalisableString TestDescription => "Preview the display of fast/slow.";
/// <summary>
/// "Lower Column Bound"
/// </summary>
public static LocalisableString LowerColumn => "Lower Column Bound";
/// <summary>
/// "Upper Column Bound"
/// </summary>
public static LocalisableString UpperColumn => "Uppper Column Bound";
/// <summary>
/// "The lower bound of the column to display the text."
/// </summary>
public static LocalisableString LowerColumnDescription => "The lower bound of the column to display the text.";
/// <summary>
/// "The upper bound of the column to display the text."
/// </summary>
public static LocalisableString UpperColumnDescription => "The upper bound of the column to display the text.";
/// <summary>
/// "Only Display One"
/// </summary>
public static LocalisableString OnlyDisplayOne => "Only Display One";
/// <summary>
/// "Display only one text at a time."
/// </summary>
public static LocalisableString OnlyDisplayOneDescription => "Display only one text at a time.";
/// <summary>
/// "None"
/// </summary>
public static LocalisableString None => "None";
/// <summary>
/// "Left Half"
/// </summary>
public static LocalisableString LeftHalf => "Left Half";
/// <summary>
/// "Right Half"
/// </summary>
public static LocalisableString RightHalf => "Right Half";
/// <summary>
/// "Middle"
/// </summary>
public static LocalisableString Middle => "Middle";
/// <summary>
/// "Single Colour"
/// </summary>
public static LocalisableString SingleColour => "Single Colour";
/// <summary>
/// "Horizontal Gradient"
/// </summary>
public static LocalisableString HorizontalGradient => "Horizontal Gradient";
/// <summary>
/// "Vertical Gradient"
/// </summary>
public static LocalisableString VerticalGradient => "Vertical Gradient";
/// <summary>
/// "Select Column"
/// </summary>
public static LocalisableString SelectColumn => "Select Column";
/// <summary>
/// "Select the column to display the text."
/// </summary>
public static LocalisableString SelectColumnDescription => "Select the column to display the text.";
}
}

View File

@@ -0,0 +1,350 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Game.Beatmaps;
using osu.Game.LAsEzExtensions.Background;
using osu.Game.LAsEzExtensions.Configuration;
using osu.Game.Rulesets.Mania.Objects.EzCurrentHitObject;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.LAsEZMania.Helper
{
public class CustomHitWindowsHelper
{
private static readonly double[,] hit_range_bms =
{
// 305 300 200 100 50 Miss Poor
{ 16.67, 33.33, 116.67, 250, 250, 250, 500 }, // IIDX
{ 15.00, 30.00, 060.00, 200, 200, 1000, 1000 }, // LR2 Hard
{ 15.00, 45.00, 112.00, 165, 165, 500, 500 }, // raja normal (75%)
{ 20.00, 60.00, 150.00, 220, 500, 500, 500 }, // raja easy (100%)
};
private EzMUGHitMode hitMode = EzMUGHitMode.Classic;
public EzMUGHitMode HitMode
{
get => hitMode;
set
{
hitMode = value;
updateRanges();
}
}
private double totalMultiplier = 1.0;
public double TotalMultiplier
{
get => totalMultiplier;
set
{
totalMultiplier = value;
updateRanges();
}
}
private double overallDifficulty = 1.0;
public double OverallDifficulty
{
get => overallDifficulty;
set
{
overallDifficulty = value;
updateRanges();
}
}
private double bpm;
public double BPM
{
get => bpm;
set
{
bpm = value;
updateRanges();
}
}
// Ranges compatible with Mania naming used elsewhere (Range305 == Perfect, Range300 == Great, ...)
public double Range305 { get; private set; }
public double Range300 { get; private set; }
public double Range200 { get; private set; }
public double Range100 { get; private set; }
public double Range050 { get; private set; }
public double Range000 { get; private set; }
public double PoolRange { get; private set; }
private static readonly DifficultyRange perfect_window_range = new DifficultyRange(22.4D, 19.4D, 13.9D);
private static readonly DifficultyRange great_window_range = new DifficultyRange(64, 49, 34);
private static readonly DifficultyRange good_window_range = new DifficultyRange(97, 82, 67);
private static readonly DifficultyRange ok_window_range = new DifficultyRange(127, 112, 97);
private static readonly DifficultyRange meh_window_range = new DifficultyRange(151, 136, 121);
private static readonly DifficultyRange miss_window_range = new DifficultyRange(188, 173, 158);
private const double pool_offset = 150.0;
public CustomHitWindowsHelper()
: this(GlobalConfigStore.EzConfig?.Get<EzMUGHitMode>(Ez2Setting.HitMode) ?? EzMUGHitMode.Classic)
{
}
public CustomHitWindowsHelper(EzMUGHitMode hitMode)
{
HitMode = hitMode;
// UpdateRanges is called by the property setter
}
public double[] GetHitWindowsClassic()
{
double invertedOd = Math.Clamp(10 - OverallDifficulty, 0, 10);
Range305 = Math.Floor(16 * TotalMultiplier) + 0.5;
Range300 = Math.Floor((34 + 3 * invertedOd) * TotalMultiplier) + 0.5;
Range200 = Math.Floor((67 + 3 * invertedOd) * TotalMultiplier) + 0.5;
Range100 = Math.Floor((97 + 3 * invertedOd) * TotalMultiplier) + 0.5;
Range050 = Math.Floor((121 + 3 * invertedOd) * TotalMultiplier) + 0.5;
Range000 = Math.Floor((158 + 3 * invertedOd) * TotalMultiplier) + 0.5;
PoolRange = Range000 + pool_offset;
return new[] { Range305, Range300, Range200, Range100, Range050, Range000, PoolRange };
}
public double[] GetHitWindowsO2Jam(double setBpm)
{
bpm = setBpm;
Range305 = 7500.0 / bpm * TotalMultiplier;
Range300 = Range305;
Range200 = 22500.0 / bpm * TotalMultiplier;
Range100 = Range200;
Range050 = 31250.0 / bpm * TotalMultiplier;
Range000 = Range050;
PoolRange = Range000 + pool_offset;
return new[] { Range305, Range300, Range200, Range100, Range050, Range000, PoolRange };
}
public double[] GetHitWindowsEZ2AC()
{
Range305 = 18.0 * TotalMultiplier;
Range300 = 38.0 * TotalMultiplier;
Range200 = 68.0 * TotalMultiplier;
Range100 = 88.0 * TotalMultiplier;
Range050 = 88.0 * TotalMultiplier;
Range000 = 100.0 * TotalMultiplier;
PoolRange = pool_offset;
return new[] { Range305, Range300, Range200, Range100, Range050, Range000, PoolRange };
}
public double[] GetHitWindowsIIDX(EzMUGHitMode mode)
{
int row = 0;
switch (mode)
{
case EzMUGHitMode.IIDX_HD:
row = 0;
break;
case EzMUGHitMode.LR2_HD:
row = 1;
break;
case EzMUGHitMode.Raja_NM:
row = 2;
break;
}
Range305 = hit_range_bms[row, 0] * TotalMultiplier;
Range300 = hit_range_bms[row, 1] * TotalMultiplier;
Range200 = hit_range_bms[row, 2] * TotalMultiplier;
Range100 = hit_range_bms[row, 3] * TotalMultiplier;
Range050 = hit_range_bms[row, 4] * TotalMultiplier;
Range000 = hit_range_bms[row, 5] * TotalMultiplier;
PoolRange = hit_range_bms[row, 6] * TotalMultiplier;
return new[] { Range305, Range300, Range200, Range100, Range050, Range000, PoolRange };
}
public double[] GetHitWindowsMelody()
{
Range305 = 20.0 * TotalMultiplier;
Range300 = 40.0 * TotalMultiplier;
Range200 = 60.0 * TotalMultiplier;
Range100 = 80.0 * TotalMultiplier;
Range050 = 100.0 * TotalMultiplier;
Range000 = 120.0 * TotalMultiplier;
PoolRange = Range000 + pool_offset;
return new[] { Range305, Range300, Range200, Range100, Range050, Range000, PoolRange };
}
private void updateRanges()
{
switch (HitMode)
{
case EzMUGHitMode.O2Jam:
SetRanges(GetHitWindowsO2Jam(bpm));
break;
case EzMUGHitMode.Lazer:
double perfect = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(OverallDifficulty, perfect_window_range) * TotalMultiplier) + 0.5;
double great = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(OverallDifficulty, great_window_range) * TotalMultiplier) + 0.5;
double good = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(OverallDifficulty, good_window_range) * TotalMultiplier) + 0.5;
double ok = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(OverallDifficulty, ok_window_range) * TotalMultiplier) + 0.5;
double meh = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(OverallDifficulty, meh_window_range) * TotalMultiplier) + 0.5;
double miss = Math.Floor(IBeatmapDifficultyInfo.DifficultyRange(OverallDifficulty, miss_window_range) * TotalMultiplier) + 0.5;
double pool = miss + pool_offset;
SetRanges(new[] { perfect, great, good, ok, meh, miss, pool });
break;
case EzMUGHitMode.EZ2AC:
SetRanges(GetHitWindowsEZ2AC());
break;
case EzMUGHitMode.IIDX_HD:
SetRanges(GetHitWindowsIIDX(0));
break;
case EzMUGHitMode.Malody:
SetRanges(GetHitWindowsMelody());
break;
default:
SetRanges(GetHitWindowsClassic());
break;
}
}
public HitResult ResultFor(double timeOffset)
{
timeOffset = Math.Abs(timeOffset);
if (AllowPoolEnabled)
{
if (IsHitResultAllowed(HitResult.Pool))
{
double miss = WindowFor(HitResult.Miss);
double poolEarlyWindow = miss + 50;
double poolLateWindow = miss + 50;
if (timeOffset > -poolEarlyWindow &&
timeOffset < -miss ||
timeOffset < poolLateWindow &&
timeOffset > miss)
return HitResult.Pool;
}
}
for (var result = HitResult.Perfect; result >= HitResult.Miss; --result)
{
if (IsHitResultAllowed(result) && timeOffset <= WindowFor(result))
return result;
}
return HitResult.None;
}
public virtual bool AllowPoolEnabled => GlobalConfigStore.EzConfig?.Get<bool>(Ez2Setting.CustomPoorHitResultBool) ?? false;
public virtual bool IsHitResultAllowed(HitResult result)
{
switch (result)
{
case HitResult.Perfect:
case HitResult.Great:
case HitResult.Good:
case HitResult.Ok:
case HitResult.Meh:
case HitResult.Miss:
return true;
case HitResult.Pool:
return AllowPoolEnabled;
default:
return false;
}
}
public double WindowFor(HitResult result)
{
switch (result)
{
case HitResult.Perfect: return Range305;
case HitResult.Great: return Range300;
case HitResult.Good: return Range200;
case HitResult.Ok: return Range100;
case HitResult.Meh: return Range050;
case HitResult.Pool: return PoolRange;
case HitResult.Miss: return Range000;
default: throw new ArgumentOutOfRangeException(nameof(result), result, null);
}
}
/// <summary>
/// Allow external code to replace the current windows (e.g. when switching hit modes).
/// </summary>
public void SetRanges(double[]? ranges)
{
if (ranges == null) return;
if (ranges.Length >= 6)
{
Range305 = ranges[0];
Range300 = ranges[1];
Range200 = ranges[2];
Range100 = ranges[3];
Range050 = ranges[4];
Range000 = ranges[5];
}
if (ranges.Length >= 7)
PoolRange = ranges[6];
else
PoolRange = Range000 + pool_offset;
}
/// <summary>
/// Compute LN (long note) tail score given head and tail offsets using this helper's ranges.
/// </summary>
public double GetLNScore(double head, double tail)
{
// This LN scoring method is Classic-specific: always use Classic hit windows
double[] classicRanges = GetHitWindowsClassic();
double r305 = classicRanges[0];
double r300 = classicRanges[1];
double r200 = classicRanges[2];
double r100 = classicRanges[3];
double r050 = classicRanges[4];
double combined = head + tail;
(double range, double headFactor, double combinedFactor, double score)[] rules = new[]
{
(range: r305, headFactor: 1.2, combinedFactor: 2.4, score: 300.0),
(range: r300, headFactor: 1.1, combinedFactor: 2.2, score: 300),
(range: r200, headFactor: 1.0, combinedFactor: 2.0, score: 200),
(range: r100, headFactor: 1.0, combinedFactor: 2.0, score: 100),
(range: r050, headFactor: 1.0, combinedFactor: 2.0, score: 50),
};
foreach (var (range, headFactor, combinedFactor, score) in rules)
{
if (head < range * headFactor && combined < range * combinedFactor)
return score;
}
return 0;
}
}
}

View File

@@ -0,0 +1,89 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Specialized;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.LAsEzExtensions.Configuration;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Screens;
using osu.Game.Screens.Play.HUD;
using osuTK;
namespace osu.Game.Rulesets.Mania.LAsEZMania
{
public abstract partial class ManiaKeyCounterDisplay : Container
{
[Resolved]
protected StageDefinition StageDefinition { get; private set; } = null!;
[Resolved]
protected InputCountController Controller { get; private set; } = null!;
[Resolved]
private Ez2ConfigManager ezSkinConfig { get; set; } = null!;
protected readonly FillFlowContainer<KeyCounter> KeyFlow;
private readonly IBindableList<InputTrigger> triggers = new BindableList<InputTrigger>();
private IBindable<double> columnWidth = null!;
private IBindable<double> specialFactor = null!;
protected ManiaKeyCounterDisplay()
{
AutoSizeAxes = Axes.Both;
InternalChild = KeyFlow = new FillFlowContainer<KeyCounter>
{
Direction = FillDirection.Horizontal,
AutoSizeAxes = Axes.Both,
Spacing = new Vector2(0),
};
}
[BackgroundDependencyLoader]
private void load()
{
columnWidth = ezSkinConfig.GetBindable<double>(Ez2Setting.ColumnWidth);
specialFactor = ezSkinConfig.GetBindable<double>(Ez2Setting.SpecialFactor);
}
protected override void LoadComplete()
{
base.LoadComplete();
triggers.BindTo(Controller.Triggers);
triggers.BindCollectionChanged(triggersChanged, true);
columnWidth.BindValueChanged(_ => updateCounterWidths());
specialFactor.BindValueChanged(_ => updateCounterWidths());
}
private void updateCounterWidths()
{
foreach (var counter in KeyFlow)
{
float width = (float)columnWidth.Value;
int index = KeyFlow.IndexOf(counter);
if (ezSkinConfig.IsSpecialColumn(StageDefinition.Columns, index))
width *= (float)specialFactor.Value;
counter.Width = width;
}
}
private void triggersChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
KeyFlow.Clear();
foreach (var trigger in Controller.Triggers)
KeyFlow.Add(CreateCounter(trigger));
updateCounterWidths();
}
protected abstract KeyCounter CreateCounter(InputTrigger trigger);
}
}

View File

@@ -29,6 +29,12 @@ namespace osu.Game.Rulesets.Mania
bool keyCountMatch = includedKeyCounts.Contains(keyCount);
bool longNotePercentageMatch = !longNotePercentage.HasFilter || (!isConvertedBeatmap(beatmapInfo) && longNotePercentage.IsInRange(calculateLongNotePercentage(beatmapInfo)));
//多选过滤实现
if (criteria.DiscreteCircleSizeValues?.Any() == true)
{
keyCountMatch = criteria.DiscreteCircleSizeValues.Contains(keyCount);
}
return keyCountMatch && longNotePercentageMatch;
}

View File

@@ -1,4 +1,4 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
@@ -9,10 +9,13 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Bindings;
using osu.Framework.Localisation;
using osu.Framework.Logging;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Legacy;
using osu.Game.Configuration;
using osu.Game.Graphics;
using osu.Game.LAsEzExtensions.Analysis;
using osu.Game.LAsEzExtensions.Background;
using osu.Game.Localisation;
using osu.Game.Overlays.Settings;
using osu.Game.Rulesets.Configuration;
@@ -24,12 +27,19 @@ using osu.Game.Rulesets.Mania.Configuration;
using osu.Game.Rulesets.Mania.Difficulty;
using osu.Game.Rulesets.Mania.Edit;
using osu.Game.Rulesets.Mania.Edit.Setup;
using osu.Game.Rulesets.Mania.LAsEzMania.Analysis;
using osu.Game.Rulesets.Mania.Mods;
using osu.Game.Rulesets.Mania.Mods.LAsMods;
using osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Replays;
using osu.Game.Rulesets.Mania.Scoring;
using osu.Game.Rulesets.Mania.Skinning.Argon;
using osu.Game.Rulesets.Mania.Skinning.Default;
using osu.Game.Rulesets.Mania.Skinning.Ez2;
using osu.Game.Rulesets.Mania.Skinning.EzStylePro;
using osu.Game.Rulesets.Mania.Skinning.Legacy;
using osu.Game.Rulesets.Mania.Skinning.SbI;
using osu.Game.Rulesets.Mania.UI;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Replays.Types;
@@ -48,7 +58,7 @@ namespace osu.Game.Rulesets.Mania
/// <summary>
/// The maximum number of supported keys in a single stage.
/// </summary>
public const int MAX_STAGE_KEYS = 10;
public const int MAX_STAGE_KEYS = 18;
public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList<Mod>? mods = null) => new DrawableManiaRuleset(this, beatmap, mods);
@@ -78,6 +88,27 @@ namespace osu.Game.Rulesets.Mania
case ArgonSkin:
return new ManiaArgonSkinTransformer(skin, beatmap);
case Ez2Skin:
if (GlobalConfigStore.EzConfig == null)
{
Logger.Log("!GlobalConfigStore.EzConfig", LoggingTarget.Runtime, LogLevel.Important);
break;
}
return new ManiaEz2SkinTransformer(skin, beatmap, GlobalConfigStore.EzConfig);
case EzStyleProSkin:
if (GlobalConfigStore.EzConfig == null)
{
Logger.Log("!GlobalConfigStore.EzConfig", LoggingTarget.Runtime, LogLevel.Important);
break;
}
return new ManiaEzStyleProSkinTransformer(skin, beatmap, GlobalConfigStore.EzConfig);
case SbISkin:
return new ManiaSbISkinTransformer(skin, beatmap);
case DefaultLegacySkin:
case RetroSkin:
return new ManiaClassicSkinTransformer(skin, beatmap);
@@ -288,6 +319,46 @@ namespace osu.Game.Rulesets.Mania
new MultiMod(new ManiaModAutoplay(), new ManiaModCinema()),
};
case ModType.YuLiangSSS_Mod:
return new Mod[]
{
new ManiaModAdjust(),
new ManiaModNtoM(),
new ManiaModNtoMAnother(),
// new ManiaModChangeSpeedByAccuracy(), // 无法使用
new ManiaModCleaner(),
new ManiaModDoublePlay(),
new ManiaModGracer(),
new ManiaModJackAdjust(),
new ManiaModJudgmentsAdjust(),
// new ManiaModLN(),
new ManiaModLNDoubleDistribution(),
new ManiaModLNJudgementAdjust(),
new ManiaModLNLongShortAddition(),
new ManiaModLNSimplify(),
new ManiaModLNTransformer(),
new ManiaModMalodyStyleLN(),
new ManiaModNewJudgement(),
new ManiaModNoteAdjust(),
new ManiaModO2Health(),
new ManiaModO2Judgement(),
new ManiaModPlayfieldTransformation(), //加载有问题
new ManiaModReleaseAdjust(),
new ManiaModRemedy(),
new ModStarRatingRebirth(),
};
case ModType.LA_Mod:
return new Mod[]
{
new ManiaModEz2Settings(),
new ManiaModCleanColumn(), // 待调试
new ManiaModNiceBPM(),
new ManiaModSpaceBody(),
new ManiaModLoopPlayClip(),
new ManiaModSRAdjust(),
};
case ModType.Fun:
return new Mod[]
{
@@ -333,8 +404,6 @@ namespace osu.Game.Rulesets.Mania
{
for (int i = 1; i <= MAX_STAGE_KEYS; i++)
yield return (int)PlayfieldType.Single + i;
for (int i = 2; i <= MAX_STAGE_KEYS * 2; i += 2)
yield return (int)PlayfieldType.Dual + i;
}
}
@@ -344,9 +413,6 @@ namespace osu.Game.Rulesets.Mania
{
case PlayfieldType.Single:
return new SingleStageVariantGenerator(variant).GenerateMappings();
case PlayfieldType.Dual:
return new DualStageVariantGenerator(getDualStageKeyCount(variant)).GenerateMappings();
}
return Array.Empty<KeyBinding>();
@@ -358,21 +424,9 @@ namespace osu.Game.Rulesets.Mania
{
default:
return $"{variant}K";
case PlayfieldType.Dual:
{
int keys = getDualStageKeyCount(variant);
return $"{keys}K + {keys}K";
}
}
}
/// <summary>
/// Finds the number of keys for each stage in a <see cref="PlayfieldType.Dual"/> variant.
/// </summary>
/// <param name="variant">The variant.</param>
private int getDualStageKeyCount(int variant) => (variant - (int)PlayfieldType.Dual) / 2;
/// <summary>
/// Finds the <see cref="PlayfieldType"/> that corresponds to a variant value.
/// </summary>
@@ -392,30 +446,54 @@ namespace osu.Game.Rulesets.Mania
HitResult.Good,
HitResult.Ok,
HitResult.Meh,
HitResult.Pool,
HitResult.IgnoreHit,
HitResult.IgnoreMiss,
// HitResult.SmallBonus is used for awarding perfect bonus score but is not included here as
// it would be a bit redundant to show this to the user.
};
}
public override StatisticItem[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => new[]
public override StatisticItem[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap)
{
new StatisticItem("Performance Breakdown", () => new PerformanceBreakdownChart(score, playableBeatmap)
var hitEventsByColumn = score.HitEvents
.Where(e => e.HitObject is ManiaHitObject)
.GroupBy(e => ((ManiaHitObject)e.HitObject).Column)
.OrderBy(g => g.Key)
.ToList();
var statistics = new List<StatisticItem>
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y
}),
new StatisticItem("Timing Distribution", () => new HitEventTimingDistributionGraph(score.HitEvents)
{
RelativeSizeAxes = Axes.X,
Height = 250
}, true),
new StatisticItem("Statistics", () => new SimpleStatisticTable(2, new SimpleStatisticItem[]
{
new AverageHitError(score.HitEvents),
new UnstableRate(score.HitEvents)
}), true)
};
new StatisticItem("Performance Breakdown", () => new PerformanceBreakdownChart(score, playableBeatmap)
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y
}),
new StatisticItem("Space Graph", () => new EzManiaScoreGraph(score, playableBeatmap)
{
RelativeSizeAxes = Axes.X,
Height = 200
}, true),
new StatisticItem("Timing Distribution", () => new HitEventTimingDistributionGraph(score.HitEvents)
{
RelativeSizeAxes = Axes.X,
Height = 120
}, true),
new StatisticItem("Column Timing Distributions", () => new CreateRotatedColumnGraphs(hitEventsByColumn)
{
RelativeSizeAxes = Axes.X,
Height = 250,
}, true),
new StatisticItem("Statistics", () => new SimpleStatisticTable(2, new SimpleStatisticItem[]
{
new AverageHitError(score.HitEvents),
new UnstableRate(score.HitEvents)
}), true)
};
return statistics.ToArray();
}
/// <seealso cref="ManiaHitWindows"/>
public override BeatmapDifficulty GetAdjustedDisplayDifficulty(IBeatmapInfo beatmapInfo, IReadOnlyCollection<Mod> mods)

View File

@@ -8,9 +8,11 @@ using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Localisation;
using osu.Game.Graphics.UserInterface;
using osu.Game.LAsEzExtensions.Configuration;
using osu.Game.Localisation;
using osu.Game.Overlays.Settings;
using osu.Game.Rulesets.Mania.Configuration;
using osu.Game.Rulesets.Mania.LAsEZMania;
using osu.Game.Rulesets.Mania.UI;
namespace osu.Game.Rulesets.Mania
@@ -25,22 +27,71 @@ namespace osu.Game.Rulesets.Mania
}
[BackgroundDependencyLoader]
private void load()
private void load(Ez2ConfigManager ezConfig)
{
var config = (ManiaRulesetConfigManager)Config;
Children = new Drawable[]
{
new SettingsEnumDropdown<EzMUGHitMode>
{
ClassicDefault = EzMUGHitMode.EZ2AC,
LabelText = EzLocalizationManager.HitMode,
TooltipText = EzLocalizationManager.HitModeTooltip,
Current = ezConfig.GetBindable<EzMUGHitMode>(Ez2Setting.HitMode),
Keywords = new[] { "mania" }
},
new SettingsEnumDropdown<EnumHealthMode>
{
ClassicDefault = EnumHealthMode.Lazer,
Current = ezConfig.GetBindable<EnumHealthMode>(Ez2Setting.CustomHealthMode),
LabelText = EzLocalizationManager.HealthMode,
TooltipText = EzLocalizationManager.HealthModeTooltip,
Keywords = new[] { "mania" }
},
new SettingsCheckbox
{
Current = ezConfig.GetBindable<bool>(Ez2Setting.CustomPoorHitResultBool),
LabelText = EzLocalizationManager.PoorHitResult,
TooltipText = EzLocalizationManager.PoorHitResultTooltip,
Keywords = new[] { "mania" }
},
new SettingsCheckbox
{
Current = ezConfig.GetBindable<bool>(Ez2Setting.ManiaBarLinesBool),
LabelText = EzLocalizationManager.ManiaBarLinesBool,
TooltipText = EzLocalizationManager.ManiaBarLinesBoolTooltip,
Keywords = new[] { "mania" }
},
new SettingsEnumDropdown<ManiaScrollingDirection>
{
LabelText = RulesetSettingsStrings.ScrollingDirection,
Current = config.GetBindable<ManiaScrollingDirection>(ManiaRulesetSetting.ScrollDirection)
},
new SettingsEnumDropdown<EzManiaScrollingStyle>
{
LabelText = "Scrolling style",
Current = config.GetBindable<EzManiaScrollingStyle>(ManiaRulesetSetting.ScrollStyle)
},
new SettingsSlider<double, ManiaScrollSlider>
{
LabelText = RulesetSettingsStrings.ScrollSpeed,
Current = config.GetBindable<double>(ManiaRulesetSetting.ScrollSpeed),
KeyboardStep = 1
KeyboardStep = 1,
},
new SettingsSlider<double, ManiaScrollBaseSlider>
{
LabelText = "Scroll Base MS (when 200 Speed)",
Current = config.GetBindable<double>(ManiaRulesetSetting.ScrollBaseSpeed),
KeyboardStep = 1,
Keywords = new[] { "base" }
},
new SettingsSlider<double, ManiaScrollMsPerSpeedSlider>
{
LabelText = "MS / Speed",
Current = config.GetBindable<double>(ManiaRulesetSetting.ScrollTimePerSpeed),
KeyboardStep = 1,
Keywords = new[] { "mps" }
},
new SettingsCheckbox
{
@@ -71,7 +122,72 @@ namespace osu.Game.Rulesets.Mania
private partial class ManiaScrollSlider : RoundedSliderBar<double>
{
public override LocalisableString TooltipText => RulesetSettingsStrings.ScrollSpeedTooltip((int)DrawableManiaRuleset.ComputeScrollTime(Current.Value), Current.Value);
// 自定义提示
private ManiaRulesetConfigManager config = null!;
[BackgroundDependencyLoader]
private void load(ManiaRulesetConfigManager config)
{
this.config = config;
}
public override LocalisableString TooltipText
{
get
{
double baseSpeed = config.Get<double>(ManiaRulesetSetting.ScrollBaseSpeed);
double timePerSpeed = config.Get<double>(ManiaRulesetSetting.ScrollTimePerSpeed);
int computedTime = (int)DrawableManiaRuleset.ComputeScrollTime(Current.Value, baseSpeed, timePerSpeed);
LocalisableString speedInfo = RulesetSettingsStrings.ScrollSpeedTooltip(computedTime, Current.Value);
return $"{baseSpeed}base - ( {Current.Value} - 200) * {timePerSpeed}mps\n = {speedInfo}";
}
}
}
private partial class ManiaScrollBaseSlider : RoundedSliderBar<double>
{
private ManiaRulesetConfigManager config = null!;
[BackgroundDependencyLoader]
private void load(ManiaRulesetConfigManager config)
{
this.config = config;
}
public override LocalisableString TooltipText
{
get
{
double speed = config.Get<double>(ManiaRulesetSetting.ScrollSpeed);
double timePerSpeed = config.Get<double>(ManiaRulesetSetting.ScrollTimePerSpeed);
int computedTime = (int)DrawableManiaRuleset.ComputeScrollTime(speed, Current.Value, timePerSpeed);
LocalisableString speedInfo = RulesetSettingsStrings.ScrollSpeedTooltip(computedTime, speed);
return $"{Current.Value}base - ( {speed} - 200) * {timePerSpeed}mps\n = {speedInfo}";
}
}
}
private partial class ManiaScrollMsPerSpeedSlider : RoundedSliderBar<double>
{
private ManiaRulesetConfigManager config = null!;
[BackgroundDependencyLoader]
private void load(ManiaRulesetConfigManager config)
{
this.config = config;
}
public override LocalisableString TooltipText
{
get
{
double speed = config.Get<double>(ManiaRulesetSetting.ScrollSpeed);
double baseSpeed = config.Get<double>(ManiaRulesetSetting.ScrollBaseSpeed);
int computedTime = (int)DrawableManiaRuleset.ComputeScrollTime(speed, baseSpeed, Current.Value);
LocalisableString speedInfo = RulesetSettingsStrings.ScrollSpeedTooltip(computedTime, speed);
return $"{baseSpeed}base - ( {speed} - 200) * {Current.Value}mps\n = {speedInfo}";
}
}
}
}
}

View File

@@ -0,0 +1,93 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Localisation;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Rulesets.UI.Scrolling.Algorithms;
namespace osu.Game.Rulesets.Mania.Mods.LAsMods
{
public class ManiaModBasicScrollSpeed : Mod, ISupportConstantAlgorithmToggle, IDrawableScrollingRuleset
{
public override string Name => "Adjust Basic Scroll Speed";
public override string Acronym => "ABSS";
public override LocalisableString Description => "LaMod: Adjust the basic scrolling speed of different keys.";
public override ModType Type => ModType.CustomMod;
public override double ScoreMultiplier => 1;
[SettingSource("Basic Scrolling Speed", "基础落速")]
public BindableNumber<double> BasicScrollingSpeed { get; } = new BindableNumber<double>
{
MinValue = 200,
MaxValue = 2000,
Default = 500,
Value = 1,
};
public BindableBool ShowSpeedChanges { get; } = new BindableBool();
public double? TimelineTimeRange { get; set; }
public required IScrollingInfo ScrollingInfo;
[BackgroundDependencyLoader]
private void load(IScrollingInfo scrollingInfo)
{
this.ScrollingInfo = scrollingInfo;
}
protected void LoadComplete()
{
ShowSpeedChanges.BindValueChanged(showChanges => VisualisationMethod = showChanges.NewValue ? ScrollVisualisationMethod.Sequential : ScrollVisualisationMethod.Constant, true);
}
public void ApplyToBeatmap(IBeatmap beatmap)
{
double currentScrollSpeed = ScrollingInfo.TimeRange.Value;
int totalKeys = beatmap.HitObjects.OfType<ManiaHitObject>().Max(h => h.Column) + 1;
double speedMultiplier = BasicScrollingSpeed.Value / 1000.0;
double newScrollSpeed = currentScrollSpeed * speedMultiplier * totalKeys;
ScrollingInfo.TimeRange.Value = newScrollSpeed;
}
private ScrollVisualisationMethod visualisationMethod = ScrollVisualisationMethod.Sequential;
public ScrollVisualisationMethod VisualisationMethod
{
get => visualisationMethod;
set
{
visualisationMethod = value;
updateScrollAlgorithm();
}
}
private void updateScrollAlgorithm()
{
switch (VisualisationMethod)
{
case ScrollVisualisationMethod.Sequential:
ScrollingInfo.Algorithm = new SequentialScrollAlgorithm(ControlPoints);
break;
case ScrollVisualisationMethod.Overlapping:
ScrollingInfo.Algorithm.Value = new OverlappingScrollAlgorithm(ControlPoints);
break;
case ScrollVisualisationMethod.Constant:
ScrollingInfo.Algorithm.Value = new ConstantScrollAlgorithm();
break;
}
}
}
}

View File

@@ -0,0 +1,186 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Bindables;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Audio;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.LAsEzExtensions.Background;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.LAsEZMania;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mods;
using osu.Game.LAsEzExtensions.Configuration;
using osu.Game.Overlays.Settings;
namespace osu.Game.Rulesets.Mania.Mods.LAsMods
{
/// <summary>
/// 基于 YuLiangSSS 的 ManiaModDeleteColumn 修改而来
/// 增加一些高阶功能
/// </summary>
public class ManiaModCleanColumn : Mod, IApplicableToBeatmapConverter, IApplicableAfterBeatmapConversion, IHasApplyOrder
{
public override string Name => "Clean Column";
public override string Acronym => "CC";
public override double ScoreMultiplier => 1;
public override LocalisableString Description => EzManiaModStrings.CleanColumn_Description;
public override IconUsage? Icon => FontAwesome.Solid.Backspace;
public override ModType Type => ModType.LA_Mod;
public override bool Ranked => false;
public override bool ValidForMultiplayer => true;
public override bool ValidForFreestyleAsRequiredMod => false;
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.DeleteSColumn_Label), nameof(EzManiaModStrings.DeleteSColumn_Description))]
public BindableBool DeleteSColumn { get; } = new BindableBool(true);
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.DeletePColumn_Label), nameof(EzManiaModStrings.DeletePColumn_Description))]
public BindableBool DeletePColumn { get; } = new BindableBool(false);
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.DeleteEColumn_Label), nameof(EzManiaModStrings.DeleteEColumn_Description))]
public BindableBool DeleteEColumn { get; } = new BindableBool(false);
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.EnableCustomDelete_Label), nameof(EzManiaModStrings.EnableCustomDelete_Description))]
public BindableBool EnableCustomDelete { get; } = new BindableBool(false);
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.CustomDeleteColumn_Label), nameof(EzManiaModStrings.CustomDeleteColumn_Description), SettingControlType = typeof(SettingsNumberBox))]
public Bindable<int?> CustomDeleteColumn { get; } = new Bindable<int?>(0);
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.ApplyOrder_Label), nameof(EzManiaModStrings.ApplyOrder_Description), SettingControlType = typeof(SettingsNumberBox))]
public Bindable<int?> ApplyOrderSetting { get; } = new Bindable<int?>(1000);
public static int TargetColumns = 7;
public void ApplyToBeatmapConverter(IBeatmapConverter converter)
{
var mbc = (ManiaBeatmapConverter)converter;
float keys = mbc.TotalColumns;
if (keys != 7)
{
return;
}
mbc.TargetColumns = TargetColumns;
}
public void ApplyToBeatmap(IBeatmap beatmap)
{
try
{
var maniaBeatmap = (ManiaBeatmap)beatmap;
int keys = maniaBeatmap.TotalColumns;
// 获取列类型
if (GlobalConfigStore.EzConfig != null)
{
EzColumnType[] columnTypes = GlobalConfigStore.EzConfig.GetColumnTypes(keys);
// 确定要删除的列
HashSet<int> columnsToDelete = new HashSet<int>();
if (DeleteSColumn.Value)
{
for (int i = 0; i < keys; i++)
{
if (columnTypes[i] == EzColumnType.S)
columnsToDelete.Add(i);
}
}
if (DeletePColumn.Value)
{
for (int i = 0; i < keys; i++)
{
if (columnTypes[i] == EzColumnType.P)
columnsToDelete.Add(i);
}
}
if (DeleteEColumn.Value)
{
for (int i = 0; i < keys; i++)
{
if (columnTypes[i] == EzColumnType.E)
columnsToDelete.Add(i);
}
}
if (EnableCustomDelete.Value && CustomDeleteColumn.Value.HasValue && CustomDeleteColumn.Value.Value >= 0 && CustomDeleteColumn.Value.Value < keys)
columnsToDelete.Add(CustomDeleteColumn.Value.Value);
if (!columnsToDelete.Any())
return; // 没有要删除的列
var newObjects = new List<ManiaHitObject>();
var locations = maniaBeatmap.HitObjects.OfType<Note>().Select(n => (
startTime: n.StartTime,
samples: n.Samples,
column: n.Column,
endTime: n.StartTime,
duration: n.StartTime - n.StartTime
))
.Concat(maniaBeatmap.HitObjects.OfType<HoldNote>().Select(h => (
startTime: h.StartTime,
samples: h.Samples,
column: h.Column,
endTime: h.EndTime,
duration: h.EndTime - h.StartTime
))).OrderBy(h => h.startTime).ThenBy(n => n.column).ToList();
foreach (var note in locations)
{
int column = note.column;
if (columnsToDelete.Contains(column))
continue;
if (note.startTime != note.endTime)
{
newObjects.Add(new HoldNote
{
Column = column,
StartTime = note.startTime,
Duration = note.endTime - note.startTime,
NodeSamples = [note.samples, Array.Empty<HitSampleInfo>()]
});
}
else
{
newObjects.Add(new Note
{
Column = column,
StartTime = note.startTime,
Samples = note.samples
});
}
}
maniaBeatmap.HitObjects = newObjects.OrderBy(h => h.StartTime).ToList();
}
}
catch
{
// 失败时返回原始谱面,不修改
}
}
// 确认此 Mod 在其他转换后 Mod 之后应用,返回更高的应用顺序。
// 没有此接口的 Mod 被视为顺序 0。
public int ApplyOrder => ApplyOrderSetting.Value ?? 1000;
}
}

View File

@@ -0,0 +1,317 @@
// 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.
// #pragma warning disable
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Audio.Sample;
using osu.Framework.Bindables;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Audio;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Beatmaps.Legacy;
using osu.Game.Configuration;
using osu.Game.Overlays.Settings;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.LAsEZMania;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Legacy;
namespace osu.Game.Rulesets.Mania.Mods.LAsMods
{
public class ManiaModEz2Settings : Mod, IApplicableToDifficulty, IApplicableToBeatmap //, IApplicableToSample //, IStoryboardElement
{
public override string Name => "Ez2 Settings";
public override string Acronym => "ES";
public override LocalisableString Description => EzManiaModStrings.Ez2Settings_Description;
public override ModType Type => ModType.LA_Mod;
public override IconUsage? Icon => FontAwesome.Solid.Tools;
public override bool Ranked => false;
public override double ScoreMultiplier => 1;
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.NoScratch_Label), nameof(EzManiaModStrings.NoScratch_Description))]
public BindableBool NoScratch { get; } = new BindableBool();
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.NoPanel_Label), nameof(EzManiaModStrings.NoPanel_Description))]
public BindableBool NoPanel { get; } = new BindableBool();
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.HealthyScratch_Label), nameof(EzManiaModStrings.HealthyScratch_Description))]
public BindableBool HealthScratch { get; } = new BindableBool(true);
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.MaxBeat_Label), nameof(EzManiaModStrings.MaxBeat_Description), SettingControlType = typeof(MultiplierSettingsSlider))]
public BindableNumber<double> MaxBeat { get; } = new BindableDouble(3)
{
MinValue = 1,
MaxValue = 16,
Precision = 1
};
// [SettingSource("Global Speed Regulation", "全局调速,开局调速有暂停,全局屏蔽倒计时.")]
// public BindableBool GlobalScrollSpeed { get; } = new BindableBool();
public ManiaModEz2Settings()
{
NoScratch.ValueChanged += OnSettingChanged;
HealthScratch.ValueChanged += OnHealthScratchChanged;
}
private void OnSettingChanged(ValueChangedEvent<bool> e)
{
if (e.NewValue)
{
HealthScratch.Value = false;
}
}
private void OnHealthScratchChanged(ValueChangedEvent<bool> e)
{
if (e.NewValue)
{
NoScratch.Value = false;
}
}
public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription
{
get
{
var settings = new List<(LocalisableString setting, LocalisableString value)>();
if (NoScratch.Value)
settings.Add((new LocalisableString("No Scratch"), new LocalisableString("Enabled")));
if (NoPanel.Value)
settings.Add((new LocalisableString("No Panel"), new LocalisableString("Enabled")));
if (HealthScratch.Value)
settings.Add((new LocalisableString("Scratch MAX Beat Space"), new LocalisableString($"1/{MaxBeat.Value} Beat")));
return settings;
}
}
private IBeatmap beatmap = null!;
public void ApplyToBeatmap(IBeatmap beatmap)
{
this.beatmap = beatmap;
var maniaBeatmap = (ManiaBeatmap)beatmap;
int keys = (int)maniaBeatmap.Difficulty.CircleSize;
if (HealthScratch.Value)
{
NoScratch.Value = false;
}
if (HealthScratch.Value && HealthTemplate.TryGetValue(keys, out var moveTargets))
{
var notesToMove = maniaBeatmap.HitObjects
.Where(h => h is ManiaHitObject maniaHitObject && moveTargets.Contains(maniaHitObject.Column))
.OrderBy(h => h.StartTime)
.ToList();
ManiaHitObject? previousNote = null;
foreach (var note in notesToMove)
{
if (previousNote != null && note.StartTime - previousNote.StartTime <= beatmap.ControlPointInfo.TimingPointAt(note.StartTime).BeatLength / MaxBeat.Value)
{
bool moved = false;
foreach (int targetColumn in MoveTemplate[keys])
{
int newColumn = targetColumn;
note.Column = newColumn % keys;
var targetColumnNotes = maniaBeatmap.HitObjects
.Where(h => h is ManiaHitObject maniaHitObject && maniaHitObject.Column == newColumn)
.OrderBy(h => h.StartTime)
.ToList();
bool isValid = true;
for (int i = 0; i < targetColumnNotes.Count - 1; i++)
{
var currentNote = targetColumnNotes[i];
var nextNote = targetColumnNotes[i + 1];
if (nextNote.StartTime - currentNote.StartTime <= beatmap.ControlPointInfo.TimingPointAt(nextNote.StartTime).BeatLength / 4)
{
isValid = false;
break;
}
if (currentNote is HoldNote holdNote && nextNote.StartTime <= holdNote.EndTime)
{
isValid = false;
break;
}
}
if (isValid)
{
moved = true;
break;
}
}
if (!moved)
{
note.Column = previousNote.Column;
}
}
previousNote = note;
}
}
if (NoScratch.Value && NoScratchTemplate.TryGetValue(keys, out var scratchToRemove))
{
var scratchNotesToRemove = maniaBeatmap.HitObjects
.Where(h => h is ManiaHitObject maniaHitObject && scratchToRemove.Contains(maniaHitObject.Column))
.OrderBy(h => h.StartTime)
.ToList();
foreach (var note in scratchNotesToRemove)
{
maniaBeatmap.HitObjects.Remove(note);
applySamples(note);
}
}
if (NoPanel.Value && NoPanelTemplate.TryGetValue(keys, out var panelToRemove))
{
var panelNotesToRemove = maniaBeatmap.HitObjects
.Where(h => h is ManiaHitObject maniaHitObject && panelToRemove.Contains(maniaHitObject.Column))
.OrderBy(h => h.StartTime)
.ToList();
foreach (var note in panelNotesToRemove)
{
maniaBeatmap.HitObjects.Remove(note);
applySamples(note);
}
}
}
private void applySamples(HitObject hitObject)
{
SampleControlPoint sampleControlPoint = (beatmap.ControlPointInfo as LegacyControlPointInfo)?.SamplePointAt(hitObject.GetEndTime() + 5)
?? SampleControlPoint.DEFAULT;
hitObject.Samples = hitObject.Samples.Select(o => sampleControlPoint.ApplyTo(o)).ToList();
}
public ISample GetSample(ISampleInfo sampleInfo)
{
if (sampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacySample && legacySample.IsLayered)
return new SampleVirtual();
return GetSample(sampleInfo);
}
// public class RemovedNoteSample
// {
// public double StartTime { get; set; }
// public required string Sample { get; set; }
// }
// public void ApplyToSample(IAdjustableAudioComponent sample) { }
public void ApplyToDifficulty(BeatmapDifficulty difficulty) { }
public override string ExtendedIconInformation => "";
public Dictionary<int, List<int>> NoScratchTemplate { get; set; } = new Dictionary<int, List<int>>
{
{ 16, new List<int> { 0, 15 } },
{ 14, new List<int> { 0, 12 } },
{ 12, new List<int> { 0, 11 } },
{ 9, new List<int> { 0 } },
{ 8, new List<int> { 0 } },
{ 7, new List<int> { 0 } },
{ 6, new List<int> { 0 } },
};
public Dictionary<int, List<int>> NoPanelTemplate { get; set; } = new Dictionary<int, List<int>>
{
{ 18, new List<int> { 6, 11 } },
{ 14, new List<int> { 6 } },
{ 9, new List<int> { 8 } },
{ 7, new List<int> { 6 } },
};
public Dictionary<int, List<int>> HealthTemplate { get; set; } = new Dictionary<int, List<int>>
{
{ 16, new List<int> { 0, 15 } },
{ 14, new List<int> { 0, 6, 12 } },
{ 12, new List<int> { 0, 11 } },
{ 9, new List<int> { 0, 8 } },
{ 8, new List<int> { 0, 8 } },
{ 7, new List<int> { 0 } },
{ 6, new List<int> { 0 } },
};
public Dictionary<int, List<int>> MoveTemplate { get; set; } = new Dictionary<int, List<int>>
{
{ 16, new List<int> { 15, 0, 2, 4, 8, 10, 6, 7, 8, 9, 5, 10 } },
{ 14, new List<int> { 12, 0, 2, 4, 8, 10, 5, 7, 1, 3, 9, 11 } },
{ 12, new List<int> { 11, 0, 2, 4, 8, 10, 5, 6, 7, 1, 3, 9 } },
{ 9, new List<int> { 8, 0, 4, 2, 3, 1, 5, 7, 6 } },
{ 8, new List<int> { 7, 0, 6, 4, 2, 5, 3, 1 } },
{ 7, new List<int> { 6, 4, 2, 5, 3, 1 } },
{ 6, new List<int> { 4, 2, 5, 3, 1 } },
};
// public Dictionary<int, List<int>> MoveTemplate { get; set; } = new Dictionary<int, List<int>>
// {
// { 16, new List<int> { 0, 15 } },
// { 14, new List<int> { 0, 12 } },
// { 12, new List<int> { 0, 11 } },
// { 9, new List<int> { 8, 0 } },
// { 8, new List<int> { 7, 0 } },
// { 7, new List<int> { 6 } },
// { 6, new List<int> { 5, 4 } },
// };
}
}
// #pragma warning restore
// public void SetTrackBackgroundColor(List<int> trackIndices, Color4 color, List<Ez2ColumnBackground> columnBackgrounds)
// {
// foreach (int trackIndex in trackIndices)
// {
// if (trackIndex >= 0 && trackIndex < columnBackgrounds.Count)
// {
// var columnBackground = columnBackgrounds[trackIndex];
// columnBackground.background.Colour = color;
// }
// }
// }
// public void ReadFromDifficulty(IBeatmapDifficultyInfo difficulty)
// {
// }
// string lines = note;
// beatmap.UnhandledEventLines.Add(lines);
// string path = note.Samples.GetHashCode().ToString();
// double time = note.StartTime;
// storyboard.GetLayer("Background").Add(new StoryboardSampleInfo(path, time, 100));
// storyboard.GetLayer("Background").Add();
// applySamples(note);
// private Storyboard storyboard = null!;
// public void ApplyToSample(IAdjustableAudioComponent sample)
// {
// foreach (var noteSample in removedSamples)
// {
// string path = noteSample.Sample.ToString() ?? string.Empty;
// double time = noteSample.StartTime;
// storyboard.GetLayer("Background").Add(new StoryboardSampleInfo(path, time, 100));
// }
// }
// processedTracks.AddRange(panelToRemove);
// setTrackBackgroundColor(panelToRemove, new Color4(0, 0, 0, 0));

View File

@@ -0,0 +1,293 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Localisation;
using osu.Game.Configuration;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.Mods.LAsMods
{
public class ManiaModFreeHit : Mod, IHitWindows
{
public override string Name => "Custom HitWindows";
public override string Acronym => "CH";
public override double ScoreMultiplier => 1;
public override bool Ranked => false;
public override LocalisableString Description => @"LaMod: Custom HitWindows. Free Hit ms.";
public override ModType Type => ModType.CustomMod;
[SettingSource("Adaptive Judgement(No Active)")]
public BindableBool AdaptiveJudgement { get; } = new BindableBool();
[SettingSource("Easy Style Judgement")]
public BindableBool UseEasyTemplate { get; } = new BindableBool();
[SettingSource("Hard Style Judgement")]
public BindableBool UseHardTemplate { get; } = new BindableBool();
[SettingSource("Perfect Offset (ms)")]
public BindableNumber<double> PerfectOffset { get; } = new BindableDouble(22)
{
MinValue = 1,
MaxValue = 60,
Precision = 1
};
[SettingSource("Great Offset (ms)")]
public BindableNumber<double> GreatOffset { get; } = new BindableDouble(42)
{
MinValue = 10,
MaxValue = 120,
Precision = 1
};
[SettingSource("Good Offset (ms)")]
public BindableNumber<double> GoodOffset { get; } = new BindableDouble(82)
{
MinValue = 20,
MaxValue = 180,
Precision = 1
};
[SettingSource("Ok Offset (ms)")]
public BindableNumber<double> OkOffset { get; } = new BindableDouble(120)
{
MinValue = 40,
MaxValue = 240,
Precision = 1
};
[SettingSource("Meh Offset (ms)")]
public BindableNumber<double> MehOffset { get; } = new BindableDouble(150)
{
MinValue = 60,
MaxValue = 300,
Precision = 1
};
[SettingSource("Miss Offset (ms)")]
public BindableNumber<double> MissOffset { get; } = new BindableDouble(180)
{
MinValue = 80,
MaxValue = 500,
Precision = 1
};
[Resolved]
private ScoreProcessor scoreProcessor { get; set; } = null!;
public ManiaModFreeHit()
{
// HitWindows.SetCustomRanges(this);
UseEasyTemplate.BindValueChanged(e =>
{
if (e.NewValue)
{
applyTemplate(HitWindowTemplates.EASY);
UseHardTemplate.Value = false;
AdaptiveJudgement.Value = false;
}
});
UseHardTemplate.BindValueChanged(e =>
{
if (e.NewValue)
{
applyTemplate(HitWindowTemplates.HARD);
UseEasyTemplate.Value = false;
AdaptiveJudgement.Value = false;
}
});
AdaptiveJudgement.BindValueChanged(e =>
{
if (e.NewValue)
{
UseHardTemplate.Value = false;
UseEasyTemplate.Value = false;
scoreProcessor?.Accuracy.BindValueChanged(acc => UpdateHitWindowsBasedOnScore(acc.NewValue), true);
}
// else
// {
// scoreProcessor.Accuracy.UnbindAll();
// }
}, true);
PerfectOffset.BindValueChanged(_ => updateHitWindows());
GreatOffset.BindValueChanged(_ => updateHitWindows());
GoodOffset.BindValueChanged(_ => updateHitWindows());
OkOffset.BindValueChanged(_ => updateHitWindows());
MehOffset.BindValueChanged(_ => updateHitWindows());
MissOffset.BindValueChanged(_ => updateHitWindows());
}
~ManiaModFreeHit()
{
HitWindows.SetModActive(false);
}
private void updateHitWindows()
{
HitWindows.SetModActive(true);
HitWindows.SetCustomRanges(this);
}
// public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor)
// {
// }
// public AdjustRank(ScoreRank rank, double accuracy)
// {
// return rank;
// }
private void applyTemplate(HitWindowTemplate template)
{
PerfectOffset.Value = template.PerfectOffset;
GreatOffset.Value = template.GreatOffset;
GoodOffset.Value = template.GoodOffset;
OkOffset.Value = template.OkOffset;
MehOffset.Value = template.MehOffset;
MissOffset.Value = template.MissOffset;
}
public class HitWindowTemplate
{
public double PerfectOffset { get; set; }
public double GreatOffset { get; set; }
public double GoodOffset { get; set; }
public double OkOffset { get; set; }
public double MehOffset { get; set; }
public double MissOffset { get; set; }
}
public static class HitWindowTemplates
{
public static readonly HitWindowTemplate EASY = new HitWindowTemplate
{
PerfectOffset = 50,
GreatOffset = 100,
GoodOffset = 150,
OkOffset = 200,
MehOffset = 250,
MissOffset = 300
};
public static readonly HitWindowTemplate HARD = new HitWindowTemplate
{
PerfectOffset = 20,
GreatOffset = 40,
GoodOffset = 60,
OkOffset = 80,
MehOffset = 100,
MissOffset = 120
};
// 可以添加更多模板
}
public void UpdateHitWindowsBasedOnScore(double accuracy)
{
if (accuracy != 0)
{
if (accuracy > 0.95)
{
// 缩小判定区间
PerfectOffset.Value = 10;
GreatOffset.Value = 20;
GoodOffset.Value = 21;
OkOffset.Value = 90;
MehOffset.Value = 100;
MissOffset.Value = 120;
}
else if (accuracy < 0.95)
{
// 放宽判定区间
PerfectOffset.Value = 30;
GreatOffset.Value = 60;
GoodOffset.Value = 100;
OkOffset.Value = 150;
MehOffset.Value = 151;
MissOffset.Value = 200;
}
}
}
public bool IsHitResultAllowed(HitResult result)
{
return result switch
{
HitResult.Perfect => true,
HitResult.Great => true,
HitResult.Good => true,
HitResult.Ok => true,
HitResult.Meh => true,
HitResult.Miss => true,
_ => false,
};
}
public double WindowFor(HitResult result)
{
switch (result)
{
case HitResult.Perfect:
return PerfectOffset.Value;
case HitResult.Great:
return GreatOffset.Value;
case HitResult.Good:
return GoodOffset.Value;
case HitResult.Ok:
return OkOffset.Value;
case HitResult.Meh:
return MehOffset.Value;
case HitResult.Miss:
return MissOffset.Value;
default:
throw new ArgumentException("Unknown enum member", nameof(result));
}
}
public DifficultyRange[] GetRanges() => new[]
{
new DifficultyRange(HitResult.Perfect, PerfectOffset.Value, PerfectOffset.Value, PerfectOffset.Value),
new DifficultyRange(HitResult.Great, GreatOffset.Value, GreatOffset.Value, GreatOffset.Value),
new DifficultyRange(HitResult.Good, GoodOffset.Value, GoodOffset.Value, GoodOffset.Value),
new DifficultyRange(HitResult.Ok, OkOffset.Value, OkOffset.Value, OkOffset.Value),
new DifficultyRange(HitResult.Meh, MehOffset.Value, MehOffset.Value, MehOffset.Value),
new DifficultyRange(HitResult.Miss, MissOffset.Value, MissOffset.Value, MissOffset.Value),
};
public override string SettingDescription
{
get
{
string perfect = $"Perfect {PerfectOffset.Value}";
string great = $"Great {GreatOffset.Value}";
string good = $"Good {GoodOffset.Value}";
string ok = $"Ok {OkOffset.Value}";
string meh = $"Meh {MehOffset.Value}";
string miss = $"Miss {MissOffset.Value}";
return string.Join(", ", new[]
{
perfect,
great,
good,
ok,
meh,
miss
}.Where(s => !string.IsNullOrEmpty(s)));
}
}
}
}

View File

@@ -0,0 +1,538 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Audio;
using osu.Framework.Bindables;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Framework.Utils;
using osu.Game.Audio;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.LAsEzExtensions.Configuration;
using osu.Game.LAsEzExtensions.Select;
using osu.Game.Overlays.Settings;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.LAsEZMania;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.UI;
using osu.Game.Screens.Play;
using osu.Game.Rulesets.Mania.UI;
namespace osu.Game.Rulesets.Mania.Mods.LAsMods
{
/// <summary>
/// 基于凉雨的 Duplicate Mod, 解决无循环音频问题;
/// <para></para>备注部分为我修改的内容, 增加IApplicableToPlayer, IApplicableToHUD, IPreviewOverrideProvider接口的使用
/// </summary>
public class ManiaModLoopPlayClip : Mod, IApplicableAfterBeatmapConversion,
IHasSeed,
IApplicableToPlayer,
IApplicableToHUD,
IPreviewOverrideProvider,
ILoopTimeRangeMod,
IApplicableFailOverride,
IApplicableToRate,
IApplicableToDrawableRuleset<ManiaHitObject>
{
private DuplicateVirtualTrack? duplicateTrack;
private IWorkingBeatmap? pendingWorkingBeatmap;
internal double? ResolvedCutTimeStart { get; private set; }
internal double? ResolvedCutTimeEnd { get; private set; }
internal double ResolvedSegmentLength { get; private set; }
public override string Name => "Loop Play Clip (No Fail)";
public override string Acronym => "LP";
public override double ScoreMultiplier => 1;
public override LocalisableString Description => EzManiaModStrings.LoopPlayClip_Description;
public override IconUsage? Icon => FontAwesome.Solid.ArrowCircleDown;
public override ModType Type => ModType.LA_Mod;
public override bool Ranked => false;
public override bool ValidForMultiplayer => false;
public override bool ValidForFreestyleAsRequiredMod => false;
// LP 内置变速(复刻 HT 的实现)后,为避免叠加导致体验混乱,直接与其它变速 Mod 互斥。
public override Type[] IncompatibleMods => new[]
{
typeof(ModRateAdjust),
typeof(ModTimeRamp),
typeof(ModAdaptiveSpeed),
typeof(ManiaModConstantSpeed),
typeof(ManiaModNoFail),
};
public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription
{
get
{
yield return ($"Speed x{SpeedChange.Value:N2}", AdjustPitch.Value ? "Pitch Adjusted" : "Pitch Unchanged");
yield return ($"{LoopCount.Value}", "Loop Count");
yield return ("Break", $"{BreakTime:N1}s");
yield return ("Start", $"{(CutTimeStart.Value is null ? "Original Start Time" : (Millisecond.Value ? $"{CutTimeStart.Value} ms" : CalculateTime((int)CutTimeStart.Value)))}");
yield return ("End", $"{(CutTimeEnd.Value is null ? "Original End Time" : (Millisecond.Value ? $"{CutTimeEnd.Value} ms" : CalculateTime((int)CutTimeEnd.Value)))}");
yield return ("Infinite Loop", InfiniteLoop.Value ? "Enabled" : "Disabled");
}
}
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.LoopCount_Label), nameof(EzManiaModStrings.LoopCount_Description))]
public BindableInt LoopCount { get; set; } = new BindableInt(20)
{
MinValue = 1,
MaxValue = 100,
Precision = 1
};
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.SpeedChange_Label), nameof(EzManiaModStrings.SpeedChange_Description), SettingControlType = typeof(MultiplierSettingsSlider))]
public BindableNumber<double> SpeedChange { get; } = new BindableDouble(1)
{
MinValue = 0.5,
MaxValue = 2.0,
Precision = 0.01,
};
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.AdjustPitch_Label), nameof(EzManiaModStrings.AdjustPitch_Description))]
public BindableBool AdjustPitch { get; } = new BindableBool();
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.ConstantSpeed_Label), nameof(EzManiaModStrings.ConstantSpeed_Description))]
public BindableBool ConstantSpeed { get; } = new BindableBool(true);
/*[SettingSource("Cut Time Start", "Select your part(second).", SettingControlType = typeof(SettingsSlider<int, CutStart>))]
public BindableInt CutTimeStart { get; set; } = new BindableInt(-10)
{
MinValue = -10,
MaxValue = 1800,
Precision = 1
};
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.CutTimeEnd_Label), nameof(EzManiaModStrings.CutTimeEnd_Description), SettingControlType = typeof(SettingsSlider<int, CutEnd>))]
public BindableInt CutTimeEnd { get; set; } = new BindableInt(1800)
{
MinValue = -10,
MaxValue = 1800,
Precision = 1
};*/
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.CutStartTime_Label), nameof(EzManiaModStrings.CutStartTime_Description), SettingControlType = typeof(SettingsNumberBox))]
public Bindable<int?> CutTimeStart { get; set; } = new Bindable<int?>();
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.CutEndTime_Label), nameof(EzManiaModStrings.CutEndTime_Description), SettingControlType = typeof(SettingsNumberBox))]
public Bindable<int?> CutTimeEnd { get; set; } = new Bindable<int?>();
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.UseMillisecond_Label), nameof(EzManiaModStrings.UseMillisecond_Description))]
public BindableBool Millisecond { get; set; } = new BindableBool(true);
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.UseGlobalABRange_Label), nameof(EzManiaModStrings.UseGlobalABRange_Description))]
public BindableBool UseGlobalAbRange { get; set; } = new BindableBool(true);
private readonly RateAdjustModHelper rateAdjustHelper;
public ManiaModLoopPlayClip()
{
rateAdjustHelper = new RateAdjustModHelper(SpeedChange);
rateAdjustHelper.HandleAudioAdjustments(AdjustPitch);
UseGlobalAbRange.BindValueChanged(_ => applyRangeFromStore(), true);
// 当全局A/B范围改变时更新设置
LoopTimeRangeStore.START_TIME_MS.BindValueChanged(_ => applyRangeFromStoreIfGlobal());
LoopTimeRangeStore.END_TIME_MS.BindValueChanged(_ => applyRangeFromStoreIfGlobal());
}
public void ApplyToTrack(IAdjustableAudioComponent track) => rateAdjustHelper.ApplyToTrack(track);
public void ApplyToSample(IAdjustableAudioComponent sample)
{
// 与 ModRateAdjust 一致sample 仅做音高/频率调整即可。
sample.AddAdjustment(AdjustableProperty.Frequency, SpeedChange);
}
public double ApplyToRate(double time, double rate = 1) => rate * SpeedChange.Value;
public void ApplyToDrawableRuleset(DrawableRuleset<ManiaHitObject> drawableRuleset)
{
if (!ConstantSpeed.Value)
return;
if (drawableRuleset is DrawableManiaRuleset maniaRuleset)
maniaRuleset.VisualisationMethod = ScrollVisualisationMethod.Constant;
}
public override void ResetSettingsToDefaults()
{
base.ResetSettingsToDefaults();
applyRangeFromStore();
}
private void applyRangeFromStore()
{
if (!UseGlobalAbRange.Value)
return;
if (!LoopTimeRangeStore.TryGet(out double startMs, out double endMs))
return;
// Store is always milliseconds.
setCutTimeFromMs(startMs, endMs);
setResolvedCut(null, null);
}
private void applyRangeFromStoreIfGlobal()
{
if (UseGlobalAbRange.Value)
applyRangeFromStore();
}
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.BreakTime_Label), nameof(EzManiaModStrings.BreakTime_Description))]
public BindableDouble BreakTime { get; set; } = new BindableDouble(0)
{
MinValue = 0,
MaxValue = 20,
Precision = 0.1
};
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Random_Label), nameof(EzManiaModStrings.Random_Description))]
public BindableBool Rand { get; set; } = new BindableBool(false);
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Mirror_Label), nameof(EzManiaModStrings.Mirror_Description))]
public BindableBool Mirror { get; set; } = new BindableBool(true);
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.InfiniteLoop_Label), nameof(EzManiaModStrings.InfiniteLoop_Description))]
public BindableBool InfiniteLoop { get; set; } = new BindableBool(false);
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.MirrorTime_Label), nameof(EzManiaModStrings.MirrorTime_Description))]
public BindableInt MirrorTime { get; set; } = new BindableInt(1)
{
MinValue = 1,
MaxValue = 10,
Precision = 1
};
//[SettingSource("Invert", "Invert next part.")]
//public BindableBool Invert { get; set; } = new BindableBool(false);
//[SettingSource("Invert Time", "Every next time part will be inverted.")]
//public BindableInt InvertTime { get; set; } = new BindableInt(1)
//{
// MinValue = 1,
// MaxValue = 10,
// Precision = 1
//};
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Seed_Label), nameof(EzManiaModStrings.Seed_Description), SettingControlType = typeof(SettingsNumberBox))]
public Bindable<int?> Seed { get; } = new Bindable<int?>();
// 提供切片时间点给 DuplicateVirtualTrack 使用
private void setResolvedCut(double? start, double? end)
{
ResolvedCutTimeStart = start;
ResolvedCutTimeEnd = end;
ResolvedSegmentLength = start.HasValue && end.HasValue ? Math.Max(0, end.Value - start.Value) : 0;
}
private bool ensureResolvedForPreview(IWorkingBeatmap beatmap)
{
if (ResolvedSegmentLength > 0 && ResolvedCutTimeStart is not null && ResolvedCutTimeEnd is not null)
return true;
try
{
var maniaBeatmap = (ManiaBeatmap)beatmap.GetPlayableBeatmap(beatmap.BeatmapInfo.Ruleset);
var (cutTimeStart, cutTimeEnd) = getEffectiveCutTimeMs();
// 若开始为空则取最早物件时间,若结束为空则取最晚物件时间(不再整体判无效)。
var minTime = maniaBeatmap.HitObjects.MinBy(h => h.StartTime);
var maxTime = maniaBeatmap.HitObjects.MaxBy(h => h.GetEndTime());
cutTimeStart ??= minTime?.StartTime;
cutTimeEnd ??= maxTime?.GetEndTime();
double? length = cutTimeEnd - cutTimeStart;
if (length is null || length <= 0)
{
setResolvedCut(null, null);
return false;
}
setResolvedCut(cutTimeStart, cutTimeEnd);
return true;
}
catch
{
setResolvedCut(null, null);
return false;
}
}
public void ApplyToBeatmap(IBeatmap beatmap)
{
Seed.Value ??= RNG.Next();
var rng = new Random((int)Seed.Value);
var maniaBeatmap = (ManiaBeatmap)beatmap;
maniaBeatmap.Breaks.Clear();
var (cutTimeStart, cutTimeEnd) = getEffectiveCutTimeMs();
double breakTime = BreakTime.Value * 1000;
// 改为最少一个非空设置
var minTimeBeatmap = maniaBeatmap.HitObjects.MinBy(h => h.StartTime);
var maxTimeBeatmap = maniaBeatmap.HitObjects.MaxBy(h => h.GetEndTime());
cutTimeStart ??= minTimeBeatmap?.StartTime;
cutTimeEnd ??= maxTimeBeatmap?.GetEndTime();
// IMPORTANT: compute length only after null defaults have been applied.
// Otherwise, when both cut times are null (default settings and no global A/B range),
// this mod would early-return and appear to have no effect (and thus no analysis change).
double? length = cutTimeEnd - cutTimeStart;
var selectedPart = maniaBeatmap.HitObjects.Where(h => h.StartTime > cutTimeStart && h.GetEndTime() < cutTimeEnd).ToList();
if (length is null || length <= 0)
{
setResolvedCut(null, null);
return;
}
setResolvedCut(cutTimeStart, cutTimeEnd);
var newPart = new List<ManiaHitObject>();
for (int timeIndex = 0; timeIndex < LoopCount.Value; timeIndex++)
{
if (timeIndex == 0)
{
if (Rand.Value)
{
var shuffledColumns = Enumerable.Range(0, maniaBeatmap.TotalColumns).OrderBy(_ => rng.Next()).ToList();
selectedPart.ForEach(h => h.Column = shuffledColumns[h.Column]);
}
if (Mirror.Value)
{
}
// 调整时间从切片起点开始
foreach (var note in selectedPart)
{
note.StartTime -= (float)cutTimeStart!;
if (note is HoldNote holdNote)
holdNote.EndTime -= (float)cutTimeStart;
}
newPart.AddRange(selectedPart);
continue;
}
var obj = new List<ManiaHitObject>();
foreach (var note in selectedPart)
{
if (note.GetEndTime() != note.StartTime)
{
obj.Add(new HoldNote
{
Column = note.Column,
StartTime = note.StartTime + timeIndex * (breakTime + (double)length),
EndTime = note.GetEndTime() + timeIndex * (breakTime + (double)length),
NodeSamples = [note.Samples, Array.Empty<HitSampleInfo>()]
});
}
else
{
obj.Add(new Note
{
Column = note.Column,
StartTime = note.StartTime + timeIndex * (breakTime + (double)length),
Samples = note.Samples,
});
}
}
if (Rand.Value)
{
var shuffledColumns = Enumerable.Range(0, maniaBeatmap.TotalColumns).OrderBy(_ => rng.Next()).ToList();
obj.OfType<ManiaHitObject>().ForEach(h => h.Column = shuffledColumns[h.Column]);
}
newPart.AddRange(obj);
}
maniaBeatmap.HitObjects = newPart;
}
// 将 Beatmap 交给 DuplicateVirtualTrack用独立 Track 实例按切片参数播放
public void ApplyToPlayer(Player player)
{
if (ResolvedSegmentLength <= 0)
return;
pendingWorkingBeatmap = player.Beatmap.Value;
// 计算总循环长度
double totalLength = InfiniteLoop.Value ? double.MaxValue : LoopCount.Value * (ResolvedSegmentLength + BreakTime.Value * 1000);
pendingWorkingBeatmap.Track.Length = totalLength;
duplicateTrack = new DuplicateVirtualTrack
{
OverrideProvider = this,
PendingOverrides = null,
};
}
public static string CalculateTime(double time)
{
int minute = Math.Abs((int)time / 60);
double second = Math.Abs(time % 60);
string minus = time < 0 ? "-" : string.Empty;
string secondLessThan10 = second < 10 ? "0" : string.Empty;
return $"{minus}{minute}:{secondLessThan10}{second:N1}";
}
// 需要有一个Drawable来承载虚拟音轨
public void ApplyToHUD(HUDOverlay overlay)
{
if (duplicateTrack == null)
return;
if (pendingWorkingBeatmap == null)
return;
overlay.Add(duplicateTrack);
duplicateTrack.StartPreview(pendingWorkingBeatmap);
}
public PreviewOverrideSettings? GetPreviewOverrides(IWorkingBeatmap beatmap)
{
if (!ensureResolvedForPreview(beatmap))
return null;
return new PreviewOverrideSettings
{
PreviewStart = ResolvedCutTimeStart,
PreviewDuration = ResolvedSegmentLength,
LoopCount = LoopCount.Value,
LoopInterval = BreakTime.Value * 1000,
ForceLooping = true,
EnableHitSounds = false
};
}
public void SetLoopTimeRange(double startTime, double endTime)
{
if (endTime <= startTime)
return;
LoopTimeRangeStore.Set(startTime, endTime);
// The editor timeline works in milliseconds, while this mod exposes seconds by default.
setCutTimeFromMs(startTime, endTime);
// Reset preview cache so changes take effect immediately where used.
setResolvedCut(null, null);
// Keep current instance in sync with the session store when global mode is enabled.
if (UseGlobalAbRange.Value)
applyRangeFromStore();
}
public bool PerformFail() => false;
public bool RestartOnFail => false;
// 简化后的统一参数访问器,自动适配全局/本地,单位换算集中
private double? cutTimeStartMs
{
get => UseGlobalAbRange.Value && LoopTimeRangeStore.TryGet(out double startMs, out _)
? startMs
: toMs(CutTimeStart.Value);
set
{
if (UseGlobalAbRange.Value)
setGlobalRange(value, cutTimeEndMs);
else
CutTimeStart.Value = fromMs(value);
}
}
private double? cutTimeEndMs
{
get => UseGlobalAbRange.Value && LoopTimeRangeStore.TryGet(out _, out double endMs)
? endMs
: toMs(CutTimeEnd.Value);
set
{
if (UseGlobalAbRange.Value)
setGlobalRange(cutTimeStartMs, value);
else
CutTimeEnd.Value = fromMs(value);
}
}
// 工具方法,集中单位换算和全局写入
private double? toMs(int? v) => v == null ? null : v * (Millisecond.Value ? 1 : 1000);
private int? fromMs(double? ms) => ms == null ? null : (Millisecond.Value ? (int)ms : (int)(ms / 1000d));
private void setGlobalRange(double? start, double? end) => LoopTimeRangeStore.Set(start ?? 0, end ?? 0);
// 新增:根据当前单位设置 CutTimeStart/End
private void setCutTimeFromMs(double startMs, double endMs)
{
CutTimeStart.Value = Millisecond.Value ? (int)startMs : (int)(startMs / 1000d);
CutTimeEnd.Value = Millisecond.Value ? (int)endMs : (int)(endMs / 1000d);
}
// 获取当前生效的切片起止时间(毫秒)
private (double? startMs, double? endMs) getEffectiveCutTimeMs()
{
if (UseGlobalAbRange.Value && LoopTimeRangeStore.TryGet(out double startMs, out double endMs))
return (startMs, endMs);
return (cutTimeStartMs, cutTimeEndMs);
}
}
/*public partial class CutStart : RoundedSliderBar<double>
{
public override LocalisableString TooltipText
{
get
{
double value = Current.Value;
if (value == -10)
{
return "Original Start Time";
}
return ManiaModLoopPlayClip.CalculateTime(value);
}
}
}
public partial class CutEnd : RoundedSliderBar<double>
{
public override LocalisableString TooltipText
{
get
{
double value = Current.Value;
if (value == 1800)
{
return "Original End Time";
}
return ManiaModLoopPlayClip.CalculateTime(value);
}
}
}*/
}

View File

@@ -0,0 +1,260 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Audio;
using osu.Framework.Bindables;
using osu.Framework.Localisation;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Overlays.Settings;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mania.LAsEZMania;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI;
namespace osu.Game.Rulesets.Mania.Mods.LAsMods
{
public class ManiaModNiceBPM : Mod, IApplicableToRate, IApplicableToDrawableHitObject, IApplicableToBeatmap, IUpdatableByPlayfield
{
public override string Name => "Nice BPM";
public override string Acronym => "NB";
public override LocalisableString Description => EzManiaModStrings.NiceBPM_Description;
public override ModType Type => ModType.LA_Mod;
public override double ScoreMultiplier => 1;
public override bool Ranked => false;
public override bool ValidForMultiplayer => true;
public override bool ValidForFreestyleAsRequiredMod => false;
// public override Type[] IncompatibleMods => new[] { typeof(ModRateAdjust), typeof(ModTimeRamp), typeof(ModAutoplay) };
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.InitialRate_Label), nameof(EzManiaModStrings.InitialRate_Description), SettingControlType = typeof(MultiplierSettingsSlider))]
public BindableNumber<double> InitialRate { get; } = new BindableDouble(1)
{
MinValue = 0.2,
MaxValue = 2,
Precision = 0.01
};
// [SettingSource("Free BPM", "BPM to speed", SettingControlType = typeof(SettingsNumberBox))]
// public Bindable<double?> FreeBPM { get; } = new Bindable<double?>();
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.AdjustPitch_Label), nameof(EzManiaModStrings.AdjustPitch_Description))]
public BindableBool AdjustPitch { get; } = new BindableBool(false);
public BindableNumber<double> SpeedChange { get; } = new BindableDouble(1)
{
MinValue = min_allowable_rate,
MaxValue = max_allowable_rate,
};
private const double min_allowable_rate = 0.4d;
private const double max_allowable_rate = 2.5d;
private const double min_allowable_rate_change = 0.9d;
private const double max_allowable_rate_change = 1.11d;
private const double rate_change_on_miss = 0.95d;
private double targetRate = 1d;
private const int recent_rate_count = 8;
/// <summary>
/// Stores the most recent <see cref="recent_rate_count"/> approximated track rates
/// which are averaged to calculate the value of <see cref="targetRate"/>.
/// </summary>
/// <remarks>
/// This list is used as a double-ended queue with fixed capacity
/// (items can be enqueued/dequeued at either end of the list).
/// When time is elapsing forward, items are dequeued from the start and enqueued onto the end of the list.
/// When time is being rewound, items are dequeued from the end and enqueued onto the start of the list.
/// </remarks>
/// <example>
/// <para>
/// The track rate approximation is calculated as follows:
/// </para>
/// <para>
/// Consider a hitobject which ends at 1000ms, and assume that its preceding hitobject ends at 500ms.
/// This gives a time difference of 1000 - 500 = 500ms.
/// </para>
/// <para>
/// Now assume that the user hit this object at 980ms rather than 1000ms.
/// When compared to the preceding hitobject, this gives 980 - 500 = 480ms.
/// </para>
/// <para>
/// With the above assumptions, the player is rushing / hitting early, which means that the track should speed up to match.
/// Therefore, the approximated target rate for this object would be equal to 500 / 480 * <see cref="InitialRate"/>.
/// </para>
/// </example>
private readonly List<double> recentRates = Enumerable.Repeat(1d, recent_rate_count).ToList();
/// <summary>
/// For each given <see cref="HitObject"/> in the map, this dictionary maps the object onto the latest end time of any other object
/// that precedes the end time of the given object.
/// This can be loosely interpreted as the end time of the preceding hit object in rulesets that do not have overlapping hit objects.
/// </summary>
private readonly Dictionary<HitObject, double> precedingEndTimes = new Dictionary<HitObject, double>();
/// <summary>
/// For each given <see cref="HitObject"/> in the map, this dictionary maps the object onto the track rate dequeued from
/// <see cref="recentRates"/> (i.e. the oldest value in the queue) when the object is hit. If the hit is then reverted,
/// the mapped value can be re-introduced to <see cref="recentRates"/> to properly rewind the queue.
/// </summary>
private readonly Dictionary<HitObject, double> ratesForRewinding = new Dictionary<HitObject, double>();
private readonly RateAdjustModHelper rateAdjustHelper;
// [Resolved]
// private IBindable<WorkingBeatmap> beatmap { get; set; } = null!;
public ManiaModNiceBPM()
{
rateAdjustHelper = new RateAdjustModHelper(SpeedChange);
rateAdjustHelper.HandleAudioAdjustments(AdjustPitch);
// if (beatmap == null) { throw new InvalidOperationException("Beatmap is not initialized."); }
// double bpm = beatmap.Value.BeatmapInfo.BPM;
InitialRate.BindValueChanged(val =>
{
SpeedChange.Value = val.NewValue;
targetRate = val.NewValue;
}, true);
// FreeBPM.BindValueChanged(val =>
// {
// SpeedChange.Value = val.NewValue / bpm;
// targetRate = val.NewValue / bpm;
// }, true);
}
public void ApplyToTrack(IAdjustableAudioComponent track)
{
InitialRate.TriggerChange();
recentRates.Clear();
recentRates.AddRange(Enumerable.Repeat(InitialRate.Value, recent_rate_count));
rateAdjustHelper.ApplyToTrack(track);
}
public void ApplyToSample(IAdjustableAudioComponent sample)
{
sample.AddAdjustment(AdjustableProperty.Frequency, SpeedChange);
}
public void Update(Playfield playfield)
{
SpeedChange.Value = Interpolation.DampContinuously(SpeedChange.Value, targetRate, 50, playfield.Clock.ElapsedFrameTime);
}
public double ApplyToRate(double time, double rate = 1) => rate * InitialRate.Value;
public void ApplyToDrawableHitObject(DrawableHitObject drawable)
{
drawable.OnNewResult += (_, result) =>
{
if (ratesForRewinding.ContainsKey(result.HitObject)) return;
if (!shouldProcessResult(result)) return;
ratesForRewinding.Add(result.HitObject, recentRates[0]);
recentRates.RemoveAt(0);
recentRates.Add(Math.Clamp(getRelativeRateChange(result) * SpeedChange.Value, min_allowable_rate, max_allowable_rate));
updateTargetRate();
};
drawable.OnRevertResult += (_, result) =>
{
if (!ratesForRewinding.TryGetValue(result.HitObject, out double rate)) return;
if (!shouldProcessResult(result)) return;
recentRates.Insert(0, rate);
ratesForRewinding.Remove(result.HitObject);
recentRates.RemoveAt(recentRates.Count - 1);
updateTargetRate();
};
}
public void ApplyToBeatmap(IBeatmap beatmap)
{
var hitObjects = getAllApplicableHitObjects(beatmap.HitObjects).ToList();
var endTimes = hitObjects.Select(x => x.GetEndTime()).Order().Distinct().ToList();
foreach (HitObject hitObject in hitObjects)
{
int index = endTimes.BinarySearch(hitObject.GetEndTime());
if (index < 0) index = ~index; // BinarySearch returns the next larger element in bitwise complement if there's no exact match
index -= 1;
if (index >= 0)
precedingEndTimes.Add(hitObject, endTimes[index]);
}
}
private IEnumerable<HitObject> getAllApplicableHitObjects(IEnumerable<HitObject> hitObjects)
{
foreach (var hitObject in hitObjects)
{
if (hitObject.HitWindows != HitWindows.Empty)
yield return hitObject;
foreach (HitObject nested in getAllApplicableHitObjects(hitObject.NestedHitObjects))
yield return nested;
}
}
private bool shouldProcessResult(JudgementResult result)
{
if (!result.Type.AffectsAccuracy()) return false;
if (!precedingEndTimes.ContainsKey(result.HitObject)) return false;
return true;
}
private double getRelativeRateChange(JudgementResult result)
{
if (!result.IsHit)
return rate_change_on_miss;
double prevEndTime = precedingEndTimes[result.HitObject];
return Math.Clamp(
(result.HitObject.GetEndTime() - prevEndTime) / (result.TimeAbsolute - prevEndTime),
min_allowable_rate_change,
max_allowable_rate_change
);
}
/// <summary>
/// Update <see cref="targetRate"/> based on the values in <see cref="recentRates"/>.
/// </summary>
private void updateTargetRate()
{
// Compare values in recentRates to see how consistent the player's speed is
// If the player hits half of the notes too fast and the other half too slow: Abs(consistency) = 0
// If the player hits all their notes too fast or too slow: Abs(consistency) = recent_rate_count - 1
int consistency = 0;
for (int i = 1; i < recentRates.Count; i++)
{
consistency += Math.Sign(recentRates[i] - recentRates[i - 1]);
}
// Scale the rate adjustment based on consistency
targetRate = Interpolation.Lerp(targetRate, recentRates.Average(), Math.Abs(consistency) / (recent_rate_count - 1d));
}
}
}

View File

@@ -0,0 +1,65 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Globalization;
using osu.Framework.Bindables;
using osu.Framework.Localisation;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Overlays.Settings;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Mania.LAsEZMania.Analysis;
namespace osu.Game.Rulesets.Mania.Mods.LAsMods
{
public class ManiaModSRAdjust : Mod, IApplicableToDifficulty
{
public override string Name => "SR Adjust";
public override string Acronym => "SRA";
public override LocalisableString Description => "修正xxySR计算中的一些系数。影响的是难度卡上的SR(月亮星)数值。";
public override ModType Type => ModType.LA_Mod;
public override double ScoreMultiplier => 1;
public override bool Ranked => false;
[SettingSource("Rescale Threshold", "超过此阈值后将降低难度膨胀速度", SettingControlType = typeof(MultiplierSettingsSlider))]
public BindableNumber<double> RescaleThreshold { get; } = new BindableDouble(SRCalculator.RescaleHighThreshold)
{
MinValue = 5,
MaxValue = 10,
Precision = 1
};
[SettingSource("LN Integral Multiplier", "LN 因子", SettingControlType = typeof(MultiplierSettingsSlider))]
public BindableNumber<double> LnMultiplier { get; } = new BindableDouble(SRCalculator.LnIntegralMultiplier)
{
MinValue = 4,
MaxValue = 8,
Precision = 0.5
};
public ManiaModSRAdjust()
{
RescaleThreshold.BindValueChanged(e => SRCalculator.RescaleHighThreshold = e.NewValue, true);
LnMultiplier.BindValueChanged(e => SRCalculator.LnIntegralMultiplier = e.NewValue, true);
}
public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription
{
get
{
yield return ("Rescale Threshold", new LocalisableString(RescaleThreshold.Value.ToString(CultureInfo.InvariantCulture)));
yield return ("LN Integral Multiplier", new LocalisableString(LnMultiplier.Value.ToString(CultureInfo.InvariantCulture)));
}
}
public void ApplyToDifficulty(BeatmapDifficulty difficulty)
{
}
}
}

View File

@@ -0,0 +1,137 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Bindables;
using osu.Framework.Localisation;
using osu.Game.Audio;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Overlays.Settings;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.LAsEZMania;
using osu.Game.Rulesets.Mods;
namespace osu.Game.Rulesets.Mania.Mods.LAsMods
{
/// <summary>
/// 需要同时使用IApplicableAfterBeatmapConversion, IHasApplyOrder
///否则时序错误
/// </summary>
public class ManiaModSpaceBody : Mod, IApplicableAfterBeatmapConversion, IHasApplyOrder
{
public override string Name => "Space Body";
public override string Acronym => "SB";
public override double ScoreMultiplier => 1;
public override LocalisableString Description => EzManiaModStrings.SpaceBody_Description;
public override ModType Type => ModType.LA_Mod;
public override bool Ranked => false;
public override bool ValidForMultiplayer => true;
public override bool ValidForFreestyleAsRequiredMod => false;
public override Type[] IncompatibleMods => new[] { typeof(ManiaModHoldOff) };
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.SpaceBody_Label), nameof(EzManiaModStrings.SpaceBodyGap_Description), SettingControlType = typeof(MultiplierSettingsSlider))]
public BindableNumber<double> SpaceBeat { get; } = new BindableDouble(4)
{
MinValue = 1,
MaxValue = 16,
Precision = 1
};
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.AddShield_Label), nameof(EzManiaModStrings.AddShield_Description))]
public BindableBool Shield { get; } = new BindableBool();
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.ApplyOrder_Label), nameof(EzManiaModStrings.ApplyOrder_Description), SettingControlType = typeof(SettingsNumberBox))]
public Bindable<int?> ApplyOrderSetting { get; } = new Bindable<int?>(100);
public void ApplyToBeatmap(IBeatmap beatmap)
{
var maniaBeatmap = (ManiaBeatmap)beatmap;
var newObjects = new List<ManiaHitObject>();
var lastHolds = new List<HoldNote>();
foreach (var column in maniaBeatmap.HitObjects.GroupBy(h => h.Column))
{
var newColumnObjects = new List<ManiaHitObject>();
var locations = Shield.Value
? column.OfType<Note>().Select(n => (startTime: n.StartTime, samples: n.Samples))
.Concat(column.OfType<HoldNote>().SelectMany(h => new[]
{
(startTime: h.StartTime, samples: h.GetNodeSamples(0)),
(startTime: h.EndTime, samples: h.GetNodeSamples(1))
}))
.OrderBy(h => h.startTime).ToList()
: column.OfType<Note>().Select(n => (startTime: n.StartTime, samples: n.Samples))
.Concat(column.OfType<HoldNote>().SelectMany(h => new[]
{
(startTime: h.StartTime, samples: h.GetNodeSamples(0)),
}))
.OrderBy(h => h.startTime).ToList();
for (int i = 0; i < locations.Count - 1; i++)
{
// 长按音符的完整持续时间。
double duration = locations[i + 1].startTime - locations[i].startTime;
// 长按音符结束时的拍长。
double beatLength = beatmap.ControlPointInfo.TimingPointAt(locations[i + 1].startTime).BeatLength;
// 减少持续时间最多1/4拍以确保没有瞬时音符。
// duration = Math.Max(duration / 2, duration - beatLength / 4);
duration = Math.Max(duration / 2, duration - beatLength / SpaceBeat.Value);
newColumnObjects.Add(new HoldNote
{
Column = Math.Clamp(column.Key, 0, maniaBeatmap.TotalColumns - 1),
StartTime = locations[i].startTime,
Duration = duration,
NodeSamples = new List<IList<HitSampleInfo>> { locations[i].samples, Array.Empty<HitSampleInfo>() }
});
}
newObjects.AddRange(newColumnObjects);
if (newColumnObjects.Any())
{
var last = (HoldNote)newColumnObjects.Last();
lastHolds.Add(last);
}
}
// 将每列最后一个长按音符的结束时间对齐到下一个 1/4 节拍
if (lastHolds.Any())
{
double maxEndTime = lastHolds.Max(h => h.StartTime + h.Duration);
var timingPoint = beatmap.ControlPointInfo.TimingPointAt(maxEndTime);
double beatLength = timingPoint.BeatLength;
double offset = timingPoint.Time;
double currentBeats = (maxEndTime - offset) / beatLength;
double alignedBeats = Math.Ceiling(currentBeats * 4) / 4;
double alignedEndTime = offset + alignedBeats * beatLength;
foreach (var last in lastHolds)
{
last.Duration = alignedEndTime - last.StartTime;
}
}
maniaBeatmap.HitObjects = newObjects.OrderBy(h => h.StartTime).ToList();
// 无休息时间
maniaBeatmap.Breaks.Clear();
}
// 确认此 Mod 在其他转换后 Mod 之后应用,返回更高的应用顺序。
// 没有此接口的 Mod 被视为顺序 0。
public int ApplyOrder => ApplyOrderSetting.Value ?? 100;
}
}

View File

@@ -0,0 +1,614 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using osu.Framework.Audio;
using osu.Framework.Bindables;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Framework.Logging;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Overlays.Settings;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.LAsEZMania;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Objects.Drawables;
using osu.Game.Rulesets.Mania.Scoring;
using osu.Game.Rulesets.Mania.UI;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI;
using osu.Game.Scoring;
using osu.Game.Screens.Play;
namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods
{
public partial class ManiaModAdjust : ModRateAdjust,
IApplicableAfterConversion,
IApplicableToDifficulty,
IApplicableToBeatmap,
IManiaRateAdjustmentMod,
IApplicableToDrawableRuleset<ManiaHitObject>,
IApplicableFailOverride,
IApplicableToHUD,
IReadFromConfig,
IApplicableToHealthProcessor,
IApplicableToScoreProcessor,
IHasSeed
{
public override string Name => @"Adjust";
public override LocalisableString Description => EzManiaModStrings.Adjust_Description;
public override string Acronym => "AJ";
public override ModType Type => ModType.YuLiangSSS_Mod;
public override IconUsage? Icon => FontAwesome.Solid.Atlas;
public override double ScoreMultiplier => ScoreMultiplierAdjust.Value;
public override bool Ranked => false;
public override bool ValidForMultiplayer => false;
public override Type[] IncompatibleMods => new[] { typeof(ModEasy), typeof(ModHardRock), typeof(ModTimeRamp), typeof(ModAdaptiveSpeed), typeof(ModRateAdjust) };
public BindableDouble OriginalOD = new BindableDouble();
[SettingSource("Score Multiplier")]
public BindableNumber<double> ScoreMultiplierAdjust { get; } = new BindableDouble(1)
{
MinValue = 0,
MaxValue = 10,
Precision = 0.01
};
public ManiaHitWindows HitWindows { get; set; } = new ManiaHitWindows();
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.HPDrain_Label), nameof(EzManiaModStrings.HPDrain_Description), SettingControlType = typeof(DifficultyAdjustSettingsControl))]
public DifficultyBindable DrainRate { get; } = new DifficultyBindable(0)
{
Precision = 0.1f,
MinValue = 0,
MaxValue = 10,
ExtendedMaxValue = 15,
ReadCurrentFromDifficulty = diff => diff.DrainRate
};
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.AdjustAccuracy_Label), nameof(EzManiaModStrings.AdjustAccuracy_Description),
SettingControlType = typeof(DifficultyAdjustSettingsControl))]
public DifficultyBindable OverallDifficulty { get; } = new DifficultyBindable(0)
{
Precision = 0.1f,
MinValue = 0,
MaxValue = 10,
ExtendedMaxValue = 15,
ReadCurrentFromDifficulty = diff => diff.OverallDifficulty
};
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.ReleaseLenience_Label), nameof(EzManiaModStrings.ReleaseLenience_Description))]
public BindableDouble ReleaseLenience { get; } = new BindableDouble(2)
{
MaxValue = 4,
MinValue = 0.1,
Precision = 0.1
};
[SettingSource("Custom HP")]
public BindableBool CustomHP { get; } = new BindableBool(false);
[SettingSource("Custom OD")]
public BindableBool CustomOD { get; } = new BindableBool(true);
[SettingSource("Custom Release")]
public BindableBool CustomRelease { get; } = new BindableBool();
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.ExtendedLimits_Label), nameof(EzManiaModStrings.ExtendedLimits_Description))]
public BindableBool ExtendedLimits { get; } = new BindableBool();
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.AdjustConstantSpeed_Label), nameof(EzManiaModStrings.AdjustConstantSpeed_Description))]
public BindableBool ConstantSpeed { get; } = new BindableBool(true);
public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription
{
get
{
if (!ScoreMultiplierAdjust.IsDefault) yield return ("Score Multiplier", $"{ScoreMultiplierAdjust.Value:N3}");
if (CustomHP.Value) yield return ("HP", $"{DrainRate.Value:N1}");
if (CustomOD.Value) yield return ("OD", $"{OverallDifficulty.Value:N1}");
if (CustomRelease.Value) yield return ("Release Lenience", $"{ReleaseLenience.Value:N1}");
if (!SpeedChange.IsDefault) yield return ("Speed", $"{SpeedChange.Value:N3}");
if (AdjustPitch.Value) yield return ("Adjust Pitch", "On");
if (ConstantSpeed.Value) yield return ("Constant Speed", "On");
if (Mirror.Value) yield return ("Mirror", "On");
if (RandomMirror.Value) yield return ("Random Mirror", "On");
if (NoFail.Value) yield return ("No Fail", "On");
if (Restart.Value) yield return ("Restart", "On");
if (RandomSelect.Value) yield return ("Random", "On");
if (TrueRandom.Value) yield return ("True Random", "On");
if (Seed.Value is not null) yield return ("Seed", $"Seed {Seed.Value}");
if (CustomHitRange.Value)
{
yield return ("Perfect Hit", $"{PerfectHit.Value}ms");
yield return ("Great Hit", $"{GreatHit.Value}ms");
yield return ("Good Hit", $"{GoodHit.Value}ms");
yield return ("Ok Hit", $"{OkHit.Value}ms");
yield return ("Meh Hit", $"{MehHit.Value}ms");
yield return ("Miss Hit", $"{MissHit.Value}ms");
}
if (CustomProportionScore.Value)
{
yield return ("Perfect", $"{Perfect.Value}");
yield return ("Great", $"{Great.Value}");
yield return ("Good", $"{Good.Value}");
yield return ("Ok", $"{Ok.Value}");
yield return ("Meh", $"{Meh.Value}");
yield return ("Miss", $"{Miss.Value}");
}
}
}
public override string ExtendedIconInformation => "";
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.SpeedChange_Label), nameof(EzManiaModStrings.SpeedChange_Description),
SettingControlType = typeof(MultiplierSettingsSlider))]
public override BindableNumber<double> SpeedChange { get; } = new BindableDouble(1)
{
MinValue = 0.1,
MaxValue = 2.5,
Precision = 0.025
};
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.AdjustPitch_Label), nameof(EzManiaModStrings.AdjustPitch_Description))]
public virtual BindableBool AdjustPitch { get; } = new BindableBool();
private readonly RateAdjustModHelper rateAdjustHelper;
public ManiaModAdjust()
{
rateAdjustHelper = new RateAdjustModHelper(SpeedChange);
foreach (var (_, property) in this.GetOrderedSettingsSourceProperties())
{
if (property.GetValue(this) is DifficultyBindable diffAdjustBindable)
diffAdjustBindable.ExtendedLimits.BindTo(ExtendedLimits);
}
rateAdjustHelper.HandleAudioAdjustments(AdjustPitch);
CustomHitRange.BindValueChanged(_ => updateCustomHitRange());
PerfectHit.BindValueChanged(_ => updateCustomHitRange());
GreatHit.BindValueChanged(_ => updateCustomHitRange());
GoodHit.BindValueChanged(_ => updateCustomHitRange());
OkHit.BindValueChanged(_ => updateCustomHitRange());
MehHit.BindValueChanged(_ => updateCustomHitRange());
MissHit.BindValueChanged(_ => updateCustomHitRange());
}
private void updateCustomHitRange()
{
if (CustomHitRange.Value)
{
HitWindows.ModifyManiaHitRange(new ManiaModifyHitRange(
PerfectHit.Value,
GreatHit.Value,
GoodHit.Value,
OkHit.Value,
MehHit.Value,
MissHit.Value
));
}
else
{
HitWindows.ResetRange();
}
}
/// <summary>
/// Apply all custom settings to the provided beatmap.
/// </summary>
/// <param name="difficulty">The beatmap to have settings applied.</param>
protected void ApplySettings(BeatmapDifficulty difficulty)
{
if (DrainRate.Value != null && CustomHP.Value)
difficulty.DrainRate = DrainRate.Value.Value;
if (OverallDifficulty.Value != null && CustomOD.Value && !CustomHitRange.Value)
{
OriginalOD.Value = difficulty.OverallDifficulty;
difficulty.OverallDifficulty = OverallDifficulty.Value.Value;
}
}
public void ApplyToDifficulty(BeatmapDifficulty difficulty)
{
HitWindows.SpeedMultiplier = SpeedChange.Value;
HitWindows.SetDifficulty(difficulty.OverallDifficulty);
ApplySettings(difficulty);
AdjustHoldNote.ReleaseLenience = ReleaseLenience.Value;
AdjustTailNote.ReleaseLenience = ReleaseLenience.Value;
AdjustDrawableHoldNoteTail.ReleaseLenience = ReleaseLenience.Value;
}
public override void ApplyToTrack(IAdjustableAudioComponent track)
{
rateAdjustHelper.ApplyToTrack(track);
}
public void ApplyToDrawableRuleset(DrawableRuleset<ManiaHitObject> drawableRuleset)
{
var maniaRuleset = (DrawableManiaRuleset)drawableRuleset;
if (ConstantSpeed.Value) maniaRuleset.VisualisationMethod = ScrollVisualisationMethod.Constant;
if (CustomRelease.Value)
{
foreach (var stage in maniaRuleset.Playfield.Stages)
{
foreach (var column in stage.Columns) column.RegisterPool<AdjustTailNote, AdjustDrawableHoldNoteTail>(10, 50);
}
}
}
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Mirror_Label), nameof(EzManiaModStrings.Mirror_Description))]
public BindableBool Mirror { get; } = new BindableBool();
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.RandomMirror_Label), nameof(EzManiaModStrings.RandomMirror_Description))]
public BindableBool RandomMirror { get; } = new BindableBool(true);
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.NoFail_Label), nameof(EzManiaModStrings.NoFail_Description))]
public BindableBool NoFail { get; } = new BindableBool(true);
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Restart_Label), nameof(EzManiaModStrings.Restart_Description))]
public BindableBool Restart { get; } = new BindableBool();
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.RandomSelect_Label), nameof(EzManiaModStrings.RandomSelect_Description))]
public BindableBool RandomSelect { get; } = new BindableBool();
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.TrueRandom_Label), nameof(EzManiaModStrings.TrueRandom_Description))]
public BindableBool TrueRandom { get; } = new BindableBool();
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Seed_Label), nameof(EzManiaModStrings.Seed_Description), SettingControlType = typeof(SettingsNumberBox))]
public Bindable<int?> Seed { get; } = new Bindable<int?>();
public void ApplyToBeatmap(IBeatmap beatmap)
{
var maniaBeatmap = (ManiaBeatmap)beatmap;
if (Test.Value)
{
var obj = maniaBeatmap;
var groups = obj.HitObjects.GroupBy(c => c.Column).OrderBy(c => c.Key);
// int note = obj.HitObjects.Select(h => h.GetEndTime() != h.StartTime).Count();
// int note = obj.HitObjects.Count - note;
foreach (var column in groups) Logger.Log($"Column {column.Key + 1}: {column.Count()} notes", level: LogLevel.Important);
//Logger.Log($"Test:\nThis beatmap has {obj.HitObjects.Count} HitObjects.\n", level: LogLevel.Important);
}
if (RandomSelect.Value)
{
Seed.Value ??= RNG.Next();
var rng = new Random((int)Seed.Value);
int availableColumns = maniaBeatmap.TotalColumns;
var shuffledColumns = Enumerable.Range(0, availableColumns).OrderBy(_ => rng.Next()).ToList();
beatmap.HitObjects.OfType<ManiaHitObject>().ForEach(h => h.Column = shuffledColumns[h.Column]);
}
if (Mirror.Value)
{
int availableColumns = maniaBeatmap.TotalColumns;
beatmap.HitObjects.OfType<ManiaHitObject>().ForEach(h => h.Column = availableColumns - 1 - h.Column);
}
if (RandomMirror.Value)
{
Seed.Value ??= RNG.Next();
var rng = new Random((int)Seed.Value);
if (rng.Next() % 2 == 0)
{
int availableColumns = maniaBeatmap.TotalColumns;
beatmap.HitObjects.OfType<ManiaHitObject>().ForEach(h => h.Column = availableColumns - 1 - h.Column);
}
}
if (TrueRandom.Value)
{
Seed.Value ??= RNG.Next();
var rng = new Random((int)Seed.Value);
int availableColumns = maniaBeatmap.TotalColumns;
foreach (var obj in beatmap.HitObjects.OfType<ManiaHitObject>().GroupBy(c => c.StartTime))
{
var columnList = new List<int>();
foreach (var hit in obj) columnList.Add(hit.Column);
var newColumn = Enumerable.Range(0, availableColumns).SelectRandom(rng, columnList.Count).ToList();
int index = 0;
foreach (var hit in obj)
{
hit.Column = newColumn[index];
index++;
}
}
}
}
//------Fail Condition------
private Action? triggerFailureDelegate;
private readonly Bindable<bool> showHealthBar = new Bindable<bool>();
public bool PerformFail()
{
return !NoFail.Value;
}
public bool RestartOnFail
{
get
{
if (NoFail.Value) return !NoFail.Value;
return Restart.Value;
}
}
public void ReadFromConfig(OsuConfigManager config)
{
config.BindWith(OsuSetting.ShowHealthDisplayWhenCantFail, showHealthBar);
}
public void ApplyToHUD(HUDOverlay overlay)
{
overlay.ShowHealthBar.BindTo(showHealthBar);
}
public void ApplyToHealthProcessor(HealthProcessor healthProcessor)
{
triggerFailureDelegate = healthProcessor.TriggerFailure;
}
protected void TriggerFailure()
{
triggerFailureDelegate?.Invoke();
}
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.CustomHitRange_Label), nameof(EzManiaModStrings.CustomHitRange_Description))]
public BindableBool CustomHitRange { get; } = new BindableBool();
[SettingSource("Perfect")]
public BindableDouble PerfectHit { get; } = new BindableDouble(22.4D)
{
Precision = 0.1,
MinValue = 0,
MaxValue = 250
};
[SettingSource("Great")]
public BindableDouble GreatHit { get; } = new BindableDouble(64)
{
Precision = 0.1,
MinValue = 0,
MaxValue = 250
};
[SettingSource("Good")]
public BindableDouble GoodHit { get; } = new BindableDouble(97)
{
Precision = 0.1,
MinValue = 0,
MaxValue = 250
};
[SettingSource("Ok")]
public BindableDouble OkHit { get; } = new BindableDouble(127)
{
Precision = 0.1,
MinValue = 0,
MaxValue = 250
};
[SettingSource("Meh")]
public BindableDouble MehHit { get; } = new BindableDouble(151)
{
Precision = 0.1,
MinValue = 0,
MaxValue = 250
};
[SettingSource("Miss")]
public BindableDouble MissHit { get; } = new BindableDouble(188)
{
Precision = 0.1,
MinValue = 0,
MaxValue = 250
};
[SettingSource("Custom Proportion Score")]
public BindableBool CustomProportionScore { get; } = new BindableBool();
[SettingSource("Perfect")]
public BindableInt Perfect { get; } = new BindableInt(300)
{
Precision = 5,
MinValue = 0,
MaxValue = 500
};
[SettingSource("Great")]
public BindableInt Great { get; } = new BindableInt(300)
{
Precision = 5,
MinValue = 0,
MaxValue = 500
};
[SettingSource("Good")]
public BindableInt Good { get; } = new BindableInt(200)
{
Precision = 5,
MinValue = 0,
MaxValue = 500
};
[SettingSource("Ok")]
public BindableInt Ok { get; } = new BindableInt(100)
{
Precision = 5,
MinValue = 0,
MaxValue = 500
};
[SettingSource("Meh")]
public BindableInt Meh { get; } = new BindableInt(50)
{
Precision = 5,
MinValue = 0,
MaxValue = 500
};
[SettingSource("Miss")]
public BindableInt Miss { get; } = new BindableInt(0)
{
Precision = 5,
MinValue = 0,
MaxValue = 500
};
[SettingSource("Test")]
public BindableBool Test { get; } = new BindableBool();
private readonly BindableInt combo = new BindableInt();
private readonly BindableDouble accuracy = new BindableDouble();
public ScoreRank AdjustRank(ScoreRank rank, double accuracy)
{
return rank;
}
public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor)
{
// var mania = (ManiaScoreProcessor)scoreProcessor;
// if (CustomProportionScore.Value)
// {
// mania.HitProportionScore.Perfect = Perfect.Value;
// mania.HitProportionScore.Great = Great.Value;
// mania.HitProportionScore.Good = Good.Value;
// mania.HitProportionScore.Ok = Ok.Value;
// mania.HitProportionScore.Meh = Meh.Value;
// mania.HitProportionScore.Miss = Miss.Value;
// }
combo.UnbindAll();
accuracy.UnbindAll();
combo.BindTo(scoreProcessor.Combo);
accuracy.BindTo(scoreProcessor.Accuracy);
}
public override void ResetSettingsToDefaults()
{
base.ResetSettingsToDefaults();
HitWindows.ResetRange();
}
public void ApplyToBeatmapAfterConversion(IBeatmap beatmap)
{
var maniaBeatmap = (ManiaBeatmap)beatmap;
if (CustomRelease.Value)
{
var hitObjects = maniaBeatmap.HitObjects.Select(obj =>
{
if (obj is HoldNote hold)
return new AdjustHoldNote(hold);
return obj;
}).ToList();
maniaBeatmap.HitObjects = hitObjects;
}
}
public partial class AdjustDrawableHoldNoteTail : DrawableHoldNoteTail
{
public static double ReleaseLenience;
protected override void CheckForResult(bool userTriggered, double timeOffset)
{
base.CheckForResult(userTriggered, timeOffset * TailNote.RELEASE_WINDOW_LENIENCE / ReleaseLenience);
}
}
private class AdjustTailNote : TailNote
{
public static double ReleaseLenience;
public override double MaximumJudgementOffset => base.MaximumJudgementOffset / RELEASE_WINDOW_LENIENCE * ReleaseLenience;
}
private class AdjustHoldNote : HoldNote
{
public static double ReleaseLenience;
public AdjustHoldNote(HoldNote hold)
{
StartTime = hold.StartTime;
Duration = hold.Duration;
Column = hold.Column;
NodeSamples = hold.NodeSamples;
}
protected override void CreateNestedHitObjects(CancellationToken cancellationToken)
{
AddNested(Head = new HeadNote
{
StartTime = StartTime,
Column = Column,
Samples = GetNodeSamples(0)
});
AddNested(Tail = new AdjustTailNote
{
StartTime = EndTime,
Column = Column,
Samples = GetNodeSamples(NodeSamples?.Count - 1 ?? 1)
});
AddNested(Body = new HoldNoteBody
{
StartTime = StartTime,
Column = Column
});
}
public override double MaximumJudgementOffset => base.MaximumJudgementOffset / TailNote.RELEASE_WINDOW_LENIENCE * ReleaseLenience;
}
}
}

View File

@@ -0,0 +1,125 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Audio;
using osu.Framework.Bindables;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Framework.Utils;
using osu.Game.Configuration;
using osu.Game.Rulesets.Mania.LAsEZMania;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI;
using osu.Game.Scoring;
namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods
{
public class ManiaModChangeSpeedByAccuracy : Mod, IUpdatableByPlayfield, IApplicableToScoreProcessor, IApplicableToRate
{
public override string Name => "Speed & Accuracy";
public override string Acronym => "SA";
public override LocalisableString Description => EzManiaModStrings.ChangeSpeedByAccuracy_Description;
public override ModType Type => ModType.YuLiangSSS_Mod;
public override IconUsage? Icon => FontAwesome.Solid.ChartLine;
public override double ScoreMultiplier => 1;
public override bool Ranked => false;
public override Type[] IncompatibleMods => new[] { typeof(ModTimeRamp) };
private readonly BindableDouble accuracy = new BindableDouble();
private readonly RateAdjustModHelper rateAdjustHelper;
public BindableNumber<double> SpeedChange { get; } = new BindableDouble(1)
{
Precision = 0.01
};
private double targetSpeed = 1;
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.ChangeSpeedAccuracy_Label), nameof(EzManiaModStrings.ChangeSpeedAccuracy_Description))]
public BindableDouble Accuracy { get; } = new BindableDouble(95)
{
MinValue = 0,
MaxValue = 100,
Precision = 0.5,
};
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.MaxSpeed_Label), nameof(EzManiaModStrings.MaxSpeed_Description))]
public BindableDouble MaxSpeed { get; } = new BindableDouble(1.5)
{
MinValue = 1,
MaxValue = 2,
Precision = 0.1,
};
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.MinSpeed_Label), nameof(EzManiaModStrings.MinSpeed_Description))]
public BindableDouble MinSpeed { get; } = new BindableDouble(0.5)
{
MinValue = 0.5,
MaxValue = 1,
Precision = 0.1,
};
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.AdjustPitch_Label), nameof(EzManiaModStrings.AdjustPitch_Description))]
public virtual BindableBool AdjustPitch { get; } = new BindableBool();
public ManiaModChangeSpeedByAccuracy()
{
rateAdjustHelper = new RateAdjustModHelper(SpeedChange);
rateAdjustHelper.HandleAudioAdjustments(AdjustPitch);
}
public void Update(Playfield playfield)
{
UpdateTargetSpeed();
SpeedChange.Value = Interpolation.DampContinuously(SpeedChange.Value, targetSpeed, 40, playfield.Clock.ElapsedFrameTime);
}
public void UpdateTargetSpeed()
{
double currentAccuracy = accuracy.Value;
double accuracyDifference = currentAccuracy - Accuracy.Value;
if (accuracyDifference > 0)
{
targetSpeed = Math.Min(MaxSpeed.Value, targetSpeed + accuracyDifference * 0.01);
}
else
{
targetSpeed = Math.Max(MinSpeed.Value, targetSpeed - Math.Abs(accuracyDifference) * 0.01);
}
SpeedChange.Value = targetSpeed;
}
public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor)
{
accuracy.UnbindAll();
accuracy.BindTo(scoreProcessor.Accuracy);
}
public ScoreRank AdjustRank(ScoreRank rank, double accuracy) => rank;
public double ApplyToRate(double time, double rate = 1) => rate * SpeedChange.Value;
public void ApplyToTrack(IAdjustableAudioComponent track)
{
rateAdjustHelper.ApplyToTrack(track);
}
public void ApplyToSample(IAdjustableAudioComponent sample)
{
sample.AddAdjustment(AdjustableProperty.Frequency, SpeedChange);
}
}
}

View File

@@ -0,0 +1,213 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Bindables;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.LAsEZMania;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mods;
namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods
{
public class ManiaModCleaner : Mod, IApplicableAfterBeatmapConversion
{
public override string Name => "Cleaner";
public override string Acronym => "CL";
public override LocalisableString Description => EzManiaModStrings.Cleaner_Description;
public override IconUsage? Icon => FontAwesome.Solid.Broom;
public override ModType Type => ModType.YuLiangSSS_Mod;
public override double ScoreMultiplier => 1;
public override bool Ranked => false;
public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription
{
get
{
yield return ("Style", $"{Style.Value}");
yield return ("Interval", $"{Interval.Value}ms");
yield return ("LN Interval", $"{LNInterval.Value}ms");
}
}
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Style_Label), nameof(EzManiaModStrings.Style_Description))]
public BindableNumber<int> Style { get; set; } = new BindableInt(2)
{
MinValue = 1,
MaxValue = 2,
Precision = 1,
};
// DurationEveryDivide = 60 / bpm / divide * 10000
// 125ms is equivalent to duration time between adjacent every two 120BPM 1/4 timing line.
// 125ms 相当于 120BPM 1/4 叠键每两行的时间间隔
// 以下个人用方便消除乱键子弹使用
//
// Level 1: 125.00ms
// 120BPM - 125.00ms 130BPM - 115.38ms 140BPM - 107.14ms
// 150BPM - 100.00ms
//
// Level 2: 100.00ms
// 160BPM - 93.75ms 170BPM - 88.23ms 180BPM - 83.33ms
// 190BPM - 78.94ms
//
// Level 3: 75.00ms
// 200BPM - 75.00ms 210BPM - 71.42ms 220BPM - 68.18ms
// 230BPM - 65.21ms 240BPM - 62.50ms
//
// Level4: 60.00ms
// 250BPM - 60.00ms 260BPM - 57.69ms 270BPM - 55.55ms
// 280BPM - 53.57ms 290BPM - 51.72ms 300BPM - 50.00ms
//
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Interval_Label), nameof(EzManiaModStrings.Interval_Description))]
public BindableNumber<int> Interval { get; set; } = new BindableInt(80)
{
MinValue = 1,
MaxValue = 125,
Precision = 1,
};
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.LNInterval_Label), nameof(EzManiaModStrings.LNInterval_Description))]
public BindableNumber<int> LNInterval { get; set; } = new BindableInt(30)
{
MinValue = 1,
MaxValue = 125,
Precision = 1,
};
public void ApplyToBeatmap(IBeatmap beatmap)
{
var maniaBeatmap = (ManiaBeatmap)beatmap;
var newObjects = new List<ManiaHitObject>();
foreach (var column in maniaBeatmap.HitObjects.GroupBy(h => h.Column))
{
var newColumnObjects = new List<ManiaHitObject>();
var locations = column.OfType<Note>().Select(n => (startTime: n.StartTime, samples: n.Samples, endTime: n.StartTime))
.Concat(column.OfType<HoldNote>().SelectMany(h => new[]
{
(startTime: h.StartTime, samples: h.GetNodeSamples(0), endTime: h.EndTime)
}))
.OrderBy(h => h.startTime).ToList();
double lastStartTime = locations[0].startTime;
double lastEndTime = locations[0].endTime;
var lastSample = locations[0].samples;
// Zero
//if (lastStartTime != lastEndTime)
//{
// newColumnObjects.Add(new HoldNote
// {
// Column = column.Key,
// StartTime = lastStartTime,
// Duration = lastEndTime - lastStartTime,
// NodeSamples = [locations[0].samples, Array.Empty<HitSampleInfo>()]
// });
//}
//else
//{
// newColumnObjects.Add(new Note
// {
// Column = column.Key,
// StartTime = lastStartTime,
// Samples = locations[0].samples
// });
//}
for (int i = 0; i < locations.Count; i++)
{
if (i == 0)
{
lastStartTime = locations[0].startTime;
lastEndTime = locations[0].endTime;
lastSample = locations[0].samples;
continue;
}
if (locations[i].startTime >= lastStartTime && locations[i].startTime <= lastEndTime)
{
locations.RemoveAt(i);
i--;
continue;
} // if the note in a LN
if (Math.Abs(locations[i].startTime - lastStartTime) <= Interval.Value)
{
if (Style.Value == 2)
{
lastStartTime = locations[i].startTime;
lastEndTime = locations[i].endTime;
lastSample = locations[i].samples;
}
locations.RemoveAt(i);
i--;
continue;
} // interval judgement
if (Math.Abs(locations[i].startTime - lastEndTime) <= LNInterval.Value)
{
if (Style.Value == 2)
{
lastStartTime = locations[i].startTime;
lastEndTime = locations[i].endTime;
lastSample = locations[i].samples;
}
locations.RemoveAt(i);
i--;
continue;
} // LN interval judgement
newColumnObjects.AddNote(lastSample, column.Key, lastStartTime, lastEndTime);
lastStartTime = locations[i].startTime;
lastEndTime = locations[i].endTime;
lastSample = locations[i].samples;
}
newColumnObjects.AddNote(lastSample, column.Key, lastStartTime, lastEndTime);
// Last
//if (lastStartTime != lastEndTime)
//{
// newColumnObjects.Add(new HoldNote
// {
// Column = column.Key,
// StartTime = locations[locations.Count - 1].startTime,
// Duration = locations[locations.Count - 1].endTime - locations[locations.Count - 1].startTime,
// NodeSamples = [locations[locations.Count - 1].samples, Array.Empty<HitSampleInfo>()]
// });
//}
//else
//{
// newColumnObjects.Add(new Note
// {
// Column = column.Key,
// StartTime = lastStartTime,
// Samples = locations[locations.Count - 1].samples
// });
//}
newObjects.AddRange(newColumnObjects);
}
maniaBeatmap.HitObjects = [.. newObjects.OrderBy(h => h.StartTime)];
}
}
}

View File

@@ -0,0 +1,509 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Bindables;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Audio;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.LAsEZMania;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mods;
namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods
{
public class ManiaModDoublePlay : Mod, IApplicableToBeatmapConverter, IApplicableAfterBeatmapConversion
{
public override string Name => "Double Play";
public override string Acronym => "DP";
public override IconUsage? Icon => FontAwesome.Solid.Sun;
public override ModType Type => ModType.YuLiangSSS_Mod;
public override LocalisableString Description => "Convert 4k to 8k (Double 4k).";
public override double ScoreMultiplier => 1;
public override bool Ranked => false;
public override bool ValidForMultiplayer => true;
public override bool ValidForFreestyleAsRequiredMod => false;
public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription
{
get
{
yield return ("Style", $"Style {Style.Value}");
}
}
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.DoublePlayStyle_Label), nameof(EzManiaModStrings.DoublePlayStyle_Description))]
public BindableNumber<int> Style { get; } = new BindableInt(1)
{
MinValue = 1,
MaxValue = 8,
Precision = 1,
};
public void ApplyToBeatmapConverter(IBeatmapConverter converter)
{
var mbc = (ManiaBeatmapConverter)converter;
float keys = mbc.TotalColumns;
if (keys != 4)
{
return;
}
mbc.TargetColumns = 8;
}
public void ApplyToBeatmap(IBeatmap beatmap)
{
var maniaBeatmap = (ManiaBeatmap)beatmap;
int keys = (int)maniaBeatmap.Difficulty.CircleSize;
if (keys != 4)
{
return;
}
var newObjects = new List<ManiaHitObject>();
var newColumnObjects = new List<ManiaHitObject>();
var locations = maniaBeatmap.HitObjects.OfType<Note>().Select(n => (
startTime: n.StartTime,
samples: n.Samples,
column: n.Column,
endTime: n.StartTime
))
.Concat(maniaBeatmap.HitObjects.OfType<HoldNote>().Select(h => (
startTime: h.StartTime,
samples: h.Samples,
column: h.Column,
endTime: h.EndTime
))).OrderBy(h => h.startTime).ToList();
for (int i = 0; i < locations.Count; i++)
{
bool isLN = false;
var note = new Note();
var hold = new HoldNote();
int columnIndex = locations[i].column;
switch (columnIndex)
{
case 1:
{
columnIndex = 0;
}
break;
case 3:
{
columnIndex = 1;
}
break;
case 5:
{
columnIndex = 2;
if (Style.Value >= 5 && Style.Value <= 8)
{
columnIndex = 4;
}
}
break;
case 7:
{
columnIndex = 3;
if (Style.Value >= 5 && Style.Value <= 8)
{
columnIndex = 5;
}
}
break;
}
if (locations[i].startTime == locations[i].endTime)
{
note.StartTime = locations[i].startTime;
note.Samples = locations[i].samples;
}
else
{
hold.StartTime = locations[i].startTime;
hold.Samples = locations[i].samples;
hold.EndTime = locations[i].endTime;
isLN = true;
}
if (isLN)
{
switch (Style.Value)
{
case 1:
{
newColumnObjects.Add(new HoldNote
{
Column = columnIndex,
StartTime = locations[i].startTime,
Duration = locations[i].endTime - locations[i].startTime,
NodeSamples = [locations[i].samples, Array.Empty<HitSampleInfo>()]
});
newColumnObjects.Add(new HoldNote
{
Column = 4 + columnIndex,
StartTime = locations[i].startTime,
Duration = locations[i].endTime - locations[i].startTime,
NodeSamples = [locations[i].samples, Array.Empty<HitSampleInfo>()]
});
}
break;
case 2:
{
newColumnObjects.Add(new HoldNote
{
Column = 3 - columnIndex,
StartTime = locations[i].startTime,
Duration = locations[i].endTime - locations[i].startTime,
NodeSamples = [locations[i].samples, Array.Empty<HitSampleInfo>()]
});
newColumnObjects.Add(new HoldNote
{
Column = 7 - columnIndex,
StartTime = locations[i].startTime,
Duration = locations[i].endTime - locations[i].startTime,
NodeSamples = [locations[i].samples, Array.Empty<HitSampleInfo>()]
});
}
break;
case 3:
{
newColumnObjects.Add(new HoldNote
{
Column = columnIndex,
StartTime = locations[i].startTime,
Duration = locations[i].endTime - locations[i].startTime,
NodeSamples = [locations[i].samples, Array.Empty<HitSampleInfo>()]
});
newColumnObjects.Add(new HoldNote
{
Column = 7 - columnIndex,
StartTime = locations[i].startTime,
Duration = locations[i].endTime - locations[i].startTime,
NodeSamples = [locations[i].samples, Array.Empty<HitSampleInfo>()]
});
}
break;
case 4:
{
newColumnObjects.Add(new HoldNote
{
Column = 3 - columnIndex,
StartTime = locations[i].startTime,
Duration = locations[i].endTime - locations[i].startTime,
NodeSamples = [locations[i].samples, Array.Empty<HitSampleInfo>()]
});
newColumnObjects.Add(new HoldNote
{
Column = 4 + columnIndex,
StartTime = locations[i].startTime,
Duration = locations[i].endTime - locations[i].startTime,
NodeSamples = [locations[i].samples, Array.Empty<HitSampleInfo>()]
});
}
break;
case 5:
{
newColumnObjects.Add(new HoldNote
{
Column = columnIndex,
StartTime = locations[i].startTime,
Duration = locations[i].endTime - locations[i].startTime,
NodeSamples = [locations[i].samples, Array.Empty<HitSampleInfo>()]
});
newColumnObjects.Add(new HoldNote
{
Column = 2 + columnIndex,
StartTime = locations[i].startTime,
Duration = locations[i].endTime - locations[i].startTime,
NodeSamples = [locations[i].samples, Array.Empty<HitSampleInfo>()]
});
}
break;
case 6:
{
if (columnIndex <= 1)
{
columnIndex = 3 - columnIndex;
}
if (columnIndex >= 4)
{
columnIndex = 7 - columnIndex + 4;
}
newColumnObjects.Add(new HoldNote
{
Column = columnIndex,
StartTime = locations[i].startTime,
Duration = locations[i].endTime - locations[i].startTime,
NodeSamples = [locations[i].samples, Array.Empty<HitSampleInfo>()]
});
newColumnObjects.Add(new HoldNote
{
Column = columnIndex - 2,
StartTime = locations[i].startTime,
Duration = locations[i].endTime - locations[i].startTime,
NodeSamples = [locations[i].samples, Array.Empty<HitSampleInfo>()]
});
}
break;
case 7:
case 8:
{
if (Style.Value == 8)
{
if (columnIndex == 0 || columnIndex == 4)
{
columnIndex++;
}
else if (columnIndex == 1 || columnIndex == 5)
{
columnIndex--;
}
}
if (columnIndex < 4)
{
newColumnObjects.Add(new HoldNote
{
Column = columnIndex,
StartTime = locations[i].startTime,
Duration = locations[i].endTime - locations[i].startTime,
NodeSamples = [locations[i].samples, Array.Empty<HitSampleInfo>()]
});
newColumnObjects.Add(new HoldNote
{
Column = 3 - columnIndex,
StartTime = locations[i].startTime,
Duration = locations[i].endTime - locations[i].startTime,
NodeSamples = [locations[i].samples, Array.Empty<HitSampleInfo>()]
});
}
if (columnIndex > 3)
{
newColumnObjects.Add(new HoldNote
{
Column = columnIndex,
StartTime = locations[i].startTime,
Duration = locations[i].endTime - locations[i].startTime,
NodeSamples = [locations[i].samples, Array.Empty<HitSampleInfo>()]
});
newColumnObjects.Add(new HoldNote
{
Column = 7 - (columnIndex - 4),
StartTime = locations[i].startTime,
Duration = locations[i].endTime - locations[i].startTime,
NodeSamples = [locations[i].samples, Array.Empty<HitSampleInfo>()]
});
}
}
break;
}
}
else
{
switch (Style.Value)
{
case 1:
{
newColumnObjects.Add(new Note
{
Column = columnIndex,
StartTime = locations[i].startTime,
Samples = locations[i].samples
});
newColumnObjects.Add(new Note
{
Column = columnIndex + 4,
StartTime = locations[i].startTime,
Samples = locations[i].samples
});
}
break;
case 2:
{
newColumnObjects.Add(new Note
{
Column = 3 - columnIndex,
StartTime = locations[i].startTime,
Samples = locations[i].samples
});
newColumnObjects.Add(new Note
{
Column = 7 - columnIndex,
StartTime = locations[i].startTime,
Samples = locations[i].samples
});
}
break;
case 3:
{
newColumnObjects.Add(new Note
{
Column = columnIndex,
StartTime = locations[i].startTime,
Samples = locations[i].samples
});
newColumnObjects.Add(new Note
{
Column = 7 - columnIndex,
StartTime = locations[i].startTime,
Samples = locations[i].samples
});
}
break;
case 4:
{
newColumnObjects.Add(new Note
{
Column = 3 - columnIndex,
StartTime = locations[i].startTime,
Samples = locations[i].samples
});
newColumnObjects.Add(new Note
{
Column = 4 + columnIndex,
StartTime = locations[i].startTime,
Samples = locations[i].samples
});
}
break;
case 5:
{
newColumnObjects.Add(new Note
{
Column = columnIndex,
StartTime = locations[i].startTime,
Samples = locations[i].samples
});
newColumnObjects.Add(new Note
{
Column = 2 + columnIndex,
StartTime = locations[i].startTime,
Samples = locations[i].samples
});
}
break;
case 6:
{
if (columnIndex <= 1)
{
columnIndex = 3 - columnIndex;
}
if (columnIndex >= 4)
{
columnIndex = 7 - columnIndex + 4;
}
newColumnObjects.Add(new Note
{
Column = columnIndex,
StartTime = locations[i].startTime,
Samples = locations[i].samples
});
newColumnObjects.Add(new Note
{
Column = columnIndex - 2,
StartTime = locations[i].startTime,
Samples = locations[i].samples
});
}
break;
case 7:
case 8:
{
if (Style.Value == 8)
{
if (columnIndex == 0 || columnIndex == 4)
{
columnIndex++;
}
else if (columnIndex == 1 || columnIndex == 5)
{
columnIndex--;
}
}
if (columnIndex < 4)
{
newColumnObjects.Add(new Note
{
Column = columnIndex,
StartTime = locations[i].startTime,
Samples = locations[i].samples
});
newColumnObjects.Add(new Note
{
Column = 3 - columnIndex,
StartTime = locations[i].startTime,
Samples = locations[i].samples
});
}
if (columnIndex > 3)
{
newColumnObjects.Add(new HoldNote
{
Column = columnIndex,
StartTime = locations[i].startTime,
Duration = locations[i].endTime - locations[i].startTime,
NodeSamples = [locations[i].samples, Array.Empty<HitSampleInfo>()]
});
newColumnObjects.Add(new HoldNote
{
Column = 7 - (columnIndex - 4),
StartTime = locations[i].startTime,
Duration = locations[i].endTime - locations[i].startTime,
NodeSamples = [locations[i].samples, Array.Empty<HitSampleInfo>()]
});
}
}
break;
}
}
}
newObjects.AddRange(newColumnObjects);
maniaBeatmap.HitObjects = newObjects;
}
}
}

View File

@@ -0,0 +1,196 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Bindables;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Framework.Utils;
using osu.Game.Audio;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Overlays.Settings;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.LAsEZMania;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mods;
namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods
{
public class ManiaModGracer : Mod, IApplicableAfterBeatmapConversion, IHasSeed
{
public const double MIN_INTERVAL = 10;
public const double PLUS_INTERVAL = 2.2;
public override string Name => "Gracer";
public override string Acronym => "GR";
public override double ScoreMultiplier => 1;
public override IconUsage? Icon => FontAwesome.Solid.Star;
public override bool Ranked => false;
public override bool ValidForMultiplayer => true;
public override bool ValidForFreestyleAsRequiredMod => false;
public override LocalisableString Description => EzManiaModStrings.Gracer_Description;
public override ModType Type => ModType.YuLiangSSS_Mod;
public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription
{
get
{
yield return ("Bias", $"{Bias.Value}");
yield return ("Interval", $"{Interval.Value}ms");
yield return ("Probability", $"{Probability.Value}%");
yield return ("Seed", $"{(Seed.Value == null ? "Null" : Seed.Value)}");
}
}
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Bias_Label), nameof(EzManiaModStrings.Bias_Description))]
public BindableNumber<int> Bias { get; set; } = new BindableInt(16)
{
MinValue = 1,
MaxValue = 50,
Precision = 1
};
// If interval is too high which will have bug taken place.
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Interval_Label), nameof(EzManiaModStrings.Interval_Description))]
public BindableNumber<double> Interval { get; set; } = new BindableNumber<double>(20)
{
MinValue = 1,
MaxValue = 50,
Precision = 1
};
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Probability_Label), nameof(EzManiaModStrings.Probability_Description))]
public BindableNumber<int> Probability { get; set; } = new BindableInt(100)
{
MinValue = 0,
MaxValue = 100,
Precision = 5
};
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Seed_Label), nameof(EzManiaModStrings.Seed_Description), SettingControlType = typeof(SettingsNumberBox))]
public Bindable<int?> Seed { get; } = new Bindable<int?>();
public void ApplyToBeatmap(IBeatmap beatmap)
{
var maniaBeatmap = (ManiaBeatmap)beatmap;
var newObjects = new List<ManiaHitObject>();
Seed.Value ??= RNG.Next();
var rng = new Random((int)Seed.Value);
var newColumnObjects = new List<ManiaHitObject>();
foreach (var column in maniaBeatmap.HitObjects.GroupBy(h => h.Column))
{
var locations = column.OfType<Note>().Select(n => (startTime: n.StartTime, samples: n.Samples, endTime: n.StartTime))
.Concat(column.OfType<HoldNote>().SelectMany(h => new[]
{
(startTime: h.StartTime, samples: h.GetNodeSamples(0), endTime: h.EndTime)
}))
.OrderBy(h => h.startTime).ToList();
double lastStartTime = int.MinValue;
double lastEndTime = int.MaxValue;
bool? lastIsLN = null;
for (int i = 0; i < locations.Count; i++)
{
bool isLN = locations[i].startTime != locations[i].endTime;
double startTime = locations[i].startTime + rng.Next(-Bias.Value, Bias.Value) + rng.NextDouble();
double endTime = locations[i].endTime + rng.Next(-Bias.Value, Bias.Value) + rng.NextDouble();
if (lastStartTime != int.MinValue && lastEndTime != int.MaxValue)
{
if (lastIsLN == true)
{
while (startTime >= lastStartTime && startTime <= lastEndTime + Interval.Value)
{
startTime += PLUS_INTERVAL;
}
while (endTime <= startTime /* + Interval.Value*/)
{
endTime += PLUS_INTERVAL;
}
}
else
{
while (startTime <= lastStartTime + Interval.Value)
{
startTime += PLUS_INTERVAL;
}
while (endTime <= startTime /* + Interval.Value */)
{
endTime += PLUS_INTERVAL;
}
}
}
if (rng.Next(100) < Probability.Value)
{
if (locations[i].startTime != locations[i].endTime)
{
newColumnObjects.Add(new HoldNote
{
Column = column.Key,
StartTime = startTime,
Duration = endTime - startTime,
NodeSamples = [locations[i].samples, Array.Empty<HitSampleInfo>()]
});
}
else
{
newColumnObjects.Add(new Note
{
Column = column.Key,
StartTime = startTime,
Samples = locations[i].samples
});
}
}
else
{
if (locations[i].startTime != locations[i].endTime)
{
newColumnObjects.Add(new HoldNote
{
Column = column.Key,
StartTime = locations[i].startTime,
Duration = locations[i].endTime - locations[i].startTime,
NodeSamples = [locations[i].samples, Array.Empty<HitSampleInfo>()]
});
}
else
{
newColumnObjects.Add(new Note
{
Column = column.Key,
StartTime = locations[i].startTime,
Samples = locations[i].samples
});
}
}
lastStartTime = startTime;
lastEndTime = endTime;
lastIsLN = isLN;
}
}
newObjects.AddRange(newColumnObjects);
maniaBeatmap.HitObjects = [.. newObjects.OrderBy(h => h.StartTime)];
}
}
}

View File

@@ -0,0 +1,635 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Audio;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Objects;
namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods
{
public static class ManiaModHelper
{
public static readonly int[] DIVIDE_NUMBER = [2, 4, 8, 3, 6, 9, 5, 7, 12, 16, 48, 35, 64];
public static void AddOriginalNoteByColumn(List<ManiaHitObject> newObjects, IGrouping<int, ManiaHitObject> column)
{
var newColumnObjects = new List<ManiaHitObject>();
var locations = column.OfType<Note>().Select(n => (startTime: n.StartTime, endTime: n.StartTime, samples: n.Samples))
.Concat(column.OfType<HoldNote>().SelectMany(h => new[]
{
(startTime: h.StartTime, endTime: h.EndTime, samples: h.GetNodeSamples(0))
//(startTime: h.EndTime, samples: h.GetNodeSamples(1))
}))
.OrderBy(h => h.startTime).ToList();
for (int i = 0; i < locations.Count; i++)
{
if (locations[i].startTime != locations[i].endTime)
{
newColumnObjects.Add(new HoldNote
{
Column = column.Key,
StartTime = locations[i].startTime,
EndTime = locations[i].endTime,
NodeSamples = [locations[i].samples, Array.Empty<HitSampleInfo>()]
});
}
else
{
newColumnObjects.Add(new Note
{
Column = column.Key,
StartTime = locations[i].startTime,
Samples = locations[i].samples
});
}
}
newObjects.AddRange(newColumnObjects);
}
[Obsolete]
public static void Transform(Random rng,
double mu,
double sigmaDivisor,
int divide,
int percentage,
double error,
bool originalLN,
IBeatmap beatmap,
List<ManiaHitObject> newObjects,
List<ManiaHitObject> oldObjects,
int gap = -1,
int forTransformColumnNum = 0,
int divide2 = -1,
double mu2 = -2,
double mu1Dmu2 = -1)
{
var locations = oldObjects.OfType<Note>().Select(n => (column: n.Column, startTime: n.StartTime, endTime: n.StartTime, samples: n.Samples))
.Concat(oldObjects.OfType<HoldNote>().SelectMany(h => new[]
{
(column: h.Column, startTime: h.StartTime, endTime: h.EndTime, samples: h.GetNodeSamples(0))
}))
.OrderBy(h => h.startTime).ToList();
var maniaBeatmap = (ManiaBeatmap)beatmap;
int keys = maniaBeatmap.TotalColumns;
int maxGap = gap;
var randomColumnList = SelectRandom(Enumerable.Range(0, keys), rng, forTransformColumnNum == 0 ? keys : forTransformColumnNum).ToList();
var noteList = new List<(double lastStartTime, double lastEndTime, bool lastLN, double thisStartTime, double thisEndTime, bool thisLN)>(keys);
noteList = Enumerable.Repeat((double.NaN, double.NaN, false, double.NaN, double.NaN, false), keys).ToList();
var sampleList = new List<(IList<HitSampleInfo> lastSample, IList<HitSampleInfo> thisSample)>(keys);
foreach (var timeGroup in locations.GroupBy(h => h.startTime))
{
foreach (var note in timeGroup)
{
if (randomColumnList.Contains(note.column))
{
if (double.IsNaN(noteList[note.column].thisStartTime))
{
noteList[note.column] = (double.NaN, double.NaN, false, note.startTime, note.endTime, note.startTime != note.endTime);
sampleList[note.column] = (sampleList[note.column].thisSample, note.samples);
}
else
{
noteList[note.column] = (noteList[note.column].thisStartTime, noteList[note.column].thisEndTime, noteList[note.column].thisLN, note.startTime, note.endTime,
note.startTime != note.endTime);
sampleList[note.column] = (sampleList[note.column].thisSample, note.samples);
double fullDuration = noteList[note.column].thisStartTime - noteList[note.column].lastStartTime;
double duration = GetDurationByDistribution(rng, beatmap, noteList[note.column].lastStartTime, fullDuration, mu, sigmaDivisor, divide, error, divide2, mu2, mu1Dmu2);
JudgementToNote(rng, newObjects, noteList[note.column].lastStartTime, note.column, noteList[note.column].lastEndTime, sampleList[note.column].lastSample, originalLN,
noteList[note.column].lastLN, percentage, duration);
}
}
else
{
if (note.startTime != note.endTime && originalLN)
newObjects.AddNote(note.samples, note.column, note.startTime, note.endTime);
else
newObjects.AddNote(note.samples, note.column, note.startTime);
}
}
gap--;
if (gap == 0)
{
randomColumnList = SelectRandom(Enumerable.Range(0, keys), rng, forTransformColumnNum).ToList();
gap = maxGap;
}
}
for (int i = 0; i < keys; i++)
{
if (!double.IsNaN(noteList[i].lastStartTime))
{
double fullDuration = noteList[i].thisStartTime - noteList[i].lastStartTime;
double duration = GetDurationByDistribution(rng, beatmap, noteList[i].lastStartTime, fullDuration, mu, sigmaDivisor, divide, error, divide2, mu2, mu1Dmu2);
JudgementToNote(rng, newObjects, noteList[i].lastStartTime, i, noteList[i].lastEndTime, sampleList[i].lastSample, originalLN, noteList[i].lastLN, percentage, duration);
}
if (!double.IsNaN(noteList[i].thisStartTime))
{
if (rng.Next(100) >= percentage || Math.Abs(noteList[i].thisEndTime - noteList[i].thisStartTime) <= error)
newObjects.AddNote(sampleList[i].thisSample, i, noteList[i].thisStartTime);
else
newObjects.AddNote(sampleList[i].thisSample, i, noteList[i].thisStartTime, noteList[i].thisEndTime);
}
}
}
public static void JudgementToNote(Random rng,
List<ManiaHitObject> newObjects,
double startTime,
int column,
double endTime,
IList<HitSampleInfo> samples,
bool originalLN,
bool isLN,
int percentage,
double duration)
{
if (originalLN && isLN)
newObjects.AddNote(samples, column, startTime, endTime);
else if (rng.Next(100) < percentage && !double.IsNaN(duration))
newObjects.AddLNByDuration(samples, column, startTime, duration);
else
newObjects.AddNote(samples, column, startTime);
}
/// <summary>
/// Return original LN objects.
/// </summary>
/// <param name="rng"></param>
/// <param name="mu"></param>
/// <param name="sigmaDivisor"></param>
/// <param name="divide"></param>
/// <param name="percentage"></param>
/// <param name="error"></param>
/// <param name="originalLN"></param>
/// <param name="beatmap"></param>
/// <param name="newObjects"></param>
/// <param name="column"></param>
/// <param name="divide2"></param>
/// <param name="mu2"></param>
/// <param name="mu1Dmu2"></param>
/// <returns></returns>
public static List<ManiaHitObject> Transform(Random rng,
double mu,
double sigmaDivisor,
int divide,
int percentage,
double error,
bool originalLN,
IBeatmap beatmap,
List<ManiaHitObject> newObjects,
IGrouping<int, ManiaHitObject> column,
int divide2 = -1,
double mu2 = -2,
double mu1Dmu2 = -1)
{
var originalLNObjects = new List<ManiaHitObject>();
var newColumnObjects = new List<ManiaHitObject>();
var locations = column.OfType<Note>().Select(n => (startTime: n.StartTime, samples: n.Samples, endTime: n.StartTime))
.Concat(column.OfType<HoldNote>().SelectMany(h => new[]
{
(startTime: h.StartTime, samples: h.GetNodeSamples(0), endTime: h.EndTime)
}))
.OrderBy(h => h.startTime).ToList();
for (int i = 0; i < locations.Count - 1; i++)
{
// double offset = locations[0].startTime;
double fullDuration = locations[i + 1].startTime - locations[i].startTime; // Full duration of the hold note.
double duration = GetDurationByDistribution(rng, beatmap, locations[i].startTime, fullDuration, mu, sigmaDivisor, divide, error, divide2, mu2, mu1Dmu2);
// Try to make timing point more precision.
// double beatLength = beatmap.ControlPointInfo.TimingPointAt(locations[i].startTime).BeatLength;
// double endTime = PreciseTime(locations[i].startTime + duration, beatLength, offset, error);
if (originalLN && locations[i].startTime != locations[i].endTime)
{
newColumnObjects.AddNote(locations[i].samples, column.Key, locations[i].startTime, locations[i].endTime);
originalLNObjects.AddNote(locations[i].samples, column.Key, locations[i].startTime, locations[i].endTime);
}
else if (rng.Next(100) < percentage && !double.IsNaN(duration))
newColumnObjects.AddLNByDuration(locations[i].samples, column.Key, locations[i].startTime, duration);
else
newColumnObjects.AddNote(locations[i].samples, column.Key, locations[i].startTime);
}
// Dispose last note on the column
if (Math.Abs(locations[^1].startTime - locations[^1].endTime) <= error || rng.Next(100) >= percentage)
{
newColumnObjects.Add(new Note
{
Column = column.Key,
StartTime = locations[^1].startTime,
Samples = locations[^1].samples
});
}
else
{
newColumnObjects.Add(new HoldNote
{
Column = column.Key,
StartTime = locations[^1].startTime,
Duration = locations[^1].endTime - locations[^1].startTime,
NodeSamples = [locations[^1].samples, Array.Empty<HitSampleInfo>()]
});
}
newObjects.AddRange(newColumnObjects);
return originalLNObjects;
}
public static double GetDurationByDistribution(Random rng,
IBeatmap beatmap,
double startTime,
double limitDuration,
double mu,
double sigmaDivisor,
int divide,
double error,
int divide2 = -1,
double mu2 = -2,
double mu1Dmu2 = -1)
{
// Beat length at the end of the hold note.
double beatLength = beatmap.ControlPointInfo.TimingPointAt(startTime).BeatLength;
// double beatBPM = beatmap.ControlPointInfo.TimingPointAt(startTime).BPM;
double timeDivide = beatLength / divide; //beatBPM / 60 * 100 / Divide.Value;
bool flag = true; // Can be transformed to LN
double sigma = timeDivide / sigmaDivisor; // LN duration σ
int timenum = (int)Math.Round(limitDuration / timeDivide, 0);
double duration = TimeRound(timeDivide, RandDistribution(rng, limitDuration * mu / 100, sigma));
if (mu1Dmu2 != -1)
{
if (rng.Next(100) >= mu1Dmu2)
{
timeDivide = beatLength / divide2;
sigma = timeDivide / sigmaDivisor;
timenum = (int)Math.Round(limitDuration / timeDivide, 0);
duration = TimeRound(timeDivide, RandDistribution(rng, limitDuration * mu2 / 100, sigma));
}
}
if (mu == -1)
{
if (timenum < 1)
duration = timeDivide;
else
{
int rdtime = rng.Next(1, timenum);
duration = rdtime * timeDivide;
duration = TimeRound(timeDivide, duration);
}
}
if (duration > limitDuration - timeDivide)
{
duration = limitDuration - timeDivide;
duration = TimeRound(timeDivide, duration);
}
if (duration <= timeDivide) duration = timeDivide;
if (duration >= limitDuration - error) // Additional processing.
flag = false;
return flag ? duration : double.NaN;
}
public static void AfterTransform(List<ManiaHitObject> afterObjects,
List<ManiaHitObject> originalLNObjects,
IBeatmap beatmap,
Random rng,
bool originalLN,
int gap = -1,
int transformColumnNum = 0,
double limitDuration = 0,
int lineSpacing = 0,
bool invertSpacing = false)
{
var maniaBeatmap = (ManiaBeatmap)beatmap;
var resultObjects = new List<ManiaHitObject>();
var originalLNSet = new HashSet<ManiaHitObject>(originalLNObjects);
int keys = maniaBeatmap.TotalColumns;
if (transformColumnNum > keys) transformColumnNum = keys;
var randomColumnSet = SelectRandom(Enumerable.Range(0, keys), rng, transformColumnNum == 0 ? keys : transformColumnNum).ToHashSet();
int maxGap = gap;
foreach (var timeGroup in afterObjects.GroupBy(h => h.StartTime))
{
foreach (var note in timeGroup)
{
if (originalLNSet.Contains(note) && originalLN)
resultObjects.Add(note);
else if (randomColumnSet.Contains(note.Column) && note.StartTime != note.GetEndTime()
&& (limitDuration > 0 && note.GetEndTime() - note.StartTime <= limitDuration * 1000 || limitDuration == 0))
resultObjects.Add(note);
else
resultObjects.AddNote(note.Samples, note.Column, note.StartTime);
}
gap--;
if (gap == 0)
{
randomColumnSet = SelectRandom(Enumerable.Range(0, keys), rng, transformColumnNum).ToHashSet();
gap = maxGap;
}
}
int maxSpacing = lineSpacing;
if (maxSpacing > 0)
{
afterObjects = resultObjects.OrderBy(h => h.StartTime).ToList();
resultObjects = new List<ManiaHitObject>();
foreach (var timeGroup in afterObjects.GroupBy(h => h.StartTime))
{
foreach (var note in timeGroup)
{
if (originalLNSet.Contains(note) && originalLN)
{
resultObjects.Add(note);
continue;
}
if (invertSpacing)
{
if (lineSpacing > 0)
resultObjects.Add(note);
else
resultObjects.AddNote(note.Samples, note.Column, note.StartTime);
}
else
{
if (lineSpacing > 0)
resultObjects.AddNote(note.Samples, note.Column, note.StartTime);
else
resultObjects.Add(note);
}
}
lineSpacing--;
if (lineSpacing < 0) lineSpacing = maxSpacing;
}
}
maniaBeatmap.HitObjects = resultObjects.OrderBy(h => h.StartTime).ToList();
maniaBeatmap.Breaks.Clear();
}
public static double RandDistribution(Random rng, double u, double d)
{
if (d <= 0) return u;
double u1 = rng.NextDouble();
double u2 = rng.NextDouble();
double z = Math.Sqrt(-2 * Math.Log(u1)) * Math.Sin(2 * Math.PI * u2);
double x = u + d * z;
return x;
}
public static double TimeRound(double timedivide, double num)
{
double remainder = num % timedivide;
if (remainder < timedivide / 2)
return num - remainder;
return num + timedivide - remainder;
}
/// <summary>
/// Try to make conversion timing point(EndTime) more precision.
/// </summary>
/// <param name="time"></param>
/// <param name="bpm"></param>
/// <param name="offset"></param>
/// <param name="error"></param>
/// <returns></returns>
public static double PreciseTime(double time, double bpm, double offset, double error)
{
foreach (int t in DIVIDE_NUMBER)
{
double tem = time;
time = offset + Math.Round((time - offset) / (bpm / t)) * bpm / t;
if (Math.Abs(time - tem) < error)
return time;
else
time = tem;
}
return time;
//try
//{
//}
//catch (Exception e)
//{
// Logger.Log(e.Message, level: LogLevel.Error);
// return null;
//}
}
/// <summary>
/// Return false if error.
/// </summary>
/// <returns></returns>
public static bool SelectRandomNumberForThis(this List<int> list, Random rng, int minValue, int maxValue, int times, bool duplicate = false)
{
if (duplicate)
{
for (int i = 0; i < times; i++)
list.Add(rng.Next(minValue, maxValue));
}
else
{
if (maxValue - minValue < times) return false;
while (times > 0)
{
int num = rng.Next(minValue, maxValue);
if (!list.Contains(num))
{
list.Add(num);
times--;
}
}
}
return true;
}
public static bool SelectRandomNumberForThis(this List<int> list, Random rng, int maxValue, int times, bool duplicate = false)
{
return list.SelectRandomNumberForThis(rng, 0, maxValue, times, duplicate);
}
public static IEnumerable<T> SelectRandom<T>(this IEnumerable<T> enumerable, Random rng, int times = 1, bool duplicate = false)
{
if (times <= 0) return Enumerable.Empty<T>();
var result = new List<T>();
var list = enumerable.ToList();
if (duplicate)
{
while (times > 0)
{
int index = rng.Next(list.Count);
result.Add(list[index]);
times--;
}
}
else
{
while (times > 0)
{
int index = rng.Next(list.Count);
result.Add(list[index]);
list.RemoveAt(index);
times--;
}
}
return result.AsEnumerable();
}
public static T SelectRandomOne<T>(this List<T> list, Random rng)
{
return list[rng.Next(list.Count)];
}
public static void AddNote(this List<ManiaHitObject> obj, IList<HitSampleInfo> samples, int column, double startTime, double? endTime = null)
{
if (endTime is null || endTime == startTime)
{
obj.Add(new Note
{
Column = column,
StartTime = startTime,
Samples = samples
});
}
else
obj.AddLNByDuration(samples, column, startTime, (double)endTime - startTime);
}
public static void RemoveNote(this List<ManiaHitObject> obj, int column, double startTime)
{
for (int i = obj.Count - 1; i >= 0; i--)
{
if (obj[i].Column == column && obj[i].StartTime == startTime)
{
obj.Remove(obj[i]);
return;
}
}
}
public static void AddLNByDuration(this List<ManiaHitObject> obj, IList<HitSampleInfo> samples, int column, double startTime, double duration)
{
obj.Add(new HoldNote
{
Column = column,
StartTime = startTime,
Duration = duration,
NodeSamples = [samples, Array.Empty<HitSampleInfo>()]
});
}
public static bool FindOverlapInList(List<ManiaHitObject> hitobj, int column, double starttime, double endtime)
{
foreach (var obj in hitobj)
{
if (obj.Column == column && starttime <= obj.StartTime && starttime >= obj.StartTime) return true;
if (obj.StartTime != obj.GetEndTime())
{
if (obj.Column == column && starttime >= obj.StartTime && starttime <= obj.GetEndTime())
{
if (endtime != starttime)
{
if (endtime >= obj.StartTime && endtime <= obj.GetEndTime())
return true;
}
return true;
}
}
}
return false;
}
public static bool FindOverlapInList(ManiaHitObject hitobj, List<ManiaHitObject> objs)
{
return FindOverlapInList(objs, hitobj.Column, hitobj.StartTime, hitobj.GetEndTime());
}
public static bool FindOverlapByNote(ManiaHitObject hitobj, int column, double starttime, double endtime)
{
List<ManiaHitObject> onenote = [hitobj];
return FindOverlapInList(onenote, column, starttime, endtime);
}
public static bool FindOverlapByList(List<ManiaHitObject> hitobj)
{
for (int i = 0; i < hitobj.Count; i++)
{
for (int j = i + 1; j < hitobj.Count; j++)
{
if (hitobj[i].Column == hitobj[j].Column && hitobj[i].StartTime == hitobj[j].StartTime) return true;
if (hitobj[j].StartTime != hitobj[j].GetEndTime())
{
if (hitobj[i].Column == hitobj[j].Column && hitobj[i].StartTime >= hitobj[j].StartTime - 2 && hitobj[i].StartTime <= hitobj[j].GetEndTime() + 2)
{
if (hitobj[i].GetEndTime() != hitobj[j].StartTime)
{
if (hitobj[i].GetEndTime() >= hitobj[j].StartTime - 2 && hitobj[i].GetEndTime() <= hitobj[j].GetEndTime() + 2)
return true;
}
return true;
}
}
}
}
return false;
}
public static IEnumerable<T> ShuffleIndex<T>(this IEnumerable<T> list, Random rng)
{
var result = list.ToList();
for (int i = 0; i < result.Count; i++)
{
int toIndex = rng.Next(result.Count);
(result[i], result[toIndex]) = (result[toIndex], result[i]);
}
return result.AsEnumerable();
}
}
}

View File

@@ -0,0 +1,319 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Bindables;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Overlays.Settings;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.LAsEZMania;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods
{
public class ManiaModJackAdjust : Mod, IApplicableAfterBeatmapConversion, IHasSeed
{
public const int MAX_KEY = 18;
public override string Name => "Jack Adjust";
public override string Acronym => "JA";
public override LocalisableString Description => EzManiaModStrings.JackAdjust_Description;
public override ModType Type => ModType.YuLiangSSS_Mod;
public override IconUsage? Icon => FontAwesome.Solid.Bars;
public override double ScoreMultiplier => 1;
public override bool Ranked => false;
public override bool ValidForMultiplayer => true;
public override bool ValidForFreestyleAsRequiredMod => false;
public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription
{
get
{
yield return ("Probability", $"{Probability.Value}");
yield return ("Line", $"{Line.Value}");
yield return ("Alignment", Align.Value ? "First Line" : "Last Line");
yield return ("Seed", $"{(Seed.Value == null ? "Null" : Seed.Value)}");
}
}
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.ToStream_Label), nameof(EzManiaModStrings.ToStream_Description))]
public BindableBool Stream { get; set; } = new BindableBool(true);
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Probability_Label), nameof(EzManiaModStrings.Probability_Description))]
public BindableInt Probability { get; set; } = new BindableInt(100)
{
Precision = 1,
MinValue = 0,
MaxValue = 100
};
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Line_Label), nameof(EzManiaModStrings.Line_Description))]
public BindableInt Line { get; set; } = new BindableInt(3)
{
Precision = 1,
MinValue = 2,
MaxValue = 16
};
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Alignment_Label), nameof(EzManiaModStrings.Alignment_Description))]
public BindableBool Align { get; set; } = new BindableBool(true);
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Seed_Label), nameof(EzManiaModStrings.Seed_Description), SettingControlType = typeof(SettingsNumberBox))]
public Bindable<int?> Seed { get; } = new Bindable<int?>();
public void ApplyToBeatmap(IBeatmap beatmap)
{
Seed.Value ??= RNG.Next();
var rng = new Random((int)Seed.Value);
var maniaBeatmap = (ManiaBeatmap)beatmap;
var newObjects = new List<ManiaHitObject>();
var areaObjects = new List<ManiaHitObject>();
int keys = maniaBeatmap.TotalColumns;
int line = Line.Value;
var lastLine = new List<ManiaHitObject>();
foreach (var timingPoint in maniaBeatmap.HitObjects.GroupBy(h => h.StartTime))
{
var thisLine = new List<ManiaHitObject>();
thisLine.AddRange(timingPoint);
if (!Stream.Value)
{
if (line > 0)
{
areaObjects.AddRange(thisLine);
line--;
}
else
{
var processed = ProcessArea(rng, areaObjects, Line.Value, keys, Probability.Value, Align.Value);
newObjects.AddRange(processed);
line = Line.Value;
areaObjects.Clear();
areaObjects.AddRange(thisLine);
line--;
}
}
else
{
var duplicateColumn = lastLine.Select(h => h.Column).ToList();
var notDuplicate = Enumerable.Range(0, keys).ToList();
notDuplicate = notDuplicate.Except(duplicateColumn).ToList();
int count = notDuplicate.Count;
thisLine = thisLine.ShuffleIndex(rng).ToList();
int selectError = 0;
for (int i = 0; i < thisLine.Count; i++)
{
if (count == 0) break;
if (duplicateColumn.Contains(thisLine[i].Column) && rng.Next(100) < Probability.Value)
{
bool jumpLoop = false;
int randColumn = notDuplicate.SelectRandomOne(rng);
while (thisLine.Any(c => c.Column == randColumn))
{
if (selectError > MAX_KEY)
{
jumpLoop = true;
selectError = 0;
break;
}
randColumn = notDuplicate.SelectRandomOne(rng);
selectError++;
}
if (jumpLoop) continue;
duplicateColumn.Remove(thisLine[i].Column);
thisLine[i].Column = randColumn;
count--;
}
}
newObjects.AddRange(thisLine);
}
lastLine = thisLine.ToList();
}
if (!Stream.Value && areaObjects.Count != 0)
{
var processed = ProcessArea(rng, areaObjects, Line.Value, keys, Probability.Value, Align.Value);
newObjects.AddRange(processed);
}
var cleanObjects = new List<ManiaHitObject>();
foreach (var column in newObjects.GroupBy(c => c.Column))
{
var newColumnObjects = new List<ManiaHitObject>();
var cleanLocations = column.OfType<Note>().Select(n => (startTime: n.StartTime, samples: n.Samples, endTime: n.StartTime))
.Concat(column.OfType<HoldNote>().SelectMany(h => new[]
{
(startTime: h.StartTime, samples: h.GetNodeSamples(0), endTime: h.EndTime)
}))
.OrderBy(h => h.startTime).ToList();
double lastStartTime = cleanLocations[0].startTime;
double lastEndTime = cleanLocations[0].endTime;
var lastSample = cleanLocations[0].samples;
for (int i = 0; i < cleanLocations.Count; i++)
{
if (i == 0)
{
lastStartTime = cleanLocations[0].startTime;
lastEndTime = cleanLocations[0].endTime;
lastSample = cleanLocations[0].samples;
continue;
}
if (cleanLocations[i].startTime >= lastStartTime && cleanLocations[i].startTime <= lastEndTime)
{
cleanLocations.RemoveAt(i);
i--;
continue;
} // if the note in a LN
newColumnObjects.AddNote(lastSample, column.Key, lastStartTime, lastEndTime);
lastStartTime = cleanLocations[i].startTime;
lastEndTime = cleanLocations[i].endTime;
lastSample = cleanLocations[i].samples;
}
newColumnObjects.AddNote(lastSample, column.Key, lastStartTime, lastEndTime);
cleanObjects.AddRange(newColumnObjects);
}
maniaBeatmap.HitObjects = cleanObjects.OrderBy(h => h.StartTime).ToList();
}
public List<ManiaHitObject> ProcessArea(Random rng, List<ManiaHitObject> area, int line, int keys, int probability, bool align)
{
var resultObjects = new List<ManiaHitObject>();
var jackLine = new List<ManiaHitObject>(); // first line
var lastLine = new List<ManiaHitObject>();
bool init = true;
// int jackCount = 0;
foreach (var group in area.GroupBy(h => h.StartTime))
{
var thisLine = group.ToList();
if (init)
{
jackLine = thisLine;
lastLine = thisLine;
resultObjects.AddRange(thisLine);
init = false;
// jackCount = jackLine.Count;
continue;
}
//if (init)
//{
// var select = SelectNote(Rng, thisLine, probability);
// var remain = select.remain.ShuffleIndex(Rng).ToList();
// var result = select.result.ShuffleIndex(Rng).ToList();
// var duplicateColumn = remain.Select(c => c.Column).ShuffleIndex(Rng).ToList();
// var forAlign = Align.Value ? SelectNote(Rng, jackLine, probability, jackCount) : SelectNote(Rng, lastLine, probability, jackCount);
// var alignResult = forAlign.result.ShuffleIndex(Rng).ToList();
// for (int i = 0; i < jackCount; i++)
// {
// if (!duplicateColumn.Contains(alignResult[i].Column))
// {
// result[i].Column = alignResult[i].Column;
// duplicateColumn.Add(alignResult[i].Column);
// }
// }
// resultObjects.AddRange(remain);
// resultObjects.AddRange(result);
//}
//int count = Math.Min(jackCount, thisLine.Count);
//var select = SelectNote(Rng, thisLine, probability, count);
//count = select.result.Count;
// var select = thisLine;
var jackColumn = jackLine.Select(c => c.Column).ShuffleIndex(rng).ToList();
if (!align) jackColumn = lastLine.Select(c => c.Column).ShuffleIndex(rng).ToList();
thisLine = thisLine.ShuffleIndex(rng).ToList();
for (int i = 0; i < thisLine.Count; i++)
{
if (!jackColumn.Contains(thisLine[i].Column) && jackColumn.Count > 0 && thisLine[i].GetEndTime() == thisLine[i].StartTime)
{
int randColumn = jackColumn.SelectRandomOne(rng);
int opportunity = 0;
const int max = 20;
while (opportunity < max)
{
if (randColumn == thisLine[i].Column || thisLine.Except(Enumerable.Repeat(thisLine[i], 1)).Any(c => c.Column == randColumn))
{
randColumn = jackColumn.SelectRandomOne(rng);
if (randColumn != thisLine[i].Column && thisLine.Except(Enumerable.Repeat(thisLine[i], 1)).All(c => c.Column != randColumn))
{
thisLine[i].Column = randColumn;
jackColumn.Remove(randColumn);
break;
}
}
opportunity++;
}
}
}
resultObjects.AddRange(thisLine);
lastLine = thisLine;
}
return resultObjects.OrderBy(s => s.StartTime).ToList();
}
public static (List<ManiaHitObject> remain, List<ManiaHitObject> result) SelectNote(Random rng, List<ManiaHitObject> obj, int probability = 100, int num = 1)
{
if (num > obj.Count)
{
var nullList = new List<ManiaHitObject>();
return (nullList, nullList);
}
var remainList = obj.ToList();
var resultList = new List<ManiaHitObject>();
for (int i = 0; i < num; i++)
{
if (rng.Next(100) < probability)
{
int index = rng.Next(remainList.Count);
resultList.Add(remainList[index]);
remainList.RemoveAt(index);
}
}
return (remainList, resultList);
}
}
}

View File

@@ -0,0 +1,214 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using osu.Framework.Bindables;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Rulesets.Mania.LAsEZMania;
using osu.Game.Rulesets.Mania.Scoring;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods
{
public class ManiaModJudgmentsAdjust : Mod, IApplicableToScoreProcessor
{
public override string Name => "Judgments Adjust";
public override string Acronym => "JU";
public override LocalisableString Description => EzManiaModStrings.JudgmentsAdjust_Description;
public override ModType Type => ModType.YuLiangSSS_Mod;
public override IconUsage? Icon => FontAwesome.Solid.Shower;
public override double ScoreMultiplier => 1;
public override bool Ranked => false;
public override bool ValidForMultiplayer => true;
public override bool ValidForFreestyleAsRequiredMod => false;
public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription
{
get
{
if (CustomHitRange.Value)
{
yield return ("Custom Hit Range", "On");
yield return ("Perfect Range", $"{PerfectHit.Value:0.#}");
yield return ("Great Range", $"{GreatHit.Value:0.#}");
yield return ("Good Range", $"{GoodHit.Value:0.#}");
yield return ("Ok Range", $"{OkHit.Value:0.#}");
yield return ("Meh Range", $"{MehHit.Value:0.#}");
yield return ("Miss Range", $"{MissHit.Value:0.#}");
}
// if (CustomProportionScore.Value)
// {
// yield return ("Custom Proportion Score", "On");
// yield return ("Perfect Score", $"{Perfect.Value:0.#}");
// yield return ("Great Score", $"{Great.Value:0.#}");
// yield return ("Good Score", $"{Good.Value:0.#}");
// yield return ("Ok Score", $"{Ok.Value:0.#}");
// yield return ("Meh Score", $"{Meh.Value:0.#}");
// yield return ("Miss Score", $"{Miss.Value:0.#}");
// }
}
}
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.CustomHitRange_Label), nameof(EzManiaModStrings.CustomHitRange_Description))]
public BindableBool CustomHitRange { get; set; } = new BindableBool(true);
[SettingSource("Perfect")]
public BindableDouble PerfectHit { get; set; } = new BindableDouble(22.4D)
{
Precision = 0.1,
MinValue = 0,
MaxValue = 250
};
[SettingSource("Great")]
public BindableDouble GreatHit { get; set; } = new BindableDouble(64)
{
Precision = 0.1,
MinValue = 0,
MaxValue = 250
};
[SettingSource("Good")]
public BindableDouble GoodHit { get; set; } = new BindableDouble(97)
{
Precision = 0.1,
MinValue = 0,
MaxValue = 250
};
[SettingSource("Ok")]
public BindableDouble OkHit { get; set; } = new BindableDouble(127)
{
Precision = 0.1,
MinValue = 0,
MaxValue = 250
};
[SettingSource("Meh")]
public BindableDouble MehHit { get; set; } = new BindableDouble(151)
{
Precision = 0.1,
MinValue = 0,
MaxValue = 250
};
[SettingSource("Miss")]
public BindableDouble MissHit { get; set; } = new BindableDouble(188)
{
Precision = 0.1,
MinValue = 0,
MaxValue = 250
};
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.CustomProportionScore_Label), nameof(EzManiaModStrings.CustomProportionScore_Description))]
public BindableBool CustomProportionScore { get; set; } = new BindableBool(true);
[SettingSource("Perfect")]
public BindableInt Perfect { get; set; } = new BindableInt(300)
{
Precision = 5,
MinValue = 0,
MaxValue = 500
};
[SettingSource("Great")]
public BindableInt Great { get; set; } = new BindableInt(300)
{
Precision = 5,
MinValue = 0,
MaxValue = 500
};
[SettingSource("Good")]
public BindableInt Good { get; set; } = new BindableInt(200)
{
Precision = 5,
MinValue = 0,
MaxValue = 500
};
[SettingSource("Ok")]
public BindableInt Ok { get; set; } = new BindableInt(100)
{
Precision = 5,
MinValue = 0,
MaxValue = 500
};
[SettingSource("Meh")]
public BindableInt Meh { get; set; } = new BindableInt(50)
{
Precision = 5,
MinValue = 0,
MaxValue = 500
};
[SettingSource("Miss")]
public BindableInt Miss { get; set; } = new BindableInt(0)
{
Precision = 5,
MinValue = 0,
MaxValue = 500
};
public ManiaHitWindows HitWindows { get; set; } = new ManiaHitWindows();
public ManiaModJudgmentsAdjust()
{
CustomHitRange.BindValueChanged(_ => updateCustomHitRange());
PerfectHit.BindValueChanged(_ => updateCustomHitRange());
GreatHit.BindValueChanged(_ => updateCustomHitRange());
GoodHit.BindValueChanged(_ => updateCustomHitRange());
OkHit.BindValueChanged(_ => updateCustomHitRange());
MehHit.BindValueChanged(_ => updateCustomHitRange());
MissHit.BindValueChanged(_ => updateCustomHitRange());
}
private void updateCustomHitRange()
{
if (CustomHitRange.Value)
{
HitWindows.ModifyManiaHitRange(new ManiaModifyHitRange(
PerfectHit.Value,
GreatHit.Value,
GoodHit.Value,
OkHit.Value,
MehHit.Value,
MissHit.Value
));
}
else
{
HitWindows.ResetRange();
}
}
public ScoreRank AdjustRank(ScoreRank rank, double accuracy)
{
return rank;
}
public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor)
{
// var mania = (ManiaScoreProcessor)scoreProcessor;
// mania.HitProportionScore.Perfect = Perfect.Value;
// mania.HitProportionScore.Great = Great.Value;
// mania.HitProportionScore.Good = Good.Value;
// mania.HitProportionScore.Ok = Ok.Value;
// mania.HitProportionScore.Meh = Meh.Value;
// mania.HitProportionScore.Miss = Miss.Value;
}
}
}

View File

@@ -0,0 +1,107 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using osu.Framework.Bindables;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Configuration;
using osu.Game.Overlays.Settings;
using osu.Game.Rulesets.Mania.LAsEZMania;
using osu.Game.Rulesets.Mods;
namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods
{
public class ManiaModLN : Mod, IHasSeed
{
public override string Name => "LN";
public override string Acronym => "LN";
public override LocalisableString Description => EzManiaModStrings.LN_Description;
public override double ScoreMultiplier => 1;
public override IconUsage? Icon => FontAwesome.Solid.YinYang;
public override ModType Type => ModType.YuLiangSSS_Mod;
public override bool Ranked => false;
public override bool ValidForMultiplayer => true;
public override bool ValidForFreestyleAsRequiredMod => false;
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Divide_Label), nameof(EzManiaModStrings.Divide_Description))]
public BindableNumber<int> Divide { get; set; } = new BindableInt(4)
{
MinValue = 1,
MaxValue = 16,
Precision = 1
};
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Percentage_Label), nameof(EzManiaModStrings.Percentage_Description))]
public BindableNumber<int> Percentage { get; set; } = new BindableInt(100)
{
MinValue = 5,
MaxValue = 100,
Precision = 5
};
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.OriginalLN_Label), nameof(EzManiaModStrings.OriginalLN_Description))]
public BindableBool OriginalLN { get; set; } = new BindableBool(false);
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.ColumnNum_Label), nameof(EzManiaModStrings.ColumnNum_Description))]
public BindableInt SelectColumn { get; set; } = new BindableInt(10)
{
MinValue = 1,
MaxValue = 20,
Precision = 1
};
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Gap_Label), nameof(EzManiaModStrings.Gap_Description))]
public BindableInt Gap { get; set; } = new BindableInt(12)
{
MinValue = 0,
MaxValue = 20,
Precision = 1
};
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.LineSpacing_Label), nameof(EzManiaModStrings.LineSpacing_Description))]
public BindableInt LineSpacing { get; set; } = new BindableInt(0)
{
MinValue = 0,
MaxValue = 20,
Precision = 1
};
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.InvertLineSpacing_Label), nameof(EzManiaModStrings.InvertLineSpacing_Description))]
public BindableBool InvertLineSpacing { get; set; } = new BindableBool(false);
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.DurationLimit_Label), nameof(EzManiaModStrings.DurationLimit_Description))]
public BindableDouble DurationLimit { get; set; } = new BindableDouble(5)
{
MinValue = 0,
MaxValue = 15,
Precision = 0.5
};
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Seed_Label), nameof(EzManiaModStrings.Seed_Description), SettingControlType = typeof(SettingsNumberBox))]
public Bindable<int?> Seed { get; } = new Bindable<int?>();
public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription
{
get
{
yield return ("Divide", $"1/{Divide.Value}");
yield return ("Percentage", $"{Percentage.Value}%");
if (OriginalLN.Value) yield return ("Original LN", "On");
yield return ("Column Num", $"{SelectColumn.Value}");
yield return ("Gap", $"{Gap.Value}");
if (DurationLimit.Value > 0) yield return ("Duration Limit", $"{DurationLimit.Value}s");
yield return ("Seed", $"{(Seed.Value == null ? "Null" : Seed.Value)}");
}
}
}
}

View File

@@ -0,0 +1,205 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Bindables;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Overlays.Settings;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.LAsEZMania;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mods;
namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods
{
public class ManiaModLNDoubleDistribution : Mod, IApplicableAfterBeatmapConversion, IHasSeed
{
public override string Name => "LN Double Distribution";
public override string Acronym => "DD";
public override double ScoreMultiplier => 1;
public override LocalisableString Description => EzManiaModStrings.LNDoubleDistribution_Description;
public override IconUsage? Icon => FontAwesome.Solid.YinYang;
public override ModType Type => ModType.YuLiangSSS_Mod;
public override bool Ranked => false;
public override bool ValidForMultiplayer => true;
public override bool ValidForFreestyleAsRequiredMod => false;
public readonly int[] DivideNumber = [2, 4, 8, 3, 6, 9, 5, 7, 12, 16, 48, 35, 64];
public readonly double ERROR = 2;
public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription
{
get
{
yield return ("Divide 1", $"1/{Divide1.Value}");
yield return ("Divide 2", $"1/{Divide2.Value}");
yield return ("Mu 1", $"{Mu1.Value}");
yield return ("Mu 2", $"{Mu2.Value}");
yield return ("Mu 1 : Mu 2", $"{Mu1DMu2.Value} : {1 - Mu1DMu2.Value}");
yield return ("Sigma", $"{SigmaInteger.Value + SigmaDouble.Value}");
yield return ("Percentage", $"{Percentage.Value}%");
if (OriginalLN.Value)
{
yield return ("Original LN", "On");
}
yield return ("Column Num", $"{SelectColumn.Value}");
yield return ("Gap", $"{Gap.Value}");
if (DurationLimit.Value > 0)
{
yield return ("Duration Limit", $"{DurationLimit.Value}s");
}
yield return ("Seed", $"{(Seed.Value == null ? "Null" : Seed.Value)}");
}
}
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Divide1_Label), nameof(EzManiaModStrings.Divide1_Description), 0)]
public BindableNumber<int> Divide1 { get; set; } = new BindableInt(4)
{
MinValue = 1,
MaxValue = 16,
Precision = 1,
};
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Divide2_Label), nameof(EzManiaModStrings.Divide2_Description), 1)]
public BindableNumber<int> Divide2 { get; set; } = new BindableInt(4)
{
MinValue = 1,
MaxValue = 16,
Precision = 1,
};
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Mu1_Label), nameof(EzManiaModStrings.Mu1_Description), 2)]
public BindableNumber<int> Mu1 { get; set; } = new BindableInt(20)
{
MinValue = -1,
MaxValue = 100,
Precision = 1,
};
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Mu2_Label), nameof(EzManiaModStrings.Mu2_Description), 3)]
public BindableNumber<int> Mu2 { get; set; } = new BindableInt(70)
{
MinValue = -1,
MaxValue = 100,
Precision = 1,
};
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.MuRatio_Label), nameof(EzManiaModStrings.MuRatio_Description), 4)]
public BindableInt Mu1DMu2 { get; set; } = new BindableInt(50)
{
MinValue = 0,
MaxValue = 100,
Precision = 1,
};
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.SigmaInteger_Label), nameof(EzManiaModStrings.SigmaInteger_Description), 5)]
public BindableInt SigmaInteger { get; set; } = new BindableInt(0)
{
MinValue = 0,
MaxValue = 20,
Precision = 1,
};
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.SigmaDecimal_Label), nameof(EzManiaModStrings.SigmaDecimal_Description), 6)]
public BindableDouble SigmaDouble { get; set; } = new BindableDouble(0.85)
{
MinValue = 0.01,
MaxValue = 0.99,
Precision = 0.01,
};
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Percentage_Label), nameof(EzManiaModStrings.Percentage_Description))]
public BindableNumber<int> Percentage { get; set; } = new BindableInt(100)
{
MinValue = 0,
MaxValue = 100,
Precision = 5,
};
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.OriginalLN_Label), nameof(EzManiaModStrings.OriginalLN_Description))]
public BindableBool OriginalLN { get; set; } = new BindableBool(false);
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.ColumnNum_Label), nameof(EzManiaModStrings.ColumnNum_Description))]
public BindableInt SelectColumn { get; set; } = new BindableInt(10)
{
MinValue = 1,
MaxValue = 20,
Precision = 1,
};
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Gap_Label), nameof(EzManiaModStrings.Gap_Description))]
public BindableInt Gap { get; set; } = new BindableInt(12)
{
MinValue = 0,
MaxValue = 20,
Precision = 1,
};
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.DurationLimit_Label), nameof(EzManiaModStrings.DurationLimit_Description))]
public BindableDouble DurationLimit { get; set; } = new BindableDouble(5)
{
MinValue = 0,
MaxValue = 15,
Precision = 0.5,
};
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.LineSpacing_Label), nameof(EzManiaModStrings.LineSpacing_Description))]
public BindableInt LineSpacing { get; set; } = new BindableInt(0)
{
MinValue = 0,
MaxValue = 20,
Precision = 1,
};
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.InvertLineSpacing_Label), nameof(EzManiaModStrings.InvertLineSpacing_Description))]
public BindableBool InvertLineSpacing { get; set; } = new BindableBool(false);
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.Seed_Label), nameof(EzManiaModStrings.Seed_Description), SettingControlType = typeof(SettingsNumberBox))]
public Bindable<int?> Seed { get; } = new Bindable<int?>();
public void ApplyToBeatmap(IBeatmap beatmap)
{
var maniaBeatmap = (ManiaBeatmap)beatmap;
var newObjects = new List<ManiaHitObject>();
var originalLNObjects = new List<ManiaHitObject>();
// int keys = maniaBeatmap.TotalColumns;
var notTransformColumn = new List<int>();
Seed.Value ??= RNG.Next();
var rng = new Random((int)Seed.Value);
foreach (var column in maniaBeatmap.HitObjects.GroupBy(h => h.Column))
{
if (notTransformColumn.Contains(column.Key))
{
ManiaModHelper.AddOriginalNoteByColumn(newObjects, column);
continue;
}
originalLNObjects = ManiaModHelper.Transform(rng, Mu1.Value, SigmaDouble.Value + SigmaInteger.Value, Divide1.Value, Percentage.Value, ERROR, OriginalLN.Value, beatmap, newObjects,
column, Divide2.Value, Mu2.Value, Mu1DMu2.Value);
}
ManiaModHelper.AfterTransform(newObjects, originalLNObjects, maniaBeatmap, rng, OriginalLN.Value, Gap.Value, SelectColumn.Value, DurationLimit.Value, LineSpacing.Value,
InvertLineSpacing.Value);
maniaBeatmap.Breaks.Clear();
}
}
}

View File

@@ -0,0 +1,346 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using System.Threading;
using osu.Framework.Bindables;
using osu.Framework.Localisation;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.Judgements;
using osu.Game.Rulesets.Mania.LAsEZMania;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Objects.Drawables;
using osu.Game.Rulesets.Mania.Scoring;
using osu.Game.Rulesets.Mania.UI;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI;
namespace osu.Game.Rulesets.Mania.Mods.YuLiangSSSMods
{
public partial class ManiaModLNJudgementAdjust : Mod, IApplicableToDifficulty, IApplicableAfterBeatmapConversion, IApplicableToDrawableRuleset<ManiaHitObject>
{
public override string Name => "LN Judgement Adjust";
public override string Acronym => "LA";
public override LocalisableString Description => EzManiaModStrings.LNJudgementAdjust_Description;
public override double ScoreMultiplier => 1;
public override bool Ranked => false;
public override bool ValidForMultiplayer => true;
public override bool ValidForFreestyleAsRequiredMod => false;
public override ModType Type => ModType.YuLiangSSS_Mod;
public HitWindows HitWindows { get; set; } = new ManiaHitWindows();
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.BodyJudgementSwitch_Label), nameof(EzManiaModStrings.BodyJudgementSwitch_Description))]
public BindableBool BodyJudgementSwitch { get; } = new BindableBool();
[SettingSource(typeof(EzManiaModStrings), nameof(EzManiaModStrings.TailJudgementSwitch_Label), nameof(EzManiaModStrings.TailJudgementSwitch_Description))]
public BindableBool TailJudgementSwitch { get; } = new BindableBool();
public void ApplyToBeatmap(IBeatmap beatmap)
{
var maniaBeatmap = (ManiaBeatmap)beatmap;
var hitObjects = maniaBeatmap.HitObjects.Select(obj =>
{
//if (obj is Note note)
// return new NoLNNote(note);
if (obj is HoldNote hold) return new LNHoldNote(hold);
return obj;
}).ToList();
maniaBeatmap.HitObjects = hitObjects;
}
public void ApplyToDrawableRuleset(DrawableRuleset<ManiaHitObject> drawableRuleset)
{
var maniaRuleset = (DrawableManiaRuleset)drawableRuleset;
foreach (var stage in maniaRuleset.Playfield.Stages)
{
foreach (var column in stage.Columns)
{
column.RegisterPool<NoLNNote, DrawableNote>(10, 50);
column.RegisterPool<NoLNHeadNote, DrawableHoldNoteHead>(10, 50);
if (!TailJudgementSwitch.Value && !BodyJudgementSwitch.Value)
{
column.RegisterPool<NoLNBodyNote, NoLNDrawableHoldNoteBody>(10, 50);
column.RegisterPool<NoLNTailNote, NoLNDrawableHoldNoteTail>(10, 50);
}
if (BodyJudgementSwitch.Value && !TailJudgementSwitch.Value)
{
column.RegisterPool<AllLNBodyNote, AllLNDrawableHoldNoteBody>(10, 50);
column.RegisterPool<NoLNTailNote, NoLNDrawableHoldNoteTail>(10, 50);
}
if (BodyJudgementSwitch.Value && TailJudgementSwitch.Value)
{
column.RegisterPool<AllLNBodyNote, AllLNDrawableHoldNoteBody>(10, 50);
column.RegisterPool<TailNote, DrawableHoldNoteTail>(10, 50);
}
if (!BodyJudgementSwitch.Value && TailJudgementSwitch.Value)
{
column.RegisterPool<HoldNoteBody, DrawableHoldNoteBody>(10, 50);
column.RegisterPool<TailNote, DrawableHoldNoteTail>(10, 50);
// Vanilla LN
}
}
}
}
public void ApplyToDifficulty(BeatmapDifficulty difficulty)
{
HitWindows = new ManiaHitWindows();
HitWindows.SetDifficulty(difficulty.OverallDifficulty);
NoLNDrawableHoldNoteTail.HitWindows = HitWindows;
LNHoldNote.BodyJudgementSwitch = BodyJudgementSwitch.Value;
LNHoldNote.TailJudgementSwitch = TailJudgementSwitch.Value;
}
private class NoLNNote : Note
{
public NoLNNote(Note note)
{
StartTime = note.StartTime;
Column = note.Column;
Samples = note.Samples;
}
protected override void CreateNestedHitObjects(CancellationToken cancellationToken)
{
}
}
private class NoLNHeadNote : HeadNote
{
}
private class NoLNBodyNote : HoldNoteBody
{
public override Judgement CreateJudgement()
{
return new NoLNBodyJudgement();
}
protected override HitWindows CreateHitWindows()
{
return HitWindows.Empty;
}
}
private class AllLNBodyNote : HoldNoteBody
{
public override Judgement CreateJudgement()
{
return new AllLNBodyJudgement();
}
protected override HitWindows CreateHitWindows()
{
return HitWindows.Empty;
}
}
private class NoLNTailNote : TailNote
{
public override Judgement CreateJudgement()
{
return new NoLNTailJudgement();
}
protected override HitWindows CreateHitWindows()
{
return new ManiaHitWindows();
}
}
private class LNHoldNote : HoldNote
{
public static bool BodyJudgementSwitch;
public static bool TailJudgementSwitch;
public LNHoldNote(HoldNote hold)
{
StartTime = hold.StartTime;
Duration = hold.Duration;
Column = hold.Column;
NodeSamples = hold.NodeSamples;
}
static LNHoldNote()
{
TailJudgementSwitch = false;
}
protected override void CreateNestedHitObjects(CancellationToken cancellationToken)
{
AddNested(Head = new HeadNote
{
StartTime = StartTime,
Column = Column,
Samples = GetNodeSamples(0)
});
if (!BodyJudgementSwitch && !TailJudgementSwitch)
{
AddNested(Body = new NoLNBodyNote
{
StartTime = StartTime,
Column = Column
});
AddNested(Tail = new NoLNTailNote
{
StartTime = EndTime,
Column = Column,
Samples = GetNodeSamples(NodeSamples?.Count - 1 ?? 1)
});
}
else if (BodyJudgementSwitch && !TailJudgementSwitch)
{
AddNested(Body = new AllLNBodyNote
{
StartTime = StartTime,
Column = Column
});
AddNested(Tail = new NoLNTailNote
{
StartTime = EndTime,
Column = Column,
Samples = GetNodeSamples(NodeSamples?.Count - 1 ?? 1)
});
}
else if (!BodyJudgementSwitch && TailJudgementSwitch)
{
AddNested(Body = new HoldNoteBody
{
StartTime = StartTime,
Column = Column
});
AddNested(Tail = new TailNote
{
StartTime = EndTime,
Column = Column,
Samples = GetNodeSamples(NodeSamples?.Count - 1 ?? 1)
});
}
else
{
AddNested(Body = new AllLNBodyNote
{
StartTime = StartTime,
Column = Column
});
AddNested(Tail = new TailNote
{
StartTime = EndTime,
Column = Column,
Samples = GetNodeSamples(NodeSamples?.Count - 1 ?? 1)
});
}
}
}
private class NoLNBodyJudgement : ManiaJudgement
{
public override HitResult MaxResult => HitResult.IgnoreHit;
public override HitResult MinResult => HitResult.IgnoreMiss;
}
private class AllLNBodyJudgement : ManiaJudgement
{
public override HitResult MaxResult => HitResult.Perfect;
public override HitResult MinResult => HitResult.Miss;
}
private class NoLNTailJudgement : ManiaJudgement
{
public override HitResult MaxResult => HitResult.IgnoreHit;
public override HitResult MinResult => HitResult.ComboBreak;
}
public partial class NoLNDrawableHoldNoteBody : DrawableHoldNoteBody
{
public new bool HasHoldBreak => false;
internal override void TriggerResult(bool hit)
{
if (AllJudged) return;
ApplyMaxResult();
}
}
public partial class AllLNDrawableHoldNoteBody : DrawableHoldNoteBody
{
public override bool DisplayResult => true;
protected internal DrawableHoldNote HoldNote => (DrawableHoldNote)ParentHitObject;
internal override void TriggerResult(bool hit)
{
if (AllJudged) return;
if (hit)
ApplyResult(HoldNote.Head.Result.Type);
else
ApplyResult(HitResult.Miss);
}
}
public partial class NoLNDrawableHoldNoteTail : DrawableHoldNoteTail
{
public static HitWindows HitWindows = new ManiaHitWindows();
public override bool DisplayResult => false;
protected override void CheckForResult(bool userTriggered, double timeOffset)
{
if (!HoldNote.Head.IsHit) return;
if (timeOffset > 0 && HoldNote.Head.IsHit)
{
ApplyMaxResult();
return;
}
else if (timeOffset > 0)
{
ApplyMinResult();
return;
}
if (HoldNote.IsHolding.Value) return;
if (HoldNote.Head.IsHit && Math.Abs(timeOffset) < Math.Abs(HitWindows.WindowFor(HitResult.Meh) * TailNote.RELEASE_WINDOW_LENIENCE))
{
ApplyMaxResult();
}
else
{
ApplyMinResult();
}
}
}
}
}

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