小游戏和GUI编程(6) | 基于 SFML 的井字棋
0. 简介
使用 SFML 实现井字棋(tic-tac-toe), 规划如下:
- 了解规则, 使用命令行实现(已经实现了)
- 使用 SFML,提供极简的交互(预计 1 小时)
- 制作 SVG 图像, 美化界面(预计 1 小时)
1. 基于命令行的实现
实现了两个用户 X 和 O 的交互下棋, 判断了输赢、 平局:
- 有胜负: 每个用户落下棋子后, 检查整个棋盘中的能获胜的 8 个线段上三个点,如果都等于当前落子的值(X或O)那么赢了
- 没胜负: 如果没有判断出有人赢了, 并且扫描棋盘网格出现了空格, 那么继续下棋; 没有扫描到空格, 说明没法落子了,是平局。
#include <stdio.h>
#include <string.h>char board[3][3];void show_board()
{for (int i = 0; i < 3; i++){if (i > 0) printf("-----\n");for (int j = 0; j < 3; j++){if (j > 0) printf("|");printf("%c", board[i][j]);}printf("\n");}
}char user = 'X';enum State {PLAYING = 0,WIN = 1,DRAW = 2
};
State state = PLAYING;
bool played = false;bool user_play()
{printf("[user=%c] please input position ([1-3] [1-3])): ", user);int x;int y;scanf("%d %d", &x, &y);if (x < 1 || x > 3 || y < 1 || y > 3){printf("invalid position\n");return false;}if (board[x-1][y-1] != ' '){printf("invalid position\n");return false;}board[x-1][y-1] = user;return true;
}void update_user()
{if (!played) return;if (user == 'X'){user = 'O';}else{user = 'X';}
}void judge()
{int data[8][6] = {{0, 0, 0, 1, 0, 2},{1, 0, 1, 1, 1, 2},{2, 0, 2, 1, 2, 2},{0, 0, 1, 0, 2, 0},{0, 1, 1, 1, 2, 1},{0, 2, 1, 2, 2, 2},{0, 0, 1, 1, 2, 2},{0, 2, 1, 1, 2, 0}};for (int i = 0; i < 8; i++){int x0 = data[i][0];int y0 = data[i][1];int x1 = data[i][2];int y1 = data[i][3];int x2 = data[i][4];int y2 = data[i][5];if (board[x0][y0] == user && board[x1][y1] == user && board[x2][y2] == user){state = WIN;return;}}for (int i = 0; i < 3; i++){for (int j = 0; j < 3; j++){if (board[i][j] == ' '){return;}}}state = DRAW;
}int main()
{for (int i = 0; i < 3; i++){for (int j = 0; j < 3; j++){board[i][j] = ' ';}}while (true){show_board();judge();if (state != PLAYING) break;update_user();played = user_play();}if (state == WIN){printf("Game end, user %c win\n", user);}else if (state == DRAW){printf("Game end, draw\n");}return 0;
}
2. 基于 SFML 的极简实现
所谓极简是说, 先不考虑美观性, 实现鼠标交互落子即可。
规划:
- 2.1 绘制窗口
- 2.2 鼠标点击后绘制单个棋子
- 2.3 绘制棋盘网格
- 2.4 轮流绘制棋子
- 2.5 显示局面信息
2.1 绘制窗口
#include <SFML/Graphics.hpp>int main()
{constexpr int win_width = 500;constexpr int win_height = 500;const std::string title = "Tic Tac Toe SFML";sf::RenderWindow window(sf::VideoMode(win_width, win_height), title);while (window.isOpen()){sf::Event event;while (window.pollEvent(event)){if (event.type == sf::Event::Closed) { window.close(); }}window.clear();// draw everything here...window.display();}return 0;
}
2.2 鼠标点击后绘制单个棋子
拆解为两部分: 获取到鼠标点击的位置; 根据位置绘制棋子。
根据位置绘制棋子
先直接绘制棋子 ‘X’. 可以使用 sf::RectangleShape
创建线段对象, 它具有宽度, 旋转它45° 和-45° 可以得到交叉效果, 比较麻烦的地方在于, 需要复杂的计算才能确保交叉点是中心点。
另一个思路是使用 sf::Vertext
, 通过在两个 vertex 之间用 sf::Lines
类型执行渲染, 省去了计算, 缺点是线段的宽度很窄。 不过够用了。
window.clear(sf::Color::White);// draw everything here...
// A B
// +----+
// | |
// +----+
// C D
sf::Vector2f A(100, 100);
sf::Vector2f D(150, 150);// draw line AD
sf::Vertex vertex[2];
vertex[0].position = A;
vertex[0].color = sf::Color::Blue;
vertex[1].position = D;
vertex[1].color = sf::Color::Blue;
window.draw(vertex, 2, sf::Lines);// draw line BC
sf::Vector2f B(150, 100);
sf::Vector2f C(100, 150);
vertex[0].position = B;
vertex[0].color = sf::Color::Blue;
vertex[1].position = C;
vertex[1].color = sf::Color::Blue;
window.draw(vertex, 2, sf::Lines);window.display();
获取鼠标位置
以获取到的鼠标点击位置为中心, 上下左右各自扩展 50 个像素, 得到的 ABCD 区域里面, 绘制 ‘X’.
获取鼠标点击位置, 是在 sfml 教程的 window - keyboard, mouse event 里:
sf::Vector2i localPosition(-1, -1);while (window.pollEvent(event)){if (event.type == sf::Event::Closed) { window.close(); }if (sf::Mouse::isButtonPressed(sf::Mouse::Left)){// get the local mouse position (relative to a window)localPosition = sf::Mouse::getPosition(window);}}
为了避免屏幕闪烁, 如果当前帧没有获取到新的鼠标位置, 那么 A,B,C,D 四个点的坐标不变,仍然执行渲染和绘制; 如果鼠标点击了, 才更新 A,B,C,D。 效果:
对应代码:
#include <SFML/Graphics.hpp>
#include <iostream>int main()
{constexpr int win_width = 500;constexpr int win_height = 500;const std::string title = "Tic Tac Toe SFML";sf::RenderWindow window(sf::VideoMode(win_width, win_height), title);sf::Vector2f A, B, C, D;while (window.isOpen()){sf::Event event;sf::Vector2i localPosition(-1, -1);while (window.pollEvent(event)){if (event.type == sf::Event::Closed) { window.close(); }if (sf::Mouse::isButtonPressed(sf::Mouse::Left)){// get the local mouse position (relative to a window)localPosition = sf::Mouse::getPosition(window);}}window.clear();constexpr int grid_len = 50;if (localPosition != sf::Vector2i(-1, -1)){// print the local position to consolestd::cout << "localPosition: " << localPosition.x << ", " << localPosition.y << std::endl;A = sf::Vector2f(localPosition.x - grid_len, localPosition.y - grid_len);D = sf::Vector2f(localPosition.x + grid_len, localPosition.y + grid_len);B = sf::Vector2f(localPosition.x + grid_len, localPosition.y - grid_len);C = sf::Vector2f(localPosition.x - grid_len, localPosition.y + grid_len);}// draw everything here...// A B// +----+// | |// +----+// C D// draw line ADsf::Vertex vertex[2];vertex[0].position = A;vertex[0].color = sf::Color::Yellow;vertex[1].position = D;vertex[1].color = sf::Color::Yellow;window.draw(vertex, 2, sf::Lines);// draw line BCvertex[0].position = B;vertex[0].color = sf::Color::Yellow;vertex[1].position = C;vertex[1].color = sf::Color::Yellow;window.draw(vertex, 2, sf::Lines);window.display();}return 0;
}
2.3 绘制棋盘网格
拆解为 2 个部分: 绘制 3x3 的网格; 对于每个网格,如果鼠标点击了它,就执行绘制。
绘制3x3网格
横向 2 条线, 纵向 2 条线。 绘制它们后就得到了网格。
window.clear(sf::Color::White);sf::Color grid_color(74, 74, 74);// draw a 3x3 grid lines
sf::RectangleShape horizon_line1(sf::Vector2f(grid_len * 3, 8));
horizon_line1.setPosition(100, 100 + grid_len);
horizon_line1.setFillColor(grid_color);
window.draw(horizon_line1);sf::RectangleShape horizon_line2(sf::Vector2f(grid_len * 3, 8));
horizon_line2.setPosition(100, 100 + 2 * grid_len);
horizon_line2.setFillColor(grid_color);
window.draw(horizon_line2);sf::RectangleShape vertical_line1(sf::Vector2f(8, grid_len * 3));
vertical_line1.setPosition(100 + grid_len, 100);
vertical_line1.setFillColor(grid_color);
window.draw(vertical_line1);sf::RectangleShape vertical_line2(sf::Vector2f(8, grid_len * 3));
vertical_line2.setPosition(100 + 2 * grid_len, 100);
vertical_line2.setFillColor(grid_color);
window.draw(vertical_line2);window.display();
每个网格内,如果鼠标有点击则执行绘制
根据前一步绘制的网格, 可以确定每个小格子的坐标范围。 根据鼠标点击获取的位置,遍历每个格子, 如果是在当前格子内部, 那么执行绘制。 简单起见, 为了区分绘制内容,只要点击了当前小格子就执行绘制.
先将刚刚回执的网格的代码做重构, 根据起始点 p00(100, 100) 和网格宽度 grid_len = 50 进行绘制, 而不是硬编码每个线段的起点。
根据网格点的起始位置, 可以定义 9 个格子的A,B,C,D坐标取值。 为了避免绘制时的闪烁,定义 class Grid
, 由每个网格自己决定是否绘制, 也就是提供 void draw(sf::RenderWindow& window)
函数, 如果鼠标左键点击时落在格子范围内, 那么 shouldRender
变量更新为 true, 执行有效的渲染:
// main 函数
for (int i = 0; i < 9; i++)
{// if the mouse is inside the grid, draw a crossif (localPosition.x >= grid[i].A.x && localPosition.x <= grid[i].D.x &&localPosition.y >= grid[i].A.y && localPosition.y <= grid[i].D.y){printf("inside grid[%d]: localPosition: %d, %d\n", i, localPosition.x, localPosition.y);grid[i].shouldRender = true;}grid[i].draw(window);
}// Grid::draw() 函数
void draw(sf::RenderWindow& window)
{if (shouldRender){sf::Vertex vertex[2];vertex[0].position = A;vertex[0].color = sf::Color::Blue;vertex[1].position = D;vertex[1].color = sf::Color::Blue;window.draw(vertex, 2, sf::Lines);vertex[0].position = B;vertex[0].color = sf::Color::Blue;vertex[1].position = C;vertex[1].color = sf::Color::Blue;window.draw(vertex, 2, sf::Lines);}
}
效果:
完整代码:
int draw_grid_and_response_mouse()
{constexpr int win_width = 350;constexpr int win_height = 350;const std::string title = "Tic Tac Toe SFML";sf::RenderWindow window(sf::VideoMode(win_width, win_height), title);constexpr int grid_len = 50;sf::Color grid_color(74, 74, 74);// p00 p01 p02 p03// +-----+-----+-----+// | | | |// +-----+-----+-----+// p10 p11 p12 p13// +-----+-----+-----+// | | | |// +-----+-----+-----+// p20 p21 p22 p23// +-----+-----+-----+// | | | |// +-----+-----+-----+// p30 p31 p32 p33sf::Vector2f p00(100, 100);sf::Vector2f p01(100 + grid_len, 100);sf::Vector2f p02(100 + 2 * grid_len, 100);sf::Vector2f p10(100, 100 + grid_len);sf::Vector2f p11(100 + grid_len, 100 + grid_len);sf::Vector2f p12(100 + 2 * grid_len, 100 + grid_len);sf::Vector2f p20(100, 100 + 2 * grid_len);sf::Vector2f p21(100 + grid_len, 100 + 2 * grid_len);sf::Vector2f p22(100 + 2 * grid_len, 100 + 2 * grid_len);std::array<Grid, 9> grid;grid[0].update(p00, grid_len);grid[1].update(p01, grid_len);grid[2].update(p02, grid_len);grid[3].update(p10, grid_len);grid[4].update(p11, grid_len);grid[5].update(p12, grid_len);grid[6].update(p20, grid_len);grid[7].update(p21, grid_len);grid[8].update(p22, grid_len);while (window.isOpen()){sf::Event event;sf::Vector2i localPosition(-1, -1);while (window.pollEvent(event)){if (event.type == sf::Event::Closed) { window.close(); }if (sf::Mouse::isButtonPressed(sf::Mouse::Left)){// get the local mouse position (relative to a window)localPosition = sf::Mouse::getPosition(window);}}window.clear(sf::Color::White);// draw a 3x3 grid linessf::RectangleShape horizon_line1(sf::Vector2f(grid_len * 3, 8));horizon_line1.setPosition(p10);horizon_line1.setFillColor(grid_color);window.draw(horizon_line1);sf::RectangleShape horizon_line2(sf::Vector2f(grid_len * 3, 8));horizon_line2.setPosition(p20);horizon_line2.setFillColor(grid_color);window.draw(horizon_line2);sf::RectangleShape vertical_line1(sf::Vector2f(8, grid_len * 3));vertical_line1.setPosition(p01);vertical_line1.setFillColor(grid_color);window.draw(vertical_line1);sf::RectangleShape vertical_line2(sf::Vector2f(8, grid_len * 3));vertical_line2.setPosition(p02);vertical_line2.setFillColor(grid_color);window.draw(vertical_line2);for (int i = 0; i < 9; i++){// if the mouse is inside the grid, draw a crossif (localPosition.x >= grid[i].A.x && localPosition.x <= grid[i].D.x &&localPosition.y >= grid[i].A.y && localPosition.y <= grid[i].D.y){printf("inside grid[%d]: localPosition: %d, %d\n", i, localPosition.x, localPosition.y);grid[i].shouldRender = true;}grid[i].draw(window);}window.display();}return 0;
}
2.4 轮流绘制棋子
拆解为如下部分: 绘制单个圆形⭕️, 交替绘制 ❌ 和 ⭕️.
绘制单个圆形
sf::CircleShape circle(grid_len / 3);
circle.setFillColor(bg_color);
circle.setOutlineThickness(4);
circle.setOutlineColor(sf::Color::Red);
circle.setPosition(200, 200);
window.draw(circle);
交替绘制 ❌ 和 ⭕️
每个网格被点击后, 要根据现有情况来执行绘制: 如果之前没有绘制过(没有落子过), 则绘制当前用户的棋子; 如果已经绘制过, 那么不能绘制, 说明当前用户落子无效, 要换一个地方落子。 如果鼠标点击的位置不在 9 个格子的范围内,也是落子无效。
#include <SFML/Graphics.hpp>
#include <iostream>
#include <array>char user = 'X';enum class DrawShape
{NONE = 0,CROSS = 1, CIRCLE = 2
};class Grid
{
public:Grid() = default;Grid(sf::Vector2f start_pos, int a_grid_len){update(start_pos, a_grid_len);}void update(sf::Vector2f start_pos, int a_grid_len){grid_len = a_grid_len;A = start_pos;B = sf::Vector2f(start_pos.x + grid_len, start_pos.y);C = sf::Vector2f(start_pos.x, start_pos.y + grid_len);D = sf::Vector2f(start_pos.x + grid_len, start_pos.y + grid_len);}void draw(sf::RenderWindow& window){if (drawShape == DrawShape::CROSS){drawCross(window);}else if (drawShape == DrawShape::CIRCLE){drawCircle(window);}}void drawCross(sf::RenderWindow& window){sf::Vertex vertex[2];vertex[0].position = A;vertex[0].color = sf::Color::Blue;vertex[1].position = D;vertex[1].color = sf::Color::Blue;window.draw(vertex, 2, sf::Lines);vertex[0].position = B;vertex[0].color = sf::Color::Blue;vertex[1].position = C;vertex[1].color = sf::Color::Blue;window.draw(vertex, 2, sf::Lines);}void drawCircle(sf::RenderWindow& window){// draw a circlefloat radius = grid_len / 3;sf::CircleShape circle(radius);int thickness = 4;circle.setOutlineThickness(thickness);circle.setOutlineColor(sf::Color::Red);// M is middle point of A and Dsf::Vector2f M((A.x + D.x) / 2, (A.y + D.y) / 2);sf::Vector2f circle_position(M.x - radius + thickness, M.y - radius + thickness);circle.setPosition(circle_position);window.draw(circle);}sf::Vector2f A, B, C, D;DrawShape drawShape{};int grid_len;
};int draw_grid_and_response_mouse()
{constexpr int win_width = 350;constexpr int win_height = 350;const std::string title = "Tic Tac Toe SFML";sf::RenderWindow window(sf::VideoMode(win_width, win_height), title);constexpr int grid_len = 50;sf::Color grid_color(74, 74, 74);// p00 p01 p02 p03// +-----+-----+-----+// | | | |// +-----+-----+-----+// p10 p11 p12 p13// +-----+-----+-----+// | | | |// +-----+-----+-----+// p20 p21 p22 p23// +-----+-----+-----+// | | | |// +-----+-----+-----+// p30 p31 p32 p33sf::Vector2f p00(100, 100);sf::Vector2f p01(100 + grid_len, 100);sf::Vector2f p02(100 + 2 * grid_len, 100);sf::Vector2f p10(100, 100 + grid_len);sf::Vector2f p11(100 + grid_len, 100 + grid_len);sf::Vector2f p12(100 + 2 * grid_len, 100 + grid_len);sf::Vector2f p20(100, 100 + 2 * grid_len);sf::Vector2f p21(100 + grid_len, 100 + 2 * grid_len);sf::Vector2f p22(100 + 2 * grid_len, 100 + 2 * grid_len);std::array<Grid, 9> grid;grid[0].update(p00, grid_len);grid[1].update(p01, grid_len);grid[2].update(p02, grid_len);grid[3].update(p10, grid_len);grid[4].update(p11, grid_len);grid[5].update(p12, grid_len);grid[6].update(p20, grid_len);grid[7].update(p21, grid_len);grid[8].update(p22, grid_len);bool played = false;while (window.isOpen()){sf::Event event;sf::Vector2i localPosition(-1, -1);while (window.pollEvent(event)){if (event.type == sf::Event::Closed) { window.close(); }if (sf::Mouse::isButtonPressed(sf::Mouse::Left)){// get the local mouse position (relative to a window)localPosition = sf::Mouse::getPosition(window);}}window.clear(sf::Color::White);// draw a 3x3 grid linessf::RectangleShape horizon_line1(sf::Vector2f(grid_len * 3, 8));horizon_line1.setPosition(p10);horizon_line1.setFillColor(grid_color);window.draw(horizon_line1);sf::RectangleShape horizon_line2(sf::Vector2f(grid_len * 3, 8));horizon_line2.setPosition(p20);horizon_line2.setFillColor(grid_color);window.draw(horizon_line2);sf::RectangleShape vertical_line1(sf::Vector2f(8, grid_len * 3));vertical_line1.setPosition(p01);vertical_line1.setFillColor(grid_color);window.draw(vertical_line1);sf::RectangleShape vertical_line2(sf::Vector2f(8, grid_len * 3));vertical_line2.setPosition(p02);vertical_line2.setFillColor(grid_color);window.draw(vertical_line2);for (int i = 0; i < 9; i++){// if the mouse is inside the grid, draw a crossif (localPosition.x >= grid[i].A.x && localPosition.x <= grid[i].D.x &&localPosition.y >= grid[i].A.y && localPosition.y <= grid[i].D.y &&grid[i].drawShape == DrawShape::NONE){printf("inside grid[%d]: localPosition: %d, %d\n", i, localPosition.x, localPosition.y);if (user == 'X'){grid[i].drawShape = DrawShape::CROSS;}else if (user == 'O'){grid[i].drawShape = DrawShape::CIRCLE;}played = true;}grid[i].draw(window);}if (played){user = (user == 'X') ? 'O' : 'X';played = false;}window.display();}return 0;
}
2.5 显示局面信息
显示这些信息:
- 轮到哪个用户落子, X 还是 O?
- 落子后判断输赢
- 如果平局, 显示平局
- 允许中途或结束时,重新来一局
显示轮到谁落子
// draw a text to show the current user
sf::Text text;
text.setFont(font);
text.setString("Current user: " + std::string(1, user));
text.setCharacterSize(24);
text.setFillColor(sf::Color::Black);
text.setPosition(10, 10);
window.draw(text);
落子后判断输赢
在基于控制台的实现中, 使用的是 char board[3][3]
记录棋子, 相比于 enum class DrawShape
要直观的多。 因此我们抛弃 enum class DrawShape
, 在 Grid 类中使用 char 类型的数据来存储当前网格里的棋子情况:
class Grid
{
public:...void draw(sf::RenderWindow& window){if (data == 'X'){drawCross(window);}else if (data == 'O'){drawCircle(window);}}
private:char data = ' ';
};
输赢局面的判断: 如果是有效落子, 那么在 8 条线段上分别判断。 如果判断出来赢了, 则更新 state; 如果没有有效落子, 检查是否存在能落子的地方 (’ '), 如果没有能落子的地方, 说明是平局(Draw):
if (played)
{int data[8][6] = {{0, 0, 0, 1, 0, 2},{1, 0, 1, 1, 1, 2},{2, 0, 2, 1, 2, 2},{0, 0, 1, 0, 2, 0},{0, 1, 1, 1, 2, 1},{0, 2, 1, 2, 2, 2},{0, 0, 1, 1, 2, 2},{0, 2, 1, 1, 2, 0}}; for (int i = 0; i < 8; i++){int x0 = data[i][0];int y0 = data[i][1];int x1 = data[i][2];int y1 = data[i][3];int x2 = data[i][4];int y2 = data[i][5];int idx0 = x0 * 3 + y0;int idx1 = x1 * 3 + y1;int idx2 = x2 * 3 + y2;if (grid[idx0].data == user && grid[idx1].data == user && grid[idx2].data == user){if (user == 'X'){state = PlayState::X_WIN;}else{state = PlayState::O_WIN;}break;}}user = (user == 'X') ? 'O' : 'X';
}if (!played)
{state = PlayState::DRAW;for (int i = 0; i < 9; i++){if (grid[i].data == ' '){state = PlayState::PLAYING;break;}}
}
顺带, 在界面上通过绘制文字的方式, 显示当前轮到谁下子、 谁赢了、 是否平局信息, 以及随时可以点击 “restart” 重来一局:
sf::Vector2f boxPos(130, 300);sf::Vector2f boxSize(100, 24);sf::RectangleShape box(boxSize);box.setFillColor(sf::Color::Red);box.setPosition(boxPos);window.draw(box);// A B// C Dsf::Vector2f boxA(boxPos);sf::Vector2f boxB(boxPos.x + boxSize.x, boxPos.y);sf::Vector2f boxC(boxPos.x, boxPos.y + boxSize.y);sf::Vector2f boxD(boxPos.x + boxSize.x, boxPos.y + boxSize.y);if (localPosition.x >= boxA.x && localPosition.y >= boxA.y &&localPosition.x <= boxD.x && localPosition.y <= boxD.y){for (int i = 0; i < 9; i++){grid[i].data = ' ';}user = 'X';state = PlayState::PLAYING;}// draw a button with text "restart"sf::Text restart;restart.setFont(font);restart.setString("Restart");restart.setCharacterSize(24);restart.setFillColor(sf::Color::White);restart.setPosition(boxPos);window.draw(restart);
效果如下:
完整代码是
#include <SFML/Graphics.hpp>
#include <iostream>
#include <array>enum class PlayState
{PLAYING = 0,X_WIN = 1,O_WIN = 2,DRAW = 3
};class Grid
{
public:Grid() = default;Grid(sf::Vector2f start_pos, int a_grid_len){update(start_pos, a_grid_len);}void update(sf::Vector2f start_pos, int a_grid_len){grid_len = a_grid_len;A = start_pos;B = sf::Vector2f(start_pos.x + grid_len, start_pos.y);C = sf::Vector2f(start_pos.x, start_pos.y + grid_len);D = sf::Vector2f(start_pos.x + grid_len, start_pos.y + grid_len);}void draw(sf::RenderWindow& window){if (data == 'X'){drawCross(window);}else if (data == 'O'){drawCircle(window);}}void drawCross(sf::RenderWindow& window){sf::Vertex vertex[2];vertex[0].position = A;vertex[0].color = sf::Color::Blue;vertex[1].position = D;vertex[1].color = sf::Color::Blue;window.draw(vertex, 2, sf::Lines);vertex[0].position = B;vertex[0].color = sf::Color::Blue;vertex[1].position = C;vertex[1].color = sf::Color::Blue;window.draw(vertex, 2, sf::Lines);}void drawCircle(sf::RenderWindow& window){// draw a circlefloat radius = grid_len / 3;sf::CircleShape circle(radius);int thickness = 4;circle.setOutlineThickness(thickness);circle.setOutlineColor(sf::Color::Red);// M is middle point of A and Dsf::Vector2f M((A.x + D.x) / 2, (A.y + D.y) / 2);sf::Vector2f circle_position(M.x - radius + thickness, M.y - radius + thickness);circle.setPosition(circle_position);window.draw(circle);}sf::Vector2f A, B, C, D;char data = ' ';int grid_len;
};char user = 'X';int draw_grid_and_response_mouse()
{constexpr int win_width = 350;constexpr int win_height = 350;const std::string title = "Tic Tac Toe SFML";sf::RenderWindow window(sf::VideoMode(win_width, win_height), title);constexpr int grid_len = 50;sf::Color grid_color(74, 74, 74);// p00 p01 p02 p03// +-----+-----+-----+// | | | |// +-----+-----+-----+// p10 p11 p12 p13// +-----+-----+-----+// | | | |// +-----+-----+-----+// p20 p21 p22 p23// +-----+-----+-----+// | | | |// +-----+-----+-----+// p30 p31 p32 p33sf::Vector2f p00(100, 100);sf::Vector2f p01(100 + grid_len, 100);sf::Vector2f p02(100 + 2 * grid_len, 100);sf::Vector2f p10(100, 100 + grid_len);sf::Vector2f p11(100 + grid_len, 100 + grid_len);sf::Vector2f p12(100 + 2 * grid_len, 100 + grid_len);sf::Vector2f p20(100, 100 + 2 * grid_len);sf::Vector2f p21(100 + grid_len, 100 + 2 * grid_len);sf::Vector2f p22(100 + 2 * grid_len, 100 + 2 * grid_len);std::array<Grid, 9> grid;grid[0].update(p00, grid_len);grid[1].update(p01, grid_len);grid[2].update(p02, grid_len);grid[3].update(p10, grid_len);grid[4].update(p11, grid_len);grid[5].update(p12, grid_len);grid[6].update(p20, grid_len);grid[7].update(p21, grid_len);grid[8].update(p22, grid_len);sf::Font font;const std::string asset_dir = "../Resources";if (!font.loadFromFile(asset_dir + "/Arial.ttf")){std::cerr << "failed to load font\n";return 1;}PlayState state = PlayState::PLAYING;while (window.isOpen()){sf::Event event;sf::Vector2i localPosition(-1, -1);while (window.pollEvent(event)){if (event.type == sf::Event::Closed) { window.close(); }if (sf::Mouse::isButtonPressed(sf::Mouse::Left)){// get the local mouse position (relative to a window)localPosition = sf::Mouse::getPosition(window);}}window.clear(sf::Color::White);// draw a text to show the current usersf::Text text;text.setFont(font);std::string state_str;if (state == PlayState::DRAW){state_str = "Draw";}else if (state == PlayState::X_WIN){state_str = "User X wins!";}else if (state == PlayState::O_WIN){state_str = "User O wins!";}else{state_str = "Current user: " + std::string(1, user);}printf("state: %s\n", state_str.c_str());text.setString(state_str);text.setCharacterSize(24);text.setFillColor(sf::Color::Black);text.setPosition(10, 10);window.draw(text);// draw a 3x3 grid linessf::RectangleShape horizon_line1(sf::Vector2f(grid_len * 3, 8));horizon_line1.setPosition(p10);horizon_line1.setFillColor(grid_color);window.draw(horizon_line1);sf::RectangleShape horizon_line2(sf::Vector2f(grid_len * 3, 8));horizon_line2.setPosition(p20);horizon_line2.setFillColor(grid_color);window.draw(horizon_line2);sf::RectangleShape vertical_line1(sf::Vector2f(8, grid_len * 3));vertical_line1.setPosition(p01);vertical_line1.setFillColor(grid_color);window.draw(vertical_line1);sf::RectangleShape vertical_line2(sf::Vector2f(8, grid_len * 3));vertical_line2.setPosition(p02);vertical_line2.setFillColor(grid_color);window.draw(vertical_line2);bool played = false;if (state == PlayState::PLAYING){for (int i = 0; i < 9; i++){// if the mouse is inside the grid, draw a crossif (localPosition.x >= grid[i].A.x && localPosition.x <= grid[i].D.x &&localPosition.y >= grid[i].A.y && localPosition.y <= grid[i].D.y &&grid[i].data == ' '){printf("inside grid[%d]: localPosition: %d, %d\n", i, localPosition.x, localPosition.y);grid[i].data = user;played = true;}grid[i].draw(window);if (played){break;}}if (played){int data[8][6] = {{0, 0, 0, 1, 0, 2},{1, 0, 1, 1, 1, 2},{2, 0, 2, 1, 2, 2},{0, 0, 1, 0, 2, 0},{0, 1, 1, 1, 2, 1},{0, 2, 1, 2, 2, 2},{0, 0, 1, 1, 2, 2},{0, 2, 1, 1, 2, 0}}; for (int i = 0; i < 8; i++){int x0 = data[i][0];int y0 = data[i][1];int x1 = data[i][2];int y1 = data[i][3];int x2 = data[i][4];int y2 = data[i][5];int idx0 = x0 * 3 + y0;int idx1 = x1 * 3 + y1;int idx2 = x2 * 3 + y2;if (grid[idx0].data == user && grid[idx1].data == user && grid[idx2].data == user){if (user == 'X'){state = PlayState::X_WIN;}else{state = PlayState::O_WIN;}break;}}user = (user == 'X') ? 'O' : 'X'; }if (!played){state = PlayState::DRAW;for (int i = 0; i < 9; i++){if (grid[i].data == ' '){state = PlayState::PLAYING;break;}}}}else{for (int i = 0; i < 9; i++){grid[i].draw(window);}}sf::Vector2f boxPos(130, 300);sf::Vector2f boxSize(100, 24);sf::RectangleShape box(boxSize);box.setFillColor(sf::Color::Red);box.setPosition(boxPos);window.draw(box);// A B// C Dsf::Vector2f boxA(boxPos);sf::Vector2f boxB(boxPos.x + boxSize.x, boxPos.y);sf::Vector2f boxC(boxPos.x, boxPos.y + boxSize.y);sf::Vector2f boxD(boxPos.x + boxSize.x, boxPos.y + boxSize.y);if (localPosition.x >= boxA.x && localPosition.y >= boxA.y &&localPosition.x <= boxD.x && localPosition.y <= boxD.y){for (int i = 0; i < 9; i++){grid[i].data = ' ';}user = 'X';state = PlayState::PLAYING;}// draw a button with text "restart"sf::Text restart;restart.setFont(font);restart.setString("Restart");restart.setCharacterSize(24);restart.setFillColor(sf::Color::White);restart.setPosition(boxPos);window.draw(restart);window.display();}return 0;
}int main()
{draw_grid_and_response_mouse();return 0;
}
3. 制作 SVG 图像, 美化界面
任务分解为: 制作 ‘X’ 和 ‘O’ 棋子的 svg 图像, 使用 SFML 导入 SVG 图像并替代先前棋子的绘制。
使用在线工具(1, 2), 结合 inkscape 和 VSCode svg 插件, 得到 X 和 O 的 svg 图像:
ttt-cross.svg:
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svgwidth="300"height="300"version="1.1"id="svg1"sodipodi:docname="ttt-cross.svg"inkscape:version="1.3.2 (091e20e, 2023-11-25)"xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"xmlns="http://www.w3.org/2000/svg"xmlns:svg="http://www.w3.org/2000/svg"><defsid="defs1" /><sodipodi:namedviewid="namedview1"pagecolor="#ffffff"bordercolor="#000000"borderopacity="0.25"inkscape:showpageshadow="2"inkscape:pageopacity="0.0"inkscape:pagecheckerboard="0"inkscape:deskcolor="#d1d1d1"inkscape:zoom="1.616"inkscape:cx="247.83416"inkscape:cy="162.74752"inkscape:window-width="1408"inkscape:window-height="953"inkscape:window-x="0"inkscape:window-y="25"inkscape:window-maximized="0"inkscape:current-layer="svg1" /><!-- Created with SVG-edit - https://github.com/SVG-Edit/svgedit--><gclass="layer"id="g1"transform="translate(25.371288,-13.61386)"><titleid="title1">Layer 1</title><rectfill="#747474"height="27"id="svg_5"stroke="#747474"transform="rotate(45,229.99949,92.78901)"width="300"x="50"y="200" /><rectfill="#747474"height="27"id="svg_7"stroke="#747474"transform="rotate(135,175,175.58344)"width="300"x="50"y="200" /></g>
</svg>
ttt-circle.svg:
<svg id="svgelem" height="140" width="140" xmlns="http://www.w3.org/2000/svg"><circle cx="70" cy="70" r="50" stroke="#eee8cf" stroke-width="10" fill="none" />
</svg>
SFML 不支持 svg 的导入, 因此转换为 png 后再使用:
cairosvg ttt-cross.svg -o ttt-cross.png
cairosvg ttt-circle.svg -o ttt-circle.png
4. 最终结果
运行效果:
代码:
#include <SFML/Graphics.hpp>
#include <iostream>
#include <array>enum class PlayState
{PLAYING = 0,X_WIN = 1,O_WIN = 2,DRAW = 3
};class Grid
{
public:Grid() = default;Grid(sf::Vector2f start_pos, int a_grid_len){update(start_pos, a_grid_len);}void update(sf::Vector2f start_pos, int a_grid_len){grid_len = a_grid_len;A = start_pos;B = sf::Vector2f(start_pos.x + grid_len, start_pos.y);C = sf::Vector2f(start_pos.x, start_pos.y + grid_len);D = sf::Vector2f(start_pos.x + grid_len, start_pos.y + grid_len);}void draw(sf::RenderWindow& window){if (data == 'X'){drawCross(window);}else if (data == 'O'){drawCircle(window);}}void drawCross(sf::RenderWindow& window){if (0){sf::Vertex vertex[2];vertex[0].position = A;vertex[0].color = sf::Color::Blue;vertex[1].position = D;vertex[1].color = sf::Color::Blue;window.draw(vertex, 2, sf::Lines);vertex[0].position = B;vertex[0].color = sf::Color::Blue;vertex[1].position = C;vertex[1].color = sf::Color::Blue;window.draw(vertex, 2, sf::Lines);}else{// load a svg file to create a texturesf::Texture texture;if (!texture.loadFromFile("../ttt-cross.png")){std::cerr << "failed to load cross image\n";return;}// draw a sprite in box region A, Dsf::Sprite sprite(texture);sprite.setPosition(A);sprite.setScale(0.2, 0.2);window.draw(sprite);}}void drawCircle(sf::RenderWindow& window){if (0){// draw a circlefloat radius = grid_len / 3;sf::CircleShape circle(radius);int thickness = 4;circle.setOutlineThickness(thickness);circle.setOutlineColor(sf::Color::Red);// M is middle point of A and Dsf::Vector2f M((A.x + D.x) / 2, (A.y + D.y) / 2);sf::Vector2f circle_position(M.x - radius + thickness, M.y - radius + thickness);circle.setPosition(circle_position);window.draw(circle);}else{// load a svg file to create a texturesf::Texture texture;if (!texture.loadFromFile("../ttt-circle.png")){std::cerr << "failed to load circle image\n";return;}// draw a sprite in box region A, Dsf::Sprite sprite(texture);sprite.setPosition(A);sprite.setScale(0.4, 0.4);window.draw(sprite); }}sf::Vector2f A, B, C, D;char data = ' ';int grid_len;
};char user = 'X';int draw_grid_and_response_mouse()
{constexpr int win_width = 350;constexpr int win_height = 350;const std::string title = "Tic Tac Toe SFML";sf::RenderWindow window(sf::VideoMode(win_width, win_height), title);constexpr int grid_len = 50;sf::Color grid_color(64, 148, 135);sf::Color bg_color(78, 177, 163);// p00 p01 p02 p03// +-----+-----+-----+// | | | |// +-----+-----+-----+// p10 p11 p12 p13// +-----+-----+-----+// | | | |// +-----+-----+-----+// p20 p21 p22 p23// +-----+-----+-----+// | | | |// +-----+-----+-----+// p30 p31 p32 p33sf::Vector2f p00(100, 100);sf::Vector2f p01(100 + grid_len, 100);sf::Vector2f p02(100 + 2 * grid_len, 100);sf::Vector2f p10(100, 100 + grid_len);sf::Vector2f p11(100 + grid_len, 100 + grid_len);sf::Vector2f p12(100 + 2 * grid_len, 100 + grid_len);sf::Vector2f p20(100, 100 + 2 * grid_len);sf::Vector2f p21(100 + grid_len, 100 + 2 * grid_len);sf::Vector2f p22(100 + 2 * grid_len, 100 + 2 * grid_len);std::array<Grid, 9> grid;grid[0].update(p00, grid_len);grid[1].update(p01, grid_len);grid[2].update(p02, grid_len);grid[3].update(p10, grid_len);grid[4].update(p11, grid_len);grid[5].update(p12, grid_len);grid[6].update(p20, grid_len);grid[7].update(p21, grid_len);grid[8].update(p22, grid_len);sf::Font font;const std::string asset_dir = "../Resources";if (!font.loadFromFile(asset_dir + "/Arial.ttf")){std::cerr << "failed to load font\n";return 1;}PlayState state = PlayState::PLAYING;while (window.isOpen()){sf::Event event;sf::Vector2i localPosition(-1, -1);while (window.pollEvent(event)){if (event.type == sf::Event::Closed) { window.close(); }if (sf::Mouse::isButtonPressed(sf::Mouse::Left)){// get the local mouse position (relative to a window)localPosition = sf::Mouse::getPosition(window);}}window.clear(bg_color);// draw a text to show the current usersf::Text text;text.setFont(font);std::string state_str;if (state == PlayState::DRAW){state_str = "Draw";}else if (state == PlayState::X_WIN){state_str = "User X wins!";}else if (state == PlayState::O_WIN){state_str = "User O wins!";}else{state_str = "Current user: " + std::string(1, user);}printf("state: %s\n", state_str.c_str());text.setString(state_str);text.setCharacterSize(24);text.setFillColor(sf::Color::Black);text.setPosition(10, 10);window.draw(text);// draw a 3x3 grid linessf::RectangleShape horizon_line1(sf::Vector2f(grid_len * 3, 8));horizon_line1.setPosition(p10);horizon_line1.setFillColor(grid_color);window.draw(horizon_line1);sf::RectangleShape horizon_line2(sf::Vector2f(grid_len * 3, 8));horizon_line2.setPosition(p20);horizon_line2.setFillColor(grid_color);window.draw(horizon_line2);sf::RectangleShape vertical_line1(sf::Vector2f(8, grid_len * 3));vertical_line1.setPosition(p01);vertical_line1.setFillColor(grid_color);window.draw(vertical_line1);sf::RectangleShape vertical_line2(sf::Vector2f(8, grid_len * 3));vertical_line2.setPosition(p02);vertical_line2.setFillColor(grid_color);window.draw(vertical_line2);bool played = false;if (state == PlayState::PLAYING){for (int i = 0; i < 9; i++){// if the mouse is inside the grid, draw a crossif (localPosition.x >= grid[i].A.x && localPosition.x <= grid[i].D.x &&localPosition.y >= grid[i].A.y && localPosition.y <= grid[i].D.y &&grid[i].data == ' '){printf("inside grid[%d]: localPosition: %d, %d\n", i, localPosition.x, localPosition.y);grid[i].data = user;played = true;}grid[i].draw(window);if (played){break;}}if (played){int data[8][6] = {{0, 0, 0, 1, 0, 2},{1, 0, 1, 1, 1, 2},{2, 0, 2, 1, 2, 2},{0, 0, 1, 0, 2, 0},{0, 1, 1, 1, 2, 1},{0, 2, 1, 2, 2, 2},{0, 0, 1, 1, 2, 2},{0, 2, 1, 1, 2, 0}}; for (int i = 0; i < 8; i++){int x0 = data[i][0];int y0 = data[i][1];int x1 = data[i][2];int y1 = data[i][3];int x2 = data[i][4];int y2 = data[i][5];int idx0 = x0 * 3 + y0;int idx1 = x1 * 3 + y1;int idx2 = x2 * 3 + y2;if (grid[idx0].data == user && grid[idx1].data == user && grid[idx2].data == user){if (user == 'X'){state = PlayState::X_WIN;}else{state = PlayState::O_WIN;}break;}}user = (user == 'X') ? 'O' : 'X'; }if (!played){state = PlayState::DRAW;for (int i = 0; i < 9; i++){if (grid[i].data == ' '){state = PlayState::PLAYING;break;}}}}else{for (int i = 0; i < 9; i++){grid[i].draw(window);}}sf::Vector2f boxPos(130, 300);sf::Vector2f boxSize(80, 30);// A B// C Dsf::Vector2f boxA(boxPos);sf::Vector2f boxB(boxPos.x + boxSize.x, boxPos.y);sf::Vector2f boxC(boxPos.x, boxPos.y + boxSize.y);sf::Vector2f boxD(boxPos.x + boxSize.x, boxPos.y + boxSize.y);if (localPosition.x >= boxA.x && localPosition.y >= boxA.y &&localPosition.x <= boxD.x && localPosition.y <= boxD.y){for (int i = 0; i < 9; i++){grid[i].data = ' ';}user = 'X';state = PlayState::PLAYING;}// draw a button with text "restart"sf::Text restart;restart.setFont(font);restart.setString("Restart");restart.setCharacterSize(24);restart.setFillColor(sf::Color(35, 44, 44));restart.setPosition(boxPos);restart.setOutlineColor(sf::Color(35, 44, 44));window.draw(restart);window.display();}return 0;
}int main()
{draw_grid_and_response_mouse();return 0;
}
总结
花了 4 个半小时, 在先前写好了控制台版本 tic-tac-toe 的基础上, 使用 SFML 做了简陋的界面, 让游戏先运行起来能够正常玩; 然后制作了 SVG 图像, 仿照谷歌搜索结果里的在线版本的界面, 稍微美化了一下显示效果。
在使用 SFML 制作界面的过程中, 没有一上来就苛求制作好看的 ‘X’ 形状, 因为它后期可以重新调整; 本来引入了 DrawShape 枚举类型, 不过后来发现有点冗余, 在 class Grid
中用 char data
更方便。
对于绘制的最终结果, 不是一蹴而就的, 而是分别写了小型函数来验证, ‘X’ 可以单独绘制正确, 并且利用 class Grid
类的 draw()
方法, 根据落子的情况做了绘制(没有落子则不绘制)。
对于输赢局面的判别, 是先前控制台版本代码里有的, 直接拿来用了。 先写控制台版本看来确实可以加速迭代, 套着一套 GUI 的时候想逻辑, 需要对界面代码的编写比较熟悉才会不卡壳, 为了避免卡壳, 用熟悉的控制台去写, 是很方便的。
这个基于 SFML 的 tic-tac-toe 可以进一步扩展, 例如增加音效, 在获胜的时候用动态效果把三个棋子连接起来, 增加人机对战模式, 并利用 alpha-beta 剪枝算法进行搜索优化。
References
- https://en.sfml-dev.org/forums/index.php?topic=21620.0
- https://www.sfml-dev.org/tutorials/2.6/window-inputs.php
- https://www.sfml-dev.org/tutorials/2.6/graphics-shape.php
- https://github.com/skiff/TicTacToe
- https://github.com/juchem/tic-tac-toe
- https://svgedit.netlify.app/editor/index.html
- https://www.nhooo.com/note/qa09md.html