はじめに
このチャレンジを行おうと思ったきっかけは、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(コードを表示 折りたたみ)
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)
}
<?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 プラグインを定義:
[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 を追加:
plugins {
alias(libs.plugins.compose.compiler) apply false
alias(libs.plugins.org.jetbrains.kotlin.android) apply false
}
Compose を使う各モジュールの build.gradle.kts で 適用:
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の修正(コードを表示 折りたたみ)
[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)の修正(コードを表示 折りたたみ)
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)(コードを表示 折りたたみ)
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
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(コードを表示 折りたたみ)
[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
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)(コードを表示 折りたたみ)
// 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に設定
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に設定
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にそろえるのが安全)。
// ① 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(コードを表示 折りたたみ)
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(コードを表示 折りたたみ)
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(コードを表示 折りたたみ)
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(コードを表示 折りたたみ)
# -*- 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(コードを表示 折りたたみ)
<?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(コードを表示 折りたたみ)
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日で完成させることが目標ですので、難しい設定変更はせず、プロジェクトの対象バージョンを上げる(新しいスマホ向けにする)のが最も早く安全な解決策です。
以下の手順で修正してください。
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日)
- 迷路の初期化
- プレイヤーの移動
- ゲームオーバー処理
- ステージクリア処理
- 残り時間の設定
- 2D描画+ゲームループ(2日)
- 詳細の調整(2日)
- 描画関連
- 操作関連
- バグ修正
- Android Studioの使い方(0.5日)
-
テストの自動化(3日)
- アプリ起動(1時間)
- 経路探索(1日)
- 探索結果の入力と移動処理の自動化(1日)
- バグ修正(7時間)
| 工程 | 手動見積もり(未経験者) | 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のモデルは日々更新されているため、同じプロンプトを使用しても同じ効果が得られない可能性があります。
※一つのモデルに依存せず複数のモデルを併用して比較するのが現状のベストプラクティスだと考えています。

