I'm going on vacation to Thailand tomorrow (touch wood). But I'm more worried about the flight itself than the current political unrest. My partner Tracy is prone to fidget, plus she has a major Freecell addiction. Combining a 14 hour flight with suddenly going cold turkey on Freecell doesn't strike me as too good an idea!
Fortunately, I have a Zune. And I have XNA Game Studio. So I spent a couple of hours over Thanksgiving knocking up a simple mobile version of Freecell. I hadn't done much work on Zune before, but it was pleasingly straightforward to develop on Windows (running in a 320x240 resolution) and then move the result over to Zune.
But here's the thing. Our flight lasts 14 hours. The Zune battery does not last that long even when playing music, let alone running a game at the same time! For this to be any use, my game had to use as little power as possible.
On Windows or Xbox, battery life is irrelevant. The machine has a certain amount of power available, and you might as well use all of it. If your Update and Draw methods take less than 1/60 second to execute, that means you are wasting processing power that could be used to add more funky effects.
Pedantic readers may wish to point out that battery life does matter for Windows laptops. True, but there's not much you can do about it. If a Windows game has spare CPU cycles the XNA Framework just busy-waits until the next tick time. We have to do that because the Windows task scheduler has a rather coarse time granularity. If we put the CPU to sleep, there is no guarantee it will wake up exactly when we want. So on Windows, if you add sleep calls in the interests of power efficiency, you can no longer guarantee a steady framerate.
Not so on Zune, which has the ability to sleep for tiny and exact periods of time. So on Zune, if your game uses fixed timestep mode and Update plus Draw finish in less than the allotted time, the framework works out when the next tick is due, then puts the CPU to sleep for exactly the right period. In other words, the less work your code does, the less battery it will use.
My first power saving tweak was to reduce the target timestep in my Game constructor:
TargetElapsedTime = TimeSpan.FromTicks(TimeSpan.TicksPerSecond / 30);
I have some animations of cards sliding around, but these look fine at 30 fps, so there is no need to waste power trying to animate them at 60. I tried reducing this even further to 20 fps, but that didn't look so good.
Note how I use TimeSpan.FromTicks rather than TimeSpan.FromSeconds: this is because the TimeSpan.FromSeconds method has a nasty implementation that internally rounds to the nearest millisecond. I've been bitten by that a few times in the past, and learned to do my time computations in ticks rather than seconds.
Freecell doesn't have a lot of animation. In fact, most of the time the display is entirely static, and the game is just waiting for user input. I only wanted to bother running my update logic and redrawing the screen if there really was something changing.
First off, I changed my InputManager.Update method to report whether any inputs have changed since the previous time I looked at them:
class InputManager { GamePadState currentState; GamePadState previousState; public bool Update() { previousState = currentState; currentState = GamePad.GetState(PlayerIndex.One); return currentState != previousState; }
I then added two boolean flags to my game class, the first to store whether any cards are currently performing a move animation, and the second to indicate whether the screen needs to be redrawn:
public class FreecellGame : Microsoft.Xna.Framework.Game { bool isAnimating = false; bool displayDirty = true;
I changed my Update method to work like so:
protected override void Update(GameTime gameTime) { if (input.Update() || isAnimating) { puzzle.Update(input); isAnimating = puzzle.IsAnimating; displayDirty |= puzzle.DisplayDirty; displayDirty |= isAnimating; } if (!displayDirty) SuppressDraw(); base.Update(gameTime); }
Finally, I updated my Draw method to reset the displayDirty flag:
protected override void Draw(GameTime gameTime) { renderer.Begin(); renderer.Draw(backgroundTexture, Vector2.Zero, Color.White); puzzle.Draw(renderer); renderer.End(); base.Draw(gameTime);
displayDirty = false;
}
With this logic in place, most update ticks will wake up, see there is no new input, and immediately put the CPU back to sleep again.
Occasionally there will be a new button press, in which case I call puzzle.Update. If that does anything interesting in response to the new input, such as moving the selection focus, it will set the puzzle.DisplayDirty property, which causes me to redraw the screen just once before going back to sleep.
Even more occasionally, the puzzle update will move a card to a new location. This begins a sliding animation, which sets puzzle.IsAnimating to true. As long as the animation is in progress, Update and Draw will be called every 30 seconds, the same as any other game. When the card reaches its final location, puzzle.IsAnimating goes back to false, so Update and Draw will no longer be called and the CPU can go back to sleep.
I thus created a veritable Prius of the Zune gaming world.
But wait...
What if Tracy wants to change music in the middle of a game? I should let her bring up the Guide to choose playlists and skip tracks, neh?
As soon as I called Guide.Show, I realized I had a problem. The Zune system UI draws over the top of the game, but if the game never refreshes the screen, the system UI never gets a chance to refresh either! The Guide appeared when I pressed the button to bring it up, but then immediately froze because my game had gone back to sleep.
I fixed this by wrapping my Update logic with an IsActive check (which skips the SuppressDraw call while the Guide is active), and adding a timer that continues redrawing the screen for one second after the Guide is dismissed, in order to properly display the Guide close animation. My final update logic:
int postGuideDelay = 0;
protected override void Update(GameTime gameTime) { if (IsActive) { if (input.Update() || isAnimating) { if (input.ShowGuide) { Guide.Show(); postGuideDelay = 30; return; } puzzle.Update(input); isAnimating = puzzle.IsAnimating; displayDirty |= puzzle.DisplayDirty; displayDirty |= isAnimating; } if (postGuideDelay > 0) { postGuideDelay--; } else { if (!displayDirty) SuppressDraw(); } } base.Update(gameTime); }