mirror of
https://github.com/SK-la/Ez2Lazer.git
synced 2026-03-13 11:20:28 +00:00
[Lua]皮肤脚本支持
This commit is contained in:
@@ -1,96 +0,0 @@
|
||||
# 需要修改的文件列表
|
||||
|
||||
以下是实现皮肤脚本系统所需修改的所有文件列表,包括修改内容的简要说明。
|
||||
|
||||
## 修改的现有文件
|
||||
|
||||
### 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块中,确保脚本错误不会影响游戏稳定性。
|
||||
@@ -1 +0,0 @@
|
||||
MoonSharp.Interpreter 2.0.0
|
||||
@@ -1,169 +0,0 @@
|
||||
# 皮肤脚本系统 (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. 确保脚本没有语法错误
|
||||
@@ -1,17 +0,0 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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 = "确定",
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
#nullable disable
|
||||
|
||||
using System;
|
||||
using System.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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,369 +0,0 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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中集成,无需额外操作
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,460 +0,0 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,504 +0,0 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
#nullable disable
|
||||
|
||||
using System;
|
||||
using System.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using 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,
|
||||
}
|
||||
}
|
||||
20
osu.Game/LAsEzExtensions/Skinning/FileImportFaultDialog.cs
Normal file
20
osu.Game/LAsEzExtensions/Skinning/FileImportFaultDialog.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using osu.Framework.Graphics.Sprites;
|
||||
using osu.Game.Overlays.Dialog;
|
||||
|
||||
namespace osu.Game.LAsEzExtensions.Skinning
|
||||
{
|
||||
public partial class FileImportFaultDialog : PopupDialog
|
||||
{
|
||||
public FileImportFaultDialog(string errorMessage)
|
||||
{
|
||||
Icon = FontAwesome.Regular.TimesCircle;
|
||||
HeaderText = "Import failed";
|
||||
BodyText = errorMessage;
|
||||
|
||||
Buttons = new PopupDialogButton[]
|
||||
{
|
||||
new PopupDialogOkButton(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
39
osu.Game/LAsEzExtensions/Skinning/ISkinScriptHost.cs
Normal file
39
osu.Game/LAsEzExtensions/Skinning/ISkinScriptHost.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using osu.Framework.Audio.Sample;
|
||||
using osu.Framework.Timing;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Textures;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Rulesets;
|
||||
using osu.Game.Skinning;
|
||||
|
||||
namespace osu.Game.LAsEzExtensions.Skinning
|
||||
{
|
||||
public interface ISkinScriptHost
|
||||
{
|
||||
IBeatmap? CurrentBeatmap { get; }
|
||||
|
||||
IRulesetInfo? CurrentRuleset { get; }
|
||||
|
||||
ISkin? CurrentSkin { get; }
|
||||
|
||||
Drawable CreateComponent(string componentType);
|
||||
|
||||
Texture? GetTexture(string name);
|
||||
|
||||
ISample? GetSample(string name);
|
||||
|
||||
void SubscribeToEvent(string eventName);
|
||||
|
||||
double GetCurrentTime();
|
||||
|
||||
void Log(string message, SkinScriptLogLevel level = SkinScriptLogLevel.Information);
|
||||
}
|
||||
|
||||
public enum SkinScriptLogLevel
|
||||
{
|
||||
Debug,
|
||||
Information,
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using MoonSharp.Interpreter;
|
||||
using MoonSharp.Interpreter.Interop;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
|
||||
namespace osu.Game.LAsEzExtensions.Skinning
|
||||
{
|
||||
[MoonSharpUserData]
|
||||
public class ManiaSkinScriptExtensions
|
||||
{
|
||||
private readonly ISkinScriptHost host;
|
||||
|
||||
public ManiaSkinScriptExtensions(ISkinScriptHost host)
|
||||
{
|
||||
this.host = host;
|
||||
}
|
||||
|
||||
[MoonSharpVisible(true)]
|
||||
public int GetColumnCount()
|
||||
{
|
||||
if (!isManiaRuleset())
|
||||
return 0;
|
||||
|
||||
int maxColumn = -1;
|
||||
|
||||
foreach (HitObject hitObject in enumerateHitObjects(host.CurrentBeatmap))
|
||||
{
|
||||
int? column = readColumn(hitObject);
|
||||
if (column != null)
|
||||
maxColumn = Math.Max(maxColumn, column.Value);
|
||||
}
|
||||
|
||||
return maxColumn + 1;
|
||||
}
|
||||
|
||||
[MoonSharpVisible(true)]
|
||||
public string GetColumnBinding(int column) => $"Column{column + 1}";
|
||||
|
||||
[MoonSharpVisible(true)]
|
||||
public float GetColumnWidth(int column)
|
||||
{
|
||||
int count = GetColumnCount();
|
||||
return count <= 0 ? 0 : 1f / count;
|
||||
}
|
||||
|
||||
[MoonSharpVisible(true)]
|
||||
public int GetNoteColumn(object note)
|
||||
{
|
||||
int? column = readColumn(note);
|
||||
return column ?? -1;
|
||||
}
|
||||
|
||||
private bool isManiaRuleset() => string.Equals(host.CurrentRuleset?.Name, "mania", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static IEnumerable<HitObject> enumerateHitObjects(dynamic? beatmap)
|
||||
{
|
||||
if (beatmap?.HitObjects == null)
|
||||
yield break;
|
||||
|
||||
var stack = new Stack<HitObject>();
|
||||
|
||||
foreach (HitObject hitObject in beatmap.HitObjects)
|
||||
stack.Push(hitObject);
|
||||
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
HitObject current = stack.Pop();
|
||||
yield return current;
|
||||
|
||||
foreach (HitObject nested in current.NestedHitObjects)
|
||||
stack.Push(nested);
|
||||
}
|
||||
}
|
||||
|
||||
private static int? readColumn(object obj)
|
||||
{
|
||||
var property = obj.GetType().GetProperty("Column");
|
||||
if (property == null)
|
||||
return null;
|
||||
|
||||
object? raw = property.GetValue(obj);
|
||||
if (raw == null)
|
||||
return null;
|
||||
|
||||
return raw switch
|
||||
{
|
||||
int i => i,
|
||||
IConvertible convertible => Convert.ToInt32(convertible),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
374
osu.Game/LAsEzExtensions/Skinning/SkinScript.cs
Normal file
374
osu.Game/LAsEzExtensions/Skinning/SkinScript.cs
Normal file
@@ -0,0 +1,374 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using MoonSharp.Interpreter.Interop;
|
||||
using MoonSharp.Interpreter;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Game.Rulesets.Judgements;
|
||||
using osu.Game.Rulesets.Objects;
|
||||
using osuTK.Graphics;
|
||||
|
||||
namespace osu.Game.LAsEzExtensions.Skinning
|
||||
{
|
||||
public class SkinScript : IDisposable
|
||||
{
|
||||
private readonly Script luaScript;
|
||||
private readonly ISkinScriptHost host;
|
||||
private readonly HashSet<string> subscribedEvents = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
public string ScriptName { get; }
|
||||
|
||||
public string Description { get; private set; } = "No description provided";
|
||||
|
||||
public bool IsActivated { get; private set; }
|
||||
|
||||
public string? ActivationError { get; private set; }
|
||||
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
|
||||
public SkinScript(string scriptContent, string scriptName, ISkinScriptHost host)
|
||||
{
|
||||
ScriptName = scriptName;
|
||||
this.host = host;
|
||||
|
||||
luaScript = new Script(CoreModules.Preset_SoftSandbox);
|
||||
|
||||
UserData.RegisterType<SkinScriptInterface>();
|
||||
UserData.RegisterType<LuaDrawableProxy>();
|
||||
UserData.RegisterType<LuaTypeInfo>();
|
||||
UserData.RegisterType<LuaJudgementResultProxy>();
|
||||
UserData.RegisterType<LuaHitObjectProxy>();
|
||||
UserData.RegisterType<ManiaSkinScriptExtensions>();
|
||||
luaScript.Globals["osu"] = new SkinScriptInterface(host, subscribeToEvent);
|
||||
|
||||
if (string.Equals(host.CurrentRuleset?.Name, "mania", StringComparison.OrdinalIgnoreCase))
|
||||
luaScript.Globals["mania"] = new ManiaSkinScriptExtensions(host);
|
||||
|
||||
try
|
||||
{
|
||||
luaScript.DoString(scriptContent);
|
||||
|
||||
var description = luaScript.Globals.Get("SCRIPT_DESCRIPTION");
|
||||
if (description.Type == DataType.String)
|
||||
Description = description.String;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ActivationError = $"SkinScript Load Failed: {ex}";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!invokeActivation())
|
||||
return;
|
||||
|
||||
IsActivated = true;
|
||||
}
|
||||
|
||||
public void NotifyComponentLoaded(Drawable component)
|
||||
{
|
||||
if (!IsEnabled)
|
||||
return;
|
||||
|
||||
invoke("onComponentLoaded", component);
|
||||
}
|
||||
|
||||
public void Update()
|
||||
{
|
||||
if (!IsEnabled)
|
||||
return;
|
||||
|
||||
invokeNoArg("update");
|
||||
}
|
||||
|
||||
public void NotifyJudgement(JudgementResult result)
|
||||
{
|
||||
if (!IsEnabled)
|
||||
return;
|
||||
|
||||
invoke("onJudgement", result);
|
||||
}
|
||||
|
||||
public void NotifyGameEvent(string eventName, IReadOnlyDictionary<string, object?>? data = null)
|
||||
{
|
||||
if (!IsEnabled || !IsSubscribedToEvent(eventName))
|
||||
return;
|
||||
|
||||
invoke("onGameEvent", eventName, data ?? new Dictionary<string, object?>());
|
||||
}
|
||||
|
||||
public void NotifyInputEvent(IReadOnlyDictionary<string, object?> eventData)
|
||||
{
|
||||
if (!IsEnabled)
|
||||
return;
|
||||
|
||||
invoke("onInputEvent", eventData);
|
||||
}
|
||||
|
||||
public bool IsSubscribedToEvent(string eventName) => subscribedEvents.Contains(eventName);
|
||||
|
||||
private void subscribeToEvent(string eventName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(eventName))
|
||||
return;
|
||||
|
||||
subscribedEvents.Add(eventName);
|
||||
}
|
||||
|
||||
private void invokeNoArg(string functionName) => invoke(functionName);
|
||||
|
||||
private bool invokeActivation()
|
||||
{
|
||||
try
|
||||
{
|
||||
var function = luaScript.Globals.Get("onLoad");
|
||||
if (function.Type != DataType.Function)
|
||||
return true;
|
||||
|
||||
luaScript.Call(function);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ActivationError = $"SkinScript onLoad Activation Failed: {ex.Message}";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void invoke(string functionName, params object[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
var function = luaScript.Globals.Get(functionName);
|
||||
if (function.Type != DataType.Function)
|
||||
return;
|
||||
|
||||
DynValue[] convertedArgs = new DynValue[args.Length];
|
||||
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
convertedArgs[i] = convertToDynValue(args[i]);
|
||||
|
||||
luaScript.Call(function, convertedArgs);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
host.Log($"[SkinScript] Callback error in {ScriptName}.{functionName}: {ex.Message}", SkinScriptLogLevel.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private DynValue convertToDynValue(object? argument)
|
||||
{
|
||||
if (argument == null)
|
||||
return DynValue.Nil;
|
||||
|
||||
if (argument is Drawable drawable)
|
||||
return UserData.Create(new LuaDrawableProxy(drawable));
|
||||
|
||||
if (argument is JudgementResult judgementResult)
|
||||
return UserData.Create(new LuaJudgementResultProxy(judgementResult));
|
||||
|
||||
if (argument is HitObject hitObject)
|
||||
return UserData.Create(new LuaHitObjectProxy(hitObject));
|
||||
|
||||
if (argument is IReadOnlyDictionary<string, object?> readonlyDictionary)
|
||||
return createTableValue(readonlyDictionary);
|
||||
|
||||
if (argument is IDictionary dictionary)
|
||||
return createTableValue(dictionary);
|
||||
|
||||
return argument switch
|
||||
{
|
||||
string str => DynValue.NewString(str),
|
||||
bool boolean => DynValue.NewBoolean(boolean),
|
||||
byte number => DynValue.NewNumber(number),
|
||||
sbyte number => DynValue.NewNumber(number),
|
||||
short number => DynValue.NewNumber(number),
|
||||
ushort number => DynValue.NewNumber(number),
|
||||
int number => DynValue.NewNumber(number),
|
||||
uint number => DynValue.NewNumber(number),
|
||||
long number => DynValue.NewNumber(number),
|
||||
ulong number => DynValue.NewNumber(number),
|
||||
float number => DynValue.NewNumber(number),
|
||||
double number => DynValue.NewNumber(number),
|
||||
decimal number => DynValue.NewNumber((double)number),
|
||||
_ => DynValue.NewString(argument.ToString() ?? string.Empty),
|
||||
};
|
||||
}
|
||||
|
||||
private DynValue createTableValue(IReadOnlyDictionary<string, object?> dictionary)
|
||||
{
|
||||
Table table = new Table(luaScript);
|
||||
|
||||
foreach (var item in dictionary)
|
||||
table.Set(item.Key, convertToDynValue(item.Value));
|
||||
|
||||
return DynValue.NewTable(table);
|
||||
}
|
||||
|
||||
private DynValue createTableValue(IDictionary dictionary)
|
||||
{
|
||||
Table table = new Table(luaScript);
|
||||
|
||||
foreach (DictionaryEntry item in dictionary)
|
||||
{
|
||||
string key = item.Key.ToString() ?? string.Empty;
|
||||
table.Set(key, convertToDynValue(item.Value));
|
||||
}
|
||||
|
||||
return DynValue.NewTable(table);
|
||||
}
|
||||
|
||||
[MoonSharpUserData]
|
||||
private class LuaTypeInfo
|
||||
{
|
||||
public readonly string Name;
|
||||
|
||||
public override string ToString() => Name;
|
||||
|
||||
public LuaTypeInfo(string name)
|
||||
{
|
||||
Name = name;
|
||||
}
|
||||
}
|
||||
|
||||
[MoonSharpUserData]
|
||||
private class LuaDrawableProxy
|
||||
{
|
||||
private readonly Drawable drawable;
|
||||
|
||||
public LuaTypeInfo Type => new LuaTypeInfo(drawable.GetType().Name);
|
||||
|
||||
public double Alpha
|
||||
{
|
||||
get => drawable.Alpha;
|
||||
set => drawable.Alpha = (float)value;
|
||||
}
|
||||
|
||||
public int? Column => readNullable<int>("Column");
|
||||
|
||||
public double? StartTime => readNullable<double>("StartTime");
|
||||
|
||||
public double? EndTime => readNullable<double>("EndTime");
|
||||
|
||||
[MoonSharpUserDataMetamethod("__newindex")]
|
||||
public void SetValue(string key, DynValue value)
|
||||
{
|
||||
if (string.Equals(key, "Colour", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (tryReadColour(value, out var colour))
|
||||
drawable.Colour = colour;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.Equals(key, "Alpha", StringComparison.OrdinalIgnoreCase) && value.Type == DataType.Number)
|
||||
{
|
||||
Alpha = value.Number;
|
||||
}
|
||||
}
|
||||
|
||||
public LuaDrawableProxy(Drawable drawable)
|
||||
{
|
||||
this.drawable = drawable;
|
||||
}
|
||||
|
||||
private T? readNullable<T>(string propertyName)
|
||||
where T : struct
|
||||
{
|
||||
var property = drawable.GetType().GetProperty(propertyName);
|
||||
|
||||
if (property == null)
|
||||
return null;
|
||||
|
||||
object? rawValue = property.GetValue(drawable);
|
||||
if (rawValue == null)
|
||||
return null;
|
||||
|
||||
return rawValue switch
|
||||
{
|
||||
T typedValue => typedValue,
|
||||
IConvertible convertible => (T)Convert.ChangeType(convertible, typeof(T)),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool tryReadColour(DynValue value, out Colour4 colour)
|
||||
{
|
||||
colour = Colour4.White;
|
||||
|
||||
if (value.Type != DataType.Table)
|
||||
return false;
|
||||
|
||||
float readChannel(string key, float fallback)
|
||||
{
|
||||
DynValue channel = value.Table.Get(key);
|
||||
return channel.Type == DataType.Number ? (float)channel.Number : fallback;
|
||||
}
|
||||
|
||||
colour = new Colour4(
|
||||
readChannel("R", 1f),
|
||||
readChannel("G", 1f),
|
||||
readChannel("B", 1f),
|
||||
readChannel("A", 1f));
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
[MoonSharpUserData]
|
||||
private class LuaJudgementResultProxy
|
||||
{
|
||||
private readonly JudgementResult result;
|
||||
|
||||
public string Type => result.Type.ToString();
|
||||
|
||||
public LuaHitObjectProxy HitObject => new LuaHitObjectProxy(result.HitObject);
|
||||
|
||||
public LuaJudgementResultProxy(JudgementResult result)
|
||||
{
|
||||
this.result = result;
|
||||
}
|
||||
}
|
||||
|
||||
[MoonSharpUserData]
|
||||
private class LuaHitObjectProxy
|
||||
{
|
||||
private readonly HitObject hitObject;
|
||||
|
||||
public int? Column => readNullable<int>("Column");
|
||||
|
||||
public double? StartTime => readNullable<double>("StartTime");
|
||||
|
||||
public double? EndTime => readNullable<double>("EndTime");
|
||||
|
||||
public LuaHitObjectProxy(HitObject hitObject)
|
||||
{
|
||||
this.hitObject = hitObject;
|
||||
}
|
||||
|
||||
private T? readNullable<T>(string propertyName)
|
||||
where T : struct
|
||||
{
|
||||
var property = hitObject.GetType().GetProperty(propertyName);
|
||||
|
||||
if (property == null)
|
||||
return null;
|
||||
|
||||
object? rawValue = property.GetValue(hitObject);
|
||||
if (rawValue == null)
|
||||
return null;
|
||||
|
||||
return rawValue switch
|
||||
{
|
||||
T typedValue => typedValue,
|
||||
IConvertible convertible => (T)Convert.ChangeType(convertible, typeof(T)),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
luaScript.Globals.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
56
osu.Game/LAsEzExtensions/Skinning/SkinScriptInterface.cs
Normal file
56
osu.Game/LAsEzExtensions/Skinning/SkinScriptInterface.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using MoonSharp.Interpreter;
|
||||
|
||||
namespace osu.Game.LAsEzExtensions.Skinning
|
||||
{
|
||||
[MoonSharpUserData]
|
||||
public class SkinScriptInterface
|
||||
{
|
||||
private readonly ISkinScriptHost host;
|
||||
private readonly Action<string>? onSubscribe;
|
||||
|
||||
public SkinScriptInterface(ISkinScriptHost host, Action<string>? onSubscribe = null)
|
||||
{
|
||||
this.host = host;
|
||||
this.onSubscribe = onSubscribe;
|
||||
}
|
||||
|
||||
public string GetBeatmapTitle() => host.CurrentBeatmap?.Metadata.Title ?? string.Empty;
|
||||
|
||||
public string GetBeatmapArtist() => host.CurrentBeatmap?.Metadata.Artist ?? string.Empty;
|
||||
|
||||
public string GetRulesetName() => host.CurrentRuleset?.Name ?? string.Empty;
|
||||
|
||||
public object CreateComponent(string componentType) => host.CreateComponent(componentType);
|
||||
|
||||
public object? GetTexture(string name) => host.GetTexture(name);
|
||||
|
||||
public object? GetSample(string name) => host.GetSample(name);
|
||||
|
||||
public void PlaySample(string name)
|
||||
{
|
||||
host.GetSample(name)?.Play();
|
||||
}
|
||||
|
||||
public void SubscribeToEvent(string eventName)
|
||||
{
|
||||
onSubscribe?.Invoke(eventName);
|
||||
host.SubscribeToEvent(eventName);
|
||||
}
|
||||
|
||||
public double GetCurrentTime() => host.GetCurrentTime();
|
||||
|
||||
public void Log(string message, string level = "info")
|
||||
{
|
||||
SkinScriptLogLevel logLevel = level.ToLowerInvariant() switch
|
||||
{
|
||||
"debug" => SkinScriptLogLevel.Debug,
|
||||
"warning" => SkinScriptLogLevel.Warning,
|
||||
"error" => SkinScriptLogLevel.Error,
|
||||
_ => SkinScriptLogLevel.Information,
|
||||
};
|
||||
|
||||
host.Log(message, logLevel);
|
||||
}
|
||||
}
|
||||
}
|
||||
315
osu.Game/LAsEzExtensions/Skinning/SkinScriptManager.cs
Normal file
315
osu.Game/LAsEzExtensions/Skinning/SkinScriptManager.cs
Normal file
@@ -0,0 +1,315 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Audio.Sample;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Framework.Graphics.Shapes;
|
||||
using osu.Framework.Graphics.Sprites;
|
||||
using osu.Framework.Graphics.Textures;
|
||||
using osu.Framework.Input.Events;
|
||||
using osu.Framework.Logging;
|
||||
using osu.Framework.Platform;
|
||||
using osu.Game.Audio;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Graphics.Sprites;
|
||||
using osu.Game.Rulesets.Judgements;
|
||||
using osu.Game.Rulesets;
|
||||
using osu.Game.Skinning;
|
||||
|
||||
namespace osu.Game.LAsEzExtensions.Skinning
|
||||
{
|
||||
[Cached]
|
||||
public partial class SkinScriptManager : Component, ISkinScriptHost
|
||||
{
|
||||
private const string script_storage_directory = "skin-scripts";
|
||||
|
||||
private readonly List<SkinScript> activeScripts = new List<SkinScript>();
|
||||
|
||||
private Bindable<bool> scriptingEnabled = null!;
|
||||
|
||||
[Resolved]
|
||||
private SkinManager skinManager { get; set; } = null!;
|
||||
|
||||
[Resolved]
|
||||
private IBindable<WorkingBeatmap> beatmap { get; set; } = null!;
|
||||
|
||||
[Resolved]
|
||||
private IBindable<RulesetInfo> ruleset { get; set; } = null!;
|
||||
|
||||
[Resolved]
|
||||
private Storage storage { get; set; } = null!;
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load()
|
||||
{
|
||||
var config = new SkinScriptingConfig(storage);
|
||||
scriptingEnabled = config.GetBindable<bool>(SkinScriptingSetting.ScriptingEnabled);
|
||||
}
|
||||
|
||||
public override bool HandleNonPositionalInput => true;
|
||||
|
||||
protected override void LoadComplete()
|
||||
{
|
||||
base.LoadComplete();
|
||||
|
||||
skinManager.CurrentSkin.BindValueChanged(_ => reloadScripts(), true);
|
||||
scriptingEnabled.BindValueChanged(_ => reloadScripts());
|
||||
}
|
||||
|
||||
private void reloadScripts()
|
||||
{
|
||||
foreach (var script in activeScripts)
|
||||
script.Dispose();
|
||||
|
||||
activeScripts.Clear();
|
||||
|
||||
if (!scriptingEnabled.Value)
|
||||
return;
|
||||
|
||||
string externalScriptPath = storage.GetStorageForDirectory(script_storage_directory).GetFullPath($"{skinManager.CurrentSkinInfo.Value.ID}.lua");
|
||||
|
||||
if (File.Exists(externalScriptPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var reader = new StreamReader(externalScriptPath);
|
||||
string scriptContent = reader.ReadToEnd();
|
||||
|
||||
Log($"Loaded script file: {Path.GetFileName(externalScriptPath)}", SkinScriptLogLevel.Information);
|
||||
|
||||
var script = new SkinScript(scriptContent, Path.GetFileName(externalScriptPath), this);
|
||||
|
||||
if (!script.IsActivated)
|
||||
{
|
||||
Log($"Script activation failed: {Path.GetFileName(externalScriptPath)} | Reason: {script.ActivationError ?? "Unknown error"}", SkinScriptLogLevel.Error);
|
||||
script.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
activeScripts.Add(script);
|
||||
Log($"Script activated: {Path.GetFileName(externalScriptPath)} | Description: {script.Description}", SkinScriptLogLevel.Information);
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"Script read exception: {Path.GetFileName(externalScriptPath)} | {ex.Message}", SkinScriptLogLevel.Error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (skinManager.CurrentSkin.Value is not Skin skin)
|
||||
return;
|
||||
|
||||
foreach (string file in skin.GetScriptFiles())
|
||||
{
|
||||
try
|
||||
{
|
||||
using Stream? stream = skin.GetFileStream(file);
|
||||
|
||||
if (stream == null)
|
||||
{
|
||||
Log($"Script read failed: {file} (stream is null)", SkinScriptLogLevel.Error);
|
||||
continue;
|
||||
}
|
||||
|
||||
using var reader = new StreamReader(stream);
|
||||
string scriptContent = reader.ReadToEnd();
|
||||
|
||||
Log($"Loaded script file: {file}", SkinScriptLogLevel.Information);
|
||||
|
||||
var script = new SkinScript(scriptContent, file, this);
|
||||
|
||||
if (!script.IsActivated)
|
||||
{
|
||||
Log($"Script activation failed: {file} | Reason: {script.ActivationError ?? "Unknown error"}", SkinScriptLogLevel.Error);
|
||||
script.Dispose();
|
||||
continue;
|
||||
}
|
||||
|
||||
activeScripts.Add(script);
|
||||
|
||||
Log($"Script activated: {file} | Description: {script.Description}", SkinScriptLogLevel.Information);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"Script load failed: {file} | {ex.Message}", SkinScriptLogLevel.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void NotifyComponentLoaded(Drawable component)
|
||||
{
|
||||
foreach (var script in activeScripts)
|
||||
script.NotifyComponentLoaded(component);
|
||||
}
|
||||
|
||||
public void NotifyJudgement(JudgementResult result)
|
||||
{
|
||||
foreach (var script in activeScripts)
|
||||
script.NotifyJudgement(result);
|
||||
|
||||
var hitEventData = new Dictionary<string, object?>
|
||||
{
|
||||
["Type"] = result.Type.ToString(),
|
||||
["IsHit"] = result.IsHit,
|
||||
["TimeOffset"] = result.TimeOffset,
|
||||
["ColumnIndex"] = getColumnIndex(result.HitObject),
|
||||
};
|
||||
|
||||
notifyGameEvent("HitEvent", hitEventData);
|
||||
|
||||
if (string.Equals(CurrentRuleset?.Name, "mania", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
int? column = getColumnIndex(result.HitObject);
|
||||
if (column != null)
|
||||
notifyGameEvent("ManiaColumnHit", new Dictionary<string, object?> { ["ColumnIndex"] = column.Value });
|
||||
|
||||
string hitObjectType = result.HitObject.GetType().Name;
|
||||
|
||||
if (hitObjectType.Contains("Hold", StringComparison.OrdinalIgnoreCase) && hitObjectType.Contains("Head", StringComparison.OrdinalIgnoreCase) && column != null)
|
||||
notifyGameEvent("ManiaHoldActivated", new Dictionary<string, object?> { ["ColumnIndex"] = column.Value });
|
||||
|
||||
if (hitObjectType.Contains("Hold", StringComparison.OrdinalIgnoreCase) && hitObjectType.Contains("Tail", StringComparison.OrdinalIgnoreCase) && column != null)
|
||||
notifyGameEvent("ManiaHoldReleased", new Dictionary<string, object?> { ["ColumnIndex"] = column.Value });
|
||||
}
|
||||
}
|
||||
|
||||
protected override void Update()
|
||||
{
|
||||
base.Update();
|
||||
|
||||
foreach (var script in activeScripts)
|
||||
script.Update();
|
||||
}
|
||||
|
||||
public IBeatmap? CurrentBeatmap => beatmap.Value?.Beatmap;
|
||||
|
||||
public IRulesetInfo? CurrentRuleset => ruleset.Value;
|
||||
|
||||
public ISkin? CurrentSkin => skinManager.CurrentSkin.Value;
|
||||
|
||||
public Drawable CreateComponent(string componentType)
|
||||
{
|
||||
switch (componentType.ToLowerInvariant())
|
||||
{
|
||||
case "container":
|
||||
return new Container();
|
||||
|
||||
case "sprite":
|
||||
return new Sprite();
|
||||
|
||||
case "box":
|
||||
return new Box();
|
||||
|
||||
case "spritetext":
|
||||
return new OsuSpriteText();
|
||||
|
||||
default:
|
||||
Log($"Unknown component type requested: {componentType}. Returning Container fallback.", SkinScriptLogLevel.Warning);
|
||||
return new Container();
|
||||
}
|
||||
}
|
||||
|
||||
public Texture? GetTexture(string name) => skinManager.CurrentSkin.Value?.GetTexture(name);
|
||||
|
||||
public ISample? GetSample(string name) => skinManager.GetSample(new SampleInfo(name));
|
||||
|
||||
public void SubscribeToEvent(string eventName)
|
||||
{
|
||||
Log($"Subscribed event: {eventName}", SkinScriptLogLevel.Debug);
|
||||
}
|
||||
|
||||
public double GetCurrentTime() => Time.Current;
|
||||
|
||||
public void Log(string message, SkinScriptLogLevel level = SkinScriptLogLevel.Information)
|
||||
{
|
||||
if (!message.StartsWith("[SkinScript]", StringComparison.Ordinal))
|
||||
message = $"[SkinScript] {message}";
|
||||
|
||||
switch (level)
|
||||
{
|
||||
case SkinScriptLogLevel.Debug:
|
||||
Logger.Log(message, level: LogLevel.Debug);
|
||||
break;
|
||||
|
||||
case SkinScriptLogLevel.Warning:
|
||||
Logger.Log(message, level: LogLevel.Important);
|
||||
break;
|
||||
|
||||
case SkinScriptLogLevel.Error:
|
||||
Logger.Log(message, level: LogLevel.Important);
|
||||
break;
|
||||
|
||||
default:
|
||||
Logger.Log(message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void Dispose(bool isDisposing)
|
||||
{
|
||||
foreach (var script in activeScripts)
|
||||
script.Dispose();
|
||||
|
||||
activeScripts.Clear();
|
||||
|
||||
base.Dispose(isDisposing);
|
||||
}
|
||||
|
||||
protected override bool OnKeyDown(KeyDownEvent e)
|
||||
{
|
||||
notifyInputEvent(new Dictionary<string, object?>
|
||||
{
|
||||
["Key"] = e.Key.ToString(),
|
||||
["State"] = "Down",
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected override void OnKeyUp(KeyUpEvent e)
|
||||
{
|
||||
notifyInputEvent(new Dictionary<string, object?>
|
||||
{
|
||||
["Key"] = e.Key.ToString(),
|
||||
["State"] = "Up",
|
||||
});
|
||||
}
|
||||
|
||||
private void notifyGameEvent(string eventName, IReadOnlyDictionary<string, object?> data)
|
||||
{
|
||||
foreach (var script in activeScripts)
|
||||
script.NotifyGameEvent(eventName, data);
|
||||
}
|
||||
|
||||
private void notifyInputEvent(IReadOnlyDictionary<string, object?> data)
|
||||
{
|
||||
foreach (var script in activeScripts)
|
||||
{
|
||||
if (script.IsSubscribedToEvent("InputEvent"))
|
||||
script.NotifyInputEvent(data);
|
||||
}
|
||||
}
|
||||
|
||||
private static int? getColumnIndex(object hitObject)
|
||||
{
|
||||
var property = hitObject.GetType().GetProperty("Column");
|
||||
if (property == null)
|
||||
return null;
|
||||
|
||||
object? raw = property.GetValue(hitObject);
|
||||
if (raw == null)
|
||||
return null;
|
||||
|
||||
return raw switch
|
||||
{
|
||||
int i => i,
|
||||
IConvertible convertible => Convert.ToInt32(convertible),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
29
osu.Game/LAsEzExtensions/Skinning/SkinScriptingConfig.cs
Normal file
29
osu.Game/LAsEzExtensions/Skinning/SkinScriptingConfig.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using osu.Framework.Configuration;
|
||||
using osu.Framework.Platform;
|
||||
|
||||
namespace osu.Game.LAsEzExtensions.Skinning
|
||||
{
|
||||
public class SkinScriptingConfig : IniConfigManager<SkinScriptingSetting>
|
||||
{
|
||||
protected override string Filename => "skin-scripting.ini";
|
||||
|
||||
public SkinScriptingConfig(Storage storage)
|
||||
: base(storage)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void InitialiseDefaults()
|
||||
{
|
||||
base.InitialiseDefaults();
|
||||
|
||||
SetDefault(SkinScriptingSetting.ScriptingEnabled, true);
|
||||
SetDefault(SkinScriptingSetting.LastImportDirectory, string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
public enum SkinScriptingSetting
|
||||
{
|
||||
ScriptingEnabled,
|
||||
LastImportDirectory,
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,72 @@
|
||||
-- Mania-specific skin script example
|
||||
-- This script shows how to customize Mania mode skin components
|
||||
-- Mania-specific skin script example (practical test effects)
|
||||
-- Added effects:
|
||||
-- 1) Hold note alpha: 0.5 -> 1.0 over hold duration
|
||||
-- 2) Per-column KPS key-light color: green/yellow/red/blue
|
||||
-- 3) Hide judgement drawable when result is Meh
|
||||
|
||||
-- Script description and metadata
|
||||
SCRIPT_DESCRIPTION = "Mania模式特定的皮肤脚本示例,展示如何自定义下落式键盘模式的外观和行为"
|
||||
SCRIPT_DESCRIPTION = "Mania test script: hold alpha ramp, per-column KPS color, hide Meh judgement"
|
||||
SCRIPT_VERSION = "1.0"
|
||||
SCRIPT_AUTHOR = "osu!team"
|
||||
|
||||
-- Cache for column information
|
||||
local columnData = {}
|
||||
|
||||
-- Hold note tracking
|
||||
local holdNotes = {}
|
||||
|
||||
-- Column key-light tracking (column -> drawable)
|
||||
local columnLights = {}
|
||||
|
||||
-- Keep recent judgement drawables for fallback hiding
|
||||
local recentJudgementDrawables = {}
|
||||
|
||||
-- Utility
|
||||
local function clamp(x, minVal, maxVal)
|
||||
if x < minVal then return minVal end
|
||||
if x > maxVal then return maxVal end
|
||||
return x
|
||||
end
|
||||
|
||||
local function colorForKps(kps)
|
||||
-- <5: green
|
||||
-- 5-6: yellow
|
||||
-- 7-8: red
|
||||
-- >8: blue
|
||||
if kps < 5 then
|
||||
return { R = 0.20, G = 1.00, B = 0.20, A = 1.00 }
|
||||
elseif kps <= 6 then
|
||||
return { R = 1.00, G = 0.90, B = 0.20, A = 1.00 }
|
||||
elseif kps <= 8 then
|
||||
return { R = 1.00, G = 0.20, B = 0.20, A = 1.00 }
|
||||
end
|
||||
|
||||
return { R = 0.20, G = 0.50, B = 1.00, A = 1.00 }
|
||||
end
|
||||
|
||||
local function setDrawableColor(drawable, colour)
|
||||
if drawable ~= nil then
|
||||
drawable.Colour = colour
|
||||
end
|
||||
end
|
||||
|
||||
local function setDrawableAlpha(drawable, alpha)
|
||||
if drawable ~= nil then
|
||||
drawable.Alpha = alpha
|
||||
end
|
||||
end
|
||||
|
||||
local function noteKey(note)
|
||||
-- use tostring(note) as a weak identity key for Lua-side tracking
|
||||
return tostring(note)
|
||||
end
|
||||
|
||||
-- Called when the script is first loaded
|
||||
function onLoad()
|
||||
osu.Log("Mania skin script loaded!", "info")
|
||||
osu.SubscribeToEvent("ManiaColumnHit")
|
||||
osu.SubscribeToEvent("ManiaHoldActivated")
|
||||
osu.SubscribeToEvent("ManiaHoldReleased")
|
||||
|
||||
-- Initialize column data if we're in mania mode
|
||||
if osu.GetRulesetName() == "mania" then
|
||||
@@ -26,10 +79,11 @@ function onLoad()
|
||||
binding = mania.GetColumnBinding(i),
|
||||
width = mania.GetColumnWidth(i),
|
||||
lastHitTime = 0,
|
||||
isHolding = false
|
||||
isHolding = false,
|
||||
hitTimes = {}
|
||||
}
|
||||
osu.Log("Column " .. i .. " has binding " .. columnData[i].binding, "debug")
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -57,6 +111,27 @@ function onComponentLoaded(component)
|
||||
-- Odd columns get another style
|
||||
note.Colour = {R = 0.4, G = 0.4, B = 0.9, A = 1.0}
|
||||
end
|
||||
|
||||
-- Practical effect #1:
|
||||
-- If this is a hold note and has start/end time, initialise alpha at 0.5 and track it.
|
||||
if note.EndTime ~= nil and note.StartTime ~= nil and note.EndTime > note.StartTime then
|
||||
setDrawableAlpha(note, 0.5)
|
||||
holdNotes[noteKey(note)] = note
|
||||
end
|
||||
end
|
||||
elseif component.Type and (component.Type.Name == "LegacyHoldNoteHeadPiece" or component.Type.Name == "LegacyBodyPiece" or component.Type.Name == "LegacyHoldNoteTailPiece") then
|
||||
-- Current lazer legacy mania pipeline exposes hold pieces with these types.
|
||||
-- Make hold-related pieces semi-transparent for a clear visible test effect.
|
||||
setDrawableAlpha(component, 0.55)
|
||||
elseif component.Type and (component.Type.Name == "ManiaColumnLighting" or component.Type.Name == "ManiaKeyLighting" or component.Type.Name == "ManiaStageLight") then
|
||||
-- Best-effort binding of column key light drawable.
|
||||
if component.Column ~= nil then
|
||||
columnLights[component.Column] = component
|
||||
end
|
||||
elseif component.Type and (component.Type.Name == "Judgement" or component.Type.Name == "ManiaJudgement" or component.Type.Name == "JudgementResult") then
|
||||
table.insert(recentJudgementDrawables, component)
|
||||
if #recentJudgementDrawables > 8 then
|
||||
table.remove(recentJudgementDrawables, 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -67,11 +142,30 @@ function onGameEvent(eventName, data)
|
||||
local columnIndex = data.ColumnIndex
|
||||
|
||||
if columnData[columnIndex] then
|
||||
columnData[columnIndex].lastHitTime = osu.GetCurrentTime()
|
||||
local now = osu.GetCurrentTime()
|
||||
columnData[columnIndex].lastHitTime = now
|
||||
|
||||
-- Practical effect #2: per-column KPS -> key-light color
|
||||
local hitTimes = columnData[columnIndex].hitTimes
|
||||
table.insert(hitTimes, now)
|
||||
|
||||
-- keep only 1-second window
|
||||
local i = 1
|
||||
while i <= #hitTimes do
|
||||
if now - hitTimes[i] > 1000 then
|
||||
table.remove(hitTimes, i)
|
||||
else
|
||||
i = i + 1
|
||||
end
|
||||
end
|
||||
|
||||
local kps = #hitTimes
|
||||
local colour = colorForKps(kps)
|
||||
setDrawableColor(columnLights[columnIndex], colour)
|
||||
|
||||
-- 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")
|
||||
osu.Log("Hit on column " .. columnIndex .. ", KPS=" .. kps, "debug")
|
||||
end
|
||||
elseif eventName == "ManiaHoldActivated" then
|
||||
local columnIndex = data.ColumnIndex
|
||||
@@ -98,6 +192,18 @@ end
|
||||
function onJudgement(result)
|
||||
if result.HitObject and result.HitObject.Column ~= nil then
|
||||
local columnIndex = result.HitObject.Column
|
||||
|
||||
-- Practical effect #3: hide judgement drawable for Meh
|
||||
if result.Type == "Meh" then
|
||||
if result.Drawable ~= nil then
|
||||
setDrawableAlpha(result.Drawable, 0)
|
||||
else
|
||||
-- fallback: hide most recent judgement drawable if explicit drawable isn't provided
|
||||
local fallback = recentJudgementDrawables[#recentJudgementDrawables]
|
||||
setDrawableAlpha(fallback, 0)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
-- Example: Play different sounds based on column and hit result
|
||||
if result.Type == "Perfect" then
|
||||
@@ -132,6 +238,24 @@ end
|
||||
-- Called every frame for continuous effects
|
||||
function update()
|
||||
local currentTime = osu.GetCurrentTime()
|
||||
|
||||
-- Practical effect #1 runtime update:
|
||||
-- While hold note is active, alpha interpolates 0.5 -> 1.0 by progress.
|
||||
for key, note in pairs(holdNotes) do
|
||||
if note == nil or note.StartTime == nil or note.EndTime == nil or note.EndTime <= note.StartTime then
|
||||
holdNotes[key] = nil
|
||||
else
|
||||
if currentTime < note.StartTime then
|
||||
setDrawableAlpha(note, 0.5)
|
||||
elseif currentTime > note.EndTime then
|
||||
setDrawableAlpha(note, 1.0)
|
||||
holdNotes[key] = nil
|
||||
else
|
||||
local progress = clamp((currentTime - note.StartTime) / (note.EndTime - note.StartTime), 0, 1)
|
||||
setDrawableAlpha(note, 0.5 + 0.5 * progress)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Example: Create pulsing effects on recently hit columns
|
||||
for i = 0, #columnData do
|
||||
@@ -0,0 +1,152 @@
|
||||
# 脚本说明
|
||||
|
||||
本文档描述当前版本的皮肤 Lua 脚本能力(与代码现状一致)。
|
||||
|
||||
## 使用方式
|
||||
|
||||
1. 打开设置中的皮肤脚本入口。
|
||||
2. 使用 `Import script` / `Update script` 按钮为当前皮肤导入 `.lua`。
|
||||
3. 脚本按皮肤 ID 存储为 `skin-scripts/<SkinID>.lua`,不会写入 Realm。
|
||||
4. 切换到对应皮肤后自动加载并激活脚本。
|
||||
|
||||
## 日志约定
|
||||
|
||||
- 皮肤脚本相关日志统一使用 `[SkinScript]` 前缀。
|
||||
- 常见日志:
|
||||
- `Loaded script file`
|
||||
- `Script activated`
|
||||
- `Script activation failed`
|
||||
- `Callback error in <script>.<function>`
|
||||
|
||||
## 脚本元数据
|
||||
|
||||
```lua
|
||||
SCRIPT_DESCRIPTION = "Your script description"
|
||||
SCRIPT_VERSION = "1.0"
|
||||
SCRIPT_AUTHOR = "YourName"
|
||||
```
|
||||
|
||||
## 回调函数
|
||||
|
||||
```lua
|
||||
function onLoad()
|
||||
-- 脚本激活时调用一次
|
||||
end
|
||||
|
||||
function onComponentLoaded(component)
|
||||
-- 皮肤组件创建时调用
|
||||
end
|
||||
|
||||
function onGameEvent(eventName, data)
|
||||
-- 订阅事件触发后调用
|
||||
end
|
||||
|
||||
function onJudgement(result)
|
||||
-- 每次判定调用
|
||||
end
|
||||
|
||||
function onInputEvent(event)
|
||||
-- 输入事件调用(需订阅 InputEvent)
|
||||
end
|
||||
|
||||
function update()
|
||||
-- 每帧调用
|
||||
end
|
||||
```
|
||||
|
||||
## osu 全局 API
|
||||
|
||||
```lua
|
||||
osu.GetBeatmapTitle()
|
||||
osu.GetBeatmapArtist()
|
||||
osu.GetRulesetName()
|
||||
osu.GetCurrentTime()
|
||||
|
||||
osu.CreateComponent(componentType)
|
||||
osu.GetTexture(name)
|
||||
osu.GetSample(name)
|
||||
osu.PlaySample(name)
|
||||
|
||||
osu.SubscribeToEvent(eventName)
|
||||
osu.Log(message, level) -- debug/info/warning/error
|
||||
```
|
||||
|
||||
### CreateComponent 当前支持
|
||||
|
||||
- `Container`
|
||||
- `Sprite`
|
||||
- `Box`
|
||||
- `SpriteText`(实际返回 `OsuSpriteText`)
|
||||
|
||||
未知类型会回退为 `Container` 并打印 warning。
|
||||
|
||||
## mania 全局 API
|
||||
|
||||
仅在当前规则集为 mania 时注入 `mania`:
|
||||
|
||||
```lua
|
||||
mania.GetColumnCount()
|
||||
mania.GetNoteColumn(note)
|
||||
mania.GetColumnBinding(column)
|
||||
mania.GetColumnWidth(column)
|
||||
```
|
||||
|
||||
说明:
|
||||
- `GetColumnCount()` 基于当前谱面对象统计。
|
||||
- `GetColumnBinding()` 返回 `Column1/Column2/...` 形式的占位绑定名。
|
||||
- `GetColumnWidth()` 返回归一化宽度(`1 / 列数`)。
|
||||
|
||||
## 事件订阅与触发
|
||||
|
||||
脚本通过 `osu.SubscribeToEvent(name)` 订阅事件。
|
||||
|
||||
当前已触发事件:
|
||||
|
||||
- `HitEvent`
|
||||
- 在判定发生时触发。
|
||||
- `data` 常见字段:`Type`, `IsHit`, `TimeOffset`, `ColumnIndex`。
|
||||
- `ManiaColumnHit`
|
||||
- mania 判定且可解析列号时触发。
|
||||
- 字段:`ColumnIndex`。
|
||||
- `ManiaHoldActivated`
|
||||
- mania 下识别到 hold head 判定时触发。
|
||||
- 字段:`ColumnIndex`。
|
||||
- `ManiaHoldReleased`
|
||||
- mania 下识别到 hold tail 判定时触发。
|
||||
- 字段:`ColumnIndex`。
|
||||
- `InputEvent`
|
||||
- 键盘输入时触发(按下/抬起)。
|
||||
- 字段:`Key`, `State`(`Down`/`Up`)。
|
||||
|
||||
## 对象字段(Lua 侧可见)
|
||||
|
||||
### component(onComponentLoaded)
|
||||
|
||||
- `component.Type.Name`
|
||||
- `component.Alpha`(可读写)
|
||||
- `component.Colour = { R, G, B, A }`(可写)
|
||||
- `component.Column`(若该组件存在此属性)
|
||||
- `component.StartTime` / `component.EndTime`(若存在)
|
||||
|
||||
### result(onJudgement)
|
||||
|
||||
- `result.Type`(例如 `Perfect`, `Great`, `Good`, `Ok`, `Meh`, `Miss`)
|
||||
- `result.HitObject.Column`(若存在)
|
||||
- `result.HitObject.StartTime` / `result.HitObject.EndTime`(若存在)
|
||||
|
||||
## 示例脚本
|
||||
|
||||
- [ExampleSkinScript.lua](ExampleSkinScript.lua)
|
||||
- [ExampleManiaSkinScript.lua](ExampleManiaSkinScript.lua)
|
||||
|
||||
## 常见问题
|
||||
|
||||
1. **日志显示加载成功但没效果**
|
||||
- 先确认回调是否匹配:有些效果依赖 `onJudgement` 或订阅事件。
|
||||
- 检查是否调用了 `osu.SubscribeToEvent()`。
|
||||
|
||||
2. **脚本描述乱码**
|
||||
- 请将 `.lua` 文件保存为 UTF-8(建议无 BOM)。
|
||||
|
||||
3. **回调报错无法转换 CLR 类型**
|
||||
- 当前已通过 Lua 代理对象传参;若仍报字段不存在,通常是目标对象本身无该属性。
|
||||
@@ -0,0 +1,8 @@
|
||||
using osu.Framework.Graphics;
|
||||
|
||||
namespace osu.Game.LAsEzExtensions.Skinning
|
||||
{
|
||||
public partial class SkinScriptingOverlayRegistration : Component
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Localisation;
|
||||
using osu.Game.Overlays.Settings;
|
||||
|
||||
namespace osu.Game.LAsEzExtensions.Skinning
|
||||
{
|
||||
public partial class SkinScriptingSettingsSection : SettingsSubsection
|
||||
{
|
||||
protected override LocalisableString Header => "Skin Scripting";
|
||||
}
|
||||
}
|
||||
@@ -47,8 +47,8 @@ using osu.Game.Input.Bindings;
|
||||
using osu.Game.IO;
|
||||
using osu.Game.LAsEzExtensions;
|
||||
using osu.Game.LAsEzExtensions.Analysis;
|
||||
using osu.Game.LAsEzExtensions.Background;
|
||||
using osu.Game.LAsEzExtensions.Configuration;
|
||||
using osu.Game.LAsEzExtensions.Skinning;
|
||||
using osu.Game.Localisation;
|
||||
using osu.Game.Online;
|
||||
using osu.Game.Online.API;
|
||||
@@ -164,6 +164,8 @@ namespace osu.Game
|
||||
|
||||
protected SkinManager SkinManager { get; private set; }
|
||||
|
||||
protected SkinScriptManager SkinScriptManager { get; private set; }
|
||||
|
||||
protected RealmRulesetStore RulesetStore { get; private set; }
|
||||
|
||||
protected RealmKeyBindingStore KeyBindingStore { get; private set; }
|
||||
@@ -398,6 +400,9 @@ namespace osu.Game
|
||||
dependencies.CacheAs<IBindable<WorkingBeatmap>>(Beatmap);
|
||||
dependencies.CacheAs(Beatmap);
|
||||
|
||||
dependencies.Cache(SkinScriptManager = new SkinScriptManager());
|
||||
base.Content.Add(SkinScriptManager);
|
||||
|
||||
dependencies.Cache(LeaderboardManager = new LeaderboardManager());
|
||||
base.Content.Add(LeaderboardManager);
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using JetBrains.Annotations;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
@@ -17,10 +19,12 @@ using osu.Framework.Graphics.Sprites;
|
||||
using osu.Framework.Graphics.UserInterface;
|
||||
using osu.Framework.Localisation;
|
||||
using osu.Framework.Logging;
|
||||
using osu.Framework.Platform;
|
||||
using osu.Game.Database;
|
||||
using osu.Game.Graphics;
|
||||
using osu.Game.Graphics.UserInterface;
|
||||
using osu.Game.Graphics.UserInterfaceV2;
|
||||
using osu.Game.LAsEzExtensions.Skinning;
|
||||
using osu.Game.Localisation;
|
||||
using osu.Game.Overlays.SkinEditor;
|
||||
using osu.Game.Screens.Select;
|
||||
@@ -85,6 +89,7 @@ namespace osu.Game.Overlays.Settings.Sections
|
||||
Text = SkinSettingsStrings.SkinLayoutEditor,
|
||||
Action = () => skinEditor?.ToggleVisibility(),
|
||||
},
|
||||
new ImportOrUpdateScriptButton(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -294,5 +299,124 @@ namespace osu.Game.Overlays.Settings.Sections
|
||||
PopOut();
|
||||
}
|
||||
}
|
||||
|
||||
public partial class ImportOrUpdateScriptButton : SettingsButtonV2, IHasPopover
|
||||
{
|
||||
private const string script_storage_directory = "skin-scripts";
|
||||
|
||||
[Resolved]
|
||||
private SkinManager skins { get; set; }
|
||||
|
||||
[Resolved]
|
||||
private Storage storage { get; set; }
|
||||
|
||||
[Resolved(CanBeNull = true)]
|
||||
private IDialogOverlay dialogOverlay { get; set; }
|
||||
|
||||
private Bindable<Skin> currentSkin = null!;
|
||||
private Bindable<string> lastImportDirectory = null!;
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load()
|
||||
{
|
||||
var scriptingConfig = new SkinScriptingConfig(storage);
|
||||
lastImportDirectory = scriptingConfig.GetBindable<string>(SkinScriptingSetting.LastImportDirectory);
|
||||
|
||||
Action = this.ShowPopover;
|
||||
}
|
||||
|
||||
protected override void LoadComplete()
|
||||
{
|
||||
base.LoadComplete();
|
||||
|
||||
currentSkin = skins.CurrentSkin.GetBoundCopy();
|
||||
currentSkin.BindValueChanged(_ => updateState());
|
||||
currentSkin.BindDisabledChanged(_ => updateState(), true);
|
||||
}
|
||||
|
||||
private void updateState()
|
||||
{
|
||||
if (currentSkin.Disabled)
|
||||
{
|
||||
Enabled.Value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
bool hasScript = File.Exists(getScriptPath());
|
||||
Text = hasScript ? "Update script" : "Import script";
|
||||
Enabled.Value = true;
|
||||
}
|
||||
|
||||
public Popover GetPopover()
|
||||
{
|
||||
string? chooserPath = string.IsNullOrEmpty(lastImportDirectory.Value) ? null : lastImportDirectory.Value;
|
||||
return new LuaScriptFileChooserPopover(onLuaFileSelected, chooserPath);
|
||||
}
|
||||
|
||||
private void onLuaFileSelected(FileInfo file)
|
||||
{
|
||||
Schedule(() => importOrUpdateScript(file));
|
||||
}
|
||||
|
||||
private void importOrUpdateScript(FileInfo selectedFile)
|
||||
{
|
||||
try
|
||||
{
|
||||
string sourcePath = selectedFile.FullName;
|
||||
|
||||
string destinationPath = getScriptPath();
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
|
||||
File.Copy(sourcePath, destinationPath, true);
|
||||
|
||||
if (selectedFile.DirectoryName != null)
|
||||
lastImportDirectory.Value = selectedFile.DirectoryName;
|
||||
|
||||
skins.CurrentSkinInfo.TriggerChange();
|
||||
updateState();
|
||||
|
||||
Logger.Log($"[SkinScript] Script import/update succeeded: {Path.GetFileName(sourcePath)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Log($"[SkinScript] Script import/update failed: {ex}", level: LogLevel.Error);
|
||||
dialogOverlay?.Push(new FileImportFaultDialog(ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private string getScriptPath()
|
||||
{
|
||||
Guid skinId = skins.CurrentSkinInfo.Value.ID;
|
||||
var scriptStorage = storage.GetStorageForDirectory(script_storage_directory);
|
||||
return scriptStorage.GetFullPath($"{skinId}.lua");
|
||||
}
|
||||
|
||||
private partial class LuaScriptFileChooserPopover : FormFileSelector.FileChooserPopover
|
||||
{
|
||||
private readonly Action<FileInfo> onFileSelected;
|
||||
|
||||
private bool fileHandled;
|
||||
|
||||
public LuaScriptFileChooserPopover(Action<FileInfo> onFileSelected, string? chooserPath)
|
||||
: base(new[] { ".lua" }, new Bindable<FileInfo>(), chooserPath)
|
||||
{
|
||||
this.onFileSelected = onFileSelected;
|
||||
}
|
||||
|
||||
protected override void LoadComplete()
|
||||
{
|
||||
base.LoadComplete();
|
||||
|
||||
FileSelector.CurrentFile.BindValueChanged(file =>
|
||||
{
|
||||
if (fileHandled || file.NewValue == null)
|
||||
return;
|
||||
|
||||
fileHandled = true;
|
||||
onFileSelected(file.NewValue);
|
||||
Hide();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ using osu.Game.Scoring;
|
||||
using osu.Game.Scoring.Legacy;
|
||||
using osu.Game.Screens.Ranking;
|
||||
using osu.Game.Skinning;
|
||||
using osu.Game.LAsEzExtensions.Skinning;
|
||||
using osu.Game.LAsEzExtensions.Configuration;
|
||||
using osu.Game.Users;
|
||||
using osu.Game.Utils;
|
||||
@@ -141,6 +142,9 @@ namespace osu.Game.Screens.Play
|
||||
[Resolved]
|
||||
private OsuGameBase game { get; set; }
|
||||
|
||||
[Resolved(CanBeNull = true)]
|
||||
private SkinScriptManager skinScriptManager { get; set; }
|
||||
|
||||
public GameplayState GameplayState { get; private set; }
|
||||
|
||||
private Ruleset ruleset;
|
||||
@@ -410,6 +414,7 @@ namespace osu.Game.Screens.Play
|
||||
HealthProcessor.ApplyResult(r);
|
||||
ScoreProcessor.ApplyResult(r);
|
||||
GameplayState.ApplyResult(r);
|
||||
skinScriptManager?.NotifyJudgement(r);
|
||||
};
|
||||
|
||||
DrawableRuleset.RevertResult += r =>
|
||||
|
||||
@@ -76,6 +76,11 @@ namespace osu.Game.Skinning
|
||||
}
|
||||
}
|
||||
|
||||
internal override IEnumerable<string> GetScriptFiles()
|
||||
=> SkinInfo.PerformRead(s => s.Files.Where(f => f.Filename.EndsWith(".lua", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(f => f.Filename)
|
||||
.ToArray());
|
||||
|
||||
[SuppressMessage("ReSharper", "RedundantAssignment")] // for `wasHit` assignments used in `finally` debug logic
|
||||
public override IBindable<TValue>? GetConfig<TLookup, TValue>(TLookup lookup)
|
||||
{
|
||||
|
||||
@@ -166,6 +166,10 @@ namespace osu.Game.Skinning
|
||||
Samples = samples;
|
||||
}
|
||||
|
||||
internal virtual IEnumerable<string> GetScriptFiles() => Array.Empty<string>();
|
||||
|
||||
internal virtual Stream? GetFileStream(string filename) => store.GetStream(filename);
|
||||
|
||||
protected virtual IResourceStore<TextureUpload> CreateTextureLoaderStore(IStorageResourceProvider resources, IResourceStore<byte[]> storage)
|
||||
=> new MaxDimensionLimitedTextureLoaderStore(resources.CreateTextureLoaderStore(storage));
|
||||
|
||||
|
||||
@@ -55,9 +55,11 @@ namespace osu.Game.Skinning
|
||||
/// <returns></returns>
|
||||
public override async Task<Live<SkinInfo>?> ImportAsUpdate(ProgressNotification notification, ImportTask task, SkinInfo original)
|
||||
{
|
||||
Guid originalId = original.ID;
|
||||
|
||||
return await Realm.WriteAsync<Live<SkinInfo>?>(r =>
|
||||
{
|
||||
var skinInfo = r.Find<SkinInfo>(original.ID)!;
|
||||
var skinInfo = r.Find<SkinInfo>(originalId)!;
|
||||
skinInfo.Files.Clear();
|
||||
|
||||
string[] filesInMountedDirectory = Directory.EnumerateFiles(task.Path, "*.*", SearchOption.AllDirectories).Select(f => Path.GetRelativePath(task.Path, f)).ToArray();
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
// 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.LAsEzExtensions.Skinning;
|
||||
using osuTK;
|
||||
|
||||
namespace osu.Game.Skinning
|
||||
@@ -35,6 +37,9 @@ namespace osu.Game.Skinning
|
||||
|
||||
private readonly ConfineMode confineMode;
|
||||
|
||||
[Resolved(CanBeNull = true)]
|
||||
private SkinScriptManager? skinScriptManager { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Create a new skinnable drawable.
|
||||
/// </summary>
|
||||
@@ -99,6 +104,13 @@ namespace osu.Game.Skinning
|
||||
InternalChild = Drawable;
|
||||
}
|
||||
|
||||
protected override void LoadComplete()
|
||||
{
|
||||
base.LoadComplete();
|
||||
|
||||
skinScriptManager?.NotifyComponentLoaded(Drawable);
|
||||
}
|
||||
|
||||
protected override void Update()
|
||||
{
|
||||
base.Update();
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<PackageReference Include="Microsoft.Data.Sqlite.Core" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.Toolkit.HighPerformance" Version="7.1.2" />
|
||||
<PackageReference Include="MoonSharp" Version="2.0.0" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="ppy.LocalisationAnalyser" Version="2025.1208.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
||||
Reference in New Issue
Block a user