NimでSDL2 ③ 入力処理

# はじめに
[NimでSDL2 ① 準備とウィンドウ表示](https://qiita.com/bucho666/items/afa37af16744a99b199e)
[NimでSDL2 ② 画像を表示](https://qiita.com/bucho666/items/ec3845317f99fa7c0b48)

前回画像を表示したので、今回はキーボードから入力を処理して画像を移動してみる。

# 概要
1. キーボードイベントを処理して入力されたキーを取得する
2. 入力されたキーに応じて画像の出力座標を更新して出力する

# リファクタリング
前回まででちとコードがわちゃわちゃしてきたのでいったん整理。
詳細はコメントを参照してくださいー。

```nim
import sdl2, sdl2/image, os, sugar

# エラー処理のテンプレート
template sdlFailIf(cond: typed, desc: string) =
  if cond: raise Exception.newException(
    desc & ": " & $getError())

# SDL2を管理するオブジェクト
type SDL2Context = ref object
  window: WindowPtr
  renderer: RendererPtr
  cleanups: seq[proc ()]

# クリーンアップ処理。登録されたクリーンアップ関数を実行する
proc cleanup(self: SDL2Context) =
  # 追加されたcleanup関数を逆順で実行
  while self.cleanups.len > 0:
    (self.cleanups.pop)()

# 各種初期化
proc init(self: SDL2Context, title: string, w, h: cint): SDL2Context {.discardable.} = # .discardableは戻り値無視可能関数
  # 自身を返す
  result = self
  # 画像を扱うのでINIT_VIDEOフラグを立てて初期化
  sdlFailIf(not sdl2.init(INIT_VIDEO), "SDL2 init")
  # SDL2のクリーンアップ処理を登録
  self.cleanups.add(() => sdl2.quit())
  # Window作成
  self.window = createWindow(title = title,
                            x = SDL_WINDOWPOS_CENTERED,
                            y = SDL_WINDOWPOS_CENTERED,
                            w = w, h = h,
                            flags = SDL_WINDOW_SHOWN)
  sdlFailIf(self.window.isNil, "createWindow")
  # windownのクリーンアップ処理を登録
  self.cleanups.add(() => self.window.destroy)
  # image初期化
  sdlFailIf(image.init(IMG_INIT_PNG) != IMG_INIT_PNG, "image init")
  # imageクリーンアップ処理
  self.cleanups.add(() => image.quit())
  # windowからrenderer作成
  self.renderer = self.window.createRenderer(
    index = -1,
    flags = Renderer_Accelerated or Renderer_PresentVsync)
  # renderer作成エラーチェック
  sdlFailIf(self.renderer.isNil, "createRenderer")
  # rendererクリーンアップ
  self.cleanups.add(() => self.renderer.destroy)
  # 背景色を設定
  self.renderer.setDrawColor(r=0, g=128, b=128)

# テクスチャー読み込み
proc loadTexture(self: SDL2Context, path: string): TexturePtr =
  # rendererを使って画像を読み込んでテクスチャにする
  result = self.renderer.loadTexture(path)
  # 画像読み込みエラーチェック
  sdlFailIf(result.isNil, "loadTexture")

# イベント処理
proc pollEvent(self: SDL2Context): bool =
  # 戻り値をtrueで初期化
  result = true
  # イベント取得用変数
  var event = defaultEvent
  # 発生している全てのイベントを処理
  while pollEvent(event):
    # イベントの種類に応じて処理
    case event.kind:
    # 終了イベントがきたらfalseを返して終了
    of QuitEvent: return false
    # その他は無視
    else: discard

# メインループ
proc mainLoop(self: SDL2Context) =
  # テクスチャー読み込み
  let neko = self.loadTexture("genbaneko.png")
  # 描画するサイズ
  let
    w: cint = 256 # 描画する画像の幅
    h: cint = 256 # 描画する画像の高さ
  # Windowサイズを取得用変数
  var
    windowWidth: cint
    windowHeight: cint
  # windowサイズ取得
  self.window.getSize(windowWidth, windowHeight)
  # 描画先のRectを作成(Windowの中心に表示)
  var dst = rect(windowWidth div 2 - w div 2, windowHeight div 2 - h div 2, w, h)
  # 画面初期化
  self.renderer.clear
  # レンダリング(描画元はnilを指定しているので画像全体を描画)
  self.renderer.copyEx(neko, nil, dst.addr, 0.0, nil, SDL_FLIP_NONE)
  # レンダリング結果を画面に反映
  self.renderer.present
  # イベントループ
  while self.pollEvent:
    # CPUを使いきらないように0.1秒Wait
    sleep(100)

# メイン関数
proc main() =
  # SDL2管理オブジェクトを作成
  let sdl2 = SDL2Context()
  # 例外が発生してもクリーンアップ処理を実行するようにdefer定義
  defer: sdl2.cleanup
  # 初期化してメインループを実行
  sdl2.init("SDL2 Image Sample", 640, 480).mainLoop

# メイン関数実行
main()
```

概ね変数をSDL2Contextオブジェクトにまとめて
各処理を関数かしてるだけだけど、クリーンアップ処理はちょっと工夫して
関数の配列(cleanups)を用意して、初期化したらそこにクリーンアップ関数を追加しておいて
しかるべき時にその配列の関数を呼ぶ、っていう方法にしてすっきりさせております。

それとアロー演算子で無名関数を定義できるよう、[sugar](https://nim-lang.org/docs/sugar.html)をimportしております。

# 画像移動処理追加コード

```nim
import sdl2, sdl2/image, os, sugar, sets

template sdlFailIf(cond: typed, desc: string) =
  if cond: raise Exception.newException(
    desc & ": " & $getError())

type SDL2Context = ref object
  window: WindowPtr
  renderer: RendererPtr
  cleanups: seq[proc ()]
  pressed: HashSet[cint] # 押されているキーを格納するハッシュセット

proc cleanup(self: SDL2Context) =
  while self.cleanups.len > 0:
    (self.cleanups.pop)()

proc init(self: SDL2Context, title: string, w, h: cint): SDL2Context {.discardable.} =
  result = self
  sdlFailIf(not sdl2.init(INIT_VIDEO), "SDL2 init")
  self.cleanups.add(() => sdl2.quit())
  self.window = createWindow(title = title,
                            x = SDL_WINDOWPOS_CENTERED,
                            y = SDL_WINDOWPOS_CENTERED,
                            w = w, h = h,
                            flags = SDL_WINDOW_SHOWN)
  sdlFailIf(self.window.isNil, "createWindow")
  self.cleanups.add(() => self.window.destroy)
  sdlFailIf(image.init(IMG_INIT_PNG) != IMG_INIT_PNG, "image init")
  self.cleanups.add(() => image.quit())
  self.renderer = self.window.createRenderer(
    index = -1,
    flags = Renderer_Accelerated or Renderer_PresentVsync)
  sdlFailIf(self.renderer.isNil, "createRenderer")
  self.cleanups.add(() => self.renderer.destroy)
  self.renderer.setDrawColor(r=0, g=128, b=128)
  # キー状態を初期化
  self.pressed = initHashSet[cint]()

proc loadTexture(self: SDL2Context, path: string): TexturePtr =
  result = self.renderer.loadTexture(path)
  sdlFailIf(result.isNil, "loadTexture")

# キー状態を更新
proc keyStateUpdate*(self: SDL2Context, event: KeyboardEventPtr) =
  case event.kind
  # キーが押されたらそのキーを追加
  of KeyDown: self.pressed.incl(event.keysym.sym)
  # キーが話されたらそのキーを削除
  of KeyUp: self.pressed.excl(event.keysym.sym)
  else: discard

proc pollEvent(self: SDL2Context): bool =
  result = true
  var event = defaultEvent
  while pollEvent(event):
    case event.kind:
    of QuitEvent: return false
    # キーイベントならキー状態を更新
    of KeyDown, KeyUp: self.keyStateUpdate(event.key)
    else: discard

proc mainLoop(self: SDL2Context) =
  let neko = self.loadTexture("genbaneko.png")
  let
    w: cint = 256
    h: cint = 256
    speed: cint = 3 # 移動スピード
  var
    windowWidth: cint
    windowHeight: cint
  self.window.getSize(windowWidth, windowHeight)
  var dst = rect(windowWidth div 2 - w div 2, windowHeight div 2 - h div 2, w, h)
  while self.pollEvent:
    # キーの状態に応じて、画像出力座標を変更
    if K_RIGHT in self.pressed: dst.x += speed
    if K_LEFT  in self.pressed: dst.x -= speed
    if K_DOWN  in self.pressed: dst.y += speed
    if K_UP    in self.pressed: dst.y -= speed
    # 描画処理をイベントループ内に移動
    self.renderer.clear
    self.renderer.copyEx(neko, nil, dst.addr, 0.0, nil, SDL_FLIP_NONE)
    self.renderer.present
    # 滑らかに動かすためにWait時間を調整
    sleep(16)

proc main() =
  let sdl2 = SDL2Context()
  defer: sdl2.cleanup
  sdl2.init("SDL2 Keyboard sample", 640, 480).mainLoop

main()
```

追加箇所はコメント記載部分。
キーのHashSetを用意しておいて
イベント処理でキーイベントが発生したら、
押されたら追加、離されたら削除してキー状態を保持、
キーが押されていたら出力先座標を更新して描画するだけ。
キーの定義はnimのSDL2ラッパーの[ここ](https://github.com/nim-lang/sdl2/blob/master/src/sdl2/private/keycodes.nim)に定義されてます。

フレーム毎の待ち時間が0.1秒だと動きがガタガタだったので
0.016秒に短縮。

# 結果
![keyboard.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/268503/da589daa-7225-c1ad-676e-27aef73b51b7.gif)
※ gifアニメです。

# 次は？
今まで適当にWaitしてたので次回はFPS(Frame Per Second)を調整して行きたいですね。

以上〜。