SDL2を使い、Nimで2Dのプラットフォーム・ゲームを書く

この記事ではシンプルな2Dのプラットフォーム・ゲームを書きます。SDLを使ったNimによるゲーム開発のチュートリアルとしてもよいでしょう。

ここでは、ユーザ入力を読み込み、グラフィックスとタイルマップを表示させ、衝突判定と処理を伴うシンプルな2Dの物理をシミュレーションします。その後、シンプルなカメラの動作とゲームロジックを実装します。また、適宜情報を表示するため、テキストをレンダリングし、そのためのキャッシュの仕組みを構築していきます。

最終的な成果物は、SDL2のみで動いて簡単に配信できる、ゲームに最適なバイナリファイルです。Linux環境の場合、NimをWindows用にクロスコンパイルする簡単な方法も提示しますので参照してください。

簡潔に説明するため、DDNetTeeworldsのなじみ深いグラフィックスを使います。このチュートリアルの成果は下の動画のようになります。

この記事では、画像とビデオによる解説で開発手順を追っていきますが、最善の学習方法は、この記事を見ながらご自身で各ステップを実装してみることです。コードは、各自でいじったりあらゆる変更を試したりすることによって直感的に理解できるよう、意図的にシンプルかつ簡易にしてあります。フルソースコードのリンクは、各セクションの末尾に用意しました。

ここで紹介するコードの各イテレーションと最終的な成果物はGitHubのレポジトリで入手可能です。出来上がったバイナリは以下からダウンロードできます。Win64Win32Linux x86_64Linux x86

準備

このプロジェクトに必要なものは下記のとおりです。

LinuxやMac OS X等のUnix系のシステムの場合は、インストールは下記のようになります。

# Debian / Ubuntu
$ sudo apt-get install git libsdl2-dev libsdl2-image-dev libsdl2-ttf-dev
# Arch Linux
$ pacman -S git sdl2 sdl2_image sdl2_ttf
# Homebrew on OS X
$ brew install git sdl2 sdl2_image sdl2_ttf
# FreeBSD
$ pkg install git sdl2 sdl2_image sdl2_ttf

$ wget http://nim-lang.org/download/nim-0.14.2.tar.xz
$ tar xvf nim-0.14.2.tar.xz
$ cd nim-0.14.2
$ make -j4
$ echo 'export PATH=$HOME/nim-0.14.2/bin:$PATH' >> ~/.profile
$ source ~/.profile

$ git clone https://github.com/nim-lang/nimble.git
$ cd nimble
$ nim -d:release c -r src/nimble install
$ echo 'export PATH=$HOME/.nimble/bin:$PATH' >> ~/.profile
$ source ~/.profile

$ nimble install sdl2 strfmt

C言語コンパイラも必要になりますので注意してください。望ましいのはGCCやClangです。

ソースコードからNimとNimbleをコンパイルする代わりに、パッケージマネージャを使って最新バージョンのNimとNimbleをインストールしてもよいでしょう。

SDL2を他のプラットフォームで設定したい人には、より幅広いガイドがあります。Nimnimbleの設定に関しても同様です。

1. 最初の動くプログラム

C言語やC++で書かれたSDL2を知っているなら、これからNimで行うことはそれに非常に似ていると気付くでしょう。実際、NimのSDL2ラッパーは、C言語による本来のSDL2インターフェースを包括する薄いレイヤーに過ぎません。SDL2のチュートリアルで学んだことを何でも適用できる利点がありますが、ハイレベルのNimライブラリを使った場合と比べると若干ボイラープレート的になってしまうのが欠点です。

まさにこのボイラープレートで画面を初期化するところから始めましょう。

import sdl2

type SDLException = object of Exception

template sdlFailIf(cond: typed, reason: string) =
  if cond: raise SDLException.newException(
    reason & ", SDL error: " & $getError())

proc main =
  sdlFailIf(not sdl2.init(INIT_VIDEO or INIT_TIMER or INIT_EVENTS)):
    "SDL2 initialization failed"

  # defer blocks get called at the end of the procedure, even if an
  # exception has been thrown
  defer: sdl2.quit()

  sdlFailIf(not setHint("SDL_RENDER_SCALE_QUALITY", "2")):
    "Linear texture filtering could not be enabled"

  let window = createWindow(title = "Our own 2D platformer",
    x = SDL_WINDOWPOS_CENTERED, y = SDL_WINDOWPOS_CENTERED,
    w = 1280, h = 720, flags = SDL_WINDOW_SHOWN)
  sdlFailIf window.isNil: "Window could not be created"
  defer: window.destroy()

  let renderer = window.createRenderer(index = -1,
    flags = Renderer_Accelerated or Renderer_PresentVsync)
  sdlFailIf renderer.isNil: "Renderer could not be created"
  defer: renderer.destroy()

  # Set the default color to use for drawing
  renderer.setDrawColor(r = 110, g = 132, b = 174)

  # Game loop, draws each frame
  while true:
    # Draw over all drawings of the last frame with the default
    # color
    renderer.clear()
    # Show the result on screen
    renderer.present()

main()

条件を確認するsdlFailIfテンプレートを導入し、条件が真ならSDLの追加エラー情報と共にSDLExceptionを発生させるようにします。mainprocで、SDL2を初期化し、通常の画面と加速した2Dレンダラを作成します。エラー処理は先ほど導入したsdlFailIfprocで行います。

いまのところ、ゲームのループは画面の消去と描画を毎フレーム行っているだけです。VSyncが有効で、スクリーンの設定が60 Hzの場合、そのループは1秒に60回実行されます。

nim -r c platformerを実行すれば、同様のステップでコンパイルと実行ができます。ファイル名はplatformer.nimになるでしょうか。最適化しながらコンパイルする場合は、nim -d:release -r c platformerを使います。結果はシンプルな単色の画面です。

Just a window with blue content
この小さなプログラムを終了するには、ターミナル画面の中でCtrl-Cを入力してください。残念ながら、まだゲームの画面そのものから終了することはできないので、その修正を行いましょう。

セクション1のフルコード

2. ユーザ入力

初めに、Input型を追加し、サポートしたいすべての入力と、ゲームの状態オブジェクトにおける入力の配列を格納できるようにしましょう。

type
  Input {.pure.} = enum none, left, right, jump, restart, quit

  Game = ref object
    inputs: array[Input, bool]
    renderer: RendererPtr

Game状態の型にref型を選ぶことで、誤ってそのコピーが作られてしまうのを簡単に防げます。初期設定では、Gameオブジェクトに対してスマートポインタだけが渡されます。inputsフィールドはInputからboolにマッピングされる配列で、現在どの入力が押されているか(true)/押されていないか(false)を示します。新規ゲーム状態オブジェクトの作成は大したことではないので、今は新規ヒープオブジェクトを作成し、あとで必要になるSDL2 rendererを割り当てます。

proc newGame(renderer: RendererPtr): Game =
  new result
  result.renderer = renderer

inputsを何らかの方法で初期化する必要はありません。全てデフォルトでバイナリnullに初期化されているため、「スタート時には全てオフになっている」という要求事項に完全に合致するからです。rendererフィールドを初期化しなかった場合、それはnullポインタになり、誤ってそこを参照してしまうと問題が起こります。

次に必要なのは、キーボードのスキャンコードを解釈可能な入力にマッピングする関数です。

proc toInput(key: Scancode): Input =
  case key
  of SDL_SCANCODE_A: Input.left
  of SDL_SCANCODE_D: Input.right
  of SDL_SCANCODE_SPACE: Input.jump
  of SDL_SCANCODE_R: Input.restart
  of SDL_SCANCODE_Q: Input.quit
  else: Input.none

toInputは、未定義のケースに対しては常にInput.noneを返すことに注意してください。この挙動を使って、コード内に分岐を設けることなく、未使用のキーボード入力を無視します。1つの入力にマッピングするための複数のスキャンコードを知るのは難しいことではありません。

次に、ゲームループを修正し、新しいhandleInputprocを呼び出してキーボード入力に反応するようにします。また、mainprocの中で関心の分離をせずにすむよう、レンダリングそのものを分割しました。

proc handleInput(game: Game) =
  var event = defaultEvent
  while pollEvent(event):
    case event.kind
    of QuitEvent:
      game.inputs[Input.quit] = true
    of KeyDown:
      game.inputs[event.key.keysym.scancode.toInput] = true
    of KeyUp:
      game.inputs[event.key.keysym.scancode.toInput] = false
    else:
      discard

proc render(game: Game) =
  # Draw over all drawings of the last frame with the default color
  game.renderer.clear()
  # Show the result on screen
  game.renderer.present()

var game = newGame(renderer)

# Game loop, draws each frame
while not game.inputs[Input.quit]:
  game.handleInput()
  game.render()

これで、qの入力、あるいは画面の閉じるボタンでゲームを終了できるようになりました。今は、その他のユーザ入力はinputs配列に格納しておき、あとで使いましょう。

できる限り効率よくアクセスできるよう、inputs配列はわざと簡潔な配列にしています。ハッシュテーブルやその他のデータ構造を使うとなると、簡単にそのような約束ができません。多くの場合、シンプルさは有利であり、開発中のシステムにおいて何が起こっているのかを理解しやすいものです。

セクション2のフルコード

3. グラフィックスの表示

これらのユーザ入力が何かの役に立つようにしたければ、青空以外のものを表示しなければなりません。

プレイヤーのテクスチャと現在の位置・速度も格納するようにゲーム状態オブジェクトを拡張しましょう。ここではbasic2dモジュールを使います。

import basic2d

type
  Player = ref object
    texture: TexturePtr
    pos: Point2d
    vel: Vector2d

  Game = ref object
    inputs: array[Input, bool]
    renderer: RendererPtr
    player: Player
    camera: Vector2d

プレイヤー用に、Teeworldの初期設定のキャラクター、teeのグラフィックを使います。

PlayerPlayer
このファイルをplayer.pngとして保存すると便利です。しっくりこないようであれば、DDNet Skin Databaseの数百のスキンの中から選んでもよいでしょう。例えば次のようなグラフィックです。

Apish Cokeaquacutiedragonhammie-chewpenguintankturtle_rbombcoalaTutawek1zzzrobin_hoodred_birddinogodlikelightbulbmariomikemouseRobot

別のイメージを使いたい場合は、忘れずにプレイヤーグラフィックの名称をplayer.pngにしてください。

まず使うと決めたプレイヤーグラフィックをnewGameの定義で読み込み、GamePlayerのデータ構造体を初期化する必要があります。次のとおりです。

import sdl2.image

proc restartPlayer(player: Player) =
  player.pos = point2d(170, 500)
  player.vel = vector2d(0, 0)
proc newPlayer(texture: TexturePtr): Player =
  new result
  result.texture = texture
  result.restartPlayer()

proc newGame(renderer: RendererPtr): Game =

  new result

  result.renderer = renderer

  result.player = newPlayer(renderer.loadTexture("player.png"))

restartPlayerを使って、プレイヤーをスタート地点にリセットします。loadTexture関数で、PNGイメージをSDL2テクスチャとしてメモリに読み込み、Gameオブジェクトに格納できるようにします。

さらに、SDL2の初期化と同じように、mainprocの中で、SDL2イメージモジュールも忘れずに初期化しましょう。

const imgFlags: cint = IMG_INIT_PNG

sdlFailIf(image.init(imgFlags) != imgFlags):

  "SDL2 Image initialization failed"

defer: image.quit()

PNGファイルだけサポートできればよいのですが、const imgFlags: cint = IMG_INIT_PNG or IMG_INIT_JPGを追記して、JPEGファイルも加えてもよいかもしれません。

次のタスクはこれを適切に配置することです。もちろん、柔軟なプレイヤーイメージを使う意図は、ボディを部分ごとに個別に動かすことにあるのですが、簡潔にするため、固定位置に置きましょう。簡単な追加で、プレイヤーの水平位置によって足を回転させることができるようになります。もう1つの追加で、目がマウスカーソルを追うようにもできます。

proc renderTee(renderer: RendererPtr, texture: TexturePtr,
               pos: Point2d) =
  let
    x = pos.x.cint
    y = pos.y.cint

  var bodyParts: array[8, tuple[source, dest: Rect, flip: cint]] = [
    (rect(192,  64, 64, 32), rect(x-60,    y, 96, 48),
     SDL_FLIP_NONE),      # back feet shadow
    (rect( 96,   0, 96, 96), rect(x-48, y-48, 96, 96),
     SDL_FLIP_NONE),      # body shadow
    (rect(192,  64, 64, 32), rect(x-36,    y, 96, 48),
     SDL_FLIP_NONE),      # front feet shadow
    (rect(192,  32, 64, 32), rect(x-60,    y, 96, 48),
     SDL_FLIP_NONE),      # back feet
    (rect(  0,   0, 96, 96), rect(x-48, y-48, 96, 96),
     SDL_FLIP_NONE),      # body
    (rect(192,  32, 64, 32), rect(x-36,    y, 96, 48),
     SDL_FLIP_NONE),      # front feet
    (rect( 64,  96, 32, 32), rect(x-18, y-21, 36, 36),
     SDL_FLIP_NONE),      # left eye
    (rect( 64,  96, 32, 32), rect( x-6, y-21, 36, 36),
     SDL_FLIP_HORIZONTAL) # right eye
  ]

  for part in bodyParts.mitems:
    renderer.copyEx(texture, part.source, part.dest, angle = 0.0,
                    center = nil, flip = part.flip)

数字がいくつかというのはさほど重要ではなく、ただプレイヤーがどう配置されるべきかを表しています。renderTeeによって、どのボディパーツをどの場所にどの順序で描くかを定義します。最後にこれらの各ボディパーツはcopyExを使ったSDL2レンダラで描画されます。

ここで、ゲームループにteeキャラクターを描くには、renderTeeを呼び出すだけです。

proc render(game: Game) =
  # Draw over all drawings of the last frame with the default color
  game.renderer.clear()
  # Actual drawing here
  game.renderer.renderTee(game.player.texture,
    game.player.pos - game.camera)
  # Show the result on screen
  game.renderer.present()

ついにまた、ビジュアルが進展を見せました。プレイヤーが空に浮いたのを見てください。

Tee on blue background
セクション3のフルコード

4. タイルマップ

これで、プレイヤー用のレンダリングシステムが動くようになったので、プレイの場、マップが必要です。タイルのリストだけでなくテクスチャも格納しなければなりません。

type
  Map = ref object
    texture: TexturePtr
    width, height: int
    tiles: seq[uint8]

  Game = ref object
    inputs: array[Input, bool]
    renderer: RendererPtr
    player: Player
    map: Map
    camera: Vector2d

それぞれのタイルはuint8になるよう定義されます。これは包括的に0から255の間の値を表すということです。都合の良いことに、Teeworldsタイルセットのグラフィックは16 × 16 = 256タイルです。芝のタイルセットを使ってみましょう。

Grass Tileset
ダウンロードし、このイメージをgrass.pngとして保存します。

Mapデータ構造体を初期化するため、以下のフォーマットのマップを解析するちょっとしたパーサを書いてみます。

 0  0  0  0 78  0  0  0  0  0  0  0  0  0  0
 4  5  0  0 78  0  0  0  0  0  0  0  0  0  0
20 21  0  0 78  0  0  0  0  0  0  0  0  0  0
20 21  0  0 78  0  0  0  0  0  0  0  0  4  5
20 21  0  0 78  0  0  0  0  0  0  0  0 20 21
20 21  0  0 78  0  0  0  0  0  0  0  0 20 21
20 21  0  0 78  0  0  4  5  0  0  0  0 20 21
20 21  0  0 78  0  0 20 21  0  0  0  0 20 21
20 38  0  0 78  0  0 22 38  0  0  0  0 22 38
20 49 16 16 16 16 16 48 49 16 16 16 16 48 49
36 52 52 52 52 52 52 52 52 52 52 52 52 52 52

このセクションの目標は、芝のタイルセットのマップで、次のようなレンダリング結果を得ることです。

Map rendered with grass tileset
各数字は芝のタイルセットから選んだタイルを表します。今後この記事内では、このdefault.mapを使います。私たちのパーサは、次のようにnewMapに実装されます。

import strutils

proc newMap(texture: TexturePtr, file: string): Map =
  new result
  result.texture = texture
  result.tiles = @[]

  for line in file.lines:
    var width = 0
    for word in line.split(' '):
      if word == "": continue
      let value = parseUInt(word)
      if value > uint(uint8.high):
        raise ValueError.newException(
          "Invalid value " & word & " in map " & file)
      result.tiles.add value.uint8
      inc width

    if result.width > 0 and result.width != width:
      raise ValueError.newException(
        "Incompatible line length in map " & file)
    result.width = width
    inc result.height

ファイルを句と行の両方で分割します。各数字を符号なしの整数にパースし、正しく0..255の範囲内にあるかをチェックします。最終的な幅と高さは、行の長さと行数から計算されます。マップデータにエラーがあると、例外が投げられ、ゲームが終了してしまいます。

ここで、newGameを拡張し、マップを初期化しなければなりません。

proc newGame(renderer: RendererPtr): Game =
  new result
  result.renderer = renderer
  result.player = newPlayer(renderer.loadTexture("player.png"))
  result.map = newMap(renderer.loadTexture("grass.png"),
    "default.map")

この時点でテクスチャとマップのタイルが用意できています。やり残したのは、このすべてをレンダリングすることだけです。

const
  tilesPerRow = 16
  tileSize: Point = (64.cint, 64.cint)

proc renderMap(renderer: RendererPtr, map: Map, camera: Vector2d) =
  var
    clip = rect(0, 0, tileSize.x, tileSize.y)
    dest = rect(0, 0, tileSize.x, tileSize.y)

  for i, tileNr in map.tiles:
    if tileNr == 0: continue

    clip.x = cint(tileNr mod tilesPerRow) * tileSize.x
    clip.y = cint(tileNr div tilesPerRow) * tileSize.y
    dest.x = cint(i mod map.width) * tileSize.x - camera.x.cint
    dest.y = cint(i div map.width) * tileSize.y - camera.y.cint

    renderer.copy(map.texture, unsafeAddr clip, unsafeAddr dest)

これはrenderTeeに似ていますが、固定サイズのテクスチャのパーツを使います。テクスチャは64 × 64ピクセルサイズに切り分けられ、1行に16タイルあります。マップ内で各タイルを反復し、マップテクスチャからタイルtileNrをレンダリングしていきます。タイル0はエアタイルであり常に空なので、レンダリングする必要はありません。典型的なマップは大部分が空なので、これによりパフォーマンスが向上します。

最後に、プレイヤーをレンダリングした後は、マップがプレイヤーの上に配置されるよう、mainprocの中でレンダリングしなければなりません。

proc render(game: Game) =
  # Draw over all drawings of the last frame with the default color
  game.renderer.clear()
  # Actual drawing here
  game.renderer.renderTee(game.player.texture,
    game.player.pos - game.camera)
  game.renderer.renderMap(game.map, game.camera)
  # Show the result on screen
  game.renderer.present()

今のところ動かせるカメラがありませんので、静的なgame.cameraで、固定のレンダリング位置を取得します。しかしまだ動くことさえできませんから、あとでユーザ入力に関連づけたシンプルな物理モデルを実装することにします。

このセクションの成果は美しいマップのレンダリングです。

Renderer Map
セクション4のフルコード

5. 物理と衝突

このゲームの物理は、1秒50ティックに決めました。新規のティックが届いた時には、次のゲームのイテレーションだけを計算し、ゲームがが60 fpsで動こうが240 fpsで動こうが関係ないようにします。それでは、mainprocにティックを追加しましょう。

import times

proc physics(game: Game) =
  discard

var
  startTime = epochTime()
  lastTick = 0

while not game.inputs[Input.quit]:
  game.handleInput()

  let newTick = int((epochTime() - startTime) * 50)
  for tick in lastTick+1 .. newTick:
    game.physics()
  lastTick = newTick

  renderer.render(game)

これがこのゲームの物理フレームワークですが、まだ一切動きません。重力を追加するところから始めます。

proc physics(game: Game) =
  game.player.vel.y += 0.75
  game.player.pos += game.player.vel


プレイヤーが動きましたね。この素晴らしいアニメーションを、rを押すだけで繰り返し見ることができるよう、ここでプレイヤーを再起動できたほうがよさそうです。

proc physics(game: Game) =
  if game.inputs[Input.restart]:
    game.player.restartPlayer()

  game.player.vel.y += 0.75
  game.player.pos += game.player.vel

まるで繰り返し再生する先のGIFそのものです。想像がつくかもしれませんし、playを何度かクリックしてみてもいいでしょう。

adで左右に動かし、spaceでジャンプするように実装するのは訳もないことでしょう。

proc physics(game: Game) =
  if game.inputs[Input.restart]:
    game.player.restartPlayer()

  if game.inputs[Input.jump]:
    game.player.vel.y = -21

  let direction = float(game.inputs[Input.right].int -
                        game.inputs[Input.left].int)

  game.player.vel.y += 0.75
  game.player.vel.x = clamp(
    0.5 * game.player.vel.x + 4.0 * direction, -8, 8)

  game.player.pos += game.player.vel

固有の値は試行錯誤で見つけるしかありません。他に優先したいことがあれば、いろいろな変更も可能です。ただ、プレイヤー位置は直接設定せず、速度ベクターを調整して位置に加算するようにしてください。これは衝突判定の際に重要です。

壁と地面にエフェクトがあるかのように動かしてみましたが、読者は気付いてしまったかもしれません。実は衝突判定と処理の準備がまだ何もできていないことに。このコードの大部分はTeeworldsから適用しています。渡した速度ベクターに基づきプレイヤーの位置を計算するmoveBox内で、タイルとの水平/垂直の衝突をチェックすることにより動作します。

衝突が起こると、プレイヤーはタイルから外れるよう正しい方向に動きます。isSolidをシンプルに保つため、air, start, finish以外のあらゆるタイルは硬い(=Solid)ブロックとみなします。浮動小数点のプレイヤー位置はタイルマップの中のインデックスに変換されるのです。

import math

type
  Collision {.pure.} = enum x, y, corner

const
  playerSize = vector2d(64, 64)

  air = 0
  start = 78
  finish = 110

getTileはマップ内の指定された位置からタイルを読み込み、オーバーフローやアンダーフローが起こらないようにします。

proc getTile(map: Map, x, y: int): uint8 =
  let
    nx = clamp(x div tileSize.x, 0, map.width - 1)
    ny = clamp(y div tileSize.y, 0, map.height - 1)
    pos = ny * map.width + nx

  map.tiles[pos]

isSolidはプレイヤーがタイルと衝突できるかどうかを決めます。先述したように、air、start、finish以外のすべてのタイルは衝突が可能です。

proc isSolid(map: Map, x, y: int): bool =
  map.getTile(x, y) notin {air, start, finish}

proc isSolid(map: Map, point: Point2d): bool =
  map.isSolid(point.x.round.int, point.y.round.int)

どちらかの足の下にブロックがある時、プレイヤーは地上にいます。

proc onGround(map: Map, pos: Point2d, size: Vector2d): bool =
  let size = size * 0.5
  result =
    map.checkPoint(point2d(pos.x - size.x, pos.y + size.y + 1)) or
    map.checkPoint(point2d(pos.x + size.x, pos.y + size.y + 1))

一方、testBoxはプレイヤーを座標軸に平行な境界ボックスとみなし、プレイヤーが硬い壁の中に挟まっているかどうかを示します。

proc testBox(map: Map, pos: Point2d, size: Vector2d): bool =
  let size = size * 0.5
  result =
    map.checkPoint(point2d(pos.x - size.x, pos.y - size.y)) or
    map.checkPoint(point2d(pos.x + size.x, pos.y - size.y)) or
    map.checkPoint(point2d(pos.x - size.x, pos.y + size.y)) or
    map.checkPoint(point2d(pos.x + size.x, pos.y + size.y))

そしてmoveBoxは、速度ベクターvelをプレイヤー位置posに適用しようとします。これで衝突が起こる場合、コードはプレイヤーをx軸上のみ、そのあとでy軸上のみで動かし、タイルのどの側面にプレイヤーが衝突したかを探っていきます。プレイヤーがどの側面にも衝突していなかった場合はコーナーにぶつかります(これが本当のコーナーケースです)。

proc moveBox(map: Map, pos: var Point2d, vel: var Vector2d,
             size: Vector2d): set[Collision] {.discardable.} =
  let distance = vel.len
  let maximum = distance.int

  if distance < 0:
    return

  let fraction = 1.0 / float(maximum + 1)

  for i in 0 .. maximum:
    var newPos = pos + vel * fraction

    if map.testBox(newPos, size):
      var hit = false

      if map.testBox(point2d(pos.x, newPos.y), size):
        result.incl Collision.y
        newPos.y = pos.y
        vel.y = 0
        hit = true

      if map.testBox(point2d(newPos.x, pos.y), size):
        result.incl Collision.x
        newPos.x = pos.x
        vel.x = 0
        hit = true

      if not hit:
        result.incl Collision.corner
        newPos = pos
        vel = vector2d(0, 0)

    pos = newPos

moveBoxprocはさらに衝突のセットを返し、このイテレーションにおいてどのような種類の衝突が起こったのかを伝えてくれます。その情報は使いませんが、ただプレイヤーを衝突した壁から押し出すのでなく、特別な方法で衝突を処理したい場合には役立つかもしれません。

やっとonGroundmoveBoxが使えるようになりました。

proc physics(game: Game) =
  if game.inputs[Input.restart]:
    game.player.restartPlayer()

  let ground = game.map.onGround(game.player.pos, playerSize)

  if game.inputs[Input.jump]:
    if ground:
      game.player.vel.y = -21

  let direction = float(game.inputs[Input.right].int -
                        game.inputs[Input.left].int)

  game.player.vel.y += 0.75
  if ground:
    game.player.vel.x = 0.5 * game.player.vel.x + 4.0 * direction
  else:
    game.player.vel.x = 0.95 * game.player.vel.x + 2.0 * direction
  game.player.vel.x = clamp(game.player.vel.x, -8, 8)

  game.map.moveBox(game.player.pos, game.player.vel, playerSize)

ジャンプは地上に立っている時のみ可能です。空中における水平の動作は地上とは別の方法で計算し、空中における摩擦と地上の摩擦を異なるものとしてシミュレーションします。


セクション5のフルコード

6. カメラ

キャラクターが画面右へ消えていったのが分かりましたか? イケてるでしょ? でも、このままでは残りのコースをプレイするのは難しすぎますよね。キャラクターはうまく動くようになりましたが、カメラが静止したままです。このセクションでは、カメラを水平方向に動かしていきましょう。この説明をすれば、垂直方向への動かし方もすぐに分かるはずです。カメラ位置はプレイヤーが動いている時だけ調整する必要があります。つまり、game.physics()の呼び出し後です。

const
  windowSize: Point = (1280.cint, 720.cint)

proc moveCamera(game: Game) =
  const halfWin = float(windowSize.x div 2)
  game.camera.x = game.player.pos.x - halfWin

for tick in lastTick+1 .. newTick:
  game.physics()
  game.moveCamera()


これでカメラは常に、プレイヤーの動きに合わせて水平方向に移動するようになりました。他にも、プレイヤーがスクリーンの中央から外れた時のみカメラを追わせるという方法もあります。今回の場合は200ピクセルです。

proc moveCamera(game: Game) =
  const halfWin = float(windowSize.x div 2)
  let
    leftArea  = game.player.pos.x - halfWin - 100
    rightArea = game.player.pos.x - halfWin + 100
  game.camera.x = clamp(game.camera.x, leftArea, rightArea)


もう1つ、カメラを滑らかに動かす方法もあります。プレイヤーが中心エリアから遠くへ離れた時ほど、カメラを素早く動かすのです。プレイヤーにくっついている輪ゴムでカメラが引っ張られていると考えてみてください。プレイヤーとカメラの距離が離れれば離れるほど、カメラはプレイヤーに強く引っ張られますよね?

proc moveCamera(game: Game) =
  const halfWin = float(windowSize.x div 2)
  let dist = game.camera.x - game.player.pos.x + halfWin
  game.camera.x -= 0.05 * dist


どれを採用するか決め難いので、とりあえず3つの方法とも実装します。コンパイルする時に-d:fluidCamera-d:innerCameraを使って選択をしてください。

proc moveCamera(game: Game) =
  const halfWin = float(windowSize.x div 2)
  when defined(fluidCamera):
    let dist = game.camera.x - game.player.pos.x + halfWin
    game.camera.x -= 0.05 * dist
  elif defined(innerCamera):
    let
      leftArea  = game.player.pos.x - halfWin - 100
      rightArea = game.player.pos.x - halfWin + 100
    game.camera.x = clamp(game.camera.x, leftArea, rightArea)
  else:
    game.camera.x = game.player.pos.x - halfWin

セクション#6のフルコード

7.ゲームステータス

これで、どこの場所に行ってもカメラがきちんとついて来てくれるようになりました。では、ゲームの目標を作りましょう。コースの始まりと終わりに、濃いグレーの明かりのような縦の線があることに気付いたでしょうか? それぞれstartfinishとして言及されているのではと予想していた人もいたんじゃないでしょうか?  もうお分かりですね。これらはスタートラインとゴールラインです。スタートしてからゴールにたどり着くまでのプレイヤーのタイムを記録する役割もあります。

type
  Time = ref object
    begin, finish, best: int

  Player = ref object
    texture: TexturePtr
    pos: Point2d
    vel: Vector2d
    time: Time

proc restartPlayer(player: Player) =
  player.pos = point2d(170, 500)
  player.vel = vector2d(0, 0)
  player.time.begin = -1
  player.time.finish = -1

proc newTime: Time =
  new result
  result.finish = -1
  result.best = -1

proc newPlayer(texture: TexturePtr): Player =
  new result
  result.texture = texture
  result.time = newTime()
  result.restartPlayer()

これで、PlayerTimeオブジェクトが格納されている状態です。プレイヤーが今回スタートを切ったタイムと、前回のタイム、プレイヤーのベストタイム、という3つの情報を教えてくれます。デフォルトでは、これらの値は、無効値を示す-1に初期化されていて、それ以外ではティックを格納します。

ディスプレイに表示する時間をフォーマットするのに、使い勝手のいいstrfmtライブラリの文字列補間を使います。

import strfmt

proc formatTime(ticks: int): string =
  let
    mins = (ticks div 50) div 60
    secs = (ticks div 50) mod 60
    cents = (ticks mod 50) * 2
  interp"${mins:02}:${secs:02}:${cents:02}"

このゲームのロジックは以下のようになっています。

  • プレイヤーがスタートラインを通過した時に、タイムのカウントが始まる。
  • プレイヤーがゴールラインを通過した時に、ゴールタイムが確定し、端末に保存される。もしそれが今までのベストタイムであれば、ベストタイムも更新される。スタートタイムはリセットされる。
proc getTile(map: Map, pos: Point2d): uint8 =
  map.getTile(pos.x.round.int, pos.y.round.int)

proc logic(game: Game, tick: int) =
  template time: expr = game.player.time
  case game.map.getTile(game.player.pos)
  of start:
    time.begin = tick
  of finish:
    if time.begin >= 0:
      time.finish = tick - time.begin
      time.begin = -1
      if time.best < 0 or time.finish < time.best:
        time.best = time.finish
      echo "Finished in ", formatTime(time.finish)
  else: discard

コースのmainprocでlogicを呼び出す必要があります。

for tick in lastTick+1 .. newTick:
  game.physics()
  game.moveCamera()
  game.logic(tick)

これで、コースをプレイすることができ、端末に以下のようなアウトプットがやっと得られるようになりました。

Finished in 00:04:38

セクション#7のフルコード

8. テキストのレンダリング

レースの結果のテキストが端末上ではなく、実際のゲーム画面に表示されたほうがいいですよね。では、SDL_ttfを使ってみましょう。

import sdl2.ttf

proc renderText(renderer: RendererPtr, font: FontPtr, text: string,
                x, y: cint, color: Color) =
  let surface = font.renderUtf8Solid(text.cstring, color)
  sdlFailIf surface.isNil: "Could not render text surface"

  discard surface.setSurfaceAlphaMod(color.a)

  var source = rect(0, 0, surface.w, surface.h)
  var dest = rect(x, y, surface.w, surface.h)
  let texture = renderer.createTextureFromSurface(surface)

  sdlFailIf texture.isNil:
    "Could not create texture from rendered text"

  surface.freeSurface()

  renderer.copyEx(texture, source, dest, angle = 0.0, center = nil,
                  flip = SDL_FLIP_NONE)

  texture.destroy()

proc renderText(game: Game, text: string,
                x, y: cint, color: Color) =
  const outlineColor = color(0, 0, 0, 64)
  game.renderer.renderText(game.font, text, x, y, color)

FontPtrでSDL2のサーフェス(RAMに保存されている)にテキストをレンダリングし、テクスチャ(GPUのVRAMに保存されている)にしました。そしてテクスチャは、スクリーン上の指定した場所にレンダリングされます。

proc render(game: Game, tick: int) =
  # Draw over all drawings of the last frame with the default color
  game.renderer.clear()
  # Actual drawing here
  game.renderer.renderTee(game.player.texture,
    game.player.pos - game.camera)
  game.renderer.renderMap(game.map, game.camera)

  let time = game.player.time
  const white = color(255, 255, 255, 255)
  if time.begin >= 0:
    game.renderText(formatTime(tick - time.begin), 50, 100, white)
  elif time.finish >= 0:
    game.renderText("Finished in: " & formatTime(time.finish),
      50, 100, white)
  if time.best >= 0:
    game.renderText("Best time: " & formatTime(time.best),
      50, 150, white)

  # Show the result on screen
  game.renderer.present()

TTFのサブシステムを初期化し、renderに現在のティックを渡す必要があります。

sdlFailIf(ttfInit() == SdlError): "SDL2 TTF initialization failed"
defer: ttfQuit()

...

game.render(lastTick)

ゴール後に画面に表示されるテキストのレンダリングが以下です。

Finished time 1
なんかカッコ悪いですね。文字のふちがギザギザして見えます。これは、renderUtf8Solidがアルファブレンドを使用してないからです。アルファブレンドは高価なので、代わりに全ピクセルをすべて白にするか、完全に透明にし、半透明など中途半端にはしないようにしましょう。テキストに対して背景の色が決まっているのなら、背景の色を決めるrenderUtf8Shadedを使うこともできますが、ダイナミックな背景にカッコいいアウトプットを載せたいなら、renderUtf8Blendedを使いましょう。

Finished time 2
よくなりました。しかし、背景が明くなると見にくいですね。この問題を解決するために、テキストを2回描いて、テキストのアウトラインを作ります。1回目は半透明の黒にして、2回目は背景と同じ色を上に載せます。

proc renderText(renderer: RendererPtr, font: FontPtr, text: string,
                x, y, outline: cint, color: Color) =
  font.setFontOutline(outline)
  let surface = font.renderUtf8Blended(text.cstring, color)
  sdlFailIf surface.isNil: "Could not render text surface"

  discard surface.setSurfaceAlphaMod(color.a)

  var source = rect(0, 0, surface.w, surface.h)
  var dest = rect(x - outline, y - outline, surface.w, surface.h)
  ...

proc renderText(game: Game, text: string,
                x, y: cint, color: Color) =
  const outlineColor = color(0, 0, 0, 64)
  game.renderer.renderText(game.font, text, x, y, 2, outlineColor)
  game.renderer.renderText(game.font, text, x, y, 0, color)

Finished time 3
セクション#8のフルコード

9. テキストのキャッシング

このチュートリアルの間にCPUの使用率を見た人は気付いたかもしれませんが、このゲームでCPUはほとんど必要ありません。私のシステムでは、今のところ60 fpsで3%ほどです。しかし、テキストをレンダリングすると20%にまで上がります。これは、現状ではテキストが前のフレームと全く同じものだとしても、毎フレームごとにテキストのテクスチャを再生成しているからです。テキストが決まっていれば、再計算をするのではなく、単純にテクスチャを保存すればいいのです。しかし、よりフレキシブルにしたければ、グリフかテクスチャ・キャッシングシステムを使ってください。例えば、SDL_FontCacheなどのシステムです。

代わりに、Nimでアプリケーションに特化したちょっとしたキャッシング・スキームを書くこともできます。ここでのヒューリスティクスは、コードベースの一行が、同じ文字列を少なくとも何回かは生成し続ける、というものです。ですから、画面に何かを表示させる行それぞれについて、1つのテキストのレンダリングのみをキャッシュに格納すればいいのです。ということで、キャッシュデータ構造で検索する必要は一切なくなり、キャッシングには確実で不変のメモリの量しか使わないことになります。

type
  CacheLine = object
    texture: TexturePtr
    w, h: cint

  TextCache = ref object
    text: string
    cache: array[2, CacheLine]

proc newTextCache: TextCache =
  new result

CacheLineが保存するもので、テクスチャへのポインタと、テクスチャの幅と高さの情報が含まれます。アウトラインの効果を出すためにテキストを2回レンダリングするので、このオブジェクトは2つ必要にあります。また、textTextCacheにも保存され、正しくテクスチャがキャッシュされたかを確認できます。

proc renderText(renderer: RendererPtr, font: FontPtr, text: string,
                x, y, outline: cint, color: Color): CacheLine =
  font.setFontOutline(outline)
  let surface = font.renderUtf8Blended(text.cstring, color)
  sdlFailIf surface.isNil: "Could not render text surface"

  discard surface.setSurfaceAlphaMod(color.a)

  result.w = surface.w
  result.h = surface.h
  result.texture = renderer.createTextureFromSurface(surface)
  sdlFailIf result.texture.isNil: "Could not create texture from rendered text"

  surface.freeSurface()

renderTextを実行して、使用できるCacheLineを返します。

proc renderText(game: Game, text: string, x, y: cint,
                color: Color, tc: TextCache) =
  let passes = [(color: color(0, 0, 0, 64), outline: 2.cint),
                (color: color, outline: 0.cint)]

  if text != tc.text:
    for i in 0..1:
      tc.cache[i].texture.destroy()
      tc.cache[i] = game.renderer.renderText(
        game.font, text, x, y, passes[i].outline, passes[i].color)
    tc.text = text

  for i in 0..1:
    var source = rect(0, 0, tc.cache[i].w, tc.cache[i].h)
    var dest = rect(x - passes[i].outline, y - passes[i].outline,
                    tc.cache[i].w, tc.cache[i].h)
    game.renderer.copyEx(tc.cache[i].texture, source, dest,
                         angle = 0.0, center = nil)

テキストがキャッシュから直接格納されたら、2つのpassesでテキストがレンダリングされます。直接ではない場合は、古いキャッシュエントリは削除され、新しいテクスチャに替えられます。

Nimのメタプログラミングは、小規模なテンプレートで使い続けることができます。数日前に『Metaprogramming in Nim(Nimのメタプログラミング)』という記事を書きましたので、Nimの強みをさらに勉強したい人はお読みください。

template renderTextCached(game: Game, text: string,
                          x, y: cint, color: Color) =
  block:
    var tc {.global.} = newTextCache()
    game.renderText(text, x, y, color, tc)

proc render(game: Game, tick: int) =
  # Draw over all drawings of the last frame with the default color
  game.renderer.clear()
  # Actual drawing here
  game.renderer.renderTee(game.player.texture,
    game.player.pos - game.camera)
  game.renderer.renderMap(game.map, game.camera)

  let time = game.player.time
  const white = color(255, 255, 255, 255)
  if time.begin >= 0:
    game.renderTextCached(formatTime(tick - time.begin),
      50, 100, white)
  elif time.finish >= 0:
    game.renderTextCached("Finished in: " &
      formatTimeExact(time.finish), 50, 100, white)
  if time.best >= 0:
    game.renderTextCached("Best time: " &
      formatTimeExact(time.best), 50, 150, white)

  # Show the result on screen
  game.renderer.present()

3つのrenderTextCached呼び出しはそれぞれのTextCacheを割り当てられました。これは残りのプログラムの実行で使われます。注意しなければいけないのは、このキャッシングスキームは、「renderTextCachedを呼び出す別々のコード行が比較的少なく、また頻繁に同じテキストを複数回連続してレンダリングする」という仮定のもとにのみ実力を発揮します。今回のケースにはぴったりです。

proc formatTime(ticks: int): string =
  let mins = (ticks div 50) div 60
  let secs = (ticks div 50) mod 60
  interp"${mins:02}:${secs:02}"

proc formatTimeExact(ticks: int): string =
  let cents = (ticks mod 50) * 2
  interp"${formatTime(ticks)}:${cents:02}"

また、現在時間のタイム・フォーマットは毎フレームごとに再計算されていたので、精度を下げました。これで、CPUの使用率は4%に戻りました。そして、ゲームの最終形は以下のようになりました。


セクション#9のフルコード

ビルドする

platformer.nimbleファイルを生成して、NimのパッケージマネージャであるNimbelにどのようにパッケージをビルドするかを命令します。

# Package

version       = "1.0"
author        = "Dennis Felsing"
description   = "An example platform game with SDL2"
license       = "MIT"

bin           = @["platformer"]

# Dependencies

requires "nim >= 0.10.0"
requires "sdl2 >= 1.1"
requires "strfmt >= 0.6"

task tests, "Compile all tutorial steps":
  for i in 1..9:
    exec "nim c tutorial/platformer_part" & $i

この記事のすべての中間コードのステータスはnimble testsでコンパイルできます。nimble buildを実行することで、ビルトをテストできます。これで、バイナリplatformerを生成できるようになります。nimble installは同じバイナリをインストールし、~/.nimble/binで使用できるようになります。

しかし、インストールされたplatformerバイナリを実行すると、以下のようなエラーが出てしまいます。

Error: unhandled exception: Could not load image player.png, SDL error: Couldn't open player.png [SDLException]

考えればこれは当然のことです。現在のディレクトリから実行時のファイルをロードしているからです。今となってはどこのディレクトリでもあり得ます。これを解決するには2つの方法があります。

  • コンパイル時にすべてのファイルを読み込み、どのアセットにも依存しないバイナリを取得する方法。このコンパイラは、staticReadでコンパイル時の任意のファイルの読み込みをサポートしているので、Nimでこれをやるのはとても単純です。
import streams

template staticReadRW(filename: string): ptr RWops =
  const file = staticRead(filename)
  rwFromConstMem(file.cstring, file.len)

template staticReadStream(filename: string): string =
  const file = staticRead(filename)
  newStringStream(file)

proc newGame(renderer: RendererPtr): Game =
  new result
  result.renderer = renderer

  result.font = openFontRW(
    staticReadRW("DejaVuSans.ttf"), freesrc = 1, 28)
  sdlFailIf result.font.isNil: "Failed to load font"

  result.player = newPlayer(renderer.loadTexture_RW(
    staticReadRW("player.png"), freesrc = 1))
  result.map = newMap(renderer.loadTexture_RW(
    staticReadRW("grass.png"), freesrc = 1),
    staticReadStream("default.map"))
  • データアセットをどこのディレクトリに保存するかを定義し、実行時にロードする方法。これで、バイナリを再コンパイルせずに、プレイヤーがカスタムしたデータアセットに切り替えることができます。DDNet Skin Databaseのこともあるので、これは便利な機能になると言えるでしょう。バイナリがファイルシステムのどこにあるかを探すことで、データディレクトリを見つけることができます。では、これを実装して、古い「コンパイル時に埋め込まれるアセット」をオプションにします。
import os, streams

const dataDir = "data"

when defined(embedData):
  template readRW(filename: string): ptr RWops =
    const file = staticRead(dataDir / filename)
    rwFromConstMem(file.cstring, file.len)

  template readStream(filename: string): Stream =
    const file = staticRead(dataDir / filename)
    newStringStream(file)
else:
  let fullDataDir = getAppDir() / dataDir

  template readRW(filename: string): ptr RWops =
    var rw = rwFromFile(cstring(fullDataDir / filename), "r")
    sdlFailIf rw.isNil: "Cannot create RWops from file"
    rw

  template readStream(filename: string): Stream =
    var stream = newFileStream(fullDataDir / filename)
    if stream.isNil: raise ValueError.newException(
      "Cannot open file stream:" & fullDataDir / filename)
    stream

proc newGame(renderer: RendererPtr): Game =
  new result
  result.renderer = renderer

  result.font = openFontRW(
    readRW("DejaVuSans.ttf"), freesrc = 1, 28)
  sdlFailIf result.font.isNil: "Failed to load font"

  result.player = newPlayer(renderer.loadTexture_RW(
    readRW("player.png"), freesrc = 1))
  result.map = newMap(renderer.loadTexture_RW(
    readRW("grass.png"), freesrc = 1),
    readStream("default.map"))

ファイル名の代わりにStreamを受け入るために、newMapを変更する必要があります。

proc newMap(texture: TexturePtr, map: Stream): Map =
  new result
  result.texture = texture
  result.tiles = @[]

  var line = ""
  while map.readLine(line):
    ...

コンパイルの時、-d:embedDataを以下のようにセットできます。

$ nim -d:release c platformer
$ ls -lha platformer
-rwxr-xr-x 1 deen deen 129K Jun 13 14:54 platformer*
$ nim -d:release -d:embedData c platformer
$ ls -lha platformer
-rwxr-xr-x 1 deen deen 888K Jun 13 14:55 platformer*

リポジトリの中に、最終的なプラットフォーム・ゲームのコードを見つけることができます。

そして、プルリクエストとしてNimble packagesにリポジトリをサブミットできるようになり、すぐにNim開発者はnimble install platformerで端末からパッケージをインストールでき、platformerを実行するだけでプレイができるようになりました。

circle.ymlファイルは、リポジトリをどのように実行してコンパイルするかを定義し、変更に応じて最新ビルドを保つのに使用できます。

バイナリ配布

しかし、このゲームのメインターゲットはおそらくNim開発者ではありませんので、Linux x86、x86-64とWindows x86、x86-64のような一般的なプラットフォーム向けにバイナリをビルドできるようにもしたいものです。Mac OS X向けにのビルドするのは、少し複雑です。DDNetがどのようにそれするのかチェックしてみてください。

もちろん、ビルドしたい各システム用に単にVMをセットアップし、この記事の冒頭のインストラクションを使うこともできます。でもそれは面倒なので、簡単に1つのマシンでビルドできるようにしたいですよね。

Linux

私はArch Linuxを使っています。Arch Linuxは、他のLinuxディストリビューションと似たような方法で実行することが可能なはずです。

Linux向けのポータブルなバイナリをビルドするのは、glibcのせいで大変です。より新しいバージョンのglibcを使ってシステム上でコンパイルすると、古いバージョンのシステムでは実行されない可能性があります。一般的な解決法は、古いLinuxをインストールしたビルドシステムを使うことです。他にも、debootstrapで古いDebianのchrootを生成するという方法もあります。また、この問題を解決するためのLinux Standard Baseというのもありますが、私はまだ使ったことがありません。

もう少し過激な解決法としては、新しいシステム上にバイナリを生成し、glibcのより新しいバージョンに対して何のシンボルとリンクしているのかを確認する方法があります。今回のケースでは、すべてにおいてGLIBC_2.2.5を使用させたいので、別のことを確認します。

objdump -T platformer | grep GLIBC | grep -v 2.2.5
0000000000000000      DF *UND*  0000000000000000  GLIBC_2.14  memcpy

memcpyだけが問題ですね。古いバージョンのmemcpyともう1つの問題realpathをリンカに無理やり使わせることもできます。例えば、インラインアセブラを使ったCコードだと以下のようになります。

__asm__(".symver memcpy,memcpy@GLIBC_2.2.5");
__asm__(".symver realpath,realpath@GLIBC_2.2.5");

しかし、これでは生成したすべてのCファイルにこれをインサートしなければならなくなります。もしくは、nimbase.hファイルを乱用して、インサートし、以下のようにコンパイルします。

$ head -n2 glibc-hack/nimbase.h
__asm__(".symver memcpy,memcpy@GLIBC_2.2.5");
__asm__(".symver realpath,realpath@GLIBC_2.2.5");
$ nim -d:release --passC:-Iglibc-hack c platformer
$ objdump -T platformer | grep memcpy
0000000000000000      DF *UND* 0000000000000000  GLIBC_2.2.5 memcpy
$ objdump -T platformer|grep GLIBC|grep -v 2.2.5

これで、glibc 2.2.5以上のLinuxバージョンでバイナリが使えるようになりました。注意したいのは、ユーザはSDL2やSDL_image2、SDL_ttf2のインストールが必要であるということです。

もし動的にリンクしていて、バイナリと一緒に共有のライブラリを配布したい場合は、nim --passC:-Wl,-rpath,. c platformerでコンパイルして、共有ライブラリをバイナリと同じディレクトリに置くこともできます。

x86-64を使用し、gcc-multilibがインストールされていれば、少なくとも、Linux上でx86向けにビルドするのは簡単です。

$ yaourt -S lib32-sdl2 lib32-sdl2_image lib32-sdl2_ttf
$ nim --cpu:i386 --passC:-m32 --passL:-m32 -d:release c platformer¥

Windows

Windows向けのポータブルなバイナリをビルドするのは驚くほど簡単です。Linuxからだとしてもです。

$ pacman -S mingw-w64-gcc
$ nim --os:windows --cpu:amd64 --gcc.exe:x86_64-w64-mingw32-gcc --gcc.linkerexe:x86_64-w64-mingw32-gcc -d:release c platformer
$ nim --os:windows --cpu:i386 --gcc.exe:i686-w64-mingw32-gcc --gcc.linkerexe:i686-w64-mingw32-gcc -d:release c platformer

Windows向けのSDLライブラリはSDL2 websiteimage, ttf)からダウンロードできます。

もし、スペースを節約したくて、バイナリをデバッグすることに抵抗がなければ、シンボルのバイナリをstrip -s platformerで削除するのは有効な方法です。

自動化されたビルドスクリプト

今までの情報を元にすると、完全に自動化されたビルドスクリプトもNimで書くことができます。

import os, strfmt

const
  app = "platformer"
  version = "1.0"

  builds = [
    (name: "linux_x86", os: "linux", cpu: "i386",
     args: "--passC:-m32 --passL:-m32"),
    (name: "linux_x86_64", os: "linux", cpu: "amd64",
     args: "--passC:-Iglibc-hack"),
    (name: "win32", os: "windows", cpu: "i386",
     args: "--gcc.exe:i686-w64-mingw32-gcc --gcc.linkerexe:i686-w64-mingw32-gcc"),
    (name: "win64", os: "windows", cpu: "amd64",
     args: "--gcc.exe:x86_64-w64-mingw32-gcc --gcc.linkerexe:x86_64-w64-mingw32-gcc"),
  ]

removeDir "builds"

for name, os, cpu, args in builds.items:
  let
    dirName = app & "_" & version & "_" & name
    dir = "builds" / dirName
    exeExt = if os == "windows": ".exe" else: ""
    bin = dir / app & exeExt

  createDir dir
  if execShellCmd(interp"nim --cpu:${cpu} --os:${os} ${args} -d:release -o:${bin} c ${app}") != 0: quit 1
  if execShellCmd(interp"strip -s ${bin}") != 0: quit 1
  copyDir("data", dir / "data")
  if os == "windows": copyDir("libs" / name, dir)
  setCurrentDir "builds"
  if os == "windows":
    if execShellCmd(interp"zip -9r ${dirName}.zip ${dirName}") != 0: quit 1
  else:
    if execShellCmd(interp"tar cfz ${dirName}.tar.gz ${dirName}") != 0: quit 1
  setCurrentDir ".."

Lunuxのユーザはパッケージマネジャを使ってsdl2やsdl2_image、sdl2_ttfをインストールしなければなりません。Windowsユーザにとってはこれらはバンドルされています。ビルドスクリプトは、nim -r c releaseで実行される時に、このディレクトリ構造を生成します。

.
├── platformer_1.0_linux_x86
│   ├── data
│   │   ├── default.map
│   │   ├── DejaVuSans.ttf
│   │   ├── grass.png
│   │   └── player.png
│   └── platformer
├── platformer_1.0_linux_x86.tar.gz
├── platformer_1.0_linux_x86_64
│   ├── data
│   │   ├── default.map
│   │   ├── DejaVuSans.ttf
│   │   ├── grass.png
│   │   └── player.png
│   └── platformer
├── platformer_1.0_linux_x86_64.tar.gz
├── platformer_1.0_win32
│   ├── data
│   │   ├── default.map
│   │   ├── DejaVuSans.ttf
│   │   ├── grass.png
│   │   └── player.png
│   ├── libfreetype-6.dll
│   ├── libpng16-16.dll
│   ├── platformer.exe
│   ├── SDL2.dll
│   ├── SDL2_image.dll
│   ├── SDL2_ttf.dll
│   └── zlib1.dll
├── platformer_1.0_win32.zip
├── platformer_1.0_win64
│   ├── data
│   │   ├── default.map
│   │   ├── DejaVuSans.ttf
│   │   ├── grass.png
│   │   └── player.png
│   ├── libfreetype-6.dll
│   ├── libpng16-16.dll
│   ├── platformer.exe
│   ├── SDL2.dll
│   ├── SDL2_image.dll
│   ├── SDL2_ttf.dll
│   └── zlib1.dll
└── platformer_1.0_win64.zip

結果はここからダウンロードしてください:Win64Win32Linux x86_64Linux x86

最後に

書いているうちに、どんどん長くなってしまいました。単純なプラットフォーム・ゲームを書くために出発したこの旅路は思った以上に壮大でした。皆さんにとってこの記事が長くすぎてつまらないものになっていないこと、そして、プラットフォーム・ゲームをSDL2を使ってNimで書く方法のいい手引きとして役に立つことを願います。

今回の記事で紹介したマテリアルはすべてGitHubのリポジトリにあります。最後まで内容をいじくりまわしたので、もしかしたら、間違えや抜けがあるかもしません。もし、バグを発見したり、コメントがあったりする場合は私にメールをお願いします:dennis@felsin9.de

r/programmingHacker Newsで議論もされています。