使用Clojure編寫文字冒險游戲

jopen 9年前發布 | 20K 次閱讀 Clojure 游戲開發


本文翻譯自: Casting SPELs in Clojure

使用Clojure編寫文字冒險游戲

準備

任何學過Lisp的人都會說Lisp和其它語言有很大的不同.它有很多不可思議的地方.本文將告訴你它有哪些獨特之處!

本文適用于Clojure,它是一個運行在JVM上的Lisp方言.Clojure的API和語法和Common Lisp很類似,但是還是有足夠多的區別,需要單獨為其寫個教程.

在大部分情況下,我們會說Lisp而不是Clojure,因為大部分的概念在Lisp中是通用的.我們會指出Clojure特有的內容.

使用Clojure編寫文字冒險游戲

Clojure是運行在JVM之上的,所以你需要先安裝JVM.如果你是MAC機,那么Java已經被安裝過了.如果是Linux或者Windows系統,你需要到 Oracle Java官網 下載對應版本的Java.

而Clojure,你可以從它的 官網 獲得最新版本.下載完成后,你只需要解壓縮,打開命令行,切換到解壓縮目錄,輸入:

java -jar clojure.jar

如果沒有問題,那么你將會看到Clojure輸出提示

Clojure 1.6.0
user=>

教程中有很多Clojure代碼片段,類似下面的樣子:

'(these kinds of boxes)

你只需要將這些代碼片段拷貝到Clojure REPL中運行就可以了!當你學習完此教程,你將會有一個你自己的文字冒險游戲了!

語法和語義

每一個編程語言是由語法和語義組成的.語法是組成你的程序的骨架,你必須要遵循它們,這樣編譯器才能知道你的程序里什么是什么,比如說什么是函數,什么是變量,等等!

而語義是個比較"隨便"的東西,例如你的程序里有哪些不同的命令,或者在程序的哪個部分能訪問到哪些變量!這里Lisp比較特別的地方就是,Lisp的語法比其它任何語言都要簡單.

首先,Lisp語法規定,所有傳遞給Lisp編譯器的文本需要是個list,當然這個list可以無限嵌套.每個list都必須使用括號包裹.

使用Clojure編寫文字冒險游戲

另外,Lisp編譯器使用兩種模式來讀取你的代碼:代碼模式和數據模式.當你在數據模式下,你可以將任何東西塞到你的list中.但是在代碼模式下,你的list需要是叫做form的特殊類型.

使用Clojure編寫文字冒險游戲

form也是個list,不過它的第一個符號被lisp編譯器特殊對待了---一般被當做函數的名字.在這種情況下,編譯器會將list中的其它元素作為函數參數傳遞給這個函數.默認情況下,編譯器運行在代碼模式下,除非你特意告訴它進入數據模式.

為我們的游戲世界定義數據

為了進一步的學習form,讓我們來創建一些form,來定義我們游戲世界里的數據.首先,我們的游戲有一些對象,玩家可以使用他們--讓我們來定義吧:

(def objects '(whiskey-bottle bucket frog chain))

讓我們來看看這行代碼是什么意思:Lisp編譯器總是使用代碼模式來讀取內容,所以第一個符號(這里是def),肯定是個命令.

在這 里,它的作用就是給某個變量設值:這里變量就是objects,而值是一個包含四個對象的list.這個list是數據(我們可不想編譯器去調用一個叫做 whiskey-bottle的函數),所以在讀取這個list時我們需要將其設值為數據模式.在list前面的哪個單引號就是干這個的:

使用Clojure編寫文字冒險游戲

def命令就是用來設值的(如果你學過Common Lisp,你應該會知道它和CommonLisp中的setf命令等價,但是Clojure中沒有setf命令)

現在我們在游戲里定義了一些對象,現在讓我們來定義一下游戲地圖.下面是我們的游戲世界:

使用Clojure編寫文字冒險游戲

在這個簡單的游戲里,只有三個地點:一個房子,它包含起居室,閣樓和花園.讓我們來定義一個新變量,叫做game-map來描述這個游戲地圖:

(def game-map (hash-map
   'living-room '((you are in the living room
                   of a wizards house - there is a wizard
                   snoring loudly on the couch -)
                  (west door garden)
                  (upstairs stairway attic))
   'garden '((you are in a beautiful garden -
              there is a well in front of you -)
             (east door living-room))
   'attic '((you are in the attic of the
             wizards house - there is a giant
             welding torch in the corner -)
            (downstairs stairway living-room))))

這個map包含了三個地點的所有重要信息:每個地點都有個獨立的名字,一個簡短的描述,描述了我們能在這些地點看到什么,以及如何進入此處或從此處出去.

請注意這個包含了豐富信息的變量是如何定義的---Lisp程序員更喜歡用小巧的代碼片段而不是一大片代碼,因為小代碼更容易理解.

現在我們有了一個地圖和一組對象,讓我們來創建另一個變量,來描述這些對象在地圖的哪些地方.

(def object-locations (hash-map
                       'whiskey-bottle 'living-room
                       'bucket 'living-room
                       'chain 'garden
                       'frog 'garden))

這里我們將每個對象和地點進行了關聯.Clojure提供了Map這個數據結構.Map使用hash-map函數來創建,它需要一組參數類似 (key1 value1 keys value2...).我們的game-map變量也是個Map---三個key分別是living-room,garden和attic.

我們定義了游戲世界,以及游戲世界中的對象,現在就剩下一件事了,就是描述玩家的地點!

(def location 'living-room)

搞定,現在讓我們來定義游戲操作吧!

使用Clojure編寫文字冒險游戲

環顧我們的游戲世界

我們想要的第一個命令能夠告訴我們當前地點的描述.那么我們該怎么定義這個函數呢?它要知道我們想要描述的地點以及能夠從map中查找地點的描述.如下:

(defn describe-location [location game-map]
  (first (location game-map)))

defn定義了一個函數.函數的名字叫做describe-location,它需要兩個參數:地點和游戲地圖.這兩個變量在函數定義的括號內,所以它們是局部變量,因此對于全局的location和game-map沒有關系.

注意到了嗎?Lisp中的函數與其它語言中的函數定義相比,更像是數學中的函數:它不打印信息或者彈出消息框:它所作的就是返回結果.

我們假設現在我們在起居室里!

使用Clojure編寫文字冒險游戲

為 了能找到起居室的描述,describe-locatin函數首先需要從地圖中找到起居室.(location game-map)就是進行從game-map中查找內容的,并返回起居室的描述.然后first命令來處理返回值,取得返回的list的第一個元素,這 個就是起居室的描述了. 現在我們來測試一下

(describe-location 'living-room game-map)
 user=> (describe-location 'living-room game-map)
 (you are in the living-room of a wizard's house -
 there is a wizard snoring loudly on the couch -)

很完美!這就是我們要的結果!請注意我們在living-room前添加了一個單引號,因為這個符號是地點map的一個名稱!但是,為什么我們沒有在game-map前面添加單引號呢?這是因為我們需要編譯器去查詢這個符號所指向的數據(就是那個map)

函數式編碼風格

你可能已經發現了describe-location函數有幾個讓人不太舒服的地方.

第一,為什么要傳遞位置和map參數,而不是直接使用已經定義的全局變量?原因是Lisp程序員喜歡寫函數式風格的代碼.函數式風格的代碼,主要遵循下面三條規則:

  • 只讀取函數傳遞的參數或在函數內創建的變量
  • 不改變已經被設值的變量的值
  • 除了返回值,不去影響函數外的任何內容

你 也許會懷疑在這種限制下你還能寫代碼嗎?答案是:可以!為什么很多人對這些規則感到疑惑呢?一個很重要的原因是:遵循此種風格的代碼更加的引用透明 (referential transparency):這意味著,對于給定的代碼,你傳入相同的參數,永遠返回相同的結果---這能減少程序的錯誤,也能提高程序的生產力!

當然了,你也會有一些非函數式風格的代碼,因為不這么做,你無法和其它用戶或外部內容進行交互.教程后面會有這些函數,他們不遵循上面的規則.

describe-location函數的另一個問題是,它沒告訴我們怎么進入一個位置或者怎么從某個位置出來.讓我們來編寫這樣的函數:

(defn describe-path [path]
  `(there is a ~(second path) going ~(first path) from here -))

這個函數看起來很明了:它看起來更像是數據而不是函數.我們先來嘗試調用它,看它做了些什么:

(describe-path '(west door garden))
user=> (describe-path '(west door garden))
(user/there user/is user/a door user/going west user/from user/here clojure.core/-)

這是什么?!結果看起來很亂,包含了很多的/和一些其它的文字!這是因為Clojure會將命名空間的名字添加到表達式的前面.我們這里不深究細節,只給你提供消除這些內容的函數:

(defn spel-print [list] (map (fn [x] (symbol (name x))) list))

修改調用方式

(spel-print (describe-path '(west door garden)))
user=> (spel-print (describe-path '(west door garden)))
(there is a door going west from here -)

現在結果很清晰了:這個函數接收一個描述路徑的list然后將其解析到一個句子里面.我們回過頭來看這個函數,這個函數和它產生的數據非常的像:它就是拼接第一個和第二個list的元素到語句中!它是怎么做到的?使用語法quote!

還記得我們使用quote來從代碼模式切換到數據模式嗎?語法quote的功能類似,但是還不只這樣.在語法quote里,我們還能使用'~'再次從數據模式切換回代碼模式.

使用Clojure編寫文字冒險游戲

語法quote是List的一個很強大的功能!它能使我們的代碼看起來像它創建的數據.這在函數式編碼中很常見:創建這種樣子的函數,使得我們的代碼更易讀也更穩健:

只要數據不變,函數就不需要修改.想象一下,你能否在VB或C中編寫類似的代碼?你可能需要將文字切成小塊,然后在一點點的組裝-這和數據本身看起來差距很大,更別說代碼的穩健性了!

現在我們能描述一個路徑,但是一個地點可能會有多個路徑,所以讓我們來創建一個函數叫做describe-paths:

(defn describe-paths [location game-map]
  (apply concat (map describe-path (rest (get game-map location)))))

這個函數使用了另一個在函數式編程中很常用的技術:高階函數.apply和map這兩個函數能將其它的函數作為參數.map函數將另一個函數分別作 用到list中的每個對象上,這里是調用describe-path函數.apply concat是為了減少多余的括號,沒有多少功能性操作!我們來試試新函數

(spel-print (describe-paths 'living-room game-map))
user=> (spel-print (describe-paths 'living-room game-map))
(there is a door going west from here -
there is a stairway going upstairs from here -)

漂亮!

最后,我們還剩下一件事要做:描述某個地點的某個對象!我們先寫個幫助函數來告訴我們在某個地方是否有某個對象!

(defn is-at? [obj loc obj-loc] (= (obj obj-loc) loc))

=也是個函數,它判斷對象的地點是否和當前地點相同!

使用Clojure編寫文字冒險游戲

我們來嘗試一下:

(is-at? 'whiskey-bottle 'living-room object-locations)
user=> (is-at? 'whiskey-bottle 'living-room object-locations)
true

返回結果是true,意味著whiskey-bottle在起居室.

現在讓我們來使用這個函數描述地板:

(defn describe-floor [loc objs obj-loc]
  (apply concat (map (fn [x]
                       `(you see a ~x on the floor -))
                     (filter (fn [x]
                               (is-at? x loc obj-loc)) objs))))

這個函數包含了很多新事物:首先,它有匿名函數(fn定義的函數).第一個fn干的事,和下面的函數做的事情是一樣的:

(defn blabla [x] `(you see a ~x on the floor.))

然后將這個blabla函數傳遞給map函數.filter函數是過濾掉那些在當前位置沒有出現的物體.我們來試一下新函數:

(spel-print (describe-floor 'living-room objects object-locations))
user=> (spel-print (describe-floor 'living-room objects object-locations))
(you see a whiskey-bottle on the floor - you see a bucket on the floor -)

現在,讓我們來將這些函數串聯起來,定義一個叫look的函數,使用全局變量(這個函數就不是函數式的了!)來描述所有的內容:

(defn look []
  (spel-print (concat (describe-location location game-map)
          (describe-paths location game-map)
          (describe-floor location objects object-locations))))

使用Clojure編寫文字冒險游戲

我們來試一下:

user=> (look)
(you are in the living room of a wizards house -
there is a wizard snoring loudly on the couch -
there is a door going west from here -
there is a stairway going upstairs from here -
you see a whiskey-bottle on the floor -
you see a bucket on the floor -)

很酷吧!

環游我們的游戲世界

好了,現在我們能看我們的世界了,讓我們來寫一些代碼來環游我們的世界.walk-direction包含了一些方向可以使我們走到那里:

(defn walk-direction [direction]
  (let [next (first (filter (fn [x] (= direction (first x)))
                            (rest (location game-map))))]
    (cond next (do (def location (nth next 2)) (look))
          :else '(you cannot go that way -))))

這里的let用來創建局部變量next,用來描述玩家的方向.rest返回一個list,包含原list中除了第一個元素外的全部元素.如果用戶輸入了錯誤的方向,next會返回().

cond 類似于if-then條件:每個cond都包含一個值,lisp檢查該值是否為真,如果為真則執行其后的動作.在這里,如果下一個位置不是nil,則會定 義玩家的location到新位置,然后告訴玩家該位置的描述!如果next是nil,則告訴玩家,無法到達,請重試:

(walk-direction 'west)
user=> (walk-direction 'west)
(you are in a beautiful garden -
there is a well in front of you -
there is a door going east from here -
you see a frog on the floor -
you see a chain on the floor -)

現在,我們通過創建look函數來簡化描述.walk-direction也是類似的功能.但是它需要輸入方向,而且還有個quote.我們能否告訴編譯器west僅僅是個數據,而不是代碼呢?

構建SPELs

現在我們開始學習Lisp中一個很強大的功能:創建SPELs!SPEL是"語義增強邏輯"的簡稱,它能夠從語言級別,按照我們的需求定制,對我們的代碼添加新的行為-這是Lisp最為強大的一部分.為了開啟SPELs,我們需要先激活Lisp編譯器的SPEL

(defmacro defspel [& rest] `(defmacro ~@rest))

現在,我們來編寫我們的SPEL,叫做walk:

(defspel walk [direction] `(walk-direction '~direction))

這段代碼干了什么?它告訴編譯器walk不是實際的名稱,實際的名字叫walk-direction,并且direction前面有個quote.SPEL的主要功能就是能在我們的代碼被編譯器編譯之前插入一些內容!

使用Clojure編寫文字冒險游戲

注意到了嗎?這段代碼和我們之前寫的describe-path很類似:在Lisp中,不只是代碼和數據看起來很像,代碼和特殊形式對于編譯器來說也是一樣的-高度的統一帶來簡明的設計!我們來試試新代碼:

(walk east)
user=> (walk east)
(you are in the living room of a wizards house -
there is a wizard snoring loudly on the couch -
there is a door going west from here -
there is a stairway going upstairs from here -
you see a whiskey-bottle on the floor -
you see a bucket on the floor -)

感覺好多了! 現在我們來創建一個命令來收集游戲里的物品

(defn pickup-object [object]
  (cond (is-at? object location object-locations)
        (do
          (def object-locations (assoc object-locations object 'body))
          `(you are now carrying the ~object))
        :else '(you cannot get that.)))

這個函數檢查物品是否在當前地點的地上-如果在,則將它放到list里面,并返回成功提示!否則提示失敗! 現在我們來創建另一個SPEL來簡化這條命令:

(defspel pickup [object] `(spel-print (pickup-object '~object)))

調用

(pickup whiskey-bottle)
user=> (pickup whiskey-bottle)
(you are now carrying the whiskey-bottle)

現在我們來添加更多有用的命令-首先,一個能讓我們查看我們撿到的物品的函 數:

(defn inventory []
  (filter (fn [x] (is-at? x 'body object-locations)) objects))

以及一個檢查我們是否有某個物品的函數:

(defn have? [object]
   (some #{object} (inventory)))

創建特殊操作

現在我們只剩下一件事情需要做了:添加一些特殊動作,使得玩家能夠贏得游戲.第一條命令是讓玩家在閣樓里給水桶焊接鏈條.

(def chain-welded false)

(defn weld [subject object]
  (cond (and (= location 'attic)
             (= subject 'chain)
             (= object 'bucket)
             (have? 'chain)
             (have? 'bucket)
             (not chain-welded))
        (do (def chain-welded true)
            '(the chain is now securely welded to the bucket -))
        :else '(you cannot weld like that -)))

首先我們創建了一個新的全局變量來進行判斷,我們是否進行了此操作.然后我們創建了一個weld函數,來確認此操作的條件是否完成,如果已完成則進行此操作.

使用Clojure編寫文字冒險游戲

來試一下:

(weld 'chain 'bucket)
user=> (weld 'chain 'bucket)
(you cannot weld like that -)

Oops...我們沒有水桶,也沒有鏈條,是吧?周圍也沒有焊接的機器!

現在,讓我們創建一條命令來將鏈條和水桶放到井里:

(def bucket-filled false)

(defn dunk [subject object]
  (cond (and (= location 'garden)
             (= subject 'bucket)
             (= object 'well)
             (have? 'bucket)
             chain-welded)
        (do (def bucket-filled true)
            '(the bucket is now full of water))
        :else '(you cannot dunk like that -)))

注意到了嗎?這個命令和weld命令看起來好像!兩條命令都需要檢查位置,物體和對象!但是它們還是有不同,以至于我們不能將它們抽到一個函數里.太可惜了!

但是...這可是Lisp.我們不止能寫函數,還能寫SPEL!我們來創建了SPEL來處理:

(defspel game-action [command subj obj place & args]
  `(defspel ~command [subject# object#]
     `(spel-print (cond (and (= location '~'~place)
                             (= '~subject# '~'~subj)
                             (= '~object# '~'~obj)
                             (have? '~'~subj))
                        ~@'~args
                        :else '(i cannot ~'~command like that -)))))

非常復雜的SPEL!它有很多怪異的quote,語法quote,逗號以及很多怪異的符號!更重要的是他是一個構建SPEL的SPEL!!即使是很有經驗的Lisp程序員,也需要費下腦細胞才能寫出這么個玩樣!!(這里我們不管)

使用Clojure編寫文字冒險游戲

這個SPEL的只是向你展示,你是否夠聰明來理解這么復雜的SPEL.而且,即使這段代碼很丑陋,如果它只需要寫一次,并且能生成幾百個命令,那么也是可以接受的!

讓我們使用這個新的SPEL來替換我們的weld命令:

(game-action weld chain bucket attic
   (cond (and (have? 'bucket) (def chain-welded true))
              '(the chain is now securely welded to the bucket -)
         :else '(you do not have a bucket -)))

現在我們來看看這條命令變得多容易理解:game-action這個SPEL使得我們能編寫我們想要的核心代碼,而不需要額外的信息.這就像我們創 建了我們自己的專門創建游戲命令的編程語言.使用SPEL創建偽語言稱為領域特定語言編程(DSL),它使得你的編碼更加的快捷優美!

(weld chain bucket)
user=> (weld chain bucket)
(you do not have a chain -)

...我們還沒有做好焊接前的準備工作,但是這條命令生效了!

使用Clojure編寫文字冒險游戲

下面我們重寫dunk命令:

(game-action dunk bucket well garden
             (cond chain-welded
                   (do (def bucket-filled true)
                       '(the bucket is now full of water))
                   :else '(the water level is too low to reach -)))

注意weld命令需要檢驗我們是否有物體,但是dunk不需要.我們的game-action這個SPEL使得這段代碼易寫易讀.

使用Clojure編寫文字冒險游戲

最后,就是將水潑到巫師身上:

(game-action splash bucket wizard living-room
             (cond (not bucket-filled) '(the bucket has nothing in it -)
                   (have? 'frog) '(the wizard awakens and sees that you stole
                                       his frog -
                                       he is so upset he banishes you to the
                                       netherworlds - you lose! the end -)
                   :else '(the wizard awakens from his slumber and greets you
                               warmly -
                               he hands you the magic low-carb donut - you win!
                               the end -)))

使用Clojure編寫文字冒險游戲

現在你已經編寫完成了一個文字冒險游戲了!

點擊 這里 是完整的游戲.

點擊 這里 是代碼.

為了使教程盡可能的簡單,很多Lisp的執行細節被忽略了,所以最后,讓我們來看看這些細節!

附錄

現在,我們來聊一聊被忽略的細節!

首先,Clojure有一套很成熟的定義變量以及改變變量值的系統.在此教程中,我們只使用了def來設置和改變全局變量的值.而在真正的Clojure代碼里,你不會這么做.取而代之,你會使用 Refs , Atoms和 Agents ,它們提供了更清晰,以及線程安全的方式來管理數據.

另一個問題就是我們在代碼中大量使用了符號(symbol)

'(this is not how Lispers usually write text)
"Lispers write text using double quotes"

符號在Clojure有特殊含義,主要是用來持有函數,變量或其它內容的.所以,在Lisp中將符號作為文本信息描述是很奇怪的事情!使用字符串來顯示文本信息可以避免這樣的尷尬!不過,使用字符串的話,在教程里就沒法講很多關于符號的內容了!

還有就是SPEL在Lisp里面更普遍的叫法是"宏",使用defmacro來定義,但是這個名字不易于教學,所以沒有提及.你可以閱讀 此文 ,這是我為什么沒有使用"宏"這個名字的原因.

最后,在編寫類似game-action這樣的SPEL的時候,很可能會發生命名重復的問題.當你編寫了足夠多的lisp的時候,你會越來越能體會到這個問題了.

Q. 后面我該閱讀哪些內容來擴充我的Lisp知識? A.

cliki網站 有很多Lisp書籍可以下載.

如果你對于理論上的內容很感興趣,那么我推薦Paul Graham的 On Lisp 電子書,它是免費的.他網站上的一些短文也很精彩.

如果你對實際應用比較感興趣,那么大多數Lisp程序員對Perter Seibel編寫的"Practical Common Lisp"這本書推崇有加,你可以從 這里 獲得

為什么沒有使用"宏"這個詞

編 寫這個教程的一個意圖是使用宏來解決真實的難題.而經常的,當我向沒有Lisp經驗的人解釋宏這個概念的時候,我得到的答復往往是,"哦!C++里也有 宏".當發生這種事情的時候,我就很難去解釋宏的概念了.的確,Lisp中的宏和C++中的宏的確有幾分相似,它們都是為了能通過編譯器來改進代碼的編 寫...

...所以,假設一下,如果John McCarthy使用了"add"而不是"cons"這個詞來將元素添加到list中:我就真的很難解釋cons是如何工作的了!

所以,我決定在此文中使用一個新的詞匯:SPEL,語義增強邏輯的簡稱,它更易理解一些:

  • 它解釋了Lisp宏的核心功能,能改變Lisp運行環境的行為
  • SPEL這個術語可以被用來很高雅的解釋很多語言上觀念.
  • 這個術語不會導致Lisp中的宏與其它的宏被混為一談
  • SPEL這個詞重復的可能性非常低.Google搜索"macro 或者 macros 程序 -lisp -scheme"返回大概1150000條結果.而搜索"spel 或者 spels 程序 -lisp -scheme"值返回28400條結果.

所以,我希望,作為一個Lisp程序員,你能接受這個術語.當然了,像這樣的新詞匯會被接受的可能性非常低.

如果你有一個庫或者是一個Lisp實現者,請先放下你手頭上的工作,先在你的庫里,添加下面這行代碼:

(defmacro defspel [& rest] `(defmacro ~@rest))

譯者感想

  • 本人對Lisp的宏還是有些了解的,所以個人無法接受SPEL這個新詞匯
  • 且SPEL使得代碼不易閱讀,就game-action這個SPEL來說,使用了兩層,而使用宏只需要一層
  • 附錄中是我使用Clojure的慣用法重新改寫的代碼,且文字翻譯成了中文.以及使用了宏而不是SPEL.各位可比較,自行選擇

源代碼

 本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!