Android打地鼠课设实战:状态机+自定义View+SoundPool
2026/9/22 13:03:26 网站建设 项目流程

简介:一套基于Android Studio的“打地鼠”课程设计工程,面向Android初学者及需要完成同类课设的学生,覆盖了界面布局、触摸事件处理、音效开关管理等基础开发环节。完整压缩包共184个文件,包含Java源码、XML布局与配置、Gradle构建脚本、WAV/MP3音频及最终APK安装包,另附docx格式的课设报告,整体24.97MB,导入Android Studio后即可运行和调试。游戏实现了点击计分打地鼠的交互逻辑,音效与背景音乐开关分别通过SoundPool/MediaPlayer结合SharedPreferences实现,还涉及Service后台播放等常规应用,有助于串联Android入门阶段的核心知识点。目前已有1860人学习下载。报告内写明了项目规划、关键代码说明、测试过程与问题解决方法,适合在源码基础上修改动画、计分规则等内容,也可用于答辩梳理和功能演示。

1. 打地鼠课设的“分量”不在游戏,在状态机

打地鼠在 Android 课设里出镜率极高,但它拿高分的关键从来不是地鼠图片好不好看,而是你把“游戏过程”拆成了什么。多数人交上去的版本是 Activity 里塞一个GridView,地鼠冒头靠postDelayed随机换图,音效用MediaPlayer临时 new 一个——能跑,但一问“游戏结束状态怎么进去的”“音效播放失败会不会崩”,答不上来。按 Android 应用开发的常规要求,课设的评分点通常是四块:界面与事件响应、多媒体资源管理、状态流转、代码健壮性。打地鼠恰好把这四块全占了,所以它适合做课设,但也因为它简单,老师更容易往细节里问。

这里给的落地方案是:自定义View绘制地鼠和坑位,SoundPool管理音效与开关状态,Handler驱动游戏心跳,再用一套显式的状态机把“待开始—游戏中—暂停—结束”串起来。全程不依赖第三方库,Kotlin 实现,Android Studio 直接建空项目即可跑通。这套结构的好处是每个类职责单一,答辩时你能清晰说出每一帧是谁画的、每一声是谁播的、游戏为什么结束时地鼠全部回洞。下文从整体架构开始,逐层给出可抄的代码,并标注关键的参数含义和改法。

2. 整体架构拆解:为什么选自定义 View + SoundPool + Handler 的组合

2.1 三个核心类各自管什么

把打地鼠拆成“画面、声音、逻辑”三条线,分别对应WhackAMoleViewSoundManagerGameState。界面层只有一个 Activity,负责把三者接起来。自定义 View 负责绘图、触摸判定、地鼠出现的动画表现;SoundManager 负责加载音效、播放、以及总开关控制;GameState 是一个枚举加几个状态标志,控制游戏什么时候能点、什么时候计时结束。

这个拆分不是为设计而设计。打地鼠的常见坑是:

  • 地鼠出现的循环在 Activity 里,Activity 一重建循环就乱;
  • 音效的AudioManager没拿到,导致开关只控制了一个 MediaPlayer 却管不住另一个;
  • 游戏结束后最后一帧地鼠还停在洞外,观感差。

成因都一样:逻辑和表现耦合在onCreate大方法里。上面三个类各自独立后,Activity 的代码会短很多,而且每一个类都可以单独测试。WhackAMoleView不感知游戏是否结束,它只按外部传入的列表去绘制;SoundManager不感知游戏逻辑,只管“给我一个音效 id,我判断开关后播放”;GameState则完全不涉及 UI 和音频。这是典型的“表现与逻辑分离”,课设答辩时比贴大段 Activity 代码更能说明你理解了 Android 应用的组成。

2.2 模块依赖关系和初始化顺序

加载顺序建议是:SoundManager先于GameState初始化,因为游戏开始时就要播放“开始”音效;WhackAMoleView在 Activity 的onCreate里绑定,但它依赖的GameState可以在onStart里创建。原因是 View 的onDraw第一帧就会执行,如果GameState还没初始化,绘制的又是空数据,就会闪一下空白画面。

模块依赖关系可以用下图理解(文字描述):Activity 持有三个实例,WhackAMoleView通过setGameState()接收状态引用,SoundManager被 Activity 和 View 共同调用,View 在地鼠被点击时调用soundManager.playWhack(),Activity 在按钮点击时调用soundManager.toggleMute()GameState提供isRunningscoretimeLeft等字段。

3. 自定义 View 实现打地鼠界面:坑位坐标、触摸判定、地鼠冒头动画

3.1 布局参数和坑位坐标计算

WhackAMoleView继承View,在onMeasure里把 View 的边长切成 3x3 网格,留出上下两条信息条的位置。每个坑位是圆形,半径取网格边长的 0.32 倍——为什么不是 0.5,因为要留出相邻坑位之间的间隔,否则玩家手指按在两个坑的边界时会被判定命中两次。

class WhackAMoleView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : View(context, attrs) { private val holes = mutableListOf<Hole>() private val gridCount = 3 private var cellWidth = 0f private var cellHeight = 0f private val holeRadiusRatio = 0.32f override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) cellWidth = w / gridCount.toFloat() cellHeight = h / gridCount.toFloat() holes.clear() for (row in 0 until gridCount) { for (col in 0 until gridCount) { val cx = cellWidth * col + cellWidth / 2f val cy = cellHeight * row + cellHeight / 2f holes.add(Hole(cx, cy, cellWidth * holeRadiusRatio)) } } } data class Hole(val cx: Float, val cy: Float, val radius: Float) }

这段代码里@JvmOverloads是为了让布局文件里的view标签可以直接引用,onSizeChanged在宽高确定后回调一次,比在onDraw里计算更合理——不要在绘制方法里做除法,每次触碰屏幕都会触发绘制,频繁算浮点除法虽然不致命但没必要。Hole作为内部数据类只存坐标和半径,不持有地鼠状态。

3.2 地鼠状态的表示与绘制顺序

地鼠的状态不只是一个Boolean,而是三个:isUp(是否冒出地面)、appearStartTime(用于计算冒出动画的进度)、moleType(普通地鼠和闪避地鼠)。闪避地鼠的分数更高,但在屏幕上只停留一秒,这是课设中体现区分度的可选设计。地鼠的冒出效果用ValueAnimator的线性插值强度实现——绘制时根据时间差算出appearProgress,从 0.0 到 1.0 表示身体从洞里升到顶部的完整轨迹。

private data class Mole( var isUp: Boolean = false, var appearStartTime: Long = 0L, var isBonus: Boolean = false ) private val moles = Array(9) { Mole() } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) drawHoles(canvas) drawMoles(canvas) } private fun drawMoles(canvas: Canvas) { val currentTime = System.currentTimeMillis() for (i in moles.indices) { if (!moles[i].isUp) continue val elapsed = currentTime - moles[i].appearStartTime val progress = minOf(1f, elapsed / APPEAR_DURATION_MS) // progress 为 0 时地鼠完全在洞里,为 1 时完全冒出 val moleTop = holes[i].cy - holes[i].radius * progress drawMoleBody(canvas, holes[i].cx, moleTop, holes[i].radius) } } private val APPEAR_DURATION_MS = 200L

绘制顺序很关键:先画所有坑位,再画地鼠,保证地鼠身体从洞里升出时背景坑位不会被覆盖成一片黑色。moleTop的算法是圆心往上抬升,背后逻辑是把地鼠想象成一个圆形身体,圆心在坑位中心时是初始隐藏状态,随着progress变为 1,圆心向上移动一个半径的距离,视觉上就是“冒头”。如果你想让地鼠像是从洞里往上钻,还可以在moleTop基础上再减掉一个sin(progress * PI)的弹性尾巴,但课设阶段线性就够了。

3.3 onTouchEvent 的命中判定与事件消费

触摸判定用event.action判断按下,不用ACTION_MOVE是因为玩家在移动手指时也可能触发地鼠的命中,但实际打地鼠游戏是“点击即判定”。每次DOWN事件遍历所有 9 个地鼠,求触摸点与地鼠中心距离,若小于半径乘以 1.2(因为手指触摸区域比视觉半径大一些)则判定命中。

override fun onTouchEvent(event: MotionEvent): Boolean { if (event.action == MotionEvent.ACTION_DOWN) { val x = event.x val y = event.y for (i in moles.indices) { if (!moles[i].isUp) continue val h = holes[i] val dx = x - h.cx val dy = y - h.cy val distance = sqrt(dx * dx + dy * dy) if (distance <= h.radius * 1.2f) { moles[i].isUp = false gameState?.addScore(if (moles[i].isBonus) 2 else 1) soundManager?.playWhack() performClick() break } } return true } return super.onTouchEvent(event) }

return true表示这个事件被消费掉,后续的MOVEUP事件仍会传给这个 View,但因为我们只在DOWN里处理逻辑,所以不会重复计数。sqrt是标准库里的kotlin.math.sqrt,不用Distance类。performClick()是为了通过无障碍测试的 lint 检查,不写会报一个 Accessibility 警告。命中后把isUp改成 false,相当于地鼠立刻缩回洞里,这是符合玩家预期的反馈——如果它还在上一秒的位置但已不显示,下一击落在同位置就不会再得分。

3.4 地鼠出现循环与随机调度

地鼠出现不能靠postDelayed写死时间间隔,因为课设要求里通常有“越来越快”的难度曲线。常见的做法是维护一个Globalscope的循环,每隔一个可变的延迟调用一次presentRandomMole()。具体逻辑是:用HandlerpostDelayed实现一个自调用的递归任务,下一次的延时比上一次缩短 10%,最短控制在 400ms,这个范围在 3x3 网格上不会让玩家觉得太难。

private val handler = Handler(Looper.getMainLooper()) private var spawnDelayMs = 1200L fun startSpawning() { handler.postDelayed(spawnRunnable, spawnDelayMs) } private val spawnRunnable = object : Runnable { override fun run() { if (gameState?.isRunning == true) { showRandomMole() spawnDelayMs = maxOf(400L, spawnDelayMs * 0.9f.toLong()) handler.postDelayed(this, spawnDelayMs) } } }

这段代码有两个坑。第一,spawnRunnablerun()结束时判断isRunning再决定是否续期,所以游戏结束时循环自然停止,不需要额外 removeCallbacks——但为了安全,onPause()里最好还是调一下handler.removeCallbacks(spawnRunnable),因为 Activity 退到后台时isRunning可能仍是 true。第二,spawnDelayMs * 0.9f是浮点乘法后转 Long,建议写成(spawnDelayMs * 0.9).toLong(),或者直接用整数除法spawnDelayMs = spawnDelayMs * 9 / 10,避免浮点误差。这里其实暴露了一个常见问题:整型乘法可能溢出吗?1200 * 9 只有 10800,远小于 Int 上限,安全。

4. 音效开关设置功能:SoundPool 集成、按钮状态保存与生命周期处理

4.1 SoundPool 初始化和音效加载

音效播放选SoundPool而不是MediaPlayer,因为打地鼠需要高并发触发音效——连续点中两只地鼠时,两个音频流几乎同时启动,MediaPlayer每次都要重新 prepare,不足 100ms 就会出现卡顿,SoundPool专为这种情况设计,它在初始化时把所有音频加载到内存里,播放时只做混音。课设中还有“切换音效开关”这个需求,SoundPoolsetVolume控制流音量,可以直接实现静音,而MediaPlayer要 stop 再释放,状态管理复杂得多。

class SoundManager(context: Context) { private var soundPool: SoundPool? = null private var whackSoundId = 0 private var startSoundId = 0 private val prefs = context.getSharedPreferences("game_setting", Context.MODE_PRIVATE) var isMuted: Boolean = false fun init() { val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) soundPool = SoundPool.Builder() .setMaxStreams(4) .setAudioAttributes( AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_GAME) .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) .build() ) .build() whackSoundId = soundPool.load(context, R.raw.whack, 1) startSoundId = soundPool.load(context, R.raw.start, 1) isMuted = prefs.getBoolean("is_muted", false) } fun playWhack() { if (isMuted) return soundPool?.play(whackSoundId, 1f, 1f, 1, 0, 1f) } }

setMaxStreams(4)表示最多同时播放 4 个音效流,超出时系统会按优先级丢弃最旧的流,这对抗住连续点击的爆发场景已经够了。load方法的第三个参数 priority 在SoundPool中已经废弃,传 1 只是占位。load返回的是音效的 id,不是播放 id,播放时用的是返回值,而不是 R.raw 的值——这里的区分值得写进注释,答辩时容易错。isMutedSharedPreferences读出来,保证用户上次关掉音效后,重启 App 不会自动打开。

4.2 音效开关按钮的状态同步

开关按钮用CompoundButton(如SwitchToggleButton),点击后写回SharedPreferences。常见错误是用Button手动 setText 切换,这样 Activity 重建后按钮文字不会自动恢复。Switch自带选中状态,布局文件设置android:checked后再绑定监听即可。

val switchSound: Switch = findViewById(R.id.switch_sound) switchSound.isChecked = soundManager.isMuted switchSound.setOnCheckedChangeListener { _, isChecked -> if (isChecked) { soundManager.isMuted = false } else { soundManager.isMuted = true } with(soundManager.getPrefs().edit()) { putBoolean("is_muted", soundManager.isMuted) apply() } }

这段代码里我给SoundManager加了一个getPrefs()方法暴露偏好文件,不这样做的话,状态保存逻辑就要散落在 Activity 里。界面上 Switch 的“开”对应音效开启,语义比 ToggleButton 更直观。注意这里绑定监听器时,构造里传入的isChecked是新的状态,而不是旧状态。有些新手会在这里犯糊涂:setOnCheckedChangeListener回调的第一个参数是按钮本身,第二个才是新状态。用一个var lastState保存旧状态做对比是多余的,因为isChecked已经包含了变化后的值。

4.3 生命周期对音效的影响

Activity 进入后台时,SoundPool并不会自动释放,但如果用户在系统设置里切走音频焦点,播放会失败但不报错。课设阶段最稳妥的写法是:onPause()里调用soundPool.autoPause(),保存isMutedonResume()里调用soundPool.autoResume(),恢复到暂停前状态。autoPause是 API 21 加入的,保存了每个流的播放位置,比手动pause所有流再记录位置要简单得多。此外,onDestroy里必须release(),否则每次进入页面都重新创建SoundPool对象,旧对象持有的音频数据不会被立即回收,首级内存会持续增长。

override fun onPause() { super.onPause() soundManager.pauseAll() } override fun onDestroy() { super.onDestroy() soundManager.release() }

onPause里不要做保存isMuted这种 IO 操作,SharedPreferences写入是异步刷盘的,但严格说apply()不会丢数据,用commit()反而会卡 UI 线程。这里pauseAll()内部调用了soundPool?.autoPause()。最后要强调的是,init()load是异步的,加载完成前调用play会返回 0(无效流),所以更好一点的做法是在加载完成后回调里再设置一个isReady标志,但课设里音效文件通常不大,加载时间在毫秒级,可以忽略这个细节。

5. GameState 与主循环构建:倒计时、计分、三种模式的状态流转

5.1 状态机定义与转换条件

打地鼠游戏的流程很典型:用户点击“开始游戏”→ 倒计时 60 秒 → 期间可暂停 → 倒计时归零 → 弹结算。用枚举定义四个状态:READYRUNNINGPAUSEDFINISHED。转换条件只有三种:开始按钮把READY → RUNNING,暂停按钮把RUNNING → PAUSED,倒计时归零或退出键把RUNNING/PAUSED → FINISHED。别把“暂停”设计成RUNNING的一个标志位,后面你会被“暂停时倒计时器还在走”这类 bug 缠住。

enum class GameState { READY, RUNNING, PAUSED, FINISHED } class GameStateMachine { var currentState: GameState = GameState.READY private set var score = 0 private set var timeLeft = 60 private set fun start() { if (currentState == GameState.READY || currentState == GameState.FINISHED) { currentState = GameState.RUNNING score = 0 timeLeft = 60 } } fun pause() { if (currentState == GameState.RUNNING) { currentState = GameState.PAUSED } } fun resume() { if (currentState == GameState.PAUSED) { currentState = GameState.RUNNING } } fun finish() { if (currentState == GameState.RUNNING || currentState == GameState.PAUSED) { currentState = GameState.FINISHED } } fun addScore(delta: Int) { if (currentState == GameState.RUNNING) { score += delta } } }

private set是关键,外部无法绕过方法直接改分数和状态。如果不需要暂停功能,可以去掉这两个方法——但课设里“暂停”这个功能点很加分,建议保留。timeLeft不是由 setter 减的,而是由单独的CountDownTimer去调一个tick()方法,否则外部可以直接state.timeLeft--跳过倒计时逻辑。

5.2 倒计时与心跳循环

倒计时用android.os.CountDownTimer,它的onTick是每秒钟回调一次,但你注意它不保证精确秒级——系统可能在 996ms、1002ms、998ms 这样跳。所以在onTick里不应该通过“回调了一次就把 timeLeft 减一”来累计,而是用“计划结束时间减去当前时间”算出剩余秒数。第一种写法在计时器的第一次回调迟到 200ms 时会少显示 1 秒,长期运行误差会累积。

private var countDownTimer: CountDownTimer? = null private var endTime: Long = 0L fun startCountdown() { endTime = System.currentTimeMillis() + 60_000L countDownTimer = object : CountDownTimer(60_000L, 100L) { override fun onTick(millisUntilFinished: Long) { val remainSec = ((endTime - System.currentTimeMillis()) / 1000.0).toInt() updateTimeDisplay(remainSec) if (remainSec != timeLeft) timeLeft = remainSec } override fun onFinish() { gameStateMachine.finish() handler.removeCallbacks(spawnRunnable) showResultDialog() } }.start() }

onTick的间隔设为 100ms 而不是 1000ms,是为了让 UI 上的数字变化更贴近真实时间。remainSec直接由减法算出,而不是millisUntilFinished / 1000——因为millisUntilFinishedCountDownTimer内部也是减法算出来的,精度依赖onTick的触发频率。timeLeft只用于逻辑判断(比如最后 3 秒变红),UI 直接用remainSec。选 100ms 间隔的代价是 UI 刷新更频繁,但打地鼠的显示数字每秒才动一次,其余onTick调用只做一次减法和比较,开销可忽略。

5.3 视图与状态机的联动

打地鼠视图不再直接访问 GameState,而是通过View.setGameStateMachine()传入的接口。这避免了视图在运行时去 new 一个GameStateMachine,让 Activity 成为唯一持有状态机的对象。联动表现在三处:onDraw里根据状态决定是否绘制地鼠;处理触摸事件时通过state.isRunning判断是否响应;暂停时清空所有isUp的地鼠,但不重置分数和时间。

fun onGamePaused() { for (mole in moles) { mole.isUp = false } invalidate() }

这里如果把invalidate()漏掉,最后一帧画面上的地鼠会留在屏幕上,看起来像“暂停时还能看到地鼠但点不到”。加了这个方法,Activity 的暂停按钮只需两行代码:stateMachine.pause()whackAMoleView.onGamePaused()invalidate()只在 UI 线程调用,所以这个函数必须在主线程执行。如果游戏中用了协程切线程,这个调用位置很容易出错。

5.4 分数与难度的联动:三种可选增难度策略

难度曲线直接影响游戏可玩性,课设的老师如果试玩,通常会注意“是不是一直一个速度”。这里提供三种策略,按实现复杂度排列:

策略一是固定间隔,最简单,纯演示用。策略二是全局加速,spawnDelayMs乘以小于 1 的系数,效果是整体节奏越来越快。策略三是基于分数的动态调整:连击越多,地鼠出现越快,单只地鼠停留时间越短。第三种已经逼近商业小游戏的调法,实现也不难。

fun onScoreChanged(newScore: Int) { // 每得 5 分,地鼠显示时间缩短 5% ,最短 300ms val newDuration = maxOf(300L, 1000L - (newScore / 5) * 50L) view.setMoleVisibleDuration(newDuration) }

setMoleVisibleDuration是视图里的一个变量,地鼠在postDelayed隐藏时用到。它和spawnDelayMs是两条独立的线:一个控制“多久出现一只”,一个控制“出现后停留多久”。只调间隔不调停留时间,游戏难度提升不明显;只调停留时间不调间隔,可能造成地鼠叠在一起。两个参数一起调,才是手感变化的关键。

6. 最后把关:把课设从“能跑”调到“能展示”的三个细节

课设验收时的表现往往取决于几个小细节。第一个是AndroidManifest.xml里的屏幕方向锁定——打地鼠是横竖屏都不影响玩法的游戏,但如果你没有在activity标签里加android:screenOrientation="portrait",翻转手机时 Activity 会重建,所有状态归零。很多课设演示时老师随手一转手机,整个游戏回到开始界面,那种情况非常尴尬。加上这一行,状态就不会丢。

第二个细节是音效文件的格式与大小。SoundPool对 PCM/WAV 的兼容性最好,如果你的音效是 MP3 但有 320kbps 高码率,低端机上加载会慢。课设素材可以在网上下载到 44.1kHz 16bit 的 WAV 文件,单个控制在 200KB 以内。两个音效加起来 400KB,对 APK 体积无感,但加载速度几乎瞬时。音频处理可以用 Audacity 导出为单声道 WAV,可以有效减小体积,加载也更稳定。

第三个细节是异常和不合法状态的兜底:游戏未开始时点击坑位,addScore内部因为状态不是RUNNING会拒绝加分;倒计时结束后连续点按屏幕,isUp全是 false 所以不会命中;音效文件如果没放在res/raw目录下,R.raw.whack编译时直接报错而不是运行时报错,这反而好排查。真正会出问题的点在SoundPool.load()是异步的,如果用户以极快的手速在游戏开始瞬间点中地鼠,此时加载可能未完成,播放返回 0。兜底写法是:

val streamId = soundPool.play(whackSoundId, 1f, 1f, 1, 0, 1f) if (streamId == 0) { // 音频未就绪,不响但不崩溃,也不用补播 }

play返回 0 时不做任何处理,这个静默失败是预期内的。最后,建议在onResume里重新绑定 Switch 状态,因为用户从系统设置返回 App 后,isMuted可能与界面不一致。验证方法很简单:装到真机上,开音效打一只地鼠,再关掉音效打一只,对比有无声音;锁屏再解锁,确认 Switch 状态不跳变;旋转屏幕(不锁方向时)确认游戏不重置。这三个测试通过,课设的核心功能就稳了。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询