贴图与译文
模组自带一张 PNG 与中英文语言文件,在游玩画面的左下角画出这张图,旁边的文案随游戏语言在「徽章 / Badge」之间切换。
纯代码画出来的界面,只有方块和文字。想把图标、曲绘、自己的 logo 放进画面,就得把图片打进模组,再在渲染回调里画出来。
只要界面里有文字,就还要考虑语言:玩家把游戏切成英文,你的中文标签不该留在屏幕上。宿主把这两件事放在同一套 assets/ 约定下——贴图按路径加载,译文按语言键查询。
这一篇把它们串起来:先让一张图出现在画面上,再让它的文案跟着语言走。
- 贴图路径:图片在 jar 里的位置,写成相对
assets/<模组id>/的路径,例如textures/badge.png。 - 命名空间(namespace):资源标识
namespace:path的前半段。模组资源的命名空间约定等于模组 id,它决定宿主去assets/<namespace>/下找文件。 - 语言键:译文的身份,形如
badge_mod.hud.label。约定以模组 id 作前缀,避免和内置文案、其它模组撞键。 - 资源重载:宿主重新加载译文层或资源的动作。切换语言会触发一次,并投递
ResourceReloadEvent;你缓存下来的译文要在这时刷新。
前置条件
- 已完成 第一个模组,工程能构建、jar 能加载。
- 一张 PNG 图片(本篇用 64×64,别的尺寸也行,绘制时按目标矩形拉伸)。
- 知道自己的模组 id。本篇统一用
badge_mod,换成你自己的 id 时,目录名、ResourceLocation的命名空间、语言键前缀要一起改。
本篇只用到 5 个类型:ResourceManager、ResourceLocation、RenderImage、RenderCanvas、LanguageRegistry。每处出现都会解释。
运行流程
开始动手
在工程里建出这样的结构:
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.jsonassets/badge_mod/ 里的 badge_mod 必须逐字等于模组 id。构建脚本会把 src/main/resources 整个拷进 jar,所以打包后路径仍是 assets/badge_mod/…,与代码里的资源标识一一对应。
资源路径全小写。文件名建议只用 [a-z0-9_.-],目录分组用 /,不要用反斜杠。
lang/zh.json:
{
"badge_mod.hud.label": "徽章"
}lang/en.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 层,切到中文时匹配不上。
用 ResourceLocation.of(命名空间, 相对路径) 描述资源,再交给 ctx.resources().loadImage(...):
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() 就是没找到的那个资源标识,日志里留着它,排查最快。
贴图加载一次、存进字段,之后每帧只是复用这个句柄。
游玩画面里,宿主在每个渲染层内容绘制的前后各投递一次 RenderEvent.Pre / RenderEvent.Post。在 onInit 里订阅 Post:
ctx.eventBus().subscribe(RenderEvent.Post.class, event -> drawBadge(event.context()));event.layer() 告诉你当前是哪一层,event.context() 给出本次回调的 RenderContext。在回调里这样画:
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, ),源区域用归一化坐标。
ctx.language().get(键) 返回 Optional<String>。当前语言缺这个键时,宿主会先回退到英文层;都没有就返回空,需要你自己 orElse 兜底:
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,就要在这里重取,否则画面上的文字会停在旧语言。
译文也可以不用文件,直接在代码里注册:
ctx.language().add("zh", Map.of(LABEL_KEY, "徽章"));
ctx.language().add("en", Map.of(LABEL_KEY, "Badge"));add 立即生效(当 locale 恰为当前语言),与语言文件可以同时用,后注册的覆盖先注册的同名键。
把上面的片段合成一个类。只用 Mod 就够——渲染事件经 ctx.eventBus() 订阅,不需要额外能力接口:
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分支后刷新。 - 切到英文后文案跟着变;两种语言都缺键时显示键名,而不是空白。
接下来
- 想画图集里的某一格(帧动画、精灵图):看 RenderContext 的
drawTexture(...)。 - 想把整个 HUD 换成自己的布局:看 自定义 HUD。
- 想更细地控制裁剪、透明度与变换:看 RenderCanvas。
- 想确认资源查找规则与异步加载:看 ResourceManager。