Godot入門到棄坑:繼續遊戲


3樓貓 發佈時間:2024-06-03 22:32:40 作者:cameLcAsE Language

遊戲中談的存檔就是一種數據持久化。遊戲在運行時會產生大量數據,如果沒有存檔功能,這些數據實際上在關閉遊戲之後——更別說關閉電腦之後就從內存中消失了。我們希望在下一次打開遊戲時,能夠恢復這些數據。因此最簡單的辦法就是把我們需要的遊戲數據保存到磁盤上——磁盤上的文件不像內存中的數據那樣,斷電後就沒有了。
也許你在日常生活中混同“內存”(RAM、memory)和“外存”(電腦上的各種硬盤、手機上的“內部存儲空間”),日常生活中,我覺得無所謂,但是這裡有必要加以區分。RAM(隨機存儲器)在中國大陸使用的簡體中文環境下一般稱內存,繁體中文常稱記憶體。它是計算機程序運行時程序自身和相關數據所使用的主要的存儲器,也就是主存儲器,亦稱主存。它的特點就是速度快,但是斷電後(關機後)數據就沒了。相應的,外存,或者說輔助存儲器,輔存。輔助存儲器一般來說速度會比內存慢,但是它容量大,且斷電後數據不會丟失。日常生活中常見的外存就是各種硬盤。不得不吐槽一句,“運行內存”這個編出來的說法實在是令人汗顏,它就好像是商家為了彌補長期誤用內存一詞造成的誤導,實在不行才編出來這樣一個說法。
我們每天都在做數據持久化,我們用的各種軟件啟動後會加載存在磁盤上的必要文件——即使這些數據是通過網絡獲得的,它在服務器上也得放在某種存儲設備上。當我們在軟件中新建一個文件,進行編輯,最後也希望把它保存在磁盤上便於下一次存取。
內存中的數據不是直接就可以從內存中挪動到外存中。即使在這個過程中有大量的程序會幫助我們在內存、操作系統、外村之間架起橋樑,我們仍然需要做出決定,我們要對哪些數據進行存儲,我們要以何種格式進行存儲。實際上走到這一步,我們就有數不盡的方法可以選擇。
下面以實現一個簡單的遊戲存檔讀檔為例,講解一下相關的知識。

確認要保存的數據

一般來說,我們不需要將整個遊戲的狀態(或者說整個運行的遊戲在內存中的狀態)保存到我們的存檔文件中。我們很多時候可能只需要保存一些具體的數據就可以在下次啟動遊戲時恢復狀態。
例如一些遊戲提供了固定的存檔點或者所謂的檢查點,玩家可以隨時從這個地方繼續遊戲。因此,我們可以在存檔中記錄玩家存檔時的位置,然後保存和玩家自身有關的狀態,比如HP、道具欄等等。
我們做一個簡單的存檔點——當然我這裡叫它檢查點,玩家碰到檢查點時遊戲就會自動保存。我們新建場景和腳本,相關的信號響應方法暫時留空。我用寶石的spritesheet給它做了個簡單的效果:
為了提示正在保存再給它一個簡單的UI讓它顯示Saving字樣,這裡簡單地加入一個Label,並默認隱藏。
腳本中沒有太複雜的代碼,主要是連接Area2D的body_entered信號,有玩家進入時我們會做出相應的保存操作。先稍微tween一下Label的各種屬性,具體的存檔代碼我們稍後實現。
我們這個遊戲目前比較簡單,實際上只需要保存一下玩家在哪個位置,當前有多少HP。碰到這個存檔點或者說檢查點的時候我們就進行保存。

善用資源

前面已經使用過的資源有一大特點就是它可以通過磁盤存取。這不正是我們想要的嗎?
我們來新建一個資源,叫它SaveData或者其它你喜歡的名字,並給它一個腳本。
和往常一樣,我們在這裡定義資源的各種屬性。這裡我們暫時只定義兩個變量,一個是玩家存檔時的位置,一個是玩家當時的HP:
到目前為止沒什麼特別的,甚至沒有用到任何新知識。

保存資源

我們希望在碰到存檔點時,可以保存玩家此時的狀態。因此在這裡我們就要保存(在必要時還需要新建)SaveData資源到磁盤上。
ResourceSaver類顧名思義就是用來保存資源的。ResourceSaver是一個單例(singleton)類,單例是一種設計模式(design pattern),一個用來實現單例模式的類至多只能有一個實例。單例有利於在任何時候任何地方訪問同一個東西——就像一個全局變量一樣,並且防止創建多個實例。當然,關於單例模式本身也有很多批評,這裡不多說。
ResourceSaver本身暴露出來的方法並不多,這裡主要是使用它的save方法來進行存檔。從文檔中可以看出,save方法是一個實例方法而不是靜態方法。

保存在哪裡

save方法有三個參數,第一個參數是要保存的資源,第二個參數是保存路徑,第三個是一些保存選項。我們著重看前兩個參數。
第一個參數是一個資源的實例,在我們這裡的問題中,就是一個SaveData的實例。存檔時直接調用new方法構造一個實例即可。
要在Godot中訪問(保存在磁盤上的)資源涉及兩種情況,一種是訪問Godot項目內部的各種資源,一種是遊戲運行時可能要訪問玩家電腦上的一些資源。
項目中的文件路徑以res://開頭。實際上可以在文件系統面板的樹形菜單最上方的根節點上看出這一點。例如:
這個資源文件的路徑就是"res://resources/save_data.tres" ,實際上你把文件拖到代碼編輯器中會自動轉換為對應的文件路徑。選中文件後右鍵菜單中也可以在操作系統的文件管理器中找到它。總之,res://對應的路徑就是Godot項目目錄的根目錄。
但是我們至今從來沒有通過文件路徑來加載一些資源,因為實際上在開發過程中我們可能隨時會移動各種資源文件的位置,所以在代碼中用寫死的文件路徑來引用資源很容易出現問題。當然,在一些可能需要在遊戲運行時通過代碼按照一定模式來動態加載資源時,我們可能還是要使用這種手段。
除了res路徑之外的另一種路徑就是user://路徑。從名字可以看出,它是用戶文件所在的目錄的路徑。它對應的操作系統路徑如圖,第一大行是它的默認路徑:
其中%APPDATA%是Windows上命令提示符引用環境變量的寫法(PowerShell是$env:APPDATA),APPDATA一般來說就對應著C:\Users\用戶名\AppData\Roaming。你也可以直接在文件資源管理器的地址欄中輸入%APPDATA%訪問。~是UNIX繫系統home文件夾的簡寫,macOS上就是那個圖標是個房子(home)的用戶文件夾。
當然,最簡單的找到這個文件夾的方法就是選擇編輯器菜單中的Project/Open User Data Folder。
默認路徑沒什麼問題,如果你想改成其他文件夾也可以在項目設置中啟用高級設置後在Config中修改。
在遊戲運行過程中res路徑下的文件可以讀,但是不能隨便寫,所以遊戲運行時要進行存取的一些用戶數據我們應當在user目錄下進行操作。
在SaveData中我們定義一個常量來指代存檔文件夾的路徑,然後又用另一個常量來指代存檔文件的路徑。這裡暫時只考慮單個存檔的情況,因此這裡存檔文件本身的路徑也用的一個常量來表示。注意,儘管SAVE_FILE_PATH的值是通過運算得出的,但是它也是一個合法的常量。因為參與運算的只有另一個常量和一個字符串字面量,它們都可以在代碼編譯時確定,因此這個表達式可以作為常量合法的初始值。
如果你想讓遊戲支持多個存檔,那麼這裡可能就要動態地根據存檔文件夾路徑來構造存檔文件名。
接下來給它寫一個save方法,它主要就是調用ResourceSaver的save方法來保存自己:
ResourceSaver的save方法有返回值。這個返回值的類型為Error,它是一個枚舉類型,對應著不同類型的錯誤。不過,Error的值為0的時候代表OK——沒有錯誤,所以這裡我把它的返回值取名為result而不是error來消除一些誤會。
剩下的就是在檢查點的腳本中,在玩家碰到時調用這個方法:
如果現在運行遊戲,走到檢查點旁邊,我們會發現報錯了。如果你嘗試print一下result的值會發現是19,對應著的就是無法打開文件的錯誤。GDScript的枚舉類型目前非常簡陋,在定義枚舉的文件外部操作枚舉值比較麻煩,沒法拿到枚舉類型的名稱,具體的值很多時候只能當成整數處理。不過這個Error枚舉類型實際上是在全局作用域中的,所以裡面具體的值在任何地方都可以訪問,比如OK直接寫出來也會有提示。直接把這些枚舉值當成數字顯示出來其實沒有多大用處,而且很麻煩。Godot提供了一個error_string函數,可以把Error的可選值轉換為字符串。
這裡可以簡單做一下錯誤處理,OS的alert方法可以調用操作系統彈一個窗口顯示一些警告信息。如果存儲過程中發生錯誤可以提示一下玩家。當然這裡如果真的發生了什麼錯誤,你其實很難解決。
這樣出現錯誤時玩家大概就知道是什麼問題。

避免文件夾不存在

當然這裡錯誤的原因很簡單,我們的存檔路徑中有一個save文件夾目前是不存在的。當然你可以自己建一個,但是遊戲運行時我們不能假設這個文件夾已經存在。
因此我們需要一種辦法來檢查文件夾存在與否,並且在不存在時創建文件夾。DirAccess類提供了各種文件夾訪問的方法。DirAccess的一些方法都提供了靜態方法和實例方法兩種版本,實例方法主要是在使用open方法成功打開一個文件夾後在它的基礎上進行操作。注意DirAccess的實例無法(不應該)通過new方法直接構造,一般來說都需要通過open方法來獲得DirAccess的實例。
對於我們的需求來說,我們首先需要檢查一個文件夾是否存在,所以我們需要通過靜態方法來檢查。DirAccess的dir_exists_absolute會檢查一個絕對路徑對應的文件夾是否存在。什麼是絕對路徑和相對路徑呢?例如,如果一個表示路徑的字符串中只有"file.txt"這樣的內容,它通常會被視作"./file.txt"。在主流的操作系統中,路徑中的點表示“當前目錄”,或者說得專業一點叫“當前工作目錄”(Current Working Directory),顧名思義就是這個程序目前正在哪個目錄下工作,很多操作會假定在這個目錄中進行。此外,兩個點表示上一級目錄。例如../file.txt表示當前目錄的上級目錄中的這個文件。以這種通配符開頭的路徑一般視作相對路徑,因為它們具體代表的路徑要根據當前目錄的相對關係來確定。
相應的,絕對路徑的開頭必須是一個確定的路徑。在Windows中一個絕對路徑應該以一個盤符開頭比如C:\Program Files\;在UNIX繫系統中(macOS也算),絕對路徑應該以根目錄開頭,比如/user/bin。需要注意的是,Windows的路徑分隔符和UNIX繫系統並不一樣,UNIX系是斜槓/,Windows是反斜槓\。但是實際上在前面的代碼中你也看到了,我用的也是斜槓。
實際上如今在常規的開發工作中可以忽略這一點,甚至應該優先考慮使用斜槓而不是反斜槓。現在的通用編程語言都會涉及在不同的操作系統上運行,它們最終會為你考慮這種差異。而反斜槓在很多編程語言的字符串字面量中都有特殊含義,也就是用來提示接下來的內容是一個轉義字符。所以在包括GDScript的編程語言中,字符串中的"C:\source\next"中的\s和\n會被視作兩個轉義字符而不是路徑的一部分。通常要防止反斜槓被轉義,必須寫成\\。所以這樣寫很麻煩。另一方面,Windows系統本身也能夠容忍路徑中的斜槓。在PowerShell中,在需要路徑作為參數的命令中如果以字符串形式傳入用斜槓表示的路徑它還是會正常執行的,甚至於你在文件資源管理器的地址欄中輸入以斜槓分割的路徑它也會正常操作。
言歸正傳,在Godot中,user和res實際上都會被Godot轉換成一個絕對路徑,所以這裡不用擔心。當我們發現這個save文件夾不存在時,我們就創建這個文件夾。利用DirAccess的make_dir方法即可創建文件夾:
在實際調用ResourceSaver的save方法之前的兩行代碼就可以保證我們的save文件夾是存在的,然後就不怕無法保存了。現在嘗試運行遊戲,碰到檢查點後相關代碼執行完畢後,我們就可以在對應的文件夾中看到這個文件,它就是我們的存檔文件。

加載資源

現在我們已經有了存檔,那麼如何讀檔呢。我們需要考慮的是,第一次啟動遊戲時我們必然沒有存檔文件,此時我們應當有所表示。
我在這裡選擇在沒有存檔文件時禁用繼續遊戲按鈕,你也可以選擇嘗試一下在沒有存檔文件時直接隱藏繼續遊戲按鈕。但是無論如何,我們需要判斷文件是否存在。
在主菜單場景的ready中檢查一下存檔文件是否存在。文件的操作有一個類似於DirAccess的FileAccess類,它也有個類似的檢查文件是否存在的方法:
那麼自然,如果發現存在有存檔文件,那麼點擊繼續按鈕時就需要加載這個資源。聰明的你可能想到,既然有個ResourceSaver那麼是不是有個ResourceLoader呢?
確實有,不過Godot提供了一個更簡單的load函數,大部分時候都可以用它完成工作。load函數唯一的參數就是資源的路徑。這裡我們在SaveData中添加一個load靜態方法,讓它調用全局函數load來加載我們的存檔。
不過先別急著修改SaveData的代碼。現在我們需要修改主場景的代碼讓它能夠處理存檔以繼續遊戲。其實最簡單粗暴的辦法就是進入場景後在ready中再次嘗試讀取存檔。但是實際情況中,我們的遊戲極有可能涉及多個關卡(場景),在存檔中我們會記錄玩到了那個場景,場景需要知道到底是從頭開始還是在存檔處繼續。
要解決這個問題其實也不止一種辦法。這裡先介紹一種簡單的辦法。在SaveData中定義一個靜態屬性用來表示“是否存在任何存檔”,同時再提供另一個靜態變量表示最近的存檔。存在存檔的情況一個是我們開始遊戲時加載了存檔,一個是我們在遊戲過程中保存過存檔。進入某個場景後我們可以直接訪問ever_saved屬性來檢查是否已經加載存檔。如果已經加載了存檔我們就通過latest_save屬性來獲得需要的信息。
還記得靜態成員的特點嗎?它們不會和某個類的實例相關聯,而是和類本身相關聯。這裡的ever_saved是一個靜態只讀屬性,只有getter沒有setter。它就是單純返回latest_save是不是null。這樣我們就不用每次都手動寫判斷條件了,也不用每次修改存檔時手動賦值了。
因此SaveData的load方法就是這樣:
同樣地,在保存存檔的save方法處我們也把新的存檔賦值給latest_save。
最後在主場景中修改ready的邏輯。由於玩家會在存檔點開始遊戲,所以不應該在一開頭的PlayerSpawner那裡生成。當然,這裡具體的做法依然取決於你的設計。這裡我直接簡單粗暴地把那個spawner的位置直接挪到存檔時的位置——偏左一點(以避免讀檔生成玩家後再次存檔,雖然即使這樣做了也無傷大雅),然後生成玩家並設置其hp。這樣一來讀檔後就算玩家死了也會在這個位置重生!
注意如果你和的代碼是一樣的的話,那麼最下面初始化HP指示物時應該把之前的max_hp改成hp,因為如果是讀檔開始遊戲的話玩家的hp應該是和存檔中的值保持一致。由於hp值默認為max_hp所以不用額外判斷。
最後,如果一個場景中有多個檢查點,我們可以在復活的時候先把player_spawner搬到最近一次存檔的檢查點附近:

資源的問題

功能基本實現了,但是我們還有一些問題。使用資源作為存檔文件是在Godot體系下很容易想到的一種解決方案。它的優點很明顯,它可以很好地集成到Godot中,可以直接在檢視面板中編輯它的屬性,也有各種內置的類和方法來進行存取,還可以用腳本給它提供各種各樣的方法。總之就是實現起來很簡單。
但是相應的,資源非常任意被篡改。以tres為後綴名保存的資源文件是文本形式的。如果你用記事本或者代碼編輯器打開你剛才的後綴名為tres的存檔文件,你會發現它就是非常普通的文本文件:
以文本形式保存的文件本身有很多好處,它們可以被人類識讀(但是有些時候是壞處),git等版本控制軟件也可以更好地跟蹤文件變化。但是這樣一來有些不懷好意的玩家可以隨意修改其中的hp甚至加入其他內容。
讀者朋友可能有所耳聞,那就是計算機無論如何最終還是在操作二進制數據,各種數據最終還是以二進制形式保存的。但是很多時候我們還是會將文件分為兩大類,那就是文本文件和二進制文件。不過文本文件不過是按照特定格式編碼的一些二進制文件,它們能夠被各種應用軟件按照對應的編碼解釋為人類看得懂的文本。我們常說的“打開方式不對”得到的亂碼很有可能就是強行將二進制文件以某種文字編碼打開後得到的錯誤結果(也有可能是以錯誤的文本編碼打開得到的)。
如果我們把之前腳本中的存檔文件的後綴名從tres改為res,保存得到的存檔文件就是二進制形式的,打開後可以看到數據部分是亂碼:
相比之下,這種格式可以一定程度上保護文件不被玩家隨意識讀並修改,但是這並不妨礙玩家根據Godot的源碼瞭解資源的二進制格式是如何的,然後自行解析並篡改。
如果你想給它加一下密要怎麼做呢?Godot也有內置的簡單加密方法。

簡單加密

這種情況下我們有無數種方法可以來加密這些存檔數據。不過Godot也內置了簡單的加密方法,這裡簡單給出一段代碼以供參考:
注意!上述代碼的寫法在Godot目前的版本中無法正常工作!預計在4.3之後的版本後可以正常運作。這個問題是已經確認且得到修復的bug。你也可以嘗試下載最新的4.3beta版來嘗試。
FileAccess的open_encrypted_with_pass可以通過一個密碼來存取一個加密文件。第一個參數依然是文件路徑,第二個參數ModeFlags代表要對文件進行哪種操作,第三個參數是密碼。此靜態方法返回一個FileAccess實例。
store_var顧名思義會在文件中存儲一個變量,它的第一個參數是任意對象,第二個參數將允許我們寫入複雜的對象(比如我們的自定義類和各種腳本),Godot會按照一定規則將其轉換為二進制數據。
隨後關閉文件並以讀取模式打開然後通過get_var獲得存入其中的數據。
FileAccess和ResourceLoader不一樣,它不止供資源使用,它是各種文件通用的類。因此存取數據的方法更復雜。為了暫時解決目前保存一整個自定義類型對象存在的問題,可以使用它提供的具體的數據存取方法來代替:
文件是按順序存取的,多次調用get_var也會按照store_var的順序返回其中的數據。

© 2022 3樓貓 下載APP 站點地圖 廣告合作:asmrly666@gmail.com