いものやま。

雑多な知識の寄せ集め

変種オセロのスタート画面を作ってみた。(その1)

強化学習の方はちょっとお休み。

今日からは、変種オセロ「良い子悪い子普通の子」のスタート画面を作っていく。

変種オセロ「良い子悪い子普通の子」については、以下を参照。

デザイン

まずは、スタート画面をどんなふうにするのかをデザイン。

必要なものとしては、

  • タイトル画像
  • 設定
    • コンピュータの強さの選択
    • 先手プレイヤーの選択
  • ゲーム開始ボタン
  • ルール表示ボタン

といったものが考えられる。

なお、設定については、専用の画面を用意して、それを表示させるためのボタンを用意するというのも考えられるけど、設定する項目がたいして多くないので、スタート画面にそのまま表示させることにした。

さて、これまでに作った素材を流用しつつ、これらの配置を「ああでもない、こうでもない」と試行錯誤して作ったイメージ図が、以下。

f:id:yamaimo0625:20150825154647p:plain

これを実現するように実装を進めていく。

レイアウトの作成

とりあえずはレイアウトの作成から。

変種オセロのUIを作ったときには、マスのサイズを固定させるために、基本的には要素の拡大/縮小は行わなかったけど、これから作ろうとしているスタート画面では、タッチする要素のサイズに余裕があるので、必要なら拡大/縮小を行って、見た目がどのデバイスでもほぼ変わらないようにすることにした。

//==============================
// YWF
//------------------------------
// StartScene.swift
//==============================

import SpriteKit

public class StartScene: SKScene {
  private static let MaxWidth: CGFloat = 828.0
  private static let MinMargin: CGFloat = 20.0
  
  private static let TitleImageSize = CGSize(width: 760.0, height: 255.0)
  private static let ConfigImageSize = CGSize(width: 760.0, height: 760.0)
  private static let PlayButtonImageSize = CGSize(width: 200.0, height: 100.0)
  
  private var scale: CGFloat
  private var margin: CGFloat
  
  public override init(size: CGSize) {
    self.scale = 1.0
    self.margin = 0.0
    
    super.init(size: size)
    
    let background = SKSpriteNode(imageNamed: "Background")
    background.position.x = size.width / 2.0
    background.position.y = size.height / 2.0
    self.addChild(background)
    
    self.settingWithSize(size)
    
    let title = SKSpriteNode(imageNamed: "Title")
    title.xScale = self.scale
    title.yScale = self.scale
    
    let config = SKSpriteNode(imageNamed: "ConfigBack")
    config.xScale = self.scale
    config.yScale = self.scale
    
    let playButton = SKSpriteNode(imageNamed: "PlayButton")
    playButton.xScale = self.scale
    playButton.yScale = self.scale
    
    title.position.x = size.width / 2.0
    title.position.y = size.height - self.margin - StartScene.TitleImageSize.height * self.scale / 2.0
    config.position.x = size.width / 2.0
    config.position.y = size.height - self.margin * 2.0 - StartScene.TitleImageSize.height * self.scale - StartScene.ConfigImageSize.height * self.scale / 2.0
    playButton.position.x = size.width / 2.0
    playButton.position.y = size.height - self.margin * 3.0 - StartScene.TitleImageSize.height * self.scale - StartScene.ConfigImageSize.height * self.scale - StartScene.PlayButtonImageSize.height * self.scale / 2.0
    
    self.addChild(title)
    self.addChild(config)
    self.addChild(playButton)
  }

  public required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
  
  private func settingWithSize(size: CGSize) {
    self.scale = size.width / StartScene.MaxWidth
    
    let titleHeight = StartScene.TitleImageSize.height * self.scale
    let configHeight = StartScene.ConfigImageSize.height * self.scale
    let playButtonHeight = StartScene.PlayButtonImageSize.height * self.scale
    
    self.margin = (size.height - titleHeight - configHeight - playButtonHeight) / 4.0
    
    if self.margin < StartScene.MinMargin {
      self.margin = StartScene.MinMargin
      let bodyHeight = size.height - self.margin * 4.0
      let contentHeight = StartScene.TitleImageSize.height + StartScene.ConfigImageSize.height + StartScene.PlayButtonImageSize.height
      self.scale = bodyHeight / contentHeight
    }
  }
}

かなりベタな実装(^^;
まぁ、そこまで複雑になるものでもないので、手抜きプリミティブな実装でもいいかな、と。
必要なら、あとでリファクタリングすればいいし。

なお、ルール表示ボタンについては、ここでは追加していない。

要素の拡大/縮小

このコードでポイントとなるのは、StartScene#settingWithSize(_ size: CGSize)の部分。
ここで、各要素のスケールとマージンを計算し、どのデバイスでもほぼ同じ見た目になるように調整している。

具体的には、以下のようにしている:

  1. 最大幅のデバイス(現状はiPhone 6 Plus)に合わせて、各要素の画像を作成。
  2. 「実際の画面の幅」と「最大幅のデバイスの幅」の比率から、仮のスケールを設定。
  3. 各要素の高さに仮のスケールを適用して、仮のスケールを適用したときの各要素の高さを算出。
  4. 画面の高さから各要素の高さを引いて、均等割りし、仮のマージンサイズを設定。
    1. もし仮のマージンサイズが最小のマージンサイズ以上ならば、仮のスケール、仮のマージンサイズを、そのまま使用する。
    2. そうでなければ、マージンサイズを最小のマージンサイズにし、実際の画面の高さからマージンを除いたサイズと、各要素の高さの和の比率から、スケールを設定。

こうすることで、縦方向/横方向のいずれも収まるように、スケールとマージンを設定することが出来る。

名前空間・・・

なお、本当だったら、スケールを算出したり、各要素のポジションを算出したりする部分は、別クラスとして切り出したいところ。

ただ、そのときに問題となるのが、名前空間のこと。

もし別クラスとして切り出すのなら、そのクラス名はLayoutManagerとなるだろうけれど、その名前はすでにゲームシーンの各要素の位置を算出するクラスとして使ってる。
なので、名前が衝突してしまう。

こういうときに(C++Javaのように)名前空間を使えれば問題はないのだけど、Swiftの名前空間プログラマがコードで明示的に設定することが出来るものではなく、モジュール単位で暗黙的に設定されるものなので、モジュール内でさらに名前空間を分けるということが出来ない。
なので、今回のようにシーン毎にレイアウトマネージャを用意したいとなったときには、クラス名がぶつかってしまって困ってしまう。

これを解決する方法としては、空のクラスを名前空間として利用して、実際に働くクラスをその拡張の内部クラスとして用意する、という方法がある。

具体的には、以下のような感じ:

==> ファイル構成 <==
source/
  - NameSpaceA.swift
  - NameSpaceB.swift
  - NameSpaceA/
    - NameSpaceA+ClassA.swift
    - NameSpaceA+ClassB.swift
  - NameSpaceB/
    - NameSpaceB+ClassA.swift
    - NameSpaceB+ClassB.swift

==> NameSpaceA.swift <==
public class NameSpaceA {
}

==> NameSpaceB.swift <==
public class NameSpaceB {
}

==> NameSpaceAClassA.swift <==
public extension NameSpaceA {
  public class ClassA {
    // ここにNameSpaceA.ClassAの実装
  }
}

==> NameSpaceBClassA.swift <==
public extension NameSpaceB {
  public class ClassA {
    // ここにNameSpace.ClassAの実装
  }
}

// NameSpaceA.ClassB、NameSpaceB.ClassBについても同様

Rubyでmoduleを名前空間として使うのにかなり似てる。

ただ、Rubyだとオープンクラスでどこでもmodule NameSpaceAといったふうに書けばいいのに対し、Swiftの場合、基本となるクラスclass NameSpaceAと拡張extension NameSpaceAを書き分ける必要がある。
あと、あくまで拡張なので、ファイル名は(拡張するクラス名)+(実装するクラス名).swiftとかにしておいた方がよさそう。
(※なお、プレイグラウンドで試してたんだけど、このような構成を試すとプレイグラウンドを再オープンできなかった。Xcodeのバグ?)

動作確認

閑話休題

これでコントローラをちょろっと書き換えて動かしてみた様子が以下。

iPhone 4S
f:id:yamaimo0625:20150825174527p:plain

iPad
f:id:yamaimo0625:20150825174546p:plain

iPhone 5s
f:id:yamaimo0625:20150825174606p:plain

iPhone 6
f:id:yamaimo0625:20150825174629p:plain

iPhone 6 Plus
f:id:yamaimo0625:20150825174659p:plain

どのデバイスでも大体同じになっていることが分かると思う。

今日はここまで!