Live reload
This commit is contained in:
parent
e057d3510b
commit
52f4590347
3 changed files with 351 additions and 12 deletions
|
|
@ -14538,7 +14538,7 @@
|
|||
"code": 401,
|
||||
"indent": 0,
|
||||
"parameters": [
|
||||
"\"Phew... It took more time than I expected. Good shit sakura and kaoss\""
|
||||
"THE GOAT"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
})();
|
||||
|
|
|
|||
Loading…
Reference in a new issue