文章目录
- 前言
- 棋盘与棋子
- 棋盘
- 棋子
- 棋盘与棋子的交互
- 主要的类文件
- 棋子类
- 棋盘类
- 基本方法
- 两个重要的点击事件
- 两个重要的特殊行为
- 其它细节
- 结语
前言
本人从小就非常喜欢下中国象棋,学习编程后就一直想自己做一个中国象棋的前端游戏,现在终于有“机会”了。
这是第一版的中国象棋,由h5+css3+原生js所实现(非canvas)。
这个版本主要实现的功能包括:棋子的鼠标交互功能,每种棋子的落子规则,将军提示和游戏结束判断,悔棋功能,各种音效等等
注:由于总代码量比较大,所以完整代码我放在github上,大家可以自己去阅读,这里我只挑选核心功能的代码进行讲解。源代码地址:完整代码 游戏的界面如下图:
棋盘与棋子
棋盘
棋盘的实现逻辑比较简单,我这里是通过background-image实现的,相关的核心代码如下:
<div class="board">
<div class="board-inner">
</div>
</div>
.board-inner {
width: 34rem;
height: 38rem;
margin: 0 auto;
position: relative;
background-image: url("../images/board.jpg");
background-size: 100%;
}
棋子
棋子的实现逻辑也不难,每颗棋子我是通过css手动绘制的,css代码如下:
.chess{
position: absolute;
cursor: move;
width: 2.2rem;
height: 2.2rem;
line-height: 2.2rem;
text-align: center;
border-radius: 50%;
background-color: wheat;
font-family: 隶书;
font-weight: 600;
font-size: x-large;
user-select: none;
transition: margin 0.5s ease;
}
.red{
color: red;
}
.black{
color: #000;
}
棋盘与棋子的交互
这部分功能的实现逻辑相对而言是很麻烦,因为每颗棋子都是通过绝对定位定到棋盘上,但是其基本思想是很简单的,通过margin-top以及margin-left来确定二维数组的下标。
定位的核心代码如下:(创建棋子)
createBoardDom() {
for (let i = 0; i < this.board.length; i++) {
const ids = [];
for (let j = 0; j < this.board[0].length; j++) {
const chess = this.board[i][j];
if (chess === role.empty) {
ids.push(0);
continue;
}
const chessDom = document.createElement('div');
chessDom.classList.add('chess');
chessDom.classList.add(chess.type);
chessDom.innerText = chess.text;
chessDom.setAttribute('id', this.id);
chessDom.style.marginTop = `${1 + (11.49 * i)}%`;
chessDom.style.marginLeft = `${0.5 + (11.56 * j)}%`;
this.boardDom.appendChild(chessDom);
ids.push(this.id);
this.id++;
}
this.idBoard.push(ids);
}
this.historyIds.push(this.deepClone(this.idBoard));
}
主要的类文件
这个版本主要的js类文件包括board.js(最核心的棋盘类,里面包含棋盘的各种逻辑代码),各种棋子类(车马炮士相兵帅,每个棋子类里面包含了该类别棋子的基本属性以及该类别棋子的操作规则),其它的配置类(比如role.js,text.js,setting.js)。
棋子类
这里我以炮为例子进行讲解,其它种类的棋子,大家可以自己看源码。
import role from './role.js';
export default class Gun {
constructor(text, type) {
this.text = text;
this.type = type; // red ,black
}
findRowPositions(rowIndex, colIndex, positions, board) {
let rowLeft = rowIndex - 1;
let rowRight = rowIndex + 1;
// 移动
while (rowLeft >= 0 && board[rowLeft][colIndex] === role.empty) {
positions.push([rowLeft, colIndex]);
rowLeft--;
}
while (rowRight < board.length && board[rowRight][colIndex] === role.empty) {
positions.push([rowRight, colIndex]);
rowRight++;
}
// 吃棋
if (rowLeft >= 0 && board[rowLeft][colIndex] !== role.empty) {
rowLeft--;
while (rowLeft >= 0 && board[rowLeft][colIndex] === role.empty) {
rowLeft--;
}
if (rowLeft >= 0 && board[rowLeft][colIndex].type !== this.type) {
positions.push([rowLeft, colIndex])
}
}
if (rowRight < board.length && board[rowRight][colIndex] !== role.empty) {
rowRight++;
while (rowRight < board.length && board[rowRight][colIndex] === role.empty) {
rowRight++;
}
if (rowRight < board.length && board[rowRight][colIndex].type !== this.type) {
positions.push([rowRight, colIndex])
}
}
}
findColPositions(rowIndex, colIndex, positions, board) {
let colLeft = colIndex - 1;
let colRight = colIndex + 1;
// 移动
while (colLeft >= 0 && board[rowIndex][colLeft] === role.empty) {
positions.push([rowIndex, colLeft]);
colLeft--;
}
while (colRight < board[rowIndex].length && board[rowIndex][colRight] === role.empty) {
positions.push([rowIndex, colRight]);
colRight++;
}
// 吃棋
if (colLeft >= 0 && board[rowIndex][colLeft] !== role.empty) {
colLeft--;
while (colLeft >= 0 && board[rowIndex][colLeft] === role.empty) {
colLeft--;
}
if (colLeft >= 0 && board[rowIndex][colLeft].type !== this.type) {
positions.push([rowIndex, colLeft])
}
}
if (colRight < board[rowIndex].length && board[rowIndex][colRight] !== role.empty) {
colRight++;
while (colRight < board[rowIndex].length && board[rowIndex][colRight] === role.empty) {
colRight++;
}
if (colRight < board[rowIndex].length && board[rowIndex][colRight].type !== this.type) {
positions.push([rowIndex, colRight])
}
}
}
killRule(position, board) {
const positions = [];
const rowIndex = position[0];
const colIndex = position[1];
let rowLeft = rowIndex - 1;
let rowRight = rowIndex + 1;
// 移动
while (rowLeft >= 0 && board[rowLeft][colIndex] === role.empty) {
rowLeft--;
}
rowLeft--;
while (rowRight < board.length && board[rowRight][colIndex] === role.empty) {
rowRight++;
}
rowRight++;
while (rowLeft >= 0) {
positions.push([rowLeft, colIndex]);
rowLeft--;
}
while (rowRight < board.length) {
positions.push([rowRight, colIndex]);
rowRight++;
}
let colLeft = colIndex - 1;
let colRight = colIndex + 1;
// 移动
while (colLeft >= 0 && board[rowIndex][colLeft] === role.empty) {
colLeft--;
}
colLeft--;
while (colRight < board[rowIndex].length && board[rowIndex][colRight] === role.empty) {
colRight++;
}
colRight++;
while (colLeft >= 0) {
positions.push([rowIndex, colLeft]);
colLeft--;
}
while (colRight < board[0].length) {
positions.push([rowIndex, colRight]);
colRight++;
}
return positions;
}
getDisturb(position, targetPosition, board) {
const positions = [];
let rowIndex = position[0];
let colIndex = position[1];
if (position[1] === targetPosition[1]) {
if (position[0] > targetPosition[0]) {
rowIndex--;
while (board[rowIndex][colIndex] === role.empty) {
rowIndex--;
}
} else {
rowIndex++;
while (board[rowIndex][colIndex] === role.empty) {
rowIndex++;
}
}
} else {
if (position[1] > targetPosition[1]) {
colIndex--;
while (board[rowIndex][colIndex] === role.empty) {
colIndex--;
}
} else {
colIndex++;
while (board[rowIndex][colIndex] === role.empty) {
colIndex++;
}
}
}
if (rowIndex === position[0]) {
if (colIndex < position[1]) {
for (let j = colIndex + 1; j < position[1]; j++) {
positions.push([rowIndex, j]);
}
} else {
for (let j = position[1] + 1; j < colIndex; j++) {
positions.push([rowIndex, j]);
}
}
} else {
if (rowIndex < position[0]) {
for (let i = rowIndex + 1; i < position[0]; i++) {
positions.push([i, colIndex]);
}
} else {
for (let i = position[0] + 1; i < rowIndex; i++) {
positions.push([i, colIndex]);
}
}
}
if (board[rowIndex][colIndex].type === board[position[0]][position[1]].type &&
board[rowIndex][colIndex].text === board[position[0]][position[1]].text) {
} else {
if (rowIndex === targetPosition[0]) {
if (colIndex < targetPosition[1]) {
for (let j = colIndex + 1; j < targetPosition[1]; j++) {
positions.push([rowIndex, j]);
}
} else {
for (let j = targetPosition[1] + 1; j < colIndex; j++) {
positions.push([rowIndex, j]);
}
}
} else {
if (rowIndex < targetPosition[0]) {
for (let i = rowIndex + 1; i < targetPosition[0]; i++) {
positions.push([i, colIndex]);
}
} else {
for (let i = targetPosition[0] + 1; i < rowIndex; i++) {
positions.push([i, colIndex]);
}
}
}
}
return positions;
}
rule(position, board) {
const positions = [];
const rowIndex = position[0];
const colIndex = position[1];
this.findRowPositions(rowIndex, colIndex, positions, board);
this.findColPositions(rowIndex, colIndex, positions, board);
return positions;
}
}
这个类中有两个属性,分别是type和text,它的作用分别是用于区分红黑方和用于区分红黑方的显示文本。
这个类中有三个主要函数,分别是rule,killRule和getDisturb。rule的参数分别是当前棋子的位置以及当前棋盘的状态,返回当前棋子可以落子的所有位置。killRule和getDisturb两函数是为判断是否为炮绝杀所服务。
棋盘类
基本方法
board类有两个基本的函数,分别是init和destroy。
init里面初始化该类需要的一些属性和方法:
init() {
this.id = 1;
this.step = 1;
this.readyPlay = false;
this.isBackward = true;
this.idBoard = [];
this.startPosition = [];
this.endPosition = [];
this.positions = [];
this.downPositions = [];
this.historyRecord = [];
this.historyIds = [];
this.boardDom = document.getElementsByClassName('board-inner')[0];
this.domChesses = document.getElementsByClassName('chess');
this.chessUpSound = document.getElementById('chessUp');
this.chessDownSound = document.getElementById('chessDown');
this.warnGeneralSound = document.getElementById('warnGeneral');
this.surrender = document.getElementById('surrender');
this.backward = document.getElementById('backward');
this.createChess();
this.initBoard();
this.createBoardDom();
this.initEvents();
}
destroy重置棋盘(这里的清除操作可能不够规范):
destroy() {
this.boardDom.removeEventListener('click', this.clickHHandler);
this.boardDom.innerHTML = '';
this.surrender.removeEventListener('click', this.surrenderHandler);
this.backward.removeEventListener('click', this.backwardHandler);
}
两个重要的点击事件
除了init和destroy两个基本方法外,还有两个重要的点击事件处理函数,分别是悔棋函数和棋盘点击函数。
悔棋的逻辑比较简单,就直接上代码:
backwardHandler = () => {
if (!this.isBackward) {
alert('提子无悔!');
return;
}
if (this.historyIds.length === 1) {
return;
}
const differences = this.getBackward(this.historyRecord[this.historyRecord.length - 2], this.historyRecord[this.historyRecord.length - 1]);
const start = differences[0];
const end = differences[1];
//更新棋盘
this.historyRecord.pop();
this.board[start[0]][start[1]] = start[2];
this.board[end[0]][end[1]] = end[2];
this.historyIds.pop();
const arr = this.historyIds[this.historyIds.length - 1];
this.idBoard[start[0]][start[1]] = arr[start[0]][start[1]];
this.idBoard[end[0]][end[1]] = arr[end[0]][end[1]];
//更新dom
this.renderBoard([start[0], start[1]]);
if (end[2] !== role.empty) {
this.recoverBoardDom([end[0], end[1]]);
}
this.clearBoard();
this.step--;
}
注:这里如果用一个全局index记录当前history的下标,那么就可以实现forward功能。
相比而言,棋盘的点击事件就更复杂一些,先上代码:
clickHandler = (e) => {
// 点击棋子
if (e.target != this.boardDom) {
const playerPosition = this.getChessPosition(e.target.id);
if (this.invalidPlayer(playerPosition)) {
return;
}
const flag = this.canDown(playerPosition);
// 已经选中了一个棋子并即将落子
if (this.readyPlay && flag) {
this.clearBoard();
// 需要判断是否是能落子的位置 如果不能落子则清空选择
this.endPosition = playerPosition;
this.changeChessPosition();
this.readyPlay = false;
this.chessDownSound.play();
if (this.gameOver()) {
setTimeout(() => {
alert(`游戏结束!${this.step % 2 === 1 ? '红' : '黑'}方胜`);
this.destroy();
}, 500);
return;
}
this.step++;
this.historyRecord.push(this.deepClone(this.board));
this.historyIds.push(this.deepClone(this.idBoard));
this.isBackward = true;
this.specialHandler(playerPosition);
} else {
// 更换选中的棋子
this.chessUpSound.play();
if (this.readyPlay && !flag) {
this.clearBoard();
}
// 如果是不同类
if (!this.isSimilar(playerPosition)) {
return;
}
// 如果是同类
this.startPosition = playerPosition;
this.setBorder();
this.readyPlay = true;
this.isBackward = false;
// 获取可以落子的位置
this.showCanDown(playerPosition);
}
} else {
// 点击棋盘
if (this.readyPlay) {
this.clearBoard();
const playerPosition = this.getEmptyPosition(e.offsetX, e.offsetY);
const flag = this.canDown(playerPosition);
this.readyPlay = false;
if (!flag) {
return;
}
this.endPosition = playerPosition;
this.changeChessPosition();
this.step++;
this.historyRecord.push(this.deepClone(this.board));
this.historyIds.push(this.deepClone(this.idBoard));
this.chessDownSound.play();
this.isBackward = true;
this.specialHandler(playerPosition);
}
}
}
这个函数将棋盘的点击行为分成了三种情况进行处理:选择空位置,选中了一个棋子并即将落子,更换选中的棋子。然后根据不同的情况执行不同的功能逻辑,包括的逻辑有落子音效、更新棋盘状态更新dom、更新历史棋盘库、设置棋子的选中特效、清除之前已设置特效的dom以及一些特殊行为的检验(如将军、绝杀、游戏结束)等等。
两个重要的特殊行为
这里有两个重要的特殊行为的函数,分别是将军和绝杀。
将军的实现逻辑比较简单,就是判断当前玩家落子后,当前玩家的所有棋子是否会在下一步能够触及到对方玩家将军的位置。这里可能会有朋友有疑问,为啥不是直接判断当前这颗棋子是否会在下一步触及到对方玩家将军的位置,如此一来效果也更高。因为这种判断方式会有漏洞,就比如执行跳马后的马后炮将军。
判断将军的代码:
warnGeneral(position) {
const play = this.board[position[0]][position[1]];
// 不止一个点,可能有双将军,士相帅可以过滤掉
const positions = [];
const allPositions = [];
const generalPosition = this.getGeneralPosition(play.type);
for (let i = 0; i < this.board.length; i++) {
for (let j = 0; j < this.board[0].length; j++) {
const targetPlay = this.board[i][j];
if (play.type === role.red) {
if (targetPlay.type !== role.red) {
continue;
}
if (targetPlay.text === text.redOfficial || targetPlay.text === text.redPhase || targetPlay.text === text.redGeneral) {
continue;
}
} else {
if (targetPlay.type !== role.black) {
continue;
}
if (targetPlay.text === text.blackOfficial || targetPlay.text === text.blackPhase || targetPlay.text === text.blackGeneral) {
continue;
}
}
allPositions.push([i, j]);
const willPositions = targetPlay.rule([i, j], this.board);
if (this.includes(generalPosition, willPositions)) {
positions.push([i, j]);
}
}
}
return [positions, allPositions];
}
这里的返回值是准备为判断绝杀所服务,因为绝杀的前提一定得先满足将军。
相比于将军,绝杀的逻辑就显得复杂很多。
这里我给出我验证绝杀的判断逻辑:首先确定将军当前能移动的范围(不包括当前位置),然后判断此范围是否有安全位置,如果没有安全位置,进一步判断当前是否有己方棋子能够干掉造成将军的棋子(只有一枚棋子造成将军),或者是当前是否有己方棋子能够干扰造成将军的所有棋子(且不能形成新的将军,比如双炮将军),如果都不能满足,那么就是绝杀。
absoluteKill(position, allPositions) {
// 确定将军当前能移动的范围
const play = this.board[position[0]][position[1]];
const generalPosition = this.getGeneralPosition(play.type);
const generalMoves = this.board[generalPosition[0]][generalPosition[1]].rule(generalPosition, this.board);
// 判断此范围是否有安全位置
for (let i = 0; i < this.board.length; i++) {
for (let j = 0; j < this.board[0].length; j++) {
const chess = this.board[i][j];
if (chess === this.empty || chess.type !== this.board[position[0]][position[1]].type) {
continue;
}
const positions = (chess.text === text.redCar || chess.text === text.redGun || chess.text === text.blackCar || chess.text === text.blackGun)
? chess.killRule([i, j], this.board)
: chess.rule([i, j], this.board);
for (let k = 0; k < generalMoves.length; k++) {
if (this.includes(generalMoves[k], positions)) {
generalMoves.splice(k, 1);
k--;
}
}
}
}
if (generalMoves.length > 0) {
return false;
}
// 判断是否能够干掉造成将军的棋子
if (allPositions.length === 1) {
const target = allPositions[0];
for (let i = 0; i < this.board.length; i++) {
for (let j = 0; j < this.board[0].length; j++) {
const chess = this.board[i][j];
if (chess === this.empty || chess.type === this.board[target[0]][target[1]].type) {
continue;
}
const positions = chess.rule([i, j], this.board);
if (this.includes(target, positions)) {
return false;
}
}
}
}
// 找到干扰造成将军的所有棋子的位置(仅限于车马炮)
const disturbPositions = [];
for (let i = 0; i < allPositions.length; i++) {
const chessPosition = allPositions[i];
const chess = this.board[chessPosition[0]][chessPosition[1]];
if (chess.text === text.blackCar || chess.text === text.redCar) {
const positions = chess.getDisturb([chessPosition[0], chessPosition[1]], generalPosition);
disturbPositions.push([...positions]);
} else if (chess.text === text.blackGun || chess.text === text.redGun) {
const positions = chess.getDisturb([chessPosition[0], chessPosition[1]], generalPosition, this.board);
disturbPositions.push([...positions]);
} else if (chess.text === text.blackHorse || chess.text === text.redHorse) {
const positions = chess.getDisturb([chessPosition[0], chessPosition[1]], this.board);
disturbPositions.push([...positions]);
}
}
let targetPositions = [];
let doubleKill = false;
for (let i = 0; i < disturbPositions.length; i++) {
let flag = false;
const positions = disturbPositions[i];
if (positions.length === 0) {
return true;
}
for (let j = 0; j < positions.length; j++) {
const targetPosition = positions[j];
if (this.includes(targetPosition, targetPositions)) {
doubleKill = true;
flag = true;
targetPositions = [];
break;
} else {
targetPositions.push([...targetPosition]);
}
}
if (flag) {
break;
}
}
if (doubleKill) {
const tmpPositions = [];
for (let i = 0; i < disturbPositions.length; i++) {
const positions = disturbPositions[i];
for (let j = 0; j < positions.length; j++) {
const targetPosition = positions[j];
if (this.includes(targetPosition, tmpPositions)) {
targetPositions.push([...targetPosition]);
} else {
tmpPositions.push([...targetPosition]);
}
}
}
}
// 判断是否能到达这些位置
for (let i = 0; i < this.board.length; i++) {
for (let j = 0; j < this.board[0].length; j++) {
const chess = this.board[i][j];
if (chess === this.empty || chess.type === this.board[position[0]][position[1]].type) {
continue;
}
const positions = chess.rule([i, j], this.board);
for (let k = 0; k < targetPositions.length; k++) {
if (this.includes(targetPositions[k], positions)) {
return false;
}
}
}
}
return true;
}
其它细节
细心的伙伴不难发现,如果在给创建的棋子dom添加一个css属性pointer-events: none;
那么在点击棋盘的时候就不需要单独去位置一组id数组从而确定被点击棋子的位置。
事实上也确实如此,但是一旦使用的这个属性,那么也就很难甚至无法给棋子赋予一些鼠标特效,比如cursor: move;
(如果有大佬有更好的解决方案,欢迎评论区留言)
结语
这是中国象棋第一版的主要内容,具体还有很多细节我在这里就不一一叙述,大家可以在源码中查看。后续第二版会以ai为主而进行扩展,再后面我可能还会将此项目改造成vue以及react形式的项目,毕竟使用框架可以更方便的添加一些配置功能。