在 Godot 中使用 C# 异步延迟

Using C# async delays in Godot

我目前正在尝试使用 Godot C# 制作一个基本的射击游戏,并且为了枪的射速,我一直在尝试不同的延迟系统。尽管我试图使脚本通用,但节点计时器仍然有效,而且计时器调用似乎只调用父脚本中的函数。

我现在正在研究 C# 的 Task.Delay 方法,它似乎也有效,因为它是一个异步操作,它看起来不会受到帧速率的影响或减慢游戏速度。

我的问题是,在游戏应用程序中使用 Task.Delay 是否存在任何已知问题:例如它是否不可靠,或者如果调用该方法的实例过多,它会崩溃吗?

下面是代码,虽然我认为它不重要:

 private void shoot() {
  //if "canShoot" spawn bullet
  ShootCooledDown();
}

private async void ShootCooledDown() {
  TimeSpan span = TimeSpan.FromSeconds((double)(new decimal(shotDelay)));
  canShoot = false;
  await Task.Delay(span);
  canShoot = true;
}  

我不是 Godot 的专家,但我可以说 Task.Delay() 被认为比 Thread.Sleep() 这样的替代方案更好,例如因为异步我将线程释放到线程池并且时间到了通过它继续执行,与后一个阻塞线程的选项相反。

我看到的问题是每个 Web 服务器可以接受最大限制的并发请求,通过在代码中使用 Task.Delay(),您可以开始累积由于延迟而“等待”的请求。因此,如果您的应用程序开始收到大量请求并伴有较长的延迟时间,这可能是请求排队(延迟)甚至被拒绝的问题。

如果延迟是数秒(重要时间),那么我可能会考虑将用户存储在缓存中(您也可以存储在字典中 Dictionary<string, bool>,其中字符串是用户 ID,但此解决方案将不横向扩展,这就是我建议使用分布式缓存的原因),如果允许用户射击,则检查 (TryGetValue()) 您的缓存。如果延迟是几微秒(可承受的时间)仍然不是理想的解决方案,但它可能会成为一个问题。

我对 Godot 没有任何经验..但我的想法是....

您可以将最后一次拍摄时间存储在 variable/field 中,而不是使用计时器。如果您想在 lastTimeShot+coolDown 范围内射击,请忽略射击命令。

例如:

private DateTime _lastShot = DateTime.MinValue;

private void shoot() 
{
    TimeSpan span = TimeSpan.FromSeconds((double)(new decimal(shotDelay)));
    
    // if the time when the last shot has fire with the cooldown time
    // is greater than the current time. You are still in the cooldown time.
    if(_lastShot.Add(span) > DateTime.UtcNow)
        return; // within cooldown, do nothing
        
    //if "canShoot" spawn bullet
    ShootCooledDown();
    _lastShot = DateTime.UtcNow;
}

根据 Theodor 的有效评论,更改系统时间会导致游戏容易出错。

我写了第二个版本

private Stopwatch _shootingCooldownStopwatch = default;
    
private void shoot()
{
    var shotDelayMs = shotDelay * 1000;

    // if the _shootingCooldownStopwatch is ever started
    // and the ElapsedMilliseconds are in the showDelay
    // we're not allowed to fire again. So exit the method.
    if (_shootingCooldownStopwatch?.ElapsedMilliseconds < shotDelayMs)
        return;

    _shootingCooldownStopwatch = Stopwatch.StartNew();

    //if "canShoot" spawn bullet
    ShootCooledDown();
}

我认为这是一个更好的解决方案。

My question is, is there any known issue for using Task.Delay in game applications: like is it unreliable or can it crash if too many instances of the method are called?

本身不是。 Task.Delay在游戏中没有什么特别的问题,也没有太多的例子。

但是,您在 Task.Delay 之后所做的事情可能会有问题。如果您执行 await Task.Delay(span); 之后的代码可能 运行 在不同的线程中,因此它可能导致竞争条件。 这是因为await,不是因为Task.Delay

例如,如果在 await Task.Delay(span); 之后您将向场景树添加 Node(例如子弹),这将干扰使用场景树的任何其他线程。 Godot 将在每一帧中使用场景树。快速浏览 Thread-safe APIs 会告诉您场景树不是线程安全的。 顺便说一句,几乎任何小部件 API 都会发生同样的情况。

解决方案是使用call_deferred(C#中的CallDeferred)与场景树交互。而且,是的,这可以抵消下一帧发生的那一刻。


我会给你一个非线程的替代方法来做到这一点。

OS class 上有方法 get_ticks_msecget_ticks_usec(C# 中的 GetTicksMsecGetTicksUsec)可用于时间比较的单调时间。

所以,如果你用它应该拍摄的时间制作一个队列(通过使用当前时间加上你需要的任何间隔来计算)。然后在您的流程或物理流程回调中,您可以检查队列。将所有逾期的时间出队,并创建那些项目符号。

如果你不想用 Godot APIs 解决这个问题,那么在游戏开始时启动一个 Stopwatch,并使用它的运行时间。


但也许这不是您想要的机制。如果你想要一个好的老cool-down,你可以在你需要cool-down的时候开始Stopwatch,然后将经过的时间与你想知道的cool-down持续时间进行比较,就知道是否结束了。

当您在 Godot 或任何其他游戏引擎中开发游戏时,您不应使用任何基于计算机时钟的计时器,例如 StopwatchTask.delay。相反,您必须使用 _Process(float delta)_PhysicsProcess(float delta) 方法接收的前一帧的增量时间来管理自己经过的时间。原因是:

  • 在帧率下降的情况下,时间会更准确。
  • 如果暂停游戏,计时器也会暂停。

这就是 Godot 为您提供 Timer 组件的主要原因,您必须将其附加到当前场景才能使用它。 如果你不想在场景中添加任何东西,这完全合理,你必须获取增量,将经过的时间存储在一个变量中并检查该变量是否达到某个限制。

在我的游戏中,我使用自己的计时器,非常简单 class:

    public class Timer {
        public float Elapsed { get; private set; } = 0;
        public bool Stopped { get; private set; } = true;
        public float Alarm { get; private set; } = float.MaxValue;

        public Timer Start() {
            Stopped = false;
            return this;
        }

        public Timer Stop() {
            Stopped = true;
            return this;
        }

        public Timer Reset() {
            Elapsed = 0;
            return this;
        }

        public Timer ClearAlarm() {
            Alarm = float.MaxValue;
            return this;
        }

        public Timer SetAlarm(float finish) {
            Alarm = finish;
            return this;
        }

        public bool IsAlarm() => Elapsed > Alarm;

        public Timer Update(float delta) {
            if (!Stopped) {
                Elapsed += delta;
            }
            return this;
        }

    }
```

You have to Update the timer in every frame