スマホアプリで一人回しがしたい(ついでにSwiftUIお勉強)その2

導入 スマホで一人回しがしたい

どうも、湊です。

私はTCGでよく一人回しというのをします。*1

ただこの一人回し、自宅などカードを広げることが許されるスペースでしかできません(当然)。

どこでもやりたくない...?
スマホでやりたくない...?

と思ったのでアプリを作ることにしました。
趣味のツール作成と仕事の勉強(SwiftUI)ができて一石二鳥です。

前回まで

前回↓はキャラクターを配置するViewをSwiftUIで作りました。
スマホアプリで一人回しがしたい(ついでにSwiftUIお勉強)① - minato_blog

今回は盤面全体を作っていくことにします。

画面イメージ(ざっくり)

スマホの画面に収める必要があるため、実際の盤面からは少々レイアウトを変える必要がありそうです。
こんな感じです。

f:id:minato_lotus:20201114153142p:plain
画面イメージ

ピンク色のマスがAFで青色はDFです。
山札や置き場は、ぱっと見でわかればいいのが枚数だけなのでまずはそれだけ表示することにします。
(山札などの中身が見れる画面は別で作ります)

AF, DF

前回作ったフィールド一つあたりのViewを6つ並べるだけでよさそうです。

これはVStackとHStackを用いることで簡単に実現できます。(Viewの表示用データを格納するstructも一緒に記載しておきます)

struct Fields {
    var leftAF = Field()
    var centerAF = Field()
    var rightAF = Field()
    var leftDF = Field()
    var centerDF = Field()
    var rightDF = Field()
}

struct FieldsView: View {
    var fields: Fields
    
    var body: some View {
        GeometryReader { geometry in
            let fieldWidth = min(geometry.size.width, geometry.size.height) / 3.2
            
            VStack {
                HStack {
                    FieldView(field: fields.leftAF)
                        .frame(width: fieldWidth, height: fieldWidth)
                    FieldView(field: fields.centerAF)
                        .frame(width: fieldWidth, height: fieldWidth)
                    FieldView(field: fields.rightAF)
                        .frame(width: fieldWidth, height: fieldWidth)
                }
                HStack {
                    FieldView(field: fields.leftDF)
                        .frame(width: fieldWidth, height: fieldWidth)
                    FieldView(field: fields.centerDF)
                        .frame(width: fieldWidth, height: fieldWidth)
                    FieldView(field: fields.rightDF)
                        .frame(width: fieldWidth, height: fieldWidth)
                }
            }
            .frame(width: geometry.size.width)
        }
    }
}

これをPreviewで見てみるとこんな感じになります。

f:id:minato_lotus:20201114171325p:plain
Preview

山札・ゴミ箱・その他置き場の情報

とりあえずその領域の名前とカード枚数だけわかればよさそうなので、Textで配置しました。

コード。ここを押して展開。

struct DomainInfoView: View {
    var onePlayerDomain: PlayerDomain
    var otherPlayerDomain: PlayerDomain
    
    init(one oneDomain: PlayerDomain, other otherDomain: PlayerDomain) {
        self.onePlayerDomain = oneDomain
        self.otherPlayerDomain = otherDomain
    }
    
    var body: some View {
        GeometryReader { geometry in
            VStack {
                HStack {
                    Spacer()
                    HStack {
                        VStack {
                            HStack {
                                VStack {
                                    Text("山札")
                                    Text("\(otherPlayerDomain.deck.cards.count)枚")
                                }
                                .padding(.leading, /*@START_MENU_TOKEN@*/10/*@END_MENU_TOKEN@*/)
                                VStack {
                                    Text("ゴミ箱")
                                    Text("\(otherPlayerDomain.trashBox.cards.count)枚")
                                }
                                Spacer()
                            }
                            .font(.system(size: 12))
                            HStack {
                                ForEach(otherPlayerDomain.somePlaces, id: \.name) { place in
                                    VStack {
                                        Text(place.name)
                                        Text("\(place.cards.count)枚")
                                    }
                                }
                            }
                            .font(.system(size: 10))
                            .padding(.bottom, 8)
                        }
                    }
                    .frame(width: geometry.size.width * 0.45)
                    .overlay(
                        Rectangle()
                            .frame(width: geometry.size.width * 0.45, height: 1),
                        alignment: .bottom
                    )
                    .overlay(
                        Rectangle()
                            .frame(width: 1),
                        alignment: .leading
                    )
                    .overlay(
                        Rectangle()
                            .frame(width: 1),
                        alignment: .trailing
                    )
                    Spacer()
                    HStack {
                        VStack {
                            HStack {
                                Spacer()
                                VStack {
                                    Text("山札")
                                    Text("\(onePlayerDomain.deck.cards.count)枚")
                                }
                                VStack {
                                    Text("ゴミ箱")
                                    Text("\(onePlayerDomain.trashBox.cards.count)枚")
                                }
                                .padding(.trailing, /*@START_MENU_TOKEN@*/10/*@END_MENU_TOKEN@*/)
                            }
                            .font(.system(size: 12))
                            .padding(.top, 8)
                            HStack {
                                ForEach(onePlayerDomain.somePlaces, id: \.name) { place in
                                    VStack {
                                        Text(place.name)
                                        Text("\(place.cards.count)枚")
                                    }
                                }
                            }
                            .font(.system(size: 10))
                        }
                    }
                    .frame(width: geometry.size.width * 0.45)
                    .overlay(
                        Rectangle()
                            .frame(width: geometry.size.width * 0.45, height: 1),
                        alignment: .top
                    )
                    .overlay(
                        Rectangle()
                            .frame(width: 1),
                        alignment: .leading
                    )
                    .overlay(
                        Rectangle()
                            .frame(width: 1),
                        alignment: .trailing
                    )
                    Spacer()
                }
                .frame(width: geometry.size.width, height: geometry.size.height)
            }
        }
    }
}

やってることは単純なのですが、枠をつけるためのコードで行数が嵩んでしまいました。。
表示はこんな感じになります。

f:id:minato_lotus:20201114203659p:plain
山・ゴミ箱・その他置き場

手札

カード情報から画像を取得して、HStackとForEachで並べます。

struct Hand {
    var cards: [Card]()
}

struct HandView: View {
    var hand: Hand
    
    var body: some View {
        HStack {
            ForEach(hand.cards.indices) { index in
                Image(hand.cards[index].imageName)
                    .resizable()
                    .scaledToFit()
            }
        }
    }
}

盤面全体

それではここまでで作ってきたViewで画面を組み立てましょう

f:id:minato_lotus:20201114205037p:plain
盤面構成図

コードは上の図の通り並べるだけです。

struct BoardView: View {
    var board: Board
    
    var body: some View {
        GeometryReader { geometry in
            VStack {
                HandView(hand: board.otherDomain.hand)
                    .frame(width: geometry.size.width, height: geometry.size.width / 8)
                FieldsView(fields: board.otherDomain.fields)
                    .rotationEffect(Angle(degrees: 180))
                DomainInfoView(one: board.oneDomain, other: board.otherDomain)
                    .frame(width: geometry.size.width, height: geometry.size.width / 6, alignment: .center)
                FieldsView(fields: board.oneDomain.fields)
                HandView(hand: board.oneDomain.hand)
                    .frame(width: geometry.size.width, height: geometry.size.width / 8)
            }
        }
    }
}

すごい簡単ですね。ここまででこんな感じの表示になります。

f:id:minato_lotus:20201115014556p:plain
Preview

まだちょっと味気ないですが、必要なものは表示できたのではないでしょうか。

その2はここまで。
その3の記事では実際に表示しているこれらのカードを操作する部分をつくっていきます。


各種カード画像はLycee公式サイトから拝借しました。

LYCEE OVERTURE TRADING CARD GAME || リセ オーバーチュア トレーディングカードゲーム

*1:一人回し:一人で2つのデッキを使って練習すること

スマホアプリで一人回しがしたい(ついでにSwiftUIお勉強)その1

スマホで一人回しがしたい

どうも、湊です。
私はTCGでよく一人回しというのをします。*1

ただこの一人回し、自宅などカードを広げることが許されるスペースでしかできません(当然)。

どこでもやりたくない...?
スマホでやりたくない...?

と思ったのでアプリを作ることにしました。
趣味のツール作成と仕事の勉強(SwiftUI)ができて一石二鳥です。

題材

今一番練習したいのはLyceeなのでLyceeで作ってみることにします。
(盤面として表現する必要のあるものが少ないので作るのが楽そうという事情もあります。)

要件(ざっくり)

  • 盤面の情報表示
    • AF, DFに配置しているカード情報
    • 山札、ゴミ箱、○○置き場などの情報
    • 手札
  • 盤面の操作
    • ドロー, シャッフル, デッキを見る, デッキからカードを抜き出し手札に加える etc...
    • カードを盤面に配置する, 盤面のカードを移動する, キャラクターをタップする(攻撃など) etc...

まずは情報表示の部分をやっていこうと思います。

表示内容洗い出し

プログラムを作るにはゲームの要素をコードで表現する必要があります。
対象を洗い出していきましょう。

カード

f:id:minato_lotus:20201113231232p:plain
カード参考(かわいい)

リセのカードはこんな感じ。
今回の要件ですと、とりあえず盤面にカードを表示できればよさそうなので、一旦テキストは無視して画像だけ呼び出せるようにしてみます。
オブジェクトとしてはIDを持っていてそれに対応した画像を呼び出せれば十分そうなので以下のようにしました。

struct Card {
    let id: CardID
    private let imageName: String
    
    init(id: CardID) {
        self.id = id
        self.imageName = id.prefix + String(id.number) + (id.pallarelSuffix ?? "")
    }
}

struct CardID {
    let prefix = "LO-"
    let number: Int
    let pallarelSuffix: String?
    
    init?(number: Int, pallarel: String? = nil) {
        guard number > 0 else { return nil }
        self.number = number
        if let pallarel = pallarel {
            self.pallarelSuffix = "-\(pallarel)"
        } else {
            self.pallarelSuffix = nil
        }
    }
    
    var string: String {
        return prefix + String(format: "%04d", number) + (pallarelSuffix ?? "")
    }
}

盤面

Lyceeの盤面はこんな感じ

f:id:minato_lotus:20201113162627p:plain
盤面
キャラクターなどのカードを置く枠が6つ、デッキやゴミ箱を置く枠があります。
この画像には現れていませんが、その他「横」と呼ばれる領域や「〇〇置き場」という領域が利用できます。
(実際の対戦では左の部分に置いている方が多いと思います)

キャラクターなどのカードを配置する枠 (6枠)

紹介していませんが、リセのカード種類には4種類(キャラクター、イベント、アイテム、エリア)があります。
そしてこの6枠にはキャラクター、アイテム、エリアがそれぞれ一枚ずつしか配置できません。*2

というわけでこのように表現してみました。

struct Field {
    var character: Card?
    var characterState: CharacterState = .stand
    var item: Card?
    var area: Card?
    
    enum CharacterState {
        case stand, rest
    }
}

紹介していませんでしたが、キャラクターは縦向き、横向きの状態で配置されることがあり、CharacterStateはそれを表現しています。

盤面全体

上記キャラクターなどを配置する6枠に加え、デッキ、ゴミ箱、「横」、「〇〇置き場」などをプレイヤーの管理領域として表現するならこのような形でしょうか

struct PlayerDomain {
    var fields = Fields()
    var deck = Deck()
    var trashBox = TrashBox()
    var somePlaces = [Place]()
    var hand = Hand()
}

struct Fields {
    var leftAF = Field()
    var centerAF = Field()
    var rightAF = Field()
    var leftDF = Field()
    var centerDF = Field()
    var rightDF = Field()
}

struct Deck {
    var cards = [Card]()
}

struct TrashBox {
    var cards = [Card]()
}

struct Hand {
    var cards = [Card]()
}

struct Place {
    let name: String
    var cards = [Card]()
}

粗はありそうですが、これでカードや盤面の情報を表現できました。
これをSwiftUIを用いて画面に表示していきます。

表示の実装

キャラクターを配置する枠

まずはキャラクターを枠に配置できるようにしましょう。
また、1枠にはキャラクターやアイテム、エリアが配置されうるため、複数のものを配置している場合はすべてが視認できるようにずらして配置してあげるのが良さそうですね。
こんな感じにしてみました。

struct FieldView: View {
    var field: Field
    
    var body: some View {
        GeometryReader { geometry in
            let fieldWidth = min(geometry.size.width, geometry.size.height)
            let areaOffset = CGSize(width: fieldWidth / 16, height: fieldWidth / 10)
            let itemOffset = CGSize(width: fieldWidth / 8, height: fieldWidth / 6)
            
            ZStack {
                if let area = field.area {
                    Image(area.imageName)
                        .resizable()
                        .scaledToFit()
                        .offset(field.character != nil ? areaOffset : .zero)
                }
                if let character = field.character {
                    Image(character.imageName)
                        .resizable()
                        .rotationEffect(.init(degrees: field.characterState == .rest ? 270 : 0))
                        .scaledToFit()
                    if let item = field.item {
                        Image(item.imageName)
                            .resizable()
                            .offset(itemOffset)
                            .scaledToFit()
                    }
                }
            }
            .frame(width: fieldWidth, height: fieldWidth)
        }
    }
}

プレビューするとこんな感じです。

f:id:minato_lotus:20201114004508p:plain
Preview

あとはこれを複数個並べたらキャラクターなどを配置する枠は出来上がりそうです。

次回

長くなってきたので今回はここまでにします。
②以降で他のViewの構築や操作周りの実装をしていきます。
SwiftUIは不慣れでまだ探り探りですので、ご指摘・ご質問等歓迎です。


各種画像はLycee公式サイトから拝借しました。

LYCEE OVERTURE TRADING CARD GAME || リセ オーバーチュア トレーディングカードゲーム

*1:一人回し:一人で2つのデッキを使って練習すること

*2:正確にはアイテムはキャラクターに装備させるものなので、キャラクターがいる状態でしか配置できません。なのでプログラムとしてもキャラクターがアイテムを持つことを表現できるとよさそうです。ただ実現したいことに対して費用対効果が高いと判断し今回はかんたんにしています。

はじめまして

はじめまして、湊(みなと)といいます。

趣味でキャラクターもののTCG、お仕事でプログラミング(iOSアプリの開発)をしています。

このブログではそういったTCGやプログラミングの話題をメインに、日々の体験や考えを書き連ねていきます。個人的な目的は書くことによる思考の整理・備忘ですが、何か読者の皆様の参考になるものがあれば幸いです。

 

プレイしているTCG

 Twitter