Создание простой игры-стрeлялки в Silverlight
Опубликовано 21 июля 2009 10:19:00 | Coding4Fun
В этой статье описаны основные особенности написания игр с применением Silverlight. Это простая игра типа «стрелялки», при написании которой использовались все базовые функции большинства игр: векторы, выявление конфликтов, основной цикл игры и обработка ввода с клавиатуры. С теми частями программы, которые описаны в этой статье не очень детально, вы можете ознакомиться подробней, скачав исходный код проекта.
- Исходный текст: https://simpleshooter.codeplex.com/
- Сложность: для начинающих и средне подготовленных
- Необходимое время: 3 часа
- Затраты: $0
- Необходимое ПО:
- Visual Basic или Visual C# Express Editions
- Expression Blend (необязательно)
Вдохновители идеи
Если вы решили написать игру, вы найдете массу примеров и пособий. Основные принципы создания моей игры аналогичны описанным в этой статье (EN) Coding4Fun. В данной же статье описывается разработка «стрелялки» в Silverlight. Первая Silverlight-игра, с которой я столкнулся, была игра в стиле Asteroids Билла Рейсса (Bill Reiss). Принципы использования векторов и цикла игры я взял из таких прекрасных примеров, как его игра и другие. Полезные сведения о разработке с использованием Silverlight можно найти на множестве сайтов и во многих блогах. Вот лишь некоторые из них:
- BlueRoseGames.com (EN);
- Farseer Games (EN);
- Cameron Albert (EN);
- Mike Snow (EN).
Общая компоновка
Откройте Visual Studio и создайте приложение Silverlight на C# или на VB.net. Начнем с компоновки экрана. Сначала добавим полотно на сетку внутри Page.xaml. Его фон сделаем черным и назовем gameRoot.
1: <UserControl x:Class="SimpleShooter.Page"
2: xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation"
3: xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml"
4: Width="400" Height="300">
5: <Grid x:Name="LayoutRoot">
6: <Canvas x:Name="gameRoot" Width="500" Height="400" Background="Black" >
7: </Canvas>
8: </Grid>
9: </UserControl>
Затем добавим элементы управления для объектов игры и отображения данных. Также добавим в проект пользовательские элементы управления Info, LivesRemaining, Score и WaveInfo. Они включают текстовые блоки для вывода данных о состоянии игры. Сделать пользовательские элементы управления можно более сложными. Я же оставил простое полотно с непосредственным указанием координат Canvas.Top и Canvas.Left. Свои элементы управления я поместил в Page.xaml, задав для каждого x:Name. К этому моменту каждый элемент управления содержал лишь TextBlock.
1: <UserControl x:Class="SimpleShooter.Page"
2: xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation"
3: xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml"
4: xmlns:SimpleShooter="clr-namespace:SimpleShooter"
5: Width="500" Height="400">
6: <Grid x:Name="LayoutRoot">
7: <Canvas x:Name="gameRoot" Width="500" Height="400" Background="Black">
8: <SimpleShooter:RemainingLives x:Name="ctlLives" Canvas.Top="380" Canvas.Left="10" />
9: <SimpleShooter:Score x:Name="ctlScore" Canvas.Top="10" Canvas.Left="10" />
10: <SimpleShooter:WaveInfo x:Name="ctlWaveInfo" Canvas.Left="440" Canvas.Top="10" />
11: <SimpleShooter:Info x:Name="ctlInfo" Canvas.Top="10"/>
12: </Canvas>
13: </Grid>
14: </UserControl>
В завершение добавим к нашему экрану звездное поле. Для этого напишем функцию генерации случайных чисел, а также другую, которая будет случайным образом располагать эллипсы на нашем базовом полотне. Нам потребуется наследование от System.Security.Cryptography:
C#
1: public partial class Page : UserControl
2: {
3: public Page()
4: {
5: InitializeComponent();
6:
7: GenerateStarField(350);
8: }
9:
10: void GenerateStarField(int numberOfStars)
11: {
12:
13: for (int i = 0; i < numberOfStars; i++)
14: {
15:
16: Ellipse star = new Ellipse();
17: double size = GetRandInt(10, 800) * .01;
18: star.Width = size;
19: star.Height = size;
20: star.Opacity = GetRandInt(1, 5) * .1;
21: star.Fill = new SolidColorBrush(Colors.White);
22: int x = GetRandInt(0, (int)Math.Round(gameRoot.Height, 0));
23: int y = GetRandInt(0, (int)Math.Round(gameRoot.Width, 0));
24: star.SetValue(Canvas.TopProperty, (double)x);
25: star.SetValue(Canvas.LeftProperty, (double)y);
26: gameRoot.Children.Add(star);
27: }
28: }
29:
30: public int GetRandInt(int min, int max)
31: {
32: Byte[] rndBytes = new Byte[10];
33: RNGCryptoServiceProvider rndC = new RNGCryptoServiceProvider();
34: rndC.GetBytes(rndBytes);
35: int seed = BitConverter.ToInt32(rndBytes, 0);
36: Random rand = new Random(seed);
37: return rand.Next(min, max);
38: }
39: }
40: VB
41: Partial Public Class Page
42: Inherits UserControl
43:
44: Public Sub New()
45: InitializeComponent()
46: GenerateStarField(350)
47: End Sub
48:
49: Private Sub GenerateStarField(ByVal numberOfStars As Integer)
50:
51: For i As Integer = 0 To numberOfStars - 1
52:
53: Dim star As New Ellipse()
54: Dim size As Double = GetRandInt(10, 800) * 0.01
55: star.Width = size
56: star.Height = size
57: star.Opacity = GetRandInt(1, 5) * 0.1
58: star.Fill = New SolidColorBrush(Colors.White)
59: Dim x As Integer = GetRandInt(0, CInt(Math.Round(gameRoot.Height, 0)))
60: Dim y As Integer = GetRandInt(0, CInt(Math.Round(gameRoot.Width, 0)))
61: star.SetValue(Canvas.TopProperty, CDbl(x))
62: star.SetValue(Canvas.LeftProperty, CDbl(y))
63: gameRoot.Children.Add(star)
64: Next
65: End Sub
66:
67: Public Function GetRandInt(ByVal min As Integer, ByVal max As Integer) As Integer
68: Dim rndBytes As [Byte]() = New [Byte](9) {}
69: Dim rndC As New RNGCryptoServiceProvider()
70: rndC.GetBytes(rndBytes)
71: Dim seed As Integer = BitConverter.ToInt32(rndBytes, 0)
72: Dim rand As New Random(seed)
73: Return rand.[Next](min, max)
74: End Function
75: End Class
Теперь у нас есть функция GenerateStarField. Обратите внимание на то, что каждый эллипс добавляется к коллекции Children нашего базового полотна gameRoot, а также на способ задания координат этих эллипсов посредством свойств Top и Left. Теперь у нас есть фон и базовая структура экрана нашей игры.
Спрайты и векторы
Я не буду подробно рассказывать о спрайтах и векторах. Этим темам посвящены многочисленные специализированные ресурсы (см. ссылки на другие статьи в данной публикации) и даже интересная статья в Coding4Fun (EN). Мы добавим в нашу программу классы, представляющие спрайты и векторы:
C#
1: public abstract class Sprite
2: {
3: public double Width { get; set; }
4: public double Height { get; set; }
5: public Vector Velocity { get; set; }
6: public Canvas SpriteCanvas { get; set; }
7: private Point _position;
8: public Point Position
9: {
10: get
11: {
12: return _position;
13: }
14: set
15: {
16: _position = value;
17: SpriteCanvas.SetValue(Canvas.TopProperty, _position.Y - (Height / 2));
18: SpriteCanvas.SetValue(Canvas.LeftProperty, _position.X - (Width / 2));
19: }
20: }
21:
22: public Sprite(Double width, Double height, Point position)
23: {
24: Width = width;
25: Height = height;
26:
27: SpriteCanvas = RenderSpriteCanvas();
28:
29: SpriteCanvas.Width = width;
30: SpriteCanvas.Height = height;
31: // Примечание: поскольку в установщике для Position используются и Height, и Width, важно, что это следует после их установки.
32: Position = position;
33: }
34:
35: public abstract Canvas RenderSpriteCanvas();
36:
37: public Canvas LoadSpriteCanvas(string xamlPath)
38: {
39: System.IO.Stream s = this.GetType().Assembly.GetManifestResourceStream(xamlPath);
40: return (Canvas)XamlReader.Load(new System.IO.StreamReader(s).ReadToEnd());
41: }
42:
43: public virtual void Update(TimeSpan elapsedTime)
44: {
45: Position = (Position + Velocity * elapsedTime.TotalSeconds);
46: }
47: }
VB
1: Public MustInherit Class Sprite
2: Private _Width As Double
3: Public Property Width() As Double
4: Get
5: Return _Width
6: End Get
7: Set(ByVal value As Double)
8: _Width = value
9: End Set
10: End Property
11: Private _Height As Double
12: Public Property Height() As Double
13: Get
14: Return _Height
15: End Get
16: Set(ByVal value As Double)
17: _Height = value
18: End Set
19: End Property
20: Private _Velocity As Vector
21: Public Property Velocity() As Vector
22: Get
23: Return _Velocity
24: End Get
25: Set(ByVal value As Vector)
26: _Velocity = value
27: End Set
28: End Property
29: Private _SpriteCanvas As Canvas
30: Public Property SpriteCanvas() As Canvas
31: Get
32: Return _SpriteCanvas
33: End Get
34: Set(ByVal value As Canvas)
35: _SpriteCanvas = value
36: End Set
37: End Property
38: Private _position As Point
39: Public Property Position() As Point
40: Get
41: Return _position
42: End Get
43: Set(ByVal value As Point)
44: _position = value
45: SpriteCanvas.SetValue(Canvas.TopProperty, _position.Y - (Height / 2))
46: SpriteCanvas.SetValue(Canvas.LeftProperty, _position.X - (Width / 2))
47: End Set
48: End Property
49:
50: Public Sub New(ByVal initialWidth As [Double], ByVal initialHeight As [Double], ByVal initialPosition As Point)
51: Width = initialWidth
52: Height = initialHeight
53:
54: SpriteCanvas = RenderSpriteCanvas()
55:
56: SpriteCanvas.Width = Width
57: SpriteCanvas.Height = Height
58: ' Примечание: поскольку в установщике для Position используются и Height, и Width, важно, что это следует после их установки.
59: Position = initialPosition
60: End Sub
61:
62: Public MustOverride Function RenderSpriteCanvas() As Canvas
63:
64: Public Function LoadSpriteCanvas(ByVal xamlPath As String) As Canvas
65: Dim s As System.IO.Stream = Me.[GetType]().Assembly.GetManifestResourceStream(xamlPath)
66: Return DirectCast(XamlReader.Load(New System.IO.StreamReader(s).ReadToEnd()), Canvas)
67: End Function
68:
69: Public Overridable Sub Update(ByVal elapsedTime As TimeSpan)
70: Position = (Position + Velocity * elapsedTime.TotalSeconds)
71: End Sub
72: End Class
Класс Sprite будет содержать такие базовые объекты игры, как корабль, пришельцы и снаряды. Для каждого из них нам надо знать местонахождение и как он выглядит. Координаты мы будем отслеживать с помощью Point, а свойство типа Canvas будем использовать для отображения XAML каждого объекта. Начальные параметры этих свойств задаются в конструкторе, а в каждом производном классе будет реализован метод RenderSpriteCanvas. Этот метод позволит производному классу устанавливать содержимое полотна, управляя внешним видом спрайта.
У нас есть также класс, описывающий векторы, с помощью которого мы будем управлять движением спрайтов. Повторяю: я не вникаю в подробности работы с векторами, эту информацию можно получить из других общедоступных источников.
C#
1: public struct Vector
2: {
3: public double X;
4: public double Y;
5:
6: public Vector(double x, double y)
7: {
8: X = x;
9: Y = y;
10: }
11:
12: public double Length
13: {
14: get
15: {
16: return Math.Sqrt(LengthSquared);
17: }
18: }
19:
20: public double LengthSquared
21: {
22: get
23: {
24: return X * X + Y * Y;
25: }
26: }
27:
28: public void Normalize()
29: {
30: double length = Length;
31: X /= length;
32: Y /= length;
33: }
34:
35: public static Vector operator -(Vector vector)
36: {
37: return new Vector(-vector.X, -vector.Y);
38: }
39:
40: public static Vector operator *(Vector vector, double scalar)
41: {
42: return new Vector(scalar * vector.X, scalar * vector.Y);
43: }
44:
45: public static Point operator +(Point point, Vector vector)
46: {
47: return new Point(point.X + vector.X, point.Y + vector.Y);
48: }
49:
50: static public Vector CreateVectorFromAngle(double angleInDegrees, double length)
51: {
52: double x = Math.Sin(DegreesToRadians(180 - angleInDegrees)) * length;
53: double y = Math.Cos(DegreesToRadians(180 - angleInDegrees)) * length;
54: return new Vector(x, y);
55: }
56:
57: static public double DegreesToRadians(double degrees)
58: {
59: double radians = ((degrees / 360) * 2 * Math.PI);
60: return radians;
61: }
62: }
VB
1: Public Structure Vector
2: Public X As Double
3: Public Y As Double
4:
5: Public Sub New(ByVal x__1 As Double, ByVal y__2 As Double)
6: X = x__1
7: Y = y__2
8: End Sub
9:
10: Public ReadOnly Property Length() As Double
11: Get
12: Return Math.Sqrt(LengthSquared)
13: End Get
14: End Property
15:
16: Public ReadOnly Property LengthSquared() As Double
17: Get
18: Return X * X + Y * Y
19: End Get
20: End Property
21:
22: Public Sub Normalize()
23: Dim length__1 As Double = Length
24: X /= length__1
25: Y /= length__1
26: End Sub
27:
28: Public Shared Operator -(ByVal vector As Vector) As Vector
29: Return New Vector(-vector.X, -vector.Y)
30: End Operator
31:
32: Public Shared Operator *(ByVal vector As Vector, ByVal scalar As Double) As Vector
33: Return New Vector(scalar * vector.X, scalar * vector.Y)
34: End Operator
35:
36: Public Shared Operator +(ByVal point As Point, ByVal vector As Vector) As Point
37: Return New Point(point.X + vector.X, point.Y + vector.Y)
38: End Operator
39:
40:
41: Public Shared Function CreateVectorFromAngle(ByVal angleInDegrees As Double, ByVal length As Double) As Vector
42: Dim x As Double = Math.Sin(DegreesToRadians(180 - angleInDegrees)) * length
43: Dim y As Double = Math.Cos(DegreesToRadians(180 - angleInDegrees)) * length
44: Return New Vector(x, y)
45: End Function
46:
47: Public Shared Function DegreesToRadians(ByVal degrees As Double) As Double
48: Dim radians As Double = ((degrees / 360) * 2 * Math.PI)
49: Return radians
50: End Function
51: End Structure
Теперь можем реализовать конкретный спрайт с помощью класса Ship. Добавьте в свой проект класс с именем Ship и файл Ship.xaml. Не забудьте установить свойства Ship.xaml в «Embedded Resource». Нам потребуется наследование от класса Sprite:
C#
1: public class Ship : Sprite
2: {
3: public Ship(double width, double height, Point firstPosition)
4: : base(width, height, firstPosition)
5: {
6:
7: }
8:
9: public override Canvas RenderSpriteCanvas()
10: {
11: return LoadSpriteCanvas("SimpleShooter.Sprites.Ship.xaml");
12: }
13: }
VB
1: Public Class Ship
2: Inherits Sprite
3: Public Sub New(ByVal width As Double, ByVal height As Double, ByVal firstPosition As Point)
4: MyBase.New(width, height, firstPosition)
5:
6: End Sub
7:
8: Public Overloads Overrides Function RenderSpriteCanvas() As Canvas
9: Return LoadSpriteCanvas("SimpleShooter.Ship.xaml")
10: End Function
11: End Class
При создании экземпляра Ship вызывается конструктор его базового класса, Sprite. В классе Ship также реализован метод RenderSpriteCanvas и определен XAML (просто белый квадрат) для загрузки в полотно данного спрайта. Теперь мы можем добавить спрайт к нашей главной странице. В этой несложной игре у нас есть всего один корабль (остальные — пришельцы), так что добавим к странице свойство и функцию, с помощью которой будет создаваться экземпляр корабля:
1: <Canvas x:Name="LayoutRoot" Width="30" Height="30"
2: xmlns="https://schemas.microsoft.com/client/2007"
3: xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml" >
4: <Rectangle Height="30" Width="30" Fill="White" />
5: </Canvas>
C#
1: void InitializeGame()
2: {
3: PlayerShip = new Ship(10, 10, new Point(100, 300));
4: gameRoot.Children.Add(PlayerShip.SpriteCanvas);
5: }
VB
1: Private Sub InitializeGame()
2: PlayerShip = New Ship(10, 10, New Point(100, 300))
3: gameRoot.Children.Add(PlayerShip.SpriteCanvas)
4: End Sub
Этот метод мы можем вызывать из конструктора страницы, и при запуске проекта в левой нижней части страницы будет появляться белый квадрат, представляющий наш корабль. Если посмотреть внимательно, можно заметить, что размер страницы равен 500×400, а метод InitializeGame располагает корабль на расстоянии 100 пикселов от левой границы полотна gameRoot и на 300 пикселов от его верхней границы.
Ввод с клавиатуры и цикл игры
Настало время привести некоторые предметы в движение. Начать надо с выбора клавиш, которыми будет управляться движение. Затем нам надо будет отслеживать их нажатие и выполнять соответствующие действия. Повторю еще раз, что применяемые мной алгоритмы обработчиков нажатий клавиш и управления циклом игры общедоступны и подробно я на их описании не останавливаюсь. Обработчик клавиатуры улавливает все события нажатия и отпускания клавиш. Это позволяет нам узнать о нажатии некоторой клавиши в любой момент. Цикл игры — это просто непрерывный цикл. В него входит раскадровка, которая запускается и тут же останавливается. Класс генерирует событие и раскадровка запускается снова. Подписчики события Update предоставляют значение, указывающее интервал времени в миллисекундах с момента последнего обновления. Это значение может использоваться в векторах для реализации плавного движения спрайтов. Нам надо добавить в класс Page экземпляры KeyHandler и GameLoop. Изменим конструкторы InitializeGame и Page, а также добавим обработчик для GameLoop:
C#
1: public Page()
2: {
3: InitializeComponent();
4: keyHandler = new KeyHandler(this);
5: GenerateStarField(350);
6: InitializeGame();
7: }
8:
9: void InitializeGame()
10: {
11: gameLoop = new GameLoop(this);
12: gameLoop.Update += new GameLoop.UpdateHandler(gameLoop_Update);
13:
14: PlayerShip = new Ship(10, 10, new Point(100, 360));
15: gameRoot.Children.Add(PlayerShip.SpriteCanvas);
16:
17: gameLoop.Start();
18: }
19:
20: void gameLoop_Update(TimeSpan elapsed)
21: {
22: // Очистим текущий вектор, чтобы спрайт не двигался, когда не нажаты те или иные клавиши
23: PlayerShip.Velocity = new Vector(0, 0);
24: if (keyHandler.IsKeyPressed(Key.Left))
25: {
26: PlayerShip.Velocity = Vector.CreateVectorFromAngle(270, 125);
27: }
28: if (keyHandler.IsKeyPressed(Key.Right))
29: {
30: PlayerShip.Velocity = Vector.CreateVectorFromAngle(90, 125);
31: }
32: PlayerShip.Update(elapsed);
33: }
VB
1: Public Sub New()
2: InitializeComponent()
3: keyHandler = New KeyHandler(Me)
4: GenerateStarField(350)
5: InitializeGame()
6: End Sub
7:
8: Private Sub InitializeGame()
9: gameLoop = New GameLoop(Me)
10: AddHandler gameLoop.Update, AddressOf gameLoop_Update
11:
12: PlayerShip = New Ship(10, 10, New Point(100, 360))
13: gameRoot.Children.Add(PlayerShip.SpriteCanvas)
14:
15: gameLoop.Start()
16: End Sub
17:
18: Private Sub gameLoop_Update(ByVal elapsed As TimeSpan)
19: ' очистим текущий вектор, чтобы спрайт не двигался, когда не нажаты те или иные клавиши
20: PlayerShip.Velocity = New Vector(0, 0)
21: If keyHandler.IsKeyPressed(Key.Left) Then
22: PlayerShip.Velocity = Vector.CreateVectorFromAngle(270, 125)
23: End If
24: If keyHandler.IsKeyPressed(Key.Right) Then
25: PlayerShip.Velocity = Vector.CreateVectorFromAngle(90, 125)
26: End If
27:
28: PlayerShip.Update(elapsed)
29: End Sub
Теперь у нас есть работающий цикл игры. Запустите приложение, щелкните элемент управления Silverlight, чтобы он получил фокус и теперь вы можете управлять кораблем кнопками со стрелками. Есть, правда, одна проблема — вы можете совсем сместить корабль за пределы экрана. Чтобы исправить ситуацию, добавим в класс, описывающий корабль, свойства MinX и MaxX и переопределим метод Update, который он наследует от Sprite. Надо также прибавить эти свойства для минимального и максимального значений в методе InitializeGame класса Page после создания экземпляра Ship:
C#
1: public class Ship : Sprite
2: {
3: public double MaxX { get; set; }
4: public double MinX { get; set; }
5:
6: public Ship(double width, double height, Point firstPosition)
7: : base(width, height, firstPosition)
8: {
9:
10: }
11:
12: public override Canvas RenderSpriteCanvas()
13: {
14: return LoadSpriteCanvas("SimpleShooter.Sprites.Ship.xaml");
15: }
16:
17: public override void Update(System.TimeSpan elapsedTime)
18: {
19: // Проверить, что к этой точке можно переместиться
20: if (Position.X > MaxX)
21: {
22: Position = new Point(MaxX, Position.Y);
23: Velocity = new Vector(0, 0);
24: }
25: if (Position.X < MinX)
26: {
27: Position = new Point(MinX, Position.Y);
28: Velocity = new Vector(0, 0);
29: }
30: base.Update(elapsedTime);
31: }
32: }
VB
1: Public Class Ship
2: Inherits Sprite
3: Private _MaxX As Double
4: Public Property MaxX() As Double
5: Get
6: Return _MaxX
7: End Get
8: Set(ByVal value As Double)
9: _MaxX = value
10: End Set
11: End Property
12: Private _MinX As Double
13: Public Property MinX() As Double
14: Get
15: Return _MinX
16: End Get
17: Set(ByVal value As Double)
18: _MinX = value
19: End Set
20: End Property
21:
22: Public Sub New(ByVal width As Double, ByVal height As Double, ByVal firstPosition As Point)
23: MyBase.New(width, height, firstPosition)
24:
25: End Sub
26:
27: Public Overloads Overrides Function RenderSpriteCanvas() As Canvas
28: Return LoadSpriteCanvas("SimpleShooter.Ship.xaml")
29: End Function
30:
31: Public Overloads Overrides Sub Update(ByVal elapsedTime As System.TimeSpan)
32: ' проверить, что к этой точке можно переместиться
33: If Position.X > MaxX Then
34: Position = New Point(MaxX, Position.Y)
35: Velocity = New Vector(0, 0)
36: End If
37: If Position.X < MinX Then
38: Position = New Point(MinX, Position.Y)
39: Velocity = New Vector(0, 0)
40: End If
41: MyBase.Update(elapsedTime)
42: End Sub
43: End Class
Приготовиться к огню!
У нас есть все необходимое для добавления других спрайтов, таких как пришельцы и снаряды. Сначала добавим производные от Sprite классы: Alien, Missle и Bomb, а также Alien.xaml, Missle.xaml и Bomb.xaml (для всех этих .xaml-файлов должен быть установлен параметр Embedded Resources). Эти xaml-файлы аналогичны Ship.xaml и отличаются лишь размерами и цветом. Сделаем захватчиков (Aliens) и их бомбы красными, уменьшив длину и ширину бомб и ракет до пяти. Файлы xaml очень похожи, а классы имеют несколько особенностей. Классы Bomb и Missile отличаются лишь загружаемыми xaml. Вот класс Bomb:
C#
1: public class Bomb : Sprite
2: {
3: public double MaxX { get; set; }
4: public double MinX { get; set; }
5:
6: public Bomb(double width, double height, Point firstPosition)
7: : base(width, height, firstPosition)
8: {
9:
10: }
11:
12: public override Canvas RenderSpriteCanvas()
13: {
14: return LoadSpriteCanvas("SimpleShooter.Sprites.Bomb.xaml");
15: }
16:
17: public override void Update(System.TimeSpan elapsedTime)
18: {
19: base.Update(elapsedTime);
20: }
21: }
VB
1: Public Class Bomb
2: Inherits Sprite
3: Private _MaxX As Double
4: Public Property MaxX() As Double
5: Get
6: Return _MaxX
7: End Get
8: Set(ByVal value As Double)
9: _MaxX = value
10: End Set
11: End Property
12: Private _MinX As Double
13: Public Property MinX() As Double
14: Get
15: Return _MinX
16: End Get
17: Set(ByVal value As Double)
18: _MinX = value
19: End Set
20: End Property
21:
22: Public Sub New(ByVal width As Double, ByVal height As Double, ByVal firstPosition As Point)
23: MyBase.New(width, height, firstPosition)
24:
25: End Sub
26:
27: Public Overloads Overrides Function RenderSpriteCanvas() As Canvas
28: Return LoadSpriteCanvas("SimpleShooter.Sprites.Bomb.xaml")
29: End Function
30:
31: Public Overloads Overrides Sub Update(ByVal elapsedTime As System.TimeSpan)
32: MyBase.Update(elapsedTime)
33: End Sub
34: End Class
Добавим класс Alien (пришелец), аналогичный классу Ship. Оба эти класса требуют дополнительных возможностей. Прежде всего, они должны уметь стрелять один в другого. Alien будет сбрасывать бомбы:
C#
1: public class Alien : Sprite
2: {
3: public double fireRateMilliseconds = 2000;
4: public double fireVelocity = 250;
5: public double wayPointMin;
6: public double wayPointMax;
7: public double speed = 100;
8: public bool spawnWait;
9: public DateTime spawnComplete;
10: public double MaxX { get; set; }
11: public double MinX { get; set; }
12:
13: public Alien(double width, double height, Point firstPosition)
14: : base(width, height, firstPosition)
15: {
16:
17: }
18:
19: public void CheckDirection()
20: {
21: if (Position.X > wayPointMax)
22: {
23: Velocity = Vector.CreateVectorFromAngle(270, speed);
24: }
25: if (Position.X < wayPointMin)
26: {
27: Velocity = Vector.CreateVectorFromAngle(90, speed);
28: }
29: }
30:
31: public override Canvas RenderSpriteCanvas()
32: {
33: return LoadSpriteCanvas("SimpleShooter.Sprites.Alien.xaml");
34: }
35:
36: public override void Update(TimeSpan elapsedTime)
37: {
38: CheckDirection();
39: base.Update(elapsedTime);
40: }
41:
42: public Bomb Fire()
43: {
44: Bomb bomb = new Bomb(5, 5, Position);
45: bomb.Velocity = Vector.CreateVectorFromAngle(180, fireVelocity);
46: return bomb;
47: }
48: }
VB
1: Public Class Alien
2: Inherits Sprite
3: Public fireRateMilliseconds As Double = 2000
4: Public fireVelocity As Double = 250
5: Public wayPointMin As Double
6: Public wayPointMax As Double
7: Public speed As Double = 100
8: Public spawnWait As Boolean
9: Public spawnComplete As DateTime
10: Private _MaxX As Double
11: Public Property MaxX() As Double
12: Get
13: Return _MaxX
14: End Get
15: Set(ByVal value As Double)
16: _MaxX = value
17: End Set
18: End Property
19: Private _MinX As Double
20: Public Property MinX() As Double
21: Get
22: Return _MinX
23: End Get
24: Set(ByVal value As Double)
25: _MinX = value
26: End Set
27: End Property
28:
29: Public Sub New(ByVal width As Double, ByVal height As Double, ByVal firstPosition As Point)
30: MyBase.New(width, height, firstPosition)
31:
32: End Sub
33:
34: Public Sub CheckDirection()
35: If Position.X > wayPointMax Then
36: Velocity = Vector.CreateVectorFromAngle(270, speed)
37: End If
38: If Position.X < wayPointMin Then
39: Velocity = Vector.CreateVectorFromAngle(90, speed)
40: End If
41: End Sub
42:
43: Public Overloads Overrides Function RenderSpriteCanvas() As Canvas
44: Return LoadSpriteCanvas("SimpleShooter.Alien.xaml")
45: End Function
46:
47: Public Overloads Overrides Sub Update(ByVal elapsedTime As TimeSpan)
48: CheckDirection()
49: MyBase.Update(elapsedTime)
50: End Sub
51:
52: Public Function Fire() As Bomb
53: Dim bomb As New Bomb(5, 5, Position)
54: bomb.Velocity = Vector.CreateVectorFromAngle(180, fireVelocity)
55: Return bomb
56: End Function
57: End Class
Нам необходим механизм отслеживания столкновений снарядов с другими спрайтами. Добавим в класс спрайта метод обнаружения столкновений. Будем использовать зону, определяемую радиусом CollisionRadius, измеряемым от центра спрайта. Для простоты будем считать радиус равным половине ширины спрайта. С помощью вектора, созданного из этих двух точек, мы сможем определять, не превышает ли сумма этих двух радиусов длины нашего вектора. Если это так, значит было столкновение:
C#
1: public static bool Collides(Sprite s1, Sprite s2)
2: {
3: Vector v = new Vector((s1.Position.X) - (s2.Position.X), (s1.Position.Y) - (s2.Position.Y));
4: if (s1.CollisionRadius + s2.CollisionRadius > v.Length)
5: {
6: return true;
7: }
8: else
9: {
10: return false;
11: }
12: }
VB
1: Public Shared Function Collides(ByVal s1 As Sprite, ByVal s2 As Sprite) As Boolean
2: Dim v As New Vector((s1.Position.X) - (s2.Position.X), (s1.Position.Y) - (s2.Position.Y))
3: If s1.CollisionRadius + s2.CollisionRadius > v.Length Then
4: Return True
5: Else
6: Return False
7: End If
8: End Function
Теперь мы можем расширить наш цикл игры возможностями, превосходящими отслеживание движения нашего корабля. Прежде всего, добавим обработку дополнительных клавиш, не только стрелок. Для стрельбы будет использоваться клавиша пробела. Добавим перечисление, описывающее состояния игры. Также добавим класс, реализующий стрельбу. Он будет достаточно простым, с большим потенциалом усовершенствования:
C#
1: public enum GameState
2: {
3: Ready = 0,
4: Running = 1,
5: Paused = 2,
6: BetweenWaves = 3,
7: GameOver = 4
8: }
9:
10: if (keyHandler.IsKeyPressed(Key.Space))
11: {
12: switch (status)
13: {
14: case GameState.Ready:
15: break;
16: case GameState.Running:
17: EntityFired(PlayerShip);
18: break;
19: case GameState.Paused:
20: break;
21: case GameState.BetweenWaves:
22: status = GameState.Running;
23: ctlInfo.GameInfo = "";
24: StartWave();
25: break;
26: case GameState.GameOver:
27: break;
28: default:
29: break;
30: }
31: }
32:
33: void EntityFired(Sprite shooter)
34: {
35: Debug.WriteLine(shooter);
36: switch (shooter.ToString())
37: {
38: case "SimpleShooter.Ship":
39: if (missles.Count == 0)
40: {
41: Missle missle = ((Ship)shooter).Fire();
42: missles.Add(missle);
43: gameRoot.Children.Add(missle.SpriteCanvas);
44:
45: }
46: break;
47: case "SimpleShooter.Alien":
48: Bomb bomb = ((Alien)shooter).Fire();
49: bombs.Add(bomb);
50: gameRoot.Children.Add(bomb.SpriteCanvas);
51: break;
52: default:
53: break;
54: }
55: }
VB
1: Public Enum GameState
2: Ready = 0
3: Running = 1
4: Paused = 2
5: BetweenWaves = 3
6: GameOver = 4
7: End Enum
8:
9: If keyHandler.IsKeyPressed(Key.Space) Then
10: Select Case status
11: Case GameState.Ready
12: Exit Select
13: Case GameState.Running
14: EntityFired(PlayerShip)
15: Exit Select
16: Case GameState.Paused
17: Exit Select
18: Case GameState.BetweenWaves
19: status = GameState.Running
20: ctlInfo.GameInfo = ""
21: StartWave()
22: Exit Select
23: Case GameState.GameOver
24: Exit Select
25: Case Else
26: Exit Select
27: End Select
28: End If
29:
30: Private Sub EntityFired(ByVal shooter As Sprite)
31: Debug.WriteLine(shooter)
32: Select Case shooter.ToString()
33: Case "SimpleShooter.Ship"
34: If missles.Count = 0 Then
35: Dim missle As Missle = DirectCast(shooter, Ship).Fire()
36: missles.Add(missle)
37:
38: gameRoot.Children.Add(missle.SpriteCanvas)
39: End If
40: Exit Select
41: Case "SimpleShooter.Alien"
42: Dim bomb As Bomb = DirectCast(shooter, Alien).Fire()
43: bombs.Add(bomb)
44: gameRoot.Children.Add(bomb.SpriteCanvas)
45: Exit Select
46: Case Else
47: Exit Select
48: End Select
49: End Sub
Чтобы состояние игры было проще отслеживать, добавим к нашим элементам управления открытые свойства. Например, добавим свойство Lives в элемент управления ReamainingLives, причем установщик этого свойства будет обновлять TextBlock этого элемента управления, чтобы пользователь видел, сколько осталось жизней. Аналогично поступим с остальными тремя элементами управления:
C#
1: public partial class RemainingLives : UserControl
2: {
3: private int _lives;
4: public int Lives
5: {
6: get { return _lives; }
7: set
8: {
9: _lives = value;
10: string livesString = string.Empty;
11: for (int i = 0; i < _lives - 1; i++)
12: {
13: livesString = string.Format("{0}{1}", livesString, "A");
14: }
15: txtRemainingLives.Text = livesString;
16: }
17: }
18:
19: public RemainingLives()
20: {
21: InitializeComponent();
22: }
23: }
VB
1: Partial Public Class RemainingLives
2: Inherits UserControl
3: Private _lives As Integer
4: Public Property Lives() As Integer
5: Get
6: Return _lives
7: End Get
8: Set(ByVal value As Integer)
9: _lives = value
10: Dim livesString As String = String.Empty
11: For i As Integer = 0 To _lives - 2
12: livesString = String.Format("{0}{1}", livesString, "A")
13: Next
14: txtRemainingLives.Text = livesString
15: End Set
16: End Property
17:
18: Public Sub New()
19: InitializeComponent()
20: End Sub
21: End Class
Пришло время добавить в наш основной класс и другие вещи. В цикле игры нам придется иметь дело со множеством объектов. У нас будет свой корабль, некоторое число пришельцев, сколько-то бомб, а также наши ракеты, которые можно будет выпускать по одной с помощью метода EntityFired. В каждом цикле надо отслеживать, не столкнулась ли та или иная ракета с одним из пришельцев и не вылетела ли за пределы поля, не коснулась ли бомба нашего корабля и не исчезла ли с экрана, а также нет ли нового выстрела пришельцев по нашему кораблю. В классе Page уже есть свойство Ship, но Aliens (пришельцы), Bombs (бомбы) и Missiles (ракеты) должны быть коллекциями. Например, просматривая по очереди бомбы, нам будет удобно удалять те из них, которые попали в корабль или вышли за пределы игрового поля. Поскольку мы собираемся производить итерацию этих коллекций, мы не можем удалять из них элементы в цикле. Есть множество решений этой задачи, но мы поступим просто: введем коллекции с удаленными элементами, соответствующие основным коллекциям Bomb, Alien и Missile. В дальнейшем, в цикле игры удаляемые элементы будут добавляться в коллекцию с удаленными элементами и оставаться в основной коллекции:
C#
1: List<Alien> aliens;
2: List<Alien> aliensRemove;
3: List<Alien> alienShooters;
4: List<Bomb> bombs;
5: List<Bomb> bombsRemove;
6: List<Missle> missles;
7: List<Missle> misslesRemove;
VB
1: Private aliens As List(Of Alien)
2: Private aliensRemove As List(Of Alien)
3: Private alienShooters As List(Of Alien)
4: Private bombs As List(Of Bomb)
5: Private bombsRemove As List(Of Bomb)
6: Private missles As List(Of Missle)
7: Private misslesRemove As List(Of Missle)
Нам также потребуется класс для отслеживания волн пришельцев, которые мы будем посылать по одной. Кроме того, добавим коллекцию для хранения этих волн. В каждой волне будет отслеживаться число пришельцев, появившихся за раз, общее число пришельцев в волне, количество пришельцев, сбросивших бомбы, а также частоту, с которой они могут метать бомбы.
C#
1: public class WaveData
2: {
3: public WaveData(int count, double fireRate, int atOnce, int fireatonce)
4: {
5: EnemyCount = count;
6: fireRateMilliseconds = fireRate;
7: enemiesAtOnce = atOnce;
8: fireAtOnce = fireatonce;
9: waveEmpty = false;
10: }
11: public int EnemyCount { get; set; }
12: public double fireRateMilliseconds { get; set; }
13: public int enemiesAtOnce { get; set; }
14: public int fireAtOnce { get; set; }
15: public bool waveEmpty { get; set; }
16: }
VB
1: Public Class WaveData
2: Public Sub New(ByVal count As Integer, ByVal fireRate As Double, ByVal atOnce As Integer, ByVal fireatonce__1 As Integer)
3: EnemyCount = count
4: fireRateMilliseconds = fireRate
5: enemiesAtOnce = atOnce
6: fireAtOnce = fireatonce__1
7: waveEmpty = False
8: End Sub
9: Private _EnemyCount As Integer
10: Public Property EnemyCount() As Integer
11: Get
12: Return _EnemyCount
13: End Get
14: Set(ByVal value As Integer)
15: _EnemyCount = value
16: End Set
17: End Property
18: Private _fireRateMilliseconds As Double
19: Public Property fireRateMilliseconds() As Double
20: Get
21: Return _fireRateMilliseconds
22: End Get
23: Set(ByVal value As Double)
24: _fireRateMilliseconds = value
25: End Set
26: End Property
27: Private _enemiesAtOnce As Integer
28: Public Property enemiesAtOnce() As Integer
29: Get
30: Return _enemiesAtOnce
31: End Get
32: Set(ByVal value As Integer)
33: _enemiesAtOnce = value
34: End Set
35: End Property
36: Private _fireAtOnce As Integer
37: Public Property fireAtOnce() As Integer
38: Get
39: Return _fireAtOnce
40: End Get
41: Set(ByVal value As Integer)
42: _fireAtOnce = value
43: End Set
44: End Property
45: Private _waveEmpty As Boolean
46: Public Property waveEmpty() As Boolean
47: Get
48: Return _waveEmpty
49: End Get
50: Set(ByVal value As Boolean)
51: _waveEmpty = value
52: End Set
53: End Property
54: End Class
Нам надо установить инициализационный метод нашей игры, все коллекции для спрайтов и добавить последовательно усложняющиеся волны. Все это будет в теле цикла игры. Для каждой коллекции мы вызываем методы, просматривающие ее содержимое и выполняющие действия в зависимости от координат и столкновений спрайтов. После выполнения цикла каждой основной коллекции, мы проходим по соответствующей ей коллекции с удаленными элементами, удаляем ненужные изображения с экрана и из основной коллекции. В конце проверяем, не настало ли время пришельцам сбросить очередные бомбы:
C#
1: void gameLoop_Update(TimeSpan elapsed)
2: {
3: // Очистим текущий вектор, чтобы спрайт не двигался, когда не нажаты те или иные клавиши
4: PlayerShip.Velocity = new Vector(0, 0);
5: if (keyHandler.IsKeyPressed(Key.Left))
6: {
7: PlayerShip.Velocity = Vector.CreateVectorFromAngle(270, 125);
8: }
9: if (keyHandler.IsKeyPressed(Key.Right))
10: {
11: PlayerShip.Velocity = Vector.CreateVectorFromAngle(90, 125);
12: }
13: if (keyHandler.IsKeyPressed(Key.Space))
14: {
15: switch (status)
16: {
17: case GameState.Ready:
18: break;
19: case GameState.Running:
20: EntityFired(PlayerShip);
21: break;
22: case GameState.Paused:
23: break;
24: case GameState.BetweenWaves:
25: status = GameState.Running;
26: ctlInfo.GameInfo = "";
27: StartWave();
28: break;
29: case GameState.GameOver:
30: break;
31: default:
32: break;
33: }
34: }
35: PlayerShip.Update(elapsed);
36:
37: BombLoop(elapsed);
38: MissleLoop(elapsed);
39: AlienLoop(elapsed);
40:
41: foreach (Alien alien in aliensRemove)
42: {
43: aliens.Remove(alien);
44: gameRoot.Children.Remove(alien.SpriteCanvas);
45: AlienShot(alien);
46: }
47: aliensRemove.Clear();
48:
49: foreach (Missle missle in misslesRemove)
50: {
51: missles.Remove(missle);
52: gameRoot.Children.Remove(missle.SpriteCanvas);
53: }
54: misslesRemove.Clear();
55:
56: if (nextShot <= DateTime.Now)
57: {
58: nextShot = DateTime.Now.AddMilliseconds(enemyShootMilliseonds).AddMilliseconds(elapsed.Milliseconds * -1);
59:
60: shotsThisPass = shotsAtOnce;
61: if (shotsThisPass > aliens.Count)
62: {
63: shotsThisPass = aliens.Count;
64: }
65:
66: if (aliens.Count > 0)
67: {
68: foreach (Alien alien in aliens)
69: {
70: alienShooters.Add(alien);
71: }
72: }
73:
74: while (alienShooters.Count > shotsThisPass)
75: {
76: alienShooters.RemoveAt(GetRandInt(0, alienShooters.Count - 1));
77: }
78:
79: foreach (Alien alien in alienShooters)
80: {
81: EntityFired(alien);
82: }
83:
84: alienShooters.Clear();
85: }
86: }
VB
1: Private Sub gameLoop_Update(ByVal elapsed As TimeSpan)
2: ' Очистим текущий вектор, чтобы спрайт не двигался, когда не нажаты те или иные клавиши
3: PlayerShip.Velocity = New Vector(0, 0)
4: If keyHandler.IsKeyPressed(Key.Left) Then
5: PlayerShip.Velocity = Vector.CreateVectorFromAngle(270, 125)
6: End If
7: If keyHandler.IsKeyPressed(Key.Right) Then
8: PlayerShip.Velocity = Vector.CreateVectorFromAngle(90, 125)
9: End If
10: If keyHandler.IsKeyPressed(Key.Space) Then
11: Select Case status
12: Case GameState.Ready
13: Exit Select
14: Case GameState.Running
15: EntityFired(PlayerShip)
16: Exit Select
17: Case GameState.Paused
18: Exit Select
19: Case GameState.BetweenWaves
20: status = GameState.Running
21: ctlInfo.GameInfo = ""
22: StartWave()
23: Exit Select
24: Case GameState.GameOver
25: Exit Select
26: Case Else
27: Exit Select
28: End Select
29: End If
30: PlayerShip.Update(elapsed)
31:
32: BombLoop(elapsed)
33: MissleLoop(elapsed)
34: AlienLoop(elapsed)
35:
36: For Each alien As Alien In aliensRemove
37: aliens.Remove(alien)
38: gameRoot.Children.Remove(alien.SpriteCanvas)
39: AlienShot(alien)
40: Next
41: aliensRemove.Clear()
42:
43: For Each missle As Missle In misslesRemove
44: missles.Remove(missle)
45: gameRoot.Children.Remove(missle.SpriteCanvas)
46: Next
47: misslesRemove.Clear()
48:
49: If nextShot <= DateTime.Now Then
50: nextShot = DateTime.Now.AddMilliseconds(enemyShootMilliseonds).AddMilliseconds(elapsed.Milliseconds * -1)
51:
52: shotsThisPass = shotsAtOnce
53: If shotsThisPass > aliens.Count Then
54: shotsThisPass = aliens.Count
55: End If
56:
57: If aliens.Count > 0 Then
58: For Each alien As Alien In aliens
59: alienShooters.Add(alien)
60: Next
61: End If
62:
63: While alienShooters.Count > shotsThisPass
64: alienShooters.RemoveAt(GetRandInt(0, alienShooters.Count - 1))
65: End While
66:
67: For Each alien As Alien In alienShooters
68: EntityFired(alien)
69: Next
70:
71: alienShooters.Clear()
72: End If
73: End Sub
Теперь это можно назвать игрой:
В качестве следующего шага я воспользовался Expression Blend и сделал более симпатичные XAML, которые наши спрайты загружают в свои SpriteCanvas. Эти XAML вы можете загрузить вместе с остальным исходным кодом. В результате игра выглядит гораздо интересней.
Завершение
Silverlight предоставляет массу возможностей для полноценной разработки. Нужно заметить, что в данной игре мы использовали лишь простейшие функции. В ее основе лежит цикл, в котором перемещаются спрайты на полотне. Такие возможности Silverlight, как анимация (EN), стили (EN), шаблоны (EN) и визуальные состояния (EN) еще шире раздвигают горизонты разработчиков и позволяют преодолевать ограничения браузерных игр. Разработка игр — прекрасная возможность изучить возможности Silverlight. Успехов!
Ссылка на исходный код игры приведена в начале статьи — загружайте и пробуйте!
Об авторе
Роджер Гесс (Roger Guess) — ИТ-директор в The Wedge Group, где он работает с такими технологиями, как Silverlight и WPF. В свободное время он пишет игры и ведет сетевой дневник: SilverlightAddict.com. С ним можно связаться по электронной почте: email@rogerguess.net.