Theseus 模组文档
教程

贴图与译文

模组自带一张 PNG 与中英文语言文件,在游玩画面的左下角画出这张图,旁边的文案随游戏语言在「徽章 / Badge」之间切换。


为什么需要这个

纯代码画出来的界面,只有方块和文字。想把图标、曲绘、自己的 logo 放进画面,就得把图片打进模组,再在渲染回调里画出来。

只要界面里有文字,就还要考虑语言:玩家把游戏切成英文,你的中文标签不该留在屏幕上。宿主把这两件事放在同一套 assets/ 约定下——贴图按路径加载,译文按语言键查询。

这一篇把它们串起来:先让一张图出现在画面上,再让它的文案跟着语言走。

先认识四个词
  • 贴图路径:图片在 jar 里的位置,写成相对 assets/<模组id>/ 的路径,例如 textures/badge.png。
  • 命名空间(namespace):资源标识 namespace:path 的前半段。模组资源的命名空间约定等于模组 id,它决定宿主去 assets/<namespace>/ 下找文件。
  • 语言键:译文的身份,形如 badge_mod.hud.label。约定以模组 id 作前缀,避免和内置文案、其它模组撞键。
  • 资源重载:宿主重新加载译文层或资源的动作。切换语言会触发一次,并投递 ResourceReloadEvent;你缓存下来的译文要在这时刷新。

前置条件

不用先读 API 参考

本篇只用到 5 个类型:ResourceManager、ResourceLocation、RenderImage、RenderCanvas、LanguageRegistry。每处出现都会解释。

运行流程

放文件 resources/assets/<id>/… 打包进 jar assets/<id>/… 不变 读取资源 loadImage / get 渲染回调 drawImage + drawText 语言文件 lang/zh.json + en.json 语言文件由宿主在 onInit 返回后自动扫描;贴图由你在 onInit 主动加载。
一张图从工程目录走到画面:文件放对位置,代码只负责「读一次、画下来」

开始动手

第 1 步:按约定放文件

在工程里建出这样的结构:

text
badge-mod/
├─ build.bat
├─ src/main/java/com/example/badgemod/BadgeMod.java
└─ src/main/resources/assets/badge_mod/
   ├─ textures/badge.png
   ├─ lang/zh.json
   └─ lang/en.json

assets/badge_mod/ 里的 badge_mod 必须逐字等于模组 id。构建脚本会把 src/main/resources 整个拷进 jar,所以打包后路径仍是 assets/badge_mod/…,与代码里的资源标识一一对应。

资源路径全小写。文件名建议只用 [a-z0-9_.-],目录分组用 /,不要用反斜杠。

第 2 步:写两个语言文件

lang/zh.json:

json
{
  "badge_mod.hud.label": "徽章"
}

lang/en.json:

json
{
  "badge_mod.hud.label": "Badge"
}

键名用 <模组id>.<自定路径>,这里是 badge_mod.hud.label。宿主在模组装配后会自动扫描 assets/<模组id>/lang/*.json,不需要你在代码里加载。

语种就是文件名去掉 .json 的部分。宿主当前使用的语言代码是 zh 与 en,所以文件必须叫 zh.json、en.json——写成 zh_cn.json 会被登记成 zh_cn 层,切到中文时匹配不上。

第 3 步:在 onInit 加载贴图

用 ResourceLocation.of(命名空间, 相对路径) 描述资源,再交给 ctx.resources().loadImage(...):

java
private static final String MOD_ID = "badge_mod";
private static final ResourceLocation BADGE =
        ResourceLocation.of(MOD_ID, "textures/badge.png");

private RenderImage badge;

@Override
public void onInit(ModContext ctx) {
    try {
        badge = ctx.resources().loadImage(BADGE);
    } catch (ResourceNotFoundException e) {
        ctx.getLogger().warning("缺少贴图 " + e.location());
    }
}

loadImage 是同步加载并解码,返回一个 RenderImage 句柄。它只依赖 JDK,解码可以在任意线程完成;但句柄要在渲染线程使用——后端纹理在第一次绘制时才创建。

找不到文件或解码失败会抛受检异常 ResourceNotFoundException。它不是返回 null,所以调用点必须 try/catch,而不是靠判空。e.location() 就是没找到的那个资源标识,日志里留着它,排查最快。

贴图加载一次、存进字段,之后每帧只是复用这个句柄。

第 4 步:订阅渲染回调,把图画出来

游玩画面里,宿主在每个渲染层内容绘制的前后各投递一次 RenderEvent.Pre / RenderEvent.Post。在 onInit 里订阅 Post:

java
ctx.eventBus().subscribe(RenderEvent.Post.class, event -> drawBadge(event.context()));

event.layer() 告诉你当前是哪一层,event.context() 给出本次回调的 RenderContext。在回调里这样画:

java
private void drawBadge(RenderContext rc) {
    if (rc.layer() != RenderLayer.HUD) {
        return;                       // 只画在 HUD 层,其它层直接跳过
    }
    RenderCanvas canvas = rc.canvas();
    double x = 40;
    double y = rc.height() - 96;      // 相对底边定位,不写死 720

    canvas.save();
    if (badge != null) {
        canvas.drawImage(badge, x, y, 64, 64);
        x += 72;
    }
    canvas.setColor(Color.WHITE);
    canvas.drawText(label, x, y + 40,
            TextStyle.of(TextStyle.FontType.BOLD, 24, Color.WHITE,
                    TextStyle.TextAlignment.LEFT));
    canvas.restore();
}

画布从 rc.canvas() 拿,只在本次回调期间有效,不要存字段跨帧使用。坐标是当前层的逻辑坐标,左上角为原点、y 向下;rc.width() / rc.height() 给出本层逻辑尺寸,用它做相对定位,窗口怎么缩都不跑偏。

drawImage(image, x, y, w, h) 把整张图拉伸到目标矩形。只想画图集的一部分时,改用 rc.drawTexture(image, x, y, w, h, u0, , u1, ),源区域用归一化坐标。

第 5 步:在 onPostInit 读译文,切语言时刷新

ctx.language().get(键) 返回 Optional<String>。当前语言缺这个键时,宿主会先回退到英文层;都没有就返回空,需要你自己 orElse 兜底:

java
private String label = LABEL_KEY;

@Override
public void onPostInit(ModContext ctx) {
    refreshLabel(ctx);
    ctx.eventBus().subscribe(ResourceReloadEvent.class, event -> {
        if (event.kind() == ResourceReloadEvent.Kind.LANGUAGE) {
            refreshLabel(ctx);        // 玩家切换语言后重新取一次
        }
    });
}

private void refreshLabel(ModContext ctx) {
    label = ctx.language().get(LABEL_KEY).orElse(LABEL_KEY);
}

必须放在 onPostInit:宿主是在你的 onInit 返回之后才扫描 jar 内语言文件的,早读只会得到空 Optional。

ResourceReloadEvent 的 Kind.LANGUAGE 在真实切换语言后投递,detail() 是新 locale。你既然缓存了 label,就要在这里重取,否则画面上的文字会停在旧语言。

译文也可以不用文件,直接在代码里注册:

java
ctx.language().add("zh", Map.of(LABEL_KEY, "徽章"));
ctx.language().add("en", Map.of(LABEL_KEY, "Badge"));

add 立即生效(当 locale 恰为当前语言),与语言文件可以同时用,后注册的覆盖先注册的同名键。

第 6 步:完整代码

把上面的片段合成一个类。只用 Mod 就够——渲染事件经 ctx.eventBus() 订阅,不需要额外能力接口:

java
package com.example.badgemod;

import com.seecmd.mod.api.Mod;
import com.seecmd.mod.api.ModContext;
import com.seecmd.mod.api.ModInfo;
import com.seecmd.mod.api.event.render.RenderEvent;
import com.seecmd.mod.api.event.resource.ResourceReloadEvent;
import com.seecmd.mod.api.registry.ResourceLocation;
import com.seecmd.mod.api.render.RenderCanvas;
import com.seecmd.mod.api.render.RenderContext;
import com.seecmd.mod.api.render.RenderImage;
import com.seecmd.mod.api.render.RenderLayer;
import com.seecmd.mod.api.render.TextStyle;
import com.seecmd.mod.api.resource.ResourceNotFoundException;

import java.awt.Color;
import java.util.List;

public final class BadgeMod implements Mod {

    private static final String MOD_ID = "badge_mod";
    private static final ResourceLocation BADGE =
            ResourceLocation.of(MOD_ID, "textures/badge.png");
    private static final String LABEL_KEY = MOD_ID + ".hud.label";

    private RenderImage badge;
    private String label = LABEL_KEY;

    @Override
    public ModInfo info() {
        return ModInfo.of(MOD_ID, "Badge Mod", "1.0.0", List.of("YourName"),
                "自带贴图与中英文案");
    }

    @Override
    public void onInit(ModContext ctx) {
        try {
            badge = ctx.resources().loadImage(BADGE);
        } catch (ResourceNotFoundException e) {
            ctx.getLogger().warning("缺少贴图 " + e.location());
        }
        ctx.eventBus().subscribe(RenderEvent.Post.class, event -> drawBadge(event.context()));
    }

    @Override
    public void onPostInit(ModContext ctx) {
        refreshLabel(ctx);
        ctx.eventBus().subscribe(ResourceReloadEvent.class, event -> {
            if (event.kind() == ResourceReloadEvent.Kind.LANGUAGE) {
                refreshLabel(ctx);
            }
        });
    }

    private void refreshLabel(ModContext ctx) {
        label = ctx.language().get(LABEL_KEY).orElse(LABEL_KEY);
    }

    private void drawBadge(RenderContext rc) {
        if (rc.layer() != RenderLayer.HUD) {
            return;
        }
        RenderCanvas canvas = rc.canvas();
        double x = 40;
        double y = rc.height() - 96;

        canvas.save();
        if (badge != null) {
            canvas.drawImage(badge, x, y, 64, 64);
            x += 72;
        }
        canvas.setColor(Color.WHITE);
        canvas.drawText(label, x, y + 40,
                TextStyle.of(TextStyle.FontType.BOLD, 24, Color.WHITE,
                        TextStyle.TextAlignment.LEFT));
        canvas.restore();
    }
}
到这里应该看到
  • 进任意一首歌,画面左下角出现你的贴图,右侧是「徽章」。
  • 贴图缺失时游戏不崩,日志里有一条带资源标识的 warning,文字仍正常显示。
  • 在设置里切成 English,再回到游玩画面,文字变成「Badge」,不需要重启。
  • 其它 HUD(连击、分数、进度条)位置不变;菜单与设置页里不会出现这张图。
  • 模组列表里状态仍是 ACTIVE。
原理:路径就是身份,扫描有先后

为什么资源要按这个目录放。 ResourceLocation 是 命名空间:路径,宿主把它拼成 assets/<命名空间>/<路径> 去读。模组资源放在 src/main/resources/assets/<模组id>/…,打出的 jar 里就是 assets/<模组id>/…,两边刚好对齐。

命名空间取模组 id,是为了让两个模组的 textures/logo.png 不撞车。宿主按两级查找:先看 data/mods/<模组id>/assets/…(玩家可以直接替换文件),再看 jar 内。没有裸路径回退——写成 "textures/badge.png" 不会去猜目录,直接抛 ResourceNotFoundException。

这也是它被声明成受检异常的原因:找不到资源必须在你调用 loadImage 的那一行暴露,而不是拖到渲染回调里变成空指针。

为什么 onInit 读不到自己的译文。 宿主在你的 onInit 返回之后才扫描 assets/<模组id>/lang/*.json 并注册进译文层。onInit 里查询自己的键得到空 Optional,不是文件写错,只是扫描还没发生。所以贴图在 onInit 加载(文件随时可读),译文放到 onPostInit 再取。

语言切换是另一件事:宿主重新加载内置译文、重放模组层,然后投递 ResourceReloadEvent。你缓存了 label,就必须在这个事件里重取一次,否则文字会停在旧语言。

这几个地方最容易踩
  • 目录名与命名空间对不上:assets/badge/textures/... 配 ResourceLocation.of("badge_mod", ...) 永远找不到。两者必须逐字相同;路径里出现大写或反斜杠,会在构造期抛 IllegalArgumentException。
  • 语言文件写成 zh_cn.json / en_us.json:locale 取文件名(去掉 .json)。宿主当前的语言代码是 zh / en,这一层用不上,表现是「译文没生效、屏幕上只剩键名」。
  • 在 onInit 里查 jar 内语言文件提供的键:扫描还没发生,拿到空 Optional,于是永远显示兜底文案。挪到 onPostInit 或更晚。
  • 在渲染回调里重新 loadImage 或调 getPixelsARGB():前者每帧解码,后者每次复制整幅像素,成本随图片尺寸上升。句柄在 onInit 加载一次,像素处理一次做完缓存起来。
  • 不过滤 layer()、save() / restore() 不配对:不过滤会把同一张图在 7 个层各画一遍;不还原状态会把颜色、透明度泄漏给同层后续绘制。
交付自查
  • 资源在 src/main/resources/assets/<模组id>/textures/… 与 …/lang/zh.json、…/lang/en.json。
  • ResourceLocation 的命名空间等于模组 id,路径全小写、用 /。
  • 贴图只在 onInit 加载一次,缺失时日志能定位到资源标识且渲染不崩。
  • 只在 RenderLayer.HUD 层绘制,save() / restore() 成对。
  • 译文在 onPostInit 读取,并在 ResourceReloadEvent 的 LANGUAGE 分支后刷新。
  • 切到英文后文案跟着变;两种语言都缺键时显示键名,而不是空白。

接下来