(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 = '
No .sb3 files found in this folder.
'; } else { for (const [dir, items] of Object.entries(grouped)) { if (dir) treeHtml += `
📁 ${this._esc(dir)}/
`; for (const item of items) { const name = item.path.split('/').pop().replace(/\.sb3$/i, ''); treeHtml += `
📄 ${this._esc(name)}.sb3
`; } } } box.innerHTML = `

Browse Projects

Root folder: ${this._esc(rootDirectoryHandle.name)}/

${treeHtml}

`; 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 = `

How the Scene Manager works

Scene Manager lets you split your Scratch project into multiple .sb3 files and seamlessly switch between them at runtime — just like loading a new level or screen.

Scratch Everywhere
All .sb3 files directly inside the scratch-everywhere folder appear in the SE! menu.

Set Root Folder 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.

Recommended project structure

📁 scratch-everywhere/ MyGame.sb3 ← launcher (appears in menu) 📁 MyGameName-Scenes/ Level1.sb3 Level2.sb3 BossLevel.sb3

Only MyGame.sb3 is visible in the game-selection menu. It uses Scene Manager blocks to load the actual levels from the scenes/ subfolder, those files stay private inside the SE! menu.

Use subfolder-prefixed paths in the scene blocks:
open scenes/Level1.sb3
Scene Manager navigates into the subfolder automatically.
`; 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 = `

Root folder set

Scene Manager will now load projects from:

📁 ${this._esc(name)}/
In Scratch Everywhere this corresponds to the scratch-everywhere/ 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.
`; 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 = `

Root folder required

${this._esc(action)} needs to know where your .sb3 files are stored.

Select your scratch-everywhere/ folder (or any folder containing your projects). After selecting, the action will continue automatically.
`; 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 ? `` : ``; box.innerHTML = `

Switch scene

Load ${this._esc(display)}?

Unsaved changes in the current project will be lost. ${currentFileHandle ? 'You can save first using the button below.' : ''}
${saveRow} ${currentFileHandle ? `` : ''}
`; 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 = `

Error

${this._esc(msg)}

`; box.querySelector('#sm-ok-btn').onclick = () => close(null); }); } _esc(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } } Scratch.extensions.register(new SceneManager()); })(Scratch);