【SwiftUI】少ないコード量でクロスワードパズルを作ってみる

本投稿は TECOTEC Advent Calendar 2022 の20日目の記事です。
こんにちは決済認証システム開発事業部の大﨑です。

実務ではまだSwiftUIでアプリを作ったことがないのですが、今後増えるだろうと思い密かに勉強はしていました。
最近SwiftUIで作られたアプリをレビューしている中で、これなら簡単にできるのではないかと思い立ったのがクロスワードパズルです!(若干クリスマス感もあるし!)

細かい部分の記述やマジックナンバーなど突っ込みどころは多いですがご容赦ください。

今回作るのはこんな感じの4×4の簡単なクロスワードです!

5×5やそれより大きいのもできるのですが、問題を作るのが大変なので一番簡単な4×4にしました(笑)
今回一番苦労したのは問題を作るところでした。。

まず用意する配列は全部で4つ

  • タップした文字を保持するための配列
  • クロスワード完成形の配列
  • タップした場所によって問題を表示する用の配列
  • タップして入力できる文字の配列
    // タップした文字を保持するための配列
    @State var crossWords = [
        ["", "", "", ""],
        ["-", "", "-", ""],
        ["", "", "", ""],
        ["", "-", "", ""]
    ]
    
    // クロスワードの完成形
    let successWords = [
        ["し", "ま", "う", "ま"],
        ["-", "ん", "-", "つ"],
        ["お", "と", "さ", "た"],
        ["に", "-", "い", "け"]
    ]
    
    // 問題を表示用
    let questions = [
        ["①横:白黒の模様を持つ馬", "②縦:袖が無い外套の一種 ヒーローや騎士が身につけること多い", "", "③縦:秋の味覚とされる高級なキノコ"],
        ["", "", "", ""],
        ["④横:便り。連絡。 〇〇〇〇がない\n 縦:想像上の怪物 〇〇ごっこで捕まえる役", "", "⑤縦:ゾウに次ぐ大型の陸上哺乳類 頭部に1本か2本の太い角をもっている", ""],
        ["", "", "⑥横:くぼ地に水がたまった所 湖や沼より小さい", ""]
    ]
    
    // 入力できる文字の設定
    let inputWords = [
        ["ら", "や", "ま", "は", "な", "た", "さ", "か", "あ"],
        ["り", "わ", "み", "ひ", "に", "ち", "し", "き", "い"],
        ["る", "ゆ", "む", "ふ", "ぬ", "つ", "す", "く", "う"],
        ["れ", "ん", "め", "へ", "ね", "て", "せ", "け", "え"],
        ["ろ", "よ", "も", "ほ", "の", "と", "そ", "こ", "お"]
    ]

次にメインとなるクロスワードの表示部分
VStack と HStack でマスの分描画します。

            // クロスワードの表示エリア
            VStack(spacing: 0) {
                ForEach(0 ..< 4) { y in
                    HStack(spacing: 0) {
                        ForEach(0 ..< 4) { x in
                            if crossWords[y][x] != "-" {
                                ZStack {
                                    Button(action:  {
                                        selectedX = x
                                        selectedY = y
                                    }) {
                                        Text(crossWords[y][x])
                                            .frame(maxWidth: .infinity, maxHeight: .infinity)
                                            .border(.black)
                                            .background((selectedY == y && selectedX == x) ? .yellow : .white)
                                            .foregroundColor(Color.black)
                                            .font(Font.system(size: 30).bold())
                                    }
                                    // マス内の左上に問題番号を表示する
                                    Text(questions[y][x].prefix(1))
                                        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
                                }
                            } else {
                                // 選択できない箇所は背景を黒にする
                                Spacer()
                                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                                    .background(Color.black)
                            }
                        }
                        .frame(width: 80)
                    }
                }
                .frame(height: 80)
            }
            .border(.black, width: 2)

左上に数字があるマスをタップしたときに問題を表示します。

            // 問題の表示エリア
            VStack() {
                if questions[selectedY][selectedX] != "" {
                    Text(questions[selectedY][selectedX])
                        .frame(maxWidth:.infinity, alignment: .leading)
                }
            }
            .frame(height: 80)

最後にタップできる文字をボタンで配置します。
入りきらない部分は横ScrollViewにしています。
余談ですがUIKitでScrollViewを実装するのは大変でしたが、SwiftUIではだいぶ簡単になりましたよね!
ボタンタップすると選択しているマスに文字を表示します。

            // 入力文字の表示エリア
            ScrollView([.horizontal]) {
                VStack(spacing: 5) {
                    ForEach(0 ..< 5) { i in
                        HStack(spacing: 5) {
                            ForEach(0 ..< 9) { j in
                                Button(action:  {
                                    // タップした文字をクロスワード上に反映する
                                    crossWords[selectedY][selectedX] = inputWords[i][j]
                                    
                                    // 完成形の配列と同じになったら成功アラートを表示
                                    if crossWords == successWords {
                                        self.successAlert = true
                                    }
                                }) {
                                    Text(inputWords[i][j])
                                        .frame(width: 50, height: 50)
                                        .background(Color(red: 248/255, green: 248/255, blue: 248/255))
                                        .border(.gray)
                                        .foregroundColor(Color.black)
                                }
                                .alert(isPresented: $successAlert) {
                                    Alert(title: Text("おめでとう!完成です!"))
                                }
                            }
                        }
                    }
                }
            }

こちらがクロスワードが完成したときのイメージです!

以下コード全体です。

import SwiftUI

struct ContentView: View {
    
    // タップした文字を保持するための配列
    @State var crossWords = [
        ["", "", "", ""],
        ["-", "", "-", ""],
        ["", "", "", ""],
        ["", "-", "", ""]
    ]
    
    // クロスワードの完成形
    let successWords = [
        ["し", "ま", "う", "ま"],
        ["-", "ん", "-", "つ"],
        ["お", "と", "さ", "た"],
        ["に", "-", "い", "け"]
    ]
    
    // 問題を表示用
    let questions = [
        ["①横:白黒の模様を持つ馬", "②縦:袖が無い外套の一種 ヒーローや騎士が身につけること多い", "", "③縦:秋の味覚とされる高級なキノコ"],
        ["", "", "", ""],
        ["④横:便り。連絡。 〇〇〇〇がない\n 縦:想像上の怪物 〇〇ごっこで捕まえる役", "", "⑤縦:ゾウに次ぐ大型の陸上哺乳類 頭部に1本か2本の太い角をもっている", ""],
        ["", "", "⑥横:くぼ地に水がたまった所 湖や沼より小さい", ""]
    ]
    
    // 入力できる文字の設定
    let inputWords = [
        ["ら", "や", "ま", "は", "な", "た", "さ", "か", "あ"],
        ["り", "わ", "み", "ひ", "に", "ち", "し", "き", "い"],
        ["る", "ゆ", "む", "ふ", "ぬ", "つ", "す", "く", "う"],
        ["れ", "ん", "め", "へ", "ね", "て", "せ", "け", "え"],
        ["ろ", "よ", "も", "ほ", "の", "と", "そ", "こ", "お"]
    ]
    
    // 選択中のボックス
    @State var selectedX = 0
    @State var selectedY = 0

    @State var successAlert = false

    var body: some View {
        VStack(spacing: 0) {
            
            // クロスワードの表示エリア
            VStack(spacing: 0) {
                ForEach(0 ..< 4) { y in
                    HStack(spacing: 0) {
                        ForEach(0 ..< 4) { x in
                            if crossWords[y][x] != "-" {
                                ZStack {
                                    Button(action:  {
                                        selectedX = x
                                        selectedY = y
                                    }) {
                                        Text(crossWords[y][x])
                                            .frame(maxWidth: .infinity, maxHeight: .infinity)
                                            .border(.black)
                                            .background((selectedY == y && selectedX == x) ? .yellow : .white)
                                            .foregroundColor(Color.black)
                                            .font(Font.system(size: 30).bold())
                                    }
                                    // マス内の左上に問題番号を表示する
                                    Text(questions[y][x].prefix(1))
                                        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
                                }
                            } else {
                                // 選択できない箇所は背景を黒にする
                                Spacer()
                                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                                    .background(Color.black)
                            }
                        }
                        .frame(width: 80)
                    }
                }
                .frame(height: 80)
            }
            .border(.black, width: 2)
            
            // 問題の表示エリア
            VStack() {
                if questions[selectedY][selectedX] != "" {
                    Text(questions[selectedY][selectedX])
                        .frame(maxWidth:.infinity, alignment: .leading)
                }
            }
            .frame(height: 80)
            
            // 入力文字の表示エリア
            ScrollView([.horizontal]) {
                VStack(spacing: 5) {
                    ForEach(0 ..< 5) { i in
                        HStack(spacing: 5) {
                            ForEach(0 ..< 9) { j in
                                Button(action:  {
                                    // タップした文字をクロスワード上に反映する
                                    crossWords[selectedY][selectedX] = inputWords[i][j]
                                    
                                    // 完成形の配列と同じになったら成功アラートを表示
                                    if crossWords == successWords {
                                        self.successAlert = true
                                    }
                                }) {
                                    Text(inputWords[i][j])
                                        .frame(width: 50, height: 50)
                                        .background(Color(red: 248/255, green: 248/255, blue: 248/255))
                                        .border(.gray)
                                        .foregroundColor(Color.black)
                                }
                                .alert(isPresented: $successAlert) {
                                    Alert(title: Text("おめでとう!完成です!"))
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

SwiftUIであれば、100行ちょっとのコードでクロスワードパズルができるので、UIKitでやっていた頃と比べると実装コストも雲泥の差になりますね。
もちろん凝ったゲームはゲーム開発用エンジンを使った方がいいのですが、SwiftUIでも内容次第で簡単にゲームが作れるんだなと感動しました!
他には数独とか単純な知育アプリであれば、SwiftUIでも出来そうな気がします。
それに、ちょっとした遊び心があれば勉強も楽しくなりますからね!

では、良いクリスマスをお過ごしください!

www.tecotec.co.jp