前言

一年多沒更新部落格了,原因是疫情期間《騎馬與砍殺2》發售,然後去寫遊戲MOD去了。

用C#大概寫了7個月的遊戲MOD,每天晚上肝到很晚,然後期間又因為介紹這個遊戲MOD,學習了PR,然後做起了B站的UP主。

再到後面有了些別的想法和公司業務調整,也懶得寫部落格,不知不覺一年多也就過去了。

收穫還是有的:

  • 比如在斷更這個MOD時,不論是在中文站還是3DM的MOD站,這個MOD的下載量都是排第一的,而且甩第二名相當遠。如果有玩《騎砍2》MOD的朋友,應該猜出來我是誰了。
  • 又比如在B站收穫了五千多粉絲,從一開始說話結結巴巴,到最後也還是說得結結巴巴。不過因為自己的剪輯,觀看效果也還不錯。
  • 又比如深刻認識到做個UP和主播有多麻煩,就我這拉胯的資料其實已經領先了B站很多UP主了。UP主中更多的不是頭部UP,而是視訊0播放的UP主。你可以看一下B站的最新視訊,翻了幾十頁全是0播放,極為壯觀。
  • 有趣的人生體驗增加了

好了,言歸正傳。

現在基本MOD斷更,UP主也懶得繼續認真做了。

這裡主要還是談一下技術相關的,也就是一個純前端實現,用於寫MOD的XML線上編輯器。

它是一個仿VSCode風格的編輯器,可以自動學習遊戲MOD檔案生成約束規則,幫助我們實現程式碼提示和程式碼校驗。

更重要的是它可以直接修改你電腦上的的檔案。

這是最終成品的程式碼倉庫:https://gitee.com/vvjiang/mod-xml-editor

以及一張成品展示圖:

本篇部落格所涉及到的技術:

  • CodeMirror
  • react-codemirror2
  • xmldom
  • FileReader
  • IndexDB
  • Web Worker
  • File System Access

讓我們從頭開始講起。

線上XML編輯器的需求

在做《騎砍2》的MOD時,需要經常寫XML檔案。

因為騎砍2的資料配置就是以XML的形式儲存,然後MOD載入後,用MOD的XML去覆蓋官方自己的XML。

通常我們做MOD資料這塊,就是參考官方的XML自己去寫XML檔案。

但是這樣會遇到一個問題,XML這東西沒有程式碼提示和程式碼校驗,寫錯一個字元也很難發現。

又或者有時候遊戲更新,它的XML規則可能會改動。

官方是不會發布通知告訴你這些改動點的,所以如果你還是用的以前的元素和屬性那就等於寫錯了。

寫錯的結果往往是遊戲載入MOD時直接崩潰,也不會給你任何提示,你只能慢慢去尋找BUG。

而騎砍2作為一個大型遊戲,每次啟動時間都很長,導致你測試一個MOD資料是否配置正確的測試流程會非常長。

媽耶,多少個夜晚,遊戲崩潰的那一瞬間,我人就崩潰了。

所以後來我就想著做一個XML線上編輯器去解決這個問題。

技術預研

視覺化程式設計

其實我一開始沒有做這個XML編輯器的想法,因為這玩意一看就難搞,而是想通過一個視覺化程式設計,通過拖拉拽元素和屬性的方式去實現。

你別說,我還真的做了一套初步方案出來,結果配置一個大型的XML這玩意拖拉拽無數次,心態逐漸爆炸,遂放棄此方案。

VSCODE外掛

想看看有沒有什麼VSCode外掛可以進行程式碼提示,有一個使用XSD進行程式碼校驗的,貌似還是IBM提供的。

但是很可惜已經廢棄,然後用不了了,放棄此方案。

線上編輯器

後來之所以使用線上編輯器的方式做這個,是因為三四月份公司這邊想要做一個線上編輯java專案環境xml配置檔案的一個東西。

然後我這邊就嘗試著做了一個,瞭解到了CodeMirror

CodeMirror通過自己配置tags來支援xml的程式碼提示,但是並不支援xml的程式碼校驗,所以需要自己去做xml的程式碼校驗。

並且因為通常我們去校驗xml用的是xsd,所以還需要將xsd轉換成CodeMirror的tags配置。

這個不論是百度Google,還是說Github,都是查不到相對應的方案,所以只能自己寫程式碼去實現。

在這個過程中,我對CodeMirrorxsdhtmllint都有了比較深的一個瞭解,最終完成了專案。

因為這是之前公司的程式碼,所以這裡就不放出來了。

總之,在這個過程中瞭解到CodeMirror這麼個東西,才有了用CodeMirror去做MOD的線上編輯器的想法。

最初形態:簡單的線上XML編輯器

好了,廢話不說,拿起鍵盤就是無腦幹。

最初形態沒有左側的檔案樹,只有一個單純的編輯器和一個規則學習彈框。

涉及到的技術就三個:

  • CodeMirror
  • FileReader
  • xmldom

用CodeMirror做編輯器

CodeMirror這塊主要使用的react的一個封裝版react-codemirror2,反正就是看文件和Demo自己配。

唯一的難度就是網上一大堆的CodeMirror配置介紹很多都是抄來抄去,轉載來轉載去,還是個錯的,簡直離譜。

總之你想玩的話最好還是看官方文件(https://codemirror.net/) 和文件上的Demo,然後自己研究下,抄別人配置的話水很深,你把握不住的。

我這裡貼一段我封裝的編輯器元件的配置程式碼吧,反正絕對可用,絕大多數編輯器的功能都OK,不過僅僅適用於編輯XML。

裡面的註釋比較詳盡了,包括常用的程式碼摺疊,程式碼格式化都有,我就懶得一一講了,你可以參考官網自己看看。

其中的一些引用程式碼我就不貼了,有興趣的可以去上面提到的程式碼倉庫看看。

import { useEffect } from 'react'
import { Controlled as ControlledCodeMirror } from 'react-codemirror2'
import CodeMirror from 'codemirror'
import 'codemirror/lib/codemirror.css'
import 'codemirror/theme/ayu-dark.css'
import 'codemirror/mode/xml/xml.js'
// 游標行程式碼高亮
import 'codemirror/addon/selection/active-line'
// 摺疊程式碼
import 'codemirror/addon/fold/foldgutter.css'
import 'codemirror/addon/fold/foldcode.js'
import 'codemirror/addon/fold/xml-fold.js'
import 'codemirror/addon/fold/foldgutter.js'
import 'codemirror/addon/fold/comment-fold.js'
// 程式碼提示補全和
import 'codemirror/addon/hint/xml-hint.js'
import 'codemirror/addon/hint/show-hint.css'
import './hint.css'
import 'codemirror/addon/hint/show-hint.js'
// 程式碼校驗
import 'codemirror/addon/lint/lint'
import 'codemirror/addon/lint/lint.css'
import CodeMirrorRegisterXmlLint from './xml-lint'
// 輸入> 時自動鍵入結束標籤
import 'codemirror/addon/edit/closetag.js'
// 註釋
import 'codemirror/addon/comment/comment.js' // 用於調整codeMirror的主題樣式
import style from './index.less' // 註冊Xml程式碼校驗
CodeMirrorRegisterXmlLint(CodeMirror) // 格式化相關
CodeMirror.extendMode("xml", {
commentStart: "<!--",
commentEnd: "-->",
newlineAfterToken: function (type, content, textAfter, state) {
return (type === "tag" && />$/.test(content) && state.context) ||
/^</.test(textAfter);
}
}); // 格式化指定範圍
CodeMirror.defineExtension("autoFormatRange", function (from, to) {
var cm = this;
var outer = cm.getMode(), text = cm.getRange(from, to).split("\n");
var state = CodeMirror.copyState(outer, cm.getTokenAt(from).state);
var tabSize = cm.getOption("tabSize"); var out = "", lines = 0, atSol = from.ch === 0;
function newline() {
out += "\n";
atSol = true;
++lines;
} for (var i = 0; i < text.length; ++i) {
var stream = new CodeMirror.StringStream(text[i], tabSize);
while (!stream.eol()) {
var inner = CodeMirror.innerMode(outer, state);
var style = outer.token(stream, state), cur = stream.current();
stream.start = stream.pos;
if (!atSol || /\S/.test(cur)) {
out += cur;
atSol = false;
}
if (!atSol && inner.mode.newlineAfterToken &&
inner.mode.newlineAfterToken(style, cur, stream.string.slice(stream.pos) || text[i + 1] || "", inner.state))
newline();
}
if (!stream.pos && outer.blankLine) outer.blankLine(state);
if (!atSol && i < text.length - 1) newline();
} cm.operation(function () {
cm.replaceRange(out, from, to);
for (var cur = from.line + 1, end = from.line + lines; cur <= end; ++cur)
cm.indentLine(cur, "smart");
cm.setSelection(from, cm.getCursor(false));
});
}); // Xml編輯器元件
function XmlEditor(props) {
const { tags, value, onChange, onErrors, onGetEditor, onSave } = props useEffect(() => {
// tags 每次變動時,都會重新改變校驗規則
CodeMirrorRegisterXmlLint(CodeMirror, tags, onErrors)
}, [onErrors, tags]) // 開始標籤
function completeAfter(cm, pred) {
if (!pred || pred()) setTimeout(function () {
if (!cm.state.completionActive)
cm.showHint({
completeSingle: false
});
}, 100);
return CodeMirror.Pass;
} // 結束標籤
function completeIfAfterLt(cm) {
return completeAfter(cm, function () {
var cur = cm.getCursor();
return cm.getRange(CodeMirror.Pos(cur.line, cur.ch - 1), cur) === "<";
});
} // 屬性和屬性值
function completeIfInTag(cm) {
return completeAfter(cm, function () {
var tok = cm.getTokenAt(cm.getCursor());
if (tok.type === "string" && (!/['"]/.test(tok.string.charAt(tok.string.length - 1)) || tok.string.length === 1)) return false;
var inner = CodeMirror.innerMode(cm.getMode(), tok.state).state;
return inner.tagName;
});
} return (
<div className={style.editor} >
<ControlledCodeMirror
value={value}
options={{
mode: {
name: 'xml',
// xml 屬性換行的時候是否加上標籤的長度
multilineTagIndentPastTag: false
},
indentUnit: 2, // 換行的預設縮排多少個空格
theme: 'ayu-dark', // 編輯器主題
lineNumbers: true,// 是否顯示行號
autofocus: true,// 自動獲取焦點
styleActiveLine: true,// 游標行程式碼高亮
autoCloseTags: true, // 在輸入>時自動鍵入結束元素
toggleComment: true, // 開啟註釋
// 摺疊程式碼 begin
lineWrapping: true,
foldGutter: true,
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter', 'CodeMirror-lint-markers'],
// 摺疊程式碼 end
extraKeys: {
// 程式碼提示
"'<'": completeAfter,
"'/'": completeIfAfterLt,
"' '": completeIfInTag,
"'='": completeIfInTag,
// 註釋功能
"Ctrl-/": (cm) => {
cm.toggleComment()
},
// 儲存功能
"Ctrl-S": (cm) => {
onSave()
},
// 格式化
"Shift-Alt-F": (cm) => {
const totalLines = cm.lineCount();
cm.autoFormatRange({ line: 0, ch: 0 }, { line: totalLines })
},
// Tab自動轉換為空格
"Tab": (cm) => {
if (cm.somethingSelected()) {// 選中後整體縮排的情況
cm.indentSelection('add')
} else {
cm.replaceSelection(Array(cm.getOption("indentUnit") + 1).join(" "), "end", "+input")
}
}
},
// 程式碼提示
hintOptions: { schemaInfo: tags, matchInMiddle: true },
lint: true
}}
editorDidMount={onGetEditor}
onBeforeChange={onChange}
/>
</div>
)
} export default XmlEditor

學習XML,並提取出tags規則

當我們使用CodeMirror做一個簡單的編輯器時,想要進行一個XML的程式碼提示,是需要使用到tags。

很明顯,不同的遊戲有不同的XML規則,包括遊戲更新之後XML規則也會更改。

所以我們必須要保證有一個機制去不斷地學習這些XML規則,所以這裡我做了一個學習XML檔案規則的彈窗去做這個事情。

點選編輯器左上方的 約束規則——>新增約束規則

會彈出這樣一個彈窗:

通過FileReader讀取指定資料夾的XML檔案,然後使用xmldom來依次解析這些xml檔案的文字,生成文件物件。

再分析這些文件物件得到最終的tags規則。

這一步驟只需要對xml有所瞭解,其實也蠻基礎的,所以不講了。

總之現在我們完成了它的最初形態,你每次使用它需要將你編輯的XML檔案內容複製到這個線上編輯器,編輯完後,再將完成的文字複製到原XML檔案儲存覆蓋。

進化形態:載入樹形檔案結構和全檔案校驗功能的線上XML編輯器

上面的編輯器其實使用場景非常窄,只能在新寫一個XML時使用。

一個MOD往往幾十上百,甚至幾千個檔案,不可能一個個貼上到編輯器中進行校驗。

所以我們需要在這個編輯器中,載入MOD的所有XML檔案,並進行一個程式碼校驗。

涉及到的技術就兩個:

  • FileReader
  • Web Worker

左側檔案樹

左側這個檔案樹使用Ant Design的Tree元件完成,這裡配置什麼的就不講了。

在點選開啟資料夾這個按鈕時

同樣使用FileReader來讀取MOD資料夾中的檔案。

但是FileReader獲取到的是一個檔案陣列,要想生成我們左側的樹形結構需要自己手動解析每個XML檔案的路徑,並據此生成一個樹形結構。

全檔案校驗功能

在開啟資料夾的一瞬間,我們需要對全部的XML檔案進行一次程式碼校驗,如果校驗有誤,需要在左側資料夾上將相關的檔案及它父級祖級的一系列資料夾全部標紅。

這個功能表面上很簡單,其實坑點很大,因為校驗的計算量實際上並不小,特別是你的MOD中有幾百幾千個檔案的時候,非常容易搞得你js阻塞,頁面無響應。

在這裡我使用了Web Worker新開一個執行緒去處理這個校驗過程,在校驗完成後將結果返回給我。

在這個過程中,我對Web Worker的使用也有了更多的瞭解。

印象中一直以為是一個new Worker(某js檔案)這樣的方式去玩,感覺很難結合react的模組化開發來使用。

但是實際上現在在webpack裡配置上worker-loader,可以很方便使用Web Worker

首先我們的worker程式碼可以寫成下面這樣:

import { lintFileTree } from '@/utils/files'

onmessage = ({ data }) => {
lintFileTree(data.fileTree, data.currentTags).then(content => {
postMessage(content)
})
}

然後我們使用這個Worker時,可以如下所示

import { useWebWorkerFromWorker } from 'react-webworker-hook'
import lintFileTreeWorker from '@/utils/webWorker/lintFileTree.webworker' const worker4LintFileTree = new lintFileTreeWorker() const [lintedFileTree, startLintFileTree] = useWebWorkerFromWorker(worker4LintFileTree)

然後你再用個useEffect依賴這個lintedFileTree,如果變動了就做某些操作,所以寫起來就像用useState一樣輕鬆。

非遞迴遍歷樹

大家可以看到上面我們用到的這些東西,很多都與樹相關,比如遍歷檔案樹去校驗程式碼。

又或者我們切換了某個約束規則後,也是需要遍歷整個檔案樹進行重新校驗的。

遍歷的過程中,之前我用的是遞迴遍歷整個樹,這樣做不好的地方在於遞迴的時候記憶體得不到釋放,所以後來我換了一種演算法,採用非遞迴的方式遍歷整個樹。

IndexDB儲存檔案內容

因為我們的MOD檔案內容比較多比較大,所以記憶體佔用可能會很大,不可能一直把這些檔案內容放到記憶體中。

所以我讀取到檔案內容會依次放入IndexDB中,只展示當前編輯檔案的內容。

只有在需要的時候,比如全檔案校驗或者切換檔案時,才從IndexDB再次獲取檔案內容。

究極進化形態:突破瀏覽器沙盒限制,實現對電腦本地檔案的增刪改

通過之前的操作,我們終於完成了一個基本可用的線上XML編輯器。

但是它有一個致命缺點,就是受到瀏覽器沙盒環境的限制,我們在修改了檔案後,沒法直接儲存到電腦上,而必須依靠手動將修改好的程式碼一一複製到對應的檔案中。

這個操作繁瑣複雜,導致我們編輯器的功能可能只能用來輔助編寫程式碼和批量校驗。

之前我以為只能做到這種程度,但是後來我在知乎上偶然看了一個帖子,發現Chrome86+的版本多了一個功能API:FileSystemAccess

另外,除非是本地localhost環境,否則這個API只在https環境下才能呼叫,也就是說你在一個http的網站上,即使你用的是Chrome86+或者是Edge86+,那也是調用不了的。

這個API可以讓我們直接操作本地電腦上的檔案,而不是像FileReader一樣只能讀,或者像FileSystem一樣只能在瀏覽器沙盒內操作。

通過FileSystemAccess我們不僅可以實現對資料夾中的檔案進行讀取修改,還能新增和刪除檔案。

所以我使用這個API全面替換了之前使用FileReader的各個點,實現了在檔案樹上右鍵進行資料夾和檔案的新增和刪除。(這裡是不支援對檔案進行重新命名的,不過其實我們可以使用刪除後再新增的方式來模擬重新命名,但是我就懶得做了)

同時在按儲存按鈕或者按儲存的快捷鍵Ctrl+S後,就可以直接對檔案進行儲存操作。

下面是一個使用FileSystemAccess開啟資料夾的元件程式碼:

    import React from 'react'

    // 自定義的開啟資料夾元件
const FileInput = (props) => {
const { children, onChange } = props
const handleClick = async () => {
const dirHandle = await window.showDirectoryPicker()
dirHandle.requestPermission({ mode : "readwrite" })
onChange(dirHandle)
}
return <span onClick={handleClick}>
{children}
</span>
} export default FileInput

只要被這個元件包裹的元素(比如按鈕)被點選後,會立即呼叫showDirectoryPicker,請求開啟資料夾。

在開啟資料夾後,通過獲得的資料夾handle去請求資料夾寫入許可權,然後再把這個資料夾handle傳到外部,獲取檔案樹結構。

這裡的操作是有瑕疵的,因為請求開啟資料夾時瀏覽器會彈框向用戶獲取讀取資料夾的許可權,

開啟完畢後又直接會彈第二次框獲取寫入許可權,也就是說在開啟資料夾時會彈兩次框。

但是我也只能通過這種手法一次性請求到所有的許可權,要不然等到要儲存時再去請求許可權也不太好。

不過瑕不掩瑜,通過這個API不僅實現了檔案的增刪改,還解除了對IndexDB的使用。

因為我們隨時可以通過檔案Handle獲取到相應的檔案內容,所以沒必要將檔案內容儲存到IndexDB中。

更多的功能與細節

以上我只是對技術上的核心功能進行了概述,實際上這個編輯器還有N多的細節。

比如調整tags規則的面板,比如那些工具欄的按鈕,比如對dva的簡單封裝處理,比如對xml進行分析時,如果屬性值是數字,那麼就不進行提示,而是直接忽略,因為數字往往沒太大意義而且列舉值太大。

這一切的一切,都太多太多,但是它們的應用都比較基礎,所以不想贅述細節,否則這篇部落格就會變得非常長,而且難以突出核心思路。

不足與總結

這裡的不足更多的是因為懶,比如之前說的資料夾和檔案重新命名功能,還有調整tags規則的自定義規則那裡不支援修改刪除。

可以實現,只是懶得做了。

這個東西前前後後做了幾個月,也不是說每天晚上都在寫這個,主要是有靈感了就來寫一下,或者發現哪裡可以更好地改進一下就再寫一下。

合起來約摸著有兩三週的每個晚上在做這個事情,然後當它愈加趨近於完善和可用時,就愈加懶得做了。

因為剩下的操作不太重要,且腦補一下就可以完成,沒有太多有挑戰性的地方了。

不過總體來說,這個東西現在的可用性還是很強的。

不僅僅可以用於《騎馬與砍殺2》、《了不起的修真模擬器》、《文明6》等一系列遊戲的XML檔案的輔助編寫,還可以用於那些沒有XSD規則,又過於複雜的XML配置,甚至它還可以學習你自定義的XML規則。

本篇部落格就到此結束了,如有疏漏之處,也希望大家不吝賜教。