//============================================================================= // TLInspector.js // Idea by Sakura · Plugin by Kao_SSS //============================================================================= /*: * @plugindesc Translation Source Inspector - shows which source file/line every * on-screen line of text and image comes from, and jumps to it in VSCode. * @author Kao_SSS * * @help * ---------------------------------------------------------------------------- * TLInspector (RPGMaker MV / MZ, NW.js) * Credits: Idea by Sakura · Plugin by Kao_SSS * ---------------------------------------------------------------------------- * A drop-in proofreading aid for translators. While the game runs, it tracks * every message / choice / scrolling-text line and every on-screen image, and * maps each one back to its EXACT source file and line number: * * - Dialogue / choices / scroll text -> www/data/MapXXX.json, * CommonEvents.json, Troops.json (by identity-matching the running event * list, so duplicate strings are never confused). * - Pictures and other images -> img/... path + the event command * (or JS call stack) that loaded them. * * Press the hotkey (default F10) to open the inspector overlay. Each row has an * "Open in VSCode" button (runs `code -g file:line`) and an **edit** button to * change the text and save it back to the source file without leaving the game. * * Live refresh (NW.js playtest only): * In-game overlay "save to file" reloads that JSON into memory immediately. * VSCode / external saves: we watch data/*.json; once the file mtime is stable * (save finished), reload instantly. The game cannot hook Ctrl+S in VSCode itself. * * Limitations: * - NW.js desktop playtest only (not browser) * - Text already shown by a running event won't update until re-triggered * (in-game overlay edit still updates the current message box) * - Plugin .js / plugins.js changes still need F5 * * ---------------------------------------------------------------------------- * Install: drop this file in www/js/plugins/ and add one line to plugins.js: * { "name": "TLInspector", "status": true, "description": "TL source inspector", "parameters": {} } * Place it LAST in the list. Remove that line to fully disable for a release. * * Config: edit the CFG object a few lines below. * ---------------------------------------------------------------------------- */ (function () { 'use strict'; //========================================================================= // Config //========================================================================= var CFG = { enabled: true, hotkey: 'F9', // key (event.key) that toggles the overlay editorCmd: 'auto', // 'auto' = auto-detect installed editors (VS Code, Cursor, // VSCodium, Insiders, Windsurf). Or set an absolute path to // force a specific editor exe. editor: 'auto', // which editor click-to-source opens files in: // 'auto' = VSCode if it's installed, else the built-in // in-game editor (also the fallback if a // VSCode launch fails). // 'builtin' = always the built-in in-game editor (view & // edit the source file right inside the game). // 'vscode' = always VSCode (uses editorCmd above). workspaceFolder: 'auto',// 'auto' = open files inside the game ROOT folder as the // VSCode workspace (so they don't land in whatever window // happened to be open last). Or set an absolute folder. editorReuseWindow: true,// pass -r so VSCode reuses its current window historySize: 80, // how many past text lines to keep captureUiText: true, // capture plugin / menu / UI text drawn via Bitmap.drawText // (so e.g. status-window strings from plugins.js are locatable) dataDirOverride: null, // set an absolute path to force the data dir uiScale: 'auto' // overlay scale: 'auto' from game width, or a number (1.5, 2, …) }; if (!CFG.enabled) { return; } function resolveUiScale(v) { if (v !== 'auto' && v != null && String(v).trim() !== '') { var n = parseFloat(v); if (!isNaN(n) && n > 0) { return Math.max(0.75, Math.min(3, n)); } } var base = 816; var gameW = (typeof Graphics !== 'undefined' && Graphics.width) ? Graphics.width : 0; var viewW = window.innerWidth || document.documentElement.clientWidth || base; var w = Math.max(gameW || base, viewW); var scale = w / base; var dpr = window.devicePixelRatio || 1; if (dpr > 1.15) { scale *= Math.min(2, 0.75 + dpr * 0.35); } return Math.max(1, Math.min(2.75, scale)); } function currentUiScale() { return resolveUiScale(CFG.uiScale); } function applyOverlayScale() { var s = currentUiScale(); var z = (s === 1) ? '' : String(s); if (ui.root) { ui.root.style.zoom = z; ui.root.style.transformOrigin = (ui.side === 'left') ? 'top left' : 'top right'; } if (editor.root) { editor.root.style.zoom = z; editor.root.style.transformOrigin = 'top center'; } if (ui.pickLabel) { ui.pickLabel.style.zoom = z; } } //========================================================================= // Node / environment bootstrap (NW.js exposes require) //========================================================================= var nodeOk = false, fs = null, path = null, cp = null; try { if (typeof require === 'function') { fs = require('fs'); path = require('path'); cp = require('child_process'); nodeOk = true; } } catch (e) { nodeOk = false; } var ENGINE = (typeof Utils !== 'undefined' && Utils.RPGMAKER_NAME) || 'MV'; // Resolve the www root (where index.html lives) -> data dir. // Primary method = exactly how the engine itself resolves paths // (StorageManager.localFileDirectoryPath uses process.mainModule.filename), // which is reliable regardless of the page URL scheme NW.js uses. function resolveWwwRoot() { try { if (nodeOk && process.mainModule && process.mainModule.filename) { return path.dirname(process.mainModule.filename); } } catch (e) { /* fall through */ } try { var href = window.location.href; // file:///C:/.../www/index.html var p = decodeURIComponent(new URL(href).pathname); if (/^\/[A-Za-z]:\//.test(p)) { p = p.slice(1); } // strip leading slash on Windows return path ? path.dirname(p) : p.replace(/\/[^\/]*$/, ''); } catch (e2) { return nodeOk ? process.cwd() : ''; } } var WWW_ROOT = resolveWwwRoot(); var DATA_DIR = CFG.dataDirOverride || (path ? path.join(WWW_ROOT, 'data') : WWW_ROOT + '/data'); // The game root = folder holding the .exe. On MV that's the PARENT of www/; on MZ // there is no www/, so it IS the resolved root. Used as the VSCode workspace folder // so opened files land in the game's own project window, not the last-used one. var GAME_ROOT = (function () { try { if (path && WWW_ROOT && path.basename(WWW_ROOT).toLowerCase() === 'www') { return path.dirname(WWW_ROOT); } } catch (e) { /* ignore */ } return WWW_ROOT; })(); function mapFile(mapId) { var n = ('000' + mapId).slice(-3); return path ? path.join(DATA_DIR, 'Map' + n + '.json') : DATA_DIR + '/Map' + n + '.json'; } function dataFile(name) { return path ? path.join(DATA_DIR, name) : DATA_DIR + '/' + name; } function log() { if (window.console) { console.log.apply(console, ['[TLInspector]'].concat([].slice.call(arguments))); } } log('init', { engine: ENGINE, node: nodeOk, dataDir: DATA_DIR, credits: 'Idea by Sakura · Plugin by Kao_SSS' }); //========================================================================= // File cache + JSON value locator (path -> char offset -> line:col) //========================================================================= var fileCache = {}; // path -> { mtime, text, lineStarts } function readFileCached(file) { if (!nodeOk) { return null; } try { var st = fs.statSync(file); var mt = st.mtimeMs; var c = fileCache[file]; if (c && c.mtime === mt) { return c; } var text = fs.readFileSync(file, 'utf8'); var lineStarts = [0]; for (var i = 0; i < text.length; i++) { if (text.charCodeAt(i) === 10) { lineStarts.push(i + 1); } } c = { mtime: mt, text: text, lineStarts: lineStarts }; fileCache[file] = c; return c; } catch (e) { return null; } } function offsetToLineCol(c, off) { var ls = c.lineStarts, lo = 0, hi = ls.length - 1, mid; while (lo < hi) { mid = (lo + hi + 1) >> 1; if (ls[mid] <= off) { lo = mid; } else { hi = mid - 1; } } return { line: lo + 1, col: off - ls[lo] + 1 }; } // --- minimal structural JSON scanner ------------------------------------ function skipWs(t, i) { while (i < t.length) { var ch = t.charCodeAt(i); if (ch === 32 || ch === 9 || ch === 10 || ch === 13) { i++; } else { break; } } return i; } function scanString(t, i) { // i at opening quote -> returns index after closing quote i++; // past " while (i < t.length) { var ch = t[i]; if (ch === '\\') { i += 2; continue; } if (ch === '"') { return i + 1; } i++; } return i; } function readKey(t, i) { // i at opening quote -> { end, value } var start = i + 1, j = i + 1, out = ''; while (j < t.length) { var ch = t[j]; if (ch === '\\') { out += t[j + 1]; j += 2; continue; } if (ch === '"') { return { end: j + 1, value: out }; } out += ch; j++; } return { end: j, value: out }; } function skipValue(t, i) { i = skipWs(t, i); var ch = t[i]; if (ch === '"') { return scanString(t, i); } if (ch === '{' || ch === '[') { var open = ch, close = ch === '{' ? '}' : ']', depth = 0; for (; i < t.length; i++) { var c = t[i]; if (c === '"') { i = scanString(t, i) - 1; continue; } if (c === open) { depth++; } else if (c === close) { depth--; if (depth === 0) { return i + 1; } } } return i; } // number / true / false / null while (i < t.length && ',}] \t\r\n'.indexOf(t[i]) === -1) { i++; } return i; } // Walk `path` (array of string keys / number indices) -> start offset of value, or -1. function locateOffset(t, pathArr) { var i = skipWs(t, 0); for (var s = 0; s < pathArr.length; s++) { var step = pathArr[s]; i = skipWs(t, i); if (typeof step === 'number') { if (t[i] !== '[') { return -1; } i = skipWs(t, i + 1); if (t[i] === ']') { return -1; } for (var k = 0; k < step; k++) { i = skipValue(t, i); i = skipWs(t, i); if (t[i] !== ',') { return -1; } i = skipWs(t, i + 1); } // i now at target element value start } else { if (t[i] !== '{') { return -1; } i = skipWs(t, i + 1); var found = false; while (i < t.length && t[i] !== '}') { if (t[i] !== '"') { return -1; } var key = readKey(t, i); i = skipWs(t, key.end); if (t[i] !== ':') { return -1; } i = skipWs(t, i + 1); if (key.value === step) { found = true; break; } i = skipValue(t, i); i = skipWs(t, i); if (t[i] === ',') { i = skipWs(t, i + 1); } } if (!found) { return -1; } } } return skipWs(t, i); } var lineCache = {}; // file::pathKey::mtime -> {line,col} function locateLine(file, pathArr) { if (!file || !pathArr || !nodeOk) { return null; } var c = readFileCached(file); if (!c) { return null; } var key = file + '::' + pathArr.join('/') + '::' + c.mtime; if (lineCache[key]) { return lineCache[key]; } var off = locateOffset(c.text, pathArr); var res = off >= 0 ? offsetToLineCol(c, off) : null; lineCache[key] = res; return res; } //========================================================================= // Provenance: map a running event command-list to its source file + path //========================================================================= function resolveList(list) { try { if (typeof $dataMap !== 'undefined' && $dataMap && $dataMap.events && typeof $gameMap !== 'undefined' && $gameMap) { var evs = $dataMap.events; for (var e = 0; e < evs.length; e++) { var ev = evs[e]; if (!ev || !ev.pages) { continue; } for (var p = 0; p < ev.pages.length; p++) { if (ev.pages[p].list === list) { return { file: mapFile($gameMap.mapId()), prefix: ['events', e, 'pages', p, 'list'], label: 'Map' + ('000' + $gameMap.mapId()).slice(-3) + ' ev' + e }; } } } } if (typeof $dataCommonEvents !== 'undefined' && $dataCommonEvents) { for (var c = 0; c < $dataCommonEvents.length; c++) { var ce = $dataCommonEvents[c]; if (ce && ce.list === list) { return { file: dataFile('CommonEvents.json'), prefix: [c, 'list'], label: 'CommonEvent ' + c }; } } } if (typeof $gameTroop !== 'undefined' && $gameTroop && $gameTroop._troopId && typeof $dataTroops !== 'undefined' && $dataTroops) { var tr = $dataTroops[$gameTroop._troopId]; if (tr && tr.pages) { for (var tp = 0; tp < tr.pages.length; tp++) { if (tr.pages[tp].list === list) { return { file: dataFile('Troops.json'), prefix: [$gameTroop._troopId, 'pages', tp, 'list'], label: 'Troop ' + $gameTroop._troopId }; } } } } } catch (e) { log('resolveList error', e); } return null; } function pathToString(arr) { var s = ''; for (var i = 0; i < arr.length; i++) { s += typeof arr[i] === 'number' ? '[' + arr[i] + ']' : (i ? '.' : '') + arr[i]; } return s; } //========================================================================= // Record store //========================================================================= var groupSeq = 0; var currentGroup = -1; var textRecords = []; // {kind,text,file,prefix,tail,group,ts,_key} var imageTriggers = {}; // picture filename -> {file,prefix,tail,label} var BUMP_GUARD_MS = 1000; function findByKey(key) { for (var i = 0; i < textRecords.length; i++) { if (textRecords[i]._key === key) { return i; } } return -1; } // Text seen again -> move it to the top (newest) so it never feels "undetected" // when it was buried in history. Guard against churn from per-frame UI redraws. function seenExisting(i, group) { var r = textRecords[i], now = Date.now(); if (now - r.ts < BUMP_GUARD_MS) { r.ts = now; if (group != null && group !== -1) { r.group = group; } return; // recently seen: keep its position } textRecords.splice(i, 1); r.ts = now; if (group != null && group !== -1) { r.group = group; } textRecords.push(r); scheduleRefresh(); } function pushText(kind, prov, tail, text, group, srcIndex, srcEventId) { if (text == null || text === '') { return; } var file = prov ? prov.file : null; var key = 't\u0000' + kind + '\u0000' + text + '\u0000' + (file || '') + '\u0000' + (tail ? JSON.stringify(tail) : ''); var i = findByKey(key); if (i >= 0) { seenExisting(i, group); return; } textRecords.push({ kind: kind, text: text, file: file, prefix: prov ? prov.prefix : null, tail: tail, label: prov ? prov.label : '(unresolved)', group: group, ts: Date.now(), srcIndex: (srcIndex == null ? null : srcIndex), srcEventId: (srcEventId == null ? 0 : srcEventId), _key: key }); while (textRecords.length > CFG.historySize) { textRecords.shift(); } scheduleRefresh(); } function captureFromList(list, startIdx, textCode, eventId) { var prov = resolveList(list); var group = ++groupSeq; currentGroup = group; var i = startIdx + 1; while (i < list.length && list[i] && list[i].code === textCode) { pushText('text', prov, prov ? prov.prefix.concat([i, 'parameters', 0]) : null, list[i].parameters[0], group, i, eventId); i++; } // inline "Show Choices" immediately following the text block if (list[i] && list[i].code === 102) { captureChoices(list, i, prov, group, eventId); } } function captureChoices(list, idx, prov, group, eventId) { if (prov === undefined) { prov = resolveList(list); } if (group === undefined) { group = ++groupSeq; currentGroup = group; } var choices = list[idx] && list[idx].parameters && list[idx].parameters[0]; if (!Array.isArray(choices)) { return; } for (var n = 0; n < choices.length; n++) { pushText('choice', prov, prov ? prov.prefix.concat([idx, 'parameters', 0, n]) : null, choices[n], group, idx, eventId); } } //========================================================================= // Hooks: text //========================================================================= if (typeof Game_Interpreter !== 'undefined') { var GI = Game_Interpreter.prototype; var _c101 = GI.command101; GI.command101 = function () { var list = this._list, start = this._index; var r = _c101.apply(this, arguments); try { if (this._index !== start) { captureFromList(list, start, 401, this._eventId); } } catch (e) { log('c101', e); } return r; }; var _c105 = GI.command105; GI.command105 = function () { var list = this._list, start = this._index; var r = _c105.apply(this, arguments); try { if (this._index !== start) { captureFromList(list, start, 405, this._eventId); } } catch (e) { log('c105', e); } return r; }; var _c102 = GI.command102; GI.command102 = function () { // A STANDALONE "Show Choices" (not following a message — those are consumed // inside command101) doesn't advance this._index within the method: MV bumps // it later in executeCommand, MZ takes params as an arg. So gating on an index // change missed standalone choice menus entirely. Instead capture exactly when // the choices get set up: this call, while the message box wasn't already busy. var list = this._list, idx = this._index; var wasBusy = (typeof $gameMessage !== 'undefined') && $gameMessage && $gameMessage.isBusy && $gameMessage.isBusy(); var r = _c102.apply(this, arguments); try { if (!wasBusy && list && list[idx] && list[idx].code === 102) { captureChoices(list, idx, undefined, undefined, this._eventId); } } catch (e) { log('c102', e); } return r; }; // Show Picture -> remember which command triggered each picture file var _c231 = GI.command231; GI.command231 = function () { try { var list = this._list, idx = this._index; var name = this._params && this._params[1]; if (name) { var prov = resolveList(list); imageTriggers['img/pictures/' + name + '.png'] = { file: prov ? prov.file : null, path: prov ? prov.prefix.concat([idx, 'parameters', 1]) : null, label: prov ? prov.label : '(unresolved)' }; } } catch (e) { log('c231', e); } return _c231.apply(this, arguments); }; } // Clear "live" marker when the message box closes if (typeof Game_Message !== 'undefined') { var _clear = Game_Message.prototype.clear; Game_Message.prototype.clear = function () { currentGroup = -1; scheduleRefresh(); return _clear.apply(this, arguments); }; } // Capture plugin / menu / UI text. The true chokepoint is Bitmap.drawText: // Window_Base.drawText/drawTextEx AND direct `this.contents.drawText(...)` calls // inside plugins all funnel through it. We skip while a message box is showing // (that text is captured precisely via the interpreter hooks) and de-duplicate by // a digit-normalized key so a ticking value ("Money: 12"/"Money: 13") logs once. if (CFG.captureUiText && typeof Bitmap !== 'undefined' && Bitmap.prototype.drawText) { // Direct draws (Window_Base.drawText, plugin `this.contents.drawText(...)`). // drawTextEx renders one character per call here, so single chars are dropped. var _bmDrawText = Bitmap.prototype.drawText; Bitmap.prototype.drawText = function (text) { try { maybeCaptureUiText(text); } catch (e) { /* ignore */ } return _bmDrawText.apply(this, arguments); }; } if (CFG.captureUiText && typeof Window_Base !== 'undefined' && Window_Base.prototype.drawTextEx) { // Escape-processed text (item descriptions, help text, names) arrives here as a // whole string before being drawn character-by-character. var _drawTextEx = Window_Base.prototype.drawTextEx; Window_Base.prototype.drawTextEx = function (text) { try { maybeCaptureUiText(text); } catch (e) { /* ignore */ } return _drawTextEx.apply(this, arguments); }; } function maybeCaptureUiText(text) { if (typeof text !== 'string') { return; } if (text.replace(/\s/g, '').length < 2) { return; } // drop single chars (per-char draws) if (!/[^\s\d.,:%/+\-()]/.test(text)) { return; } // require a real letter if (typeof $gameMessage !== 'undefined' && $gameMessage && $gameMessage.isBusy && $gameMessage.isBusy()) { return; } var key = 'ui ' + text.replace(/\d+/g, '#'); // digit-normalized: "Money: 12/13" -> one entry var i = findByKey(key); if (i >= 0) { seenExisting(i, -1); return; } var site = firstUserFrame(new Error().stack); textRecords.push({ kind: 'ui', text: text, file: site.file, prefix: null, tail: null, label: site.label, uiLine: site.line, group: -1, ts: Date.now(), _key: key }); while (textRecords.length > CFG.historySize) { textRecords.shift(); } scheduleRefresh(); } function firstUserFrame(stack) { var lines = (stack || '').split('\n'); for (var i = 1; i < lines.length; i++) { var m = lines[i].match(/(js\/plugins\/[^):]+|js\/[^):]+):(\d+):(\d+)/); if (m && m[1].indexOf('TLInspector') === -1) { var f = m[1]; return { file: path ? path.join(WWW_ROOT, f) : WWW_ROOT + '/' + f, line: parseInt(m[2], 10), label: f + ':' + m[2] }; } } return { file: null, line: null, label: '(unknown)' }; } //========================================================================= // Hooks: images (load log with call-site) //========================================================================= var imageLog = []; // {url, label, ts, bitmap} if (typeof ImageManager !== 'undefined') { var _loadBitmap = ImageManager.loadBitmap; ImageManager.loadBitmap = function (folder, filename) { var bm = _loadBitmap.apply(this, arguments); try { if (filename) { var url = folder + filename + '.png'; var last = imageLog[imageLog.length - 1]; if (!last || last.url !== url) { var site = firstUserFrame(new Error().stack); imageLog.push({ url: url, label: site.label, ts: Date.now(), bitmap: bm }); while (imageLog.length > CFG.historySize) { imageLog.shift(); } } else if (last && !last.bitmap) { last.bitmap = bm; } } } catch (e) { /* ignore */ } return bm; }; } //========================================================================= // Live on-screen image collection (walk the sprite tree) //========================================================================= function collectOnScreenImages() { var out = [], seen = {}; var scene = (typeof SceneManager !== 'undefined') && SceneManager._scene; if (!scene) { return out; } var canvas = getGameCanvas(); var rect = canvas ? canvas.getBoundingClientRect() : null; var scale = rect && typeof Graphics !== 'undefined' && Graphics.width ? rect.width / Graphics.width : 1; (function walk(obj) { if (!obj) { return; } if (obj.visible === false) { return; } var bmp = obj.bitmap; if (bmp && bmp._url) { var vis = ('worldVisible' in obj) ? obj.worldVisible : true; if (vis) { var b = null; try { b = obj.getBounds && obj.getBounds(); } catch (e) { b = null; } if (b && b.width > 0 && b.height > 0) { if (!seen[bmp._url]) { seen[bmp._url] = true; } out.push({ url: decodeUrl(bmp._url), bitmap: bmp, screen: rect ? { x: rect.left + b.x * scale, y: rect.top + b.y * scale, w: b.width * scale, h: b.height * scale } : null }); } } } var kids = obj.children; if (kids) { for (var i = 0; i < kids.length; i++) { walk(kids[i]); } } })(scene); return out; } function getGameCanvas() { if (typeof Graphics !== 'undefined') { if (Graphics._canvas) { return Graphics._canvas; } if (Graphics._renderer && Graphics._renderer.view) { return Graphics._renderer.view; } } return document.querySelector('canvas'); } // Bitmap URLs are percent-encoded (ImageManager uses encodeURIComponent on the // filename), e.g. "img/pictures/%E3%83%90%E3%82%B9.png" for "バス". Decode so the // path matches the real file on disk. function decodeUrl(u) { if (!u) { return u; } try { return decodeURIComponent(u); } catch (e) { return u; } } function urlToAbsPath(url) { if (!url) { return null; } var rel = decodeUrl(url).replace(/^\.?\//, ''); return path ? path.join(WWW_ROOT, rel) : WWW_ROOT + '/' + rel; } //========================================================================= // External actions (VSCode / file explorer) //========================================================================= // Auto-locate installed editors so the user never has to touch the system PATH. // We support the whole VS Code family (VS Code, Insiders, Cursor, VSCodium, // Windsurf): they share an identical CLI (" -g file:line:col"), so the // same launch path works for all of them. The user picks which one to use from a // dropdown in the panel (see the editor ' + 'Aa' + 'W' + '.*' + '' + '' + '' + '' + '' + '
' + '' + 'Replace' + 'Replace All' + '
'; root.appendChild(find); var main = document.createElement('div'); main.style.cssText = 'flex:1 1 auto;display:flex;overflow:hidden;background:#1b1e26'; var gutter = document.createElement('div'); gutter.className = 'tl-ed-gutter'; gutter.style.cssText = 'flex:0 0 auto;min-width:42px;overflow:hidden;text-align:right;' + 'padding:8px 6px 8px 10px;color:#566;background:#161922;white-space:pre;' + 'user-select:none;-webkit-user-select:none;font:12px/18px Consolas,Menlo,monospace'; var area = document.createElement('textarea'); area.className = 'tl-ed-area'; area.setAttribute('wrap', 'off'); // one logical line == one visual row area.setAttribute('spellcheck', 'false'); area.style.cssText = 'flex:1 1 auto;resize:none;border:0;outline:0;padding:8px 10px;' + 'color:#e6e6e6;background:#1b1e26;white-space:pre;overflow:auto;' + 'font:12px/18px Consolas,Menlo,monospace;tab-size:4;-moz-tab-size:4'; main.appendChild(gutter); main.appendChild(area); root.appendChild(main); var foot = document.createElement('div'); foot.style.cssText = 'padding:5px 12px;background:#11131a;border-top:1px solid #333;color:#8aa;flex:0 0 auto;display:flex;gap:14px;border-radius:0 0 8px 8px'; foot.innerHTML = 'Ctrl+S save · Ctrl+F find · Ctrl+H replace · Esc close · Tab indent' + ''; root.appendChild(foot); var st = document.createElement('style'); st.textContent = '#tl-editor .tl-ed-btn{cursor:pointer;padding:3px 9px;border-radius:4px;background:#333;color:#ddd}' + '#tl-editor .tl-ed-btn:hover{filter:brightness(1.35)}' + '#tl-editor .tl-ed-tabs{display:flex;overflow-x:auto;background:#0e1016;border-bottom:1px solid #333;flex:0 0 auto}' + '#tl-editor .tl-ed-tab{display:flex;align-items:center;gap:6px;padding:5px 8px;border-right:1px solid #222;color:#9ab;cursor:pointer;white-space:nowrap;max-width:220px;flex:0 0 auto}' + '#tl-editor .tl-ed-tab.on{background:#1b1e26;color:#fff}' + '#tl-editor .tl-ed-tab .nm{overflow:hidden;text-overflow:ellipsis}' + '#tl-editor .tl-ed-tab .dot{color:#fd6}' + '#tl-editor .tl-ed-tab .x{color:#889;border-radius:3px;padding:0 4px}' + '#tl-editor .tl-ed-tab .x:hover{background:#555;color:#fff}' + '#tl-editor .tl-ed-find{background:#12141b;border-bottom:1px solid #333;padding:5px 8px;flex:0 0 auto}' + '#tl-editor .tl-fr{display:flex;align-items:center;gap:6px;margin:2px 0}' + '#tl-editor .tl-fq,#tl-editor .tl-fr-in{flex:0 0 240px;width:240px;min-width:0;background:#1b1e26;border:1px solid #344;color:#eee;padding:3px 6px;font:12px Consolas,Menlo,monospace;outline:none}' + '#tl-editor .tl-fopt{cursor:pointer;padding:2px 6px;border-radius:3px;background:#222;color:#9ab;font:11px monospace}' + '#tl-editor .tl-fopt.on{background:#2a5db0;color:#fff}' + '#tl-editor .tl-fcount{color:#9ab;min-width:62px;text-align:center;font-size:11px}' + '#tl-editor .tl-fcount.err{color:#f88}' + '#tl-editor .tl-fbtn{cursor:pointer;padding:2px 8px;border-radius:3px;background:#333;color:#ddd;white-space:nowrap}' + '#tl-editor .tl-fbtn:hover{filter:brightness(1.3)}'; root.appendChild(st); head.addEventListener('click', function (e) { var a = e.target.getAttribute('data-act'); if (a === 'save') { saveEditor(); } else if (a === 'close') { closeEditor(); } else if (a === 'find') { openFind(false); } }); // tab strip: click selects, click the x closes tabs.addEventListener('click', function (e) { var t = e.target; while (t && t !== tabs && !t.hasAttribute('data-ti')) { t = t.parentNode; } if (!t || t === tabs) { return; } var i = parseInt(t.getAttribute('data-ti'), 10); if (e.target.classList.contains('x')) { closeTab(i); } else { selectTab(i); } }); // find widget wiring editor.fq = find.querySelector('.tl-fq'); editor.frin = find.querySelector('.tl-fr-in'); editor.fcount = find.querySelector('.tl-fcount'); editor.fq.addEventListener('input', function () { editor.find.q = this.value; recomputeMatches(); showMatch(editor.find.idx < 0 ? 0 : editor.find.idx); }); editor.fq.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); showMatch(editor.find.idx + (e.shiftKey ? -1 : 1)); } else if (e.key === 'Escape') { e.preventDefault(); closeFind(); } }); editor.frin.addEventListener('input', function () { editor.find.r = this.value; }); editor.frin.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); replaceCurrent(); } else if (e.key === 'Escape') { e.preventDefault(); closeFind(); } }); find.addEventListener('click', function (e) { var opt = e.target.getAttribute('data-opt'); var fa = e.target.getAttribute('data-fa'); if (opt) { var f = editor.find; f[opt] = !f[opt]; e.target.classList.toggle('on', f[opt]); recomputeMatches(); showMatch(f.idx < 0 ? 0 : f.idx); } else if (fa === 'next') { showMatch(editor.find.idx + 1); } else if (fa === 'prev') { showMatch(editor.find.idx - 1); } else if (fa === 'close') { closeFind(); } else if (fa === 'rep') { replaceCurrent(); } else if (fa === 'repall') { replaceAll(); } }); area.addEventListener('input', function () { markDirty(); refreshGutter(); if (editor.find.open) { recomputeMatches(); updateFindCount(); } }); area.addEventListener('scroll', syncGutter); area.addEventListener('keydown', onEditorKey, false); // Keep the game from reacting to clicks/keys aimed at the editor (mirrors the // inspector panel's isolation). With "freeze" off this is what protects typing. ['mousedown', 'mouseup', 'click', 'dblclick', 'wheel', 'contextmenu', 'touchstart', 'touchend', 'touchmove', 'pointerdown', 'pointerup', 'keydown', 'keyup', 'keypress'].forEach(function (t) { root.addEventListener(t, function (ev) { ev.stopPropagation(); }, false); }); document.body.appendChild(root); editor.root = root; editor.area = area; editor.gutter = gutter; editor.tabsEl = tabs; editor.findEl = find; editor.statusEl = foot.querySelector('.tl-ed-status'); applyOverlayScale(); } //--- documents / tabs ---------------------------------------------------- function docIndexByFile(file) { for (var i = 0; i < editor.docs.length; i++) { if (editor.docs[i].file === file) { return i; } } return -1; } function createDoc(file) { var raw; try { raw = fs.readFileSync(file, 'utf8'); } catch (e) { toast('Could not read file'); log('read', e); return null; } var bom = raw.charCodeAt(0) === 0xFEFF; if (bom) { raw = raw.slice(1); } var nl = /\r\n/.test(raw) ? '\r\n' : '\n'; // preserve the file's line endings return { file: file, name: baseName(file), text: raw.replace(/\r\n/g, '\n'), nl: nl, bom: bom, dirty: false, line: 0, scrollTop: 0, selStart: 0, selEnd: 0 }; } function stashActive() { var d = activeDoc(); if (!d) { return; } d.text = editor.area.value; d.scrollTop = editor.area.scrollTop; d.selStart = editor.area.selectionStart; d.selEnd = editor.area.selectionEnd; } function loadActive() { var d = activeDoc(); editor._lines = -1; if (!d) { editor.area.value = ''; refreshGutter(); updateEditorTitle(); return; } editor.area.value = d.text; refreshGutter(); editor.area.scrollTop = d.scrollTop || 0; try { editor.area.setSelectionRange(d.selStart || 0, d.selEnd || 0); } catch (e) { /* ignore */ } syncGutter(); updateEditorTitle(); if (editor.find.open) { recomputeMatches(); updateFindCount(); } } function selectTab(i, line) { if (i < 0 || i >= editor.docs.length) { return; } if (i !== editor.active) { stashActive(); editor.active = i; loadActive(); } renderTabs(); if (line) { jumpToLine(line); } editor.area.focus(); } function closeTab(i) { var d = editor.docs[i]; if (!d) { return; } if (d.dirty && !window.confirm('Discard unsaved changes to ' + d.name + '?')) { return; } if (i === editor.active) { editor.docs.splice(i, 1); if (!editor.docs.length) { editor.active = -1; renderTabs(); editor.root.style.display = 'none'; return; } editor.active = Math.min(i, editor.docs.length - 1); loadActive(); } else { if (i < editor.active) { editor.active--; } editor.docs.splice(i, 1); } renderTabs(); } function renderTabs() { var el = editor.tabsEl; if (!el) { return; } el.innerHTML = ''; editor.docs.forEach(function (d, i) { var t = document.createElement('div'); t.className = 'tl-ed-tab' + (i === editor.active ? ' on' : ''); t.setAttribute('data-ti', i); t.title = d.file; t.innerHTML = (d.dirty ? '' : '') + '' + esc(d.name) + '' + ''; el.appendChild(t); }); } function markDirty() { var d = activeDoc(); if (!d) { return; } if (!d.dirty) { d.dirty = true; renderTabs(); } updateEditorTitle(); } //--- view helpers -------------------------------------------------------- function insertAtCursor(area, str) { var s = area.selectionStart, e = area.selectionEnd, v = area.value; area.value = v.slice(0, s) + str + v.slice(e); area.selectionStart = area.selectionEnd = s + str.length; } function onEditorKey(e) { if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) { e.preventDefault(); saveEditor(); return; } if ((e.ctrlKey || e.metaKey) && (e.key === 'f' || e.key === 'F')) { e.preventDefault(); openFind(false); return; } if ((e.ctrlKey || e.metaKey) && (e.key === 'h' || e.key === 'H')) { e.preventDefault(); openFind(true); return; } if (e.key === 'Escape') { e.preventDefault(); if (editor.find.open) { closeFind(); } else { closeEditor(); } return; } if (e.key === 'Tab') { e.preventDefault(); insertAtCursor(editor.area, '\t'); markDirty(); } } function syncGutter() { if (editor.gutter && editor.area) { editor.gutter.scrollTop = editor.area.scrollTop; } } function refreshGutter() { var v = editor.area.value, count = 1; for (var i = 0; i < v.length; i++) { if (v.charCodeAt(i) === 10) { count++; } } if (count === editor._lines) { return; } editor._lines = count; var s = ''; for (var k = 1; k <= count; k++) { s += k + '\n'; } editor.gutter.textContent = s; syncGutter(); } function updateEditorTitle(line) { var d = activeDoc(); if (!d) { setEditorStatus(''); return; } if (line != null) { d.line = line; } setEditorStatus(d.name + (d.line ? ':' + d.line : '') + (d.dirty ? ' (unsaved)' : '')); } function setEditorStatus(msg) { if (editor.statusEl) { editor.statusEl.textContent = msg || ''; } } function lineStartOffset(text, line) { var off = 0, cur = 1; while (cur < line) { var nl = text.indexOf('\n', off); if (nl < 0) { break; } off = nl + 1; cur++; } return off; } function jumpToLine(line) { var area = editor.area, text = area.value; var so = lineStartOffset(text, line); var eo = text.indexOf('\n', so); if (eo < 0) { eo = text.length; } try { area.setSelectionRange(so, eo); } catch (e) { /* ignore */ } area.scrollTop = Math.max(0, (line - 4) * EDLH); syncGutter(); updateEditorTitle(line); } // Entry point from openInEditor(): open (or focus) a tab for `file` and jump to line. function openBuiltInEditor(file, line, col) { if (!nodeOk || !file) { toast('No file to open'); return; } buildEditor(); editor.root.style.display = 'flex'; var i = docIndexByFile(file); if (i < 0) { var d = createDoc(file); if (!d) { return; } stashActive(); editor.docs.push(d); i = editor.docs.length - 1; editor.active = i; loadActive(); renderTabs(); jumpToLine(line || 1); editor.area.focus(); } else { selectTab(i, line || 1); } } function saveEditor() { var d = activeDoc(); if (!d) { return; } d.text = editor.area.value; var out = (d.nl === '\r\n') ? d.text.replace(/\n/g, '\r\n') : d.text; if (d.bom) { out = String.fromCharCode(0xFEFF) + out; } try { fs.writeFileSync(d.file, out, 'utf8'); d.dirty = false; renderTabs(); updateEditorTitle(); invalidateFile(d.file); // Push the just-saved edit straight onto the live game: reload data files, // re-fetch on-screen images and redraw the scene so the change is visible // immediately without leaving the editor or restarting. refreshGameElements(); toast('Saved ' + d.name + ' — reloaded in-game'); } catch (e) { setEditorStatus('save failed'); toast('Save failed: ' + e.message); log('save', e); } } // Closes the editor window but KEEPS the open tabs in memory (like minimizing), // so unsaved edits aren't lost; reopening any result shows them again. function closeEditor() { if (!editor.root) { return; } editor.root.style.display = 'none'; } //--- find / replace ------------------------------------------------------ function openFind(replaceFocus) { buildEditor(); var f = editor.find; f.open = true; editor.findEl.style.display = 'block'; // seed the query from a short single-line selection (VSCode behaviour) var sel = editor.area.value.slice(editor.area.selectionStart, editor.area.selectionEnd); if (sel && sel.indexOf('\n') < 0 && sel.length <= 200) { f.q = sel; editor.fq.value = sel; } recomputeMatches(); var inp = replaceFocus ? editor.frin : editor.fq; inp.focus(); inp.select(); showMatch(f.idx < 0 ? 0 : f.idx); } function closeFind() { editor.find.open = false; if (editor.findEl) { editor.findEl.style.display = 'none'; } editor.area.focus(); } function buildFindRegex(global) { var f = editor.find; if (!f.q) { return null; } var src = f.re ? f.q : f.q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); if (f.ww) { src = '\\b' + src + '\\b'; } try { return new RegExp(src, (global ? 'g' : '') + (f.cs ? '' : 'i') + 'm'); } catch (e) { return null; } } function recomputeMatches(caret) { var f = editor.find, text = editor.area.value; f.matches = []; f.invalid = false; if (!f.q) { f.idx = -1; updateFindCount(); return; } var re = buildFindRegex(true); if (!re) { f.invalid = !!f.re; f.idx = -1; updateFindCount(); return; } var m, guard = 0; while ((m = re.exec(text)) !== null) { f.matches.push({ start: m.index, end: m.index + m[0].length }); if (m[0].length === 0) { re.lastIndex++; } // never loop on empty match if (++guard > 200000) { break; } } var c = (caret == null) ? (editor.area.selectionStart || 0) : caret; f.idx = -1; for (var i = 0; i < f.matches.length; i++) { if (f.matches[i].start >= c) { f.idx = i; break; } } if (f.idx === -1 && f.matches.length) { f.idx = 0; } updateFindCount(); } // Select + scroll to match i (wraps). Does NOT move focus, so the user keeps // typing in the find box while matches highlight live in the textarea. function showMatch(i) { var f = editor.find; if (!f.matches.length) { updateFindCount(); return; } f.idx = ((i % f.matches.length) + f.matches.length) % f.matches.length; var mt = f.matches[f.idx], area = editor.area; try { area.setSelectionRange(mt.start, mt.end); } catch (e) { /* ignore */ } var line = area.value.slice(0, mt.start).split('\n').length; area.scrollTop = Math.max(0, (line - 4) * EDLH); syncGutter(); updateFindCount(); } function updateFindCount() { var f = editor.find, el = editor.fcount; if (!el) { return; } if (f.invalid) { el.textContent = 'bad regex'; el.className = 'tl-fcount err'; return; } el.className = 'tl-fcount'; if (!f.q) { el.textContent = ''; } else if (!f.matches.length) { el.textContent = 'No results'; } else { el.textContent = (f.idx + 1) + ' of ' + f.matches.length; } } function computeReplacement(matched) { var f = editor.find; if (!f.re) { return f.r; } // literal: insert as-is var re = buildFindRegex(false); try { return matched.replace(re, f.r); } // regex: honour $1, $& etc. catch (e) { return matched; } } function replaceCurrent() { var f = editor.find; if (!f.matches.length) { return; } if (f.idx < 0) { f.idx = 0; } var mt = f.matches[f.idx], area = editor.area, val = area.value; var rep = computeReplacement(val.slice(mt.start, mt.end)); area.value = val.slice(0, mt.start) + rep + val.slice(mt.end); markDirty(); refreshGutter(); recomputeMatches(mt.start + rep.length); // positions shifted; reanchor showMatch(f.idx); } function replaceAll() { var f = editor.find, area = editor.area; var reg = buildFindRegex(true); if (!reg) { return; } var arr = area.value.match(reg); var count = arr ? arr.length : 0; if (!count) { toast('No matches'); return; } // literal mode: function replacer so $ in the replacement stays literal. area.value = f.re ? area.value.replace(reg, f.r) : area.value.replace(reg, function () { return f.r; }); markDirty(); refreshGutter(); recomputeMatches(); showMatch(0); toast('Replaced ' + count + ' occurrence' + (count === 1 ? '' : 's')); } //========================================================================= // Live refresh: reload the data files + images a translator just edited and // redraw the current scene, so saved changes show up in-game without a restart. // Bound to the panel's "Reload" button. //========================================================================= var DATA_GLOBALS = { 'System': '$dataSystem', 'Actors': '$dataActors', 'Classes': '$dataClasses', 'Skills': '$dataSkills', 'Items': '$dataItems', 'Weapons': '$dataWeapons', 'Armors': '$dataArmors', 'Enemies': '$dataEnemies', 'Troops': '$dataTroops', 'States': '$dataStates', 'Animations': '$dataAnimations', 'Tilesets': '$dataTilesets', 'CommonEvents': '$dataCommonEvents', 'MapInfos': '$dataMapInfos' }; // Run the engine's post-load step on a freshly parsed data object. This is what // rebuilds each entry's `.meta`/notetag fields (via DataManager.extractMetadata) // AND re-fires any plugin that aliased DataManager.onLoad to process notetags. // Skipping it is what crashed plugins like character-lighting (event.meta was // undefined -> "Cannot read property 'CharLight' of undefined"). function runOnLoad(obj) { try { if (typeof DataManager !== 'undefined' && typeof DataManager.onLoad === 'function') { DataManager.onLoad(obj); } } catch (e) { /* a plugin's onLoad hook may itself throw; don't abort the reload */ } } function reloadDataFiles() { var n = 0; for (var name in DATA_GLOBALS) { if (!DATA_GLOBALS.hasOwnProperty(name)) { continue; } var gname = DATA_GLOBALS[name]; if (typeof window[gname] === 'undefined') { continue; } try { window[gname] = JSON.parse(fs.readFileSync(dataFile(name + '.json'), 'utf8')); runOnLoad(window[gname]); n++; } catch (e) { /* file may be absent for this game; skip */ } } try { if (typeof $gameMap !== 'undefined' && $gameMap && $gameMap.mapId()) { // $dataMap must be assigned BEFORE onLoad: it keys off (object === $dataMap) // to know it should extract event metadata. window.$dataMap = JSON.parse(fs.readFileSync(mapFile($gameMap.mapId()), 'utf8')); runOnLoad(window.$dataMap); n++; } } catch (e) { /* ignore */ } return n; } // Redraw every window in the current scene (item names, descriptions, terms, // status text, etc. are re-read from the freshly reloaded data). function refreshSceneWindows() { var scene = (typeof SceneManager !== 'undefined') && SceneManager._scene; if (!scene || typeof Window_Base === 'undefined') { return 0; } var n = 0; (function walk(obj) { if (!obj) { return; } if (obj instanceof Window_Base && typeof obj.refresh === 'function') { try { obj.refresh(); n++; } catch (e) { /* some windows refresh only with state */ } } var kids = obj.children; if (kids) { for (var i = 0; i < kids.length; i++) { walk(kids[i]); } } })(scene); return n; } function clearImageCaches() { try { if (typeof ImageManager !== 'undefined') { if (typeof ImageManager.clear === 'function') { ImageManager.clear(); } if (ImageManager._cache) { ImageManager._cache = {}; } if (ImageManager._system) { ImageManager._system = {}; } if (typeof ImageCache !== 'undefined' && ImageManager._imageCache) { ImageManager._imageCache = new ImageCache(); } } } catch (e) { /* ignore */ } thumbCache = {}; } // Force on-screen sprites to re-fetch their bitmap from disk. Routing through // ImageManager.loadBitmap (cache already cleared) keeps decryption working. function reloadOnScreenImages() { var scene = (typeof SceneManager !== 'undefined') && SceneManager._scene; if (!scene || typeof ImageManager === 'undefined') { return 0; } var n = 0; (function walk(obj) { if (!obj) { return; } var bmp = obj.bitmap; if (bmp && bmp._url) { var url = decodeUrl(bmp._url); var slash = url.lastIndexOf('/'); var folder = url.slice(0, slash + 1); var fname = url.slice(slash + 1).replace(/\.png$/i, ''); try { obj.bitmap = ImageManager.loadBitmap(folder, fname); n++; } catch (e) { /* ignore */ } } var kids = obj.children; if (kids) { for (var i = 0; i < kids.length; i++) { walk(kids[i]); } } })(scene); return n; } function refreshGameElements() { if (!nodeOk) { toast('Reload needs Node (NW.js)'); return; } // Drop our own caches so the panel relocates against the new file contents. fileCache = {}; lineCache = {}; dupIndex = null; _scanFiles = null; var parts = []; var d = reloadDataFiles(); if (d) { parts.push(d + ' data file' + (d === 1 ? '' : 's')); } clearImageCaches(); var im = reloadOnScreenImages(); if (im) { parts.push(im + ' image' + (im === 1 ? '' : 's')); } var w = refreshSceneWindows(); if (w) { parts.push(w + ' window' + (w === 1 ? '' : 's')); } // Nudge the map to rebuild event sprites/state if we're on it. try { if (typeof $gameMap !== 'undefined' && $gameMap && $gameMap.requestRefresh) { $gameMap.requestRefresh(); } } catch (e) { /* ignore */ } render(); // refresh our own panel too toast(parts.length ? ('Reloaded ' + parts.join(', ')) : 'Nothing on screen to reload'); } //========================================================================= // Overlay UI //========================================================================= var ui = { root: null, body: null, tab: 'text', highlight: null, hlLayer: null, open: false, refreshTimer: 0, side: 'right', pick: false, catcher: null, pickLabel: null, _pickHits: null, selection: null, ignoreNl: false, freeze: false }; try { ui.side = window.localStorage.getItem('tlins.side') || 'right'; } catch (e) { /* ignore */ } try { ui.ignoreNl = window.localStorage.getItem('tlins.ignl') === '1'; } catch (e) { /* ignore */ } try { ui.freeze = window.localStorage.getItem('tlins.freeze') === '1'; } catch (e) { /* ignore */ } // Editor switch: remembered choice if set, else seeded from CFG.editor (with 'auto' // resolving to built-in only when VSCode isn't actually installed). try { var _sb = window.localStorage.getItem('tlins.builtin'); ui.useBuiltin = _sb === '1' ? true : _sb === '0' ? false : (CFG.editor === 'builtin' || (CFG.editor !== 'vscode' && !vscodeAvailable())); } catch (e) { ui.useBuiltin = (CFG.editor === 'builtin'); } // Which external editor the dropdown opens results in (null => first detected). try { ui.editorId = window.localStorage.getItem('tlins.editor') || null; } catch (e) { ui.editorId = null; } function applySide() { if (!ui.root) { return; } var left = ui.side === 'left'; ui.root.style.left = left ? '0' : 'auto'; ui.root.style.right = left ? 'auto' : '0'; ui.root.style.boxShadow = (left ? '2px' : '-2px') + ' 0 12px rgba(0,0,0,0.6)'; applyOverlayScale(); } function flipSide() { ui.side = ui.side === 'left' ? 'right' : 'left'; try { window.localStorage.setItem('tlins.side', ui.side); } catch (e) { /* ignore */ } applySide(); } function buildUI() { if (ui.root) { return; } var root = document.createElement('div'); root.id = 'tl-inspector'; root.style.cssText = [ 'position:fixed', 'top:0', 'right:0', 'width:460px', 'height:100%', 'background:rgba(20,22,28,0.96)', 'color:#e6e6e6', 'z-index:2147483646', 'font:12px/1.45 Consolas,Menlo,monospace', 'box-shadow:-2px 0 12px rgba(0,0,0,0.6)', 'display:none', 'flex-direction:column' ].join(';'); var head = document.createElement('div'); head.style.cssText = 'background:#11131a;border-bottom:1px solid #333;flex:0 0 auto'; head.innerHTML = '
' + 'TL Inspector' + '' + 'Text' + 'Images' + '' + '' + '' + '
' + '
' + '⌖ Inspect' + '' + '' + '' + '↻ Reload' + '
'; root.appendChild(head); var body = document.createElement('div'); body.style.cssText = 'flex:1 1 auto;overflow:auto;padding:6px 8px'; root.appendChild(body); var style = document.createElement('style'); style.textContent = '#tl-inspector .tl-hrow{display:flex;align-items:center;gap:6px;flex-wrap:wrap}' + '#tl-inspector .tl-tab{cursor:pointer;padding:3px 11px;border-radius:4px;background:#222;color:#bbb;display:inline-flex;align-items:center;line-height:1.3}' + '#tl-inspector .tl-tab.on{background:#2a5db0;color:#fff}' + '#tl-inspector .tl-btn{cursor:pointer;padding:3px 10px;border-radius:4px;background:#333;color:#ddd;display:inline-flex;align-items:center;gap:5px;line-height:1.3}' + '#tl-inspector .tl-btn.on{background:#3a7;color:#022}' + '#tl-inspector .tl-ic{min-width:30px;justify-content:center;padding:3px 6px}' + '#tl-inspector .tl-edsel{background:#333;color:#ddd;border:1px solid #475;border-radius:4px;padding:3px 4px;font:inherit;cursor:pointer;max-width:140px;line-height:1.3}' + '#tl-inspector .tl-frz input{margin:0 1px 0 0}' + '#tl-inspector .tl-btn:hover,#tl-inspector .tl-tab:hover{filter:brightness(1.3)}' + '#tl-inspector .tl-row{border:1px solid #2b2f3a;border-radius:6px;padding:6px 8px;margin:0 0 6px;background:#1b1e26}' + '#tl-inspector .tl-row.live{border-color:#3a7;background:#1b2620}' + '#tl-inspector .tl-txt{color:#fff;white-space:pre-wrap;word-break:break-word;margin-bottom:4px;' + '-webkit-user-select:text;user-select:text;cursor:text}' + '#tl-inspector .tl-meta{color:#8aa;font-size:11px;display:flex;gap:10px;flex-wrap:wrap;align-items:center}' + '#tl-inspector .tl-loc{color:#9cd;cursor:pointer;text-decoration:underline}' + '#tl-inspector .tl-badge{font-size:10px;padding:0 5px;border-radius:8px;background:#345}' + '#tl-inspector .tl-badge.choice{background:#534}' + '#tl-inspector .tl-badge.ui{background:#553}' + '#tl-inspector .tl-badge.live{background:#3a7;color:#022}' + '#tl-inspector .tl-mini{cursor:pointer;color:#cda;background:#2a2f3a;padding:1px 7px;border-radius:4px;white-space:nowrap}' + '#tl-inspector .tl-mini:hover{background:#363c4a;color:#eda}' + '#tl-inspector .tl-imgwrap{display:flex;gap:8px;align-items:flex-start}' + '#tl-inspector .tl-thumb{width:60px;height:60px;object-fit:contain;border:1px solid #333;border-radius:4px;flex:0 0 auto;' + 'background-image:linear-gradient(45deg,#2a2a2a 25%,transparent 25%),linear-gradient(-45deg,#2a2a2a 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#2a2a2a 75%),linear-gradient(-45deg,transparent 75%,#2a2a2a 75%);background-size:12px 12px;background-position:0 0,0 6px,6px -6px,-6px 0;background-color:#15171d;image-rendering:pixelated}' + '#tl-inspector .tl-noimg{display:flex;align-items:center;justify-content:center;color:#556;font-size:18px}' + '#tl-inspector .tl-imginfo{flex:1;min-width:0}' + '#tl-inspector .tl-dups{margin-top:6px;padding:4px 0 2px 8px;border-left:2px solid #46506a}' + '#tl-inspector .tl-dup{color:#9cd;cursor:pointer;text-decoration:underline;display:block;margin:2px 0}' + '#tl-inspector .tl-bar{margin:0 0 8px;color:#9ab}' + '#tl-inspector .tl-bar input{vertical-align:middle}' + '#tl-inspector .tl-empty{color:#778;padding:20px;text-align:center}'; root.appendChild(style); document.body.appendChild(root); ui.root = root; ui.body = body; applySide(); // Stop panel mouse/wheel/touch events from reaching the game's document-level // input handlers. Fixes: clicking the scrollbar/buttons driving the character, // and the mouse wheel not scrolling (the game preventDefault()s wheel events). ['mousedown', 'mouseup', 'click', 'dblclick', 'wheel', 'contextmenu', 'touchstart', 'touchend', 'touchmove', 'pointerdown', 'pointerup'].forEach(function (t) { root.addEventListener(t, function (ev) { ev.stopPropagation(); }, false); }); var frz = root.querySelector('[data-act="freeze"]'); if (frz) { frz.addEventListener('change', function () { ui.freeze = this.checked; try { window.localStorage.setItem('tlins.freeze', ui.freeze ? '1' : ''); } catch (e) { /* ignore */ } toast(ui.freeze ? 'Game controls frozen while editor is open' : 'Game controls unfrozen'); }); } // Editor dropdown: list every detected external editor; only shown when the // built-in editor is NOT selected (since then clicks open externally). var edsel = root.querySelector('[data-act="edsel"]'); function syncEdsel() { if (!edsel) { return; } edsel.style.display = ui.useBuiltin ? 'none' : ''; } if (edsel) { var list = detectEditors(); list.forEach(function (e) { var o = document.createElement('option'); o.value = e.id; o.textContent = e.name; edsel.appendChild(o); }); edsel.value = currentEditor().id; syncEdsel(); edsel.addEventListener('change', function () { ui.editorId = this.value; try { window.localStorage.setItem('tlins.editor', ui.editorId); } catch (e) { /* ignore */ } toast('Results open in ' + currentEditor().name); }); } var bld = root.querySelector('[data-act="builtin"]'); if (bld) { bld.addEventListener('change', function () { ui.useBuiltin = this.checked; try { window.localStorage.setItem('tlins.builtin', ui.useBuiltin ? '1' : '0'); } catch (e) { /* ignore */ } syncEdsel(); toast(ui.useBuiltin ? 'Results open in the built-in editor' : 'Results open in ' + currentEditor().name); }); } head.addEventListener('click', function (ev) { var t = ev.target.getAttribute('data-tab'); var a = ev.target.getAttribute('data-act'); if (t) { ui.tab = t; if (t !== 'images' && ui.pick) { setPick(false); } render(); } else if (a === 'refresh') { refreshGameElements(); } else if (a === 'pick') { setPick(!ui.pick); } else if (a === 'flip') { flipSide(); } else if (a === 'close') { toggle(false); } }); var hl = document.createElement('div'); hl.style.cssText = 'position:fixed;border:2px solid #ff5;background:rgba(255,255,0,0.12);z-index:2147483645;pointer-events:none;display:none'; document.body.appendChild(hl); ui.highlight = hl; var pl = document.createElement('div'); pl.style.cssText = 'position:fixed;background:#000d;color:#9f9;padding:3px 7px;border-radius:4px;font:11px monospace;z-index:2147483647;pointer-events:none;display:none;max-width:60vw;white-space:nowrap;overflow:hidden;text-overflow:ellipsis'; document.body.appendChild(pl); ui.pickLabel = pl; var hlz = document.createElement('div'); // container for multiple pick rects hlz.style.cssText = 'position:fixed;left:0;top:0;z-index:2147483645;pointer-events:none;display:none'; document.body.appendChild(hlz); ui.hlLayer = hlz; applyOverlayScale(); } function setTabStyles() { var tabs = ui.root.querySelectorAll('.tl-tab'); for (var i = 0; i < tabs.length; i++) { tabs[i].classList.toggle('on', tabs[i].getAttribute('data-tab') === ui.tab); } // Inspect only works on images, so only surface it on the Images tab. var insp = ui.root.querySelector('.tl-inspect'); if (insp) { insp.style.display = (ui.tab === 'images') ? '' : 'none'; } } function esc(s) { return String(s).replace(/[&<>]/g, function (c) { return c === '&' ? '&' : c === '<' ? '<' : '>'; }); } function render() { if (!ui.root || !ui.open) { return; } setTabStyles(); var st = ui.body.scrollTop; // preserve scroll across rebuilds ui.body.innerHTML = ''; if (ui.tab === 'text') { renderText(); } else { renderImages(); } ui.body.scrollTop = st; } // Signature of what's currently on screen + how many thumbs are ready, so the // auto-refresh only rebuilds the Images tab when something actually changed // (otherwise the scrollbar would keep snapping back to the top). function onScreenSignature() { var urls = collectOnScreenImages().map(function (i) { return i.url; }); var ready = urls.filter(function (u) { return thumbCache[u]; }).length; return urls.join('|') + '#' + ready + '#' + (ui.selection ? ui.selection.length : 0); } function renderText() { var bar = htmlEl('
' + '' + '' + 'Clear History (' + textRecords.length + ')
'); bar.querySelector('input').addEventListener('change', function () { ui.ignoreNl = this.checked; try { window.localStorage.setItem('tlins.ignl', ui.ignoreNl ? '1' : ''); } catch (e) { /* ignore */ } }); bar.querySelector('[data-act="clearhist"]').addEventListener('click', function () { textRecords.length = 0; currentGroup = -1; render(); }); ui.body.appendChild(bar); if (!textRecords.length) { ui.body.appendChild(htmlEl('
No text captured yet.
Trigger a message in-game.
')); return; } var msgBusy = (typeof $gameMessage !== 'undefined') && $gameMessage && $gameMessage.isBusy && $gameMessage.isBusy(); for (var i = textRecords.length - 1; i >= 0; i--) { var r = textRecords[i]; if (!r.file) { resolveRecord(r); } // text-search fallback for identity misses var loc = (r.file && r.tail) ? locateLine(r.file, r.tail) : null; var line = loc ? loc.line : (r.uiLine || null); var col = loc ? loc.col : null; var live = msgBusy && r.group === currentGroup && currentGroup !== -1; var row = document.createElement('div'); row.className = 'tl-row' + (live ? ' live' : ''); var fileShort = r.file ? r.file.replace(/^.*[\\\/]/, '') : null; var locText = fileShort ? (fileShort + (line ? ':' + line : '')) : (r._ambig ? r._ambig + ' candidates — click "locate"' : r.label); row.innerHTML = '
' + esc(r.text) + '
' + '
' + '' + r.kind + '' + (live ? 'LIVE' : '') + '' + esc(locText) + '' + '' + (r.kind === 'ui' ? 'Locate In Files' : (r.file ? 'Find Duplicates' : 'Locate In Files')) + '' + '
' + ''; (function (rec, ln, cl, rowEl) { var locEl = rowEl.querySelector('.tl-loc'); if (locEl) { locEl.addEventListener('click', function () { if (rec.file) { openInEditor(rec.file, ln, cl); } else { toast('Unresolved: ' + rec.label); } }); } var dupBtn = rowEl.querySelector('[data-act="dups"]'); var dupsEl = rowEl.querySelector('.tl-dups'); if (dupBtn) { dupBtn.addEventListener('click', function () { if (dupsEl.style.display === 'none') { dupsEl.style.display = 'block'; dupsEl.innerHTML = 'searching…'; setTimeout(function () { populateDups(dupsEl, rec); }, 0); } else { dupsEl.style.display = 'none'; } }); } })(r, line, col, row); ui.body.appendChild(row); } } function populateDups(container, rec) { container.innerHTML = ''; var MAXSHOW = 150; var note = '', total = 0, shown = []; if (rec.kind === 'ui') { // UI/plugin text: literal search across data + plugin files. var s = searchFilesSmart(rec.text); total = s.results.length; note = total + ' match' + (total === 1 ? '' : 'es') + (s.partial ? ' for "' + s.query + '" (value part omitted)' : ''); shown = s.results.slice(0, MAXSHOW).map(function (o) { return { file: o.file, line: o.line, col: o.col, quoted: o.quoted }; }); } else { // Dialogue/choice text: list every identical line in the data files. // IMPORTANT: slice BEFORE resolving line numbers — a frequent speaker name // ("Yui") has thousands of hits and locating every one would freeze the game. var occ = findOccurrences(rec.text, ui.ignoreNl); total = occ.length; note = total + ' occurrence' + (total === 1 ? '' : 's') + (ui.ignoreNl ? ' (ignoring \\n)' : ''); shown = occ.slice(0, MAXSHOW).map(function (o) { var l = locateLine(o.file, o.path); return { file: o.file, line: l && l.line, col: l && l.col, isSelf: rec.file === o.file && JSON.stringify(rec.tail) === JSON.stringify(o.path) }; }); } if (!total) { container.innerHTML = 'no matches found' + (rec.kind === 'ui' ? ' (text may be built at runtime)' : '') + ''; return; } var head = htmlEl('
' + note + (total > MAXSHOW ? ' — showing first ' + MAXSHOW : '') + '  Open All (' + shown.length + ')
'); container.appendChild(head); shown.forEach(function (x) { var fn = x.file.replace(/^.*[\\\/]/, ''); // For UI/file searches, flag whether the match is the WHOLE string value // ("exact") or just contained inside a longer one ("within longer text"). var tag = (x.quoted === true) ? ' exact' : (x.quoted === false) ? ' within longer text' : ''; var el = htmlEl('' + esc(fn) + (x.line ? ':' + x.line : '') + tag + (x.isSelf ? ' (this line)' : '') + ''); el.addEventListener('click', function () { openInEditor(x.file, x.line, x.col); }); container.appendChild(el); }); head.querySelector('[data-act="openall"]').addEventListener('click', function () { if (shown.length > 8 && !window.confirm('Open ' + shown.length + ' files in the editor?')) { return; } shown.forEach(function (x) { openInEditor(x.file, x.line, x.col); }); }); } function renderImages() { // Objects captured from an Inspect click (the full stack under the cursor) if (ui.selection && ui.selection.length) { var selHead = htmlEl('
' + 'Under cursor (' + ui.selection.length + ')' + 'Clear
'); selHead.querySelector('[data-act="clearsel"]').addEventListener('click', function () { ui.selection = null; render(); }); ui.body.appendChild(selHead); ui.selection.forEach(function (sel) { ui.body.appendChild(buildImgRow(sel.url, sel.bitmap, { screen: sel.screen })); }); ui.body.appendChild(htmlEl('
')); } var live = collectOnScreenImages(); ui.body.appendChild(htmlEl('
On screen now (' + live.length + ')
')); if (!live.length) { ui.body.appendChild(htmlEl('
No image sprites on screen.
')); } live.forEach(function (img) { ui.body.appendChild(buildImgRow(img.url, img.bitmap, { screen: img.screen, trigger: imageTriggers[img.url] })); }); // recent loads (history) ui.body.appendChild(htmlEl('
Recently loaded
')); for (var i = imageLog.length - 1; i >= 0 && i > imageLog.length - 25; i--) { var it = imageLog[i]; ui.body.appendChild(buildImgRow(it.url, it.bitmap, { fromLabel: it.label })); } } function htmlEl(html) { var d = document.createElement('div'); d.innerHTML = html; return d.firstChild; } function showHighlight(s) { if (!s || !ui.highlight) { return; } var h = ui.highlight; h.style.left = s.x + 'px'; h.style.top = s.y + 'px'; h.style.width = s.w + 'px'; h.style.height = s.h + 'px'; h.style.display = 'block'; } function hideHighlight() { if (ui.highlight) { ui.highlight.style.display = 'none'; } } //========================================================================= // Inspect / pick mode: hover the game canvas to highlight the image object // under the cursor (topmost), click to reveal its source file. //========================================================================= function canvasScale(rect) { return (rect && typeof Graphics !== 'undefined' && Graphics.width) ? rect.width / Graphics.width : 1; } function boundsToScreen(b, rect, scale) { return { x: rect.left + b.x * scale, y: rect.top + b.y * scale, w: b.width * scale, h: b.height * scale }; } // ALL bitmap objects whose bounds contain the point, bottom-to-top draw order. function hitTestAll(stageX, stageY) { var scene = (typeof SceneManager !== 'undefined') && SceneManager._scene; var hits = []; if (!scene) { return hits; } (function walk(obj) { if (!obj || obj.visible === false) { return; } var bmp = obj.bitmap; if (bmp && bmp._url) { var vis = ('worldVisible' in obj) ? obj.worldVisible : true; if (vis) { var b = null; try { b = obj.getBounds && obj.getBounds(); } catch (e) { b = null; } if (b && b.width > 0 && b.height > 0 && stageX >= b.x && stageX <= b.x + b.width && stageY >= b.y && stageY <= b.y + b.height) { hits.push({ url: decodeUrl(bmp._url), bounds: b, bitmap: bmp }); } } } var kids = obj.children; if (kids) { for (var i = 0; i < kids.length; i++) { walk(kids[i]); } } })(scene); return hits; // index 0 = bottom-most, last = top-most } function showHighlights(rects) { // multiple rects; last (top-most) is brighter var layer = ui.hlLayer; if (!layer) { return; } layer.innerHTML = ''; rects.forEach(function (r, i) { var top = i === rects.length - 1; var d = document.createElement('div'); d.style.cssText = 'position:fixed;pointer-events:none;left:' + r.x + 'px;top:' + r.y + 'px;width:' + r.w + 'px;height:' + r.h + 'px;border:2px ' + (top ? 'solid #ff5' : 'dashed #6cf') + ';background:' + (top ? 'rgba(255,255,0,0.12)' : 'rgba(80,170,255,0.06)'); layer.appendChild(d); }); layer.style.display = 'block'; } function hideHighlights() { if (ui.hlLayer) { ui.hlLayer.innerHTML = ''; ui.hlLayer.style.display = 'none'; } } function onPickMove(ev) { var canvas = getGameCanvas(); if (!canvas) { return; } var rect = canvas.getBoundingClientRect(); var scale = canvasScale(rect); var sx = (ev.clientX - rect.left) / scale, sy = (ev.clientY - rect.top) / scale; var hits = hitTestAll(sx, sy); if (hits.length) { showHighlights(hits.map(function (h) { return boundsToScreen(h.bounds, rect, scale); })); var topName = hits[hits.length - 1].url.replace(/^.*\//, ''); ui.pickLabel.innerHTML = (hits.length > 1 ? '' + hits.length + ' objects here — click to list
' : '') + 'top: ' + esc(topName); ui.pickLabel.style.left = Math.min(ev.clientX + 12, window.innerWidth - 260) + 'px'; ui.pickLabel.style.top = (ev.clientY + 14) + 'px'; ui.pickLabel.style.display = 'block'; ui._pickHits = hits.map(function (h) { return { url: h.url, bitmap: h.bitmap, screen: boundsToScreen(h.bounds, rect, scale) }; }); } else { hideHighlights(); ui.pickLabel.style.display = 'none'; ui._pickHits = null; } } // Click does NOT open anything: it lists every object under the cursor in the // panel, where the user can highlight/reveal each one deliberately. function onPickClick(ev) { ev.preventDefault(); ev.stopPropagation(); var hits = ui._pickHits; setPick(false); if (hits && hits.length) { ui.selection = hits; ui.tab = 'images'; render(); } } function setPick(on) { buildUI(); ui.pick = !!on; var btn = ui.root.querySelector('[data-act="pick"]'); if (btn) { btn.classList.toggle('on', ui.pick); } if (ui.pick) { var canvas = getGameCanvas(); var rect = canvas ? canvas.getBoundingClientRect() : { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight }; if (!ui.catcher) { ui.catcher = document.createElement('div'); ui.catcher.style.cssText = 'position:fixed;z-index:2147483644;cursor:crosshair;background:transparent'; document.body.appendChild(ui.catcher); ui.catcher.addEventListener('mousemove', onPickMove); ui.catcher.addEventListener('click', onPickClick, true); // Swallow all pointer input over the canvas while inspecting, so the // game never reacts (this also keeps "freeze" effective during Inspect). ['mousedown', 'mouseup', 'dblclick', 'wheel', 'contextmenu', 'mousemove', 'touchstart', 'touchmove', 'touchend', 'pointerdown', 'pointerup', 'pointermove'].forEach(function (t) { ui.catcher.addEventListener(t, function (e) { e.stopPropagation(); }, false); }); } ui.catcher.style.left = rect.left + 'px'; ui.catcher.style.top = rect.top + 'px'; ui.catcher.style.width = rect.width + 'px'; ui.catcher.style.height = rect.height + 'px'; ui.catcher.style.display = 'block'; toast('Inspect: hover an object, click to reveal. Esc/Inspect to exit.'); } else { if (ui.catcher) { ui.catcher.style.display = 'none'; } hideHighlight(); hideHighlights(); if (ui.pickLabel) { ui.pickLabel.style.display = 'none'; } } } var refreshPending = false; function scheduleRefresh() { if (refreshPending || !ui.open) { return; } refreshPending = true; setTimeout(function () { refreshPending = false; render(); }, 60); } function toggle(force) { buildUI(); ui.open = (typeof force === 'boolean') ? force : !ui.open; ui.root.style.display = ui.open ? 'flex' : 'none'; if (!ui.open && ui.pick) { setPick(false); } hideHighlight(); if (ui.open) { render(); if (!ui.refreshTimer) { ui.refreshTimer = setInterval(function () { if (ui.open && ui.tab === 'images') { var sig = onScreenSignature(); if (sig !== ui._imgSig) { ui._imgSig = sig; render(); } } }, 700); } } } //========================================================================= // Toast //========================================================================= var toastEl = null, toastTimer = 0; function toast(msg) { if (!toastEl) { toastEl = document.createElement('div'); toastEl.style.cssText = 'position:fixed;bottom:18px;left:50%;transform:translateX(-50%);background:#222;color:#fff;padding:8px 14px;border-radius:6px;z-index:2147483647;font:12px monospace;box-shadow:0 2px 8px rgba(0,0,0,.5)'; document.body.appendChild(toastEl); } toastEl.textContent = msg; toastEl.style.display = 'block'; clearTimeout(toastTimer); toastTimer = setTimeout(function () { toastEl.style.display = 'none'; }, 2600); } //========================================================================= // Hotkey //========================================================================= document.addEventListener('keydown', function (e) { if (e.key === CFG.hotkey) { e.preventDefault(); e.stopPropagation(); toggle(); } else if (e.key === 'Escape' && ui.pick) { e.preventDefault(); e.stopPropagation(); setPick(false); } }, true); // "Freeze game controls" guard: when enabled and the editor is open, block input // events from reaching the game's document/window listeners. Events targeting the // panel (or the Inspect catcher) and our own hotkeys are always allowed through. function inPanel(node) { return !!(node && ( (ui.root && ui.root.contains(node)) || (ui.catcher && ui.catcher === node) || (editor.root && editor.root.contains(node)) // built-in editor needs raw input )); } function freezeGuard(e) { if (!ui.open || !ui.freeze) { return; } if (inPanel(e.target)) { return; } // our panel / built-in editor get input untouched var isKey = (e.type === 'keydown' || e.type === 'keyup' || e.type === 'keypress'); if (isKey) { if (e.key === CFG.hotkey || e.key === 'Escape') { return; } // hotkeys always work // Always block the game from seeing the key (stopPropagation), but do NOT // cancel the browser default for copy/select shortcuts (Ctrl+C / Ctrl+A) so // the user can still copy selected text from the panel. e.stopPropagation(); if (e.stopImmediatePropagation) { e.stopImmediatePropagation(); } if (e.cancelable && !(e.ctrlKey || e.metaKey)) { e.preventDefault(); } return; } if (inPanel(e.target)) { return; } // let the panel receive mouse/wheel/touch e.stopPropagation(); if (e.stopImmediatePropagation) { e.stopImmediatePropagation(); } if (e.cancelable) { e.preventDefault(); } } ['mousedown', 'mouseup', 'click', 'dblclick', 'wheel', 'mousemove', 'contextmenu', 'touchstart', 'touchmove', 'touchend', 'keydown', 'keyup', 'keypress', 'pointerdown', 'pointerup', 'pointermove'].forEach(function (t) { window.addEventListener(t, freezeGuard, true); // capture: runs before game handlers }); window.TLInspector = { open: function () { toggle(true); }, close: function () { toggle(false); }, records: textRecords, images: imageLog, cfg: CFG }; log('ready - press ' + CFG.hotkey + ' to open'); })();