28
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AIを使ってAndroidアプリの作成と自動テストを1日でやってみたら想像以上だった

Last updated at Posted at 2025-12-01

はじめに

このチャレンジを行おうと思ったきっかけは、AI活用により自身の持つ知見が少ない領域でどこまでスピード開発が行えるかに興味があったのと、そこから得られた学びの共有を行いたかったためです。

目標

  • Androidアプリの作成とそのテスト自動化を1日で完了する

条件

  • 期限は1日(8時間)
  • 個人開発
  • Android Studioを開発環境として使用
  • 使用言語はKotlin
  • AIはCopilotとGemini

※Copilotは自称GPT-5ベース、実際にはGPT-4か4oらしい
※Geminiは自称1.5-Flash、どこかで2.5-Flashの表記をみたような…
※AIは自分自身のモデルを正確に答えられないようです

作成するアプリの内容

  • 2D迷路
  • 制限時間30秒以内にゴールへ辿り着くとステージクリア
  • ステージクリアすると難易度が上がる
  • 制限時間内にゴールへ辿り着けなかった場合ゲームオーバー
  • ゲームオーバー画面で画面をタップすることで新にゲームを開始
  • 操作はスワイプとゲームオーバー画面のタップ
  • 画面上でスワイプした距離の2倍、スワイプした方向に移動
  • 画面の構成要素は移動可能な通路(◻︎)、移動不可の壁(▫️)、自分の現在地(🔵)、ゴール(🔴)、難易度を表すLevel表記(1からの数値)、制限時間の表記

テスト自動化の内容

  • 使用する言語はPython
  • Androidを制御するためにadbコマンドを使用
    ・アプリの起動
    ・キャプチャの取得
    ・スワイプやタッチ等の操作

初回のプロンプトで与える内容

1日でAndroidアプリの作成とテストの自動化を実装したいと考えています。
Androidアプリの作成の経験はあまりないです。
アプリの開発環境はAndroid Studioを考えています。
使用する言語はKotlinです。

以下アプリの内容
・2D迷路
・制限時間30秒以内にゴールへ辿り着くとステージクリア
・ステージクリアすると難易度が上がる
・制限時間内にゴールへ辿り着けなかった場合ゲームオーバー
・ゲームオーバー画面で画面をタップすることで新にゲームを開始
・操作はスワイプとゲームオーバー画面のタップ
・画面上でスワイプした距離の2倍、スワイプした方向に移動
・画面の構成要素は移動可能な通路(白)、移動不可の壁(暗いグレー)、自分の現在地(青い丸)、ゴール(赤い丸)、難易度を表すLevel表記(1からの数値)、制限時間の表記
・Levelの表記と制限時間の表記はテストで使用するため、adbコマンドで取得できる形式で実装します。

以下自動テストの内容
・言語はPythonを使用
・端末の操作にはadbコマンドを使用
・アプリの起動、ゲームオーバーの判定、ステージクリアの際に継続してテストを実施、ゲームオーバーになったらテストを終了

やったこと

Androidアプリの作成準備(1時間)

Android Studioのセットアップ
Android Studioのダウンロードとインストール
実機端末の準備
USBデバッグON

Copilot

アプリの作成(3.5時間)

プロンプトでのやり取りの内容

初回プロンプト
1日でAndroidアプリの作成とテストの自動化を実装したいと考えています。
Androidアプリの作成の経験はあまりないです。
アプリの開発環境はAndroid Studioを考えています。
使用する言語はKotlinです。

以下アプリの内容
・2D迷路
・制限時間30秒以内にゴールへ辿り着くとステージクリア
・ステージクリアすると難易度が上がる
・制限時間内にゴールへ辿り着けなかった場合ゲームオーバー
・ゲームオーバー画面で画面をタップすることで新にゲームを開始
・操作はスワイプとゲームオーバー画面のタップ
・画面上でスワイプした距離の2倍、スワイプした方向に移動
・画面の構成要素は移動可能な通路(白)、移動不可の壁(暗いグレー)、自分の現在地(青い丸)、ゴール(赤い丸)、難易度を表すLevel表記(1からの数値)、制限時間の表記
・Levelの表記と制限時間の表記はテストで使用するため、adbコマンドで取得できる形式で実装します。
初期プロンプト入力の結果
0) 今日の進め方(1日プラン)
 午前(3h)
  Android Studioプロジェクト作成(Kotlin, Jetpack Compose)
  迷路生成・描画(Canvas)
  スワイプ→移動(距離2倍)、壁衝突処理
  HUD(Level/Time)をadbで読めるTextViewとして配置(※Compose内にAndroidView(TextView))

 午後(3–4h)
  制限時間30秒・ゲームオーバー処理
  ステージクリア→難易度上昇(迷路サイズ拡大)
  Game Over画面タップで新規開始

 夕方(1–2h)
  Python(adb操作)自動テスト実装:
  アプリ起動→UIからLevel/Timeをadbで取得→スワイプ操作→ステージクリア時は継続→ゲームオーバー検出で終了
  
1) プロジェクト作成
 ApplicationId: com.example.maze(テストのresource-idで使うため固定推奨)
 Min SDK: 24+(任意)
 テンプレート: “Empty Activity”
 言語: Kotlin
 UI: Jetpack Compose(ゲーム描画はCanvas、ただしLevel/TimeはTextViewをAndroidViewで表示してadb取得可能に)

2) 仕様の要点(MVP)
3) リソースIDの定義(adb取得用)[※後述 app/src/main/res/values/ids.xml]
4) Kotlin 実装(最小構成)[※後述 MainActivity.kt]
5) adbで情報取得(手動確認)
6) Python(adb)自動テストスクリプト
7) 重要ポイント(仕様に沿った実装の根拠)
8) よくある詰まりポイントと回避策
9) 次にすべきこと(チェックリスト)
  1. Android Studioでプロジェクト作成(上記ファイルを貼付)
  2. ids.xmlを追加(resource-id定義)
  3. 実機(またはエミュレータ)にインストール
  4. adb shell am start -n com.example.maze/.MainActivityで起動
  5. uiautomator dump→level_text/time_textが読めることを確認
  6. python test_maze.pyを実行→ログで進行確認
10) 拡張(任意)
11) 質問&提案
  迷路サイズや難易度の上げ方、30秒固定でOKですか?(仕様では固定でしたが、変更可能)
  HUDの取得はUI Automator XMLで良いですか?Logcat優先にもできます。
  必要なら、**Android Studio用のプロジェクト雛形(フォルダ構成一式)**をこちらで生成してお渡しします。

※出力が長かったので必要だと思った部分だけ書いてあります(以下同様)

9)次にすべきこと(チェックリスト)があるため手順に沿って進めます。

1. Android Studioでプロジェクト作成(上記ファイルを貼付)
2. ids.xmlを追加(resource-id定義)
3. 実機(またはエミュレータ)にインストール

プロンプトで「Androidアプリの作成の経験はあまりないです。」と少しあるかのように見栄を張ってしまったためか、本当は欲しかったどこに何を貼り付けるのかの情報が全くありません(涙)
素直に「全く作成したことはありません」とした方が詳細な手順を期待できたかもしれないです。

やったこと
1. Android StudioでMazeAppという名前でプロジェクト作成
2. {Sproject_root}/app/src/main/java/com/example/mazeapp/MainActivity.kyにプロンプトで出力されたMainActivity.kyを上書き
3. {Sproject_root}/app/src/main/res/values/ids.xmlを追加してプロンプトで出力されたids.xmlの内容で上書き
4. ビルド

下記のコードをプロジェクトに反映

MainActivity.kt(コードを表示 折りたたみ)
MainActivity.kt

package com.example.maze

import android.os.Bundle
import android.view.View
import android.widget.TextView
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.awaitPointerEventScope
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.awaitFirstDown
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.consume
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.viewinterop.AndroidView
import kotlinx.coroutines.delay
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.random.Random

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MazeGameApp()
        }
    }
}

data class Maze(val rows: Int, val cols: Int, val grid: Array<BooleanArray>, val start: Pair<Int, Int>, val goal: Pair<Int, Int>)

@Composable
fun MazeGameApp() {
    // Game states
    var level by remember { mutableStateOf(1) }
    var timeLeftMs by remember { mutableStateOf(30_000L) }
    var gameOver by remember { mutableStateOf(false) }
    var maze by remember { mutableStateOf(generateMazeForLevel(level)) }
    var playerPos by remember { mutableStateOf(cellCenter(maze.start, maze)) }

    // Logcat (adb logcat -s MazeTAG で取得可能)
    LaunchedEffect(level) {
        android.util.Log.i("MazeTAG", "Level=$level started")
    }
    LaunchedEffect(timeLeftMs) {
        android.util.Log.i("MazeTAG", "TimeLeftMs=$timeLeftMs")
    }

    // Timer
    LaunchedEffect(level, gameOver) {
        timeLeftMs = 30_000L
        if (!gameOver) {
            val startTime = System.currentTimeMillis()
            while (timeLeftMs > 0 && !gameOver) {
                delay(100L)
                val elapsed = System.currentTimeMillis() - startTime
                timeLeftMs = max(0L, 30_000L - elapsed)
            }
            if (timeLeftMs <= 0) {
                gameOver = true
            }
        }
    }

    val context = LocalContext.current

    Column(
        modifier = Modifier.fillMaxSize().background(Color.Black)
    ) {
        // HUD Row (AndroidView TextView で adb から text を取得可能にする)
        Row(
            modifier = Modifier.fillMaxWidth().padding(8.dp),
            horizontalArrangement = Arrangement.SpaceBetween,
            verticalAlignment = Alignment.CenterVertically
        ) {
            AndroidView(
                modifier = Modifier.wrapContentSize(),
                factory = { ctx ->
                    TextView(ctx).apply {
                        id = R.id.level_text
                        contentDescription = "LEVEL_TEXT"
                        textSize = 18f
                        setTextColor(android.graphics.Color.WHITE)
                    }
                },
                update = { tv ->
                    tv.text = "Level: $level"
                }
            )
            AndroidView(
                modifier = Modifier.wrapContentSize(),
                factory = { ctx ->
                    TextView(ctx).apply {
                        id = R.id.time_text
                        contentDescription = "TIME_TEXT"
                        textSize = 18f
                        setTextColor(android.graphics.Color.WHITE)
                    }
                },
                update = { tv ->
                    val sec = (timeLeftMs / 1000.0)
                    tv.text = String.format("Time: %.1fs", sec)
                }
            )
        }

        Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
            // Game Canvas
            GameCanvas(
                maze = maze,
                playerPos = playerPos,
                onSwipeMove = { delta ->
                    // 仕様: スワイプ距離の 2 倍移動
                    val desired = Offset(delta.x * 2f, delta.y * 2f)
                    val next = tryMovePlayer(playerPos, desired, maze)
                    playerPos = next

                    // ゴール到達チェック
                    val goalCenter = cellCenter(maze.goal, maze)
                    if (distance(playerPos, goalCenter) < 1.0f) {
                        level += 1
                        maze = generateMazeForLevel(level)
                        playerPos = cellCenter(maze.start, maze)
                        timeLeftMs = 30_000L
                        android.util.Log.i("MazeTAG", "StageCleared! Next Level=$level")
                    }
                }
            )

            // Game Over overlay
            if (gameOver) {
                AndroidView(
                    modifier = Modifier
                        .fillMaxSize(),
                    factory = { ctx ->
                        TextView(ctx).apply {
                            id = R.id.game_over_text
                            contentDescription = "GAME_OVER_TEXT"
                            text = "GAME OVER (tap to restart)"
                            textSize = 24f
                            setTextColor(android.graphics.Color.RED)
                            setBackgroundColor(android.graphics.Color.argb(160, 0, 0, 0))
                            gravity = android.view.Gravity.CENTER
                        }
                    }
                )
                // タップで新規開始
                Box(
                    modifier = Modifier
                        .fillMaxSize()
                        .pointerInput(Unit) {
                            awaitPointerEventScope {
                                awaitFirstDown()
                                // タップ検知
                                gameOver = false
                                level = 1
                                maze = generateMazeForLevel(level)
                                playerPos = cellCenter(maze.start, maze)
                                timeLeftMs = 30_000L
                                android.util.Log.i("MazeTAG", "Restart")
                            }
                        }
                )
            }
        }
    }
}

// ===== Maze generation (recursive backtracking) =====
fun generateMazeForLevel(level: Int): Maze {
    val base = 15
    val size = base + (level - 1) * 2 // 難易度上昇 → 迷路サイズ拡大
    val rows = size
    val cols = size
    val grid = Array(rows) { BooleanArray(cols) { false } } // false=壁, true=通路

    // carve passages
    fun carve(r: Int, c: Int) {
        grid[r][c] = true
        val dirs = mutableListOf(0, 1, 2, 3).shuffled()
        val dr = intArrayOf(-1, 1, 0, 0)
        val dc = intArrayOf(0, 0, -1, 1)
        for (d in dirs) {
            val nr = r + dr[d] * 2
            val nc = c + dc[d] * 2
            if (nr in 0 until rows && nc in 0 until cols && !grid[nr][nc]) {
                grid[r + dr[d]][c + dc[d]] = true
                carve(nr, nc)
            }
        }
    }

    val start = 0 to 0
    val goal = (rows - 1) to (cols - 1)

    // 縦横奇数に調整(壁で区切りやすくする)
    val rr = if (rows % 2 == 0) rows - 1 else rows
    val cc = if (cols % 2 == 0) cols - 1 else cols

    // 再生成のため少し乱数
    val sr = 0
    val sc = 0
    carve(sr, sc)

    // 念のためスタート/ゴールを通路化
    grid[start.first][start.second] = true
    grid[goal.first][goal.second] = true

    return Maze(rows, cols, grid, start, goal)
}

// プレイヤー位置はピクセル座標。セル中心をピクセルに変換するヘルパ
fun cellCenter(cell: Pair<Int, Int>, maze: Maze): Offset {
    // 仮の値。実際の Canvas サイズで補正は GameCanvas 側で行うため
    return Offset(cell.second.toFloat(), cell.first.toFloat())
}

// 距離
fun distance(a: Offset, b: Offset): Float {
    val dx = a.x - b.x
    val dy = a.y - b.y
    return kotlin.math.sqrt(dx*dx + dy*dy)
}

// ===== Canvas 描画とスワイプ検出 =====
@Composable
fun GameCanvas(
    maze: Maze,
    playerPos: Offset,
    onSwipeMove: (Offset) -> Unit
) {
    // Canvas上での座標系を管理するため、描画中に計算(HUDの下全域にフィット)
    var lastDown by remember { mutableStateOf<Offset?>(null) }
    var totalDrag by remember { mutableStateOf(Offset.Zero) }

    Box(modifier = Modifier.fillMaxSize()) {
        Canvas(
            modifier = Modifier
                .fillMaxSize()
                .background(Color.Black)
                .pointerInput(maze) {
                    detectDragGesturesCustom(
                        onDragStart = { pos -> lastDown = pos; totalDrag = Offset.Zero },
                        onDrag = { change, dragAmount ->
                            totalDrag += dragAmount
                            change.consume()
                        },
                        onDragEnd = {
                            val delta = totalDrag
                            totalDrag = Offset.Zero
                            lastDown = null
                            onSwipeMove(delta)
                        }
                    )
                }
        ) {
            val w = size.width
            val h = size.height
            val cellW = w / maze.cols
            val cellH = h / maze.rows
            val cellSize = min(cellW, cellH)

            // draw maze: walls dark gray, paths white
            for (r in 0 until maze.rows) {
                for (c in 0 until maze.cols) {
                    val left = c * cellSize
                    val top = r * cellSize
                    val rectColor = if (maze.grid[r][c]) Color.White else Color(0xFF333333)
                    drawRect(
                        color = rectColor,
                        topLeft = Offset(left, top),
                        size = androidx.compose.ui.geometry.Size(cellSize, cellSize)
                    )
                }
            }

            // goal (red circle)
            val goalCenter = Offset(
                maze.goal.second * cellSize + cellSize / 2f,
                maze.goal.first * cellSize + cellSize / 2f
            )
            drawCircle(Color.Red, radius = cellSize * 0.35f, center = goalCenter)

            // player (blue circle)
            // playerPos は論理座標→ここでキャンバス座標に合わせて補正
            val playerCanvas = Offset(
                playerPos.x * cellSize + cellSize / 2f,
                playerPos.y * cellSize + cellSize / 2f
            )
            drawCircle(Color.Blue, radius = cellSize * 0.35f, center = playerCanvas)
        }
    }
}

// カスタムドラッグ検出(ComposeのdetectDragGestures簡略版)
suspend fun androidx.compose.ui.input.pointer.PointerInputScope.detectDragGesturesCustom(
    onDragStart: (Offset) -> Unit,
    onDrag: (androidx.compose.ui.input.pointer.PointerInputChange, Offset) -> Unit,
    onDragEnd: () -> Unit
) {
    awaitPointerEventScope {
        val down = awaitFirstDown()
        onDragStart(down.position)
        var pointerId = down.id
        while (true) {
            val event = awaitPointerEvent(PointerEventPass.Main)
            val change = event.changes.find { it.id == pointerId } ?: break
            if (change.pressed) {
                onDrag(change, change.positionChange())
            } else {
                onDragEnd()
                break
            }
        }
    }
}

// プレイヤー移動(壁衝突を段階的にチェックして手前で止める)
fun tryMovePlayer(currentLogical: Offset, desiredDeltaLogical: Offset, maze: Maze): Offset {
    var nx = currentLogical.x + desiredDeltaLogical.x
    var ny = currentLogical.y + desiredDeltaLogical.y

    fun isPathCell(logical: Offset): Boolean {
        val r = logical.y.roundToInt()
        val c = logical.x.roundToInt()
        return r in 0 until maze.rows && c in 0 until maze.cols && maze.grid[r][c]
    }

    // 線形にサンプルして衝突判定(ステップ数は距離に応じて)
    val steps = max(10, (max(abs(desiredDeltaLogical.x), abs(desiredDeltaLogical.y)) * 2).roundToInt())
    var okX = currentLogical.x
    var okY = currentLogical.y
    for (i in 1..steps) {
        val t = i / steps.toFloat()
        val sx = currentLogical.x + desiredDeltaLogical.x * t
        val sy = currentLogical.y + desiredDeltaLogical.y * t
        val probe = Offset(sx, sy)
        if (isPathCell(probe)) {
            okX = sx
            okY = sy
        } else {
            // 壁に当たったので直前で停止
            break
        }
    }

    // 範囲クランプ
    okX = okX.coerceIn(0f, (maze.cols - 1).toFloat())
    okY = okY.coerceIn(0f, (maze.rows - 1).toFloat())
    return Offset(okX, okY)
}
idx.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <item name="level_text" type="id"/>
    <item name="time_text" type="id"/>
    <item name="game_over_text" type="id"/>
</resources>

ビルドを実行

ビルド時のエラー
Manifest merger failed : uses-sdk:minSdkVersion 21 cannot be smaller than version 23 declared in library [androidx.navigationevent:navigationevent-android:1.0.0] /Users/hiroshi.yamaki/.gradle/caches/8.13/transforms/8cd88919b81e1a09e7d324be2a9680e5/transformed/navigationevent/AndroidManifest.xml as the library might be using APIs not available in 21

手元にあった端末がAndroidの5系ととても古かったのでAPIレベルを21のものを選択してプロジェクトの作成を行ったのですがこれが原因でエラーが出ています。
AIの回答ではGroovyDSLを使用した場合の対応方法が出てきたのですが、使用しているのはKotlinDSLです。
KotlinDSLでの対応方法を提示するように指示したところ

対応方法

plugins {
    id("com.android.application")
    kotlin("android")
}

android {
    namespace = "com.example.app"
    compileSdk = 34

    defaultConfig {
        applicationId = "com.example.app"
        minSdk = 21  // ← minSdkが23未満なら、Activityを1.7.2などに落とす方針
        targetSdk = 34
        versionCode = 1
        versionName = "1.0"
    }

    buildFeatures {
        compose = true
    }
    composeOptions {
        kotlinCompilerExtensionVersion = "1.5.3" // 使用するCompose BOMに整合
    }

    kotlinOptions {
        jvmTarget = "17"
    }
}

dependencies {
    // Activity をダウングレード(例: minSdkが23未満なら 1.7.2 を使う)
    implementation("androidx.activity:activity:1.7.2")
    implementation("androidx.activity:activity-ktx:1.7.2")

    // Compose を使うなら activity-compose も
    implementation("androidx.activity:activity-compose:1.7.2")

    // Compose BOM と整合(古めに合わせる)
    implementation(platform("androidx.compose:compose-bom:2023.08.00"))

    // BOM管理下ではバージョンを付けない
    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.material3:material3")

    // 他のCompose依存も BOM に合わせる
    implementation("androidx.compose.ui:ui-tooling-preview")
    debugImplementation("androidx.compose.ui:ui-tooling")

build.gradle.ktsの中身を書き換えるように指示されました。
再度ビルドを行うと違うエラーが発生

ビルド時のエラー
Starting in Kotlin 2.0, the Compose Compiler Gradle plugin is required

プロンプトに渡すと

プロンプトの回答
そのエラーは Kotlin 2.x 以降で導入された変更が原因です。
Kotlin 2.0+ では Compose Compiler が Kotlin 側のプラグインとして分離され、Compose(buildFeatures { compose = true })を有効にしているモジュールでは org.jetbrains.kotlin.plugin.compose を追加・適用することが必須になりました。
対処法(どちらかの方法を選択)
1) Version Catalog(libs.versions.toml)を使う場合(推奨)
2) Version Catalog を使わない場合(従来の書き方)

言ってることがわからない...。
ひとまず推奨の方でやってみる。

gradle/libs.versions.toml に Kotlin と Compose Compiler プラグインを定義:

gradle/libs.versions.toml
[versions]
kotlin = "2.1.10" # ← あなたの Kotlin バージョンに合わせる

[plugins]
org-jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

プロジェクトルートの build.gradle.kts に apply false を追加:

build.gradle.kts(app)
plugins {
    alias(libs.plugins.compose.compiler) apply false
    alias(libs.plugins.org.jetbrains.kotlin.android) apply false
}

Compose を使う各モジュールの build.gradle.kts で 適用:

build.gradle.kts(mazeapp)
plugins {
    id("com.android.application") // or com.android.library
    alias(libs.plugins.org.jetbrains.kotlin.android)
    alias(libs.plugins.compose.compiler)  // ← これが必須
}

gradle syncするとエラー...。

ビルド時のエラー
Plugin with id 'org.jetbrains.kotlin.android' was already requested at line 2

埒が空かないので修正したコードを全てプロンプトに投げるとそれぞれのファイルを修正しなさいと。

gradle/libs.versions.tomlの修正(コードを表示 折りたたみ)
gradle/libs.versions.tomlの修正
	[versions]
	agp = "8.13.1"
	kotlin = "2.0.21"
	coreKtx = "1.17.0"
	junit = "4.13.2"
	junitVersion = "1.3.0"
	espressoCore = "3.7.0"
	lifecycleRuntimeKtx = "2.10.0"
	activityCompose = "1.12.0"
	composeBom = "2024.09.00"

	[libraries]
	androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
	junit = { group = "junit", name = "junit", version.ref = "junit" }
	androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
	androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
	androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
	androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
	androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
	androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
	androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
	androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
	androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
	androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
	androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
	androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }

	[plugins]
	android-application = { id = "com.android.application", version.ref = "agp" }
	kotlin-android      = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }

build.gradle.kts(app)の修正(コードを表示 折りたたみ)
build.gradle.kts(app)の修正
	plugins {
		alias(libs.plugins.android.application)   // ← Version Catalog に統一
		alias(libs.plugins.kotlin.android)        // ← kotlin("android") をやめて alias 1本に
		alias(libs.plugins.compose.compiler)      // ← Compose Compiler プラグイン
	}
build.gradle.kts(mazeapp)(コードを表示 折りたたみ)
build.gradle.kts(mazeapp)の修正
	plugins {
		alias(libs.plugins.android.application) apply false
		alias(libs.plugins.kotlin.android) apply false
		alias(libs.plugins.compose.compiler) apply false
	}

gradle syncを実行
エラーが発生したので内容をプロンプトに投げる

ビルド時のエラー
1. Unresolved reference 'compose'.
プロンプトの回答
よくある原因
原因 A:android { buildFeatures { compose = true } } を ルートの build.gradle.kts に書いている

android { ... } や buildFeatures { compose = true } は アプリ(or ライブラリ)モジュールの build.gradle.kts にだけ書けます。
ルートの build.gradle.kts は プロジェクト全体の宣言用で、android ブロックは使えません。
→ ルートに android { ... } があると Kotlin DSL のシンボル解決ができず、「Unresolved reference: compose」が出ます。 [developer....ndroid.com]

原因 B:Version Catalog の プラグイン alias が重複/誤参照

libs.versions.toml に同じ ID のプラグインを複数 alias していたり、ルートとモジュールで 適用が二重になっていると、スクリプト評価の途中で参照が壊れて別の未解決エラーを誘発します。
→ 既にご案内したとおり、Kotlin/Compose のプラグイン alias を1つに統一してください。 [developer....ndroid.com]

対応後、gradle sync
が、ダメ。プロンプトに投げる。

ビルド時のエラー
1. Unresolved reference 'compose'.
プロンプトの回答
Catalog 名変更で通るかをまず確認(libs → appLibs)

ソースコードを丸投げして全て置き換えるように指示。

settings.gradle.kts(コードを表示 折りたたみ)
settings.gradle.kts

	// settings.gradle.kts

	pluginManagement {
		repositories {
			google()
			mavenCentral()
			gradlePluginPortal()
		}
		// ※ ここに versionCatalogs は置かない
	}

	dependencyResolutionManagement {
		repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
		repositories {
			google()
			mavenCentral()
		}
		versionCatalogs {
			create("appLibs") {
				from(files("gradle/libs.versions.toml")) // ★ 呼び出しは一度だけ
			}
		}
	}

	rootProject.name = "MazeApp6"
	include(":app")
libs.gradle.toml(コードを表示 折りたたみ)
libs.gradle.toml

	[versions]
	agp = "8.13.1"
	kotlin = "2.0.21"
	coreKtx = "1.17.0"
	junit = "4.13.2"
	junitVersion = "1.3.0"
	espressoCore = "3.7.0"
	lifecycleRuntimeKtx = "2.10.0"
	activityCompose = "1.12.0"
	composeBom = "2024.09.00"

	[libraries]
	androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
	junit = { group = "junit", name = "junit", version.ref = "junit" }
	androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
	androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
	androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }

	androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }

	androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
	androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
	androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
	androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
	androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }

	[plugins]
	android-application = { id = "com.android.application",           version.ref = "agp" }
	kotlin-android      = { id = "org.jetbrains.kotlin.android",        version.ref = "kotlin" }
	compose-compiler    = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
build.gradle.kts(mazeapp)(コードを表示 折りたたみ)
build.gradle.kts(mazeapp)
	// build.gradle.kts
	plugins {
		alias(appLibs.plugins.android.application) apply false
		alias(appLibs.plugins.kotlin.android)      apply false
		alias(appLibs.plugins.compose.compiler)    apply false
	}
build.gradle.kts(app)(コードを表示 折りたたみ)
build.gradle.kts(app)

	// app/build.gradle.kts

	plugins {
		alias(appLibs.plugins.android.application) // AGP
		alias(appLibs.plugins.kotlin.android)      // Kotlin Android
		alias(appLibs.plugins.compose.compiler)    // Compose Compiler(Kotlin 2.0+ 必須)
	}

	android {
		namespace = "com.example.app" // ← 実プロジェクトに合わせて変更
		compileSdk = 34

		defaultConfig {
			applicationId = "com.example.app"
			minSdk = 21
			targetSdk = 34
			versionCode = 1
			versionName = "1.0"
		}

		buildFeatures { compose = true }

		// Kotlin 2.x + Compose Compiler プラグイン導入後は通常不要(必要なら composeCompiler { ... } を使用)
		// composeOptions { kotlinCompilerExtensionVersion = "1.5.15" }

		compileOptions {
			sourceCompatibility = JavaVersion.VERSION_17
			targetCompatibility = JavaVersion.VERSION_17
		}
		kotlinOptions { jvmTarget = "17" }
	}

	dependencies {
		// Compose BOM(BOM配下はバージョン指定なし)
		implementation(platform(appLibs.androidx.compose.bom))
		implementation(appLibs.androidx.compose.ui)
		implementation(appLibs.androidx.compose.material3)
		implementation(appLibs.androidx.compose.ui.tooling.preview)
		debugImplementation(appLibs.androidx.compose.ui.tooling)

		// Activity Compose
		implementation(appLibs.androidx.activity.compose)

		// 他の依存
		implementation(appLibs.androidx.core.ktx)
		implementation(appLibs.androidx.lifecycle.runtime.ktx)
		testImplementation(appLibs.junit)
		androidTestImplementation(appLibs.androidx.junit)
		androidTestImplementation(appLibs.androidx.espresso.core)
	}

gradle syncで解決
次にビルド実行

ビルド時のエラー
18 issues were found when checking AAR metadata:
プロンプトの回答
compileSdk を 36 に更新してください(35でも一部は解消しますが、NavigationEventやActivityは36必須なので36が安全)。

compileSdkを36に設定

build.gradle.kts(app)
    compileSdk = 36

ビルド実行
エラー

ビルド時のエラー
Manifest merger failed : uses-sdk:minSdkVersion 21 cannot be smaller than version 23 declared in library 
プロンプトの回答
minSdk を 23 に上げる(最も安全)
影響:Android 5.0 (API 21) と 5.1 (API 22) の端末ではインストールできなくなりますが、2025年現在ほぼ市場から消えているので実務的には問題ないことが多いです。

Android5系しか持ってないんだが・・・。
Android13の端末を用意して、minSdkを23に設定

build.gradle.kts(app)
    defaultConfig { applicationId = "com.example.app"; minSdk = 23; targetSdk = 34 }

ビルド実行
エラー

ビルド時のエラー
Execution failed for task ':app:compileDebugKotlin'.
> Inconsistent JVM-target compatibility detected for tasks 'compileDebugJavaWithJavac' (1.8) and 'compileDebugKotlin' (17).
プロンプトの回答
compileDebugJavaWithJavac が 1.8、compileDebugKotlin が 17 のため、Java と Kotlin のターゲット JVM が不一致になっています。
両方を 17 に揃えるのが確実な解決策です。AGP 8系は JDK 17 を要求します(IDE/Gradle 実行用のJDKも17にそろえるのが安全)。
build.gradle.kts(app)
    // ① Java のコンパイル互換性を 17 に統一
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }

    // ② Kotlin の jvmTarget も 17 に統一
    kotlinOptions {
        jvmTarget = "17"
    }

ビルド実行
エラー

ビルド時のエラー
1. Unresolved reference 'awaitPointerEventScope'.
2. Unresolved reference 'awaitFirstDown'.
3. Unresolved reference 'consume'.
4. None of the following candidates is applicable: class R : Any
5. Unresolved reference 'id'.
6. Expression 'positionChange' of type 'kotlin.Boolean' cannot be invoked as a function. Function 'invoke()' is not found.
プロンプトの回答
未解決参照が awaitFirstDown / awaitPointerEvent / awaitPointerEventScope / consume で発生しているのは、
(A)低レベルのポインタ入力APIを使っているが、該当APIを提供するパッケージ/依存が揃っていない、または(B)Compose版の食い違いが原因です。
最短で直すなら、低レベルAPIをやめて 高レベルのジェスチャAPI(detectDragGestures / detectTapGestures)へ置換するのが安全です。
高レベルAPIは androidx.compose.foundation:foundation に含まれるため、依存を一つ足せば未解決参照を一掃できます。

ここまでのやり取りでだいぶ疲れたので手っ取り早く動作する修正にしたい・・・。

与えたプロンプト
依存関係を修正するのは辛そうなので高レベルAPIで解決してください。
プロンプトの提案してきた修正:MainActivity.kt(コードを表示 折りたたみ)
プロンプトの提案してきた修正:MainActivity.kt

	package com.example.maze

	import android.os.Bundle
	import android.view.View
	import android.view.MotionEvent
	import android.widget.TextView
	import androidx.activity.ComponentActivity
	import androidx.activity.compose.setContent
	import androidx.compose.foundation.Canvas
	import androidx.compose.foundation.background
	import androidx.compose.foundation.layout.*
	import androidx.compose.material3.MaterialTheme
	import androidx.compose.material3.Surface
	import androidx.compose.runtime.*
	import androidx.compose.ui.Alignment
	import androidx.compose.ui.Modifier
	import androidx.compose.ui.geometry.Offset
	import androidx.compose.ui.graphics.Color
	import androidx.compose.ui.graphics.drawscope.Stroke
	import androidx.compose.ui.platform.LocalContext
	import androidx.compose.ui.unit.dp
	import androidx.compose.ui.viewinterop.AndroidView
	import androidx.compose.ui.input.pointer.pointerInteropFilter
	import kotlinx.coroutines.delay
	import kotlin.math.abs
	import kotlin.math.max
	import kotlin.math.min
	import kotlin.math.roundToInt
	import kotlin.random.Random

	class MainActivity : ComponentActivity() {
		override fun onCreate(savedInstanceState: Bundle?) {
			super.onCreate(savedInstanceState)
			setContent { MaterialTheme { Surface { MazeGameApp() } } }
		}
	}

	/** 迷路グリッド: false=壁, true=通路 */
	data class Maze(
		val rows: Int,
		val cols: Int,
		val grid: Array<BooleanArray>,
		val start: Pair<Int, Int>,
		val goal: Pair<Int, Int>
	)

	/** ====== メインUI ====== */
	@Composable
	fun MazeGameApp() {
		// Game states
		var level by remember { mutableStateOf(1) }
		var timeLeftMs by remember { mutableStateOf(30_000L) }
		var gameOver by remember { mutableStateOf(false) }

		// 迷路&プレイヤー(論理座標=セル単位。x=列, y=行)
		var maze by remember { mutableStateOf(generateMazeForLevel(level)) }
		var playerPos by remember { mutableStateOf(cellCenter(maze.start, maze)) }

		// Logcat
		LaunchedEffect(level) { android.util.Log.i("MazeTAG", "Level=$level started") }
		LaunchedEffect(timeLeftMs) { android.util.Log.i("MazeTAG", "TimeLeftMs=$timeLeftMs") }

		// Timer
		LaunchedEffect(level, gameOver) {
			timeLeftMs = 30_000L
			if (!gameOver) {
				val startTime = System.currentTimeMillis()
				while (timeLeftMs > 0 && !gameOver) {
					delay(100L)
					val elapsed = System.currentTimeMillis() - startTime
					timeLeftMs = max(0L, 30_000L - elapsed)
				}
				if (timeLeftMs <= 0) gameOver = true
			}
		}

		Column(modifier = Modifier.fillMaxSize().background(Color.Black)) {
			// HUD(TextView:adbでも取得しやすい)
			Row(
				modifier = Modifier.fillMaxWidth().padding(8.dp),
				horizontalArrangement = Arrangement.SpaceBetween,
				verticalAlignment = Alignment.CenterVertically
			) {
				AndroidView(
					modifier = Modifier.wrapContentSize(),
					factory = { ctx ->
						TextView(ctx).apply {
							id = View.generateViewId()
							contentDescription = "LEVEL_TEXT"
							textSize = 18f
							setTextColor(android.graphics.Color.WHITE)
						}
					},
					update = { tv -> tv.text = "Level: $level" }
				)
				AndroidView(
					modifier = Modifier.wrapContentSize(),
					factory = { ctx ->
						TextView(ctx).apply {
							id = View.generateViewId()
							contentDescription = "TIME_TEXT"
							textSize = 18f
							setTextColor(android.graphics.Color.WHITE)
						}
					},
					update = { tv ->
						val sec = (timeLeftMs / 1000.0)
						tv.text = String.format("Time: %.1fs", sec)
					}
				)
			}

			Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
				// Game Canvas(描画&ドラッグ終端で移動)
				GameCanvas(
					maze = maze,
					playerPos = playerPos,
					onSwipeMove = { deltaLogical ->
						val next = tryMovePlayer(playerPos, deltaLogical, maze)
						playerPos = next

						// ゴール到達判定(論理座標)
						val goalCenter = cellCenter(maze.goal, maze)
						if (distance(playerPos, goalCenter) < 0.2f) {
							level += 1
							maze = generateMazeForLevel(level)
							playerPos = cellCenter(maze.start, maze)
							timeLeftMs = 30_000L
							android.util.Log.i("MazeTAG", "StageCleared! Next Level=$level")
						}
					}
				)

				// Game Over overlay
				if (gameOver) {
					AndroidView(
						modifier = Modifier.fillMaxSize(),
						factory = { ctx ->
							TextView(ctx).apply {
								id = View.generateViewId()
								contentDescription = "GAME_OVER_TEXT"
								text = "GAME OVER (tap to restart)"
								textSize = 24f
								setTextColor(android.graphics.Color.RED)
								setBackgroundColor(android.graphics.Color.argb(160, 0, 0, 0))
								gravity = android.view.Gravity.CENTER
							}
						}
					)
					// 画面タップで再スタート(高レベルAPI不要・onTouchEvent互換)
					Box(
						modifier = Modifier
							.fillMaxSize()
							.pointerInteropFilter { event ->
								when (event.actionMasked) {
									MotionEvent.ACTION_UP -> {
										gameOver = false
										level = 1
										maze = generateMazeForLevel(level)
										playerPos = cellCenter(maze.start, maze)
										timeLeftMs = 30_000L
										android.util.Log.i("MazeTAG", "Restart")
										true
									}
									else -> true
								}
							}
					)
				}
			}
		}
	}

	/** ===== 迷路生成(再帰的バックトラッキング) ===== */
	fun generateMazeForLevel(level: Int): Maze {
		val base = 15
		val size = base + (level - 1) * 2
		val rows = size
		val cols = size
		val grid = Array(rows) { BooleanArray(cols) { false } } // false=壁, true=通路

		fun carve(r: Int, c: Int) {
			grid[r][c] = true
			val dirs = mutableListOf(0, 1, 2, 3).shuffled(Random(level))
			val dr = intArrayOf(-1, 1, 0, 0)
			val dc = intArrayOf(0, 0, -1, 1)
			for (d in dirs) {
				val nr = r + dr[d] * 2
				val nc = c + dc[d] * 2
				if (nr in 0 until rows && nc in 0 until cols && !grid[nr][nc]) {
					grid[r + dr[d]][c + dc[d]] = true
					carve(nr, nc)
				}
			}
		}

		val start = 0 to 0
		val goal = (rows - 1) to (cols - 1)

		// 奇数格子調整( carve の分岐前提を満たす目的で控え)
		val rr = if (rows % 2 == 0) rows - 1 else rows
		val cc = if (cols % 2 == 0) cols - 1 else cols

		carve(0, 0)

		// 念のためスタート/ゴールを通路化
		grid[start.first][start.second] = true
		grid[goal.first][goal.second] = true

		return Maze(rows, cols, grid, start, goal)
	}

	/** プレイヤー位置(論理座標=セル単位)。セル中心を論理座標へ */
	fun cellCenter(cell: Pair<Int, Int>, maze: Maze): Offset {
		// 論理座標:x=列+0.5, y=行+0.5(Canvasでセルサイズ×+オフセット)
		return Offset(cell.second + 0.5f, cell.first + 0.5f)
	}

	/** 2D距離(論理座標) */
	fun distance(a: Offset, b: Offset): Float {
		val dx = a.x - b.x
		val dy = a.y - b.y
		return kotlin.math.sqrt(dx * dx + dy * dy)
	}

	/** ===== Canvas 描画とドラッグ検出(pointerInteropFilter版) =====
	* - 描画時にセルサイズ(ピクセル)を計算して state に保持
	* - ドラッグ合計(ピクセル)→ セル単位(論理Δ)へ変換
	* - 仕様:スワイプ距離の2倍で移動 → 変換時に×2して onSwipeMove に渡す
	*/
	@Composable
	fun GameCanvas(
		maze: Maze,
		playerPos: Offset,                // 論理座標(セル単位)
		onSwipeMove: (Offset) -> Unit     // 論理Δ(セル単位)で渡す
	) {
		var totalDragPx by remember { mutableStateOf(Offset.Zero) }
		var cellSize by remember { mutableStateOf(0f) }

		Box(modifier = Modifier.fillMaxSize()) {
			Canvas(
				modifier = Modifier
					.fillMaxSize()
					.background(Color.Black)
					// ViewのonTouchEvent互換の高レベル(UI側)API。低レベルawait*は不要 
					.pointerInteropFilter { event ->
						when (event.actionMasked) {
							MotionEvent.ACTION_DOWN -> {
								totalDragPx = Offset.Zero
								true
							}
							MotionEvent.ACTION_MOVE -> {
								// 直近MOVE分のΔを足す(単純化:履歴は使用しない)
								// ※ より厳密に取りたい場合は event.history を見る
								// ここでは1フレーム分の差分推定
								// (ComposeのCanvasは十分な頻度でMOVEが来る前提)
								// → 細かい調整は必要なら追加対応します
								val dx = event.x - (event.getHistoricalX(0) ?: event.x)
								val dy = event.y - (event.getHistoricalY(0) ?: event.y)
								totalDragPx += Offset(dx, dy)
								true
							}
							MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
								val deltaPx = totalDragPx
								totalDragPx = Offset.Zero
								if (cellSize > 0f) {
									// ピクセルΔ → セルΔ(論理)へ変換し、仕様どおり「2倍」にして渡す
									val deltaLogical = Offset(
										(deltaPx.x / cellSize) * 2f,
										(deltaPx.y / cellSize) * 2f
									)
									if (deltaLogical != Offset.Zero) {
										onSwipeMove(deltaLogical)
									}
								}
								true
							}
							else -> true
						}
					}
			) {
				val w = size.width
				val h = size.height
				val cellW = w / maze.cols
				val cellH = h / maze.rows
				cellSize = min(cellW, cellH)

				// グリッド左上原点(中央寄せ)
				val totalW = cellSize * maze.cols
				val totalH = cellSize * maze.rows
				val originX = (w - totalW) / 2f
				val originY = (h - totalH) / 2f

				// 迷路描画:通路(白)・壁(ダークグレー)
				for (r in 0 until maze.rows) {
					for (c in 0 until maze.cols) {
						val left = originX + c * cellSize
						val top = originY + r * cellSize
						val rectColor = if (maze.grid[r][c]) Color.White else Color(0xFF333333)
						drawRect(
							color = rectColor,
							topLeft = Offset(left, top),
							size = androidx.compose.ui.geometry.Size(cellSize, cellSize)
						)
					}
				}

				// 外枠
				drawRect(
					color = Color(0xFF303030),
					topLeft = Offset(originX, originY),
					size = androidx.compose.ui.geometry.Size(totalW, totalH),
					style = Stroke(width = 2f)
				)

				// ゴール(赤)
				val goalCenter = Offset(
					x = originX + (maze.goal.second + 0.5f) * cellSize,
					y = originY + (maze.goal.first + 0.5f) * cellSize
				)
				drawCircle(Color.Red, radius = cellSize * 0.35f, center = goalCenter)

				// プレイヤー(青)論理→ピクセルへ
				val playerCanvas = Offset(
					x = originX + playerPos.x * cellSize,
					y = originY + playerPos.y * cellSize
				)
				drawCircle(Color.Blue, radius = cellSize * 0.35f, center = playerCanvas)
			}
		}
	}

	/** プレイヤー移動(論理Δで移動。壁衝突は段階的チェック) */
	fun tryMovePlayer(currentLogical: Offset, desiredDeltaLogical: Offset, maze: Maze): Offset {
		fun isPathCell(logical: Offset): Boolean {
			val r = logical.y.roundToInt()
			val c = logical.x.roundToInt()
			return r in 0 until maze.rows && c in 0 until maze.cols && maze.grid[r][c]
		}

		val steps = max(10, (max(abs(desiredDeltaLogical.x), abs(desiredDeltaLogical.y)) * 2).roundToInt())
		var okX = currentLogical.x
		var okY = currentLogical.y
		for (i in 1..steps) {
			val t = i / steps.toFloat()
			val sx = currentLogical.x + desiredDeltaLogical.x * t
			val sy = currentLogical.y + desiredDeltaLogical.y * t
			val probe = Offset(sx, sy)
			if (isPathCell(probe)) {
				okX = sx
				okY = sy
			} else {
				// 壁に当たったので直前で停止
				break
			}
		}

		// 範囲クランプ(グリッド内)
		okX = okX.coerceIn(0f, (maze.cols - 1).toFloat())
		okY = okY.coerceIn(0f, (maze.rows - 1).toFloat())
		return Offset(okX, okY)
	}

ビルド実行
ようやくビルドが通った!!!
が、起動後クラッシュ・・・。

クラッシュログの内容
ClassNotFoundException: Didn't find class "com.example.app.MainActivity"

そうだ。packageの名前もnamespaceも、applicationIdも書き換えてなかった。。。

プロンプトの回答
applicationId(実際のインストールパッケージ名)
app/build.gradle.kts の defaultConfig { applicationId = "..." } が Manifest と整合しているかを確認。

起動ログにある Process: com.example.app は applicationId が com.example.app を示唆しています。
applicationId と Activity の FQCN(完全修飾名)は独立ですが、Manifest の Activity 参照は FQCN が正しくないと落ちます。

ファイルの package と実際のディレクトリ
package com.example.app のクラスは app/src/main/java/com/example/app/MainActivity.kt にあるのが理想です。
パッケージとディレクトリが食い違うと保守上のミスが増えます。

ビルドして起動

動いた!!!
しかし、スワイプしても動いたり動かなかったりする・・・。
そして壁にめり込んだり突き抜けたりする・・・。

プロンプトに与えた内容
起動しました。動くようになりましたが、スワイプで移動したりしなかったりと操作性が悪いです。
あとは壁にめり込んだり壁を突き抜けます。

出力されたコードを反映してビルドして起動

まだ半分くらい壁にめり込むけど許容。
しかし、外側に侵入できない当たり判定のようなものがあってゴールに辿り着けない。

プロンプトに与えた内容
動きは滑らかで良いのですが、スワイプした画面上での距離の2倍になっていないような気がします。
あとは外側に壁の当たり判定か入れない領域のようなものがあり、ゴールに到達できません。
迷路が単純なのでもう少し難しい迷路を生成するようにしてください。
MainActivity.kt(コードを表示 折りたたみ)
MainActivity.kt

	package com.example.mazeapp

	import android.os.Bundle
	import android.view.MotionEvent
	import android.view.View
	import android.widget.TextView
	import androidx.activity.ComponentActivity
	import androidx.activity.compose.setContent
	import androidx.compose.foundation.Canvas
	import androidx.compose.foundation.background
	import androidx.compose.foundation.layout.*
	import androidx.compose.material3.MaterialTheme
	import androidx.compose.material3.Surface
	import androidx.compose.runtime.*
	import androidx.compose.ui.Alignment
	import androidx.compose.ui.Modifier
	import androidx.compose.ui.geometry.Offset
	import androidx.compose.ui.graphics.Color
	import androidx.compose.ui.graphics.drawscope.Stroke
	import androidx.compose.ui.input.pointer.pointerInteropFilter
	import androidx.compose.ui.unit.dp
	import androidx.compose.ui.viewinterop.AndroidView
	import kotlinx.coroutines.delay
	import kotlin.math.abs
	import kotlin.math.ceil
	import kotlin.math.max
	import kotlin.math.min
	import kotlin.math.roundToInt
	import kotlin.random.Random

	class MainActivity : ComponentActivity() {
		override fun onCreate(savedInstanceState: Bundle?) {
			super.onCreate(savedInstanceState)
			setContent { MaterialTheme { Surface { MazeGameApp() } } }
		}
	}

	/** 迷路:false=壁, true=通路(rows/colsは奇数、外周は壁リングを残す) */
	data class Maze(
		val rows: Int,
		val cols: Int,
		val grid: Array<BooleanArray>, // false=wall, true=path
		val start: Pair<Int, Int>,
		val goal: Pair<Int, Int>
	)

	/** 操作・表示パラメータ(セル単位) */
	private const val MOVE_MULTIPLIER = 2f           // 画面距離の何倍動かすか
	private const val DEAD_PX = 0.5f                 // デッドゾーン(px)
	private const val PLAYER_RADIUS_CELL = 0.28f     // プレイヤー半径(セル比)
	private const val GOAL_RADIUS_CELL = 0.35f       // ゴール半径(セル比)
	private const val GOAL_TOUCH_THRESHOLD_CELL =
		PLAYER_RADIUS_CELL + GOAL_RADIUS_CELL        // 円同士が接したらクリア
	private const val BRAID_RATIO = 0.0f             // ループ追加(0でオフ。必要なら0.03〜0.08程度)

	/** ====== メインUI ====== */
	@Composable
	fun MazeGameApp() {
		var level by remember { mutableStateOf(1) }
		var timeLeftMs by remember { mutableStateOf(30_000L) }
		var gameOver by remember { mutableStateOf(false) }

		var maze by remember { mutableStateOf(generateMazeForLevel(level)) }
		var playerPos by remember { mutableStateOf(cellCenter(maze.start, maze)) }

		LaunchedEffect(level) { android.util.Log.i("MazeTAG", "Level=$level started") }
		LaunchedEffect(timeLeftMs) { android.util.Log.i("MazeTAG", "TimeLeftMs=$timeLeftMs") }

		// Timer
		LaunchedEffect(level, gameOver) {
			timeLeftMs = 30_000L
			if (!gameOver) {
				val startTime = System.currentTimeMillis()
				while (timeLeftMs > 0 && !gameOver) {
					delay(100L)
					val elapsed = System.currentTimeMillis() - startTime
					timeLeftMs = max(0L, 30_000L - elapsed)
				}
				if (timeLeftMs <= 0) gameOver = true
			}
		}

		Column(modifier = Modifier.fillMaxSize().background(Color.Black)) {
			// HUD(TextView)
			Row(
				modifier = Modifier.fillMaxWidth().padding(8.dp),
				horizontalArrangement = Arrangement.SpaceBetween,
				verticalAlignment = Alignment.CenterVertically
			) {
				AndroidView(
					modifier = Modifier.wrapContentSize(),
					factory = { ctx ->
						TextView(ctx).apply {
							id = View.generateViewId()
							contentDescription = "LEVEL_TEXT"
							textSize = 18f
							setTextColor(android.graphics.Color.WHITE)
						}
					},
					update = { tv -> tv.text = "Level: $level" }
				)
				AndroidView(
					modifier = Modifier.wrapContentSize(),
					factory = { ctx ->
						TextView(ctx).apply {
							id = View.generateViewId()
							contentDescription = "TIME_TEXT"
							textSize = 18f
							setTextColor(android.graphics.Color.WHITE)
						}
					},
					update = { tv ->
						tv.text = String.format("Time: %.1fs", timeLeftMs / 1000.0)
					}
				)
			}

			Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
				GameCanvas(
					maze = maze,
					playerPos = playerPos,
					onSwipeMove = { deltaLogical ->
						val next = tryMovePlayer(playerPos, deltaLogical, maze)
						playerPos = next

						// ★ 赤円に触れたらゴール(円と円の接触)
						val goalCenter = cellCenter(maze.goal, maze)
						if (distance(playerPos, goalCenter) <= GOAL_TOUCH_THRESHOLD_CELL) {
							level += 1
							maze = generateMazeForLevel(level)
							playerPos = cellCenter(maze.start, maze)
							timeLeftMs = 30_000L
							android.util.Log.i("MazeTAG", "StageCleared! Next Level=$level")
						}
					}
				)

				if (gameOver) {
					AndroidView(
						modifier = Modifier.fillMaxSize(),
						factory = { ctx ->
							TextView(ctx).apply {
								id = View.generateViewId()
								contentDescription = "GAME_OVER_TEXT"
								text = "GAME OVER (tap to restart)"
								textSize = 24f
								setTextColor(android.graphics.Color.RED)
								setBackgroundColor(android.graphics.Color.argb(160, 0, 0, 0))
								gravity = android.view.Gravity.CENTER
							}
						}
					)
					Box(
						modifier = Modifier
							.fillMaxSize()
							.pointerInteropFilter { event ->
								when (event.actionMasked) {
									MotionEvent.ACTION_UP -> {
										gameOver = false
										level = 1
										maze = generateMazeForLevel(level)
										playerPos = cellCenter(maze.start, maze)
										timeLeftMs = 30_000L
										android.util.Log.i("MazeTAG", "Restart")
										true
									}
									else -> true
								}
							}
					)
				}
			}
		}
	}

	/** ===== 迷路生成:ランダム Prim 法(外周壁/奇数セルのみ掘る) ===== */
	fun generateMazeForLevel(level: Int): Maze {
		// ベースサイズを奇数で大きめに。レベルで緩やかに拡大(複雑化の主因)
		val baseOdd = 21
		val rawSize = baseOdd + (level - 1) * 2
		val rows = if (rawSize % 2 == 0) rawSize + 1 else rawSize
		val cols = rows // 正方形(必要なら独立にしてもOK)

		val grid = Array(rows) { BooleanArray(cols) { false } } // false=壁, true=通路

		// 奇数セルのうち、外周を除いた範囲で開始
		val startCell = 1 to 1
		val goalCell = (rows - 2) to (cols - 2)

		// Prim 法:訪問済み奇数セル集合+フロンティア壁集合
		val visited = Array(rows) { BooleanArray(cols) { false } }
		fun isOddCell(r: Int, c: Int) = (r % 2 == 1) && (c % 2 == 1) && (r in 1 until rows - 1) && (c in 1 until cols - 1)

		// 壁(偶数セル)の座標と、その両側の奇数セルを保持
		data class Wall(val r: Int, val c: Int, val aR: Int, val aC: Int, val bR: Int, val bC: Int)

		fun addFrontierFor(cellR: Int, cellC: Int, walls: MutableList<Wall>) {
			val dirs = arrayOf(0 to -1, 0 to 1, -1 to 0, 1 to 0) // 左・右・上・下(壁の向き)
			for ((dr, dc) in dirs) {
				val wr = cellR + dr
				val wc = cellC + dc
				val br = cellR + dr * 2
				val bc = cellC + dc * 2
				if (wr in 1 until rows - 1 && wc in 1 until cols - 1 &&
					br in 1 until rows - 1 && bc in 1 until cols - 1 &&
					isOddCell(br, bc) && !visited[br][bc]
				) {
					walls.add(Wall(wr, wc, cellR, cellC, br, bc))
				}
			}
		}

		// 初期化
		val walls = mutableListOf<Wall>()
		val (sr, sc) = startCell
		visited[sr][sc] = true
		grid[sr][sc] = true
		addFrontierFor(sr, sc, walls)

		// メインループ:フロンティアからランダムに壁を選び、未訪問側を開通
		val rng = Random(level * 9973)
		while (walls.isNotEmpty()) {
			val idx = rng.nextInt(walls.size)
			val wall = walls.removeAt(idx)

			val aVisited = visited[wall.aR][wall.aC]
			val bVisited = visited[wall.bR][wall.bC]

			if (aVisited.xor(bVisited)) {
				// 壁を開け、未訪問側のセルを訪問・開通
				grid[wall.r][wall.c] = true
				if (!aVisited) {
					visited[wall.aR][wall.aC] = true
					grid[wall.aR][wall.aC] = true
					addFrontierFor(wall.aR, wall.aC, walls)
				}
				if (!bVisited) {
					visited[wall.bR][wall.bC] = true
					grid[wall.bR][wall.bC] = true
					addFrontierFor(wall.bR, wall.bC, walls)
				}
			}
		}

		// (任意)弱いブライド。必要なら BRAID_RATIO を 0.03〜0.08 に
		if (BRAID_RATIO > 0f) {
			val dirs = arrayOf(0 to -1, 0 to 1, -1 to 0, 1 to 0)
			for (r in 1 until rows - 1) for (c in 1 until cols - 1) {
				if (!grid[r][c]) { // 壁セルだけを対象
					var openSides = 0
					for ((dr, dc) in dirs) {
						val nr = r + dr
						val nc = c + dc
						if (nr in 1 until rows - 1 && nc in 1 until cols - 1 && grid[nr][nc]) openSides++
					}
					// 片側だけ開いている壁(デッドエンドを接続しやすい)をランダムに開ける
					if (openSides == 1 && Random.nextFloat() < BRAID_RATIO) {
						grid[r][c] = true
					}
				}
			}
		}

		// 念のためスタート/ゴールを通路化
		grid[startCell.first][startCell.second] = true
		grid[goalCell.first][goalCell.second] = true

		return Maze(rows, cols, grid, startCell, goalCell)
	}

	/** セル中心(論理座標:x=列+0.5, y=行+0.5) */
	fun cellCenter(cell: Pair<Int, Int>, maze: Maze): Offset {
		return Offset(cell.second + 0.5f, cell.first + 0.5f)
	}

	/** 2D距離(論理座標) */
	fun distance(a: Offset, b: Offset): Float {
		val dx = a.x - b.x
		val dy = a.y - b.y
		return kotlin.math.sqrt(dx * dx + dy * dy)
	}

	/** ===== Canvas描画&入力(MOVEごとに“2倍”適用) ===== */
	@Composable
	fun GameCanvas(
		maze: Maze,
		playerPos: Offset,
		onSwipeMove: (Offset) -> Unit
	) {
		var lastX by remember { mutableStateOf(0f) }
		var lastY by remember { mutableStateOf(0f) }
		var cellSize by remember { mutableStateOf(0f) }

		Box(modifier = Modifier.fillMaxSize()) {
			Canvas(
				modifier = Modifier
					.fillMaxSize()
					.background(Color.Black)
					.pointerInteropFilter { event ->
						when (event.actionMasked) {
							MotionEvent.ACTION_DOWN -> {
								lastX = event.x
								lastY = event.y
								true
							}
							MotionEvent.ACTION_MOVE -> {
								val dxPx = event.x - lastX
								val dyPx = event.y - lastY
								lastX = event.x
								lastY = event.y

								if (cellSize > 0f) {
									if (abs(dxPx) > DEAD_PX || abs(dyPx) > DEAD_PX) {
										val deltaLogical = Offset(
											(dxPx / cellSize) * MOVE_MULTIPLIER,
											(dyPx / cellSize) * MOVE_MULTIPLIER
										)
										onSwipeMove(deltaLogical)
									}
								}
								true
							}
							MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> true
							else -> true
						}
					}
			) {
				val w = size.width
				val h = size.height
				val cellW = w / maze.cols
				val cellH = h / maze.rows
				cellSize = min(cellW, cellH)

				val totalW = cellSize * maze.cols
				val totalH = cellSize * maze.rows
				val originX = (w - totalW) / 2f
				val originY = (h - totalH) / 2f

				// 迷路(通路=白、壁=ダークグレー)
				for (r in 0 until maze.rows) {
					for (c in 0 until maze.cols) {
						val left = originX + c * cellSize
						val top = originY + r * cellSize
						val rectColor = if (maze.grid[r][c]) Color.White else Color(0xFF333333)
						drawRect(
							color = rectColor,
							topLeft = Offset(left, top),
							size = androidx.compose.ui.geometry.Size(cellSize, cellSize)
						)
					}
				}

				// 外枠
				drawRect(
					color = Color(0xFF303030),
					topLeft = Offset(originX, originY),
					size = androidx.compose.ui.geometry.Size(totalW, totalH),
					style = Stroke(width = 2f)
				)

				// ゴール(赤:半径は GOAL_RADIUS_CELL * cellSize)
				val goalCenterPx = Offset(
					x = originX + (maze.goal.second + 0.5f) * cellSize,
					y = originY + (maze.goal.first + 0.5f) * cellSize
				)
				drawCircle(
					Color.Red,
					radius = cellSize * GOAL_RADIUS_CELL,
					center = goalCenterPx
				)

				// プレイヤー(青:半径は PLAYER_RADIUS_CELL * cellSize)
				val playerPx = Offset(
					x = originX + playerPos.x * cellSize,
					y = originY + playerPos.y * cellSize
				)
				drawCircle(
					Color.Blue,
					radius = cellSize * PLAYER_RADIUS_CELL,
					center = playerPx
				)
			}
		}
	}

	/** ===== プレイヤー移動:軸分離+floor判定(斜めトンネル防止) ===== */
	fun tryMovePlayer(current: Offset, deltaLogical: Offset, maze: Maze): Offset {
		fun isPath(p: Offset): Boolean {
			val r = p.y.toInt()  // floor
			val c = p.x.toInt()
			return r in 0 until maze.rows && c in 0 until maze.cols && maze.grid[r][c]
		}

		val mag = max(abs(deltaLogical.x), abs(deltaLogical.y))
		val steps = when {
			mag < 0.05f -> 1
			mag < 0.15f -> 4
			else -> ceil(mag * 12f).toInt().coerceAtMost(64)
		}
		val sx = deltaLogical.x / steps
		val sy = deltaLogical.y / steps

		var pos = current
		repeat(steps) {
			// Xのみ
			val tryX = Offset(pos.x + sx, pos.y)
			if (isPath(tryX)) pos = tryX
			// Yのみ
			val tryY = Offset(pos.x, pos.y + sy)
			if (isPath(tryY)) pos = tryY
		}

		val clampedX = pos.x.coerceIn(0f, (maze.cols - 1).toFloat())
		val clampedY = pos.y.coerceIn(0f, (maze.rows - 1).toFloat())
		return Offset(clampedX, clampedY)
	}

大体完成。だけどレベルが上がると通路に背景色が混じるのとゴールの当たり判定がシビアなので調整。

与えたプロンプト
レベルが上がると通路に背景色が混じるのとゴールの当たり判定がシビアです。
最終的なコード:MainActivity.kt(コードを表示 折りたたみ)
最終的なコード:MainActivity.kt

	package com.example.mazeapp

	import android.os.Bundle
	import android.view.MotionEvent
	import android.view.View
	import android.widget.TextView
	import androidx.activity.ComponentActivity
	import androidx.activity.compose.setContent
	import androidx.compose.foundation.Canvas
	import androidx.compose.foundation.background
	import androidx.compose.foundation.layout.*
	import androidx.compose.material3.MaterialTheme
	import androidx.compose.material3.Surface
	import androidx.compose.runtime.*
	import androidx.compose.ui.Alignment
	import androidx.compose.ui.Modifier
	import androidx.compose.ui.geometry.Offset
	import androidx.compose.ui.graphics.BlendMode
	import androidx.compose.ui.graphics.Color
	import androidx.compose.ui.graphics.drawscope.Stroke
	import androidx.compose.ui.input.pointer.pointerInteropFilter
	import androidx.compose.ui.unit.dp
	import androidx.compose.ui.viewinterop.AndroidView
	import kotlinx.coroutines.delay
	import kotlin.math.abs
	import kotlin.math.ceil
	import kotlin.math.floor
	import kotlin.math.max
	import kotlin.math.min
	import kotlin.math.roundToInt
	import kotlin.random.Random

	class MainActivity : ComponentActivity() {
		override fun onCreate(savedInstanceState: Bundle?) {
			super.onCreate(savedInstanceState)
			setContent { MaterialTheme { Surface { MazeGameApp() } } }
		}
	}

	/** 迷路:false=壁, true=通路(rows/colsは奇数、外周は壁リング) */
	data class Maze(
		val rows: Int,
		val cols: Int,
		val grid: Array<BooleanArray>, // false=wall, true=path
		val start: Pair<Int, Int>,
		val goal: Pair<Int, Int>
	)

	/** 操作・表示パラメータ(セル単位) */
	private const val MOVE_MULTIPLIER = 2f           // 画面距離の何倍動かすか
	private const val DEAD_PX = 0.5f                 // デッドゾーン(px)
	private const val PLAYER_RADIUS_CELL = 0.28f     // プレイヤー半径(セル比)
	private const val GOAL_RADIUS_CELL = 0.35f       // ゴール半径(セル比)
	private const val GOAL_TOUCH_THRESHOLD_CELL =
		PLAYER_RADIUS_CELL + GOAL_RADIUS_CELL        // 円同士が接したらクリア

	/** 描画色(画像認識向けに厳密な2値を確保) */
	private val BACKGROUND_COLOR = Color(0xFF000000) // 黒
	private val PATH_COLOR       = Color(0xFFFFFFFF) // 純白
	private val WALL_COLOR       = Color(0xFF202020) // 濃いグレー(白と十分差がありOCR向き)

	/** ====== メインUI ====== */
	@Composable
	fun MazeGameApp() {
		var level by remember { mutableStateOf(1) }
		var timeLeftMs by remember { mutableStateOf(30_000L) }
		var gameOver by remember { mutableStateOf(false) }

		var maze by remember { mutableStateOf(generateMazeForLevel(level)) }
		var playerPos by remember { mutableStateOf(cellCenter(maze.start, maze)) }

		LaunchedEffect(level) { android.util.Log.i("MazeTAG", "Level=$level started") }
		LaunchedEffect(timeLeftMs) { android.util.Log.i("MazeTAG", "TimeLeftMs=$timeLeftMs") }

		// Timer
		LaunchedEffect(level, gameOver) {
			timeLeftMs = 30_000L
			if (!gameOver) {
				val startTime = System.currentTimeMillis()
				while (timeLeftMs > 0 && !gameOver) {
					delay(100L)
					val elapsed = System.currentTimeMillis() - startTime
					timeLeftMs = max(0L, 30_000L - elapsed)
				}
				if (timeLeftMs <= 0) gameOver = true
			}
		}

		Column(modifier = Modifier.fillMaxSize().background(BACKGROUND_COLOR)) {
			// HUD(TextView)
			Row(
				modifier = Modifier.fillMaxWidth().padding(8.dp),
				horizontalArrangement = Arrangement.SpaceBetween,
				verticalAlignment = Alignment.CenterVertically
			) {
				AndroidView(
					modifier = Modifier.wrapContentSize(),
					factory = { ctx ->
						TextView(ctx).apply {
							id = View.generateViewId()
							contentDescription = "LEVEL_TEXT"
							textSize = 18f
							setTextColor(android.graphics.Color.WHITE)
						}
					},
					update = { tv -> tv.text = "Level: $level" }
				)
				AndroidView(
					modifier = Modifier.wrapContentSize(),
					factory = { ctx ->
						TextView(ctx).apply {
							id = View.generateViewId()
							contentDescription = "TIME_TEXT"
							textSize = 18f
							setTextColor(android.graphics.Color.WHITE)
						}
					},
					update = { tv ->
						tv.text = String.format("Time: %.1fs", timeLeftMs / 1000.0)
					}
				)
			}

			Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
				GameCanvas(
					maze = maze,
					playerPos = playerPos,
					onSwipeMove = { deltaLogical ->
						val next = tryMovePlayer(playerPos, deltaLogical, maze)
						playerPos = next

						// ★ 赤円に触れたらゴール(円と円の接触)
						val goalCenter = cellCenter(maze.goal, maze)
						if (distance(playerPos, goalCenter) <= GOAL_TOUCH_THRESHOLD_CELL) {
							level += 1
							maze = generateMazeForLevel(level)
							playerPos = cellCenter(maze.start, maze)
							timeLeftMs = 30_000L
							android.util.Log.i("MazeTAG", "StageCleared! Next Level=$level")
						}
					}
				)

				if (gameOver) {
					AndroidView(
						modifier = Modifier.fillMaxSize(),
						factory = { ctx ->
							TextView(ctx).apply {
								id = View.generateViewId()
								contentDescription = "GAME_OVER_TEXT"
								text = "GAME OVER (tap to restart)"
								textSize = 24f
								setTextColor(android.graphics.Color.RED)
								setBackgroundColor(android.graphics.Color.argb(160, 0, 0, 0))
								gravity = android.view.Gravity.CENTER
							}
						}
					)
					Box(
						modifier = Modifier
							.fillMaxSize()
							.pointerInteropFilter { event ->
								when (event.actionMasked) {
									MotionEvent.ACTION_UP -> {
										gameOver = false
										level = 1
										maze = generateMazeForLevel(level)
										playerPos = cellCenter(maze.start, maze)
										timeLeftMs = 30_000L
										android.util.Log.i("MazeTAG", "Restart")
										true
									}
									else -> true
								}
							}
					)
				}
			}
		}
	}

	/** ===== 迷路生成:ランダム Prim 法(外周壁/奇数セルのみ掘る) ===== */
	fun generateMazeForLevel(level: Int): Maze {
		val baseOdd = 21
		val rawSize = baseOdd + (level - 1) * 2
		val rows = if (rawSize % 2 == 0) rawSize + 1 else rawSize
		val cols = rows

		val grid = Array(rows) { BooleanArray(cols) { false } } // false=壁, true=通路

		val startCell = 1 to 1
		val goalCell  = (rows - 2) to (cols - 2)

		val visited = Array(rows) { BooleanArray(cols) { false } }
		fun isOddCell(r: Int, c: Int) =
			(r % 2 == 1) && (c % 2 == 1) && (r in 1 until rows - 1) && (c in 1 until cols - 1)

		data class Wall(val r: Int, val c: Int, val aR: Int, val aC: Int, val bR: Int, val bC: Int)

		fun addFrontierFor(cellR: Int, cellC: Int, walls: MutableList<Wall>) {
			val dirs = arrayOf(0 to -1, 0 to 1, -1 to 0, 1 to 0)
			for ((dr, dc) in dirs) {
				val wr = cellR + dr
				val wc = cellC + dc
				val br = cellR + dr * 2
				val bc = cellC + dc * 2
				if (wr in 1 until rows - 1 && wc in 1 until cols - 1 &&
					br in 1 until rows - 1 && bc in 1 until cols - 1 &&
					isOddCell(br, bc) && !visited[br][bc]
				) {
					walls.add(Wall(wr, wc, cellR, cellC, br, bc))
				}
			}
		}

		val walls = mutableListOf<Wall>()
		val (sr, sc) = startCell
		visited[sr][sc] = true
		grid[sr][sc] = true
		addFrontierFor(sr, sc, walls)

		val rng = Random(level * 9973)
		while (walls.isNotEmpty()) {
			val idx = rng.nextInt(walls.size)
			val wall = walls.removeAt(idx)
			val aVisited = visited[wall.aR][wall.aC]
			val bVisited = visited[wall.bR][wall.bC]
			if (aVisited.xor(bVisited)) {
				grid[wall.r][wall.c] = true
				if (!aVisited) {
					visited[wall.aR][wall.aC] = true
					grid[wall.aR][wall.aC] = true
					addFrontierFor(wall.aR, wall.aC, walls)
				}
				if (!bVisited) {
					visited[wall.bR][wall.bC] = true
					grid[wall.bR][wall.bC] = true
					addFrontierFor(wall.bR, wall.bC, walls)
				}
			}
		}

		grid[startCell.first][startCell.second] = true
		grid[goalCell.first][goalCell.second] = true

		return Maze(rows, cols, grid, startCell, goalCell)
	}

	/** セル中心(論理座標:x=列+0.5, y=行+0.5) */
	fun cellCenter(cell: Pair<Int, Int>, maze: Maze): Offset {
		return Offset(cell.second + 0.5f, cell.first + 0.5f)
	}

	/** 2D距離(論理座標) */
	fun distance(a: Offset, b: Offset): Float {
		val dx = a.x - b.x
		val dy = a.y - b.y
		return kotlin.math.sqrt(dx * dx + dy * dy)
	}

	/** ===== Canvas描画&入力(“二値・整数ピクセル”で混色防止) ===== */
	@Composable
	fun GameCanvas(
		maze: Maze,
		playerPos: Offset,
		onSwipeMove: (Offset) -> Unit
	) {
		var lastX by remember { mutableStateOf(0f) }
		var lastY by remember { mutableStateOf(0f) }
		var cellSize by remember { mutableStateOf(0f) }

		Box(modifier = Modifier.fillMaxSize()) {
			Canvas(
				modifier = Modifier
					.fillMaxSize()
					.background(BACKGROUND_COLOR)
					.pointerInteropFilter { event ->
						when (event.actionMasked) {
							MotionEvent.ACTION_DOWN -> {
								lastX = event.x
								lastY = event.y
								true
							}
							MotionEvent.ACTION_MOVE -> {
								val dxPx = event.x - lastX
								val dyPx = event.y - lastY
								lastX = event.x
								lastY = event.y

								if (cellSize > 0f) {
									if (abs(dxPx) > DEAD_PX || abs(dyPx) > DEAD_PX) {
										val deltaLogical = Offset(
											(dxPx / cellSize) * MOVE_MULTIPLIER,
											(dyPx / cellSize) * MOVE_MULTIPLIER
										)
										onSwipeMove(deltaLogical)
									}
								}
								true
							}
							MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> true
							else -> true
						}
					}
			) {
				val w = size.width
				val h = size.height

				// ▼ 小数を避ける:整数ピクセルで描画
				val rawCell = min(w / maze.cols, h / maze.rows)
				val snappedCell = floor(rawCell)         // 切り捨て
				cellSize = snappedCell

				val totalW = snappedCell * maze.cols
				val totalH = snappedCell * maze.rows
				val originX = floor((w - totalW) / 2f)   // 左右中央に整数配置
				val originY = floor((h - totalH) / 2f)   // 上下中央に整数配置

				// === グリッド領域をまず「純白」で一括塗り(通路色を担保) ===
				drawRect(
					color = PATH_COLOR,
					topLeft = Offset(originX, originY),
					size = androidx.compose.ui.geometry.Size(totalW, totalH),
					blendMode = BlendMode.Src // 完全上書き
				)

				// === その上に「壁セル」だけを濃色で塗る(隙間ゼロ) ===
				for (r in 0 until maze.rows) {
					for (c in 0 until maze.cols) {
						if (!maze.grid[r][c]) {
							// 壁のみ描く(整数座標・整数サイズ)
							val left = originX + c * snappedCell
							val top  = originY + r * snappedCell
							drawRect(
								color = WALL_COLOR,
								topLeft = Offset(left, top),
								size = androidx.compose.ui.geometry.Size(snappedCell, snappedCell),
								blendMode = BlendMode.Src // 完全上書き(混色なし)
							)
						}
					}
				}

				// 外枠(整数座標・Strokeで視覚補助のみ)
				drawRect(
					color = Color(0xFF303030),
					topLeft = Offset(originX, originY),
					size = androidx.compose.ui.geometry.Size(totalW, totalH),
					style = Stroke(width = 2f)
				)

				// ゴール(赤:視覚用、OCRは壁/通路二値だけ見ればOK)
				val goalCenterPx = Offset(
					x = originX + (maze.goal.second + 0.5f) * snappedCell,
					y = originY + (maze.goal.first + 0.5f) * snappedCell
				)
				drawCircle(
					Color.Red,
					radius = snappedCell * GOAL_RADIUS_CELL,
					center = goalCenterPx
				)

				// プレイヤー(青)
				val playerPx = Offset(
					x = originX + playerPos.x * snappedCell,
					y = originY + playerPos.y * snappedCell
				)
				drawCircle(
					Color.Blue,
					radius = snappedCell * PLAYER_RADIUS_CELL,
					center = playerPx
				)
			}
		}
	}

	/** ===== プレイヤー移動:軸分離+floor判定(斜めトンネル防止) ===== */
	fun tryMovePlayer(current: Offset, deltaLogical: Offset, maze: Maze): Offset {
		fun isPath(p: Offset): Boolean {
			val r = p.y.toInt()  // floor
			val c = p.x.toInt()
			return r in 0 until maze.rows && c in 0 until maze.cols && maze.grid[r][c]
		}

		val mag = max(abs(deltaLogical.x), abs(deltaLogical.y))
		val steps = when {
			mag < 0.05f -> 1
			mag < 0.15f -> 4
			else -> ceil(mag * 12f).toInt().coerceAtMost(64)
		}
		val sx = deltaLogical.x / steps
		val sy = deltaLogical.y / steps

		var pos = current
		repeat(steps) {
			// Xのみ
			val tryX = Offset(pos.x + sx, pos.y)
			if (isPath(tryX)) pos = tryX
			// Yのみ
			val tryY = Offset(pos.x, pos.y + sy)
			if (isPath(tryY)) pos = tryY
		}

		val clampedX = pos.x.coerceIn(0f, (maze.cols - 1).toFloat())
		val clampedY = pos.y.coerceIn(0f, (maze.rows - 1).toFloat())
		return Offset(clampedX, clampedY)
	}

完成

次はテスト自動化です。

テストの自動化(3.5時間 時間切れ)

初回与えたプロンプト
これまで作成したアプリのテストを自動化したいです。
・言語はPythonを使用
・端末の操作にはadbコマンドを使用
・アプリの起動、ゲームオーバーの判定、ステージクリアの際に継続してテストを実施、ゲームオーバーになったらテストを終了
上記の実装をしてください。

実装がランダムの操作だったため、画像認識+経路探索で行うように指示。
追加でLogcatを使用しているものは使用しないように置き換える指示を追加

与えたプロンプト
操作がランダムになっているため画像認識+経路探索でゴールへ辿り着くように操作してください。
Logcatを使用している箇所はLayoutから取得するようにしてください。

経路探索でゴールが見つからないエラーログ

エラーログ
python maze_vision_test.py
[INFO] Starting app...
[WARN] Initial UI dump failed: reason=NOT_XML, detail=no XML header in sdcard dump
[WARN] Path not found; retry...

対応後、動作開始が遅い、移動量がおかしい、以前と同じログが出る。

エラーログ
python maze_vision_test.py --app-id com.example.mazeapp --activity .MainActivity
[INFO] Starting app...
[WARN] Initial UI dump failed: reason=NOT_XML, detail=no XML obtained from any method
[INFO] Game Over detected. Ending test.
与えたプロンプト
動作し始めるまで遅いです。
あと移動量が違うっぽいです。
python maze_vision_test.py --app-id com.example.mazeapp --activity .MainActivity
[INFO] Starting app...
[WARN] Initial UI dump failed: reason=NOT_XML, detail=no XML obtained from any method
[INFO] Game Over detected. Ending test.
ログもでます。

対応後、Android の「バックジェスチャー」が出続けて自動処理が動かない

与えたプロンプト
Android の「バックジェスチャー」が出続けて自動処理が動きません。

対応後、やっぱり経路探索の移動量がおかしい

時間切れ

最終的な自動化のコード:maze_vision_test.py(コードを表示 折りたたみ)
最終的な自動化のコード:maze_vision_test.py
	# -*- coding: utf-8 -*-
	"""
	Vision + Pixel-path BFS (no Logcat) with Edge-Safe & Source Override
	- スクショ→最大白矩形を迷路ROIに抽出
	- ゴール(赤)/プレイヤー(青):HSV→最大輪郭の重心(閾値を自動緩和)
	- 通路二値マスク(白=1)→ ダウンサンプル(<=256px)→ BFSで最短経路
	- ★ バックジェスチャー対策:安全マージンのクランプ、入力ソース mouse/stylus 切替、
		事前セーフティタップ(中心付近)、経路点の内側寄せ(オフセット)
	- 連続ドラッグ(input motionevent/draganddropフォールバック)
	- UIAutomatorは初動では使わず、移動後にのみチェック(--no-ui-checkで無効化)

	使い方:
	python maze_vision_test.py \
		--app-id com.example.mazeapp \
		--activity .MainActivity \
		--source mouse \
		--edge-dp 120 \
		--display-id 0
	"""

	import argparse, re, sys, time, xml.etree.ElementTree as ET, subprocess
	from collections import deque
	from dataclasses import dataclass
	from typing import Optional, Tuple, List

	import numpy as np
	import cv2

	# ===== 閾値・チューニング =====
	WHITE_MIN = (240, 240, 240)
	WHITE_MAX = (255, 255, 255)

	RED_RANGES       = [((0,120,120),(10,255,255)), ((160,120,120),(180,255,255))]
	BLUE_RANGE       = ((100,120,120),(130,255,255))
	RED_RANGES_RELAX = [((0, 60, 60),(10,255,255)), ((160, 60, 60),(180,255,255))]
	BLUE_RANGE_RELAX = ((95, 60, 60),(135,255,255))

	DRAG_GAIN   = 0.50    # 送る距離の倍率(×2補正の逆数)
	MOVE_SLEEP  = 0.018   # MOVEのインターバル
	CHECK_GO_DELAY_MS = 800

	SAFE_PRETAP_DP = 32   # セーフティタップの位置(中心寄り)に使う余白(dp)
	INNER_OFFSET_PX = 24  # 経路点をROI内側に寄せるオフセット(px)※端ジェスチャーを避ける

	# ===== ADB =====
	@dataclass
	class Adb:
		serial: Optional[str] = None
		def _adb(self) -> str: return "adb" if self.serial is None else f"adb -s {self.serial}"
		def run(self, cmd: str, timeout: float = 12.0):
			p = subprocess.Popen(f"{self._adb()} {cmd}", shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
			try: out_b, err_b = p.communicate(timeout=timeout)
			except subprocess.TimeoutExpired: p.kill(); out_b, err_b = p.communicate()
			return p.returncode, out_b.decode(errors="ignore"), err_b.decode(errors="ignore")
		def run_bytes(self, cmd: str, timeout: float = 12.0):
			p = subprocess.Popen(f"{self._adb()} {cmd}", shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
			try: out_b, err_b = p.communicate(timeout=timeout)
			except subprocess.TimeoutExpired: p.kill(); out_b, err_b = p.communicate()
			return p.returncode, out_b, err_b.decode(errors="ignore")
		def check_device(self):
			code, out, err = self.run("devices")
			lines = [l for l in out.splitlines() if "\tdevice" in l]
			if code != 0 or (not lines and self.serial is None):
				raise RuntimeError("No connected devices")
			if self.serial and self.serial not in out:
				raise RuntimeError(f"Serial {self.serial} not found")
		def start_app(self, app_id: str, activity: str):
			name = activity if not activity.startswith(".") else f"{app_id}{activity}"
			code, out, err = self.run(f"shell am start -n {app_id}/{name}")
			if code != 0: raise RuntimeError(f"Failed to start: {err or out}")
		def screencap_np(self) -> np.ndarray:
			self.run("shell screencap -p /sdcard/screen.png")
			code, png, err = self.run_bytes("exec-out cat /sdcard/screen.png")
			self.run("shell rm /sdcard/screen.png")
			if code != 0 or not png: raise RuntimeError(f"screencap failed: {err}")
			img = cv2.imdecode(np.frombuffer(png, dtype=np.uint8), cv2.IMREAD_COLOR)
			if img is None: raise RuntimeError("cv2.imdecode failed")
			return img

	# ===== 画面メトリクス(安全マージン算出) =====
	@dataclass
	class ScreenMetrics:
		width_px: int
		height_px: int
		density_dpi: int
		edge_margin_px: int
		top_margin_px: int
		bottom_margin_px: int

	def parse_wm_size(text: str) -> Tuple[int,int]:
		m = re.search(r"Physical size:\s*(\d+)x(\d+)", text)
		if not m: raise RuntimeError(f"wm size parse failed: {text}")
		return int(m.group(1)), int(m.group(2))

	def parse_wm_density(text: str) -> Optional[int]:
		m = re.search(r"Physical density:\s*(\d+)", text)
		return int(m.group(1)) if m else None

	def get_screen_metrics(adb: Adb, edge_dp: int = 24, top_dp: int = 16, bottom_dp: int = 24) -> ScreenMetrics:
		_, out_size, _ = adb.run("shell wm size")
		w, h = parse_wm_size(out_size)
		_, out_den, _ = adb.run("shell wm density")
		dpi = parse_wm_density(out_den)
		if not dpi:
			_, prop, _ = adb.run("shell getprop ro.sf.lcd_density")
			dpi = int(prop.strip()) if prop.strip().isdigit() else 440
		def dp_to_px(dp: int) -> int: return int(round(dp * dpi / 160.0))
		return ScreenMetrics(
			width_px=w, height_px=h, density_dpi=dpi,
			edge_margin_px=dp_to_px(edge_dp),
			top_margin_px=dp_to_px(top_dp),
			bottom_margin_px=dp_to_px(bottom_dp)
		)

	def clamp_edge_safe(x: int, y: int, sm: ScreenMetrics) -> Tuple[int,int]:
		cx = max(sm.edge_margin_px, min(sm.width_px  - sm.edge_margin_px, x))
		cy = max(sm.top_margin_px,  min(sm.height_px - sm.bottom_margin_px, y))
		return cx, cy

	# ===== UI dump(必要なら) =====
	@dataclass
	class DumpResult:
		ok: bool; xml: Optional[str]; reason: Optional[str]; detail: Optional[str]
	CMD_ERROR="CMD_ERROR"; NOT_XML="NOT_XML"; PARSE_ERR="PARSE_ERROR"; EMPTY_H="EMPTY_HIERARCHY"

	def _xml_only(t: str) -> Optional[str]:
		m = re.search(r"(<\?xml[\s\S]+?</hierarchy>)", t)
		return m.group(1) if m else None

	def safe_uia_dump(adb: Adb) -> DumpResult:
		for comp in (True, False):
			flag = " --compressed" if comp else ""
			code, out, err = adb.run(f"exec-out uiautomator dump{flag} /dev/tty", timeout=6.0)
			if code == 0:
				xml = _xml_only(out)
				if xml:
					try:
						root = ET.fromstring(xml)
						if any(True for _ in root.iter("node")): return DumpResult(True, xml, None, None)
						else: return DumpResult(False, xml, EMPTY_H, "empty")
					except ET.ParseError as e: return DumpResult(False, xml, PARSE_ERR, str(e))
		for comp in (True, False):
			flag = " --compressed" if comp else ""
			code, out, err = adb.run(f"shell uiautomator dump{flag}", timeout=6.0)
			if code == 0:
				m = re.search(r"dumped to:\s*(/.*?\.xml)", out)
				if m:
					path = m.group(1).strip()
					code2, txt, err2 = adb.run(f"exec-out cat {path}", timeout=6.0)
					if code2 == 0:
						xml = _xml_only(txt)
						if xml:
							try:
								root = ET.fromstring(xml)
								if any(True for _ in root.iter("node")): return DumpResult(True, xml, None, None)
								else: return DumpResult(False, xml, EMPTY_H, "empty")
							except ET.ParseError as e: return DumpResult(False, xml, PARSE_ERR, str(e))
		return DumpResult(False, None, NOT_XML, "no XML")

	def parse_level(xml: str) -> Optional[int]:
		try:
			root = ET.fromstring(xml)
			for node in root.iter("node"):
				if node.attrib.get("content-desc","")=="LEVEL_TEXT":
					txt=node.attrib.get("text",""); m=re.search(r"Level:\s*(\d+)", txt)
					if m: return int(m.group(1))
		except ET.ParseError: return None
		return None

	def is_game_over(xml: str) -> bool:
		try:
			root = ET.fromstring(xml)
			for node in root.iter("node"):
				if node.attrib.get("content-desc","")=="GAME_OVER_TEXT":
					txt=node.attrib.get("text","")
					if "GAME OVER" in txt.upper(): return True
		except ET.ParseError: return False
		return False

	# ===== Vision =====
	def largest_white_rect(img: np.ndarray) -> Tuple[int,int,int,int]:
		mask = cv2.inRange(img, np.array(WHITE_MIN), np.array(WHITE_MAX))
		contours,_ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
		best=(0,0,0,0); best_area=0
		for c in contours:
			x,y,w,h = cv2.boundingRect(c); area=w*h
			if area>best_area: best_area=area; best=(x,y,w,h)
		return best

	def detect_centroid_hsv(img_bgr: np.ndarray, ranges: List[Tuple[Tuple[int,int,int],Tuple[int,int,int]]]) -> Optional[Tuple[int,int]]:
		hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV)
		total=None
		for low,high in ranges:
			mask = cv2.inRange(hsv, np.array(low), np.array(high))
			total = mask if total is None else cv2.bitwise_or(total, mask)
		if total is None: return None
		contours,_ = cv2.findContours(total, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
		if not contours: return None
		c = max(contours, key=cv2.contourArea)
		M = cv2.moments(c)
		if M["m00"]==0: return None
		return int(M["m10"]/M["m00"]), int(M["m01"]/M["m00"])

	def detect_player_goal(roi: np.ndarray) -> Tuple[Optional[Tuple[int,int]], Optional[Tuple[int,int]]]:
		g = detect_centroid_hsv(roi, RED_RANGES); p = detect_centroid_hsv(roi, [BLUE_RANGE])
		if g is None: g = detect_centroid_hsv(roi, RED_RANGES_RELAX)
		if p is None: p = detect_centroid_hsv(roi, [BLUE_RANGE_RELAX])
		return p, g


	# --- 色マスク(HSV)を生成:指定色域のピクセルを 1、その他を 0 ---
	def color_mask_hsv(img_bgr: np.ndarray,
					ranges: List[Tuple[Tuple[int,int,int], Tuple[int,int,int]]]) -> np.ndarray:
		"""
		img_bgr : BGR画像(ROI)
		ranges  : HSVの下限/上限タプルのリスト 例: [((0,120,120),(10,255,255)), ((160,120,120),(180,255,255))]
		戻り値  : 2値マスク(対象色=1, それ以外=0)ノイズ除去済み
		"""
		hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV)
		total = None
		for low, high in ranges:
			m = cv2.inRange(hsv, np.array(low), np.array(high))
			total = m if total is None else cv2.bitwise_or(total, m)
		if total is None:
			total = np.zeros(img_bgr.shape[:2], dtype=np.uint8)

		# ノイズ除去(少し開閉)
		k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3,3))
		total = cv2.morphologyEx(total, cv2.MORPH_OPEN, k, iterations=1)
		total = cv2.morphologyEx(total, cv2.MORPH_CLOSE, k, iterations=1)
		return (total > 0).astype(np.uint8)


	# --- 色マスク(HSV)を生成:指定色域のピクセルを 1、その他を 0 ---
	def color_mask_hsv(img_bgr: np.ndarray,
					ranges: List[Tuple[Tuple[int,int,int], Tuple[int,int,int]]]) -> np.ndarray:
		"""
		img_bgr : BGR画像(ROI)
		ranges  : HSVの下限/上限タプルのリスト 例: [((0,120,120),(10,255,255)), ((160,120,120),(180,255,255))]
		戻り値  : 2値マスク(対象色=1, それ以外=0)ノイズ除去済み
		"""
		hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV)
		total = None
		for low, high in ranges:
			m = cv2.inRange(hsv, np.array(low), np.array(high))
			total = m if total is None else cv2.bitwise_or(total, m)
		if total is None:
			total = np.zeros(img_bgr.shape[:2], dtype=np.uint8)

		# ノイズ除去(少し開閉)
		k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3,3))
		total = cv2.morphologyEx(total, cv2.MORPH_OPEN, k, iterations=1)
		total = cv2.morphologyEx(total, cv2.MORPH_CLOSE, k, iterations=1)
		return (total > 0).astype(np.uint8)


	# --- 通路マスク(白 OR 赤 OR 青)+膨張+スタート/ゴールのブリッジ ---
	def build_passable_mask(roi: np.ndarray,
							dilate_px: int,
							include_colors: bool,
							player_px: Tuple[int,int],
							goal_px: Tuple[int,int]) -> np.ndarray:
		"""
		通路マスクを構築:白(必須) +(任意)赤/青 を移動可能に含める。
		さらに dilate で細い通路/小ギャップを接続し、start/goal 周辺を円形にブリッジして
		BFSの開始/終了を確実化します。

		引数:
		roi            : 迷路領域のBGR画像
		dilate_px      : 膨張カーネルの直径(px)例: 3,5,7
		include_colors : Trueなら赤/青も移動可能に含める
		player_px      : ROI座標系のプレイヤー重心 (x,y)
		goal_px        : ROI座標系のゴール重心 (x,y)
		戻り値:
		passable       : 2値マスク(移動可能=1, それ以外=0)
		"""
		# 1) 白(通路)
		white = cv2.inRange(roi, np.array(WHITE_MIN), np.array(WHITE_MAX))
		k = cv2.getStructuringElement(cv2.MORPH_RECT, (3,3))
		white = cv2.morphologyEx(white, cv2.MORPH_OPEN, k, iterations=1)
		white = cv2.morphologyEx(white, cv2.MORPH_CLOSE, k, iterations=1)
		passable = (white > 0).astype(np.uint8)

		# 2) 赤/青(ゴール円・プレイヤー円)も移動可能に含める(必要なら閾値を緩和)
		if include_colors:
			red_bin = color_mask_hsv(roi, RED_RANGES)
			if np.sum(red_bin) == 0:
				red_bin = color_mask_hsv(roi, RED_RANGES_RELAX)
			blue_bin = color_mask_hsv(roi, [BLUE_RANGE])
			if np.sum(blue_bin) == 0:
				blue_bin = color_mask_hsv(roi, [BLUE_RANGE_RELAX])

			# 円周を少し広げて “接触でOK” の体感を出す
			dilate_k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5,5))
			red_bin  = cv2.dilate(red_bin,  dilate_k, iterations=1)
			blue_bin = cv2.dilate(blue_bin, dilate_k, iterations=1)

			passable = np.clip(passable + red_bin + blue_bin, 0, 1).astype(np.uint8)

		# 3) 全体を膨張して細切れを接続
		if dilate_px > 0:
			dk = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (dilate_px, dilate_px))
			passable = cv2.dilate(passable, dk, iterations=1)

		# 4) start/goal 周辺に円形ブリッジ(強制的に移動可能化)
		h, w = passable.shape
		def paint_disk(cx: int, cy: int, r: int):
			cx = int(cx); cy = int(cy)
			x0 = max(0, cx - r); x1 = min(w-1, cx + r)
			y0 = max(0, cy - r); y1 = min(h-1, cy + r)
			for yy in range(y0, y1+1):
				for xx in range(x0, x1+1):
					if (xx - cx)*(xx - cx) + (yy - cy)*(yy - cy) <= r*r:
						passable[yy, xx] = 1

		r_start = max(2, dilate_px // 2)
		r_goal  = max(2, dilate_px // 2)
		paint_disk(player_px[0], player_px[1], r_start)
		paint_disk(goal_px[0],   goal_px[1],  r_goal)

		return passable


	# --- 互換のための別名(古い呼び出し名が残っていても動かす) ---
	def build_possible_mask(*args, **kwargs):
		return build_passable_mask(*args, **kwargs)

	# ===== Pixel-path BFS =====
	def build_path_mask_white_only(roi: np.ndarray) -> np.ndarray:
		mask = cv2.inRange(roi, np.array(WHITE_MIN), np.array(WHITE_MAX))
		k = cv2.getStructuringElement(cv2.MORPH_RECT, (3,3))
		mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, k, iterations=1)
		mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k, iterations=1)
		return (mask>0).astype(np.uint8)

	def downscale_mask(mask: np.ndarray, max_side: int = 256) -> Tuple[np.ndarray, float]:
		h,w = mask.shape; scale=1.0
		if max(h,w)>max_side:
			scale = max_side/float(max(h,w))
			new_w = max(2, int(w*scale)); new_h = max(2, int(h*scale))
			small = cv2.resize(mask, (new_w,new_h), interpolation=cv2.INTER_NEAREST)
			return small, scale
		return mask.copy(), 1.0

	def bfs_path(mask_small: np.ndarray, start_xy: Tuple[int,int], goal_xy: Tuple[int,int]) -> Optional[List[Tuple[int,int]]]:
		h,w = mask_small.shape
		def inb(x,y): return 0<=x<w and 0<=y<h
		sx,sy = start_xy; gx,gy = goal_xy
		if not inb(sx,sy) or not inb(gx,gy): return None
		if mask_small[sy,sx]==0 or mask_small[gy,gx]==0: return None
		dq = deque([(sx,sy)]); visited=np.zeros((h,w), dtype=np.uint8)
		visited[sy,sx]=1; prev=np.full((h,w,2), -1, dtype=np.int32)
		while dq:
			x,y = dq.popleft()
			if x==gx and y==gy:
				path=[(x,y)]
				while not (x==sx and y==sy):
					px,py = prev[y,x]
					if px<0: break
					x,y = px,py
					path.append((x,y))
				path.reverse(); return path
			for dx,dy in ((1,0),(-1,0),(0,1),(0,-1)):
				nx,ny = x+dx, y+dy
				if 0<=nx<w and 0<=ny<h and not visited[ny,nx] and mask_small[ny,nx]==1:
					visited[ny,nx]=1; prev[ny,nx]=[x,y]; dq.append((nx,ny))
		return None

	def sample_points(path_small: List[Tuple[int,int]], step: int = 8) -> List[Tuple[int,int]]:
		if not path_small: return []
		pts=[path_small[0]]
		for i in range(step, len(path_small), step): pts.append(path_small[i])
		pts.append(path_small[-1])
		uniq=[pts[0]]
		for p in pts[1:]:
			if p!=uniq[-1]: uniq.append(p)
		return uniq

	# ===== Edge-safe 入力送出器(入力ソース/表示ID切替・クランプ・内側寄せ・事前タップ) =====
	class EdgeSafeSender:
		def __init__(self, adb: Adb, sm: ScreenMetrics, source: str = "touchscreen", display_id: Optional[int] = None):
			self.adb = adb
			self.sm = sm
			self.source = source  # "mouse" | "stylus" | "touchscreen"
			self.display_id = display_id

		def _prefix(self) -> str:
			# e.g. "input mouse -d 0"
			s = f"{self.source}" if self.source else ""
			d = f"-d {self.display_id}" if self.display_id is not None else ""
			# 両方指定したいので、順番は [source] [-d DISPLAY_ID] の形に
			return f"{s} {d}".strip()

		def _run_input(self, cmd: str):
			prefix = self._prefix()
			if prefix:
				return self.adb.run(f"shell input {prefix} {cmd}")
			else:
				return self.adb.run(f"shell input {cmd}")

		def safe_pretap_center(self, roi_rect: Tuple[int,int,int,int], safe_dp: int = SAFE_PRETAP_DP):
			# ROI中心付近を事前タップ(ジェスチャー判定を安定化)
			rx, ry, rw, rh = roi_rect
			cx = rx + rw // 2
			cy = ry + rh // 2
			# 画面マージンを考慮して少し内側をタップ
			tap_x, tap_y = clamp_edge_safe(cx, cy, self.sm)
			self._run_input(f"tap {tap_x} {tap_y}")

		def drag_with_gain(self, pts: List[Tuple[int,int]], gain: float) -> bool:
			if len(pts) < 2: return False
			# すべてクランプ&内側寄せ
			safe_pts = []
			for (x,y) in pts:
				sx, sy = clamp_edge_safe(x, y, self.sm)
				# 経路点をさらに内側へオフセット(左端に近いなら右へ、右端なら左へ)
				if sx < self.sm.edge_margin_px + INNER_OFFSET_PX:
					sx += INNER_OFFSET_PX
				elif sx > self.sm.width_px - self.sm.edge_margin_px - INNER_OFFSET_PX:
					sx -= INNER_OFFSET_PX
				safe_pts.append((sx, sy))

			# motionevent path
			rc,_,_ = self._run_input(f"motionevent DOWN {safe_pts[0][0]} {safe_pts[0][1]}")
			if rc == 0:
				cur_x, cur_y = safe_pts[0]
				for i in range(1, len(safe_pts)):
					tx, ty = safe_pts[i]
					dx = tx - cur_x; dy = ty - cur_y
					step_x = int(cur_x + dx * gain); step_y = int(cur_y + dy * gain)
					step_x, step_y = clamp_edge_safe(step_x, step_y, self.sm)
					self._run_input(f"motionevent MOVE {step_x} {step_y}")
					cur_x, cur_y = step_x, step_y
					time.sleep(MOVE_SLEEP)
				self._run_input(f"motionevent UP {cur_x} {cur_y}")
				return True

			# fallback: draganddrop chain
			cur_x, cur_y = safe_pts[0]
			for i in range(1, len(safe_pts)):
				tx, ty = safe_pts[i]
				dx = tx - cur_x; dy = ty - cur_y
				step_x = int(cur_x + dx * gain); step_y = int(cur_y + dy * gain)
				step_x, step_y = clamp_edge_safe(step_x, step_y, self.sm)
				self._run_input(f"draganddrop {cur_x} {cur_y} {step_x} {step_y} 60")
				cur_x, cur_y = step_x, step_y
				time.sleep(MOVE_SLEEP)
			self._run_input(f"tap {cur_x} {cur_y}")
			return True

	# ===== Runner =====
	@dataclass
	class RunnerConfig:
		app_id: str
		activity: str
		serial: Optional[str] = None
		ui_check: bool = True
		edge_dp: int = 24
		source: str = "touchscreen"   # "mouse" / "stylus" / "touchscreen"
		display_id: Optional[int] = None

	class VisionPixelRunner:
		def __init__(self, cfg: RunnerConfig):
			self.adb=Adb(serial=cfg.serial); self.cfg=cfg
			self.start_ts=0; self.current_level: Optional[int]=None
			self.sm: Optional[ScreenMetrics] = None
			self.sender: Optional[EdgeSafeSender] = None

		def run(self):
			self.adb.check_device()
			print("[INFO] Starting app...")
			self.adb.start_app(self.cfg.app_id, self.cfg.activity)
			self.start_ts = time.time()

			# 画面メトリクス
			self.sm = get_screen_metrics(self.adb, edge_dp=self.cfg.edge_dp, top_dp=16, bottom_dp=24)
			# 入力送出器(source/display 指定)
			self.sender = EdgeSafeSender(self.adb, self.sm, source=self.cfg.source, display_id=self.cfg.display_id)

			moved_once=False
			while True:
				try: img = self.adb.screencap_np()
				except Exception as e:
					print(f"[WARN] Screencap failed: {e}"); time.sleep(0.3); continue

				x,y,w,h = largest_white_rect(img)
				if w==0 or h==0:
					print("[WARN] Maze area not found; retry..."); time.sleep(0.3); continue
				roi = img[y:y+h, x:x+w].copy()

				# 事前セーフティタップ(中心付近)
				self.sender.safe_pretap_center((x,y,w,h), safe_dp=SAFE_PRETAP_DP)

				player_px, goal_px = detect_player_goal(roi)
				if player_px is None or goal_px is None:
					print("[WARN] Player/Goal not detected; retry..."); time.sleep(0.3); continue

				# 通路(白のみ)でまず試行(軽量)
				mask = build_path_mask_white_only(roi)
				small, scale = downscale_mask(mask, max_side=256)
				sx = max(0, min(small.shape[1]-1, int(player_px[0]*scale)))
				sy = max(0, min(small.shape[0]-1, int(player_px[1]*scale)))
				gx = max(0, min(small.shape[1]-1, int(goal_px[0]*scale)))
				gy = max(0, min(small.shape[0]-1, int(goal_px[1]*scale)))
				path = bfs_path(small, (sx,sy), (gx,gy))

				if not path:
					# フォールバック:赤/青も含めて膨張
					from math import ceil
					for dil in (3, 5, 7):
						passable = build_passable_mask(roi, dilate_px=dil, include_colors=True,
													player_px=player_px, goal_px=goal_px)
						small2, scale2 = downscale_mask(passable, max_side=256)
						sx2 = max(0, min(small2.shape[1]-1, int(player_px[0]*scale2)))
						sy2 = max(0, min(small2.shape[0]-1, int(player_px[1]*scale2)))
						gx2 = max(0, min(small2.shape[1]-1, int(goal_px[0]*scale2)))
						gy2 = max(0, min(small2.shape[0]-1, int(goal_px[1]*scale2)))
						path = bfs_path(small2, (sx2,sy2), (gx2,gy2))
						if path:
							small, scale = small2, scale2
							break

				if not path:
					print("[WARN] Path not found; retry..."); time.sleep(0.3); continue

				pts_small = sample_points(path, step=8)
				pts_roi   = [(int(px/scale), int(py/scale)) for (px,py) in pts_small]
				pts_screen= [(x+px, y+py) for (px,py) in pts_roi]

				# Edge-safe 送出(ソース指定+クランプ+内側寄せ+ゲイン)
				if self.sender.drag_with_gain(pts_screen, gain=DRAG_GAIN): moved_once=True

				time.sleep(0.35)

	# ===== CLI =====
	if __name__ == "__main__":
		ap = argparse.ArgumentParser(description="Vision + Pixel-path BFS (no Logcat, Edge-Safe & Source Override)")
		ap.add_argument("--serial", default=None)
		ap.add_argument("--app-id", default="com.example.mazeapp")
		ap.add_argument("--activity", default=".MainActivity")
		ap.add_argument("--no-ui-check", action="store_true")
		ap.add_argument("--edge-dp", type=int, default=50, help="左右端の安全マージン(dp)")
		ap.add_argument("--source", default="touchscreen", help="input source: touchscreen|mouse|stylus")
		ap.add_argument("--display-id", type=int, default=None, help="input -d DISPLAY_ID(通常 0)")
		args = ap.parse_args()

		cfg = RunnerConfig(
			app_id=args.app_id, activity=args.activity, serial=args.serial,
			ui_check=(not args.no_ui_check), edge_dp=args.edge_dp,
			source=args.source, display_id=args.display_id
		)

		try:
			VisionPixelRunner(cfg).run()
		except Exception as e:
			print(f"[ERROR] {e}"); sys.exit(1)

Gemini

アプリの作成(1時間)

初期プロンプトの入力
1日でAndroidアプリの作成とテストの自動化を実装したいと考えています。
Androidアプリの作成の経験はあまりないです。
アプリの開発環境はAndroid Studioを考えています。
使用する言語はKotlinです。

以下アプリの内容
・2D迷路
・制限時間30秒以内にゴールへ辿り着くとステージクリア
・ステージクリアすると難易度が上がる
・制限時間内にゴールへ辿り着けなかった場合ゲームオーバー
・ゲームオーバー画面で画面をタップすることで新にゲームを開始
・操作はスワイプとゲームオーバー画面のタップ
・画面上でスワイプした距離の2倍、スワイプした方向に移動
・画面の構成要素は移動可能な通路(白)、移動不可の壁(暗いグレー)、自分の現在地(青い丸)、ゴール(赤い丸)、難易度を表すLevel表記(1からの数値)、制限時間の表記
・Levelの表記と制限時間の表記はテストで使用するため、adbコマンドで取得できる形式で実装します。
プロンプトの回答
ご要望の仕様を満たすための、最短ルートの設計とコードを用意しました。以下の構成で進めます。
UI設計: ADBで取得しやすいよう、標準的なTextViewを使用。
ゲームロジック: Canvasを使った描画と、シンプルな迷路生成アルゴリズム。
自動化: ADBコマンドでの情報取得方法。
res/layout/activity_main.xml(コードを表示 折りたたみ)
res/layout/activity_main.xml
	<?xml version="1.0" encoding="utf-8"?>
	<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
		xmlns:app="http://schemas.android.com/apk/res-auto"
		xmlns:tools="http://schemas.android.com/tools"
		android:layout_width="match_parent"
		android:layout_height="match_parent"
		android:fitsSystemWindows="true"
		tools:context=".MainActivity">

		<TextView
			android:id="@+id/levelText"
			android:layout_width="wrap_content"
			android:layout_height="wrap_content"
			android:text="Level: 1"
			android:textSize="24sp"
			app:layout_constraintStart_toStartOf="parent"
			app:layout_constraintTop_toTopOf="parent"
			android:layout_margin="16dp" />

		<TextView
			android:id="@+id/timeText"
			android:layout_width="wrap_content"
			android:layout_height="wrap_content"
			android:text="Time: 30"
			android:textSize="24sp"
			app:layout_constraintEnd_toEndOf="parent"
			app:layout_constraintTop_toTopOf="parent"
			android:layout_margin="16dp" />

		<FrameLayout
			android:id="@+id/gameContainer"
			android:layout_width="0dp"
			android:layout_height="0dp"
			android:layout_marginTop="16dp"
			app:layout_constraintBottom_toBottomOf="parent"
			app:layout_constraintEnd_toEndOf="parent"
			app:layout_constraintStart_toStartOf="parent"
			app:layout_constraintTop_toBottomOf="@+id/levelText">
		</FrameLayout>

		<TextView
			android:id="@+id/gameOverOverlay"
			android:layout_width="match_parent"
			android:layout_height="match_parent"
			android:background="#AA000000"
			android:gravity="center"
			android:text="GAME OVER\nTap to Restart"
			android:textColor="#FFFFFF"
			android:textSize="40sp"
			android:visibility="gone"
			android:clickable="true"
			android:focusable="true"/>
	</androidx.constraintlayout.widget.ConstraintLayout>
MainActivity.kt(コードを表示 折りたたみ)
MainActivity.kt
	package com.example.simplemaze // パッケージ名はご自身の環境に合わせてください

	import android.content.Context
	import android.graphics.Canvas
	import android.graphics.Color
	import android.graphics.Paint
	import android.os.Bundle
	import android.os.CountDownTimer
	import android.view.MotionEvent
	import android.view.View
	import android.widget.FrameLayout
	import android.widget.TextView
	import androidx.appcompat.app.AppCompatActivity
	import java.util.*
	import kotlin.math.abs

	class MainActivity : AppCompatActivity() {

		private lateinit var levelText: TextView
		private lateinit var timeText: TextView
		private lateinit var gameOverOverlay: TextView
		private lateinit var gameContainer: FrameLayout
		private lateinit var mazeView: MazeView

		private var currentLevel = 1
		private var timer: CountDownTimer? = null
		private var isGameOver = false

		override fun onCreate(savedInstanceState: Bundle?) {
			super.onCreate(savedInstanceState)
			setContentView(R.layout.activity_main)

			levelText = findViewById(R.id.levelText)
			timeText = findViewById(R.id.timeText)
			gameOverOverlay = findViewById(R.id.gameOverOverlay)
			gameContainer = findViewById(R.id.gameContainer)

			// ゲーム開始
			startNewLevel()

			// ゲームオーバー画面タップでリスタート
			gameOverOverlay.setOnClickListener {
				currentLevel = 1 // レベルリセット
				startNewLevel()
			}
		}

		private fun startNewLevel() {
			isGameOver = false
			gameOverOverlay.visibility = View.GONE
			levelText.text = "Level: $currentLevel" // ADBで取得する形式

			// 古いViewがあれば削除して新しく迷路を作成
			gameContainer.removeAllViews()
			
			// 難易度(壁の密度やサイズ)を調整したい場合はここで行いますが、
			// 今回はシンプルに毎回新しい迷路を生成します。
			mazeView = MazeView(this, currentLevel, ::onGoal, ::onGameOver)
			gameContainer.addView(mazeView)

			// タイマー開始 (30秒)
			timer?.cancel()
			timer = object : CountDownTimer(30000, 1000) {
				override fun onTick(millisUntilFinished: Long) {
					// ADBで取得する形式
					timeText.text = "Time: ${millisUntilFinished / 1000}" 
				}

				override fun onFinish() {
					if (!isGameOver) {
						timeText.text = "Time: 0"
						onGameOver()
					}
				}
			}.start()
		}

		private fun onGoal() {
			timer?.cancel()
			currentLevel++
			startNewLevel()
		}

		private fun onGameOver() {
			isGameOver = true
			timer?.cancel()
			runOnUiThread {
				gameOverOverlay.visibility = View.VISIBLE
			}
		}
	}

	// 迷路とプレイヤーを描画・処理するカスタムView
	class MazeView(
		context: Context, 
		val level: Int,
		val onGoalCallback: () -> Unit,
		val onGameOverCallback: () -> Unit
	) : View(context) {

		// 定数・パラメータ
		private val cellSize = 100f // 1マスのサイズ
		private val paint = Paint()
		
		// 迷路データ (1=壁, 0=通路)
		// 簡易化のため固定サイズか、画面サイズに合わせて動的に生成します
		// ここではロジック簡略化のため、少し小さめのグリッドで生成します
		private val cols = 9
		private val rows = 13
		private val map = Array(rows) { IntArray(cols) { 1 } } // 初期は全部壁

		// プレイヤー位置 (ピクセル単位)
		private var playerX = cellSize * 1.5f
		private var playerY = cellSize * 1.5f
		
		// ゴール位置 (マス座標)
		private var goalCol = cols - 2
		private var goalRow = rows - 2

		// タッチ操作用
		private var lastTouchX = 0f
		private var lastTouchY = 0f

		init {
			generateMaze()
			// プレイヤーの初期位置を通路に合わせる
			playerX = cellSize * 1.5f
			playerY = cellSize * 1.5f
		}

		// 穴掘り法による簡易迷路生成
		private fun generateMaze() {
			// 全て壁で埋める(initで実施済)
			// 奇数座標を通路にして穴を掘る
			for (y in 1 until rows - 1 step 2) {
				for (x in 1 until cols - 1 step 2) {
					map[y][x] = 0
					if (x > 1) { // 横方向へランダムに道を繋げる(簡易版)
						if (Random().nextBoolean()) map[y][x-1] = 0
					}
					if (y > 1) { // 縦方向へ
						if (Random().nextBoolean()) map[y-1][x] = 0
					}
				}
			}
			// スタートとゴールを確実に空ける
			map[1][1] = 0
			map[rows-2][cols-2] = 0
			map[rows-2][cols-3] = 0 // ゴール周辺を少し広く
		}

		override fun onDraw(canvas: Canvas) {
			super.onDraw(canvas)
			canvas.drawColor(Color.WHITE) // 通路(背景)は白

			// 壁の描画 (暗いグレー)
			paint.color = Color.DKGRAY
			for (y in 0 until rows) {
				for (x in 0 until cols) {
					if (map[y][x] == 1) {
						canvas.drawRect(
							x * cellSize, y * cellSize,
							(x + 1) * cellSize, (y + 1) * cellSize,
							paint
						)
					}
				}
			}

			// ゴール (赤い丸)
			paint.color = Color.RED
			val goalCx = (goalCol * cellSize) + (cellSize / 2)
			val goalCy = (goalRow * cellSize) + (cellSize / 2)
			canvas.drawCircle(goalCx, goalCy, cellSize / 3, paint)

			// プレイヤー (青い丸)
			paint.color = Color.BLUE
			canvas.drawCircle(playerX, playerY, cellSize / 3, paint)
			
			// ゴール判定
			val dist = Math.hypot((playerX - goalCx).toDouble(), (playerY - goalCy).toDouble())
			if (dist < cellSize / 2) {
				onGoalCallback()
			}
		}

		override fun onTouchEvent(event: MotionEvent): Boolean {
			when (event.action) {
				MotionEvent.ACTION_DOWN -> {
					lastTouchX = event.x
					lastTouchY = event.y
					return true
				}
				MotionEvent.ACTION_MOVE -> {
					// スワイプ距離の2倍移動
					val dx = (event.x - lastTouchX) * 2
					val dy = (event.y - lastTouchY) * 2

					movePlayer(dx, dy)

					lastTouchX = event.x
					lastTouchY = event.y
					invalidate() // 再描画
					return true
				}
			}
			return super.onTouchEvent(event)
		}

		private fun movePlayer(dx: Float, dy: Float) {
			val nextX = playerX + dx
			val nextY = playerY + dy
			val radius = cellSize / 3

			// 簡易的な衝突判定(移動先の中心点が壁の中にないか確認)
			// ※本来は円と矩形の判定が必要ですが、1日で作るため中心点判定で簡略化
			val col = (nextX / cellSize).toInt()
			val row = (nextY / cellSize).toInt()

			// 配列範囲内かつ壁(1)でない場合のみ移動許可
			if (col in 0 until cols && row in 0 until rows) {
				if (map[row][col] == 0) {
					playerX = nextX
					playerY = nextY
				} else {
					// 壁に当たった場合、壁の手前で止める等の処理を入れるとスムーズですが
					// ここでは「壁なら移動しない(スライドしない)」という単純な実装にします
				}
			}
		}
	}

ビルドを実行

エラーログ
Execution failed for task ':app:processDebugMainManifest'.
> Manifest merger failed : uses-sdk:minSdkVersion 21 cannot be smaller than version 23 declared in library [androidx.activity:activity:1.12.0] /Users/hiroshi.yamaki/.gradle/caches/8.13/transforms/b1faee7bc2667469f92ae8a3adba8d2c/transformed/activity-1.12.0/AndroidManifest.xml as the library might be using APIs not available in 21
  	Suggestion: use a compatible library with a minSdk of at most 21,
  		or increase this project's minSdk version to at least 23,
  		or use tools:overrideLibrary="androidx.activity" to force usage (may lead to runtime failures)

エラーログをプロンプトに入力

プロンプトの回答
このエラーは、「プロジェクトがサポートしているAndroidのバージョン(21)が古すぎて、新しいライブラリ(バージョン23以上が必要)が使えない」 という意味です。
1日で完成させることが目標ですので、難しい設定変更はせず、プロジェクトの対象バージョンを上げる(新しいスマホ向けにする)のが最も早く安全な解決策です。
以下の手順で修正してください。
build.gradle.kts (Module :app)
defaultConfig {
    applicationId = "com.example.simplemaze"
    minSdk = 24  // ← ここを24に
    targetSdk = 34
    versionCode = 1
    versionName = "1.0"
    // ...
}

ビルド実行

なんか迷路になってないけど画面が出た!

与えたプロンプト
迷路と呼べない感じですがビルドが成功して操作できるようになりました。
赤い丸と重なるとステージもクリアされて、ゲームオーバー表示もされます。
迷路を修正してください。

プロンプトで出力されたコードで上書き。

迷路のロジックは良さそうですが初めから難しすぎる。あとスワイプした時に壁を突き抜けてしまう。

与えたプロンプト
迷路生成のロジックは良い感じですが、最初から難しいので簡単なものから徐々に難しくなるように修正してください。
あとは操作した時に壁を突き抜けてしまうので壁を突き抜けないようにしてください。

提案されたコードで上書き

少しわかりにくいですが画像を見ると壁の部分に白い線が混じっています。

与えたプロンプト
ステージが進むと壁の部分に白い線が入ってしまいます。
プロンプトの回答で修正した最終的なコード(コードを表示 折りたたみ)
プロンプトの回答で修正した最終的なコード
	package com.example.mazeapp // パッケージ名はご自身の環境に合わせてください

	import android.content.Context
	import android.graphics.Canvas
	import android.graphics.Color
	import android.graphics.Paint
	import android.os.Bundle
	import android.os.CountDownTimer
	import android.view.MotionEvent
	import android.view.View
	import android.widget.FrameLayout
	import android.widget.TextView
	import androidx.appcompat.app.AppCompatActivity
	import java.util.*
	import kotlin.math.abs

	class MainActivity : AppCompatActivity() {

		private lateinit var levelText: TextView
		private lateinit var timeText: TextView
		private lateinit var gameOverOverlay: TextView
		private lateinit var gameContainer: FrameLayout
		private lateinit var mazeView: MazeView

		private var currentLevel = 1
		private var timer: CountDownTimer? = null
		private var isGameOver = false

		override fun onCreate(savedInstanceState: Bundle?) {
			super.onCreate(savedInstanceState)
			setContentView(R.layout.activity_main)

			levelText = findViewById(R.id.levelText)
			timeText = findViewById(R.id.timeText)
			gameOverOverlay = findViewById(R.id.gameOverOverlay)
			gameContainer = findViewById(R.id.gameContainer)

			// ゲーム開始
			startNewLevel()

			// ゲームオーバー画面タップでリスタート
			gameOverOverlay.setOnClickListener {
				currentLevel = 1 // レベルリセット
				startNewLevel()
			}
		}

		private fun startNewLevel() {
			isGameOver = false
			gameOverOverlay.visibility = View.GONE
			levelText.text = "Level: $currentLevel" // ADBで取得する形式

			// 古いViewがあれば削除して新しく迷路を作成
			gameContainer.removeAllViews()

			// 難易度(壁の密度やサイズ)を調整したい場合はここで行いますが、
			// 今回はシンプルに毎回新しい迷路を生成します。
			mazeView = MazeView(this, currentLevel, ::onGoal, ::onGameOver)
			gameContainer.addView(mazeView)

			// タイマー開始 (30秒)
			timer?.cancel()
			timer = object : CountDownTimer(30000, 1000) {
				override fun onTick(millisUntilFinished: Long) {
					// ADBで取得する形式
					timeText.text = "Time: ${millisUntilFinished / 1000}"
				}

				override fun onFinish() {
					if (!isGameOver) {
						timeText.text = "Time: 0"
						onGameOver()
					}
				}
			}.start()
		}

		private fun onGoal() {
			timer?.cancel()
			currentLevel++
			startNewLevel()
		}

		private fun onGameOver() {
			isGameOver = true
			timer?.cancel()
			runOnUiThread {
				gameOverOverlay.visibility = View.VISIBLE
			}
		}
	}

	// 迷路とプレイヤーを描画・処理するカスタムView
	// 迷路とプレイヤーを描画・処理するカスタムView
	class MazeView(
		context: Context,
		val level: Int,
		val onGoalCallback: () -> Unit,
		val onGameOverCallback: () -> Unit
	) : View(context) {

		// プレイヤーの大きさ(セルの何割か)
		private val playerRatio = 0.6f

		// 迷路のサイズ(レベルに応じて大きくする)
		// レベル1: 7x7, レベル2: 9x9 ... 奇数である必要あり
		private val cols = 7 + (level - 1) * 2
		private val rows = 7 + (level - 1) * 3 // 縦長画面に合わせて縦を少し増やす

		// データクラス
		private data class Point(val x: Int, val y: Int)

		// 変数
		private var cellSize = 0f
		private val paint = Paint()
		private val map = Array(rows) { IntArray(cols) { 1 } } // 1=壁, 0=通路

		private var playerX = 0f
		private var playerY = 0f

		private var goalCol = cols - 2
		private var goalRow = rows - 2

		private var lastTouchX = 0f
		private var lastTouchY = 0f

		// 画面サイズが決まったタイミングで呼び出される
		override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
			super.onSizeChanged(w, h, oldw, oldh)

			// 画面幅に合わせてセルサイズを決定(余白を少し考慮)
			cellSize = (w.toFloat() / cols)

			// 迷路生成と配置の初期化
			generateMaze()

			// プレイヤー初期位置(1,1 の中心)
			playerX = cellSize * 1.5f
			playerY = cellSize * 1.5f

			// ゴール位置(右下)
			goalCol = cols - 2
			goalRow = rows - 2

			// 念のためゴール周辺を通路にする
			map[goalRow][goalCol] = 0
		}

		private fun generateMaze() {
			// 全て壁で初期化
			for (y in 0 until rows) for (x in 0 until cols) map[y][x] = 1

			// 穴掘り法
			var currentX = 1
			var currentY = 1
			map[currentY][currentX] = 0

			val stack = Stack<Point>()
			stack.push(Point(currentX, currentY))

			val random = Random()
			val directions = listOf(Point(0, -1), Point(0, 1), Point(-1, 0), Point(1, 0))

			while (stack.isNotEmpty()) {
				val validDirections = mutableListOf<Point>()
				for (dir in directions) {
					val nextX = currentX + (dir.x * 2)
					val nextY = currentY + (dir.y * 2)
					if (nextX in 1 until cols - 1 && nextY in 1 until rows - 1) {
						if (map[nextY][nextX] == 1) {
							validDirections.add(dir)
						}
					}
				}

				if (validDirections.isNotEmpty()) {
					val dir = validDirections[random.nextInt(validDirections.size)]
					map[currentY + dir.y][currentX + dir.x] = 0
					currentX += dir.x * 2
					currentY += dir.y * 2
					map[currentY][currentX] = 0
					stack.push(Point(currentX, currentY))
				} else {
					val prevPoint = stack.pop()
					currentX = prevPoint.x
					currentY = prevPoint.y
				}
			}

			// ゴールへの道を保証
			map[rows-2][cols-2] = 0
			if (map[rows-2][cols-3] == 1 && map[rows-3][cols-2] == 1) {
				map[rows-2][cols-3] = 0 // 詰まないように一つ開ける
			}
		}

		override fun onDraw(canvas: Canvas) {
			super.onDraw(canvas)
			if (cellSize == 0f) return

			canvas.drawColor(Color.WHITE) // 背景(通路)

			// 壁の描画
			paint.color = Color.DKGRAY
			for (y in 0 until rows) {
				for (x in 0 until cols) {
					if (map[y][x] == 1) {
						// ▼▼▼ 修正箇所ここから ▼▼▼
						// 右と下を +1f (1ピクセル) 伸ばして、隣の壁と強制的に重ねる
						canvas.drawRect(
							x * cellSize,
							y * cellSize,
							(x + 1) * cellSize + 1f,
							(y + 1) * cellSize + 1f,
							paint
						)
						// ▲▲▲ 修正箇所ここまで ▲▲▲
					}
				}
			}

			// ... (以下、ゴールとプレイヤーの描画はそのまま)
			// ゴール
			paint.color = Color.RED
			val goalCx = (goalCol * cellSize) + (cellSize / 2)
			val goalCy = (goalRow * cellSize) + (cellSize / 2)
			val goalRadius = (cellSize / 2) * 0.8f
			canvas.drawCircle(goalCx, goalCy, goalRadius, paint)

			// プレイヤー
			paint.color = Color.BLUE
			val playerRadius = (cellSize / 2) * playerRatio
			canvas.drawCircle(playerX, playerY, playerRadius, paint)

			// ゴール判定
			val dist = Math.hypot((playerX - goalCx).toDouble(), (playerY - goalCy).toDouble())
			if (dist < cellSize / 2) {
				onGoalCallback()
			}
		}

		override fun onTouchEvent(event: MotionEvent): Boolean {
			when (event.action) {
				MotionEvent.ACTION_DOWN -> {
					lastTouchX = event.x
					lastTouchY = event.y
					return true
				}
				MotionEvent.ACTION_MOVE -> {
					// スワイプ距離の2倍移動したい
					val dx = (event.x - lastTouchX) * 2
					val dy = (event.y - lastTouchY) * 2

					// 壁抜け防止のため、移動処理を細かく行う
					movePlayerWithCollision(dx, dy)

					lastTouchX = event.x
					lastTouchY = event.y
					invalidate()
					return true
				}
			}
			return super.onTouchEvent(event)
		}

		// 壁判定を含めた移動処理
		private fun movePlayerWithCollision(dx: Float, dy: Float) {
			if (cellSize == 0f) return

			// 1回の判定で動かす最大距離(セルの1/4程度)
			// これにより、高速にスワイプしても壁をすり抜けなくなる
			val stepSize = cellSize / 4

			// 移動距離の合計
			val dist = Math.hypot(dx.toDouble(), dy.toDouble()).toFloat()

			// 分割数(最低1回)
			val steps = (dist / stepSize).toInt().coerceAtLeast(1)

			val stepX = dx / steps
			val stepY = dy / steps

			// 少しずつ動かして毎回壁判定を行う
			for (i in 0 until steps) {
				// X方向移動
				if (!checkCollision(playerX + stepX, playerY)) {
					playerX += stepX
				}
				// Y方向移動(Xとは独立させることで壁沿いの「スライド移動」が可能になる)
				if (!checkCollision(playerX, playerY + stepY)) {
					playerY += stepY
				}
			}
		}

		// 指定した座標にプレイヤーが居られるかチェック
		private fun checkCollision(x: Float, y: Float): Boolean {
			val radius = (cellSize / 2) * playerRatio

			// プレイヤーの上下左右の端の点が壁に入っていないかチェック
			// これで「見た目上のめり込み」を防ぐ
			val pointsToCheck = listOf(
				Pair(x, y - radius), // 上端
				Pair(x, y + radius), // 下端
				Pair(x - radius, y), // 左端
				Pair(x + radius, y)  // 右端
			)

			for ((checkX, checkY) in pointsToCheck) {
				val c = (checkX / cellSize).toInt()
				val r = (checkY / cellSize).toInt()

				// 範囲外または壁なら衝突とみなす
				if (c !in 0 until cols || r !in 0 until rows) return true
				if (map[r][c] == 1) return true
			}
			return false
		}
	}

テストの自動化(0.5時間)

初期のプロンプト
これらを踏まえた上でテストを自動化したいです。
以下自動テストの内容
・言語はPythonを使用
・端末の操作にはadbコマンドを使用
・アプリの起動、ゲームオーバーの判定、ステージクリアの際に継続してテストを実施、ゲームオーバーになったらテストを終了

ランダムな移動になっているので画像認識+経路探索を行うように指示

maze_test.py(コードを表示 折りたたみ)
出力されたコード
	import subprocess
	import cv2
	import numpy as np
	import time
	import re
	from collections import deque
	import logging
	import random

	# ==========================================
	# ログ設定
	# ==========================================
	logging.basicConfig(
		level=logging.INFO,
		format='%(asctime)s %(message)s',
		datefmt='[%H:%M:%S]'
	)
	logger = logging.getLogger(__name__)

	# ==========================================
	# 設定エリア
	# ==========================================
	PACKAGE_NAME = "com.example.simplemaze" # ※環境に合わせて変更してください
	ACTIVITY_NAME = ".MainActivity"
	SWIPE_DURATION = 150 

	# 色定義 (BGR)
	LOWER_WALL = np.array([40, 40, 40]) 
	UPPER_WALL = np.array([90, 90, 90])
	LOWER_RED = np.array([0, 0, 100])
	UPPER_RED = np.array([100, 100, 255])
	LOWER_BLUE = np.array([100, 0, 0])
	UPPER_BLUE = np.array([255, 100, 100])

	# ==========================================
	# ADB / 端末操作関数
	# ==========================================
	def adb_shell(command):
		cmd = f"adb shell {command}"
		result = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
		return result.stdout.strip()

	def get_screenshot():
		cmd = ['adb', 'exec-out', 'screencap', '-p']
		try:
			proc = subprocess.Popen(cmd, stdout=subprocess.PIPE)
			screenshot_data, _ = proc.communicate()
			if not screenshot_data: return None
			nparr = np.frombuffer(screenshot_data, np.uint8)
			img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
			return img
		except Exception as e:
			logger.error(f"Screenshot Error: {e}")
			return None

	def swipe(start_x, start_y, end_x, end_y):
		cmd = f"adb shell input swipe {start_x} {start_y} {end_x} {end_y} {SWIPE_DURATION} &"
		subprocess.run(cmd, shell=True)

	# ==========================================
	# レベル取得 (XML - Source of Truth)
	# ==========================================
	def fetch_level_from_system():
		try:
			subprocess.run("adb shell uiautomator dump /sdcard/window_dump.xml", shell=True, stderr=subprocess.DEVNULL)
			result = subprocess.run("adb shell cat /sdcard/window_dump.xml", shell=True, stdout=subprocess.PIPE, text=True)
			xml_text = result.stdout
			
			if 'text="GAME OVER"' in xml_text:
				return "GAME_OVER"
				
			match = re.search(r'text="Level: (\d+)"', xml_text)
			if match:
				return int(match.group(1))
			return None
		except Exception as e:
			logger.error(f"Level Fetch Error: {e}")
			return None

	def is_game_over_image(img):
		mask_p = cv2.inRange(img, LOWER_BLUE, UPPER_BLUE)
		mask_g = cv2.inRange(img, LOWER_RED, UPPER_RED)
		if cv2.countNonZero(mask_p) < 20 and cv2.countNonZero(mask_g) < 20:
			return True
		return False

	# ==========================================
	# デバッグ画像生成
	# ==========================================
	def save_debug_image(img, grid, cell_w, cell_h, offset, start_pos, path=None):
		if img is None: return
		debug_img = img.copy()
		
		if grid is None or cell_w is None or offset is None:
			cv2.imwrite("grid_debug.png", debug_img)
			return

		rows, cols = grid.shape
		maze_left, maze_top = offset

		# 計算上の迷路サイズ
		maze_width_calc = int(cols * cell_w)
		maze_height_calc = int(rows * cell_h)

		# グリッド線 (青)
		for r in range(rows + 1):
			y = int(maze_top + r * cell_h)
			cv2.line(debug_img, (int(maze_left), y), (int(maze_left + maze_width_calc), y), (255, 0, 0), 1)
		for c in range(cols + 1):
			x = int(maze_left + c * cell_w)
			cv2.line(debug_img, (x, int(maze_top)), (x, int(maze_top + maze_height_calc)), (255, 0, 0), 1)

		# 判定結果
		for r in range(rows):
			for c in range(cols):
				cx = int(maze_left + c * cell_w + cell_w / 2)
				cy = int(maze_top + r * cell_h + cell_h / 2)
				
				if cx < 0 or cx >= debug_img.shape[1] or cy < 0 or cy >= debug_img.shape[0]: continue
				
				if grid[r, c] == 0:
					cv2.rectangle(debug_img, (cx-2, cy-2), (cx+2, cy+2), (0, 0, 255), -1)
				else:
					cv2.circle(debug_img, (cx, cy), 2, (0, 255, 0), -1)

		if start_pos:
			sx = int(maze_left + start_pos[0] * cell_w + cell_w / 2)
			sy = int(maze_top + start_pos[1] * cell_h + cell_h / 2)
			cv2.circle(debug_img, (sx, sy), 8, (255, 255, 0), 2)

		if path and start_pos:
			curr = start_pos
			cx = int(maze_left + curr[0] * cell_w + cell_w / 2)
			cy = int(maze_top + curr[1] * cell_h + cell_h / 2)
			for move in path:
				next_c, next_r = curr[0], curr[1]
				if move == "UP": next_r -= 1
				elif move == "DOWN": next_r += 1
				elif move == "LEFT": next_c -= 1
				elif move == "RIGHT": next_c += 1
				
				nx = int(maze_left + next_c * cell_w + cell_w / 2)
				ny = int(maze_top + next_r * cell_h + cell_h / 2)
				cv2.line(debug_img, (cx, cy), (nx, ny), (255, 0, 255), 2)
				cx, cy = nx, ny
				curr = (next_c, next_r)

		cv2.imwrite("grid_debug.png", debug_img)

	# ==========================================
	# ★グリッド解析 (ゴール・アンカー方式)
	# ==========================================
	def analyze_grid_mathematical(img, level):
		h, w, _ = img.shape
		cols = 7 + (level - 1) * 2
		rows = 7 + (level - 1) * 3
		
		# 1. 迷路の上端(maze_top)と左端(maze_left)を特定
		center_x = w // 2
		vertical_line = img[:, center_x]
		mask_v = cv2.inRange(vertical_line.reshape(-1, 1, 3), LOWER_WALL, UPPER_WALL)
		wall_indices_v = np.where(mask_v > 0)[0]

		if len(wall_indices_v) == 0: return None, None, None, None, None, None
		
		# 上端 (Top)
		maze_top = wall_indices_v[0]
		# ステータスバー回避
		if maze_top < 50:
			diffs = np.diff(wall_indices_v)
			splits = np.where(diffs > 10)[0]
			if len(splits) > 0:
				maze_top = wall_indices_v[splits[0]+1]

		# 左端 (Left)
		scan_y = min(h-1, maze_top + 50)
		horizontal_line = img[scan_y, :]
		mask_h = cv2.inRange(horizontal_line.reshape(-1, 1, 3), LOWER_WALL, UPPER_WALL)
		wall_indices_h = np.where(mask_h > 0)[0]
		
		maze_left = 0
		if len(wall_indices_h) > 0:
			maze_left = wall_indices_h[0]

		# 2. ゴール位置(赤丸)を見つけてサイズを逆算
		mask_goal = cv2.inRange(img, LOWER_RED, UPPER_RED)
		M_g = cv2.moments(mask_goal)
		
		# デフォルト (画面幅いっぱいと仮定)
		est_width = w - maze_left
		cell_w = est_width / cols
		cell_h = cell_w # デフォルトは正方形

		if M_g["m00"] > 0:
			goal_px = int(M_g["m10"] / M_g["m00"])
			goal_py = int(M_g["m01"] / M_g["m00"])
			
			# ゴールはグリッドの (cols-2, rows-2) にある
			# 中心座標 = Left + (Index + 0.5) * Size
			
			count_x = (cols - 2) + 0.5
			count_y = (rows - 2) + 0.5
			
			# 距離からサイズを逆算
			dist_x = goal_px - maze_left
			dist_y = goal_py - maze_top
			
			if count_x > 0: cell_w = dist_x / count_x
			if count_y > 0: cell_h = dist_y / count_y
			
			# logger.info(f"Grid Calibrated: {cell_w:.1f}x{cell_h:.1f}")
		
		# 3. プレイヤー位置特定 (マッピング)
		mask_player = cv2.inRange(img, LOWER_BLUE, UPPER_BLUE)
		M_p = cv2.moments(mask_player)
		
		start_pos_grid = None
		start_pos_pixel = None

		if M_p["m00"] > 0:
			px = int(M_p["m10"] / M_p["m00"])
			py = int(M_p["m01"] / M_p["m00"])
			start_pos_pixel = (px, py)
			
			p_col = int((px - maze_left) / cell_w)
			p_row = int((py - maze_top) / cell_h)
			p_col = max(0, min(cols-1, p_col))
			p_row = max(0, min(rows-1, p_row))
			start_pos_grid = (p_col, p_row)
		
		# 4. グリッド構築
		grid = np.zeros((rows, cols), dtype=np.uint8)
		for r in range(rows):
			for c in range(cols):
				cx = int(maze_left + c * cell_w + cell_w / 2)
				cy = int(maze_top + r * cell_h + cell_h / 2)
				if cx < 0 or cx >= w or cy < 0 or cy >= h: continue

				patch = img[max(0, cy-2):min(h, cy+3), max(0, cx-2):min(w, cx+3)]
				if patch.size == 0: continue
				mean_color = np.mean(patch, axis=(0, 1))
				val_b, val_g, val_r = mean_color

				is_blue = (val_b > 150 and val_r < 100)
				is_red = (val_r > 150 and val_b < 100)
				is_bright = (val_b > 100 and val_g > 100 and val_r > 100)

				if start_pos_grid and (c, r) == start_pos_grid:
					grid[r, c] = 1
				elif (c == cols-2 and r == rows-2):
					grid[r, c] = 1
				elif is_blue or is_red or is_bright:
					grid[r, c] = 1
				else:
					grid[r, c] = 0

		return grid, cell_w, cell_h, (maze_left, maze_top), start_pos_grid, start_pos_pixel

	# ==========================================
	# 経路探索
	# ==========================================
	def solve_bfs(grid, start, goal):
		rows, cols = grid.shape
		if grid[start[1], start[0]] == 0:
			logger.warning(f"Start {start} is Wall! Grid misalignment?")
			return None

		queue = deque([(start, [])])
		visited = set([start])
		directions = [((0, -1), "UP"), ((0, 1), "DOWN"), ((-1, 0), "LEFT"), ((1, 0), "RIGHT")]
		
		while queue:
			(cx, cy), path = queue.popleft()
			if (cx, cy) == goal: return path
			for (dx, dy), name in directions:
				nx, ny = cx + dx, cy + dy
				if 0 <= nx < cols and 0 <= ny < rows:
					if grid[ny, nx] == 1 and (nx, ny) not in visited:
						visited.add((nx, ny))
						new_path = list(path)
						new_path.append(name)
						queue.append(((nx, ny), new_path))
		return None

	# ==========================================
	# メイン処理
	# ==========================================
	def main():
		logger.info("Starting Maze Solver (Goal Anchor + Independent Scale)...")
		
		current_level = fetch_level_from_system()
		while not isinstance(current_level, int):
			logger.warning("Initializing... Waiting for Level.")
			time.sleep(1.0)
			current_level = fetch_level_from_system()
		
		logger.info(f"Initial Level: {current_level}")

		last_grid_pos = None
		stuck_count = 0

		try:
			while True:
				img = get_screenshot()
				if img is None: continue
				h, w = img.shape[:2]

				if is_game_over_image(img):
					logger.info("GAME OVER detected. Restarting...")
					adb_shell("input tap 500 1000") 
					time.sleep(2.0)
					current_level = fetch_level_from_system()
					last_grid_pos = None
					continue

				# グリッド解析
				result = analyze_grid_mathematical(img, current_level)
				grid, cell_w, cell_h, offset, start_pos_grid, start_pos_pixel = result
				
				if start_pos_grid is None:
					logger.warning("Player lost. Re-checking level...")
					check_level = fetch_level_from_system()
					if isinstance(check_level, int) and check_level != current_level:
						logger.info(f"Level Updated: {current_level} -> {check_level}")
						current_level = check_level
					time.sleep(0.5)
					continue
				
				rows, cols = grid.shape
				goal_pos = (cols - 2, rows - 2)

				# フィニッシュムーブ
				if start_pos_grid == goal_pos:
					maze_left, maze_top = offset
					target_px = int(maze_left + goal_pos[0] * cell_w + cell_w / 2)
					target_py = int(maze_top + goal_pos[1] * cell_h + cell_h / 2)
					curr_px, curr_py = start_pos_pixel
					
					dx = target_px - curr_px
					dy = target_py - curr_py
					
					logger.info("[FINISH] Inside Goal! Forcing finish...")
					swipe_dx = int(dx * 0.7)
					swipe_dy = int(dy * 0.7)
					min_finish = 30
					if abs(swipe_dx) < min_finish and abs(dx) > 2: swipe_dx = min_finish if dx > 0 else -min_finish
					if abs(swipe_dy) < min_finish and abs(dy) > 2: swipe_dy = min_finish if dy > 0 else -min_finish
					
					start_sx, start_sy = int(w/2), int(h/2)
					swipe(start_sx, start_sy, start_sx + swipe_dx, start_sy + swipe_dy)
					
					time.sleep(3.0)
					new_level = fetch_level_from_system()
					if isinstance(new_level, int) and new_level != current_level:
						logger.info(f"[*] Level Updated: {current_level} -> {new_level}")
						current_level = new_level
						stuck_count = 0
					continue

				# 経路探索
				path = solve_bfs(grid, start_pos_grid, goal_pos)
				save_debug_image(img, grid, cell_w, cell_h, offset, start_pos_grid, path)

				if not path:
					logger.warning("No Path found.")
					time.sleep(0.2)
					continue

				# スタック検知
				if last_grid_pos == start_pos_grid:
					stuck_count += 1
				else:
					stuck_count = 0
				last_grid_pos = start_pos_grid

				if stuck_count > 3:
					logger.warning(f"[STUCK] Stuck in {start_pos_grid}! Wiggle...")
					rx = random.choice([-1, 1]) * int(cell_w * 0.4)
					ry = random.choice([-1, 1]) * int(cell_h * 0.4)
					start_sx, start_sy = int(w/2), int(h/2)
					swipe(start_sx, start_sy, start_sx + rx, start_sy + ry)
					time.sleep(0.2)
					stuck_count = 0
					continue

				# 移動実行
				first_move = path[0]
				steps = 0
				for move in path:
					if move == first_move: steps += 1
					else: break
				
				curr_col, curr_row = start_pos_grid
				target_col, target_row = curr_col, curr_row
				if first_move == "UP": target_row -= steps
				elif first_move == "DOWN": target_row += steps
				elif first_move == "LEFT": target_col -= steps
				elif first_move == "RIGHT": target_col += steps
				
				maze_left, maze_top = offset
				target_px = int(maze_left + target_col * cell_w + cell_w / 2)
				target_py = int(maze_top + target_row * cell_h + cell_h / 2)
				curr_px, curr_py = start_pos_pixel
				
				raw_dx = target_px - curr_px
				raw_dy = target_py - curr_py
				
				final_dx = int(raw_dx * 0.7)
				final_dy = int(raw_dy * 0.7)
				
				min_swipe = max(20, int(cell_w * 0.15))
				if abs(final_dx) < min_swipe and abs(final_dx) > 0:
					final_dx = min_swipe if final_dx > 0 else -min_swipe
				if abs(final_dy) < min_swipe and abs(final_dy) > 0:
					final_dy = min_swipe if final_dy > 0 else -min_swipe

				# 軸固定
				if first_move in ["UP", "DOWN"]:
					if abs(final_dx) < min_swipe: final_dx = 0
				elif first_move in ["LEFT", "RIGHT"]:
					if abs(final_dy) < min_swipe: final_dy = 0

				sx, sy = int(w/2), int(h/2)
				ex = sx + final_dx
				ey = sy + final_dy

				dist_log = max(abs(final_dx), abs(final_dy))
				logger.info(f"[MOVE ] {first_move} x {steps} | Vec({final_dx}, {final_dy}) Lvl:{current_level}")
				swipe(sx, sy, ex, ey)
				
				if (target_col, target_row) == goal_pos:
					logger.info("[*] Goal reached. Waiting...")
					time.sleep(0.5) 
					new_level = fetch_level_from_system()
					if isinstance(new_level, int) and new_level != current_level:
						logger.info(f"[*] Level Updated: {current_level} -> {new_level}")
						current_level = new_level
						stuck_count = 0
				else:
					time.sleep(0.15)

		except KeyboardInterrupt:
			logger.info("Solver stopped.")

	if __name__ == "__main__":
		main()

少し先のレベルに進むと精度が低くなってくるけど、調整の範囲なので許容かな〜。
Gemini賢い

手動で全てを実装するとした場合の見積もり(初心者想定)

  • アプリの作成(10.5日)
    • Android Studioの使い方(0.5日)
      • セットアップ+端末の準備(1時間)
      • サンプルコードのビルド(1時間)
      • ライブラリのimportの仕方(2時間)
    • Kotlinの学習+AndroidのUI学習(2日)
      • 文法(1日)
      • Activity,Layout,Viewの仕組みの理解(4時間)
      • ConstraintLayoutやCanvasの基本(4時間)
    • アプリの作成(6日)
      • 2D描画+ゲームループ(2日)
        • 画面レイアウトなどの描画
        • 初期化
        • ゲーム開始
        • ゲーム中処理
      • 迷路作成(2日)
        • アルゴリズム(12時間)
        • データ構造(4時間)
      • ゲーム要素の追加(2日)
        • 迷路の初期化
        • プレイヤーの移動
        • ゲームオーバー処理
        • ステージクリア処理
        • 残り時間の設定
    • 詳細の調整(2日)
      • 描画関連
      • 操作関連
      • バグ修正
  • テストの自動化(3日)
    • アプリ起動(1時間)
    • 経路探索(1日)
    • 探索結果の入力と移動処理の自動化(1日)
    • バグ修正(7時間)
比較(AI使用 vs 未使用)
工程 手動見積もり(未経験者) AI実績(Gemini) 短縮効果
環境構築・学習 3.5日(言語、UI、ツール) 1時間(準備) 約96%削減
アプリ実装 4日(描画・迷路・ゲームループ) 1時間 約97%削減
自動テスト 3日(adb制御・経路探索) 0.5時間 約98%削減
合計 10.5日 約2.5時間 約33倍の効率化

まとめ

※今回使用した二つのAIについて検証するモデルの順序により、検証中に得られた知見をフィードバックして後の検証に利用したということは全くありません。

使用するAIのモデルに依存するとはいえ、今回の取り組みを通じてGeminiの提案するコードの初期品質の高さを感じた。
まず動作するコードを提示してくるため、その後の調整にかける時間が相対的に増えアプリの品質の向上が見込める。
期待通りに動作しなかった際のプロンプトへの応答もGeminiの方が的確に問題点を捉えて解決に導いていたように感じる。

テスト自動化については両者とも初手はランダムな入力で実装してきたが、画像認識+経路探索と条件を明示した際の応答はGeminiの反応が素晴らしかったように思う。
Copilotでは一つ課題をクリアすると別の課題が出てきてしまうことが多くなかなか実装が進まなかったが、Geminiでは期待通り動作しなかった場合のログ出力が的確で再度プロンプトに入力することでテキパキと課題を解決していた。
Androidだから・・・?

実際にはAIもモデルによって得意な分野が異なるので情報が一般的であるかどうかによって精度も変わってくるし、こんなに綺麗には短縮できないとはいえ、全くAIを使わずにコーディングを行うというのはもはや考えられないよな、というのが感想。
今回はアプリの作成とその自動化というところでAIを利用してどこまでできるかを試してみました。
コードの妥当性などはレビューしていないのであくまで見た目の動作に限定されますが、何か気になることがあった時にまず学習からというのが一つの障壁になるところをまず動くところから始められるのはAIのもたらす恩恵だと思います。
年末年始、軽くAIに触れてみるのはいかがですか?

※この記事は執筆直前(2025年11月下旬頃)のGeminiとCopilotを使用しています。
※AIのモデルは日々更新されているため、同じプロンプトを使用しても同じ効果が得られない可能性があります。
※一つのモデルに依存せず複数のモデルを併用して比較するのが現状のベストプラクティスだと考えています。

28
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
28
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?