Basic Time and Timers Guide
本文内容整理自 tModLoader 官方 Wiki(Terraria 模组开发指南),原文:Basic Time and Timers Guide。
本指南已更新到 1.4,想看旧的 1.3 版本页面点这里。
写模组的时候,你迟早会想让某个动作晚点发生,或者别做得那么频繁 —— 这就是时间和计时器要解决的问题。下面把跟时间相关的概念挨个过一遍,免得你在代码里埋下自己都没察觉的坑。
冷却时间
冷却(cooldown)本质就是个计时器,区别只在于它往往配一个触发条件,而不是无休止地重复。这篇里的所有思路都能拿来写冷却:按自定义按键、冲刺(dash),各种场合都一样。
别用循环
写计时器最要紧的一点:调用那些钩子和方法的,是游戏自己的主循环。你代码里写的循环不会变成计时器 —— 它在纳秒之内就转完了该转的次数,然后接着往下执行。计时器必须建立在「游戏循环每 tick 调用一次这些方法」这个前提上。下面两个是模组作者最常犯的错。
常见错误示范
第一个例子误以为 while 循环会让代码在这儿等 60 tick 再往下走。并不会:循环转 60 次,紧接着就执行下面的代码。正确做法是把计时器存进非局部变量,并且把循环去掉,后面几个例子都是这么写的。
1
2
3
4
5
6
int timer = 60;
while (timer > 0)
{
timer--;
}
// Other code intended to run after timer expires第二个例子同样不行:timer 定义在同一个作用域里,永远涨不过 60 —— 它从 0 开始,自增到 1,下一 tick 又从 0 开始。把 int timer = 0; 这类定义挪到方法外面(一般是放到类里)就行,细节见下面的「用新字段做计时器」一节。
1
2
3
4
5
6
7
int timer = 0;
timer++;
if (timer > 60)
{
// Other code intended to run after timer expires
timer = 0;
}现实时间、游戏时间与世界时间
先弄清楚这几种「时间」分别指什么。
现实时间(Wall Time) 是现实世界里真实流逝的时间。写模组几乎不该碰它,因为我们的效果通常要跟游戏、跟游戏世界保持同样的节奏。用现实时间意味着:游戏暂停了、或者帧率掉下来在慢放,你写的事件照样在跑 —— 这会引出一堆 bug。
世界时间(World Time) 是世界自己报告的时间。用得不对会出很荒唐的事,比如附魔日晷(enchanted sundial)生效时,事件以极快的速度接连发生。
游戏时间(Game Time) 才是绝大多数玩法效果该用的东西。Terraria 的目标是每秒 60 帧,但机器扛不住的时候游戏会整体慢放。逻辑里用游戏时间计数,效果就能维持同样的相对速度。举个例子:某个敌怪本该每秒攻击一次,若用现实时间,跑 30 FPS 的玩家要躲的攻击次数就翻倍了。
总结一下:几乎什么都用游戏时间,别用现实时间;只有跟世界昼夜循环绑在一起的东西才用世界时间。
游戏时间
游戏时间是我们写玩法计时器时的主力。它跟帧率挂钩,所以效果出现的频率和游戏本身一致 —— 想在模组里实现一个「计时器」,这基本就是你要的行为。
游戏时间以 tick 为单位。tick 指的是游戏逻辑更新一次:每过一个 tick,弹幕按速度前进一段,其它东西也各自往前走一步。机器够好的话,一秒有 60 tick,所以一个想持续 2 秒的效果要数到 120 tick。提醒一句:玩家跑不满 60 FPS 又关掉了跳帧(frame skip)时,这个「2 秒」并不精确 —— 不过没关系,游戏里别的东西也一样被拖慢了。
按 tick 数实现的计时器很简单:在类里放一个字段,每次 Update / AI 之类的调用里给它加一,然后拿 if 判断有没有到目标值,到了就执行你要的动作,比如生成一个 Projectile。如果这个动作要重复触发,就在 if 里把计时器归零。(反过来写也行:给计时器一个初始值,一路减到 0 —— 效果一样,看个人习惯。)
常用字段
Main.GameUpdateCount- 每加载一次世界就重置为 0。每 tick 加 1,游戏暂停期间也在加。Player.miscCounter- 每 tick 加 1,在 0 到 299 之间循环。适合用来驱动玩家的视觉表现。NPC.ai[]和Projectile.ai[]- 原版代码里很常见的做法:直接拿这两个数组当计时器。Projectile.timeLeft- 一路倒数到 0,到 0 弹幕就自动消失。默认是 3600(60 秒),可以在SetDefaults里改。可以当个简单计时器用,一般用来数生成之后过了多少 tick,或者离消失还有多久。见下面的例子。Projectile.extraUpdates/Projectile.MaxUpdates- 它会让Projectile.Update在一个 tick 里跑多次,以实现特殊效果。如果你写的计时器快得不对劲,多半是它的锅 —— 得按多出来的 Update 次数把计时逻辑换算一下。
示例
下面的例子都用 ModNPC.AI 或 ModProjectile.AI 来推进计时器。
用 Projectile.ai[] 做计时器(NPC.ai[] 同理)
(这里拿 Projectile.ai[] 举例,NPC.ai[] 的用法完全一样)
如果你写的是自己实现的 AI,或者确认所用的 aiStyle 没有占用那两个 ai 槽位(NPC 是 4 个),就可以拿 Projectile.ai[] 当计时器。它自带联机同步,用起来挺方便 —— 前提是你得先查清楚这个槽位确实没人用:
1
2
3
4
5
6
Projectile.ai[0]++;
if(Projectile.ai[0] > 120) {
// Our timer has finished, do something here:
// SoundEngine.PlaySound, Dust.NewDust, Projectile.NewProjectile, etc. Up to you.
Projectile.ai[0] = 0;
}ExampleAdvancedAnimatedProjectile.cs 就是个现成的例子:它用计时器让弹幕在 50 tick 里淡入,然后淡出。
用属性包一层,代码会好读很多:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public float Timer {
get => Projectile.ai[0];
set => Projectile.ai[0] = value;
}
public override void AI() {
// Other code...
Timer++;
if (Timer > 120) {
// Our timer has finished, do something here:
// SoundEngine.PlaySound, Dust.NewDust, Projectile.NewProjectile, etc. Up to you.
Timer = 0;
}
// Other code...
}ExampleCustomAISlimeNPC.cs 用的就是这个写法。
用新字段做计时器
NPC.ai[] 和 Projectile.ai[] 由游戏自动做联机同步,但你也可以在 ModNPC 或 ModProjectile 里自己加字段。这样读起来清楚得多,代价是这份数据可能得你手动同步 —— 想了解细节去看 Multiplayer Compatibility。也有些数据本来就不需要同步,平时多留心哪些该同步、哪些不用。
加个字段当计时器很简单:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class ExampleBullet : ModProjectile
{
public int Timer;
public override void SetDefaults() {
// code here
}
public override void AI() {
Timer++;
if (Timer > 120) {
// Our timer has finished, do something here:
// SoundEngine.PlaySound, Dust.NewDust, Projectile.NewProjectile, etc. Up to you.
Timer = 0;
}
}
}ExampleMod 里例子很多:Abomination 的 Send/ReceiveExtraAI、更多例子。
用 Projectile.timeLeft 做一次性计时
只需要在生成后第 X tick 做一件事的话,可以直接借 Projectile.timeLeft(也许你想在 ModProjectile.SetDefaults 里把它改小一点):
1
2
3
4
// when the projectile has 1 seconds left in its life
if(Projectile.timeLeft == 60) {
// do something here.
}取模计时器
纯视觉的东西可以偷个懒,直接拿 Main.GameUpdateCount 做计时。玩法逻辑别这么干 —— 不精确,会出问题。
1
2
3
if(Main.GameUpdateCount % 60 == 0) {
// Dust.NewDust or some other visual effect.
}再看一个例子:让 4 种颜色每 1 秒切换一次。GameUpdateCount 除以 60 就变成了「秒」,再对 4 取模就能循环起来。这段代码来自 ModifyTooltips 方法,不过思路放到别的地方也通用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
switch (Main.GameUpdateCount / 60 % 4)
{
case 0:
line.OverrideColor = new Color(254, 105, 47);
break;
case 1:
line.OverrideColor = new Color(34, 221, 151);
break;
case 2:
line.OverrideColor = new Color(190, 30, 209);
break;
case 3:
line.OverrideColor = new Color(0, 106, 185);
break;
}循环、往复与插值效果
要做一个随时间变化的视觉效果,可以交给计时器控制。比如让 tooltip 的颜色在两个颜色之间过渡 —— 用计时器控制「离第二个颜色还有多近」,这就叫插值(interpolation)。做法是用取模计时器,再把结果除以周期:(Main.GameUpdateCount % 60) / 60f 会从 0 慢慢走向 1,60 次游戏更新(1 秒)之后回到 0 重来。把这个值丢给 Color.Lerp,拿到的就是落在两个颜色之间的结果。
循环
1
2
float lerpAmount = (Main.GameUpdateCount % 60) / 60f;
Color color = Color.Lerp(Color.Red, Color.Green, fade);游戏每次更新都跑一遍这段,颜色就会在一秒里从红过渡到绿,然后跳回红。改 60 就能调节奏。类似的还有 MathHelper.Lerp、Utils.MultiLerp,可以自己试着玩。
往复
另一种做法是往复(cycling):从 0 插值到 1,再回到 0。这样能避开循环那种「啪」地跳回去的突兀感。Utils.PingPongFrom01To010 接收一个 0 到 1 的值,把它在 0 和 1 之间来回插值而不产生跳变。下面的例子就是红 → 绿 → 红,然后重复。
1
2
float lerpAmount = Utils.PingPongFrom01To010((Main.GameUpdateCount % 60) / 60f);
Color color = Color.Lerp(Color.Red, Color.Green, fade);Utils.Turn01ToCyclic010 作用一样,但走的是余弦波那样平滑的路径。想要更顺、避免中途突然反向,就用它。
世界时间
世界时间通常和游戏时间同步推进,但有些场合两者的差别很关键:游戏暂停时世界时间不走;附魔日晷生效期间,时间是平时的 60 倍速;旅途模式(Journey Mode)也能调世界时间。另外要注意,模组同样可能改变时间流速 —— 比如 HerosMod 能把时间停住,这时候靠世界时间做玩法效果就会失灵。
常用字段
Main.dayTime- 白天为 true,夜晚为 falseMain.time- 白天是 0(凌晨 4:30)到 54000(晚上 7:30)之间的值,夜晚是 0(晚上 7:30)到 32400(凌晨 4:30)之间的值,要配合Main.dayTime使用。它通常每 tick 加 1(见Main.dayRate)。游戏里每个小时等于 3600 tick。- 例如:
Main.dayTime && Main.time < 18000.0表示早晨 4:30 到 9:30(因为 18000/3600 == 5)
- 例如:
Main.dayRate- 正常是 1,附魔日晷生效期间是 60。这个值会在Main.UpdateTime方法里被加到Main.time上。
典型用途
- NPC 生成
- 世界更新事件,例如 ExampleWorld 里的火山事件(原文这里还挂着 TODO)
现实时间
刚上手的时候,你可能去搜过「c# timer」,然后看到一堆用 System.Timers 的写法。那些跟电子游戏、跟 tModLoader 模组完全不搭边,它们属于现实时间。该用哪种,回去看上面的世界时间和游戏时间。再说一遍:用 System.Timers 基本一定是错的 —— 玩家一暂停游戏或者点开附魔日晷,你的代码就会出问题。
Thread.Sleep 同理,用它等一段时间再执行会把游戏卡死。想让代码「过一会儿再跑」,得自己利用游戏循环写逻辑。
现实时间对模组作者也有一个正当用途:测一段代码的性能,比如世界生成的方法快不快。这种时候放心用:
1
2
3
var stopWatch = Stopwatch.StartNew();
// Code to measure
ModContent.GetInstance<ExampleMod>().Logger.Info($"{stopWatch.ElapsedMilliseconds} milliseconds have elapsed.");