3-6 项目-图形版井字棋(上)
⏱ 本节预计用时:30分钟
本节意义:可以进一步体会model-view架构,初步理解状态机的思想,这些都是必用的。更别提你是在结合前两节学到的知识,给图形版井字棋搭骨架,这难道不是一件很爽的事情吗?
本节目标:理清这节只改 view 和 main、model 一行不动;能画出主菜单和空棋盘;用 Screen 这个枚举当"状态变量"控制界面切换;理解"一个界面对应一个状态,改状态就切界面"。
1 只改view、不改model
第二章(2-6)我们已经把井字棋拆成了 5 个文件:
c
code/6-4井字棋-多文件/
├── game.h ← Model 头文件(类型定义 + 声明)
├── game.c ← Model 实现(数据 + 规则)
├── view.h ← View 头文件
├── view.c ← View 实现(画界面)
└── main.c ← 主控(安排流程)
这一节我们就照着 2-6 的样子,把界面换成图形。上一讲我们说过,井字棋从命令行版改成图形版,底层的逻辑完全没变,变的只是"界面"。 model 层里的board、currentPlayer、initBoard、checkWin 这些"数据 + 规则"不变,游戏规则不会因为换个界面就坏掉,所以model层的代码一个字母都不用动,需要重写的只有 view 和 main。
| 文件 | 这一节动不动 | 动了啥 |
|---|---|---|
game.h |
不动 | ------(直接复制 2-6 的) |
game.c |
不动 | ------(直接复制 2-6 的) |
view.h |
小改 | 引入 raylib、加一个"当前界面"状态、加几个"判断点了哪里"的函数 |
view.c |
大改 | 原来是一堆 printf,现在换成 raylib 的绘图 |
main.c |
大改 | 原来"读数字选菜单",现在"读鼠标点按钮" |
直接上 view.h :
c
#ifndef VIEW_H
#define VIEW_H
#include "raylib.h"
#include "game.h"
/* 当前显示哪个界面(图形版的"状态",和 2-5 里靠数字切换是一个意思) */
typedef enum { SCREEN_MENU, SCREEN_GAME, SCREEN_RESULT } Screen;
/* 画主菜单(标题 + 开始/退出按钮) */
void showMainMenu(void);
/* 画游戏界面里的"返回主菜单"按钮 */
void showGameMenu(void);
/* 画棋盘和上面已经下的棋子 */
void showBoard(void);
/* 画"当前轮到谁"这行提示 */
void showPlayerInfo(void);
/* 画结算界面(谁赢了 / 平局,加一个返回按钮) */
void showResult(CellType winner);
/* ------ 下面几个函数把"鼠标点了哪里"翻译成"点中了什么",给 main 用来做决定 ------ */
int startBtnClicked(Vector2 mouse); /* 点中了"开始游戏"按钮 */
int exitBtnClicked(Vector2 mouse); /* 点中了"退出游戏"按钮 */
int backBtnClicked(Vector2 mouse); /* 点中了"返回主菜单"按钮 */
int cellClicked(Vector2 mouse, int row, int col); /* 点中了棋盘第 row 行第 col 列 */
#endif
后面的工作就是在view.c里把这些函数补全,然后在main.c里组织界面按想要的逻辑显示。
2 第一步:加入主菜单
因为程序一启动是从主菜单启动,所以先画它,之后在这个基础上往上加其它的功能就行。
先设计界面,粗略确定每个矩形、每行字的位置,位置画出来不满意在这个基础上再改。

和他相关的函数有showMainMenu(显示主菜单),startBtnClicked(开始游戏按键被按下),backBtnClicked(返回游戏按键被按下)。
现在井字棋项目里的提示都是英文,下一章会说中文怎么加入。
view.c :
c
#include "view.h"
#include "raylib.h"
/* 按钮位置(x, y, 宽, 高) */
Rectangle startBtn = { 250, 250, 300, 80 }; /* 主菜单的"开始游戏" */
Rectangle exitBtn = { 250, 360, 300, 80 }; /* 主菜单的"退出游戏" */
/* 显示主菜单 */
void showMainMenu(void)
{
DrawText("Tic-Tac-Toe", 240, 110, 50, DARKGRAY);
DrawRectangleRec(startBtn, GRAY); /* 画"开始游戏"按钮 */
DrawRectangleRec(exitBtn, GRAY); /* 画"退出游戏"按钮 */
DrawText("START", 355, 272, 30, WHITE);
DrawText("EXIT", 360, 382, 30, WHITE);
}
int startBtnClicked(Vector2 mouse) {
return CheckCollisionPointRec(mouse, startBtn);
}
int exitBtnClicked(Vector2 mouse) {
return CheckCollisionPointRec(mouse, exitBtn);
}
main.c :
c
#include "raylib.h"
#include "view.h"
int main(void)
{
InitWindow(800, 600, "Tic-Tac-Toe");
SetTargetFPS(60);
Screen screen = SCREEN_MENU; /* 一开机,先显示主菜单 */
while (!WindowShouldClose())
{
// 1. 更新阶段:读取输入、更新游戏逻辑
/* 每一帧,先把"输入"读进来 */
Vector2 mouse = GetMousePosition();
int clicked = IsMouseButtonPressed(MOUSE_BUTTON_LEFT);
/* 处理这次点击(现在只有菜单这一层) */
if (screen == SCREEN_MENU)
{
//没有处理startBtnClicked相关逻辑,因为还没写游戏菜单,点Start键没东西
if (clicked && exitBtnClicked(mouse)) {
break; /* 点退出:关程序 */
}
}
// 2. 渲染阶段:只负责画东西
/* 画这一帧 */
BeginDrawing();
ClearBackground(RAYWHITE);
if (screen == SCREEN_MENU) {
showMainMenu();
}
EndDrawing();
}
CloseWindow();
return 0;
}
让ai帮你编译。命令大概是这样:
c
gcc main.c game.c view.c -o "井字棋图形版.exe" -I ../raylib/include -L ../raylib/lib -lraylib -lopengl32 -lgdi32 -lwinmm
-I的参数写include的路径,-L的参数写lib的路径。
运行:

此时点Start键没东西(因为还没写游戏菜单),除了点右上角错号之外,点EXIT键也能关闭窗口。
3 第二步:空棋盘 + 返回按钮
游戏界面比主菜单复杂,所以我们照 2-5 的样子,把它拆成三步(对应着二、三、四),每步只加一部分东西。
我们先把棋盘的绘图和返回主菜单的按钮加进去。
和主菜单一样,先粗略确定棋盘和返回按钮的位置和大小。

先对 view.c 做改动:
我们不用把整张棋盘每个点的位置都记录下来,用"格子边长 + 棋盘左上角坐标"来定位就可以,这样能少记很多数据。
c
/* 棋盘:格子边长 + 棋盘左上角坐标 */
#define CELL_SIZE 120
#define BOARD_X 220
#define BOARD_Y 140
开始画棋盘,这里还是用宏定义表示两个格子间的缝宽。
c
#define LINE_SIZE 6 /* 格线的粗细(格子之间的缝) */
c
/* 画棋盘:先铺深灰色大矩形当底,再盖 9 个浅灰色格子矩形,露出来的深灰就是格线 */
void showBoard(void)
{
int row, col;
/* 1. 深灰色大矩形:覆盖整个棋盘区域 */
DrawRectangle(BOARD_X, BOARD_Y, 3 * CELL_SIZE, 3 * CELL_SIZE, DARKGRAY);
/* 2. 9 个浅灰色格子:每个比一格小一圈(四周各让出 LINE_SIZE / 2),盖在深灰底上 */
for (row = 0; row < 3; row++) {
for (col = 0; col < 3; col++) {
DrawRectangle(BOARD_X + col * CELL_SIZE + LINE_SIZE / 2,
BOARD_Y + row * CELL_SIZE + LINE_SIZE / 2,
CELL_SIZE - LINE_SIZE,
CELL_SIZE - LINE_SIZE,
LIGHTGRAY);
}
}
}
加入返回主菜单按钮的部分:
c
Rectangle backBtn = { 300, 515, 200, 60 }; /* 游戏界面里的"返回主菜单" */
/* 画游戏界面里的"返回主菜单"按钮 */
void showGameMenu(void)
{
DrawRectangleRec(backBtn, LIGHTGRAY);
DrawText("MENU", 370, 530, 25, DARKGRAY);
}
int backBtnClicked(Vector2 mouse) { return CheckCollisionPointRec(mouse, backBtn); }
完整的 view.c :
c
#include "view.h"
#include "raylib.h"
/* ---------- 界面的尺寸和位置(都写在这里,以后改界面就改这里) ---------- */
#define CELL_SIZE 120 /* 棋盘每个格子的边长 */
#define BOARD_X 220 /* 棋盘左上角的横坐标 */
#define BOARD_Y 140 /* 棋盘左上角的纵坐标 */
#define LINE_SIZE 6 /* 格线的粗细(格子之间的缝) */
/* 三个按钮的位置(x, y, 宽, 高) */
Rectangle startBtn = { 250, 250, 300, 80 }; /* 主菜单的"开始游戏" */
Rectangle exitBtn = { 250, 360, 300, 80 }; /* 主菜单的"退出游戏" */
Rectangle backBtn = { 300, 515, 200, 60 }; /* 游戏界面里的"返回主菜单" */
/* 显示主菜单 */
void showMainMenu(void)
{
DrawText("Tic-Tac-Toe", 240, 110, 50, DARKGRAY);
DrawRectangleRec(startBtn, GRAY); /* 画"开始游戏"按钮 */
DrawRectangleRec(exitBtn, GRAY); /* 画"退出游戏"按钮 */
DrawText("START", 355, 272, 30, WHITE);
DrawText("EXIT", 360, 382, 30, WHITE);
}
/* 画游戏界面里的"返回主菜单"按钮 */
void showGameMenu(void)
{
DrawRectangleRec(backBtn, LIGHTGRAY);
DrawText("MENU", 370, 530, 25, DARKGRAY);
}
/* 画棋盘:先铺深灰色大矩形当底,再盖 9 个浅灰色格子矩形,露出来的深灰就是格线 */
void showBoard(void)
{
int row, col;
/* 1. 深灰色大矩形:覆盖整个棋盘区域 */
DrawRectangle(BOARD_X, BOARD_Y, 3 * CELL_SIZE, 3 * CELL_SIZE, DARKGRAY);
/* 2. 9 个浅灰色格子:每个比一格小一圈(四周各让出 LINE_SIZE / 2),盖在深灰底上 */
for (row = 0; row < 3; row++) {
for (col = 0; col < 3; col++) {
DrawRectangle(BOARD_X + col * CELL_SIZE + LINE_SIZE / 2,
BOARD_Y + row * CELL_SIZE + LINE_SIZE / 2,
CELL_SIZE - LINE_SIZE,
CELL_SIZE - LINE_SIZE,
LIGHTGRAY);
}
}
}
/* ---------- 判断鼠标点了哪里(把鼠标坐标翻译成"点中了什么") ---------- */
int startBtnClicked(Vector2 mouse) { return CheckCollisionPointRec(mouse, startBtn); }
int exitBtnClicked(Vector2 mouse) { return CheckCollisionPointRec(mouse, exitBtn); }
int backBtnClicked(Vector2 mouse) { return CheckCollisionPointRec(mouse, backBtn); }
下面开始改 main.c 。
只需要在更新阶段和渲染阶段,把 screen == SCREEN_GAME 的情况加上就行了,于是能写出这样的代码:
c
#include "raylib.h"
#include "view.h"
int main(void)
{
InitWindow(800, 600, "Tic-Tac-Toe");
SetTargetFPS(60);
Screen screen = SCREEN_MENU; /* 一开机,先显示主菜单 */
while (!WindowShouldClose())
{
// 1. 更新阶段:读取输入、更新游戏逻辑
Vector2 mouse = GetMousePosition();
int clicked = IsMouseButtonPressed(MOUSE_BUTTON_LEFT);
if (screen == SCREEN_MENU)
{
if (clicked && startBtnClicked(mouse)) {
screen = SCREEN_GAME; /* 点开始:进入游戏界面 */
}
else if (clicked && exitBtnClicked(mouse)) {
break; /* 点退出:关程序 */
}
}
else if (screen == SCREEN_GAME)
{
if (clicked && backBtnClicked(mouse)) {
screen = SCREEN_MENU; /* 点返回:回主菜单 */
}
/* 这一小步先不写下棋逻辑:点棋盘上的格子,没反应 */
}
// 2. 渲染阶段:只负责画东西
BeginDrawing();
ClearBackground(RAYWHITE);
if (screen == SCREEN_MENU) {
showMainMenu();
}
else { /* SCREEN_GAME:空棋盘 + 返回按钮 */
showBoard();
showGameMenu();
}
EndDrawing();
}
CloseWindow();
return 0;
}
编译运行:
点 START 进入游戏界面,看到空棋盘和下方的 MENU 按钮。

点 MENU 回到主菜单,点 EXIT 退出。

回过头去看代码,你会注意到一个特别有意思的事情,界面一直在刷新,如果状态screen == SCREEN_MENU的时候就加载主菜单界面,如果状态screen == SCREEN_GAME的时候就加载游戏界面,在主菜单界面的时候(screen = SCREEN_MENU时)你可以点按钮,令screen = SCREEN_GAME,这样就切到游戏界面了,反之亦如是。
一个界面对应一个状态变量,状态变量改变则界面改变。要增加一个新的界面,只需要再增加一个状态变量,在更新阶段下加入别的状态变量怎么变成它,它怎么变成别人的逻辑,在渲染阶段下加入如果当前的状态变量是我,就渲染新界面的逻辑,就行了。
我们管这种编程设计的思想叫状态机。
ok,本节我们写好了井字棋图形化项目的骨架,了解了状态机的思想,知道了怎么做才能点一下切换界面。下一讲,我们会进入这个项目真正的核心部分,你,准备好了吗?