最近在刷FCC的前端课程。其中的一个小任务是写个Tic Tac Toe也就是常说的三连棋游戏。这其实是个简单的单页应用,想来用最爱的Clojure(Script)语言来实现必定十分容易,下面就简述下开发过程。等不及的同学可直接看源码。最终实现的游戏在文章末尾,你可以先体验一把再来看实现过程。
确定目标
参考此页,我们的目标是:
- 初次进去用户可以选择作为
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-atom
和reagent
作为依赖包:
: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,我们不必关注数据改变时刷新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
为用户选择的角色(x
或o
)。为空时表示用户还没有选择角色,此时需要打开对话框。这句话换成代码就是
(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))))))
至此我们的轮流走棋基本就实现了。
进一步完善功能
前面我们没有考虑如何结束游戏,即走棋后应该作出胜、平、负判断。也没有考虑如何开始下一局游戏,重置,及胜平负统计等。大家可自行实现或看我的源码,完成的页面在这里。
玩一把
最终实现的游戏如下。提示:页面未做适配,若手机看请横屏: