跳转至

原创教程 · 目标版本:Forge 1.20.1 + FPSMatch 1.3.0 · 面向新手:从零写一个属于自己服务器的枪战玩法 mod 技术事实基于 fpsmatch-1.20.1-1.3.0-forge-snapshot-20260916-1623 反编译实证(非 wiki 转述)

本次更新(对齐 2026-09-16 上游快照):① LDLib2 界面全部移除,改用原生 Modern UI(依赖表已变,ldlib2 不再是必需依赖);② 新增客户端相机系统CameraDirector / CameraRig / CameraSequence,新增客户端命令 /fpsm camera);③ 地图工具强化:地图 ID 规范、OP 2 级权限、对局中禁止编辑地图区域编辑与导入、缩略图/背景图设置;④ 网络协议号 1.4.0 → 1.4.2客户端与服务端必须同版本);⑤ 新增 cameraSystemCheck / commandTreeCheck 自检任务;⑥ 新增两份设计稿(尚未实现):世界沙盘编辑器、服务端联动与持久化

联动 FPSMatch:从零写一个枪战玩法 mod

目录

  1. 开篇:这一章教什么
  2. FPSMatch 是什么:框架、定位与依赖
  3. 环境准备:Forge 工程与 Gradle 依赖
  4. 第一个扩展 mod 骨架
  5. 核心概念:地图、游戏类型、队伍、能力、回合
  6. 注册你的游戏类型:RegisterFPSMapEvent
  7. 写第一张地图:继承 BaseMap
  8. 回合制玩法:BaseRoundMap 与 RoundLifecycle
  9. 让地图可配置:Setting
  10. 能力系统:给地图/队伍挂功能
  11. 队伍与玩家数据:分组、比分、KDA
  12. 商店与经济:FPSMShop
  13. 事件系统:监听对局、队伍、枪械
  14. 把数据存下来:RegisterFPSMSaveDataEvent 与 SaveHolder
  15. 扩展命令:RegisterFPSMCommandEvent
  16. 客户端相机系统:CameraDirector 与镜头编排
  17. 地图工具与地图 ID 规范(OP 编辑 / 区域编辑 / 导入 / 缩略图)
  18. KubeJS 联动:不写 Java 也能挂规则
  19. 调试与常见错误排查
  20. 打包与发布:让别人也能玩
  21. 上游设计稿(尚未实现):世界沙盘编辑器 / 服务端联动与持久化

开篇:这一章教什么

读完这一章,你将能够创建一个属于自己服务器的枪战玩法 mod,包括自定义游戏类型、队伍、回合、商店、事件奖励等。要开始这一章的学习,你需要具备以下前置知识:

  • Forge 模组开发基础
  • Java 17 编程语言
  • Gradle 项目构建工具

本章的学习路线如下:

  1. 环境准备:配置 Forge 开发环境和 FPSMatch 依赖
  2. 创建 mod 骨架:建立基本的 mod 结构和事件总线
  3. 注册游戏类型:使用 FPSMatch 的 API 注册自定义游戏类型
  4. 编写地图:创建一个基本的游戏地图和回合系统
  5. 实现能力系统:使用 FPSMatch 的能力系统添加自定义能力
  6. 开发商店系统:创建一个基本的商店系统和事件奖励
  7. 处理事件:使用 FPSMatch 的事件系统处理游戏事件
  8. 存档数据:使用 FPSMatch 的存档系统存储游戏数据
  9. 与 KubeJS 联动:使用 KubeJS 插件与 FPSMatch 进行联动
  10. 调试和发布:调试和发布自己的 mod

注意,FPSMatch 是一个框架,而不是一个成品玩法,所以你需要自己写代码来实现自己的游戏逻辑。通过这一章的学习,你将能够创建一个属于自己的枪战玩法 mod,并将其应用到自己的 Minecraft 服务器中。


FPSMatch 是什么:框架、定位与依赖

FPSMatch 是一个团队 FPS 竞技框架/库模组,它为开发者提供了对局生命周期、地图实例、队伍、经济商店、投掷物、HUD 统计、旁观等功能。装完 FPSMatch 模组后,你可能会发现没有任何新的玩法出现,这是正常的,因为 FPSMatch 只是一个框架,需要开发者自己注册和实现具体的玩法。

下面是 1.20.1 Forge 1.3.0 版本的依赖表:

modId fpsmatch
版本 1.3.0(快照 20260916-1623
Minecraft 1.20.1
Forge [47.4.10,)
Java 17
必需依赖 kotlinforforge [4.11.0,)(全局必需);客户端另需 Modern UI 1.20.1-3.12.0.1(专用服务端不要求)
可选依赖 tacz [1.1.7-hotfix]kubejs2001.6.5-build.14)、tacztweaks 2.11.2lrtactical 0.4.3CounterStrikeGrenade 1.5.2

Warning

2026-09-16 起,ldlib2 已从 FPSMatch 依赖中彻底移除(LDLib2 界面全部删除,改为原生 Modern UI)。如果你还在 mods.tomlbuild.gradle 里声明 ldlib2,请删掉——保留它反而可能拖入独立 kotlin-stdlib 造成 ResolutionException。老版本(1.2.5)才需要 ldlib2。

新手开发者需要关心依赖,因为缺少依赖会导致模组直接启动崩溃。确保你的模组中包含了所有必要的依赖,才能正常使用 FPSMatch 的功能。

那么,一条玩法是怎么被注册进去的呢?下面是一个整体的流程图:

  1. 注册游戏类型:开发者需要使用 FPSMCoreregisterGameType 方法注册一个新的游戏类型。
  2. 创建地图实例:开发者需要创建一个新的地图实例,实现 BaseMap 接口。
  3. 注册地图:开发者需要使用 FPSMCoreregisterMap 方法注册地图实例。
  4. 实现玩法逻辑:开发者需要在地图实例中实现具体的玩法逻辑,例如游戏规则、队伍管理、经济商店等。
  5. 注册事件监听器:开发者需要注册事件监听器,监听游戏中的事件,例如玩家加入、离开、死亡等。
  6. 更新 HUD 统计:开发者需要更新 HUD 统计,显示玩家的成绩、队伍信息等。

通过这个流程,开发者可以注册和实现自己的玩法,丰富 FPSMatch 的功能。下一节,我们将详细介绍如何注册游戏类型和创建地图实例。

Warning

注意:缺少依赖会导致模组直接启动崩溃,确保你的模组中包含了所有必要的依赖。


环境准备:Forge 工程与 Gradle 依赖

在开始开发 FPSMatch 模组之前,我们需要准备好 Forge 工程环境。这里我们将使用 Forge MDK 1.20.1 和 Forge 47.4.10 版本。

步骤 1:创建 Forge 工程

首先,下载 Forge MDK 1.20.1 并解压到一个文件夹中。然后,使用 IDEA 或其他 IDE 导入该项目。确保你的 JDK 版本是 17。

步骤 2:添加 FPSMatch 依赖

build.gradle 文件中添加 FPSMatch 依赖。这里有个版本相关的坑要分清:

  • 1.3.0 官方构建栈 = net.neoforged.moddev.legacyforge(2.0.140+),它提供 modImplementation / modCompileOnly / modRuntimeOnly —— 所以新项目里 modImplementation正确写法
  • 老栈 ForgeGradle 6(1.2.5 时代)不提供 modImplementation,那时才必须写 implementation fg.deobf("...")
repositories {
    maven { url "https://maven.modrinth.com/" }
    // 源码 composite build 不写这条;吃生产 jar 才需要
}

dependencies {
    // moddev legacyforge 栈(1.3.0 推荐)
    modImplementation "maven.modrinth:fpsmatch:1.2.5"
    // 或本地 jar(源码快照):modImplementation files("libs/fpsmatch-1.20.1-1.3.0-forge-snapshot.jar")

    // 老栈 ForgeGradle 6 才这样写:
    // implementation fg.deobf("maven.modrinth:fpsmatch:1.2.5")
}

Tip

官方与 BlockOffensive 都用 git submodule + settings.gradleincludeBuild('FPSMatch') + dependencySubstitution 做 composite build(直接编译源码,dev 环境不加载生产 jar),从根上避免下面步骤 3 的 mixin 坑。

repositories {
    maven {
        url "https://maven.modrinth.com/"
    }
}

dependencies {
    implementation fg.deobf("maven.modrinth:fpsmatch:1.3.0")
    // 或者使用本地 jar 文件
    // implementation fg.deobf(files("path/to/fpsmatch-1.3.0.jar"))
}

步骤 3:解决 dev 环境 mixin 报错

1.3.0 的正确姿势:官方已改用 moddev legacyforge + mixin refmap remap,旧的那个 @Shadow method m_91277_ was not located 崩溃从根上被解决。所以新项目优先用:

  1. 源码 composite buildincludeBuild('FPSMatch'),官方与 BlockOffensive 的做法,推荐);
  2. 或直接吃官方 GitHub Release 的 1.3.0 快照 jar。

如果你还在用 1.2.5(老 ForgeGradle 6 + 生产 jar),那个坑依然存在,原因是 jar 内 mixin 的 @Shadow 用了 SRG 名(如 m_91277_ = Minecraft.startUseItem()),而 dev 是 mojmap 可读名。此时只能:① 用 dev 修补 jar(清空 mixin 列表)② 补全 refmap ③ 改源码自构建。

步骤 4:客户端测试环境

客户端测试环境需要 Modern UI1.20.1-3.12.0.1)和 Kotlin for Forge4.11.0+)。服务端不需要 Modern UI(它只用于客户端界面)。

mods.toml 中声明依赖:

[[dependencies]]
modId="kotlinforforge"
mandatory=true
versionRange="[4.11.0,)"

[[dependencies]]
modId="modernui"
mandatory=true
versionRange="[3.12.0.1,)"

Tip

composite build 不会传递 FPSMatch 自己的运行时依赖,使用方必须在根 build.gradle 自己声明 ModernUI-Forge + kotlinforforge(1.3.0 不要加 ldlib2),否则 runClient 会报 Missing mandatory dependencies

总结

至此,我们已经准备好了 Forge 工程环境和 Gradle 依赖。接下来,我们可以开始开发 FPSMatch 模组了。

Warning

注意:在开发过程中,可能会遇到各种问题和错误。请仔细检查代码和配置文件,并参考 FPSMatch 文档和 ForgeGradle 文档来解决问题。


第一个扩展 mod 骨架

要从零开始写一个 FPSMatch 的扩展 mod,我们需要创建一个基本的 mod 骨架。这个骨架包括一个 @Mod 主类、一个 mods.toml 文件用于声明依赖,以及一个空的注册类。

@Mod 主类

首先,我们需要创建一个 @Mod 主类。这个类是 mod 的入口点,用于注册事件总线和监听 Forge 事件。

// MyFPSMatchMod.java
@Mod("myfpsmatchmod")
public class MyFPSMatchMod {
    public MyFPSMatchMod() {
        // 注册事件总线
        FMLJavaModLoadingContext.get().getModEventBus().addListener(this::setup);
        // 监听 Forge 事件
        MinecraftForge.EVENT_BUS.register(this);
    }

    private void setup(FMLCommonSetupEvent event) {
        // 这里可以进行一些 mod 的初始化工作
    }
}

mods.toml 文件

接下来,我们需要创建一个 mods.toml 文件来声明我们的 mod 依赖。我们需要依赖 fpsmatchldlib2kotlinforforge

# mods.toml
modid="myfpsmatchmod"
version="1.0"
displayName="My FPSMatch Mod"
description="A mod that extends FPSMatch"

[[dependencies]]
modId="fpsmatch"
mandatory=true
versionRange="[1.3.0,)"

[[dependencies]]
modId="kotlinforforge"
mandatory=true
versionRange="[4.11.0,)"

# 仅客户端需要(服务端可不装)
[[dependencies]]
modId="modernui"
mandatory=false
side="CLIENT"
versionRange="[3.12.0.1,)"

注册类

最后,我们需要创建一个空的注册类。这个类将用于注册我们的 mod 的内容,例如游戏类型、地图和能力。

// MyFPSMatchModRegistry.java
public class MyFPSMatchModRegistry {
    // 这里可以注册我们的 mod 的内容
}

注册代码

FPSMatch 提供了专门的 Forge 事件用于注册游戏类型、存档数据、命令等内容。我们需要监听 RegisterFPSMapEvent 事件来注册游戏类型。这个事件是在 FPSMatch 初始化时触发的。

// MyFPSMatchMod.java
import net.ptcrys.fpsmatch.common.event.register.RegisterFPSMapEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;

@Mod("myfpsmatchmod")
public class MyFPSMatchMod {
    public MyFPSMatchMod() {
        // 注册事件总线
        FMLJavaModLoadingContext.get().getModEventBus().addListener(this::setup);
        // 监听 Forge 事件总线(FPSMatch 的注册事件在 Forge 事件总线上)
        MinecraftForge.EVENT_BUS.register(this);
    }

    private void setup(FMLCommonSetupEvent event) {
        // 这里可以进行一些 mod 的初始化工作
    }

    @SubscribeEvent
    public void onRegisterGameType(RegisterFPSMapEvent event) {
        // 注册我们的 mod 的游戏类型
        event.registerGameType("my_game_type", (level, mapName, areaData) -> new MyGameTypeMap(level, mapName, areaData));
    }
}

为什么要监听 FPSMatch 的注册事件

我们需要监听 RegisterFPSMapEvent 事件,以便在 FPSMatch 注册其内容时注册我们的 mod 的内容。这样可以确保我们的 mod 的游戏类型被正确注册和加载。注意这个事件在 MinecraftForge.EVENT_BUS 上,而不是 mod 事件总线上。

完整可抄的代码

以下是完整可抄的代码:

// MyFPSMatchMod.java
import net.ptcrys.fpsmatch.common.event.register.RegisterFPSMapEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;

@Mod("myfpsmatchmod")
public class MyFPSMatchMod {
    public MyFPSMatchMod() {
        // 注册事件总线
        FMLJavaModLoadingContext.get().getModEventBus().addListener(this::setup);
        // 监听 Forge 事件总线(FPSMatch 的注册事件在 Forge 事件总线上)
        MinecraftForge.EVENT_BUS.register(this);
    }

    private void setup(FMLCommonSetupEvent event) {
        // 这里可以进行一些 mod 的初始化工作
    }

    @SubscribeEvent
    public void onRegisterGameType(RegisterFPSMapEvent event) {
        // 注册我们的 mod 的游戏类型
        event.registerGameType("my_game_type", (level, mapName, areaData) -> new MyGameTypeMap(level, mapName, areaData));
    }
}

// MyFPSMatchModRegistry.java
public class MyFPSMatchModRegistry {
    // 这里可以注册我们的 mod 的内容
}

// mods.toml
modid="myfpsmatchmod"
version="1.0"
displayName="My FPSMatch Mod"
description="A mod that extends FPSMatch"

[[dependencies]]
modId="fpsmatch"
mandatory=true
versionRange="[1.3.0,)"

[[dependencies]]
modId="kotlinforforge"
mandatory=true
versionRange="[4.11.0,)"

# 仅客户端需要服务端可不装
[[dependencies]]
modId="modernui"
mandatory=false
side="CLIENT"
versionRange="[3.12.0.1,)"

Warning

注意:上述代码只是一个基本的 mod 骨架,需要根据实际需求进行修改和扩展。同时,需要确保依赖的 mod 版本与实际使用的版本相符。


核心概念:地图、游戏类型、队伍、能力、回合

在 FPSMatch 中,有几个核心概念需要理解:游戏类型、地图实例、队伍、玩家数据、能力和回合。下面我们将通过生活化的类比来解释这些概念及其关系。

  • 游戏类型:可以想象成一个玩法模板,比如 CS 的死亡竞赛(csdm)模式。你在代码里对应写什么:需要注册一个游戏类型,使用 registerGameType 方法。
  • 地图实例:是一个具体的房间,比如一个 CS 的地图。你在代码里对应写什么:需要创建一个 BaseMap 对象,并注册到 FPSMCore 中。
  • 队伍:在一个地图实例中,可以有多个队伍,比如 Terrorist 和 Counter-Terrorist。你在代码里对应写什么:需要创建一个 TeamData 对象,并添加到地图实例的 MapTeams 中。
  • 玩家数据:每个玩家在一个队伍中,都有自己的数据,比如 KDA 统计。你在代码里对应写什么:需要使用 PlayerData 类来存储和管理玩家数据。
  • 能力:可以给地图或队伍「挂功能」,比如爆破模式或商店系统。你在代码里对应写什么:需要创建一个 MapCapabilityTeamCapability 对象,并添加到地图或队伍中。
  • 回合:在一些游戏类型中,可能会有多个回合,比如一个地图实例中的多个回合。你在代码里对应写什么:需要创建一个 BaseRoundMap 对象,并使用 RoundLifecycle 来管理回合的生命周期。

这些概念的生命周期顺序是:注册游戏类型 → 创建地图实例 → 加入队伍 → 开局 → 回合 → 结算 → 重置。在整个过程中,能力和玩家数据会被不断更新和管理。

总结

通过上面的解释,我们可以看到 FPSMatch 中的核心概念是如何相互关联和作用的。理解这些概念和它们的关系是开发一个 FPSMatch 模组的关键。下一步,我们将深入探讨如何注册游戏类型和创建地图实例。

Warning

注意:在开发过程中,需要注意各个概念的生命周期和相互关系,以避免错误和冲突。同时,需要参考 FPSMatch 的官方文档和 API 以获取最新的信息和示例。


注册你的游戏类型:RegisterFPSMapEvent

在 FPSMatch 中,注册游戏类型是通过 RegisterFPSMapEventregisterGameType 方法实现的。这个方法需要三个参数:游戏类型名、地图名和区域数据,返回一个 BaseMap 的实例。

函数式接口

registerGameType 方法的第二个参数是一个函数式接口 Function3<ServerLevel, String, AreaData, BaseMap>。这个接口定义了一个函数,它接受三个参数:服务器等级、地图名和区域数据,返回一个 BaseMap 的实例。

在这个函数中,你需要创建一个你的游戏类型对应的 BaseMap 子类实例,并返回它。这个实例将被 FPSMatch 用来创建一个新的游戏实例。

注册监听器

要注册游戏类型,你需要在 Forge 事件订阅中监听 RegisterFPSMapEvent 事件。这个事件会在 FPSMatch 初始化时被触发。

@Mod.EventBusSubscriber(modid = "yourmodid", bus = Mod.EventBusSubscriber.Bus.FORGE)
public class YourMod {
    @SubscribeEvent
    public static void onRegisterFPSMapEvent(RegisterFPSMapEvent event) {
        event.registerGameType("yourgametypename", (level, mapName, areaData) -> new YourGameTypeMap(level, mapName, areaData));
    }
}

游戏类型名约定

游戏类型名应该是小写的,并且应该遵循一定的约定。例如,csdm 是一个常见的游戏类型名,表示 "Counter-Strike DeathMatch"。

示例

下面是一个完整的示例,包括一个 MyTdmMap 类和注册监听器的代码:

// MyTdmMap.java
public class MyTdmMap extends BaseMap {
    public MyTdmMap(ServerLevel level, String mapName, AreaData areaData) {
        super(level, mapName, areaData);
    }

    @Override
    public boolean victoryGoal() {
        // 实现你的游戏胜利条件
        return false;
    }

    @Override
    public String getGameType() {
        return "tdm";
    }
}

// YourMod.java
@Mod.EventBusSubscriber(modid = "yourmodid", bus = Mod.EventBusSubscriber.Bus.FORGE)
public class YourMod {
    @SubscribeEvent
    public static void onRegisterFPSMapEvent(RegisterFPSMapEvent event) {
        event.registerGameType("tdm", (level, mapName, areaData) -> new MyTdmMap(level, mapName, areaData));
    }
}

验证注册

要验证你的游戏类型是否注册成功,你可以使用 /fpsm 命令来查看所有注册的游戏类型。或者,你可以使用 FPSMCore.getInstance().checkGameType(name) 方法来检查你的游戏类型是否注册。

// YourMod.java
@Mod.EventBusSubscriber(modid = "yourmodid", bus = Mod.EventBusSubscriber.Bus.FORGE)
public class YourMod {
    @SubscribeEvent
    public static void onServerStarted(FMLServerStartedEvent event) {
        if (FPSMCore.getInstance().checkGameType("tdm")) {
            System.out.println("游戏类型 tdm 注册成功");
        } else {
            System.out.println("游戏类型 tdm 注册失败");
        }
    }
}

写第一张地图:继承 BaseMap

你会看到,我们需要继承 BaseMap 类,并实现两个抽象方法:victoryGoal()getGameType()。让我们一步一步来做。

步骤 1:继承 BaseMap

首先,我们需要创建一个新的 Java 类,并继承 BaseMap 类:

public class MyMap extends BaseMap {
    // ...
}

步骤 2:实现构造器

接下来,我们需要实现构造器,调用 super 方法:

public MyMap(ServerLevel level, String mapName, AreaData area) {
    super(level, mapName, area);
}

步骤 3:实现抽象方法

现在,我们需要实现两个抽象方法:victoryGoal()getGameType()。让我们以「团队死斗先到 30 杀」为例子:

@Override
public boolean victoryGoal() {
    // 获取队伍比分
    MapTeams teams = getMapTeams();
    for (ServerTeam team : teams.getNormalTeams()) {
        // 如果队伍杀敌数达到 30
        if (team.getPlayersData().stream().mapToInt(PlayerData::getKills).sum() >= 30) {
            return true;
        }
    }
    return false;
}

@Override
public String getGameType() {
    return "MyMap";
}

步骤 4:覆写其他方法(可选)

除了抽象方法之外,我们还可以覆写其他方法来实现自定义逻辑。这些方法包括:

  • start(): 开局逻辑
  • victory(): 胜利逻辑
  • reset(): 重置逻辑
  • syncToClient(): 客户端同步逻辑
  • tick(): 每 tick 逻辑
  • handleDeath(DeathContext): 处理死亡逻辑
  • cleanupMap(): 清理地图逻辑

注意:mapTick()checkForVictory() 方法是 final 的,不要尝试覆写它们。

完整类代码

以下是完整的 MyMap 类代码:

public class MyMap extends BaseMap {
    public MyMap(ServerLevel level, String mapName, AreaData area) {
        super(level, mapName, area);
    }

    @Override
    public boolean victoryGoal() {
        // 获取队伍比分
        MapTeams teams = getMapTeams();
        for (ServerTeam team : teams.getNormalTeams()) {
            // 如果队伍杀敌数达到 30
            if (team.getPlayersData().stream().mapToInt(PlayerData::getKills).sum() >= 30) {
                return true;
            }
        }
        return false;
    }

    @Override
    public String getGameType() {
        return "MyMap";
    }
}

在游戏里创建/进入这张地图

要在游戏里创建/进入这张地图,你需要注册游戏类型和地图。具体步骤请参考《注册游戏类型和地图》章节。

Tip

记得注册游戏类型和地图,否则你无法在游戏里创建/进入这张地图。


回合制玩法:BaseRoundMap 与 RoundLifecycle

在 FPSMatch 中,回合制玩法是通过 BaseRoundMapRoundLifecycle 实现的。回合制玩法适用于爆破、竞技等游戏模式。

继承 BaseRoundMap

要创建一个回合制玩法的 mod,你需要继承 BaseRoundMap 类。BaseRoundMap 类有一个抽象方法 buildRoundLifecycle(),你需要实现这个方法来创建一个 RoundLifecycle 实例。

public class MyRoundMap extends BaseRoundMap<MyRoundMap, MyRoundResult> {
    @Override
    protected RoundLifecycle<MyRoundMap, MyRoundResult> buildRoundLifecycle() {
        // 创建 RoundLifecycle 实例
        return RoundLifecycle.builder()
                // 等待时间(tick)
                .waitingTicks(20 * 10) // 10 秒
                // 回合时间(tick)
                .roundTicks(20 * 60 * 3) // 3 分钟
                // 回合结束等待时间(tick)
                .roundEndTicks(20 * 10) // 10 秒
                // 回合开始回调
                .onRoundStart(() -> {
                    // 回合开始时执行的代码
                })
                // 回合结束回调
                .onRoundEnd(result -> {
                    // 回合结束时执行的代码
                })
                // 下一回合请求回调
                .onNextRoundRequested(() -> {
                    // 下一回合请求时执行的代码
                })
                // 等待时 tick 回调
                .onWaitingTick(ctx -> {
                    // 等待时 tick 执行的代码
                })
                // 回合时 tick 回调
                .onRoundTick(ctx -> {
                    // 回合时 tick 执行的代码
                })
                .build();
    }
}

回调函数

  • onRoundStart(): 回合开始时触发。
  • onRoundEnd(result): 回合结束时触发,参数 result 为回合结果。
  • onNextRoundRequested(): 下一回合请求时触发。
  • onWaitingTick(ctx): 等待时 tick 触发,参数 ctx 为回合上下文。
  • onRoundTick(ctx): 回合时 tick 触发,参数 ctx 为回合上下文。

完整骨架代码

以下是「3 回合、每回合 3 分钟」的完整骨架代码:

public class MyRoundMap extends BaseRoundMap<MyRoundMap, MyRoundResult> {
    private int roundCount = 0;

    @Override
    protected RoundLifecycle<MyRoundMap, MyRoundResult> buildRoundLifecycle() {
        return RoundLifecycle.builder()
                .waitingTicks(20 * 10) // 10 秒
                .roundTicks(20 * 60 * 3) // 3 分钟
                .roundEndTicks(20 * 10) // 10 秒
                .onRoundStart(() -> {
                    roundCount++;
                    System.out.println("回合 " + roundCount + " 开始");
                })
                .onRoundEnd(result -> {
                    System.out.println("回合 " + roundCount + " 结束,结果: " + result);
                    if (roundCount >= 3) {
                        // 3 回合结束,游戏结束
                    } else {
                        // 请求下一回合
                    }
                })
                .onNextRoundRequested(() -> {
                    System.out.println("下一回合请求");
                })
                .onWaitingTick(ctx -> {
                    System.out.println("等待时 tick");
                })
                .onRoundTick(ctx -> {
                    System.out.println("回合时 tick");
                })
                .build();
    }
}

Tip

在实现回合制玩法时,需要注意回合的开始和结束时机,以及回合之间的等待时间。同时,需要处理好回合结果和下一回合的请求。


让地图可配置:Setting

在开发地图 mod 的过程中,你可能会遇到需要让管理员能够在游戏内配置某些数值的情况。这时候,Setting<T> 就派上用场了。Setting<T> 是一个通用类,能够让你定义一个可配置的数值,并能够自动存档和同步到客户端。

为什么要用 Setting

使用 Setting<T> 有几个好处:

  • 管理员可以在游戏内修改配置,而不需要修改代码。
  • 配置可以自动存档,当服务器重启时,配置会被保留。
  • 配置可以自动同步到客户端,客户端可以实时看到配置的变化。

添加 Setting

要添加一个 Setting<T>,你可以使用 addSetting 方法或者 Setting.of 静态工厂方法。这里我们以 BaseMap 为例,展示如何添加一个 Setting<T>

// 添加一个整数类型的 Setting
public Setting<Integer> maxRounds = addSetting("maxRounds", 10);

// 添加一个整数类型的 Setting,带有分类
public Setting<Integer> maxRounds = addSetting("游戏设置", "maxRounds", 10);

或者使用 Setting.of 静态工厂方法:

// 添加一个整数类型的 Setting
public Setting<Integer> maxRounds = Setting.of("maxRounds", 10);

// 添加一个整数类型的 Setting,带有分类
public Setting<Integer> maxRounds = Setting.of("游戏设置", "maxRounds", 10);

读取和修改 Setting

要读取 Setting<T> 的值,你可以使用 get 方法:

int maxRoundsValue = maxRounds.get();

要修改 Setting<T> 的值,你可以使用 set 方法:

maxRounds.set(20);

BaseMap 自带的 Setting

BaseMap 已经自带了一些 Setting<T>,例如:

  • displayName:地图的显示名称。
  • iconTexture:地图的图标纹理。
  • backgroundTexture:地图的背景纹理。
  • autoStart:是否自动开始游戏。
  • autoStartTime:自动开始游戏的时间。
  • readyStartEnabled:是否启用准备开始游戏。
  • readyStartTime:准备开始游戏的时间。
  • minAssistDamageRatio:最小助攻伤害比例。
  • allowJoinInProgress:是否允许在游戏进行中加入。
  • teammateGlow:队友发光效果。
  • enemyGlow:敌人发光效果。
  • hideEnemyNameTag:是否隐藏敌人名称标签。

这些 Setting<T> 可以通过 get 方法读取和 set 方法修改。

自定义 Setting 示例

下面是一个自定义 Setting<T> 的示例:

// 添加一个整数类型的 Setting
public Setting<Integer> maxRounds = addSetting("maxRounds", 10);

// 读取 maxRounds 的值
int maxRoundsValue = maxRounds.get();

// 修改 maxRounds 的值
maxRounds.set(20);

在这个示例中,我们添加了一个名为 maxRoundsSetting<T>,其类型为整数,默认值为 10。然后,我们读取了 maxRounds 的值,并将其修改为 20。

Tip

使用 Setting<T> 可以让你的 mod 更加灵活和易于配置。记得要使用 get 方法读取 Setting<T> 的值,并使用 set 方法修改 Setting<T> 的值。


能力系统:给地图/队伍挂功能

在 FPSMatch 中,能力系统(Capability)是给地图或队伍添加功能的关键机制。为什么要用能力系统,而不是简单地通过继承来实现呢?这是因为继承关系会导致类的层次结构变得复杂,而能力系统可以让我们更灵活地组合不同的功能。

FPSMCapability 的生命周期方法

FPSMCapability 是能力系统的基类,它有几个重要的生命周期方法:

  • init(): 初始化能力
  • tick(): 每 tick 更新能力
  • reset(): 重置能力
  • destroy(): 销毁能力

这些方法可以在能力类中被覆写,以实现特定的功能。

MapCapability 和 TeamCapability

MapCapabilityTeamCapability 是两个重要的能力类,它们分别对应地图和队伍。这些类可以被用来给地图或队伍添加特定的功能。

注册能力

要使用能力系统,首先需要注册能力类。可以使用 FPSMCapabilityManagerregister() 方法来注册能力类:

FPSMCapabilityManager.register(
    FPSMCapabilityManager.CapabilityType.MAP,
    MyMapCapability.class,
    new FPSMCapability.Factory<BaseMap, MyMapCapability>() { // 泛型顺序是 <持有者, 能力类>
        @Override
        public MyMapCapability create(BaseMap holder) {
            return new MyMapCapability(holder);
        }
    });

声明能力列表

在地图构造器中,可以声明能力列表:

public MyMap(ServerLevel level, String mapName, AreaData area) {
    super(level, mapName, area, Arrays.asList(MyMapCapability.class));
}

获取能力实例

可以通过 CapabilityMap 来获取能力实例:

// 注意:get(...) 返回 Optional,一定要判空
CapabilityMap<BaseMap, MapCapability> caps = getCapabilityMap();
caps.get(MyMapCapability.class).ifPresent(cap -> {
    // 拿到能力实例,随便用
});
// 也可以直接写一行的静态写法(返回 Optional):
// CapabilityMap.getMapCapability(this, MyMapCapability.class).ifPresent(cap -> { ... });

内置能力

1.3.0 版本中,内置了 DemolitionModeCapabilityGameEndTeleportCapability 两个能力,可以直接参考它们的写法。

示例:击杀播报能力

下面是一个最小的击杀播报能力示例,继承 MapCapability(它已经帮你实现了 getHolder() 等方法):

public class KillAnnounceCapability extends MapCapability {
    public KillAnnounceCapability(BaseMap map) {
        super(map);
    }

    @Override
    public void tick() {
        // 每 tick 更新能力
    }

    @Override
    public void init() {
        // 初始化能力
    }

    @Override
    public void reset() {
        // 重置能力
    }

    @Override
    public void destroy() {
        // 销毁能力
    }
}
可以通过注册这个能力类,并在地图构造器中声明能力列表,来使用这个能力。

Warning

注意:能力系统的使用需要谨慎,避免能力之间的冲突和循环依赖。同时,能力类的注册和使用需要遵循 FPSMatch 的 API 规范。


队伍与玩家数据:分组、比分、KDA

在 FPSMatch 中,队伍和玩家数据是通过 TeamDataPlayerData 类来管理的。这里我们将介绍如何定义队伍、创建队伍、查询队伍和玩家数据,以及如何统计胜方队伍的 KDA 并广播。

定义队伍

要定义一个队伍,你需要创建一个 TeamData 对象。TeamData 有两个构造器:一个需要队伍名称和人数限制,另一个还需要一个队伍能力列表。这里我们使用第一个构造器:

TeamData teamData = TeamData.of("队伍名称", 10);

创建队伍

创建队伍需要将 TeamData 对象添加到 BaseMap 中:

BaseMap map = ...; // 获取地图对象
map.addTeam(teamData);

查询队伍和玩家数据

你可以通过 MapTeams 类来查询队伍和玩家数据。MapTeams 提供了多个方法来获取队伍和玩家数据,例如:

MapTeams mapTeams = ...; // 获取地图队伍管理器
Optional<ServerTeam> team = mapTeams.getTeamByPlayer(player);
Optional<Pair<ServerTeam, PlayerData>> teamAndData = mapTeams.getPlayerTeamAndData(player);

ServerTeam 的常用方法

ServerTeam 类提供了多个方法来管理队伍数据,例如:

ServerTeam team = ...; // 获取队伍对象
List<UUID> onlinePlayers = team.getOnlinePlayers(); // 返回 UUID 列表
List<ServerPlayer> online = team.getOnline(); // 返回 ServerPlayer 列表
int playerCount = team.getPlayerCount();
List<UUID> livingPlayers = team.getLivingPlayers(); // 存活玩家的 UUID 列表
List<ServerPlayer> online = team.getOnline();       // 当前在线的 ServerPlayer 列表
team.sendMessage(Component.literal("队伍消息"));

PlayerData 的统计字段

PlayerData 类提供了多个统计字段(通过 getter 方法访问),例如:

PlayerData data = ...; // 获取玩家数据
int kills = data.getKills();
int deaths = data.getDeaths();
int assists = data.getAssists();
float damage = data.getDamage();

结算时统计胜方队伍 KDA 并广播

这里我们提供一个例子,展示如何在结算时统计胜方队伍的 KDA 并广播:

// 获取胜方队伍
ServerTeam winningTeam = ...;

// 统计 KDA
int kills = 0;
int deaths = 0;
int assists = 0;
for (PlayerData data : winningTeam.getPlayersData()) {
    kills += data.getKills();
    deaths += data.getDeaths();
    assists += data.getAssists();
}

// 广播 KDA
Component message = Component.literal("胜方队伍 KDA: " + kills + " / " + deaths + " / " + assists);
winningTeam.sendMessage(message);
注意:在使用 Optional 类时,需要判空以避免 NoSuchElementException。例如:
Optional<ServerTeam> team = mapTeams.getTeamByPlayer(player);
if (team.isPresent()) {
    ServerTeam serverTeam = team.get();
    // 处理队伍数据
} else {
    // 处理队伍不存在的情况
}

Warning

在使用 Optional 类时,务必判空以避免 NoSuchElementException。同时,需要注意 ServerTeamPlayerData 对象的线程安全性,以避免并发访问问题。


商店与经济:FPSMShop

在 FPSMatch 1.3.0 中,商店体系是通过 FPSMShop 类实现的。要使用商店功能,首先需要定义一个枚举类,实现 INamedType 接口。这个接口要求实现四个方法:name()slotCount()dorpUnlock()defaultSlots()

定义枚举类

下面是一个示例枚举类,定义了两个商品类型:手枪和长枪。注意 ShopSlot 的构造器接受 ItemStack 和价格,你需要用实际的物品来创建商品槽。

public enum GunType implements INamedType {
    HANDGUN("handgun", 5, true, 
        new ArrayList<>(Arrays.asList(
            new ShopSlot(new ItemStack(Items.IRON_SWORD), 100),
            new ShopSlot(new ItemStack(Items.ARROW), 50)
        ))
    ),
    RIFLE("rifle", 3, false, 
        new ArrayList<>(Arrays.asList(
            new ShopSlot(new ItemStack(Items.DIAMOND_SWORD), 200),
            new ShopSlot(new ItemStack(Items.CROSSBOW), 150)
        ))
    );

    private final String name;
    private final int slotCount;
    private final boolean dorpUnlock;
    private final ArrayList<ShopSlot> defaultSlots;

    GunType(String name, int slotCount, boolean dorpUnlock, ArrayList<ShopSlot> defaultSlots) {
        this.name = name;
        this.slotCount = slotCount;
        this.dorpUnlock = dorpUnlock;
        this.defaultSlots = defaultSlots;
    }

    @Override
    public String name() {
        return name;
    }

    @Override
    public int slotCount() {
        return slotCount;
    }

    @Override
    public boolean dorpUnlock() {
        return dorpUnlock;
    }

    @Override
    public ArrayList<ShopSlot> defaultSlots() {
        return defaultSlots;
    }
}

注册商店类型

定义好枚举类后,需要将其注册为商店类型。

FPSMShop.registerShopType("gun", GunType.class);

创建商店

注册好商店类型后,可以创建一个商店实例。

FPSMShop<GunType> shop = FPSMShop.create(GunType.class, "枪械商店");
也可以指定起始金钱。
FPSMShop<GunType> shop = FPSMShop.create(GunType.class, "枪械商店", 1000);

商店数据

商店数据会随地图存档,包括每个玩家的余额和已购商品。因此,修改商店数据时需要注意兼容性。

ShopSlot 和 ShopSlotPurchaseRules

ShopSlot 类代表一个商品槽,包含商品名称和价格。ShopSlotPurchaseRules 类代表购买规则,可以用来限制购买数量或设置购买条件。

ShopData

ShopData 类存储每个玩家的商店数据,包括余额和已购商品。

通过这些类和方法,可以实现一个基本的商店体系。记得在修改商店数据时注意兼容性,以避免数据损坏或异常。


事件系统:监听对局、队伍、枪械

FPSMatch 的事件系统允许你监听各种游戏事件,例如对局开始、队伍加入、枪械射击等。这些事件可以帮助你实现自定义的游戏逻辑和功能。

事件类型

FPSMatch 提供了多种事件类型,包括:

  • FPSMapEvent:对局相关事件,例如对局开始、胜利、清除、重置和重载。
  • FPSMapEvent.PlayerEvent:玩家相关事件,例如玩家加入、离开、受伤、死亡、击杀、记录击杀、捡起物品、丢弃物品、聊天、登录和退出。
  • FPSMTeamEvent:队伍相关事件,例如队伍加入和离开。
  • 枪械相关事件,例如 FPSMGunFireEventFPSMGunShootEventFPSMGunReloadEventFPSMGunKillEventFPSMGunDamageEventFPSMThrowGrenadeEventFPSMShopEventFPSMReloadEvent

事件参数

每个事件都有其特定的参数,你可以通过这些参数获取事件相关的信息。例如:

  • FPSMapEvent:可以获取对局地图 (map)。
  • FPSMapEvent.PlayerEvent:可以获取玩家 (player)、对局地图 (map)、死亡原因 (source)、伤害金额 (amount) 等。
  • FPSMTeamEvent:可以获取队伍 (team) 和玩家 (player)。

可取消事件

有些事件是可取消的,例如 FPSMThrowGrenadeEventFPSMapEvent.StartEventFPSMapEvent.PlayerEvent.KillEvent / DeathEvent(它们在源码里实现了 isCancelable(),返回 true)。你可以通过调用事件的 setCanceled(true) 方法来取消事件。

Warning

FPSMGunFireEvent 这类枪械事件不可取消(源码里没有覆写 isCancelable()),对它调 setCanceled(true) 不会有任何效果。写之前先确认目标事件的 isCancelable() 是不是 true

代码示例

以下是三个监听代码示例:

击杀记分

@SubscribeEvent
public void onKill(FPSMGunKillEvent event) {
    // 获取杀死的玩家(LivingEntity)
    LivingEntity attacker = event.getAttacker();
    // 获取被杀死的玩家(LivingEntity)
    LivingEntity dead = event.getKilledEntity();
    // 注意:FPSMGunKillEvent 不直接提供 BaseMap,需要通过 FPSMCore 查询
    if (attacker instanceof ServerPlayer killerPlayer) {
        Optional<BaseMap> mapOpt = FPSMCore.getInstance().getMapByPlayer(killerPlayer);
        if (mapOpt.isPresent()) {
            BaseMap map = mapOpt.get();
            // 获取杀死玩家的数据
            Optional<PlayerData> killerDataOpt = map.getMapTeams().getPlayerData(killerPlayer);
            if (killerDataOpt.isPresent()) {
                PlayerData killerData = killerDataOpt.get();
                // 增加杀死玩家的积分
                killerData.addScore(10);
                // 标记数据为脏数据
                killerData.markDirty();
            }
        }
    }
}

开局公告

@SubscribeEvent
public void onStart(FPSMapEvent.StartEvent event) {
    // 获取对局地图
    BaseMap map = event.getMap();
    // 发送开局公告给所有在线玩家
    for (ServerTeam team : map.getMapTeams().getNormalTeams()) {
        for (ServerPlayer player : team.getOnline()) {
            player.sendSystemMessage(Component.literal("对局开始!"));
        }
    }
}

死亡掉落处理

@SubscribeEvent
public void onDeath(FPSMapEvent.PlayerEvent.DeathEvent event) {
    // 获取死亡的玩家
    ServerPlayer dead = event.getPlayer();
    // 获取对局地图
    BaseMap map = event.getMap();
    // 获取死亡原因
    DamageSource source = event.getSource();
    // 处理死亡掉落
    if (source != null) {
        //掉落物品
        dead.getInventory().dropAll();
    }
}

Warning

注意:在使用事件系统时,需要确保事件处理方法的参数类型正确,并且事件处理方法需要被 @SubscribeEvent 注解标记。


把数据存下来:RegisterFPSMSaveDataEvent 与 SaveHolder

当你需要存储一些跨地图或跨重启的数据时,例如玩家配置或统计数据,你需要使用 SaveHolder 来注册你的数据。这里,我们将一步步地讲解如何使用 SaveHolder 来存储数据。

什么时候需要自己存数据

在 FPSMatch 中,你可能需要存储一些数据,例如:

  • 玩家的总击杀数
  • 玩家的配置信息
  • 地图的统计数据

这些数据需要在游戏重启或地图切换后仍然保留。

使用 SaveHolder.Builder

要注册你的数据,你需要创建一个 SaveHolder 实例。这里,我们将使用 SaveHolder.Builder 来创建一个 SaveHolder 实例。

// 使用 RecordCodecBuilder 创建 Codec(推荐方式)
public static final Codec<MyData> MY_DATA_CODEC = RecordCodecBuilder.create(instance -> instance.group(
    Codec.INT.fieldOf("version").forGetter(MyData::getVersion),
    Codec.STRING.fieldOf("name").forGetter(MyData::getName)
).apply(instance, MyData::new));

// 创建一个 SaveHolder.Builder 实例
SaveHolder.Builder<MyData> builder = new SaveHolder.Builder<>(MY_DATA_CODEC);

// 设置版本号
builder.withVersion(1);

// 设置初始化器
builder.withInitializer(() -> new MyData());

// 设置读取处理器
builder.withLoadHandler(myData -> {
    // 读取数据到内存
});

// 设置写入处理器
builder.withSaveHandler(dataManager -> {
    // 写入数据到文件
});

// 设置是否为全局数据
builder.isGlobal(true);

// 设置合并处理器
builder.withMergeHandler((oldData, newData) -> {
    // 合并数据
    return oldData;
});

// 设置文件类型
builder.withFileType("json");

// 创建 SaveHolder 实例
SaveHolder<MyData> holder = builder.build();

readHandler 与 writeHandler

readHandler 负责读取数据从文件到内存,而 writeHandler 负责写入数据从内存到文件。

  • readHandler:当数据从文件读取到内存时,会调用 readHandler 来处理数据。
  • writeHandler:当数据从内存写入到文件时,会调用 writeHandler 来处理数据。

isGlobal

isGlobal 表示数据是否为全局数据。如果为 true,则数据只有一份,所有地图共享。如果为 false,则每个地图都有一份数据。

示例:记录每个玩家总击杀数

这里,我们将创建一个 SaveHolder 实例来记录每个玩家总击杀数。

// 创建一个数据类来存储玩家总击杀数
public class PlayerKillData {
    private final Map<UUID, Integer> killCount;

    public PlayerKillData() {
        this.killCount = new HashMap<>();
    }

    public PlayerKillData(Map<UUID, Integer> killCount) {
        this.killCount = killCount;
    }

    public Map<UUID, Integer> getKillCount() {
        return killCount;
    }

    public void addKill(UUID playerUUID) {
        killCount.put(playerUUID, killCount.getOrDefault(playerUUID, 0) + 1);
    }

    public int getKillCount(UUID playerUUID) {
        return killCount.getOrDefault(playerUUID, 0);
    }
}

// 创建一个 Codec 来编码和解码 PlayerKillData(使用 RecordCodecBuilder)
public static final Codec<PlayerKillData> PLAYER_KILL_DATA_CODEC = RecordCodecBuilder.create(instance -> instance.group(
    Codec.unboundedMap(Codec.STRING, Codec.INT)
        .fieldOf("killCount")
        .forGetter(data -> {
            Map<String, Integer> stringKeyMap = new HashMap<>();
            data.getKillCount().forEach((uuid, count) -> stringKeyMap.put(uuid.toString(), count));
            return stringKeyMap;
        })
).apply(instance, stringKeyMap -> {
    Map<UUID, Integer> uuidKeyMap = new HashMap<>();
    stringKeyMap.forEach((uuidStr, count) -> uuidKeyMap.put(UUID.fromString(uuidStr), count));
    return new PlayerKillData(uuidKeyMap);
}));

// 创建一个 SaveHolder 实例
SaveHolder.Builder<PlayerKillData> builder = new SaveHolder.Builder<>(PLAYER_KILL_DATA_CODEC);

// 设置版本号
builder.withVersion(1);

// 设置初始化器
builder.withInitializer(() -> new PlayerKillData());

// 设置读取处理器
builder.withLoadHandler(data -> {
    // 读取数据到内存
});

// 设置写入处理器
builder.withSaveHandler(dataManager -> {
    // 写入数据到文件
});

// 设置是否为全局数据
builder.isGlobal(true);

// 设置合并处理器
builder.withMergeHandler((oldData, newData) -> {
    // 合并数据
    return oldData;
});

// 设置文件类型
builder.withFileType("json");

// 创建 SaveHolder 实例
SaveHolder<PlayerKillData> holder = builder.build();

// 注册 SaveHolder 实例(在 RegisterFPSMSaveDataEvent 中调用)
event.registerData(PlayerKillData.class, "playerKillData", holder);

Warning

注意:在创建 SaveHolder 实例时,需要设置正确的 CodecversioninitializerreadHandlerwriteHandlerisGlobalfileType。否则,可能会导致数据损坏或无法读取。


扩展命令:RegisterFPSMCommandEvent

在 FPSMatch 中,你可以通过监听 RegisterFPSMCommandEvent 事件来扩展 /fpsm 命令。这个事件允许你添加自己的子命令到 /fpsm 命令下面。

监听 RegisterFPSMCommandEvent

首先,你需要监听 RegisterFPSMCommandEvent 事件。这个事件会在 FPSMatch 初始化命令系统时触发。

@SubscribeEvent
public void onRegisterFPSMCommandEvent(RegisterFPSMCommandEvent event) {
    // 在这里添加你的子命令
}

添加子命令

要添加子命令,你需要使用 addChild 方法将你的子命令添加到 /fpsm 命令下面。addChild 方法需要一个 LiteralArgumentBuilder 对象作为参数。

event.addChild(LiteralArgumentBuilder.literal("mymod")
    .then(LiteralArgumentBuilder.literal("give")
        .then(LiteralArgumentBuilder.argument("player", EntityArgument.player())
            .then(LiteralArgumentBuilder.argument("amount", IntegerArgumentType.integer())))));

在这个例子中,我们添加了一个 /fpsm mymod give <player> <amount> 子命令。

获取 CommandBuildContext

在添加子命令时,你可能需要获取 CommandBuildContext 对象。这个对象提供了命令执行的上下文信息。

CommandBuildContext context = event.getContext();

补充帮助和参数

为了让你的子命令更易用,你可以使用 registerHelpregisterParameters 方法来补充帮助和参数。

event.registerHelp("mymod give", "给予玩家一定数量的物品");
event.registerParameters("mymod give", "player", "amount");

完整示例

下面是一个完整的示例:

@SubscribeEvent
public void onRegisterFPSMCommandEvent(RegisterFPSMCommandEvent event) {
    CommandBuildContext buildContext = event.getContext();
    event.addChild(LiteralArgumentBuilder.literal("mymod")
        .then(LiteralArgumentBuilder.literal("give")
            .requires(source -> source.hasPermission(2)) // 需要 OP 等级 2
            .executes(ctx -> {                            // 这里的 ctx 是 CommandContext<CommandSourceStack>
                ServerPlayer target = EntityArgument.getPlayer(ctx, "player");
                int amount = IntegerArgumentType.getInteger(ctx, "amount");
                // 在这里实现给予玩家物品的逻辑
                return 1;
            })));
    event.registerHelp("mymod give", "给予玩家一定数量的物品");
    event.registerParameters("mymod give", "player", "amount");
}

Tip

注意变量名别撞车:event.getContext() 拿到的是 CommandBuildContext(建命令用的),而 .executes(...) 的入参是 CommandContext<CommandSourceStack>(执行时用的)。两个不要写成同一个名字,否则新手很容易搞混。

测试方法

要测试你的子命令,你可以在游戏中使用 /fpsm mymod give <player> <amount> 命令。记得替换 <player><amount> 为实际的玩家名称和数量。

Warning

注意:在添加子命令时,需要确保你的子命令不会与现有的命令冲突。同时,需要设置合适的权限等级来控制谁可以使用你的子命令。


客户端相机系统:CameraDirector 与镜头编排

FPSMatch 1.3.0 新增了客户端镜头底座:普通玩家视角仍由原版计算;只有「受限观战」与「临时场景」由 CameraDirector 仲裁。CameraBackend 是项目中唯一的相机实体与自定义姿态写入入口——不要再自己 new 相机实体或直接写相机坐标

控制权与生命周期

  • CameraSession独占句柄:低优先级请求返回 null;同级或更高级请求会替换当前会话并触发一次清理;旧句柄的 close() 不会关掉新会话。
  • 默认优先级:死亡 100,比赛过场 200CameraDirector 提供常量,如 CINEMATIC_PRIORITY)。设计新场景时取一个不冲突的级别(编辑器建议 50)。
  • 会话类型:PLAYER_LIFE 在重生时结束;SCENE 可跨过重生(例如传送前的入场黑幕)。地图/比赛重置、退服、世界卸载会清理所有会话。
  • 所有 API 只在客户端主线程调用;服务端代码不要引用 common.client.camera。服务端继续负责观战权限、冻结、传送与比赛阶段。

Rig(机位)与 Frame

CameraRig.sample(double ticks) 返回 CameraFrame:可选实体绑定 + 可选姿态 + 淡黑强度。姿态含位置、yaw、pitch、roll、FOV(NaN 表示继承原值)。内置 rig:

Rig 用途
EntityViewRig 绑定真实目标实体,保留 TaCZ 第一人称手部/枪械同步
FixedRig 独立固定机位(底座维护幽灵相机)
OrbitRig 环绕角度+半径;碰墙收近、离墙缓慢恢复
PathRig 有序关键帧、平滑插值、可选 look-at(跨 ±180° 走最短路径)
CollisionRig 把相机约束在锚点与目标机位之间的可见线段上,防穿墙

CameraPolicy

CameraPolicy 声明输入与呈现策略(是否锁视角、是否禁止移动/交互、能否切观战、隐藏手部/HUD/本地模型/toast、抑制画面特效、锁定人称)。内置预设:PLAYER / CINEMATIC / PREVIEW / DEATH / SPECTATOR / ORBIT。场景 HUD 请用 CameraOverlayEvent 绘制(统一入口保证每帧只派发一次)。

时间轴编排示例

SequenceClock clock = new SequenceClock();
clock.at(0, true, actors::start);
clock.at(20, false, audio::play); // 迟到 seek 时不补播瞬时音效

CameraSequence sequence = new CameraSequence(List.of(
    new CameraSequence.Shot(40, new FixedRig(startPose),
        CameraSequence.Transition.CUT, 0),
    new CameraSequence.Shot(80, pathRig,
        CameraSequence.Transition.BLEND, 10)
));

CameraSession session = CameraDirector.playSequence(
    "mymod:scene", CameraDirector.CINEMATIC_PRIORITY,
    sequence, CameraPolicy.CINEMATIC, clock,
    () -> sceneStillValid(),      // 有效性判定
    reason -> cleanupScene()      // 结束回调,释放自己持有的资源
);
  • playSequence 按总时长自动结束play 需由业务显式结束(适合等待观战目标这类变长流程)。
  • 不要再调用 clock.tick():调度器会推进会话时钟,业务只读 sample(partialTick)
  • 平滑混合(BLEND)只用于两个都提供姿态的镜头;实体附着镜头请用硬切或淡黑。

调试

  • 客户端执行 /fpsm camera:打印当前会话所有者、优先级、tick,或基础观战模式。
  • 自检任务:./gradlew check 包含 FPSMatch:cameraSystemCheck(抢占、旧句柄、清理重入、关键帧与过渡)等回归检查。

地图工具与地图 ID 规范(OP 编辑 / 区域编辑 / 导入 / 缩略图)

地图 ID 规则(1.3.0 起强制)

地图 ID 是持久化标识,会作为文件名与命令参数:

  • 长度 1–48;只允许小写 a-z、数字 0-9、下划线 _、连字符 -
  • 例:dust2metro_a
  • 给玩家看的名字请写在地图设置里的 displayName

权限与编辑保护

  • 地图创建工具、出生点工具都要求 OP 2 级权限。
  • 对局进行中会拒绝编辑地图边界与出生点game_started 类错误),避免破坏进行中的比赛。

出生点校验(比 1.2.5 严)

出生点必须:① 在地图区域内 且与地图同一维度;② 脚下有可碰撞支撑;③ 玩家脚部与头部两格无碰撞体;④ 两格无流体;⑤ 不与已有点重复。

新增:地图区域编辑与导入

1.3.0 新增了区域编辑与地图导入链路(对应网络包 MapRegionActionC2SPacketRequestMapImportSourcesC2SPacket / MapImportSourcesS2CPacket / ImportMapConfigC2SPacket):可在界面里编辑地图区域、列出可导入来源、把外部地图配置导入成新地图。命令入口仍走 /fpsm map ...(游戏内 /fpsm help 是最新事实来源)。

地图缩略图与详情背景

iconTexture / backgroundTexture 填的是 Minecraft 客户端资源位置(不是服务器绝对路径、不是 URL、不能 C:\...)。

  • 图片需存在于模组资源或每位玩家已启用的资源包中,否则界面显示色块兜底。
  • 推荐 PNG、16:9:图标 320x180640x360;详情背景至少 1280x720
  • 资源目录与填写值:
assets/fpsmatch/textures/gui/maps/dust2_icon.png   →   fpsmatch:textures/gui/maps/dust2_icon.png
assets/fpsmatch/textures/gui/maps/dust2_bg.png     →   fpsmatch:textures/gui/maps/dust2_bg.png
/fpsm map modify cs dust2 settings set iconTexture fpsmatch:textures/gui/maps/dust2_icon.png
/fpsm map modify cs dust2 settings set backgroundTexture fpsmatch:textures/gui/maps/dust2_bg.png
/fpsm map modify cs dust2 settings save

Tip

图片必须通过模组包 / 服务器资源包分发到每个客户端;只把 PNG 丢进服务器文件夹,玩家是加载不到的。

界面:LDLib2 已移除

1.3.0 的地图浏览、房间子页面、商店配置与购买界面全部改为原生 Modern UI 控件(LDLib2 相关界面类已从仓库删除)。想做风格统一的扩展 UI,请直接基于 Modern UI 写,不要再依赖 LDLib2。


KubeJS 联动:不写 Java 也能挂规则

从 1.3.0 版本开始,FPSMatch 官方提供了 KubeJS 的兼容支持,这意味着你可以使用 KubeJS 脚本语言来编写规则,而不需要编写 Java 代码。这种方式可以让你更快速、更方便地实现各种自定义功能。

事件组和事件名

在 KubeJS 中,FPSMatch 提供了一个名为 FPSMatchEvents 的事件组,你可以在这个事件组中监听各种事件。目前支持的事件名包括: - mapStart:地图开始时触发 - mapVictory:地图胜利时触发 - mapClear:地图清除时触发 - mapReset:地图重置时触发 - playerJoin:玩家加入地图时触发 - playerLeave:玩家离开地图时触发 - playerHurt:玩家受伤时触发 - playerDeath:玩家死亡时触发 - playerKill:玩家击杀其他玩家时触发 - teamJoin:玩家加入队伍时触发 - teamLeave:玩家离开队伍时触发 - playerLoggedIn:玩家登录时触发 - playerLoggedOut:玩家退出时触发

事件字段

每个事件都有一些字段可以被访问,例如: - map:当前地图 - player:触发事件的玩家 - dead:死亡的玩家(在 playerDeath 事件中) - source:伤害来源(在 playerHurtplayerDeath 事件中) - amount:伤害量(在 playerHurt 事件中,可以通过 setAmount 方法修改)

脚本示例

以下是三个实用脚本示例,展示了如何使用 KubeJS 来实现一些有用的功能:

示例 1:开局公告

// 开局公告:给地图里每支队伍的在线的玩家发一条提示
onEvent('FPSMatchEvents.mapStart', (event) => {
  const map = event.getMap()
  for (const team of map.getMapTeams().getNormalTeams()) {
    for (const player of team.getOnline()) {
      player.tell('§e游戏开始!')   // KJS 里用 tell 发消息,别用 Java 的 Component.literal
    }
  }
})

示例 2:击杀计数写入 persistentData

// 击杀计数写入玩家持久化数据
onEvent('FPSMatchEvents.playerKill', (event) => {
  const killer = event.getPlayer()          // 击杀者(ServerPlayer)
  // KJS 里访问 NBT 可以直接用属性写法,且不需要手动初始化
  killer.persistentData.kills = (killer.persistentData.kills || 0) + 1
})

Tip

KubeJS 的持久化数据是 player.persistentData(NBT 对象)。可以直接读属性、写属性,也可以用 killer.persistentData.contains('kills') 判断字段是否存在。

示例 3:伤害免疫保护区

// 伤害免疫保护区:坐标区间内不掉血
onEvent('FPSMatchEvents.playerHurt', (event) => {
  const p = event.getPlayer()
  if (p.x > 100 && p.x < 200 && p.z > 100 && p.z < 200) {
    event.setAmount(0)      // 把伤害改成 0,等效于免疫
  }
})

什么时候用 KJS,什么时候必须写 Java

以下是判断表,帮助你决定何时使用 KubeJS,何时必须写 Java 代码:

功能 必须 Java 可以 KJS
注册玩法/地图类型
能力(Capability)
商店类型
奖励、播报、统计、活动倍率

一般来说,如果你需要实现一些简单的规则,例如发送公告、统计击杀数、保护区等,KubeJS 是一个很好的选择。然而,如果你需要注册新的玩法、地图、能力或商店类型,还是需要写 Java 代码。


调试与常见错误排查

在开发 FPSMatch 模组的过程中,新手开发者可能会遇到一些常见的问题。下面我们将汇总这些问题,并提供相应的解决方法。

1. Dev 环境 Mixin 崩溃

现象: 在开发环境中,模组启动时崩溃,报错信息中包含「@Shadow method m_xxxxx_ was not located」。 原因: 这是因为在开发环境中,Mixin 使用的方法名与实际的方法名不同。开发环境使用的是 mojmap,而不是 SRG 名。 怎么查: 可以使用 Linkie 或 genSources 工具来查找实际的方法名。然后,在 @Shadow 注解中使用实际的方法名,而不是 SRG 名。

2. @Shadow 在 Dev 要写可读名

现象: 在开发环境中,使用 @Shadow 注解时,方法名不正确,导致模组崩溃。 原因: 这是因为在开发环境中,@Shadow 注解需要使用可读名,而不是 SRG 名。 怎么查: 可以使用 Linkie 或 genSources 工具来查找实际的方法名。然后,在 @Shadow 注解中使用实际的方法名。

3. FPSMCore 查询返回 Optional 忘了判空

现象: 使用 FPSMCore 查询方法时,返回的 Optional 对象没有进行判空检查,导致模组崩溃。 原因: 这是因为 FPSMCore 查询方法返回的 Optional 对象可能为空,如果没有进行判空检查,会导致模组崩溃。 怎么查: 可以使用 isPresent() 方法来检查 Optional 对象是否为空,如果为空,则不进行后续操作。

4. 缺 kotlinforforge / Modern UI 导致启动崩溃

现象: 模组启动时崩溃,报错 Missing mandatory dependencies,点名 kotlinforforgemodernui原因: 依赖没声明齐全(composite build 不传递上游运行时依赖;客户端需要 Modern UI,服务端不需要)。 怎么查: 在根 build.gradle 里补 ModernUI-Forge + kotlinforforge1.3.0 不要再加 ldlib2(ldlib2 会拖入独立 kotlin-stdlib,与 kotlinforforge 抢 kotlin.jvm.functionsResolutionException)。

4.1 客户端与服务端协议不一致

现象: 进服被拒 / 界面异常,日志提示协议版本不匹配。 原因: FPSMatch 的网络协议号在 1.3.0 已升到 1.4.2(旧快照是 1.4.0)。客户端与服务端必须使用同一份 FPSMatch,混用会握手失败。 怎么查: 两端换成同一个快照 jar(如 fpsmatch-1.20.1-1.3.0-forge-snapshot-20260916-1623)。

5. 注册时机不对

现象: 模组注册时机不正确,导致模组无法正常工作。 原因: 这是因为模组注册时机不正确,可能是注册太早或太晚。 怎么查: 可以检查模组的注册代码,确保注册时机正确。

6. 数据没存上

现象: 模组数据没有存储上,导致模组无法正常工作。 原因: 这是因为模组没有正确配置 SaveHolder 或 registerData。 怎么查: 可以检查模组的 SaveHolder 和 registerData 配置,确保正确配置。

测试流程清单

  1. 进入开发客户端。
  2. 创建一个新地图。
  3. 开启游戏。
  4. 击杀敌人。
  5. 结算游戏。
  6. 查看日志,检查是否有错误信息。

通过这些步骤,可以帮助开发者快速定位和解决问题。同时,也可以帮助开发者更好地理解模组的工作原理和调试方法。


打包与发布:让别人也能玩

打包流程

要让别人也能玩你的 mod,你需要将其打包成一个 jar 文件。这个过程很简单,你只需要在终端中运行以下命令:

./gradlew build

这个命令会编译你的代码,打包成一个 jar 文件,并将其放在 build/libs 目录下。

产物位置

打包完成后,你可以在 build/libs 目录下找到你的 mod 的 jar 文件。这个文件就是你需要发布的文件。

正式发布时依赖

在正式发布时,你应该使用官方的 Maven 坐标来依赖 FPSMatch,而不是使用 dev 修补 jar。这样可以确保你的 mod 与官方版本的 FPSMatch 兼容。

dependencies {
    implementation 'net.ptcrys:FPSMatch:1.3.0'
}

服务端与客户端

要运行你的 mod,需要在服务端和客户端安装以下依赖:

  • 服务端kotlinforforge(必需);可选 tacz / kubejs
  • 客户端kotlinforforge + Modern UI 1.20.1-3.12.0.1(界面必需);可选 tacz
  • ❌ 不再需要 ldlib2(1.3.0 已移除)

版本兼容注意

FPSMatch 1.3.0 是一个快照版本,这意味着它可能会有breaking changes。为了确保你的 mod 与 FPSMatch 兼容,你应该锁定版本:

dependencies {
    implementation 'net.ptcrys:FPSMatch:1.3.0'
}

安装说明

给玩家写安装说明时,你应该包括以下要点:

  • 下载并安装 FPSMatch 1.3.0(服务端与客户端同一份快照
  • 下载并安装 kotlinforforge
  • 客户端额外:下载并安装 Modern UI 1.20.1-3.12.0.1
  • 可选:下载并安装 tacz / kubejs
  • ❌ 不要再让玩家装 ldlib2
  • 将你的 mod 的 jar 文件放入 mods 目录

发布前检查清单

发布前,你应该检查以下清单:

  • 是否已经打包成 jar 文件
  • 是否已经测试过 mod 的功能
  • 是否已经锁定 FPSMatch 的版本
  • 是否已经写好安装说明
  • 是否已经测试过 mod 在服务端和客户端上的兼容性

Warning

发布前检查清单是非常重要的,它可以帮助你避免发布前的错误和问题。请务必检查这些清单,以确保你的 mod 能够正常运行。


上游设计稿(尚未实现):世界沙盘编辑器 / 服务端联动与持久化

!!! danger 这两份是设计稿,不是可调用 API 下面内容来自上游 docs/WORLD_EDITOR_DESIGN.mddocs/server-integration-design.zh-CN.md(2026-09-15/16)。状态:方案阶段,接口是拟定契约,代码尚未实现。不要按这些接口写业务代码,先按第 16/17 章已落地的 API 开发。

1)世界沙盘编辑器(World Editor)

目标:在真实世界画面里编辑地图配置(自由镜头 + 俯视 + 点击/拖动点位),一期支持队伍出生点、地图边界、爆破区域;后续扩展商店区域、结束传送点、入场布置、镜头关键帧。方块建造不在范围内。

  • 建议模块:WorldEditorScreen(面板/输入路由)、EditorCameraRig(自由/环绕/俯视,走 CameraDirector)、EditorDocument(基线快照+草稿+撤销栈)、EditorObjectAdapter(属性/校验/映射正式配置)、EditorOverlayRenderer / EditorPickingMapEditService(服务端授权/快照/冲突检测/提交持久化)。
  • 相机会话建议优先级 50(低于死亡 100、过场 200);失去镜头控制权时暂停编辑并保留草稿,不抢镜头。
  • 保存流程:打开时服务端下发地图快照 + 版本标识 → 拖动只改本地草稿 → 点保存才整包提交 → 服务端在主线程复核权限/维度/比赛状态/基线版本/坐标合法性与请求规模,先全量校验再统一应用,冲突时保留草稿并提示重载,不静默覆盖。
  • 已知边界:独立相机不会让服务器把远处区块发给客户端(仅移动镜头不等于全图可见)。一期先限定在客户端已接收范围内,未加载区域不允许作为地面拾取。

2)服务端联动与持久化(跨服 / 数据库)

目标:玩家经 BungeeCord/Waterfall 转入游戏服时携带可信入服上下文自动进地图进队;结算后按配置执行胜利/失败/平局动作(含执行 Bukkit 插件指令);并提供通用数据库能力(MySQL/MariaDB 跨服共享 + SQLite 单服模式)。

  • 新增服务端间消息协议 FPSMatchMessage 与服务 FPSMatchMessagingrequest / send / handle / listen),与现有客户端 SimpleChannel 包完全分开
  • 传输:首版 PLUGIN_MESSAGE(BungeeCord/Waterfall ↔ Mohist);REDIS_STREAMS 为后续可选;LOCAL 用于单服契约测试。
  • 首版验收组合:BungeeCord/Waterfall + Mohist 1.20.1;Velocity / 其他混合端 / 纯 Forge 的代理接入为后续适配。
  • Mohist 下计划由模组内置一个小型 Bukkit 桥接 JAR,让游戏服仅装模组即可出现名为 FPSMatch 的 Bukkit 插件(代理服仍需独立代理插件)。
  • FPSMDataManager 将升级为唯一数据门面(文件持久化 + SQL 文档/事务/迁移),业务不要自己再建第二套数据库管理器。

Tip

这些能力都还没落地。当前要做跨服/结算动作,先走各自整合包或代理插件体系,等上游发版后再迁移。