BOUNCE GAME
Introduction
This tutorial is designed to give an example of the basic structure of Nimgame2 applications. The following concepts will be explained here:
- engine initialization
- assets loading
- scenes
- texture graphics
- text graphics
- entity objects
- colliders and collisions
- keyboard input
- pausing
- sound
Assets
For any basic game you'll need three basic kinds of assets:
- fonts
- graphics
- sounds
So, the assets directory of the game will be structured as shown here:
./data/ | |-- fnt/ |-- gfx/ |-- sfx/
For rendering all text in the game we will use good old Fixedsys Excelsior (at "data/fnt").
We will need just two graphic assets here: the ball and the paddle (at "data/gfx").
As for the sounds, we will not worry about it for now.
Starting to Code
All source code will be placed into the "src" directory. Let's start with a file for storing constants and variables that might be accessed from different points of the game.
data.nim
import nimgame2 / [ assets, scene, types, ] const GameWidth* = 640 GameHeight* = 360 GameTitle* = "Nimgame 2 Bounce" var titleScene*, mainScene*: Scene
As you can see, we will have two game scenes: title and main. We will create a souce file for each of them.
title.nim
import nimgame2 / [ nimgame, scene, types, ], data type TitleScene = ref object of Scene proc init*(scene: TitleScene) = init Scene(scene) proc free*(scene: TitleScene) = discard proc newTitleScene*(): TitleScene = new result, free init result method event*(scene: TitleScene, event: Event) = if event.kind == KeyDown: game.scene = mainScene
main.nim
import nimgame2 / [ scene, ], data type MainScene = ref object of Scene proc init*(scene: MainScene) = init Scene(scene) proc free*(scene: MainScene) = discard proc newMainScene*(): MainScene = new result, free init result method show*(scene: MainScene) = echo "Switched to MainScene"
And, finally, we will create a main source file:
bounce.nim
import nimgame2 / [ nimgame, settings, types, ], data, title, main game = newGame() if game.init(GameWidth, GameHeight, title = GameTitle, integerScale = true): # Init game.setResizable(true) # Window could be resized game.minSize = (GameWidth, GameHeight) # Minimal window size game.windowSize = (GameWidth * 2, GameHeight * 2) # Double scaling (1280x720) game.centrify() # Place window at the center of the screen # Create scenes titleScene = newTitleScene() mainScene = newMainScene() # Run game.scene = titleScene # Initial scene run game # Let's go!
During the process of development we will rebuild the game constantly, so let's create some basic scripts for that:
build.debug.sh
#!/bin/sh cd src nim c --out:../ng2bounce bounce.nim cd ..
build.release.sh
#!/bin/sh cd src nim c --out:../ng2bounce -d:release --opt:speed bounce.nim rm -rf nimcache cd ..
Or, if you are working on Windows:
build.debug.bat
cd src nim c --out:..\ng2bounce.exe bounce.nim cd ..
build.release.bat
cd src nim c --out:..\ng2bounce -d:release --opt:speed --app:gui bounce.nim rmdir /s /q nimcache cd ..
So, at this moment, our directory structure looks like this:
./ng2bounce/ | |-- build.debug.sh |-- build.release.sh | |-- data/ | | | |-- fnt/ | | | | | |-- FSEX300.ttf | | | |-- gfx/ | | | | | |-- ball.png | | |-- paddle.png | | | |-- sfx/ | |-- src/ | |-- bounce.nim |-- data.nim |-- main.nim |-- title.nim
When we run our "build.debug.sh" script, we should get an executalbe "ng2bounce" that just shows a black screen and outputs a "Switched to MainScene" message to the console when any keyboard key is pressed.
Title Screen
We will start with populating the title screen. There will be two text graphics: the title of the game and a subtitle hint under it.
To render any text we need to load our font first.
data.nim
import nimgame2 / [ ... font, ... truetypefont, ... ] ... var ... defaultFont*, bigFont*: TrueTypeFont proc loadData*() = defaultFont = newTrueTypeFont() if not defaultFont.load("data/fnt/FSEX300.ttf", 16): echo "ERROR: Can't load font" bigFont = newTrueTypeFont() if not bigFont.load("data/fnt/FSEX300.ttf", 32): echo "ERROR: Can't load font" proc freeData*() = defaultFont.free() bigFont.free()
We will call the loading procedure from our main file.
bounce.nim
... loadData() # Call it before any scene initialization # Create scenes ...
And finally, we will create our entities in TitleScene's init() procedure.
title.nim
import nimgame2 / [ entity, font, ... textgraphic, ... ], ... proc init*(scene: TitleScene) = ... # Create a title text graphic with a big font let titleText = newTextGraphic(bigFont) titleText.setText GameTitle # Set the text to render let title = newEntity() # Create a title entity title.graphic = titleText # Assign the title text graphic title.centrify() # Set the origin point to the graphic's center title.pos = (GameWidth / 2, GameHeight / 3) # Set the title position on the screen scene.add title # Add title entity to the scene # Create a subtitle text graphic with a default font let subtitleText = newTextGraphic(defaultFont) subtitleText.setText "Press any key to start" # Set the text let subtitle = newEntity() # Create a subtitle entity subtitle.graphic = subtitleText # Assign the subtitle text graphic subtitle.centrify() # Set the origin point to the graphic's center subtitle.pos = game.size / 2 # Place to the center of the screen scene.add subtitle # Add subtitle entity to the scene ...
After running the game we should see this screen:
Loading Assets
Though we only have two graphic assets now it is surelly an overkill to use an asset manager, but it is a good opportunity to give an example of its usage.
data.nim
import nimgame2 / [ ... texturegraphic, ... ] ... var ... gfxData*:Assets[TextureGraphic] proc loadData*() = ... gfxData = newAssets[TextureGraphic]( "data/gfx", proc(file: string): TextureGraphic = newTextureGraphic(file)) proc freeData*() = ... for graphic in gfxData.values: graphic.free()
As you can see, we pass two arguments to the asset manager constructor: a path to the assets and a procedure to load them. The procedure recieves a file name and should return an object to be loaded in our collection.
Note: If you try to load any directory containing other types of files, the loading procedure will throw an error.
Paddles
Let's start with creating paddle entity. This object will represent players in this game.
paddle.nim
import nimgame2 / [ assets, graphic, input, nimgame, entity, ], data const Speed = 250.0 # Speed (in pixels per second) type PaddlePlacement* = enum ppLeft, ppRight PaddleControl* = enum pcPlayer1, pcPlayer2 Paddle* = ref object of Entity control*: PaddleControl
As you can see, two enums represent the paddle position and how it will be controlled.
paddle.nim
... proc init*(paddle: Paddle, placement: PaddlePlacement, control: PaddleControl) = paddle.initEntity() paddle.graphic = gfxData["paddle"] paddle.centrify() # Set the center point offset # Set position paddle.pos = case placement: of ppLeft: (paddle.graphic.w.float, game.size.h / 2) of ppRight: (float(game.size.w - paddle.graphic.w), game.size.h / 2) paddle.control = control # Set control mode proc newPaddle*(placement: PaddlePlacement, control: PaddleControl): Paddle = new result result.init(placement, control)
As you see, for now we need to have only one param in our Paddle object — a control state, that we will need in the update() method.
paddle.nim
... method update*(paddle: Paddle, elapsed: float) = var movement = Speed * elapsed # Read input case paddle.control: # First player of pcPlayer1: if ScancodeQ.down: paddle.pos.y -= movement # Move up if ScancodeA.down: paddle.pos.y += movement # Move down # Second player of pcPlayer2: if ScancodeUp.down: paddle.pos.y -= movement # Move up if ScancodeDown.down: paddle.pos.y += movement # Move down # Check for the screen borders paddle.pos.y = clamp( paddle.pos.y, paddle.center.y, game.size.h.float - paddle.center.y)
We calculate movement distance by multiplying the speed and time elapsed since previous update. Finally, don't forget to check for the screen borders.
Now we just need to add two Paddle entities into the main scene.
main.nim
import nimgame2 / [ scene, ], data, paddle type MainScene = ref object of Scene leftPaddle, rightPaddle: Paddle proc init*(scene: MainScene) = init Scene(scene) # left paddle scene.leftPaddle = newPaddle(ppLeft, pcPlayer1) scene.add scene.leftPaddle # right paddle scene.rightPaddle = newPaddle(ppRight, pcPlayer2) scene.add scene.rightPaddle ...
Now the main scene should look like this:
Colliders
To detect collisions between the ball and paddles we need to init colliders for these entities. All collider counstructors accept a parent entity reference, center point offset, and collider dimensions that depend on a particular collider type. We will use a simple box collider for our paddles.
paddle.nim
... proc init*(paddle: Paddle, placement: PaddlePlacement, control: PaddleControl) = ... # Collisions paddle.tags.add "paddle" paddle.collider = paddle.newBoxCollider((0.0, 0.0), paddle.graphic.dim) paddle.collider.tags.add "ball" ...
The "paddle" and "ball" tags will be used to filter out the collisions.
Now we define a Ball entity the same way, but using the circle collider instead of a box one, passing the circle radius to its constructor.
ball.nim
import nimgame2 / [ assets, entity, graphic, nimgame, types, utils, ], data type Ball* = ref object of Entity radius: float proc reset*(ball: Ball) = ball.pos = game.size / 2 # place to the center of the screen proc init*(ball: Ball) = ball.initEntity() ball.graphic = gfxData["ball"] ball.radius = ball.graphic.dim.w / 2 ball.centrify() # Set the center point offset ball.reset() # Collisions ball.tags.add "ball" ball.collider = ball.newCircleCollider((0.0, 0.0), ball.radius) ball.collider.tags.add "paddle" proc newBall*(): Ball = new result result.init()
To be sure that we have initialized colliders correctly we could use a colliderOutline boolean variable from the "nimgame2/settings" module.
main.nim
import nimgame2 / [ input, scene, settings, ], ball, ... type MainScene = ref object of Scene ... ball: Ball proc init*(scene: MainScene) = ... # ball scene.ball = newBall() scene.add scene.ball ... method show*(scene: MainScene) = ... scene.ball.reset() ... method update*(scene: MainScene, elapsed: float) = scene.updateScene(elapsed) if ScancodeF10.pressed: colliderOutline = not colliderOutline if ScancodeF11.pressed: showInfo = not showInfo
Now we could press F10 to toggle collider outlines. We also assigned the info panel toggle to F11 for good measure.
Bounce
Now, as we have a ball entity, bounce logic could be implemented. The ball should bounce from top and bottom walls, and also from both paddles. Paddle bounces will increase ball's speed each time.
ball.nim
... const Speed = 100.0 # Speed (in pixels per second) SpeedInc = 25.0 # Speed increase after each bounce ... proc reset*(ball: Ball) = ball.pos = game.size / 2 # place to the center of the screen ball.vel.x = Speed * randomSign().float ball.vel.y = Speed * randomSign().float ... method update*(ball: Ball, elapsed: float) = var movement = ball.vel * elapsed ball.pos += movement # Top and bottom walls collisions if ball.pos.y < ball.radius or ball.pos.y >= (game.size.h.float - ball.radius): ball.vel.y = -ball.vel.y method onCollide*(ball: Ball, target: Entity) = if "paddle" in target.tags: # Check if the ball is in front of a paddle if (ball.pos.y >= target.pos.y - target.center.y) and (ball.pos.y <= target.pos.y + target.center.y): ball.vel.x = -ball.vel.x # change horizontal direction # Move the ball out of the collision zone if ball.pos.x < game.size.w / 2: ball.pos.x = target.pos.x + target.center.x + ball.radius + 1 else: ball.pos.x = target.pos.x - target.center.x - ball.radius - 1 # increase speed ball.vel += (SpeedInc, SpeedInc) * ball.vel / abs(ball.vel)
Reset
Each time the ball leaves the screen it should be returned to the center and relaunched. It would be good to add a small pause before the launch too. This pause will be indicated by a white circle, shrinking as the pause runs out.
ball.nim
nimgame2 / [
...
draw,
...
],
...
const
...
Pause = 1.0 # Pause value before launch (in seconds)
type
Ball* = ref object of Entity
...
pause: float
proc reset*(ball: Ball) =
...
ball.pause = Pause
method render*(ball: Ball) =
ball.renderEntity()
if ball.pause > 0.0: # pre-launch pause
discard circle(ball.pos, ball.pause * 100, 0xFFFFFFFFF'u32, DrawMode.aa)
method update*(ball: Ball, elapsed: float) =
if ball.pause <= 0.0:
var movement = ball.vel * elapsed
ball.pos += movement
# Top and bottom walls collisions
if ball.pos.y < ball.radius or
ball.pos.y >= (game.size.h.float - ball.radius):
ball.vel.y = -ball.vel.y
else: # pre-launch pause
ball.pause -= elapsed
...
The actual checking of the ball position will be performed in the main scene file.
main.nim
... method update*(scene: MainScene, elapsed: float) = ... # check if the ball is out of the screen borders if (scene.ball.pos.x < 0) or (scene.ball.pos.x >= game.size.w.float) or (scene.ball.pos.y < 0) or (scene.ball.pos.y >= game.size.h.float): scene.ball.reset()
Score
At this moment we are ready to add a simple score counting into the game.
data.nim
... var ... score1*, score2*: int ...
main.nim
import nimgame2 / [ entity, graphic, ... textgraphic, ... ], ... MainScene = ref object of Scene ... scoreText: TextGraphic proc init*(scene: MainScene) = ... # score scene.scoreText = newTextGraphic(bigFont) scene.scoreText.setText "0:0" let score = newEntity() score.graphic = scene.scoreText score.centrify() score.pos = (game.size.w / 2, score.graphic.dim.h.float) score.layer = 10 # text should be over other entities scene.add score proc free*(scene: MainScene) = scene.scoreText.free() ... method show*(scene: MainScene) = ... score1 = 0 score2 = 0 scene.scoreText.setText "0:0" method update*(scene: MainScene, elapsed: float) = ... # check if the ball is out of the screen borders if (scene.ball.pos.x < 0) or (scene.ball.pos.x >= game.size.w.float) or (scene.ball.pos.y < 0) or (scene.ball.pos.y >= game.size.h.float): # increase score if scene.ball.pos.x < 0: inc score2 else: inc score1 # update score graphic scene.scoreText.setText $score1 & ":" & $score2 # reset scene.ball.reset()
Now your game screen should look like this:
Pause
Adding the option to pause the game is actually pretty easy.
main.nim
import nimgame2 / [ ..., types, ], ... type MainScene = ref object of Scene ... pause: Entity proc init*(scene: MainScene) = ... # pause let pauseText = newTextGraphic(bigFont) pauseText.setText "PAUSE" scene.pause = newEntity() scene.pause.graphic = pauseText scene.pause.centrify() scene.pause.pos = game.size / 2 scene.pause.visible = false scene.add scene.pause ... method event*(scene: MainScene, event: Event) = scene.eventScene(event) if event.kind == KeyDown: # Pause/Unpause if event.key.keysym.scancode == ScancodeP: gamePaused = not gamePaused scene.pause.visible = gamePaused ...
Now pressing P will toggle pause mode.
Sound
Finally, we will add sound effects. WAV files will be placed into "data/sfx" directory:
- bell.wav
- hit1.wav
- hit2.wav
- hit3.wav
data.nim
import nimgame2 / [ ... audio, ... ] ... var ... sfxData*: Assets[Sound] proc loadData*() = ... sfxData = newAssets[Sound]( "data/sfx", proc(file: string): Sound = newSound(file)) ... proc freeData*() = ... for sound in sfxData.values: sound.free()
ball.nim
import nimgame2 / [ ... audio, ... ], ... ... method update*(ball: Ball, elapsed: float) = ... if ball.pos.y < ball.radius or ball.pos.y >= (game.size.h.float - ball.radius): ... discard sfxData["hit3"].play() ... method onCollide*(ball: Ball, target: Entity) = ... if ball.pos.x < game.size.w / 2: ... discard sfxData["hit1"].play() else: ... discard sfxData["hit2"].play() ...
main.nim
import nimgame2 / [ ... audio, ... ], ... ... method show*(scene: MainScene) = ... discard sfxData["bell"].play() ... method update*(scene: MainScene, elapsed: float) = ... # reset scene.ball.reset() discard sfxData["bell"].play()
What's Next?
Though the game is ready, there's a multitude of ways to improve it (like changing the angle of the ball's trajectory, for example). As this isn't directly related to the engine functionality, it is left for you to explore on your own.