跳到主要內容

Escape from Planet Delta by Russell Wallace 文字冒險遊戲

Escape from Planet Delta是以OASYS系統製作的文字冒險遊戲,OASIS是一個用於製作文字冒險遊戲的物件導向的系統。最早Escape是在1980年代中期在Commodore64上以The Quill製作的,在1991年時移植至OASYS系統。Escape發佈於public domain,可以任意複制和散佈source和可執行檔。

OASYS是Object-Oriented Adventure System的縮寫,它的作者是Russell Wallace,在1992年於comp.binaries.ibm.pc發佈的,並且可以在各大FTP站取得。下載的zip包包含一個說明文件檔、二個範例(其中一個是escape)、反組譯器、編譯器及解譯器的C的source、以及MS-DOS的反組譯器、編譯器及解譯器的可執行檔。原始的zip包裡的source缺少include檔不過不難移植到非MS-DOS的平台。

[Play] / [Github]

1. 編譯程式碼及執行

OASYS製作的遊戲是先以OASYS程式語言撰寫好程式碼後,再以oac編譯器編譯為物件碼,再透過oai解譯器執行。

1.1 OAC

首先你需要使用一個文字編輯器編輯你的遊戲原始碼,一般附屬檔名為s,例如test.s。接著使用oac編譯器把你的遊戲原始碼編譯為物件碼。

請執行以下命令編譯原始碼

oac test.s

或者省略附屬檔名

oac test

編譯成功的話會輸出同檔名的物件檔,不包含附屬檔名。如編譯test.s成功後,產生名為test的物件檔。

編譯過程如果有任何錯誤,則會輸出錯誤訊息。請再根據錯誤訊息,修正程式錯誤,並再重複以上的編譯命令及修正錯誤的流程,直到成功編譯出物件檔。

1.2 OAI

編譯成功的物件檔,可透過oai解譯器執行。

請執行以下命令執行物件檔

oai test

載入並成功啟動遊戲的話,可以看的遊戲輸出的訊息,以及接受文字命令的輸入提示符號。透過文字命令進行遊戲,quit離開。

2. OASYS程式語言 Language

底下介紹基本的OASYS程式語言語法規則,更詳細的說明請參數OASYS說明文件。

2.1 大小寫 Case

雖然底下的範例都是以大寫字母作示範,但OASYS程式語言不區分大小寫英文字母。

2.2 空白字元 Spaces

空白字元包含space、tab及換行字元。多個空白字元都會被合併視為一個單一空白字元。

2.3 識別字 Identifiers

跟所有其他程式語言的識別字一樣,識別字由英文字母及數字組成,但必需以英文字母或底線字元作為開頭第一個字元。

如以下範例都是有效的識別字

ROOM
SATURNS
HYDRUGONBOMB

以下是不合法的識別字的例子

9TO5

2.4 註解 Comments

註解有兩種形式,一是單行註解,另一是多行註解。

... // 這是單行註解

/* 這是一個
    多行註解  */

2.5 宣告的次序 Order of definitions

基於效率的考量,OASYS在解析編譯程式碼的時候只會掃描一遍。這表示說,在程式碼中的任何參考都必須事先宣告。

2.6 類別及物件 Classes and objects

OASYS是基於物件的系統,像是位置或玩家都是物件。當遊戲開始時並沒有任何物件,必須在開始時建立並初始化後才能使用。等到物件不再使用了,物件則被刪除。

物件是基於類別來建立的,所以第一步就是定義類別。

例如

CLASS PLAYER {{ME} {SELF} {MYSELF}}
CLASS ROOM {}
CLASS MACHINE_GUN {{GUN} {MACHINE GUN}}

一個類別以關鍵字CLASS開頭,接著是空白字元,然後是類別名稱識別字,接著空白字元,然後是一對大刮號。大刮號裡面可以定義可有可無的用來存取以此類別生成的物件別名,每一個別名也用一對大括號刮起來。例如類別PLAYER的物件,可以以ME、SELF或MYSELF來存取。而像是ROOM類別的物件則無法讓玩家透過任何名字存取,只能作為內部使用。而MACHINE_GUN類別物件有兩個可以存取的別名,分別是GUN及MACHINE GUN。

2.7 屬性 Properties

就像許多物件導向的程式語言一樣,OASYS的類別也有屬性,例如描述或重量。只是宣告的方式是以PROPERTY宣告一個屬性列表,這些屬性並不是特別專屬于那個類別,而是所有類別都能使用宣告的任何屬性。

例如

PROPERTY INT WEIGHT
PROPERTY STRING DESCRIPTION
PROPERTY OBJECT IN

如上面的例子,屬性以一個PROPERTY關鍵字開始,接著空白字元,然後是一個屬性類型關鍵字,接著是空白字元,最後是屬性名稱識別字。

屬性類型總共有三種,INT、STRING或OBJECT。INT是整數型別,初始值為0,如上面的WEIGHT屬性。STRING是字串型別,初始值為"NULL STRING",如上面的描述屬性。OBJECT則是一個指向其它物件的參考的屬性,初始值為OBJECT 0。

2.8 方法 Methods

方法是OASYS裡面要作的事情怎麼完成的的動作。如下是一個簡單範例

METHOD INT SQUARE INT X
{
  RETURN X * X
}

這是一個計算平方數的方法。一個方法以METHOD關鍵字開始,接著空白字元,可有可無的回傳值類型,接著是空白字元,然後是方法名稱識別字,此例中是SQUARE。再來是可有可無的參數列,此例中有一個類型為INT的叫作X的參數。接著是方法主體,以一對大括號括起來。

底下是另一個例子

METHOD LOOK VERBS {{LOOK} {DESCRIBE LOCATION}}
{
  PRINT PLAYER IN DESCRIPTION
}

這個方法叫作LOOK,它沒有回傳值所以沒有回傳值類型。另外關鍵字VERBS表示接下來的一對大括號裡面,是這個方法的別名列表,定義的方法類似CLASS的別名。定義方法的VERB別名後,就能在遊戲的過程中,使用此定義的別名作為操作命令輸入遊戲中。

下面是比較複雜的例子

METHOD GIVE_TO OBJECT X IS_CARRIED OBJECT Y IS_VISIBLE
   VERBS {{GIVE X TO Y} {OFFER X TO Y} {GIVE Y X} {OFFER Y X}}
{
  ...
}

這樣就能以如下的方法呼叫到GIVE_TO方法

GIVE FISH TO TIGER
OFFER FISH TO TIGER
GIVE THE TIGER THE FISH
OFFER THE TIGER THE FISH

注意到GIVE_TO方法的參數列的定義裡有IS_CARRIED及IS_VISIBLE,其實這是另外兩個定義的修飾方法,用來修飾X和Y參數,底下會再介紹。另外上面的例子裡面可以看到THE這個單字,這個特別的單字也是作為修飾用,會被系統自動忽略。

2.9 變數 Variables

類似在定義PROPERTY一樣,你也可以定義GLOBAL變數,但只能有一個實體,如下所示

INT SCORE

如果加上PROPERTY關鍵字,就變成了物件屬性,可以被多個物件擁有。另外在方法裡定義的變數則為LOCAL變數,可見範圍及生命周期只在方法內部有效。如下面的例子

METHOD INT SQUARE INT X
{
  INT RESULT

  RESULT = X * X
  RETURN RESULT
}

此外方法的參數的地位則相當於LOCAL變數。

2.10 THIS

所有的方法都會假定為被指定為某個物件呼叫。例如,當你輸入指令時,就預設假定為指令是被PLAYER物件所呼叫。方法呼叫時,系統維護一個隱含的THIS物件參考,表示此方法是被此物件呼叫的。另外像上面的SQUARE方法的實作裡,並沒有使用到THIS物件,但系統還是一樣會指派一個THIS物件參考。

2.11 INIT方法 METHOD INIT

每一個遊戲都需要一個INIT方法,INIT方法是遊戲程式的進入點,遊戲啟動時第一個被系統呼叫的方法就是INIT,可以在此作初始化。

2.12 選擇器方法 Selector methods

上面的GIVE_TO方法裡定義的參數列中,我們看到IS_CARRIED及IS_VISIBLE。前面提到這是修飾方法,也就是選擇器方法。

這二個方法定義如下

METHOD INT IS_CARRIED
"You haven't got that.\n"
{
  RETURN THIS IN == PLAYER
}

METHOD INT IS_VISIBLE
"That isn't here.\n"
{
  RETURN THIS IS_CARRIED OR THIS IN == PLAYER IN
}

選擇器方法不帶參數,回傳值類型都是INT,也就是0或非0,即FALSE或TRUE。在左大括號前的字串,是定義為當選擇器方法回傳FALSE時,顯示給usr的提示訊息。

2.13 SELECT_ADDRESSEE方法 METHOD SELECT_ADDRESSEE

在遊戲中,玩家可以和其它物件互動或交談。使用如下的語法

GUARD, DROP THE MACHINE GUN

如果沒有前面的"GUARD,",則表示玩家執行“DROP THE MACHINE GUN",加上前面的"GUARD,"後,這個動作則變為是由GUARD執行。

一般來說,要把命令指派給物件,要先輸入物件的名字,接著一個逗號,然後是指令。至於那些物件能夠和玩家互動或交談呢?

有個特殊的選擇器方法叫作SELECT_ADDRESSEE就是用來做這個檢查的。如果你有定義一個SELECT_ADDRESSEE,則需要時它就會被呼叫,否則系統總是會顯示預設的訊息"You can't talk to that"。

下面是一個範例

METHOD SELECT_ADDRESSEE
"It doesn't understand you!\n"
{
  RETURN (THIS IS GUARD OR THIS IS ROBOT) AND THIS IS_VISIBLE
}

2.14 玩家 Player

PLAYER是需要預先定義的物件,在INIT方法裡需要被產生出來。如

PLAYER = CREATE PLAYER

如上的例子PLAYER物件是以PLAYER類別產生出來的,當然這也不是必需這樣,你也能以其它類別的產生PLAYER物件。如

PLAYER = CREATE ROOM

上面的例子中,PLAYER物件是以ROOM類別建立的。這表示說,在遊戲過程中玩家可以在不同時間轉換為不同角色。

例如在Commodore 64上的魔戒這個遊戲裡,4個哈比人Frodo、Sam、Merry和Pippin,可以用4個類別來表示這4個角色。玩家可以使用BECOME這個命令來操控這4個角色。遊戲開始時,玩家是先從FRODO這個角色開始,然後使用BECOME SAME切換為SAM。一個使用OASYS的可能實現方式如下

CLASS FRODO {{FRODO}}
CLASS SAM {{SAM}}
CLASS MERRY {{MERRY}}
CLASS PIPPIN {{PIPPIN}}

...

OBJECT FRODO
OBJECT SAM
OBJECT MERRY
OBJECT PIPPIN

...

METHOD INT IS_BECOMABLE
{
  RETURN THIS SELECT_ADDRESSEE AND (THIS IS FRODO OR
                                    THIS IS SAM OR
                                    THIS IS MERRY OR
                                    THIS IS PIPPIN)
}

METHOD BECOME OBJECT X IS_BECOMABLE VERBS {{BECOME X}}
{
  PLAYER = X
}

METHOD INIT
{
  ...
  FRODO = CREATE FRODO
  SAM = CREATE SAM
  MERRY = CREATE MERRY
  PIPPIN = CREATE PIPPIN

  PLAYER = FRODO      // Player starts off playing Frodo
  ...
}

3. 攻略 Walkthrough

Escape from Planet Delta by Russell Wallace

Originally written with The Quill on the Commodore 64 in the mid eighties and
ported to OASYS in January 1991. This game is public domain and source and
object code may be freely copied and used.

The plot is as follows: You were on a starship flying to the planet Betelgeuse
Delta, fourth planet of the Betelgeuse star system. On approaching the planet
for landing, the ship developed engine trouble and appeared to be about to
crash. Unfortunately there was not quite enough lifeboat space for everyone, so
your shipmates beat you up, threw you into the control room, locked the door,
grabbed the lifeboat, ejected and are probably on their way back to Earth by
now. The ship crash landed but amazingly is still mostly intact and you
survived the landing. However there is no chance of your being able to fly it
out of here, so you will have to look for the Starfleet base on the planet and
try to find some means of escape.

Commands can be multiple words, but words like ALL, IT and AND are not
supported. SAVE saves the game and LOAD reloads your position. VERBOSE, BRIEF
and NORMAL give you different lengths of location descriptions.

Good luck!

底下是一系列需要輸入的指令,括號裡的文字是一些關鍵訊息。

(Control Room)
open box
open box
open box
open box
open box
open box
open box
open box
open box
open box
open box
(以上總共11次open box)
(You open the box and remove an American Express credit card and an
oxy-acetylene torch.)
get card
get torch
open door with card
(You manage to unlock the door with the credit card.)
e
(Corridor)

以上的solution只是拋磚引玉起個頭,改天有什麼好心人士提供或我自己過關了再補上。

4. 移植雜記

4.1 試移植

如上面提到的,在網路上下載的v1版本oa_script.zip有缺檔案,另外找到v1.01版本oasys101.zip就完整了。

一開始我先在emsc環境試下po,看看有沒有很多err。還好不多,也很快就修正可以編譯出目標檔。

基本上以以下命令編譯

em++ OAI.C OALIB.C

然後試著用node執行看看,會有當掉的錯誤。原因是我先用下載的oac.exe編譯escape,但是下載下來的執行檔是古董程式,那時候的int是2個bytes。所以如果我用現在的編譯工具編譯oai.exe,新的oai會認為一個int是4個bytes。所以主要問題出在這裡。

想辦法用硬幹的方式修正後,也可以成功在console下看到print的遊戲文字了。但是emsc不能直接移植scanf,所以試po到此為止。

以以下命令執行編譯出來的程式

node a.out.js

4.2 建立oac和oai專案作改版

在Visual studio下建立oac和oai專案,之所有用vs建立專案只是方便移植,任何可以達到目的的方式或工具都可以。

建立vs的oac和oai專案後,用類似試po時的改法,先改版一個可以執行的版本。接著就是針對這個可以執行的版本作更多的跨平台改版。這裡只要針對oai改版即可,先不用管oac,除非我們之後還要可以編譯並執行usr輸入的程式,否則現在只要改版oai並一律使用目前可執行的oac的版本。

移植oai有兩個關鍵點。

第一點是載入檔案的地方都改成從byte stream載入,這樣方便在上層一次載入檔案資料再一次解析再執行。第二個地方是usr輸入的處理,只要把原本無窮的REPL迴圈改成事件驅動即可。

第一點沒有甚麼特別的,現在只說明一下第二個重點,其實很簡單。底下是原本的主REPL迴圈

LoadGame(argv[1]);
NewGame();
for (;;) {
  assert (sp == 0);
  if (!getinput ()) {
    continue;
  }
  command ();
  if (restart) {
    if (!getyn ("Would you like to play again? (Y/N) "))
      return 0;
    NewGame();
  }
}

現在只要改成當使用emsc編譯時,不要進入一個無窮的REPL迴圈,而時把這個迴圈打散,以事件趨動的方式每次從javascript那邊呼叫並傳遞進來作為usr輸入的字串時,再進行下一步的command解析處理。

#ifdef __EMSCRIPTEN__
LoadGameFromStream((char*)BIN);
NewGame();
getinput();
emscripten_exit_with_live_runtime();
#endif

BIN是將透過oac編譯好的escape遊戲的物件檔binary使用bin2c之類的工具轉成c程式碼裡的array,底下是ESCAPE.h的一個部份範例。

#ifndef ESCAPE_INC
#define ESCAPE_INC

const unsigned char BIN[] = {
0x6f,0x61,0x73,0x00,0x9f,0x01,0x00,0x00,0x0d,0x00,0x00,0x00,0x2a,0x4e,0x55,0x4c,
...
};

#endif // ESCAPE_INC

底下是對getinput的修改

void input (char *s)
{
  printx = printy = 0;
#ifdef __EMSCRIPTEN__
  // 不作事, 等待從javascript傳遞usr輸入字串.
#else
  gets (s);
#endif
}

int getinput_i(void)
{
  // 作原來的處理.
}

int getinput (void)
{
  print ("> ");
  input (buf);
#ifndef __EMSCRIPTEN__
  return getinput_i();
#else
  return FALSE;
#endif
}

至於提供給javascript的api,用來模擬usr輸入字串的cSendCmd如下

#ifdef __EMSCRIPTEN__
#include 
extern "C" {
int EMSCRIPTEN_KEEPALIVE cSendCmd(char *pBuff)
{
  strcpy(buf, pBuff);
  if (getinput_i()) {
    printf("%s\n", pBuff);
    command();
  }
  getinput();
  return 0;
}
} // extern "C"
#endif

另外javascript的部份,底下只列出怎麼呼叫cSendCmd模擬usr輸入

<script>
function sendCommand(e) {
  if (13 === e.keyCode || 14 === e.keyCode) {
    sendCommand_i();
  }
}

function sendCommand_i() {
  var el = document.getElementById('cmd');
  var cmd = el.value;
  el.value = '';
  if ('' == cmd) {
    return;
  }
  var ptr  = allocate(intArrayFromString(cmd), 'i8', ALLOC_NORMAL);
  Module.ccall('cSendCmd', 'number', ['number'], [ptr]);
  var resValue = Pointer_stringify(retPtr);
  _free(ptr);
}
</script>

這樣基本就完成了移植。

留言

這個網誌中的熱門文章

猜數字遊戲 (電腦猜人)

前幾天午睡時突然被告知要參加公司內部的程式設計比賽,題目是用C寫一支文字模式的4位數字猜數字遊戲,由使用者來猜電腦的數字。在上星期時其實就已經有公佈了,但我沒有注意到所以是臨時加入,還好這是個簡單的題目,不用花多少時間就可以寫出來。 規則: - 這是一對一比賽,雙方各選擇一4位數字,不讓對方知道。 - 4位數字由數字0至9組成,每位數不得重複。 - 雙方輪流猜對方的數字,直到一方猜中為止。 - A方猜B方的數字後,B方根據A方的猜測回答幾A幾B。 - 一個A表示猜中一個數字且位置正確,一個B表示猜中一個數字但位置不正確。 - 當一方猜中4A0B時即表示猜中對方全部4個數字且位置正確,贏得比賽。 - 例:B的謎底是4208,底下箭頭左測是A的猜測,箭頭右測是B的回答。    1234 ==> 1A1B    5678 ==> 1A0B    2406 ==> 1A2B    ...    4208 ==> 4A0B ; 寫個程式讓玩家來猜電腦的數字不難,不過我從來沒有寫過讓電腦來猜玩家數字的版本,所以花了點時間想想怎麼寫。 研究後歸納出二個點。 1, 使用窮舉法將所有可能數字組合列出。 2, 每次猜測後根據結果排除不可能是答案的組合,重複這個動作直到猜中答案為止。 第1點只是實作問題,第2點概念也很簡單,但要過濾不是答案的組合根據的是什麼?乍看之下沒什麼頭緒,不過想通之後就非常簡單了。 它的基本原理如下:假如謎底是4561,如果猜1524則會得到1A2B。從相反的角度來看,如果謎底是1524,則猜4561時也會得到1A2B的回答。 利用這個方法,每一次猜測一個數字X後,再以這個數字當作答案,來和所有剩下來的候選答案作比對,如果得到的結果(幾A幾B)和數字X是一樣的話,就把這個數字保留下來繼續作為候選答案,否則就過把這個數字過濾掉。下一把,繼續從候選答案裡選一個出來猜,重複上面的動作,直到猜中為止。 ; C++ STL的algorithm裡有個叫作next_permutation的函數,可以用來生成排列。 #include <iostream> #include <algorithm> using namespace std; int main () {   int myints[] = {1,2,3};  ...

KillSudoku 4顆星精彩數獨詳解 - 鍊技巧

這題數獨(sudoku)題目估計為4+顆星,有點難度。解題需要應用多種技巧,過程非常精彩有趣,是個好題。 底下使用 KillSudoku 作詳細圖解。 1,使用基本排除法則,可以簡單填入6個數字。到此為止,開始使用 候選數法 來解題。如下所示,為填入6個數字後的狀態圖。 2,如下圖,使用進階排除法,在第9列和第4行可以先排除幾個候選數。 3,如圖,在第2行有一個 Naked Subset (3,4),可以對3,4候選數作排除。附帶提一下,反過來看在同一行裡面也可以說有另一個Hidden Subset(2,5,8)存在。Naked Subset和Hidden Subset常是一體二面同時存在,只不過對我們來說,Naked Subset是相對比較容易看的出來。 排除第2行的3,4後,又可以對第2列以外的3作排除,如下圖。 4,接著,在第5行又發現了一個 Naked Subset (3,7,8)。 對第5行三個Subset以外的候選數3,7,8作排除後,又接著產生可以對第5行以外的3作排除。 5,這一題解到此為止,開始進入高潮。大部份能解到3顆星題目的人,猜想應該就此卡住。以下開始需要應用更高級的鍊技巧,才能夠繼續進行。 應用X-Chains鍊技巧,可以找到一條由4條強連結組成的鍊,可以排除候選數2。這裡的鍊指的是由2條以上的強連結組成,而所謂的強連結是指在同一行、或同一列或同一個Box裡,由唯二的候選數構成的連結。如上圖中的第9行中,只有二個2,這二個2構成一條強連結。為什麼說這是一條強連結?因為在這條連結的AB二個端點中,肯定會有一個2存在,要麼是A點要麼是B點。鍊技巧就是將多條強連結串連起來作候選數排除的技巧,而X-Chains是高級的鍊技巧裡面的基本技巧。 接上圖,這樣一來就又可以應用基本排除方法,填入3個數字,如下圖所示。 6,接下來就是本題最精彩的部份,以下需要連續找到3條鍊,才能繼續往下解。 7,找出3條鍊後,剩下來的部份就沒什麼特別的了,只需要應用基本法就能把所有剩餘數字填完。

KillSudoku 4顆星精彩數獨 (三) - XY-Chains

這是數獨解題技巧裡面的高級技巧,比X-Chains還再高一點點。會這個技巧的話,就可以解4或5顆星的題目了。 這個用來測試的題目,用 KillSudoku 來解可以解出,中間使用了2次Naked Subset,1次 W-Wings ,1次 X-Chains ,2次 XY-Chains 。所以算起來,這一題應該是有5顆星的題目。 附帶一提,目前找鍊的演算法並沒有去找一條最短的鍊,所以可以看到用 KillSudoku 解的時候,第36的步驟找到一條超長的鍊,這條鍊足足由13條連線構成,要是沒練過的話,絕對頭昏眼花,找不出這樣的鍊來的。 實際上在這個步驟裡,是可以找到另一條更短的鍊。不過目前以先能work,之後有空會再改進演算法的部份。