自定义 HUD
把原版右上角的连击数藏掉,改成屏幕中央一行自己的大字连击,并在底部画一条简易进度条。
它不碰判定、不碰谱面、不影响成绩——就算画错了,最多是难看,不会把游戏逻辑弄坏。同时它又能让你第一次接触到「宿主每帧叫你一次」这种运行期回调,这是后面做所有视觉效果的共同模式。
另外,HUD 暴露的是「数值 + 开关 + 一块画布」三件套,没有封装好的样式预设。你想画成什么样都行,但坐标、字号、对齐得自己定。
GameplayHud:你的 HUD 实现。一个模组最多提供一个,经createGameplayHud()交出。HudValues:一帧的数值快照(连击、分数、准度、进度、各判定计数、音乐时间)。由宿主注入,你只读。RenderCanvas:唯一允许的绘制入口。画矩形、线、文字、图片都走它,不接触任何图形库。- 覆盖渲染(overlay):宿主在原版 HUD 画完之后,把画布交给你再画一层。
它是怎么被调用的
关键规则只有一条,但它决定了「为什么我写了 renderOverlay 却没被调用」:
只有当至少一个原版组件被你声明隐藏,或者
wantsOverlayWithoutHiding()返回true时,宿主才会调用renderOverlay。
也就是说,HUD 覆盖的默认语义是「替换」,不是「附加」。只想在原版之上加一行字,必须显式声明。
前置条件
- 已完成 第一个模组,工程能构建、能加载。
开始动手
GameplayMod 继承 Mod,多了两个「能力提供」钩子。只改 implements 一行,其余代码不动:
import com.seecmd.mod.api.GameplayMod;
import com.seecmd.mod.api.capability.GameplayHud;
public class MyHudMod implements GameplayMod {
@Override
public ModInfo info() {
return ModInfo.of("my_hud", "My HUD", "0.1.0", List.of("You"), "自绘连击");
}
@Override
public GameplayHud createGameplayHud() {
return new MyHud(); // 返回 null(默认)表示不提供
}
}先不画东西,只把原版连击藏掉,确认机制生效:
private static final class MyHud implements GameplayHud {
@Override public HudValues values() { return null; }
@Override public void updateValues(HudValues values) { }
@Override public void setProgressVisible(boolean v) { }
@Override public void setComboVisible(boolean v) { }
@Override public void setScoreVisible(boolean v) { }
@Override public void setAccuracyVisible(boolean v) { }
@Override public boolean isProgressVisible() { return true; }
@Override public boolean isComboVisible() { return false; } // ← 声明隐藏连击
@Override public boolean isScoreVisible() { return true; }
@Override public boolean isAccuracyVisible() { return true; }
@Override public void renderOverlay(RenderCanvas canvas, HudContext ctx) {
// 先空着:这一步只验证「连击消失了」
}
}宿主每帧反查的是 isXxxVisible();setXxxVisible(...) 是给你自己留的开关入口(想做成可配置就存字段),宿主不会主动调用它。
进游戏打一首歌,右上角连击数应该不见了,其它 HUD(分数、准度、血条)原样保留。
如果连击还在:
createGameplayHud()是否真的返回了实例(不是null);- 模组状态是否
ACTIVE; - 入口类是否
implements GameplayMod(只implements Mod时这个方法根本不会被调用)。
renderOverlay 里不能靠「猜」当前连击是多少。宿主每次调用覆盖渲染前都会先调 updateValues,你要做的是把它存下来:
private HudValues snapshot;
@Override public HudValues values() { return snapshot; }
@Override
public void updateValues(HudValues values) {
this.snapshot = values; // 宿主保证非 null
}HudValues 提供的字段:
| 方法 | 含义 |
|---|---|
combo() / maxCombo() | 当前连击 / 历史最大连击 |
score() | 当前分数 |
accuracy() | 准度 0–1 |
progress() | 游玩进度 0–1 |
musicTimeMs() | 当前音乐时间(毫秒) |
perfects() / greats() / misses() | 各判定计数 |
覆盖渲染的画布是画布逻辑坐标 1280 × 720,原点在左上角,y 向下。宿主已经处理好窗口缩放,你不需要换算。
@Override
public void renderOverlay(RenderCanvas canvas, HudContext hudCtx) {
HudValues v = values();
if (v == null || v.combo() <= 0) {
return; // 还没开始打,或连击被清空:什么都不画
}
TextStyle style = TextStyle.of(
TextStyle.FontType.BOLD,
56,
new Color(0, 255, 200),
TextStyle.TextAlignment.CENTER); // x 变成文本中心
canvas.drawText(String.valueOf(v.combo()), 640, 300, style);
}TextAlignment.CENTER 表示传进去的 x 是文本的水平中心,不是左边缘——这样连击数变长时不会往右跑偏。
用 progress() 画底部进度,顺便体会一下「状态要自己 save/restore」:
@Override
public void renderOverlay(RenderCanvas canvas, HudContext hudCtx) {
HudValues v = values();
if (v == null) {
return;
}
// 连击
if (v.combo() > 0) {
canvas.drawText(String.valueOf(v.combo()), 640, 300,
TextStyle.of(TextStyle.FontType.BOLD, 56, new Color(0, 255, 200),
TextStyle.TextAlignment.CENTER));
}
// 进度条:底槽 + 实心
canvas.save();
canvas.setAlpha(0.25);
canvas.setColor(Color.WHITE);
canvas.fillRect(240, 690, 800, 6);
canvas.restore();
canvas.save();
canvas.setColor(new Color(0, 255, 200));
canvas.fillRect(240, 690, 800 * v.progress(), 6);
canvas.restore();
}- 连击数字出现在屏幕中央偏上,且随连击增长保持水平居中;
- 底部细线随歌曲推进变长,打完时铺满;
- MISS 清空连击后数字消失,但进度条仍在走;
- 暂停时 HUD 正常显示,不会闪。
完整代码
package com.example.myhud;
import com.seecmd.mod.api.GameplayMod;
import com.seecmd.mod.api.ModInfo;
import com.seecmd.mod.api.capability.GameplayHud;
import com.seecmd.mod.api.context.HudContext;
import com.seecmd.mod.api.context.HudValues;
import com.seecmd.mod.api.render.RenderCanvas;
import com.seecmd.mod.api.render.TextStyle;
import java.awt.Color;
import java.util.List;
public final class MyHudMod implements GameplayMod {
@Override
public ModInfo info() {
return ModInfo.of("my_hud", "My HUD", "0.1.0",
List.of("You"), "自绘连击与进度");
}
@Override
public GameplayHud createGameplayHud() {
return new MyHud();
}
private static final double CANVAS_W = 1280;
private static final double CANVAS_H = 720;
private static final class MyHud implements GameplayHud {
private HudValues snapshot;
private boolean progressVisible = true;
private boolean comboVisible = false; // 只接管控件:藏掉原版连击
private boolean scoreVisible = true;
private boolean accuracyVisible = true;
@Override public HudValues values() { return snapshot; }
@Override public void updateValues(HudValues values) { this.snapshot = values; }
@Override public void setProgressVisible(boolean v) { this.progressVisible = v; }
@Override public void setComboVisible(boolean v) { this.comboVisible = v; }
@Override public void setScoreVisible(boolean v) { this.scoreVisible = v; }
@Override public void setAccuracyVisible(boolean v) { this.accuracyVisible = v; }
@Override public boolean isProgressVisible() { return progressVisible; }
@Override public boolean isComboVisible() { return comboVisible; }
@Override public boolean isScoreVisible() { return scoreVisible; }
@Override public boolean isAccuracyVisible() { return accuracyVisible; }
@Override
public void renderOverlay(RenderCanvas canvas, HudContext hudCtx) {
HudValues v = values();
if (v == null) {
return;
}
if (v.combo() > 0) {
canvas.drawText(String.valueOf(v.combo()), CANVAS_W / 2.0, 300,
TextStyle.of(TextStyle.FontType.BOLD, 56, new Color(0, 255, 200),
TextStyle.TextAlignment.CENTER));
}
canvas.save();
canvas.setAlpha(0.25);
canvas.setColor(Color.WHITE);
canvas.fillRect(240, CANVAS_H - 30, 800, 6);
canvas.restore();
canvas.save();
canvas.setColor(new Color(0, 255, 200));
canvas.fillRect(240, CANVAS_H - 30, 800 * v.progress(), 6);
canvas.restore();
}
}
}画布逻辑坐标固定为 1280 × 720,宽高比与游戏内容区一致。宿主在把这块画布真正画到屏幕之前,会整体做一次缩放。
所以你的代码里出现的 640、690 这些数字,在不同窗口大小、不同分辨率下含义不变。代价是不能假设「一个逻辑像素等于一个物理像素」——细线在低分辨率下可能糊,需要更稳的线就画 2px。
所有 createGameplayHud() 返回非 null 的实例会按启用顺序收集,然后:
- 可见性声明取「任一隐藏即隐藏」——只要有一个模组说要藏连击,连击就藏;
renderOverlay依次调用,后画的盖住先画的,可以互相叠加。
这意味着你的「藏掉连击」会影响别的模组看到的画面。做 HUD 模组时,只藏自己真正接管的那部分。
常见坑
renderOverlay没被调用:你没有藏任何原版组件,也没让wantsOverlayWithoutHiding()返回true。默认不画。values()返回 null 就崩了:第一次覆盖渲染之前snapshot还是 null,必须判空。- 在
renderOverlay里自己算连击:宿主注入的才是权威值;自己数会和判定层对不上(尤其是倒带重演时)。 - 改了画布状态没还原:
setColor/setAlpha/translate都是持续生效的,用save()/restore()包住自己的一段,别把状态泄漏给同帧后续绘制。 - 用
setXxxVisible当唯一开关:宿主不调用 setter,它读的是 getter。setter 必须真的改到 getter 返回的字段上。 - 把数值缓存到静态字段:跨局会残留上一首的连击。实例字段即可。
进阶:不替换,只在原版之上加东西
只想加一行字、保留全部原版 HUD 时,别去藏组件,覆写 wantsOverlayWithoutHiding():
@Override
public boolean wantsOverlayWithoutHiding() {
return true; // 原版组件全保留,同时调用 renderOverlay
}另一种更轻的玩法是只微调单个元素,不接管整层:覆写 override(HudElement) 返回偏移、颜色或透明度,宿主会把它应用到那个元素上(比如把血条整体右移 20px、调暗 30%)。多个模组的偏移会相加、透明度会相乘。
接下来
- 想知道每个 HUD 元素支持哪些微调:HudElement
- 想改按键 → 轨道的映射:LaneInputMapper
- 想画在 HUD 以外的地方(背景、判定层之上):看 渲染与视觉扩展 与 RenderLayer
- 想给打击加特效,而不是改 HUD:自定义打击特效