2018年12月18日火曜日

他人の卒検の手伝いでダイヤモンドゲームを作った話

TUT Advent Calendar 2018 18日目の記事です
タイトルの通り、他人の卒検の手伝いでダイアモンドゲームを作った話です。ちなみにiOSアプリケーションです。まさか自分が初めてSwiftを書くのがこんな機会だとは夢にも思っていませんでした。

はじまり

いつもどおり世界の不条理な忙しさと戦っていたある日、ゲームを通じて知り合った友達から「卒検がやばいので助けて欲しい」と頼まれた。(でもうち学部3年やし)と思いつつ話を聞くと、以下のようなことがわかった。
  1. 引き継ぎの研究である(Objective-Cで構想だけ練ったらしい)
  2. ダイヤモンドゲームをつくるのが目標らしい
  3. 本体はiOSアプリケーションで、iPadで動くものにしたい
  4. コードはSwiftで書いてある
  5. その友達は Cをちょっとやったくらいしたコードを書いた経験がない
ここまで聞いてまずこれが頭に浮かんだ

またゲームじゃねぇか...
知っている人は知っていることだが、自分はゲーム作成がトラウマになっている。大学の課題でgreenfootを使って毛虫のシューティングゲームを作ったり、printfだけで不思議なシナリオのRPGを作ったりしたらゲーム制作が嫌になってしまった。
そんなわけでふんわり助言するくらいならと思っていたが、一番最後を聞いて決意を決めなくてはいけなくなった。書いたことがなかったので初めて知ったが、Swiftはオブジェクト指向の言語でありクラスの概念がある。一方これを書こうとしている彼はクラスどころか構造体すら使ったことがない。赤の他人なら「頑張れよ」で済ませたいところだが、もう話を聞いてしまったし「グ」で始まるゲームでお世話になってるので無下にするのも悪い。というわけで山積するタスクの最優先にこれがぶち込まれた。

Hello,Swift

というわけで、直近の土曜日に顔を合わせて[1]現状どこまでできているのかをみせてもらい作業を始めた。Swiftの初見での感想は「JavaとPythonの合いの子」といった感じだった。しかもどっちも多少なりとも学んだ言語なので結果的に大体ソースが読めた。どれくらい読めたかというと書いた本人より読めた。そしてアプリケーションは本人の申告どおり「盤を作り表示するところまでできているのだが駒を動かすことができない」な状況であった。

自分が理解したiOSアプリケーションの表示周り

先にiOSアプリケーションのGUI周りがどんな感じだと理解したのかを示しておく。
まず、画面遷移はStoryBoardというGUI操作するなにかで管理する[2]。そこで管理される画面はUIViewクラスで、StoryBoardを使ってそれぞれの画面にUIViewを継承したクラスを割り当てる。そうすることで作成したクラスを表示させることができる。このUIViewクラスはCALayer型インスタンスlayerを持っており、こいつが実際の見た目そのものであるようだ。CALayerクラスはaddSublayer()メソッドを持っており、これにCALayer型オブジェクトを渡すことで見た目に反映させることができる。[3]ほかにもオブジェクトを表示する方法は様々あるようだが、彼が使えるのはこれだけだったので今回はこれだけでやる。またゲーム制作の縛りゲーかなどと言ってはいけない
画像はStoryBoardの編集画面。ここでViewにオブジェクトを配置することもできるようだが、今回はしなかった。

StoryBoardの様子

当時のプロジェクトの状況

現状ではUIViewを継承したクラス1つだけで構成されており、その中に赤・黃・青のコマの数だけCAShapeLayerインスタンス[4]が宣言されて表示されるだけのコードだった。なるほどこれなら駒を表示することはできる。

開始時点で描けていたボード

ちなみにスクショは執筆時に撮ったので日付がおかしいが気にしてはいけない。

じゃあ動くようにしよう

「駒を動かす」から「スイッチを切り替える」に

これを受けてまず、「駒が置かれるところにはあらかじめオブジェクトを配置し、駒の有無でそれぞれの状態をスイッチするようにしよう」と提案した。というのも現状は駒があるところにしかオブジェクトがない。そのため、ある一点を指定して「ここに駒があるか」を調べるとき判断が面倒そうである。というわけでまずこんな感じのCellクラスを作成した。[5]
Cellクラスの概要
class Cell : Equatable{

  var x:Int      //セルのX座標
  var y:Int      //セルのY座標
  var row:Int    //セルの所在する列数
  var line:Int   //セルの所在する行数
  var mitame:CAShapeLayer
  var team:Team
  var view:UIView
  var allowTeam:[Team] = [Team.blue,Team.red,Team.yellow]//このマスに移動が許可されるチーム

  static func ==(lhs: Cell, rhs: Cell) -> Bool{}
  /* イニシャライザ Javaでいうところのコンストラクタ*/
  init(x:Int,y:Int,row:Int,line:Int,team:Team,view:UIView) {}

  /**
    このマスが指定されたチームの移動先にできるかを判定する関数
    引数:  team   -  判定するチーム
    戻り値: 移動可能かどうか:Bool
  **/
  func isAllowedTeam(team:Team) -> Bool {}

  /*
    コマの状態の更新
    引数:新しくなるチーム
    ex 無い状態 -> 青  青 -> 無い状態
  */
  func updateTeam(team:Team,view:UIView){}

  /**
    自分がタッチされたかどうかを判断する関数
    引数: タッチされた座標(CGPoint型)
    戻り値: 自分がタッチされてたとき -> 自分自身(Cell型)
            自分がタッチされてなかった -> nul
  **/
  func hitTest(touchedPoint: CGPoint) -> Cell? {  }

  /**
    マスの表示を「選択中」に切り替える関数
  **/
  func switchSelected(){  }

  /**
    「選択中」のマスの表示をもとに戻す関数
  **/
  func switchUnSelected(){  }

  /**
    マスの表示を「移動可能」にする関数
  **/
  func switchCanMoveCell(){  }
}
そんでもって表示するためのクラス、UIViewを継承するBoardクラスをつくる。その中で配列としてCellを必要な数だけ宣言して初期化する。関係ないけど可変長配列ってほんとすごいべんり。
Boardクラスの概要
import UIKit

class Board: UIView {
  var grid : [Cell] = []
  let numOfLines:Int = 17 //ボードの行数
  let centerOfx = 510     //中心のx座標
  let topOfy = 30         //いちばん上の頂点のy座標
  let diff_x = 80         //x座標の間隔
  let diff_y = 40         //y座標の間隔
  let directionList:[Direction] = [Direction.rightfront,Direction.leftfront,Direction.right,Direction.left,Direction.rightback,Direction.leftback]
  let cantMove:[(team:Team,disAllowLabels:[Int])] = [(team : Team.red,disAllowLabels: [0,1,2,3,4,5,6,7,8,9,74,84,85,95,96,97,107,108,109,110]),(team:Team.blue,disAllowLabels:[0,1,2,3,4,5,6,7,8,9,65,75,76,86,87,88,98,99,100,101]),(team:Team.yellow,disAllowLabels:[74,84,85,95,96,97,107,108,109,110,65,75,76,86,87,88,98,99,100,101])]

  required init?(coder aDecoder: NSCoder) {
    super.init(coder:aDecoder)

  }

  override func draw(_ rect: CGRect) {
    initBoard();
  }

  /**
  一行あたりのマスの数を返す関数
  引数:何行目か
  戻り値:その行のマスの数
  **/
  func getNumInRow(lineNum:Int) -> Int {  }

  /**
  行番号と何マス目かを渡すと、そのマスの座標を返す関数
  **/
  func calcPointXY(line: Int, cell: Int) -> (x: Int,y: Int){  }

  /**
    初期配置の時に、その座標がなんのチームに属するかを返す関数
    引数: 座標 x,y
    戻り値: 所属するチーム
  **/
  func whichTeamAtStart(lineNum: Int,cellNum: Int) -> Team{  }

  /**
  マスの位置に応じてそのマスに移動可能なチームに配列を返す関数
  **/
  func tellAllowTeam(labelOfGrid :Int) -> [Team]{
    var allowTeam: [Team] = [Team.blue,Team.red,Team.yellow]
    for nowCheckTeams in cantMove {
      for checkLabel in nowCheckTeams.disAllowLabels {
        if checkLabel == labelOfGrid {
          allowTeam.remove(at: allowTeam.firstIndex(of: nowCheckTeams.team)!)
          break;
        }
      }
    }
    return allowTeam
  }

  /**
  盤の背景を初期化/描画する関数
  **/
  func initBackground(){  }

  /**
    盤上のCellオブジェクトを初期化する関数
  **/
  func initCells(){
    for line:Int in 1 ... numOfLines{ //その行にあるマスの数(getNumInRow(line))だけforを回す
      for row:Int in 1 ... getNumInRow(lineNum: line){ //今処理する行は奇数行めかどうか
        let set_x :Int = calcPointXY(line: line,cell: row).x
        let set_y :Int = calcPointXY(line: line,cell: row).y
        //設定した値でCellオブジェクトを初期化して配列に追加
        grid.append(
          Cell.init(
            x: set_x,
            y: set_y,
            row: row,
            line: line,
            team: whichTeamAtStart(lineNum: line, cellNum: row),
            view: self)
          )
        grid[grid.count - 1].allowTeam = tellAllowTeam(labelOfGrid: grid.count - 1) //マスに移動が許可されるチームを登録
      }
    }
  }

  /**
    タッチされた座標にマスがあるか探す関数
    引数: 探す座標(CGPoint)
    戻り値: マスが見つかった -> みつかったマス(Cell型)
    マスがなかった   -> nil
  **/
  func searchPointedObject(touchedPoint: CGPoint) -> Cell? {
    for nowCheckingCell in grid {
      if let touchedCell = nowCheckingCell.hitTest(touchedPoint: touchedPoint) {
        return touchedCell
      }
    }
    return nil
  }

  /**
  ゲーム開始の状況を作る関数
  **/
  func initBoard(){
    initBackground();   //背景を初期化する
    initCells();        //各交点のオブジェクト達を初期化
  }
案の定細かいところは省略しているので、全部読みたい方はgithubへどうぞ。
ここまでで、
  • 移動可能な位置にはオブジェクトがすでにある
  • オブジェクトはそのマスが移動可能かを返したり、自分がそこにいるかを教えてくれる
な状況が完成した。

駒を移動させてみる

じゃあ準備ができたので駒を動かせるようにする。移動方法は、移動する駒->移動先の順でタッチする2回タッチ方式にした。UIViewクラスはタッチされるイベントに応じていくつかのメソッドが呼ばれる。今回はタッチされた直後に呼ばれるtouchesBeganメソッドを使った。流れとしては以下のような感じの処理をする。
  1. タッチされた座標を変数に格納
  2. これって移動元を決めるタッチ?
    1. タッチされた座標からタッチされたマスを算出して格納[6]
    2. タッチされたマスの表示を「選択中フォーム」に変更
    3. タッチされたマスを元に移動可能なマスを算出、配列にする
    4. 移動可能なマスたちを「移動可能フォーム」に変更
  3. これって移動先を決めるタッチ?
    1. タッチされた座標からタッチされたマスを算出して格納
    2. タッチされたマスが移動可能なマスだったら移動元と移動先の状態を交換
  4. どこにも関係ないところがタッチされてないか?
    1. いま見た目を変えているマスたちの見た目を元に戻す
一番頭を使ったのは移動可能なマスを探索するところだ。「探索といえば再帰」みたいなイメージこそあれど、まず確定で移動元のマスの6方向を調べなくてはいけない。ついでに、ダイアモンドゲームの醍醐味として「直近1マスに駒があってもその方向に1つ飛ばした先が空いていれば移動できる。これを1手で再帰的にできる。」という満たす必要があった。これを再帰で書けるような頭が自分にはあるんだろうか。結果からいうとあった。こうして作られたのが渾身のメソッド、その名もrecursionSearchである。
処理としては下のようなことをしている。
  1. 引数で探索基準のマス・移動始点のチーム・移動可能なマスのリスト・探索が済んだマスのリスト・探索深度を受け取る
  2. 6方向を1方向づつ検証する=探索する方向の数(=6)だけforを回す
  3. 今回調べる方向の座標を算出する
  4. その座標を元にマスのオブジェクトを取る
  5. そのマスが移動できるマスかつ探索深度が0のとき -> そのマスを移動可能なマスのリストに加える
  6. そのマスが移動不可能なマスのとき
    1. そのマスのもう一つ向こうにあるマスを取得する
    2. もう一つ向こうのマスが移動可能なとき
      1. そのマスを移動可能なリストに追加
      2. そのマスを基準点として自身をもう一度よぶ(再帰呼出し)
  7. 全方位終わるまで3に戻る

recursionSearch
/**
  周囲1マスの範囲で移動できるマスを探索する関数
  引数:  selectedObject  -  探索基準のマス
  canMoveTo       -  移動可能なマスのリスト(参照渡し)
  checkedCellList - 探索済みのマスのリスト(参照渡し)
  depth            -  探索深度
  **/
  func recursionSearch(selectedObject : (cell: Cell,team: Team),canMoveTo: inout [Int],checkedCellList: inout [Int],depth:Int){
    var searchingPoint :CGPoint //探索する座標
    //探索する方向によって探索するマスの相対的な座標を割り出し、座標からオブジェクトを取得する
    for nowCheckingDirection in directionList {
      switch nowCheckingDirection {
      case Direction.rightfront:
        searchingPoint = CGPoint(x: selectedObject.cell.x + diff_x/2, y: selectedObject.cell.y - diff_y)
      case Direction.leftfront:
        searchingPoint = CGPoint(x: selectedObject.cell.x - diff_x/2, y: selectedObject.cell.y - diff_y)
      case Direction.right:
        searchingPoint = CGPoint(x: selectedObject.cell.x + diff_x, y: selectedObject.cell.y)
      case Direction.left:
        searchingPoint = CGPoint(x: selectedObject.cell.x - diff_x, y: selectedObject.cell.y)
      case Direction.rightback:
        searchingPoint = CGPoint(x: selectedObject.cell.x + diff_x/2, y: selectedObject.cell.y + diff_y)
      case Direction.leftback:
        searchingPoint = CGPoint(x: selectedObject.cell.x - diff_x/2, y: selectedObject.cell.y + diff_y)
      }
      let searchingObject = searchPointedObject(touchedPoint: searchingPoint)
      if searchingObject != nil{ //指定した座標にオブジェクトがあるとき
        if(!checkedCellList.contains(grid.index(of: searchingObject!)!) && (searchingObject!.isAllowedTeam(team: selectedObject.team))){ //探索中のマスは今までに探索したマスと被っておらず、移動先は所属していたチームが移動できるマスか
          checkedCellList.append(grid.index(of: searchingObject!)!)  //これから探索するマスをチェック済みリストに追加
          if(searchingObject?.team == Team.nai && depth == 0){ //調べた先のオブジェクトがチームに所属しているかどうか
            canMoveTo.append(grid.index(of: searchingObject!)!) //オブジェクトのgrid配列の要素番号を移動可能なマスの一覧に追加
          }else if (searchingObject?.team != Team.nai ) {
            /* 見つけた埋まってるマスの先を探索 */
            switch nowCheckingDirection {
            case Direction.rightfront:
              searchingPoint = CGPoint(x: searchingObject!.x + diff_x/2, y: searchingObject!.y - diff_y)
            case Direction.leftfront:
              searchingPoint = CGPoint(x: searchingObject!.x - diff_x/2, y: searchingObject!.y - diff_y)
            case Direction.right:
              searchingPoint = CGPoint(x: searchingObject!.x + diff_x, y: searchingObject!.y)
            case Direction.left:
              searchingPoint = CGPoint(x: searchingObject!.x - diff_x, y: searchingObject!.y)
            case Direction.rightback:
              searchingPoint = CGPoint(x: searchingObject!.x + diff_x/2, y: searchingObject!.y + diff_y)
            case Direction.leftback:
              searchingPoint = CGPoint(x: searchingObject!.x - diff_x/2, y: searchingObject!.y + diff_y)
            }
            let nextSearchObject = searchPointedObject(touchedPoint: searchingPoint)
            if nextSearchObject != nil {
              if(nextSearchObject?.team == Team.nai && nextSearchObject!.isAllowedTeam(team: selectedObject.team)){
                canMoveTo.append(grid.index(of: nextSearchObject!)!)
                recursionSearch(selectedObject: (nextSearchObject!,selectedObject.team), canMoveTo: &canMoveTo, checkedCellList: &checkedCellList,depth: depth+1)
              }
            }
          }
        }
      }
    }
  }
これらをいい感じに実装することで、ダイアモンドゲームの動きができるようになった。

完成した動き

ただし、ターンの概念などは敢えて実装していない。それは本人がやったほうがいいだろうから...

感想

Swiftは初見の所感どおりほとんどJavaで割と経験を使って書くことができた。ただ、メソッドがnilを返す事ができるのはいいが、これに伴って「その値に以外を強制するとき(末尾に!)」「その値にnilを許すとき(末尾に?)」が必要になるのがわかりにくかった。正直excptionで処理すればいいのではと思ってしまう。ちなみのexceptionの概念はある。まぁこれがこうなってる理由に納得できない段階で自分も雑魚なんだなって[7]...
まさかこれが徹夜の1.5日仕事になるとは思っていなかったがいい経験ができた。ただ悔しかったのが、これが卒検にできる大学ではAppleのDeveloper登録がされたアカウントが配布されるのに、弊学ではそんなのないというところだ。豊橋のTUTさんはどうなんでしょうね。
これって次の人は~とか書かなくていいのかな。まぁいいや


  1. 実際に顔を見たり会ったりはこれが初めてだった ↩︎
  2. 本当はUIView同士のつながりを管理する、つまりStoryBoardが担っている役割を持つUIViewControllerクラスがあるのだが、彼がこれを使っていた都合でStoryBoardを使うタイプで話をする。 ↩︎
  3. 当たり前だがprint関数の出力先はコンソールなので、かつてのようにprintだけで作ることができないのはここで確定した。 慣れればprintのほうが楽なのでは ↩︎
  4. CALayerのサブクラスの1つ。お名前の通り形が書けるメソッド/変数つき。 ↩︎
  5. 実はCALayerクラスは自身のある場所をCGPoint型で保持している(CALayer.path)のでCellクラスはxとyを保持していなくてもいい。まぁ読みやすさのためです。 ↩︎
  6. これ、本当はCALayerのメソッドでできそうなのに、うまくできなかったので全部のマスを探索するクソムーブをしている。 ↩︎
  7. ほんまやぞ ↩︎

0 件のコメント:

コメントを投稿