同步更新,修改CS过滤代码

This commit is contained in:
LA
2025-08-27 23:57:40 +08:00
parent df03a84771
commit 9a45d0e0eb
468 changed files with 15777 additions and 4142 deletions

View File

@@ -3,7 +3,6 @@
<component name="UserContentModel">
<attachedFolders />
<explicitIncludes />
<explicitExcludes>
</explicitExcludes>
<explicitExcludes />
</component>
</project>

View File

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

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

34
comparison_example.cs Normal file
View File

@@ -0,0 +1,34 @@
namespace Example
{
// 主文件
public partial class FilterControl
{
private string privateField = "只有嵌套类能访问";
private void privateMethod() { }
}
// 情况1正确的嵌套类写法
public partial class FilterControl
{
public partial class DifficultyRangeSlider
{
public void AccessParent()
{
// ✅ 可以访问外层类的私有成员
var field = privateField; // 编译成功
privateMethod(); // 编译成功
}
}
}
// 情况2错误的独立类写法
public partial class DifficultyRangeSlider // 这是独立的类,不是嵌套类
{
public void AccessParent()
{
// ❌ 无法访问 FilterControl 的私有成员
// var field = privateField; // 编译错误!
// privateMethod(); // 编译错误!
}
}
}

142
fixed.txt Normal file
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;
using System.IO;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Animations;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Rendering;
using osu.Framework.Graphics.Textures;
using osu.Framework.IO.Stores;
using osu.Framework.Logging;
using osu.Framework.Platform;
using LogLevel = osu.Framework.Logging.LogLevel;
using osu.Game.Skinning.Components;
using osuTK;
namespace osu.Game.Screens
{
public partial class EzNoteFactory : CompositeDrawable, IPreviewable
{
public Bindable<string> TextureNameBindable { get; } = new Bindable<string>("evolve");
public string TextureBasePath { get; } = @"EzResources\note";
private readonly TextureStore textureStore;
private readonly EzSkinSettingsManager ezSkinConfig;
private string? notesPath = string.Empty;
private const float fps = 60;
/// <summary>
/// 简化后的构造函数直接使用TextureStore
/// </summary>
/// <param name="textureStore">用于加载纹理的TextureStore</param>
/// <param name="ezSkinConfig">皮肤设置管理器</param>
/// <param name="customTexturePath">自定义纹理路径(可选)</param>
public EzNoteFactory(TextureStore textureStore, EzSkinSettingsManager ezSkinConfig, string? customTexturePath = null)
{
this.textureStore = textureStore;
this.ezSkinConfig = ezSkinConfig;
if (!string.IsNullOrEmpty(customTexturePath))
TextureBasePath = customTexturePath;
RelativeSizeAxes = Axes.Both;
Anchor = Anchor.Centre;
Origin = Anchor.Centre;
Blending = new BlendingParameters
{
Source = BlendingType.SrcAlpha,
Destination = BlendingType.One,
};
Initialize();
}
private void Initialize()
{
// 我们不再需要获取完整路径和创建目录因为我们将直接使用TextureStore
// 直接从配置中获取纹理名称
TextureNameBindable.Value = ezSkinConfig.Get<string>(EzSkinSetting.NoteSetName);
ezSkinConfig.GetBindable<string>(EzSkinSetting.NoteSetName).BindValueChanged(e =>
TextureNameBindable.Value = e.NewValue, true);
var gif = new DrawableAnimation
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Scale = new Vector2(1.2f),
DefaultFrameLength = 1000 / fps,
Loop = true,
};
AddInternal(gif);
}
// 保留BackgroundDependencyLoader方法来维持兼容性但它不再必需
[BackgroundDependencyLoader]
private void load()
{
// 所有初始化逻辑都已移动到Initialize()方法中
}
public virtual Drawable CreateAnimation(string component)
{
// 规范化
string noteSetName = TextureNameBindable.Value;
string normalizedComponent = component.Replace('/', Path.DirectorySeparatorChar);
var animation = new TextureAnimation
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Scale = new Vector2(1.2f),
DefaultFrameLength = 1000 / fps,
Loop = false
};
// 为了适配TextureStore的方式我们尝试加载序列帧001.png, 002.png等
for (int i = 1; i <= 60; i++) // 假设最多60帧
{
string framePath = $"{TextureBasePath}/{noteSetName}/{normalizedComponent}/{i:D3}";
var texture = textureStore.Get(framePath);
if (texture == null)
{
// 如果找不到更多帧,退出循环
if (i == 1)
Logger.Log($"EzNoteFactory: No frames found for {framePath}", LoggingTarget.Runtime, LogLevel.Warning);
else
Logger.Log($"EzNoteFactory: Found {i-1} frames for {component}", LoggingTarget.Runtime, LogLevel.Debug);
break;
}
animation.AddFrame(texture);
}
// 如果没有找到任何帧,尝试加载单张图片
if (animation.FrameCount == 0)
{
string singleImagePath = $"{TextureBasePath}/{noteSetName}/{normalizedComponent}";
var texture = textureStore.Get(singleImagePath);
if (texture != null)
{
Logger.Log($"EzNoteFactory: Loaded single image for {component}", LoggingTarget.Runtime, LogLevel.Debug);
animation.AddFrame(texture);
}
else
{
Logger.Log($"EzNoteFactory: Failed to load any textures for {component}", LoggingTarget.Runtime, LogLevel.Warning);
}
}
return animation;
}
}
}

View File

@@ -0,0 +1,56 @@
// 示例:嵌套类 vs 顶级类的区别
namespace Example
{
// ========== 情况1嵌套类设计osu! 当前使用) ==========
public partial class FilterControl : OverlayContainer
{
private readonly Dictionary<string, object> controlState = new();
private bool isInitialized = false;
// 嵌套类 - 逻辑上是 FilterControl 的一部分
public partial class KeyModeFilterTabControl : CompositeDrawable
{
public void AccessParentState()
{
// ✅ 可以访问外层类的私有成员
// 注意:需要通过外层类实例访问非静态成员
}
public void UpdateParentControl(FilterControl parent)
{
// ✅ 可以访问私有成员
parent.controlState["keyMode"] = "updated";
if (parent.isInitialized) { /* ... */ }
}
}
// 其他相关的嵌套类
public partial class DifficultyRangeSlider : ShearedRangeSlider { }
public partial class SongSelectSearchTextBox : ShearedFilterTextBox { }
}
// ========== 情况2顶级类设计替代方案 ==========
public class FilterControl : OverlayContainer
{
internal readonly Dictionary<string, object> controlState = new(); // 必须改为 internal
internal bool isInitialized = false; // 必须改为 internal
}
// 独立的顶级类
public class KeyModeFilterTabControl : CompositeDrawable
{
public void UpdateParentControl(FilterControl parent)
{
// ✅ 只能访问 internal/public 成员
parent.controlState["keyMode"] = "updated";
if (parent.isInitialized) { /* ... */ }
// ❌ 无法访问 private 成员
// parent.somePrivateField = value; // 编译错误
}
}
public class DifficultyRangeSlider : ShearedRangeSlider { }
public class SongSelectSearchTextBox : ShearedFilterTextBox { }
}

View File

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

View File

@@ -2,7 +2,6 @@
"solution": {
"path": "osu.sln",
"projects": [
"..\\osu-framework\\osu.Framework.NativeLibs\\osu.Framework.NativeLibs.csproj",
"..\\osu-framework\\osu.Framework\\osu.Framework.csproj",
"..\\osu-resources\\osu.Game.Resources\\osu.Game.Resources.csproj",
"osu.Desktop\\osu.Desktop.csproj",

View File

@@ -36,7 +36,7 @@ namespace osu.Desktop
// IMPORTANT DON'T IGNORE: For general sanity, velopack's setup needs to run before anything else.
// This has bitten us in the rear before (bricked updater), and although the underlying issue from
// last time has been fixed, let's not tempt fate.
setupVelopack();
setupVelopack(args);
if (OperatingSystem.IsWindows())
{
@@ -174,8 +174,21 @@ namespace osu.Desktop
return false;
}
private static void setupVelopack()
private static void setupVelopack(string[] args)
{
// Arguments being present indicate the user is either starting the game in a special (aka tournament) mode,
// or is running with pending imports via file association or otherwise.
//
// In both these scenarios, we'd hope the game does not attempt to update.
//
// Special consideration for velopack startup arguments, which must be handled during update.
// See https://docs.velopack.io/integrating/hooks#command-line-hooks.
if (args.Length > 0 && !args[0].StartsWith("--velo", StringComparison.Ordinal))
{
Logger.Log("Handling arguments, skipping velopack setup.");
return;
}
if (OsuGameDesktop.IsPackageManaged)
{
Logger.Log("Updates are being managed by an external provider. Skipping Velopack setup.");

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

View File

@@ -22,8 +22,9 @@ namespace osu.Game.Rulesets.Catch.Tests
{
var ruleset = new CatchRuleset();
var difficulty = new BeatmapDifficulty { ApproachRate = originalApproachRate };
var beatmapInfo = new BeatmapInfo { Difficulty = difficulty };
var adjustedDifficulty = ruleset.GetAdjustedDisplayDifficulty(difficulty, []);
var adjustedDifficulty = ruleset.GetAdjustedDisplayDifficulty(beatmapInfo, []);
Assert.That(adjustedDifficulty.ApproachRate, Is.EqualTo(originalApproachRate));
}
@@ -33,8 +34,9 @@ namespace osu.Game.Rulesets.Catch.Tests
{
var ruleset = new CatchRuleset();
var difficulty = new BeatmapDifficulty();
var beatmapInfo = new BeatmapInfo { Difficulty = difficulty };
var adjustedDifficulty = ruleset.GetAdjustedDisplayDifficulty(difficulty, [new CatchModHalfTime()]);
var adjustedDifficulty = ruleset.GetAdjustedDisplayDifficulty(beatmapInfo, [new CatchModHalfTime()]);
Assert.That(adjustedDifficulty.ApproachRate, Is.EqualTo(1.67).Within(0.01));
}
@@ -44,8 +46,9 @@ namespace osu.Game.Rulesets.Catch.Tests
{
var ruleset = new CatchRuleset();
var difficulty = new BeatmapDifficulty();
var beatmapInfo = new BeatmapInfo { Difficulty = difficulty };
var adjustedDifficulty = ruleset.GetAdjustedDisplayDifficulty(difficulty, [new CatchModDoubleTime()]);
var adjustedDifficulty = ruleset.GetAdjustedDisplayDifficulty(beatmapInfo, [new CatchModDoubleTime()]);
Assert.That(adjustedDifficulty.ApproachRate, Is.EqualTo(7.67).Within(0.01));
}

View File

@@ -0,0 +1,21 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Game.Rulesets.Catch.Mods;
using osu.Game.Tests.Visual;
namespace osu.Game.Rulesets.Catch.Tests.Mods
{
public partial class TestSceneCatchModMovingFast : ModTestScene
{
protected override Ruleset CreatePlayerRuleset() => new CatchRuleset();
[Test]
public void TestMovingFast() => CreateModTest(new ModTestData
{
Mod = new CatchModMovingFast(),
PassCondition = () => true
});
}
}

View File

@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
@@ -11,6 +12,7 @@ using osu.Framework.Localisation;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Legacy;
using osu.Game.Graphics;
using osu.Game.Localisation;
using osu.Game.Rulesets.Catch.Beatmaps;
using osu.Game.Rulesets.Catch.Difficulty;
using osu.Game.Rulesets.Catch.Edit;
@@ -25,6 +27,7 @@ using osu.Game.Rulesets.Catch.UI;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Rulesets.Replays.Types;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Scoring.Legacy;
@@ -151,6 +154,7 @@ namespace osu.Game.Rulesets.Catch
new CatchModFloatingFruits(),
new CatchModMuted(),
new CatchModNoScope(),
new CatchModMovingFast(),
};
case ModType.System:
@@ -266,9 +270,9 @@ namespace osu.Game.Rulesets.Catch
}
/// <seealso cref="CatchHitObject.ApplyDefaultsToSelf"/>
public override BeatmapDifficulty GetAdjustedDisplayDifficulty(IBeatmapDifficultyInfo difficulty, IReadOnlyCollection<Mod> mods)
public override BeatmapDifficulty GetAdjustedDisplayDifficulty(IBeatmapInfo beatmapInfo, IReadOnlyCollection<Mod> mods)
{
BeatmapDifficulty adjustedDifficulty = new BeatmapDifficulty(difficulty);
BeatmapDifficulty adjustedDifficulty = base.GetAdjustedDisplayDifficulty(beatmapInfo, mods);
double rate = ModUtils.CalculateRateWithMods(mods);
double preempt = IBeatmapDifficultyInfo.DifficultyRange(adjustedDifficulty.ApproachRate, CatchHitObject.PREEMPT_MAX, CatchHitObject.PREEMPT_MID, CatchHitObject.PREEMPT_MIN);
@@ -278,6 +282,33 @@ namespace osu.Game.Rulesets.Catch
return adjustedDifficulty;
}
public override IEnumerable<RulesetBeatmapAttribute> GetBeatmapAttributesForDisplay(IBeatmapInfo beatmapInfo, IReadOnlyCollection<Mod> mods)
{
var originalDifficulty = beatmapInfo.Difficulty;
var effectiveDifficulty = GetAdjustedDisplayDifficulty(beatmapInfo, mods);
yield return new RulesetBeatmapAttribute(SongSelectStrings.CircleSize, @"CS", originalDifficulty.CircleSize, effectiveDifficulty.CircleSize, 10)
{
Description = "Affects the size of fruits.",
AdditionalMetrics =
[
new RulesetBeatmapAttribute.AdditionalMetric("Hit circle radius", (CatchHitObject.OBJECT_RADIUS * LegacyRulesetExtensions.CalculateScaleFromCircleSize(effectiveDifficulty.CircleSize)).ToLocalisableString("0.#"))
]
};
yield return new RulesetBeatmapAttribute(SongSelectStrings.ApproachRate, @"AR", originalDifficulty.ApproachRate, effectiveDifficulty.ApproachRate, 10)
{
Description = "Affects how early fruits fade in on the screen.",
AdditionalMetrics =
[
new RulesetBeatmapAttribute.AdditionalMetric("Fade-in time", LocalisableString.Interpolate($@"{IBeatmapDifficultyInfo.DifficultyRange(effectiveDifficulty.ApproachRate, CatchHitObject.PREEMPT_RANGE):#,0.##} ms"))
]
};
yield return new RulesetBeatmapAttribute(SongSelectStrings.HPDrain, @"HP", originalDifficulty.DrainRate, effectiveDifficulty.DrainRate, 10)
{
Description = "Affects the harshness of health drain and the health penalties for missing."
};
}
public override bool EditorShowScrollSpeed => false;
}
}

View File

@@ -18,6 +18,9 @@ namespace osu.Game.Rulesets.Catch.Edit
new CheckBananaShowerGap(),
new CheckConcurrentObjects(),
// Spread
new CheckCatchLowestDiffDrainTime(),
// Settings
new CheckCatchAbnormalDifficultySettings(),
};

View File

@@ -36,7 +36,7 @@ namespace osu.Game.Rulesets.Catch.Edit.Checks
public IEnumerable<Issue> Run(BeatmapVerifierContext context)
{
var hitObjects = context.Beatmap.HitObjects;
var hitObjects = context.CurrentDifficulty.Playable.HitObjects;
(int expectedStartDelta, int expectedEndDelta) = spinner_delta_threshold[context.InterpretedDifficulty];
for (int i = 0; i < hitObjects.Count - 1; ++i)

View File

@@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Catch.Edit.Checks
public override IEnumerable<Issue> Run(BeatmapVerifierContext context)
{
var diff = context.Beatmap.Difficulty;
var diff = context.CurrentDifficulty.Playable.Difficulty;
Issue? issue;
if (HasMoreThanOneDecimalPlace("Approach rate", diff.ApproachRate, out issue))

View File

@@ -0,0 +1,21 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit.Checks;
namespace osu.Game.Rulesets.Catch.Edit.Checks
{
public class CheckCatchLowestDiffDrainTime : CheckLowestDiffDrainTime
{
protected override IEnumerable<(DifficultyRating rating, double thresholdMs, string name)> GetThresholds()
{
// See lowest difficulty requirements in https://osu.ppy.sh/wiki/en/Ranking_criteria/osu%21catch#general
yield return (DifficultyRating.Hard, new TimeSpan(0, 2, 30).TotalMilliseconds, "Platter");
yield return (DifficultyRating.Insane, new TimeSpan(0, 3, 15).TotalMilliseconds, "Rain");
yield return (DifficultyRating.Expert, new TimeSpan(0, 4, 0).TotalMilliseconds, "Overdose");
}
}
}

View File

@@ -1,7 +1,9 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Catch.Replays;
using osu.Game.Rulesets.Mods;
@@ -10,6 +12,8 @@ namespace osu.Game.Rulesets.Catch.Mods
{
public class CatchModAutoplay : ModAutoplay
{
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(CatchModMovingFast) }).ToArray();
public override ModReplayData CreateReplayData(IBeatmap beatmap, IReadOnlyList<Mod> mods)
=> new ModReplayData(new CatchAutoGenerator(beatmap).Generate(), new ModCreatedUser { Username = "osu!salad" });
}

View File

@@ -1,7 +1,9 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Catch.Replays;
@@ -11,6 +13,8 @@ namespace osu.Game.Rulesets.Catch.Mods
{
public class CatchModCinema : ModCinema<CatchHitObject>
{
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(CatchModMovingFast) }).ToArray();
public override ModReplayData CreateReplayData(IBeatmap beatmap, IReadOnlyList<Mod> mods)
=> new ModReplayData(new CatchAutoGenerator(beatmap).Generate(), new ModCreatedUser { Username = "osu!salad" });
}

View File

@@ -24,7 +24,7 @@ namespace osu.Game.Rulesets.Catch.Mods
public override BindableBool ComboBasedSize { get; } = new BindableBool(true);
public override float DefaultFlashlightSize => 325;
public override float DefaultFlashlightSize => 203.125f;
protected override Flashlight CreateFlashlight() => new CatchFlashlight(this, playfield);

View File

@@ -3,6 +3,7 @@
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.UI;
@@ -16,7 +17,7 @@ namespace osu.Game.Rulesets.Catch.Mods
public override string Acronym => "FF";
public override LocalisableString Description => "The fruits are... floating?";
public override double ScoreMultiplier => 1;
public override IconUsage? Icon => FontAwesome.Solid.Cloud;
public override IconUsage? Icon => OsuIcon.ModFloatingFruits;
public void ApplyToDrawableRuleset(DrawableRuleset<CatchHitObject> drawableRuleset)
{

View File

@@ -0,0 +1,82 @@
// 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 osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Catch.UI;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.UI;
using osu.Game.Screens.Play;
using osuTK;
namespace osu.Game.Rulesets.Catch.Mods
{
public partial class CatchModMovingFast : Mod, IApplicableToDrawableRuleset<CatchHitObject>, IApplicableToPlayer
{
public override string Name => "Moving Fast";
public override string Acronym => "MF";
public override LocalisableString Description => "Dashing by default, slow down!";
public override ModType Type => ModType.Fun;
public override double ScoreMultiplier => 1;
public override IconUsage? Icon => OsuIcon.ModMovingFast;
public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay), typeof(ModRelax) };
private DrawableCatchRuleset drawableRuleset = null!;
public void ApplyToDrawableRuleset(DrawableRuleset<CatchHitObject> drawableRuleset)
{
this.drawableRuleset = (DrawableCatchRuleset)drawableRuleset;
}
public void ApplyToPlayer(Player player)
{
if (!drawableRuleset.HasReplayLoaded.Value)
{
var catchPlayfield = (CatchPlayfield)drawableRuleset.Playfield;
catchPlayfield.Catcher.Dashing = true;
catchPlayfield.CatcherArea.Add(new InvertDashInputHelper(catchPlayfield.CatcherArea));
}
}
private partial class InvertDashInputHelper : Drawable, IKeyBindingHandler<CatchAction>
{
private readonly CatcherArea catcherArea;
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true;
public InvertDashInputHelper(CatcherArea catcherArea)
{
this.catcherArea = catcherArea;
RelativeSizeAxes = Axes.Both;
}
public bool OnPressed(KeyBindingPressEvent<CatchAction> e)
{
switch (e.Action)
{
case CatchAction.MoveLeft or CatchAction.MoveRight:
break;
case CatchAction.Dash:
catcherArea.Catcher.Dashing = false;
return true;
}
return false;
}
public void OnReleased(KeyBindingReleaseEvent<CatchAction> e)
{
if (e.Action == CatchAction.Dash)
catcherArea.Catcher.Dashing = true;
}
}
}
}

View File

@@ -1,6 +1,8 @@
// 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.Graphics;
using osu.Framework.Input;
using osu.Framework.Input.Bindings;
@@ -19,6 +21,8 @@ namespace osu.Game.Rulesets.Catch.Mods
{
public override LocalisableString Description => @"Use the mouse to control the catcher.";
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(CatchModMovingFast) }).ToArray();
private DrawableCatchRuleset drawableRuleset = null!;
public void ApplyToDrawableRuleset(DrawableRuleset<CatchHitObject> drawableRuleset)

View File

@@ -150,7 +150,7 @@ namespace osu.Game.Rulesets.Catch.Objects
{
base.ApplyDefaultsToSelf(controlPointInfo, difficulty);
TimePreempt = (float)IBeatmapDifficultyInfo.DifficultyRange(difficulty.ApproachRate, PREEMPT_MAX, PREEMPT_MID, PREEMPT_MIN);
TimePreempt = (float)IBeatmapDifficultyInfo.DifficultyRange(difficulty.ApproachRate, PREEMPT_RANGE);
Scale = LegacyRulesetExtensions.CalculateScaleFromCircleSize(difficulty.CircleSize);
}
@@ -203,6 +203,8 @@ namespace osu.Game.Rulesets.Catch.Objects
/// </summary>
public const double PREEMPT_MAX = 1800;
public static readonly DifficultyRange PREEMPT_RANGE = new DifficultyRange(PREEMPT_MAX, PREEMPT_MID, PREEMPT_MIN);
/// <summary>
/// The Y position of the hit object is not used in the normal osu!catch gameplay.
/// It is preserved to maximize the backward compatibility with the legacy editor, in which the mappers use the Y position to organize the patterns.

View File

@@ -55,6 +55,16 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor.Checks
});
}
[Test]
public void TestHoldNotesAlmostConcurrentOnSameColumn()
{
assertAlmostConcurrentSame(new List<HitObject>
{
createHoldNote(startTime: 100, endTime: 400.75d, column: 1),
createHoldNote(startTime: 408, endTime: 700.75d, column: 1)
});
}
private void assertOk(List<HitObject> hitobjects)
{
Assert.That(check.Run(getContext(hitobjects)), Is.Empty);
@@ -65,7 +75,17 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor.Checks
var issues = check.Run(getContext(hitobjects)).ToList();
Assert.That(issues, Has.Count.EqualTo(count));
Assert.That(issues.All(issue => issue.Template is CheckConcurrentObjects.IssueTemplateConcurrentSame));
Assert.That(issues.All(issue => issue.Template is CheckConcurrentObjects.IssueTemplateConcurrent));
Assert.That(issues.All(issue => issue.ToString().Contains("s are concurrent here")));
}
private void assertAlmostConcurrentSame(List<HitObject> hitobjects)
{
var issues = check.Run(getContext(hitobjects)).ToList();
Assert.That(issues, Has.Count.EqualTo(1));
Assert.That(issues.All(issue => issue.Template is CheckConcurrentObjects.IssueTemplateAlmostConcurrent));
Assert.That(issues.All(issue => issue.ToString().Contains("s are less than 10ms apart")));
}
private BeatmapVerifierContext getContext(List<HitObject> hitobjects)

View File

@@ -22,6 +22,7 @@ namespace osu.Game.Rulesets.Mania.Tests
[TestCase("convert-samples")]
[TestCase("mania-samples")]
[TestCase("mania-slider")] // e.g. second and fourth notes of https://osu.ppy.sh/beatmapsets/73883#mania/216407
[TestCase("slider-convert-samples")]
public void Test(string name) => base.Test(name);
@@ -32,6 +33,7 @@ namespace osu.Game.Rulesets.Mania.Tests
StartTime = hitObject.StartTime,
EndTime = hitObject.GetEndTime(),
Column = ((ManiaHitObject)hitObject).Column,
PlaySlidingSamples = hitObject is HoldNote holdNote && holdNote.PlaySlidingSamples,
Samples = getSampleNames(hitObject.Samples),
NodeSamples = getNodeSampleNames((hitObject as HoldNote)?.NodeSamples)
};
@@ -57,12 +59,14 @@ namespace osu.Game.Rulesets.Mania.Tests
public double StartTime;
public double EndTime;
public int Column;
public bool PlaySlidingSamples;
public IList<string> Samples;
public IList<IList<string>> NodeSamples;
public bool Equals(SampleConvertValue other)
=> Precision.AlmostEquals(StartTime, other.StartTime, conversion_lenience)
&& Precision.AlmostEquals(EndTime, other.EndTime, conversion_lenience)
&& PlaySlidingSamples == other.PlaySlidingSamples
&& samplesEqual(Samples, other.Samples)
&& nodeSamplesEqual(NodeSamples, other.NodeSamples);

View File

@@ -5,6 +5,7 @@
"StartTime": 1000.0,
"EndTime": 2750.0,
"Column": 1,
"PlaySlidingSamples": true,
"NodeSamples": [
["Gameplay/normal-hitnormal"],
["Gameplay/soft-hitnormal"],
@@ -15,6 +16,7 @@
"StartTime": 1875.0,
"EndTime": 2750.0,
"Column": 0,
"PlaySlidingSamples": true,
"NodeSamples": [
["Gameplay/soft-hitnormal"],
["Gameplay/drum-hitnormal"]

View File

@@ -5,6 +5,7 @@
"StartTime": 500.0,
"EndTime": 1500.0,
"Column": 0,
"PlaySlidingSamples": false,
"NodeSamples": [
["Gameplay/normal-hitnormal"],
[]
@@ -17,6 +18,7 @@
"StartTime": 2000.0,
"EndTime": 3000.0,
"Column": 2,
"PlaySlidingSamples": false,
"NodeSamples": [
["Gameplay/drum-hitnormal"],
[]

View File

@@ -0,0 +1,18 @@
{
"Mappings": [{
"StartTime": 500.0,
"Objects": [{
"StartTime": 500.0,
"EndTime": 2500,
"Column": 2,
"PlaySlidingSamples": true,
"NodeSamples": [
["Gameplay/soft-hitnormal"],
["Gameplay/soft-hitnormal"],
["Gameplay/soft-hitnormal"],
["Gameplay/soft-hitnormal"]
],
"Samples": ["Gameplay/soft-hitnormal"]
}]
}]
}

View File

@@ -0,0 +1,29 @@
osu file format v5
[General]
StackLeniency: 0.7
Mode: 3
[Difficulty]
HPDrainRate:2
CircleSize:5
OverallDifficulty:2
SliderMultiplier:1
SliderTickRate:2
[Events]
//Background and Video events
//Break Periods
//Storyboard Layer 0 (Background)
//Storyboard Layer 1 (Failing)
//Storyboard Layer 2 (Passing)
//Storyboard Layer 3 (Foreground)
//Storyboard Sound Samples
//Background Colour Transformations
3,100,163,162,255
[TimingPoints]
355,476.190476190476,4,2,1,60,1,0
[HitObjects]
256,352,500,2,0,L|256:208,3,140

View File

@@ -203,7 +203,7 @@ namespace osu.Game.Rulesets.Mania.Tests
private void toggleTouchControls(bool enabled)
{
var maniaConfig = (ManiaRulesetConfigManager)RulesetConfigs.GetConfigFor(CreatePlayerRuleset())!;
maniaConfig.SetValue(ManiaRulesetSetting.MobileLayout, enabled ? ManiaMobileLayout.LandscapeWithOverlay : ManiaMobileLayout.Portrait);
maniaConfig.SetValue(ManiaRulesetSetting.TouchOverlay, enabled);
}
private ManiaTouchInputArea? getTouchOverlay() => this.ChildrenOfType<ManiaTouchInputArea>().SingleOrDefault();

View File

@@ -47,8 +47,6 @@ namespace osu.Game.Rulesets.Mania.Tests
drawableRuleset = (DrawableManiaRuleset)Ruleset.Value.CreateInstance().CreateDrawableRulesetWith(createTestBeatmap())
}
};
drawableRuleset.AllowBackwardsSeeks = true;
});
AddStep("retrieve config bindable", () =>
{

View File

@@ -99,7 +99,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps
}
}
public static int GetColumnCount(LegacyBeatmapConversionDifficultyInfo difficulty, IReadOnlyList<Mod>? mods = null)
public static int GetColumnCount(LegacyBeatmapConversionDifficultyInfo difficulty, IReadOnlyCollection<Mod>? mods = null)
{
var converter = new ManiaBeatmapConverter(null, difficulty, new ManiaRuleset());

View File

@@ -3,8 +3,10 @@
using System.Collections.Generic;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Legacy;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Utils;
@@ -30,12 +32,18 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy
if (HitObject is IHasDuration endTimeData)
{
// despite the beatmap originally being made for mania, if the object is parsed as a slider rather than a hold, sliding samples should still be played.
// this is seemingly only possible to achieve by modifying the .osu file directly, but online beatmaps that do that exist
// (see second and fourth notes of https://osu.ppy.sh/beatmapsets/73883#mania/216407)
bool playSlidingSamples = (HitObject is IHasLegacyHitObjectType hasType && hasType.LegacyType == LegacyHitObjectType.Slider) || HitObject is IHasPath;
pattern.Add(new HoldNote
{
StartTime = HitObject.StartTime,
Duration = endTimeData.Duration,
Column = column,
Samples = HitObject.Samples,
PlaySlidingSamples = playSlidingSamples,
NodeSamples = (HitObject as IHasRepeats)?.NodeSamples ?? HoldNote.CreateDefaultNodeSamples(HitObject)
});
}

View File

@@ -521,6 +521,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy
Duration = endTime - startTime,
Column = column,
Samples = HitObject.Samples,
PlaySlidingSamples = true,
NodeSamples = nodeSamplesAt(startTime)
};
}

View File

@@ -15,6 +15,7 @@ namespace osu.Game.Rulesets.Mania.Configuration
public ManiaRulesetConfigManager(SettingsStore? settings, RulesetInfo ruleset, int? variant = null)
: base(settings, ruleset, variant)
{
Migrate();
}
private const double current_scroll_speed_precision = 1.0;
@@ -34,6 +35,20 @@ namespace osu.Game.Rulesets.Mania.Configuration
SetDefault(ManiaRulesetSetting.ScrollDirection, ManiaScrollingDirection.Down);
SetDefault(ManiaRulesetSetting.TimingBasedNoteColouring, false);
SetDefault(ManiaRulesetSetting.MobileLayout, ManiaMobileLayout.Portrait);
SetDefault(ManiaRulesetSetting.TouchOverlay, false);
}
public void Migrate()
{
var mobileLayout = GetBindable<ManiaMobileLayout>(ManiaRulesetSetting.MobileLayout);
#pragma warning disable CS0618 // Type or member is obsolete
if (mobileLayout.Value == ManiaMobileLayout.LandscapeWithOverlay)
#pragma warning restore CS0618 // Type or member is obsolete
{
mobileLayout.Value = ManiaMobileLayout.Landscape;
SetValue(ManiaRulesetSetting.TouchOverlay, true);
}
}
public override TrackedSettings CreateTrackedSettings() => new TrackedSettings
@@ -68,5 +83,6 @@ namespace osu.Game.Rulesets.Mania.Configuration
ScrollDirection,
TimingBasedNoteColouring,
MobileLayout,
TouchOverlay,
}
}

View File

@@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Checks
public IEnumerable<Issue> Run(BeatmapVerifierContext context)
{
var diff = context.Beatmap.Difficulty;
var diff = context.CurrentDifficulty.Playable.Difficulty;
if (diff.CircleSize < 4)
{

View File

@@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Checks
public override IEnumerable<Issue> Run(BeatmapVerifierContext context)
{
var diff = context.Beatmap.Difficulty;
var diff = context.CurrentDifficulty.Playable.Difficulty;
Issue? issue;
if (HasMoreThanOneDecimalPlace("Overall difficulty", diff.OverallDifficulty, out issue))

View File

@@ -13,7 +13,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Checks
{
public override IEnumerable<Issue> Run(BeatmapVerifierContext context)
{
var hitObjects = context.Beatmap.HitObjects;
var hitObjects = context.CurrentDifficulty.Playable.HitObjects;
for (int i = 0; i < hitObjects.Count - 1; ++i)
{
@@ -28,14 +28,18 @@ namespace osu.Game.Rulesets.Mania.Edit.Checks
continue;
// Two hitobjects cannot be concurrent without also being concurrent with all objects in between.
// So if the next object is not concurrent, then we know no future objects will be either.
if (!AreConcurrent(hitobject, nextHitobject))
// So if the next object is not concurrent or almost concurrent, then we know no future objects will be either.
if (!AreConcurrent(hitobject, nextHitobject) && !AreAlmostConcurrent(hitobject, nextHitobject))
break;
if (hitobject.GetType() == nextHitobject.GetType())
yield return new IssueTemplateConcurrentSame(this).Create(hitobject, nextHitobject);
else
yield return new IssueTemplateConcurrentDifferent(this).Create(hitobject, nextHitobject);
if (AreConcurrent(hitobject, nextHitobject))
{
yield return new IssueTemplateConcurrent(this).Create(hitobject, nextHitobject);
}
else if (AreAlmostConcurrent(hitobject, nextHitobject))
{
yield return new IssueTemplateAlmostConcurrent(this).Create(hitobject, nextHitobject);
}
}
}
}

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