Project-Shoko result皮肤代码阅读

未分类
8.6k 词

前言

这是一个阅读Project-Shokoresult部分代码来理解如何编写皮肤和Beatoraja的皮肤系统的流水账,内容可能未整理,随时间修改的可能性较大。

Beatoraja Wiki

初始化

首先一个皮肤肯定要定义自己,然后被读取,解析,执行成一个java object。根据wiki的指示可以知道一个皮肤文件从一个.luaskin文件开始,它会作为一个lua文件被执行,其返回值就是这个皮肤的定义:

1
2
3
4
5
6
7
-- result/result.luaskin
local t = require("resultmain")
if skin_config then
return t.main()
else
return t.header
end

显然skin_config是由beatoraja通过某种方式传下来的信息,目前不清楚这个分支有什么作用。让我们把这里的事情展开一下:

  • 首先Project-Shoko有多个文件夹,分别对应不同的beatoraja的场景,例如selectresult
  • 这样的场景文件夹下有一个.luaskin文件定义了这个场景的皮肤文件
  • 执行这个.luaskin文件的返回值会被转换成java中的一个object实例。

wiki给的例子(不完整)是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- example.luaskin
return {
type = 0, -- skin type: 7keys
name = "Skin Name",
w = 1280,
h = 720,
playstart = 1000,
scene = 3600000,
input = 500,
close = 1500,
fadeout = 1000,
property = {},
filepath = {},
offset = {}
}

他在java会被转换成一个bms.player.beatoraja.skin.SkinHeader的实例,不难发现字段都是对应的。

不难猜到beatoarja里对应读取lua皮肤的文件是bms.player.beatoraja.skin.lua.LuaSkinLoader,打开就能发现bms.player.beatoraja.skin.lua.LuaSkinLoader#load这个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public Skin load(Path p, SkinType type, SkinConfig.Property property) {
Skin skin = null;
SkinHeader header = loadHeader(p);
if(header == null) {
return null;
}
header.setSkinConfigProperty(property);

try {
filemap = new ObjectMap<>();
for(SkinHeader.CustomFile customFile : header.getCustomFiles()) {
if(customFile.getSelectedFilename() != null) {
filemap.put(customFile.path, customFile.getSelectedFilename());
}
}

lua.exportSkinProperty(header, property, (String path) -> {
return getPath(p.getParent().toString() + "/" + path, filemap).getPath();
});
LuaValue value = lua.execFile(p);
sk = fromLuaValue(JsonSkin.Skin.class, value);
skin = loadJsonSkin(header, sk, type, property, p);
} catch (Throwable e) {
e.printStackTrace();
}
return skin;
}

这个方法的行为应该已经非常明确了:

  • 首先我们不用关心这个方法的调用路径,那不重要
  • 参数中p很明确是文件路径,type是对应的场景(play, select, result等),而property可以根据header.setSkinConfigProperty(property)猜出来这是已配置的信息,注入到读取到的皮肤里
  • 返回值是一个Skin实例,很明显就是一个皮肤的完整定义

image-20250915092841994

读取到header后,beatoraja会继续:

  • 读取这个皮肤的自定义的文件列表,这个事情是定义在header.filepath里的:

    • local filepath = {
          {name = "Default Stagefile",			path = "customize/stagefile/*.png"},
          {name = "Result Background (CLEAR AAA)",	path = "customize/bg/aaa/*.png"},
          {name = "Result Background (CLEAR AA)",		path = "customize/bg/aa/*.png"},
          {name = "Result Background (CLEAR A)",		path = "customize/bg/a/*.png"},
          {name = "Result Background (CLEAR)",		path = "customize/bg/clear/*.png"},
          {name = "Result Background (FAILED)",		path = "customize/bg/failed/*.png"},
      }
      
      local header = {
          -- 省略
          filepath = filepath
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62

      - 这个步骤只需要提取其中的`name/path`作为k/v即可,不需要在意

      - `lua.exportSkinProperty`这个方法最终将一个`table`导出到了一个全局变量`skin_config`里,通过检查其内容可以发现这是配置的信息:

      - ![image-20250915094049905](/Users/xiangyang/Documents/hexo-blog/public/images/image-20250915094049905.png)

      接着beatoraja会再解析执行一次这个lua文件,并将返回值处理成一个`Skin`实例。

      那么现在就可以理解为什么`.luaskin`会写成这个样子了,这和读取一个纯文本的手法相同:

      - 首先读取这个文件的前若干个字节,检查是否是某个特定值,例如java的class文件的前几个字节是`0xCAFFBABE`,通过这个方式可以知道这个文件确实是一个java的class文件。对应到皮肤这里就是先解释执行第一次,获得这个皮肤的头信息,即先要知道这个皮肤的“轮廓”,大概长什么样,有哪些文件要引入,有哪些配置
      - 然后执行第二次读取,将配置和内容混合在一起得到完整的皮肤信息,并转换到java的实例里

      > 这样的设计是一个很常见的方式,这里再举一个例子来说明这样做的原因:
      >
      > Project-Shoko的result有一个配置项`Play Side`,可取值是`1P/2P`。但是beatoraja肯定不能提前假定皮肤有哪些可配置的信息,这样的话自由度太低了,而且随着beatoraja的更新很多皮肤都要联动修改,所以皮肤的配置项都是由皮肤自己定义的。相对的,beatoraja也就不知道皮肤有哪些可能的配置了,因此它需要先问一下皮肤有哪些配置,这就是第一次读取获得的header信息。第二次就是把用户的配置再传给皮肤,这个例子里就是告知皮肤用户`Play Side`这个配置的值是`1P`。
      >
      > 整个手法的好处就是把设计配置项的权利交给了皮肤这边。

      现在我们大概了解了皮肤的初始化步骤,这里做个总结:

      - 首先皮肤这边需要定义一个`.luaskin`文件,这个文件会被beatoraja当作`lua`文件解释执行
      - 这个文件的返回值根据`skin_config`这个全局变量是否存在来区分是第一次预读取头信息还是第二次读取完整皮肤信息
      - 第一次读取时,返回的是`t.header`,内容很简单,就是一个皮肤的基本信息
      - 第二次读取时,beatoraja会将用户的配置,文件路径等信息导出到全局变量`skin_config`中,此时返回的就是完整的皮肤信息

      ## 配置

      那么,皮肤的自定义配置又是怎么定义的呢。首先不难想到自定义配置信息应该是在`header`里的,因为beatoraja配置皮肤的信息是在启动器里做的,此时肯定不需要加载完整的皮肤。不难发现代码里写的很明确:

      ```lua
      -- result/resultmain.lua
      local property = require("lua.mainproperty")

      local header = {
      property = property.property,
      }

      -- result/lua/mainproperty.lua
      local module = {}

      module.property = {
      {name = "Play Side", item = {
      {name = "1P", op = 900},
      {name = "2P", op = 901}
      }},
      {name = "Stagefile", item = {
      {name = "ON", op = 910},
      {name = "OFF", op = 911}
      }},
      {name = "Judge Detail", item = {
      {name = "Extra Fast/Slow Info", op = 920},
      --{name = "Pie", op = 921} --WIP
      }},
      {name = "Network Info", item = {
      {name = "On", op = 922},
      {name = "Off", op = 923} --WIP
      }},
      }

      return module

每个配置项是一个table,其中每个配置项有一个自己的名字name和可取值item,其中item又是一个table,内部就是一个简单的k/v定义。这里唯独比较奇怪的是op,不过不难发现它就是保存到beatoraja的存档文件里的数值,只要互不相同就可以了。

通过在resultmain.lua中寻找可以发现:

1
2
3
4
5
6
7
8
9
10
11
12
13
-- result/resultmain.lua
local timingX = 2500
local playerX = 60
local playInfoX = 1776
local songInfoX = 60
local RGB = diffRGB()

if skin_config.option["Play Side"] == 900 then
timingX = 60
playerX = 2500
playInfoX = 60
songInfoX = 1776
end

配置项就是通过beatoraja导出的全局变量skin_config来获取的,然后具体皮肤可以根据配置项的取值来设置不同的值来控制不同的渲染结果。

至于配置项是如何展示到beatoraja里的,这里就先略过了,并不太重要。

完整的皮肤定义

现在可以进入到重点了,一个皮肤在第二次读取时会返回给beatoraja这个皮肤的完整定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
local function main()
local textproperty = require("lua.textproperty")
local imageproperty = require("lua.imageproperty")
local valueproperty = require("lua.valueproperty")
local graph = require("lua.graph")

local skin = {}
for k, v in pairs(header) do
skin[k] = v
end

-- 省略

return skin
end

目前我们只知道这个方法会返回一个完整的skin对象,beatoraja会将其转换为一个bms.player.beatoraja.skin.json.JsonSkin.Skin对象,那么皮肤这边的定义就需要对应beatoraja的Skin类进行编写了,先简单看一下Skin类的轮廓:image-20250915101952687

内容有点多,不过首先需要明确的一点是整个JsonSkin只是一个存储类定义的地方,他没有自己的字段。原始的lua文件的执行结果会被转换为一个JsonSkin.Skin类的实例,然后被当作一个json定义皮肤读取到beatoraja中,后面这个步骤属于beatoraja内部的处理,暂时不深入。

通过阅读lua这边的代码可以发现大部分内容都是在定义skin.destination

1
2
3
4
5
6
7
8
9
skin.destination = {
--Background
{id = "bg_c", op = {90}, dst = {{x = 0, y = 0, w = 2560, h = 1440}}},
{id = "bg_aaa", op = {90,300}, dst = {{x = 0, y = 0, w = 2560, h = 1440}}},
{id = "bg_aa", op = {90,301}, dst = {{x = 0, y = 0, w = 2560, h = 1440}}},
{id = "bg_a", op = {90,302}, dst = {{x = 0, y = 0, w = 2560, h = 1440}}},
{id = "bg_f", op = {91}, dst = {{x = 0, y = 0, w = 2560, h = 1440}}},
-- 省略
}

可以猜测destination是指最终在beatoraja中渲染的组件。最开始的部分注释指出了是背景图,这部分代码也比较好理解,id是一个唯一标识,op是渲染这个dst的条件,dst是这个组件的坐标和长宽。显然对于背景图来说坐标是(0,0),长宽为整个窗口。

这里补充一下op的取值,其中90, 91, 300, 301, 302这几个值都不是定义在皮肤里的,而是定义在beatoraja里的,具体定义在bms.player.beatoraja.skin.SkinProperty,而这里的设计也比较随意,见bms.player.beatoraja.skin.SkinObject#setDrawCondition(int[])https://github.com/Catizard/lr2oraja-endlessdream/blob/112c5ce0ce7fd3d7942afef572486b40d142b35f/core/src/bms/player/beatoraja/skin/Skin.java#L193,如果op里的取值是定义在SkinProperty里的话,那么按beatoraja的定义来,否则就是皮肤自定义的配置项,按皮肤的配置来。

到这里实际上大部分内容都可以阅读了,这里给出一张截图来对比代码

image-20250915115618562

先从最下面的静态文字开始,它的定义很简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
if skin_config.option["Play Side"] == 900 then
table.insert(skin.text, {id = "avg", font = 0, size = 24, align = 0, constantText = avg_str})
table.insert(skin.text, {id = "std", font = 0, size = 24, align = 0, constantText = std_str})
table.insert(skin.text, {id = "player", font = 0, size = 24, align = 2, constantText = playerid_str})
table.insert(skin.text, {id = "ir", font = 0, size = 24, align = 2, constantText = ir_str})
table.insert(skin.text, {id = "offline", font = 0, size = 48, align = 2, constantText = "NETWORK OFFLINE"})
else
table.insert(skin.text, {id = "avg", font = 0, size = 24, align = 2, constantText = avg_str})
table.insert(skin.text, {id = "std", font = 0, size = 24, align = 2, constantText = std_str})
table.insert(skin.text, {id = "player", font = 0, size = 24, align = 0, constantText = playerid_str})
table.insert(skin.text, {id = "ir", font = 0, size = 24, align = 0, constantText = ir_str})
table.insert(skin.text, {id = "offline", font = 0, size = 48, align = 0, constantText = "NETWORK OFFLINE"})
end

文字是不能直接定义坐标的,它也不是直接渲染出来的,例如id=offline的text实际上还可以找到一个对应的destination:

1
2
3
4
-- destination
{id = "offline", op = {50, 922, -923}, filter = 1, dst = {{x = playerX, y = 6, h = 48, r = 221, g = 120, b = 120}}}
-- text
table.insert(skin.text, {id = "offline", font = 0, size = 48, align = 0, constantText = "NETWORK OFFLINE"})

于是可以猜想到align的定义是相对于坐标的偏移,取值0/1/2分别代表了左中右。