功能完备的井字棋——基于css3和vue

我在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);
  • 6
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值