📌

オセロを作る(SwiftUI)

2024/10/27に公開

オセロとは

オセロは、2人のプレイヤーが交互に盤面へ石を打ちながら…以下略
今回は、プレーヤーは双方とも人間のシンプルなものを作成します
出来上がりはこんな感じの超ミニマルなものです
本文の一番下にコード全文を載せていますので、まずはコピペして貼り付けて触ってみることをお勧めします。

プログラム的に考えるオセロのアルゴリズム

いつ始まっていつ終わるのか

ゲームが終わる時ってどんな時でしょうか?結論を書くと、黒白双方の石を置く場所がなくなった時であり、例外はありません。
黒か白の石をどこかに置ける状態が継続していればゲームは続行中であり、その状態が維持できなければゲームは終了です。最初の状態がどうであるとかはプログラム的にはどうでもいいです。
つまり、石を置くたびにゲーム盤を調べて、石が置ける場所があればプレイヤーを交代して(場合によっては同じプレイヤーで)ゲーム続行、なければゲーム終了です。

これを図にすると下のようになります。意外と単純です。

①初期設定

画面構成

View

オセロは8x8の盤面に「黒い石が置かれたマス」と「白い石が置かれたマス」と「何も石が置かれていないマス」の3種類の状態の組み合わせで構成されています。
要するにゲーム中はこのマスをタイルのように張り、タップした際のプレイヤーや状態に応じてタイルを張り替えていけばいいわけです。

ということで下のように VStack と HStack の spacing をそれぞれ0にして画面を作成しています。
ReversiManager(後述)というクラスを作ってそれぞれのマス(Cellとネーミングしています)の状態を記録した Cellsという配列を管理しています。
Cellをタップすることでゲームを進めていきます。

ContentView
struct ContentView: View {
    @ObservedObject var reversiManager = ReversiManager()
    var body: some View {
        VStack(spacing: 0.0){
            ForEach(0..<8, id:\.self){y in
                HStack(spacing: 0.0){
                    ForEach(0..<8, id:\.self){x in
                        reversiManager.cells[y][x]
                            .onTapGesture {
                                reversiManager.progressGame(y, x)
                            }
                    }
                }
            }
        }
    }
}

Stone

白黒の石は Stone という enum を作って管理しています。
ちなみにこのenumでは黒(black)、白(white)、何もない(none) という3種類のcaseを持っています。何も無いんだからnoneというケースはおかしいかもしれませんが、ここら辺は好みの問題かな?
なお、このenumにはそこそこ汎用性の高い
colored(色付きの石) ※配列ではなく集合を使っています。地味なこだわりで他意はありません
color(色)
opposedStone(反対の色)
という静的定数を持たせています。

Stone
enum Stone{
    //Stone(石)の種類は黒と白と無色の3種類
    case black,white,none
    //色付きの石(黒白)のみの集合(Set)。別に配列でもいい。
    static let colored:Set<Self> = [.black,.white]
    //石の色
    static let color = {(stone:Self)->Color in
        switch stone{
        case .black:.black
        case .white:.white
        case .none :.clear
        }
    }
    //反対の石黒の反対は白で、白の反対は黒。透明の反対は存在しないのでこの式にnoneを入れた場合はエラーが発生するようにしてます
    static let opposedStone = {(stone:Self)->Self in
        switch stone {
        case .black:.white
        case .white:.black
        case .none :fatalError()
        }
    }
}

Cell

上述した Stone の状態を持たせた CellというView構造体です。
CellにはtargetStonesという辞書型の配列を作り、このセルをタップしたらどのCellの石がひっくり返るかの情報を入れています。
全体として、Cellsの座標はタプルで(y:Int,x:Int)で表現しています。

Cell
struct Cell:View {
    var stone:Stone = .none
    var targetStones:[Stone:[(y:Int,x:Int)]] = [.black:[],.white:[]]
    mutating func clearTargetStones(){
        for stone in Stone.colored{
            targetStones[stone]!.removeAll()
        }
    }
    var body: some View{
        ZStack(){
            Rectangle()
                .foregroundStyle(.green)
                .border(.black, width: 1.0)
            Circle()
                .foregroundStyle(Stone.color(stone))
        }.scaledToFit()
    }
}

ここまで準備したところで初期設定を実施して、ゲームの最初の画面を作成していきます。
手順としては、
④石を置く
②石を置ける場所の確認
になりますが、後述します。

②石を置ける場所の確認

全てのセルに対して、仮に黒または白のStoneを置いた場合にどのセルのStoneがひっくり返るか(もしくは全く変化しないか)あらかじめ確認しておきます。

石をひっくり返すアルゴリズム

多分、今回はこれが一番難しいアルゴリズムです。人によって作り方も変わってくると思います。

この図を使って石をひっくり返す条件を考えてみましょう。
この図では黒い石を置いたときにどうなるか考えます。
まず、周囲の8方向について考える必要があります。

  1. 右上と右は白い石をひっくり返すことができます。
  2. 左上、左下、右下は考える必要がありません。石が置いていないからです。
  3. 上はひっくり返すことができません。途中に石が置いていないセルがあるからです。
  4. 左はひっくり返すことができません。終点に黒い石がないからです。
  5. 下はひっくり返すことができません。最初に黒い石が置いてあるからです。

ここで導き出されることは
定められた方向の探索をする
ただし、

  1. 白い石が現れたらその石はひっくり返せる可能性があるので記録して探索を続ける。
  2. 何も石がないセルが現れたらひっくり返せるcellは存在しないため探索を終了する。
  3. ゲームの盤面の外に出たらひっくり返せるcellは存在しないため探索を終了する。
  4. 黒い石が現れたら終了。上記1.のcellをひっくり返すことができると判断できる。
  5. 次の方向の探索を行う

ということが言えます。
コードにすると以下の感じ。
これを全てのcellに対して黒白双方の石を置くと仮定した結果をそれぞれのcellに記録していきます。

private func checkAllCells(){
        for y in 0..<8{
            for x in 0..<8{
                cells[y][x].clearTargetStones()//ひっくり返せるセルを初期化
                if(cells[y][x].stone == .none){//石が置いてないセルのみ調査
                    checkTargetStones(y,x)
                }
            }
        }
    }
    private func checkTargetStones(_ y:Int,_ x:Int){
        //調査方位(上下左右と斜めの座標方向)
        let directions:[(y:Int,x:Int)] = [(-1,-1),(-1,0),(-1,1),(0,-1),(0,1),(1,-1),(1,0),(1,1)]
        for stone in Stone.colored{//黒白両方を確認
            for direction in directions{
                let foundTargetStones:[(y:Int,x:Int)] = findTargetStones(y,x,direction.y,direction.x,stone)
                cells[y][x].targetStones[stone]!.append(contentsOf: foundTargetStones)
            }
        }
    }
    private func findTargetStones(_ y:Int,_ x:Int,_ dy:Int,_ dx:Int,_ stone:Stone)->[(y:Int,x:Int)]{
        //そのセルはゲーム版の外もしくはそのセルには何も石がないかを確認
        let noneOrOB = {(y:Int,x:Int)->Bool in (!(0..<8).contains(y) || !(0..<8).contains(x)) || self.cells[y][x].stone == .none}

        var position:(y:Int,x:Int) = (y:y+dy,x:x+dx) //探索場所を更新
        if(noneOrOB(position.y, position.x)){return []} //何もないもしくはゲーム版の外なら終了。記録なし
        
        var result:[(y:Int,x:Int)] = []
        while(cells[position.y][position.x].stone == Stone.opposedStone(stone)){ //反対の色の石が出続ける限り探索をおこなう
            result.append(position) //結果を記録
            position = (y:position.y+dy,x:position.x+dx) //探索場所を更新
            if(noneOrOB(position.y, position.x)){return []} //何もないもしくはゲーム版の外なら終了。記録なし
        }
        return result //結果をかえす
    }

④石を置く

石を置くという処理は非常に簡単であり、これだけです。

putStone
private func putStone(_ y:Int,_ x:Int,_ stone:Stone){
        cells[y][x].stone = stone
    }

上記②石を置ける場所の確認で各cellの targetStones には黒のstoneを置いた場合にひっくり返せるcell、白のstoneを置いた場合にひっくり返せるcellが記録されました。
プレイヤー(黒)が石を置いてゲームを進めていくことを考えます。
まずは、黒い石を置ける場所でないと当然置くことができません。
つまり、タップしたcellのtargetStones[.black]!がemptyでは置くことが出来ません。
石を置くことができるのならは、

  1. タップしたセルを黒のstoneに
  2. targetStones[.black]!に記録された全てのセルを黒のstoneに
    して、再度上記②石を置ける場所の確認を実施してからプレイヤーの交代を実施します。
progressGame
public func progressGame(_ y:Int,_ x:Int){
        if(cells[y][x].targetStones[player]!.isEmpty){return}//石を置くことができない
        putStone(y, x, player)
        for targetStone in cells[y][x].targetStones[player]!{
            putStone(targetStone.y, targetStone.x, player)
        }
        checkAllCells()
        changePlayer() //後述
    }

③プレイヤー交代

上記「④石を置く」で石を置いてひっくり返した後に「②石を置ける場所の確認」を再度実施しました。
ここで、各セルの全てのtargetStonesを調べて

  1. 全てemptyであれば、白黒とも石を置けるcellがないためゲーム終了。
  2. 反対のstoneのtargetStonesがひとつでもあればプレーヤー交代し、ゲーム続行
  3. 反対のstoneのtargetStonesがひとつもなければプレーヤー交代せずに、ゲーム続行

となります。
※意図的に高階関数や三項演算子を使ってちょっと複雑に見えるようにしています。頑張って解読してみてください。

changePlayer
private func changePlayer(){
        var canChange:[Stone:Bool] = [:]
        for stone in Stone.colored{
            canChange[stone] = (cells.flatMap{$0}.map{$0.targetStones[stone]!.isEmpty}.filter{!$0}.count != 0)
        }
        player = (canChange[.opposedStone(player)]!) ? .opposedStone(player):player
    }

まとめ

まとめたものが以下になります。コメント抜きで130行くらいです。まあまあシンプルに作れたと思います。コピーしてContentViewに貼り付ければそのまま動きますので試してみてください。
双方の石の数を表示する等の改造をしてみてください。

ContentView
import SwiftUI

enum Stone{
    //Stone(石)の種類は黒と白と無色の3種類
    case black,white,none
    //色付きの石(黒白)のみの集合(Set)。別に配列でもいい。
    static let colored:Set<Self> = [.black,.white]
    //石の色
    static let color = {(stone:Self)->Color in
        switch stone{
        case .black:.black
        case .white:.white
        case .none :.clear
        }
    }
    //反対の石黒の反対は白で、白の反対は黒。透明の反対は存在しないのでこの式にnoneを入れた場合はエラーが発生するようにしてます
    static let opposedStone = {(stone:Self)->Self in
        switch stone {
        case .black:.white
        case .white:.black
        case .none :fatalError()
        }
    }
}

struct Cell:View {
    var stone:Stone = .none
    var targetStones:[Stone:[(y:Int,x:Int)]] = [.black:[],.white:[]]
    mutating func clearTargetStones(){
        for stone in Stone.colored{
            targetStones[stone]!.removeAll()
        }
    }
    
    var body: some View{
        
        ZStack(){
            Rectangle()
                .foregroundStyle(.green)
                .border(.black, width: 1.0)
            Circle()
                .foregroundStyle(Stone.color(stone))
        }.scaledToFit()
    }
}

class ReversiManager:ObservableObject{
    var player:Stone = .black
    @Published var cells:[[Cell]] = []
    init(){
        setUp()
    }
    public func setUp(){
        player = .black
        cells = [[Cell]](repeating: [Cell](repeating: Cell(), count: 8), count: 8)
        putStone(3,3,.black)
        putStone(4,4,.black)
        putStone(3,4,.white)
        putStone(4,3,.white)
        checkAllCells()
    }
    private func putStone(_ y:Int,_ x:Int,_ stone:Stone){
        cells[y][x].stone = stone
    }
    private func checkAllCells(){
        for y in 0..<8{
            for x in 0..<8{
                cells[y][x].clearTargetStones()//ひっくり返せるセルを初期化
                if(cells[y][x].stone == .none){//石が置いてないセルのみ調査
                    checkTargetStones(y,x)
                }
            }
        }
    }
    private func checkTargetStones(_ y:Int,_ x:Int){
        //調査方位(上下左右と斜めの座標方向)
        let directions:[(y:Int,x:Int)] = [(-1,-1),(-1,0),(-1,1),(0,-1),(0,1),(1,-1),(1,0),(1,1)]
        for stone in Stone.colored{//黒白両方を確認
            for direction in directions{
                let foundTargetStones:[(y:Int,x:Int)] = findTargetStones(y,x,direction.y,direction.x,stone)
                cells[y][x].targetStones[stone]!.append(contentsOf: foundTargetStones)
            }
        }
    }
    private func findTargetStones(_ y:Int,_ x:Int,_ dy:Int,_ dx:Int,_ stone:Stone)->[(y:Int,x:Int)]{
        //そのセルはゲーム版の外もしくはそのセルには何も石がないかを確認
        let noneOrOB = {(y:Int,x:Int)->Bool in (!(0..<8).contains(y) || !(0..<8).contains(x)) || self.cells[y][x].stone == .none}

        var position:(y:Int,x:Int) = (y:y+dy,x:x+dx) //探索場所を更新
        if(noneOrOB(position.y, position.x)){return []} //何もないもしくはゲーム版の外なら終了。記録なし
        
        var result:[(y:Int,x:Int)] = []
        while(cells[position.y][position.x].stone == Stone.opposedStone(stone)){ //反対の色の石が出続ける限り探索をおこなう
            result.append(position) //結果を記録
            position = (y:position.y+dy,x:position.x+dx) //探索場所を更新
            if(noneOrOB(position.y, position.x)){return []} //何もないもしくはゲーム版の外なら終了。記録なし
        }
        return result
    }
    public func progressGame(_ y:Int,_ x:Int){
        if(cells[y][x].targetStones[player]!.isEmpty){return}//石を置くことができない
        putStone(y, x, player)
        for targetStone in cells[y][x].targetStones[player]!{
            putStone(targetStone.y, targetStone.x, player)
        }
        checkAllCells()
        changePlayer()
    }
    private func changePlayer(){
        var canChange:[Stone:Bool] = [:]
        for stone in Stone.colored{
            canChange[stone] = (cells.flatMap{$0}.map{$0.targetStones[stone]!.isEmpty}.filter{!$0}.count != 0)
        }
        player = (canChange[.opposedStone(player)]!) ? .opposedStone(player):player
    }
}

struct ContentView: View {
    @ObservedObject var reversiManager = ReversiManager()
    var body: some View {
        VStack(spacing: 0.0){
            ForEach(0..<8, id:\.self){y in
                HStack(spacing: 0.0){
                    ForEach(0..<8, id:\.self){x in
                        reversiManager.cells[y][x]
                            .onTapGesture {
                                reversiManager.progressGame(y, x)
                            }
                    }
                }
            }
        }
        VStack(){
            Button("Restart") {
                reversiManager.setUp()
            }
        }.font(.largeTitle)
    }
}
#Preview {
    ContentView()
}

Discussion