ID:2667370
 
Let's talk about time for a minute. There are a lot of ways to track things over time, but as far as games are concerned, there are three major things you'll want to do over time: tickers, cooldowns, and sequences. What are they and why are they so important to justify a tutorial? To the second question: Because you'll do them wrong if you don't understand the patterns and the logic behind them. To the first question: Read on.

Tickers:

Tickers are events that operate over time, effectively completing the same series of actions over and over again after a fixed delay. These are generally implemented in code in a really straightforward way. Here's a really simple ticker:

proc
FireTicker()
set waitfor = 0
while(1)
for(var/mob/m in world)
if(locate(/obj/fire) in obounds(m))
m.Burn()
sleep(10)

world
New()
..()
FireTicker()


This example will look for any mob in the world, and trigger their Burn() proc provided they are standing on an object derived from /obj/fire.

This isn't a particularly good example, but the structure is what's important here. Tickers are where you want to focus on optimization. Either by eliminating the need for them as much as possible, or by reducing the amount of work that they need to do.

Sequences

A sequence is an event that will spread itself out over time, doing little pieces of the work to be done with delays in between each of the pieces. Animate() is a great example of a generic sequencer. You will write a lot of these implicitly in your code, and once you start to recognize the pattern as a discrete thing, you can start to work with them in more abstract ways that allow you to recycle code and speed up development.

Cooldowns

A cooldown is a way of preventing something from happening for a set period of time. There are a ton of ways to implement cooldowns, but here's a really common one you are gonna see in a lot of code around these parts:

atom_movable
var
tmp/next_move = 0
move_delay = 0
proc
Step(dir,speed=step_size,delay=move_delay)
if(next_move > world.time)
return 0
. = step(src,dir,speed)
next_move = world.time + move_delay


The above code will prevent additional Step()s when the movable is on cooldown. The cooldown is stored in a timestamp based on world.time values, which is incremented by world.tick_lag every tick the server is running.

You have several options with how your cooldowns are tracked. You can use world.realtime, world.timeofday, or world.time for time-based cooldowns. You can also use *any* value that effectively moves up over time. For instance, you can put a cooldown on a player that only allows them to use an ability based on a kill-counter, provided you track the number of kills in a variable, or you can set cooldowns based on in-game days again, provided you track the in-game day in a counter variable somewhere.

One of the mistakes I see a lot of, even by veteran developers, is the lack of recognition that storing a timestamp is all you need to do to keep track of a cooldown. You don't actually need to have a ticker running for the cooldown to be functional. Just check for expiration each time the event you are preventing from running with this cooldown attempts to run again. This problem is complicated by secondary systems that interact with your cooldowns, where developers will sometimes think they need to mess with the cooldown by counting it down every tick because they've got some kind of a system that shows the player the remaining time on the cooldown. You don't actually need to do this, because if you know the cooldown time, and the current time, you have the values you need to determine the remaining time. Whatever system you are attaching to your cooldown that needs to know that time will already be heavier than determining the remainder, but also resetting the cooldown over and over again will be less efficient than reading the cooldown and subtracting the current time from it.

Misusing tickers to keep track of cooldowns will rob you of performance you could be otherwise using for other things, as well as increase the workload you are saddling yourself with. Let's take a look at a few rebuttals to the common reasons people offer for not using timestamp based cooldowns:

I only want it to tick down when players are online.

You can handle this by saving the cooldown as a remainder, rather than storing the realtime end of the cooldown.

mob
var/tmp
some_realtime_cooldown

Write(savefile/F)
..()
F["some_realtime_cooldown"] << max(some_realtime_cooldown - world.realtime,0)

Read(savefile/F)
..()
var/cd
F["some_realtime_cooldown"] >> cd
if(cd)
some_realtime_cooldown = cd + world.realtime



I want to notify the player when the cooldown ends.

You can implement a time-sorted queue as a ticker that will only check for N+1 cooldowns per tick, where N is the number of cooldowns that will expire this tick. The end result is a single ticker backing a stamp-based cooldown structure that will reduce the amount of work you are offloading to the CPU, reduce the overall impact you have on the scheduler, and reduce the amount of work you have to do thereafter by giving you the option of including any cooldown in the ticker.

This is a cooldown queue that will help you in situations where you need to keep track of when specific cooldowns expire:

cooldown_queue
var
list/data = list()
proc
operator[]=(idx,B)
if(islist(idx))
for(var/id in idx)
src[id] = idx[id] || B
else if(!istext(idx))
throw EXCEPTION("Illegal write of non-string key.")
else
//we cannot allow duplicate entries, so remove the id we're adding if it exists
data -= idx

var/len = data.len
if(len)
//Begin binary insertion sort:

//If this cooldown is sooner than the first in the queue, just insert the id at position 1
if(B < data[data[1]])
data.Insert(1,idx)
//If this cooldown is sooner than the last in the queue, begin the binary seek
else if(B < data[data[len]])
//set up the pivot point by shifting the length of the list downward
var/pos = len >> 1, sz = pos

//continue until the size of our iterative movement reaches 0.
while(sz>0)
sz >>= 1

//if the data at pivot is less than our time, pivot left, if the data at our pivot is greater or equal, pivot right a half step.
if(B>=data[data[pos]])
pos += sz
else
pos -= sz

//Once we've reached a pivot size of 0, we can be sure pos is where the value should be inserted
data.Insert(pos+1,idx)

//in all three cases, the index is now in the right position, so just associate the timeout with it.
data[idx] = B

operator[](idx)
return data[idx]


To use the queue, you can do something like this:

mob
var
list/cooldowns //we store all cooldowns here, unordered.
cooldown_queue/cooldown_tracker //we only store tracked cooldowns here.

proc
Cooldown(id,duration,notify=0)
var/time = world.time + duration
cooldowns[id] = time
if(notify)
cooldown_queue[id] = time

setCooldown(id,time,notify=0)
cooldowns[id] = time
if(notify)
cooldown_queue[id] = time


Then you can set up the notification watcher I talked about above:

world
New()
..()
CooldownTicker()

proc
CooldownTicker()
set waitfor = 0
while(1)
for(var/client/c in world)
c.CooldownTick()
sleep(world.tick_lag)

var
list/cooldown_message = list(
"somecooldown" = "Something is ready to be used!"
)

client
proc
CooldownTick()
set waitfor = 0
var/list/l = mob.cooldown_queue?.data
if(l)
var/idx = l[1], msg
while(idx)
if(l[idx]<=world.time)
l -= idx
idx = l.len ? l[1] : null
msg = cooldown_message[idx]
if(msg)
src << msg
else
idx = null


The ugly:

mob/var
canMove = 0

mob/proc
doThing()
canMove = 0
sleep(10)
canMove = 1


This kind of pattern is where code goes wrong. Generally speaking, you don't want to do stuff like this, because it's unnecessary and doesn't allow subsequent systems to get involved and change how things work. It's not compatible with saving/loading to boot, so you are gonna wind up with bugged characters or exploits if this is how you go about things.

Use timestamps. They are more widely accessible, require less work for the scheduler, and they are much easier to implement in the long run, because systems like this example will lead you to all kinds of screwed up workarounds and bug investigations in the long run.
Love these snippets!
Thank you for this one! Was on my mind.
Great snippet. Any chance of adding some snippets for animate or particle effects?
I went in depth with Particle Effects over on Hazordhu's Patreon Page, where I dissected how I went over the entire system, how I'm managing particle systems, how I went about setting up the 5-piece fire effect, and how I'm using particles to produce the lighting in Hazordhu.