原创教程 · 目标版本: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¶
目录¶
- 开篇:这一章教什么
- FPSMatch 是什么:框架、定位与依赖
- 环境准备:Forge 工程与 Gradle 依赖
- 第一个扩展 mod 骨架
- 核心概念:地图、游戏类型、队伍、能力、回合
- 注册你的游戏类型:RegisterFPSMapEvent
- 写第一张地图:继承 BaseMap
- 回合制玩法:BaseRoundMap 与 RoundLifecycle
- 让地图可配置:Setting
- 能力系统:给地图/队伍挂功能
- 队伍与玩家数据:分组、比分、KDA
- 商店与经济:FPSMShop
- 事件系统:监听对局、队伍、枪械
- 把数据存下来:RegisterFPSMSaveDataEvent 与 SaveHolder
- 扩展命令:RegisterFPSMCommandEvent
- 客户端相机系统:CameraDirector 与镜头编排
- 地图工具与地图 ID 规范(OP 编辑 / 区域编辑 / 导入 / 缩略图)
- KubeJS 联动:不写 Java 也能挂规则
- 调试与常见错误排查
- 打包与发布:让别人也能玩
- 上游设计稿(尚未实现):世界沙盘编辑器 / 服务端联动与持久化
开篇:这一章教什么¶
读完这一章,你将能够创建一个属于自己服务器的枪战玩法 mod,包括自定义游戏类型、队伍、回合、商店、事件奖励等。要开始这一章的学习,你需要具备以下前置知识:
- Forge 模组开发基础
- Java 17 编程语言
- Gradle 项目构建工具
本章的学习路线如下:
- 环境准备:配置 Forge 开发环境和 FPSMatch 依赖
- 创建 mod 骨架:建立基本的 mod 结构和事件总线
- 注册游戏类型:使用 FPSMatch 的 API 注册自定义游戏类型
- 编写地图:创建一个基本的游戏地图和回合系统
- 实现能力系统:使用 FPSMatch 的能力系统添加自定义能力
- 开发商店系统:创建一个基本的商店系统和事件奖励
- 处理事件:使用 FPSMatch 的事件系统处理游戏事件
- 存档数据:使用 FPSMatch 的存档系统存储游戏数据
- 与 KubeJS 联动:使用 KubeJS 插件与 FPSMatch 进行联动
- 调试和发布:调试和发布自己的 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]、kubejs(2001.6.5-build.14)、tacztweaks 2.11.2、lrtactical 0.4.3、CounterStrikeGrenade 1.5.2 |
Warning
2026-09-16 起,ldlib2 已从 FPSMatch 依赖中彻底移除(LDLib2 界面全部删除,改为原生 Modern UI)。如果你还在 mods.toml 或 build.gradle 里声明 ldlib2,请删掉——保留它反而可能拖入独立 kotlin-stdlib 造成 ResolutionException。老版本(1.2.5)才需要 ldlib2。
新手开发者需要关心依赖,因为缺少依赖会导致模组直接启动崩溃。确保你的模组中包含了所有必要的依赖,才能正常使用 FPSMatch 的功能。
那么,一条玩法是怎么被注册进去的呢?下面是一个整体的流程图:
- 注册游戏类型:开发者需要使用
FPSMCore的registerGameType方法注册一个新的游戏类型。 - 创建地图实例:开发者需要创建一个新的地图实例,实现
BaseMap接口。 - 注册地图:开发者需要使用
FPSMCore的registerMap方法注册地图实例。 - 实现玩法逻辑:开发者需要在地图实例中实现具体的玩法逻辑,例如游戏规则、队伍管理、经济商店等。
- 注册事件监听器:开发者需要注册事件监听器,监听游戏中的事件,例如玩家加入、离开、死亡等。
- 更新 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.gradle 的 includeBuild('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 崩溃从根上被解决。所以新项目优先用:
- 源码 composite build(
includeBuild('FPSMatch'),官方与 BlockOffensive 的做法,推荐); - 或直接吃官方 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 UI(1.20.1-3.12.0.1)和 Kotlin for Forge(4.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 依赖。我们需要依赖 fpsmatch、ldlib2 和 kotlinforforge。
# 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 的内容,例如游戏类型、地图和能力。
注册代码¶
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类来存储和管理玩家数据。 - 能力:可以给地图或队伍「挂功能」,比如爆破模式或商店系统。你在代码里对应写什么:需要创建一个
MapCapability或TeamCapability对象,并添加到地图或队伍中。 - 回合:在一些游戏类型中,可能会有多个回合,比如一个地图实例中的多个回合。你在代码里对应写什么:需要创建一个
BaseRoundMap对象,并使用RoundLifecycle来管理回合的生命周期。
这些概念的生命周期顺序是:注册游戏类型 → 创建地图实例 → 加入队伍 → 开局 → 回合 → 结算 → 重置。在整个过程中,能力和玩家数据会被不断更新和管理。
总结¶
通过上面的解释,我们可以看到 FPSMatch 中的核心概念是如何相互关联和作用的。理解这些概念和它们的关系是开发一个 FPSMatch 模组的关键。下一步,我们将深入探讨如何注册游戏类型和创建地图实例。
Warning
注意:在开发过程中,需要注意各个概念的生命周期和相互关系,以避免错误和冲突。同时,需要参考 FPSMatch 的官方文档和 API 以获取最新的信息和示例。
注册你的游戏类型:RegisterFPSMapEvent¶
在 FPSMatch 中,注册游戏类型是通过 RegisterFPSMapEvent 的 registerGameType 方法实现的。这个方法需要三个参数:游戏类型名、地图名和区域数据,返回一个 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 类:
步骤 2:实现构造器¶
接下来,我们需要实现构造器,调用 super 方法:
步骤 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 中,回合制玩法是通过 BaseRoundMap 和 RoundLifecycle 实现的。回合制玩法适用于爆破、竞技等游戏模式。
继承 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 方法:
要修改 Setting<T> 的值,你可以使用 set 方法:
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);
在这个示例中,我们添加了一个名为 maxRounds 的 Setting<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¶
MapCapability 和 TeamCapability 是两个重要的能力类,它们分别对应地图和队伍。这些类可以被用来给地图或队伍添加特定的功能。
注册能力¶
要使用能力系统,首先需要注册能力类。可以使用 FPSMCapabilityManager 的 register() 方法来注册能力类:
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 版本中,内置了 DemolitionModeCapability 和 GameEndTeleportCapability 两个能力,可以直接参考它们的写法。
示例:击杀播报能力¶
下面是一个最小的击杀播报能力示例,继承 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 中,队伍和玩家数据是通过 TeamData 和 PlayerData 类来管理的。这里我们将介绍如何定义队伍、创建队伍、查询队伍和玩家数据,以及如何统计胜方队伍的 KDA 并广播。
定义队伍¶
要定义一个队伍,你需要创建一个 TeamData 对象。TeamData 有两个构造器:一个需要队伍名称和人数限制,另一个还需要一个队伍能力列表。这里我们使用第一个构造器:
创建队伍¶
创建队伍需要将 TeamData 对象添加到 BaseMap 中:
查询队伍和玩家数据¶
你可以通过 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。同时,需要注意 ServerTeam 和 PlayerData 对象的线程安全性,以避免并发访问问题。
商店与经济: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;
}
}
注册商店类型¶
定义好枚举类后,需要将其注册为商店类型。
创建商店¶
注册好商店类型后,可以创建一个商店实例。
也可以指定起始金钱。商店数据¶
商店数据会随地图存档,包括每个玩家的余额和已购商品。因此,修改商店数据时需要注意兼容性。
ShopSlot 和 ShopSlotPurchaseRules¶
ShopSlot 类代表一个商品槽,包含商品名称和价格。ShopSlotPurchaseRules 类代表购买规则,可以用来限制购买数量或设置购买条件。
ShopData¶
ShopData 类存储每个玩家的商店数据,包括余额和已购商品。
通过这些类和方法,可以实现一个基本的商店体系。记得在修改商店数据时注意兼容性,以避免数据损坏或异常。
事件系统:监听对局、队伍、枪械¶
FPSMatch 的事件系统允许你监听各种游戏事件,例如对局开始、队伍加入、枪械射击等。这些事件可以帮助你实现自定义的游戏逻辑和功能。
事件类型¶
FPSMatch 提供了多种事件类型,包括:
FPSMapEvent:对局相关事件,例如对局开始、胜利、清除、重置和重载。FPSMapEvent.PlayerEvent:玩家相关事件,例如玩家加入、离开、受伤、死亡、击杀、记录击杀、捡起物品、丢弃物品、聊天、登录和退出。FPSMTeamEvent:队伍相关事件,例如队伍加入和离开。- 枪械相关事件,例如
FPSMGunFireEvent、FPSMGunShootEvent、FPSMGunReloadEvent、FPSMGunKillEvent、FPSMGunDamageEvent、FPSMThrowGrenadeEvent、FPSMShopEvent和FPSMReloadEvent。
事件参数¶
每个事件都有其特定的参数,你可以通过这些参数获取事件相关的信息。例如:
FPSMapEvent:可以获取对局地图 (map)。FPSMapEvent.PlayerEvent:可以获取玩家 (player)、对局地图 (map)、死亡原因 (source)、伤害金额 (amount) 等。FPSMTeamEvent:可以获取队伍 (team) 和玩家 (player)。
可取消事件¶
有些事件是可取消的,例如 FPSMThrowGrenadeEvent、FPSMapEvent.StartEvent、FPSMapEvent.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 实例时,需要设置正确的 Codec、version、initializer、readHandler、writeHandler、isGlobal 和 fileType。否则,可能会导致数据损坏或无法读取。
扩展命令: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 对象。这个对象提供了命令执行的上下文信息。
补充帮助和参数¶
为了让你的子命令更易用,你可以使用 registerHelp 和 registerParameters 方法来补充帮助和参数。
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,比赛过场 200(
CameraDirector提供常量,如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、下划线_、连字符- - 例:
dust2、metro_a - 给玩家看的名字请写在地图设置里的
displayName
权限与编辑保护¶
- 地图创建工具、出生点工具都要求 OP 2 级权限。
- 对局进行中会拒绝编辑地图边界与出生点(
game_started类错误),避免破坏进行中的比赛。
出生点校验(比 1.2.5 严)¶
出生点必须:① 在地图区域内 且与地图同一维度;② 脚下有可碰撞支撑;③ 玩家脚部与头部两格无碰撞体;④ 两格无流体;⑤ 不与已有点重复。
新增:地图区域编辑与导入¶
1.3.0 新增了区域编辑与地图导入链路(对应网络包 MapRegionActionC2SPacket、RequestMapImportSourcesC2SPacket / MapImportSourcesS2CPacket / ImportMapConfigC2SPacket):可在界面里编辑地图区域、列出可导入来源、把外部地图配置导入成新地图。命令入口仍走 /fpsm map ...(游戏内 /fpsm help 是最新事实来源)。
地图缩略图与详情背景¶
iconTexture / backgroundTexture 填的是 Minecraft 客户端资源位置(不是服务器绝对路径、不是 URL、不能 C:\...)。
- 图片需存在于模组资源或每位玩家已启用的资源包中,否则界面显示色块兜底。
- 推荐 PNG、16:9:图标
320x180或640x360;详情背景至少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:伤害来源(在 playerHurt 和 playerDeath 事件中)
- 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,点名 kotlinforforge 或 modernui。
原因: 依赖没声明齐全(composite build 不传递上游运行时依赖;客户端需要 Modern UI,服务端不需要)。
怎么查: 在根 build.gradle 里补 ModernUI-Forge + kotlinforforge;1.3.0 不要再加 ldlib2(ldlib2 会拖入独立 kotlin-stdlib,与 kotlinforforge 抢 kotlin.jvm.functions → ResolutionException)。
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 配置,确保正确配置。
测试流程清单¶
- 进入开发客户端。
- 创建一个新地图。
- 开启游戏。
- 击杀敌人。
- 结算游戏。
- 查看日志,检查是否有错误信息。
通过这些步骤,可以帮助开发者快速定位和解决问题。同时,也可以帮助开发者更好地理解模组的工作原理和调试方法。
打包与发布:让别人也能玩¶
打包流程¶
要让别人也能玩你的 mod,你需要将其打包成一个 jar 文件。这个过程很简单,你只需要在终端中运行以下命令:
这个命令会编译你的代码,打包成一个 jar 文件,并将其放在 build/libs 目录下。
产物位置¶
打包完成后,你可以在 build/libs 目录下找到你的 mod 的 jar 文件。这个文件就是你需要发布的文件。
正式发布时依赖¶
在正式发布时,你应该使用官方的 Maven 坐标来依赖 FPSMatch,而不是使用 dev 修补 jar。这样可以确保你的 mod 与官方版本的 FPSMatch 兼容。
服务端与客户端¶
要运行你的 mod,需要在服务端和客户端安装以下依赖:
- 服务端:
kotlinforforge(必需);可选tacz/kubejs - 客户端:
kotlinforforge+ Modern UI1.20.1-3.12.0.1(界面必需);可选tacz等 - ❌ 不再需要
ldlib2(1.3.0 已移除)
版本兼容注意¶
FPSMatch 1.3.0 是一个快照版本,这意味着它可能会有breaking changes。为了确保你的 mod 与 FPSMatch 兼容,你应该锁定版本:
安装说明¶
给玩家写安装说明时,你应该包括以下要点:
- 下载并安装 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.md 与 docs/server-integration-design.zh-CN.md(2026-09-15/16)。状态:方案阶段,接口是拟定契约,代码尚未实现。不要按这些接口写业务代码,先按第 16/17 章已落地的 API 开发。
1)世界沙盘编辑器(World Editor)¶
目标:在真实世界画面里编辑地图配置(自由镜头 + 俯视 + 点击/拖动点位),一期支持队伍出生点、地图边界、爆破区域;后续扩展商店区域、结束传送点、入场布置、镜头关键帧。方块建造不在范围内。
- 建议模块:
WorldEditorScreen(面板/输入路由)、EditorCameraRig(自由/环绕/俯视,走CameraDirector)、EditorDocument(基线快照+草稿+撤销栈)、EditorObjectAdapter(属性/校验/映射正式配置)、EditorOverlayRenderer/EditorPicking、MapEditService(服务端授权/快照/冲突检测/提交持久化)。 - 相机会话建议优先级 50(低于死亡 100、过场 200);失去镜头控制权时暂停编辑并保留草稿,不抢镜头。
- 保存流程:打开时服务端下发地图快照 + 版本标识 → 拖动只改本地草稿 → 点保存才整包提交 → 服务端在主线程复核权限/维度/比赛状态/基线版本/坐标合法性与请求规模,先全量校验再统一应用,冲突时保留草稿并提示重载,不静默覆盖。
- 已知边界:独立相机不会让服务器把远处区块发给客户端(仅移动镜头不等于全图可见)。一期先限定在客户端已接收范围内,未加载区域不允许作为地面拾取。
2)服务端联动与持久化(跨服 / 数据库)¶
目标:玩家经 BungeeCord/Waterfall 转入游戏服时携带可信入服上下文自动进地图进队;结算后按配置执行胜利/失败/平局动作(含执行 Bukkit 插件指令);并提供通用数据库能力(MySQL/MariaDB 跨服共享 + SQLite 单服模式)。
- 新增服务端间消息协议
FPSMatchMessage与服务FPSMatchMessaging(request/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
这些能力都还没落地。当前要做跨服/结算动作,先走各自整合包或代理插件体系,等上游发版后再迁移。