(文章略長,請先有心理準備)
# 3. 整合零碎片段
但是當我們將程式碼隨手撒在遊戲的每個角落,雖然方便,也運行正常,等到要改動的時候便麻煩了。小遊戲還好,可是隨作品越來越大,要找到特定內容就會逐漸困難。
如果我現在想要「在執行清除全部圖片之後,添加其他操作」,然而光介面就有十幾個事件,分散於上百張地圖,找都找不到,何況是修改。
### 統一修改
就好像我們將功能包裝為函數,是為了重覆使用,我們也可以把常用的語句包裝起來,使用時,就調用包裝後的函數名稱,而非再寫一遍。
但是我們應該定義在哪裡呢?編輯器中沒有地方可以定義全域函數。
這就要利用 RM 的插件系統了。這套插件系統其實很粗糙,就只是讀取了檔案之後直接執行而已,我們可以直接在插件中添加全域函數。
讓我們開一個檔案,也許叫 `functions.js` ,像下面這樣:
```js
// functions.js
function clearAllPicture() {
// ...
$gameScreen.clearPictures()
// ...
}
```
接著放入插件欄位,就可以直接使用 `clearAllPicture()` ,也能簡單地添加功能了。
### 以指令形式使用
在事件中寫 JavaScript 其實不太安全,它畢竟沒有語法提示、高亮等功能,不夠熟悉很可能會有錯字,想要保險,還得回到 VScode 之類的編輯器去寫。
此時做成插件指令就會是更好的選擇,在 MZ 中,不像 MV 仍需要一個字一個字輸入,MZ 可以用簡單的介面操作、選擇,而且也有不同的高亮提示,比一片灰的腳本段好讀很多。
要於 `functions.js` 加入指令列表非常容易,不一定要讀官方的註解系統介紹,你也可以複製預設插件或任何其它插件上方的注解整理為樣版,像這樣:
```js
/*:
* @target MZ
* @author 作者名稱
* @plugindesc 檔案概述
*
* @help 檔案詳細介紹。
*
* @param 插件參數的程式內名稱
* @text 插件參數在編輯器的顯示名稱
* @desc 參數描述
* @type 參數類型
* @default 預設值
*
* @command 指令在程式中用的名稱
* @text 指令在編輯器中顯示的名稱
* @desc 指令概述(因為編輯器視窗限制,只有兩行能夠顯示出來)。
*
* @arg 上面最後一個指令的參數名稱
* @text 顯示名稱
* @desc 參數概述(與指令概述一樣,只有兩行能夠顯示出來)。
* @type 參數類型
* @default 預設值
*/
```
稍微調整一下,讓它符合我們的需求,並且把我們剛才的函數註冊進插件指令系統裡:
```js
// functions.js
/*:
* @target MZ
* @author 竹鳥
* @plugindesc 快速函數集。
*
* @help
* 指令列表:
*
* — clearAllPicture:清除所有圖片
*
* @command clearAllPicture
* @text 清除所有圖片
* @desc 先做點別的事情,接著清除所有圖片。
*/
// 用箭頭函數包裝,這樣就不用再汙染全局命名空間了。
(() => {
// 這個參數需要保持與檔名一致,這樣指令系統才讀得到。
const filename = "functions"
// 我們忙了一段時間的函數。
function clearAllPicture() {
// ...
$gameScreen.clearPictures()
// ...
}
// 註冊進插件指令系統。
PluginManager.registerCommand(filename, "clearAllPicture", clearAllPicture)
})()
```
# 範例2:多背包插件
現在我們知道一個插件大概的模樣了:編輯器註解、註冊插件系統,還有功能本身。理論上,我們已經足以寫出自己的插件,不過第一次總是不太熟悉。就讓我們以一個簡單的功能來嘗試看看吧。
這次不是把事件腳本裡的片段摘出來變成插件了,我們將從頭開始。
### 確定目標與使用方法
假設我們有個遊戲,玩家需要在多個視角之間切換,可角色都換了,背包卻像超次元空間袋一樣共用,這實在不太合理。
首先,讓我們先只專注在「切換背包」這方面。不處理角色,以免功能太複雜。
限制目標很重要,即使龐大如 YEP ,也仍然是由多個小功能去組成一個大型單一插件。我們隨時可以把自己寫的插件整合成一個檔案,但若是一開始就把全部的功能混在一起寫,不僅可能難以切割分類,目標太大也容易讓人失去動力——就像我當初面對上萬行程式碼時一樣。
我打算讓使用者可以用「變數」與「直接指定文字」來切換,因為這是最簡單的形式,我們在前面也提過了取得變數的方法。
至於儲存背包資料,則是直接在存檔裡多寫一個屬性。不使用變數來存,是因為我們終究需要在玩家保存時一併儲存背包狀態,若想不涉及存檔系統,我們就得在發生任何更動時都保存進變數中,那也太麻煩、太浪費效能了。
### 探索程式碼
不過在真正動筆以前,我們需要知道自己將處理什麼東西。遊戲裡的背包如何儲存、如何使用?畫面圖片使用了陣列來裝 `Game_Picture` 類,背包也是如此嗎?
首先,按照前文查詢程式碼的經驗,我們可以先從 `rmmz_objects.js` 開始。
`Game_Item` 是個非常明顯的目標,就如同 `Game_Picture` 一樣, `Game_Item` 也是用來保存單個道具資訊的類,不過這裡的「Item」含義更廣泛,從技能、武器與道具,全都涵蓋其中。
同樣搜尋 `_item` ,可以在最底下那幾個結果找到 `Game_Party` ,程式將所有道具都存在裡面,我們有熟悉的初始化函數:
```js
Game_Party.prototype.initAllItems = function () {
this._items = {};
this._weapons = {};
this._armors = {};
};
```
幾乎就是 `Game_Screen.prototype.clearPictures` 的翻版,只不過這裡它改成了物件。
我們可以再往下找一點,從「程式碼如何使用它」來推測物件內部結構,不過這次就稍微偷懶一點,直接在遊戲裡看吧:
如圖可見,`_items` 是個鍵與值均為數字的物件,鍵代表道具編號、值代表持有數量。
我們再做個測試,只要改了它的值,道具欄也會一同變化(不過需要退出重進,因為道具欄不是直接使用這個數據)。
###### 關鍵道具
仔細看看,裡面似乎沒有關鍵道具?
這是因為關鍵道具(`keyItem`)也是道具(`item`)的一種,如果深入去尋找關鍵道具究竟如何在道具欄中顯示,那我們會發現,「一般道具」、「關鍵道具」、「隠藏道具A」與「隠藏道具B」只是在 `Game_Item.itypeId` 的值有所不同而已。
###### 存檔系統
至於存檔資料則是由 `DataManager` 所處理,在這裡為避免太過瑣碎,我就不詳細講述了,有興趣可以根據後文的程式碼來了解。
### 實現功能
為了從 `Game_Party` 中取值與賦值,我們先從以下函數開始。這部份雖然看起來很長很複雜,但這只是因為我把每個步驟都切割開來了,如果只看函數名稱,我相信會簡單很多:
```js
function GameBackpack(key) {
const pipe = (...fns) => (x) => fns.reduce((v, f) => f(v), x)
// 從插件專用參數中獲取與儲存背包資料。
const getSave = () => window[Birds][thisPlugin].backpackData
const setSave = allData => (
window[Birds][thisPlugin].backpackData = allData,
allData
)
// 從 `Game_Party` 中獲取與儲存資料。
const getNowBackpack = () => ({
items: $gameParty._items,
weapons: $gameParty._weapons,
armors: $gameParty._armors,
gold: $gameParty._gold,
})
const setNewBackpack = newBackpack => {
$gameParty._items = newBackpack.items
$gameParty._weapons = newBackpack.weapons
$gameParty._armors = newBackpack.armors
$gameParty._gold = newBackpack.gold
return newBackpack
}
// 返回標籤在資料中對應的值,不存在則使用預設㥀。
const getDataByKeyOrDefault = def => data => data[key] || def
// 合併目前的背包狀態。
const getAllBackpack = () => {
const now = getNowBackpack()
const all = getSave()
return Object.assign({}, all, { [all.useing]: now })
}
// 更新使用中背包的標籤。
const setNewUseingKey = all =>
Object.assign({}, all, { useing: key })
// 更換背包。
const useChange = pipe(
getAllBackpack,
setNewUseingKey,
setSave,
getDataByKeyOrDefault({ items: {}, weapons: {}, armors: {}, gold: 0 }),
setNewBackpack
)
// 儲存目前狀態。
const useSaveAll = pipe(
getAllBackpack,
setSave,
)
return {
useChange: useChange,
useSaveAll: useSaveAll,
}
}
```
其中第一個 `pipe` 函數在前文從沒出現過,對一些人來說可能較為複雜,不過一旦展開討論,便又會是另一篇長文,還請允許我跳過不提——只要知道它能返回一個函數,讓做為參數的函數按順序執行就可以了。
至於 `window[Birds][thisPlugin]` 則是用來管理與儲存我的每個插件,以避免它們和其他人所寫的插件混在一起,彼此干擾。你也可以換成任何你喜歡的位置,我只是為了以防萬一。
接著,修改存檔函數,讓我們將背包資料也裝進存檔中:
```js
const DataManager_SetupNewGame = DataManager.setupNewGame
const DataManager_MakeSaveContents = DataManager.makeSaveContents
const DataManager_ExtractSaveContents = DataManager.extractSaveContents
// 當建立新遊戲時,重置背包資料。
DataManager.setupNewGame = function () {
DataManager_SetupNewGame.call(this)
window[Birds][thisPlugin].backpackData = { useing: DEFAULT_BACKPACK_NAME }
}
// 當保存時,將背包存入存檔中。
DataManager.makeSaveContents = function () {
const content = DataManager_MakeSaveContents()
GameBackpack().useSaveAll()
content[savaDataKey] = {
backpackData: window[Birds][thisPlugin].backpackData,
}
return content
}
// 當讀取存檔時,以存檔內的資料覆蓋目前資料。
DataManager.extractSaveContents = function (content) {
DataManager_ExtractSaveContents(content)
window[Birds][thisPlugin].backpackData = content[savaDataKey].backpackData
}
```
最後,讓我們把這些功能與編輯器插件系統結合在一起:
```js
/**
* 檔案名稱。
*
* 如果你有更改檔名,請保持這個值與檔名一致,否則無法讀取到插件參數。
*/
const filename = "Bird_MultipleBackpacks"
const Birds = Symbol.for("Bird_Plugins")
const thisPlugin = Symbol.for(filename)
const savaDataKey = `${Birds.description}_${thisPlugin.description}`
// 取得使用者設定的插件參數
const userParams = PluginManager.parameters(filename)
const DEFAULT_BACKPACK_NAME = userParams["defaultName"] && userParams["defaultName"] !== ""
? userParams["defaultName"]
: "1"
// 定義插件命令功能
PluginManager.registerCommand(filename, "useChangeBackpackByVariable", args =>
GameBackpack($gameVariables.value(args.variableIndex)).useChange()
)
PluginManager.registerCommand(filename, "useChangeBackpackByString", args =>
GameBackpack(args.backpackName).useChange()
)
// 儲存插件資料。
window[Birds] = window[Birds] || {}
window[Birds][thisPlugin] = window[Birds][thisPlugin] || {
scriptName: filename,
backpackData: null,
}
```
以及加上讓編輯器能讀懂的標記:
```js
/*:
* @target MZ
* @author 竹鳥
* @plugindesc (1.0.0) 簡易多背包插件。
*
* @help
*
* # 功能簡介
*
* 單純讓使用者可以透過插件指令切換使用不同背包。
*
* @param defaultName
* @text 預設背包的名稱
* @desc 這是當開始新遊戲時,第一個背包所使用的名字。
* @type string
* @default 1
*
* @command useChangeBackpackByVariable
* @text 切換至變數對應的背包
* @desc 切換至變數對應的背包,如果不存在,則建立空背包。
*
* @arg variableIndex
* @text 變數編號
* @desc 要使用的變數
* @type variable
* @default 1
*
* @command useChangeBackpackByString
* @text 切換至文字對應的背包
* @desc 切換至文字對應的背包,如果不存在,則建立空背包。
*
* @arg backpackName
* @text 背包標籤
* @desc 要使用的背包標籤。
* @type string
* @default 1
*/
```
完成!
若想查看完整插件,你可以前往我在個人小屋中的[分享文章]。如果未來我有更新或除錯,將僅修改那篇插件文,不會同步訂正此處。
# 結語
這樣一來,我們就已經寫好一個插件了。也許我無法讓你成為撰寫大型戰鬥系統插件的大神,但至少現在,我希望可以讓你知道如何找到需要的東西,並且更順利地開始學習製作自己的作品。
因此,本文到此結束,祝你旅途愉快。
# 備註:編程工具包
也許你會覺得,「等等,這程式碼寫的都是什麼鬼?」
我確實聽過關於箭頭函數、三元運算符等簡寫需要熟悉才能快速理解的說法,而新手可能沒有太多接觸經驗,甚至可能根本沒有聽說過。這也是為什麼我寫了註解,希望讓讀者可以不用深入也能了解內容。
沒錯,我是故意的(雖然這確實是我平常的寫法)。我沒有用簡單易懂的 `while + if` 來處理循環邏輯判斷,而是把循環內容獨立為單一函數,還用了 `Array.reduce + => + ?:` 這種大雜燴。
對於 FizzBuzz 來說,它似乎過度複雜,官方程式碼也極少這麼寫。
這是因為我想突顯「編程風格」這個問題。僅管 FizzBuzz 只是個邏輯簡單、直接的題目,但仍然可以有多種不同的思路與解法,更別說是網路上各個插件作者想解決的問題了。
當我們想要處理困難問題,勢必得參考其他人的做法,從他人的插件中學習。因此,能夠看懂他人的思考方向相當重要,函數導向或物件導向只是個最粗淺的區分,但也是相對大眾化的起點。
而除了用來看懂別人的程式以外,各種「XX導向」也都只是種工具而已。
只有在畫家「知道」為什麼他要使用粉彩、漆墨時,這些表現手法才能產生意義;如果程式員敲下鍵盤,不是因為他知道在所有選擇中這種做法最為適當,而是因為他「只知道這麼寫」,那他遲早會遇到「理髮師難題」——超出了工具的極限。
有些問題以函數導向風格處理起來十分簡潔明暸,有些則更適合物件導向風格,強行使用不適合的工具,就像用鎯頭轉鏍絲一樣。
在這種情況下,我想,若能在平時就累積多種工具,寫起插件也能更順暢吧。
# 備註:不同的 FizzBuzz
以下我另外列出了三種 FizzBuzz 問題的解決方法, 使用各種不同的語言元素,按照難度排序,充作〈備註:編程工具包〉的補充範例。
JS 十分自由,沒有特別在語言規範層面遵循某種範式或寫法。不像 C# 與 JAVA 專為物件導向而設計,也不像 Haskell 、 Elm 專為函數導向而設計,更不存在如 Python 的 Python 之禪,因此每個人所寫的程式,可能會有不小區別。
以初學者來說,如果看不懂也十分正常。你可以放心跳過,或者藉由標題處的關鍵字來查詢了解——既然放在備註,它便並非屬於「不會就寫不了插件」的內容。
### Class/while
```js
class FizzBuzz {
constructor(rules, matchMethod) {
this._rules = {
map: rules,
key: Object.keys(rules),
}
this._match = matchMethod
}
getMapStringByNumber(num) {
return this._rules.key
.reduce((res, key) => this._match(num, key)
? (res || "") + this._rules.map[key]
: res
, null) || num.toString()
}
getMapStringByRange(num) {
let result = ""
while(num > 0){
result = this.getMapStringByNumber(num) + "\n" + result
num--
}
return result
}
}
const limit = $gameVariables.value(1)
const rules = {
3: "Fizz",
5: "Buzz",
}
function method(a, b){
return a % b === 0
}
const fizzBuzz = new FizzBuzz(rules, method)
const result = fizzBuzz.getMapStringByRange(limit)
$gameVariables.setValue(2, result)
```
### Map/for of
```js
const limit = $gameVariables.value(1)
const rules = new Map()
rules.set(v => v % 3 === 0, "Fizz")
rules.set(v => v % 5 === 0, "Buzz")
function fizzBuzz(num, ruleMap) {
let res = ""
for(let i = 1; i <= num; i++) {
let str = ""
for(method of ruleMap.keys())
if (method(i)) str += ruleMap.get(method)
res += (str === "" ? i.toString() : str) + "\n"
}
return res
}
const result = fizzBuzz(limit, rules)
$gameVariables.setValue(2, result)
```
### 遞歸
```js
const limit = $gameVariables.value(1)
const rules = {
3: "Fizz",
5: "Buzz",
}
function fizzBuzz(limit, rules) {
const keys = Object.keys(rules)
const matchMethod = num => (res, key) => num % key === 0
? res + rules[key]
: res
const recGetMapString = (num, res = "") => {
if (num <= 0) return res
const str = keys
.reduce(matchMethod(num), "")
return recGetMapString(
num - 1,
`${str === "" ? num.toString() : str}\n${res}`)
}
return recGetMapString(limit)
}
const result = fizzBuzz(limit, rules)
$gameVariables.setValue(2, result)
```