PLATFORMER GAME
Introduction
Tihs tutorial is designed to give a basic example of a simple platformer-type game structure. The following concepts will be explained here:
- buttons
- tilemaps
- tilemap colliders
- group colliders
- platformer physics
- physics and logic procedures
- scene camera
Assets
Apart from our usual need of fonts, graphic and sounds, we will need map data (in CSV) and GUI graphics. So here is our data directory structure:
./data/
|
|-- csv/
|-- fnt/
|-- gfx/
|-- gui/
|-- sfx/
For the text rendering we use Fixedsys Excelsior.
Game graphics consist of the tile sheet, player and enemy sprites.
To create menu buttons, we will need just one asset:
Sounds are located in the sfx directory and were created with the help of bfxr.net online tool.
Starting to Code
The first lines of code are actually pretty similar to the Tutorial 101 so we will not dive too deep here this time.
data.nim
import
nimgame2 / [
assets,
audio,
font,
mosaic,
scene,
texturegraphic,
truetypefont,
types,
]
const
GameWidth* = 640
GameHeight* = 360
GameTitle* = "Nimgame 2 Platformer"
var
titleScene*, mainScene*: Scene
defaultFont*, bigFont*: TrueTypeFont
gfxData*: Assets[TextureGraphic]
sfxData*: Assets[Sound]
buttonMosaic*: Mosaic
buttonSkin*: TextureGraphic
score*: int
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"
gfxData = newAssets[TextureGraphic](
"data/gfx",
proc(file: string): TextureGraphic = newTextureGraphic(file))
sfxData = newAssets[Sound](
"data/sfx",
proc(file: string): Sound = newSound(file))
buttonMosaic = newMosaic("data/gui/button.png", (8, 8))
buttonSkin = newTextureGraphic()
discard buttonSkin.assignTexture buttonMosaic.render(
patternStretchBorder(8, 2))
proc freeData*() =
defaultFont.free()
bigFont.free()
for graphic in gfxData.values:
graphic.free()
for sound in sfxData.values:
sound.free()
buttonSkin.free()
buttonMosaic.free()
title.nim
import
nimgame2 / [
assets,
entity,
font,
gui/button,
gui/widget,
mosaic,
nimgame,
scene,
settings,
textgraphic,
texturegraphic,
types,
],
data
type
TitleScene = ref object of Scene
proc init*(scene: TitleScene) =
init Scene(scene)
# Title text
let titleText = newTextGraphic bigFont
titleText.setText GameTitle
let title = newEntity()
title.graphic = titleText
title.centrify()
title.pos = (GameWidth / 2, GameHeight / 3)
scene.add title
proc free*(scene: TitleScene) =
discard
proc newTitleScene*(): TitleScene =
new result, free
init result
method event*(scene: TitleScene, event: Event) =
scene.eventScene event
if event.kind == KeyDown:
case event.key.keysym.sym:
of K_Space, K_Return:
game.scene = mainScene # quick start
else:
discard
main.nim
import
nimgame2 / [
assets,
entity,
font,
graphic,
gui/button,
gui/widget,
nimgame,
scene,
textgraphic,
types,
],
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 event*(scene: MainScene, event: Event) =
scene.eventScene event
if event.kind == KeyDown:
case event.key.keysym.sym:
of K_F10:
colliderOutline = not colliderOutline
of K_F11:
showInfo = not showInfo
else: discard
platformer.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) # Doulbe scaling (1280x720)
game.centrify() # Place window at the center of the screen
background = 0x151B8D'u32
loadData() # Call it before any scene initialization
# Create scenes
titleScene = newTitleScene()
mainScene = newMainScene()
# Run
game.scene = titleScene # Initial scene
run game # Let's go!
Level
Here we will create a wireframe for our level module.
level.nim
import
parseutils,
nimgame2 / [
assets,
entity,
texturegraphic,
tilemap,
utils,
],
data
const
TileDim* = (32, 32)
type
Level* = ref object of TileMap
proc init*(level: Level, tiles: TextureGraphic) =
init Tilemap level
level.tags.add("level")
level.graphic = tiles
level.initSprite(TileDim)
proc newLevel*(tiles: TextureGraphic): Level =
new result
result.init(tiles)
proc load*(level: Level, csv: string) =
level.map = loadCSV[int](
csv,
proc(input: string): int = discard parseInt(input, result))
level.hidden.add @[8, 9, 10, 11] # tiles on the third row are invisible markers
level.passable.add @[0, 2, 3, 4, 8, 9, 10, 11] # tiles without colliders
level.onlyReachableColliders = true # do not init unreachable colliders
level.initCollider()
As you see, we are passing CSV data into the two-dimensional map sequence. The third row of tiles should not be visible as it is used for spawn selectors and such. All tiles, that the player should not be able to walk on, are added to the passable sequence. To speed up phyiscs we enabled the onlyReachableColliders option prior to calling the initCollider procedure.
Now we could add the level into the main scene.
main.nim
import
...
data,
level
...
type
MainScene = ref object of Scene
level: Level
proc init*(scene: MainScene) =
...
# Level
scene.level = newLevel gfxData["tiles"]
scene.level.load "data/csv/map1.csv"
scene.level.layer = LevelLayer
scene.add scene.level
You should see the top-left corner of the map on the main screen at the moment.
Player
The next step is to create a player entity with a couple of animations and the ability to spawn itself at player spawn selector (tile index 8).
player.nim
import
nimgame2 / [
entity,
texturegraphic,
tilemap,
types,
],
data,
level
const
Framerate = 1/12
VisibilityDim: Dim = (w: 12, h: 10)
Spawn = 8 # player spawn selector tile index
PlayerRadius = 16
PlayerSize = PlayerRadius * 2
type
Player* = ref object of Entity
level*: Level
dying: bool
proc updateVisibility*(player: Player) =
# update the visible portion of the map
let center = player.level.tileIndex(player.pos)
player.level.show = (
x: (center.x - VisibilityDim.w)..(center.x + VisibilityDim.x),
y: (center.y - VisibilityDim.h)..(center.y + VisibilityDim.h))
proc resetPosition*(player: Player) =
# reset player position to a given tile
player.pos = player.level.tilePos player.level.firstTileIndex(Spawn)
proc init*(player: Player, graphic: TextureGraphic, level: Level) =
player.initEntity()
player.tags.add "player"
player.level = level
player.graphic = graphic
player.initSprite((PlayerSize, PlayerSize))
discard player.addAnimation("right", [0, 1, 2, 3], Framerate)
discard player.addAnimation("left", [0, 1, 2, 3], Framerate, Flip.horizontal)
discard player.addAnimation("death", [4, 5, 6, 7], Framerate)
proc newPlayer*(graphic: TextureGraphic, level: Level): Player =
new result
result.init(graphic, level)
method update*(player: Player, elapsed: float) =
player.updateEntity elapsed
player.updateVisibility()
Adding it to the scene is trivial.
main.nim
import
...
level,
player
const
LevelLayer = 0
PlayerLayer = 10
type
MainScene = ref object of Scene
level: Level
player: Player
proc init*(scene: MainScene) =
init Scene scene
# Camera
scene.camera = newEntity()
scene.camera.tags.add "camera"
scene.cameraBondOffset = game.size / 2 # set camera to the center
...
# Player
scene.player = newPlayer(gfxData["player"], scene.level)
scene.player.collisionEnvironment = @[Entity(scene.level)]
scene.player.layer = PlayerLayer
scene.player.resetPosition()
scene.add scene.player
scene.cameraBond = scene.player # bind camera to the player entity
scene.player.updateVisibility()
...
Player Movement
To make the player entity move, we will use the platformerPhysics procedure from the engine's entity module. As you could see from the previous section, we added the level as the only item in the player's collisionEnvironment sequence. This sequence is used by the platformerPhysics procedure to check if the player entity could move in any given direction.
player.nim
const
...
ColliderRadius = PlayerRadius - 1
GravAcc = 1000
Drag = 400
JumpVel = 450
WalkVel = 750
MaxVel = 350
...
proc init*(player: Player, graphic: TextureGraphic, level: Level) =
...
# collider
let c = newGroupCollider(player)
player.collider = c
# 1st collider
c.list.add newCircleCollider(
player,
(PlayerRadius, PlayerRadius),
ColliderRadius)
# 2nd collider
c.list.add newBoxCollider(
player,
(PlayerRadius, PlayerRadius + PlayerRadius div 2),
(PlayerSize - 2, ColliderRadius))
# physics
player.acc.y = GravAcc
player.drg.x = Drag
player.physics = platformerPhysics
...
proc jump*(player: Player) =
if player.vel.y == 0.0:
player.vel.y -= JumpVel
proc right*(player: Player, elapsed: float) =
if player.dying: return
player.vel.x += WalkVel * elapsed
if player.vel.x > MaxVel:
player.vel.x = MaxVel
if not player.sprite.playing and player.vel.y == 0.0:
player.play("right", 1)
proc left*(player: Player, elapsed: float) =
if player.dying: return
player.vel.x -= WalkVel * elapsed
if player.vel.x < -MaxVel:
player.vel.x = -MaxVel
if not player.sprite.playing and player.vel.y == 0.0:
player.play("left", 1)
...
Next, we bind movement action to keyboard controls.
main.nim
...
method update*(scene: MainScene, elapsed: float) =
scene.updateScene elapsed
if ScancodeSpace.pressed:
scene.player.jump()
if ScancodeRight.down:
scene.player.right(elapsed)
if ScancodeLeft.down:
scene.player.left(elapsed)
Dying and Spikes
You might have noticed that the spikes tile in our tile sheet is added to the passable sequence. The reason for this is that the platformerPhysics procedure prevents any collisions with the tile map before the standard collision callback could happen. That is why we should initialize any tiles, that you want player to interact with, as separate entities with their own colliders. So, for each "spikes" tile index in the map data we create a collidable entity (without any graphics) in its place.
main.nim
...
const
...
Spikes = 4 # Spikes tile index
...
proc init*(scene: MainScene) =
...
# Spikes
const
SpikesOrigin = TileDim / 2 + (0, TileDim[1] div 4)
SpikesDim = TileDim / (1, 2)
for tileCoord in scene.level.tileIndex(Spikes):
let e = newEntity()
e.tags.add "spikes"
e.pos = scene.level.tilePos(tileCoord)
e.collider = newBoxCollider(e, SpikesOrigin, SpikesDim)
e.collider.tags.add "player" # collide only with player entity
e.parent = scene.camera
scene.add e
...
Now we could use the standard collision callbacks in the player entity.
player.nim
...
proc die*(player: Player) =
if not player.dying:
player.dying = true
player.play("death", 3)
player.vel.y = -JumpVel
method update*(player: Player, elapsed: float) =
...
if player.dying:
if not player.sprite.playing:
# reset
player.play("right", 0)
player.resetPosition()
player.updateVisibility()
player.dying = false
else:
return
method onCollide*(player: Player, target: Entity) =
if "spikes" in target.tags:
player.die()
Boxes and Coins
As you could imagine, any other interactive tiles are created similarly. Compare the following code to the previous section.
player.nim
...
type
Player* = ref object of Entity
...
requestCoins*: seq[CoordInt]
...
proc init*(player: Player, graphic, TextureGraphic, level: Level) =
...
player.requestCoins = @[]
...
method onCollide*(player: Player, target: Entity) =
...
if "box" in target.tags:
let index = player.level.tileIndex(target.pos)
player.level.tile(index) += 1 # red box -> grey box
player.requestCoins.add index + (0, -1) # request coin spawn one tile higher
target.dead = true
if "coin" in target.tags:
inc score
target.dead = true
main.nim
...
const
...
Box = 6 # Box tile index
CoinA = 2 # Coin tile index (frame A)
CoinB = 3 # Coin tile index (frame B)
...
proc spawnCoin*(scene: MainScene, index: CoordInt) =
let e = newEntity()
e.tags.add "coin"
e.graphic = gfxData["tiles"]
e.initSprite(TileDim)
discard e.addAnimation("rotate", [2, 3], 1/8)
e.play("rotate", -1) # continuous animation
e.pos = scene.level.tilePos index
e.collider = newCircleCollider(e, TileDim / 2 - 1, TileDim[0] / 2 - 1)
e.collider.tags.add "player"
e.parent = scene.camera
scene.add e
proc init*(scene: MainScene) =
...
# Boxes
const
BoxPos1 = (2, TileDim[1] + 2)
BoxPos2 = (TileDim[0] - 2, TileDim[1] + 2)
for tileCoord in scene.level.tileIndex(Box):
let e = newEntity()
e.tags.add "box"
e.pos = scene.level.tilePos(tileCoord)
e.collider = newLineCollider(e, BoxPos1, BoxPos2)
e.collider.tags.add "player" # collide only with player entity
e.parent = scene.camera
scene.add e
# Coins
for value in [CoinA, CoinB]:
for tileCoord in scene.level.tileIndex(value):
scene.level.tile(tileCoord) = 0
scene.spawnCoin(tileCoord)
...
...
method update*(scene: MainScene, elapsed: float) =
...
# Spawn coins
while scene.player.requestCoins.len > 0:
scene.spawnCoin scene.player.requestCoins.pop()
Enemy
Our enemies should be able to move left and right (though not falling off the ledges) and kill the player entity on collision. The walking logic is implemented in the custom enemyLogic procedure.
We do not want physics and logic to be updated while the enemy is off the screen, so we do not call the updateEntity procedure this time, checking the enemy's position and calling procedures manually instead.
enemy.nim
import
nimgame2 / [
entity,
graphic,
texturegraphic,
tilemap,
types,
],
data,
level
const
GravAcc = 1000
WalkVel = 50
type
Enemy* = ref object of Entity
level*: Level
prevVel: float
proc right*(enemy: Enemy) =
enemy.vel.x = WalkVel
proc left*(enemy: Enemy) =
enemy.vel.x -= WalkVel
proc enemyLogic(enemy: Entity, elapsed: float) =
let enemy = Enemy enemy
# check for pits
if enemy.vel.x != 0.0:
let
pos = enemy.pos + enemy.graphic.dim * 0.5 *
(if enemy.vel.x < 0: -1.0 else: 1.0)
aheadIdx = enemy.level.tileIndex(pos) + (0, 1)
if enemy.level.tile(aheadIdx) in enemy.level.passable:
enemy.vel.x = 0.0
# change direction
if enemy.vel.x == 0.0:
if enemy.prevVel <= 0.0:
enemy.vel.x = WalkVel
enemy.prevVel = WalkVel
else:
enemy.vel.x = -WalkVel
enemy.prevVel = -WalkVel
proc init*(enemy: Enemy, graphic: TextureGraphic, level: Level) =
enemy.initEntity()
enemy.tags.add "enemy"
enemy.level = level
enemy.graphic = graphic
enemy.centrify(ver = VAlign.top)
enemy.logic = enemyLogic
# collider
let c = newPolyCollider(enemy, points = [(-15.0, 15.0), (0, 0), (16, 15)])
c.tags.add "level"
enemy.collider = c
# physics
enemy.acc.y = GravAcc
enemy.fastPhysics = true
enemy.physics = platformerPhysics
proc newEnemy*(graphic: TextureGraphic, level: Level): Enemy =
new result
result.init(graphic, level)
method update*(enemy: Enemy, elapsed: float) =
# updateEntity override
let index = enemy.level.tileIndex(enemy.pos)
# physics and logic only if visible
if index.x in enemy.level.show.x and index.y in enemy.level.show.y:
enemy.logic(enemy, elapsed)
enemy.physics(enemy, elapsed)
Enemy spawning is pretty similar to spikes and coins creation.
main.nim
import
...
enemy,
...
const
...
EnemySpawn = 9 # Enemy spawn tile index
proc init*(scene: MainScene) =
...
# Enemy
for tileCoord in scene.level.tileIndex(EnemySpawn):
let e = newEnemy(gfxData["enemy"], scene.level)
e.collisionEnvironment = @[Entity(scene.level)]
e.pos = scene.level.tilePos(tileCoord) + TileDim[1] / 2
e.parent = scene.camera
scene.add e
...
player.nim
...
method onCollide*(player: Player, target: Entity) =
if "spikes" in target.tags or "enemy" in target.tags:
player.die()
...
Victory
The victory is achieved when the player entity has collided with "finish" tile, or, more specifically, with an invisible collidable entity that was spawned in this tile.
Note, that we are fetching the index with the firstTileIndex procedure. It means that if there is more than one appearance of this tile index on a map, only one will be processed. If you want to have multiple finish points, change it to the tileIndex iterator.
main.nim
...
const
...
UILayer = 20
...
Finish = 11
type
MainScene = ref object of Scene
...
victory: Entity
...
proc init*(scene: MainScene) =
...
# Finish
let
finishIdx = scene.level.firstTileIndex(Finish)
f = newEntity()
f.tags.add "finish"
f.collider = newCircleCollider(f, TileDim / 2, TileDim[0] / 2 - 1)
f.pos = scene.level.tilePos(finishIdx)
f.parent = scene.camera
scene.add f
# Victory
let victoryText = newTextGraphic bigFont
victoryText.setText "VICTORY!"
scene.victory = newEntity()
scene.victory.graphic = victoryText
scene.victory.centrify(ver = VAlign.top)
scene.victory.visible = false
scene.victory.layer = UILayer
scene.victory.pos = (GameWidth / 2, 0.0)
scene.add scene.victory
...
...
method update*(scene: MainScene, elapsed: float) =
...
# Check for victory
if scene.player.won:
scene.victory.visible = true
For simplicity, the victory is represented with a simple text entity. You could try to implement a separate victory scene on your own for practice.
player.nim
...
type
Player* = ref object of Entity
...
won*: bool
...
...
method onCollide*(player: Player, target: Entity) =
...
if "finish" in target.tags:
player.won = true
Score
Finally, we add the score indicator, which is a simple text graphic updated with the data from the score variable. Note that we do not select scene.camera as its parent, so this entity is located constantly at the same position on the game screen.
main.nim
...
type
MainScene = ref object of Scene
...
score: TextGraphic
...
...
proc init*(scene: MainScene) =
...
# Score
let score = newEntity()
scene.score = newTextGraphic defaultFont
scene.score.setText "SCORE: 0"
score.graphic = scene.score
score.layer = UILayer
score.pos = (12, 8)
scene.add score
...
...
method update*(scene: MainScene, elapsed: float) =
...
# Update score
scene.score.setText "SCORE: " & $score
...
Sounds
You could set the global sound volume in the initialization routine. It could also be changed in runtime, if you plan to add game settings controls later.
platformer.nim
import
nimgame2 / [
audio,
...
...
if game.init(GameWidth, GameHeight, title = GameTitle, integerScale = true):
# Init
...
setSoundVolume Volume.high div 2 # set sound volume to a 50%
...
As we loaded our sounds into the sfxData assets collection, playing them at the right moment is trivial.
player.nim
import
nimgame2 / [
assets,
audio,
...
...
proc jump*(player: Player) =
...
if player.vel.y == 0.0:
...
discard sfxData["jump"].play()
...
proc die*(player: Player) =
...
discard sfxData["death"].play()
...
method onCollide*(player: Player, target: Entity) =
...
if "box" in target.tags:
...
discard sfxData["box"].play()
if "coin" in target.tags:
...
discard sfxData["pickup"].play()
if "finish" in target.tags:
if not player.won:
discard sfxData["victory"].play()
player.won = true
Reset
Finally, we will add the option to return to the title screen, resetting the level.
main.nim
... proc init*(scene: MainScene) = ... if scene.level.map.len == 0: scene.level.load "data/csv/map1.csv" # changed from: scene.level.load "data/csv/map1.csv" ... proc newMainScene*(): MainScene = new result, free # removed: init result method show*(scene: MainScene) = hideCursor() init scene ...
As you can see, we moved the init call from the constructor to the show method, so the scene will be rebuilt each time it is switched to. Nim's GC will take care of previously allocated resources.
What's Next?
To practice your skills and improve the game, you could try adding the game settings screen, victory animation, leaderboard, different types of hazards and enemies, multiple levels, etc.