简介:面向零基础玩家的《杀戮尖塔》Mod开发入门教程,适合想为游戏添加自定义卡牌、角色或新机制的玩家与Java初学者。教程以ModTheSpire、BaseMod、StSLib三大支持库为主线,覆盖从Steam创意工坊订阅依赖、安装游戏本体,到IntelliJ IDEA开发环境搭建、Maven环境变量配置、创建Maven项目及编辑pom.xml依赖的完整流程,帮助读者跨过首个可运行Mod之前的全部门槛。资源打包为单个PDF文档,压缩包约1.5MB,内容按简介、准备工作、构建项目、测试构建效果、作业练习与Mod开发语法基础等章节组织,适合边读边练,也方便作为开发手册随时查阅。目前已有2121人学习下载,常被用作社区入门参考。除项目构建外,教程还侧重讲解控制台输出调试、基础变量与函数、类作用域、监听函数与继承等面向对象知识,在动手过程中建立对Mod代码结构的理解,为后续自制卡牌、遗物或完整角色打下扎实基础。
1. 杀戮尖塔mod制作入门:从创意工坊的旁观者到能改规则的开发者
你还记得第一次在创意工坊刷到一张只改了数值的卡牌mod吗?我当时的反应是:这也能算mod?我自己上是不是也行?杀戮尖塔mod制作入门,没有想象中那么高,它本质上是一场Java工程实战:游戏本体由Java编写,mod的开发链路也围绕Java展开。我们要做的只有一件事——用modthespire、basemod、stslib这三个支持库提供的API,把自己的代码接进游戏原本的逻辑里,去加卡牌、改规则、甚至重塑角色。这份笔记适合两类人:没写过Java的新手,能照着一路把第一个jar跑起来;有编程基础的熟手,能顺着环境链路的拆解快速定位到可以下手的位置。
2. 环境搭建与依赖配置:三大支持库和Maven工程是怎么组合的
2.1 三大支持库的分工:加载器、基础库、扩展库
杀戮尖塔的mod并不是一个孤立的jar丢进游戏目录就能跑。真正启动时,游戏会以带mod的方式拉起ModTheSpire,再由它逐个扫描、加载mod目录下的jar文件。动手之前先把三个库的身份搞清楚,否则后面看到代码里的import语句,会一直疑惑某个包到底来自哪里。
ModTheSpire是加载器本体。它负责读jar里的ModTheSpire.json、决定加载顺序、在游戏内弹出mod选择界面。没有它,我们的jar根本不会进入游戏进程;换种说法,它才是真正调用我们initialize()方法的宿主。
BaseMod是事件与注册中心。我们要在“玩家抽卡”“战斗结束”这类事件上挂钩子,不是去改游戏源码,而是向BaseMod注册一个订阅者——你的类实现某个Subscriber接口,BaseMod在对应时机回调你。它也统一管理卡牌、遗物、药水的注册API。没有它,mod和游戏之间的交互就没有落点。
stslib是BaseMod上的扩展库,社区常称STS Lib。它提供预置的好用组件,比如常见卡牌效果、字符串工具、UI组件。只做单张卡牌mod时stslib不一定直接用上,但做角色mod时它能把大量通用效果变成现成调用,省下不少重复代码。
一句话记住分工:ModTheSpire管加载,BaseMod管事件与注册,stslib管常用组件。后面写代码时,import的包名基本就在这三者之间转。
2.2 IDE与Maven环境准备
我推荐IntelliJ IDEA社区版加Maven的组合。IDEA社区版免费,对Java工程的支持足够完整;Eclipse也完全可以,只是下文示例的菜单路径以IDEA为例。Maven负责把依赖声明、编译、打包串成一条流水线,这正是mod工程需要的:用pom.xml声明依赖,用package命令输出jar。
建议先把Maven装好再打开IDE。Windows下的步骤不算复杂,但有两处容易踩坑:
- 下载Maven并解压到本地目录,比如
E:\Maven\apache-maven-3.3.9。路径尽量不要有中文和空格,后续命令行解析会少很多麻烦。 - 进入“计算机 - 属性 - 高级系统设置”,打开环境变量窗口。新建系统变量
MAVEN_HOME,变量值填Maven所在目录。再在系统变量Path里追加一行%MAVEN_HOME%\bin。
注意一定是“系统变量”,不是用户变量,因为后续IDEA和命令行都要读取这个值。改完环境变量后,旧的命令行窗口不会自动刷新,必须新开一个窗口,输入mvn -version验证。输出里能看到Maven版本和Java版本,说明环境已生效。
如果执行mvn提示“不是内部或外部命令”,多半是Path没配好,或追加时路径前后多了空格。处理完再新开窗口,不要在旧窗口里反复试,那是很多人最容易忽略的一步。
2.3 依赖获取与配置思路
三个支持库的获取方式比较直接:在创意工坊里搜索 stslib、basemod、modthespire 并订阅。订阅成功后,它们会出现在创意工坊的内容目录下,形如workshop/content/646570/xxx的文件夹中。
订阅完成后先别急着开发,把游戏本体完整启动一次,确认能以mod模式进入游戏、并在启动器界面看到BaseMod和ModTheSpire。这一步的意义是验证游戏和库的版本搭配没问题。如果连游戏本身都进不去,后面所有开发动作都没有载体。
这里有一个常见的失败顺序:只订阅了basemod,忘掉ModTheSpire;或者下载了加载器但游戏没识别出来。我的习惯是先把三件套完整订阅、把游戏跑通一次,再回来搭建开发环境。顺序反了,配置了半天IDE,最后被一个缺失依赖卡住,排查起来很影响心情。
到pom.xml里,三个库jar会以system依赖的方式被引用,所以现在就把workshop目录下的jar路径抄下来。抄写要完整,从workshop开始一直到jar文件名,少一级目录都会编译失败。后续如果游戏更新,这些jar的路径或文件名也可能变化,需要重新确认。
3. 工程骨架拆解:pom.xml、ModTheSpire.json和打包链路
3.1 新建Maven工程与目录结构
在IDEA中新建工程,选择Maven,不勾选archetype模板,随后填写GroupId和ArtifactId。为了让示例能直接对号入座,ArtifactId可以填tutorial,工程名称也取tutorial。
生成后的目录结构大致是这样:
tutorial/ ├── pom.xml └── src/ ├── main/ │ ├── java/ │ └── resources/ └── test/.idea是IDE的辅助配置,与mod本体无关;src/main/java是存放代码的地方;src/main/resources用来放图片、json等资源,ModTheSpire.json就要放在这里;src/test是Maven自动生成的测试目录,构建时Maven会先生成测试产物,再把测试目录生成的文件拷贝到目标文件夹。
这里有个值得提前说明的点:如果不希望测试代码被打进最终jar,最好保持测试目录为空,或者在打包前执行mvn clean package。很多新手第一次打出来的jar体积异常,多半就是测试类被带了进去。jar里多出无用的class不影响加载,但会干扰排查,尤其是当你怀疑某个类是不是被旧版本覆盖时。
3.2 pom.xml关键配置逐段解释
pom.xml是整个工程的命脉。下面是去掉注释后的最小可用版本:
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>org.example</groupId> <artifactId>tutorial</artifactId> <version>0.1</version> <packaging>jar</packaging> <name>tutorialMod</name> <description>杀戮尖塔mod入门示例工程</description> <properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <steam.path>E:\SteamLibrary\steamapps</steam.path> </properties> <dependencies> <dependency> <groupId>bin</groupId> <artifactId>BaseMod</artifactId> <scope>system</scope> <version>1.0</version> <systemPath>${steam.path}/workshop/content/646570/1605833019/BaseMod.jar</systemPath> </dependency> <dependency> <groupId>bin</groupId> <artifactId>SlayTheSpire</artifactId> <scope>system</scope> <version>1.0</version> <systemPath>${steam.path}/common/SlayTheSpire/desktop-1.0.jar</systemPath> </dependency> <dependency> <groupId>bin</groupId> <artifactId>ModTheSpire</artifactId> <scope>system</scope> <version>1.0</version> <systemPath>${steam.path}/workshop/content/646570/1605060445/ModTheSpire.jar</systemPath> </dependency> </dependencies> <build> <finalName>tutorialMod</finalName> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.7.0</version> <configuration> <source>1.8</source> <target>1.8</target> </configuration> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-antrun-plugin</artifactId> <version>1.8</version> <executions> <execution> <phase>package</phase> <configuration> <target> <copy file="target/tutorialMod.jar" tofile="${steam.path}/common/SlayTheSpire/mods/tutorialMod.jar"/> </target> </configuration> <goals> <goal>run</goal> </goals> </execution> </executions> </plugin> </plugins> </build> </project>properties里的project.build.sourceEncoding设为UTF-8,保证代码里能直接用中文写字符串,打包后不乱码;steam.path指向游戏平台的库目录,如果你的游戏装在其他盘,这个值必须跟着改。我的做法是先把游戏本体所在路径完整复制到记事本,再粘贴进pom.xml,避免手打路径漏掉一级目录。
dependencies里三个依赖的scope都写成了system,配合systemPath直接定位到jar文件。原因是ModTheSpire、BaseMod这些jar不是中央仓库里的公共制品,没必要推到私有仓库,直接引用本地文件最省事。desktop-1.0.jar是游戏主程序,BaseMod.jar和ModTheSpire.jar是两个支持库。如果你之后订阅了stslib,可以按同样格式再追加一条dependency。
build部分做了两件事:maven-compiler-plugin把源码编译级别固定在Java 1.8,这和游戏本身的编译版本保持一致,避免出现class版本过新导致加载失败;maven-antrun-plugin在package阶段把生成的jar复制到游戏目录下的mods文件夹。这个mods文件夹是游戏识别mod的标准目录,如果不存在需要先手动创建。
3.3 ModTheSpire.json配置说明
在src/main/resources下新建文件ModTheSpire.json,内容如下:
{ "modid": "tutorial", "name": "tutorialMod", "author_list": ["A", "B"], "description": "新手教程mod", "version": "0.0.1", "sts_version": "03-29-2018", "mts_version": "2.6.0", "dependencies": ["basemod"] }modid是整个mod在加载器中的唯一标识,尽量简洁小写,后续代码里可能通过它做资源路径引用;name是界面上显示的名称;author_list是数组,可以填多个作者;dependencies声明了本mod依赖basemod,加载器会优先把BaseMod排在前面。
json里最容易出问题的是大括号和方括号的配对。见过不止一次,开头花括号写成方括号,加载器直接忽略这个mod。文件不大,建议用带json语法高亮的编辑器写,不要用记事本硬写。一旦语法出错,加载器不会给任何弹窗提示,表现就是mod列表里没有你。
3.4 package打包与游戏内验证
配置写完后,展开IDEA右侧的Maven面板,在Lifecycle里双击package即可打包;也可以在终端执行mvn package。构建成功后target目录下会出现tutorialMod.jar,antrun插件会自动把它复制到游戏目录下的mods文件夹。
用mod模式启动游戏,加载器界面里应该能看到刚才写入的信息。此时勾选BaseMod、ModTheSpire和你自己的mod,然后运行。进入游戏后打开mod菜单,能看到json里写的标识,但因为还没写任何代码,这个mod暂时没有任何功能。
第一次验证时,多留意加载器弹出的log窗口。这里面每一行都有含义:哪个mod先加载、耗时多少毫秒、有没有依赖缺失。后面排查问题,一半的答案都在log里。如果log里提示找不到某个类,优先怀疑pom.xml里的依赖路径或版本配错了。
4. 语法实战:变量、函数、类、作用域与监听函数
4.1 变量:int、float、boolean、String怎么选
mod开发中最常用的是四类变量。int表示整数,占4字节;float表示小数,字面量后要加f;boolean只有true和false;String表示字符串。选错类型很常见,比如把费用写成float,之后做数值比较时会出现精度问题;把命中率写成int,计算概率时又丢失小数。在杀戮尖塔的mod场景里,对应关系很直观:卡牌费用用int,概率计算用float,是否被某种状态影响用boolean,描述文本用String。
public int number; private float dot = 2.4f; public static boolean judge = true; public final String HELLO = "Hello world!";声明变量的语法是“属性 + 类型 + 名称 + 初始化”。public表示类外可访问,private表示仅当前类可访问;static表示这个变量归属类而非对象;final表示常量,值不可修改,按习惯常量名全大写并用下划线分隔。
这里有一个常见误用:把不该为static的成员变量设置成static。一个卡牌的伤害如果写成static,所有同名卡牌的伤害会互相覆盖,战斗中就出现“一张被buff,其他全跟着变”的灵异现象。判断标准很简单——这个值究竟是“每个对象各自一份”,还是“整个类型共享一份”。
4.2 函数:return、参数和作用域的协作
函数是类里面的方法,有输入有输出。声明时用返回值类型表示输出,用括号里的参数列表表示输入。
private int print(String string, int num) { System.out.print(string); return num; } private void hello() { System.out.print("Hello world!"); return; }第一个函数传入一个字符串和一个整数,输出字符串后把整数返回给调用者;第二个函数返回值类型是void,表示不返回任何东西,这里的return只是跳出函数体。括号里的string和num属于形式参数,调用时真正传入的值叫实际参数,两者的名字没有任何关系。
初学者最容易把“形式参数”和“外部变量”搞混。写mod时,我们会频繁在监听函数里收到游戏传入的对象,比如一张卡牌。此时不要试图通过修改形参去影响外部变量,先看看这个形参对象本身提供了哪些字段,再决定怎么改。这是面向对象设计和面向过程思维的分水岭。
4.3 类、对象与引用
类是Java的基本代码单元。在杀戮尖塔里,类有很强的现实对应:你手上打出的每张“打击”都是打击类的一个对象,生成的每个电球都属于电球类。函数和变量都放在类里讨论,对象之间互不干扰。
A a = new A();这行代码中,A是类名,a是对象引用,new A()创建一个真正的对象并执行构造函数。注意a本身不是对象,只是指向对象的“把手”。后面把a传给其他函数,传的是引用,不是对象副本。
创建新卡牌时,构造函数往往要传入费用、基础伤害这些初始值,这就是为什么new一个卡牌对象时经常要写参数。卡牌对象在场上被修改攻击力,其他同名卡牌不受影响,因为这些成员变量属于各自的实例。反过来说,如果想要某种数据在所有对象间共享,就得用static。
4.4 作用域与static
作用域决定了哪些代码能访问某个成员。public可以被任何类访问,private只能在该类内部访问。IDE通常强制公有类以单独文件存在,其他类要import它的包路径才能使用。import的格式是“包名.类名”,最后一个单词是类名,前面的单词是包所在的目录路径,这种结构很像文件与文件夹。
Strike a = new Strike(); int dam = a.damage; // 某一张打击对象a的伤害 int summary = Strike.sum; // 整个Strike类累计生成的卡牌数damage是实例字段,不同打击卡各存各的;sum是static字段,所有打击卡共享同一个值。访问方式也不同,实例字段要通过对象访问,static字段可以直接用类名访问。后面作业里统计战斗总伤害,就要用static字段来跨对象累计。
4.5 监听函数:mod和游戏互动的最低成本方案
有了上面的基础,可以写出第一个有实际行为的mod了。扩充入口类,让它监听“抽卡”事件:
package tutorial; import basemod.BaseMod; import basemod.interfaces.PostDrawSubscriber; import com.evacipated.cardcrawl.modthespire.lib.SpireInitializer; import com.megacrit.cardcrawl.cards.AbstractCard; @SpireInitializer public class Tutorial implements PostDrawSubscriber { public Tutorial() { BaseMod.subscribe(this); } public static void initialize() { new Tutorial(); System.out.print("hello world! 5*6+30="); System.out.print(5 * 6 + 30); System.out.print("end of line!\n2+2=" + 4); } @Override public void receivePostDraw(AbstractCard abstractCard) { System.out.println(abstractCard.name); } }整个流程:ModTheSpire通过@SpireInitializer找到initialize(),initialize里new出Tutorial对象;构造函数调用BaseMod.subscribe(this),把自己注册成事件订阅者;之后游戏里每次抽卡,加载器都会回调receivePostDraw方法,传入被抽到的卡牌对象。方法体里只做了一件事——打印卡牌名称,但这已经是mod与游戏互动的完整闭环。
System.out.print和println的区别在于后者输出后自动换行;字符串拼接里的+表示文本相连,\n是换行符。这些输出会出现在ModTheSpire的log窗口里,是早期调试最重要的观察点。加载mod时控制台显示的时间消耗信息,也是来自同一条输出通道。
5. 避坑排查:从环境到打包最容易翻车的五个点
写mod最痛苦的不是概念不会,而是环境、依赖、打包这几件事连环翻车。下面五条都是新手期高频出现的问题,按“现象—原因—解决”整理。
5.1 现象:mvn package成功,但游戏mod目录里没有新jar
原因:antrun插件的copy路径写错。常见的是steam.path指向了游戏库根目录,游戏本体其实装在另一个路径,或者mods目录不存在时copy静默失败。
解决:先在文件管理器里确认${steam.path}/common/SlayTheSpire/mods/是否存在,不存在就手动创建。把pom.xml里的tofile路径改准确后重新执行package,留意构建日志里是否出现copy相关的源文件和目标文件。如果copy一直没有打印,检查antrun插件的phase和goal是否配置正确。
5.2 现象:加载器界面里看不到自己的mod
原因:ModTheSpire.json缺失、放错位置、或json格式错误。json必须放在src/main/resources根目录下,不是java目录下。大括号和方括号配对错误时,加载器会静默跳过。
解决:先用带格式校验的编辑器打开json,确认花括号和方括号成对;再检查打出的jar,确认json位于jar包根路径,不在某个子目录里。用解压工具打开jar看结构是最直接的验证方式,比反复猜原因省时得多。
5.3 现象:加载mod时报ClassNotFound或NoSuchMethod
原因:游戏版本、BaseMod版本、ModTheSpire版本的匹配出了问题。你引用的desktop-1.0.jar里的类名或方法签名,和实际运行的游戏版本对不上。
解决:把pom.xml里依赖的三个jar换成同一时期、同一批更新下的版本。最稳妥的操作是先不带mod启动一次游戏,确认三件套都能正常加载,再同步更新工程里的依赖路径。如果某个方法名报错,说明你参考的代码版本和当前环境不一致,上游戏对应版本的源码里检索方法签名。
5.4 现象:Java代码改了,重开游戏还是旧行为
原因:只改了源码,没有重新打包。IDEA里修改代码不会自动触发maven package,mods目录里躺着的还是上一次的旧jar。
解决:把“改源码—执行package—开游戏验证”变成固定节奏。条件允许的话,在IDEA里给mvn package配一个外部工具快捷键,每次保存代码后顺手执行一次。这个坑几乎人人都会踩,而且踩完一次还会踩第二次,因为游戏启动本身就有延迟,等你意识到没更新时,前几分钟的验证全白做了。
5.5 现象:控制台中文乱码
原因:pom.xml没有声明UTF-8编码,或者源文件本身用系统默认编码保存。Windows下默认编码经常是GBK,中文注释和字符串在打包后全乱了。
解决:保留properties里的project.build.sourceEncoding为UTF-8,并在IDEA的File编码设置里把项目编码、模块编码、默认编码都切到UTF-8。中文字符串在json和Java代码里都会出现,这一步不处理好,后面做卡牌描述时会非常崩溃。
排查这些问题的通用路径是:先看log,再看jar,最后回头看pom。log里有加载顺序、耗时、异常栈;jar能确认json和class是否到位;pom决定了依赖和复制路径是否准确。按这个顺序走,绝大多数玄学问题都能落回一个具体的配置错误。
6. 二次开发思路:把作业题改出一个真正有用的功能
到这里,工程能跑、代码能写,剩下的就是知识落进实战。原教程留了两道作业,稍微改动就能变成突破口。
第一道作业:仿照前面的流程,输出一个jar并在游戏中运行,给自己mod取名、在作者栏写上自己的ID。它逼着你走完环境、依赖、配置、打包、加载全链路,别跳过。第二道作业更有价值:给定一个只完成了一半的监听类,要求统计每场战斗玩家受到的总伤害。我补全后的版本如下:
package tutorial; import basemod.BaseMod; import basemod.interfaces.OnPlayerDamagedSubscriber; import basemod.interfaces.PostBattleSubscriber; import com.evacipated.cardcrawl.modthespire.lib.SpireInitializer; import com.megacrit.cardcrawl.cards.DamageInfo; import com.megacrit.cardcrawl.rooms.AbstractRoom; @SpireInitializer public class Tutorial implements OnPlayerDamagedSubscriber, PostBattleSubscriber { private static int totalDamage = 0; public Tutorial() { BaseMod.subscribe(this); } public static void initialize() { new Tutorial(); } @Override public int receiveOnPlayerDamaged(int damage, DamageInfo damageInfo) { totalDamage += damage; return damage; } @Override public void receivePostBattle(AbstractRoom r) { System.out.println("本场战斗玩家受伤总量: " + totalDamage); totalDamage = 0; } }两个关键点:totalDamage声明为static,因为每次回调都在不同对象上下文里,只有类级字段能在多次回调之间保持累计;receivePostBattle在战斗结束后执行,打印后把totalDamage重置为0,确保下一场战斗重新统计。
从这个问题往外走一步,就能触及mod开发的真正玩法。把totalDamage存起来,在某个时机触发增益;或者在receivePostDraw里判断抽到的是不是特定卡牌,修改它的伤害;更进一步,调用BaseMod的注册API把一张自制卡牌加进卡池。凡是能实现的效果,本质上都是先订阅一个事件、再在回调里操作游戏对象。
我自己的习惯是:不直接上手做新mod,先把作业拆成“事件来源、回调时机、修改对象”三个要素,用log验证回调时机,再动手改数据。从那以后,每次写完代码我都强制走一遍“改源码—mvn package—开log验证”的闭环,这个习惯帮我躲过了大量“改了半天发现没生效”的尴尬时刻。希望这个流程也能帮到你。
本文还有配套的精品资源,点击获取