banner
Hello!

杀戮尖塔mod开发的二三事

Scroll down

杀戮尖塔mod开发的二三事

注意:此文不是教程,只是个人的感想和体会。教程实际上也可以写,但是懒得写了(

文中提到的人物mod还没有做完,暂缓更新。

2023/9/14更新:此mod已经在创意工坊发布,且于今日增加了英文翻译。

开发过程

准备

本人对杀戮尖塔这款游戏的可拓展性早有耳闻,也玩过许许多多其他人开发的mod,于是就心血来潮想要自己开发一个杀戮尖塔的mod。

由于没有任何相关的项目实践基础,我只能去参考一些相关的教程来了解如何开发。下面是我参考过的教程链接(中文):

杀戮尖塔MOD制作 - 知乎 (zhihu.com)

REME大佬主编的mod制作教程

以及官方文档(生肉):

Github-Basemod

Github-StSLib

本人在开发过程中遇到的许多技术难题都是靠着这几份教程解决的。

除了这些文档与教程之外,我也大量参考了其他mod作品,由于java的开源本质,这些mod文件可以被很轻易地反编译。这些mod包括替罪羔羊mod、AKD遗物mod、蔡徐坤mod等等。在此向这些文档、教程以及mod作者致以谢意。

构建框架

在翻了一点资料后,我发现这款游戏是用基于Java编写的开源框架libGDX开发的。然后又查了一下,发现这框架可不得了,因为我在这个框架开发的游戏之中又见到了一些其他熟悉的身影:

  1. mindustry(像素工厂)

一款游戏性极强的RTS塔防游戏,整体玩法上类似异星工厂,开源免费

  1. unciv(像素版文明5)

手机上能玩的仿文明类游戏中最好的一款,轻量流畅,开源免费,还支持联机

  1. infinitode 2(无尽塔防2)

继塔防游戏三幻神(王国保卫战,植物大战僵尸,气球塔防)之后又一款优秀的塔防游戏,机制上比较纯粹,少数的雷点包括资源太肝(不过反正是单机,简单地修改一下掉落率就行)和制作者亲乌(没办法的事),其他都很不错,而且免费游戏要啥自行车啊(

  1. shattered pixel dungeon(破碎像素地牢)

这位更是重量级,极其经典的传统式rogue-like游戏像素地牢最成功的改版,也是开源免费

这就是开源框架吗,开源社区的威力可见一斑。而杀戮尖塔虽然不是免费游戏,但开源(代码可以随便反编译),而且作为界内公认的最佳rogue-like卡牌游戏,它也没有辜负这个框架的威名(笑)

官网也把杀戮尖塔放在了第一个。

实际上手

接下来就要看看这游戏mod的基本结构了。在折腾了好一段时间后,我也弄清楚了这一点。

这游戏mod的基本组成结构只有两个:

  • 内容类,内部包含自己定义的增添内容,包括卡牌、遗物、事件、能力等;
  • 注册类,将已经定义的内容在basemod中注册,以让MTS(ModTheSpire,mod启动器)在给游戏打包时将增添的内容打包进去。

注册类

先说注册类。在MTS打包游戏时,它首先会检测包含注解@SpireInitializer的类,将这些类中的Subscriber相关内容打包进游戏文件。这个动作是由接口——回调函数实现的。

虽然每一个类都可以加上这个注解,但出于代码管理与游戏性能考虑,一般把大宗的注册任务集中放在一个类中。

1
2
3
4
5
6
@SpireInitializer
public class xxxMod
implements EditCardsSubscriber, EditCharactersSubscriber, EditStringsSubscriber, EditRelicsSubscriber,
EditKeywordsSubscriber {
……
}

这个注册类实现了EditCardsSubscriberEditCharactersSubscriber等接口,而这些接口是另一个接口ISubscriber的子接口。这些接口的定义中只有一个对应的回调函数(如EditCardsSubscriber接口包含receiveEditCards()等),而这些函数在这里被实现后便会被MTS在初始化过程中被调用。

1
2
3
4
//主注册类的构造函数
public xxxMod() {
BaseMod.subscribe((ISubscriber) this);
}

具体来讲,receiveEditCards()里需要这样实现:

1
2
3
4
@Override
public void receiveEditCards() {
BaseMod.addCard((AbstractCard) new Example_Card());
}

这样便可以在游戏中添加Example_Card()类对应的卡牌。

而这样添加的本质,是basemod把这些卡加进了一个数组之中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//摘自BaseMod.class
public static void addCard(AbstractCard card) {
switch (1.$SwitchMap$com$megacrit$cardcrawl$cards$AbstractCard$CardColor[card.color.ordinal()]) {
case 1:
redToAdd.add(card);
break;
case 2:
greenToAdd.add(card);
break;
case 3:
blueToAdd.add(card);
break;
case 4:
purpleToAdd.add(card);
break;
case 5:
colorlessToAdd.add(card);
break;
case 6:
curseToAdd.add(card);
break;
default:
customToAdd.add(card);
}
}

至于这里的数组具体是怎么被读取并加入游戏中的,这里就不过多追究了。

除了卡牌,其他的内容也是以类似的形式加进去的。

1
2
3
4
5
//添加遗物
@Override
public void receiveEditRelics() {
BaseMod.addRelicToCustomPool((AbstractRelic) new CustomRelic(), COLOR_OF_YOUR_CHARACTER);
}
1
2
3
4
5
6
//添加本地化文本
@Override
public void receiveEditStrings() {
String cardStrings = Gdx.files.internal("path/to/your/localization/json").readString(String.valueOf(StandardCharsets.UTF_8));
BaseMod.loadCustomStrings(CardStrings.class, cardStrings);
}

以上便是注册类的大致内容。

内容类

尖塔mod可加入的东西的种类非常之多,包括但不限于人物、卡牌、遗物、关键词、事件、动作、能力等等。接下来便会大致讲讲这些东西都是怎么实现的。

注意,在mod制作过程中应继承已由basemod封装的Customxxx父类而非原版的Abstractxxx父类。

人物

人物类是抽象类AbstractPlayer的子类。它包括的抽象方法包括但不限于指定初始卡组、指定初始遗物、指定对应颜色(尖塔里的卡牌种类是按颜色区分的)、指定选人界面素材、指定心脏对话、指定吸血鬼对话性别等等。人物类的构建比较繁杂与单一,这里就不细说了。

卡牌

卡牌类是抽象类AbstractCard的子类,需要在被定义后在注册类中注册。它包括的抽象方法有两个,use()upgrade()。前者在卡牌被使用时调用,而后者在卡牌被升级时调用。

卡牌被使用/触发效果时,一般都会触发一个或多个动作(action)。动作留待后面叙述,这里主要讲卡牌相关的功能大致是怎么实现的。

CustomCard的父类AbstractCard预定义了大量的函数与内置钩子供开发者自己与mod作者使用。这些函数与钩子囊括了大量的相关功能,包括但不限于规定此卡在本回合中的耗能(setCostForTurn()),规定此卡可以被打出的条件(canUse()),在主动丢弃此卡时触发特定效果(triggerOnManualDiscard())等等。而这些函数或钩子只需要在对应子类中调用或覆写即可使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
//战士哥基础打击的源代码
//包名以及导入的依赖类
package com.megacrit.cardcrawl.cards.red;

import com.megacrit.cardcrawl.actions.AbstractGameAction;
import com.megacrit.cardcrawl.actions.common.DamageAction;
import com.megacrit.cardcrawl.actions.common.DamageAllEnemiesAction;
import com.megacrit.cardcrawl.cards.AbstractCard;
import com.megacrit.cardcrawl.cards.DamageInfo;
import com.megacrit.cardcrawl.characters.AbstractPlayer;
import com.megacrit.cardcrawl.core.AbstractCreature;
import com.megacrit.cardcrawl.core.CardCrawlGame;
import com.megacrit.cardcrawl.core.Settings;
import com.megacrit.cardcrawl.dungeons.AbstractDungeon;
import com.megacrit.cardcrawl.localization.CardStrings;
import com.megacrit.cardcrawl.monsters.AbstractMonster;

public class Strike_Red extends AbstractCard {//注意,此处继承的是基本的AbstractCard类,mod作者在此处应继承已封装的CustomCard类
public static final String ID = "Strike_R";//规定卡牌ID

private static final CardStrings cardStrings = CardCrawlGame.languagePack.getCardStrings("Strike_R");//从本地化文件中获取对应文本

public Strike_Red() {
super("Strike_R", cardStrings.NAME, "red/attack/strike", 1, cardStrings.DESCRIPTION, AbstractCard.CardType.ATTACK, AbstractCard.CardColor.RED, AbstractCard.CardRarity.BASIC, AbstractCard.CardTarget.ENEMY);//构造函数。其中的参数分别为:ID,卡牌名称,卡图路径,耗能,卡牌描述,卡牌类型,卡牌颜色,卡牌稀有度,卡牌目标
this.baseDamage = 6;//规定卡牌基础伤害值,这个数值并不是最终的伤害值,需要送进calculateCardDamage()函数里计算出最终的this.damage
this.tags.add(AbstractCard.CardTags.STRIKE);//加打击标签,某些东西(如完美打击)要用
this.tags.add(AbstractCard.CardTags.STARTER_STRIKE);//加基础打击标签,某些东西(如打击木偶)要用
}

public void use(AbstractPlayer p, AbstractMonster m) {//使用时调用的函数
if (Settings.isDebug) {//这个判断条件下的代码仅供测试使用,mod编写时无需考虑
if (Settings.isInfo) {//群伤150,估计是测试时秒杀用的
this.multiDamage = new int[(AbstractDungeon.getCurrRoom()).monsters.monsters.size()];
for (int i = 0; i < (AbstractDungeon.getCurrRoom()).monsters.monsters.size(); i++)
this.multiDamage[i] = 150;
addToBot((AbstractGameAction)new DamageAllEnemiesAction((AbstractCreature)p, this.multiDamage, this.damageTypeForTurn, AbstractGameAction.AttackEffect.SLASH_DIAGONAL));
} else {
addToBot((AbstractGameAction)new DamageAction((AbstractCreature)m, new DamageInfo((AbstractCreature)p, 150, this.damageTypeForTurn), AbstractGameAction.AttackEffect.BLUNT_HEAVY));
}
} else {//这里才是打击的一般效果,效果为在动作管理器的底部加入一个新的伤害动作
addToBot((AbstractGameAction)new DamageAction((AbstractCreature)m, new DamageInfo((AbstractCreature)p, this.damage, this.damageTypeForTurn), AbstractGameAction.AttackEffect.SLASH_DIAGONAL));
}
}

public void upgrade() {//升级时调用的函数
if (!this.upgraded) {//如果卡牌尚未升级
upgradeName();//更改名称,打击变成打击+
upgradeDamage(3);//增加3点伤害
}
}

public AbstractCard makeCopy() {//复制(如双持等)时调用的函数,mod作者可以用来整点小花活,但正常情况下返回自己的一个新对象就可以了(继承CustomCard的情况下无需覆写这个函数)
return new Strike_Red();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
//我人物mod里的一张卡的相关代码,这张卡的内容是:技能·不能被打出。抽到这张牌时抽2(3)张牌。回忆:触发回忆时,抽2(3)张牌,然后将此牌返回手牌。(回忆:在手牌中时,满足条件便触发效果并消耗。)
//2023/9/14更新:此卡牌由于出现严重bug,具体实现方式已更改,不过例子也不用改吧(
//包名依赖省略

public class TrapLoop extends CustomCard implements AbstractMemoryCard {//这里使用了一个自定义接口,这个接口中的memory()函数会在合适的时机被调用
public static final String ID = YsyModHelper.MakePath(TrapLoop.class.getSimpleName());//卡牌ID
private static final CardStrings cardStrings = CardCrawlGame.languagePack.getCardStrings(ID);//卡牌文本
private static final String NAME = cardStrings.NAME;//卡牌名
private static final String DESCRIPTION = cardStrings.DESCRIPTION;//描述
private static final String IMG_PATH = "img/cards/TrapLoop.png";//图片
private static final int COST = -2;//花费。-2为不能被打出,-1为x费。
private static final CardType CARD_TYPE = CardType.SKILL;//类型,技能。
private static CardColor COLOR = AbstractCardEnum.xxx_COLOR;//颜色。这里的颜色在另一个文件中被@SpireEnum注解标明。
private static CardRarity CARD_RARITY = CardRarity.UNCOMMON;//稀有度。罕见。

public TrapLoop() {
super(NAME, cardStrings.NAME, IMG_PATH, COST, DESCRIPTION, CARD_TYPE,
COLOR, CARD_RARITY, CardTarget.NONE);
this.baseMagicNumber = 2;//基础魔法数字。所谓魔法数字,一般就是卡牌中除打防数值之外的数字。
this.magicNumber = this.baseMagicNumber;//魔法数字初始化
tags.add(CardTagsEnum.WHEN_MEMORY);//特殊标签(回忆时触发),这里其实用回调函数实现更好,但懒得做优化了
}

@Override
public void use(AbstractPlayer p, AbstractMonster m) {//卡牌不能被使用,这里的代码一般来说没有机会被调用
this.addToBot(new DrawCardAction(p, this.magicNumber));
}

public boolean canUse(AbstractPlayer p, AbstractMonster m) {//能否使用
this.cantUseMessage = cardStrings.EXTENDED_DESCRIPTION[0];//试图使用时角色会说一段话,在这里特殊定义
return false;//不能使用返回false
}

public void triggerWhenDrawn() {//抽到时触发
this.addToBot(new DrawCardAction(AbstractDungeon.player, this.magicNumber));//抽2(3)张牌的动作
}

@Override
public void memory() {//触发回忆时触发的函数
AbstractDungeon.actionManager.addToBottom(new UnExhaustAction(this));//这里借用了replaythespire中的相关代码,没错说的就是你地狱打击
AbstractDungeon.actionManager.addToTop(new DrawCardAction(AbstractDungeon.player, this.magicNumber));//抽2(3)张牌
}

@Override
public AbstractCard makeCopy() {
return new TrapLoop();
}

@Override
public void upgrade() {//升级
if (!this.upgraded) {
this.upgradeName();//修改名称
this.upgradeMagicNumber(1);//将魔法数字提升1
}
}
}
遗物

遗物是抽象类AbstractRelic的子类,不包含抽象方法。它的相关定义与卡牌类似,都是相关图片文本资源+定义属性+相关效果。遗物在定义后也需要在注册类中注册。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//破损核心
package com.megacrit.cardcrawl.relics;

import com.megacrit.cardcrawl.dungeons.AbstractDungeon;
import com.megacrit.cardcrawl.orbs.AbstractOrb;
import com.megacrit.cardcrawl.orbs.Lightning;

public class CrackedCore extends AbstractRelic {
public static final String ID = "Cracked Core";

public CrackedCore() {
super("Cracked Core", "crackedOrb.png", AbstractRelic.RelicTier.STARTER, AbstractRelic.LandingSound.CLINK);//遗物名称,遗物图片,遗物稀有度,遗物查看时的音效
}

public String getUpdatedDescription() {
return this.DESCRIPTIONS[0];
}

public void atPreBattle() {//战斗开始前
AbstractDungeon.player.channelOrb((AbstractOrb)new Lightning());//生成一个闪电充能球
}

public AbstractRelic makeCopy() {
return new CrackedCore();
}
}

遗物的实现方式与效果也多种多样,许多遗物的效果实际上并没有在本文件中说明,而是在对应的地方加入新的代码来实现的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
//灵体外质,这里只能看出生成条件与加一费
package com.megacrit.cardcrawl.relics;

import com.megacrit.cardcrawl.characters.AbstractPlayer;
import com.megacrit.cardcrawl.dungeons.AbstractDungeon;
import com.megacrit.cardcrawl.helpers.PowerTip;

public class Ectoplasm extends AbstractRelic {
public static final String ID = "Ectoplasm";

public Ectoplasm() {
super("Ectoplasm", "ectoplasm.png", AbstractRelic.RelicTier.BOSS, AbstractRelic.LandingSound.FLAT);
}

public String getUpdatedDescription() {
if (AbstractDungeon.player != null)
return setDescription(AbstractDungeon.player.chosenClass);
return setDescription((AbstractPlayer.PlayerClass)null);
}

private String setDescription(AbstractPlayer.PlayerClass c) {
return this.DESCRIPTIONS[1] + this.DESCRIPTIONS[0];
}

public void updateDescription(AbstractPlayer.PlayerClass c) {
this.description = setDescription(c);
this.tips.clear();
this.tips.add(new PowerTip(this.name, this.description));
initializeTips();
}

public void onEquip() {//获得后加一费
AbstractDungeon.player.energy.energyMaster++;
}

public void onUnequip() {//失去时减回去
AbstractDungeon.player.energy.energyMaster--;
}

public boolean canSpawn() {//只能在一层生成
if (AbstractDungeon.actNum > 1)
return false;
return true;
}

public AbstractRelic makeCopy() {
return new Ectoplasm();
}
}
//而它不能获得金币的效果实际上在AbstractPlayer类的gainGold()方法中
public void gainGold(int amount) {
if (this.hasRelic("Ectoplasm")) {//如果有灵体外质
this.getRelic("Ectoplasm").flash();//遗物闪一下,并取消加金币
} else {
if (amount <= 0) {
logger.info("NEGATIVE MONEY???");
} else {
CardCrawlGame.goldGained += amount;
this.gold += amount;//这里才是加金币
Iterator var2 = this.relics.iterator();

while(var2.hasNext()) {
AbstractRelic r = (AbstractRelic)var2.next();
r.onGainGold();//触发所有遗物的“获得金币时”效果
}
}
}
}

那么自定义的卡牌遗物等如何加入新的效果呢?一般有两个方法:

  • 一个是使用basemod增添的钩子在特定条件中加入触发的效果(如PostBattleSubscriber钩子的回调函数receivePostBattle()中的代码在注册此钩子后会在战斗开始前运行);

  • 如果basemod也没有相关的钩子,可以使用SpirePatch在尖塔源代码中插入一段特定的代码。

不过这些功能就比较复杂了,可以先去看看上面的教程和文档(

动作

动作是AbstractGameAction的子类,有一个抽象函数update(),在动作执行时被调用。它一般是一段特定的效果,在对应的时机被调用,加入尖塔的动作管理器(actionManager)中。它的意义是对一段常用或会触发其他东西的效果进行封装。动作不需要在注册类中注册,在合适的时机调用即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
//原版消耗特定数量卡的动作
package com.megacrit.cardcrawl.actions.common;

import com.megacrit.cardcrawl.actions.AbstractGameAction;
import com.megacrit.cardcrawl.cards.AbstractCard;
import com.megacrit.cardcrawl.characters.AbstractPlayer;
import com.megacrit.cardcrawl.core.AbstractCreature;
import com.megacrit.cardcrawl.core.CardCrawlGame;
import com.megacrit.cardcrawl.core.Settings;
import com.megacrit.cardcrawl.dungeons.AbstractDungeon;
import com.megacrit.cardcrawl.localization.UIStrings;

public class ExhaustAction extends AbstractGameAction {
private static final UIStrings uiStrings = CardCrawlGame.languagePack.getUIString("ExhaustAction");

public static final String[] TEXT = uiStrings.TEXT;

private AbstractPlayer p;

private boolean isRandom;

private boolean anyNumber;

private boolean canPickZero;

public static int numExhausted;
//以下为各种参数的构造函数
public ExhaustAction(int amount, boolean isRandom, boolean anyNumber, boolean canPickZero) {
this.anyNumber = anyNumber;//是否任意数量
this.p = AbstractDungeon.player;//玩家
this.canPickZero = canPickZero;//是否可以选0个
this.isRandom = isRandom;//是否是随机的
this.amount = amount;//消耗的数量
this.duration = this.startDuration = Settings.ACTION_DUR_FAST;//设置相关的配置
this.actionType = AbstractGameAction.ActionType.EXHAUST;//动作类型
}

public ExhaustAction(AbstractCreature target, AbstractCreature source, int amount, boolean isRandom, boolean anyNumber) {
this(amount, isRandom, anyNumber);
this.target = target;
this.source = source;
}

public ExhaustAction(AbstractCreature target, AbstractCreature source, int amount, boolean isRandom) {
this(amount, isRandom, false, false);
this.target = target;
this.source = source;
}

public ExhaustAction(AbstractCreature target, AbstractCreature source, int amount, boolean isRandom, boolean anyNumber, boolean canPickZero) {
this(amount, isRandom, anyNumber, canPickZero);
this.target = target;
this.source = source;
}

public ExhaustAction(boolean isRandom, boolean anyNumber, boolean canPickZero) {
this(99, isRandom, anyNumber, canPickZero);
}

public ExhaustAction(int amount, boolean canPickZero) {
this(amount, false, false, canPickZero);
}

public ExhaustAction(int amount, boolean isRandom, boolean anyNumber) {
this(amount, isRandom, anyNumber, false);
}

public ExhaustAction(int amount, boolean isRandom, boolean anyNumber, boolean canPickZero, float duration) {
this(amount, isRandom, anyNumber, canPickZero);
this.duration = this.startDuration = duration;
}

public void update() {//动作在执行时实际调用的函数
if (this.duration == this.startDuration) {//某项配置
if (this.p.hand.size() == 0) {//如果手牌数为0
this.isDone = true;//直接结束。isDone被标为true的动作会在接下来被清除动作队列。
return;//结束
}
if (!this.anyNumber &&
this.p.hand.size() <= this.amount) {//如果不是任选,且手牌数小于消耗数
this.amount = this.p.hand.size();
numExhausted = this.amount;
int tmp = this.p.hand.size();
for (int i = 0; i < tmp; i++) {//实际上就是挨个消耗所有手牌
AbstractCard c = this.p.hand.getTopCard();
this.p.hand.moveToExhaustPile(c);
}
CardCrawlGame.dungeon.checkForPactAchievement();//似乎是某项成就
return;//结束
}
if (this.isRandom) {//如果随机消耗
for (int i = 0; i < this.amount; i++)
this.p.hand.moveToExhaustPile(this.p.hand.getRandomCard(AbstractDungeon.cardRandomRng)); //随机消耗手牌
CardCrawlGame.dungeon.checkForPactAchievement();
} else {//如果要选
numExhausted = this.amount;
AbstractDungeon.handCardSelectScreen.open(TEXT[0], this.amount, this.anyNumber, this.canPickZero);//打开选卡屏幕
tickDuration();//度过一帧后标记为解决
return;
}
}
if (!AbstractDungeon.handCardSelectScreen.wereCardsRetrieved) {//选择未解决
for (AbstractCard c : AbstractDungeon.handCardSelectScreen.selectedCards.group)
this.p.hand.moveToExhaustPile(c); //选择的卡全进消耗堆
CardCrawlGame.dungeon.checkForPactAchievement();
AbstractDungeon.handCardSelectScreen.wereCardsRetrieved = true;//选择标记为已解决
}
tickDuration();//度过一帧后标记为解决
}
}

在其他地方(如卡牌遗物代码中),只需要addToBot()addToTop()即可把对应动作加入动作队列的底部/顶部。游戏会从上往下依次处理这些事件。

能力

能力是抽象类AbstractPower的子类,没有抽象方法。它的实现方法和卡牌遗物类似,也是图片文本素材+特定效果。不过某些效果可能比较难以实现。。。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
//战士第一卡,腐化对应的能力
package com.megacrit.cardcrawl.powers;

import com.megacrit.cardcrawl.actions.utility.UseCardAction;
import com.megacrit.cardcrawl.cards.AbstractCard;
import com.megacrit.cardcrawl.core.AbstractCreature;
import com.megacrit.cardcrawl.core.CardCrawlGame;
import com.megacrit.cardcrawl.localization.PowerStrings;

public class CorruptionPower extends AbstractPower {
public static final String POWER_ID = "Corruption";

private static final PowerStrings powerStrings = CardCrawlGame.languagePack.getPowerStrings("Corruption");

public static final String NAME = powerStrings.NAME;

public static final String[] DESCRIPTIONS = powerStrings.DESCRIPTIONS;

public CorruptionPower(AbstractCreature owner) {
this.name = NAME;
this.ID = "Corruption";
this.owner = owner;
this.amount = -1;//这里指定为-1表示该能力无法叠加
this.description = DESCRIPTIONS[0];
loadRegion("corruption");
}

public void updateDescription() {
this.description = DESCRIPTIONS[1];
}

public void onCardDraw(AbstractCard card) {//抽到卡牌时
if (card.type == AbstractCard.CardType.SKILL)//如果此牌为技能牌
card.setCostForTurn(-9); //设置临时花费为-9
}

public void onUseCard(AbstractCard card, UseCardAction action) {//使用卡牌时
if (card.type == AbstractCard.CardType.SKILL) {//如果此牌为能力牌
flash();//闪一下
action.exhaustCard = true;//消耗此牌
}
}
}

然而腐化的实现远没有看起来那么简单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//摘自原版ShowCardAndAddToHandEffect.class的一个构造函数
public ShowCardAndAddToHandEffect(AbstractCard card, float offsetX, float offsetY) {
this.card = card;
UnlockTracker.markCardAsSeen(card.cardID);
card.current_x = (float)Settings.WIDTH / 2.0F;
card.current_y = (float)Settings.HEIGHT / 2.0F;
card.target_x = offsetX;
card.target_y = offsetY;
this.duration = 0.8F;
card.drawScale = 0.75F;
card.targetDrawScale = 0.75F;
card.transparency = 0.01F;
card.targetTransparency = 1.0F;
card.fadingOut = false;
this.playCardObtainSfx();
if (card.type != CardType.CURSE && card.type != CardType.STATUS && AbstractDungeon.player.hasPower("MasterRealityPower")) {//操控现实
card.upgrade();
}

AbstractDungeon.player.hand.addToHand(card);
card.triggerWhenCopied();
AbstractDungeon.player.hand.refreshHandLayout();
AbstractDungeon.player.hand.applyPowers();
AbstractDungeon.player.onCardDrawOrDiscard();
if (AbstractDungeon.player.hasPower("Corruption") && card.type == CardType.SKILL) {//腐化
card.setCostForTurn(-9);
}
}

这里实际上就是让战斗中临时加入的卡牌也能吃到腐化的减费效果。而操控现实也类似。

然而游戏中的类似动作或效果实际上远不止这一个,而这些东西都要加上腐化、操控现实之类的判断,于是相关效果的实现实际上是非常麻烦且极其容易出bug的。

官方和basemod都没有提供相关钩子,这感觉也是很难看见mod里有类似设计的原因。。。(不过还是有的)

其他感想

总的来说,游戏的逻辑跟我原来想当然的完全不一样。我跟你讲逻辑,你跟我讲遍历,许多看似高大上的效果说到底还是在特定地方加个特定条件然后遍历而已。

1
2
3
4
5
6
7
8
9
10
11
//basemod中注册钩子的相关代码,实际上就是挨个判断然后加进对应数组里
public static void subscribe(ISubscriber sub, Class<? extends ISubscriber> additionClass) {
if (additionClass.equals(StartActSubscriber.class)) {
startActSubscribers.add((StartActSubscriber)sub);
} else if (additionClass.equals(PostCampfireSubscriber.class)) {
postCampfireSubscribers.add((PostCampfireSubscriber)sub);
} else if (additionClass.equals(PostDrawSubscriber.class)) {
postDrawSubscribers.add((PostDrawSubscriber)sub);
}
//以下省略一万字
}

那这些钩子是在哪起作用的呢?这里举个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//以下代码摘自basemod中的特定文件
//ApplyPowerActionPostPowerApplyHook.class,名字挺长实际上就是在ApplyPowerAction中插了一段用于触发钩子的代码
@SpirePatch(cls = "com.megacrit.cardcrawl.actions.common.ApplyPowerAction", method = "update")
public class ApplyPowerActionPostPowerApplyHook {
@SpireInsertPatch(rloc = 6, localvars = {"powerToApply", "target", "source"})//插入定位
public static void Insert(ApplyPowerAction apa, AbstractPower powerToApply, AbstractCreature target, AbstractCreature source) {
BaseMod.publishPostPowerApply(powerToApply, target, source);//触发钩子
}
}
//那这个publishPostPowerApply()又是个啥?
//basemod.class
public static void publishPostPowerApply(AbstractPower p, AbstractCreature target, AbstractCreature source) {
logger.info("publish on post power apply");
for (PostPowerApplySubscriber sub : postPowerApplySubscribers)//遍历钩子
sub.receivePostPowerApplySubscriber(p, target, source); //触发钩子
unsubscribeLaterHelper((Class)PostPowerApplySubscriber.class);
}

总的来说还是遍历。

有人说尖塔不过是一个带动画的计算器而已,这种说法确实有道理,不过有的代码里面还是有惊喜的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
//崩坠mod中枪手神抽调用的动作。神抽:罕见·1·技能:抽牌直到总耗能为3(4)
package hermit.actions;

import com.megacrit.cardcrawl.actions.AbstractGameAction;
import com.megacrit.cardcrawl.actions.common.DrawCardAction;
import com.megacrit.cardcrawl.actions.common.EmptyDeckShuffleAction;
import com.megacrit.cardcrawl.cards.AbstractCard;
import com.megacrit.cardcrawl.characters.AbstractPlayer;
import com.megacrit.cardcrawl.core.AbstractCreature;
import com.megacrit.cardcrawl.core.Settings;
import com.megacrit.cardcrawl.dungeons.AbstractDungeon;

public class LuckDrawAction extends AbstractGameAction {
private AbstractPlayer p;

private AbstractCard.CardType typeToCheck;

private int energy;

private int tracker = 0;

public LuckDrawAction(int energy) {
this.p = AbstractDungeon.player;
setValues((AbstractCreature)this.p, (AbstractCreature)this.p);
this.actionType = AbstractGameAction.ActionType.CARD_MANIPULATION;
this.duration = Settings.ACTION_DUR_MED;
this.typeToCheck = AbstractCard.CardType.CURSE;
this.energy = energy;
}

public void update() {
if (this.duration == Settings.ACTION_DUR_MED) {
if (this.tracker >= this.energy || (this.p.drawPile.isEmpty() && this.p.discardPile.isEmpty()) || this.p.hand.size() >= 10) {//追踪量大于总能量数目(3/4)或抽牌弃牌堆皆空或手牌满了(不过这里有点小问题,应该用BaseMod.MAX_HAND_SIZE代替10以保证兼容性)
this.isDone = true;//结束动作
return;
}
if (AbstractDungeon.player.hasPower("No Draw")) {//如果玩家有无法抽牌的能力
AbstractDungeon.player.getPower("No Draw").flash();//能力闪一下
this.isDone = true;//结束动作
return;
}
if (!this.p.drawPile.isEmpty()) {//抽牌堆不为空
AbstractCard c = AbstractDungeon.player.drawPile.group.get(AbstractDungeon.player.drawPile.size() - 1);//提取抽牌堆最后一张卡
if (c.cost > 0)//耗能大于0的话
this.tracker += c.cost; //追踪量加上它的耗能
addToTop(new LuckDrawAction(this.energy - this.tracker));//递归调用自身,传入总能量减去当前追踪量
addToTop((AbstractGameAction)new DrawCardAction(1));//把这张牌抽上来
} else {//否则直接递归并抽牌
addToTop(new LuckDrawAction(this.energy - this.tracker));
addToTop((AbstractGameAction)new EmptyDeckShuffleAction());
}
this.isDone = true;//结束
return;
}
tickDuration();//度过一帧后结束
}
}

这里居然能看见递归,也是惊喜了。

其他文章
cover
【自设架空】随笔一
  • 23/07/20
  • 12:40
  • 架空
目录导航 置顶
  1. 1. 杀戮尖塔mod开发的二三事
    1. 1.1. 开发过程
      1. 1.1.1. 准备
      2. 1.1.2. 构建框架
      3. 1.1.3. 实际上手
    2. 1.2. 其他感想