feat: add a Monaco-based Rune script editor

This commit is contained in:
Steve Biedermann 2026-10-07 18:54:15 +02:00
parent dd02a16d36
commit 1e7a5f911a
1 changed files with 143 additions and 14 deletions

View File

@ -25,16 +25,35 @@
width: 56px; width: 56px;
} }
#script { #editorPanel {
width: 100%; display: none;
height: 260px; position: fixed;
box-sizing: border-box; top: 0;
background: #181818; right: 0;
color: #ddd; bottom: 0;
font: 12px monospace; width: min(760px, 70vw);
flex-direction: column;
background: #1e1e1e;
border-left: 1px solid #444;
z-index: 10;
}
#editorBar {
padding: 6px 10px;
display: flex;
gap: 8px;
align-items: center;
}
#monaco {
flex: 1;
min-height: 0;
} }
#scriptErr { #scriptErr {
padding: 6px 10px;
max-height: 25vh;
overflow: auto;
color: #f77; color: #f77;
white-space: pre-wrap; white-space: pre-wrap;
} }
@ -90,15 +109,25 @@
<h3>Stockpile</h3> <h3>Stockpile</h3>
<div id="stockEdit"></div> <div id="stockEdit"></div>
<h3>Behaviour script (Rune)</h3> <h3>Behaviour script (Rune)</h3>
<textarea id="script" spellcheck="false"></textarea> <div><button id="openEditor">Open script editor</button></div>
<div><button id="applyScript">Apply script</button></div>
<div id="scriptErr"></div>
<h3>Events</h3> <h3>Events</h3>
<div id="log"></div> <div id="log"></div>
<h3>Legend</h3> <h3>Legend</h3>
<div>yellow: person, white: rabbit, red: wolf<br>green dot: tree, pink: berries, grey: stone<br>brown square: house <div>yellow: person, white: rabbit, red: wolf<br>green dot: tree, pink: berries, grey: stone<br>brown square: house
</div> </div>
</div> </div>
<div id="editorPanel">
<div id="editorBar">
<b>behavior.rn</b>
<button id="applyScript">Apply (Ctrl+Enter)</button>
<button id="closeEditor">Close</button>
<label><input type="checkbox" id="vimToggle"> Vim</label>
<span id="scriptStatus"></span>
</div>
<div id="monaco"></div>
<div id="vimStatus" style="padding:2px 10px;font:12px monospace;min-height:1.4em"></div>
<div id="scriptErr"></div>
</div>
<script> <script>
const T = 10; const T = 10;
const COLORS = ['#10305e', '#2a62a8', '#e0d08a', '#6aaa4a', '#3f7d3a', '#8a8a6a', '#aaaaaa']; const COLORS = ['#10305e', '#2a62a8', '#e0d08a', '#6aaa4a', '#3f7d3a', '#8a8a6a', '#aaaaaa'];
@ -318,11 +347,111 @@
} }
} }
fetch('/api/script').then(r => r.text()).then(t => { $('script').value = t; }); // Monaco is fetched from a CDN on first use, so the browser needs internet access for the script editor.
$('applyScript').addEventListener('click', async () => { const MONACO = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.0/min';
const r = await post('/api/script', $('script').value); let monacoReady = null, editor = null;
$('scriptErr').textContent = r.ok ? 'Script applied.' : await r.text();
function loadMonaco() {
monacoReady ??= new Promise((resolve, reject) => {
window.MonacoEnvironment = {
getWorkerUrl: () => URL.createObjectURL(new Blob(
[`self.MonacoEnvironment={baseUrl:'${MONACO}/'};importScripts('${MONACO}/vs/base/worker/workerMain.js');`],
{ type: 'text/javascript' })),
};
const s = document.createElement('script');
s.src = MONACO + '/vs/loader.js';
s.onload = () => {
require.config({ paths: { vs: MONACO + '/vs' } });
require(['vs/editor/editor.main'], () => resolve(window.monaco), reject);
};
s.onerror = () => { monacoReady = null; reject(new Error('could not load the editor from the CDN')); };
document.head.appendChild(s);
}); });
return monacoReady;
}
function showErrors(text) {
const model = editor.getModel();
const markers = [];
for (const block of text.split(/\n(?=error:|warning:)/)) {
const m = /behavior:(\d+):(\d+)/.exec(block);
if (!m) continue;
const line = +m[1], col = +m[2];
const word = model.getWordAtPosition({ lineNumber: line, column: col });
markers.push({
severity: block.startsWith('warning') ? monaco.MarkerSeverity.Warning : monaco.MarkerSeverity.Error,
message: block.split('\n')[0].replace(/^(error|warning): /, ''),
startLineNumber: line, startColumn: col,
endLineNumber: line, endColumn: word ? word.endColumn : col + 1,
});
}
monaco.editor.setModelMarkers(model, 'rune', markers);
}
async function applyScript() {
if (!editor) return;
const r = await post('/api/script', editor.getValue());
if (r.ok) {
monaco.editor.setModelMarkers(editor.getModel(), 'rune', []);
$('scriptErr').textContent = '';
$('scriptStatus').textContent = 'Applied.';
} else {
const text = await r.text();
$('scriptStatus').textContent = 'Rejected; the previous script is still running.';
$('scriptErr').textContent = text;
showErrors(text);
}
}
$('openEditor').addEventListener('click', async () => {
$('editorPanel').style.display = 'flex';
try {
await loadMonaco();
if (!editor) {
const src = await (await fetch('/api/script')).text();
editor = monaco.editor.create($('monaco'), {
value: src, language: 'rust', theme: 'vs-dark',
automaticLayout: true, minimap: { enabled: false }, fontSize: 13, tabSize: 4,
});
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, applyScript);
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, applyScript);
if ($('vimToggle').checked) await setVim(true);
}
editor.focus();
} catch (e) {
$('scriptErr').textContent = e.message;
}
});
$('closeEditor').addEventListener('click', () => { $('editorPanel').style.display = 'none'; });
$('applyScript').addEventListener('click', applyScript);
const VIM = 'https://cdn.jsdelivr.net/npm/monaco-vim@0.4.2/dist/monaco-vim';
let vimMode = null, vimLib = null;
async function setVim(on) {
localStorage.setItem('vim', on ? '1' : '0');
if (!editor) return;
if (!on) {
vimMode?.dispose();
vimMode = null;
$('vimStatus').textContent = '';
return;
}
try {
vimLib ??= await new Promise((resolve, reject) => {
require.config({ paths: { 'monaco-vim': VIM } });
require(['monaco-vim'], resolve, reject);
});
if (vimMode || localStorage.getItem('vim') !== '1') return;
vimMode = vimLib.initVimMode(editor, $('vimStatus'));
vimLib.VimMode.Vim.defineEx('write', 'w', applyScript);
editor.focus();
} catch (e) {
$('scriptErr').textContent = 'Could not load vim mode: ' + e.message;
}
}
$('vimToggle').checked = localStorage.getItem('vim') !== '0';
$('vimToggle').addEventListener('change', e => setVim(e.target.checked));
loadTerrain().then(poll); loadTerrain().then(poll);
</script> </script>