Zune Text Browser using the TextHandler
Class
Applies to: Zune
(Code works in Windows as
well)
In my last tutorial, I covered building a static TextHandler
class
to assist in drawing text to the screen. In this tutorial, I'm going to
expand on that concept by building a simple text file viewer for the
Zune. First, though, some discussion about the platform and the
limitations we have under it.
The Zune is a relatively restricted device (especially true
compared
to your Windows PC). The only (official) way to get files on or off of
the Zune is via the Zune software or an XNA installer. We don't have
access to browse the Zune's file system directly or store arbitrary
types of files on the device.
The reason I bring this up is that the Text Viewer we will be
building is really more of an example and proof of concept, since we
can't actually add text files to it outside of the Visual Studio
environment. All of the text files we want to view with our Zune will
need to be included in our project when it is deployed to the Zune
device.
That said, this tutorial will cover some interesting concepts
such
as:
- Working at a basic level with the file system (Loading
files and
setting up to save "games" on the Zune)
- Utilizing the TextHandler class
- Fun String Handling!
- Dealing with input on the Zune
- Managing simple Game States
- and a few other things...
Getting Started
From a design standpoint, we will create a "game" with two
modes. In
the first mode, we will be browsing a scrollable list of text files
that have been added as content to the project before it gets deployed
to the Zune. We will use the System.IO namespace's features to retrieve
the list of files.
In the second mode, we will display the contents of the
selected
file to the screen, displaying enough lines to fill the display. The
user can use the Zune Pad to scroll up and down, and use left and right
on the pad to page up and down.
In both modes, pressing the "Play" button on the Zune switches
to
the other mode.
In order to demonstrate a few more features, we will also
track the
position we were in when we last viewed a file so that we can return
directly to that position if we open the file again. We will do this by
creating files in our save game area.
Fire up Visual Studio and begin by creating a new Zune Game
project
calling it "ZuneTextViewer". Use Windows Explorer to copy the
TextHandler.cs into your project folder and then include it in your
project (Right click on the project name in Solution Explorer, Add,
Existing Item). Update the namespace in the TextHandler.cs class to
match the namespace of the game project (ZuneTextViewer).
I've created a "starter" project which can be downloaded here
that contains the new project with TextHandler.cs set up appropriately.
As with our demo project, we are going to need some
SpriteFonts, so
create a Fonts folder under Content and right click on Fonts, select
Add, New Item, select SpriteFont, and add a SpriteFont called
"Pescadero12.spritefont" to the project.
Edit the details of the SpriteFont file to change the FontName
from
Kootenay to Pescadero, and change the size to 12. Scroll down a bit
further in the file and look for a line that says:
<!--
<DefaultCharacter>*</DefaultCharacter>
-->
Remove the comment characters (<!-- from the beginning
and -->
from the end) from the line to uncomment it.
The DefaultCharacter attribute in a SpriteFont allows us to
specify
a character that will be used whenver we try to print a character that
is not in our SpriteFont. This can happen if we convert text from a
file that uses angled quote marks or the like and will prevent our
viewer from crashing if the text files we view contain characters that
aren't part of our default character set.
Extending TextHandler.cs
We will be adding two new methods to TextHandler.cs in order
to make
coding the main part of our TextHandler a little easier. These will
make it easier to pull a potentially long text file apart into single
lines that we can then use in our code.
The first of these methods is fairly generic, and simply
returns the
number of times a particular character appears in a string. We will be
using this to detect newline characters in the files we process:
//
Return the number of occurances of a particular character in a string
static
public int GetCharCount(char SearchFor, string SearchIn)
{
return SearchIn.Length -
SearchIn.Replace(SearchFor.ToString(), "").Length;
}
All we are actually doing here is getting
the length of the string we are searching, replacing the character we
are looking for in the string with an empty string (thus removing it
from the string) and subtracting the length of the result from the
original. This will give us the count of the number of times the
character appears in the string. We could, of course, have used a loop
and counter variable to do the same job (and in fact I had it coded
that way to begin with) but this is shorter and works just as well.
The second method we will be adding will be called in the same
way
that our WrapText method is called, except that instead of inserting
newlines into the return string, it will return just enough characters
to make up a single line, dropping the rest.
static
public string GetOneLine(string Text, string FontName, float MaxLineWidth)
{
// Create an array (words) with one
entry for each word in the passed text string
string[] words =
Text.Split(' ');
// A StringBuilder lets us add to a
string and finally return the result
StringBuilder
sb = new StringBuilder();
// How long is the line we are
currently working on so far
float lineWidth = 0.0f;
// Store a measurement of the size of
a space in the font we are using.
float spaceWidth =
Fonts[FontName].MeasureString("
").X;
// Loop through each word in the string
foreach (string word in words)
{
Vector2 size;
size =
Fonts[FontName].MeasureString(word);
//
If this word will fit on the current line, add it and keep track
//
of how long the line has gotten.
if
(word.Contains("\n"))
{
sb.Append(' ',
GetCharCount('\n',
word));
break;
}
if
(lineWidth + size.X < MaxLineWidth)
{
sb.Append(word + "
");
lineWidth
+= size.X + spaceWidth;
}
else
//
otherwise, append a newline character to start a new line.
Add the
//
word and a space, and set the size of the new line.
{
break;
}
}
// return the resultant string
return sb.ToString();
}
It is, in fact, very similar to our WrapText method. You will
notice
that a line can end one of two ways: we can fill up our MaxLineWidth,
or we can run into a "word" that contains a newline (\n) character. In
the latter case, we append a number of spaces to the string equal to
the number of newline characters the word contains. This simply helps
with formatting a bit.
Building the Text Viewer
That's it for our modifications to the TextHandler class.
Everything
else we do will now be done inside our Game class, so switch back to
the Game1.cs file.
As I stated above, we will be using the System.IO namespace,
so make
sure you add:
to the using declarations at the top of the file.
We are going to have a number of declarations that we will add
right
after "SpriteBatch spriteBatch;" in our game's declarations area. I'm
going to paste the full list of them in here. They are all commented,
but I will discuss a few of them in detail below.
//
List holding the names of all valid Text Files
List<string> TextFiles =
new List<string>();
//
Texture for the Scroll Bar
Texture2D
t2dScrollBar;
//
Font to use when viewing text/menu items
string
ViewFont = "Pescadero12";
//
Our "game" can have two modes : Menu and Reader
enum
Modes
{ Menu, Reader } ;
//
Default to starting in Menu Mode
Modes
CurrentMode = Modes.Menu;
//
The file we last viewed so we don't have to reload if we go back to it.
int
CurrentFile = -1;
//
Track where we are on the menu screen
int
MenuMode_TopFileLine = 0;
int
MenuMode_CurFileLine = 0;
//
A List object to break SourceText into lines that fit on the display
//
of the Zune in the selected font (Pescadero12)
List<string> TextLines =
new List<string>();
//
The top line of text displayed on the Zune
int
ReaderMode_CurLine = 0;
//
How long to delay between scrolling the screen
float
KeyPressDelay = 0.2f;
//
Amount of time that has passed since the screen last scrolled.
float
KeyPressTimer = 0.0f;
//
Lowest cursor position at which text will be printed. This
value defaults
//
to the height of the screen, but it will be modified later to take the
height
//
of text lines into account.
int
CursorBottom;
//
The number of lines that fit on the display. Will be
calculated later
int
LinesPerPage;
//
The maximum width of a single line of text.
int
SingleLineWidth;
//
A StorageContainer where we can save a small text file containing the
//
line number we are on when we exit the program. This allows
us to return
//
to the program and start at the line we left off on when the program
closed.
StorageContainer
SaveLocation;
When our application is launched, we will cache the names of
all of
the text files available for us to browse into a List object for
display when the reader is in "Menu Mode". In the particular case of
this application, we don't need to update this list dynamically, becase
the only way to get files onto the Zune and available to our Text
Reader is to add them to your XNA project before deployment.
The "TextFiles" List object will hold this list of file names
and be
used both for displaying the names on the menu and passed to our file
loader function to actually read the file from the device.
Our next object is a Texture2D called t2dScrollBar. We will
create
this shortly, but essentially we will be using a 1x1 pixel white
texture and taking advantage of the ability of the SpriteBatch object
to colorize a texture when drawing. We'll create and load the 1x1
texture when we get to the LoadContent method.
The ViewFont object simply specifies the name of the font that
we
will use throughout the program to display text. You could add other
fonts to the project and change this variable to customize the viewer.
I mentioned "Menu Mode" above. In fact, we will have two modes
in
our application: Menu Mode and Reader Mode. Our Update and Draw methods
will check to see which mode the application is in and act accordingly.
While we could do something like define constants where 1=Menu Mode and
2=Reader Mode, we will take a more "C#" approach, and use an
enumeration.
An enumeration (or enum) is a series of potential values that
basically result in our own custom type. When we define an enum
containing our list of modes, we can then create variables of that
type, assigning them to the modes we established. This sounds more
complex than it is. As you can see above, we declared the "Modes" enum
to have possible values of "Menu" and "Reader". We then created a
variable called "CurrentMode" and set it to Modes.Menu, indicating that
the program will start out in Menu Mode.
The remainder of the variables are mostly involved in keeping
track
of our place either within the menu or within the file we are viewing.
I'll skip over these integer values here and address them when we use
them later.
Besides those values, there is also another List object called
"TextLines". We will use this when we load a text file to split the
file up into single lines that we will display to the screen. It is
simply a List of strings containing the line data.
Finally, if you have followed my other game tutorials, you
will be
familiar with the two float values calld KeyPressDelay and
KeyPressTimer. We will use these to pace the input that the program
receives so we aren't taking actions as fast as the game's Update loop
runs.
The last of these declarations I'll talk about here is the
"SaveLocation" variable, which is of type StorageContainer. This
variable will be set to the location we will retrieve from the
Framework where we can store "save games". In our case we will be
storing information files, but the concept is the same.
Preparing for Testing on Windows
If you have done any type of "Device" development in the past,
you
might remember that Microsoft had a Device Emulator for Windows Mobile
devices. When I first started thinking about XNA Zune development, I
tried to track down the Zune Device Emulator to test my code on, since
it is fairly inconvenient to deploy every single build to the device to
try it out.
As it turns out, there isn't a Zune Device Emulator, but
that's Ok!
Since we aren't working with a completely foreign Framework like we
would be in Windows Moble (after all, XNA runs on Windows and the XBox
too) all we really need to do is set the screen size appropriately and
we have a defacto Zune emulator for our purposes.
We are going to add a few lines to the Game1's Initialize()
method
to accomplish this. They aren't really necessary when running the
project on the Zune, but they don't hurt anything either, so rather
than use compiler directives to eliminate them when running on the
Zune, I just leave them in place.
First, though, we need to make a Windows version of our
project.
This is actually very easy. On the Visual Studio menu bar, select the
Project menu and click "Create Copy of [Project Name] for Windows". The
project will be created and added to your solution.
You can make this the default execution project by right
clicking on
the project name (which will be called "Windows copy of [Project
Name]") and select "Set as Startup Project".
So, back to our Initialize() method. Add the following before
the
call to base.Initialize() :
graphics.PreferredBackBufferWidth = 240;
graphics.PreferredBackBufferHeight = 320;
graphics.ApplyChanges();
There is really nothing special here... this is the standard way we
would set our desired resolution in XNA. We are just setting the
backbuffer to match the size of the Zune display.
Helper Methods
There are a few things we need to be able to do during our
project's
initalization, and while we could simply pile all of the code into the
LoadContent() method, I've split the functions out to helper methods to
keep things more organized. It also allows for smaller chunks of code
for discussion.
Here is the first one:
//
Establish some initial parameters about the way we will display
//
text on the screen.
private
void
DetermineScreenSettings()
{
int ViewHeight =
graphics.GraphicsDevice.Viewport.Height;
// We won't start a new line of text
below the point at which
// one would entirely fit on the
screen.
CursorBottom = ViewHeight - (int)TextHandler.Fonts[ViewFont].MeasureString("X").Y;
// Calculate the number of lines that
can be displayed on
// a single screen.
LinesPerPage = (ViewHeight / (ViewHeight - CursorBottom) - 1);
// Set the size of a single line to
the width of the screen minus
// 10 pixels. We will use
these 10 pixels to draw a "scroll bar" on
// the right side of the screen.
SingleLineWidth = graphics.GraphicsDevice.Viewport.Width - 10;
}
You will remember that I said in the declaration section that
I was
going to talk about some of the Integer values later on. Well, here is
the first set.
All we are doing here is determining some basic properties of
the
screen we will be displaying to. We will need this information when
determining how much text to put on a single line, or how many lines to
put onto the screen.
We can retrieve the size of the display from the Viewport
object. As
you can see above, we use the height of the viewport to base our
CursorBottom and LinesPerPage variables on, and the Width of the
viewport to determine the width of a line of text.
The "CursorBottom" variable is what we will consider the
lowest
point on the screen to start drawing a new line of text. Any further
down and the line will be partially cut off the display.
You may also see that we reserve 10 pixels on the right side
of the
screen for the "scroll bar" we will add later.
Our next helper method will let us find out what files there
are to
view on the device:
//
Since we can't add files at "run time" we will cache a list of all of
the
//
text files we have deployed with the application.
private
void
CacheFileNames()
{
TextFiles.Clear();
// Get a list of all of the text files
located in the TEXT folder of content.
DirectoryInfo
info = new DirectoryInfo(StorageContainer.TitleLocation
+ @"\Content\Text");
// Add each file to the TextFiles list
foreach (FileInfo file
in info.GetFiles())
{
TextFiles.Add(file.Name);
}
}
The first thing we do here is clear out the TextFiles List
object.
Strictly speaking this should not be necessary, since creating an
instance of the List object results in an empty list. However, it is
always good practice to make absolutely sure you are in a known state
before doing anything with an object, so we'll clear it out (after all,
in a program under windows we might have a way to change folders, in
which case we could call this method multiple times).
Next we create a DirectoryInfo object and read the contents of
the
diretory indicated by "StorageContainer.TitleLocation" with
"\Content\Text" appended to it. The StorageContainer.TitleLocation
string is provided by XNA and points to where in the filesystem the
application is installed to/running from. The result of this is that we
get a DirectoryInfo object containing the directory where we will be
storing our Text Files.
Finally we use a simple foreach loop to add all of the files
in the
directory to the TextFiles list.
//
We will be using "SaveLocation" to place marker files that
//
hold what the last view we viewed and where we were in that
//
file are. In order to do this, we need to use the Guide to get
//
the StorageDevice. On the Zune, there is nothing actually
//
shown when BeginShowStorageDevice is called. Instead, it
//
immediately returns the result via EndShowStorageDeviceSelector.
private
void
FindStorageDevice()
{
// "Show" the StorageDeviceSelector
IAsyncResult
result = Guide.BeginShowStorageDeviceSelector(null, null);
// Retrieve the result (note- Nothing
is shown to the user on the Zune)
StorageDevice
device = Guide.EndShowStorageDeviceSelector(result);
// If we found a device (and we always
will) save it to our
// "SaveLocation" variable.
if (device.IsConnected)
{
SaveLocation = device.OpenContainer("Zune XNA-Book Reader");
}
}
This next method uses the XNA "Guide" object to find out where
we
can save files at runtime. On Windows and the Zune, executing
BeginShowStorageDeviceSelector doesn't actually do anything that the
user needs to interact with, and we can use a shortcut method of
handling the result (which is returned immediately with a call to
EndShowStorageDeviceSelector. This wouldn't work the same way on the
XBox, however, since the user would be prompted to select the storage
device (ie, disk drive or memory module) they want to use to store the
game's save data. You would actually have to handle the asynchronous
nature of the call on the XBox.
On the Zune, however, you simply get back a directory name
that we
can use. We call the device's "OpenContainer()" method and pass it the
name we wish to use for our application. The result is a StorageDevice
object that we save into our "SaveLocation" variable. We will use this
later when we want to save data about where the user left off when
reading a text file.
Our next two helper funcitons deal with saving file to and
reading
files from the location we just received via the FindStorageDevice()
call. We want to be able to save the current line we are viewing in
each text file so that when we reload that text file we return to the
same place. In order to do that, we will need a way to save the
information, and a way to reload it, so we have the following to
functions (I'm putting them together since they handle the same files):
private
void
GetSavedLastLine(string
CheckFileName)
{
if (File.Exists(SaveLocation.Path
+ @"\LastLoc_"
+ CheckFileName + ".txt"))
{
StreamReader
sr = File.OpenText(SaveLocation.Path
+ @"\LastLoc_"
+ CheckFileName + ".txt");
ReaderMode_CurLine = (int)MathHelper.Clamp(int.Parse(sr.ReadLine()),
0, TextLines.Count - 1);
sr.Close();
}
else
{
ReaderMode_CurLine = 0;
}
}
private
void
SaveCurrentLine(string
FileName)
{
StreamWriter
sw = File.CreateText(SaveLocation.Path
+ @"\LastLoc_"
+ FileName + ".txt");
sw.WriteLine(ReaderMode_CurLine.ToString());
sw.Close();
}
In the GetSavedLastLine() function we check to see if a file
called
"LastLoc_" plus the filename for the file we are loading exists. If it
does, we use a StreamReader object to read the line number from the
file. We use MathHelper.Clamp to make sure that the value we get back
is at least within the limits of the text file we are reading.
The SaveCurrentLine() function simply does the opposite. We
create
the text file and write the current line number to it.
Loading Text Files
We've done a lot of background work, so lets get on to
actually
loading a text file into our TextLines list object. There is actually
only a few lines of code in this function, but a lot of comments to
clarify what is going on.
Essentially, we will open a text file and then use the
GetOneLine()
method of the TextHandler class that we added earlier to break it into
individual lines. We simply keep processing the remainder of the file
until we run out of text to handle.
//
Called when a text files is selected from the menu. This
function
//
will load the requested text file and parse it into lines for
//
the display code to use.
private
void LoadTextFile(string FileName)
{
// Clear the TextLines list.
TextLines.Clear();
// Read the entire file into a
string. We will then pull it apart. First,
// though we will replace and carrage
returns (\r) with nothing (removing them)
// and replace new line characters
(\n) with the same character with a space
// in front of it. This will
make splitting our lines easier as a space marks
// the end of a word.
string WorkText = File.OpenText(StorageContainer.TitleLocation
+
@"\Content\Text\"
+
FileName).ReadToEnd().Replace("\r", "").Replace("\n", " \n");
// Build the List object of lines from
the text file. In our case, the lines
// are enough text to fit on a display
line on the screen.
do
{
//
Extract a single line of text. We only consider enough
characters from
//
the file equal to the width of the line in pixels to accomodate lines
that
//
do not have a space in them up to the point where we would break the
line.
string
thisline = TextHandler.GetOneLine(WorkText.Substring(
0, Math.Min(WorkText.Length,
SingleLineWidth)),
ViewFont,
SingleLineWidth);
//
If we didn't get any characters back from the above, it indicates that
there
//
is no breaking space in the line that would allow it to fit on a line
//
on the screen (ie, a long series of dashes, for example)
//
In this case, we will simply take all of the characters up to the
//
next available space. They will get chopped off on the
display.
if
(thisline.Length == 0)
{ thisline = WorkText.Substring(0,
WorkText.IndexOf(' '));
}
//
If this line is less than the length of the remainder of the file,
//
we remove it from the string containing the text file so we start
//
with a smaller string next time through the loop
if
(thisline.Length <= WorkText.Length)
WorkText
= WorkText.Substring(thisline.Length);
else
WorkText
= "";
//
Add this line, removing any newline characters, to the lines list
TextLines.Add(thisline.Replace("\n", " "));
} while (WorkText != "");
GetSavedLastLine(FileName);
}
Most everthing that I would explain here is covered by the
comments
in this code, so take a look at them. The most important things here
are the handling of the special characers \r and \n. These characters
represent a Carriage Return (\r) and a New Line (\n). We are really
only interested in the New Line characters, so we strip out all \r
characters from the outset.
When we hit a New Line, it is important to review hour our
TextHandler methods will look at it. Recall that in our GetOneLine()
method we treat a "word" containing a new line as a special case. we
append a number of spaces equal to the number of \n characters in the
word and drop the word completely.
This means that we can't have an actual word that ends in a \n
character, which is going to be pretty common in text. Lets say we take
that last sentance the way it looks in a text file : "... pretty common
in text.\n". Our "text." would be dropped because it is part of the
"word" that contains a newline.
So instead, we replace the "\n" character with a " \n" (a
space
before the \n) so that \n will always start a new word. The reason we
replace the \n characters with spaces in the return value is that we
check the length of the return value to determine how many characters
to remove from the front of our work string before we continue
processing.
The last thing we do in the LoadTextFile() method is call the
GetSavedLastLine() method we added above to see if we should start
somewhere other than the top of the file.
Lets LoadContent()!
Now that we have all of our Helper Methods in place, lets take
a
look at LoadContent(). Because we split almost everything out into
helper functions, LoadContent() is going to be very simple, but we need
to add a texture to our project first.
Normally at this point in my tutorials, I would create a
sample
texture and upload it for you to download and include in your project.
I'm not going to do that this time. Lets put those art skills to work!
Right-click on Content in Solution Explorer and add a new
folder
called "Textures". Then, right click on Textures and click Add, New
Item. In the Add New Item window that appears, select "Bitmap File" and
call it ScrollBar.bmp.
When you do this, you will get an edit that looks a lot like
the
standard Windows "Paint" program, zoomed really far into a 48x48 pixel
bitmap. In the Properties window for the new bitmap object, set both
the Height and the Width to 1 (if you don't see a Height and Width
property, you are probably seeing the Content Manager related
properties. Double-click on the "ScrollBar.bmp" file name in Solution
Explorer and the properties should change to the file properties.
Save your ScrollBar.bmp file and close it. Yes, we are going
to be
using a 1x1 pixel white bitmap as our scroll bar image. Even *my* art
skills are up to that challenge, and I can barely draw a straight line.
Back in our Game1.cs file, lets set up our LoadContent()
method to
initialize the TextHandler, load our bitmap, and call our helper
methods to get things set up:
protected
override void LoadContent()
{
// Create a new SpriteBatch, which can
be used to draw textures.
spriteBatch = new
SpriteBatch(GraphicsDevice);
t2dScrollBar = Content.Load<Texture2D>(@"Textures/ScrollBar");
// Initialize the TextHandler class
TextHandler.Initialize(spriteBatch,
Content, "Fonts");
// Set up the Screen Size and Lines
Per Page
DetermineScreenSettings();
CacheFileNames();
FindStorageDevice();
// TODO: use this.Content to load your
game content here
}
Again, since we broke our actual setup tasks down to their own
methods, LoadContent() becomes pretty straightforward.
The Update() Method
It is finally time to handle some user input. (I know, I
know... we
*still* haven't displayed anything onto the screen yet!). Our control
scheme is fairly simple. We have two modes (or Game States if you want
to call them that). Our controls are similar in both modes : The user
can scroll up or down, page up or down, change between modes, or exit
the program.
We will handle all of the input at one time, determining the
command
the user wants to execute, and then process that "command" based on the
mode we are currently in.
The Update() method is a big chunk of code, but it is also
fairly
simple. We start by reading the GamePad and Keyboard states and storing
them. We then check for various button/key presses and, based on what
is pressed, determine a command to process.
Then it is simply a matter of translating the command into
what we
want to happen on the screen for each mode. Here is the Update() method
itself. I'll take a bit more about it after the code.
protected
override void Update(GameTime
gameTime)
{
// Start with no command to perform
this cycle
string CurrentCommand = "NONE";
GamePadState
gs = GamePad.GetState(PlayerIndex.One);
KeyboardState
ks = Keyboard.GetState();
// Pressing "Back" on the Zune (Win:
Esc) exits the reader
if ((gs.Buttons.Back == ButtonState.Pressed)
||
(ks.IsKeyDown(Keys.Escape)))
{
CurrentCommand = "EXIT";
}
// Check for Zune-Pad
"Touches". We can scroll one line at a
// time up or down by touching the
Zune Pad (paging requires a
// "Click" of the left/right DPad)
if (gs.ThumbSticks.Left.Y
> 0.25)
CurrentCommand = "UP";
if (gs.ThumbSticks.Left.Y
< -0.25)
CurrentCommand = "DOWN";
// Handle the DPad (for earlier Zunes
and for paging on new zunes)
// Since these are handles AFTER the
Zune Pad Touches, if the user
// is actually clicking left or right,
the Touch command that may
// have been triggered by being a
little high or low will be overridden.
if (gs.DPad.Up == ButtonState.Pressed)
CurrentCommand = "UP";
if (gs.DPad.Down == ButtonState.Pressed)
CurrentCommand = "DOWN";
if (gs.DPad.Left == ButtonState.Pressed)
CurrentCommand = "PAGEUP";
if (gs.DPad.Right == ButtonState.Pressed)
CurrentCommand = "PAGEDOWN";
// Handle the Keyboard (For
Windows-based testing)
if (ks.IsKeyDown(Keys.Up))
CurrentCommand = "UP";
if (ks.IsKeyDown(Keys.Down))
CurrentCommand = "DOWN";
if (ks.IsKeyDown(Keys.Left))
CurrentCommand = "PAGEUP";
if (ks.IsKeyDown(Keys.Right))
CurrentCommand = "PAGEDOWN";
// On the Zune, the "Play" button
toggles a mode switch between
// Reader and Men modes.
Windows uses the Space Bar or Enter key.
if (gs.Buttons.B == ButtonState.Pressed)
CurrentCommand = "MODESWITCH";
if (ks.IsKeyDown(Keys.Enter)
|| ks.IsKeyDown(Keys.Space))
CurrentCommand = "MODESWITCH";
KeyPressTimer += (float)gameTime.ElapsedGameTime.TotalSeconds;
// Process commands while in Reader
Mode.
if (CurrentMode == Modes.Reader)
{
//
Make sure enough time has passed to repeat a command
if
(KeyPressTimer >= KeyPressDelay)
{
switch (CurrentCommand)
{
//
Save the current line position for this file and exit.
case
"EXIT":
SaveCurrentLine(TextFiles[CurrentFile]);
this.Exit();
break;
//
UP and DOWN decrement or increment the ReaderMode_CurLine
//
variable by 1, clamping it to a valid range.
case
"UP":
ReaderMode_CurLine = (int)MathHelper.Clamp(ReaderMode_CurLine
- 1, 0, TextLines.Count - LinesPerPage);
break;
case
"DOWN":
ReaderMode_CurLine = (int)MathHelper.Clamp(ReaderMode_CurLine
+ 1, 0, TextLines.Count - LinesPerPage);
break;
//
PAGEUP and PAGEDOWN decrement or increment the ReaderMode_CurLine
//
variable by the number of lines per page, clamping it to valid values.
case
"PAGEUP":
ReaderMode_CurLine = (int)MathHelper.Clamp(ReaderMode_CurLine
- LinesPerPage, 0, TextLines.Count - LinesPerPage);
break;
case
"PAGEDOWN":
ReaderMode_CurLine = (int)MathHelper.Clamp(ReaderMode_CurLine
+ LinesPerPage, 0, TextLines.Count - LinesPerPage);
break;
//
The MODESWITCH command saves the current line in this file
//
and switches to Menu mode.
case
"MODESWITCH":
SaveCurrentLine(TextFiles[CurrentFile]);
CurrentMode = Modes.Menu;
break;
default:
break;
}
// Reset the delay timer to keep the
command from repeating too fast.
if (CurrentCommand != "NONE")
KeyPressTimer = 0.0f;
}
}
else
{
//
Menu Mode
if
(KeyPressTimer >= KeyPressDelay)
{
switch (CurrentCommand)
{
//
No need to save a file location since we aren't currently
//
viewing a file, so just exit.
case
"EXIT":
this.Exit();
break;
//
UP and DOWN scroll through the menu by 1 item.
case
"UP":
MenuMode_CurFileLine = (int)MathHelper.Clamp(MenuMode_CurFileLine
- 1, 0, TextFiles.Count - 1);
break;
case
"DOWN":
MenuMode_CurFileLine = (int)MathHelper.Clamp(MenuMode_CurFileLine
+ 1, 0, TextFiles.Count - 1);
break;
//
PAGEUP and PAGEDOWN scroll through the menu by LinesPerPage items.
case
"PAGEUP":
MenuMode_CurFileLine = (int)MathHelper.Clamp(MenuMode_CurFileLine
- LinesPerPage, 0, TextFiles.Count - 1);
break;
case
"PAGEDOWN":
MenuMode_CurFileLine = (int)MathHelper.Clamp(MenuMode_CurFileLine
+ LinesPerPage, 0, TextFiles.Count - 1);
break;
//
MODESWITCH loads the selected file (if it wasn't the one we
//
were already viewing) and switches to Reader Mode.
case
"MODESWITCH":
if (CurrentFile !=
MenuMode_CurFileLine)
{
CurrentFile = MenuMode_CurFileLine;
LoadTextFile(TextFiles[MenuMode_CurFileLine]);
}
CurrentMode = Modes.Reader;
break;
default:
break;
}
// Reset the delay timer to keep the
command from repeating too fast.
if (CurrentCommand != "NONE")
KeyPressTimer = 0.0f;
}
//
Handle scrolling the menu up or down if necessary.
if
(MenuMode_CurFileLine < MenuMode_TopFileLine)
MenuMode_TopFileLine = MenuMode_CurFileLine;
if
(MenuMode_CurFileLine > (MenuMode_TopFileLine + LinesPerPage))
MenuMode_TopFileLine = MenuMode_CurFileLine - LinesPerPage;
}
// If no command was entered (and
therefore no button was pressed) boost up
// the delay timer so that a user can
press, release, and press again without
// being limited by the delay timer.
if (CurrentCommand == "NONE")
KeyPressTimer = KeyPressDelay + 1.0f;
// TODO: Add your update logic here
base.Update(gameTime);
}
One thing to note is the handling of the "NONE" command.
Whenever we
process a "NONE" command, we set KeyPressTimer to KeyPressDelay + 1,
ensuring that the next time we process a command KeyPressTimer will be
greater than KeyPressDelay.
Why? Well, we have the delay in there to prevent the screen
from
scrolling as fast as the Zune can process the Update event as soon as
you touch the Zune Pad. But what if the user WANTS to scroll faster
than the built-in delay? By writing it this way, we can allow the user
to rapidly "tap" their commands. Each time they release the button the
KeyPressTimer will "reset" allowing the next press to be detected
immediately.
The Draw() Method
We are finally there. We have all the background work we need
so all
that is left is to display the results of our efforts to the screen.
Just like our Update() method, we will be handling two
different
modes (or States) with our Draw() method. When CurrentMode is equal to
Modes.Reader we will be displaying lines from the current text file.
Otherwise we will be displaying the list of text files to select from.
I'm going to paste the whole method here, and then talk about
the
interesting bits below.
protected
override void Draw(GameTime
gameTime)
{
GraphicsDevice.Clear(Color.Black);
spriteBatch.Begin(SpriteBlendMode.AlphaBlend);
// Specify our TextHandler starting
conditions.
TextHandler.CurrentFont
= ViewFont;
TextHandler.CurrentColor
= Color.White;
TextHandler.CursorLocation
= Vector2.Zero;
// Reader Mode
if (CurrentMode == Modes.Reader)
{
//
Begin with the current line...
int
ThisLine = ReaderMode_CurLine;
//
Loop untip we reach the bottom of the screen
while
(TextHandler.CursorLocation.Y
< CursorBottom)
{
// Make sure the line we are going to
print exists in
// the line list.
if (ThisLine <
TextLines.Count)
{
//
Print the line to the screen and increment ThisLine
TextHandler.Print(TextLines[ThisLine++]);
}
else
{
//
If it doesn't, just print a blank line
TextHandler.Print(" ");
}
}
//
Draw the "Scroll Bar"
//
First, Draw a yellow "line" the entire height of the screen
spriteBatch.Draw(t2dScrollBar,
new Rectangle(GraphicsDevice.Viewport.Width
- 3, 0, 1,
GraphicsDevice.Viewport.Height),
Color.Yellow);
//
Determine where the file position marker should start,
//
based on the current line in the file versus the number
//
of total lines.
int
MarkerPos = (int)(
(float)GraphicsDevice.Viewport.Height
*
((float)ReaderMode_CurLine /
(float)TextLines.Count));
//
Determine the height of the file position marker. Short
//
files will have larger markers.
int
MarkerHeight = (int)(
((float)LinesPerPage / (float)TextLines.Count) *
GraphicsDevice.Viewport.Height);
//
Make sure we have at least 3 pixels on the marker.
if
(MarkerHeight < 3)
MarkerHeight = 3;
//
Draw the Marker
spriteBatch.Draw(t2dScrollBar,
new Rectangle(
GraphicsDevice.Viewport.Width - 3,
(int)MarkerPos,
1,
MarkerHeight),
Color.Red);
}
else
// Menu Mode
{
//
Starting with "MenuMode_TopFileLine", loop until we either
//
pass the total number of text files, or until we run out of
//
room on the screen.
for
(int x =
MenuMode_TopFileLine;
x
<= (MathHelper.Min(MenuMode_TopFileLine
+ LinesPerPage,
TextFiles.Count - 1)); x++)
{
// If the item we are about to draw is
the currently selected
// menu item, change our drawing color
to LightGreen
if (x ==
MenuMode_CurFileLine)
{
TextHandler.CurrentColor
= Color.LightGreen;
}
else
// Otherwise, we'll draw in White.
{
TextHandler.CurrentColor
= Color.White;
}
// Use TextHandler to print the file
name for this menu item.
TextHandler.Print(TextFiles[x]);
}
}
We start by clearing the display to a black screen, and then
start a
SpriteBatch.Begin() call.
Next, we need to set our defaults for the TextHandler class
for this
interation of the Draw() method. We want to set our font, set the
default drawing color to white, and position our virtual "cursor" at
the upper left of the display.
At this point, we split depending on which mode we are in. In
Reader
mode, we start a simple While loop that will continue running for each
"line" on the display (there are actually several ways we could have
done this... a for loop from 1 to LinesPerPage would also work, but I
wanted to use the CursorLocation check just to make some more use of
the TextHandler.)
For each line we are going to print, we check to see of the
line
exists in our TextLines list. If it does, we use TextHandler's Print()
method (which will take care of advancing the cursor to the next line
for us) to display the line.
If we have gone off of the end of the text file, we simply
print a
blank space. Again, we could also just do nothing here and accomplish
the same result if we were using a for loop, but since we are looping
until we run out of display room, we need to print the blank line so
the cursor moves down.
Our next step in Reader mode is to draw the scroll bar. Our
1x1
pixel white bitmap is going to do some extra duty for us here thanks to
a few features of the SpriteBatch.Draw() method.
In our first spriteBatch.Draw() call we draw a yellow line
from the
top of the display to the bottom of the display along the right side of
the screen. we can do this because when we specify the destination
rectangle for the sprite to be drawn, it doesn't have to be the same
size as the source (1x1 pixel) rectangle. SpriteBatch will stretch the
image out over the destination area.
We can also use the last parameter of SpriteBatch.Draw() to
tint our
sprite. Since our bitmap is white, any color we specify in the last
parameter will be the color for the whole "texture" when it is
displayed.
The math comes next. We have to figure out how far down on the
scroll bar to draw the position indicator, and how big to make it. The
code looks more complicated than it is because of all of the type
casting involved (we can't divide two integers and get a float, so we
have to (float) both ReaderMode_CurrentLine and TextLines.Count and
divide one by the other to get a percentage (ie, if we are at line 0 of
100, 0/100=0. If we are at line 50 of 100, 50/100 = 0.5).
We multiply this percentage by the height of the screen,
resulting
in the number of pixels from the top we will start drawing our marker
block (after finally converting all of those (float)s back to an (int)).
The process to determine the height of the marker is similar.
We
divide LinesPerPage by TextLines.Count, giving the percentage of the
file that a single page consist of (ie, if 10 lines fit on the screen
and we have a 100 line file, 10/100=0.1). We multiply this by the
height of the display to get the height of a single page relative to
the height of the whole file (in this case, 320 * 0.1 = 32).
Lastly, we make sure that the marker is going to be at least 3
pixels high just so we can actually see it on long files.
All that is left to do now is to draw it. Again, we will be
stretching our sprite over the required area and colorizing it, this
time with Color.Red.
That handles Reader Mode, so the next thing to deal with is
Menu
Mode.
This time around (just for variety) we are going to use a for
loop.
We will loop starting at MenuMode_TopFileLine, and continue until we
either run out of space on the screen, or reach the last item in the
TextFiles list.
The only decision we have to make here is what color to draw
the
item in. If it is our "current" menu item (meaning that
MenuMode_CurFileLine is equal to this item) we want to draw the item in
Color.LightGreen to show it as hilighted. Otherwise, we will draw it
with Color.White.
After we decide on a color, we simply use TextHandler.Print to
print
the entry and advance the cursor.
Add Some Files
The last thing to do is add some text files to view. Right
click on
Content once again, and add a new folder called Text. Now browse around
and find some interesting things to put on your Zune as text files. Add
each one of them to the Text folder and include them in your project.
In the Properties window, set the Build Action to "None" and
set
Copy to Output Directory to "Copy Always". We need to make these
changes since we don't want Content Manager to try to process these
items.
Fire It Up!
You should now have a working (though admittedly of limited
use
since you can't add files to the Zune outside of Visual Studio and
redeploying your application) text file browser.
Going in and out of text files should store your current
position so
that when you return to that file later you start out at the same point.
Ideas for Expansion
As I have already said, due to the limitations of what we can
actually place on the Zune, this application has a fairly limited
usefulness potential. That said, there are a couple of ways you could
make it fancier:
- Include a way to change fonts. As written, this would
require
rereading the current file since the file is split into lines when it
is loaded based on the current font.
- Add the ability to rotate the display and read in
"landscape"
mode.
Here is my final project folder with a few text files that you
may
find familiar included:
