Getting a Device
Getting the device to save
your data to is quite error prone. What makes this situation so easy to
get wrong is how the user chooses where to save the data. It is expected
that all Xbox games enable the user to choose where to save data, so
the API has to enable him or her to do so. The problem arises because
the system requires the game to continue to run (and to continue to
draw) in order to display the Guide and enable the user to choose the
device, but the API to pick the device blocks whatever thread on which
it is running. If the thread happens to be the same one drawing, your
game hangs. What’s even worse though, if you have only a single device
on the system, the Guide does not show at all. This means you can write
code that hangs your game without even knowing it, which is what many
people unfortunately do.
The API to get the device
follows the common .NET async pattern, which is a hint to developers
that this API needs to be performed asynchronously. With that small
preamble out of the way, let’s implement data storage for this example.
First, declare a variable for your device:
StorageDevice storageDevice;
Now, you might wonder why you
are going to store the device for this example when you didn’t at first
with the phone example. This is because obtaining the device can
possibly force a UI popup to appear, and you don’t want to ask the user
multiple times, “Hey, where do you want to store this data?” So long as
the device remains valid, you should continue to use it.
Now, create the device. Because
this should be done at startup, do the loading of the data if it exists
then as well. Add the following code to your game’s Initialize method:
// Load the data
StorageDevice.BeginShowSelector(new AsyncCallback(
(result) =>
{
storageDevice = StorageDevice.EndShowSelector(result);
if (storageDevice != null)
{
storageDevice.BeginOpenContainer("GameData", new AsyncCallback(
(openResult) =>
{
using (StorageContainer file =
storageDevice.EndOpenContainer(openResult))
{
using (BinaryReader reader = new
BinaryReader(file.OpenFile("pointData")))
{
for (int i = 0; i < reader.BaseStream.Length / 8; i++)
{
points.Add(new Vector2(reader.ReadSingle(),
reader.ReadSingle()));
}
}
}
}
), null);
}
}
), null);
This
was certainly much more in depth (and complicated looking) than the
isolated storage version! Looks are a little deceiving though, because
the majority of the complication is handling the async pattern that is
used to both get the device and the container. Note that when you use
the AsyncCallback (such as this
example), the callback happens on a separate thread from the one on
which you called the method. This enables the code to work if the Guide
pops up because the main thread is not blocked. After you have a
container, the code is identical to the isolated storage code you wrote
previously.
Again, you might ask yourself
why you’re doing this in the constructor for this example, when during
the phone example you did it in the OnActivating
override. Unlike the phone, which doesn’t have the concept of the game
not the focus (since it is killed), the Xbox 360 does. When your game is
not focused, it gets the Deactivated event, and when it regains focus it gets the Activated
event. Showing the Guide deactivates the game while it is up, and then
reactivates it when it goes away. So essentially, if you use that
override, you get stuck in an endless loop of creating the device.
Now, if you run the game, it
doesn’t actually run—you get an exception. The exception text is quite
descriptive, telling you that you need a GamerServicesComponent.
Now add the following to your
components collection in your game’s Initialize method:
Components.Add(new GamerServicesComponent(this));
Now when you run the example, it shows the Guide if you have more than one device available like you see in Figure 4. If you have a only single device available, it is automatically chosen for you.
Note
Notice also how you check
whether the device returned is null. This is because the user can
easily press the B button on the dialog and cancel choosing a device. In
a real game, you want to send a warning about this and confirm that a
user wants to continue without saving. Here, continue without saving.
Also, notice that the first parameter is a string in BeginOpenContainer calls. This is the friendly name of the container that users see when they look at the containers in the dashboard (as seen in Figure 3). This is, of course, the name of the container you open, so use the same string to get the same container again.
A common use for the containers
is to separate data logically. For example, you might have each saved
game stored in its own container. This gives the user the capability to
manage the save game from the system user interface without being in the
game.
Before getting into the
details of the API, let’s finish the example. Save the data before you
leave the game, but this time use the following code for the OnExiting override:
protected override void OnExiting(object sender, EventArgs args)
{
// Save our data
if (storageDevice != null)
{
storageDevice.BeginOpenContainer("GameData", new AsyncCallback(
(openResult) =>
{
using (StorageContainer file =
storageDevice.EndOpenContainer(openResult))
{
using (BinaryWriter writer =
new BinaryWriter(file.CreateFile("pointData")))
{
foreach (Vector2 v in points)
{
writer.Write(v.X);
writer.Write(v.Y);
}
}
}
}
), null);
}
base.OnExiting(sender, args);
}
This is similar to before, in
that you open the same container if your storage device isn’t null, and
then use the same code to write out the points you used earlier. Running
the game now enables you to place points on the screen, exit, and run
the game again to see them show back up.
Looking at the API
Now that you have the basic functionality down, let’s look at the API a bit more in depth now. The StorageDevice has the two static methods you already used as well as a single event called DeviceChanged. Proper handling of this event can be crucial for your game.
Imagine that your player
starts the game and chooses to save on the memory unit. Halfway through
the game, the user pulls the memory unit out for some reason. Now you
can no longer save, and worse yet, your game might crash! The DeviceChanged
event is fired when any device is changed. To update this example to
handle this case, add the following code to the end of your Initialize method:
StorageDevice.DeviceChanged += new EventHandler<EventArgs>(
(o, e) =>
{
// The device changed, make sure my current device is still valid
if (storageDevice != null)
{
if (!storageDevice.IsConnected)
{
// My device was disconnected, set it to null
storageDevice = null;
// Warn the user that we won't be saving now
Guide.BeginShowMessageBox("No Longer Saving",
"The device you were using to save no longer exists " +
"so saving automatically has been disabled.",
new string[] { "Ok" }, 0, MessageBoxIcon.Warning,
new AsyncCallback((result) =>
{
Guide.EndShowMessageBox(result);
}
), null);
}
}
}
);
When the event is fired, make sure that you already have a device selected and whether it is still connected via the IsConnected
property. If it is no longer connected, set your device to null
(because your code already handles that scenario) and inform the user
that you are turning off automatically saving. You can handle this in
other ways; for example, you can allow the user to choose a new device
instead. The user should never have an unexpected loss of data though,
so at a minimum you need to warn him or her, which is what this example
did.
The BeginShowSelector method actually has four overloads as well. There are two pairs of similar overloads; one in each pair takes a PlayerIndex to specify which user selects the device, while the other does not. In the overload used in this example, any user can
select the device when the Guide pops up, and the data is stored in
such a way that any user on the console can see the data. If you use the
similar overload that included the PlayerIndex as the first parameter, a few things happen.
First, only the user who
matches the player index is allowed to select the device. This can
easily lead your players to consider your game has locked up if you
aren’t careful. For example, if you always assume that the first player
is signed in and use PlayerIndex.One as
the parameter to this method, but the only player signed in to the
console is player two, he or she is never able to get out of this dialog
without signing in to the first player.
Note
Virtually all games have a Push Start to Begin screen to detect which player is actually controlling the game.
When using a PlayerIndex,
only the player can see the data. Much like how each game’s data is
segregated from any other game’s data, all of the player data is
separate as well.
The other two overloads each contain two new integer parameters: sizeInBytes and directoryCount.
The first one is the total size of the data you plan on writing to the
device, and the second is how many directories you will create. If you
know this data before you create the device, using these parameters
enable the user to select a device that doesn’t have enough free space
on it. If you do not use these parameters, the system lets you select
any device.
The StorageDevice also has a few instance members. It has three properties, including the IsConnected property you saw previously (which as the name implies, tells you if it’s connected). You can also use the TotalSpace property and FreeSpace property to get information about how much data the device can hold. It also has a DeleteContainer method, which takes in the name you passed in when opening it.
As mentioned earlier, the StorageContainer object is similar to the IsolatedStorageFile object, and they contain basically the same methods, so you can look back at the IsolatedStorage
section to see those if you like. The container does contain two
properties that the isolated storage file does not, namely the read-only
DisplayName, which is what you used to create it with, as well as the StorageDevice that was used to create it.