news 2026/9/3 6:42:19

Android五子棋开发实战:从自定义View到胜负判断算法

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Android五子棋开发实战:从自定义View到胜负判断算法

简介:这是一份基于Android Studio开发的五子棋安卓小游戏完整工程,面向计算机科学与技术、软件工程等专业本科生及移动应用开发初学者,解决安卓GUI交互、游戏逻辑实现与AI策略设计等核心实践问题。资源包共657个文件,包含27个Java源码文件(实现棋盘绘制、胜负判定、悔棋逻辑等)、39个XML布局与配置文件、45个PNG图标资源、124个JSON配置及Gradle构建相关文件,整体压缩包大小为30.71MB,结构清晰,便于模块化学习与二次开发。已有2089人下载学习,适合课程设计、实训项目或毕业设计参考。读者可直接导入Android Studio 4.0.1及以上版本运行,完整获得人机对战(基于得分评估的AI算法)、人人对战、背景音乐控制、实时计分、悔棋与新局重置等全部功能,代码注释充分,逻辑分层明确,是理解安卓事件驱动模型与简单AI决策机制的优质入门案例。

1. 项目概述与核心价值

最近在整理一些适合计算机专业学生或者安卓开发新手练手的项目,发现五子棋这个小游戏真是个“宝藏”。它规则简单,但实现起来却能把安卓开发里UI绘制、事件处理、逻辑判断这些核心知识点都串起来。用Android Studio来实现一个五子棋,远不止是画个棋盘、摆几个棋子那么简单。你得考虑怎么把用户的触摸点击精准地映射到棋盘的交点上,怎么实时判断横、竖、斜任意方向是否连成了五子,还要设计一个清晰的数据结构来记录棋盘状态。这个过程,对于理解MVC(或MVVM)架构思想、自定义View的绘制流程、以及Android的基础组件使用,有非常大的帮助。如果你正在学《安卓程序设计》这门课,或者想找个项目来巩固Java/Kotlin和Android开发基础,这个项目再合适不过了。它不像一个完整的社交或电商App那样庞大复杂,但又足够完整,能让你体验到从界面设计到核心算法实现的完整闭环,做完之后成就感十足。

2. 整体设计与架构思路拆解

2.1 为什么选择五子棋作为练手项目?

五子棋项目之所以经典,是因为它在有限的复杂度内,涵盖了安卓应用开发的多个核心层面。首先,在视图层(View),它需要自定义一个ViewSurfaceView来绘制棋盘和棋子,这涉及到CanvasPaint的熟练使用,包括画线、画圆、处理抗锯齿等细节。其次,在控制层(Controller),需要处理用户的触摸事件(onTouchEvent),将屏幕坐标转换为棋盘逻辑坐标,这是理解Android事件分发机制的好例子。最后,在模型层(Model),需要一个二维数组或更高效的数据结构来存储棋盘状态,并实现胜负判断算法,这考验的是基本的数据结构和算法能力。这种清晰的分层,正好对应了安卓开发中倡导的分离关注点思想。通过这个项目,你能直观地感受到数据、逻辑和视图是如何协同工作的,而不是仅仅停留在拖拽控件和编写简单事件监听器的层面。

2.2 技术选型与核心组件决策

在动手之前,有几个关键的技术决策点需要想清楚,这直接关系到代码结构和后续扩展的难易程度。

  1. 绘制方式的选择:自定义View vs. SurfaceView

    • 自定义View(继承自View:对于五子棋这种刷新频率不高(只有落子时重绘)的场景,自定义View完全够用。它的优点是简单直接,利用onDraw方法进行绘制,系统会自动管理重绘。代码逻辑清晰,易于理解和调试。
    • SurfaceView:适用于需要高频、主动刷新的场景,比如游戏主循环。五子棋用它会显得“杀鸡用牛刀”,引入额外的线程管理(HolderCallback)复杂度。因此,对于初学者和这个具体项目,强烈推荐使用自定义View。我们可以在onDraw方法里一次性绘制棋盘网格和所有已落下的棋子。
  2. 数据模型的设计:二维数组与位运算

    • 最直观的模型是一个int[][]byte[][]二维数组,比如board[15][15],用0表示空位,1表示黑子,2表示白子。这种方案简单易懂,访问和修改状态非常方便。
    • 更高级或追求极致性能的玩法,可以考虑使用位棋盘(Bitboard),用两个long型整数(对于15x15棋盘,需要225个位,一个long有64位,需要4个)分别表示黑子和白子的位置。通过位运算(与、或、移位)来进行胜负判断,速度极快。但对于教学和大多数应用场景,二维数组的清晰度和可维护性优势更大。本项目将采用二维数组方案,重点在于把逻辑表达清楚。
  3. 胜负判断算法的实现思路这是项目的算法核心。最朴素的方法是在每次落子后,以该子为中心,向四个方向(水平、垂直、左上右下、右上左下)进行搜索,统计连续同色棋子的数量。一旦某个方向达到5,即判胜。虽然听起来简单,但实现时要注意数组越界检查,以及如何高效地实现双向计数。我们稍后会详细拆解一个既清晰又高效的判断函数。

3. 核心模块实现与代码详解

3.1 棋盘绘制模块(自定义View)

我们创建一个名为GobangView的类,继承自View。它的核心任务是绘制一个15x15的网格棋盘,并根据模型数据绘制棋子。

public class GobangView extends View { private int mBoardSize = 15; // 棋盘格数,15x15 private float mCellSize; // 每个格子的宽度(像素) private float mOffset; // 棋盘边距 private Paint mGridPaint; // 绘制网格的画笔 private Paint mBlackPiecePaint; // 绘制黑棋的画笔 private Paint mWhitePiecePaint; // 绘制白棋的画笔 private Paint mLastMovePaint; // 高亮最后一手的画笔(可选) private int[][] mBoard; // 棋盘数据模型,0空,1黑,2白 public GobangView(Context context) { this(context, null); } public GobangView(Context context, @Nullable AttributeSet attrs) { super(context, attrs); init(); } private void init() { // 初始化棋盘数据模型 mBoard = new int[mBoardSize][mBoardSize]; // 初始化网格画笔 mGridPaint = new Paint(Paint.ANTI_ALIAS_FLAG); mGridPaint.setColor(Color.BLACK); mGridPaint.setStrokeWidth(3); // 初始化黑棋画笔(实心圆) mBlackPiecePaint = new Paint(Paint.ANTI_ALIAS_FLAG); mBlackPiecePaint.setColor(Color.BLACK); mBlackPiecePaint.setStyle(Paint.Style.FILL); // 初始化白棋画笔(实心圆+黑边) mWhitePiecePaint = new Paint(Paint.ANTI_ALIAS_FLAG); mWhitePiecePaint.setColor(Color.WHITE); mWhitePiecePaint.setStyle(Paint.Style.FILL); mWhitePiecePaint.setStrokeWidth(2); // 为白棋增加一个黑色边框,更清晰 mWhitePiecePaint.setShadowLayer(2, 0, 0, Color.BLACK); // 简单模拟边框效果,也可用两层圆绘制 // 高亮最后一手(例如一个红圈) mLastMovePaint = new Paint(Paint.ANTI_ALIAS_FLAG); mLastMovePaint.setColor(Color.RED); mLastMovePaint.setStyle(Paint.Style.STROKE); mLastMovePaint.setStrokeWidth(4); } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); // 计算棋盘绘制参数,确保棋盘居中且为正方形 int minSize = Math.min(w, h); mOffset = 20; // 边距20像素 float boardTotalWidth = minSize - 2 * mOffset; mCellSize = boardTotalWidth / (mBoardSize - 1); // 注意:格子数-1 = 间隔数 } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); drawBoard(canvas); drawPieces(canvas); // drawLastMoveHighlight(canvas); // 可选:绘制最后一手高亮 } /** * 绘制棋盘网格 */ private void drawBoard(Canvas canvas) { float start = mOffset; float end = mOffset + (mBoardSize - 1) * mCellSize; // 画横线 for (int i = 0; i < mBoardSize; i++) { float y = mOffset + i * mCellSize; canvas.drawLine(start, y, end, y, mGridPaint); } // 画竖线 for (int i = 0; i < mBoardSize; i++) { float x = mOffset + i * mCellSize; canvas.drawLine(x, start, x, end, mGridPaint); } } /** * 根据mBoard数据绘制棋子 */ private void drawPieces(Canvas canvas) { float radius = mCellSize * 0.4f; // 棋子半径为格子宽度的0.4倍 for (int i = 0; i < mBoardSize; i++) { for (int j = 0; j < mBoardSize; j++) { if (mBoard[i][j] == 0) continue; // 空位跳过 float centerX = mOffset + j * mCellSize; // 注意:列j对应x坐标 float centerY = mOffset + i * mCellSize; // 行i对应y坐标 if (mBoard[i][j] == 1) { canvas.drawCircle(centerX, centerY, radius, mBlackPiecePaint); } else if (mBoard[i][j] == 2) { // 先画一个稍大的黑圈作为边框,再画白棋,效果更好 Paint borderPaint = new Paint(mBlackPiecePaint); borderPaint.setStyle(Paint.Style.STROKE); borderPaint.setStrokeWidth(4); canvas.drawCircle(centerX, centerY, radius + 2, borderPaint); canvas.drawCircle(centerX, centerY, radius, mWhitePiecePaint); } } } } // 提供方法供外部(如Activity)更新棋盘数据并重绘 public void setBoardData(int[][] board) { this.mBoard = board; invalidate(); // 请求重绘 } }

关键点解析:

  • onSizeChanged: 这是关键回调,当View尺寸确定后调用。我们在这里计算每个格子的大小(mCellSize)和棋盘起始偏移(mOffset),确保棋盘在不同屏幕上都能居中并适配。
  • onDraw: 绘制逻辑的核心。分离drawBoarddrawPieces使代码更清晰。注意绘制棋子时,mBoard数组的行列索引(i, j)与屏幕坐标(x, y)的对应关系。
  • 白棋边框处理:为了让白棋在白色背景或浅色棋盘上更醒目,为其添加一个黑色边框是常见做法。代码中展示了两种思路:使用ShadowLayer简单模拟,或者更精确地先画一个稍大的黑圈再画白圈。
  • invalidate(): 当棋盘数据改变后,必须调用此方法通知系统重绘View

3.2 触摸事件处理与坐标转换

棋盘画好了,接下来要让它能响应玩家的触摸落子。这需要在GobangView中重写onTouchEvent方法。

public class GobangView extends View { // ... 之前的成员变量 ... // 定义回调接口,通知Activity落子事件 public interface OnPiecePlayListener { void onPiecePlayed(int row, int col); } private OnPiecePlayListener mListener; public void setOnPiecePlayListener(OnPiecePlayListener listener) { this.mListener = listener; } @Override public boolean onTouchEvent(MotionEvent event) { if (event.getAction() == MotionEvent.ACTION_DOWN) { // 1. 获取触摸点坐标 float touchX = event.getX(); float touchY = event.getY(); // 2. 将屏幕坐标转换为棋盘网格坐标 // 公式: (触摸坐标 - 棋盘起始偏移 + 半个格子宽度) / 格子宽度 // 加半个格子宽度是为了实现“吸附”效果,点击格子附近区域也能落到交叉点上 int col = Math.round((touchX - mOffset + mCellSize / 2) / mCellSize); int row = Math.round((touchY - mOffset + mCellSize / 2) / mCellSize); // 3. 检查坐标是否在棋盘有效范围内 (0 到 mBoardSize-1) if (row >= 0 && row < mBoardSize && col >= 0 && col < mBoardSize) { // 4. 通过接口回调,将落子位置通知给逻辑控制器(Activity) if (mListener != null) { mListener.onPiecePlayed(row, col); } return true; // 消费此触摸事件 } } return super.onTouchEvent(event); // 其他事件交给父类处理 } }

关键点解析:

  • 坐标转换:这是触摸处理的核心。event.getX()event.getY()获得的是相对于GobangView左上角的像素坐标。我们需要将其转换为棋盘的(行, 列)索引。通过Math.round进行四舍五入,结合“加半个格子”的技巧,实现了自动吸附到最近交叉点的效果,提升了用户体验。
  • 接口回调:自定义View不应该直接处理游戏逻辑(如当前轮到谁、是否可落子、胜负判断)。它只负责“感知”用户想在哪里落子,然后通过OnPiecePlayListener接口将这个意图“报告”给外部的逻辑控制器(通常是ActivityViewModel)。这是遵守单一职责原则和实现模块解耦的关键。
  • 边界检查:必须检查转换后的rowcol是否在[0, mBoardSize-1]范围内,防止用户点击棋盘外区域导致数组越界。

3.3 游戏逻辑控制与胜负判断

现在,我们创建一个GameController类(或者直接在Activity中)来管理游戏状态和核心逻辑。

public class GameController { public static final int EMPTY = 0; public static final int BLACK = 1; public static final int WHITE = 2; private int mBoardSize; private int[][] mBoard; private int mCurrentPlayer; // 当前行棋方,BLACK 或 WHITE private boolean mGameOver; private GameStateListener mListener; public interface GameStateListener { void onBoardChanged(int[][] board); void onGameOver(int winner); // winner: BLACK, WHITE, 或 EMPTY(和棋) void onCurrentPlayerChanged(int player); } public GameController(int boardSize, GameStateListener listener) { mBoardSize = boardSize; mBoard = new int[mBoardSize][mBoardSize]; mCurrentPlayer = BLACK; // 黑棋先行 mGameOver = false; mListener = listener; } /** * 尝试在指定位置落子 * @param row 行 * @param col 列 * @return 落子是否成功(位置为空且游戏未结束) */ public boolean placePiece(int row, int col) { if (mGameOver || mBoard[row][col] != EMPTY) { return false; // 游戏已结束或该位置已有子 } // 落子 mBoard[row][col] = mCurrentPlayer; // 通知界面更新 if (mListener != null) { mListener.onBoardChanged(mBoard); } // 检查是否获胜 if (checkWin(row, col)) { mGameOver = true; if (mListener != null) { mListener.onGameOver(mCurrentPlayer); } return true; } // 检查是否和棋(棋盘已满) if (isBoardFull()) { mGameOver = true; if (mListener != null) { mListener.onGameOver(EMPTY); // 用EMPTY表示和棋 } return true; } // 切换行棋方 switchPlayer(); if (mListener != null) { mListener.onCurrentPlayerChanged(mCurrentPlayer); } return true; } /** * 胜负判断核心算法 * @param row 刚落子的行 * @param col 刚落子的列 * @return 是否形成五连珠 */ private boolean checkWin(int row, int col) { int player = mBoard[row][col]; // 四个方向向量:水平(1,0), 垂直(0,1), 左上右下(1,1), 右上左下(1,-1) int[][] directions = {{1, 0}, {0, 1}, {1, 1}, {1, -1}}; for (int[] dir : directions) { int count = 1; // 刚落下的这颗子本身 // 向正方向搜索 count += countDirection(row, col, dir[0], dir[1], player); // 向反方向搜索 count += countDirection(row, col, -dir[0], -dir[1], player); if (count >= 5) { return true; } } return false; } /** * 向指定方向统计连续同色棋子数量 * @param row 起始行 * @param col 起始列 * @param dRow 行方向增量 * @param dCol 列方向增量 * @param player 棋子颜色 * @return 该方向上的连续同色棋子数(不包括起始点) */ private int countDirection(int row, int col, int dRow, int dCol, int player) { int count = 0; int r = row + dRow; int c = col + dCol; while (r >= 0 && r < mBoardSize && c >= 0 && c < mBoardSize && mBoard[r][c] == player) { count++; r += dRow; c += dCol; } return count; } /** * 判断棋盘是否已满(和棋条件) */ private boolean isBoardFull() { for (int i = 0; i < mBoardSize; i++) { for (int j = 0; j < mBoardSize; j++) { if (mBoard[i][j] == EMPTY) { return false; } } } return true; } private void switchPlayer() { mCurrentPlayer = (mCurrentPlayer == BLACK) ? WHITE : BLACK; } // 重置游戏 public void resetGame() { for (int i = 0; i < mBoardSize; i++) { Arrays.fill(mBoard[i], EMPTY); } mCurrentPlayer = BLACK; mGameOver = false; if (mListener != null) { mListener.onBoardChanged(mBoard); mListener.onCurrentPlayerChanged(mCurrentPlayer); } } }

关键点解析:

  • 状态管理GameController集中管理了棋盘数据mBoard、当前行棋方mCurrentPlayer和游戏是否结束mGameOver的状态。所有对游戏状态的修改都应通过它提供的方法(如placePiece)进行。
  • 事件驱动与监听器:通过GameStateListener接口,控制器将“棋盘变化”、“游戏结束”、“玩家切换”等事件通知给UI(Activity)。这是一种松耦合的设计,便于测试和扩展。
  • 胜负判断算法checkWin方法是核心。它从刚落子的位置(row, col)出发,向四个方向(水平、垂直、两条对角线)分别进行搜索。countDirection函数负责向一个方向(如(1,0)代表向右)累加连续的同色棋子数。关键技巧是向一个方向的正反两侧分别计数,然后加上当前子(count初始为1),判断总和是否>=5。这个算法清晰高效,时间复杂度为O(1)(因为最多检查约4*8=32个位置)。
  • 和棋判断:简单的遍历棋盘,检查是否还有空位(EMPTY)。

3.4 主Activity整合与UI交互

最后,我们需要一个Activity来把ViewController和用户界面(如状态提示、重新开始按钮)粘合起来。

public class MainActivity extends AppCompatActivity implements GobangView.OnPiecePlayListener, GameController.GameStateListener { private GobangView mGobangView; private GameController mGameController; private TextView mStatusText; private Button mRestartButton; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); mGobangView = findViewById(R.id.gobang_view); mStatusText = findViewById(R.id.tv_status); mRestartButton = findViewById(R.id.btn_restart); // 1. 初始化游戏控制器 mGameController = new GameController(15, this); // 15x15棋盘,this作为监听器 // 2. 为自定义View设置落子监听 mGobangView.setOnPiecePlayListener(this); // 3. 初始化视图的棋盘数据 mGobangView.setBoardData(mGameController.getBoard()); // 假设GameController提供了getBoard方法 // 4. 设置重新开始按钮的点击事件 mRestartButton.setOnClickListener(v -> { mGameController.resetGame(); mStatusText.setText("游戏开始!黑方先行。"); }); // 5. 初始化状态显示 updateStatus(); } // 实现 OnPiecePlayListener 接口 @Override public void onPiecePlayed(int row, int col) { // 将触摸事件传递给控制器处理 boolean success = mGameController.placePiece(row, col); // 如果落子失败(如位置已有子),可以给个Toast提示 if (!success) { Toast.makeText(this, "此处不能落子!", Toast.LENGTH_SHORT).show(); } } // 实现 GameStateListener 接口 @Override public void onBoardChanged(int[][] board) { // 控制器通知棋盘数据已更新,刷新View mGobangView.setBoardData(board); } @Override public void onCurrentPlayerChanged(int player) { runOnUiThread(() -> updateStatus()); } @Override public void onGameOver(int winner) { runOnUiThread(() -> { String message; if (winner == GameController.BLACK) { message = "游戏结束!黑方获胜!"; } else if (winner == GameController.WHITE) { message = "游戏结束!白方获胜!"; } else { message = "游戏结束!棋盘已满,和棋!"; } mStatusText.setText(message); // 可以禁用触摸或显示一个对话框 mGobangView.setOnPiecePlayListener(null); // 暂时禁用落子 }); } private void updateStatus() { int currentPlayer = mGameController.getCurrentPlayer(); // 假设有getter方法 String playerName = (currentPlayer == GameController.BLACK) ? "黑方" : "白方"; mStatusText.setText("当前轮到:" + playerName); } }

对应的activity_main.xml布局文件:

<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:padding="16dp" android:gravity="center_horizontal"> <TextView android:id="@+id/tv_status" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="18sp" android:textStyle="bold" android:text="游戏开始!黑方先行。" android:layout_marginBottom="16dp"/> <com.yourpackage.GobangView android:id="@+id/gobang_view" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" /> <Button android:id="@+id/btn_restart" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="重新开始" android:layout_marginTop="16dp"/> </LinearLayout>

关键点解析:

  • 粘合层MainActivity扮演了“粘合剂”的角色。它实例化了GameControllerGobangView,并实现了两者的监听器接口,负责在它们之间传递消息。
  • UI线程更新:注意在onGameOveronCurrentPlayerChanged回调中,我们使用了runOnUiThread来更新TextView。这是因为这些回调可能发生在非UI线程(如果游戏逻辑计算复杂,未来可能放在子线程),而UI操作必须在主线程进行。
  • 游戏流程:用户触摸GobangView->onPiecePlayed被调用 ->Activity调用GameController.placePiece()-> 控制器更新数据、判断胜负 -> 通过监听器回调通知Activity->Activity更新View和状态文本。这是一个清晰的事件驱动流程。

4. 功能扩展与性能优化思路

一个基础的五子棋已经完成了。但如果你想让它更完善、更像一个真正的产品,或者作为课程设计/毕业设计有更高的要求,可以考虑以下扩展方向。

4.1 悔棋与棋谱记录功能

悔棋实现:核心是维护一个Stack(栈)来记录每一步的落子位置(行、列、玩家)。当用户点击悔棋时,从栈中弹出上一步,将对应棋盘位置重置为EMPTY,并切换回上一个玩家。

public class GameController { private Stack<Move> mMoveHistory = new Stack<>(); class Move { int row; int col; int player; } public boolean undo() { if (mMoveHistory.isEmpty() || mGameOver) return false; Move lastMove = mMoveHistory.pop(); mBoard[lastMove.row][lastMove.col] = EMPTY; mCurrentPlayer = lastMove.player; // 切换回上一步的玩家 // ... 通知UI更新 ... return true; } // 在placePiece成功时,将Move压入栈中 }

棋谱记录(存盘/读盘):可以将每一步的Move序列(或整个mBoard数组)用JSON或自定义二进制格式保存到文件或SharedPreferences中。实现saveGame()loadGame()方法。这涉及到Android的本地存储操作。

4.2 人机对战AI实现入门

为游戏增加一个简单的AI对手,能极大提升项目的挑战性和技术含量。可以从最简单的“随机落子”开始,逐步实现有一定智能的算法。

  1. 随机AI:在空白位置中随机选择一个落子。毫无智能,但实现简单。

  2. 基于规则的AI:这是入门级AI的常见选择。可以定义一些规则优先级:

    • 规则1(进攻):检查AI(白方)是否有位置可以形成“活四”(两头无阻挡的四连子)或“冲四”(一头被堵的四连子),有则落子取胜或防守。
    • 规则2(防守):检查玩家(黑方)是否有位置可以形成“活四”或“冲四”,有则必须堵住。
    • 规则3(创造机会):寻找能让自己形成“活三”(两头无阻挡的三连子)的位置。
    • 规则4(随机):以上都不满足,则在棋盘中心区域或对方棋子附近随机落子。 实现时,可以遍历所有空位,为每个位置根据上述规则打分,选择分数最高的位置落子。
  3. 极小化极大算法(Minimax)与Alpha-Beta剪枝:这是棋类游戏AI的经典算法。它通过模拟未来几步棋的所有可能走法,构建一棵博弈树,并假设对手总是做出对自己最不利的走法(极小化),自己则选择最优走法(极大化)。Alpha-Beta剪枝用于优化,减少不必要的搜索分支。对于15x15的五子棋,搜索深度不能太深(通常3-4层),否则计算量会指数级增长。实现这个算法是计算机科学中关于搜索和博弈论的绝佳实践。

4.3 性能优化与体验提升

  • 绘制优化:在GobangViewonDraw中,避免创建新的Paint对象。所有Paint对象应在init()中初始化并复用。对于固定不变的背景(如木纹棋盘图),可以考虑用Bitmap缓存起来。
  • 胜负判断优化:我们实现的checkWin算法已经很快。但如果追求极致,可以在每次落子后,只更新该子所在行、列、对角线的“连续子状态”缓存,用空间换时间。
  • 动画效果:落子时增加一个缩放动画(ValueAnimator),让棋子从无到有、从小变大落下,提升视觉体验。同样,获胜时可以高亮显示连成五子的那一条线。
  • 声音与振动:落子、获胜时添加音效和短振动,让游戏更有沉浸感。使用SoundPool播放短音效,使用Vibrator实现振动(注意权限声明)。

5. 开发中常见问题与调试技巧

在实际编码和测试过程中,你可能会遇到下面这些问题。这里记录了我自己踩过的一些坑和解决方法。

5.1 触摸不灵敏或落子位置偏差

  • 现象:点击棋盘交叉点附近,棋子却下在了别处,或者根本没反应。
  • 排查
    1. 检查坐标转换公式:这是最常见的问题。确保onTouchEvent中的转换公式正确,特别是mOffset(棋盘起始偏移)和mCellSize(格子大小)的计算是否准确。可以在onDraw里临时画一个十字标记你计算出的落子点,看是否与触摸点匹配。
    2. 打印日志:在onTouchEvent中打印touchX,touchY,row,col的值,观察转换过程。
    3. 检查onSizeChanged:确保mCellSizemOffset是在onSizeChanged中正确计算的,并且View的尺寸已经确定。有时在onCreate中获取的View宽高为0。
  • 解决:仔细核对onSizeChanged和坐标转换代码。确保mOffset是棋盘线开始的位置,mCellSize = (总宽度 - 2*mOffset) / (格子数-1)

5.2 胜负判断逻辑错误

  • 现象:四连子就判赢,或者五连子没反应。
  • 排查
    1. 检查计数逻辑checkWin函数中,count初始值应为1(代表刚落下的子)。countDirection函数向一个方向搜索时,不应包含起始点。确保正反两个方向计数的和加上1(当前子)等于5才判赢。
    2. 边界条件countDirection函数中的while循环条件,必须首先检查数组索引rc是否越界,再检查棋子颜色。顺序反了会导致ArrayIndexOutOfBoundsException
    3. 方向向量:确保四个方向向量{1,0}, {0,1}, {1,1}, {1,-1}覆盖了所有可能连成五子的方向。
  • 解决:单步调试checkWin函数。在一个新游戏的第5步,在checkWin内部打印每个方向的count值,看累加是否正确。

5.3 游戏状态不同步或界面不更新

  • 现象:落子后棋盘没显示,或者状态提示没变。
  • 排查
    1. invalidate()调用:在GobangView.setBoardData()方法中,修改数据后是否调用了invalidate()postInvalidate()来请求重绘?
    2. 监听器是否设置MainActivity是否正确地实现了GameController.GameStateListener并设置给了控制器?GobangViewOnPiecePlayListener是否设置?
    3. UI线程更新:确保在onBoardChanged,onGameOver等回调中更新UI控件(如TextView,Button)时,是在主线程进行的。如果控制器逻辑运行在子线程,必须用runOnUiThread()Handler包装UI操作。
  • 解决:在关键回调方法开始处加日志,确认它们被调用。检查Activity中设置监听器的代码行。

5.4 内存泄漏预防

  • 问题:在Activity中持有ViewController的引用是正常的。但要小心在Controller或自定义View中持有ActivityContext或匿名内部类(如Listener)的强引用,且其生命周期长于Activity,这会导致Activity无法被回收。
  • 解决
    • 对于监听器,考虑使用弱引用(WeakReference)。
    • ActivityonDestroy方法中,将View的监听器置为nullmGobangView.setOnPiecePlayListener(null)),并让GameController移除对Activity的引用(如果可能)。

5.5 在不同屏幕尺寸上的适配问题

  • 现象:在平板或不同分辨率的手机上,棋盘显示不全或太小。
  • 解决
    • GobangViewonSizeChanged中,我们根据View的实际宽高动态计算mCellSize,这本身就是一个很好的适配方案。
    • 确保布局文件中GobangView的宽度和高度使用match_parent0dpweight,使其能填充可用空间。
    • 可以考虑在onSizeChanged中根据屏幕密度(getResources().getDisplayMetrics().density)动态调整mOffset(边距)和画笔的StrokeWidth,使在不同密度的屏幕上视觉粗细一致。

这个项目从零开始实现,涵盖了Android开发从UI到逻辑的完整链条。过程中你会遇到并解决具体的问题,这种经验比单纯看教程要宝贵得多。建议你在实现基础功能后,挑选一两个扩展方向深入做下去,比如尝试实现一个简单的规则AI,或者给棋子加上落子动画,这会让你的项目脱颖而出。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/3 6:42:03

STM32开发进阶:从HAL库到底层优化,突破15K薪资瓶颈

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/3 6:40:51

论文问卷不会设计?AI问卷工具帮你搞定调研全流程

做实证类论文的同学基本都绕不开问卷调查。不少同学确定研究主题之后&#xff0c;不知道如何搭建问卷整体框架&#xff0c;题目设计逻辑混乱&#xff0c;维度划分不合理&#xff1b;问题设置出现重复、引导性表述&#xff0c;不符合学术问卷规范&#xff1b;花费大量时间手动编…

作者头像 李华
网站建设 2026/9/3 6:37:15

全开源上门服务预约系统:H5+小程序双端架构与二次开发实战

简介&#xff1a;这是一套面向创业者、本地生活服务商及技术开发者的2024年最新同城上门服务H5小程序全开源解决方案&#xff0c;专为家政、按摩、美甲、维修等多行业上门业务设计&#xff0c;解决预约管理、技师调度、分佣结算与数据运营等核心痛点。资源包共3个文件&#xff…

作者头像 李华
网站建设 2026/9/3 6:37:09

工业级条码二维码检测数据集:面向真实产线的视觉基准标尺

简介&#xff1a;本资源是面向计算机视觉工程师与AI算法开发者的多类别目标检测数据集&#xff0c;专为条形码与二维码在真实场景下的精准识别任务设计&#xff0c;适用于零售自助结账、物流包裹分拣、制造标签质检及移动端扫码应用等工业级落地需求。压缩包共1002个文件&#…

作者头像 李华
网站建设 2026/9/3 6:35:51

真诚安利✨不允许还有毕业生不知道这个免费论文AI!

真心掏心窝安利&#xff01;&#xff01;写论文焦虑、赶DDL、预算有限的宝子&#xff0c;一定要试试Paperxie&#x1f62d;用过无数付费、免费论文工具&#xff0c;踩过无数套路坑&#xff0c;最后还是被这款纯学生向、零套路、全刚需免费的学术AI圈粉。它没有花里胡哨的多余功…

作者头像 李华
网站建设 2026/9/3 6:35:40

Labelme中文版EXE:开箱即用的工业级AI标注工具

简介&#xff1a;本资源是Labelme图像标注工具的最新中文版独立可执行程序&#xff08;v5.0.1&#xff09;&#xff0c;专为国内计算机视觉初学者、算法工程师及数据标注人员设计&#xff0c;解决原版在中文系统下显示异常、图标错位、部署繁琐等实际痛点。压缩包共42个文件&am…

作者头像 李华