none
How To: Get Started with Video Game Development in Visual Basic .Net using the PuppyBreath framework. RRS feed

  • General discussion

  • PuppyBreath on NuGet GitHub

    I've long endeavored to try to assist people who want to make little video games using VB.Net by offering advice and by trying to come up with a versatile, easy-to-use, robust and performant general-purpose 2D game engine.  The latter has been a labor of love for many years and I've gone through countless iterations of varying design and implementation.  At long-last I think I've managed to build something that meets all of the objectives, so I've published my first GitHub repository and NuGet package.

    PuppyBreath is "A simplistic and robust video game engine for .Net Windows Forms, based entirely on managed code and GDI+ graphics (authored in Visual Basic .Net)."  Simply add the NuGet package to a new Windows Forms project, drag an instance of RenderCanvas onto the Form, add a few lines of boilerplate code to the Form (templates are planned to replace this step later) and you're ready to start writing your game using a simple and versatile object model.

    To demonstrate, the remainder of this post will be a walkthrough to create a very simple "Gem Grabber!" game which demonstrates all of the major features of the PuppyBreath engine.

    Tutorial Walkthrough - Part 1

    Phase 1 - Prep the Project

    The first phase is to create a project and add the PuppyBreath package.

    1. Begin by creating a new Windows Forms project.
    2. Using the NuGet Package Manager, search for ReedKimble.PuppyBreath from nuget.org and install it
    3. Open the Code File for Form1 and add "Imports PuppyBreath" to the top
    4. Switch to Design View and drag an instance of RenderCanvas from the Toolbox onto Form1
    5. Dock or anchor the canvas in the Form as you like and size the form to something reasonable to work with (eg 800x600)

    Phase 2 - Prep the Assets

    You'll need to download the assets (free) used in the example, or provide your own.

    • https://opengameart.org/content/alternate-lpc-character-sprites-george
    • https://opengameart.org/content/basic-gems-icon-set-remix
    • https://freesound.org/people/wildweasel/sounds/39017/
    • https://freesound.org/people/zagi2/sounds/319407/

    In this example the files are added to the project resources images and audio.  The audio files must have their Copy To Output Directory property set to Always or Copy if Newer.

    Phase 3 - Boilerplate

    The next step is to add some general "boilerplate" code which just handles the common mechanics of interacting with the game engine (things like starting, stopping, and pausing the game).

    We'll begin by creating a string dictionary of GameScene objects for the sole purpose of demonstrating how a game might manage its own collection of various GameScenes.  A game designed around a single scene, such as this one, does not necessarily have to track GameScene instances.

    Public Class Form1
        Private gameScenes As New Dictionary(Of String, GameScene)

    Next we'll handle the Form.Load event, configure the RenderCanvas, and get the game engine started.  We'll begin by mapping some standard movement input keys to friendly names, which we will use later when writing the code to control our player.  After that we'll just create a new instance of the main scene (more on that in a bit), set it as the current scene, and then start the game engine.

    Public Class Form1
        Private gameScenes As New Dictionary(Of String, GameScene)
    
        Private Async Sub Form1_Load(sender As Object, e As EventArgs) Handles MyBase.Load
            With RenderCanvas1.GetGameInput
                .GetKeyMap("Up").AddRange({Keys.W, Keys.Up, Keys.NumPad8})
                .GetKeyMap("Down").AddRange({Keys.S, Keys.Down, Keys.NumPad2})
                .GetKeyMap("Left").AddRange({Keys.A, Keys.Left, Keys.NumPad4})
                .GetKeyMap("Right").AddRange({Keys.D, Keys.Right, Keys.NumPad6})
            End With
            gameScenes("main") = CreateMainScene()
            RenderCanvas1.ChangeScene(gameScenes("main"))
            Await RenderCanvas1.BeginAsync()
        End Sub

    All we really need to do now is handle the game ending.  It's also convenient to make the game pause itself if the main window looses focus.  This is all done with the following four simple event handlers:

    Private Sub Form1_FormClosing(sender As Object, e As FormClosingEventArgs) Handles Me.FormClosing
        RenderCanvas1.StopRenderer()
        If RenderCanvas1.IsRunning Then e.Cancel = True
    End Sub
    
    Private Sub RenderCanvas1_RenderingComplete(sender As Object, e As EventArgs) Handles RenderCanvas1.RenderingComplete
        Close()
    End Sub
    
    Private Sub Form1_Deactivate(sender As Object, e As EventArgs) Handles Me.Deactivate
        RenderCanvas1.Pause()
    End Sub
    
    Private Sub Form1_Activated(sender As Object, e As EventArgs) Handles Me.Activated
        RenderCanvas1.Resume()
    End Sub

    And that's all there is to preparing the form for use with the PuppyBreath engine. We do still need to add the method to create the main scene instance, but there isn't a lot to that and we'll need to create some other game object classes first.  For now, we'll just stub it out and then begin to fill in functionality as we test our progress.

    Private Function CreateMainScene() As GameScene
        Dim scene As New GameScene
    
        Return scene
    End Function
    

    Phase 4 - The Player

    The first thing our game needs is a player.  Since we have a nice little sprite sheet of walking animations for our player, we'll create a Player class that inherits from AnimatedSprite.  For the purposes of this simple game, our player only needs to track two values, his movement speed and his score, so we'll add two properties to hold those values.

    Imports PuppyBreath
    Imports PuppyBreath.Animation
    
    Public Class Player
        Inherits AnimatedSprite
    
        Public Property Score As Integer
        Public Property Speed As Single = 48.0!

    Now we will override the Initialize() method and write some code to configure the player game object.  We'll set the Image property to the appropriate resource, set the FrameSize to the size of each frame of animation within the source image (48x48 for the George image), set the size of the game object as it appears on screen at 100% scale to the same as the FrameSize, set the collision radius (the circle around the game object used to detect collision) to 18 (collision usually looks and works better with bounds that are quite a bit smaller than the actual image) and enable collision by setting the CheckCollision property to true.

    Protected Overrides Sub Initialize(state As GameState)
        MyBase.Initialize(state)
        'image source: https://opengameart.org/content/alternate-lpc-character-sprites-george
        Image = My.Resources.george
        FrameSize = New Size(48, 48)
        Size = FrameSize
        CollisionRadius = 18
        CheckCollision = True
    

    Next we're going to write a small lambda "helper function" within this sub that we will use to create new animation instances for the player.  This will allow us to specify an animation name, time, and series of frame positions without resorting to a bunch of ugly copy-paste-looking code.

    Dim createAnimation = Sub(name As String, time As Single, frames As IEnumerable(Of Point))
                              Dim anim As New SpriteSheetAnimation
                              anim.Name = name
                              anim.AnimationTime = time
                              With anim.Frames
                                  For Each f In frames
                                      .Add(f)
                                  Next
                              End With
                              Animations.Add(anim)
                          End Sub
    

    With that in place we can now create the four walking animations for the player:

    createAnimation("Walk Down", 1, {New Point(0, 0), New Point(0, 48), New Point(0, 96), New Point(0, 144)})
    createAnimation("Walk Left", 1, {New Point(48, 0), New Point(48, 48), New Point(48, 96), New Point(48, 144)})
    createAnimation("Walk Up", 1, {New Point(96, 0), New Point(96, 48), New Point(96, 96), New Point(96, 144)})
    createAnimation("Walk Right", 1, {New Point(144, 0), New Point(144, 48), New Point(144, 96), New Point(144, 144)})
    

    Finally we'll set the current animation to the "walk down" animation:

        CurrentAnimationName = "Walk Down"
    End Sub
    

    That concludes the Initialize() method and our Player is ready to appear on screen and take commands!  To verify this, lets go back to CreateMainScene, create an instance of the player and add the instance to the scene in the middle of the canvas.

    Private Function CreateMainScene() As GameScene
        Dim scene As New GameScene
    
        Dim hero As New Player
        hero.Position = New PointF(RenderCanvas1.Width * 0.5, RenderCanvas1.Height * 0.5)
        scene.GameObjects.Add(hero)
    
        Return scene
    End Function
    

    Hit F5 and or click Start to test the program.  You should see George walking in place in the middle of a black background.  Success!  Now we need to control George so that he can walk around the screen.  Return to the Player class and override the Update() method.  This method is called one per frame and is where you process any user input or AI logic for your game objects.  The player object is simply going to look at the input keys we defined earlier and move the player and/or update the playing animation as appropriate.  The code should be self-explanatory.

    Protected Overrides Sub Update(state As GameState)
        MyBase.Update(state)
        Dim moveDelta As New SizeF
    
        If state.Input.IsKeyDown("Left") Then
            moveDelta.Width = -Speed * state.Time.LastFrame
            CurrentAnimationName = "Walk Left"
        End If
        If state.Input.IsKeyDown("Right") Then
            moveDelta.Width = Speed * state.Time.LastFrame
            CurrentAnimationName = "Walk Right"
        End If
        If state.Input.IsKeyDown("Up") Then
            moveDelta.Height = -Speed * state.Time.LastFrame
            CurrentAnimationName = "Walk Up"
        End If
        If state.Input.IsKeyDown("Down") Then
            moveDelta.Height = Speed * state.Time.LastFrame
            CurrentAnimationName = "Walk Down"
        End If
        If moveDelta = SizeF.Empty Then
            CurrentAnimation.Active = False
        Else
            CurrentAnimation.Active = True
            Position += moveDelta
        End If
    End Sub

    With this new block of code in place you can run the example again and this time use WASD, the arrow keys, or the numeric keypad to move the character around the screen with the correct animation playing.

    So that completes the Player object, but now he needs something to do!  Let's give George some Gems to collect.

    Phase 5 - Collecting Gems

    Our gem sprite sheet isn't an animation sheet, its just a collection of static images of different gems.  So we won't use an AnimatedSprite for the Gem class, just a regular Sprite, however, we will provide some animation effects by demonstrating the scaling and rotation features of the engine.

    We're going to allow the pulse rate of the animation and the cool-down time of the spawner to be variable so we'll add those as properties to the class.  We'll also need some private variables to control the pulsing and rotating "animation" so we'll add those as well.

    Public Class Gem
        Inherits Sprite
    
        Public Property CoolDown As Single = 8.0!
        Public Property PulseRate As Single = 2.0!
        Public Property RotateRate As Single = 30.0!
    
        Private pulseTime As Single = PulseRate
        Private scaleDelta As Single = 0.1!

    As with the Player, we'll override the Initialize() method to configure the gem's settings:

    Protected Overrides Sub Initialize(state As GameState)
        MyBase.Initialize(state)
        'image source: https://opengameart.org/content/basic-gems-icon-set-remix
        Image = My.Resources.crystals_trans_shadow_black
        FrameSize = New Size(64, 64)
        Size = New SizeF(28, 28)
        CollisionRadius = 14
        CheckCollision = True
        Scale = 1.0!
        scaleDelta = 0.1!
        pulseTime = PulseRate
    End Sub
    

    For the gem's game logic we'll need to do two things.  First we'll need to execute the scaling and rotation effect, and second we'll need to check for collisions with the Player and act accordingly.

    While we could technically check collision on the Player as well (since it will receive CollisionInfo instances just like the Gems), it is often easier to keep track of game logic by placing collision handling code in the object's the player collides with rather than in the player itself, as much as possible.

    So to begin, we will scale and rotate the gem over time:

    Protected Overrides Sub Update(state As GameState)
        MyBase.Update(state)
        pulseTime -= state.Time.LastFrame
        If pulseTime <= 0 Then
            pulseTime = PulseRate
            scaleDelta *= -1
        End If
        Scale += scaleDelta * state.Time.LastFrame
        Rotation = Mathf.WrapDegrees(Rotation + RotateRate * state.Time.LastFrame)
    

    And then we will check to see if any of the objects in our collision list are the player and if so, we'll increase the score based on the particular gem being displayed, play the "item pickup" audio effect and finally destroy the gem (remove it from the scene).

        For Each c In Collisions
            If TypeOf (c.Other) Is Player Then
                Dim score As Integer = ((FrameLocation.X * 2) + (FrameLocation.Y * 4)) / 64
                DirectCast(c.Other, Player).Score += score
    
                'sound source: https://freesound.org/people/wildweasel/sounds/39017/
                state.Audio.PlayEffect("39017__wildweasel__dsgetpow")
                Destroy(state)
            End If
        Next
    End Sub
    

    This completes the Gem class and now we just need a way to spawn new gems into the game.  We'll do this with a GemSpawner class which inherits from GameObject.  The spawner doesn't need any visual representation in the game so we don't need to use a Sprite object.

    The spawner will have a GemCount and GemRespawnTime property to control the spawning process.  It will also have its own internal list of Gem instances that will be populated with GemCount gems on initialization.  The update method will loop through each gem and if it is destroyed, update its cool-down time.  When the cool down reaches zero, the gem will be respawned into the game at a random location with a random image.  The remaining functions are helper methods to get random images and locations.

    Imports PuppyBreath
    
    Public Class GemSpawner
        Inherits GameObject
    
        Public Property GemCount As Integer = 24
        Public Property GemRespawnTime As Single = 8.0!
    
        Private gems As New List(Of Gem)
    
        Protected Overrides Sub Initialize(state As GameState)
            MyBase.Initialize(state)
            For i = 0 To GemCount - 1
                Dim g As New Gem With {
                    .Position = GetRandomGemPosition(state),
                    .FrameLocation = GetRandomGemFrame(state)
                }
                gems.Add(g)
                state.Scene.GameObjects.Add(g)
            Next
        End Sub
    
        Protected Overrides Sub Update(state As GameState)
            MyBase.Update(state)
            For Each g In gems
                If g.IsDestroyed Then
                    g.CoolDown -= state.Time.LastFrame
                    If g.CoolDown <= 0 Then
                        g.CoolDown = GemRespawnTime
                        g.Position = GetRandomGemPosition(state)
                        g.FrameLocation = GetRandomGemFrame(state)
                        g.Reset()
                        state.Scene.GameObjects.Add(g)
                    End If
                End If
            Next
        End Sub
    
        Private Function GetRandomGemFrame(state As GameState) As Point
            Dim rx = state.Random.Next(0, 5)
            Dim ry = state.Random.Next(0, 8)
            Return New Point(rx * 64, ry * 64)
        End Function
    
        Private Function GetRandomGemPosition(state As GameState) As PointF
            Return New PointF(state.Random.Next(state.CanvasBounds.Left + 32, state.CanvasBounds.Right - 32), state.Random.Next(state.CanvasBounds.Top + 32, state.CanvasBounds.Bottom - 32))
        End Function
    End Class

    If we return to the Form1 code file we can add the GemSpawner to the scene and test our new functionality.

    Private Function CreateMainScene() As GameScene
        Dim scene As New GameScene
    
        Dim hero As New Player
        hero.Position = New PointF(RenderCanvas1.Width * 0.5, RenderCanvas1.Height * 0.5)
        scene.GameObjects.Add(hero)
    
        Dim spawner As New GemSpawner
        scene.GameObjects.Add(spawner)
    
        Return scene
    End Function
    

    Run the program and George can now walk around and pick up any gems he runs into.

    That does it for Part 1 of the tutorial.  In the next part we'll add some UI elements so that we can see George's score and we'll add we'll explore creating UI controls like a button we can click to spend points and increase George's speed!  We'll also look at making the game more interesting with pop-up feedback labels and something other than blackness to walk around in!  Stay tuned.

    Summary

    PuppyBreath is meant to be an easy to use 2D game engine for use in Windows Forms projects.  It is installed via a NuGet package and the source code is available on GitHub. PuppyBreath uses a common video game object model in terms of Scenes and GameObjects (Sprites being derived GameObjects).  It offers adequate performance for most retro-style games supporting a couple of hundred draw calls per frame at 25+ fps.  By managing the number of draws necessary per frame, the engine easily supports several hundred active game objects at one time, with full collision detection on each object.

    PupptyBreath provides a versatile API allowing for game objects created through derived classes or by configuring delegate functionality handlers on general purpose game object instances.

    The framework is currently in alpha release version 0.1.0.0 but is stable and mostly feature complete.  I welcome any comments, suggestions and discussion on existing or missing functionality.

    Problems/Issues

    If you find any issues while using PuppyBreath, please create an issue on the GitHub repository.

    PLEASE DO NOT POST IN THIS FORUM WITH ISSUES OR TO REPORT BUGS


    Reed Kimble - "When you do things right, people won't be sure you've done anything at all"

    Monday, January 8, 2018 9:31 PM
    Moderator

All replies

  •  Nice,  now i can somewhat retire the links to the old tutorials you made on developing games in .Net applications.  I did not try this yet but,  i am sure coming from you that it is good advice and works.  By the way,  who would have thought that "Puppy Breath" would be a good thing.  haha

     Thanks for sharing this Reed.  8)


    If you say it can`t be done then i`ll try it

    Monday, January 8, 2018 10:25 PM
  • Hi Reed!

    Will give it a try.

    Monday, January 8, 2018 11:51 PM
  • Tutorial Walkthrough - Part 2

    Phase 1 - User Interface Elements

    Now that George can run around and collect gems, we need to be able to see his score.  Since we're going to let him purchase speed enhancements with his points, we'll want to show his speed as well.  This can be accomplished with a very simple PlayerScoreLabel class that inherits from Sprite.  Since it is a label it will need a Font property, but since it is displaying specific text about the player we will give it a reference to the Player object instead of a Text property like a traditional label would have.  Since we are drawing a string with GDI we'll want an instance of StringFormat so we'll add one of those as well.

    The Initialize and Render methods are pretty straight forward.  All that is necessary is to configure the StringFormat and code the render method to draw the player's score and speed.

    Imports PuppyBreath
    
    Public Class PlayerScoreLabel
        Inherits Sprite
    
        Public Property Font As Font = New Font("Consolas", 18.0!, FontStyle.Bold)
        Public Property Player As Player
    
        Private format As New StringFormat
        Protected Overrides Sub Initialize(state As GameState)
            MyBase.Initialize(state)
            format.Alignment = StringAlignment.Near
            format.LineAlignment = StringAlignment.Center
        End Sub
    
        Protected Overrides Sub Render(g As Graphics)
            MyBase.Render(g)
            g.DrawString($"Score: {Player.Score}{vbCrLf}Speed: {Player.Speed}", Font, Brushes.White, Position, format)
        End Sub
    End Class

    Return to the Form1 code file and update CreateMainScene to add the new score label, then test the game.

    Private Function CreateMainScene() As GameScene
        Dim scene As New GameScene
    
        Dim hero As New Player
        hero.Position = New PointF(RenderCanvas1.Width * 0.5, RenderCanvas1.Height * 0.5)
        scene.GameObjects.Add(hero)
    
        Dim spawner As New GemSpawner
        scene.GameObjects.Add(spawner)
    
        Dim scorelabel As New PlayerScoreLabel
        scorelabel.Position = New PointF(18, 42)
        scorelabel.Player = hero
        scorelabel.ZOrder = 100
        scene.GameObjects.Add(scorelabel)
    
        Return scene
    End Function
    

    Now we can see the score increase when gems are collected.  Time to add a button so that we can purchase upgrades!

    The GuiButton is similar to the PlayerScoreLabel in that it inherits from Sprite and renders text, but it also draws a button and supports clicking.  To make it easy to specify what happens when the button is clicked, there is an OnClick property which takes a delegate to an Action(Of GameState).  We'll see this in action when it comes time to add the button to the scene.

    The GuiButton class begins with a Color, Font, and Text property along with the OnClick delegate property.  It then defines a StringFormat and an isPushed flag used to determine the drawing state.  The Initialize() method is the same as PlayerScoreLabel and just configures the StringFormat and sets the ZOrder.

    Imports PuppyBreath
    
    Public Class GuiButton
        Inherits Sprite
    
        Public Property Color As Color = SystemColors.ControlText
        Public Property Font As Font = New Font("Consolas", 11.0!, FontStyle.Bold)
        Public Property Text As String
    
        Public Property OnClick As Action(Of GameState)
    
        Private format As New StringFormat
        Private isPushed As Boolean
    
        Protected Overrides Sub Initialize(state As GameState)
            MyBase.Initialize(state)
            format.Alignment = StringAlignment.Center
            format.LineAlignment = StringAlignment.Center
            ZOrder = 100
        End Sub

    The Render() method auto-sizes the button to fit the text then draws the button background in the appropriate state with the button text on top.

    Protected Overrides Sub Render(g As Graphics)
        MyBase.Render(g)
        Size = g.MeasureString(Text, Font)
        Dim dst As New Rectangle(Position.X - Size.Width * 0.5, Position.Y - Size.Height * 0.5, Size.Width, Size.Height)
        ControlPaint.DrawButton(g, dst, If(isPushed, ButtonState.Pushed, ButtonState.Normal))
        Using brsh As New SolidBrush(Color)
            g.DrawString(Text, Font, brsh, If(isPushed, Position + New SizeF(2, 2), Position), format)
        End Using
    End Sub
    

    Finally the update method checks the left mouse button state and if it is pressed, it then checks to see if the mouse position is within the bounds of the button.  If so the pushed state is set.  If the button is not pressed, it checks to see if the pushed state is set and, if so, invokes the OnClick delegate and clears the pushed state.

    Protected Overrides Sub Update(state As GameState)
        MyBase.Update(state)
        If state.Input.IsButtonDown(MouseButtons.Left) Then
            Dim dst As New Rectangle(Position.X - Size.Width * 0.5, Position.Y - Size.Height * 0.5, Size.Width, Size.Height)
            If dst.Contains(Point.Truncate(state.Input.GetMousePosition)) Then
                isPushed = True
            End If
        Else
            If isPushed Then
                OnClick?.Invoke(state)
                isPushed = False
            End If
        End If
    End Sub
    

    We can now return to the CreateMainScene() method and add an instance of the GuiButton.  First we'll set the button text and position and then create the delegate for OnClick.  In this lambda delegate we'll check the hero's score and if it is greater or equal to 200 we will increase the speed by 4 and decrease the score by 200.

    scene.GameObjects.Add(scorelabel)
    
    Dim buySpeedButton As New GuiButton
    buySpeedButton.Text = "Buy +4 Speed for 200 Points"
    buySpeedButton.Position = New PointF(RenderCanvas1.Width * 0.5, RenderCanvas1.Height - 32)
    buySpeedButton.OnClick = Sub(state As GameState)
                                 If hero.Score >= 200 Then
                                     hero.Speed += 4
                                     hero.Score -= 200
                                 End If
                             End Sub
    scene.GameObjects.Add(buySpeedButton)
    
    Return scene
    

    Run the game and test the new button.  Note how nothing happens when you click the button until you have collected at least 200 points.

    Phase 2 - Adding Polish

    Our little game is looking pretty good, but there are a few more things we can do to help bring it to life.  When our player gains points or increases their speed, it would be nice to get more visual feedback than just the score label updating.  To achieve this we'll add a PopUpLabel class.  Like the other GUI elements, this class will be a Sprite with a Font, Color, and text but it will move upward and fade out after being displayed, creating a temporary "floating text" kind of alert.  The code is very similar to the previous GUI elements.

    Imports PuppyBreath
    
    Public Class PopUpLabel
        Inherits Sprite
    
        Public Property Color As Color = Color.Gold
        Public Property Duration As Single = 3.0!
        Public Property Font As Font = New Font("Consolas", 11.0!, FontStyle.Bold)
        Public Property Speed As Single = 24.0!
    
        Private format As New StringFormat
        Private text As String
        Private remainingDuration As Single
    
        Protected Overrides Sub Initialize(state As GameState)
            MyBase.Initialize(state)
            format.Alignment = StringAlignment.Center
            format.LineAlignment = StringAlignment.Center
            ZOrder = 100
        End Sub
    
        Public Overrides Sub Destroy(state As GameState)
            MyBase.Destroy(state)
        End Sub
    
        Protected Overrides Sub Render(g As Graphics)
            MyBase.Render(g)
            Using brsh As New SolidBrush(Color.FromArgb(CInt(255 * (remainingDuration / Duration)), Color))
                g.DrawString(text, Font, brsh, Position, format)
            End Using
        End Sub
    
        Public Sub Show(value As String, location As PointF)
            text = value
            Position = location
            remainingDuration = Duration
        End Sub
    
        Protected Overrides Sub Update(state As GameState)
            MyBase.Update(state)
            remainingDuration -= state.Time.LastFrame
            If remainingDuration <= 0 Then
                remainingDuration = 0
                Destroy(state)
            End If
            Dim p = Position
            p.Y -= Speed * state.Time.LastFrame
            Position = p
        End Sub
    End Class

    To make use of the new PopUpLabel, we'll first go to the Gem's Update method and modify the collision checking loop to create an instance of the pop up showing the points that the gem was worth when a gem is collected.

    For Each c In Collisions
        If TypeOf (c.Other) Is Player Then
            Dim score As Integer = ((FrameLocation.X * 2) + (FrameLocation.Y * 4)) / 64
            DirectCast(c.Other, Player).Score += score
    
            Dim pl = state.Cache.GetInstance(Of PopUpLabel)
            state.Scene.GameObjects.Add(pl)
            pl.Show($"+{score}!", Position)
    
            'sound source: https://freesound.org/people/wildweasel/sounds/39017/
            state.Audio.PlayEffect("39017__wildweasel__dsgetpow")
            Destroy(state)
        End If
    Next
    

    Then we'll go to the CreateMainScene() method and modify our buySpeedButton.OnClick delegate to also create a popup with the result of clicking the button.

    buySpeedButton.OnClick = Sub(state As GameState)
                                 Dim resultMessage As String
                                 If hero.Score >= 200 Then
                                     hero.Speed += 4
                                     hero.Score -= 200
                                     resultMessage = "Speed Up +4!"
                                 Else
                                     resultMessage = "Not enough points!"
                                 End If
                                 Dim pl = state.Cache.GetInstance(Of PopUpLabel)
                                 state.Scene.GameObjects.Add(pl)
                                 pl.Show(resultMessage, hero.Position)
                             End Sub
    

    Before we run the game to see how the new popups improve the gameplay experience, let's play some background music.  We can do this with an OnInitialize delegate on the scene.  We'll configure one at the end of the CreateMainScene() method:

        scene.OnInitialize = Sub(state As GameState)
                                 'source: https://freesound.org/people/zagi2/sounds/319407/
                                 scene.AudioPlayers("backgroundMusic") = state.Audio.PlayMusic("319407__zagi2__voyage-loop")
                             End Sub
        Return scene
    End Function
    

    The only thing really missing at this point is a background for everything instead of just blackness.  There are a number of different kinds of backgrounds for different games and different scenarios.  Implementing a static image background should be pretty obvious at this point - it would just be a Sprite object with one big picture.  Scrolling backgrounds should be similarly easy, using a Render() routine to draw part of the image on one side of the screen and the rest on the other, moving the cut line to create a scrolling effect.

    A more complex background is one composed by repeatedly tiling smaller images, each designed to blend with themselves.  Such a "tile map" image can require many draw calls and so may require a buffer image which is only updated periodically and in smaller "chunks" at a time.  To demonstrate how you can create a large background image out of tiles and still provide some animation effects, we're going to create a TiledGrassBackground class that can create a large background with intermittent animation.

    Before we can start the TiledGrassBackground class we're going to need a couple support classes.  We're going to need an AnimatedTile class that can store information about a tile and its animation state as well as a keyed collection of AnimatedTiles to make selecting them easier at runtime.

    The AnimatedTile class has properties to control the active state of the animation and its run time, along with a collection of images associated with the animation, the tile size, and a name.  The class maintains a current image index and tracks the update time for changing frames of the animation.  The Update method executes the actual animation logic and returns an indicator of whether or not anything changed after being called.

    Imports PuppyBreath
    
    Public Class AnimatedTile
        Public Property AnimationActive As Boolean = True
        Public Property AnimationTime As Single = 1.0!
        Public Property Images As New List(Of Image)
        Public Property TileSize As Size
        Public Property Name As String
    
        Public ReadOnly Property CurrentImage As Image
            Get
                Return Images(imageIndex)
            End Get
        End Property
    
        Protected Friend imageIndex As Integer
        Private nextUpdateTime As Single = AnimationTime
    
        Public Function Update(state As GameState) As Boolean
            If Not AnimationActive Then Return False
            nextUpdateTime -= state.Time.LastFrame
            If nextUpdateTime <= 0 Then
                nextUpdateTime = AnimationTime
                imageIndex += 1
                If imageIndex >= Images.Count Then imageIndex = 0
                Return True
            End If
            Return False
        End Function
    End Class

    The AnimatedTileCollection is simply a KeyedCollection implementation of String and AnimatedTile.

    Public Class AnimatedTileCollection
        Inherits ObjectModel.KeyedCollection(Of String, AnimatedTile)
    
        Protected Overrides Function GetKeyForItem(item As AnimatedTile) As String
            Return item.Name
        End Function
    End Class

    With these support classes in place we can begin to build the TiledGrassBackground.  The class will inherit from Sprite and have two private fields; one to hold the AnimatedTileCollection and one to hold a tile-map of points-to-tile-indices.  We'll also create a simple method to create a random tile map.

    Imports PuppyBreath
    
    Public Class TiledGrassBackground
        Inherits Sprite
    
        Private tiles As New AnimatedTileCollection
        Private tileMap As New Dictionary(Of Point, Integer)
    
        Private Sub BuildRandomMap(state As GameState)
            tileMap.Clear()
            For y = 0 To Size.Height Step FrameSize.Height
                For x = 0 To Size.Width Step FrameSize.Width
                    tileMap(New Point(x, y)) = state.Random.Next(tiles.Count)
                Next
            Next
        End Sub

    Next we'll create a helper method to generate the individual tile instances.  We'll use 4 tiles with 3 images each.  The images can be downloaded at:

    https://opengameart.org/content/dungeon-crawl-32x32-tiles

    Note that this is a fairly large image collection.  The files used in this example are located in the crawl-tiles Oct-5-2010\dc-dngn\floor\grass folder within the download.

        Private Sub CreateTiles(state As GameState)
            'image source:  https://opengameart.org/content/dungeon-crawl-32x32-tiles
            ' located in folder:  crawl-tiles Oct-5-2010\dc-dngn\floor\grass
            Dim addNewTile = Sub(tileName As String, resources As IEnumerable(Of Image), animated As Boolean)
                                 Dim tile As New AnimatedTile
                                 tile.Name = tileName
                                 For Each ir In resources
                                     tile.Images.Add(ir)
                                 Next
                                 tile.TileSize = New Size(32, 32)
                                 tile.AnimationTime = state.Random.Next(1.5, 4)
                                 tile.imageIndex = state.Random.Next(tile.Images.Count)
                                 tile.AnimationActive = animated
                                 tiles.Add(tile)
                             End Sub
            addNewTile("Grass", {My.Resources.grass0, My.Resources.grass1, My.Resources.grass2}, True)
            addNewTile("Blue Flowers", {My.Resources.grass_flowers_blue1, My.Resources.grass_flowers_blue2, My.Resources.grass_flowers_blue3}, False)
            addNewTile("Red Flowers", {My.Resources.grass_flowers_red1, My.Resources.grass_flowers_red2, My.Resources.grass_flowers_red3}, False)
            addNewTile("Yellow Flowers", {My.Resources.grass_flowers_yellow1, My.Resources.grass_flowers_yellow2, My.Resources.grass_flowers_yellow3}, False)
        End Sub

    We'll also need a helper method to draw the initial map buffer image using the tile-map data and the tiles collection.

    Private Sub InitMapImage()
        Image = New Bitmap(CInt(Size.Width), CInt(Size.Height))
        Using g As Graphics = Graphics.FromImage(Image)
            For y = 0 To Size.Height Step FrameSize.Height
                For x = 0 To Size.Width Step FrameSize.Width
                    Dim pt As New Point(x, y)
                    Dim i = tileMap(pt)
                    Dim tile = tiles(i)
                    g.DrawImage(tile.CurrentImage, pt)
                Next
            Next
        End Using
    End Sub
    

    Now we can override the Initialize() method, configure the sprite and call our helper methods.

    Protected Overrides Sub Initialize(state As GameState)
        MyBase.Initialize(state)
        CreateTiles(state)
        FrameSize = New Size(32, 32)
        Size = Screen.PrimaryScreen.WorkingArea.Size
        ZOrder = -1
        BuildRandomMap(state)
        InitMapImage()
        FrameSize = Drawing.Size.Truncate(Size)
        Position = New PointF(Size.Width * 0.5, Size.Height * 0.5)
    End Sub
    

    Finally we'll override the Update() method and update each of the AnimatedTile instances.  Whenever a tile reports back that it changed, we'll redraw just those tiles on the buffer image.

    Protected Overrides Sub Update(state As GameState)
        MyBase.Update(state)
        For i = 0 To tiles.Count - 1
            Dim idx = i
            Dim t = tiles(idx)
            If t.Update(state) Then
                Using g As Graphics = Graphics.FromImage(Image)
                    For Each pt In (From kvp In tileMap Where kvp.Value = idx Select kvp.Key)
                        g.DrawImage(t.CurrentImage, pt)
                    Next
                End Using
            End If
        Next
    End Sub
    

    Return to the CreateMainScene() method and add the background to the scene after the buySpeedButton and before setting the background music.

    scene.GameObjects.Add(buySpeedButton)
    
    Dim background As New TiledGrassBackground
    scene.GameObjects.Add(background)
    
    scene.OnInitialize = Sub(state As GameState)
    

    And there you have it.  Hit run and test the completed game.  Notice how the flower tiles do not change, but the grass tiles occasionally change to a different image.  This could simulate the "wind" blowing the grass (even though these images do not lend themselves well to that effect).  The point is that you can have a very large image (works well on a 4K monitor) that can support some animation without eating up all of your available draw calls.

    Conclusion

    The PuppyBreath game engine provides a full range of 2D game design capability through a quick and easy to use object model that provides both versatility and performance.  A variety of 2D games can be readily implemented from platformers to shooters, side scrolling to top-down, even isometric.


    Reed Kimble - "When you do things right, people won't be sure you've done anything at all"

    Tuesday, January 9, 2018 1:25 AM
    Moderator