Test your hand’s coordination and ability to hold your phone still in Balance Test, a fun little 2D-accelerometerbased game. You’ve got one minute to line up the images as many times as possible. You must keep the phone still at the correct angle for 3 seconds to earn each point. If the images become unaligned for just a moment, the hold-still time resets. This app keeps track of your best score, average score, and number of attempts, so you can keep track of your performance and watch it improve over time.
Balance Test is a lot like a 2D level app, in that you can move an image around the screen by tilting your phone in the X and Y dimensions. However, the image always “sinks” toward the corner of the screen closest to the Earth, whereas a bubble in a level would float to the highest corner.
From a code perspective, Balance Test is like a cross between the Moo Cow and Reflex Test apps. It uses Moo Cow’s accelerometer-based motion extended in an additional dimension, and its scoring feature (which keeps track of the best and average scores) is almost identical to Reflex Test.
The Main Page
This app uses a single page (ignoring the standard calibration and about pages, as well as an instructions page). It features the two images that need to be aligned to earn points, shown in Figure 49.1. The one shown in white is referred to as the target, and gets randomly placed on the screen. The one shown in blue is referred to as the moving piece and is controlled by tilting the phone.
When you’re not actively playing, you can see the score stats, shown in Figure 49.2. This app does something a little different—it gives the application bar a background color matching the theme accent color. The application bar is shown expanded in Figure 49.3.
Listing 49.1 contains the XAML for the main page, and Listing 49.2 contains its code-behind.
LISTING 49.1 MainPage.xaml—The User Interface for Balance Test’s Main Page
[code]
<phone:PhoneApplicationPage x:Class=”WindowsPhoneApp.MainPage”
xmlns=”http://schemas.microsoft.com/winfx/2006/xaml/presentation”
xmlns:x=”http://schemas.microsoft.com/winfx/2006/xaml”
xmlns:phone=”clr-namespace:Microsoft.Phone.Controls;assembly=Microsoft.Phone”
xmlns:shell=”clr-namespace:Microsoft.Phone.Shell;assembly=Microsoft.Phone”
FontFamily=”{StaticResource PhoneFontFamilyNormal}”
FontSize=”{StaticResource PhoneFontSizeNormal}”
Foreground=”{StaticResource PhoneForegroundBrush}”
SupportedOrientations=”Portrait”>
<!– Add four animations to the page’s resource dictionary –>
<phone:PhoneApplicationPage.Resources>
<!– Move the target and scale both images –>
<Storyboard x:Name=”MoveTargetStoryboard”
Storyboard.TargetName=”TargetTransform”>
<DoubleAnimation x:Name=”TargetXAnimation”
Storyboard.TargetProperty=”TranslateX” Duration=”0:0:1”>
<DoubleAnimation.EasingFunction>
<QuarticEase/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
<DoubleAnimation x:Name=”TargetYAnimation”
Storyboard.TargetProperty=”TranslateY” Duration=”0:0:1”>
<DoubleAnimation.EasingFunction>
<QuarticEase/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
<DoubleAnimation x:Name=”TargetScaleXAnimation”
Storyboard.TargetProperty=”ScaleX” Duration=”0:0:1”>
<DoubleAnimation.EasingFunction>
<QuarticEase/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
<DoubleAnimation x:Name=”TargetScaleYAnimation”
Storyboard.TargetProperty=”ScaleY” Duration=”0:0:1”>
<DoubleAnimation.EasingFunction>
<QuarticEase/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
<DoubleAnimation x:Name=”MovingPieceScaleXAnimation”
Storyboard.TargetName=”MovingPieceTransform”
Storyboard.TargetProperty=”ScaleX” Duration=”0:0:1”>
<DoubleAnimation.EasingFunction>
<QuadraticEase/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
<DoubleAnimation x:Name=”MovingPieceScaleYAnimation”
Storyboard.TargetName=”MovingPieceTransform”
Storyboard.TargetProperty=”ScaleY” Duration=”0:0:1”>
<DoubleAnimation.EasingFunction>
<QuadraticEase/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
<!– Rotate the target, just for fun –>
<DoubleAnimation Storyboard.TargetProperty=”Rotation” By=”360”
Duration=”0:0:1”>
<DoubleAnimation.EasingFunction>
<QuarticEase/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
<!– Slide the best score out then back in –>
<Storyboard x:Name=”SlideBestScoreStoryboard”>
<DoubleAnimationUsingKeyFrames Storyboard.TargetName=”BestScoreTransform”
Storyboard.TargetProperty=”TranslateX”>
<DiscreteDoubleKeyFrame KeyTime=”0:0:0” Value=”0”/>
<EasingDoubleKeyFrame KeyTime=”0:0:.4” Value=”-800”>
<EasingDoubleKeyFrame.EasingFunction>
<QuadraticEase/>
</EasingDoubleKeyFrame.EasingFunction>
</EasingDoubleKeyFrame>
<DiscreteDoubleKeyFrame KeyTime=”0:0:.4” Value=”800”/>
<EasingDoubleKeyFrame KeyTime=”0:0:.8” Value=”0”>
<EasingDoubleKeyFrame.EasingFunction>
<QuadraticEase/>
</EasingDoubleKeyFrame.EasingFunction>
</EasingDoubleKeyFrame>
</DoubleAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName=”BestScoreTextBlock”
Storyboard.TargetProperty=”Visibility”>
<!– Ensure the score is visible on the way in,
even if collapsed on the way out –>
<DiscreteObjectKeyFrame KeyTime=”0:0:.4” Value=”Visible”/>
</ObjectAnimationUsingKeyFrames>
</Storyboard>
<!– Slide the average score out then back in –>
<Storyboard x:Name=”SlideAvgScoreStoryboard”>
<DoubleAnimationUsingKeyFrames Storyboard.TargetName=”AvgScoreTransform”
Storyboard.TargetProperty=”TranslateX”>
<DiscreteDoubleKeyFrame KeyTime=”0:0:0” Value=”0”/>
<EasingDoubleKeyFrame KeyTime=”0:0:.4” Value=”-800”>
<EasingDoubleKeyFrame.EasingFunction>
<QuadraticEase/>
</EasingDoubleKeyFrame.EasingFunction>
</EasingDoubleKeyFrame>
<DiscreteDoubleKeyFrame KeyTime=”0:0:.4” Value=”800”/>
<EasingDoubleKeyFrame KeyTime=”0:0:.8” Value=”0”>
<EasingDoubleKeyFrame.EasingFunction>
<QuadraticEase/>
</EasingDoubleKeyFrame.EasingFunction>
</EasingDoubleKeyFrame>
</DoubleAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName=”AvgScoreTextBlock”
Storyboard.TargetProperty=”Visibility”>
<!– Ensure the score is visible on the way in,
even if collapsed on the way out –>
<DiscreteObjectKeyFrame KeyTime=”0:0:.4” Value=”Visible”/>
</ObjectAnimationUsingKeyFrames>
</Storyboard>
<!– Animate in (then out) a message –>
<Storyboard x:Name=”ShowMessageStoryboard”
Storyboard.TargetName=”MessageTransform”>
<DoubleAnimationUsingKeyFrames Storyboard.TargetProperty=”TranslateY”>
<DiscreteDoubleKeyFrame KeyTime=”0:0:0” Value=”800”/>
<EasingDoubleKeyFrame KeyTime=”0:0:.5” Value=”50”>
<EasingDoubleKeyFrame.EasingFunction>
<QuadraticEase/>
</EasingDoubleKeyFrame.EasingFunction>
</EasingDoubleKeyFrame>
<DiscreteDoubleKeyFrame KeyTime=”0:0:2.5” Value=”50”/>
<EasingDoubleKeyFrame KeyTime=”0:0:3” Value=”-800”>
<EasingDoubleKeyFrame.EasingFunction>
<QuadraticEase/>
</EasingDoubleKeyFrame.EasingFunction>
</EasingDoubleKeyFrame>
</DoubleAnimationUsingKeyFrames>
</Storyboard>
</phone:PhoneApplicationPage.Resources>
<!– The application bar, with four menu items and one button –>
<phone:PhoneApplicationPage.ApplicationBar>
<shell:ApplicationBar Opacity=”.999”
BackgroundColor=”{StaticResource PhoneAccentColor}”>
<shell:ApplicationBarIconButton Text=”start”
IconUri=”/Shared/Images/appbar.play.png”
Click=”StartButton_Click”/>
<shell:ApplicationBar.MenuItems>
<shell:ApplicationBarMenuItem Text=”instructions”
Click=”InstructionsMenuItem_Click”/>
<shell:ApplicationBarMenuItem Text=”calibrate”
Click=”CalibrateMenuItem_Click”/>
<shell:ApplicationBarMenuItem Text=”clear scores”
Click=”ClearScoresMenuItem_Click”/>
<shell:ApplicationBarMenuItem Text=”about” Click=”AboutMenuItem_Click”/>
</shell:ApplicationBar.MenuItems>
</shell:ApplicationBar>
</phone:PhoneApplicationPage.ApplicationBar>
<Grid>
<!– These two elements are shown while the images are aligned –>
<ProgressBar x:Name=”ProgressBar” Maximum=”3000” VerticalAlignment=”Top”/>
<Rectangle x:Name=”HighlightRectangle” Opacity=”0”
Fill=”{StaticResource PhoneAccentBrush}”/>
<!– The canvas with the two images –>
<Canvas>
<Rectangle x:Name=”Target” Fill=”{StaticResource PhoneAccentBrush}”
Width=”480” Height=”482”>
<Rectangle.OpacityMask>
<ImageBrush ImageSource=”Images/target.png”/>
</Rectangle.OpacityMask>
<Rectangle.RenderTransform>
<CompositeTransform x:Name=”TargetTransform” ScaleX=”0” ScaleY=”0”/>
</Rectangle.RenderTransform>
</Rectangle>
<Rectangle Fill=”{StaticResource PhoneForegroundBrush}”
Width=”480” Height=”482”>
<Rectangle.OpacityMask>
<ImageBrush ImageSource=”Images/movingPiece.png”/>
</Rectangle.OpacityMask>
<Rectangle.RenderTransform>
<CompositeTransform x:Name=”MovingPieceTransform”
ScaleX=”0” ScaleY=”0”/>
</Rectangle.RenderTransform>
</Rectangle>
</Canvas>
<!– A display for the best score, average score, and # of tries –>
<StackPanel x:Name=”ScorePanel” VerticalAlignment=”Bottom”
HorizontalAlignment=”Right” Margin=”12,72”>
<TextBlock Text=”BEST SCORE” Foreground=”{StaticResource PhoneSubtleBrush}”
HorizontalAlignment=”Right”/>
<TextBlock x:Name=”BestScoreTextBlock” HorizontalAlignment=”Right”
FontSize=”{StaticResource PhoneFontSizeExtraExtraLarge}”
Margin=”0,-15,0,30”>
<TextBlock.RenderTransform>
<CompositeTransform x:Name=”BestScoreTransform”/>
</TextBlock.RenderTransform>
</TextBlock>
<TextBlock x:Name=”AvgScoreHeaderTextBlock” Text=”AVG SCORE”
Foreground=”{StaticResource PhoneSubtleBrush}”
HorizontalAlignment=”Right”/>
<TextBlock x:Name=”AvgScoreTextBlock” HorizontalAlignment=”Right”
FontSize=”{StaticResource PhoneFontSizeExtraExtraLarge}”
Margin=”0,-15,0,0”>
<TextBlock.RenderTransform>
<CompositeTransform x:Name=”AvgScoreTransform”/>
</TextBlock.RenderTransform>
</TextBlock>
</StackPanel>
<!– An animated message –>
<Grid RenderTransformOrigin=”.5,.5” HorizontalAlignment=”Center”
VerticalAlignment=”Top”>
<Grid.RenderTransform>
<CompositeTransform x:Name=”MessageTransform” TranslateY=”800”/>
</Grid.RenderTransform>
<TextBlock x:Name=”MessageTextBlockShadow” FontWeight=”Bold” FontSize=”90”
Margin=”4,4,0,0” Foreground=”{StaticResource PhoneBackgroundBrush}”/>
<TextBlock x:Name=”MessageTextBlock” FontWeight=”Bold” FontSize=”90”/>
</Grid>
</Grid>
</phone:PhoneApplicationPage>
[/code]
- Notice that the application bar is given an opacity of .999. This is used instead of an opacity of 1 (which looks the same) to make the code-behind a little simpler. This is explained after the next listing.
- The page isn’t using two Image elements, but rather the familiar trick of using theme-colored rectangles with image brush opacity masks. This gives the target the appropriate accent color, and the moving piece the appropriate foreground color.
LISTING 49.2 MainPage.xaml.cs—The Code-Behind for Balance Test’s Main Page
[code]
using System;
using System.Windows;
using System.Windows.Media.Animation;
using System.Windows.Navigation;
using Microsoft.Phone.Applications.Common; // For AccelerometerHelper
using Microsoft.Phone.Controls;
using Microsoft.Phone.Shell;
namespace WindowsPhoneApp
{
public partial class MainPage : PhoneApplicationPage
{
// The bounds for the moving piece
double minX, maxX, lengthX, midX;
double minY, maxY, lengthY, midY;
DateTime? timeEntered;
DateTime beginTime;
Random random;
// Persistent settings
Setting<int> bestScore = new Setting<int>(“BestScore”, 0);
Setting<double> avgScore = new Setting<double>(“AvgScore”, 0);
Setting<int> numTries = new Setting<int>(“NumTries”, 0);
int score;
bool isRunning;
const double TOLERANCE = 6;
public MainPage()
{
InitializeComponent();
// Use the accelerometer via Microsoft’s helper
AccelerometerHelper.Instance.ReadingChanged += Accelerometer_ReadingChanged;
this.random = new Random();
}
protected override void OnNavigatedTo(NavigationEventArgs e)
{
base.OnNavigatedTo(e);
// Respect the persisted values
UpdateLabels(true);
// Reset
this.isRunning = false;
// Start the accelerometer with Microsoft’s helper
AccelerometerHelper.Instance.Active = true;
// While on this page, don’t allow the screen to auto-lock
PhoneApplicationService.Current.UserIdleDetectionMode =
IdleDetectionMode.Disabled;
}
protected override void OnNavigatedFrom(NavigationEventArgs e)
{
base.OnNavigatedTo(e);
// Restore the ability for the screen to auto-lock when on other pages
PhoneApplicationService.Current.UserIdleDetectionMode =
IdleDetectionMode.Enabled;
}
void UpdateLabels(bool animateBestScore)
{
if (this.numTries.Value > 0)
{
// Ensure the panel is visible and update the text blocks
this.ScorePanel.Visibility = Visibility.Visible;
this.BestScoreTextBlock.Text = this.bestScore.Value.ToString();
this.AvgScoreTextBlock.Text = this.avgScore.Value.ToString(“##0.##”);
if (this.numTries.Value == 1)
this.AvgScoreHeaderTextBlock.Text = “AVG SCORE (1 TRY)”;
else
this.AvgScoreHeaderTextBlock.Text = “AVG SCORE (“ + this.numTries.Value
+ “ TRIES)”;
// Animate the text blocks out then in. The animations take care of
// showing the text blocks if they are collapsed.
this.SlideAvgScoreStoryboard.Begin();
if (animateBestScore)
this.SlideBestScoreStoryboard.Begin();
else
this.BestScoreTextBlock.Visibility = Visibility.Visible;
}
else
{
// Hide everything
this.ScorePanel.Visibility = Visibility.Collapsed;
this.BestScoreTextBlock.Visibility = Visibility.Collapsed;
this.AvgScoreTextBlock.Visibility = Visibility.Collapsed;
}
}
// Process data coming from the accelerometer
void Accelerometer_ReadingChanged(object sender,
AccelerometerHelperReadingEventArgs e)
{
// Transition to the UI thread
this.Dispatcher.BeginInvoke(delegate()
{
if (!this.isRunning)
return;
// End the game after 1 minute
if ((DateTime.Now – beginTime).TotalMinutes >= 1)
{
GameOver();
return;
}
// Move the object based on the horizontal and vertical forces
this.MovingPieceTransform.TranslateX =
Clamp(this.midX + this.lengthX * e.LowPassFilteredAcceleration.X,
this.minX, this.maxX);
this.MovingPieceTransform.TranslateY =
Clamp(this.midY – this.lengthY * e.LowPassFilteredAcceleration.Y,
this.minY, this.maxY);
// Check if the two elements are aligned, with a little bit of wiggle room
if (Math.Abs(this.MovingPieceTransform.TranslateX
– this.TargetTransform.TranslateX) <= TOLERANCE &&
Math.Abs(this.MovingPieceTransform.TranslateY
– this.TargetTransform.TranslateY) <= TOLERANCE)
{
if (this.timeEntered == null)
{
// Start tracking the time
this.timeEntered = DateTime.Now;
this.HighlightRectangle.Opacity = .5;
}
// Show the progress
this.ProgressBar.Value =
(DateTime.Now – this.timeEntered.Value).TotalMilliseconds;
if (this.ProgressBar.Value >= this.ProgressBar.Maximum)
{
// Success!
this.score++;
// Animate in the score
this.MessageTextBlock.Text = this.score.ToString();
this.MessageTextBlockShadow.Text = this.score.ToString();
this.ShowMessageStoryboard.Begin();
// Move the target to the next location
MoveTarget();
}
}
else
{
// The elements are not aligned, so reset everything
this.HighlightRectangle.Opacity = 0;
this.timeEntered = null;
this.ProgressBar.Value = 0;
}
});
}
void GameOver()
{
this.isRunning = false;
this.ApplicationBar.IsVisible = true;
if (this.MoveTargetStoryboard.GetCurrentState() == ClockState.Active)
this.MoveTargetStoryboard.Stop();
// Shrink both elements to nothing
this.TargetScaleXAnimation.To = 0;
this.TargetScaleYAnimation.To = 0;
this.MovingPieceScaleXAnimation.To = 0;
this.MovingPieceScaleYAnimation.To = 0;
this.MoveTargetStoryboard.Begin();
// Record this attempt and update the UI
double oldTotal = this.avgScore.Value * this.numTries.Value;
// New average
this.avgScore.Value = (oldTotal + this.score) / (this.numTries.Value + 1);
// New total number of tries
this.numTries.Value++;
if (this.score > this.bestScore.Value)
{
// New best score
this.bestScore.Value = this.score;
UpdateLabels(true);
// Animate in a congratulations message
this.MessageTextBlock.Text = “NEW BEST!”;
this.MessageTextBlockShadow.Text = “NEW BEST!”;
this.ShowMessageStoryboard.Begin();
}
else
{
UpdateLabels(false);
}
}
void MoveTarget()
{
// Choose a random scale for the images, from .1 to .5
double scale = (random.Next(5) + 1) / 10d;
// Adjust the horizontal bounds of the moving piece accordingly
this.maxY = this.ActualHeight – 379 * scale;
this.minY = -104 * scale;
this.lengthY = Math.Abs(this.minY) + this.maxY;
this.midY = this.minY + this.lengthY / 2;
// Adjust the vertical bounds of the moving piece accordingly
this.maxX = this.ActualWidth – 280 * scale;
this.minX = -224 * scale;
this.lengthX = Math.Abs(this.minX) + this.maxX;
this.midX = this.minX + this.lengthX / 2;
// Prepare and begin the animation to a new location & size
this.TargetScaleXAnimation.To = this.TargetScaleYAnimation.To = scale;
this.MovingPieceScaleXAnimation.To = scale;
this.MovingPieceScaleYAnimation.To = scale;
this.TargetXAnimation.To = this.minX + random.Next((int)this.lengthX);
this.TargetYAnimation.To = this.minY + random.Next((int)this.lengthY);
this.MoveTargetStoryboard.Begin();
}
// “Clamp” the incoming value so it’s no lower than min & no larger than max
static double Clamp(double value, double min, double max)
{
return Math.Max(min, Math.Min(max, value));
}
// Application bar handlers
void StartButton_Click(object sender, EventArgs e)
{
// Get started
this.isRunning = true;
this.ApplicationBar.IsVisible = false;
this.ScorePanel.Visibility = Visibility.Collapsed;
// Center the target before it animates to a new location
this.TargetTransform.TranslateX = this.ActualWidth – this.Target.Width / 2;
this.TargetTransform.TranslateY = this.ActualHeight – this.Target.Height / 2;
MoveTarget();
this.score = 0;
this.beginTime = DateTime.Now;
}
void InstructionsMenuItem_Click(object sender, EventArgs e)
{
this.NavigationService.Navigate(new Uri(“/InstructionsPage.xaml”,
UriKind.Relative));
}
void CalibrateMenuItem_Click(object sender, EventArgs e)
{
this.NavigationService.Navigate(new Uri(
“/Shared/Calibrate/CalibratePage.xaml?appName=Balance Test&”
+ “calibrateX=true&calibrateY=false”, UriKind.Relative));
}
void ClearScoresMenuItem_Click(object sender, EventArgs e)
{
if (MessageBox.Show(“Are you sure you want to clear your scores?”,
“Clear scores”, MessageBoxButton.OKCancel) == MessageBoxResult.OK)
{
this.numTries.Value = 0;
this.bestScore.Value = 0;
UpdateLabels(true);
}
}
void AboutMenuItem_Click(object sender, EventArgs e)
{
this.NavigationService.Navigate(new Uri(
“/Shared/About/AboutPage.xaml?appName=Balance Test”, UriKind.Relative));
}
}
}
[/code]
- This app uses the low-pass-filtered acceleration data from the AccelerometerHelper library. This gives a nice balance of smoothness and the right amount of latency. (Using the optimally filtered data ends up being a bit too jumpy in this case.)
- The calculations that adjust the moving piece’s position should look familiar from the Moo Can app. Notice that the acceleration-based offset is added to the X midpoint, whereas it’s subtracted from the Y midpoint. As illustrated back in Figure 44.1, the Y-acceleration dimension grows in the opposite direction than Silverlight’s typical Y dimension.
- The page’s ActualWidth and ActualHeight properties are used throughout. If the application bar had an opacity of 1, the page’s ActualHeight would be 728 while it is visible versus 800 while it is hidden. However, in StartButton_Click, accessing ActualHeight after hiding the application bar would still report 728, as it hasn’t given the layout system a chance to make the change. Thanks to the application bar’s custom opacity that enables the page to extend underneath it, the timing of this is no longer a concern; ActualHeight reports 800 at all times.
The Finished Product