2016年7月11日
SDL2を使い、Nimで2Dのプラットフォーム・ゲームを書く
(2016-06-14)by Dennis Felsing
本記事は、原著者の許諾のもとに翻訳・掲載しております。
この記事ではシンプルな2Dの プラットフォーム・ゲーム を書きます。SDLを使ったNimによるゲーム開発のチュートリアルとしてもよいでしょう。
ここでは、ユーザ入力を読み込み、グラフィックスとタイルマップを表示させ、衝突判定と処理を伴うシンプルな2Dの物理をシミュレーションします。その後、シンプルなカメラの動作とゲームロジックを実装します。また、適宜情報を表示するため、テキストをレンダリングし、そのためのキャッシュの仕組みを構築していきます。
最終的な成果物は、SDL2のみで動いて簡単に配信できる、ゲームに最適なバイナリファイルです。Linux環境の場合、NimをWindows用にクロスコンパイルする簡単な方法も提示しますので参照してください。
簡潔に説明するため、 DDNet と Teeworlds のなじみ深いグラフィックスを使います。このチュートリアルの成果は下の動画のようになります。
この記事では、画像とビデオによる解説で開発手順を追っていきますが、最善の学習方法は、この記事を見ながらご自身で各ステップを実装してみることです。コードは、各自でいじったりあらゆる変更を試したりすることによって直感的に理解できるよう、意図的にシンプルかつ簡易にしてあります。フルソースコードのリンクは、各セクションの末尾に用意しました。
ここで紹介するコードの各イテレーションと最終的な成果物は GitHubのレポジトリ で入手可能です。出来上がったバイナリは以下からダウンロードできます。 Win64 、 Win32 、 Linux x86_64 、 Linux x86
準備
このプロジェクトに必要なものは下記のとおりです。
- プログラミング言語 Nim とパッケージマネジャ Nimble
- SDL 2 、 SDL_image 2 、 SDL_ttf 2 (すべて開発者向け)
- Nim SDL2 ラッパー と strfmt 。Nimbleでインストールできます。
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を他のプラットフォームで設定したい人には、 より幅広いガイド があります。 Nim と nimble の設定に関しても同様です。
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
を発生させるようにします。 main
procで、SDL2を初期化し、通常の画面と加速した2Dレンダラを作成します。エラー処理は先ほど導入した sdlFailIf
procで行います。
いまのところ、ゲームのループは画面の消去と描画を毎フレーム行っているだけです。VSyncが有効で、スクリーンの設定が60 Hzの場合、そのループは1秒に60回実行されます。
nim -r c platformer
を実行すれば、同様のステップでコンパイルと実行ができます。ファイル名は platformer.nim
になるでしょうか。最適化しながらコンパイルする場合は、 nim -d:release -r c platformer
を使います。結果はシンプルな単色の画面です。
この小さなプログラムを終了するには、ターミナル画面の中でCtrl-Cを入力してください。残念ながら、まだゲームの画面そのものから終了することはできないので、その修正を行いましょう。
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つの入力にマッピングするための複数のスキャンコードを知るのは難しいことではありません。
次に、ゲームループを修正し、新しい handleInput
procを呼び出してキーボード入力に反応するようにします。また、 main
procの中で関心の分離をせずにすむよう、レンダリングそのものを分割しました。
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配列はわざと簡潔な配列にしています。ハッシュテーブルやその他のデータ構造を使うとなると、簡単にそのような約束ができません。多くの場合、シンプルさは有利であり、開発中のシステムにおいて何が起こっているのかを理解しやすいものです。
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のグラフィックを使います。
このファイルを player.png として保存すると便利です。しっくりこないようであれば、 DDNet Skin Database の数百のスキンの中から選んでもよいでしょう。例えば次のようなグラフィックです。
別のイメージを使いたい場合は、忘れずにプレイヤーグラフィックの名称を player.png
にしてください。
まず使うと決めたプレイヤーグラフィックを newGame
の定義で読み込み、 Game
と Player
のデータ構造体を初期化する必要があります。次のとおりです。
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の初期化と同じように、 main
procの中で、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()
ついにまた、ビジュアルが進展を見せました。プレイヤーが空に浮いたのを見てください。
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.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
このセクションの目標は、芝のタイルセットのマップで、次のようなレンダリング結果を得ることです。
各数字は芝のタイルセットから選んだタイルを表します。今後この記事内では、 この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はエアタイルであり常に空なので、レンダリングする必要はありません。典型的なマップは大部分が空なので、これによりパフォーマンスが向上します。
最後に、プレイヤーをレンダリングした後は、マップがプレイヤーの上に配置されるよう、 main
procの中でレンダリングしなければなりません。
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
で、固定のレンダリング位置を取得します。しかしまだ動くことさえできませんから、あとでユーザ入力に関連づけたシンプルな物理モデルを実装することにします。
このセクションの成果は美しいマップのレンダリングです。
5. 物理と衝突
このゲームの物理は、1秒50ティックに決めました。新規のティックが届いた時には、次のゲームのイテレーションだけを計算し、ゲームがが60 fpsで動こうが240 fpsで動こうが関係ないようにします。それでは、 main
procにティックを追加しましょう。
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 を何度かクリックしてみてもいいでしょう。
a と d で左右に動かし、 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
moveBox
procはさらに衝突のセットを返し、このイテレーションにおいてどのような種類の衝突が起こったのかを伝えてくれます。その情報は使いませんが、ただプレイヤーを衝突した壁から押し出すのでなく、特別な方法で衝突を処理したい場合には役立つかもしれません。
やっと onGround
と moveBox
が使えるようになりました。
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)
ジャンプは地上に立っている時のみ可能です。空中における水平の動作は地上とは別の方法で計算し、空中における摩擦と地上の摩擦を異なるものとしてシミュレーションします。
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
7.ゲームステータス
これで、どこの場所に行ってもカメラがきちんとついて来てくれるようになりました。では、ゲームの目標を作りましょう。コースの始まりと終わりに、濃いグレーの明かりのような縦の線があることに気付いたでしょうか? それぞれ start
と finish
として言及されているのではと予想していた人もいたんじゃないでしょうか? もうお分かりですね。これらはスタートラインとゴールラインです。スタートしてからゴールにたどり着くまでのプレイヤーのタイムを記録する役割もあります。
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()
これで、 Player
に Time
オブジェクトが格納されている状態です。プレイヤーが今回スタートを切ったタイムと、前回のタイム、プレイヤーのベストタイム、という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
コースの main
procで logic
を呼び出す必要があります。
for tick in lastTick+1 .. newTick:
game.physics()
game.moveCamera()
game.logic(tick)
これで、コースをプレイすることができ、端末に以下のようなアウトプットがやっと得られるようになりました。
Finished in 00:04:38
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)
ゴール後に画面に表示されるテキストのレンダリングが以下です。
なんかカッコ悪いですね。文字のふちがギザギザして見えます。これは、 renderUtf8Solid
がアルファブレンドを使用してないからです。アルファブレンドは高価なので、代わりに全ピクセルをすべて白にするか、完全に透明にし、半透明など中途半端にはしないようにしましょう。テキストに対して背景の色が決まっているのなら、背景の色を決める renderUtf8Shaded
を使うこともできますが、ダイナミックな背景にカッコいいアウトプットを載せたいなら、 renderUtf8Blended
を使いましょう。
よくなりました。しかし、背景が明くなると見にくいですね。この問題を解決するために、テキストを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)
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つ必要にあります。また、 text
は TextCache
にも保存され、正しくテクスチャがキャッシュされたかを確認できます。
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%に戻りました。そして、ゲームの最終形は以下のようになりました。
ビルドする
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 website ( image, 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
結果はここからダウンロードしてください: Win64 、 Win32 、 Linux x86_64 、 Linux x86
最後に
書いているうちに、どんどん長くなってしまいました。単純なプラットフォーム・ゲームを書くために出発したこの旅路は思った以上に壮大でした。皆さんにとってこの記事が長くすぎてつまらないものになっていないこと、そして、プラットフォーム・ゲームをSDL2を使ってNimで書く方法のいい手引きとして役に立つことを願います。
今回の記事で紹介したマテリアルはすべて GitHubのリポジトリ にあります。最後まで内容をいじくりまわしたので、もしかしたら、間違えや抜けがあるかもしません。もし、バグを発見したり、コメントがあったりする場合は私にメールをお願いします: dennis@felsin9.de
r/programming と Hacker News で議論もされています。
株式会社リクルート プロダクト統括本部 プロダクト開発統括室 グループマネジャー 株式会社ニジボックス デベロップメント室 室長 Node.js 日本ユーザーグループ代表
- Twitter: @yosuke_furukawa
- Github: yosuke-furukawa