1
0
Files
test.br0tcraft.de/ext/scene-manager.js
2026-08-20 20:36:47 +02:00

617 lines
24 KiB
JavaScript

(function (Scratch) {
'use strict';
if (!Scratch.extensions.unsandboxed) {
throw new Error('SceneManager must run unsandboxed!');
}
let rootDirectoryHandle = null;
let currentFileHandle = null;
const styleEl = document.createElement('style');
styleEl.textContent = `
/* ── Overlay & Box ───────────────────────────────────────────── */
.sm-ov {
position: fixed; inset: 0;
background: rgba(0,0,0,.6);
display: flex; align-items: center; justify-content: center;
z-index: 99999;
animation: smFade .14s ease;
}
@keyframes smFade { from { opacity:0 } to { opacity:1 } }
.sm-box {
background: #1e1e2e;
border: 1px solid #383860;
border-radius: 14px;
padding: 26px 28px 22px;
max-width: 540px;
width: calc(100% - 40px);
color: #cdd6f4;
box-shadow: 0 28px 70px rgba(0,0,0,.55);
animation: smSlide .18s ease;
max-height: 90vh;
overflow-y: auto;
}
@keyframes smSlide { from { transform:translateY(-10px);opacity:0 } to { transform:none;opacity:1 } }
/* ── Typography ──────────────────────────────────────────────── */
.sm-box h2 {
margin: 0 0 14px;
font: 600 17px/1.3 system-ui, sans-serif;
color: #89b4fa;
display: flex; align-items: center; gap: 8px;
}
.sm-box p {
margin: 0 0 12px;
font: 400 13.5px/1.65 system-ui, sans-serif;
color: #bac2de;
}
.sm-box p:last-child { margin-bottom: 0; }
.sm-box code {
background: #313244;
border-radius: 4px;
padding: 1px 5px;
font-size: 12px;
font-family: monospace;
color: #a6e3a1;
}
/* ── Note / Info block ───────────────────────────────────────── */
.sm-note {
background: #252538;
border-left: 3px solid #89b4fa;
border-radius: 0 8px 8px 0;
padding: 10px 14px;
font: 400 13px/1.6 system-ui, sans-serif;
color: #bac2de;
margin: 12px 0;
}
.sm-note strong { color: #cdd6f4; font-weight: 600; }
/* ── Path display ────────────────────────────────────────────── */
.sm-path {
background: #181825;
border: 1px solid #45475a;
border-radius: 7px;
padding: 9px 13px;
font: 400 12.5px/1.5 monospace;
color: #a6e3a1;
margin: 12px 0;
word-break: break-all;
white-space: pre-wrap;
}
/* ── Buttons ─────────────────────────────────────────────────── */
.sm-row {
display: flex; gap: 10px; justify-content: flex-end;
margin-top: 22px;
flex-wrap: wrap;
}
.sm-btn {
padding: 8px 20px;
border-radius: 7px;
border: none;
cursor: pointer;
font: 600 13px system-ui, sans-serif;
transition: filter .12s, transform .1s;
white-space: nowrap;
}
.sm-btn:hover { filter: brightness(1.13); }
.sm-btn:active { transform: scale(.97); }
.sm-btn-primary { background: #89b4fa; color: #1e1e2e; }
.sm-btn-ghost { background: #313244; color: #cdd6f4; }
.sm-btn-warn { background: #f38ba8; color: #1e1e2e; }
.sm-btn:disabled { opacity: .4; cursor: default; filter: none; }
/* ── Project tree ────────────────────────────────────────────── */
.sm-tree {
background: #181825;
border: 1px solid #45475a;
border-radius: 8px;
max-height: 300px;
overflow-y: auto;
margin: 12px 0;
}
.sm-tree-empty {
padding: 28px 16px;
text-align: center;
color: #585b70;
font: italic 13px system-ui, sans-serif;
}
.sm-folder-row {
padding: 7px 14px;
background: #1e1e2e;
border-bottom: 1px solid #2a2a42;
font: 600 12.5px system-ui, sans-serif;
color: #fab387;
display: flex; align-items: center; gap: 7px;
user-select: none;
}
.sm-file-row {
padding: 8px 14px 8px 30px;
border-bottom: 1px solid #252538;
font: 400 13px system-ui, sans-serif;
color: #cdd6f4;
cursor: pointer;
display: flex; align-items: center; gap: 8px;
transition: background .1s;
}
.sm-file-row:last-child { border-bottom: none; }
.sm-file-row:hover { background: #313244; }
.sm-file-row.sm-sel { background: #3a3a5c; color: #89b4fa; }
/* ── Toast ───────────────────────────────────────────────────── */
.sm-toast {
position: fixed; bottom: 24px; right: 24px;
background: #313244;
border: 1px solid #45475a;
border-radius: 8px;
padding: 10px 16px;
font: 400 13px system-ui, sans-serif;
color: #cdd6f4;
z-index: 100000;
opacity: 0;
transform: translateY(8px);
transition: opacity .22s ease, transform .22s ease;
max-width: 320px;
box-shadow: 0 8px 24px rgba(0,0,0,.4);
}
.sm-toast.sm-toast-in { opacity: 1; transform: none; }
.sm-toast.sm-ok { border-left: 3px solid #a6e3a1; }
.sm-toast.sm-err { border-left: 3px solid #f38ba8; }
/* ── Divider ─────────────────────────────────────────────────── */
.sm-sep {
border: none; border-top: 1px solid #313244;
margin: 14px 0;
}
/* ── Loading spinner ─────────────────────────────────────────── */
.sm-spin {
display: inline-block; width: 14px; height: 14px;
border: 2px solid #45475a;
border-top-color: #89b4fa;
border-radius: 50%;
animation: smSpin .7s linear infinite;
}
@keyframes smSpin { to { transform: rotate(360deg); } }
`;
document.head.appendChild(styleEl);
/** Opens a modal. `buildFn(box, close)` populates .sm-box and calls close(value) to resolve. */
function openModal(buildFn) {
return new Promise(resolve => {
const overlay = document.createElement('div');
overlay.className = 'sm-ov';
const box = document.createElement('div');
box.className = 'sm-box';
overlay.appendChild(box);
document.body.appendChild(overlay);
let closed = false;
const close = val => {
if (closed) return;
closed = true;
document.removeEventListener('keydown', onKey);
overlay.remove();
resolve(val);
};
const onKey = e => { if (e.key === 'Escape') close(null); };
document.addEventListener('keydown', onKey);
overlay.addEventListener('click', e => { if (e.target === overlay) close(null); });
buildFn(box, close);
});
}
function showToast(msg, type = 'ok') {
const t = document.createElement('div');
t.className = `sm-toast sm-${type}`;
t.textContent = msg;
document.body.appendChild(t);
requestAnimationFrame(() => requestAnimationFrame(() => t.classList.add('sm-toast-in')));
setTimeout(() => {
t.classList.remove('sm-toast-in');
t.addEventListener('transitionend', () => t.remove(), { once: true });
}, 2800);
}
class SceneManager {
getInfo() {
return {
id: 'sceneManager',
name: 'Scene Manager',
color1: '#4A90E2',
color2: '#357ABD',
color3: '#285A8F',
blocks: [
// ── Folder management ──
{
blockType: Scratch.BlockType.LABEL,
text: 'Root Folder'
},
{
opcode: 'selectFolder',
blockType: Scratch.BlockType.BUTTON,
text: 'Set Root Folder',
func: 'selectFolder'
},
{
opcode: 'browseProjects',
blockType: Scratch.BlockType.BUTTON,
text: 'Browse & Switch Project',
func: 'browseProjects'
},
{
opcode: 'showHelp',
blockType: Scratch.BlockType.BUTTON,
text: 'How does this work?',
func: 'showHelp'
},
// ── Scene blocks ──
{
blockType: Scratch.BlockType.LABEL,
text: 'Scene Blocks'
},
{
opcode: 'openSB3',
blockType: Scratch.BlockType.COMMAND,
text: 'open [arg0].sb3',
arguments: {
arg0: { type: Scratch.ArgumentType.STRING, defaultValue: 'MyGame/Level2' }
}
},
{
opcode: 'openSB3withData',
blockType: Scratch.BlockType.COMMAND,
text: 'open [arg0].sb3 with data [arg1]',
arguments: {
arg0: { type: Scratch.ArgumentType.STRING, defaultValue: 'MyGame/Level2' },
arg1: { type: Scratch.ArgumentType.STRING, defaultValue: 'score=100' }
}
},
{
opcode: 'receivedData',
blockType: Scratch.BlockType.REPORTER,
text: 'received data'
}, {
blockType: Scratch.BlockType.LABEL,
text: 'SE Support: Coming in v1.0'
}
]
};
}
async selectFolder() {
const handle = await this._pickDirectory();
if (!handle) return;
rootDirectoryHandle = handle;
currentFileHandle = null;
this._showFolderSetModal(handle.name);
}
async browseProjects() {
if (!rootDirectoryHandle) {
const ok = await this._requireFolder('Open the project browser');
if (!ok) return;
}
const files = await this._scanSb3(rootDirectoryHandle);
await openModal((box, close) => {
let selectedPath = null;
// Build tree HTML
const grouped = {};
for (const f of files) {
const parts = f.path.split('/');
const dir = parts.length > 1 ? parts.slice(0, -1).join('/') : '';
(grouped[dir] = grouped[dir] || []).push(f);
}
let treeHtml = '';
if (files.length === 0) {
treeHtml = '<div class="sm-tree-empty">No .sb3 files found in this folder.</div>';
} else {
for (const [dir, items] of Object.entries(grouped)) {
if (dir) treeHtml += `<div class="sm-folder-row">📁 ${this._esc(dir)}/</div>`;
for (const item of items) {
const name = item.path.split('/').pop().replace(/\.sb3$/i, '');
treeHtml += `<div class="sm-file-row" data-path="${this._esc(item.path)}">📄 ${this._esc(name)}.sb3</div>`;
}
}
}
box.innerHTML = `
<h2>Browse Projects</h2>
<p>Root folder: <code>${this._esc(rootDirectoryHandle.name)}/</code></p>
<div class="sm-tree" id="sm-tree">${treeHtml}</div>
<p id="sm-sel-label" style="font-size:12.5px;color:#585b70;min-height:18px;margin-bottom:0"></p>
<div class="sm-row">
<button class="sm-btn sm-btn-ghost" id="sm-cancel-btn">Cancel</button>
<button class="sm-btn sm-btn-primary" id="sm-switch-btn" disabled>Switch →</button>
</div>
`;
const tree = box.querySelector('#sm-tree');
const switchBtn = box.querySelector('#sm-switch-btn');
const selLabel = box.querySelector('#sm-sel-label');
tree.addEventListener('click', e => {
const row = e.target.closest('.sm-file-row');
if (!row) return;
tree.querySelectorAll('.sm-sel').forEach(el => el.classList.remove('sm-sel'));
row.classList.add('sm-sel');
selectedPath = row.dataset.path;
selLabel.textContent = `Selected: ${selectedPath}`;
selLabel.style.color = '#89b4fa';
switchBtn.disabled = false;
});
box.querySelector('#sm-cancel-btn').onclick = () => close(null);
switchBtn.onclick = () => {
if (!selectedPath) return;
close(selectedPath);
};
}).then(async path => {
if (!path) return;
await this._loadScene(path, '', /* saveFirst */ true);
});
}
async showHelp() {
await openModal((box, close) => {
box.innerHTML = `
<h2>How the Scene Manager works</h2>
<p>
<strong style="color:#cdd6f4">Scene Manager</strong> lets you split your Scratch project
into multiple <code>.sb3</code> files and seamlessly switch between them at runtime —
just like loading a new level or screen.
</p>
<div class="sm-note">
<strong>Scratch Everywhere</strong><br>
All <code>.sb3</code> files directly inside the scratch-everywhere folder appear in the SE! menu.<br><br>
<strong>Set Root Folder</strong> tells Scene Manager which folder acts as that root folder while
you're developing in Scratch/Turbowarp/ScratchBox/Mistwarp or any other SE! compatible editor.
</div>
<hr class="sm-sep">
<p><strong style="color:#cdd6f4">Recommended project structure</strong></p>
<div class="sm-path">📁 scratch-everywhere/
MyGame.sb3 ← launcher (appears in menu)
📁 MyGameName-Scenes/
Level1.sb3
Level2.sb3
BossLevel.sb3</div>
<p>
Only <code>MyGame.sb3</code> is visible in the game-selection menu.
It uses Scene Manager blocks to load the actual levels from the
<code>scenes/</code> subfolder, those files stay private inside the SE! menu.
</p>
<div class="sm-note">
Use <strong>subfolder-prefixed paths</strong> in the scene blocks:<br>
<code>open scenes/Level1.sb3</code><br>
Scene Manager navigates into the subfolder automatically.
</div>
<div class="sm-row">
<button class="sm-btn sm-btn-primary" id="sm-ok-btn">Got it!</button>
</div>
`;
box.querySelector('#sm-ok-btn').onclick = () => close(null);
});
}
async openSB3(args) {
await this._loadScene(args.arg0, '');
}
async openSB3withData(args) {
await this._loadScene(args.arg0, args.arg1);
}
receivedData() {
return sessionStorage.getItem('scenemanager_data') || '';
}
/** Opens a directory picker. Returns null if cancelled. */
async _pickDirectory() {
try {
return await window.showDirectoryPicker({ id: 'scenemanager-root', mode: 'readwrite', startIn: 'documents' });
} catch (e) {
if (e.name !== 'AbortError') console.error('[SceneManager] Directory picker failed:', e);
return null;
}
}
/** Shows the "folder has been set" confirmation modal. */
_showFolderSetModal(name) {
openModal((box, close) => {
box.innerHTML = `
<h2>Root folder set</h2>
<p>Scene Manager will now load projects from:</p>
<div class="sm-path">📁 ${this._esc(name)}/</div>
<div class="sm-note">
In <strong>Scratch Everywhere</strong> this corresponds to the
<code>scratch-everywhere/</code> folder, where all your .sb3 files are located.
Put your launcher project in the root and keep scene files in a subfolder.
You theoretically don't have to put all your scenes inside a subfolder, but it is recommended.
</div>
<div class="sm-row">
<button class="sm-btn sm-btn-primary" id="sm-ok-btn">Got it!</button>
</div>
`;
box.querySelector('#sm-ok-btn').onclick = () => close(null);
});
}
/**
* If no root folder is set, prompts the user to select one.
* Returns true if a folder is now available, false if cancelled.
*/
async _requireFolder(action) {
return openModal((box, close) => {
box.innerHTML = `
<h2>Root folder required</h2>
<p>
<strong style="color:#cdd6f4">${this._esc(action)}</strong> needs to know
where your <code>.sb3</code> files are stored.
</p>
<div class="sm-note">
Select your <code>scratch-everywhere/</code> folder (or any folder containing your projects).
After selecting, the action will continue automatically.
</div>
<div class="sm-row">
<button class="sm-btn sm-btn-ghost" id="sm-cancel-btn">Cancel</button>
<button class="sm-btn sm-btn-primary" id="sm-select-btn">Select folder…</button>
</div>
`;
box.querySelector('#sm-cancel-btn').onclick = () => close(false);
box.querySelector('#sm-select-btn').onclick = async () => {
const handle = await this._pickDirectory();
if (handle) { rootDirectoryHandle = handle; currentFileHandle = null; close(true); }
else close(false);
};
});
}
/** Recursively scans a directory for .sb3 files. Returns [{path, handle}]. */
async _scanSb3(dirHandle, prefix = '') {
const results = [];
for await (const [name, handle] of dirHandle.entries()) {
const rel = prefix ? `${prefix}/${name}` : name;
if (handle.kind === 'file' && name.toLowerCase().endsWith('.sb3')) {
results.push({ path: rel, handle });
} else if (handle.kind === 'directory') {
results.push(...await this._scanSb3(handle, rel));
}
}
return results.sort((a, b) => a.path.localeCompare(b.path));
}
/**
* Resolves a relative path (e.g. "scenes/Level2" or "scenes/Level2.sb3")
* to a FileSystemFileHandle inside rootDirectoryHandle.
*/
async _getFileHandle(relativePath) {
if (!rootDirectoryHandle) {
const ok = await this._requireFolder('A scene block tried to load a file');
if (!ok) return null;
}
// Normalise: strip trailing .sb3, then re-add
const clean = relativePath.replace(/\.sb3$/i, '');
const parts = clean.split('/').filter(Boolean);
const fileName = parts[parts.length - 1] + '.sb3';
let handle = rootDirectoryHandle;
try {
for (let i = 0; i < parts.length - 1; i++) {
handle = await handle.getDirectoryHandle(parts[i]);
}
return await handle.getFileHandle(fileName);
} catch {
await this._showError(`File not found: ${clean}.sb3`);
return null;
}
}
/** Core loading routine used by both the scene blocks and the project browser. */
async _loadScene(filePath, dataToPass, fromBrowser = false) {
const display = filePath.replace(/\.sb3$/i, '') + '.sb3';
// Confirm
const confirmed = await openModal((box, close) => {
const saveRow = currentFileHandle
? `<button class="sm-btn sm-btn-primary" id="sm-save-btn">Save current & switch</button>`
: `<button class="sm-btn sm-btn-primary" id="sm-switch-btn">Switch</button>`;
box.innerHTML = `
<h2>Switch scene</h2>
<p>Load <code>${this._esc(display)}</code>?</p>
<div class="sm-note">
Unsaved changes in the current project will be lost.
${currentFileHandle ? 'You can save first using the button below.' : ''}
</div>
<div class="sm-row">
<button class="sm-btn sm-btn-ghost" id="sm-cancel-btn">Cancel</button>
${saveRow}
${currentFileHandle ? `<button class="sm-btn sm-btn-warn" id="sm-switch-btn">Switch without saving</button>` : ''}
</div>
`;
box.querySelector('#sm-cancel-btn').onclick = () => close('cancel');
const switchBtn = box.querySelector('#sm-switch-btn');
if (switchBtn) switchBtn.onclick = () => close('switch');
const saveBtn = box.querySelector('#sm-save-btn');
if (saveBtn) saveBtn.onclick = () => close('save-and-switch');
});
if (!confirmed || confirmed === 'cancel') return;
// Optionally save current project back to its file
if (confirmed === 'save-and-switch' && currentFileHandle) {
try {
const blob = await Scratch.vm.saveProjectSb3();
const writable = await currentFileHandle.createWritable();
await writable.write(blob);
await writable.close();
showToast(`Saved: ${currentFileHandle.name}`, 'ok');
} catch (e) {
console.error('[SceneManager] Save failed:', e);
showToast('Save failed — switching anyway.', 'err');
}
}
// Resolve file
const fh = await this._getFileHandle(filePath);
if (!fh) return;
// Load
try {
const file = await fh.getFile();
const buf = await file.arrayBuffer();
sessionStorage.setItem('scenemanager_data', dataToPass);
currentFileHandle = fh;
await Scratch.vm.loadProject(buf);
Scratch.vm.greenFlag();
} catch (e) {
console.error('[SceneManager] Load failed:', e);
await this._showError('Could not load the project. Is it a valid .sb3 file?');
}
}
async _showError(msg) {
await openModal((box, close) => {
box.innerHTML = `
<h2>Error</h2>
<p>${this._esc(msg)}</p>
<div class="sm-row">
<button class="sm-btn sm-btn-primary" id="sm-ok-btn">OK</button>
</div>
`;
box.querySelector('#sm-ok-btn').onclick = () => close(null);
});
}
_esc(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
}
Scratch.extensions.register(new SceneManager());
})(Scratch);