(ns scittle-matrix.game) (defonce state (atom {:board (vec (repeat 9 nil)) :turn :x :status :playing :label "Tic-tac-toe"})) (defonce initialized? (atom false)) (declare render!) (def winning-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 winner [board] (some (fn [[a b c]] (let [mark (nth board a)] (when (and mark (= mark (nth board b) (nth board c))) mark))) winning-lines)) (defn full? [board] (every? some? board)) (defn widget-id [] (try (let [id (.get (js/URLSearchParams. (.-search js/location)) "widgetId")] (when (and id (pos? (.-length id))) id)) (catch js/Error _ nil))) (defn storage-key [] (when-let [id (widget-id)] (str "scittle-matrix.game:" id))) ;; Only an embedded Element widget uses localStorage. The server-side state ;; snapshot is used after OpenID/credential connect for any page with a session. (defn snapshot [] (clj->js (select-keys @state [:board :turn :status :label]))) (defn save-state! [] (let [value (snapshot)] (when-let [key (storage-key)] (try (.setItem (.-localStorage js/window) key (js/JSON.stringify value)) (catch js/Error _ nil))) (when-let [save (.-__scittleSaveState js/window)] (save value)))) (defn parse-mark [value] (case value "x" :x "o" :o nil)) (defn parse-status [value] (case value "x-won" :x-won "o-won" :o-won "draw" :draw :playing)) (defn normalize-state [data] (when (map? data) (let [board (mapv parse-mark (:board data)) status (parse-status (:status data)) turn (if (= "o" (:turn data)) :o :x) label (let [value (:label data)] (if (string? value) value "Tic-tac-toe"))] (when (and (= 9 (count board)) (every? #(or (nil? %) (= :x %) (= :o %)) board)) {:board board :turn turn :status status :label label})))) (defn load-state! [] (when-let [key (storage-key)] (try (when-let [raw (.getItem (.-localStorage js/window) key)] (when-let [normalized (normalize-state (js->clj (js/JSON.parse raw) :keywordize-keys true))] (swap! state merge normalized))) (catch js/Error _ nil)))) (defn restore-session! [js-state] (when (some? js-state) (try (when-let [normalized (normalize-state (js->clj js-state :keywordize-keys true))] (swap! state merge normalized) (render!)) (catch js/Error _ nil)))) (defn current-state [] (snapshot)) (set! (.-__scittleRestoreSession js/window) restore-session!) (set! (.-__scittleCurrentState js/window) current-state) (add-watch state :persist (fn [_ _ _ _] (save-state!))) (defn status-text [{:keys [turn status]}] (case status :x-won "X wins" :o-won "O wins" :draw "Draw" (str (if (= turn :x) "X" "O") "'s turn"))) (defn render! [] (let [{:keys [board status label]} @state] (set! (.-textContent (js/document.getElementById "game-label")) label) (set! (.-textContent (js/document.getElementById "game-status")) (status-text @state)) (doseq [[index cell] (map-indexed vector (js/Array.from (js/document.querySelectorAll "#board .cell")))] (let [mark (nth board index) mark-text (cond (= mark :x) "X" (= mark :o) "O" :else "")] (set! (.-textContent cell) mark-text) (set! (.-disabled cell) (or (some? mark) (not= status :playing))) (.setAttribute cell "data-mark" (if mark (name mark) "")) (.setAttribute cell "aria-label" (str "Cell " (inc index) (if (some? mark) (str ", " mark-text) ""))))))) (defn play! [index] (swap! state (fn [{:keys [board turn status] :as current}] (if (or (not= status :playing) (nth board index)) current (let [next-board (assoc board index turn) won (winner next-board)] (assoc current :board next-board :status (cond won (if (= won :x) :x-won :o-won) (full? next-board) :draw :else :playing) :turn (if (= turn :x) :o :x)))))) (render!)) (defn reset-game! [] (swap! state assoc :board (vec (repeat 9 nil)) :turn :x :status :playing) (render!)) ;; The DOM listeners call these stable dispatchers. A live redefinition of ;; play! or reset-game! therefore takes effect without rebinding controls. (defn handle-cell-click! [event] (let [index (js/parseInt (.. event -currentTarget -dataset -index) 10)] (play! index))) (defn handle-reset! [] (reset-game!)) (defn init! [] ;; Re-evaluating the source over the live REPL must not add duplicate DOM ;; listeners. The listeners are installed once per page load. (when-not @initialized? (load-state!) (doseq [cell (js/Array.from (js/document.querySelectorAll "#board .cell"))] (.addEventListener cell "click" handle-cell-click!)) (when-let [reset (js/document.getElementById "reset-game")] (.addEventListener reset "click" handle-reset!)) (reset! initialized? true)) (render!) (set! (.-__scittleAppReady js/window) true)) (init!)