我在csdn上浏览过几个井字棋的代码,有的照抄 借鉴太多已有的react版本的井字棋项目,有的代码冗余多、耦合度高,总体质量也不算高。相信我这个版本的代码质量能胜过他们的QAQ
效果demo
被csdn搞🤮了,图片只能传5mb,先拿这个阉割的凑合看看吧……目前我没发现有bug,如果有神犇发现了可以评论区告诉我qwq
这个井字棋项目的功能基本上都在“游戏说明”里了。
- 可以输入你喜欢的双方标识,左侧为先手~
- 人机模式下不允许查看轮到AI落子的那些棋局~
由于作者水平有限,目前仅支持电脑先手~- 提供了“历史棋局”功能~
对象名为什么叫g1?可以看到我有两个对象g1和g2,g2全程没有使用。把它放着,意在展示可以通过复制html代码+修改变量名为g2,从而复制一个子游戏。
一些细节问题
- 游戏的各个阶段的表示
用的是ES6的Symbol,在这里它的作用类似于c语言的enum,好处主要是方便,且能提高可读性。
不过下面的gameEnd()就没有用Symbol,直接用数字标识,因为我懒得起名字……
const NOTSTARTED = Symbol(),SELFGAME = Symbol(),AIGAME = Symbol(),ENDING_ANIME = Symbol();
求当下是否处于游戏阶段:
inGameProcess(){return this.state === SELFGAME || this.state === AIGAME;}
- 电脑的决策
井字棋游戏在双方都是最优决策的情况下,是必然平局的。但是电脑的最优决策并不容易写出,这也是为什么我只做了电脑先手。电脑的决策不是我自己想的,参考的代码:决策
大致过程就是特判电脑的第1步和第2步,后续过程另外考虑。可以直接看代码,注释很详细了。我的主要工作就是把上面链接的电脑决策代码给简化了一些。
- 判定游戏结束
之前的方案
gameEnd(){
//行
for(let i = 0;i < 3;++i){
let c = [0,0];
for(let j = 0;j < 3;++j){
if(this.currentBoard[ID(i,j)] > 0) c[this.currentBoard[ID(i,j)]-1]++;
}
if(c[0] === 3) return 1;
if(c[1] === 3) return 2;
}
//列
for(let j = 0;j < 3;++j){
let c = [0,0];
for(let i = 0;i < 3;++i){
if(this.currentBoard[ID(i,j)] > 0) c[this.currentBoard[ID(i,j)]-1]++;
}
if(c[0] === 3) return 1;
if(c[1] === 3) return 2;
}
let c;
//对角线
c = [0,0];
for(let i = 0;i < 3;++i) if(this.currentBoard[ID(i,i)] > 0) ++c[this.currentBoard[ID(i,i)]-1];
if(c[0] === 3) return 1;
if(c[1] === 3) return 2;
//反对角线
c = [0,0];
for(let i = 0;i < 3;++i) if(this.currentBoard[ID(i,2-i)] > 0) ++c[this.currentBoard[ID(i,2-i)]-1];
if(c[0] === 3) return 1;
if(c[1] === 3) return 2;
//无赢家的前提下再看棋盘是否已满
if(this.currentBoard.indexOf(0) === -1) return 3;
return 0;
}
现在新增一个需求,就是对形成3连子的格子染色突出。我们发现如果加上满足新需求的代码,耦合度将会太高。
因此我们改成用二维数组allPossible来存每种可能的胜利局面。这样修改颜色的耦合代码就只需要加到一处了。
gameEnd(){
const allPossible = [
[0,1,2],[3,4,5],[6,7,8],
[0,3,6],[1,4,7],[2,5,8],
[0,4,8],[2,4,6]
];
for(let cur of allPossible){
let c = [0,0];
for(let idx of cur){
if(this.currentBoard[idx] > 0) ++c[this.currentBoard[idx]-1];
}
if(c[0] === 3 || c[1] === 3){
for(let idx of cur) this.boardColor.splice(idx,1,1);
}
if(c[0] === 3) return 1;
if(c[1] === 3) return 2;
}
//无赢家的前提下再看棋盘是否已满
if(this.currentBoard.indexOf(0) === -1) return 3;
return 0;
}
代码
html
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
<title>井字棋</title>
<link rel="stylesheet" type="text/css" href = "./tic.css" />
<script src="https://cdn.staticfile.org/jquery/1.10.2/jquery.min.js"></script>
<script src="https://cdn.staticfile.org/vue/2.5.2/vue.min.js"></script>
</head>
<body>
<h1 class="title">TicTacToe</h1>
<div id="game">
<div class="subgame">
<h1>
<input v-model="g1.fn" class="firstName" />
<span>VS</span>
<input v-model="g1.sn" class="secondName" />
</h1>
<div class="body">
<div class="board">
<div v-for="(val,idx) in g1.currentBoard" @click="g1.putPiece(idx)" v-bind:style="{
cursor: g1.inGameProcess() && val === 0 ? 'pointer' : 'auto',
'background-color': g1.boardColor[idx] ? '#877f6c' : 'white',
}" class="cell">
{{ g1.boardText(val) }}
</div>
</div>
<div class="menu">
<button @click="help_show = true;">游戏说明</button>
<template v-if="g1.state === NOTSTARTED">
<button @click="g1.startGame(SELFGAME)">自己玩</button>
<button @click="g1.startGame(AIGAME)">人机</button>
</template>
<template v-else>
<button @click="g1.returnToMenu()"
v-bind:style="{cursor: g1.inGameProcess() ? 'pointer' : 'not-allowed'}"
:disabled="!g1.inGameProcess()">
返回
</button>
<p>
请棋手
<span class="turn-info">{{ g1.boardText(g1.getCurTurn()) }}</span>
落子
</p>
<p>第 <span class="turn-info">{{ g1.step }}</span> 步</p>
</template>
</div>
</div>
<!-- 展示历史棋盘局面 -->
<div class="history">
<!-- 人机模式下不允许查看轮到AI落子的那些棋局 -->
<div v-for="(board,idx1) in g1.boards"
@click="g1.showHistoryBoard(board,idx1)"
v-bind:style="{
display: g1.state !== NOTSTARTED ? 'grid' : 'none',
cursor: g1.canClickHistoryBoard(idx1) ? 'pointer' : 'not-allowed'
}"
class="history-board">
<!-- 这里复用了.cell样式,并重新设置字体大小 -->
<div v-for="(val,idx2) in board" class="cell history-cell">
{{ g1.boardText(val) }}
</div>
</div>
</div>
<!-- 展示赢家 -->
<transition name="winner">
<div class="winner" v-if="g1.winnerShow">{{ g1.winnerText }}</div>
</transition>
<!-- 用scale(1)控制弹出框出现 -->
<div class="help-container" v-bind:style="{transform: help_show ? 'scale(1)' : 'scale(0)'}">
<div class="help-head">
<span class="help-title">游戏说明</span>
<span class="close" @click="help_show = false;">X</span>
</div>
<p>作者:hans774882968</p>
<p>可以输入你喜欢的双方标识,左侧为先手~</p>
<p>人机模式下不允许查看轮到AI落子的那些棋局~</p>
<p>由于作者水平有限,目前仅支持电脑先手~</p>
<p>提供了“历史棋局”功能~</p>
<p>我怎么这么菜,哭哭~</p>
</div>
</div>
</div>
<script src="./tic.js"></script>
</body>
</html>
css
body{
margin: 0;
}
a{
text-decoration: none;
}
input{
outline: none;
}
:root{
--gap: 2px;
--cell-size: 120px;
--board-size: calc(3 * var(--cell-size) + (3 - 1) * var(--gap));
--history-gap: 1px;
--history-csz: 40px;
--history-bsz: calc(3 * var(--history-csz) + (3 - 1) * var(--history-gap));
}
.title{
color: #cb4042;
text-align: center;
}
#game{
display: flex;
justify-content: space-evenly;
}
.subgame{
position: relative;
}
.subgame>h1{
margin-bottom: 80px;
display: flex;
justify-content: center;
color: #777;
}
.subgame>h1 input{
width: 85px;
height: 42.5px;
padding-left: 15px;
font-size: 20px;
color: #777;
border-radius: 10px;
border: 1px solid #bbb;
}
.subgame>h1 span{
margin: 0 10px;
}
.body{
display: flex;
}
.board{
display: grid;
grid-template-rows: repeat(3,var(--cell-size));
grid-template-columns: repeat(3,var(--cell-size));
grid-gap: var(--gap);
padding: var(--gap);
width: var(--board-size);
height: var(--board-size);
background-color: #cb4042;
}
.cell{
background-color: white;
color: #cb4042;
font-size: 35px;
display: grid;
place-items:center;/* 居于中心 */
user-select: none;
overflow: hidden;
}
.menu{
margin-left: 70px;
width: 200px;
display: flex;
flex-direction: column;
align-items: center;
}
.menu p{
font-size: 20px;
}
.menu .turn-info{
--info-size: 40px;
display: inline-block;/* 保持在同一行 */
border-radius: 10px;
padding: 10px;
width: var(--info-size);
height: var(--info-size);
line-height: var(--info-size);
text-align: center;
background-color: #eaf5e9;
color: #026e00;
}
.menu button{
margin-top: 20px;
width: 110px;
height: 48px;
cursor: pointer;
font-size: 18px;
background-color: #f39c12;
color: #fff;
border: 1px solid;
border-radius: 10px;
}
.menu button:active{
background-color: #e58e26;
}
/* 历史棋盘局面:由子元素撑大 */
.history{
display: grid;
grid-template-columns: repeat(4,calc(100% / 4));
place-items: center;
}
.history .history-board{
margin-top: 30px;
display: grid;
grid-template-rows: repeat(3,var(--history-csz));
grid-template-columns: repeat(3,var(--history-csz));
grid-gap: var(--history-gap);
padding: var(--history-gap);
width: var(--history-bsz);
height: var(--history-bsz);
background-color: #cb4042;
cursor: pointer;
}
.history .history-cell{
font-size: 16px;
overflow: hidden;
}
/* 展示赢家 */
.winner{
position: absolute;
top: 100px;
width: 100%;
height: 160px;
text-align: center;
user-select: none;
font-size: 6em;
color: red;
}
.winner-enter-active{
transition: opacity .8s;
}
.winner-enter,.winner-leave-to{
opacity: 0;
}
/* 游戏帮助 */
.help-container{
position: absolute;
top: 30%;
left: 15%;
z-index: 60000;
transition: .4s;
transform: scale(0);/* 用scale(1)控制元素出现 */
width: calc(100% - 2 * 15%);
box-sizing: border-box;
border: 1px solid #999;
border-radius: 10px;
background-color: #fff;
padding: 30px;
}
.help-container .help-head{
display: flex;
justify-content: space-between;
padding-bottom: 16px;
border-bottom: 1px solid #999;
}
.help-head span{
font-size: 24px;
font-weight: bold;
}
.help-container .help-title{
color: #cb4042;
}
.help-container .close{
color: #777;
cursor: pointer;
}
js
"use strict";
const ID = (x,y) => x*3+y;
const NOTSTARTED = Symbol(),SELFGAME = Symbol(),AIGAME = Symbol(),ENDING_ANIME = Symbol();
//输入棋盘局面,输出决策下标
/*
注:棋盘的3种位置我们分别称为:正中、角落、非角落。
电脑策略(注:已经是最优策略,即玩家不可能赢电脑):
一、开始下棋。rand即可。
二、第2步(详见decideStep2(board))。分4种情况:
1-电脑下正中且玩家下角落。
2-电脑下正中且玩家下非角落。
3-电脑下角落且玩家没下正中。
4-电脑下角落且玩家下正中。
三、后续。
这里需要一个函数:checkWin(who,board)表示查看哪个角色的赢的位置,board就是棋盘。返回一个数组,表示所有who可以赢的位置。
1-判定电脑能赢,就下那个位置(取0号即可)。
2-判定玩家能赢,就下那个位置(取0号即可)。
3-双方都不能赢,就枚举每个空位置,设置它,然后查看能赢的局面最多,就贪心地选那个位置。
*/
function checkWin(who,board){
let ret = [];
const allPossible = [
[0,1,2],[3,4,5],[6,7,8],
[0,3,6],[1,4,7],[2,5,8],
[0,4,8],[2,4,6]
];
for(let cur of allPossible){
let has = cur.reduce((tot,v) => tot + (board[v] === who),0);
if(has !== 2) continue;
for(let i = 0;i < 3;++i){
if(board[cur[i]] === 0){
ret.push(cur[i]);break;
}
}
}
return ret;
}
//电脑第二步棋的特判。
function decideStep2(board){
//电脑第一步下正中
const cor = [0,8,2,6];//角落坐标
if(board[4] === 1){
//如果玩家下角落,则电脑下玩家的对角
for(let i = 0;i < 4;++i) if(board[cor[i]] === 2) return cor[i^1];
//如果玩家下非角落,就会有两个角靠着它,在这两个角中随机选一个
let opts = [0,0];
if(board[1] === 2) opts = [0,2];
else if(board[3] === 2) opts = [0,6];
else if(board[5] === 2) opts = [2,8];
else if(board[7] === 2) opts = [6,8];
return opts[parseInt(Math.random()*2)];
}
//电脑第一步下角落
if(board[4] === 0) return 4;//如果玩家没下正中,则电脑下正中
//如果玩家下正中,则下电脑第一步的对角
for(let i = 0;i < 4;++i) if(board[cor[i]] === 1) return cor[i^1];
//备用策略
for(let i = 0;i < 9;++i) if(!board[i]) return i;
return 0;
}
function AIDecision(board){
let step = board.reduce((tot,v) => tot + (v > 0),0);
if(step === 0){
return [0,2,4,6,8][parseInt(Math.random()*5)];
}
else if(step === 2){
return decideStep2(board);
}
//电脑能赢
let aiPos = checkWin(1,board);
if(aiPos.length > 0){
return aiPos[0];
}
//防止玩家赢
let playerPos = checkWin(2,board);
if(playerPos.length > 0){
return playerPos[0];
}
//备用策略
for(let i = 0;i < 9;++i) if(!board[i]) return i;
return 0;
}
class Game{
constructor(){
this.setDefault();
}
setDefault(){
this.state = NOTSTARTED;
this.firstName = "×";
this.secondName = "0";
this.currentBoard = [0,0,0,0,0,0,0,0,0];
this.boards = [[0,0,0,0,0,0,0,0,0]];
this.boardColor = [0,0,0,0,0,0,0,0,0];//0=白色,1=获胜颜色
this.step = 1;//在此定义下,this.step === this.boards.length
//展示赢家
this.winnerText = "";
this.winnerShow = false;
//双方标志(用于绑定input标签)
this.fn = "";
this.sn = "";
}
//mode:自己玩or人机
startGame(mode){
if(this.state !== NOTSTARTED) return;
let fn = this.fn,sn = this.sn;
if((fn === "" && sn !== "") || (fn !== "" && sn === "")){
alert("双方标志要么都指定,要么都不指定!");
return;
}
if(fn !== "" && fn === sn){
alert("双方标志不能相同!");
return;
}
if(fn.length > 2){
alert("先手方标志长度不能超过2!");
return;
}
if(sn.length > 2){
alert("后手方标志长度不能超过2!");
return;
}
if(fn !== "") this.firstName = fn;
if(sn !== "") this.secondName = sn;
this.state = mode;
if(this.state === AIGAME) this.AIPutPiece();
}
boardText(val){
if(typeof val !== "number" || val === 0) return "";
return val === 1 ? this.firstName : this.secondName;
}
//当前轮到谁落子
getCurTurn(){return 1 + (this.step + 1) % 2;}
inGameProcess(){return this.state === SELFGAME || this.state === AIGAME;}
updateBoard(where,who){
this.currentBoard.splice(where,1,who);
this.boards.splice(this.step,this.boards.length - this.step,this.currentBoard.concat());//历史棋盘局面
this.step++;
}
//玩家落子
putPiece(idx){
if(this.currentBoard[idx]) return;
if(!this.inGameProcess()) return;
this.updateBoard(idx,this.getCurTurn());
let winner = this.gameEnd();
if(winner) this.endWork(winner);
else if(this.state === AIGAME) this.AIPutPiece();
}
//AI落子
AIPutPiece(){
let decision = AIDecision(this.currentBoard);
this.updateBoard(decision,1);
let winner = this.gameEnd();
if(winner) this.endWork(winner);
}
//0:未结束。3:平局
gameEnd(){
const allPossible = [
[0,1,2],[3,4,5],[6,7,8],
[0,3,6],[1,4,7],[2,5,8],
[0,4,8],[2,4,6]
];
for(let cur of allPossible){
let c = [0,0];
for(let idx of cur){
if(this.currentBoard[idx] > 0) ++c[this.currentBoard[idx]-1];
}
if(c[0] === 3 || c[1] === 3){
for(let idx of cur) this.boardColor.splice(idx,1,1);
}
if(c[0] === 3) return 1;
if(c[1] === 3) return 2;
}
//无赢家的前提下再看棋盘是否已满
if(this.currentBoard.indexOf(0) === -1) return 3;
return 0;
}
gameWinnerText(winner){
if(this.state === SELFGAME) return ["先手赢","后手赢","平局"][winner-1];
return ["AI赢","玩家赢","平局"][winner-1];
}
endWork(winner){
return new Promise((resolve) => {
this.winnerText = this.gameWinnerText(winner);
this.state = ENDING_ANIME;//文本设置好后,立刻设置,防止响应事件
this.winnerShow = true;
setTimeout(() => resolve(),800);
}).then(() => new Promise((resolve) => {
setTimeout(() => {
this.winnerShow = false;
resolve();
},2000);
})).then(() => {
this.setDefault();
});
}
//返回开始界面
returnToMenu(){
if(!this.inGameProcess()) return;
this.setDefault();
}
canClickHistoryBoard(idx){
if(!this.inGameProcess()) return false;
//人机模式下不允许查看轮到AI落子的那些棋局
if(this.state === AIGAME && idx % 2 === 0) return false;
return true;
}
showHistoryBoard(board,idx){
if(!this.canClickHistoryBoard(idx)) return;
this.currentBoard = board.concat();
this.step = idx + 1;
}
};
let g1 = new Game(),g2 = new Game();
function main(){
let vm = new Vue({
el: "#game",
data: {
"g1": g1,
"g2": g2,
"NOTSTARTED": NOTSTARTED,
"SELFGAME": SELFGAME,
"AIGAME": AIGAME,
"ENDING_ANIME": ENDING_ANIME,
"help_show": false,
}
});
}
$(document).ready(main);