はじめに
最近、3歳の子どもが私のiPhoneを使って遊ぶことが増えてきました。
ゲームをしたり、動画を見たり、画面をタップして楽しそうに遊んでいる姿を見ているうちに、ふと思ったんです。
「自分でも、子どもが喜んで遊んでくれるゲームアプリを作れないかな?」
私は普段Webエンジニアとして働いていて、iOSアプリの開発は未経験です。興味はあったものの、なかなか作り始めるきっかけがありませんでした。
そんな中、社内のペアブログ企画でゲームエンジニアの高岡さんとペアになり、打ち合わせでこの話をしたところ、子ども向けのゲームアプリを一緒に作ることになりました。
最近、子どもがよく自分のiPhoneで遊んでいるんですよ。せっかくなら、自分でも子どもが喜びそうなゲームアプリを作ってみたいなと思っていて。
それ、面白そうですね! せっかくペアになったので、一緒に作ってみませんか?
いいですね! ただ、iOSアプリは作ったことがないので、いろいろ教えてもらうことになりそうですが……。
全然大丈夫です! ゲームの内容や仕組みを一緒に考えながら作っていきましょう!
それなら・・・・・・よろしくお願いします!!
こうして、ペアブログの打ち合わせをきっかけに、子ども向けiOSゲームアプリの開発が始まりました。
今回作ったのが、「できたよ!どうぶつパーク」です。
歯みがき・手洗い・お片付けなど、毎日の行動を達成することで、動物たちが成長していく子ども向けのアプリです。
役割分担はこんな感じです。
- 高岡さん:ゲーム設計・画面構成・ミッションや成長ルールの設計
- 片岡:高岡さんに教えてもらいながらSwiftで実装
- Claude Code:アイデア整理や実装を支える相棒として参加
- 片岡の子ども(2人):最年少テスターとして実際に遊んで検証。3歳(今年12月で4歳)と、来月2歳になる兄弟
この記事では、iOSアプリ開発未経験のWebエンジニアが、ゲームエンジニアとClaude Codeの力を借りながら、子ども向けアプリを完成させるまでの流れを紹介します。
使用技術
- 言語:Swift 6
- UIフレームワーク:SwiftUI
- データ永続化:RealmSwift 20.0.5
- 開発環境:Xcode 26
- 対象OS:iOS(実機確認あり)
- 音声再生:AVFoundation(音声再生・動画ループ)
- 動画生成:生成AI(ループ動画 / シネマグラフ)
- 開発サポート:Claude Code
まず動かす——Xcode初体験と実機確認までの道のり
Xcodeを使うのはこれが初めて。しかもUIはほぼ英語です。「Signing & Capabilities」「Embed & Sign」など、見慣れない用語が並ぶ画面を前に、まずどこを触ればいいかを把握するところから始まりました。
とはいえ、環境構築そのものは一つひとつ順番に対応すれば進められました。つまずいた点をいくつか紹介します。
まず、既存プロジェクトで使用していたRealm 10系が、Xcode 26とSwift 6に対応しておらず、ビルドエラーが発生しました。Realmを20系へ更新することで解消しました。
次に、実機で起動すると以下のエラーでクラッシュしました。
Library not loaded: RealmSwift.framework
原因は、RealmSwift.frameworkがアプリに同梱されていなかったことでした。Xcodeの設定でRealmSwiftをEmbed & Signに変更し、起動できるようになりました。
さらに実機確認では、ミッション詳細のテキストが背景に溶け込んで読めない問題も見つかりました。シミュレータでは問題なく表示されていたので、実機ならではの罠でした。
原因は、テキストカラーに指定していたColor.secondaryです。ダークモード時に色が変わるため、固定のクリーム色背景とは相性がよくありませんでした。
// 変更前
static let textSecondary = Color.secondary
// 変更後
static let textSecondary = Color(red: 0.45, green: 0.42, blue: 0.37)
このアプリは固定配色で設計していたので、テキストカラーも固定値に変更しました。シミュレータで問題なくても実機で崩れる——これはiOS開発ならではの気づきでした。
ミッション永続化の設計(Realm × SwiftUI × 高岡さん)
変わらない情報と変わる情報を分けて、DBには最小限のデータだけ持つようにしましょう。「今日達成済みか」も状態として保存するより、達成日から導出する方がリセット処理も不要になってシンプルになりますよ。
普段はサーバ側を作ることが多いのですが、今回はあえてバックエンドもクラウド同期も作らず、端末内のRealmだけで完結させました。まずはiOSアプリ本体を作りきることに集中したかったので。
今回のゴールは、短期間で動くものを作り、実際に子どもに遊んでもらって反応を見ることでした。ユーザーは手元の1人だけで、端末間でデータを共有する必要もありません。そのため、バックエンドやアカウント・同期の仕組みはあえて作らず、保存はすべて端末内のRealmで完結させています。もし今後リリースを目指すなら、ミッションを追加・変更するたびにアプリを再申請せずに済むよう、定義の配信などをサーバ側に寄せる余地はありますが、今回のプロトタイプには不要と判断しました。
ミッション機能を実装するにあたり、最初に整理したのが「永続化すべきデータは何か」という点です。
ミッションには、タイトル・アイコン・説明文などの静的な定義と、最後に達成した日付などのユーザーごとに変化する状態があります。
今回はこの2つを分離し、静的な情報はコードで、可変状態だけをRealmで管理する構成にしました。
// 静的なミッション定義
struct MissionDefinition {
let id: String
let title: String
let icon: String
}
// ユーザーごとに変化する進捗だけを永続化
class MissionProgress: EmbeddedObject {
@Persisted var lastClearedDate: Date = .distantPast
}
Realmに保存するのは、ミッションの達成状態を復元するために必要なデータだけです。
タイトルやアイコンまで保存すると、コード上の定義とDB内のデータが二重管理になり、変更時の同期処理も必要になります。静的な定義と進捗を分けることで、永続化するデータを最小限に抑えました。
次に考えたのが、「今日達成済みか」をどのように表現するかです。
単純なBoolで管理すると、日付が変わったタイミングでfalseへ戻す処理が必要になります。また、アプリが日付変更の瞬間に起動しているとは限らないため、リセット処理をどのタイミングで実行するかも考えなければなりません。
そこで、達成済みかどうかをDBに直接保存するのではなく、最後に達成した日付から導出する設計にしました。
func isCleared(on date: Date) -> Bool {
Calendar.current.isDate(lastClearedDate, inSameDayAs: date)
}
lastClearedDateと現在日を比較し、同じ日であれば達成済み、異なる日であれば未達成と判定します。
これにより、日付が変わった際に全ミッションを更新する必要がなくなり、DBへのリセット書き込みも発生しません。「達成済み」という状態を保存するのではなく、保存した事実から計算する形です。
また、lastClearedDateはOptionalにせず、初期値にDate.distantPastを設定しました。
@Persisted var lastClearedDate: Date = .distantPast
未達成のミッションは、最後の達成日が十分に過去の日付として扱われます。そのため、初回でも特別なnil判定を追加せず、通常と同じ日付比較のロジックで未達成と判定できます。
今回の設計では、静的定義と可変状態を分離し、画面で必要な状態は保存データから導出することで、Realmのデータ構造と日付変更時の処理をシンプルに保ちました。
子どもが楽しめるアプリにするための改善
基本的な機能ができたあと、子どもが実際に触って楽しめるように、動物の成長演出や操作したときの反応を追加していきました。
高岡さんの実装:動物を育てる体験
動物が成長する瞬間を楽しんでもらえるように、専用のレベルアップ画面を作りました。動物を選んだり名前をつけたりする部分も、自分で育てる動物を決める大切な体験だと思っています。
高岡さんの実装のポイントを、設計の「なぜ」と一緒に紹介します。
星の数だけが「今の姿」を決める
土台になるのが、成長段階の持ち方です。段階を別のフラグとして保存するのではなく、合計星数だけを唯一の基準にして、そこから段階を導き出す設計にしました。
enum GrowthStage: Int, CaseIterable, Comparable {
case sleepy = 1 // あかちゃん(星 0〜4)
case excited // こども (星 5〜9)
case sparkly // おとな (星 10〜)
}
// 星数だけが「今の姿」を決める
var stage: GrowthStage { GrowthStage(starCount: totalStars) }
段階を状態として別に持つと、「星は10なのに段階は1のまま」といったズレが起こり得ます。星数からその都度導出すれば、そもそも不整合が生まれません。おかげで成長を扱うコードはどこも「保存・更新」を持たず、ただ星数を渡すだけになりました。
動物を「動き」で見せる——ループ動画
段階が上がると、画面の動物の姿も変わります。静止画ではなくシネマグラフ(無音の短いループ動画)で見せることで、生きている感じを出しました。SwiftUIから直接は扱いにくいので、AVPlayerLooperをラップしています。
final class LoopingPlayerView: UIView {
// レイアウトに追従させるため backing layer を差し替える
override static var layerClass: AnyClass { AVPlayerLayer.self }
func play(resourceName: String) {
guard currentResource != resourceName else { return } // 同じ動画なら何もしない
let player = AVQueuePlayer()
looper = AVPlayerLooper(player: player, templateItem: item) // 継ぎ目なくループ
player.play()
}
}
「最後まで再生したら頭に戻す」を素朴に書くと、境目で一瞬カクッと止まります。AVQueuePlayerとAVPlayerLooperの組み合わせなら継ぎ目なくつながるので、子どもがずっと眺めても違和感がありません。「同じ動画なら作り直さない」の一行も、SwiftUIの再描画でチラつかないための工夫です。
ループ動画は、動物の静止画をもとに生成AIで作成しました。プロンプトはこんな感じです。
【目的】動物の静止画をベースに、生きているように自然に動く
ループ動画(シネマグラフ)を生成。iOSアプリ上で表示する。
【仕様】3〜5秒 / 元画像と同じ比率(または9:16)/ MP4 /
始まりと終わりがスムーズに繋がるループ
【動き(子犬が寝ている場合)】
・お腹や背中が呼吸に合わせてゆっくり上下する
・耳や毛並みが、かすかな風でほんの少しだけ揺れる
・体や背景は大きく動かさず、眠ったままキープ
・目や口が不自然に変形・破綻しないようにする
「呼吸で命を感じさせつつ、体や背景は動かさない」としたのは、動きを絞ることでAIの破綻を防ぎ、ループの継ぎ目を目立たなくするためです。動画が未用意の動物は、自動で静止画の仮表示にフォールバックします。
成長した瞬間を、祝う
段階が1つ上がった瞬間に、全画面のレベルアップ演出を挟みます。大事なのは「いつ発火させるか」。星を足した前後で段階を比べるだけで判定できます。
let previousStage = stage
missionStore.clearMission(mission.id) // 星 +1
if stage > previousStage { // 段階が上がった時だけ
presentLevelUp(from: previousStage) // レベルアップ音も優先
}
段階は星数から導出しているので、GrowthStageをComparableにしておけば>で比較できます。演出と効果音は排他にしていて、レベルアップ時は通常の達成音を鳴らしません。1回のタップで音が重なるのを防ぐためです。
相棒を選んで、覚えておく
育てる動物は最初に1回だけ選びます。ここで工夫したのが、動画が揃った動物だけを自動で「育てられる」状態にする判定です。
…
記事の続きは下のURLをクリック!
https://rightcode.co.jp/blogs/56512
もっと上を目指したあなたへ
現在、ライトコードでは「WEBエンジニア」「データエンジニア」「ゲーム・XRエンジニア」「UI/UXデザイナー」「PM/PMO」などのポジションで積極採用中です!
ライトコードは、技術力に定評のある受託開発をメインにしているIT企業。
有名WEBサービスやアプリの受託開発などの企画、開発案件が目白押しの状況です!
- もっと大きなことに挑戦したい!
- エンジニアとしてもっと成長したい!
- モダンな技術に触れたい!
現状に満足していない方は、まずは一度、【Wantedly内の弊社ページ】をのぞいてみてください。