开发指南
资源与语言
模组自带的图片、文本与译文都经 ModContext 提供的两个入口访问:ctx.resources()(ResourceManager)与 ctx.language()(LanguageRegistry)。两者共用同一套 assets/ 路径约定。
5.1 路径约定与查找顺序
- 一个
ResourceLocation(namespace:path)对应文件路径assets/<namespace>/<path>。模组应把自己的资源放在src/main/resources/assets/<模组id>/…。 - 构造方式:
ResourceLocation.of("<模组id>", "相对路径"),例如ResourceLocation.of("mymod", "textures/note.png")对应 jar 内assets/mymod/textures/note.png。 - 宿主按两级顺序查找:
- 磁盘覆盖:
data/mods/<模组id>/assets/<namespace>/<path>——玩家 / 开发期可直接替换,无需重打包 jar; - 模组 jar 内:
assets/<namespace>/<path>。 - 都找不到时抛
ResourceNotFoundException(受检异常):没有裸路径回退,旧的"textures/logo.png"式调用在当前版本不再有效(旧 API 在找不到资源时静默返回null,模组漏判即在渲染回调里抛 NPE)。 namespace约定等于模组 id(内置内容用seecmd);namespace与path均强制小写(资源路径会落到 桌面系统 的大小写不敏感文件系统上),非法格式在构造期抛IllegalArgumentException。
5.2 ResourceManager 各方法用途
java
byte[] readBytes(ResourceLocation location) throws ResourceNotFoundException;
String readText(ResourceLocation location) throws ResourceNotFoundException;
RenderImage loadImage(ResourceLocation location) throws ResourceNotFoundException;
CompletableFuture<RenderImage> loadImageAsync(ResourceLocation location);
List<ResourceLocation> list(String pathPrefix);
RenderImage createImage(int width, int height, int[] argbPixels);| 方法 | 用途与要点 |
|---|---|
readBytes | 读取资源原始字节;两级查找均未命中或读取失败抛 ResourceNotFoundException |
readText | 以 UTF-8 读取资源文本(语言文件、JSON 配置等);未命中或读取失败抛 ResourceNotFoundException |
loadImage | 同步加载并解码图像(在调用线程解码),返回 RenderImage 句柄;解码只依赖 JDK,与后端无关,返回的句柄可在任意后端绘制 |
loadImageAsync | 在宿主 IO 线程池解码。游戏 / 渲染线程加载大图应优先用它,避免阻塞当帧;失败时 future 以 ResourceNotFoundException 异常完成 |
list | 列出本模组 assets/ 下的资源(“扫描目录里全部皮肤 / 音效”这类需求)。只列模组 jar 内的条目,磁盘覆盖目录不参与枚举。参数形如 "mymod:"(全部)或 "mymod:textures/note"(前缀);返回的 ResourceLocation 的 path 是相对 assets/<namespace>/ 的完整路径,因此返回值可直接再喂给 readBytes / loadImage;结果按 ResourceLocation 字典序稳定排序。pathPrefix 不含 ':' 或命名空间非法时抛 IllegalArgumentException |
createImage | 像素级处理的出口:模组经 RenderImage#getPixelsARGB() 读出原始像素,自行完成模糊 / 变暗 / 缩放等计算后,用本方法生成新图像用于渲染。width / height 须大于 0,argbPixels 为 0xAARRGGBB(Alpha 前置)且长度须不小于 width * height,否则抛 IllegalArgumentException |
RenderImage 提供 width()、height() 与 getPixelsARGB()(长度为 width() * height()、按行优先、每像素 0xAARRGGBB)。每次调用 getPixelsARGB() 都会复制一份像素数据,成本随图像尺寸上升,应一次性读取后自行缓存处理结果,不要每帧调用。
5.3 线程注意
- 字节读取与位图解码可在任意线程(不依赖渲染上下文),因此
readBytes/readText/loadImage的解码部分不要求渲染线程。 - 但后端资源(GL 纹理 / FX 图像)在渲染线程首次绘制时懒创建,所以图像句柄必须在渲染线程使用(在渲染回调里绘制)。不要在非渲染线程把句柄传给绘制 API。
- 阻塞式 IO 不应在游戏 / 渲染线程调用,请用
loadImageAsync。
5.4 语言
模组有两种等价的贡献译文方式:
- 资源文件(推荐):在模组 jar 内放
assets/<模组id>/lang/<locale>.json(形如{"mymod.hud.label": "准度"}),宿主在模组装配后自动扫描并加载; - 代码注册:
ctx.language().add(locale, map),适合译文由代码计算或来自配置的场景。可对同一 locale 多次调用,后注册的覆盖先注册的同名键。
java
void add(String locale, Map<String, String> entries);
Optional<String> get(String key);
Optional<String> get(String locale, String key);- 键名前缀:必须以模组 id 作前缀(
"<模组id>.<key>"),避免与内置译文(gameplay.*/settings.*…)及其它模组冲突。 - 切语言重放:模组层译文由宿主单独持有;玩家切换语言时宿主会重新加载内置译文并重放全部模组层,模组无需在语言切换时做任何事。
- 优先级:解析顺序为「模组层 → 内置层 → 英文层 → 键名本身」,即模组层覆盖同名内置键(模组可改写内置文案),当前语言缺失的键先回退到英文层,再缺失则回退键名本身。
get(key)未命中返回空Optional,由调用方自行决定兜底展示。 get(locale, key)查询指定语言下的译文,不切换当前语言;未命中返回空。
读取时机的坑(务必注意):宿主在你的 onInit 返回之后才扫描你的 assets/<模组id>/lang/*.json 语言文件。因此:
- 在
onInit里读自己的译文(jar 内 lang 文件提供的键)读到的是空值; - 要读自己的译文,请放到
onPostInit或更晚(例如渲染回调里、或某个事件回调里)。 - 相比之下,
onInit里通过ctx.language().add(...)代码注册的译文会立即生效(若 locale 恰为当前语言),可以在同一次onInit中查到。
5.5 完整示例:读取自带贴图 + 自带语言文件
假设模组 id 为 example_assets,jar 内资源布局为:
text
src/main/resources/assets/example_assets/textures/badge.png
src/main/resources/assets/example_assets/lang/zh.json // {"example_assets.badge.label": "徽章"}
src/main/resources/assets/example_assets/lang/en.json // {"example_assets.badge.label": "Badge"}java
package com.example.assetmod;
import com.seecmd.mod.api.GameplayMod;
import com.seecmd.mod.api.ModContext;
import com.seecmd.mod.api.ModInfo;
import com.seecmd.mod.api.registry.RegistryKey;
import com.seecmd.mod.api.registry.ResourceLocation;
import com.seecmd.mod.api.render.FilterFrame;
import com.seecmd.mod.api.render.RenderCanvas;
import com.seecmd.mod.api.render.RenderFilter;
import com.seecmd.mod.api.render.RenderImage;
import com.seecmd.mod.api.render.TextStyle;
import com.seecmd.mod.api.resource.ResourceNotFoundException;
import java.awt.Color;
import java.util.List;
import java.util.Map;
/**
* 资源与语言示例:加载自带贴图、贡献并读取自带译文。
*
* <p>资源文件:assets/example_assets/textures/badge.png
* <p>语言文件:assets/example_assets/lang/{zh,en}.json(宿主自动扫描)
*/
public final class AssetExampleMod implements GameplayMod {
private static final String MOD_ID = "example_assets";
/** 对应 assets/example_assets/textures/badge.png(小写、两级查找、无裸路径回退)。 */
private static final ResourceLocation BADGE =
ResourceLocation.of(MOD_ID, "textures/badge.png");
/** 译文键必须以模组 id 作前缀。 */
private static final String LABEL_KEY = MOD_ID + ".badge.label";
/** 图像句柄:onInit 加载一次,之后在渲染回调里复用(不要每帧加载)。 */
private RenderImage badge;
/** 译文缓存:在 onPostInit 读取(onInit 里读会被宿主扫描时序挡住,读到空值)。 */
private String label = LABEL_KEY;
@Override
public ModInfo info() {
return ModInfo.of(MOD_ID, "Asset Example", "1.0.0", List.of("YourName"),
"演示读取自带贴图与自带语言文件");
}
@Override
public void onPreInit(ModContext ctx) {
// 渲染内容仍在注册窗口内注册(运行期再注册会抛 RegistryFrozenException)。
ctx.registries().renderFilters().register(
RegistryKey.of(MOD_ID, "badge", RenderFilter.class),
new BadgeFilter(this));
}
@Override
public void onInit(ModContext ctx) {
loadBadge(ctx);
// 代码注册译文:与 jar 内 lang/*.json 等价,二者可同时使用;
// 这里的注册立即生效(若 locale 恰为当前语言)。
ctx.language().add("zh", Map.of(LABEL_KEY, "徽章"));
ctx.language().add("en", Map.of(LABEL_KEY, "Badge"));
// 反例提示:此处 ctx.language().get(LABEL_KEY) 对 jar 内 lang 文件提供的键
// 可能是空的——宿主在本方法返回之后才扫描语言文件。
}
@Override
public void onPostInit(ModContext ctx) {
// 正确时机:此时 jar 内 assets/example_assets/lang/<locale>.json 已被宿主加载。
label = ctx.language().get(LABEL_KEY).orElse(LABEL_KEY);
ctx.getLogger().info("译文就绪:" + label);
}
/** 加载自带贴图;缺图不阻断加载,渲染回调里直接跳过绘制。 */
private void loadBadge(ModContext ctx) {
try {
badge = ctx.resources().loadImage(BADGE);
} catch (ResourceNotFoundException e) {
// 当前版本的 ResourceManager 找不到资源时抛异常(不再静默返回 null),
// 所以要用 try/catch 明确处理,而不是靠判空。
ctx.getLogger().warning("贴图缺失:" + BADGE + "(" + e.getMessage() + ")");
}
}
/** 在画面左上角绘制自带贴图与自带译文(渲染滤镜,叠加在游玩内容之上)。 */
private static final class BadgeFilter implements RenderFilter {
private final AssetExampleMod mod;
BadgeFilter(AssetExampleMod mod) {
this.mod = mod;
}
@Override
public int zOrder() {
return 50; // 滤镜层内偏上(数字越大越靠上)
}
@Override
public void apply(RenderCanvas canvas, FilterFrame frame) {
RenderImage image = mod.badge;
if (image == null) {
return; // 贴图缺失时静默跳过
}
double size = 48;
canvas.save();
canvas.drawImage(image, 16, 16, size, size);
canvas.setColor(Color.WHITE);
canvas.drawText(mod.label, 16, 16 + size + 18, TextStyle.of(16, Color.WHITE));
canvas.restore();
}
}
}