-
Notifications
You must be signed in to change notification settings - Fork 143
feat(skins): 添加离线自定义皮肤支持 #3518
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
sysfox
wants to merge
24
commits into
PCL-Community:dev
Choose a base branch
from
sysfox:skins
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
feat(skins): 添加离线自定义皮肤支持 #3518
Changes from 22 commits
Commits
Show all changes
24 commits
Select commit
Hold shift + click to select a range
fc7a0a7
feat(skin): 新增离线自定义皮肤核心库(PCL.Core)
sysfox 2a183b5
feat(skin): 离线自定义皮肤加载、启动注入与 UI 集成
sysfox 4009319
refactor(skin): 根据独立审阅意见精简代码
sysfox b062b1b
test(skin): 新增皮肤核心算法单测(14 项)
sysfox 8d0b332
fix(skin): 修复离线皮肤不生效与头像加载失败
sysfox 2d9d853
fix(skin): 离线皮肤服务器存活到游戏退出而非启动流程结束
sysfox c57b9d3
fix(skin): 重写皮肤设置弹窗为稳定两栏 Grid 布局
sysfox c7b63b5
fix(skin): 移除已删除预览元素的代码引用,修复应用无法启动
sysfox 2717347
chore(skin): 移除无用注释与死代码
sysfox ee09187
refactor(skin): 精简皮肤核心库,消除死代码与手写轮子
sysfox 01cd3e0
fix(skin): 修复多开时离线皮肤服务器被误关的问题
sysfox 8213d0d
refactor(skin): 移除 CSL 贴图下载无效的缓存分支
sysfox 6da5f66
test(skin): 修正测试中冗余的嵌套 unchecked
sysfox 2150c4b
feat(skin): 添加离线皮肤设置页面并重构相关逻辑
sysfox c719d80
ui(skin): 本地文件选择按钮改为文件地址右侧的图标按钮
sysfox 6d19d9b
ui(skin): 皮肤设置页确定与取消按钮居中排列
sysfox 4ab5426
ui(skin): LittleSkin 面板"打开官网"文案改为"打开其官网"
sysfox 015d661
ui(skin): 皮肤设置页文字按钮高度统一为 35,避免挤压文字
sysfox f08a2f3
ui(skin): 文件夹选择按钮包裹可见的圆角正方形背景框
sysfox 67a8fb5
ui(skin): 修改 LittleSkin 面板"打开其官网"文案为"打开官网"
sysfox af1a9c4
ui(skin): 重构 PageLoginOfflineSkin 界面布局,优化皮肤类型和本地文件选项显示
sysfox fc4aec2
ui(skin): 将底部按钮从 Grid 改为 StackPanel,优化按钮布局和间距
sysfox 36ef7a2
ui(skin): 调整边框圆角为2,优化界面元素的视觉效果
sysfox 6ff5e73
fix: update PCL.Core/App/Localization/Languages/en-US.xaml
sysfox File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| using System; | ||
| using System.Linq; | ||
| using System.Net.Http; | ||
| using System.Threading.Tasks; | ||
| using Microsoft.VisualStudio.TestTools.UnitTesting; | ||
| using PCL.Core.Minecraft.Skin; | ||
|
|
||
| namespace PCL.Core.Test.Minecraft; | ||
|
|
||
| [TestClass] | ||
| public class OfflineSkinServerTest | ||
| { | ||
| /// <summary> | ||
| /// 回归测试:HttpServer 构造函数必须能为 IPv6 回环地址生成合法的 URI 前缀。 | ||
| /// 此前直接拼接 "http://::1:port/" 会在 AddPrefix 时抛 | ||
| /// "Only Uri prefixes with a valid hostname are supported",导致离线皮肤注入静默失败。 | ||
| /// </summary> | ||
| [TestMethod] | ||
| public void Ctor_Ipv6Loopback_DoesNotThrow() | ||
| { | ||
| using var server = new OfflineSkinServer(); | ||
| Assert.IsTrue(server.Port > 0); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// 启动后元数据路由可访问,且签名公钥非空。 | ||
| /// </summary> | ||
| [TestMethod] | ||
| public async Task Start_MetadataRoute_Reachable() | ||
| { | ||
| using var server = new OfflineSkinServer(); | ||
| server.AddCharacter(Guid.NewGuid(), "test", null); | ||
| server.Start(); | ||
|
|
||
| using var client = new HttpClient(); | ||
| using var response = await client.GetAsync($"http://127.0.0.1:{server.Port}/"); | ||
| response.EnsureSuccessStatusCode(); | ||
|
|
||
| var body = await response.Content.ReadAsStringAsync(); | ||
| StringAssert.Contains(body, "signaturePublickey"); | ||
| StringAssert.Contains(body, "127.0.0.1"); | ||
| StringAssert.Contains(body, "PCL CE"); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// 注册角色后,hasJoined 能返回带 textures 签名的完整档案。 | ||
| /// </summary> | ||
| [TestMethod] | ||
| public async Task Start_HasJoined_ReturnsProfile() | ||
| { | ||
| using var server = new OfflineSkinServer(); | ||
| var uuid = Guid.NewGuid(); | ||
| server.AddCharacter(uuid, "Tester", null); | ||
| server.Start(); | ||
|
|
||
| using var client = new HttpClient(); | ||
| using var response = await client.GetAsync( | ||
| $"http://127.0.0.1:{server.Port}/sessionserver/session/minecraft/hasJoined?username=Tester"); | ||
| response.EnsureSuccessStatusCode(); | ||
|
|
||
| var body = await response.Content.ReadAsStringAsync(); | ||
| StringAssert.Contains(body, uuid.ToString("N")); | ||
| StringAssert.Contains(body, "textures"); | ||
| StringAssert.Contains(body, "signature"); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// 未知玩家 hasJoined 返回 404。 | ||
| /// </summary> | ||
| [TestMethod] | ||
| public async Task Start_HasJoined_UnknownPlayer_Returns404() | ||
| { | ||
| using var server = new OfflineSkinServer(); | ||
| server.AddCharacter(Guid.NewGuid(), "Tester", null); | ||
| server.Start(); | ||
|
|
||
| using var client = new HttpClient(); | ||
| using var response = await client.GetAsync( | ||
| $"http://127.0.0.1:{server.Port}/sessionserver/session/minecraft/hasJoined?username=Nobody"); | ||
| Assert.AreEqual(System.Net.HttpStatusCode.NotFound, response.StatusCode); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,201 @@ | ||
| using System; | ||
| using System.Drawing; | ||
| using System.Drawing.Imaging; | ||
| using System.Text.Json.Nodes; | ||
| using Microsoft.VisualStudio.TestTools.UnitTesting; | ||
| using PCL.Core.Minecraft.Skin; | ||
| using SkinRecord = PCL.Core.Minecraft.Skin.Skin; | ||
|
|
||
| namespace PCL.Core.Test.Minecraft; | ||
|
|
||
| [TestClass] | ||
| public class SkinTest | ||
| { | ||
| /// <summary> | ||
| /// 构造纯色位图。 | ||
| /// </summary> | ||
| private static Bitmap CreateBitmap(int width, int height, int argb) | ||
| { | ||
| var color = Color.FromArgb(argb); | ||
| var bmp = new Bitmap(width, height, PixelFormat.Format32bppArgb); | ||
| for (var y = 0; y < height; y++) | ||
| for (var x = 0; x < width; x++) | ||
| bmp.SetPixel(x, y, color); | ||
| return bmp; | ||
| } | ||
|
|
||
| // ---- SkinTexture.ComputeHash ---- | ||
|
|
||
| [TestMethod] | ||
| public void ComputeHash_MatchesReference() | ||
| { | ||
| // 1x1 纯色位图:alpha=0xFF, r=0x12, g=0x34, b=0x56 | ||
| var bmp = new Bitmap(1, 1, PixelFormat.Format32bppArgb); | ||
| bmp.SetPixel(0, 0, Color.FromArgb(0xFF, 0x12, 0x34, 0x56)); | ||
|
|
||
| // 参考实现:宽(4B BE) + 高(4B BE) + a,r,g,b 各 1B | ||
| var reference = new byte[12]; | ||
| reference[0] = 0; reference[1] = 0; reference[2] = 0; reference[3] = 1; // 宽=1 | ||
| reference[4] = 0; reference[5] = 0; reference[6] = 0; reference[7] = 1; // 高=1 | ||
| reference[8] = 0xFF; reference[9] = 0x12; reference[10] = 0x34; reference[11] = 0x56; | ||
| using var sha = System.Security.Cryptography.SHA256.Create(); | ||
| var expected = Convert.ToHexString(sha.ComputeHash(reference)).ToLowerInvariant(); | ||
|
|
||
| Assert.AreEqual(expected, SkinTexture.ComputeHash(bmp)); | ||
| } | ||
|
|
||
| [TestMethod] | ||
| public void ComputeHash_TransparentPixel_RgbNormalizedToZero() | ||
| { | ||
| // 两个透明像素(RGB 不同)的哈希必须一致:alpha==0 时 RGB 清零 | ||
| var bmpA = new Bitmap(1, 1, PixelFormat.Format32bppArgb); | ||
| bmpA.SetPixel(0, 0, Color.FromArgb(0, 0xFF, 0, 0)); // 透明红色 | ||
| var bmpB = new Bitmap(1, 1, PixelFormat.Format32bppArgb); | ||
| bmpB.SetPixel(0, 0, Color.FromArgb(0, 0, 0, 0xFF)); // 透明蓝色 | ||
|
|
||
| Assert.AreEqual(SkinTexture.ComputeHash(bmpA), SkinTexture.ComputeHash(bmpB)); | ||
| } | ||
|
|
||
| [TestMethod] | ||
| public void Load_CachesByHash() | ||
| { | ||
| var bmp = CreateBitmap(64, 64, unchecked((int)0xFF123456)); | ||
| var tex1 = SkinTexture.Load(bmp); | ||
| var tex2 = SkinTexture.Load(CreateBitmap(64, 64, unchecked((int)0xFF123456))); | ||
|
|
||
| Assert.AreSame(tex1, tex2); // 相同内容命中缓存,返回同一实例 | ||
| Assert.AreEqual(tex1.Hash, tex2.Hash); | ||
| Assert.IsNotNull(SkinTexture.Get(tex1.Hash)); | ||
| Assert.IsNull(SkinTexture.Get("ffff")); // 未命中返回 null | ||
| } | ||
|
|
||
| // ---- NormalizedSkin ---- | ||
|
|
||
| [TestMethod] | ||
| public void NormalizedSkin_InvalidSize_Throws() | ||
| { | ||
| Assert.ThrowsExactly<InvalidSkinException>(() => new NormalizedSkin(CreateBitmap(32, 32, unchecked((int)0xFF000000)))); | ||
| Assert.ThrowsExactly<InvalidSkinException>(() => new NormalizedSkin(CreateBitmap(64, 48, unchecked((int)0xFF000000)))); | ||
| } | ||
|
|
||
| [TestMethod] | ||
| public void NormalizedSkin_NewFormat_64x64_NotOld() | ||
| { | ||
| var skin = new NormalizedSkin(CreateBitmap(64, 64, unchecked((int)0xFF000000))); | ||
| Assert.IsFalse(skin.IsOldFormat); | ||
| Assert.AreEqual(1, skin.Scale); | ||
| Assert.AreEqual(64, skin.NormalizedTexture.Width); | ||
| Assert.AreEqual(64, skin.NormalizedTexture.Height); | ||
| } | ||
|
|
||
| [TestMethod] | ||
| public void NormalizedSkin_OldFormat_64x32_ConvertedTo64x64() | ||
| { | ||
| var skin = new NormalizedSkin(CreateBitmap(64, 32, unchecked((int)0xFF000000))); | ||
| Assert.IsTrue(skin.IsOldFormat); | ||
| Assert.AreEqual(1, skin.Scale); | ||
| // 旧格式转换后目标应为 64x64 | ||
| Assert.AreEqual(64, skin.NormalizedTexture.Width); | ||
| Assert.AreEqual(64, skin.NormalizedTexture.Height); | ||
| } | ||
|
|
||
| [TestMethod] | ||
| public void NormalizedSkin_IsSlim_TransparentRightArm_ReturnsTrue() | ||
| { | ||
| // 64x64 皮肤,右臂区域 (50,16)2x4 放一个透明像素 → slim | ||
| var bmp = CreateBitmap(64, 64, unchecked((int)0xFF888888)); | ||
| for (var y = 16; y < 20; y++) | ||
| for (var x = 50; x < 52; x++) | ||
| bmp.SetPixel(x, y, Color.FromArgb(0, 0, 0, 0)); | ||
|
|
||
| Assert.IsTrue(new NormalizedSkin(bmp).IsSlim()); | ||
| } | ||
|
|
||
| [TestMethod] | ||
| public void NormalizedSkin_IsSlim_SolidSkin_ReturnsFalse() | ||
| { | ||
| // 全不透明且非黑的皮肤 → 非 slim | ||
| var bmp = CreateBitmap(64, 64, unchecked((int)0xFF888888)); | ||
| Assert.IsFalse(new NormalizedSkin(bmp).IsSlim()); | ||
| } | ||
|
|
||
| [TestMethod] | ||
| public void NormalizedSkin_IsSlim_AllBlackRightArm_ReturnsTrue() | ||
| { | ||
| // 右臂四区域全黑 → slim(HMCL 的 isAreaBlack 分支) | ||
| var bmp = CreateBitmap(64, 64, unchecked((int)0xFF888888)); | ||
| for (var y = 16; y < 20; y++) | ||
| for (var x = 50; x < 52; x++) | ||
| bmp.SetPixel(x, y, Color.FromArgb(0xFF, 0, 0, 0)); | ||
| for (var y = 20; y < 32; y++) | ||
| for (var x = 54; x < 56; x++) | ||
| bmp.SetPixel(x, y, Color.FromArgb(0xFF, 0, 0, 0)); | ||
| for (var y = 48; y < 52; y++) | ||
| for (var x = 42; x < 44; x++) | ||
| bmp.SetPixel(x, y, Color.FromArgb(0xFF, 0, 0, 0)); | ||
| for (var y = 52; y < 64; y++) | ||
| for (var x = 46; x < 48; x++) | ||
| bmp.SetPixel(x, y, Color.FromArgb(0xFF, 0, 0, 0)); | ||
|
|
||
| Assert.IsTrue(new NormalizedSkin(bmp).IsSlim()); | ||
| } | ||
|
|
||
| // ---- Skin 序列化 ---- | ||
|
|
||
| [TestMethod] | ||
| public void Skin_WriteRead_RoundTrip() | ||
| { | ||
| var skin = new SkinRecord(SkinType.LocalFile, null, TextureModel.Slim, @"C:\skin.png", @"C:\cape.png"); | ||
| var storage = new JsonObject(); | ||
| skin.WriteStorage(storage); | ||
|
|
||
| var restored = SkinRecord.FromStorage(storage); | ||
| Assert.IsNotNull(restored); | ||
| Assert.AreEqual(SkinType.LocalFile, restored!.Type); | ||
| Assert.AreEqual(TextureModel.Slim, restored.Model); | ||
| Assert.AreEqual(@"C:\skin.png", restored.LocalSkinPath); | ||
| Assert.AreEqual(@"C:\cape.png", restored.LocalCapePath); | ||
| Assert.AreEqual("local_file", (string?)storage["type"]); | ||
| Assert.AreEqual("slim", (string?)storage["textureModel"]); | ||
| } | ||
|
|
||
| [TestMethod] | ||
| public void Skin_FromStorage_UnknownType_ReturnsNull() | ||
| { | ||
| var storage = new JsonObject { ["type"] = "unknown_type" }; | ||
| Assert.IsNull(SkinRecord.FromStorage(storage)); | ||
| } | ||
|
|
||
| [TestMethod] | ||
| public void Skin_FromStorage_MissingSkin_ReturnsNull() | ||
| { | ||
| Assert.IsNull(SkinRecord.FromStorage(new JsonObject())); | ||
| } | ||
|
|
||
| [TestMethod] | ||
| public void Skin_FromStorage_NonSlimModelDefaultsToWide() | ||
| { | ||
| var storage = new JsonObject | ||
| { | ||
| ["type"] = "local_file", | ||
| ["textureModel"] = "wide", | ||
| ["localSkinPath"] = @"C:\skin.png" | ||
| }; | ||
| var skin = SkinRecord.FromStorage(storage); | ||
| Assert.IsNotNull(skin); | ||
| Assert.AreEqual(TextureModel.Wide, skin!.Model); | ||
| } | ||
|
|
||
| [TestMethod] | ||
| public void Skin_FromStorage_SnakeCaseTypes() | ||
| { | ||
| // 验证所有枚举名的 snake_case 往返 | ||
| foreach (var type in Enum.GetValues<SkinType>()) | ||
| { | ||
| var skin = new SkinRecord(type, null, TextureModel.Wide, null, null); | ||
| var storage = new JsonObject(); | ||
| skin.WriteStorage(storage); | ||
| Assert.AreEqual(type, SkinRecord.FromStorage(storage)!.Type, $"往返失败:{type}"); | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.