PageSourceSearch

https://landgris.github.io/ROCalculator/js/skill_tree_data.js?v=3

js landgris.github.io collected 2026-10-03 09:29:26 UTC 18,234 bytes, 359 lines download raw bytes

1/*
2 * 技能樹資料載入/正規化。
3 *
4 * 執行期直接 fetch data/grf/zero/*.lua(Big5 編碼)用 lua_table_parser.js
5 * 解析,不做離線轉檔——官方改版增加技能時,直接覆蓋這幾個 lua 檔重新整理
6 * 頁面即可生效。
7 *
8 * 對外只提供這份 SkillTreeData 物件;技能樹 dialog(skill_tree_view.js)
9 * 透過它拿資料,不直接碰 lua_table_parser。
10 */
11(function (global) {
12    'use strict';
13
14    var COLS_PER_ROW = 7;
15    var COLOR_CODE_RE = /\^[0-9A-Fa-f]{6}/g;
16
17    // 等級從 1 開始(Lv1 不用花點數),所以能實際花的點數 = 該轉職最高等級 - 1。
18    // 下面兩份表先照「最高等級」寫,再統一用這個函式轉成「可花點數」,這樣之後
19    // 對照官方等級表修正數字時,直接改最高等級就好,不用每次手動再減 1。
20    function levelToPoints(map) {
21        var out = {};
22        Object.keys(map).forEach(function (k) { out[k] = map[k] - 1; });
23        return out;
24    }
25
26    // ==================== Zero 資料來源 ====================
27    // 每個 JOBID 頁的「最高等級」(見 docs/SKILLTREE_DIALOG_PLAN.md 第三節)
28    var ZERO_MAX_LEVEL = {
29        JT_SWORDMAN: 50, JT_MAGICIAN: 50, JT_ARCHER: 50, JT_ACOLYTE: 50, JT_MERCHANT: 50, JT_THIEF: 50,
30        JT_KNIGHT: 70, JT_PRIEST: 70, JT_WIZARD: 70, JT_BLACKSMITH: 70, JT_HUNTER: 70, JT_ASSASSIN: 70,
31        JT_CRUSADER: 70, JT_MONK: 70, JT_SAGE: 70, JT_ROGUE: 70, JT_ALCHEMIST: 70, JT_BARD: 70, JT_DANCER: 70,
32        JT_REBELLION: 70, JT_KAGEROU: 70, JT_OBORO: 70, JT_NINJA: 70, JT_GUNSLINGER: 70,
33        JT_SUPERNOVICE: 99, JT_SUPERNOVICE2: 70
34    };
35    var ZERO_POINT_CAP = levelToPoints(ZERO_MAX_LEVEL);
36
37    // 1轉頁 -> 2轉頁 -> 計算機 classes.json 的最終職業代碼。
38    // 已用 classes.json 實際的 label(如 RK="騎士/盧恩騎士/盧恩龍爵")核對過,
39    // 見 docs/SKILLTREE_DIALOG_PLAN.md 第二節。
40    // SE/SL/SUM/DR 是太極/召喚師/特殊職系,Zero 資料沒有對應內容,不列入。
41    var ZERO_LINEAGE = [
42        { finalClass: 'RK', stages: ['JT_SWORDMAN', 'JT_KNIGHT'] },
43        { finalClass: 'RG', stages: ['JT_SWORDMAN', 'JT_CRUSADER'] },
44        { finalClass: 'WL', stages: ['JT_MAGICIAN', 'JT_WIZARD'] },
45        { finalClass: 'SO', stages: ['JT_MAGICIAN', 'JT_SAGE'] },
46        { finalClass: 'RA', stages: ['JT_ARCHER', 'JT_HUNTER'] },
47        { finalClass: 'MI', stages: ['JT_ARCHER', 'JT_BARD'] },
48        { finalClass: 'WA', stages: ['JT_ARCHER', 'JT_DANCER'] },
49        { finalClass: 'AB', stages: ['JT_ACOLYTE', 'JT_PRIEST'] },
50        { finalClass: 'SU', stages: ['JT_ACOLYTE', 'JT_MONK'] },
51        { finalClass: 'ME', stages: ['JT_MERCHANT', 'JT_BLACKSMITH'] },
52        { finalClass: 'GE', stages: ['JT_MERCHANT', 'JT_ALCHEMIST'] },
53        { finalClass: 'GX', stages: ['JT_THIEF', 'JT_ASSASSIN'] },
54        { finalClass: 'SC', stages: ['JT_THIEF', 'JT_ROGUE'] },
55        { finalClass: 'RE', stages: ['JT_GUNSLINGER'/*, 'JT_REBELLION'*/] },
56        { finalClass: 'KO', stages: ['JT_NINJA'/*, 'JT_KAGEROU'*/] },
57        { finalClass: 'OB', stages: ['JT_NINJA'/*, 'JT_OBORO'*/] },
58        { finalClass: 'SN', stages: ['JT_SUPERNOVICE'/*, 'JT_SUPERNOVICE2'*/] }
59    ];
60
61    // ==================== 標準版(轉職制/含三轉四轉)資料來源 ====================
62    // 以下數字是「該轉職最高等級」(使用者確認):1轉/2轉(非轉生) 50,3轉 70,4轉 60;
63    // 忍者/格鬥槍手系 70/70/60;拳聖系、悟靈系 50/50/70/60(跟六大職業同一套數字);
64    // 召喚師系(魂靈師)只有 60/60。轉生二轉(_H 結尾那些頁)不使用、不列入 lineage。
65    // 太極系(使用者確認):JT_TAEKWON=50,之後不管選拳聖線還是悟靈線,第二頁都是50
66    // (JT_STAR=50、JT_LINKER=50),之後拳聖線的 JT_STAR_EMPEROR 沿用60(沒有第三頁可用70)。
67    // 實際可花的技能點 = 最高等級 - 1(Lv1 不用花點),由下面 levelToPoints() 統一轉換。
68    var STANDARD_MAX_LEVEL = {
69        // 六大職業:1轉/2轉 50,3轉 70,4轉 60
70        JT_SWORDMAN: 50, JT_MAGICIAN: 50, JT_ARCHER: 50, JT_ACOLYTE: 50, JT_MERCHANT: 50, JT_THIEF: 50,
71        JT_KNIGHT: 50, JT_CRUSADER: 50, JT_WIZARD: 50, JT_SAGE: 50, JT_HUNTER: 50, JT_BARD: 50, JT_DANCER: 50,
72        JT_PRIEST: 50, JT_MONK: 50, JT_BLACKSMITH: 50, JT_ALCHEMIST: 50, JT_ASSASSIN: 50, JT_ROGUE: 50,
73        JT_RUNE_KNIGHT: 70, JT_ROYAL_GUARD: 70, JT_WARLOCK: 70, JT_SORCERER: 70, JT_RANGER: 70,
74        JT_MINSTREL: 70, JT_WANDERER: 70, JT_ARCHBISHOP: 70, JT_SURA: 70, JT_MECHANIC: 70, JT_GENETIC: 70,
75        JT_GUILLOTINE_CROSS: 70, JT_SHADOW_CHASER: 70,
76        JT_DRAGON_KNIGHT: 60, JT_IMPERIAL_GUARD: 60, JT_ARCH_MAGE: 60, JT_ELEMENTAL_MASTER: 60,
77        JT_WINDHAWK: 60, JT_TROUBADOUR: 60, JT_TROUVERE: 60, JT_CARDINAL: 60, JT_INQUISITOR: 60,
78        JT_MEISTER: 60, JT_BIOLO: 60, JT_SHADOW_CROSS: 60, JT_ABYSS_CHASER: 60,
79        // 忍者 / 神槍手系:70/70/60
80        JT_NINJA: 70, JT_KAGEROU: 70, JT_SHINKIRO: 60,
81        JT_OBORO: 70, JT_SHIRANUI: 60,
82        JT_GUNSLINGER: 70, JT_REBELLION: 70, JT_NIGHT_WATCH: 60,
83        // 跆拳:JT_TAEKWON=50 兩條線共用;拳聖線 JT_STAR=50、JT_STAR_EMPEROR=60;
84        // 悟靈線 JT_LINKER=50、JT_SOUL_ASCETIC=70、JT_SOUL_REAPER=60
85        JT_TAEKWON: 50,
86        JT_STAR: 50, JT_STAR_EMPEROR: 60,
87        JT_LINKER: 50, JT_SOUL_ASCETIC: 70, JT_SOUL_REAPER: 60,
88        // 召喚師系(魂靈師):60/60
89        JT_DO_SUMMONER: 60, JT_SPIRIT_HANDLER: 60,
90        // SuperNovice
91        JT_SUPERNOVICE: 99, JT_SUPERNOVICE2: 70, JT_HYPER_NOVICE: 60
92    };
93    var STANDARD_POINT_CAP = levelToPoints(STANDARD_MAX_LEVEL);
94
95    var STANDARD_LINEAGE = [
96        { finalClass: 'RK', stages: ['JT_SWORDMAN', 'JT_KNIGHT', 'JT_RUNE_KNIGHT', 'JT_DRAGON_KNIGHT'] },
97        { finalClass: 'RG', stages: ['JT_SWORDMAN', 'JT_CRUSADER', 'JT_ROYAL_GUARD', 'JT_IMPERIAL_GUARD'] },
98        { finalClass: 'WL', stages: ['JT_MAGICIAN', 'JT_WIZARD', 'JT_WARLOCK', 'JT_ARCH_MAGE'] },
99        { finalClass: 'SO', stages: ['JT_MAGICIAN', 'JT_SAGE', 'JT_SORCERER', 'JT_ELEMENTAL_MASTER'] },
100        { finalClass: 'RA', stages: ['JT_ARCHER', 'JT_HUNTER', 'JT_RANGER', 'JT_WINDHAWK'] },
101        { finalClass: 'MI', stages: ['JT_ARCHER', 'JT_BARD', 'JT_MINSTREL', 'JT_TROUBADOUR'] },
102        { finalClass: 'WA', stages: ['JT_ARCHER', 'JT_DANCER', 'JT_WANDERER', 'JT_TROUVERE'] },
103        { finalClass: 'AB', stages: ['JT_ACOLYTE', 'JT_PRIEST', 'JT_ARCHBISHOP', 'JT_CARDINAL'] },
104        { finalClass: 'SU', stages: ['JT_ACOLYTE', 'JT_MONK', 'JT_SURA', 'JT_INQUISITOR'] },
105        { finalClass: 'ME', stages: ['JT_MERCHANT', 'JT_BLACKSMITH', 'JT_MECHANIC', 'JT_MEISTER'] },
106        { finalClass: 'GE', stages: ['JT_MERCHANT', 'JT_ALCHEMIST', 'JT_GENETIC', 'JT_BIOLO'] },
107        { finalClass: 'GX', stages: ['JT_THIEF', 'JT_ASSASSIN', 'JT_GUILLOTINE_CROSS', 'JT_SHADOW_CROSS'] },
108        { finalClass: 'SC', stages: ['JT_THIEF', 'JT_ROGUE', 'JT_SHADOW_CHASER', 'JT_ABYSS_CHASER'] },
109        { finalClass: 'RE', stages: ['JT_GUNSLINGER', 'JT_REBELLION', 'JT_NIGHT_WATCH'] },
110        { finalClass: 'KO', stages: ['JT_NINJA', 'JT_KAGEROU', 'JT_SHINKIRO'] },
111        { finalClass: 'OB', stages: ['JT_NINJA', 'JT_OBORO', 'JT_SHIRANUI'] },
112        { finalClass: 'SN', stages: ['JT_SUPERNOVICE', 'JT_SUPERNOVICE2', 'JT_HYPER_NOVICE'] },
113        { finalClass: 'SE', stages: ['JT_TAEKWON', 'JT_STAR', 'JT_STAR_EMPEROR'] },
114        { finalClass: 'SL', stages: ['JT_TAEKWON', 'JT_LINKER', 'JT_SOUL_ASCETIC', 'JT_SOUL_REAPER'] },
115        { finalClass: 'SUM', stages: ['JT_DO_SUMMONER', 'JT_SPIRIT_HANDLER'] }
116    ];
117
118    // 兩份資料來源的總表:basePath 對應 data/grf/<xxx>/ 的資料夾名稱
119    var DATASETS = {
120        zero: { key: 'zero', label: 'Zero', basePath: 'grf/zero/', pointCap: ZERO_POINT_CAP, lineage: ZERO_LINEAGE },
121        standard: { key: 'standard', label: '四轉服版本', basePath: 'grf/standard/', pointCap: STANDARD_POINT_CAP, lineage: STANDARD_LINEAGE }
122    };
123
124    function stripColorCodes(s) {
125        return typeof s === 'string' ? s.replace(COLOR_CODE_RE, '').trim() : s;
126    }
127
128    // hybrid array(具名屬性 + 數字 index)-> 純 JS 陣列,只取連續數字 index 部分
129    function toCleanArray(raw) {
130        if (!raw) return [];
131        var out = [];
132        var i = 0;
133        while (raw[i] !== undefined) { out.push(raw[i]); i++; }
134        return out;
135    }
136
137    // NeedSkillList 的原始 pair 陣列 -> [{skid, level}](沒給等級的視為 1)
138    function normalizeNeedList(raw) {
139        return toCleanArray(raw).map(function (p) {
140            var arr = toCleanArray(p);
141            return { skid: arr[0], level: (arr.length > 1 ? arr[1] : 1) };
142        });
143    }
144
145    async function fetchBig5Text(url) {
146        var res = await fetch(url);
147        if (!res.ok) throw new Error('HTTP ' + res.status + ' ' + url);
148        var buf = await res.arrayBuffer();
149        return new TextDecoder('big5').decode(buf);
150    }
151
152    // delayRaw:LuaTableParser.parse() 出來的 SKILL_DELAY_LIST,{skid: {SkillCastFixedDelay, ...}}
153    // -> {skid: {fixedDelay[], statDelay[], singlePostDelay[], globalPostDelay[]}}(單位 ms,陣列 index 0 對應 Lv1)
154    function parseSkillDelay(delayRaw) {
155        var skillDelay = {};
156        Object.keys(delayRaw).forEach(function (skid) {
157            var raw = delayRaw[skid];
158            skillDelay[skid] = {
159                fixedDelay: toCleanArray(raw.SkillCastFixedDelay),
160                statDelay: toCleanArray(raw.SkillCastStatDelay),
161                singlePostDelay: toCleanArray(raw.SkillSinglePostDelay),
162                globalPostDelay: toCleanArray(raw.SkillGlobalPostDelay)
163            };
164        });
165        return skillDelay;
166    }
167
168    // 只載入/解析 skilldelaylist.lua(給主畫面拿官方詠唱延遲用,不需要技能樹的其他三個檔案)。
169    // 缺檔案(例如標準版/四轉沒有提供)回傳空物件,呼叫端退回手填的 FCT/VCT/CD/GCD 即可。
170    async function loadSkillDelayList(basePath) {
171        try {
172            var text = await fetchBig5Text(basePath + 'skilldelaylist.lua');
173            var delayRaw = LuaTableParser.parse(text, {}).SKILL_DELAY_LIST || [];
174            return parseSkillDelay(delayRaw);
175        } catch (e) {
176            console.warn('[SkillTreeData] skilldelaylist.lua 無法載入(' + e.message + ')', e);
177            return {};
178        }
179    }
180
181    async function loadSkillTreeData(basePath) {
182        var warnings = [];
183        var onWarning = function (msg) { warnings.push(msg); console.warn('[SkillTreeData]', msg); };
184
185        // skilldelaylist.lua 不是每個資料來源都有(例如標準版/四轉沒有提供),
186        // 缺檔案就當作沒有詠唱延遲資料,不要讓整份技能樹都載入失敗。
187        var texts = await Promise.all([
188            fetchBig5Text(basePath + 'skilltreeview.lua'),
189            fetchBig5Text(basePath + 'skillinfolist.lua'),
190            fetchBig5Text(basePath + 'skilldescript.lua'),
191            fetchBig5Text(basePath + 'skilldelaylist.lua').catch(function (e) {
192                onWarning('skilldelaylist.lua 無法載入(' + e.message + '),此資料來源將略過詠唱延遲資訊');
193                return '';
194            })
195        ]);
196
197        var treeRaw = (LuaTableParser.parse(texts[0], { onWarning: onWarning }).SKILL_TREEVIEW_FOR_JOB) || [];
198        var infoRaw = (LuaTableParser.parse(texts[1], { onWarning: onWarning }).SKILL_INFO_LIST) || [];
199        var descRaw = (LuaTableParser.parse(texts[2], { onWarning: onWarning }).SKILL_DESCRIPT) || [];
200        var delayRaw = (LuaTableParser.parse(texts[3], { onWarning: onWarning }).SKILL_DELAY_LIST) || [];
201
202        // ---- skillTreeByJob: {jobKey: [{col,row,colInRow,skid}]}(依 col 排序,去除 hole)----
203        var skillTreeByJob = {};
204        Object.keys(treeRaw).forEach(function (jobKey) {
205            if (jobKey === 'JT_NOVICE') return; // 不使用新手職業頁
206            var cols = treeRaw[jobKey];
207            var entries = [];
208            Object.keys(cols).forEach(function (colStr) {
209                var col = Number(colStr);
210                if (isNaN(col) || cols[col] === undefined) return;
211                entries.push({ col: col, row: Math.floor(col / COLS_PER_ROW), colInRow: col % COLS_PER_ROW, skid: cols[col] });
212            });
213            entries.sort(function (a, b) { return a.col - b.col; });
214            skillTreeByJob[jobKey] = entries;
215        });
216
217        // ---- skillInfo: {skid: {maxLv, spAmount[], needSkillListDefault, needSkillListOverrides}} ----
218        var skillInfo = {};
219        Object.keys(infoRaw).forEach(function (skid) {
220            var raw = infoRaw[skid];
221            var overrides = {};
222            if (raw.NeedSkillList) {
223                Object.keys(raw.NeedSkillList).forEach(function (jobKey) {
224                    overrides[jobKey] = normalizeNeedList(raw.NeedSkillList[jobKey]);
225                });
226            }
227            skillInfo[skid] = {
228                skid: skid,
229                rawName: raw[0],
230                maxLv: raw.MaxLv || 1,
231                spAmount: toCleanArray(raw.SpAmount),
232                needSkillListDefault: normalizeNeedList(raw._NeedSkillList),
233                needSkillListOverrides: overrides
234            };
235        });
236
237        // ---- skillDescript: {skid: {nameZh, nameEn, lines[]}}(去色碼,第一行拆中英名)----
238        var skillDescript = {};
239        Object.keys(descRaw).forEach(function (skid) {
240            var lines = toCleanArray(descRaw[skid]).map(stripColorCodes);
241            var nameLine = lines[0] || skid;
242            var m = /^(.*?)[((](.*)[))]\s*$/.exec(nameLine);
243            var nameZh = nameLine, nameEn = '';
244            if (m) { nameZh = m[1].trim(); nameEn = m[2].trim(); }
245            skillDescript[skid] = { nameZh: nameZh, nameEn: nameEn, lines: lines.slice(1) };
246        });
247
248        // ---- skillDelay: {skid: {fixedDelay[], statDelay[], singlePostDelay[], globalPostDelay[]}} ----
249        var skillDelay = parseSkillDelay(delayRaw);
250
251        return {
252            skillTreeByJob: skillTreeByJob,
253            skillInfo: skillInfo,
254            skillDescript: skillDescript,
255            skillDelay: skillDelay,
256            warnings: warnings
257        };
258    }
259
260    // 目前這頁 JOBID 對某個技能生效的前置需求(有 override 用 override,否則用預設)
261    function getEffectiveNeedList(skillInfo, jobKey, skid) {
262        var entry = skillInfo[skid];
263        if (!entry) return [];
264        if (entry.needSkillListOverrides && entry.needSkillListOverrides[jobKey]) {
265            return entry.needSkillListOverrides[jobKey];
266        }
267        return entry.needSkillListDefault || [];
268    }
269
270    // 簡易 CSV 逐行分割(支援雙引號包欄位與 "" 逸出,這幾個檔案不會有更複雜的情況)
271    function parseCsvLine(line) {
272        var out = [];
273        var cur = '';
274        var inQuotes = false;
275        for (var i = 0; i < line.length; i++) {
276            var c = line[i];
277            if (inQuotes) {
278                if (c === '"') {
279                    if (line[i + 1] === '"') { cur += '"'; i++; } else { inQuotes = false; }
280                } else { cur += c; }
281            } else {
282                if (c === '"') inQuotes = true;
283                else if (c === ',') { out.push(cur); cur = ''; }
284                else cur += c;
285            }
286        }
287        out.push(cur);
288        return out;
289    }
290
291    // 回傳 {skid: {locale: name}}(每個語系一欄),讓呼叫端依目前 UI 語系自己選要顯示哪個,
292    // 不在這裡寫死 zh-TW——跟計算機主程式 uiTranslations/skillTranslations 同一種格式。
293    async function loadSkillI18n(url) {
294        var res = await fetch(url);
295        var text = await res.text();
296        var lines = text.split(/\r?\n/).filter(function (l) { return l.length > 0; });
297        if (lines.length === 0) return {};
298        var header = parseCsvLine(lines[0]);
299        var map = {};
300        for (var i = 1; i < lines.length; i++) {
301            var cols = parseCsvLine(lines[i]);
302            if (!cols[0]) continue;
303            var entry = {};
304            for (var c = 1; c < header.length; c++) {
305                if (cols[c]) entry[header[c]] = cols[c];
306            }
307            map[cols[0]] = entry;
308        }
309        return map;
310    }
311
312    async function loadSkillIconMap(url) {
313        try {
314            var res = await fetch(url);
315            if (!res.ok) return {};
316            var text = await res.text();
317            var lines = text.split(/\r?\n/).filter(function (l) { return l.length > 0; });
318            var map = {};
319            for (var i = 1; i < lines.length; i++) {
320                var cols = parseCsvLine(lines[i]);
321                if (cols[0] && cols[1]) map[cols[0]] = cols[1];
322            }
323            return map;
324        } catch (e) {
325            return {};
326        }
327    }
328
329    // 任務取得技能清單:純文字檔,一行一個技能代碼(不含 SKID. 前綴),
330    // 空行、# 開頭的註解行忽略。這些技能不能被使用者調整等級,也不計入已用點數。
331    async function loadQuestSkillSet(url) {
332        try {
333            var res = await fetch(url);
334            if (!res.ok) return {};
335            var text = await res.text();
336            var set = {};
337            text.split(/\r?\n/).forEach(function (line) {
338                var s = line.trim();
339                if (!s || s.charAt(0) === '#') return;
340                set[s] = true;
341            });
342            return set;
343        } catch (e) {
344            return {};
345        }
346    }
347
348    global.SkillTreeData = {
349        DATASETS: DATASETS,
350        COLS_PER_ROW: COLS_PER_ROW,
351        loadSkillTreeData: loadSkillTreeData,
352        loadSkillDelayList: loadSkillDelayList,
353        getEffectiveNeedList: getEffectiveNeedList,
354        loadSkillI18n: loadSkillI18n,
355        loadSkillIconMap: loadSkillIconMap,
356        loadQuestSkillSet: loadQuestSkillSet,
357        parseCsvLine: parseCsvLine
358    };
359})(typeof window !== 'undefined' ? window : this);

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.