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.