February 4, 2009

Days and Nights

My first trip through Kithicor Forrest when like so:

"A Zombie Trooper begins to cast a spell."
"You have been slain by a Zombie Trooper."

Here I learned the lesson of not trying to pass through Kithicor Forrest at night. I would have to wait 12 hours for daylight (or 36 actual minutes). Kithicor is a zone in the online game Everquest. Days in Everquest are 72 real-world minutes long.

I've been thinking about how to handle the passage of time. I knew that I wanted days to be much shorter than real time. World of Warcraft uses actual time. As a player, I found EQ's system much more engaging. It would actually get dark, vendors went inside their homes, werewolves came out, and Kithicor Forrest filled with $*@#& zombie troopers.

WoW just lowers the gamma a touch. Boring. My guess is Blizzard chose real time to simplify the scheduling of in-world events with real-world holidays. The downside is you can't ask players to wait eight hours while an NPC goes to bed so vendors never sleep.

I decided to go with Real Time x 12 where a game day equals two real hours. That gives us an hour of daylight followed by an hour of night for sneaky rogue-style stuff.

I also wanted to (roughly) align game years to real months. I decided to junk game months entirely since they were only about two and half actual days long. You've probably seen the Chinese Calendar where they cycle through years named after animals. That's what I wanted. Named years that cycle as real months pass. You get an illusion of years passing contained within an endless, MUD friendly cycle.

Now, how to implement it?

Some games use a tick counter that tracks time but my design isn't based around game ticks -- plus I want time to pass even if the server is taken offline. Worse, I really don't want it to revert to 00:00:00 whenever I restart it like an unplugged VCR.

My scheduler was already polling time.time() which returns the number of seconds since Jan 1, 1970. Since this is an imaginary world, I can get away with a few things. Real years are about 365 and 1/4 days long, hence the need for leap days. If I use a solar year instead of a calendar year I can forget about leap days.

## Number of seconds in a solar year
SOLAR_YEAR = 31556925.215999998

To align the first game year I decided to define a mini-epoch of January 1st, 2009. Consulting the UNIX timestamp generator gave me:

## UNIX_ADJ is used to align the start of game time with Jan 1, 2009 00:00 GMT.
UNIX_ADJ = 1230768000.0

Next, I defined a game cycle that gave me a spot to speed up time in case I want to test things:

## GAME_CYCLE describes the relation of twelve game years to one real year.
## If you want to speed up or slow down time change the divisor.
GAME_CYCLE = SOLAR_YEAR / 1.0

Now, let's define some constants for computing relative days. As mentioned above, we probably wont use GAME_MONTH, GAME_DAY, or GAME_SECOND for anything. Instead, we'll describe the date in Julian terms like 'Day 240 of the Year of the Ominous Sounding Noun'. In our perfect orbit, game years are 360 game days long.

GAME_YEAR = GAME_CYCLE / 12.0
GAME_MONTH = GAME_YEAR / 12.0
GAME_DAY = GAME_MONTH / 30.0
GAME_JULIAN = GAME_YEAR / 360.0
GAME_HOUR = GAME_DAY / 24.0
GAME_MINUTE = GAME_HOUR / 60.0
GAME_SECOND = GAME_MINUTE / 60.0

The calculations are pretty simple. We can define CENTURY_OFFSET to tweak the displayed century in case you wanted the game to begin in 1100 or Stardate 26,301.

tstamp = time.time() - UNIX_ADJ
minute = int((tstamp % GAME_HOUR) / GAME_MINUTE)
hour = int((tstamp % GAME_DAY) / GAME_HOUR)
day = int((tstamp % GAME_MONTH) / GAME_DAY)
julian = int((tstamp % GAME_YEAR) / GAME_JULIAN) + 1
month = int((tstamp % GAME_YEAR) / GAME_MONTH)
numbered_year = int(tstamp / GAME_YEAR) + CENTURY_OFFSET
named_year= numbered_year % 12

Finally, we can also compute the current amount of sunlight for outdoor areas:

def sunlight(self):
"""
Calculate the current sunlight level.
Return an integer value in the range of 0 (Midnight) to 12 (Noon).
"""
tstamp = THE_TIME - UNIX_ADJ
hour = int((tstamp % GAME_DAY) / GAME_HOUR)
return int(12 - abs(12 - hour))