Live reload

This commit is contained in:
DazedAnon 2026-06-12 12:16:35 -05:00
parent e057d3510b
commit 52f4590347
3 changed files with 351 additions and 12 deletions

View file

@ -14538,7 +14538,7 @@
"code": 401,
"indent": 0,
"parameters": [
"\"Phew... It took more time than I expected. Good shit sakura and kaoss\""
"THE GOAT"
]
},
{

View file

@ -524,7 +524,7 @@
"Sort",
"Save",
"Quit Game",
"Options",
"Suc",
"Weapon",
"Armor",
"Key Items",
@ -532,7 +532,7 @@
"Optimize",
"Remove All",
"New Game",
"Good Job",
"Continue Not",
null,
"To Title",
"Quit",

View file

@ -24,6 +24,18 @@
* "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.
* F9 manual reload of all database JSON + the current map
*
* 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": {} }
@ -52,6 +64,10 @@
captureUiText: true, // capture plugin / menu / UI text drawn via Bitmap.drawText
// (so e.g. status-window strings from plugins.js are locatable)
liveEdit: true, // allow saving edited text back to the source file in-game
liveRefresh: true, // auto-reload data/*.json when files change on disk (NW.js)
liveRefreshStableMs: 120,// VSCode save: reload once mtime unchanged this long
liveRefreshPollMs: 400, // how often we check data/*.json for external saves
liveRefreshHotkey: 'F9',// manual reload: all DB JSON + current map
dataDirOverride: null // set an absolute path to force the data dir
};
@ -366,6 +382,7 @@
fs.writeFileSync(file, merged, 'utf8');
} catch (e) { return { ok: false, err: String(e.message || e) }; }
invalidateFileCache(file);
markSelfWrite(file);
return { ok: true };
}
@ -453,6 +470,23 @@
return true;
}
function isDataJsonFile(file) {
if (!file || !path) { return false; }
try {
var ap = absPath(file);
var dir = absPath(DATA_DIR);
if (process.platform === 'win32') {
return ap.toLowerCase().indexOf(dir.toLowerCase()) === 0 && /\.json$/i.test(ap);
}
return ap.indexOf(dir) === 0 && /\.json$/i.test(ap);
} catch (e) { return false; }
}
function applyReloadAfterOverlaySave(file) {
if (!CFG.liveRefresh || !isDataJsonFile(file)) { return false; }
return reloadFile(file, { silent: true });
}
function saveRecordEdit(rec, newText) {
if (!nodeOk) { toast('Live edit requires the desktop NW.js build'); return false; }
if (newText === rec.text) { toast('No changes'); return false; }
@ -460,10 +494,6 @@
var r;
if (rec.file && rec.tail) {
r = writeAtPath(rec.file, rec.tail, newText, expected);
if (r.ok) {
var root = memoryRootForFile(rec.file);
if (root) { setAtPath(root, rec.tail, newText); }
}
} else if (rec.file && rec._editSite) {
r = writeAtEditSite(rec.file, rec._editSite, newText, expected);
} else if (rec.file && (rec._editLine || rec.uiLine)) {
@ -484,8 +514,10 @@
rec._key = 't\u0000' + rec.kind + '\u0000' + newText + '\u0000' + (rec.file || '') +
'\u0000' + (rec.tail ? JSON.stringify(rec.tail) : '');
applyLiveMessage(rec, newText);
var note = rec.kind === 'ui' ? ' — reload scene to refresh UI' : '';
toast('Saved to file' + note);
var reloaded = applyReloadAfterOverlaySave(rec.file);
if (!reloaded) { refreshSceneWindows(); }
var note = rec.kind === 'ui' && !reloaded ? ' — UI may need scene change' : '';
toast(reloaded ? 'Saved & reloaded' : ('Saved to file' + note));
scheduleRefresh();
return true;
}
@ -1411,7 +1443,8 @@
});
['keydown', 'keyup', 'keypress'].forEach(function (t) {
root.addEventListener(t, function (ev) {
if (ev.key === CFG.hotkey) { return; } // F10 toggle handled globally
if (ev.key === CFG.hotkey ||
(CFG.liveRefresh && ev.key === CFG.liveRefreshHotkey)) { return; }
ev.stopPropagation();
}, false);
});
@ -1856,6 +1889,301 @@
}
}
//=========================================================================
// Live refresh — hot-reload data/*.json from external editor saves (NW.js)
//=========================================================================
var _selfWrittenFiles = {}; // absPath -> timestamp (skip watcher echo from overlay saves)
var _pendingStable = {}; // absPath -> { lastMt, stableSince, timer }
var _watcherStarted = false;
var _pollTimer = null;
var _watchScanTimer = 0;
var _knownMtimes = {}; // absPath -> mtimeMs (poll-based change detection)
var DB_FILES = {}; // 'Items.json' -> { global: '$dataItems', meta: true }
function absPath(file) {
if (!file || !path) { return file; }
try { return path.resolve(file); } catch (e) { return file; }
}
function syncMtimeCache(file) {
if (!nodeOk || !file) { return; }
try {
_knownMtimes[absPath(file)] = fs.statSync(absPath(file)).mtimeMs;
} catch (e) { /* ignore */ }
}
function markSelfWrite(file) {
if (!CFG.liveRefresh || !file) { return; }
_selfWrittenFiles[absPath(file)] = Date.now();
syncMtimeCache(file);
}
function isSelfWrite(file) {
if (!file) { return false; }
var key = absPath(file);
var t = _selfWrittenFiles[key];
if (!t) { return false; }
var windowMs = (CFG.liveRefreshStableMs || 120) + 600;
if (Date.now() - t < windowMs) { return true; }
delete _selfWrittenFiles[key];
return false;
}
function buildDbFileMap() {
DB_FILES = {};
var entries = (typeof DataManager !== 'undefined' && DataManager._databaseFiles)
? DataManager._databaseFiles : null;
if (entries && entries.length) {
entries.forEach(function (entry) {
DB_FILES[entry.src] = {
global: entry.name,
meta: entry.src !== 'System.json'
};
});
return;
}
// Fallback before DataManager is ready (MV/MZ standard database list)
[
['Actors.json', '$dataActors', true],
['Classes.json', '$dataClasses', true],
['Skills.json', '$dataSkills', true],
['Items.json', '$dataItems', true],
['Weapons.json', '$dataWeapons', true],
['Armors.json', '$dataArmors', true],
['Enemies.json', '$dataEnemies', true],
['Troops.json', '$dataTroops', true],
['States.json', '$dataStates', true],
['Animations.json', '$dataAnimations', true],
['Tilesets.json', '$dataTilesets', true],
['CommonEvents.json', '$dataCommonEvents', true],
['System.json', '$dataSystem', false],
['MapInfos.json', '$dataMapInfos', true]
].forEach(function (row) {
DB_FILES[row[0]] = { global: row[1], meta: row[2] };
});
}
function parseMapId(fname) {
var m = /^Map(\d+)\.json$/i.exec(fname || '');
return m ? parseInt(m[1], 10) : null;
}
function loadJsonFromDisk(file) {
var ap = absPath(file);
invalidateFileCache(ap);
return JSON.parse(fs.readFileSync(ap, 'utf8'));
}
function applyDatabaseGlobal(globalName, data, useMeta) {
window[globalName] = data;
if (useMeta && typeof DataManager !== 'undefined' && DataManager.extractArrayMetadata) {
DataManager.extractArrayMetadata(data);
}
}
function refreshSceneWindows() {
try {
var scene = (typeof SceneManager !== 'undefined') && SceneManager._scene;
if (!scene || !scene._windowLayer) { return; }
var kids = scene._windowLayer.children;
if (!kids) { return; }
for (var i = 0; i < kids.length; i++) {
var w = kids[i];
if (w && typeof w.refresh === 'function') {
try { w.refresh(); } catch (e) { /* ignore */ }
}
}
} catch (e) { log('refreshSceneWindows', e); }
}
function reloadFile(filePath, options) {
options = options || {};
if (!nodeOk || !fs || !path) {
if (!options.silent) { toast('Live refresh requires NW.js playtest'); }
return false;
}
var ap = absPath(filePath);
if (!fs.existsSync(ap)) {
if (!options.silent) { toast('File not found: ' + path.basename(ap)); }
return false;
}
var fname = path.basename(ap);
if (!/\.json$/i.test(fname)) { return false; }
try {
var mapId = parseMapId(fname);
if (mapId != null) {
var currentMap = (typeof $gameMap !== 'undefined' && $gameMap && $gameMap.mapId)
? $gameMap.mapId() : 0;
if (currentMap !== mapId) {
syncMtimeCache(ap);
if (!options.silent) { toast('Map file saved (switch maps to apply)'); }
return true;
}
if (typeof $dataMap !== 'undefined') {
$dataMap = loadJsonFromDisk(ap);
}
syncMtimeCache(ap);
if (!options.silent) { toast('Reloaded ' + fname); }
refreshSceneWindows();
return true;
}
if (!Object.keys(DB_FILES).length) { buildDbFileMap(); }
var spec = DB_FILES[fname];
if (spec) {
applyDatabaseGlobal(spec.global, loadJsonFromDisk(ap), spec.meta);
syncMtimeCache(ap);
if (!options.silent) { toast('Reloaded ' + fname); }
refreshSceneWindows();
return true;
}
log('reload skip (unknown data file)', fname);
return false;
} catch (e) {
log('reload failed', fname, e);
if (!options.silent) { toast('Reload failed: ' + fname); }
return false;
}
}
function reloadAllForPlaytest() {
if (!nodeOk) { toast('Live refresh requires NW.js playtest'); return; }
if (!Object.keys(DB_FILES).length) { buildDbFileMap(); }
var count = 0;
Object.keys(DB_FILES).forEach(function (fname) {
if (reloadFile(dataFile(fname), { silent: true })) { count++; }
});
var mapId = (typeof $gameMap !== 'undefined' && $gameMap && $gameMap.mapId)
? $gameMap.mapId() : 0;
if (mapId > 0 && reloadFile(mapFile(mapId), { silent: true })) { count++; }
refreshSceneWindows();
toast('Reloaded database + current map (' + count + ' files)');
}
// VSCode (etc.) saves land on disk; wait until mtime stops changing, then reload.
function scheduleFileReload(filePath) {
if (!CFG.liveRefresh || !nodeOk) { return; }
waitForStableThenReload(absPath(filePath));
}
function waitForStableThenReload(ap) {
if (isSelfWrite(ap)) { return; }
var stableMs = CFG.liveRefreshStableMs || 120;
var checkMs = 50;
if (!_pendingStable[ap]) {
_pendingStable[ap] = { lastMt: null, stableSince: 0, timer: 0 };
}
if (_pendingStable[ap].timer) { clearTimeout(_pendingStable[ap].timer); }
function tick() {
if (isSelfWrite(ap)) {
delete _pendingStable[ap];
return;
}
try {
if (!fs.existsSync(ap)) {
_pendingStable[ap].timer = setTimeout(tick, checkMs);
return;
}
var mt = fs.statSync(ap).mtimeMs;
var p = _pendingStable[ap];
if (p.lastMt !== mt) {
p.lastMt = mt;
p.stableSince = Date.now();
} else if (Date.now() - p.stableSince >= stableMs) {
delete _pendingStable[ap];
reloadFile(ap);
return;
}
p.timer = setTimeout(tick, checkMs);
} catch (e) {
delete _pendingStable[ap];
}
}
_pendingStable[ap].timer = setTimeout(tick, checkMs);
}
function seedMtimes() {
if (!nodeOk) { return; }
try {
fs.readdirSync(DATA_DIR).forEach(function (fname) {
if (!/\.json$/i.test(fname)) { return; }
syncMtimeCache(path.join(DATA_DIR, fname));
});
} catch (e) { log('seedMtimes', e); }
}
function pollDataDirForChanges() {
if (!CFG.liveRefresh || !nodeOk) { return; }
try {
var names = fs.readdirSync(DATA_DIR);
for (var i = 0; i < names.length; i++) {
var fname = names[i];
if (!/\.json$/i.test(fname)) { continue; }
var ap = path.join(DATA_DIR, fname);
try {
var mt = fs.statSync(ap).mtimeMs;
var prev = _knownMtimes[ap];
if (prev != null && mt !== prev && !isSelfWrite(ap)) {
scheduleFileReload(ap);
} else {
_knownMtimes[ap] = mt;
}
} catch (e2) { /* ignore */ }
}
} catch (e) { /* ignore */ }
}
function startLiveRefreshPoll() {
if (_pollTimer || !CFG.liveRefresh || !nodeOk) { return; }
seedMtimes();
var ms = CFG.liveRefreshPollMs || 400;
_pollTimer = setInterval(pollDataDirForChanges, ms);
log('live refresh polling every', ms + 'ms');
}
function scheduleDataDirScan() {
if (!CFG.liveRefresh) { return; }
clearTimeout(_watchScanTimer);
_watchScanTimer = setTimeout(pollDataDirForChanges, 200);
}
function startLiveRefreshWatcher() {
if (!CFG.liveRefresh || !nodeOk || _watcherStarted) { return; }
_watcherStarted = true;
buildDbFileMap();
startLiveRefreshPoll();
try {
fs.watch(DATA_DIR, function (eventType, filename) {
if (filename && /\.json$/i.test(filename)) {
scheduleFileReload(path.join(DATA_DIR, filename));
} else {
// Windows fs.watch often fires with a null filename
scheduleDataDirScan();
}
});
log('live refresh watching', DATA_DIR);
} catch (e) {
log('live refresh watch failed (poll-only)', e);
}
}
if (CFG.liveRefresh && nodeOk) {
if (typeof DataManager !== 'undefined') {
startLiveRefreshWatcher();
} else {
var _waitDbTimer = setInterval(function () {
if (typeof DataManager !== 'undefined') {
clearInterval(_waitDbTimer);
startLiveRefreshWatcher();
}
}, 100);
}
}
//=========================================================================
// Toast
//=========================================================================
@ -1880,6 +2208,10 @@
e.preventDefault();
e.stopPropagation();
toggle();
} else if (CFG.liveRefresh && e.key === CFG.liveRefreshHotkey) {
e.preventDefault();
e.stopPropagation();
reloadAllForPlaytest();
} else if (e.key === 'Escape' && ui.pick) {
e.preventDefault();
e.stopPropagation();
@ -1902,7 +2234,8 @@
if (!ui.open || !ui.freeze) { return; }
var isKey = (e.type === 'keydown' || e.type === 'keyup' || e.type === 'keypress');
if (isKey) {
if (e.key === CFG.hotkey || e.key === 'Escape') { return; } // toggles always work
if (e.key === CFG.hotkey || e.key === 'Escape' ||
(CFG.liveRefresh && e.key === CFG.liveRefreshHotkey)) { return; }
// Typing in the panel / edit box must keep normal browser behaviour
// (Backspace, Enter for newlines, etc.) — only block keys aimed at the game.
if (inPanel(e.target) || isEditableInPanel(document.activeElement)) { return; }
@ -1926,10 +2259,16 @@
window.TLInspector = {
open: function () { toggle(true); },
close: function () { toggle(false); },
reload: function (file) { return reloadFile(file); },
reloadAll: function () { reloadAllForPlaytest(); return true; },
records: textRecords,
images: imageLog,
cfg: CFG
};
log('ready - press ' + CFG.hotkey + ' to open');
var _readyMsg = 'ready - ' + CFG.hotkey + ' inspector';
if (CFG.liveRefresh && nodeOk) {
_readyMsg += ', ' + CFG.liveRefreshHotkey + ' reload data (auto-watch on)';
}
log(_readyMsg);
})();