deterministically locate a lot of UI strings + UI scale fix
This commit is contained in:
parent
acfc72da6e
commit
03c8952ac8
1 changed files with 667 additions and 45 deletions
|
|
@ -19,8 +19,18 @@
|
||||||
* - Dialogue / choices / scroll text -> www/data/MapXXX.json,
|
* - Dialogue / choices / scroll text -> www/data/MapXXX.json,
|
||||||
* CommonEvents.json, Troops.json (by identity-matching the running event
|
* CommonEvents.json, Troops.json (by identity-matching the running event
|
||||||
* list, so duplicate strings are never confused).
|
* list, so duplicate strings are never confused).
|
||||||
|
* - Scenario-plugin dialogue -> scenario/Scenario.json and similar
|
||||||
|
* (plugin scenario engines such as Nore/"Tes" that keep command-lists in one
|
||||||
|
* big JSON dictionary keyed by scene name, loaded into $dataScenario). Both
|
||||||
|
* in-game location and "Locate in Files" cover these.
|
||||||
* - Pictures and other images -> img/... path + the event command
|
* - Pictures and other images -> img/... path + the event command
|
||||||
* (or JS call stack) that loaded them.
|
* (or JS call stack) that loaded them.
|
||||||
|
* - Plugin / menu UI text (Bitmap.drawText / drawTextEx) -> where the VALUE
|
||||||
|
* itself lives whenever it can be resolved: a TextManager vocab assignment
|
||||||
|
* in a plugin .js, a System.json term, a database name/description, a
|
||||||
|
* notetag <tag:value>, or a plugins.js parameter. Strings built with
|
||||||
|
* String.prototype.format ("%1 remaining") are traced back to their
|
||||||
|
* template. The draw call site is kept as secondary info.
|
||||||
*
|
*
|
||||||
* Press the hotkey (default F10) to open the inspector overlay. Each row has an
|
* 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
|
* "Open in VSCode" button (runs `code -g file:line`) and an **edit** button to
|
||||||
|
|
@ -84,12 +94,16 @@
|
||||||
if (!isNaN(n) && n > 0) { return Math.max(0.75, Math.min(3, n)); }
|
if (!isNaN(n) && n > 0) { return Math.max(0.75, Math.min(3, n)); }
|
||||||
}
|
}
|
||||||
var base = 816;
|
var base = 816;
|
||||||
var gameW = (typeof Graphics !== 'undefined' && Graphics.width) ? Graphics.width : 0;
|
|
||||||
var viewW = window.innerWidth || document.documentElement.clientWidth || base;
|
var viewW = window.innerWidth || document.documentElement.clientWidth || base;
|
||||||
var w = Math.max(gameW || base, viewW);
|
// The overlay lives in WINDOW pixels, so scale from the window size only.
|
||||||
var scale = w / base;
|
// Using the game's internal resolution made a 1920px-wide game in a ~1366px
|
||||||
|
// window zoom 2.35x, with the 460px panel swallowing >80% of the screen.
|
||||||
|
var scale = viewW / base;
|
||||||
var dpr = window.devicePixelRatio || 1;
|
var dpr = window.devicePixelRatio || 1;
|
||||||
if (dpr > 1.15) { scale *= Math.min(2, 0.75 + dpr * 0.35); }
|
if (dpr > 1.15) { scale *= Math.min(2, 0.75 + dpr * 0.35); }
|
||||||
|
// Whatever the math says, keep the panel under ~45% of the window
|
||||||
|
// (the >=1 floor below still wins for small windows).
|
||||||
|
scale = Math.min(scale, (viewW * 0.45) / 460);
|
||||||
return Math.max(1, Math.min(2.75, scale));
|
return Math.max(1, Math.min(2.75, scale));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -168,6 +182,41 @@
|
||||||
return path ? path.join(DATA_DIR, name) : DATA_DIR + '/' + name;
|
return path ? path.join(DATA_DIR, name) : DATA_DIR + '/' + name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scenario systems (e.g. the Nore / "Tes" engine) keep event command-lists OUTSIDE
|
||||||
|
// Map/CommonEvents/Troops: one big JSON dictionary under scenario/, keyed by scene
|
||||||
|
// name, loaded into a global ($dataScenario) and run via Game_Interpreter.setupChild.
|
||||||
|
// Each value is a plain MZ command list, so once we know the file + key it resolves
|
||||||
|
// to a line just like any other data file.
|
||||||
|
var SCENARIO_DIR = 'scenario';
|
||||||
|
var SCENARIO_SETS = [
|
||||||
|
{ global: '$dataScenario', file: 'Scenario.json' },
|
||||||
|
{ global: '$dataScenario_en', file: 'Scenario_en.json' }
|
||||||
|
];
|
||||||
|
function scenarioPath(fileName) {
|
||||||
|
return path ? path.join(WWW_ROOT, SCENARIO_DIR, fileName)
|
||||||
|
: WWW_ROOT + '/' + SCENARIO_DIR + '/' + fileName;
|
||||||
|
}
|
||||||
|
// Every *.json actually present in the scenario/ folder (empty for normal games).
|
||||||
|
function scenarioFiles() {
|
||||||
|
var out = [];
|
||||||
|
if (!nodeOk) { return out; }
|
||||||
|
try {
|
||||||
|
fs.readdirSync(scenarioPath('')).forEach(function (f) {
|
||||||
|
if (/\.json$/i.test(f)) { out.push(scenarioPath(f)); }
|
||||||
|
});
|
||||||
|
} catch (e) { /* no scenario/ folder -> normal for most games */ }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
// If a scenario file's dictionary is already parsed into a known global, return the
|
||||||
|
// global name so callers can reuse it instead of re-parsing a (often huge) file.
|
||||||
|
function scenarioGlobalForFile(file) {
|
||||||
|
var base = baseName(file).toLowerCase();
|
||||||
|
for (var i = 0; i < SCENARIO_SETS.length; i++) {
|
||||||
|
if (SCENARIO_SETS[i].file.toLowerCase() === base) { return SCENARIO_SETS[i].global; }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function log() {
|
function log() {
|
||||||
if (window.console) { console.log.apply(console, ['[TLInspector]'].concat([].slice.call(arguments))); }
|
if (window.console) { console.log.apply(console, ['[TLInspector]'].concat([].slice.call(arguments))); }
|
||||||
}
|
}
|
||||||
|
|
@ -290,15 +339,43 @@
|
||||||
return skipWs(t, i);
|
return skipWs(t, i);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// File drifted under a structural path (e.g. the scenario JSON was re-exported while
|
||||||
|
// the game held an older parse, shifting command indices): find the exact quoted
|
||||||
|
// value instead, preferring the occurrence nearest the path's top-level container so
|
||||||
|
// duplicate lines in other scenes/events don't win.
|
||||||
|
function reanchorByText(c, pathArr, quoted) {
|
||||||
|
var hits = [], p = c.text.indexOf(quoted), CAP = 5000;
|
||||||
|
while (p >= 0 && hits.length < CAP) { hits.push(p); p = c.text.indexOf(quoted, p + quoted.length); }
|
||||||
|
if (!hits.length) { return null; }
|
||||||
|
if (hits.length === 1) { return offsetToLineCol(c, hits[0]); }
|
||||||
|
var anchor = locateOffset(c.text, pathArr.slice(0, 1)); // start of the scene / list
|
||||||
|
var best = hits[0];
|
||||||
|
if (anchor >= 0) {
|
||||||
|
var bd = Math.abs(hits[0] - anchor);
|
||||||
|
for (var k = 1; k < hits.length; k++) { var dd = Math.abs(hits[k] - anchor); if (dd < bd) { bd = dd; best = hits[k]; } }
|
||||||
|
}
|
||||||
|
return offsetToLineCol(c, best);
|
||||||
|
}
|
||||||
|
|
||||||
var lineCache = {}; // file::pathKey::mtime -> {line,col}
|
var lineCache = {}; // file::pathKey::mtime -> {line,col}
|
||||||
function locateLine(file, pathArr) {
|
function locateLine(file, pathArr, expectedText) {
|
||||||
if (!file || !pathArr || !nodeOk) { return null; }
|
if (!file || !pathArr || !nodeOk) { return null; }
|
||||||
var c = readFileCached(file);
|
var c = readFileCached(file);
|
||||||
if (!c) { return null; }
|
if (!c) { return null; }
|
||||||
var key = file + '::' + pathArr.join('/') + '::' + c.mtime;
|
var key = file + '::' + pathArr.join('/') + '::' + c.mtime;
|
||||||
if (lineCache[key]) { return lineCache[key]; }
|
if (lineCache.hasOwnProperty(key)) { return lineCache[key]; }
|
||||||
var off = locateOffset(c.text, pathArr);
|
var off = locateOffset(c.text, pathArr);
|
||||||
var res = off >= 0 ? offsetToLineCol(c, off) : null;
|
var quoted = (expectedText != null && expectedText !== '') ? JSON.stringify(String(expectedText)) : null;
|
||||||
|
var res;
|
||||||
|
if (off >= 0 && (!quoted || c.text.substr(off, quoted.length) === quoted)) {
|
||||||
|
res = offsetToLineCol(c, off); // path still valid (verified against the text)
|
||||||
|
} else if (quoted) {
|
||||||
|
// Path drifted (or the text isn't where the path points) -> re-anchor by exact
|
||||||
|
// text; if that finds nothing, still fall back to the structural offset.
|
||||||
|
res = reanchorByText(c, pathArr, quoted) || (off >= 0 ? offsetToLineCol(c, off) : null);
|
||||||
|
} else {
|
||||||
|
res = off >= 0 ? offsetToLineCol(c, off) : null;
|
||||||
|
}
|
||||||
lineCache[key] = res;
|
lineCache[key] = res;
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
@ -345,6 +422,18 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Scenario dictionaries: the running list is a value of $dataScenario keyed
|
||||||
|
// by scene name (setupChild runs the list by reference, so identity holds).
|
||||||
|
for (var si = 0; si < SCENARIO_SETS.length; si++) {
|
||||||
|
var sg = SCENARIO_SETS[si], dict = window[sg.global];
|
||||||
|
if (!dict || typeof dict !== 'object') { continue; }
|
||||||
|
for (var sk in dict) {
|
||||||
|
if (dict.hasOwnProperty(sk) && dict[sk] === list) {
|
||||||
|
return { file: scenarioPath(sg.file), prefix: [sk],
|
||||||
|
label: sg.file.replace(/\.json$/i, '') + ':' + sk };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) { log('resolveList error', e); }
|
} catch (e) { log('resolveList error', e); }
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -535,8 +624,23 @@
|
||||||
var i = findByKey(key);
|
var i = findByKey(key);
|
||||||
if (i >= 0) { seenExisting(i, -1); return; }
|
if (i >= 0) { seenExisting(i, -1); return; }
|
||||||
var site = firstUserFrame(new Error().stack);
|
var site = firstUserFrame(new Error().stack);
|
||||||
textRecords.push({ kind: 'ui', text: text, file: site.file, prefix: null,
|
var rec = { kind: 'ui', text: text, file: site.file, prefix: null,
|
||||||
tail: null, label: site.label, uiLine: site.line, group: -1, ts: Date.now(), _key: key });
|
tail: null, label: site.label, uiLine: site.line, uiCol: null,
|
||||||
|
group: -1, ts: Date.now(), _key: key };
|
||||||
|
// Prefer the VALUE's own home (vocab assignment / term / notetag / parameter)
|
||||||
|
// over the draw call site; keep the site as secondary info.
|
||||||
|
try {
|
||||||
|
var src = resolveUiSource(text);
|
||||||
|
if (src) {
|
||||||
|
rec.srcKind = src.kind; rec.label = src.label; rec.expText = src.expText;
|
||||||
|
if (src.file) {
|
||||||
|
rec.file = src.file; rec.uiLine = src.line || null; rec.uiCol = src.col || null;
|
||||||
|
rec.tail = src.tail || null;
|
||||||
|
rec.siteFile = site.file; rec.siteLine = site.line; rec.siteLabel = site.label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { log('uiSource', e); }
|
||||||
|
textRecords.push(rec);
|
||||||
while (textRecords.length > CFG.historySize) { textRecords.shift(); }
|
while (textRecords.length > CFG.historySize) { textRecords.shift(); }
|
||||||
scheduleRefresh();
|
scheduleRefresh();
|
||||||
}
|
}
|
||||||
|
|
@ -554,6 +658,363 @@
|
||||||
return { file: null, line: null, label: '(unknown)' };
|
return { file: null, line: null, label: '(unknown)' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//=========================================================================
|
||||||
|
// UI text provenance: map a drawn string back to the VALUE's own home
|
||||||
|
// (TextManager vocab assignment, System.json term, database field, notetag
|
||||||
|
// meta value, or plugins.js parameter) instead of just the draw call site.
|
||||||
|
// Index is built lazily from live game state; invalidated on F9 reload.
|
||||||
|
//=========================================================================
|
||||||
|
var uiValueIndex = null, uiIndexBuiltAt = 0;
|
||||||
|
|
||||||
|
function uiIndexAdd(map, value, cand) {
|
||||||
|
if (typeof value !== 'string') { return; }
|
||||||
|
if (value.replace(/\s/g, '').length < 2) { return; } // same floor as capture
|
||||||
|
(map[value] || (map[value] = [])).push(cand);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUiValueIndex() {
|
||||||
|
// null prototype: game text can legitimately be 'constructor'/'toString'…
|
||||||
|
var map = Object.create(null);
|
||||||
|
// TextManager vocab: plugin-assigned own DATA properties. (The engine's own
|
||||||
|
// terms are accessor properties reading $dataSystem.terms -> indexed below.)
|
||||||
|
try {
|
||||||
|
if (typeof TextManager !== 'undefined') {
|
||||||
|
Object.getOwnPropertyNames(TextManager).forEach(function (p) {
|
||||||
|
var d = Object.getOwnPropertyDescriptor(TextManager, p);
|
||||||
|
if (d && !d.get && typeof d.value === 'string') {
|
||||||
|
uiIndexAdd(map, d.value, { t: 'tm', prop: p });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) { log('uiIndex tm', e); }
|
||||||
|
// System.json: terms, type-name lists, and a few top-level strings.
|
||||||
|
try {
|
||||||
|
if (typeof $dataSystem !== 'undefined' && $dataSystem) {
|
||||||
|
var sysFile = dataFile('System.json');
|
||||||
|
var terms = $dataSystem.terms || {};
|
||||||
|
['basic', 'commands', 'params'].forEach(function (g) {
|
||||||
|
(terms[g] || []).forEach(function (v, i) {
|
||||||
|
uiIndexAdd(map, v, { t: 'json', file: sysFile, path: ['terms', g, i],
|
||||||
|
label: 'System terms.' + g + '[' + i + ']' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
var msgs = terms.messages || {};
|
||||||
|
Object.keys(msgs).forEach(function (k) {
|
||||||
|
uiIndexAdd(map, msgs[k], { t: 'json', file: sysFile, path: ['terms', 'messages', k],
|
||||||
|
label: 'System terms.messages.' + k });
|
||||||
|
});
|
||||||
|
['elements', 'skillTypes', 'weaponTypes', 'armorTypes', 'equipTypes'].forEach(function (g) {
|
||||||
|
($dataSystem[g] || []).forEach(function (v, i) {
|
||||||
|
uiIndexAdd(map, v, { t: 'json', file: sysFile, path: [g, i],
|
||||||
|
label: 'System ' + g + '[' + i + ']' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
uiIndexAdd(map, $dataSystem.gameTitle, { t: 'json', file: sysFile, path: ['gameTitle'], label: 'System gameTitle' });
|
||||||
|
uiIndexAdd(map, $dataSystem.currencyUnit, { t: 'json', file: sysFile, path: ['currencyUnit'], label: 'System currencyUnit' });
|
||||||
|
}
|
||||||
|
} catch (e) { log('uiIndex system', e); }
|
||||||
|
// Database display fields + notetag meta values. A meta value resolves to the
|
||||||
|
// object's "note" line; locateMetaValue narrows to the value inside the tag.
|
||||||
|
[
|
||||||
|
['$dataActors', 'Actors.json', ['name', 'nickname', 'profile']],
|
||||||
|
['$dataClasses', 'Classes.json', ['name']],
|
||||||
|
['$dataSkills', 'Skills.json', ['name', 'description', 'message1', 'message2']],
|
||||||
|
['$dataItems', 'Items.json', ['name', 'description']],
|
||||||
|
['$dataWeapons', 'Weapons.json', ['name', 'description']],
|
||||||
|
['$dataArmors', 'Armors.json', ['name', 'description']],
|
||||||
|
['$dataEnemies', 'Enemies.json', ['name']],
|
||||||
|
['$dataStates', 'States.json', ['name', 'message1', 'message2', 'message3', 'message4']]
|
||||||
|
].forEach(function (spec) {
|
||||||
|
try {
|
||||||
|
var arr = window[spec[0]];
|
||||||
|
if (!Array.isArray(arr)) { return; }
|
||||||
|
var file = dataFile(spec[1]), short = spec[1].replace(/\.json$/i, '');
|
||||||
|
for (var id = 1; id < arr.length; id++) {
|
||||||
|
var o = arr[id];
|
||||||
|
if (!o) { continue; }
|
||||||
|
spec[2].forEach(function (f) {
|
||||||
|
uiIndexAdd(map, o[f], { t: 'json', file: file, path: [id, f],
|
||||||
|
label: short + '[' + id + '].' + f });
|
||||||
|
});
|
||||||
|
if (o.meta) {
|
||||||
|
Object.keys(o.meta).forEach(function (mk) {
|
||||||
|
if (typeof o.meta[mk] === 'string') {
|
||||||
|
uiIndexAdd(map, o.meta[mk], { t: 'meta', file: file, path: [id, 'note'],
|
||||||
|
label: short + '[' + id + '] note <' + mk + ':…>' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { log('uiIndex ' + spec[0], e); }
|
||||||
|
});
|
||||||
|
// Plugin parameters (plugins.js).
|
||||||
|
try {
|
||||||
|
if (window.$plugins) {
|
||||||
|
window.$plugins.forEach(function (pl) {
|
||||||
|
if (!pl || pl.status === false || !pl.parameters) { return; }
|
||||||
|
Object.keys(pl.parameters).forEach(function (pk) {
|
||||||
|
uiIndexAdd(map, pl.parameters[pk], { t: 'param', plugin: pl.name, param: pk });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) { log('uiIndex params', e); }
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uiValueCandidates(text) {
|
||||||
|
if (!uiValueIndex) { uiValueIndex = buildUiValueIndex(); uiIndexBuiltAt = Date.now(); }
|
||||||
|
if (!jsLitIndex) { jsLitIndex = buildJsLitIndex(); }
|
||||||
|
var c = uiValueIndex[text] || null;
|
||||||
|
var lits = jsLitIndex[text] || null;
|
||||||
|
if (!c && !lits && Date.now() - uiIndexBuiltAt > 5000) {
|
||||||
|
// Vocab tables can be rewritten at runtime (language toggles): on a miss,
|
||||||
|
// rebuild against current state, at most once every 5s.
|
||||||
|
uiValueIndex = buildUiValueIndex(); uiIndexBuiltAt = Date.now();
|
||||||
|
c = uiValueIndex[text] || null;
|
||||||
|
}
|
||||||
|
if (lits) { c = (c || []).concat(lits); }
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse a JS string literal starting at t[i] (the quote) -> {value, end} or null.
|
||||||
|
function parseJsString(t, i) {
|
||||||
|
var q = t[i];
|
||||||
|
if (q !== '"' && q !== "'" && q !== '`') { return null; }
|
||||||
|
var out = '', j = i + 1;
|
||||||
|
while (j < t.length) {
|
||||||
|
var ch = t[j];
|
||||||
|
if (ch === '\\') {
|
||||||
|
var n = t[j + 1];
|
||||||
|
if (n === 'n') { out += '\n'; }
|
||||||
|
else if (n === 't') { out += '\t'; }
|
||||||
|
else if (n === 'r') { out += '\r'; }
|
||||||
|
else if (n === 'u') { out += String.fromCharCode(parseInt(t.substr(j + 2, 4), 16) || 0); j += 4; }
|
||||||
|
else if (n === 'x') { out += String.fromCharCode(parseInt(t.substr(j + 2, 2), 16) || 0); j += 2; }
|
||||||
|
else { out += n; }
|
||||||
|
j += 2; continue;
|
||||||
|
}
|
||||||
|
if (ch === q) { return { value: out, end: j + 1 }; }
|
||||||
|
if (ch === '\n' && q !== '`') { return null; } // unterminated
|
||||||
|
out += ch; j++;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plugin .js files in LOAD order (later plugins overwrite earlier assignments, so
|
||||||
|
// "last literal match" below = the assignment that actually won at runtime).
|
||||||
|
function pluginFilesInLoadOrder() {
|
||||||
|
var out = [];
|
||||||
|
if (!nodeOk) { return out; }
|
||||||
|
var pdir = path.join(WWW_ROOT, 'js', 'plugins');
|
||||||
|
try {
|
||||||
|
if (window.$plugins) {
|
||||||
|
window.$plugins.forEach(function (pl) {
|
||||||
|
if (pl && pl.status !== false && pl.name && pl.name !== 'TLInspector') {
|
||||||
|
out.push(path.join(pdir, pl.name + '.js'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
if (!out.length) {
|
||||||
|
try {
|
||||||
|
fs.readdirSync(pdir).forEach(function (f) {
|
||||||
|
if (/\.js$/.test(f) && f !== 'TLInspector.js') { out.push(path.join(pdir, f)); }
|
||||||
|
});
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the plugin-file assignment `TextManager.<prop> = '<literal>'` whose literal
|
||||||
|
// equals the property's CURRENT runtime value — this is what distinguishes an
|
||||||
|
// English vocab file from the Japanese one when both assign the same property.
|
||||||
|
// Falls back to the last assignment site in load order (value built at runtime).
|
||||||
|
var tmAssignCache = {}; // prop \0 value -> {file,line,col,exact} | null
|
||||||
|
function locateTextManagerAssignment(prop, value) {
|
||||||
|
var key = prop + '\u0000' + value;
|
||||||
|
if (tmAssignCache.hasOwnProperty(key)) { return tmAssignCache[key]; }
|
||||||
|
var safe = String(prop).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
var re = new RegExp('TextManager\\s*(?:\\.\\s*' + safe +
|
||||||
|
'|\\[\\s*[\'"]' + safe + '[\'"]\\s*\\])\\s*=(?![=>])', 'g');
|
||||||
|
var literalHit = null, lastSite = null;
|
||||||
|
pluginFilesInLoadOrder().forEach(function (file) {
|
||||||
|
var c = readFileCached(file);
|
||||||
|
if (!c) { return; }
|
||||||
|
re.lastIndex = 0;
|
||||||
|
var m;
|
||||||
|
while ((m = re.exec(c.text))) {
|
||||||
|
if (inComment(c, m.index)) { continue; }
|
||||||
|
var vi = skipWs(c.text, m.index + m[0].length);
|
||||||
|
var site = { file: file, off: vi };
|
||||||
|
lastSite = site;
|
||||||
|
var lit = parseJsString(c.text, vi);
|
||||||
|
if (lit && lit.value === value) { literalHit = site; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
var hit = literalHit || lastSite, res = null;
|
||||||
|
if (hit) {
|
||||||
|
var lc = offsetToLineCol(readFileCached(hit.file), hit.off);
|
||||||
|
res = { file: hit.file, line: lc.line, col: lc.col, off: hit.off, exact: !!literalHit };
|
||||||
|
}
|
||||||
|
tmAssignCache[key] = res;
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locate a plugin parameter value inside plugins.js (bounded to that plugin's entry).
|
||||||
|
function locatePluginParam(plugin, param, value) {
|
||||||
|
if (!nodeOk) { return null; }
|
||||||
|
var file = path.join(WWW_ROOT, 'js', 'plugins.js');
|
||||||
|
var c = readFileCached(file);
|
||||||
|
if (!c) { return null; }
|
||||||
|
var anchor = c.text.indexOf('"name":"' + plugin + '"');
|
||||||
|
if (anchor < 0) {
|
||||||
|
anchor = c.text.search(new RegExp('"name"\\s*:\\s*"' +
|
||||||
|
String(plugin).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '"'));
|
||||||
|
}
|
||||||
|
if (anchor < 0) { return null; }
|
||||||
|
var next = c.text.indexOf('"name":', anchor + 7); // start of the NEXT plugin entry
|
||||||
|
var p = c.text.indexOf(JSON.stringify(String(value)), anchor);
|
||||||
|
var lc = offsetToLineCol(c, (p >= 0 && (next < 0 || p < next)) ? p : anchor);
|
||||||
|
return { file: file, line: lc.line, col: lc.col };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line/col of a notetag VALUE inside an object's "note" string. The JSON path
|
||||||
|
// points at the whole note; narrow to the meta value's escaped text within it.
|
||||||
|
function locateMetaValue(file, pathArr, value) {
|
||||||
|
if (!file || !pathArr || !nodeOk) { return null; }
|
||||||
|
var c = readFileCached(file);
|
||||||
|
if (!c) { return null; }
|
||||||
|
var key = file + '::meta:' + pathArr.join('/') + '::' + value + '::' + c.mtime;
|
||||||
|
if (lineCache.hasOwnProperty(key)) { return lineCache[key]; }
|
||||||
|
var res = null;
|
||||||
|
var off = locateOffset(c.text, pathArr);
|
||||||
|
if (off >= 0 && c.text[off] === '"') {
|
||||||
|
var end = scanString(c.text, off);
|
||||||
|
var esc = JSON.stringify(String(value)).slice(1, -1);
|
||||||
|
var p = esc ? c.text.indexOf(esc, off) : -1;
|
||||||
|
res = offsetToLineCol(c, (p >= 0 && p < end) ? p : off);
|
||||||
|
} else if (off >= 0) {
|
||||||
|
res = offsetToLineCol(c, off);
|
||||||
|
}
|
||||||
|
lineCache[key] = res;
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// String literals hardcoded in plugin .js code — e.g. a medal/skill table defined
|
||||||
|
// as arrays right in a plugin ("Great Sage" in Nore_MedalParam.js). Lowest-priority
|
||||||
|
// source: a drawn string can coincidentally equal an unrelated code literal, so
|
||||||
|
// vocab/data/notetag/param sources always win over this. Built once, lazily
|
||||||
|
// (plugin files can't change without F5); dropped on F9 in case one was edited
|
||||||
|
// through the built-in editor.
|
||||||
|
var jsLitIndex = null;
|
||||||
|
var JSLIT_MAX_PER_VALUE = 8;
|
||||||
|
function buildJsLitIndex() {
|
||||||
|
// null prototype: plugin code is full of literals like 'constructor'/'toString'
|
||||||
|
var map = Object.create(null);
|
||||||
|
pluginFilesInLoadOrder().forEach(function (file) {
|
||||||
|
var c = readFileCached(file);
|
||||||
|
if (!c) { return; }
|
||||||
|
var t = c.text, i = 0, n = t.length;
|
||||||
|
while (i < n) {
|
||||||
|
var ch = t[i], nx = t[i + 1];
|
||||||
|
if (ch === '/' && nx === '/') { var e1 = t.indexOf('\n', i); i = e1 < 0 ? n : e1 + 1; continue; }
|
||||||
|
if (ch === '/' && nx === '*') { var e2 = t.indexOf('*/', i + 2); i = e2 < 0 ? n : e2 + 2; continue; }
|
||||||
|
if (ch === '"' || ch === "'" || ch === '`') {
|
||||||
|
var lit = parseJsString(t, i);
|
||||||
|
if (lit) {
|
||||||
|
var v = lit.value;
|
||||||
|
// same floors as maybeCaptureUiText: >=2 non-space chars + a real letter
|
||||||
|
if (v.replace(/\s/g, '').length >= 2 && /[^\s\d.,:%/+\-()]/.test(v)) {
|
||||||
|
var arr = map[v] || (map[v] = []);
|
||||||
|
if (arr.length < JSLIT_MAX_PER_VALUE) { arr.push({ t: 'jslit', file: file, off: i }); }
|
||||||
|
}
|
||||||
|
i = lit.end; continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent String.prototype.format calls (template -> output), so a drawn string
|
||||||
|
// like "2 turn(s) remaining" resolves to its '%1 turn(s) remaining' template even
|
||||||
|
// though the drawn form never exists in any file.
|
||||||
|
var FORMAT_LOG = [], FORMAT_LOG_MAX = 40;
|
||||||
|
if (CFG.captureUiText && String.prototype.format) {
|
||||||
|
var _strFormat = String.prototype.format;
|
||||||
|
String.prototype.format = function () {
|
||||||
|
var out = _strFormat.apply(this, arguments);
|
||||||
|
try {
|
||||||
|
if (arguments.length && typeof out === 'string' && out.length >= 2) {
|
||||||
|
var tpl = String(this);
|
||||||
|
if (tpl !== out) {
|
||||||
|
var last = FORMAT_LOG[FORMAT_LOG.length - 1];
|
||||||
|
if (!last || last.out !== out || last.tpl !== tpl) {
|
||||||
|
FORMAT_LOG.push({ tpl: tpl, out: out });
|
||||||
|
if (FORMAT_LOG.length > FORMAT_LOG_MAX) { FORMAT_LOG.shift(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drawn string -> best value-source descriptor, or null if unknown.
|
||||||
|
function resolveUiSource(text) {
|
||||||
|
var val = text, via = '', cands = uiValueCandidates(text);
|
||||||
|
if (!cands) {
|
||||||
|
for (var i = FORMAT_LOG.length - 1; i >= 0; i--) {
|
||||||
|
if (FORMAT_LOG[i].out === text) {
|
||||||
|
cands = uiValueCandidates(FORMAT_LOG[i].tpl);
|
||||||
|
if (cands) { val = FORMAT_LOG[i].tpl; via = ' ← format()'; }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!cands || !cands.length) { return null; }
|
||||||
|
var ORDER = { tm: 0, json: 1, meta: 2, param: 3, jslit: 4 };
|
||||||
|
var best = cands[0];
|
||||||
|
for (var k = 1; k < cands.length; k++) {
|
||||||
|
if (ORDER[cands[k].t] < ORDER[best.t]) { best = cands[k]; }
|
||||||
|
}
|
||||||
|
var loc = null;
|
||||||
|
if (best.t === 'tm') {
|
||||||
|
loc = locateTextManagerAssignment(best.prop, val);
|
||||||
|
// The assignment's own RHS literal is also in the jslit index — that's the
|
||||||
|
// SAME location, not an "other match": drop it before counting extras.
|
||||||
|
if (loc) {
|
||||||
|
cands = cands.filter(function (x) {
|
||||||
|
return !(x.t === 'jslit' && x.file === loc.file && x.off === loc.off);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var extra = cands.length - 1;
|
||||||
|
var suffix = via + (extra > 0 ? ' (+' + extra + ' other match' + (extra > 1 ? 'es' : '') + ')' : '');
|
||||||
|
if (best.t === 'tm') {
|
||||||
|
if (loc) {
|
||||||
|
return { kind: 'tm', file: loc.file, line: loc.line, col: loc.col,
|
||||||
|
label: 'TextManager.' + best.prop + suffix, expText: val };
|
||||||
|
}
|
||||||
|
return { kind: 'tm', file: null, expText: val,
|
||||||
|
label: 'TextManager.' + best.prop + ' (assignment not in plugin files)' + suffix };
|
||||||
|
}
|
||||||
|
if (best.t === 'param') {
|
||||||
|
var pp = locatePluginParam(best.plugin, best.param, val);
|
||||||
|
return { kind: 'param', file: pp && pp.file, line: pp && pp.line, col: pp && pp.col,
|
||||||
|
label: 'plugins.js ' + best.plugin + ' → ' + best.param + suffix, expText: val };
|
||||||
|
}
|
||||||
|
if (best.t === 'jslit') {
|
||||||
|
var cf = readFileCached(best.file);
|
||||||
|
if (!cf) { return null; }
|
||||||
|
var ll = offsetToLineCol(cf, best.off);
|
||||||
|
return { kind: 'jslit', file: best.file, line: ll.line, col: ll.col,
|
||||||
|
label: baseName(best.file) + ' string literal' + suffix, expText: val };
|
||||||
|
}
|
||||||
|
return { kind: best.t, file: best.file, tail: best.path, label: best.label + suffix, expText: val };
|
||||||
|
}
|
||||||
|
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
// Hooks: images (load log with call-site)
|
// Hooks: images (load log with call-site)
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
|
|
@ -912,37 +1373,48 @@
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
var dupIndex = null; // [{file, path, text}]
|
var dupIndex = null; // [{file, path, text}]
|
||||||
|
|
||||||
function collectMessages(absFile, fname, obj, emit) {
|
// Emit every message / choice string in an MZ command list, tagged with its JSON path.
|
||||||
function walkList(list, prefix) {
|
function walkCommandList(list, prefix, emit) {
|
||||||
if (!Array.isArray(list)) { return; }
|
if (!Array.isArray(list)) { return; }
|
||||||
for (var i = 0; i < list.length; i++) {
|
for (var i = 0; i < list.length; i++) {
|
||||||
var cmd = list[i];
|
var cmd = list[i];
|
||||||
if (!cmd) { continue; }
|
if (!cmd) { continue; }
|
||||||
if (cmd.code === 401 || cmd.code === 405) {
|
if (cmd.code === 401 || cmd.code === 405) {
|
||||||
if (cmd.parameters && typeof cmd.parameters[0] === 'string') {
|
if (cmd.parameters && typeof cmd.parameters[0] === 'string') {
|
||||||
emit(prefix.concat([i, 'parameters', 0]), cmd.parameters[0]);
|
emit(prefix.concat([i, 'parameters', 0]), cmd.parameters[0]);
|
||||||
}
|
|
||||||
} else if (cmd.code === 102 && cmd.parameters && Array.isArray(cmd.parameters[0])) {
|
|
||||||
cmd.parameters[0].forEach(function (ch, n) {
|
|
||||||
if (typeof ch === 'string') { emit(prefix.concat([i, 'parameters', 0, n]), ch); }
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
} else if (cmd.code === 102 && cmd.parameters && Array.isArray(cmd.parameters[0])) {
|
||||||
|
cmd.parameters[0].forEach(function (ch, n) {
|
||||||
|
if (typeof ch === 'string') { emit(prefix.concat([i, 'parameters', 0, n]), ch); }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectMessages(absFile, fname, obj, emit) {
|
||||||
if (/^Map\d+/.test(fname) && obj && obj.events) {
|
if (/^Map\d+/.test(fname) && obj && obj.events) {
|
||||||
obj.events.forEach(function (ev, e) {
|
obj.events.forEach(function (ev, e) {
|
||||||
if (!ev || !ev.pages) { return; }
|
if (!ev || !ev.pages) { return; }
|
||||||
ev.pages.forEach(function (pg, p) { walkList(pg.list, ['events', e, 'pages', p, 'list']); });
|
ev.pages.forEach(function (pg, p) { walkCommandList(pg.list, ['events', e, 'pages', p, 'list'], emit); });
|
||||||
});
|
});
|
||||||
} else if (fname === 'CommonEvents.json' && Array.isArray(obj)) {
|
} else if (fname === 'CommonEvents.json' && Array.isArray(obj)) {
|
||||||
obj.forEach(function (ce, c) { if (ce && ce.list) { walkList(ce.list, [c, 'list']); } });
|
obj.forEach(function (ce, c) { if (ce && ce.list) { walkCommandList(ce.list, [c, 'list'], emit); } });
|
||||||
} else if (fname === 'Troops.json' && Array.isArray(obj)) {
|
} else if (fname === 'Troops.json' && Array.isArray(obj)) {
|
||||||
obj.forEach(function (tr, t) {
|
obj.forEach(function (tr, t) {
|
||||||
if (tr && tr.pages) { tr.pages.forEach(function (pg, p) { walkList(pg.list, [t, 'pages', p, 'list']); }); }
|
if (tr && tr.pages) { tr.pages.forEach(function (pg, p) { walkCommandList(pg.list, [t, 'pages', p, 'list'], emit); }); }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scenario dictionary { "scene name": [ ...command list... ], ... } -> each value is
|
||||||
|
// a command list whose JSON path is [sceneName, index, 'parameters', 0].
|
||||||
|
function collectScenarioMessages(dict, emit) {
|
||||||
|
if (!dict || typeof dict !== 'object') { return; }
|
||||||
|
Object.keys(dict).forEach(function (key) {
|
||||||
|
if (Array.isArray(dict[key])) { walkCommandList(dict[key], [key], emit); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function buildDupIndex() {
|
function buildDupIndex() {
|
||||||
if (dupIndex) { return dupIndex; }
|
if (dupIndex) { return dupIndex; }
|
||||||
dupIndex = [];
|
dupIndex = [];
|
||||||
|
|
@ -961,6 +1433,20 @@
|
||||||
dupIndex.push({ file: abs, path: pathArr, text: text });
|
dupIndex.push({ file: abs, path: pathArr, text: text });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
// Scenario dictionaries: reuse the already-parsed global (Scenario.json can be
|
||||||
|
// tens of MB) and only fall back to parsing the file if the global is absent.
|
||||||
|
scenarioFiles().forEach(function (abs) {
|
||||||
|
var gname = scenarioGlobalForFile(abs);
|
||||||
|
var dict = gname ? window[gname] : null;
|
||||||
|
if (!dict) {
|
||||||
|
var sc = readFileCached(abs);
|
||||||
|
if (!sc) { return; }
|
||||||
|
try { dict = JSON.parse(sc.text); } catch (e) { return; }
|
||||||
|
}
|
||||||
|
collectScenarioMessages(dict, function (pathArr, text) {
|
||||||
|
dupIndex.push({ file: abs, path: pathArr, text: text });
|
||||||
|
});
|
||||||
|
});
|
||||||
log('dup index built', dupIndex.length, 'messages');
|
log('dup index built', dupIndex.length, 'messages');
|
||||||
} catch (e) { log('dup index', e); }
|
} catch (e) { log('dup index', e); }
|
||||||
return dupIndex;
|
return dupIndex;
|
||||||
|
|
@ -1077,6 +1563,9 @@
|
||||||
})
|
})
|
||||||
.forEach(function (f) { _scanFiles.push(dataFile(f)); });
|
.forEach(function (f) { _scanFiles.push(dataFile(f)); });
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
|
// Scenario dictionaries (plugin scenario systems) hold the bulk of dialogue in
|
||||||
|
// some games; without these "Locate in Files" can't find scenario-only text.
|
||||||
|
scenarioFiles().forEach(function (f) { _scanFiles.push(f); });
|
||||||
var jsDir = path.join(WWW_ROOT, 'js');
|
var jsDir = path.join(WWW_ROOT, 'js');
|
||||||
_scanFiles.push(path.join(jsDir, 'plugins.js'));
|
_scanFiles.push(path.join(jsDir, 'plugins.js'));
|
||||||
var pdir = path.join(jsDir, 'plugins');
|
var pdir = path.join(jsDir, 'plugins');
|
||||||
|
|
@ -1129,6 +1618,9 @@
|
||||||
if (!nodeOk || !text) { return results; }
|
if (!nodeOk || !text) { return results; }
|
||||||
var escaped = JSON.stringify(text).slice(1, -1); // JSON-escaped form (\n etc.)
|
var escaped = JSON.stringify(text).slice(1, -1); // JSON-escaped form (\n etc.)
|
||||||
var variants = escaped === text ? [text] : [text, escaped]; // raw form + escaped, deduped
|
var variants = escaped === text ? [text] : [text, escaped]; // raw form + escaped, deduped
|
||||||
|
// As the text appears inside a single-quoted JS literal ('Let\'s go home').
|
||||||
|
var jsq = escaped.replace(/\\"/g, '"').replace(/'/g, "\\'");
|
||||||
|
if (variants.indexOf(jsq) < 0) { variants.push(jsq); }
|
||||||
pluginAndSystemFiles().forEach(function (file) {
|
pluginAndSystemFiles().forEach(function (file) {
|
||||||
var c = readFileCached(file);
|
var c = readFileCached(file);
|
||||||
if (!c) { return; }
|
if (!c) { return; }
|
||||||
|
|
@ -1145,9 +1637,11 @@
|
||||||
if (!embedded && !(isJs && inComment(c, pos))) {
|
if (!embedded && !(isJs && inComment(c, pos))) {
|
||||||
var lc = offsetToLineCol(c, pos);
|
var lc = offsetToLineCol(c, pos);
|
||||||
var k = file + ':' + lc.line;
|
var k = file + ':' + lc.line;
|
||||||
// A complete JSON string value ("text") is a high-confidence
|
// A complete string value ("text" in JSON; '…' / "…" / `…` in
|
||||||
// match (e.g. a menu term) vs. text buried in a longer string.
|
// JS) is a high-confidence match vs. text buried in a longer one.
|
||||||
var quoted = c.text[pos - 1] === '"' && c.text[pos + v.length] === '"';
|
var qb = c.text[pos - 1], qa = c.text[pos + v.length];
|
||||||
|
var quoted = (qb === '"' && qa === '"') ||
|
||||||
|
(isJs && qb === qa && (qb === "'" || qb === '`'));
|
||||||
if (!seen[k]) { seen[k] = 1; results.push({ file: file, line: lc.line, col: lc.col, quoted: quoted }); }
|
if (!seen[k]) { seen[k] = 1; results.push({ file: file, line: lc.line, col: lc.col, quoted: quoted }); }
|
||||||
}
|
}
|
||||||
pos = c.text.indexOf(v, pos + v.length);
|
pos = c.text.indexOf(v, pos + v.length);
|
||||||
|
|
@ -1165,6 +1659,14 @@
|
||||||
function searchFilesSmart(text) {
|
function searchFilesSmart(text) {
|
||||||
var r = searchFiles(text);
|
var r = searchFiles(text);
|
||||||
if (r.length) { return { results: r, query: text, partial: false }; }
|
if (r.length) { return { results: r, query: text, partial: false }; }
|
||||||
|
// Numbers may be runtime-substituted into a %1/%2 template: "2 turn(s) left"
|
||||||
|
// never exists on disk while '%1 turn(s) left' does.
|
||||||
|
var n = 0;
|
||||||
|
var tpl = text.replace(/\d+(?:\.\d+)?/g, function () { return '%' + (++n); });
|
||||||
|
if (n > 0 && tpl !== text) {
|
||||||
|
var rt = searchFiles(tpl);
|
||||||
|
if (rt.length) { return { results: rt, query: tpl, partial: true }; }
|
||||||
|
}
|
||||||
var words = text.replace(/\n/g, ' ').split(/\s+/).filter(Boolean);
|
var words = text.replace(/\n/g, ' ').split(/\s+/).filter(Boolean);
|
||||||
for (var n = words.length - 1; n >= 1; n--) {
|
for (var n = words.length - 1; n >= 1; n--) {
|
||||||
var sub = words.slice(0, n).join(' ');
|
var sub = words.slice(0, n).join(' ');
|
||||||
|
|
@ -1382,15 +1884,50 @@
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDoc(file) {
|
// Files above this size are NOT loaded whole into the <textarea>: a 68MB scenario
|
||||||
|
// file would push ~3M lines into the DOM and build a gutter string with millions of
|
||||||
|
// numbers, freezing then crashing the game. Instead we load a WINDOW of lines around
|
||||||
|
// the target and splice edits back into the full text on save (see saveEditor).
|
||||||
|
var BIG_FILE_BYTES = 3 * 1024 * 1024;
|
||||||
|
var WINDOW_BEFORE = 300, WINDOW_AFTER = 300;
|
||||||
|
|
||||||
|
// Slice a window of [WINDOW_BEFORE+WINDOW_AFTER] lines centred on targetLine out of the
|
||||||
|
// full text; fills winStart/winEnd/baseLine/text on the doc.
|
||||||
|
function windowDoc(doc, targetLine) {
|
||||||
|
var full = doc.fullText, tgt = (targetLine && targetLine > 0) ? targetLine : 1;
|
||||||
|
var startLine = Math.max(1, tgt - WINDOW_BEFORE);
|
||||||
|
var winStart = lineStartOffset(full, startLine);
|
||||||
|
var winEnd = winStart, n = 0, cap = WINDOW_BEFORE + WINDOW_AFTER;
|
||||||
|
while (n < cap) { var p = full.indexOf('\n', winEnd); if (p < 0) { winEnd = full.length; break; } winEnd = p + 1; n++; }
|
||||||
|
doc.baseLine = startLine; doc.winStart = winStart; doc.winEnd = winEnd;
|
||||||
|
doc.text = full.slice(winStart, winEnd);
|
||||||
|
}
|
||||||
|
function winLineCount(d) {
|
||||||
|
var c = 1, t = d.text; for (var i = 0; i < t.length; i++) { if (t.charCodeAt(i) === 10) { c++; } }
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
function lineInWindow(d, line) {
|
||||||
|
return d.windowed && line >= d.baseLine && line <= d.baseLine + winLineCount(d) - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDoc(file, targetLine) {
|
||||||
var raw;
|
var raw;
|
||||||
try { raw = fs.readFileSync(file, 'utf8'); }
|
try { raw = fs.readFileSync(file, 'utf8'); }
|
||||||
catch (e) { toast('Could not read file'); log('read', e); return null; }
|
catch (e) { toast('Could not read file'); log('read', e); return null; }
|
||||||
var bom = raw.charCodeAt(0) === 0xFEFF;
|
var bom = raw.charCodeAt(0) === 0xFEFF;
|
||||||
if (bom) { raw = raw.slice(1); }
|
if (bom) { raw = raw.slice(1); }
|
||||||
var nl = /\r\n/.test(raw) ? '\r\n' : '\n'; // preserve the file's line endings
|
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'),
|
var full = raw.replace(/\r\n/g, '\n');
|
||||||
nl: nl, bom: bom, dirty: false, line: 0, scrollTop: 0, selStart: 0, selEnd: 0 };
|
var doc = { file: file, name: baseName(file), nl: nl, bom: bom, dirty: false,
|
||||||
|
line: 0, scrollTop: 0, selStart: 0, selEnd: 0, windowed: false, baseLine: 1 };
|
||||||
|
if (full.length > BIG_FILE_BYTES) {
|
||||||
|
doc.windowed = true;
|
||||||
|
doc.fullText = full;
|
||||||
|
windowDoc(doc, targetLine);
|
||||||
|
} else {
|
||||||
|
doc.text = full;
|
||||||
|
}
|
||||||
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stashActive() {
|
function stashActive() {
|
||||||
|
|
@ -1480,10 +2017,11 @@
|
||||||
function refreshGutter() {
|
function refreshGutter() {
|
||||||
var v = editor.area.value, count = 1;
|
var v = editor.area.value, count = 1;
|
||||||
for (var i = 0; i < v.length; i++) { if (v.charCodeAt(i) === 10) { count++; } }
|
for (var i = 0; i < v.length; i++) { if (v.charCodeAt(i) === 10) { count++; } }
|
||||||
if (count === editor._lines) { return; }
|
var d = activeDoc(), base = (d && d.windowed) ? d.baseLine : 1;
|
||||||
editor._lines = count;
|
if (count === editor._lines && base === editor._gutterBase) { return; }
|
||||||
|
editor._lines = count; editor._gutterBase = base;
|
||||||
var s = '';
|
var s = '';
|
||||||
for (var k = 1; k <= count; k++) { s += k + '\n'; }
|
for (var k = 0; k < count; k++) { s += (base + k) + '\n'; }
|
||||||
editor.gutter.textContent = s;
|
editor.gutter.textContent = s;
|
||||||
syncGutter();
|
syncGutter();
|
||||||
}
|
}
|
||||||
|
|
@ -1509,14 +2047,31 @@
|
||||||
return off;
|
return off;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Actual rendered line height of the textarea. Under a CSS zoom the browser
|
||||||
|
// rounds every rendered line box to whole device pixels, so the nominal 18px is
|
||||||
|
// off by up to ~1% — across a 70k-line file that puts the viewport hundreds of
|
||||||
|
// lines away from the (correctly placed) cursor. Measure instead of trusting EDLH.
|
||||||
|
function editorLineHeight() {
|
||||||
|
var area = editor.area;
|
||||||
|
if (!area) { return EDLH; }
|
||||||
|
var v = area.value, lines = 1;
|
||||||
|
for (var i = 0; i < v.length; i++) { if (v.charCodeAt(i) === 10) { lines++; } }
|
||||||
|
var h = (area.scrollHeight - 16) / lines; // 16 = 8px top + bottom padding (CSS above)
|
||||||
|
return (h > EDLH / 2 && h < EDLH * 2) ? h : EDLH;
|
||||||
|
}
|
||||||
|
|
||||||
function jumpToLine(line) {
|
function jumpToLine(line) {
|
||||||
|
var d = activeDoc();
|
||||||
|
// In windowed mode `line` is a FILE line; translate to the textarea's local line.
|
||||||
|
var local = (d && d.windowed) ? (line - d.baseLine + 1) : line;
|
||||||
|
if (local < 1) { local = 1; }
|
||||||
var area = editor.area, text = area.value;
|
var area = editor.area, text = area.value;
|
||||||
var so = lineStartOffset(text, line);
|
var so = lineStartOffset(text, local);
|
||||||
var eo = text.indexOf('\n', so); if (eo < 0) { eo = text.length; }
|
var eo = text.indexOf('\n', so); if (eo < 0) { eo = text.length; }
|
||||||
try { area.setSelectionRange(so, eo); } catch (e) { /* ignore */ }
|
try { area.setSelectionRange(so, eo); } catch (e) { /* ignore */ }
|
||||||
area.scrollTop = Math.max(0, (line - 4) * EDLH);
|
area.scrollTop = Math.max(0, (local - 4) * editorLineHeight());
|
||||||
syncGutter();
|
syncGutter();
|
||||||
updateEditorTitle(line);
|
updateEditorTitle(line); // status shows the real FILE line
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entry point from openInEditor(): open (or focus) a tab for `file` and jump to line.
|
// Entry point from openInEditor(): open (or focus) a tab for `file` and jump to line.
|
||||||
|
|
@ -1526,7 +2081,7 @@
|
||||||
editor.root.style.display = 'flex';
|
editor.root.style.display = 'flex';
|
||||||
var i = docIndexByFile(file);
|
var i = docIndexByFile(file);
|
||||||
if (i < 0) {
|
if (i < 0) {
|
||||||
var d = createDoc(file); if (!d) { return; }
|
var d = createDoc(file, line); if (!d) { return; }
|
||||||
stashActive();
|
stashActive();
|
||||||
editor.docs.push(d);
|
editor.docs.push(d);
|
||||||
i = editor.docs.length - 1;
|
i = editor.docs.length - 1;
|
||||||
|
|
@ -1535,15 +2090,38 @@
|
||||||
renderTabs();
|
renderTabs();
|
||||||
jumpToLine(line || 1);
|
jumpToLine(line || 1);
|
||||||
editor.area.focus();
|
editor.area.focus();
|
||||||
|
if (d.windowed) {
|
||||||
|
toast('Large file — showing lines ' + d.baseLine + '–' +
|
||||||
|
(d.baseLine + winLineCount(d) - 1) + ' of ' + d.name + ' (partial view)');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
selectTab(i, line || 1);
|
var d2 = editor.docs[i];
|
||||||
|
// Re-open of a windowed file whose target is outside the loaded window: recut
|
||||||
|
// the window around the new line (unless there are unsaved edits to preserve).
|
||||||
|
if (d2.windowed && line && !lineInWindow(d2, line) && !d2.dirty) {
|
||||||
|
stashActive(); editor.active = i;
|
||||||
|
windowDoc(d2, line);
|
||||||
|
loadActive(); renderTabs(); jumpToLine(line); editor.area.focus();
|
||||||
|
} else {
|
||||||
|
selectTab(i, line || 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveEditor() {
|
function saveEditor() {
|
||||||
var d = activeDoc(); if (!d) { return; }
|
var d = activeDoc(); if (!d) { return; }
|
||||||
d.text = editor.area.value;
|
d.text = editor.area.value;
|
||||||
var out = (d.nl === '\r\n') ? d.text.replace(/\n/g, '\r\n') : d.text;
|
// Windowed docs hold only a slice in the textarea: splice it back into the full
|
||||||
|
// text (and re-anchor the window to the edited slice's new extent) before writing.
|
||||||
|
var full;
|
||||||
|
if (d.windowed) {
|
||||||
|
full = d.fullText.slice(0, d.winStart) + d.text + d.fullText.slice(d.winEnd);
|
||||||
|
d.fullText = full;
|
||||||
|
d.winEnd = d.winStart + d.text.length;
|
||||||
|
} else {
|
||||||
|
full = d.text;
|
||||||
|
}
|
||||||
|
var out = (d.nl === '\r\n') ? full.replace(/\n/g, '\r\n') : full;
|
||||||
if (d.bom) { out = String.fromCharCode(0xFEFF) + out; }
|
if (d.bom) { out = String.fromCharCode(0xFEFF) + out; }
|
||||||
try {
|
try {
|
||||||
fs.writeFileSync(d.file, out, 'utf8');
|
fs.writeFileSync(d.file, out, 'utf8');
|
||||||
|
|
@ -1554,7 +2132,7 @@
|
||||||
// re-fetch on-screen images and redraw the scene so the change is visible
|
// re-fetch on-screen images and redraw the scene so the change is visible
|
||||||
// immediately without leaving the editor or restarting.
|
// immediately without leaving the editor or restarting.
|
||||||
refreshGameElements();
|
refreshGameElements();
|
||||||
toast('Saved ' + d.name + ' — reloaded in-game');
|
toast('Saved ' + d.name + (d.windowed ? ' (partial view)' : '') + ' — reloaded in-game');
|
||||||
} catch (e) { setEditorStatus('save failed'); toast('Save failed: ' + e.message); log('save', e); }
|
} catch (e) { setEditorStatus('save failed'); toast('Save failed: ' + e.message); log('save', e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1622,7 +2200,7 @@
|
||||||
var mt = f.matches[f.idx], area = editor.area;
|
var mt = f.matches[f.idx], area = editor.area;
|
||||||
try { area.setSelectionRange(mt.start, mt.end); } catch (e) { /* ignore */ }
|
try { area.setSelectionRange(mt.start, mt.end); } catch (e) { /* ignore */ }
|
||||||
var line = area.value.slice(0, mt.start).split('\n').length;
|
var line = area.value.slice(0, mt.start).split('\n').length;
|
||||||
area.scrollTop = Math.max(0, (line - 4) * EDLH);
|
area.scrollTop = Math.max(0, (line - 4) * editorLineHeight());
|
||||||
syncGutter(); updateFindCount();
|
syncGutter(); updateFindCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1697,6 +2275,30 @@
|
||||||
} catch (e) { /* a plugin's onLoad hook may itself throw; don't abort the reload */ }
|
} catch (e) { /* a plugin's onLoad hook may itself throw; don't abort the reload */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reload scenario dictionaries only when the file actually changed on disk. These can
|
||||||
|
// be tens of MB, so re-parsing on every reload (when nothing scenario-side changed)
|
||||||
|
// would stall F9. On first sight we adopt the current mtime WITHOUT re-parsing an
|
||||||
|
// already-loaded global, so an unchanged scenario never costs a big parse.
|
||||||
|
var _scenarioMtime = {};
|
||||||
|
function scenarioMtime(file) {
|
||||||
|
try { var st = fs.statSync(file); return st.mtimeMs || (st.mtime && st.mtime.getTime()) || 0; }
|
||||||
|
catch (e) { return -1; }
|
||||||
|
}
|
||||||
|
function reloadScenarioData() {
|
||||||
|
var n = 0;
|
||||||
|
SCENARIO_SETS.forEach(function (sg) {
|
||||||
|
if (typeof window[sg.global] === 'undefined') { return; } // game doesn't use it
|
||||||
|
var file = scenarioPath(sg.file), m = scenarioMtime(file);
|
||||||
|
if (m < 0) { return; }
|
||||||
|
var known = _scenarioMtime.hasOwnProperty(file);
|
||||||
|
if (!known) { _scenarioMtime[file] = m; if (window[sg.global]) { return; } }
|
||||||
|
if (known && _scenarioMtime[file] === m && window[sg.global]) { return; } // unchanged
|
||||||
|
try { window[sg.global] = JSON.parse(fs.readFileSync(file, 'utf8')); _scenarioMtime[file] = m; n++; }
|
||||||
|
catch (e) { /* mid-write or gone; keep the in-memory copy */ }
|
||||||
|
});
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
function reloadDataFiles() {
|
function reloadDataFiles() {
|
||||||
var n = 0;
|
var n = 0;
|
||||||
for (var name in DATA_GLOBALS) {
|
for (var name in DATA_GLOBALS) {
|
||||||
|
|
@ -1709,6 +2311,7 @@
|
||||||
n++;
|
n++;
|
||||||
} catch (e) { /* file may be absent for this game; skip */ }
|
} catch (e) { /* file may be absent for this game; skip */ }
|
||||||
}
|
}
|
||||||
|
n += reloadScenarioData();
|
||||||
try {
|
try {
|
||||||
if (typeof $gameMap !== 'undefined' && $gameMap && $gameMap.mapId()) {
|
if (typeof $gameMap !== 'undefined' && $gameMap && $gameMap.mapId()) {
|
||||||
// $dataMap must be assigned BEFORE onLoad: it keys off (object === $dataMap)
|
// $dataMap must be assigned BEFORE onLoad: it keys off (object === $dataMap)
|
||||||
|
|
@ -1777,6 +2380,7 @@
|
||||||
if (!nodeOk) { toast('Reload needs Node (NW.js)'); return; }
|
if (!nodeOk) { toast('Reload needs Node (NW.js)'); return; }
|
||||||
// Drop our own caches so the panel relocates against the new file contents.
|
// Drop our own caches so the panel relocates against the new file contents.
|
||||||
fileCache = {}; lineCache = {}; dupIndex = null; _scanFiles = null;
|
fileCache = {}; lineCache = {}; dupIndex = null; _scanFiles = null;
|
||||||
|
uiValueIndex = null; tmAssignCache = {}; jsLitIndex = null; // notetags / vocab / plugin edits
|
||||||
|
|
||||||
var parts = [];
|
var parts = [];
|
||||||
var d = reloadDataFiles(); if (d) { parts.push(d + ' data file' + (d === 1 ? '' : 's')); }
|
var d = reloadDataFiles(); if (d) { parts.push(d + ' data file' + (d === 1 ? '' : 's')); }
|
||||||
|
|
@ -2039,9 +2643,14 @@
|
||||||
for (var i = textRecords.length - 1; i >= 0; i--) {
|
for (var i = textRecords.length - 1; i >= 0; i--) {
|
||||||
var r = textRecords[i];
|
var r = textRecords[i];
|
||||||
if (!r.file) { resolveRecord(r); } // text-search fallback for identity misses
|
if (!r.file) { resolveRecord(r); } // text-search fallback for identity misses
|
||||||
var loc = (r.file && r.tail) ? locateLine(r.file, r.tail) : null;
|
var expText = (r.expText != null) ? r.expText : r.text; // format(): locate the template, not the output
|
||||||
|
var loc = null;
|
||||||
|
if (r.file && r.tail) {
|
||||||
|
loc = (r.srcKind === 'meta') ? locateMetaValue(r.file, r.tail, expText)
|
||||||
|
: locateLine(r.file, r.tail, expText);
|
||||||
|
}
|
||||||
var line = loc ? loc.line : (r.uiLine || null);
|
var line = loc ? loc.line : (r.uiLine || null);
|
||||||
var col = loc ? loc.col : null;
|
var col = loc ? loc.col : (r.uiCol || null);
|
||||||
var live = msgBusy && r.group === currentGroup && currentGroup !== -1;
|
var live = msgBusy && r.group === currentGroup && currentGroup !== -1;
|
||||||
|
|
||||||
var row = document.createElement('div');
|
var row = document.createElement('div');
|
||||||
|
|
@ -2057,6 +2666,9 @@
|
||||||
'<span class="tl-badge ' + r.kind + '">' + r.kind + '</span>' +
|
'<span class="tl-badge ' + r.kind + '">' + r.kind + '</span>' +
|
||||||
(live ? '<span class="tl-badge live">LIVE</span>' : '') +
|
(live ? '<span class="tl-badge live">LIVE</span>' : '') +
|
||||||
'<span class="tl-loc">' + esc(locText) + '</span>' +
|
'<span class="tl-loc">' + esc(locText) + '</span>' +
|
||||||
|
(r.srcKind && r.file ? '<span style="color:#8a9">' + esc(r.label) + '</span>' : '') +
|
||||||
|
(r.siteFile ? '<span class="tl-mini" data-act="site">drawn at ' +
|
||||||
|
esc((r.siteLabel || '').replace(/^.*\//, '')) + '</span>' : '') +
|
||||||
'<span class="tl-mini" data-act="dups">' +
|
'<span class="tl-mini" data-act="dups">' +
|
||||||
(r.kind === 'ui' ? 'Locate In Files' : (r.file ? 'Find Duplicates' : 'Locate In Files')) + '</span>' +
|
(r.kind === 'ui' ? 'Locate In Files' : (r.file ? 'Find Duplicates' : 'Locate In Files')) + '</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
|
|
@ -2070,6 +2682,12 @@
|
||||||
else { toast('Unresolved: ' + rec.label); }
|
else { toast('Unresolved: ' + rec.label); }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
var siteBtn = rowEl.querySelector('[data-act="site"]');
|
||||||
|
if (siteBtn) {
|
||||||
|
siteBtn.addEventListener('click', function () {
|
||||||
|
openInEditor(rec.siteFile, rec.siteLine, null);
|
||||||
|
});
|
||||||
|
}
|
||||||
var dupBtn = rowEl.querySelector('[data-act="dups"]');
|
var dupBtn = rowEl.querySelector('[data-act="dups"]');
|
||||||
var dupsEl = rowEl.querySelector('.tl-dups');
|
var dupsEl = rowEl.querySelector('.tl-dups');
|
||||||
if (dupBtn) {
|
if (dupBtn) {
|
||||||
|
|
@ -2107,7 +2725,7 @@
|
||||||
total = occ.length;
|
total = occ.length;
|
||||||
note = total + ' occurrence' + (total === 1 ? '' : 's') + (ui.ignoreNl ? ' (ignoring \\n)' : '');
|
note = total + ' occurrence' + (total === 1 ? '' : 's') + (ui.ignoreNl ? ' (ignoring \\n)' : '');
|
||||||
shown = occ.slice(0, MAXSHOW).map(function (o) {
|
shown = occ.slice(0, MAXSHOW).map(function (o) {
|
||||||
var l = locateLine(o.file, o.path);
|
var l = locateLine(o.file, o.path, o.text);
|
||||||
return { file: o.file, line: l && l.line, col: l && l.col,
|
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) };
|
isSelf: rec.file === o.file && JSON.stringify(rec.tail) === JSON.stringify(o.path) };
|
||||||
});
|
});
|
||||||
|
|
@ -2412,6 +3030,10 @@
|
||||||
close: function () { toggle(false); },
|
close: function () { toggle(false); },
|
||||||
records: textRecords,
|
records: textRecords,
|
||||||
images: imageLog,
|
images: imageLog,
|
||||||
|
// debug/testing helpers
|
||||||
|
resolveUiSource: resolveUiSource, // drawn string -> value-source descriptor
|
||||||
|
locateMetaValue: locateMetaValue,
|
||||||
|
searchFilesSmart: searchFilesSmart,
|
||||||
cfg: CFG
|
cfg: CFG
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue