游戏:Tic Tac Toe

最近在刷FCC的前端课程。其中的一个小任务是写个Tic Tac Toe也就是常说的三连棋游戏。这其实是个简单的单页应用,想来用最爱的Clojure(Script)语言来实现必定十分容易,下面就简述下开发过程。等不及的同学可直接看源码。最终实现的游戏在文章末尾,你可以先体验一把再来看实现过程。

确定目标

Reagent loop

参考此页,我们的目标是:

  • 初次进去用户可以选择作为X还是O玩。选定后
  • 进入棋盘页面,等待用户落子
  • 用户落子后,电脑再落子,如此往复

下面几点作为细节,我会放在实现主要功能后再加雕琢:

  • 在落子之间需要考虑游戏是否结束(一方获胜或平局)
  • 电脑落子的策略
  • 存储游戏状态

创建ClojureScript项目

用ClojureScript,自然少不了figwheel。cljs+figwheel给前端开发带来的交互式编程体验是无与伦比的。没接触过figwheel的同学一定要看看这个2014年的视频演示

创建项目:

lein new figwheel tictactoe

创建好的目录结构如下:

├── project.clj
├── README.md
├── resources
│   └── public
│       ├── css
│       │   └── style.css
│       └── index.html
└── src
    └── tictactoe
        └── core.cljs

首先修改project.clj文件,添加storage-atomreagent作为依赖包:

  :dependencies [
                 ...
                 [alandipert/storage-atom "2.0.1" ]
                 [reagent "0.5.1"]]

关于ClojureScript项目project.clj的更多配置可参考官方文件sample.project.clj,figwheel插件的配置可参考其项目文档

实现静态效果

页面会用ClojureScript生成,所以我们的html文件只有简单几行,其中的div#app元素即为我们cljs代码的DOM入口。

<html>
  <head>
    <meta name="viewport" content="width=device-width, initial-scale=0.67, maximum-scale=0.67, user-scalable=no">
    <title>TicTacToe</title>
    <link href="css/style.css" rel="stylesheet" type="text/css">
  </head>
  <body>
    <div id="app">
    </div>
    <script src="cljs/tictactoe.js" type="text/javascript"></script>
  </body>
</html>

调整CSS样式也是件耗时的工作,本文会略过,最终文件在这里

修改src/tictactoe/core.cljs

(ns tictactoe.core
  (:require
   [reagent.core :as reagent :refer [atom]]))

(enable-console-print!)

(defn my-app []
  (fn []
    [:div
     "hello world"]))

(defn main []
  (reagent/render [#'my-app] (.getElementById js/document "app")))

(main)

ClojureScript:让页面‘动’起来

运行lein figwheel(或在Emacs里执行cider-jack-in-clojure-script),待编译完成后用浏览器打开./resources/public/index.html. 如果你看到了hello world,那么恭喜你,你的交互式编程之旅已经开始了,后面对代码的修改会立即反应在页面上。当然你还可以在REPL里,测试cljs函数。

下面创建静态的角色选框与游戏面板。

创建角色选择对话框

采用hiccup语法,注意[select-player]用的是方括号,原因可看reagent的文档。

(defn select-player []
  [:div.dialog
   [:div
    [:h3 "X or O?"]
    [:div
     [:button.xoro {:type :button} "X"]
     [:button.xoro {:type :button} "O"]]]])

(defn my-app []
  (fn []
    [:div
     [select-player]]))

创建游戏面板

(defn display-box []
  (fn [cls i]
    [:div.box
     {:class cls} [:span ""]]))

(defn my-app []
  (fn []
    [:div
     ;;[select-player]
     [:div
      [:div.top.row
       [display-box "top-left" 0]
       [display-box "top-center" 1]
       [display-box "top-right" 2]]
      [:div.middle.row
       [display-box "middle-left" 3]
       [display-box "middle-center" 4]
       [display-box "middle-right" 5]]
      [:div.bottom.row
       [display-box "bottom-left" 6]
       [display-box "bottom-center" 7]
       [display-box "bottom-right" 8]]
      [:div
       [:div.row
        [:div.player (str "player")]
        [:div.player (str "ties")]
        [:div.player (str "computer")]]
       [:div.row
        [:div.player 0]
        [:div.player 0]
        [:div.player 0]]
       [:button.reset {:type :button} "Reset"]]]]))

引入状态

状态正如程序世界里的小精灵,少了它们我们的世界只有死寂,但滥用也能让它们变成破坏世界的魔鬼。 Clojure的一个优点是让状态以程序员易控的方式出现。Reagent借助于React或许是最好的例子。

Reagent loop

使用reagent,我们不必关注数据改变时刷新DOM这步通常很繁琐的操作。

回到我们的程序,我们添加两种状态:

(ns tictactoe.core
  (:require
   [alandipert.storage-atom :refer [local-storage]]
   [reagent.core :as reagent :refer [atom]]))

(defonce board-state (local-storage (atom {}) :board-state))
(defonce player-state (local-storage (atom {:player nil}) :player-state))

其中board-state保存棋子在棋盘的布局。我们在此约定:棋盘的格子从左至右,从上至下用0-8数字表示。{4 "x" 0 "o"}表示棋盘上有两个子,分别是左上角的O与棋盘正中的X。

(defn display-box []
  (fn [cls i]
    [:div.box {:class cls} [:span (@board-state i)]]))

让我们来测试下,在刚才打开的REPL里,切换ns,并更改下游戏状态:

cljs.user=> (in-ns 'tictactoe.core)
tictactoe.core=> (swap! board-state assoc 8 "x")

再到你的浏览器里看下是不是在右下角已经有个X呢?你还可以试下添加或删除棋子。

类似地,我们约定player-state:player为用户选择的角色(xo)。为空时表示用户还没有选择角色,此时需要打开对话框。这句话换成代码就是

(when-not (:player @player-state)
       [select-player])

为了便于后面使用,创建两个函数分别返回当前的玩家与电脑的角色:

(defn player []
  (:player @player-state))

(defn computer []
  (when-let [p (player)]
    (case p
      "x" "o"
      "o" "x")))

事件与动作

下面看如何实现走子。

给棋格添加点击事件

(defn display-box []
  (fn [cls i]
    [:div.box
     {:class cls
      :on-click #(place-item i)} [:span (@board-state i)]]))

当前位置没有棋子时,我们把玩家的棋子放上去:

(defn place-item [i]
  (when-not (@board-state i)
    (swap! board-state assoc i (player))))

玩家走棋这个动作在程序里的结果就是棋盘状态(board-state)的改变。我们让程序据此判断是谁走棋,如果是玩家,那么下步就该电脑走棋了。add-watch可以很好的实现这个任务。

(add-watch
 board-state
 :play-monitor
 (fn [_ _ o n]
   (let [i (first (remove o (keys n)))
         v (n i)
         player-move? (= (player) v)]
     (when player-move?
       (computer-move!)))))

下面是电脑走棋动作的实现:

;; all-lines是所有的横、竖、斜线:
(def all-lines #{[0 1 2]
                 [3 4 5]
                 [6 7 8]
                 [0 3 6]
                 [1 4 7]
                 [2 5 8]
                 [0 4 8]
                 [2 4 6]})

(defn computer-move! []
  (let [c (computer)
        candid-lines (filter #(seq (filter (complement @board-state) %)) all-lines)
        cm (->> (sort compare-lines candid-lines)
                first
                (remove #(@board-state %))
                first)]
    (swap! board-state assoc cm c)))

下面是compare-lines的实现,我们的策略是先看自己能不能胜(cond里前两个判断),若不能就确保自己不输(cond里第3,4个判断),最后选择有最多自己子的线落棋(最后一个判断)。

(defn compare-lines [prev next]
  (let [pv (map @board-state prev)
        nv (map @board-state next)
        p (player)
        c (computer)]
    (cond
      (second (filter (partial = c) pv))
      true

      (second (filter (partial = c) nv))
      false

      (second (filter (partial = p) pv))
      true

      (second (filter (partial = p) nv))
      false
      
      :esle
      (> (count (filter (partial = c) pv))
         (count (filter (partial = c) nv))))))

至此我们的轮流走棋基本就实现了。

进一步完善功能

前面我们没有考虑如何结束游戏,即走棋后应该作出胜、平、负判断。也没有考虑如何开始下一局游戏,重置,及胜平负统计等。大家可自行实现或看我的源码,完成的页面在这里

玩一把

最终实现的游戏如下。提示:页面未做适配,若手机看请横屏:

Loading...

留言