Theseus 模组文档
教程

自定义 HUD

把原版右上角的连击数藏掉,改成屏幕中央一行自己的大字连击,并在底部画一条简易进度条。


为什么 HUD 是最好上手的第一个「游玩内」功能

它不碰判定、不碰谱面、不影响成绩——就算画错了,最多是难看,不会把游戏逻辑弄坏。同时它又能让你第一次接触到「宿主每帧叫你一次」这种运行期回调,这是后面做所有视觉效果的共同模式。

另外,HUD 暴露的是「数值 + 开关 + 一块画布」三件套,没有封装好的样式预设。你想画成什么样都行,但坐标、字号、对齐得自己定。

四个词
  • GameplayHud:你的 HUD 实现。一个模组最多提供一个,经 createGameplayHud() 交出。
  • HudValues:一帧的数值快照(连击、分数、准度、进度、各判定计数、音乐时间)。由宿主注入,你只读。
  • RenderCanvas:唯一允许的绘制入口。画矩形、线、文字、图片都走它,不接触任何图形库。
  • 覆盖渲染(overlay):宿主在原版 HUD 画完之后,把画布交给你再画一层。

它是怎么被调用的

isXxxVisible() 问:哪些原版组件要藏 画原版 HUD 跳过你声明隐藏的 updateValues() 注入本帧数值 renderOverlay() 你自绘一层 values() 在 renderOverlay 里读回 一帧一次,循环往复。你的代码只在最右边那一格执行。
每帧的顺序:先问可见性 → 画原版 → 注入数值 → 调你的覆盖渲染

关键规则只有一条,但它决定了「为什么我写了 renderOverlay 却没被调用」:

只有当至少一个原版组件被你声明隐藏,或者 wantsOverlayWithoutHiding() 返回 true 时,宿主才会调用 renderOverlay。

也就是说,HUD 覆盖的默认语义是「替换」,不是「附加」。只想在原版之上加一行字,必须显式声明。

前置条件

开始动手

把入口类换成 GameplayMod

GameplayMod 继承 Mod,多了两个「能力提供」钩子。只改 implements 一行,其余代码不动:

java
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(默认)表示不提供
    }
}
写一个只会「藏」的 HUD

先不画东西,只把原版连击藏掉,确认机制生效:

java
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,你要做的是把它存下来:

java
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 向下。宿主已经处理好窗口缩放,你不需要换算。

java
@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」:

java
@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 正常显示,不会闪。

完整代码

java
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。

多个 HUD 模组不会互相顶掉

所有 createGameplayHud() 返回非 null 的实例会按启用顺序收集,然后:

  • 可见性声明取「任一隐藏即隐藏」——只要有一个模组说要藏连击,连击就藏;
  • renderOverlay 依次调用,后画的盖住先画的,可以互相叠加。

这意味着你的「藏掉连击」会影响别的模组看到的画面。做 HUD 模组时,只藏自己真正接管的那部分。

常见坑

这几个地方最容易踩
  • renderOverlay 没被调用:你没有藏任何原版组件,也没让 wantsOverlayWithoutHiding() 返回 true。默认不画。
  • values() 返回 null 就崩了:第一次覆盖渲染之前 snapshot 还是 null,必须判空。
  • 在 renderOverlay 里自己算连击:宿主注入的才是权威值;自己数会和判定层对不上(尤其是倒带重演时)。
  • 改了画布状态没还原:setColor / setAlpha / translate 都是持续生效的,用 save() / restore() 包住自己的一段,别把状态泄漏给同帧后续绘制。
  • 用 setXxxVisible 当唯一开关:宿主不调用 setter,它读的是 getter。setter 必须真的改到 getter 返回的字段上。
  • 把数值缓存到静态字段:跨局会残留上一首的连击。实例字段即可。

进阶:不替换,只在原版之上加东西

只想加一行字、保留全部原版 HUD 时,别去藏组件,覆写 wantsOverlayWithoutHiding():

java
@Override
public boolean wantsOverlayWithoutHiding() {
    return true;   // 原版组件全保留,同时调用 renderOverlay
}

另一种更轻的玩法是只微调单个元素,不接管整层:覆写 override(HudElement) 返回偏移、颜色或透明度,宿主会把它应用到那个元素上(比如把血条整体右移 20px、调暗 30%)。多个模组的偏移会相加、透明度会相乘。

接下来