DragonWind

杀戮尖塔源码分析

主要聚焦于战斗部分

Relics

Ascension实现

就是在Ascension对应的地方插入if(hasLevel),有点原始。

if (AscensionHelper.HasAscension(AscensionLevel.Poverty))
{
	num *= AscensionHelper.PovertyAscensionGoldMultiplier;
}

1.SwarmingElites

~60% more Elites will spawn.

在地图生成的时候,根据值来判断生成几个。

NumOfElites = (int)Math.Round(5f * (AscensionHelper.HasAscension(AscensionLevel.SwarmingElites) ? 1.6f : 1f));

2.Weary Traveler

Ancients only heal 80% of your missing HP. This includes Neow at the start of the game.

同样,回血的时候Hook一下实现。

if (RunManager.Instance.HasAscension(AscensionLevel.WearyTraveler))
{
	amount *= 0.8m;
}

3.Poverty

Enemies and Treasure Chests drop 25% less Gold.

掉钱的地方Hook一下,肯定不是每次掉钱的地方,应该有一个统一的接口。

具体实现有些特殊,因为Enemy掉钱是在每个Encounter里的,所以在Encounter里Hook一下。Treasure里就直接Hook,此处略。

public virtual int MinGoldReward
{
	get
	{
		double num = RoomType switch
		{
			RoomType.Monster => 10, 
			RoomType.Elite => 35, 
			RoomType.Boss => 100, 
			_ => 0, 
		};
		if (AscensionHelper.HasAscension(AscensionLevel.Poverty))
		{
			num *= AscensionHelper.PovertyAscensionGoldMultiplier;
		}
		return (int)num;
	}
}

4.Tight Belt

Start each run with 1 less potion slot.

像这种,游戏开始的时候Hook一下。

5.Ascender’s Bane

Start each run with an StS2 CardIcon Curse.png Ascender’s Bane.

同上。

6.Gloom

1 fewer Rest Site per Act.

这个是生成地图的时候,Hook一下。

if (AscensionHelper.HasAscension(AscensionLevel.Gloom))
{
	num--;
}

7.Scarcity

Rare and Upgraded cards appear half as often. This includes combat rewards from all Enemies as well as the stock of the Merchant.

设置卡概率的时候Hook一下。

8.Tough Enemies

All Enemies have more HP.

应该是怪物属性base的地方Hook一下。

每个怪物都Hook了。

应该是为了差异化,而不是设置一个倍率。

9.Deadly Enemies

All Enemies deal more damage.

同上

10.(MAX Level) Double Boss.

Fight two Bosses at the end of Act 3.

这个是生成房间的时候Hook。

Hook实现

开篇

简单来说,其实很多的Power都是通过Hook来实现的。

Power就是在对应的时机,执行对应的操作。

我们先从效果入手,从局部到整体。

现在我们有一个遗物BurningBlood

At the end of combat, heal 6 HP.

思考一下怎么实现。

我们把个效果拆成两部分。

At the end of combat,也就是时机

heal 6 hp,也就是行为。

综合起来就是,在什么时机进行什么行为

我们再多举几个例子:

Ring of the Snake

At the start of each combat, draw 2 additional cards.

Anchor

Start each combat with 10 Block

Blood Vial

At the start of each combat, heal 2 HP

如何实现AfterCombatVictory

注意到Unity有类似的时机实现逻辑:MonoBehaviour的生命周期,OnAwake OnStart OnUpdate OnDestroy,我们新建的脚本继承之,找到我们需要的时机进行对应的逻辑编写。

在杀戮尖塔2中,所有逻辑行为在类中,类抽象为一个Model,包括RelicModel PotionModel CardModel 等。这些Model都继承一个抽象类AbstractModel。在AbstractModel中定义了所有游戏内时机的虚函数,比如AfterCombatVictory

public abstract class AbstractModel{
	public virtual Task AfterCombatVictory(CombatRoom room)
	{
		return Task.CompletedTask;
	}
}

BurningBlood的实现中,重写改虚函数:

public override async Task AfterCombatVictory(CombatRoom _)
{
	if (!base.Owner.Creature.IsDead)
	{
		Flash();
		await CreatureCmd.Heal(base.Owner.Creature, base.DynamicVars.Heal.BaseValue);
	}
}

注意到这里的行为Cmd关键字,有很强的设计因素,会在后续Cmd实现中介绍。此处略过,只需知道会做行为即可。

如何触发AfterCombatVictory

Hook中,我们有如下代码:

public static async Task AfterCombatVictory(IRunState runState, CombatState? combatState, CombatRoom room)
{
	foreach (AbstractModel model in runState.IterateHookListeners(combatState))
	{
		await model.AfterCombatVictoryEarly(room);
		model.InvokeExecutionFinished();
	}
	foreach (AbstractModel model in runState.IterateHookListeners(combatState))
	{
		await model.AfterCombatVictory(room);
		model.InvokeExecutionFinished();
	}
}

代码很清晰了,把游戏内数据传进来,在Hook中,遍历本局游戏的所有AbstractModel,然后执行model对应的逻辑,把必要的数据传进去。

在杀戮尖塔有两种遍历方式:

实际上,几乎所有的时机都是这样触发的,区别只是要传进的数据不同。

什么时候触发AfterCombatVictory

那么在什么时候才会触发AfterCombatVictory呢?这里就到了游戏主循环中。

public class CombatManager{
	public async Task EndCombatInternal(){
		//部分代码略去
		await Hook.AfterCombatVictory(runState, combatState, room);
		//部分代码略去
	}
}

这里就和游戏逻辑强绑定了,也就是我们所说的核心Hook概念。在对应的时机插入Hook

小结

杀戮尖塔采用Hook插入的方式,实现时机触发行为。其实一些看起来奇怪的行为都是这样实现的,做一些变换即可。实际上,所有的遗物效果和能力牌都是这样实现的。举几个简单例子:

Ring of the Drake

At the start of your first 3 turns, draw 2 additional cards.

这里有几种实现方式:

第一种:定义一个时机前三回合开始时,用这个时机

第二种:用回合开始时时机,但是执行的时候,判断当前回合数。

第三种:用回合开始时玩家抽牌,是否要改变抽牌数量的时机。同样判断当前回合数。

public virtual decimal ModifyHandDraw(Player player, decimal count)
{
	return count;
}

举几个练习例子

Book of Five Rings

Every 5 cards you add to your Deck, heal 15 HP.

时机AfterCardChangedPiles

Happy Flower

Every 3 turns, gain StS2 EnergyColorless.png.

时机AfterSideTurnStart

Juzu Bracelet

Regular enemy combats are no longer encountered in ? rooms.

这个时机有点难确定,但是我们思考一下,每次进入?房间,是不是都要随机一下这个?房间的类型是什么,是奖励房/怪物房/事件房/商人房。

public enum RoomType
{
	Unassigned,
	Monster,
	Elite,
	Boss,
	Treasure,
	Shop,
	Event,
	RestSite,
	Map
}

那么时机就呼之欲出了,在每次Roll?房间类型的时候,改变能Roll到的类型。ModifyUnknownMapPointRoomTypes

其他游戏的例子

我们可以思考一下这一套Hook逻辑是否可以应用到其他游戏中。

炉石传说中:Ruby Sanctum

Ruby Sanctum

Your next Healing effect this turn deals damage instand.

这个其实有些复杂,因为它牵扯到了Power,关于Power的生命周期后面会讲。这里先简单认为,使用这张地标卡,会给玩家加上这个Power,回合结束时,会删掉这个Power。我们这里聚焦这个Power的实现。

对应地,我们会定义一个时机,这里有几种方式,比如:

第一种,定义时机该治疗是否要转为伤害,重写该函数并返回true即可。

第二种,定义时机更改治疗值,重写该函数并返回原值的负数即可,但是考虑到造成伤害的逻辑,可能还要额外返回修改了类型,把治疗修改为伤害。

英雄联盟中:

nami有一个e技能Tidecaller‘s BlessingNami Tidecaller's Blessing

*ACTIVE:* Nami blesses herself or an allied champion for 6 seconds, empowering their next 3 basic attacks or abilities to each deal bonus magic damage and Slow icon slow enemies for 1 second.

思考一下怎么实现,首先当然是给英雄一个Power,在英雄联盟中我们习惯称Buff。那么这个Power的触发时机应该是什么?

简单地,我们可以定义时机造成伤害后。然后行为就是造成伤害+上减速Buff。

或者我们可以定义时机造成伤害前追加伤害段,然后追加一个伤害段,追加一个减速Buff段。

Potion实现

Potion使用方式PotionUsage分为几种:

CombatOnly

AnyTime

Automatic

然后会有使用目标TargetType

Self AnyEnemy AllEnemies RandomEnemy AnyPlayer AnyAlly AllAllies TargetedNoCreature Osty

使用结果一般都是执行Cmd把对应参数传进去。

举个例子

Ashwater

GameAction实现

我们把玩家能做的操作都抽象成GameAction,这样做有两个好处:

DiscardPotion

UsePotion

EndPlayerTurn

UndoEndPlayerTurn

MoveToMapCoord

PickRelic

PlayCard

这些操作的执行通过GUI发送到游戏逻辑中。

我们从一个简单的使用药水的例子说起,略去了部分代码:

public class UsePotionAction : GameAction
{
	public override ulong OwnerId => Player.NetId;

	public UsePotionAction(PotionModel potion, Creature? target, bool isCombatInIsProgress)
	{
	//初始化逻辑
	}

	public UsePotionAction(Player player, uint potionIndex, uint? targetId, ulong? targetPlayerId, bool isCombatInProgress)
	{
	//初始化逻辑
	}

	protected override async Task ExecuteAction()
	{
		//执行逻辑
	}

	protected override void CancelAction()
	{
		//取消执行逻辑
	}

	public override INetAction ToNetAction()
	{
		return new NetUsePotionAction
		{
			potionIndex = PotionIndex,
			targetId = TargetId,
			targetPlayerId = TargetPlayerId,
			enqueuedInCombat = WasEnqueuedInCombat
		};
	}

	public override string ToString()
	{
		打印数据,Debug用
	}
}

每个具体的GameAction会定义好自己需要的字段,在外面GUI执行的过程中通过构造函数传进来;在ExecuteAction中定义好自己的执行逻辑(异步);ToNetAction的作用是把本GameAction转成能序列化的结构。

值得一提的是,杀戮尖塔2的消息序列化没有用到常用的序列化库,比如二进制序列化,ProtoBuf等等,而是自己手动把结构字段写进字节流中,后面讲到网络同步模块会详细讲述。

如何生成GameAction

在GUI中根据玩家操作,手动new出对于的GameAction,这里不多赘述。

如何执行GameAction

杀戮尖塔采用的是类似生产者-消费者模型,维护一个内部队列。

RunManager.Instance.ActionQueueSynchronizer.RequestEnqueue(GameAction);
public void RequestEnqueue(GameAction action)
{
//如果本操作需要战斗中,但是当前游戏没在,那么先进waiting队列。
	if (action.ActionType == GameActionType.CombatPlayPhaseOnly && CombatState == ActionSynchronizerCombatState.NotPlayPhase)
	{
	_requestedActionsWaitingForPlayerTurn.Add(action);
		return;
	}
	
//区分主机客机,如果是客机玩家,则把GameAction发包过去。如果是主机玩家或者单人游戏,直接入队。
	switch (_netService.Type)
	{
	case NetGameType.Client:
	{
		RequestEnqueueActionMessage message = new RequestEnqueueActionMessage
		{
			action = action.ToNetAction(),
			location = _messageBuffer.CurrentLocation
		};
		_netService.SendMessage(message);
		break;
	}
	case NetGameType.Singleplayer:
	case NetGameType.Host:
		EnqueueAction(action, _netService.NetId);
		break;
	}
}
private void EnqueueAction(GameAction action, ulong actionOwnerId)
{
    //如果是主机,广播一下操作给其他客机
	if (_netService.Type == NetGameType.Host)
	{
		ActionEnqueuedMessage message = new ActionEnqueuedMessage
		{
			playerId = actionOwnerId,
			location = _messageBuffer.CurrentLocation,
			action = action.ToNetAction()
		};
		_netService.SendMessage(message);
	}
	
	//进同步队列
	_actionQueueSet.EnqueueWithoutSynchronizing(action);
}

简单总结一下,假设A是主机,BCD是客机:

A发起Action,进同步队列,广播给BCD。

B发起Action,发送RequestAction消息给主机,主机收到后,进同步队列,广播给BCD。

public void EnqueueWithoutSynchronizing(GameAction gameAction)
{
	//初始化 同时赋值ID给GameAction
	//事件触发,给GUI那边同步
	gameAction.OnEnqueued(PopAction, GetAndIncrementActionId());
		this.ActionEnqueued?.Invoke(gameAction);
		
	//找到对应玩家的Action队列,入队,事件触发
	ActionQueue queue = GetQueue(gameAction.OwnerId);
		queue.actions.Add(gameAction);
		this.ActionQueueChanged?.Invoke();
	}
}
private async Task ExecuteActions()
{
	_queueTaskCompletionSource = new TaskCompletionSource<bool>();
	_actionCancelToken = new CancellationTokenSource();
	//找到一个OK的Action
		GameAction readyAction = _actionQueueSet.GetReadyAction();
		//执行到没有可执行的Action为止
		while (readyAction != null)
		{
			await WaitForUnpause();
			if (readyAction.State == GameActionState.Canceled)
			{
				readyAction = _actionQueueSet.GetReadyAction();
				continue;
			}
			this.BeforeActionExecuted?.Invoke(readyAction);
			
			if (NonInteractiveMode.IsActive)
			{
				CurrentlyRunningAction = readyAction;
				await readyAction.Execute();
				AfterActionFinished(readyAction);
			}
			
			else
			{
				CurrentlyRunningAction = readyAction;
				readyAction.AfterFinished += AfterActionFinished;
				进入执行逻辑
				Task actionTask = readyAction.Execute();
				//轮询等待
				while (!actionTask.IsCompleted && !_actionCancelToken.IsCancellationRequested)
				{
					await Engine.GetMainLoop().ToSignal(Engine.GetMainLoop(), SceneTree.SignalName.ProcessFrame);
				}
			}
			if (CombatManager.Instance.IsInProgress)
			{
				await CombatManager.Instance.CheckWinCondition();
			}
			readyAction.AfterFinished -= AfterActionFinished;
			readyAction = _actionQueueSet.GetReadyAction();
		}
		_queueTaskCompletionSource?.SetResult(result: true);
	}
}

To be continued.