[Lua]皮肤脚本支持

This commit is contained in:
LA
2026-02-20 20:20:34 +08:00
parent 3c6f7af13a
commit daa381934e
38 changed files with 1389 additions and 3283 deletions

View File

@@ -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块中确保脚本错误不会影响游戏稳定性。

View File

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

View File

@@ -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. 确保脚本没有语法错误

View File

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

View File

@@ -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 = "确定",
}
};
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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中集成无需额外操作
}
}
}

View File

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

View File

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

View File

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

View 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(),
};
}
}
}

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

View File

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

View 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();
}
}
}

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

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

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

View File

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

View File

@@ -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 侧可见)
### componentonComponentLoaded
- `component.Type.Name`
- `component.Alpha`(可读写)
- `component.Colour = { R, G, B, A }`(可写)
- `component.Column`(若该组件存在此属性)
- `component.StartTime` / `component.EndTime`(若存在)
### resultonJudgement
- `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 代理对象传参;若仍报字段不存在,通常是目标对象本身无该属性。

View File

@@ -0,0 +1,8 @@
using osu.Framework.Graphics;
namespace osu.Game.LAsEzExtensions.Skinning
{
public partial class SkinScriptingOverlayRegistration : Component
{
}
}

View File

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

View File

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

View File

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

View File

@@ -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 =>

View File

@@ -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)
{

View File

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

View File

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

View File

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

View File

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