mirror of
https://github.com/litruv/picoGraph.git
synced 2026-07-24 02:36:04 +10:00
Compare commits
7 Commits
v2025-10-1
...
v2026-03-2
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d56c7ce04 | |||
| f917cf6ad5 | |||
| 71354ad463 | |||
| ae374db9fb | |||
| 1f5da03474 | |||
|
|
9261def7cd | ||
|
|
b9712954bf |
7
InstallationLog.txt
Normal file
7
InstallationLog.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
************************************* Invoked: Sat Mar 21 02:32:52 2026
|
||||
[0] Arguments: C:\Users\litru\AppData\Local\Temp\WinGet\MSYS2.MSYS2.20251213\msys2-x86_64-20251213.exe, install, --confirm-command, --root, C:\msys64
|
||||
[10] Operations sanity check succeeded.
|
||||
[22] Using metadata cache from "C:/Users/litru/AppData/Local/cache\\qt-installer-framework\\d75f1c19-3379-3717-ae8d-1404b51494a9"
|
||||
[22] Found 0 cached items.
|
||||
[29] Loaded control script ":/metadata/installer-config/control_js"
|
||||
[30] TargetDirectoryInUse : Error : The directory you selected already exists and contains an installation. Choose a different target for installation.
|
||||
BIN
all_star_croquet-0.p8.png
Normal file
BIN
all_star_croquet-0.p8.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
227
electron-main.js
227
electron-main.js
@@ -1,6 +1,24 @@
|
||||
const { app, BrowserWindow, ipcMain } = require("electron");
|
||||
// Enable hot-reload during development
|
||||
try {
|
||||
require('electron-reloader')(module, {
|
||||
debug: false,
|
||||
watchRenderer: true,
|
||||
ignore: [
|
||||
/node_modules/,
|
||||
/\.git/,
|
||||
/dist/,
|
||||
/build/,
|
||||
/pico8-temp-player\.html$/
|
||||
]
|
||||
});
|
||||
} catch (_) {
|
||||
// Ignore errors in production (when electron-reloader isn't installed)
|
||||
}
|
||||
|
||||
const { app, BrowserWindow, ipcMain, Menu, shell, dialog } = require("electron");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { spawn } = require("node:child_process");
|
||||
|
||||
const OUTPUT_FILENAME = "compiled.lua";
|
||||
|
||||
@@ -23,6 +41,16 @@ function getOutputPath() {
|
||||
return path.join(getOutputDirectory(), OUTPUT_FILENAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the absolute directory where tileset images are stored.
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
function getTilesetsDirectory() {
|
||||
const applicationData = app.getPath("appData");
|
||||
return path.join(applicationData, "pico-8", "carts", "picograph", "tilesets");
|
||||
}
|
||||
|
||||
/** @type {import("electron").BrowserWindow | null} */
|
||||
let mainWindow = null;
|
||||
|
||||
@@ -37,6 +65,7 @@ function createMainWindow() {
|
||||
height: 900,
|
||||
minWidth: 960,
|
||||
minHeight: 640,
|
||||
frame: false,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
@@ -51,6 +80,23 @@ function createMainWindow() {
|
||||
|
||||
window.loadFile(path.join(__dirname, "index.html"));
|
||||
|
||||
// Open DevTools in development
|
||||
window.webContents.openDevTools();
|
||||
|
||||
// Register F12 to toggle DevTools
|
||||
window.webContents.on('before-input-event', (event, input) => {
|
||||
if (input.key === 'F12') {
|
||||
window.webContents.toggleDevTools();
|
||||
}
|
||||
});
|
||||
|
||||
const emitMaximizedState = () => {
|
||||
window.webContents.send("window:maximized-changed", window.isMaximized());
|
||||
};
|
||||
|
||||
window.on("maximize", emitMaximizedState);
|
||||
window.on("unmaximize", emitMaximizedState);
|
||||
|
||||
window.on("closed", () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
@@ -75,6 +121,184 @@ function registerIpcHandlers() {
|
||||
|
||||
return { filePath: outputPath };
|
||||
});
|
||||
|
||||
ipcMain.handle("cart:preview", async (_event, cartContent) => {
|
||||
if (typeof cartContent !== "string") {
|
||||
throw new TypeError("Cart content must be a string");
|
||||
}
|
||||
|
||||
const tempCartPath = path.join(app.getPath("temp"), `preview_${Date.now()}.p8`);
|
||||
await fs.writeFile(tempCartPath, cartContent, "utf8");
|
||||
|
||||
return { filePath: tempCartPath };
|
||||
});
|
||||
|
||||
ipcMain.handle("player:writeHtml", async (_event, htmlContent) => {
|
||||
if (typeof htmlContent !== "string") {
|
||||
throw new TypeError("HTML content must be a string");
|
||||
}
|
||||
|
||||
const playerPath = path.join(__dirname, "public", "pico8-temp-player.html");
|
||||
await fs.writeFile(playerPath, htmlContent, "utf8");
|
||||
|
||||
return { filePath: playerPath };
|
||||
});
|
||||
|
||||
ipcMain.handle("player:writeCart", async (_event, cartContent) => {
|
||||
if (typeof cartContent !== "string") {
|
||||
throw new TypeError("Cart content must be a string");
|
||||
}
|
||||
|
||||
const cartPath = path.join(__dirname, "public", "pico8-cart.p8");
|
||||
await fs.writeFile(cartPath, cartContent, "utf8");
|
||||
|
||||
return { filePath: cartPath };
|
||||
});
|
||||
|
||||
ipcMain.handle("window:minimize", () => {
|
||||
mainWindow?.minimize();
|
||||
});
|
||||
|
||||
ipcMain.handle("window:toggle-maximize", () => {
|
||||
if (!mainWindow) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mainWindow.isMaximized()) {
|
||||
mainWindow.unmaximize();
|
||||
} else {
|
||||
mainWindow.maximize();
|
||||
}
|
||||
|
||||
return mainWindow.isMaximized();
|
||||
});
|
||||
|
||||
ipcMain.handle("window:close", () => {
|
||||
mainWindow?.close();
|
||||
});
|
||||
|
||||
ipcMain.handle("window:is-maximized", () => {
|
||||
return mainWindow?.isMaximized() ?? false;
|
||||
});
|
||||
|
||||
ipcMain.handle("tileset:save", async (_event, fileBuffer, suggestedName) => {
|
||||
if (!(fileBuffer instanceof Uint8Array)) {
|
||||
throw new TypeError("File buffer must be a Uint8Array");
|
||||
}
|
||||
if (typeof suggestedName !== "string") {
|
||||
throw new TypeError("Suggested name must be a string");
|
||||
}
|
||||
|
||||
const tilesetsDir = getTilesetsDirectory();
|
||||
await fs.mkdir(tilesetsDir, { recursive: true });
|
||||
|
||||
// Ensure unique filename
|
||||
let fileName = suggestedName.endsWith(".png") ? suggestedName : `${suggestedName}.png`;
|
||||
let filePath = path.join(tilesetsDir, fileName);
|
||||
let counter = 1;
|
||||
|
||||
while (await fs.access(filePath).then(() => true).catch(() => false)) {
|
||||
const nameWithoutExt = suggestedName.replace(/\.png$/i, "");
|
||||
fileName = `${nameWithoutExt}_${counter}.png`;
|
||||
filePath = path.join(tilesetsDir, fileName);
|
||||
counter++;
|
||||
}
|
||||
|
||||
await fs.writeFile(filePath, Buffer.from(fileBuffer));
|
||||
|
||||
return { filePath, fileName };
|
||||
});
|
||||
|
||||
ipcMain.handle("tileset:show-in-folder", async (_event, filePath) => {
|
||||
if (typeof filePath !== "string") {
|
||||
throw new TypeError("File path must be a string");
|
||||
}
|
||||
|
||||
shell.showItemInFolder(filePath);
|
||||
});
|
||||
|
||||
ipcMain.handle("tileset:reload", async (_event, filePath) => {
|
||||
if (typeof filePath !== "string") {
|
||||
throw new TypeError("File path must be a string");
|
||||
}
|
||||
|
||||
const buffer = await fs.readFile(filePath);
|
||||
return { buffer: Array.from(buffer) };
|
||||
});
|
||||
|
||||
ipcMain.handle("cart:get-stats", async (_event, cartContent) => {
|
||||
if (typeof cartContent !== "string") {
|
||||
throw new TypeError("Cart content must be a string");
|
||||
}
|
||||
|
||||
// Write cart to a temporary file
|
||||
const tempDir = app.getPath("temp");
|
||||
const tempCartPath = path.join(tempDir, `stats_${Date.now()}.p8`);
|
||||
|
||||
try {
|
||||
await fs.writeFile(tempCartPath, cartContent, "utf8");
|
||||
|
||||
// Run p8tool stats command
|
||||
const picotoolPath = path.join(__dirname, "picotool");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const p8tool = spawn("p8tool", ["stats", tempCartPath], {
|
||||
cwd: __dirname,
|
||||
shell: true
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
p8tool.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
p8tool.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
p8tool.on("close", async (code) => {
|
||||
// Clean up temp file
|
||||
try {
|
||||
await fs.unlink(tempCartPath);
|
||||
} catch (cleanupError) {
|
||||
console.warn("Failed to delete temp cart:", cleanupError);
|
||||
}
|
||||
|
||||
if (code !== 0) {
|
||||
reject(new Error(`p8tool stats exited with code ${code}: ${stderr}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse the output to extract token count
|
||||
// Expected format: "version: 0 lines: 48 chars: 419 tokens: 134"
|
||||
const tokenMatch = stdout.match(/tokens:\s*(\d+)/);
|
||||
const linesMatch = stdout.match(/lines:\s*(\d+)/);
|
||||
const charsMatch = stdout.match(/chars:\s*(\d+)/);
|
||||
|
||||
resolve({
|
||||
tokens: tokenMatch ? parseInt(tokenMatch[1], 10) : 0,
|
||||
lines: linesMatch ? parseInt(linesMatch[1], 10) : 0,
|
||||
chars: charsMatch ? parseInt(charsMatch[1], 10) : 0,
|
||||
rawOutput: stdout
|
||||
});
|
||||
});
|
||||
|
||||
p8tool.on("error", (error) => {
|
||||
reject(new Error(`Failed to run p8tool: ${error.message}`));
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
// Clean up temp file if it exists
|
||||
try {
|
||||
await fs.unlink(tempCartPath);
|
||||
} catch (_) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,6 +308,7 @@ function registerIpcHandlers() {
|
||||
*/
|
||||
function registerAppLifecycle() {
|
||||
app.whenReady().then(() => {
|
||||
Menu.setApplicationMenu(null);
|
||||
createMainWindow();
|
||||
registerIpcHandlers();
|
||||
|
||||
|
||||
118
index.html
118
index.html
@@ -3,16 +3,23 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; font-src 'self' data:;" />
|
||||
<title>picoGraph</title>
|
||||
<link rel="stylesheet" href="styles/main.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div class="header-primary-actions">
|
||||
<button id="compileButton" class="header-primary-button" type="button">Compile</button>
|
||||
</div>
|
||||
<div class="header-title">picoGraph</div>
|
||||
<div class="header-actions">
|
||||
<div class="header-actions header-actions--left" aria-label="Workspace tools">
|
||||
<button
|
||||
id="tileManagerButton"
|
||||
class="header-icon-button"
|
||||
type="button"
|
||||
aria-label="Tile manager"
|
||||
title="Tile manager"
|
||||
>
|
||||
<span class="header-icon header-icon--tiles" aria-hidden="true"></span>
|
||||
</button>
|
||||
<span class="header-icon-spacer" aria-hidden="true"></span>
|
||||
<button
|
||||
id="projectSettingsButton"
|
||||
class="header-icon-button"
|
||||
@@ -22,6 +29,25 @@
|
||||
>
|
||||
<span class="header-icon header-icon--settings" aria-hidden="true"></span>
|
||||
</button>
|
||||
<button
|
||||
id="exportLuaButton"
|
||||
class="header-icon-button"
|
||||
type="button"
|
||||
aria-label="Export Lua"
|
||||
title="Export Lua"
|
||||
>
|
||||
<span class="header-icon header-icon--export" aria-hidden="true"></span>
|
||||
</button>
|
||||
<span class="header-icon-spacer" aria-hidden="true"></span>
|
||||
<button
|
||||
id="frameSelectionButton"
|
||||
class="header-icon-button"
|
||||
type="button"
|
||||
aria-label="Frame selection"
|
||||
title="Frame selection"
|
||||
>
|
||||
<span class="header-icon header-icon--frame" aria-hidden="true"></span>
|
||||
</button>
|
||||
<button
|
||||
id="paletteToggleButton"
|
||||
class="header-icon-button"
|
||||
@@ -32,24 +58,58 @@
|
||||
>
|
||||
<span class="header-icon header-icon--palette" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="header-titlebar">
|
||||
<div class="header-title">picoGraph</div>
|
||||
</div>
|
||||
<div class="header-primary-actions">
|
||||
<button
|
||||
id="frameSelectionButton"
|
||||
id="saveButton"
|
||||
class="header-icon-button"
|
||||
type="button"
|
||||
aria-label="Frame selection"
|
||||
title="Frame selection"
|
||||
aria-label="Save workspace"
|
||||
title="Save workspace"
|
||||
>
|
||||
<span class="header-icon header-icon--frame" aria-hidden="true"></span>
|
||||
<span class="header-icon header-icon--save" aria-hidden="true"></span>
|
||||
</button>
|
||||
<button
|
||||
id="exportLuaButton"
|
||||
class="header-icon-button"
|
||||
id="previewButton"
|
||||
class="header-icon-button header-icon-button--accent"
|
||||
type="button"
|
||||
aria-label="Export Lua"
|
||||
title="Export Lua"
|
||||
aria-label="Preview cart"
|
||||
title="Preview cart"
|
||||
>
|
||||
<span class="header-icon header-icon--export" aria-hidden="true"></span>
|
||||
<span class="header-icon header-icon--play" aria-hidden="true"></span>
|
||||
</button>
|
||||
<div id="windowControls" class="window-controls" aria-label="Window controls">
|
||||
<button
|
||||
id="minimizeWindowButton"
|
||||
class="window-control-button"
|
||||
type="button"
|
||||
aria-label="Minimize window"
|
||||
title="Minimize"
|
||||
>
|
||||
<span class="header-icon header-icon--minimize" aria-hidden="true"></span>
|
||||
</button>
|
||||
<button
|
||||
id="maximizeWindowButton"
|
||||
class="window-control-button"
|
||||
type="button"
|
||||
aria-label="Maximize window"
|
||||
title="Maximize"
|
||||
>
|
||||
<span class="header-icon header-icon--maximize" aria-hidden="true"></span>
|
||||
</button>
|
||||
<button
|
||||
id="closeWindowButton"
|
||||
class="window-control-button window-control-button--close"
|
||||
type="button"
|
||||
aria-label="Close window"
|
||||
title="Close"
|
||||
>
|
||||
<span class="header-icon header-icon--close-window" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main id="appBody" class="app-body">
|
||||
@@ -127,6 +187,36 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="tileModal"
|
||||
class="modal"
|
||||
aria-hidden="true"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="tileModalTitle"
|
||||
hidden
|
||||
>
|
||||
<div class="modal__backdrop" data-modal-close></div>
|
||||
<div class="modal__panel modal__panel--tile" role="document">
|
||||
<header class="modal__header">
|
||||
<h2 id="tileModalTitle">Tile Manager</h2>
|
||||
<div class="modal__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="modal__icon-button close-modal"
|
||||
aria-label="Close tile manager"
|
||||
title="Close"
|
||||
data-modal-close
|
||||
>
|
||||
<span class="modal-icon modal-icon--close" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="modal__body">
|
||||
<div class="tile-browser-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template id="nodeTemplate">
|
||||
<article class="blueprint-node" draggable="false">
|
||||
<header class="node-header">
|
||||
|
||||
232
package-lock.json
generated
232
package-lock.json
generated
@@ -10,7 +10,8 @@
|
||||
"devDependencies": {
|
||||
"electron": "^30.0.0",
|
||||
"electron-builder": "^24.6.3",
|
||||
"electron-icon-builder": "^2.0.1"
|
||||
"electron-icon-builder": "^2.0.1",
|
||||
"electron-reloader": "^1.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
@@ -1318,6 +1319,20 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/anymatch": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"normalize-path": "^3.0.0",
|
||||
"picomatch": "^2.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/app-builder-bin": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-4.0.0.tgz",
|
||||
@@ -1729,6 +1744,19 @@
|
||||
"tweetnacl": "^0.14.3"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||
@@ -2017,6 +2045,31 @@
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"anymatch": "~3.1.2",
|
||||
"braces": "~3.0.2",
|
||||
"glob-parent": "~5.1.2",
|
||||
"is-binary-path": "~2.1.0",
|
||||
"is-glob": "~4.0.1",
|
||||
"normalize-path": "~3.0.0",
|
||||
"readdirp": "~3.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
|
||||
@@ -2375,6 +2428,19 @@
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/date-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-time/-/date-time-3.1.0.tgz",
|
||||
"integrity": "sha512-uqCUKXE5q1PNBXjPqvwhwJf9SwMoAHBgWJ6DcrnS5o+W2JOiIILl0JEdVD8SGujrNS02GGxgwAg2PN2zONgtjg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"time-zone": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -2881,6 +2947,13 @@
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-is-dev": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/electron-is-dev/-/electron-is-dev-1.2.0.tgz",
|
||||
"integrity": "sha512-R1oD5gMBPS7PVU8gJwH6CtT0e6VSoD0+SzSnYpNm+dBkcijgA+K7VAMHDfnRq/lkKPZArpzplTW6jfiMYosdzw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/electron-publish": {
|
||||
"version": "24.13.1",
|
||||
"resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-24.13.1.tgz",
|
||||
@@ -2935,6 +3008,50 @@
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-reloader": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/electron-reloader/-/electron-reloader-1.2.3.tgz",
|
||||
"integrity": "sha512-aDnACAzNg0QvQhzw7LYOx/nVS10mEtbuG6M0QQvNQcLnJEwFs6is+EGRCnM+KQlQ4KcTbdwnt07nd7ZjHpY4iw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "^4.1.0",
|
||||
"chokidar": "^3.5.0",
|
||||
"date-time": "^3.1.0",
|
||||
"electron-is-dev": "^1.2.0",
|
||||
"find-up": "^5.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-reloader/node_modules/find-up": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
||||
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^6.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-reloader/node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
@@ -3369,6 +3486,21 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -3993,6 +4125,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"binary-extensions": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-ci": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz",
|
||||
@@ -4495,6 +4640,22 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
@@ -4802,7 +4963,6 @@
|
||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -4891,6 +5051,38 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"yocto-queue": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-locate": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
|
||||
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-map": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
|
||||
@@ -5564,6 +5756,19 @@
|
||||
"minimatch": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"picomatch": "^2.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.13.11",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
@@ -6449,6 +6654,16 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/time-zone": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/time-zone/-/time-zone-1.0.0.tgz",
|
||||
"integrity": "sha512-TIsDdtKo6+XrPtiTm1ssmMngN1sAhyKnTO2kunQWqNPWIVvCm15Wmw4SWInwTVgJ5u/Tr04+8Ei9TNcw4x4ONA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/timm": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/timm/-/timm-1.7.1.tgz",
|
||||
@@ -6869,6 +7084,19 @@
|
||||
"fd-slicer": "~1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zip-stream": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz",
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
"devDependencies": {
|
||||
"electron": "^30.0.0",
|
||||
"electron-builder": "^24.6.3",
|
||||
"electron-icon-builder": "^2.0.1"
|
||||
"electron-icon-builder": "^2.0.1",
|
||||
"electron-reloader": "^1.2.3"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.litruv.picograph",
|
||||
@@ -22,7 +23,8 @@
|
||||
"output": "dist"
|
||||
},
|
||||
"files": [
|
||||
"**/*"
|
||||
"**/*",
|
||||
"!picotool/**/*"
|
||||
],
|
||||
"win": {
|
||||
"icon": "build/icons/icon.ico",
|
||||
|
||||
3416
pico8-edu-page.html
Normal file
3416
pico8-edu-page.html
Normal file
File diff suppressed because it is too large
Load Diff
1
picotool
Submodule
1
picotool
Submodule
Submodule picotool added at 49808e5ddb
158
preload.js
158
preload.js
@@ -17,6 +17,164 @@ const api = {
|
||||
|
||||
return ipcRenderer.invoke("lua:compile", source);
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates a temporary .p8 cart file for preview
|
||||
*
|
||||
* @param {string} cartContent Complete .p8 cart file content
|
||||
* @returns {Promise<{ filePath: string }>}
|
||||
*/
|
||||
previewCart(cartContent) {
|
||||
if (typeof cartContent !== "string") {
|
||||
return Promise.reject(new TypeError("Cart content must be a string"));
|
||||
}
|
||||
|
||||
return ipcRenderer.invoke("cart:preview", cartContent);
|
||||
},
|
||||
|
||||
/**
|
||||
* Writes the PICO-8 player HTML to disk and returns the file path
|
||||
*
|
||||
* @param {string} htmlContent The player HTML content
|
||||
* @returns {Promise<{ filePath: string }>}
|
||||
*/
|
||||
writePlayerHtml(htmlContent) {
|
||||
if (typeof htmlContent !== "string") {
|
||||
return Promise.reject(new TypeError("HTML content must be a string"));
|
||||
}
|
||||
|
||||
return ipcRenderer.invoke("player:writeHtml", htmlContent);
|
||||
},
|
||||
|
||||
/**
|
||||
* Writes the cart .p8 file to the public directory for the player to load
|
||||
*
|
||||
* @param {string} cartContent The .p8 cart content
|
||||
* @returns {Promise<{ filePath: string }>}
|
||||
*/
|
||||
writePlayerCart(cartContent) {
|
||||
if (typeof cartContent !== "string") {
|
||||
return Promise.reject(new TypeError("Cart content must be a string"));
|
||||
}
|
||||
|
||||
return ipcRenderer.invoke("player:writeCart", cartContent);
|
||||
},
|
||||
|
||||
/**
|
||||
* Minimizes the current window.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
minimizeWindow() {
|
||||
return ipcRenderer.invoke("window:minimize");
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggles the current window maximize state.
|
||||
*
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
toggleMaximizeWindow() {
|
||||
return ipcRenderer.invoke("window:toggle-maximize");
|
||||
},
|
||||
|
||||
/**
|
||||
* Closes the current window.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
closeWindow() {
|
||||
return ipcRenderer.invoke("window:close");
|
||||
},
|
||||
|
||||
/**
|
||||
* Reads the current maximize state for the window.
|
||||
*
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
getWindowMaximized() {
|
||||
return ipcRenderer.invoke("window:is-maximized");
|
||||
},
|
||||
|
||||
/**
|
||||
* Subscribes to maximize state changes.
|
||||
*
|
||||
* @param {(isMaximized: boolean) => void} callback
|
||||
* @returns {() => void}
|
||||
*/
|
||||
onWindowMaximizedChanged(callback) {
|
||||
if (typeof callback !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const listener = (_event, isMaximized) => {
|
||||
callback(Boolean(isMaximized));
|
||||
};
|
||||
ipcRenderer.on("window:maximized-changed", listener);
|
||||
return () => {
|
||||
ipcRenderer.removeListener("window:maximized-changed", listener);
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Saves a tileset image file to the application data directory.
|
||||
*
|
||||
* @param {Uint8Array} fileBuffer The PNG file data
|
||||
* @param {string} suggestedName Suggested filename
|
||||
* @returns {Promise<{ filePath: string, fileName: string }>}
|
||||
*/
|
||||
saveTileset(fileBuffer, suggestedName) {
|
||||
if (!(fileBuffer instanceof Uint8Array)) {
|
||||
return Promise.reject(new TypeError("File buffer must be a Uint8Array"));
|
||||
}
|
||||
if (typeof suggestedName !== "string") {
|
||||
return Promise.reject(new TypeError("Suggested name must be a string"));
|
||||
}
|
||||
|
||||
return ipcRenderer.invoke("tileset:save", fileBuffer, suggestedName);
|
||||
},
|
||||
|
||||
/**
|
||||
* Shows a tileset file in the system file explorer.
|
||||
*
|
||||
* @param {string} filePath Path to the tileset file
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
showTilesetInFolder(filePath) {
|
||||
if (typeof filePath !== "string") {
|
||||
return Promise.reject(new TypeError("File path must be a string"));
|
||||
}
|
||||
|
||||
return ipcRenderer.invoke("tileset:show-in-folder", filePath);
|
||||
},
|
||||
|
||||
/**
|
||||
* Reloads a tileset image from disk.
|
||||
*
|
||||
* @param {string} filePath Path to the tileset file
|
||||
* @returns {Promise<{ buffer: number[] }>}
|
||||
*/
|
||||
reloadTileset(filePath) {
|
||||
if (typeof filePath !== "string") {
|
||||
return Promise.reject(new TypeError("File path must be a string"));
|
||||
}
|
||||
|
||||
return ipcRenderer.invoke("tileset:reload", filePath);
|
||||
},
|
||||
|
||||
/**
|
||||
* Gets PICO-8 cart statistics using p8tool stats command.
|
||||
*
|
||||
* @param {string} cartContent Complete .p8 cart file content
|
||||
* @returns {Promise<{ tokens: number, lines: number, chars: number, rawOutput: string }>}
|
||||
*/
|
||||
getCartStats(cartContent) {
|
||||
if (typeof cartContent !== "string") {
|
||||
return Promise.reject(new TypeError("Cart content must be a string"));
|
||||
}
|
||||
|
||||
return ipcRenderer.invoke("cart:get-stats", cartContent);
|
||||
},
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", api);
|
||||
|
||||
51
public/pico8-cart.p8
Normal file
51
public/pico8-cart.p8
Normal file
@@ -0,0 +1,51 @@
|
||||
pico-8 cartridge // http://www.pico-8.com
|
||||
version 41
|
||||
__lua__
|
||||
-- Generated with picoGraph
|
||||
|
||||
zzzz = ""
|
||||
offset = 0
|
||||
color = 5
|
||||
var1 = nil
|
||||
|
||||
function _update()
|
||||
-- sequence a
|
||||
cls()
|
||||
offset = 0
|
||||
-- sequence b
|
||||
if btn(0, 0) then
|
||||
print("left", 0, offset, rnd((16) + (1)))
|
||||
custom_addoffset()
|
||||
end
|
||||
-- sequence c
|
||||
if btn(1, 0) then
|
||||
print("right", 0, offset, color)
|
||||
local itsalocalvariable = true
|
||||
if itsalocalvariable then
|
||||
custom_addoffset()
|
||||
end
|
||||
end
|
||||
-- sequence d
|
||||
if btn(2, 0) then
|
||||
print("up", 0, offset, color)
|
||||
custom_addoffset()
|
||||
end
|
||||
-- sequence e
|
||||
if btn(3, 0) then
|
||||
print("down", 0, offset, color)
|
||||
custom_addoffset()
|
||||
end
|
||||
end
|
||||
|
||||
function custom_addoffset()
|
||||
offset = (offset) + (7)
|
||||
end
|
||||
__gfx__
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
3304
public/pico8-player.html
Normal file
3304
public/pico8-player.html
Normal file
File diff suppressed because it is too large
Load Diff
95
public/pico8-temp-player.html
Normal file
95
public/pico8-temp-player.html
Normal file
@@ -0,0 +1,95 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: #1a1a2e; display: flex; align-items: center; justify-content: center; width: 100vw; height: 100vh; overflow: hidden; }
|
||||
#canvas { image-rendering: pixelated; image-rendering: crisp-edges; width: 512px !important; height: 512px !important; border: 8px solid #000; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="canvas" width="128" height="128"></canvas>
|
||||
<script>
|
||||
var pico8_state = [];
|
||||
var pico8_buttons = [0,0,0,0,0,0,0,0];
|
||||
var codo_command = 0;
|
||||
var _cartname = ['untitled.p8'];
|
||||
var codo_key_buffer = [];
|
||||
var p8_keyboard_state = 0;
|
||||
var pico8_mouse = [];
|
||||
var pico8_gamepads = { count: 0 };
|
||||
var pico8_gpio = new Array(128).fill(0);
|
||||
var p8_dropped_cart = null;
|
||||
var p8_dropped_cart_name = '';
|
||||
|
||||
var CART_B64 = 'cGljby04IGNhcnRyaWRnZSAvLyBodHRwOi8vd3d3LnBpY28tOC5jb20KdmVyc2lvbiA0MQpfX2x1YV9fCi0tIEdlbmVyYXRlZCB3aXRoIHBpY29HcmFwaAoKZnVuY3Rpb24gX2RyYXcoKQogIGNscygwKQogIGNpcmNmaWxsKDMyLCAzMiwgMjQsIDgpCmVuZApmdW5jdGlvbiBfdXBkYXRlKCkKZW5kCgpfX2dmeF9fCjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwCjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwCjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwCjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwCjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwCjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwCjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwCjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwCg==';
|
||||
|
||||
// Silence all XHR — PICO-8 makes BBS metadata calls that crash from file:// origin.
|
||||
(function() {
|
||||
function MockXHR() {
|
||||
this.onload = null; this.onerror = null; this.onreadystatechange = null;
|
||||
this.readyState = 0; this.status = 0; this.statusText = '';
|
||||
this.response = null; this.responseText = ''; this.responseType = '';
|
||||
}
|
||||
MockXHR.UNSENT=0; MockXHR.OPENED=1; MockXHR.HEADERS_RECEIVED=2; MockXHR.LOADING=3; MockXHR.DONE=4;
|
||||
MockXHR.prototype.open = function() { this.readyState = 1; };
|
||||
MockXHR.prototype.send = function() {
|
||||
var self = this;
|
||||
setTimeout(function() {
|
||||
self.readyState = 4; self.status = 200; self.statusText = 'OK';
|
||||
self.response = self.responseType === 'arraybuffer' ? new ArrayBuffer(0) : '';
|
||||
self.responseText = '';
|
||||
if (typeof self.onload === 'function') self.onload({ target: self });
|
||||
if (typeof self.onreadystatechange === 'function') self.onreadystatechange();
|
||||
}, 0);
|
||||
};
|
||||
MockXHR.prototype.setRequestHeader = function() {};
|
||||
MockXHR.prototype.getResponseHeader = function() { return null; };
|
||||
MockXHR.prototype.getAllResponseHeaders = function() { return ''; };
|
||||
MockXHR.prototype.abort = function() {};
|
||||
MockXHR.prototype.addEventListener = function(e, fn) {
|
||||
if (e==='load') this.onload=fn;
|
||||
if (e==='error') this.onerror=fn;
|
||||
if (e==='readystatechange') this.onreadystatechange=fn;
|
||||
};
|
||||
MockXHR.prototype.removeEventListener = function() {};
|
||||
window.XMLHttpRequest = MockXHR;
|
||||
})();
|
||||
|
||||
var Module = {
|
||||
arguments: [],
|
||||
canvas: document.getElementById('canvas'),
|
||||
preRun: [function() {
|
||||
if (typeof IDBFS !== 'undefined' && typeof MEMFS !== 'undefined') {
|
||||
var orig = FS.mount.bind(FS);
|
||||
FS.mount = function(type, opts, mp) { return orig(type===IDBFS?MEMFS:type, opts, mp); };
|
||||
}
|
||||
FS.syncfs = function(populate, callback) {
|
||||
var cb = typeof callback==='function' ? callback : typeof populate==='function' ? populate : null;
|
||||
if (cb) setTimeout(function(){ cb(null); }, 0);
|
||||
};
|
||||
}],
|
||||
postRun: [function() {
|
||||
setTimeout(function() {
|
||||
p8_dropped_cart = 'data:application/octet-stream;base64,' + CART_B64;
|
||||
p8_dropped_cart_name = 'picograph.p8';
|
||||
codo_command = 9;
|
||||
|
||||
// After cart loads, inject "run" + Enter to execute it
|
||||
setTimeout(function() {
|
||||
// Push keystrokes: r u n Enter (lowercase ASCII)
|
||||
codo_key_buffer.push(114, 117, 110, 13);
|
||||
}, 300);
|
||||
}, 200);
|
||||
}],
|
||||
print: function(t) { console.log('P8:', t); },
|
||||
printErr: function(t) { console.error('P8:', t); },
|
||||
setStatus: function() {}
|
||||
};
|
||||
|
||||
function pico8_audio_context_suspended() { return false; }
|
||||
</script>
|
||||
<script src="./pico8_edu.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
25
public/pico8_edu.js
Normal file
25
public/pico8_edu.js
Normal file
File diff suppressed because one or more lines are too long
25
public/pico8_player.js
Normal file
25
public/pico8_player.js
Normal file
File diff suppressed because one or more lines are too long
55
public/player.html
Normal file
55
public/player.html
Normal file
@@ -0,0 +1,55 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PICO-8 Player</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
#canvas {
|
||||
image-rendering: pixelated;
|
||||
image-rendering: crisp-edges;
|
||||
background: #000;
|
||||
}
|
||||
#p8_container {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="p8_container">
|
||||
<canvas id="canvas"></canvas>
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
// Cart will be injected here
|
||||
window.PICO8_CART_DATA = null;
|
||||
</script>
|
||||
<script src="pico8_player.js"></script>
|
||||
<script>
|
||||
Module = {
|
||||
arguments: ['-run'],
|
||||
canvas: document.getElementById('canvas'),
|
||||
preRun: [],
|
||||
postRun: []
|
||||
};
|
||||
|
||||
// Wait for cart data to be set
|
||||
if (window.PICO8_CART_DATA) {
|
||||
// Create virtual file system entry
|
||||
Module.preRun.push(function() {
|
||||
FS.createDataFile('/', 'cart.p8', window.PICO8_CART_DATA, true, true, true);
|
||||
});
|
||||
Module.arguments.push('/cart.p8');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
55
scripts/ci/find-edu-js.js
Normal file
55
scripts/ci/find-edu-js.js
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Downloads the latest PICO-8 Education Edition player JS from pico-8-edu.com.
|
||||
* Run with: node scripts/ci/find-edu-js.js
|
||||
*/
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const INDEX_URL = 'https://www.pico-8-edu.com/';
|
||||
const PUBLIC_DIR = path.join(__dirname, '../../public');
|
||||
|
||||
function fetchText(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
https.get(url, res => {
|
||||
const chunks = [];
|
||||
res.on('data', d => chunks.push(d));
|
||||
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function fetchBinary(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
https.get(url, res => {
|
||||
const chunks = [];
|
||||
res.on('data', d => chunks.push(d));
|
||||
res.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Fetching index to find current edu player version...');
|
||||
const html = await fetchText(INDEX_URL);
|
||||
|
||||
const match = html.match(/['"]\/play\/(pico8_edu[^'"]+\.js)['"]/);
|
||||
if (!match) {
|
||||
console.error('Could not find edu player JS path in page HTML.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const jsPath = match[1];
|
||||
const jsUrl = `https://www.pico-8-edu.com/play/${jsPath}`;
|
||||
const outFile = path.join(PUBLIC_DIR, 'pico8_edu.js');
|
||||
|
||||
console.log(`Found: ${jsPath}`);
|
||||
console.log(`Downloading from ${jsUrl}...`);
|
||||
|
||||
const buf = await fetchBinary(jsUrl);
|
||||
fs.writeFileSync(outFile, buf);
|
||||
console.log(`Saved to public/pico8_edu.js (${buf.length} bytes)`);
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @property {string} id Unique pin identifier scoped to the node.
|
||||
* @property {string} name Public display name.
|
||||
* @property {('input'|'output')} direction Pin direction relative to the node.
|
||||
* @property {('exec'|'number'|'boolean'|'string'|'table'|'any')} kind Pin kind describing blueprint behavior.
|
||||
* @property {('exec'|'number'|'boolean'|'string'|'table'|'any'|'color')} kind Pin kind describing blueprint behavior.
|
||||
* @property {string} [description] Optional tooltip/description.
|
||||
* @property {unknown} [defaultValue] Default value for data pins.
|
||||
*/
|
||||
|
||||
@@ -20,7 +20,7 @@ export class Connection {
|
||||
/**
|
||||
* @param {PinReference} from Origin pin (must be output).
|
||||
* @param {PinReference} to Target pin (must be input).
|
||||
* @param {('exec'|'number'|'boolean'|'string'|'table'|'any')} kind Pin kind carried by the connection.
|
||||
* @param {('exec'|'number'|'boolean'|'string'|'table'|'any'|'color')} kind Pin kind carried by the connection.
|
||||
* @param {string} [id] Optional connection identifier.
|
||||
*/
|
||||
constructor(from, to, kind, id) {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { formatLiteral, defaultLiteralForKind } from './lua/LuaLiteralTypes.js';
|
||||
import { normalizeVariableType, getTypeConverter } from '../types/TypeRegistry.js';
|
||||
|
||||
/**
|
||||
* @typedef {import('./NodeGraph.js').NodeGraph} NodeGraph
|
||||
*/
|
||||
@@ -278,7 +281,7 @@ export class LuaGenerator {
|
||||
findExecTargets: (pinId) => this.#findExecTargets(node.id, pinId),
|
||||
sanitizeIdentifier: (value) => this.#sanitizeIdentifier(String(value)),
|
||||
sanitizeOperator: (value) => this.#sanitizeOperator(String(value)),
|
||||
formatLiteral: (kind, value) => this.#formatLiteral(kind, value),
|
||||
formatLiteral,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -338,7 +341,7 @@ export class LuaGenerator {
|
||||
this.#resolveValueInput(node, inputPinId, fallback),
|
||||
sanitizeIdentifier: (value) => this.#sanitizeIdentifier(String(value)),
|
||||
sanitizeOperator: (value) => this.#sanitizeOperator(String(value)),
|
||||
formatLiteral: (kind, value) => this.#formatLiteral(kind, value),
|
||||
formatLiteral,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -516,7 +519,7 @@ export class LuaGenerator {
|
||||
const argEntries = signature.map((entry) => {
|
||||
const fallback = entry.optional
|
||||
? "nil"
|
||||
: this.#defaultLiteralForKind(entry.kind);
|
||||
: defaultLiteralForKind(entry.kind);
|
||||
const value = this.#resolveValueInput(
|
||||
node,
|
||||
entry.inputPinId,
|
||||
@@ -602,6 +605,49 @@ export class LuaGenerator {
|
||||
.map((connection) => connection.to);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies Lua type conversion when source and target types differ.
|
||||
*
|
||||
* @param {string} expression Source expression to convert.
|
||||
* @param {string} fromType Source type identifier.
|
||||
* @param {string} toType Target type identifier.
|
||||
* @returns {string}
|
||||
*/
|
||||
#applyTypeConversion(expression, fromType, toType) {
|
||||
if (fromType === toType || toType === "any" || fromType === "any") {
|
||||
return expression;
|
||||
}
|
||||
|
||||
const converter = getTypeConverter(fromType, toType);
|
||||
if (!converter) {
|
||||
return expression;
|
||||
}
|
||||
|
||||
// Generate Lua code for type conversions
|
||||
if ((fromType === "number" || fromType === "color") && toType === "boolean") {
|
||||
return `(${expression}) ~= 0`;
|
||||
}
|
||||
|
||||
if (fromType === "boolean" && toType === "number") {
|
||||
return `((${expression}) and 1 or 0)`;
|
||||
}
|
||||
|
||||
if (fromType === "boolean" && toType === "color") {
|
||||
return `((${expression}) and 7 or 0)`;
|
||||
}
|
||||
|
||||
if (fromType === "boolean" && toType === "string") {
|
||||
return `((${expression}) and "true" or "false")`;
|
||||
}
|
||||
|
||||
if (fromType === "number" && toType === "color") {
|
||||
return `flr(${expression}) % 16`;
|
||||
}
|
||||
|
||||
// No conversion needed for color to number (colors are numbers in PICO-8)
|
||||
return expression;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the expression bound to an input pin.
|
||||
*
|
||||
@@ -613,7 +659,17 @@ export class LuaGenerator {
|
||||
#resolveValueInput(node, pinId, fallback) {
|
||||
const connection = this.#getConnectionTo(node.id, pinId);
|
||||
if (connection) {
|
||||
return this.#evaluateValue(connection.from.nodeId, connection.from.pinId);
|
||||
const sourceExpression = this.#evaluateValue(connection.from.nodeId, connection.from.pinId);
|
||||
const targetPin = this.#findPin(node, pinId);
|
||||
const targetType = targetPin?.kind ?? "any";
|
||||
|
||||
// Get the actual source type, handling special cases like get_var
|
||||
const sourceNode = this.nodes.get(connection.from.nodeId);
|
||||
const sourceType = sourceNode
|
||||
? this.#getOutputPinType(sourceNode, connection.from.pinId)
|
||||
: connection.kind ?? "any";
|
||||
|
||||
return this.#applyTypeConversion(sourceExpression, sourceType, targetType);
|
||||
}
|
||||
|
||||
const pin = this.#findPin(node, pinId);
|
||||
@@ -621,12 +677,12 @@ export class LuaGenerator {
|
||||
if (Object.prototype.hasOwnProperty.call(node.properties, inlineKey)) {
|
||||
const inlineValue = node.properties[inlineKey];
|
||||
if (inlineValue !== undefined && inlineValue !== null) {
|
||||
return this.#formatLiteral(pin?.kind ?? "any", inlineValue);
|
||||
return formatLiteral(pin?.kind ?? "any", inlineValue);
|
||||
}
|
||||
}
|
||||
|
||||
if (pin && pin.defaultValue !== undefined && pin.defaultValue !== null) {
|
||||
return this.#formatLiteral(pin.kind, pin.defaultValue);
|
||||
return formatLiteral(pin.kind, pin.defaultValue);
|
||||
}
|
||||
|
||||
return fallback;
|
||||
@@ -691,24 +747,6 @@ export class LuaGenerator {
|
||||
|
||||
if (expression === undefined) {
|
||||
switch (node.type) {
|
||||
case "number_literal":
|
||||
expression = this.#formatLiteral(
|
||||
"number",
|
||||
node.properties.value ?? 0
|
||||
);
|
||||
break;
|
||||
case "string_literal":
|
||||
expression = this.#formatLiteral(
|
||||
"string",
|
||||
node.properties.value ?? ""
|
||||
);
|
||||
break;
|
||||
case "boolean_literal":
|
||||
expression = this.#formatLiteral(
|
||||
"boolean",
|
||||
node.properties.value ?? "true"
|
||||
);
|
||||
break;
|
||||
case "get_var": {
|
||||
const name = this.#resolveWorkspaceVariableName(node);
|
||||
expression = name;
|
||||
@@ -721,18 +759,32 @@ export class LuaGenerator {
|
||||
expression = parameterName ?? "nil";
|
||||
break;
|
||||
}
|
||||
case "add_number": {
|
||||
case "add_number":
|
||||
case "math_add": {
|
||||
const a = this.#resolveValueInput(node, "a", "0");
|
||||
const b = this.#resolveValueInput(node, "b", "0");
|
||||
expression = `(${a}) + (${b})`;
|
||||
break;
|
||||
}
|
||||
case "multiply_number": {
|
||||
case "math_subtract": {
|
||||
const a = this.#resolveValueInput(node, "a", "0");
|
||||
const b = this.#resolveValueInput(node, "b", "0");
|
||||
expression = `(${a}) - (${b})`;
|
||||
break;
|
||||
}
|
||||
case "multiply_number":
|
||||
case "math_multiply": {
|
||||
const a = this.#resolveValueInput(node, "a", "1");
|
||||
const b = this.#resolveValueInput(node, "b", "1");
|
||||
expression = `(${a}) * (${b})`;
|
||||
break;
|
||||
}
|
||||
case "math_divide": {
|
||||
const a = this.#resolveValueInput(node, "a", "0");
|
||||
const b = this.#resolveValueInput(node, "b", "1");
|
||||
expression = `(${a}) / (${b})`;
|
||||
break;
|
||||
}
|
||||
case "compare": {
|
||||
const a = this.#resolveValueInput(node, "a", "0");
|
||||
const b = this.#resolveValueInput(node, "b", "0");
|
||||
@@ -788,60 +840,6 @@ export class LuaGenerator {
|
||||
return "==";
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a literal based on pin kind.
|
||||
*
|
||||
* @param {string} kind Pin kind.
|
||||
* @param {unknown} value Raw value.
|
||||
* @returns {string}
|
||||
*/
|
||||
#formatLiteral(kind, value) {
|
||||
switch (kind) {
|
||||
case "number": {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? String(numeric) : "0";
|
||||
}
|
||||
case "boolean": {
|
||||
if (typeof value === "string") {
|
||||
return value === "false" ? "false" : "true";
|
||||
}
|
||||
return value ? "true" : "false";
|
||||
}
|
||||
case "table": {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length ? trimmed : "{}";
|
||||
}
|
||||
return "{}";
|
||||
}
|
||||
case "string":
|
||||
return JSON.stringify(String(value ?? ""));
|
||||
default:
|
||||
return JSON.stringify(String(value ?? ""));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a default literal for the supplied pin kind.
|
||||
*
|
||||
* @param {string} kind Pin kind reference.
|
||||
* @returns {string}
|
||||
*/
|
||||
#defaultLiteralForKind(kind) {
|
||||
switch (kind) {
|
||||
case "number":
|
||||
return "0";
|
||||
case "boolean":
|
||||
return "false";
|
||||
case "string":
|
||||
return '""';
|
||||
case "table":
|
||||
return "{}";
|
||||
default:
|
||||
return "nil";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the display name for a custom event node.
|
||||
*
|
||||
@@ -1012,14 +1010,11 @@ export class LuaGenerator {
|
||||
const value = variable?.defaultValue;
|
||||
|
||||
switch (type) {
|
||||
case "number": {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? String(numeric) : "0";
|
||||
}
|
||||
case "number":
|
||||
case "boolean":
|
||||
return value ? "true" : "false";
|
||||
case "string":
|
||||
return JSON.stringify(String(value ?? ""));
|
||||
case "color":
|
||||
return formatLiteral(type, value);
|
||||
case "table": {
|
||||
if (Array.isArray(value)) {
|
||||
const fragments = value
|
||||
@@ -1137,6 +1132,38 @@ export class LuaGenerator {
|
||||
return this.#sanitizeIdentifier(fallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the type of a workspace variable by its ID.
|
||||
*
|
||||
* @param {string} variableId Variable identifier.
|
||||
* @returns {string}
|
||||
*/
|
||||
#getVariableType(variableId) {
|
||||
const variable = this.variables.find((v) => v.id === variableId);
|
||||
return variable?.type ?? "any";
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the output type for a node's output pin.
|
||||
*
|
||||
* @param {BlueprintNode} node Source node.
|
||||
* @param {string} pinId Output pin identifier.
|
||||
* @returns {string}
|
||||
*/
|
||||
#getOutputPinType(node, pinId) {
|
||||
// For get_var nodes, return the actual variable type
|
||||
if (node.type === "get_var") {
|
||||
const variableId = node.properties?.variableId;
|
||||
if (variableId) {
|
||||
return this.#getVariableType(variableId);
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, use the pin's declared kind
|
||||
const pin = this.#findPin(node, pinId);
|
||||
return pin?.kind ?? "any";
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a variable type descriptor.
|
||||
*
|
||||
@@ -1144,19 +1171,7 @@ export class LuaGenerator {
|
||||
* @returns {string}
|
||||
*/
|
||||
#normalizeVariableType(value) {
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
switch (normalized) {
|
||||
case "number":
|
||||
case "string":
|
||||
case "boolean":
|
||||
case "table":
|
||||
return normalized;
|
||||
default:
|
||||
return "any";
|
||||
}
|
||||
}
|
||||
return "any";
|
||||
return normalizeVariableType(value);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BlueprintNode } from "./BlueprintNode.js";
|
||||
import { Connection } from "./Connection.js";
|
||||
import { areTypesCompatible } from "../types/TypeRegistry.js";
|
||||
|
||||
/** @typedef {import('./BlueprintNode.js').BlueprintNodeInit} BlueprintNodeInit */
|
||||
/** @typedef {import('./BlueprintNode.js').PinDescriptor} PinDescriptor */
|
||||
@@ -321,11 +322,7 @@ export class NodeGraph extends EventTarget {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
toPin.kind !== "any" &&
|
||||
fromPin.kind !== "any" &&
|
||||
fromPin.kind !== toPin.kind
|
||||
) {
|
||||
if (!areTypesCompatible(fromPin.kind, toPin.kind)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
268
scripts/core/SpriteSheet.js
Normal file
268
scripts/core/SpriteSheet.js
Normal file
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Manages PICO-8 sprite sheet data with 256 8x8 sprites.
|
||||
* Total sheet size: 128x128 pixels, 16-color palette.
|
||||
*/
|
||||
export class SpriteSheet {
|
||||
/**
|
||||
* @param {Object} [options={}] Configuration options.
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
/** @type {Uint8Array} Pixel data (128x128, each byte is a palette index 0-15) */
|
||||
this.pixels = new Uint8Array(128 * 128);
|
||||
|
||||
/** @type {Uint8Array} Sprite flags (256 sprites, 8 flags each) */
|
||||
this.flags = new Uint8Array(256);
|
||||
|
||||
/** @type {Array<string>} PICO-8 default 16-color palette */
|
||||
this.palette = [
|
||||
"#000000", "#1D2B53", "#7E2553", "#008751",
|
||||
"#AB5236", "#5F574F", "#C2C3C7", "#FFF1E8",
|
||||
"#FF004D", "#FFA300", "#FFEC27", "#00E436",
|
||||
"#29ADFF", "#83769C", "#FF77A8", "#FFCCAA"
|
||||
];
|
||||
|
||||
this.#initialize(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes sprite sheet data.
|
||||
*
|
||||
* @param {Object} options Configuration options.
|
||||
*/
|
||||
#initialize(options) {
|
||||
if (options.pixels instanceof Uint8Array && options.pixels.length === 128 * 128) {
|
||||
this.pixels.set(options.pixels);
|
||||
}
|
||||
|
||||
if (options.flags instanceof Uint8Array && options.flags.length === 256) {
|
||||
this.flags.set(options.flags);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets pixel color at coordinates.
|
||||
*
|
||||
* @param {number} x X coordinate (0-127).
|
||||
* @param {number} y Y coordinate (0-127).
|
||||
* @returns {number} Palette index (0-15).
|
||||
*/
|
||||
getPixel(x, y) {
|
||||
if (x < 0 || x >= 128 || y < 0 || y >= 128) return 0;
|
||||
return this.pixels[y * 128 + x];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets pixel color at coordinates.
|
||||
*
|
||||
* @param {number} x X coordinate (0-127).
|
||||
* @param {number} y Y coordinate (0-127).
|
||||
* @param {number} color Palette index (0-15).
|
||||
*/
|
||||
setPixel(x, y, color) {
|
||||
if (x < 0 || x >= 128 || y < 0 || y >= 128) return;
|
||||
this.pixels[y * 128 + x] = Math.max(0, Math.min(15, color));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all pixels for a specific sprite.
|
||||
*
|
||||
* @param {number} spriteIndex Sprite number (0-255).
|
||||
* @returns {Uint8Array} 8x8 pixel array.
|
||||
*/
|
||||
getSprite(spriteIndex) {
|
||||
const spriteData = new Uint8Array(8 * 8);
|
||||
const sx = (spriteIndex % 16) * 8;
|
||||
const sy = Math.floor(spriteIndex / 16) * 8;
|
||||
|
||||
for (let y = 0; y < 8; y++) {
|
||||
for (let x = 0; x < 8; x++) {
|
||||
spriteData[y * 8 + x] = this.getPixel(sx + x, sy + y);
|
||||
}
|
||||
}
|
||||
|
||||
return spriteData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets all pixels for a specific sprite.
|
||||
*
|
||||
* @param {number} spriteIndex Sprite number (0-255).
|
||||
* @param {Uint8Array} data 8x8 pixel array.
|
||||
*/
|
||||
setSprite(spriteIndex, data) {
|
||||
if (data.length !== 64) return;
|
||||
|
||||
const sx = (spriteIndex % 16) * 8;
|
||||
const sy = Math.floor(spriteIndex / 16) * 8;
|
||||
|
||||
for (let y = 0; y < 8; y++) {
|
||||
for (let x = 0; x < 8; x++) {
|
||||
this.setPixel(sx + x, sy + y, data[y * 8 + x]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets flags for a sprite.
|
||||
*
|
||||
* @param {number} spriteIndex Sprite number (0-255).
|
||||
* @returns {number} 8-bit flag value.
|
||||
*/
|
||||
getFlags(spriteIndex) {
|
||||
if (spriteIndex < 0 || spriteIndex >= 256) return 0;
|
||||
return this.flags[spriteIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets flags for a sprite.
|
||||
*
|
||||
* @param {number} spriteIndex Sprite number (0-255).
|
||||
* @param {number} flagValue 8-bit flag value.
|
||||
*/
|
||||
setFlags(spriteIndex, flagValue) {
|
||||
if (spriteIndex < 0 || spriteIndex >= 256) return;
|
||||
this.flags[spriteIndex] = flagValue & 0xFF;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a specific flag is set.
|
||||
*
|
||||
* @param {number} spriteIndex Sprite number (0-255).
|
||||
* @param {number} flagBit Flag bit (0-7).
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasFlag(spriteIndex, flagBit) {
|
||||
if (flagBit < 0 || flagBit > 7) return false;
|
||||
return (this.getFlags(spriteIndex) & (1 << flagBit)) !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a specific flag.
|
||||
*
|
||||
* @param {number} spriteIndex Sprite number (0-255).
|
||||
* @param {number} flagBit Flag bit (0-7).
|
||||
* @param {boolean} value Flag state.
|
||||
*/
|
||||
setFlag(spriteIndex, flagBit, value) {
|
||||
if (flagBit < 0 || flagBit > 7) return;
|
||||
|
||||
const currentFlags = this.getFlags(spriteIndex);
|
||||
const mask = 1 << flagBit;
|
||||
|
||||
if (value) {
|
||||
this.setFlags(spriteIndex, currentFlags | mask);
|
||||
} else {
|
||||
this.setFlags(spriteIndex, currentFlags & ~mask);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all sprite data.
|
||||
*/
|
||||
clear() {
|
||||
this.pixels.fill(0);
|
||||
this.flags.fill(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes sprite sheet to JSON.
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
toJSON() {
|
||||
return {
|
||||
pixels: Array.from(this.pixels),
|
||||
flags: Array.from(this.flags)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates sprite sheet from JSON data.
|
||||
*
|
||||
* @param {Object} data Serialized sprite data.
|
||||
* @returns {SpriteSheet}
|
||||
*/
|
||||
static fromJSON(data) {
|
||||
return new SpriteSheet({
|
||||
pixels: new Uint8Array(data.pixels || []),
|
||||
flags: new Uint8Array(data.flags || [])
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports to PICO-8 .p8 format gfx section.
|
||||
*
|
||||
* @returns {string} Hex string for __gfx__ section.
|
||||
*/
|
||||
toP8Gfx() {
|
||||
const lines = [];
|
||||
|
||||
for (let row = 0; row < 128; row++) {
|
||||
let line = "";
|
||||
for (let col = 0; col < 128; col += 2) {
|
||||
const p1 = this.getPixel(col, row);
|
||||
const p2 = this.getPixel(col + 1, row);
|
||||
line += p1.toString(16) + p2.toString(16);
|
||||
}
|
||||
lines.push(line);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports from PICO-8 .p8 format gfx section.
|
||||
*
|
||||
* @param {string} gfxData Hex string from __gfx__ section.
|
||||
*/
|
||||
fromP8Gfx(gfxData) {
|
||||
const lines = gfxData.trim().split("\n");
|
||||
|
||||
for (let row = 0; row < 128 && row < lines.length; row++) {
|
||||
const line = lines[row].trim();
|
||||
for (let col = 0; col < 128; col += 2) {
|
||||
const hexPair = line.substring(col, col + 2);
|
||||
if (hexPair.length === 2) {
|
||||
this.setPixel(col, row, parseInt(hexPair[0], 16));
|
||||
this.setPixel(col + 1, row, parseInt(hexPair[1], 16));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports sprite flags to PICO-8 .p8 format.
|
||||
*
|
||||
* @returns {string} Hex string for __gff__ section.
|
||||
*/
|
||||
toP8Flags() {
|
||||
const lines = [];
|
||||
|
||||
for (let i = 0; i < 256; i += 2) {
|
||||
const f1 = this.getFlags(i).toString(16).padStart(2, "0");
|
||||
const f2 = this.getFlags(i + 1).toString(16).padStart(2, "0");
|
||||
lines.push(f1 + f2);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports sprite flags from PICO-8 .p8 format.
|
||||
*
|
||||
* @param {string} flagData Hex string from __gff__ section.
|
||||
*/
|
||||
fromP8Flags(flagData) {
|
||||
const lines = flagData.trim().split("\n");
|
||||
|
||||
for (let i = 0; i < 128 && i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (line.length >= 2) {
|
||||
this.setFlags(i * 2, parseInt(line.substring(0, 2), 16));
|
||||
}
|
||||
if (line.length >= 4) {
|
||||
this.setFlags(i * 2 + 1, parseInt(line.substring(2, 4), 16));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
167
scripts/core/TileSet.js
Normal file
167
scripts/core/TileSet.js
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Represents a single tileset with 8x8 tiles (128x128px total, 16x16 grid).
|
||||
*/
|
||||
export class TileSet {
|
||||
/**
|
||||
* @param {Object} options Configuration options.
|
||||
* @param {string} options.id Unique identifier.
|
||||
* @param {string} options.name Display name.
|
||||
* @param {string} options.filePath Path or URL to PNG file (file:// or blob:).
|
||||
* @param {string} [options.nativePath] Native file system path (for Electron).
|
||||
* @param {number} [options.tileWidth=8] Tile width in pixels.
|
||||
* @param {number} [options.tileHeight=8] Tile height in pixels.
|
||||
* @param {number} [options.columns=16] Number of tiles per row (128px / 8px = 16).
|
||||
* @param {number} [options.rows=16] Number of tile rows (128px / 8px = 16).
|
||||
*/
|
||||
constructor(options) {
|
||||
this.id = options.id || this.#generateId();
|
||||
this.name = options.name || "Untitled Tileset";
|
||||
this.filePath = options.filePath || "";
|
||||
this.nativePath = options.nativePath || "";
|
||||
this.tileWidth = options.tileWidth || 8;
|
||||
this.tileHeight = options.tileHeight || 8;
|
||||
this.columns = options.columns || 16;
|
||||
this.rows = options.rows || 16;
|
||||
|
||||
/** @type {HTMLImageElement | null} */
|
||||
this.image = null;
|
||||
|
||||
/** @type {boolean} */
|
||||
this.isLoaded = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique identifier.
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
#generateId() {
|
||||
return `tileset_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the total number of tiles.
|
||||
*
|
||||
* @returns {number}
|
||||
*/
|
||||
getTileCount() {
|
||||
return this.columns * this.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the total width of the tileset image.
|
||||
*
|
||||
* @returns {number}
|
||||
*/
|
||||
getWidth() {
|
||||
return this.columns * this.tileWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the total height of the tileset image.
|
||||
*
|
||||
* @returns {number}
|
||||
*/
|
||||
getHeight() {
|
||||
return this.rows * this.tileHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the tileset image from file path.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async load() {
|
||||
if (!this.filePath) {
|
||||
throw new Error("No file path specified");
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
// Validate image is exactly 128x128px
|
||||
if (img.width !== 128 || img.height !== 128) {
|
||||
reject(new Error(`Tileset must be exactly 128x128px (found ${img.width}x${img.height}px)`));
|
||||
return;
|
||||
}
|
||||
|
||||
this.image = img;
|
||||
this.isLoaded = true;
|
||||
|
||||
// Fixed grid: 16x16 tiles for 128x128 image with 8x8 tiles
|
||||
this.columns = 16;
|
||||
this.rows = 16;
|
||||
|
||||
resolve();
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
reject(new Error(`Failed to load image: ${this.filePath}`));
|
||||
};
|
||||
|
||||
// Add cache-busting for file:// URLs to ensure reload works
|
||||
const cacheBuster = this.filePath.startsWith("file://")
|
||||
? `?t=${Date.now()}`
|
||||
: "";
|
||||
img.src = this.filePath + cacheBuster;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads the tileset image.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async reload() {
|
||||
this.isLoaded = false;
|
||||
this.image = null;
|
||||
return this.load();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets tile coordinates for a specific tile index.
|
||||
*
|
||||
* @param {number} tileIndex Tile index (0-based).
|
||||
* @returns {{x: number, y: number, width: number, height: number}}
|
||||
*/
|
||||
getTileRect(tileIndex) {
|
||||
const col = tileIndex % this.columns;
|
||||
const row = Math.floor(tileIndex / this.columns);
|
||||
|
||||
return {
|
||||
x: col * this.tileWidth,
|
||||
y: row * this.tileHeight,
|
||||
width: this.tileWidth,
|
||||
height: this.tileHeight
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes tileset to JSON.
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
filePath: this.filePath,
|
||||
nativePath: this.nativePath,
|
||||
tileWidth: this.tileWidth,
|
||||
tileHeight: this.tileHeight,
|
||||
columns: this.columns,
|
||||
rows: this.rows
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates tileset from JSON data.
|
||||
*
|
||||
* @param {Object} data Serialized tileset.
|
||||
* @returns {TileSet}
|
||||
*/
|
||||
static fromJSON(data) {
|
||||
return new TileSet(data);
|
||||
}
|
||||
}
|
||||
41
scripts/core/lua/LuaLiteralBoolean.js
Normal file
41
scripts/core/lua/LuaLiteralBoolean.js
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @typedef {import('./LuaLiteralTypes.js').LuaLiteralHandler} LuaLiteralHandler
|
||||
*/
|
||||
|
||||
/** @type {LuaLiteralHandler} */
|
||||
export const LuaLiteralBoolean = {
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {string}
|
||||
*/
|
||||
format(value) {
|
||||
if (typeof value === "string") {
|
||||
return value === "false" ? "false" : "true";
|
||||
}
|
||||
return value ? "true" : "false";
|
||||
},
|
||||
|
||||
/** @returns {string} */
|
||||
defaultLiteral() {
|
||||
return "false";
|
||||
},
|
||||
|
||||
spawnableByDefault: true,
|
||||
|
||||
nodeModule: {
|
||||
definition: {
|
||||
id: "boolean_literal",
|
||||
title: "Boolean",
|
||||
category: "Values",
|
||||
description: "Constant boolean literal.",
|
||||
searchTags: ["boolean", "literal", "true", "false"],
|
||||
inputs: [],
|
||||
outputs: [{ id: "value", name: "Value", direction: "output", kind: "boolean" }],
|
||||
properties: [{ key: "value", label: "Value", type: "boolean", defaultValue: true }],
|
||||
},
|
||||
behavior: {
|
||||
evaluateValue: ({ node, formatLiteral }) =>
|
||||
formatLiteral("boolean", node.properties.value ?? true),
|
||||
},
|
||||
},
|
||||
};
|
||||
39
scripts/core/lua/LuaLiteralColor.js
Normal file
39
scripts/core/lua/LuaLiteralColor.js
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* @typedef {import('./LuaLiteralTypes.js').LuaLiteralHandler} LuaLiteralHandler
|
||||
*/
|
||||
|
||||
/** @type {LuaLiteralHandler} */
|
||||
export const LuaLiteralColor = {
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {string}
|
||||
*/
|
||||
format(value) {
|
||||
const n = Math.max(0, Math.min(15, Math.floor(Number(value) || 0)));
|
||||
return String(n);
|
||||
},
|
||||
|
||||
/** @returns {string} */
|
||||
defaultLiteral() {
|
||||
return "7";
|
||||
},
|
||||
|
||||
spawnableByDefault: true,
|
||||
|
||||
nodeModule: {
|
||||
definition: {
|
||||
id: "color_literal",
|
||||
title: "Color",
|
||||
category: "Values",
|
||||
description: "Constant color literal (0-15).",
|
||||
searchTags: ["color", "literal", "constant", "value"],
|
||||
inputs: [],
|
||||
outputs: [{ id: "value", name: "Value", direction: "output", kind: "color" }],
|
||||
properties: [{ key: "value", label: "Color", type: "number", defaultValue: 7 }],
|
||||
},
|
||||
behavior: {
|
||||
evaluateValue: ({ node, formatLiteral }) =>
|
||||
formatLiteral("color", node.properties.value ?? 7),
|
||||
},
|
||||
},
|
||||
};
|
||||
39
scripts/core/lua/LuaLiteralNumber.js
Normal file
39
scripts/core/lua/LuaLiteralNumber.js
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* @typedef {import('./LuaLiteralTypes.js').LuaLiteralHandler} LuaLiteralHandler
|
||||
*/
|
||||
|
||||
/** @type {LuaLiteralHandler} */
|
||||
export const LuaLiteralNumber = {
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {string}
|
||||
*/
|
||||
format(value) {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? String(numeric) : "0";
|
||||
},
|
||||
|
||||
/** @returns {string} */
|
||||
defaultLiteral() {
|
||||
return "0";
|
||||
},
|
||||
|
||||
spawnableByDefault: true,
|
||||
|
||||
nodeModule: {
|
||||
definition: {
|
||||
id: "number_literal",
|
||||
title: "Number",
|
||||
category: "Values",
|
||||
description: "Constant numeric literal.",
|
||||
searchTags: ["number", "literal", "constant", "value"],
|
||||
inputs: [],
|
||||
outputs: [{ id: "value", name: "Value", direction: "output", kind: "number" }],
|
||||
properties: [{ key: "value", label: "Number", type: "number", defaultValue: 0 }],
|
||||
},
|
||||
behavior: {
|
||||
evaluateValue: ({ node, formatLiteral }) =>
|
||||
formatLiteral("number", node.properties.value ?? 0),
|
||||
},
|
||||
},
|
||||
};
|
||||
38
scripts/core/lua/LuaLiteralString.js
Normal file
38
scripts/core/lua/LuaLiteralString.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @typedef {import('./LuaLiteralTypes.js').LuaLiteralHandler} LuaLiteralHandler
|
||||
*/
|
||||
|
||||
/** @type {LuaLiteralHandler} */
|
||||
export const LuaLiteralString = {
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {string}
|
||||
*/
|
||||
format(value) {
|
||||
return JSON.stringify(String(value ?? ""));
|
||||
},
|
||||
|
||||
/** @returns {string} */
|
||||
defaultLiteral() {
|
||||
return '""';
|
||||
},
|
||||
|
||||
spawnableByDefault: true,
|
||||
|
||||
nodeModule: {
|
||||
definition: {
|
||||
id: "string_literal",
|
||||
title: "String",
|
||||
category: "Values",
|
||||
description: "Constant string literal.",
|
||||
searchTags: ["string", "literal", "text", "value"],
|
||||
inputs: [],
|
||||
outputs: [{ id: "value", name: "Value", direction: "output", kind: "string" }],
|
||||
properties: [{ key: "value", label: "Text", type: "string", defaultValue: "hello" }],
|
||||
},
|
||||
behavior: {
|
||||
evaluateValue: ({ node, formatLiteral }) =>
|
||||
formatLiteral("string", node.properties.value ?? ""),
|
||||
},
|
||||
},
|
||||
};
|
||||
27
scripts/core/lua/LuaLiteralTable.js
Normal file
27
scripts/core/lua/LuaLiteralTable.js
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* @typedef {import('./LuaLiteralTypes.js').LuaLiteralHandler} LuaLiteralHandler
|
||||
*/
|
||||
|
||||
/** @type {LuaLiteralHandler} */
|
||||
export const LuaLiteralTable = {
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {string}
|
||||
*/
|
||||
format(value) {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length ? trimmed : "{}";
|
||||
}
|
||||
return "{}";
|
||||
},
|
||||
|
||||
/** @returns {string} */
|
||||
defaultLiteral() {
|
||||
return "{}";
|
||||
},
|
||||
|
||||
spawnableByDefault: false,
|
||||
|
||||
nodeModule: null,
|
||||
};
|
||||
56
scripts/core/lua/LuaLiteralTypes.js
Normal file
56
scripts/core/lua/LuaLiteralTypes.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { LuaLiteralNumber } from "./LuaLiteralNumber.js";
|
||||
import { LuaLiteralBoolean } from "./LuaLiteralBoolean.js";
|
||||
import { LuaLiteralString } from "./LuaLiteralString.js";
|
||||
import { LuaLiteralTable } from "./LuaLiteralTable.js";
|
||||
import { LuaLiteralColor } from "./LuaLiteralColor.js";
|
||||
|
||||
/**
|
||||
* @typedef {Object} LuaLiteralHandler
|
||||
* @property {(value: unknown) => string} format Formats a raw value into a Lua literal string.
|
||||
* @property {() => string} defaultLiteral Returns the default Lua literal string for this type.
|
||||
* @property {boolean} [spawnableByDefault] Whether this type creates a spawnable literal node in the graph.
|
||||
* @property {import('../../nodes/nodeTypes.js').NodeModule | null} [nodeModule] Node module definition, or null if no literal node exists.
|
||||
*/
|
||||
|
||||
/** @type {Map<string, LuaLiteralHandler>} */
|
||||
const HANDLERS = new Map([
|
||||
["number", LuaLiteralNumber],
|
||||
["boolean", LuaLiteralBoolean],
|
||||
["string", LuaLiteralString],
|
||||
["table", LuaLiteralTable],
|
||||
["color", LuaLiteralColor],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Formats a raw value into a Lua literal string for the given pin kind.
|
||||
*
|
||||
* @param {string} kind Pin kind.
|
||||
* @param {unknown} value Raw value.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatLiteral(kind, value) {
|
||||
const handler = HANDLERS.get(kind);
|
||||
return handler ? handler.format(value) : LuaLiteralString.format(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the default Lua literal string for the given pin kind.
|
||||
*
|
||||
* @param {string} kind Pin kind.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function defaultLiteralForKind(kind) {
|
||||
const handler = HANDLERS.get(kind);
|
||||
return handler ? handler.defaultLiteral() : "nil";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all node modules defined by literal handlers that have a nodeModule.
|
||||
*
|
||||
* @returns {Array<import('../../nodes/nodeTypes.js').NodeModule>}
|
||||
*/
|
||||
export function getLiteralNodeModules() {
|
||||
return [...HANDLERS.values()]
|
||||
.map((handler) => handler.nodeModule)
|
||||
.filter(Boolean);
|
||||
}
|
||||
271
scripts/main.js
271
scripts/main.js
@@ -1,9 +1,31 @@
|
||||
import { BlueprintWorkspace } from "./ui/BlueprintWorkspace.js";
|
||||
import { NodeRegistry } from "./nodes/NodeRegistry.js";
|
||||
import { LuaGenerator } from "./core/LuaGenerator.js";
|
||||
import { EmbeddedPreview } from "./ui/EmbeddedPreview.js";
|
||||
import { TileManager } from "./ui/TileManager.js";
|
||||
|
||||
// Global error handlers
|
||||
window.addEventListener('error', (event) => {
|
||||
console.error('Global error:', event.error);
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
console.error('Unhandled promise rejection:', event.reason);
|
||||
});
|
||||
|
||||
/**
|
||||
* @typedef {{ compileLua(source: string): Promise<{ filePath: string }> }} ElectronAPI
|
||||
* @typedef {{
|
||||
* compileLua(source: string): Promise<{ filePath: string }>;
|
||||
* previewCart?(cartContent: string): Promise<{ filePath: string }>;
|
||||
* writePlayerHtml?(htmlContent: string): Promise<{ filePath: string }>;
|
||||
* writePlayerCart?(cartContent: string): Promise<{ filePath: string }>;
|
||||
* getCartStats?(cartContent: string): Promise<{ tokens: number, lines: number, chars: number, rawOutput: string }>;
|
||||
* minimizeWindow?(): Promise<void>;
|
||||
* toggleMaximizeWindow?(): Promise<boolean>;
|
||||
* closeWindow?(): Promise<void>;
|
||||
* getWindowMaximized?(): Promise<boolean>;
|
||||
* onWindowMaximizedChanged?(callback: (isMaximized: boolean) => void): () => void;
|
||||
* }} ElectronAPI
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -70,8 +92,11 @@ const deleteNodeButton = /** @type {HTMLButtonElement} */ (
|
||||
"deleteNodeButton"
|
||||
)
|
||||
);
|
||||
const compileButton = /** @type {HTMLButtonElement} */ (
|
||||
requireElement(document.getElementById("compileButton"), "compileButton")
|
||||
const saveButton = /** @type {HTMLButtonElement} */ (
|
||||
requireElement(document.getElementById("saveButton"), "saveButton")
|
||||
);
|
||||
const previewButton = /** @type {HTMLButtonElement} */ (
|
||||
requireElement(document.getElementById("previewButton"), "previewButton")
|
||||
);
|
||||
const exportLuaButton = /** @type {HTMLButtonElement} */ (
|
||||
requireElement(document.getElementById("exportLuaButton"), "exportLuaButton")
|
||||
@@ -94,6 +119,24 @@ const frameSelectionButton = /** @type {HTMLButtonElement} */ (
|
||||
"frameSelectionButton"
|
||||
)
|
||||
);
|
||||
const windowControls = /** @type {HTMLElement} */ (
|
||||
requireElement(document.getElementById("windowControls"), "windowControls")
|
||||
);
|
||||
const minimizeWindowButton = /** @type {HTMLButtonElement} */ (
|
||||
requireElement(
|
||||
document.getElementById("minimizeWindowButton"),
|
||||
"minimizeWindowButton"
|
||||
)
|
||||
);
|
||||
const maximizeWindowButton = /** @type {HTMLButtonElement} */ (
|
||||
requireElement(
|
||||
document.getElementById("maximizeWindowButton"),
|
||||
"maximizeWindowButton"
|
||||
)
|
||||
);
|
||||
const closeWindowButton = /** @type {HTMLButtonElement} */ (
|
||||
requireElement(document.getElementById("closeWindowButton"), "closeWindowButton")
|
||||
);
|
||||
const luaModal = /** @type {HTMLElement} */ (
|
||||
requireElement(document.getElementById("luaModal"), "luaModal")
|
||||
);
|
||||
@@ -115,6 +158,12 @@ const closeLuaModalButton = /** @type {HTMLButtonElement} */ (
|
||||
"closeLuaModalButton"
|
||||
)
|
||||
);
|
||||
const tileManagerButton = /** @type {HTMLButtonElement} */ (
|
||||
requireElement(
|
||||
document.getElementById("tileManagerButton"),
|
||||
"tileManagerButton"
|
||||
)
|
||||
);
|
||||
|
||||
const registry = new NodeRegistry();
|
||||
const generator = new LuaGenerator(registry);
|
||||
@@ -133,6 +182,7 @@ const workspace = new BlueprintWorkspace({
|
||||
eventList,
|
||||
variableList,
|
||||
addVariableButton,
|
||||
saveButton,
|
||||
projectSettingsButton,
|
||||
paletteToggleButton,
|
||||
appBodyElement,
|
||||
@@ -141,12 +191,106 @@ const workspace = new BlueprintWorkspace({
|
||||
|
||||
workspace.initialize();
|
||||
|
||||
// Initialize preview system
|
||||
const embeddedPreview = new EmbeddedPreview({
|
||||
getLuaCode: () => {
|
||||
return workspace.exportLua();
|
||||
},
|
||||
});
|
||||
|
||||
// Initialize tile manager
|
||||
const tileManager = new TileManager();
|
||||
tileManager.initialize();
|
||||
|
||||
/** @type {ElectronAPI | undefined} */
|
||||
const electronAPI =
|
||||
typeof window !== "undefined" && "electronAPI" in window
|
||||
? /** @type {ElectronAPI} */ (window.electronAPI)
|
||||
: undefined;
|
||||
|
||||
// Get titlebar element
|
||||
const titleBarElement = document.querySelector('.header-title');
|
||||
|
||||
/**
|
||||
* Updates the window titlebar with token count information.
|
||||
*
|
||||
* @param {number} tokenCount Number of tokens in the cart
|
||||
*/
|
||||
function updateTitleBarWithTokens(tokenCount) {
|
||||
if (titleBarElement) {
|
||||
const maxTokens = 8192;
|
||||
const percentage = ((tokenCount / maxTokens) * 100).toFixed(1);
|
||||
titleBarElement.textContent = `picoGraph - ${tokenCount} / ${maxTokens} tokens (${percentage}%)`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates cart content from Lua code.
|
||||
*
|
||||
* @param {string} luaCode Generated Lua code
|
||||
* @returns {string}
|
||||
*/
|
||||
function generateCartContent(luaCode) {
|
||||
const hasUpdateCallback = /function\s+_update(?:60)?\b|_update(?:60)?\s*=/.test(luaCode);
|
||||
const hasDrawCallback = /function\s+_draw\b|_draw\s*=/.test(luaCode);
|
||||
const fallback = (hasUpdateCallback && hasDrawCallback) ? '' :
|
||||
(!hasUpdateCallback ? '\nfunction _update()\nend\n' : '') +
|
||||
(!hasDrawCallback ? '\nfunction _draw()\n cls()\n print("picograph ok",2,60,7)\nend\n' : '');
|
||||
return `pico-8 cartridge // http://www.pico-8.com
|
||||
version 41
|
||||
__lua__
|
||||
${luaCode}${fallback}
|
||||
__gfx__
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates token count display after workspace changes.
|
||||
*/
|
||||
async function updateTokenCount() {
|
||||
if (!electronAPI?.getCartStats) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const luaCode = workspace.exportLua();
|
||||
let cartContent = generateCartContent(luaCode);
|
||||
// Ensure Unix line endings (p8tool requires \n, not \r\n)
|
||||
cartContent = cartContent.replace(/\r\n/g, '\n');
|
||||
const stats = await electronAPI.getCartStats(cartContent);
|
||||
updateTitleBarWithTokens(stats.tokens);
|
||||
} catch (error) {
|
||||
console.error("Failed to update token count:", error);
|
||||
if (titleBarElement) {
|
||||
titleBarElement.textContent = "picoGraph";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Override handlePersistenceStateChange to also update token count
|
||||
const originalHandlePersistenceStateChange = workspace.handlePersistenceStateChange.bind(workspace);
|
||||
workspace.handlePersistenceStateChange = function(state) {
|
||||
originalHandlePersistenceStateChange(state);
|
||||
|
||||
// Update token count when save completes (both auto and manual)
|
||||
if (state?.status === "saved") {
|
||||
updateTokenCount();
|
||||
}
|
||||
};
|
||||
|
||||
// Initial token count update after workspace loads
|
||||
if (electronAPI?.getCartStats) {
|
||||
updateTokenCount();
|
||||
}
|
||||
|
||||
document.addEventListener("contextmenu", (event) => {
|
||||
event.preventDefault();
|
||||
});
|
||||
@@ -212,7 +356,6 @@ let isLuaModalOpen = false;
|
||||
let lastFocusedElement = /** @type {HTMLElement | null} */ (null);
|
||||
let copyFeedbackTimeout = /** @type {number | null} */ (null);
|
||||
let currentLuaSource = "";
|
||||
let compileFeedbackTimeout = /** @type {number | null} */ (null);
|
||||
|
||||
const scheduleMicrotask = (callback) => {
|
||||
if (typeof queueMicrotask === "function") {
|
||||
@@ -354,77 +497,67 @@ const closeLuaModal = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Restores the compile button to its idle appearance.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
const resetCompileButton = () => {
|
||||
if (compileFeedbackTimeout !== null) {
|
||||
window.clearTimeout(compileFeedbackTimeout);
|
||||
compileFeedbackTimeout = null;
|
||||
}
|
||||
previewButton.addEventListener("click", () => {
|
||||
embeddedPreview.open();
|
||||
});
|
||||
|
||||
compileButton.classList.remove("is-success", "is-error", "is-busy");
|
||||
compileButton.disabled = false;
|
||||
compileButton.textContent = "Compile";
|
||||
const setMaximizeButtonState = (isMaximized) => {
|
||||
maximizeWindowButton.classList.toggle("is-active", Boolean(isMaximized));
|
||||
maximizeWindowButton.setAttribute(
|
||||
"title",
|
||||
isMaximized ? "Restore" : "Maximize"
|
||||
);
|
||||
maximizeWindowButton.setAttribute(
|
||||
"aria-label",
|
||||
isMaximized ? "Restore window" : "Maximize window"
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles compile button clicks by requesting a Lua export from Electron.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const handleCompileClick = async () => {
|
||||
if (!electronAPI) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (compileFeedbackTimeout !== null) {
|
||||
window.clearTimeout(compileFeedbackTimeout);
|
||||
compileFeedbackTimeout = null;
|
||||
}
|
||||
|
||||
compileButton.classList.remove("is-success", "is-error");
|
||||
compileButton.classList.add("is-busy");
|
||||
compileButton.disabled = true;
|
||||
compileButton.textContent = "Compiling...";
|
||||
|
||||
const source = workspace.exportLua();
|
||||
|
||||
try {
|
||||
const result = await electronAPI.compileLua(source);
|
||||
console.info("Lua compiled to", result.filePath);
|
||||
compileButton.classList.remove("is-busy");
|
||||
compileButton.classList.add("is-success");
|
||||
compileButton.disabled = false;
|
||||
compileButton.textContent = "Compiled!";
|
||||
compileFeedbackTimeout = window.setTimeout(() => {
|
||||
resetCompileButton();
|
||||
}, 1800);
|
||||
} catch (error) {
|
||||
console.error("Failed to compile Lua", error);
|
||||
compileButton.classList.remove("is-busy");
|
||||
compileButton.classList.add("is-error");
|
||||
compileButton.disabled = false;
|
||||
compileButton.textContent = "Failed";
|
||||
compileFeedbackTimeout = window.setTimeout(() => {
|
||||
resetCompileButton();
|
||||
}, 2200);
|
||||
}
|
||||
};
|
||||
|
||||
if (electronAPI && typeof electronAPI.compileLua === "function") {
|
||||
compileButton.disabled = false;
|
||||
compileButton.addEventListener("click", () => {
|
||||
handleCompileClick().catch((error) => {
|
||||
console.error("Unhandled compile error", error);
|
||||
resetCompileButton();
|
||||
if (
|
||||
electronAPI &&
|
||||
typeof electronAPI.minimizeWindow === "function" &&
|
||||
typeof electronAPI.toggleMaximizeWindow === "function" &&
|
||||
typeof electronAPI.closeWindow === "function"
|
||||
) {
|
||||
minimizeWindowButton.addEventListener("click", () => {
|
||||
electronAPI.minimizeWindow().catch((error) => {
|
||||
console.error("Failed to minimize window", error);
|
||||
});
|
||||
});
|
||||
|
||||
maximizeWindowButton.addEventListener("click", () => {
|
||||
electronAPI.toggleMaximizeWindow()
|
||||
.then((isMaximized) => {
|
||||
setMaximizeButtonState(isMaximized);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to toggle maximize state", error);
|
||||
});
|
||||
});
|
||||
|
||||
closeWindowButton.addEventListener("click", () => {
|
||||
electronAPI.closeWindow().catch((error) => {
|
||||
console.error("Failed to close window", error);
|
||||
});
|
||||
});
|
||||
|
||||
if (typeof electronAPI.getWindowMaximized === "function") {
|
||||
electronAPI.getWindowMaximized()
|
||||
.then((isMaximized) => {
|
||||
setMaximizeButtonState(isMaximized);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to query maximize state", error);
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof electronAPI.onWindowMaximizedChanged === "function") {
|
||||
electronAPI.onWindowMaximizedChanged((isMaximized) => {
|
||||
setMaximizeButtonState(isMaximized);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
compileButton.disabled = true;
|
||||
compileButton.title = "Compile is available in the desktop app";
|
||||
windowControls.hidden = true;
|
||||
}
|
||||
|
||||
exportLuaButton.addEventListener("click", () => {
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { createNodeModule } from "../nodeTypes.js";
|
||||
|
||||
/**
|
||||
* Constant boolean literal.
|
||||
*/
|
||||
export const booleanLiteralNode = createNodeModule(
|
||||
{
|
||||
id: "boolean_literal",
|
||||
title: "Boolean",
|
||||
category: "Values",
|
||||
description: "Constant boolean literal.",
|
||||
searchTags: ["boolean", "literal", "true", "false"],
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ id: "value", name: "Value", direction: "output", kind: "boolean" },
|
||||
],
|
||||
properties: [
|
||||
{ key: "value", label: "Value", type: "boolean", defaultValue: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
evaluateValue: ({ node, formatLiteral }) => {
|
||||
return formatLiteral("boolean", node.properties.value ?? true);
|
||||
},
|
||||
}
|
||||
);
|
||||
26
scripts/nodes/library/converters/colorToNumber.js
Normal file
26
scripts/nodes/library/converters/colorToNumber.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createNodeModule } from "../../nodeTypes.js";
|
||||
|
||||
/**
|
||||
* Converts a color value to a numeric value.
|
||||
*/
|
||||
export const colorToNumberNode = createNodeModule(
|
||||
{
|
||||
id: "convert_color_to_number",
|
||||
title: "Color to Number",
|
||||
category: "Converters",
|
||||
description: "Convert a color value to a numeric value.",
|
||||
searchTags: ["color", "number", "convert", "cast"],
|
||||
inputs: [
|
||||
{ id: "color", name: "Color", direction: "input", kind: "color" }
|
||||
],
|
||||
outputs: [
|
||||
{ id: "number", name: "Number", direction: "output", kind: "number" }
|
||||
],
|
||||
properties: [],
|
||||
},
|
||||
{
|
||||
evaluateValue: ({ resolveValueInput }) => {
|
||||
return resolveValueInput("color", "0");
|
||||
},
|
||||
}
|
||||
);
|
||||
7
scripts/nodes/library/converters/index.js
Normal file
7
scripts/nodes/library/converters/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { colorToNumberNode } from "./colorToNumber.js";
|
||||
import { numberToColorNode } from "./numberToColor.js";
|
||||
|
||||
/**
|
||||
* All type conversion nodes.
|
||||
*/
|
||||
export const converterNodes = [colorToNumberNode, numberToColorNode];
|
||||
26
scripts/nodes/library/converters/numberToColor.js
Normal file
26
scripts/nodes/library/converters/numberToColor.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createNodeModule } from "../../nodeTypes.js";
|
||||
|
||||
/**
|
||||
* Converts a numeric value to a color value.
|
||||
*/
|
||||
export const numberToColorNode = createNodeModule(
|
||||
{
|
||||
id: "convert_number_to_color",
|
||||
title: "Number to Color",
|
||||
category: "Converters",
|
||||
description: "Convert a numeric value to a color value.",
|
||||
searchTags: ["number", "color", "convert", "cast"],
|
||||
inputs: [
|
||||
{ id: "number", name: "Number", direction: "input", kind: "number" }
|
||||
],
|
||||
outputs: [
|
||||
{ id: "color", name: "Color", direction: "output", kind: "color" }
|
||||
],
|
||||
properties: [],
|
||||
},
|
||||
{
|
||||
evaluateValue: ({ resolveValueInput }) => {
|
||||
return resolveValueInput("number", "0");
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -33,7 +33,7 @@ export const circNode = createNodeModule(
|
||||
kind: "number",
|
||||
defaultValue: 4,
|
||||
},
|
||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||
],
|
||||
outputs: [
|
||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||
|
||||
@@ -33,7 +33,7 @@ export const circfillNode = createNodeModule(
|
||||
kind: "number",
|
||||
defaultValue: 4,
|
||||
},
|
||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||
],
|
||||
outputs: [
|
||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||
|
||||
@@ -12,7 +12,7 @@ export const clsNode = createNodeModule(
|
||||
searchTags: ["cls", "clear", "screen", "background"],
|
||||
inputs: [
|
||||
{ id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||
],
|
||||
outputs: [
|
||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||
|
||||
@@ -12,7 +12,7 @@ export const colorNode = createNodeModule(
|
||||
searchTags: ["color", "color", "ink", "draw"],
|
||||
inputs: [
|
||||
{ id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||
],
|
||||
outputs: [
|
||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||
|
||||
@@ -15,7 +15,7 @@ export const cursorNode = createNodeModule(
|
||||
{ id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
|
||||
{ id: "x", name: "X", direction: "input", kind: "number" },
|
||||
{ id: "y", name: "Y", direction: "input", kind: "number" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||
],
|
||||
outputs: [
|
||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||
|
||||
@@ -29,7 +29,7 @@ export const lineNode = createNodeModule(
|
||||
},
|
||||
{ id: "x1", name: "X1", direction: "input", kind: "number" },
|
||||
{ id: "y1", name: "Y1", direction: "input", kind: "number" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||
],
|
||||
outputs: [
|
||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||
|
||||
@@ -40,7 +40,7 @@ export const ovalNode = createNodeModule(
|
||||
kind: "number",
|
||||
defaultValue: 8,
|
||||
},
|
||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||
],
|
||||
outputs: [
|
||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||
|
||||
@@ -40,7 +40,7 @@ export const ovalfillNode = createNodeModule(
|
||||
kind: "number",
|
||||
defaultValue: 8,
|
||||
},
|
||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||
],
|
||||
outputs: [
|
||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||
|
||||
@@ -12,12 +12,12 @@ export const palNode = createNodeModule(
|
||||
searchTags: ["pal", "palette", "color", "remap"],
|
||||
inputs: [
|
||||
{ id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
|
||||
{ id: "c0", name: "Color 0", direction: "input", kind: "number" },
|
||||
{ id: "c0", name: "Color 0", direction: "input", kind: "color" },
|
||||
{
|
||||
id: "c1",
|
||||
name: "Color 1",
|
||||
direction: "input",
|
||||
kind: "number",
|
||||
kind: "color",
|
||||
defaultValue: 0,
|
||||
},
|
||||
{ id: "p", name: "Palette", direction: "input", kind: "number" },
|
||||
|
||||
@@ -12,7 +12,7 @@ export const paltNode = createNodeModule(
|
||||
searchTags: ["palt", "transparency", "palette", "sprite"],
|
||||
inputs: [
|
||||
{ id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||
{
|
||||
id: "transparent",
|
||||
name: "Transparent",
|
||||
|
||||
@@ -27,7 +27,7 @@ export const pgetNode = createNodeModule(
|
||||
},
|
||||
],
|
||||
outputs: [
|
||||
{ id: "value", name: "Color", direction: "output", kind: "number" },
|
||||
{ id: "value", name: "Color", direction: "output", kind: "color" },
|
||||
],
|
||||
properties: [],
|
||||
},
|
||||
|
||||
@@ -26,7 +26,7 @@ export const psetNode = createNodeModule(
|
||||
kind: "number",
|
||||
defaultValue: 0,
|
||||
},
|
||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||
],
|
||||
outputs: [
|
||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||
|
||||
@@ -40,7 +40,7 @@ export const rectNode = createNodeModule(
|
||||
kind: "number",
|
||||
defaultValue: 8,
|
||||
},
|
||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||
],
|
||||
outputs: [
|
||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||
|
||||
@@ -40,7 +40,7 @@ export const rectfillNode = createNodeModule(
|
||||
kind: "number",
|
||||
defaultValue: 8,
|
||||
},
|
||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||
],
|
||||
outputs: [
|
||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||
|
||||
@@ -47,7 +47,7 @@ export const rrectNode = createNodeModule(
|
||||
kind: "number",
|
||||
defaultValue: 2,
|
||||
},
|
||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||
],
|
||||
outputs: [
|
||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||
|
||||
@@ -47,7 +47,7 @@ export const rrectfillNode = createNodeModule(
|
||||
kind: "number",
|
||||
defaultValue: 2,
|
||||
},
|
||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||
],
|
||||
outputs: [
|
||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||
|
||||
@@ -28,7 +28,7 @@ export const sgetNode = createNodeModule(
|
||||
},
|
||||
],
|
||||
outputs: [
|
||||
{ id: "value", name: "Color", direction: "output", kind: "number" },
|
||||
{ id: "value", name: "Color", direction: "output", kind: "color" },
|
||||
],
|
||||
properties: [],
|
||||
},
|
||||
|
||||
@@ -30,7 +30,7 @@ export const ssetNode = createNodeModule(
|
||||
id: "color",
|
||||
name: "Color",
|
||||
direction: "input",
|
||||
kind: "number",
|
||||
kind: "color",
|
||||
defaultValue: 0,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { getLiteralNodeModules } from "../../core/lua/LuaLiteralTypes.js";
|
||||
import { eventInitNode } from "./eventStart.js";
|
||||
import { eventUpdateNode } from "./eventUpdate.js";
|
||||
import { eventDrawNode } from "./eventDraw.js";
|
||||
import { printNode } from "./print.js";
|
||||
import { setVariableNode } from "./setVariable.js";
|
||||
import { getVariableNode } from "./getVariable.js";
|
||||
import { numberLiteralNode } from "./numberLiteral.js";
|
||||
import { stringLiteralNode } from "./stringLiteral.js";
|
||||
import { booleanLiteralNode } from "./booleanLiteral.js";
|
||||
import { addNumberNode } from "./addNumber.js";
|
||||
import { multiplyNumberNode } from "./multiplyNumber.js";
|
||||
import { compareNode } from "./compare.js";
|
||||
import { sequenceNode } from "./sequence.js";
|
||||
import { ifNode } from "./ifNode.js";
|
||||
@@ -31,19 +27,17 @@ import { serialNodes, serialFunctionChecklist } from "./serial/index.js";
|
||||
import { devkitNodes, devkitFunctionChecklist } from "./devkit/index.js";
|
||||
import { luaNodes, luaFunctionChecklist } from "./lua/index.js";
|
||||
import { localVariableNodes } from "./localVariables.js";
|
||||
import { converterNodes } from "./converters/index.js";
|
||||
import { rerouteNode, rerouteExecNode } from "./reroute.js";
|
||||
|
||||
export const nodeModules = [
|
||||
...getLiteralNodeModules(),
|
||||
eventInitNode,
|
||||
eventUpdateNode,
|
||||
eventDrawNode,
|
||||
printNode,
|
||||
setVariableNode,
|
||||
getVariableNode,
|
||||
numberLiteralNode,
|
||||
stringLiteralNode,
|
||||
booleanLiteralNode,
|
||||
addNumberNode,
|
||||
multiplyNumberNode,
|
||||
compareNode,
|
||||
sequenceNode,
|
||||
ifNode,
|
||||
@@ -51,6 +45,9 @@ export const nodeModules = [
|
||||
customEventNode,
|
||||
callCustomEventNode,
|
||||
...localVariableNodes,
|
||||
...converterNodes,
|
||||
rerouteNode,
|
||||
rerouteExecNode,
|
||||
...graphicsNodes,
|
||||
...systemNodes,
|
||||
...tableNodes,
|
||||
|
||||
@@ -90,6 +90,8 @@ export const controllerButtonsNode = createNodeModule(
|
||||
name: "Button",
|
||||
direction: "input",
|
||||
kind: "number",
|
||||
defaultValue: DEFAULT_BUTTON_VALUE,
|
||||
options: BUTTON_OPTIONS,
|
||||
description: "Optional button index override",
|
||||
},
|
||||
{
|
||||
@@ -97,6 +99,8 @@ export const controllerButtonsNode = createNodeModule(
|
||||
name: "Player",
|
||||
direction: "input",
|
||||
kind: "number",
|
||||
defaultValue: DEFAULT_PLAYER_VALUE,
|
||||
options: PLAYER_OPTIONS,
|
||||
description: "Optional player index override",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import { createNodeModule } from "../nodeTypes.js";
|
||||
import {
|
||||
getLocalVariableTypeOptions,
|
||||
variablePropertyKey,
|
||||
localVariableDefault,
|
||||
LOCAL_VARIABLE_TYPE_IDS,
|
||||
} from "../../types/TypeRegistry.js";
|
||||
|
||||
const LOCAL_TYPES = [
|
||||
{ value: "number", label: "Number" },
|
||||
{ value: "string", label: "String" },
|
||||
{ value: "boolean", label: "Boolean" },
|
||||
{ value: "table", label: "Table" },
|
||||
];
|
||||
|
||||
const LOCAL_TYPES = getLocalVariableTypeOptions();
|
||||
const DEFAULT_LOCAL_TYPE = LOCAL_TYPES[0]?.value ?? "number";
|
||||
|
||||
const sanitizeType = (raw) => {
|
||||
const candidate = typeof raw === "string" ? raw.toLowerCase() : DEFAULT_LOCAL_TYPE;
|
||||
return LOCAL_TYPES.some((entry) => entry.value === candidate)
|
||||
? candidate
|
||||
: DEFAULT_LOCAL_TYPE;
|
||||
const candidate = typeof raw === "string" ? raw.trim().toLowerCase() : "";
|
||||
return LOCAL_VARIABLE_TYPE_IDS.has(candidate) ? candidate : DEFAULT_LOCAL_TYPE;
|
||||
};
|
||||
|
||||
const ensureName = (raw, fallback = "localVar") => {
|
||||
@@ -21,33 +19,10 @@ const ensureName = (raw, fallback = "localVar") => {
|
||||
return value.length ? value : fallback;
|
||||
};
|
||||
|
||||
const resolveValuePropertyKey = (type) => {
|
||||
switch (type) {
|
||||
case "string":
|
||||
return "valueString";
|
||||
case "boolean":
|
||||
return "valueBoolean";
|
||||
case "table":
|
||||
return "valueTable";
|
||||
case "number":
|
||||
default:
|
||||
return "valueNumber";
|
||||
}
|
||||
};
|
||||
const resolveValuePropertyKey = variablePropertyKey;
|
||||
|
||||
const defaultValueForType = localVariableDefault;
|
||||
|
||||
const defaultValueForType = (type) => {
|
||||
switch (type) {
|
||||
case "string":
|
||||
return "";
|
||||
case "boolean":
|
||||
return false;
|
||||
case "table":
|
||||
return "{}";
|
||||
case "number":
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const initializeLocalProperties = (properties, { includeValue }) => {
|
||||
const type = sanitizeType(properties.variableType);
|
||||
@@ -68,6 +43,15 @@ const initializeLocalProperties = (properties, { includeValue }) => {
|
||||
if (typeof properties.valueTable !== "string") {
|
||||
properties.valueTable = String(properties.valueTable ?? "{}");
|
||||
}
|
||||
if (
|
||||
typeof properties.valueColor !== "number" ||
|
||||
!Number.isFinite(properties.valueColor)
|
||||
) {
|
||||
const n = Number(properties.valueColor);
|
||||
properties.valueColor = Number.isFinite(n)
|
||||
? Math.max(0, Math.min(15, Math.round(n)))
|
||||
: 7;
|
||||
}
|
||||
|
||||
if (includeValue) {
|
||||
const key = resolveValuePropertyKey(type);
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { createNodeModule } from "../nodeTypes.js";
|
||||
import { createNodeModule } from "../../nodeTypes.js";
|
||||
|
||||
/**
|
||||
* Adds two numeric values.
|
||||
*/
|
||||
export const addNumberNode = createNodeModule(
|
||||
export const mathAddNode = createNodeModule(
|
||||
{
|
||||
id: "add_number",
|
||||
id: "math_add",
|
||||
title: "Add",
|
||||
category: "Math",
|
||||
description: "Add two numbers.",
|
||||
searchTags: ["add", "math", "sum", "number"],
|
||||
searchTags: ["add", "math", "sum", "plus", "number", "+"],
|
||||
inputs: [
|
||||
{
|
||||
id: "a",
|
||||
41
scripts/nodes/library/math/divide.js
Normal file
41
scripts/nodes/library/math/divide.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { createNodeModule } from "../../nodeTypes.js";
|
||||
|
||||
/**
|
||||
* Divides one numeric value by another.
|
||||
*/
|
||||
export const mathDivideNode = createNodeModule(
|
||||
{
|
||||
id: "math_divide",
|
||||
title: "Divide",
|
||||
category: "Math",
|
||||
description: "Divide one number by another (floating point division).",
|
||||
searchTags: ["divide", "division", "math", "slash", "number", "/"],
|
||||
inputs: [
|
||||
{
|
||||
id: "a",
|
||||
name: "A",
|
||||
direction: "input",
|
||||
kind: "number",
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
id: "b",
|
||||
name: "B",
|
||||
direction: "input",
|
||||
kind: "number",
|
||||
defaultValue: 1,
|
||||
},
|
||||
],
|
||||
outputs: [
|
||||
{ id: "res", name: "Result", direction: "output", kind: "number" },
|
||||
],
|
||||
properties: [],
|
||||
},
|
||||
{
|
||||
evaluateValue: ({ resolveValueInput }) => {
|
||||
const a = resolveValueInput("a", "0");
|
||||
const b = resolveValueInput("b", "1");
|
||||
return `(${a}) / (${b})`;
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -1,3 +1,7 @@
|
||||
import { mathAddNode } from "./add.js";
|
||||
import { mathSubtractNode } from "./subtract.js";
|
||||
import { mathMultiplyNode } from "./multiply.js";
|
||||
import { mathDivideNode } from "./divide.js";
|
||||
import { mathMaxNode } from "./max.js";
|
||||
import { mathMinNode } from "./min.js";
|
||||
import { mathMidNode } from "./mid.js";
|
||||
@@ -25,6 +29,10 @@ import { mathDivNode } from "./div.js";
|
||||
* All math-related node modules backed by PICO-8 helpers.
|
||||
*/
|
||||
export const mathNodes = [
|
||||
mathAddNode,
|
||||
mathSubtractNode,
|
||||
mathMultiplyNode,
|
||||
mathDivideNode,
|
||||
mathMaxNode,
|
||||
mathMinNode,
|
||||
mathMidNode,
|
||||
@@ -54,6 +62,10 @@ export const mathNodes = [
|
||||
* @type {Array<{ function: string, nodeId: string, implemented: boolean }>}
|
||||
*/
|
||||
export const mathFunctionChecklist = [
|
||||
{ function: "+", nodeId: "math_add", implemented: true },
|
||||
{ function: "-", nodeId: "math_subtract", implemented: true },
|
||||
{ function: "*", nodeId: "math_multiply", implemented: true },
|
||||
{ function: "/", nodeId: "math_divide", implemented: true },
|
||||
{ function: "MAX", nodeId: "math_max", implemented: true },
|
||||
{ function: "MIN", nodeId: "math_min", implemented: true },
|
||||
{ function: "MID", nodeId: "math_mid", implemented: true },
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import { createNodeModule } from "../nodeTypes.js";
|
||||
import { createNodeModule } from "../../nodeTypes.js";
|
||||
|
||||
/**
|
||||
* Multiplies two numeric values.
|
||||
*/
|
||||
export const multiplyNumberNode = createNodeModule(
|
||||
export const mathMultiplyNode = createNodeModule(
|
||||
{
|
||||
id: "multiply_number",
|
||||
id: "math_multiply",
|
||||
title: "Multiply",
|
||||
category: "Math",
|
||||
description: "Multiply two numbers.",
|
||||
searchTags: ["multiply", "product", "math", "number"],
|
||||
searchTags: ["multiply", "product", "math", "times", "*"],
|
||||
inputs: [
|
||||
{
|
||||
id: "a",
|
||||
name: "A",
|
||||
direction: "input",
|
||||
kind: "number",
|
||||
defaultValue: 1,
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
id: "b",
|
||||
name: "B",
|
||||
direction: "input",
|
||||
kind: "number",
|
||||
defaultValue: 1,
|
||||
defaultValue: 0,
|
||||
},
|
||||
],
|
||||
outputs: [
|
||||
@@ -33,8 +33,8 @@ export const multiplyNumberNode = createNodeModule(
|
||||
},
|
||||
{
|
||||
evaluateValue: ({ resolveValueInput }) => {
|
||||
const a = resolveValueInput("a", "1");
|
||||
const b = resolveValueInput("b", "1");
|
||||
const a = resolveValueInput("a", "0");
|
||||
const b = resolveValueInput("b", "0");
|
||||
return `(${a}) * (${b})`;
|
||||
},
|
||||
}
|
||||
41
scripts/nodes/library/math/subtract.js
Normal file
41
scripts/nodes/library/math/subtract.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { createNodeModule } from "../../nodeTypes.js";
|
||||
|
||||
/**
|
||||
* Subtracts one numeric value from another.
|
||||
*/
|
||||
export const mathSubtractNode = createNodeModule(
|
||||
{
|
||||
id: "math_subtract",
|
||||
title: "Subtract",
|
||||
category: "Math",
|
||||
description: "Subtract one number from another.",
|
||||
searchTags: ["subtract", "math", "minus", "difference", "-"],
|
||||
inputs: [
|
||||
{
|
||||
id: "a",
|
||||
name: "A",
|
||||
direction: "input",
|
||||
kind: "number",
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
id: "b",
|
||||
name: "B",
|
||||
direction: "input",
|
||||
kind: "number",
|
||||
defaultValue: 0,
|
||||
},
|
||||
],
|
||||
outputs: [
|
||||
{ id: "res", name: "Result", direction: "output", kind: "number" },
|
||||
],
|
||||
properties: [],
|
||||
},
|
||||
{
|
||||
evaluateValue: ({ resolveValueInput }) => {
|
||||
const a = resolveValueInput("a", "0");
|
||||
const b = resolveValueInput("b", "0");
|
||||
return `(${a}) - (${b})`;
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -1,26 +0,0 @@
|
||||
import { createNodeModule } from "../nodeTypes.js";
|
||||
|
||||
/**
|
||||
* Constant numeric literal.
|
||||
*/
|
||||
export const numberLiteralNode = createNodeModule(
|
||||
{
|
||||
id: "number_literal",
|
||||
title: "Number",
|
||||
category: "Values",
|
||||
description: "Constant numeric literal.",
|
||||
searchTags: ["number", "literal", "constant", "value"],
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ id: "value", name: "Value", direction: "output", kind: "number" },
|
||||
],
|
||||
properties: [
|
||||
{ key: "value", label: "Number", type: "number", defaultValue: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
evaluateValue: ({ node, formatLiteral }) => {
|
||||
return formatLiteral("number", node.properties.value ?? 0);
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -37,7 +37,7 @@ export const printNode = createNodeModule(
|
||||
id: "color",
|
||||
name: "Color",
|
||||
direction: "input",
|
||||
kind: "number",
|
||||
kind: "color",
|
||||
defaultValue: 7,
|
||||
},
|
||||
],
|
||||
|
||||
53
scripts/nodes/library/reroute.js
Normal file
53
scripts/nodes/library/reroute.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import { createNodeModule } from "../nodeTypes.js";
|
||||
|
||||
/**
|
||||
* A transparent pass-through node for redirecting data wires.
|
||||
* Emits no code on its own — the value resolves directly through the input.
|
||||
*/
|
||||
export const rerouteNode = createNodeModule(
|
||||
{
|
||||
id: "reroute",
|
||||
title: "Reroute",
|
||||
category: "Utility",
|
||||
description: "Redirects a data wire without affecting the value.",
|
||||
searchTags: ["reroute", "redirect", "wire", "relay", "pass", "through"],
|
||||
inputs: [
|
||||
{ id: "value", name: "", direction: "input", kind: "any" },
|
||||
],
|
||||
outputs: [
|
||||
{ id: "value", name: "", direction: "output", kind: "any" },
|
||||
],
|
||||
properties: [],
|
||||
},
|
||||
{
|
||||
evaluateValue: ({ resolveValueInput }) => {
|
||||
return resolveValueInput("value", "nil");
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* A transparent pass-through node for redirecting exec wires.
|
||||
* Emits no code on its own — execution continues directly to the next node.
|
||||
*/
|
||||
export const rerouteExecNode = createNodeModule(
|
||||
{
|
||||
id: "reroute_exec",
|
||||
title: "Reroute",
|
||||
category: "Utility",
|
||||
description: "Redirects an exec wire without generating any code.",
|
||||
searchTags: ["reroute", "redirect", "exec", "wire", "relay", "pass", "through"],
|
||||
inputs: [
|
||||
{ id: "exec_in", name: "", direction: "input", kind: "exec" },
|
||||
],
|
||||
outputs: [
|
||||
{ id: "exec_out", name: "", direction: "output", kind: "exec" },
|
||||
],
|
||||
properties: [],
|
||||
},
|
||||
{
|
||||
emitExec: ({ emitNextExec }) => {
|
||||
return emitNextExec("exec_out");
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -1,26 +0,0 @@
|
||||
import { createNodeModule } from "../nodeTypes.js";
|
||||
|
||||
/**
|
||||
* Constant string literal.
|
||||
*/
|
||||
export const stringLiteralNode = createNodeModule(
|
||||
{
|
||||
id: "string_literal",
|
||||
title: "String",
|
||||
category: "Values",
|
||||
description: "Constant string literal.",
|
||||
searchTags: ["string", "literal", "text", "value"],
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ id: "value", name: "Value", direction: "output", kind: "string" },
|
||||
],
|
||||
properties: [
|
||||
{ key: "value", label: "Text", type: "string", defaultValue: "hello" },
|
||||
],
|
||||
},
|
||||
{
|
||||
evaluateValue: ({ node, formatLiteral }) => {
|
||||
return formatLiteral("string", node.properties.value ?? "");
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -41,7 +41,7 @@ export const extcmdNode = createNodeModule(
|
||||
],
|
||||
inputs: [
|
||||
{ id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
|
||||
{ id: "command", name: "Command", direction: "input", kind: "string" },
|
||||
{ id: "command", name: "Command", direction: "input", kind: "string", defaultValue: EXT_CMD_DEFAULT, options: EXT_CMD_OPTIONS },
|
||||
{ id: "param1", name: "Param 1", direction: "input", kind: "number" },
|
||||
{ id: "param2", name: "Param 2", direction: "input", kind: "number" },
|
||||
],
|
||||
|
||||
@@ -13,9 +13,11 @@
|
||||
* @property {string} id Stable identifier unique within the node definition.
|
||||
* @property {string} name Display name.
|
||||
* @property {('input'|'output')} direction Pin direction relative to the node.
|
||||
* @property {('exec'|'number'|'boolean'|'string'|'table'|'any')} kind Blueprint connection kind.
|
||||
* @property {('exec'|'number'|'boolean'|'string'|'table'|'any'|'color')} kind Blueprint connection kind.
|
||||
* @property {string} [description] Optional tooltip text.
|
||||
* @property {unknown} [defaultValue] Default literal for data pins.
|
||||
* @property {Array<{label: string, value: string}>} [options] Renders an inline select dropdown when the pin is unconnected.
|
||||
* @property {boolean} [spawnLiteralNode] Whether dragging off this pin offers the default literal node. Defaults to the handler's spawnableByDefault.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
13
scripts/types/AnyType.js
Normal file
13
scripts/types/AnyType.js
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* @typedef {import('./TypeRegistry.js').TypeDescriptor} TypeDescriptor
|
||||
*/
|
||||
|
||||
/** @type {TypeDescriptor} */
|
||||
export const AnyType = {
|
||||
id: "any",
|
||||
label: "Any",
|
||||
color: "#ff7f11",
|
||||
isVariable: true,
|
||||
isLocalVariable: false,
|
||||
defaultValue: null,
|
||||
};
|
||||
19
scripts/types/BooleanType.js
Normal file
19
scripts/types/BooleanType.js
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* @typedef {import('./TypeRegistry.js').TypeDescriptor} TypeDescriptor
|
||||
*/
|
||||
|
||||
/** @type {TypeDescriptor} */
|
||||
export const BooleanType = {
|
||||
id: "boolean",
|
||||
label: "Boolean",
|
||||
color: "#ff4f4f",
|
||||
isVariable: true,
|
||||
isLocalVariable: true,
|
||||
defaultValue: false,
|
||||
variablePropertyKey: "valueBoolean",
|
||||
compatibleWith: [
|
||||
{ type: "string", convert: (v) => v ? "true" : "false" },
|
||||
{ type: "number", convert: (v) => v ? 1 : 0 },
|
||||
{ type: "color", convert: (v) => v ? 7 : 0 }
|
||||
],
|
||||
};
|
||||
18
scripts/types/ColorType.js
Normal file
18
scripts/types/ColorType.js
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @typedef {import('./TypeRegistry.js').TypeDescriptor} TypeDescriptor
|
||||
*/
|
||||
|
||||
/** @type {TypeDescriptor} */
|
||||
export const ColorType = {
|
||||
id: "color",
|
||||
label: "Color",
|
||||
color: "#5b8ef5",
|
||||
isVariable: true,
|
||||
isLocalVariable: true,
|
||||
defaultValue: 7,
|
||||
variablePropertyKey: "valueColor",
|
||||
compatibleWith: [
|
||||
{ type: "number", convert: (v) => Number(v) },
|
||||
{ type: "boolean", convert: (v) => v !== 0 }
|
||||
],
|
||||
};
|
||||
13
scripts/types/ExecType.js
Normal file
13
scripts/types/ExecType.js
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Type descriptor for execution flow pins.
|
||||
*
|
||||
* @type {import('./TypeRegistry.js').TypeDescriptor}
|
||||
*/
|
||||
export const ExecType = {
|
||||
id: "exec",
|
||||
label: "Exec",
|
||||
color: "#ffffff",
|
||||
isVariable: false,
|
||||
isLocalVariable: false,
|
||||
defaultValue: null,
|
||||
};
|
||||
17
scripts/types/NumberType.js
Normal file
17
scripts/types/NumberType.js
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* @typedef {import('./TypeRegistry.js').TypeDescriptor} TypeDescriptor
|
||||
*/
|
||||
|
||||
/** @type {TypeDescriptor} */
|
||||
export const NumberType = {
|
||||
id: "number",
|
||||
label: "Number",
|
||||
color: "#3ee581",
|
||||
isVariable: true,
|
||||
isLocalVariable: true,
|
||||
defaultValue: 0,
|
||||
variablePropertyKey: "valueNumber",
|
||||
compatibleWith: [
|
||||
{ type: "color", convert: (v) => Math.floor(Number(v)) % 16 },
|
||||
],
|
||||
};
|
||||
14
scripts/types/StringType.js
Normal file
14
scripts/types/StringType.js
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* @typedef {import('./TypeRegistry.js').TypeDescriptor} TypeDescriptor
|
||||
*/
|
||||
|
||||
/** @type {TypeDescriptor} */
|
||||
export const StringType = {
|
||||
id: "string",
|
||||
label: "String",
|
||||
color: "#ff66ff",
|
||||
isVariable: true,
|
||||
isLocalVariable: true,
|
||||
defaultValue: "",
|
||||
variablePropertyKey: "valueString",
|
||||
};
|
||||
15
scripts/types/TableType.js
Normal file
15
scripts/types/TableType.js
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* @typedef {import('./TypeRegistry.js').TypeDescriptor} TypeDescriptor
|
||||
*/
|
||||
|
||||
/** @type {TypeDescriptor} */
|
||||
export const TableType = {
|
||||
id: "table",
|
||||
label: "Table",
|
||||
color: "#5ec4ff",
|
||||
isVariable: true,
|
||||
isLocalVariable: true,
|
||||
defaultValue: [],
|
||||
localDefaultValue: "{}",
|
||||
variablePropertyKey: "valueTable",
|
||||
};
|
||||
165
scripts/types/TypeRegistry.js
Normal file
165
scripts/types/TypeRegistry.js
Normal file
@@ -0,0 +1,165 @@
|
||||
import { AnyType } from "./AnyType.js";
|
||||
import { NumberType } from "./NumberType.js";
|
||||
import { StringType } from "./StringType.js";
|
||||
import { BooleanType } from "./BooleanType.js";
|
||||
import { TableType } from "./TableType.js";
|
||||
import { ColorType } from "./ColorType.js";
|
||||
import { ExecType } from "./ExecType.js";
|
||||
|
||||
/**
|
||||
* @typedef {'any'|'number'|'string'|'boolean'|'table'|'color'} VariableType
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} TypeCompatibility
|
||||
* @property {string} type Target type ID this type can connect to.
|
||||
* @property {(value: unknown) => unknown} [convert] Optional conversion function.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} TypeDescriptor
|
||||
* @property {string} id Type identifier.
|
||||
* @property {string} label Display label.
|
||||
* @property {string} color Accent color used for pins, wires, and variable labels (hex or CSS variable).
|
||||
* @property {string} [icon] Optional icon character or symbol shown in type labels.
|
||||
* @property {boolean} isVariable Whether usable as a workspace variable type.
|
||||
* @property {boolean} isLocalVariable Whether usable as a local variable node type.
|
||||
* @property {unknown} defaultValue Default value for workspace variables.
|
||||
* @property {unknown} [localDefaultValue] Default for local variable nodes when different from defaultValue.
|
||||
* @property {string} [variablePropertyKey] Property key used in local variable nodes.
|
||||
* @property {ReadonlyArray<TypeCompatibility>} [compatibleWith] Types this type can implicitly convert to.
|
||||
*/
|
||||
|
||||
/** @type {ReadonlyArray<TypeDescriptor>} */
|
||||
export const ALL_TYPES = [
|
||||
ExecType,
|
||||
AnyType,
|
||||
NumberType,
|
||||
StringType,
|
||||
BooleanType,
|
||||
TableType,
|
||||
ColorType,
|
||||
];
|
||||
|
||||
/** @type {Map<string, TypeDescriptor>} */
|
||||
const TYPE_MAP = new Map(ALL_TYPES.map((t) => [t.id, t]));
|
||||
|
||||
/** @type {ReadonlySet<string>} */
|
||||
export const LOCAL_VARIABLE_TYPE_IDS = new Set(
|
||||
ALL_TYPES.filter((t) => t.isLocalVariable).map((t) => t.id)
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns type options available for workspace variable type dropdowns.
|
||||
*
|
||||
* @returns {Array<{ value: string, label: string }>}
|
||||
*/
|
||||
export function getVariableTypeOptions() {
|
||||
return ALL_TYPES.filter((t) => t.isVariable).map((t) => ({ value: t.id, label: t.label }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns type options available for local variable node type dropdowns.
|
||||
*
|
||||
* @returns {Array<{ value: string, label: string }>}
|
||||
*/
|
||||
export function getLocalVariableTypeOptions() {
|
||||
return ALL_TYPES.filter((t) => t.isLocalVariable).map((t) => ({ value: t.id, label: t.label }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes an arbitrary value to a valid registered type id, falling back to "any".
|
||||
*
|
||||
* @param {unknown} value Candidate type value.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeVariableType(value) {
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (TYPE_MAP.has(normalized)) return normalized;
|
||||
}
|
||||
return "any";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the default value for a workspace variable of the given type.
|
||||
*
|
||||
* @param {string} type Type identifier.
|
||||
* @returns {unknown}
|
||||
*/
|
||||
export function defaultVariableValue(type) {
|
||||
return TYPE_MAP.get(type)?.defaultValue ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the default value for a local variable node property of the given type.
|
||||
*
|
||||
* @param {string} type Type identifier.
|
||||
* @returns {unknown}
|
||||
*/
|
||||
export function localVariableDefault(type) {
|
||||
const desc = TYPE_MAP.get(type);
|
||||
if (!desc) return 0;
|
||||
return desc.localDefaultValue !== undefined ? desc.localDefaultValue : desc.defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the property key used to store the typed value in a local variable node.
|
||||
*
|
||||
* @param {string} type Type identifier.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function variablePropertyKey(type) {
|
||||
return TYPE_MAP.get(type)?.variablePropertyKey ?? "valueNumber";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the accent color for the given type (used for pins, wires, and variable labels).
|
||||
*
|
||||
* @param {string} type Type identifier.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getTypeColor(type) {
|
||||
return TYPE_MAP.get(type)?.color ?? "#ffffff";
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if two types are compatible for pin connections.
|
||||
*
|
||||
* @param {string} outputType The output pin type.
|
||||
* @param {string} inputType The input pin type.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function areTypesCompatible(outputType, inputType) {
|
||||
if (inputType === "any" || outputType === "any") {
|
||||
return true;
|
||||
}
|
||||
if (outputType === inputType) {
|
||||
return true;
|
||||
}
|
||||
const outputDesc = TYPE_MAP.get(outputType);
|
||||
if (outputDesc?.compatibleWith?.some((c) => c.type === inputType)) {
|
||||
return true;
|
||||
}
|
||||
const inputDesc = TYPE_MAP.get(inputType);
|
||||
if (inputDesc?.compatibleWith?.some((c) => c.type === outputType)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the conversion function for converting from one type to another.
|
||||
*
|
||||
* @param {string} fromType Source type ID.
|
||||
* @param {string} toType Target type ID.
|
||||
* @returns {((value: unknown) => unknown) | undefined}
|
||||
*/
|
||||
export function getTypeConverter(fromType, toType) {
|
||||
if (fromType === toType) {
|
||||
return (v) => v;
|
||||
}
|
||||
const fromDesc = TYPE_MAP.get(fromType);
|
||||
const compat = fromDesc?.compatibleWith?.find((c) => c.type === toType);
|
||||
return compat?.convert;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NodeGraph } from "../core/NodeGraph.js";
|
||||
import { createColorSwatchPicker } from "./ColorSwatchPicker.js";
|
||||
import { WorkspaceGeometry } from "./workspace/WorkspaceGeometry.js";
|
||||
import { WorkspaceDragManager } from "./workspace/WorkspaceDragManager.js";
|
||||
import { WorkspaceHistoryManager } from "./workspace/WorkspaceHistoryManager.js";
|
||||
@@ -6,34 +7,30 @@ import { WorkspaceClipboardManager } from "./workspace/WorkspaceClipboardManager
|
||||
import { WorkspacePersistenceManager } from "./workspace/WorkspacePersistenceManager.js";
|
||||
import { WorkspaceMarqueeManager } from "./workspace/WorkspaceMarqueeManager.js";
|
||||
import { WorkspaceContextMenuManager } from "./workspace/WorkspaceContextMenuManager.js";
|
||||
import { WorkspaceNodeContextMenu } from "./workspace/WorkspaceNodeContextMenu.js";
|
||||
import { WorkspacePaletteManager } from "./workspace/WorkspacePaletteManager.js";
|
||||
import { WorkspaceDropManager } from "./workspace/WorkspaceDropManager.js";
|
||||
import { WorkspaceInlineEditorManager } from "./workspace/WorkspaceInlineEditorManager.js";
|
||||
import { WorkspaceNodeRenderer } from "./workspace/WorkspaceNodeRenderer.js";
|
||||
import { WorkspaceNavigationManager } from "./workspace/WorkspaceNavigationManager.js";
|
||||
import { WorkspaceWireCutManager } from "./workspace/WorkspaceWireCutManager.js";
|
||||
import { renderEventList } from "./workspace/renderEventList.js";
|
||||
import { renderVariableList } from "./workspace/renderVariableList.js";
|
||||
import {
|
||||
WORKSPACE_SHORTCUTS,
|
||||
} from "./WorkspaceSpawnShortcuts.js";
|
||||
import {
|
||||
normalizeVariableType,
|
||||
getVariableTypeOptions,
|
||||
defaultVariableValue as getDefaultVariableValue,
|
||||
getTypeColor,
|
||||
areTypesCompatible,
|
||||
} from "../types/TypeRegistry.js";
|
||||
|
||||
/**
|
||||
* @typedef {'any'|'number'|'string'|'boolean'|'table'} VariableType
|
||||
* @typedef {import('../types/TypeRegistry.js').VariableType} VariableType
|
||||
*/
|
||||
|
||||
/** @type {Array<{ value: VariableType, label: string }>} */
|
||||
const VARIABLE_TYPE_DEFINITIONS = [
|
||||
{ value: "any", label: "Any" },
|
||||
{ value: "number", label: "Number" },
|
||||
{ value: "string", label: "String" },
|
||||
{ value: "boolean", label: "Boolean" },
|
||||
{ value: "table", label: "Table" },
|
||||
];
|
||||
|
||||
const SUPPORTED_VARIABLE_TYPES = new Set(
|
||||
VARIABLE_TYPE_DEFINITIONS.map((entry) => entry.value)
|
||||
);
|
||||
|
||||
/** @typedef {import('./WorkspaceSpawnShortcuts.js').WorkspaceShortcut} WorkspaceShortcut */
|
||||
/** @typedef {import('./WorkspaceSpawnShortcuts.js').WorkspaceShortcutModifier} WorkspaceShortcutModifier */
|
||||
|
||||
@@ -85,6 +82,7 @@ const SUPPORTED_VARIABLE_TYPES = new Set(
|
||||
* @property {HTMLUListElement} eventList Event summary list element.
|
||||
* @property {HTMLUListElement} variableList Variable summary list element.
|
||||
* @property {HTMLButtonElement} addVariableButton Global variable creation button.
|
||||
* @property {HTMLButtonElement} saveButton Save action button.
|
||||
* @property {HTMLButtonElement} projectSettingsButton Project settings toggle.
|
||||
* @property {HTMLButtonElement} paletteToggleButton Palette toggle control.
|
||||
* @property {HTMLElement} appBodyElement Layout container hosting the workspace panes.
|
||||
@@ -143,6 +141,8 @@ export class BlueprintWorkspace {
|
||||
this.variableListElement = options.variableList;
|
||||
this.addVariableButton = options.addVariableButton;
|
||||
/** @type {HTMLButtonElement} */
|
||||
this.saveButton = options.saveButton;
|
||||
/** @type {HTMLButtonElement} */
|
||||
this.projectSettingsButton = options.projectSettingsButton;
|
||||
/** @type {HTMLButtonElement} */
|
||||
this.frameSelectionButton = options.frameSelectionButton;
|
||||
@@ -175,6 +175,12 @@ export class BlueprintWorkspace {
|
||||
this.nodeDragRotationDecayFrame = null;
|
||||
/** @type {number | null} */
|
||||
this.connectionRefreshFrame = null;
|
||||
/** @type {number | null} */
|
||||
this.periodicUpdateTimer = null;
|
||||
/** @type {number | null} */
|
||||
this.saveFeedbackTimer = null;
|
||||
/** @type {boolean} */
|
||||
this.hasPendingSave = false;
|
||||
|
||||
this.graph = new NodeGraph();
|
||||
/** @type {Map<string, WorkspaceVariable>} */
|
||||
@@ -240,6 +246,8 @@ export class BlueprintWorkspace {
|
||||
this.paletteManager = null;
|
||||
/** @type {WorkspaceContextMenuManager | null} */
|
||||
this.contextMenuManager = null;
|
||||
/** @type {WorkspaceNodeContextMenu | null} */
|
||||
this.nodeContextMenu = null;
|
||||
/** @type {WorkspaceDropManager | null} */
|
||||
this.dropManager = null;
|
||||
/** @type {WorkspaceMarqueeManager | null} */
|
||||
@@ -252,6 +260,8 @@ export class BlueprintWorkspace {
|
||||
this.navigationManager = null;
|
||||
/** @type {WorkspaceDragManager | null} */
|
||||
this.dragManager = null;
|
||||
/** @type {WorkspaceWireCutManager | null} */
|
||||
this.wireCutManager = null;
|
||||
/** @type {HTMLTemplateElement} */
|
||||
this.nodeTemplate = BlueprintWorkspace.#requireTemplate(
|
||||
"nodeTemplate",
|
||||
@@ -349,6 +359,7 @@ export class BlueprintWorkspace {
|
||||
isTypeDropdownOpen: (context) => this.#isTypeDropdownOpen(context),
|
||||
});
|
||||
this.contextMenuManager = new WorkspaceContextMenuManager(this);
|
||||
this.nodeContextMenu = new WorkspaceNodeContextMenu(this);
|
||||
this.paletteManager = new WorkspacePaletteManager(this);
|
||||
this.dropManager = new WorkspaceDropManager(this);
|
||||
this.marqueeManager = new WorkspaceMarqueeManager(this);
|
||||
@@ -356,6 +367,7 @@ export class BlueprintWorkspace {
|
||||
renderConnections: () => this.#renderConnections(),
|
||||
schedulePersist: () => this.persistenceManager.schedulePersist(),
|
||||
});
|
||||
this.wireCutManager = new WorkspaceWireCutManager(this);
|
||||
this.nodeRenderer = new WorkspaceNodeRenderer(this, {
|
||||
inlineEditorManager: this.inlineEditorManager,
|
||||
applySelectionState: () => this.#applySelectionState(),
|
||||
@@ -380,12 +392,14 @@ export class BlueprintWorkspace {
|
||||
});
|
||||
|
||||
this.contextMenuManager.initialize();
|
||||
this.nodeContextMenu.initialize();
|
||||
this.paletteManager.initialize();
|
||||
this.dropManager.initialize();
|
||||
this.marqueeManager.ensureElement();
|
||||
|
||||
this.navigationManager.updateZoomDisplay();
|
||||
this.navigationManager.setBackgroundOffset({ x: 0, y: 0 });
|
||||
this.#resetSaveButtonAppearance();
|
||||
|
||||
if (typeof this.generator.setProjectSettings === "function") {
|
||||
this.generator.setProjectSettings({ ...this.projectSettings });
|
||||
@@ -405,6 +419,127 @@ export class BlueprintWorkspace {
|
||||
|
||||
this.historyManager.initialize();
|
||||
this.isInitialized = true;
|
||||
this.#startPeriodicUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a periodic update that refreshes the graph when idle.
|
||||
*/
|
||||
#startPeriodicUpdate() {
|
||||
if (this.periodicUpdateTimer !== null) {
|
||||
return;
|
||||
}
|
||||
this.periodicUpdateTimer = window.setInterval(() => {
|
||||
if (!this.isDraggingNodes && this.workspaceDragDepth === 0) {
|
||||
this.#renderConnections();
|
||||
this.nodeRenderer?.refreshAllPinConnections();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the periodic update timer.
|
||||
*/
|
||||
#stopPeriodicUpdate() {
|
||||
if (this.periodicUpdateTimer !== null) {
|
||||
window.clearInterval(this.periodicUpdateTimer);
|
||||
this.periodicUpdateTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the active save feedback timer.
|
||||
*/
|
||||
#clearSaveFeedbackTimer() {
|
||||
if (this.saveFeedbackTimer !== null && typeof window !== "undefined") {
|
||||
window.clearTimeout(this.saveFeedbackTimer);
|
||||
this.saveFeedbackTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the save button to its current base state.
|
||||
*/
|
||||
#resetSaveButtonAppearance() {
|
||||
const button = this.saveButton;
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
button.classList.remove("is-busy", "is-success", "is-error", "is-pending");
|
||||
button.disabled = !this.persistenceManager?.isStorageAvailable;
|
||||
button.setAttribute(
|
||||
"title",
|
||||
this.persistenceManager?.isStorageAvailable
|
||||
? this.hasPendingSave
|
||||
? "Unsaved changes"
|
||||
: "Workspace saved"
|
||||
: "Saving is unavailable"
|
||||
);
|
||||
|
||||
if (this.hasPendingSave) {
|
||||
button.classList.add("is-pending");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies transient visual state to the save button.
|
||||
*
|
||||
* @param {{ status: 'idle'|'pending'|'saving'|'saved'|'error', source: 'manual'|'auto' }} state
|
||||
*/
|
||||
#updateSaveButtonAppearance(state) {
|
||||
const button = this.saveButton;
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#clearSaveFeedbackTimer();
|
||||
this.#resetSaveButtonAppearance();
|
||||
|
||||
if (!this.persistenceManager?.isStorageAvailable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.status === "pending") {
|
||||
button.classList.add("is-pending");
|
||||
button.setAttribute("title", "Unsaved changes");
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.status === "saving") {
|
||||
button.classList.remove("is-pending");
|
||||
button.classList.add("is-busy");
|
||||
button.disabled = true;
|
||||
button.setAttribute("title", "Saving workspace");
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.status === "saved") {
|
||||
button.classList.remove("is-pending");
|
||||
button.classList.add("is-success");
|
||||
button.setAttribute(
|
||||
"title",
|
||||
state.source === "auto" ? "Workspace autosaved" : "Workspace saved"
|
||||
);
|
||||
this.saveFeedbackTimer = window.setTimeout(() => {
|
||||
this.#resetSaveButtonAppearance();
|
||||
this.saveFeedbackTimer = null;
|
||||
}, 1400);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.status === "error") {
|
||||
button.classList.remove("is-pending");
|
||||
button.classList.add("is-error");
|
||||
button.setAttribute("title", "Workspace save failed");
|
||||
this.saveFeedbackTimer = window.setTimeout(() => {
|
||||
this.#resetSaveButtonAppearance();
|
||||
this.saveFeedbackTimer = null;
|
||||
}, 1800);
|
||||
return;
|
||||
}
|
||||
|
||||
this.#resetSaveButtonAppearance();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -428,6 +563,41 @@ export class BlueprintWorkspace {
|
||||
return this.#refreshLuaOutput();
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the current workspace state immediately.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
saveWorkspace() {
|
||||
if (!this.persistenceManager?.isStorageAvailable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.persistenceManager.persistImmediately("manual");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reflects persistence lifecycle changes in the save button state.
|
||||
*
|
||||
* @param {{ status: 'idle'|'pending'|'saving'|'saved'|'error', source: 'manual'|'auto' }} state
|
||||
*/
|
||||
handlePersistenceStateChange(state) {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.status === "pending") {
|
||||
this.hasPendingSave = true;
|
||||
} else if (state.status === "saved") {
|
||||
this.hasPendingSave = false;
|
||||
} else if (state.status === "error") {
|
||||
this.hasPendingSave = true;
|
||||
}
|
||||
|
||||
this.#updateSaveButtonAppearance(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to graph lifecycle changes.
|
||||
*/
|
||||
@@ -661,6 +831,12 @@ export class BlueprintWorkspace {
|
||||
* Registers DOM event listeners for interactivity.
|
||||
*/
|
||||
#bindUiEvents() {
|
||||
if (this.saveButton) {
|
||||
this.saveButton.addEventListener("click", () => {
|
||||
this.saveWorkspace();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.paletteToggleButton) {
|
||||
this.paletteToggleButton.addEventListener("click", () => {
|
||||
this.#togglePaletteVisibility();
|
||||
@@ -726,7 +902,7 @@ export class BlueprintWorkspace {
|
||||
|
||||
event.preventDefault();
|
||||
const direction = event.deltaY < 0 ? 1 : -1;
|
||||
this.navigationManager.zoomAt(pointer, direction, {
|
||||
this.navigationManager.pushSmoothZoom(pointer, direction, {
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
});
|
||||
@@ -754,10 +930,25 @@ export class BlueprintWorkspace {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.nodeContextMenu?.isVisible()) {
|
||||
if (
|
||||
!(rawTarget instanceof Node) ||
|
||||
!this.nodeContextMenu.isTargetInside(rawTarget)
|
||||
) {
|
||||
this.nodeContextMenu.hide();
|
||||
}
|
||||
}
|
||||
|
||||
if (!(rawTarget instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Middle mouse starts a wire-cut gesture from anywhere in the workspace.
|
||||
if (event.button === 1) {
|
||||
this.wireCutManager?.beginCut(event);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.#isWorkspaceBackgroundTarget(rawTarget)) {
|
||||
return;
|
||||
}
|
||||
@@ -789,6 +980,7 @@ export class BlueprintWorkspace {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
this.nodeContextMenu?.hide();
|
||||
this.contextMenuManager.show(event.clientX, event.clientY);
|
||||
});
|
||||
|
||||
@@ -921,7 +1113,7 @@ export class BlueprintWorkspace {
|
||||
this.clipboardManager?.clearPointerPosition();
|
||||
});
|
||||
|
||||
this.connectionLayer.addEventListener("pointerdown", (event) => {
|
||||
this.workspaceElement.addEventListener("pointerdown", (event) => {
|
||||
this.#handleConnectionPointerDown(event);
|
||||
});
|
||||
}
|
||||
@@ -1031,6 +1223,72 @@ export class BlueprintWorkspace {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes a node by replacing it with a new instance, preserving connections.
|
||||
*
|
||||
* @param {string} nodeId Target node identifier.
|
||||
* @returns {boolean} Whether the node was successfully refreshed.
|
||||
*/
|
||||
refreshNode(nodeId) {
|
||||
const node = this.graph.nodes.get(nodeId);
|
||||
if (!node) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const connections = this.graph.getConnectionsForNode(nodeId);
|
||||
const position = { ...node.position };
|
||||
const nodeType = node.type;
|
||||
const properties = { ...node.properties };
|
||||
|
||||
this.historyManager.withSuspended(() => {
|
||||
this.removeNode(nodeId);
|
||||
|
||||
const newNode = this.registry.createNode(nodeType, {
|
||||
id: nodeId,
|
||||
position,
|
||||
});
|
||||
|
||||
newNode.properties = properties;
|
||||
|
||||
this.graph.addNode(newNode);
|
||||
this.renderNode(newNode);
|
||||
|
||||
connections.forEach((connection) => {
|
||||
const isSource = connection.from.nodeId === nodeId;
|
||||
const isTarget = connection.to.nodeId === nodeId;
|
||||
|
||||
if (isSource) {
|
||||
const fromPin = newNode.getPin(connection.from.pinId);
|
||||
const toNode = this.graph.nodes.get(connection.to.nodeId);
|
||||
const toPin = toNode?.getPin(connection.to.pinId);
|
||||
|
||||
if (fromPin && toNode && toPin) {
|
||||
this.graph.connect(
|
||||
{ nodeId: newNode.id, pinId: fromPin.id },
|
||||
{ nodeId: toNode.id, pinId: toPin.id }
|
||||
);
|
||||
}
|
||||
} else if (isTarget) {
|
||||
const fromNode = this.graph.nodes.get(connection.from.nodeId);
|
||||
const fromPin = fromNode?.getPin(connection.from.pinId);
|
||||
const toPin = newNode.getPin(connection.to.pinId);
|
||||
|
||||
if (fromNode && fromPin && toPin) {
|
||||
this.graph.connect(
|
||||
{ nodeId: fromNode.id, pinId: fromPin.id },
|
||||
{ nodeId: newNode.id, pinId: toPin.id }
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.#renderConnections();
|
||||
this.nodeRenderer.refreshAllPinConnections();
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the current node selection.
|
||||
*/
|
||||
@@ -1635,18 +1893,7 @@ export class BlueprintWorkspace {
|
||||
* @returns {VariableDefaultValue}
|
||||
*/
|
||||
#defaultVariableValue(type) {
|
||||
switch (type) {
|
||||
case "number":
|
||||
return 0;
|
||||
case "string":
|
||||
return "";
|
||||
case "table":
|
||||
return [];
|
||||
case "boolean":
|
||||
return false;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
return /** @type {VariableDefaultValue} */ (getDefaultVariableValue(type));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1679,6 +1926,11 @@ export class BlueprintWorkspace {
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
case "color": {
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
const safe = Number.isFinite(n) ? Math.round(n) : 7;
|
||||
return Math.max(0, Math.min(15, safe));
|
||||
}
|
||||
case "boolean": {
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
@@ -2013,6 +2265,18 @@ export class BlueprintWorkspace {
|
||||
this.#setVariableDefault(variable.id, defaultToggle.checked);
|
||||
});
|
||||
defaultField.appendChild(defaultToggle);
|
||||
} else if (variable.type === "color") {
|
||||
const rawDefault = variable.defaultValue;
|
||||
const n = typeof rawDefault === "number" ? rawDefault : Number(rawDefault);
|
||||
const initialIndex = Number.isFinite(n) ? Math.max(0, Math.min(15, Math.round(n))) : 7;
|
||||
const picker = createColorSwatchPicker({
|
||||
id: defaultId,
|
||||
value: initialIndex,
|
||||
onChange: (index) => {
|
||||
this.#setVariableDefault(variable.id, index);
|
||||
},
|
||||
});
|
||||
defaultField.appendChild(picker.element);
|
||||
} else if (variable.type === "number") {
|
||||
const defaultInput = document.createElement("input");
|
||||
defaultInput.type = "number";
|
||||
@@ -2487,6 +2751,7 @@ export class BlueprintWorkspace {
|
||||
option.type = "button";
|
||||
option.className = "variable-type-option";
|
||||
option.dataset.variableType = entry.value;
|
||||
option.style.setProperty("--overview-variable-color", getTypeColor(entry.value));
|
||||
option.textContent = entry.label;
|
||||
option.setAttribute("role", "menuitemradio");
|
||||
option.setAttribute(
|
||||
@@ -2908,12 +3173,8 @@ export class BlueprintWorkspace {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rect = workspaceElement.getBoundingClientRect();
|
||||
const screenPoint = {
|
||||
x: Math.max(0, Math.min(rect.width, event.clientX - rect.left)),
|
||||
y: Math.max(0, Math.min(rect.height, event.clientY - rect.top)),
|
||||
};
|
||||
const worldPoint = navigator.screenPointToWorld(screenPoint);
|
||||
// Use dragManager.clientToWorkspace for proper coordinate conversion
|
||||
const worldPoint = this.dragManager.clientToWorkspace(event.clientX, event.clientY);
|
||||
return this.addNode(shortcut.definitionId, worldPoint);
|
||||
}
|
||||
|
||||
@@ -3286,9 +3547,7 @@ export class BlueprintWorkspace {
|
||||
* @returns {string}
|
||||
*/
|
||||
#formatVariableType(type) {
|
||||
const entry = VARIABLE_TYPE_DEFINITIONS.find(
|
||||
(definition) => definition.value === type
|
||||
);
|
||||
const entry = getVariableTypeOptions().find((opt) => opt.value === type);
|
||||
return entry ? entry.label : "Any";
|
||||
}
|
||||
|
||||
@@ -3299,17 +3558,7 @@ export class BlueprintWorkspace {
|
||||
* @returns {VariableType}
|
||||
*/
|
||||
#normalizeVariableType(value) {
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (
|
||||
SUPPORTED_VARIABLE_TYPES.has(
|
||||
/** @type {VariableType} */ (normalized)
|
||||
)
|
||||
) {
|
||||
return /** @type {VariableType} */ (normalized);
|
||||
}
|
||||
}
|
||||
return "any";
|
||||
return /** @type {VariableType} */ (normalizeVariableType(value));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3318,7 +3567,7 @@ export class BlueprintWorkspace {
|
||||
* @returns {Array<{ value: VariableType, label: string }>}
|
||||
*/
|
||||
#getVariableTypeOptions() {
|
||||
return VARIABLE_TYPE_DEFINITIONS.map((entry) => ({ ...entry }));
|
||||
return getVariableTypeOptions();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3521,12 +3770,14 @@ export class BlueprintWorkspace {
|
||||
node.type === "set_local_var" || node.type === "get_local_var";
|
||||
article.classList.add("blueprint-node--variable");
|
||||
article.dataset.variableType = binding.type;
|
||||
article.style.setProperty("--overview-variable-color", getTypeColor(binding.type));
|
||||
article.dataset.variableRole = role;
|
||||
article.dataset.variableScope = isLocal ? "local" : "global";
|
||||
|
||||
header.classList.add("node-header--variable");
|
||||
header.setAttribute("data-variable-role", role);
|
||||
header.setAttribute("data-variable-type", binding.type);
|
||||
header.style.setProperty("--overview-variable-color", getTypeColor(binding.type));
|
||||
header.setAttribute("data-variable-scope", isLocal ? "local" : "global");
|
||||
const prefixBase = role === "set" ? "Set" : "Get";
|
||||
const prefix = isLocal ? `${prefixBase} Local` : prefixBase;
|
||||
@@ -4739,15 +4990,24 @@ export class BlueprintWorkspace {
|
||||
* @param {PointerEvent} event Pointer interaction payload.
|
||||
*/
|
||||
#handleConnectionPointerDown(event) {
|
||||
if (!(event.target instanceof SVGPathElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.button !== 0 || !event.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = /** @type {SVGPathElement} */ (event.target);
|
||||
// The nodeLayer div sits above the SVG in z-order, so event.target is usually a
|
||||
// node element rather than an SVGPathElement. Fall back to a point-based lookup.
|
||||
let path = event.target instanceof SVGPathElement ? /** @type {SVGPathElement} */ (event.target) : null;
|
||||
if (!path) {
|
||||
const hits = document.elementsFromPoint(event.clientX, event.clientY);
|
||||
path = /** @type {SVGPathElement | null} */ (
|
||||
hits.find((el) => el instanceof SVGPathElement && /** @type {SVGPathElement} */ (el).dataset.connectionId) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
|
||||
const connectionId = path.dataset.connectionId;
|
||||
if (!connectionId) {
|
||||
return;
|
||||
@@ -4828,7 +5088,7 @@ export class BlueprintWorkspace {
|
||||
* Spawns a node at the requested context menu position.
|
||||
*
|
||||
* @param {PaletteNodeDefinition} definition Node definition to instantiate.
|
||||
* @param {{ x: number, y: number }} spawnPosition Workspace-relative spawn coordinates.
|
||||
* @param {{ x: number, y: number }} spawnPosition Screen-relative spawn coordinates (relative to workspace element).
|
||||
* @returns {import('../core/BlueprintNode.js').BlueprintNode | null}
|
||||
*/
|
||||
spawnNodeFromContextMenu(definition, spawnPosition) {
|
||||
@@ -4836,7 +5096,14 @@ export class BlueprintWorkspace {
|
||||
x: Math.max(0, spawnPosition.x),
|
||||
y: Math.max(0, spawnPosition.y),
|
||||
};
|
||||
const worldSpawn = this.navigationManager.screenPointToWorld(clamped);
|
||||
// Convert screen-relative position to world coordinates
|
||||
const zoom = this.navigationManager?.getZoomLevel() ?? this.zoomLevel ?? 1;
|
||||
const clampedZoom = Math.max(0.01, zoom);
|
||||
const offset = this.navigationManager?.getEffectiveOffset() ?? { x: 0, y: 0 };
|
||||
const worldSpawn = {
|
||||
x: clamped.x / clampedZoom - offset.x,
|
||||
y: clamped.y / clampedZoom - offset.y,
|
||||
};
|
||||
const spawn = WorkspaceGeometry.snapPositionToGrid(this, worldSpawn);
|
||||
const pendingConnectionSnapshot = this.pendingConnectionSpawn
|
||||
? {
|
||||
@@ -5179,23 +5446,20 @@ export class BlueprintWorkspace {
|
||||
const workspaceRect = this.workspaceElement.getBoundingClientRect();
|
||||
const startRect = startHandle.getBoundingClientRect();
|
||||
const zoom = this.zoomLevel || 1;
|
||||
const offset = this.navigationManager?.getEffectiveOffset() ?? this.workspaceBackgroundOffset ?? { x: 0, y: 0 };
|
||||
const anchor = {
|
||||
x:
|
||||
(startRect.left - workspaceRect.left + startRect.width / 2) /
|
||||
zoom,
|
||||
y:
|
||||
(startRect.top - workspaceRect.top + startRect.height / 2) /
|
||||
zoom,
|
||||
x: (startRect.left - workspaceRect.left + startRect.width / 2) / zoom - offset.x,
|
||||
y: (startRect.top - workspaceRect.top + startRect.height / 2) / zoom - offset.y,
|
||||
};
|
||||
const pointer = {
|
||||
x: (clientX - workspaceRect.left) / zoom,
|
||||
y: (clientY - workspaceRect.top) / zoom,
|
||||
x: (clientX - workspaceRect.left) / zoom - offset.x,
|
||||
y: (clientY - workspaceRect.top) / zoom - offset.y,
|
||||
};
|
||||
|
||||
const start = direction === "output" ? anchor : pointer;
|
||||
const end = direction === "output" ? pointer : anchor;
|
||||
|
||||
const controlOffset = Math.max(60 / zoom, Math.abs(end.x - start.x) * 0.5);
|
||||
const controlOffset = Math.max(60, Math.abs(end.x - start.x) * 0.5);
|
||||
const d = `M ${start.x} ${start.y} C ${start.x + controlOffset} ${
|
||||
start.y
|
||||
} ${end.x - controlOffset} ${end.y} ${end.x} ${end.y}`;
|
||||
@@ -5374,10 +5638,88 @@ export class BlueprintWorkspace {
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
path.classList.add("connection-path");
|
||||
path.dataset.type = kind;
|
||||
path.style.stroke = getTypeColor(kind);
|
||||
this.connectionLayer.appendChild(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the SVG defs element on the connection layer, creating it if absent.
|
||||
*
|
||||
* @returns {SVGDefsElement}
|
||||
*/
|
||||
#getOrCreateSvgDefs() {
|
||||
let defs = this.connectionLayer.querySelector("defs");
|
||||
if (!defs) {
|
||||
defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
|
||||
this.connectionLayer.prepend(defs);
|
||||
}
|
||||
return /** @type {SVGDefsElement} */ (defs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or updates a linearGradient element for a cross-type wire.
|
||||
*
|
||||
* @param {SVGDefsElement} defs Defs container.
|
||||
* @param {string} connectionId Connection identifier.
|
||||
* @param {string} fromKind Source pin kind.
|
||||
* @param {string} toKind Target pin kind.
|
||||
* @param {{x:number,y:number}} start Gradient start point.
|
||||
* @param {{x:number,y:number}} end Gradient end point.
|
||||
* @returns {string} The gradient element id.
|
||||
*/
|
||||
#upsertConnectionGradient(defs, connectionId, fromKind, toKind, start, end) {
|
||||
const gradId = `cgrad-${connectionId}`;
|
||||
let grad = defs.querySelector(`#${CSS.escape(gradId)}`);
|
||||
if (!grad) {
|
||||
grad = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"linearGradient"
|
||||
);
|
||||
grad.id = gradId;
|
||||
grad.setAttribute("gradientUnits", "userSpaceOnUse");
|
||||
const stop1 = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"stop"
|
||||
);
|
||||
stop1.setAttribute("offset", "0%");
|
||||
const stop2 = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"stop"
|
||||
);
|
||||
stop2.setAttribute("offset", "100%");
|
||||
grad.appendChild(stop1);
|
||||
grad.appendChild(stop2);
|
||||
defs.appendChild(grad);
|
||||
}
|
||||
|
||||
grad.setAttribute("x1", String(start.x));
|
||||
grad.setAttribute("y1", String(start.y));
|
||||
grad.setAttribute("x2", String(end.x));
|
||||
grad.setAttribute("y2", String(end.y));
|
||||
|
||||
const style = getComputedStyle(this.workspaceElement);
|
||||
const stops = grad.querySelectorAll("stop");
|
||||
stops[0].setAttribute(
|
||||
"stop-color",
|
||||
this.#getPinKindColor(style, fromKind)
|
||||
);
|
||||
stops[1].setAttribute("stop-color", this.#getPinKindColor(style, toKind));
|
||||
|
||||
return gradId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the color for a given pin kind.
|
||||
*
|
||||
* @param {CSSStyleDeclaration} _style Computed style (unused, kept for API compatibility).
|
||||
* @param {string} kind Pin kind identifier.
|
||||
* @returns {string}
|
||||
*/
|
||||
#getPinKindColor(_style, kind) {
|
||||
return getTypeColor(kind);
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a connection re-render on the next animation frame.
|
||||
*/
|
||||
@@ -5445,6 +5787,7 @@ export class BlueprintWorkspace {
|
||||
const targetHeight = Math.max(1, workspaceRect.height / zoom);
|
||||
this.connectionLayer.style.width = `${targetWidth}px`;
|
||||
this.connectionLayer.style.height = `${targetHeight}px`;
|
||||
this.connectionLayer.setAttribute("overflow", "visible");
|
||||
if (this.connectionLayer.hasAttribute("viewBox")) {
|
||||
this.connectionLayer.removeAttribute("viewBox");
|
||||
}
|
||||
@@ -5452,6 +5795,7 @@ export class BlueprintWorkspace {
|
||||
this.connectionLayer.removeAttribute("preserveAspectRatio");
|
||||
}
|
||||
}
|
||||
const defs = this.#getOrCreateSvgDefs();
|
||||
const activeIds = new Set();
|
||||
this.graph.getConnections().forEach((connection) => {
|
||||
activeIds.add(connection.id);
|
||||
@@ -5461,7 +5805,6 @@ export class BlueprintWorkspace {
|
||||
this.connectionElements.set(connection.id, path);
|
||||
}
|
||||
|
||||
path.dataset.type = connection.kind;
|
||||
path.dataset.connectionId = connection.id;
|
||||
const geometry = this.#computeConnectionGeometry(
|
||||
connection.from,
|
||||
@@ -5472,18 +5815,53 @@ export class BlueprintWorkspace {
|
||||
}
|
||||
|
||||
const { start, end } = geometry;
|
||||
const controlOffset = Math.max(60 / zoom, Math.abs(end.x - start.x) * 0.5);
|
||||
const controlOffset = Math.max(60, Math.abs(end.x - start.x) * 0.5);
|
||||
const d = `M ${start.x} ${start.y} C ${start.x + controlOffset} ${
|
||||
start.y
|
||||
} ${end.x - controlOffset} ${end.y} ${end.x} ${end.y}`;
|
||||
path.dataset.active = "false";
|
||||
path.setAttribute("d", d);
|
||||
|
||||
const fromPin = this.graph.nodes
|
||||
.get(connection.from.nodeId)
|
||||
?.getPin(connection.from.pinId);
|
||||
const toPin = this.graph.nodes
|
||||
.get(connection.to.nodeId)
|
||||
?.getPin(connection.to.pinId);
|
||||
const fromKind = fromPin?.kind;
|
||||
const toKind = toPin?.kind;
|
||||
|
||||
if (
|
||||
fromKind &&
|
||||
toKind &&
|
||||
fromKind !== toKind &&
|
||||
fromKind !== "any" &&
|
||||
toKind !== "any"
|
||||
) {
|
||||
path.dataset.type = "mixed";
|
||||
const gradId = this.#upsertConnectionGradient(
|
||||
defs,
|
||||
connection.id,
|
||||
fromKind,
|
||||
toKind,
|
||||
start,
|
||||
end
|
||||
);
|
||||
path.style.stroke = "";
|
||||
path.setAttribute("stroke", `url(#${gradId})`);
|
||||
} else {
|
||||
path.dataset.type = connection.kind;
|
||||
path.removeAttribute("stroke");
|
||||
path.style.stroke = getTypeColor(connection.kind);
|
||||
defs.querySelector(`#cgrad-${connection.id}`)?.remove();
|
||||
}
|
||||
});
|
||||
|
||||
this.connectionElements.forEach((path, id) => {
|
||||
if (!activeIds.has(id)) {
|
||||
path.remove();
|
||||
this.connectionElements.delete(id);
|
||||
defs.querySelector(`#cgrad-${id}`)?.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -5504,22 +5882,17 @@ export class BlueprintWorkspace {
|
||||
|
||||
const workspaceRect = this.workspaceElement.getBoundingClientRect();
|
||||
const zoom = this.zoomLevel || 1;
|
||||
const offset = this.navigationManager?.getEffectiveOffset() ?? this.workspaceBackgroundOffset ?? { x: 0, y: 0 };
|
||||
const startRect = startHandle.getBoundingClientRect();
|
||||
const endRect = endHandle.getBoundingClientRect();
|
||||
return {
|
||||
start: {
|
||||
x:
|
||||
(startRect.left - workspaceRect.left + startRect.width / 2) /
|
||||
zoom,
|
||||
y:
|
||||
(startRect.top - workspaceRect.top + startRect.height / 2) /
|
||||
zoom,
|
||||
x: (startRect.left - workspaceRect.left + startRect.width / 2) / zoom - offset.x,
|
||||
y: (startRect.top - workspaceRect.top + startRect.height / 2) / zoom - offset.y,
|
||||
},
|
||||
end: {
|
||||
x:
|
||||
(endRect.left - workspaceRect.left + endRect.width / 2) / zoom,
|
||||
y:
|
||||
(endRect.top - workspaceRect.top + endRect.height / 2) / zoom,
|
||||
x: (endRect.left - workspaceRect.left + endRect.width / 2) / zoom - offset.x,
|
||||
y: (endRect.top - workspaceRect.top + endRect.height / 2) / zoom - offset.y,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -5560,14 +5933,11 @@ export class BlueprintWorkspace {
|
||||
const rect = handle.getBoundingClientRect();
|
||||
const workspaceRect = this.workspaceElement.getBoundingClientRect();
|
||||
const zoom = this.zoomLevel || 1;
|
||||
const offset = this.navigationManager?.getEffectiveOffset() ?? this.workspaceBackgroundOffset ?? { x: 0, y: 0 };
|
||||
|
||||
return {
|
||||
x:
|
||||
(rect.left - workspaceRect.left + rect.width / 2) /
|
||||
zoom,
|
||||
y:
|
||||
(rect.top - workspaceRect.top + rect.height / 2) /
|
||||
zoom,
|
||||
x: (rect.left - workspaceRect.left + rect.width / 2) / zoom - offset.x,
|
||||
y: (rect.top - workspaceRect.top + rect.height / 2) / zoom - offset.y,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5767,10 +6137,7 @@ export class BlueprintWorkspace {
|
||||
* @returns {boolean}
|
||||
*/
|
||||
#arePinKindsCompatible(outputKind, inputKind) {
|
||||
if (inputKind === "any" || outputKind === "any") {
|
||||
return true;
|
||||
}
|
||||
return outputKind === inputKind;
|
||||
return areTypesCompatible(outputKind, inputKind);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -6078,12 +6445,14 @@ export class BlueprintWorkspace {
|
||||
row.className = "custom-event-parameter-row";
|
||||
row.dataset.parameterId = parameter.id;
|
||||
row.dataset.parameterType = parameter.type;
|
||||
row.style.setProperty("--overview-variable-color", getTypeColor(parameter.type));
|
||||
|
||||
const typeButton = document.createElement("button");
|
||||
typeButton.type = "button";
|
||||
typeButton.className =
|
||||
"variable-type-button custom-event-parameter-type-button";
|
||||
typeButton.dataset.variableType = parameter.type;
|
||||
typeButton.style.setProperty("--overview-variable-color", getTypeColor(parameter.type));
|
||||
typeButton.setAttribute("aria-haspopup", "menu");
|
||||
typeButton.setAttribute("aria-expanded", "false");
|
||||
row.appendChild(typeButton);
|
||||
@@ -6099,7 +6468,9 @@ export class BlueprintWorkspace {
|
||||
const display = this.#formatVariableType(normalized);
|
||||
const referenceName = nameInputElement.value.trim() || parameter.id;
|
||||
row.dataset.parameterType = normalized;
|
||||
row.style.setProperty("--overview-variable-color", getTypeColor(normalized));
|
||||
typeButton.dataset.variableType = normalized;
|
||||
typeButton.style.setProperty("--overview-variable-color", getTypeColor(normalized));
|
||||
typeButton.title = `Type: ${display}`;
|
||||
typeButton.setAttribute(
|
||||
"aria-label",
|
||||
@@ -6356,10 +6727,12 @@ export class BlueprintWorkspace {
|
||||
*/
|
||||
applySerializedWorkspace(payload) {
|
||||
this.isRestoring = true;
|
||||
this.#stopPeriodicUpdate();
|
||||
try {
|
||||
this.persistenceManager.cancelScheduledPersist();
|
||||
|
||||
this.contextMenuManager.hide();
|
||||
this.nodeContextMenu?.hide();
|
||||
this.#closeTypeDropdown();
|
||||
this.dragManager.removePaletteGhost();
|
||||
this.dragManager.removeNodeGhost();
|
||||
@@ -6368,6 +6741,7 @@ export class BlueprintWorkspace {
|
||||
this.connectionElements.forEach((path) => path.remove());
|
||||
this.nodeElements.clear();
|
||||
this.connectionElements.clear();
|
||||
this.connectionLayer.querySelector("defs")?.remove();
|
||||
this.inlineEditorManager.clear();
|
||||
this.nodeDragRotation.clear();
|
||||
this.dragManager.stopNodeRotationDecay();
|
||||
@@ -6410,7 +6784,8 @@ export class BlueprintWorkspace {
|
||||
entry.type === "string" ||
|
||||
entry.type === "boolean" ||
|
||||
entry.type === "any" ||
|
||||
entry.type === "table"
|
||||
entry.type === "table" ||
|
||||
entry.type === "color"
|
||||
? entry.type
|
||||
: "any";
|
||||
const defaultValue = this.#normalizeVariableDefault(
|
||||
@@ -6480,7 +6855,6 @@ export class BlueprintWorkspace {
|
||||
this.isPaletteVisible = paletteVisible;
|
||||
this.#applyPaletteVisibility(this.isPaletteVisible);
|
||||
|
||||
this.navigationManager.setBackgroundOffset({ x: 0, y: 0 });
|
||||
this.#updateProjectSettingsToggle();
|
||||
this.#renderOverview();
|
||||
this.#setInspectorView("default");
|
||||
@@ -6489,6 +6863,7 @@ export class BlueprintWorkspace {
|
||||
this.#refreshLuaOutput();
|
||||
} finally {
|
||||
this.isRestoring = false;
|
||||
this.#startPeriodicUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
184
scripts/ui/ColorSwatchPicker.js
Normal file
184
scripts/ui/ColorSwatchPicker.js
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Ordered PICO-8 palette hex strings, indices 0–15.
|
||||
*
|
||||
* @type {ReadonlyArray<string>}
|
||||
*/
|
||||
const PICO8_COLORS = Object.freeze([
|
||||
'#000002', '#1D2B53', '#7E2553', '#008751',
|
||||
'#AB5236', '#5F574F', '#C2C3C7', '#FFF1E8',
|
||||
'#FF004D', '#FFA300', '#FFEC27', '#00E436',
|
||||
'#29ADFF', '#83769C', '#FF77A8', '#FFCCAA',
|
||||
]);
|
||||
|
||||
/**
|
||||
* @typedef {{ element: HTMLElement, setValue: (index: number) => void }} ColorSwatchPickerInstance
|
||||
*/
|
||||
|
||||
/**
|
||||
* Builds an inline PICO-8 color swatch picker control.
|
||||
*
|
||||
* Renders a colored trigger button that opens a 4×4 grid of all 16 PICO-8
|
||||
* palette swatches. Clicking a swatch fires `onChange` with the selected
|
||||
* palette index (0–15) and closes the dropdown.
|
||||
*
|
||||
* @param {{
|
||||
* id?: string,
|
||||
* value: number,
|
||||
* onChange: (index: number) => void,
|
||||
* }} options Picker configuration.
|
||||
* @returns {ColorSwatchPickerInstance}
|
||||
*/
|
||||
export function createColorSwatchPicker({ id, value, onChange }) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'color-swatch-picker';
|
||||
|
||||
const trigger = document.createElement('button');
|
||||
trigger.type = 'button';
|
||||
trigger.className = 'color-swatch-trigger';
|
||||
if (id) {
|
||||
trigger.id = id;
|
||||
}
|
||||
trigger.setAttribute('aria-haspopup', 'menu');
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
wrapper.appendChild(trigger);
|
||||
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'color-swatch-dropdown';
|
||||
dropdown.setAttribute('role', 'menu');
|
||||
// Appended to body on open so it escapes overflow:hidden on node cards.
|
||||
|
||||
/** @type {Array<HTMLButtonElement>} */
|
||||
const swatchButtons = [];
|
||||
|
||||
/** @type {((event: MouseEvent) => void) | null} */
|
||||
let outsideClickHandler = null;
|
||||
|
||||
/** @type {(() => void) | null} */
|
||||
let scrollAbortHandler = null;
|
||||
|
||||
const repositionDropdown = () => {
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
dropdown.style.top = `${rect.bottom + 4}px`;
|
||||
dropdown.style.left = `${rect.left}px`;
|
||||
};
|
||||
|
||||
const closeDropdown = () => {
|
||||
if (dropdown.isConnected) {
|
||||
dropdown.remove();
|
||||
}
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
if (outsideClickHandler) {
|
||||
document.removeEventListener('click', outsideClickHandler, true);
|
||||
outsideClickHandler = null;
|
||||
}
|
||||
if (scrollAbortHandler) {
|
||||
document.removeEventListener('scroll', scrollAbortHandler, true);
|
||||
scrollAbortHandler = null;
|
||||
}
|
||||
};
|
||||
|
||||
const openDropdown = () => {
|
||||
repositionDropdown();
|
||||
document.body.appendChild(dropdown);
|
||||
trigger.setAttribute('aria-expanded', 'true');
|
||||
outsideClickHandler = (event) => {
|
||||
if (
|
||||
!(event.target instanceof Node) ||
|
||||
(!wrapper.contains(event.target) && !dropdown.contains(event.target))
|
||||
) {
|
||||
closeDropdown();
|
||||
}
|
||||
};
|
||||
scrollAbortHandler = () => closeDropdown();
|
||||
document.addEventListener('click', outsideClickHandler, true);
|
||||
document.addEventListener('scroll', scrollAbortHandler, true);
|
||||
};
|
||||
|
||||
let currentIndex = clampColorIndex(value);
|
||||
|
||||
/**
|
||||
* Updates the trigger button to reflect the supplied palette index.
|
||||
*
|
||||
* @param {number} index Palette index (0–15).
|
||||
*/
|
||||
const updateTrigger = (index) => {
|
||||
const hex = PICO8_COLORS[index] ?? '#000000';
|
||||
trigger.style.backgroundColor = hex;
|
||||
trigger.title = `Color ${index}`;
|
||||
trigger.setAttribute('aria-label', `Color ${index}`);
|
||||
};
|
||||
|
||||
PICO8_COLORS.forEach((hex, index) => {
|
||||
const swatch = document.createElement('button');
|
||||
swatch.type = 'button';
|
||||
swatch.className = 'color-swatch-option';
|
||||
swatch.style.backgroundColor = hex;
|
||||
swatch.dataset.colorIndex = String(index);
|
||||
swatch.title = String(index);
|
||||
swatch.setAttribute('role', 'menuitemradio');
|
||||
swatch.setAttribute('aria-label', `Color ${index}`);
|
||||
swatch.setAttribute('aria-checked', 'false');
|
||||
|
||||
swatch.addEventListener('pointerdown', (event) => {
|
||||
event.stopPropagation();
|
||||
});
|
||||
swatch.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const selected = index;
|
||||
closeDropdown();
|
||||
if (selected !== currentIndex) {
|
||||
currentIndex = selected;
|
||||
updateTrigger(selected);
|
||||
swatchButtons.forEach((btn, i) => {
|
||||
btn.setAttribute('aria-checked', i === selected ? 'true' : 'false');
|
||||
});
|
||||
onChange(selected);
|
||||
}
|
||||
});
|
||||
|
||||
dropdown.appendChild(swatch);
|
||||
swatchButtons.push(swatch);
|
||||
});
|
||||
|
||||
trigger.addEventListener('pointerdown', (event) => {
|
||||
event.stopPropagation();
|
||||
});
|
||||
trigger.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
if (dropdown.isConnected) {
|
||||
closeDropdown();
|
||||
} else {
|
||||
openDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Updates the picker display without firing `onChange`.
|
||||
*
|
||||
* @param {number} newIndex Palette index (0–15).
|
||||
*/
|
||||
const setValue = (newIndex) => {
|
||||
currentIndex = clampColorIndex(newIndex);
|
||||
updateTrigger(currentIndex);
|
||||
swatchButtons.forEach((btn, i) => {
|
||||
btn.setAttribute('aria-checked', i === currentIndex ? 'true' : 'false');
|
||||
});
|
||||
};
|
||||
|
||||
setValue(currentIndex);
|
||||
|
||||
return { element: wrapper, setValue };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamps an arbitrary value to a valid PICO-8 palette index (0–15).
|
||||
*
|
||||
* @param {unknown} value Raw index candidate.
|
||||
* @returns {number}
|
||||
*/
|
||||
function clampColorIndex(value) {
|
||||
const n = typeof value === 'number' ? value : Number(value);
|
||||
const safe = Number.isFinite(n) ? Math.round(n) : 0;
|
||||
return Math.max(0, Math.min(15, safe));
|
||||
}
|
||||
385
scripts/ui/EmbeddedPreview.js
Normal file
385
scripts/ui/EmbeddedPreview.js
Normal file
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* Manages embedded Pico-8 cart preview within the application.
|
||||
*/
|
||||
export class EmbeddedPreview {
|
||||
/**
|
||||
* @param {object} options Configuration options
|
||||
* @param {() => string} options.getLuaCode Function to get generated Lua code
|
||||
*/
|
||||
constructor(options) {
|
||||
this.getLuaCode = options.getLuaCode;
|
||||
this.modal = null;
|
||||
this.canvas = null;
|
||||
this.isOpen = false;
|
||||
this.isRunning = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the preview modal if it doesn't exist
|
||||
*/
|
||||
#ensureModal() {
|
||||
if (this.modal) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create modal structure
|
||||
this.modal = document.createElement('div');
|
||||
this.modal.className = 'preview-modal';
|
||||
this.modal.setAttribute('role', 'dialog');
|
||||
this.modal.setAttribute('aria-modal', 'true');
|
||||
this.modal.setAttribute('aria-labelledby', 'previewModalTitle');
|
||||
this.modal.hidden = true;
|
||||
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'preview-modal__backdrop';
|
||||
backdrop.addEventListener('click', () => this.close());
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'preview-modal__panel';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'preview-modal__header';
|
||||
|
||||
// Header controls (left side)
|
||||
const headerControls = document.createElement('div');
|
||||
headerControls.className = 'preview-header-controls';
|
||||
|
||||
const refreshButton = document.createElement('button');
|
||||
refreshButton.type = 'button';
|
||||
refreshButton.className = 'preview-header-btn';
|
||||
refreshButton.setAttribute('aria-label', 'Reload cart');
|
||||
refreshButton.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>';
|
||||
refreshButton.addEventListener('click', () => this.#runCart());
|
||||
|
||||
const stopButton = document.createElement('button');
|
||||
stopButton.type = 'button';
|
||||
stopButton.className = 'preview-header-btn preview-header-btn--stop';
|
||||
stopButton.setAttribute('aria-label', 'Stop cart');
|
||||
stopButton.innerHTML = '<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><rect x="4" y="4" width="16" height="16" rx="2"/></svg>';
|
||||
stopButton.addEventListener('click', () => this.#stopCart());
|
||||
|
||||
const saveButton = document.createElement('button');
|
||||
saveButton.type = 'button';
|
||||
saveButton.className = 'preview-header-btn preview-header-btn--save';
|
||||
saveButton.setAttribute('aria-label', 'Save .p8');
|
||||
saveButton.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
|
||||
saveButton.addEventListener('click', () => this.#downloadCart());
|
||||
|
||||
headerControls.appendChild(refreshButton);
|
||||
headerControls.appendChild(stopButton);
|
||||
headerControls.appendChild(saveButton);
|
||||
|
||||
const closeButton = document.createElement('button');
|
||||
closeButton.type = 'button';
|
||||
closeButton.className = 'preview-modal__close';
|
||||
closeButton.setAttribute('aria-label', 'Close preview');
|
||||
closeButton.textContent = '×';
|
||||
closeButton.addEventListener('click', () => this.close());
|
||||
|
||||
header.appendChild(headerControls);
|
||||
header.appendChild(closeButton);
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'preview-modal__content';
|
||||
|
||||
// Create iframe for PICO-8 player
|
||||
this.iframe = document.createElement('iframe');
|
||||
this.iframe.className = 'preview-iframe';
|
||||
this.iframe.allow = 'gamepad';
|
||||
this.iframe.style.display = 'none';
|
||||
|
||||
// Create the canvas for rendering placeholder
|
||||
this.canvas = document.createElement('canvas');
|
||||
this.canvas.width = 128;
|
||||
this.canvas.height = 128;
|
||||
this.canvas.className = 'preview-canvas';
|
||||
|
||||
content.appendChild(this.iframe);
|
||||
content.appendChild(this.canvas);
|
||||
|
||||
// Refocus iframe when clicking in the modal to ensure input keeps working
|
||||
panel.addEventListener('click', (e) => {
|
||||
if (this.iframe.style.display !== 'none') {
|
||||
setTimeout(() => {
|
||||
this.iframe.focus();
|
||||
if (this.iframe.contentWindow) {
|
||||
this.iframe.contentWindow.focus();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
|
||||
panel.appendChild(header);
|
||||
panel.appendChild(content);
|
||||
|
||||
this.modal.appendChild(backdrop);
|
||||
this.modal.appendChild(panel);
|
||||
document.body.appendChild(this.modal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the cart in the embedded preview
|
||||
*/
|
||||
async #runCart() {
|
||||
try {
|
||||
const luaCode = this.getLuaCode();
|
||||
|
||||
if (!luaCode || !luaCode.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRunning = true;
|
||||
|
||||
// Generate cart content
|
||||
const cartContent = this.#generateCartContent(luaCode);
|
||||
|
||||
// Load cart in iframe using PICO-8 web player
|
||||
await this.#loadCartInPlayer(cartContent);
|
||||
} catch (error) {
|
||||
console.error('Error running cart:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a cart into the PICO-8 web player
|
||||
*
|
||||
* @param {string} cartData The .p8 cart file content as a string
|
||||
*/
|
||||
async #loadCartInPlayer(cartData) {
|
||||
try {
|
||||
const electronAPI = window.electronAPI;
|
||||
if (!electronAPI?.writePlayerHtml) {
|
||||
throw new Error('Player IPC not available');
|
||||
}
|
||||
|
||||
const cartB64 = btoa(unescape(encodeURIComponent(cartData)));
|
||||
|
||||
// HTML references pico8_edu.js by relative path — both files live in public/.
|
||||
const playerHtml = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: #1a1a2e; display: flex; align-items: center; justify-content: center; width: 100vw; height: 100vh; overflow: hidden; }
|
||||
#canvas { image-rendering: pixelated; image-rendering: crisp-edges; width: 512px !important; height: 512px !important; border: 8px solid #000; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="canvas" width="128" height="128"></canvas>
|
||||
<script>
|
||||
var pico8_state = [];
|
||||
var pico8_buttons = [0,0,0,0,0,0,0,0];
|
||||
var codo_command = 0;
|
||||
var _cartname = ['untitled.p8'];
|
||||
var codo_key_buffer = [];
|
||||
var p8_keyboard_state = 0;
|
||||
var pico8_mouse = [];
|
||||
var pico8_gamepads = { count: 0 };
|
||||
var pico8_gpio = new Array(128).fill(0);
|
||||
var p8_dropped_cart = null;
|
||||
var p8_dropped_cart_name = '';
|
||||
|
||||
var CART_B64 = '${cartB64}';
|
||||
|
||||
// Silence all XHR — PICO-8 makes BBS metadata calls that crash from file:// origin.
|
||||
(function() {
|
||||
function MockXHR() {
|
||||
this.onload = null; this.onerror = null; this.onreadystatechange = null;
|
||||
this.readyState = 0; this.status = 0; this.statusText = '';
|
||||
this.response = null; this.responseText = ''; this.responseType = '';
|
||||
}
|
||||
MockXHR.UNSENT=0; MockXHR.OPENED=1; MockXHR.HEADERS_RECEIVED=2; MockXHR.LOADING=3; MockXHR.DONE=4;
|
||||
MockXHR.prototype.open = function() { this.readyState = 1; };
|
||||
MockXHR.prototype.send = function() {
|
||||
var self = this;
|
||||
setTimeout(function() {
|
||||
self.readyState = 4; self.status = 200; self.statusText = 'OK';
|
||||
self.response = self.responseType === 'arraybuffer' ? new ArrayBuffer(0) : '';
|
||||
self.responseText = '';
|
||||
if (typeof self.onload === 'function') self.onload({ target: self });
|
||||
if (typeof self.onreadystatechange === 'function') self.onreadystatechange();
|
||||
}, 0);
|
||||
};
|
||||
MockXHR.prototype.setRequestHeader = function() {};
|
||||
MockXHR.prototype.getResponseHeader = function() { return null; };
|
||||
MockXHR.prototype.getAllResponseHeaders = function() { return ''; };
|
||||
MockXHR.prototype.abort = function() {};
|
||||
MockXHR.prototype.addEventListener = function(e, fn) {
|
||||
if (e==='load') this.onload=fn;
|
||||
if (e==='error') this.onerror=fn;
|
||||
if (e==='readystatechange') this.onreadystatechange=fn;
|
||||
};
|
||||
MockXHR.prototype.removeEventListener = function() {};
|
||||
window.XMLHttpRequest = MockXHR;
|
||||
})();
|
||||
|
||||
var Module = {
|
||||
arguments: [],
|
||||
canvas: document.getElementById('canvas'),
|
||||
preRun: [function() {
|
||||
if (typeof IDBFS !== 'undefined' && typeof MEMFS !== 'undefined') {
|
||||
var orig = FS.mount.bind(FS);
|
||||
FS.mount = function(type, opts, mp) { return orig(type===IDBFS?MEMFS:type, opts, mp); };
|
||||
}
|
||||
FS.syncfs = function(populate, callback) {
|
||||
var cb = typeof callback==='function' ? callback : typeof populate==='function' ? populate : null;
|
||||
if (cb) setTimeout(function(){ cb(null); }, 0);
|
||||
};
|
||||
}],
|
||||
postRun: [function() {
|
||||
setTimeout(function() {
|
||||
p8_dropped_cart = 'data:application/octet-stream;base64,' + CART_B64;
|
||||
p8_dropped_cart_name = 'picograph.p8';
|
||||
codo_command = 9;
|
||||
|
||||
// After cart loads, inject "run" + Enter to execute it
|
||||
setTimeout(function() {
|
||||
// Push keystrokes: r u n Enter (lowercase ASCII)
|
||||
codo_key_buffer.push(114, 117, 110, 13);
|
||||
}, 300);
|
||||
}, 200);
|
||||
}],
|
||||
print: function(t) { console.log('P8:', t); },
|
||||
printErr: function(t) { console.error('P8:', t); },
|
||||
setStatus: function() {}
|
||||
};
|
||||
|
||||
function pico8_audio_context_suspended() { return false; }
|
||||
</script>
|
||||
<script src="./pico8_edu.js"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const { filePath } = await electronAPI.writePlayerHtml(playerHtml);
|
||||
const fileUrl = 'file:///' + filePath.replace(/\\/g, '/') + '?t=' + Date.now();
|
||||
|
||||
this.iframe.src = 'about:blank';
|
||||
this.iframe.src = fileUrl;
|
||||
this.iframe.style.display = 'block';
|
||||
this.canvas.style.display = 'none';
|
||||
|
||||
// Focus iframe once loaded so it captures keyboard/gamepad input
|
||||
this.iframe.onload = () => {
|
||||
setTimeout(() => {
|
||||
this.iframe.focus();
|
||||
if (this.iframe.contentWindow) {
|
||||
this.iframe.contentWindow.focus();
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load player:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the running cart
|
||||
*/
|
||||
#stopCart() {
|
||||
this.isRunning = false;
|
||||
|
||||
// Hide iframe and show canvas
|
||||
if (this.iframe) {
|
||||
this.iframe.src = 'about:blank';
|
||||
this.iframe.style.display = 'none';
|
||||
}
|
||||
|
||||
if (this.canvas) {
|
||||
this.canvas.style.display = 'block';
|
||||
const ctx = this.canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fillRect(0, 0, 128, 128);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the current cart as a .p8 file
|
||||
*/
|
||||
#downloadCart() {
|
||||
try {
|
||||
const luaCode = this.getLuaCode();
|
||||
const cartContent = this.#generateCartContent(luaCode);
|
||||
|
||||
const blob = new Blob([cartContent], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `picograph_${Date.now()}.p8`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Error downloading cart:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the .p8 cart file content
|
||||
*
|
||||
* @param {string} luaCode Generated Lua code
|
||||
* @returns {string}
|
||||
*/
|
||||
#generateCartContent(luaCode) {
|
||||
const hasUpdateCallback = /function\s+_update(?:60)?\b|_update(?:60)?\s*=/.test(luaCode);
|
||||
const hasDrawCallback = /function\s+_draw\b|_draw\s*=/.test(luaCode);
|
||||
const fallback = (hasUpdateCallback && hasDrawCallback) ? '' :
|
||||
(!hasUpdateCallback ? '\nfunction _update()\nend\n' : '') +
|
||||
(!hasDrawCallback ? '\nfunction _draw()\n cls()\n print("picograph ok",2,60,7)\nend\n' : '');
|
||||
const content = `pico-8 cartridge // http://www.pico-8.com
|
||||
version 41
|
||||
__lua__
|
||||
${luaCode}${fallback}
|
||||
__gfx__
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
`;
|
||||
// Ensure Unix line endings (PICO-8 tools expect \n, not \r\n)
|
||||
return content.replace(/\r\n/g, '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the preview modal
|
||||
*/
|
||||
open() {
|
||||
this.#ensureModal();
|
||||
this.modal.hidden = false;
|
||||
this.isOpen = true;
|
||||
|
||||
// Auto-run the cart
|
||||
this.#runCart();
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the preview modal
|
||||
*/
|
||||
close() {
|
||||
this.#stopCart();
|
||||
if (this.modal) {
|
||||
this.modal.hidden = true;
|
||||
this.isOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the preview modal
|
||||
*/
|
||||
toggle() {
|
||||
if (this.isOpen) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
}
|
||||
102
scripts/ui/PreviewManager.js
Normal file
102
scripts/ui/PreviewManager.js
Normal file
@@ -0,0 +1,102 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
/**
|
||||
* Manages cart preview functionality
|
||||
*/
|
||||
export class PreviewManager {
|
||||
/**
|
||||
* @param {object} options Configuration options
|
||||
* @param {() => string} options.getLuaCode Function to get generated Lua code
|
||||
* @param {string} options.fake08Path Path to fake-08 executable
|
||||
*/
|
||||
constructor(options) {
|
||||
this.getLuaCode = options.getLuaCode;
|
||||
this.fake08Path = options.fake08Path || null;
|
||||
this.currentProcess = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports the current graph as a .p8 file
|
||||
*
|
||||
* @param {string} outputPath Path to save the .p8 file
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async exportCart(outputPath) {
|
||||
const luaCode = this.getLuaCode();
|
||||
|
||||
// Create basic .p8 cart format
|
||||
const cartContent = `pico-8 cartridge // http://www.pico-8.com
|
||||
version 41
|
||||
__lua__
|
||||
${luaCode}
|
||||
__gfx__
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
`;
|
||||
|
||||
await writeFile(outputPath, cartContent, 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Launches the preview with fake-08
|
||||
*
|
||||
* @param {string} cartPath Path to the .p8 file
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async launchPreview(cartPath) {
|
||||
if (!this.fake08Path) {
|
||||
throw new Error('fake-08 path not configured');
|
||||
}
|
||||
|
||||
// Kill existing preview if running
|
||||
if (this.currentProcess) {
|
||||
this.currentProcess.kill();
|
||||
this.currentProcess = null;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.currentProcess = spawn(this.fake08Path, [cartPath]);
|
||||
|
||||
this.currentProcess.on('error', (err) => {
|
||||
console.error('Failed to launch preview:', err);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
this.currentProcess.on('exit', (code) => {
|
||||
console.log(`Preview exited with code ${code}`);
|
||||
this.currentProcess = null;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports and previews the current cart
|
||||
*
|
||||
* @param {string} [tempPath] Optional path for temporary cart file
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async exportAndPreview(tempPath = null) {
|
||||
const outputPath = tempPath || join(process.cwd(), 'temp_preview.p8');
|
||||
|
||||
await this.exportCart(outputPath);
|
||||
|
||||
if (this.fake08Path) {
|
||||
await this.launchPreview(outputPath);
|
||||
} else {
|
||||
console.log(`Cart exported to: ${outputPath}`);
|
||||
console.log('fake-08 not configured - cannot launch preview');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops any running preview
|
||||
*/
|
||||
stopPreview() {
|
||||
if (this.currentProcess) {
|
||||
this.currentProcess.kill();
|
||||
this.currentProcess = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
622
scripts/ui/SpriteEditor.js
Normal file
622
scripts/ui/SpriteEditor.js
Normal file
@@ -0,0 +1,622 @@
|
||||
import { SpriteSheet } from "../core/SpriteSheet.js";
|
||||
|
||||
/**
|
||||
* Interactive sprite sheet editor for PICO-8.
|
||||
*/
|
||||
export class SpriteEditor {
|
||||
/**
|
||||
* @param {HTMLElement} container Parent element for the editor.
|
||||
* @param {SpriteSheet} [spriteSheet] Existing sprite sheet or creates new one.
|
||||
*/
|
||||
constructor(container, spriteSheet = null) {
|
||||
this.container = container;
|
||||
this.spriteSheet = spriteSheet || new SpriteSheet();
|
||||
|
||||
/** @type {number} Currently selected color (0-15) */
|
||||
this.selectedColor = 7;
|
||||
|
||||
/** @type {number} Currently selected sprite (0-255) */
|
||||
this.selectedSprite = 0;
|
||||
|
||||
/** @type {string} Current tool: "pen", "fill", "line", "rect", "circle", "erase" */
|
||||
this.currentTool = "pen";
|
||||
|
||||
/** @type {number} Zoom level for sprite sheet canvas */
|
||||
this.sheetZoom = 2;
|
||||
|
||||
/** @type {number} Zoom level for sprite preview */
|
||||
this.previewZoom = 8;
|
||||
|
||||
/** @type {Object|null} Drawing state for line/rect tools */
|
||||
this.drawStart = null;
|
||||
|
||||
this.#createUI();
|
||||
this.#attachEvents();
|
||||
this.#render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the UI structure.
|
||||
*/
|
||||
#createUI() {
|
||||
this.container.innerHTML = "";
|
||||
this.container.className = "sprite-editor";
|
||||
|
||||
// Toolbar
|
||||
const toolbar = document.createElement("div");
|
||||
toolbar.className = "sprite-toolbar";
|
||||
|
||||
const tools = [
|
||||
{ id: "pen", label: "Pen", icon: "✏️" },
|
||||
{ id: "fill", label: "Fill", icon: "🪣" },
|
||||
{ id: "line", label: "Line", icon: "📏" },
|
||||
{ id: "rect", label: "Rect", icon: "⬜" },
|
||||
{ id: "circle", label: "Circle", icon: "⭕" },
|
||||
{ id: "erase", label: "Erase", icon: "🧹" }
|
||||
];
|
||||
|
||||
tools.forEach((tool) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "sprite-tool-btn";
|
||||
btn.dataset.tool = tool.id;
|
||||
btn.title = tool.label;
|
||||
btn.textContent = tool.icon;
|
||||
if (tool.id === this.currentTool) {
|
||||
btn.classList.add("active");
|
||||
}
|
||||
toolbar.appendChild(btn);
|
||||
});
|
||||
|
||||
this.container.appendChild(toolbar);
|
||||
|
||||
// Main content area
|
||||
const content = document.createElement("div");
|
||||
content.className = "sprite-content";
|
||||
|
||||
// Left panel: Sprite sheet
|
||||
const leftPanel = document.createElement("div");
|
||||
leftPanel.className = "sprite-panel sprite-sheet-panel";
|
||||
|
||||
const sheetLabel = document.createElement("div");
|
||||
sheetLabel.className = "sprite-panel-label";
|
||||
sheetLabel.textContent = "Sprite Sheet (128x128)";
|
||||
leftPanel.appendChild(sheetLabel);
|
||||
|
||||
this.sheetCanvas = document.createElement("canvas");
|
||||
this.sheetCanvas.className = "sprite-sheet-canvas";
|
||||
this.sheetCanvas.width = 128 * this.sheetZoom;
|
||||
this.sheetCanvas.height = 128 * this.sheetZoom;
|
||||
this.sheetCtx = this.sheetCanvas.getContext("2d", { alpha: false });
|
||||
this.sheetCtx.imageSmoothingEnabled = false;
|
||||
leftPanel.appendChild(this.sheetCanvas);
|
||||
|
||||
content.appendChild(leftPanel);
|
||||
|
||||
// Right panel: Preview and palette
|
||||
const rightPanel = document.createElement("div");
|
||||
rightPanel.className = "sprite-panel sprite-controls-panel";
|
||||
|
||||
// Sprite preview
|
||||
const previewContainer = document.createElement("div");
|
||||
previewContainer.className = "sprite-preview-container";
|
||||
|
||||
const previewLabel = document.createElement("div");
|
||||
previewLabel.className = "sprite-panel-label";
|
||||
previewLabel.textContent = "Sprite Preview";
|
||||
previewContainer.appendChild(previewLabel);
|
||||
|
||||
this.previewCanvas = document.createElement("canvas");
|
||||
this.previewCanvas.className = "sprite-preview-canvas";
|
||||
this.previewCanvas.width = 8 * this.previewZoom;
|
||||
this.previewCanvas.height = 8 * this.previewZoom;
|
||||
this.previewCtx = this.previewCanvas.getContext("2d", { alpha: false });
|
||||
this.previewCtx.imageSmoothingEnabled = false;
|
||||
previewContainer.appendChild(this.previewCanvas);
|
||||
|
||||
this.spriteIndexLabel = document.createElement("div");
|
||||
this.spriteIndexLabel.className = "sprite-index-label";
|
||||
this.spriteIndexLabel.textContent = `Sprite: ${this.selectedSprite}`;
|
||||
previewContainer.appendChild(this.spriteIndexLabel);
|
||||
|
||||
rightPanel.appendChild(previewContainer);
|
||||
|
||||
// Color palette
|
||||
const paletteContainer = document.createElement("div");
|
||||
paletteContainer.className = "sprite-palette-container";
|
||||
|
||||
const paletteLabel = document.createElement("div");
|
||||
paletteLabel.className = "sprite-panel-label";
|
||||
paletteLabel.textContent = "Palette";
|
||||
paletteContainer.appendChild(paletteLabel);
|
||||
|
||||
this.paletteElement = document.createElement("div");
|
||||
this.paletteElement.className = "sprite-palette";
|
||||
|
||||
this.spriteSheet.palette.forEach((color, index) => {
|
||||
const swatch = document.createElement("div");
|
||||
swatch.className = "sprite-palette-swatch";
|
||||
swatch.style.backgroundColor = color;
|
||||
swatch.dataset.color = index;
|
||||
swatch.title = `Color ${index}`;
|
||||
if (index === this.selectedColor) {
|
||||
swatch.classList.add("selected");
|
||||
}
|
||||
this.paletteElement.appendChild(swatch);
|
||||
});
|
||||
|
||||
paletteContainer.appendChild(this.paletteElement);
|
||||
rightPanel.appendChild(paletteContainer);
|
||||
|
||||
// Actions
|
||||
const actionsContainer = document.createElement("div");
|
||||
actionsContainer.className = "sprite-actions";
|
||||
|
||||
const clearBtn = document.createElement("button");
|
||||
clearBtn.className = "sprite-action-btn";
|
||||
clearBtn.textContent = "Clear Sprite";
|
||||
clearBtn.dataset.action = "clear-sprite";
|
||||
actionsContainer.appendChild(clearBtn);
|
||||
|
||||
const clearAllBtn = document.createElement("button");
|
||||
clearAllBtn.className = "sprite-action-btn";
|
||||
clearAllBtn.textContent = "Clear All";
|
||||
clearAllBtn.dataset.action = "clear-all";
|
||||
actionsContainer.appendChild(clearAllBtn);
|
||||
|
||||
rightPanel.appendChild(actionsContainer);
|
||||
|
||||
content.appendChild(rightPanel);
|
||||
this.container.appendChild(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches event listeners.
|
||||
*/
|
||||
#attachEvents() {
|
||||
// Tool selection
|
||||
this.container.querySelectorAll(".sprite-tool-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
this.currentTool = btn.dataset.tool;
|
||||
this.container.querySelectorAll(".sprite-tool-btn").forEach((b) => {
|
||||
b.classList.remove("active");
|
||||
});
|
||||
btn.classList.add("active");
|
||||
});
|
||||
});
|
||||
|
||||
// Color palette
|
||||
this.paletteElement.querySelectorAll(".sprite-palette-swatch").forEach((swatch) => {
|
||||
swatch.addEventListener("click", (e) => {
|
||||
this.selectedColor = parseInt(swatch.dataset.color);
|
||||
this.paletteElement.querySelectorAll(".sprite-palette-swatch").forEach((s) => {
|
||||
s.classList.remove("selected");
|
||||
});
|
||||
swatch.classList.add("selected");
|
||||
});
|
||||
});
|
||||
|
||||
// Actions
|
||||
this.container.querySelectorAll(".sprite-action-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
const action = btn.dataset.action;
|
||||
if (action === "clear-sprite") {
|
||||
this.#clearCurrentSprite();
|
||||
} else if (action === "clear-all") {
|
||||
if (confirm("Clear all sprites? This cannot be undone.")) {
|
||||
this.spriteSheet.clear();
|
||||
this.#render();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Sheet canvas interactions
|
||||
let isDrawing = false;
|
||||
|
||||
this.sheetCanvas.addEventListener("mousedown", (e) => {
|
||||
isDrawing = true;
|
||||
const coords = this.#getSheetCoords(e);
|
||||
this.#handleToolStart(coords);
|
||||
});
|
||||
|
||||
this.sheetCanvas.addEventListener("mousemove", (e) => {
|
||||
if (!isDrawing) return;
|
||||
const coords = this.#getSheetCoords(e);
|
||||
this.#handleToolMove(coords);
|
||||
});
|
||||
|
||||
this.sheetCanvas.addEventListener("mouseup", (e) => {
|
||||
if (!isDrawing) return;
|
||||
isDrawing = false;
|
||||
const coords = this.#getSheetCoords(e);
|
||||
this.#handleToolEnd(coords);
|
||||
});
|
||||
|
||||
this.sheetCanvas.addEventListener("mouseleave", () => {
|
||||
isDrawing = false;
|
||||
this.drawStart = null;
|
||||
});
|
||||
|
||||
// Sprite selection
|
||||
this.sheetCanvas.addEventListener("click", (e) => {
|
||||
const coords = this.#getSheetCoords(e);
|
||||
const spriteX = Math.floor(coords.x / 8);
|
||||
const spriteY = Math.floor(coords.y / 8);
|
||||
this.selectedSprite = spriteY * 16 + spriteX;
|
||||
this.spriteIndexLabel.textContent = `Sprite: ${this.selectedSprite}`;
|
||||
this.#renderPreview();
|
||||
});
|
||||
|
||||
// Preview canvas editing
|
||||
let isDrawingPreview = false;
|
||||
|
||||
this.previewCanvas.addEventListener("mousedown", (e) => {
|
||||
isDrawingPreview = true;
|
||||
const coords = this.#getPreviewCoords(e);
|
||||
this.#drawPixelInPreview(coords);
|
||||
});
|
||||
|
||||
this.previewCanvas.addEventListener("mousemove", (e) => {
|
||||
if (!isDrawingPreview) return;
|
||||
const coords = this.#getPreviewCoords(e);
|
||||
this.#drawPixelInPreview(coords);
|
||||
});
|
||||
|
||||
this.previewCanvas.addEventListener("mouseup", () => {
|
||||
isDrawingPreview = false;
|
||||
});
|
||||
|
||||
this.previewCanvas.addEventListener("mouseleave", () => {
|
||||
isDrawingPreview = false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets sheet coordinates from mouse event.
|
||||
*
|
||||
* @param {MouseEvent} e Mouse event.
|
||||
* @returns {{x: number, y: number}}
|
||||
*/
|
||||
#getSheetCoords(e) {
|
||||
const rect = this.sheetCanvas.getBoundingClientRect();
|
||||
const x = Math.floor((e.clientX - rect.left) / this.sheetZoom);
|
||||
const y = Math.floor((e.clientY - rect.top) / this.sheetZoom);
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets preview coordinates from mouse event.
|
||||
*
|
||||
* @param {MouseEvent} e Mouse event.
|
||||
* @returns {{x: number, y: number}}
|
||||
*/
|
||||
#getPreviewCoords(e) {
|
||||
const rect = this.previewCanvas.getBoundingClientRect();
|
||||
const x = Math.floor((e.clientX - rect.left) / this.previewZoom);
|
||||
const y = Math.floor((e.clientY - rect.top) / this.previewZoom);
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles tool start.
|
||||
*
|
||||
* @param {{x: number, y: number}} coords Coordinates.
|
||||
*/
|
||||
#handleToolStart(coords) {
|
||||
if (this.currentTool === "pen") {
|
||||
this.#drawPixel(coords);
|
||||
} else if (this.currentTool === "erase") {
|
||||
this.#erasePixel(coords);
|
||||
} else if (this.currentTool === "fill") {
|
||||
this.#floodFill(coords);
|
||||
} else if (["line", "rect", "circle"].includes(this.currentTool)) {
|
||||
this.drawStart = coords;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles tool move.
|
||||
*
|
||||
* @param {{x: number, y: number}} coords Coordinates.
|
||||
*/
|
||||
#handleToolMove(coords) {
|
||||
if (this.currentTool === "pen") {
|
||||
this.#drawPixel(coords);
|
||||
} else if (this.currentTool === "erase") {
|
||||
this.#erasePixel(coords);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles tool end.
|
||||
*
|
||||
* @param {{x: number, y: number}} coords Coordinates.
|
||||
*/
|
||||
#handleToolEnd(coords) {
|
||||
if (!this.drawStart) return;
|
||||
|
||||
if (this.currentTool === "line") {
|
||||
this.#drawLine(this.drawStart, coords);
|
||||
} else if (this.currentTool === "rect") {
|
||||
this.#drawRect(this.drawStart, coords);
|
||||
} else if (this.currentTool === "circle") {
|
||||
this.#drawCircle(this.drawStart, coords);
|
||||
}
|
||||
|
||||
this.drawStart = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a pixel.
|
||||
*
|
||||
* @param {{x: number, y: number}} coords Coordinates.
|
||||
*/
|
||||
#drawPixel(coords) {
|
||||
this.spriteSheet.setPixel(coords.x, coords.y, this.selectedColor);
|
||||
this.#render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Erases a pixel.
|
||||
*
|
||||
* @param {{x: number, y: number}} coords Coordinates.
|
||||
*/
|
||||
#erasePixel(coords) {
|
||||
this.spriteSheet.setPixel(coords.x, coords.y, 0);
|
||||
this.#render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Flood fill from a point.
|
||||
*
|
||||
* @param {{x: number, y: number}} coords Starting coordinates.
|
||||
*/
|
||||
#floodFill(coords) {
|
||||
const targetColor = this.spriteSheet.getPixel(coords.x, coords.y);
|
||||
if (targetColor === this.selectedColor) return;
|
||||
|
||||
const stack = [coords];
|
||||
const visited = new Set();
|
||||
|
||||
while (stack.length > 0) {
|
||||
const { x, y } = stack.pop();
|
||||
const key = `${x},${y}`;
|
||||
|
||||
if (visited.has(key)) continue;
|
||||
if (x < 0 || x >= 128 || y < 0 || y >= 128) continue;
|
||||
if (this.spriteSheet.getPixel(x, y) !== targetColor) continue;
|
||||
|
||||
visited.add(key);
|
||||
this.spriteSheet.setPixel(x, y, this.selectedColor);
|
||||
|
||||
stack.push({ x: x + 1, y });
|
||||
stack.push({ x: x - 1, y });
|
||||
stack.push({ x, y: y + 1 });
|
||||
stack.push({ x, y: y - 1 });
|
||||
}
|
||||
|
||||
this.#render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a line.
|
||||
*
|
||||
* @param {{x: number, y: number}} start Start coordinates.
|
||||
* @param {{x: number, y: number}} end End coordinates.
|
||||
*/
|
||||
#drawLine(start, end) {
|
||||
const dx = Math.abs(end.x - start.x);
|
||||
const dy = Math.abs(end.y - start.y);
|
||||
const sx = start.x < end.x ? 1 : -1;
|
||||
const sy = start.y < end.y ? 1 : -1;
|
||||
let err = dx - dy;
|
||||
|
||||
let x = start.x;
|
||||
let y = start.y;
|
||||
|
||||
while (true) {
|
||||
this.spriteSheet.setPixel(x, y, this.selectedColor);
|
||||
|
||||
if (x === end.x && y === end.y) break;
|
||||
|
||||
const e2 = 2 * err;
|
||||
if (e2 > -dy) {
|
||||
err -= dy;
|
||||
x += sx;
|
||||
}
|
||||
if (e2 < dx) {
|
||||
err += dx;
|
||||
y += sy;
|
||||
}
|
||||
}
|
||||
|
||||
this.#render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a rectangle.
|
||||
*
|
||||
* @param {{x: number, y: number}} start Start coordinates.
|
||||
* @param {{x: number, y: number}} end End coordinates.
|
||||
*/
|
||||
#drawRect(start, end) {
|
||||
const x1 = Math.min(start.x, end.x);
|
||||
const y1 = Math.min(start.y, end.y);
|
||||
const x2 = Math.max(start.x, end.x);
|
||||
const y2 = Math.max(start.y, end.y);
|
||||
|
||||
for (let x = x1; x <= x2; x++) {
|
||||
this.spriteSheet.setPixel(x, y1, this.selectedColor);
|
||||
this.spriteSheet.setPixel(x, y2, this.selectedColor);
|
||||
}
|
||||
|
||||
for (let y = y1; y <= y2; y++) {
|
||||
this.spriteSheet.setPixel(x1, y, this.selectedColor);
|
||||
this.spriteSheet.setPixel(x2, y, this.selectedColor);
|
||||
}
|
||||
|
||||
this.#render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a circle.
|
||||
*
|
||||
* @param {{x: number, y: number}} center Center coordinates.
|
||||
* @param {{x: number, y: number}} edge Edge point.
|
||||
*/
|
||||
#drawCircle(center, edge) {
|
||||
const radius = Math.round(
|
||||
Math.sqrt((edge.x - center.x) ** 2 + (edge.y - center.y) ** 2)
|
||||
);
|
||||
|
||||
let x = radius;
|
||||
let y = 0;
|
||||
let err = 0;
|
||||
|
||||
while (x >= y) {
|
||||
this.spriteSheet.setPixel(center.x + x, center.y + y, this.selectedColor);
|
||||
this.spriteSheet.setPixel(center.x + y, center.y + x, this.selectedColor);
|
||||
this.spriteSheet.setPixel(center.x - y, center.y + x, this.selectedColor);
|
||||
this.spriteSheet.setPixel(center.x - x, center.y + y, this.selectedColor);
|
||||
this.spriteSheet.setPixel(center.x - x, center.y - y, this.selectedColor);
|
||||
this.spriteSheet.setPixel(center.x - y, center.y - x, this.selectedColor);
|
||||
this.spriteSheet.setPixel(center.x + y, center.y - x, this.selectedColor);
|
||||
this.spriteSheet.setPixel(center.x + x, center.y - y, this.selectedColor);
|
||||
|
||||
y += 1;
|
||||
err += 1 + 2 * y;
|
||||
if (2 * (err - x) + 1 > 0) {
|
||||
x -= 1;
|
||||
err += 1 - 2 * x;
|
||||
}
|
||||
}
|
||||
|
||||
this.#render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a pixel in the preview canvas.
|
||||
*
|
||||
* @param {{x: number, y: number}} coords Coordinates (0-7).
|
||||
*/
|
||||
#drawPixelInPreview(coords) {
|
||||
if (coords.x < 0 || coords.x >= 8 || coords.y < 0 || coords.y >= 8) return;
|
||||
|
||||
const sx = (this.selectedSprite % 16) * 8;
|
||||
const sy = Math.floor(this.selectedSprite / 16) * 8;
|
||||
|
||||
const color = this.currentTool === "erase" ? 0 : this.selectedColor;
|
||||
this.spriteSheet.setPixel(sx + coords.x, sy + coords.y, color);
|
||||
|
||||
this.#render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the currently selected sprite.
|
||||
*/
|
||||
#clearCurrentSprite() {
|
||||
const clearData = new Uint8Array(64);
|
||||
this.spriteSheet.setSprite(this.selectedSprite, clearData);
|
||||
this.#render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders all canvases.
|
||||
*/
|
||||
#render() {
|
||||
this.#renderSheet();
|
||||
this.#renderPreview();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the sprite sheet canvas.
|
||||
*/
|
||||
#renderSheet() {
|
||||
for (let y = 0; y < 128; y++) {
|
||||
for (let x = 0; x < 128; x++) {
|
||||
const color = this.spriteSheet.getPixel(x, y);
|
||||
this.sheetCtx.fillStyle = this.spriteSheet.palette[color];
|
||||
this.sheetCtx.fillRect(
|
||||
x * this.sheetZoom,
|
||||
y * this.sheetZoom,
|
||||
this.sheetZoom,
|
||||
this.sheetZoom
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw grid
|
||||
this.sheetCtx.strokeStyle = "rgba(255, 255, 255, 0.2)";
|
||||
this.sheetCtx.lineWidth = 1;
|
||||
|
||||
for (let i = 0; i <= 16; i++) {
|
||||
const pos = i * 8 * this.sheetZoom;
|
||||
this.sheetCtx.beginPath();
|
||||
this.sheetCtx.moveTo(pos, 0);
|
||||
this.sheetCtx.lineTo(pos, 128 * this.sheetZoom);
|
||||
this.sheetCtx.stroke();
|
||||
|
||||
this.sheetCtx.beginPath();
|
||||
this.sheetCtx.moveTo(0, pos);
|
||||
this.sheetCtx.lineTo(128 * this.sheetZoom, pos);
|
||||
this.sheetCtx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the sprite preview canvas.
|
||||
*/
|
||||
#renderPreview() {
|
||||
const sx = (this.selectedSprite % 16) * 8;
|
||||
const sy = Math.floor(this.selectedSprite / 16) * 8;
|
||||
|
||||
for (let y = 0; y < 8; y++) {
|
||||
for (let x = 0; x < 8; x++) {
|
||||
const color = this.spriteSheet.getPixel(sx + x, sy + y);
|
||||
this.previewCtx.fillStyle = this.spriteSheet.palette[color];
|
||||
this.previewCtx.fillRect(
|
||||
x * this.previewZoom,
|
||||
y * this.previewZoom,
|
||||
this.previewZoom,
|
||||
this.previewZoom
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw grid
|
||||
this.previewCtx.strokeStyle = "rgba(255, 255, 255, 0.3)";
|
||||
this.previewCtx.lineWidth = 1;
|
||||
|
||||
for (let i = 0; i <= 8; i++) {
|
||||
const pos = i * this.previewZoom;
|
||||
this.previewCtx.beginPath();
|
||||
this.previewCtx.moveTo(pos, 0);
|
||||
this.previewCtx.lineTo(pos, 8 * this.previewZoom);
|
||||
this.previewCtx.stroke();
|
||||
|
||||
this.previewCtx.beginPath();
|
||||
this.previewCtx.moveTo(0, pos);
|
||||
this.previewCtx.lineTo(8 * this.previewZoom, pos);
|
||||
this.previewCtx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the sprite sheet data.
|
||||
*
|
||||
* @returns {SpriteSheet}
|
||||
*/
|
||||
getSpriteSheet() {
|
||||
return this.spriteSheet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets sprite sheet data.
|
||||
*
|
||||
* @param {SpriteSheet} spriteSheet New sprite sheet.
|
||||
*/
|
||||
setSpriteSheet(spriteSheet) {
|
||||
this.spriteSheet = spriteSheet;
|
||||
this.#render();
|
||||
}
|
||||
}
|
||||
205
scripts/ui/SpriteEditorManager.js
Normal file
205
scripts/ui/SpriteEditorManager.js
Normal file
@@ -0,0 +1,205 @@
|
||||
import { SpriteEditor } from './SpriteEditor.js';
|
||||
import { SpriteSheet } from '../core/SpriteSheet.js';
|
||||
|
||||
/**
|
||||
* Manages the sprite editor modal integration.
|
||||
*/
|
||||
export class SpriteEditorManager {
|
||||
/**
|
||||
* @param {HTMLElement} modal Modal element.
|
||||
* @param {HTMLElement} container Container for sprite editor.
|
||||
*/
|
||||
constructor(modal, container) {
|
||||
this.modal = modal;
|
||||
this.container = container;
|
||||
this.spriteSheet = new SpriteSheet();
|
||||
this.editor = null;
|
||||
this.isOpen = false;
|
||||
|
||||
this.#setupModal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up modal event listeners.
|
||||
*/
|
||||
#setupModal() {
|
||||
// Close modal on backdrop click
|
||||
const backdrop = this.modal.querySelector('.modal__backdrop');
|
||||
backdrop?.addEventListener('click', () => this.close());
|
||||
|
||||
// Close modal on close button click
|
||||
const closeButtons = this.modal.querySelectorAll('[data-modal-close]');
|
||||
closeButtons.forEach((btn) => {
|
||||
btn.addEventListener('click', () => this.close());
|
||||
});
|
||||
|
||||
// Export to .p8
|
||||
const exportBtn = document.getElementById('exportP8Button');
|
||||
exportBtn?.addEventListener('click', () => this.#exportToP8());
|
||||
|
||||
// Import from .p8
|
||||
const importBtn = document.getElementById('importP8Button');
|
||||
importBtn?.addEventListener('click', () => this.#importFromP8());
|
||||
|
||||
// Initialize editor on first open
|
||||
this.modal.addEventListener('transitionend', () => {
|
||||
if (this.isOpen && !this.editor) {
|
||||
this.#initializeEditor();
|
||||
}
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the sprite editor.
|
||||
*/
|
||||
#initializeEditor() {
|
||||
if (this.editor) return;
|
||||
|
||||
this.editor = new SpriteEditor(this.container, this.spriteSheet);
|
||||
console.log('Sprite editor initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the sprite editor modal.
|
||||
*/
|
||||
open() {
|
||||
if (this.isOpen) return;
|
||||
|
||||
this.isOpen = true;
|
||||
this.modal.removeAttribute('hidden');
|
||||
this.modal.setAttribute('aria-hidden', 'false');
|
||||
document.body.classList.add('modal-open');
|
||||
|
||||
// Initialize editor immediately if not already done
|
||||
if (!this.editor) {
|
||||
this.#initializeEditor();
|
||||
}
|
||||
|
||||
// Focus trap
|
||||
requestAnimationFrame(() => {
|
||||
const closeBtn = this.modal.querySelector('[data-modal-close]');
|
||||
closeBtn?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the sprite editor modal.
|
||||
*/
|
||||
close() {
|
||||
if (!this.isOpen) return;
|
||||
|
||||
this.isOpen = false;
|
||||
this.modal.setAttribute('hidden', '');
|
||||
this.modal.setAttribute('aria-hidden', 'true');
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports sprite data to .p8 format.
|
||||
*/
|
||||
#exportToP8() {
|
||||
if (!this.editor) return;
|
||||
|
||||
const gfxData = this.editor.getSpriteSheet().toP8Gfx();
|
||||
const flagData = this.editor.getSpriteSheet().toP8Flags();
|
||||
|
||||
const p8Content = `pico-8 cartridge // http://www.pico-8.com
|
||||
version 41
|
||||
__lua__
|
||||
-- generated by picoGraph
|
||||
-- paste your blueprinted code here
|
||||
|
||||
__gfx__
|
||||
${gfxData}
|
||||
__gff__
|
||||
${flagData}
|
||||
`;
|
||||
|
||||
const blob = new Blob([p8Content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'sprites.p8';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
// Show success feedback
|
||||
const exportBtn = document.getElementById('exportP8Button');
|
||||
if (exportBtn) {
|
||||
exportBtn.classList.add('is-success');
|
||||
setTimeout(() => {
|
||||
exportBtn.classList.remove('is-success');
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports sprite data from .p8 format.
|
||||
*/
|
||||
#importFromP8() {
|
||||
if (!this.editor) return;
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.p8';
|
||||
|
||||
input.addEventListener('change', async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
|
||||
// Extract __gfx__ section
|
||||
const gfxMatch = text.match(/__gfx__\s*\n([\s\S]*?)(?=\n__|$)/);
|
||||
if (gfxMatch) {
|
||||
this.editor.getSpriteSheet().fromP8Gfx(gfxMatch[1]);
|
||||
}
|
||||
|
||||
// Extract __gff__ section
|
||||
const gffMatch = text.match(/__gff__\s*\n([\s\S]*?)(?=\n__|$)/);
|
||||
if (gffMatch) {
|
||||
this.editor.getSpriteSheet().fromP8Flags(gffMatch[1]);
|
||||
}
|
||||
|
||||
// Re-render
|
||||
this.editor.setSpriteSheet(this.editor.getSpriteSheet());
|
||||
|
||||
// Show success feedback
|
||||
const importBtn = document.getElementById('importP8Button');
|
||||
if (importBtn) {
|
||||
importBtn.classList.add('is-success');
|
||||
setTimeout(() => {
|
||||
importBtn.classList.remove('is-success');
|
||||
}, 1500);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to import sprite data:', error);
|
||||
alert('Failed to import sprite data. Please ensure the file is a valid .p8 file.');
|
||||
}
|
||||
});
|
||||
|
||||
input.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current sprite sheet data.
|
||||
*
|
||||
* @returns {SpriteSheet}
|
||||
*/
|
||||
getSpriteSheet() {
|
||||
return this.spriteSheet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets sprite sheet data.
|
||||
*
|
||||
* @param {SpriteSheet} spriteSheet New sprite sheet.
|
||||
*/
|
||||
setSpriteSheet(spriteSheet) {
|
||||
this.spriteSheet = spriteSheet;
|
||||
if (this.editor) {
|
||||
this.editor.setSpriteSheet(spriteSheet);
|
||||
}
|
||||
}
|
||||
}
|
||||
114
scripts/ui/TileManager.js
Normal file
114
scripts/ui/TileManager.js
Normal file
@@ -0,0 +1,114 @@
|
||||
import { TileSetBrowser } from "./TileSetBrowser.js";
|
||||
|
||||
/**
|
||||
* Manages the tile browser modal and provides access to tileset management.
|
||||
*/
|
||||
export class TileManager {
|
||||
constructor() {
|
||||
this.modal = null;
|
||||
this.browser = null;
|
||||
this.isOpen = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the tile manager.
|
||||
*/
|
||||
initialize() {
|
||||
this.modal = document.getElementById("tileModal");
|
||||
if (!this.modal) {
|
||||
console.error("Tile modal element not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const container = this.modal.querySelector(".tile-browser-container");
|
||||
if (!container) {
|
||||
console.error("Tile browser container not found");
|
||||
return;
|
||||
}
|
||||
|
||||
this.browser = new TileSetBrowser(container);
|
||||
|
||||
const closeBtn = this.modal.querySelector(".close-modal");
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener("click", () => this.close());
|
||||
}
|
||||
|
||||
this.modal.addEventListener("click", (e) => {
|
||||
if (e.target === this.modal) {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && this.isOpen) {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
|
||||
const tileBtn = document.getElementById("tileManagerButton");
|
||||
if (tileBtn) {
|
||||
tileBtn.addEventListener("click", () => this.open());
|
||||
}
|
||||
|
||||
this.#loadFromLocalStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the tile manager modal.
|
||||
*/
|
||||
open() {
|
||||
if (!this.modal) return;
|
||||
this.modal.style.display = "flex";
|
||||
this.isOpen = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the tile manager modal.
|
||||
*/
|
||||
close() {
|
||||
if (!this.modal) return;
|
||||
this.modal.style.display = "none";
|
||||
this.isOpen = false;
|
||||
this.#saveToLocalStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all tilesets.
|
||||
*
|
||||
* @returns {Array<TileSet>}
|
||||
*/
|
||||
getTileSets() {
|
||||
return this.browser ? this.browser.getTileSets() : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves tileset data to localStorage.
|
||||
*/
|
||||
#saveToLocalStorage() {
|
||||
if (!this.browser) return;
|
||||
|
||||
try {
|
||||
const data = this.browser.toJSON();
|
||||
localStorage.setItem("picograph_tilesets", JSON.stringify(data));
|
||||
} catch (error) {
|
||||
console.error("Failed to save tilesets:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads tileset data from localStorage.
|
||||
*/
|
||||
async #loadFromLocalStorage() {
|
||||
if (!this.browser) return;
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem("picograph_tilesets");
|
||||
if (stored) {
|
||||
const data = JSON.parse(stored);
|
||||
await this.browser.fromJSON(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load tilesets:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
511
scripts/ui/TileSetBrowser.js
Normal file
511
scripts/ui/TileSetBrowser.js
Normal file
@@ -0,0 +1,511 @@
|
||||
import { TileSet } from "../core/TileSet.js";
|
||||
|
||||
/**
|
||||
* Manages multiple tilesets (8x8 tiles in 128x128px sheets) and provides UI for browsing and editing externally.
|
||||
*/
|
||||
export class TileSetBrowser {
|
||||
/**
|
||||
* @param {HTMLElement} container Parent element for the browser.
|
||||
*/
|
||||
constructor(container) {
|
||||
this.container = container;
|
||||
|
||||
/** @type {Map<string, TileSet>} */
|
||||
this.tileSets = new Map();
|
||||
|
||||
/** @type {string | null} */
|
||||
this.selectedTileSetId = null;
|
||||
|
||||
/** @type {number | null} */
|
||||
this.selectedTileIndex = null;
|
||||
|
||||
this.#createUI();
|
||||
this.#attachEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the UI structure.
|
||||
*/
|
||||
#createUI() {
|
||||
this.container.innerHTML = "";
|
||||
this.container.className = "tileset-browser";
|
||||
|
||||
// Header with actions
|
||||
const header = document.createElement("div");
|
||||
header.className = "tileset-header";
|
||||
|
||||
const title = document.createElement("div");
|
||||
title.className = "tileset-title";
|
||||
title.textContent = "Tilesets";
|
||||
header.appendChild(title);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "tileset-actions";
|
||||
|
||||
const addBtn = document.createElement("button");
|
||||
addBtn.className = "tileset-action-btn";
|
||||
addBtn.textContent = "+ Add Tileset";
|
||||
addBtn.dataset.action = "add";
|
||||
actions.appendChild(addBtn);
|
||||
|
||||
header.appendChild(actions);
|
||||
this.container.appendChild(header);
|
||||
|
||||
// Tileset list
|
||||
this.tileSetList = document.createElement("div");
|
||||
this.tileSetList.className = "tileset-list";
|
||||
this.container.appendChild(this.tileSetList);
|
||||
|
||||
// Tile preview panel
|
||||
this.previewPanel = document.createElement("div");
|
||||
this.previewPanel.className = "tileset-preview-panel";
|
||||
this.previewPanel.innerHTML = `
|
||||
<div class="tileset-preview-header">
|
||||
<span class="tileset-preview-title">Tile Preview</span>
|
||||
</div>
|
||||
<div class="tileset-preview-content">
|
||||
<canvas class="tileset-preview-canvas"></canvas>
|
||||
<div class="tileset-preview-info">Select a tileset to preview</div>
|
||||
</div>
|
||||
`;
|
||||
this.container.appendChild(this.previewPanel);
|
||||
|
||||
this.previewCanvas = this.previewPanel.querySelector(".tileset-preview-canvas");
|
||||
this.previewCtx = this.previewCanvas.getContext("2d", { alpha: false });
|
||||
this.previewInfo = this.previewPanel.querySelector(".tileset-preview-info");
|
||||
|
||||
this.#renderEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches event listeners.
|
||||
*/
|
||||
#attachEvents() {
|
||||
// Add tileset button
|
||||
this.container.addEventListener("click", (e) => {
|
||||
const target = e.target;
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
|
||||
const actionBtn = target.closest("[data-action]");
|
||||
if (actionBtn) {
|
||||
const action = actionBtn.dataset.action;
|
||||
|
||||
if (action === "add") {
|
||||
this.#addTileSet();
|
||||
} else if (action === "open") {
|
||||
const id = actionBtn.dataset.id;
|
||||
if (id) this.#openExternal(id);
|
||||
} else if (action === "reload") {
|
||||
const id = actionBtn.dataset.id;
|
||||
if (id) this.#reloadTileSet(id);
|
||||
} else if (action === "remove") {
|
||||
const id = actionBtn.dataset.id;
|
||||
if (id) this.#removeTileSet(id);
|
||||
} else if (action === "edit-settings") {
|
||||
const id = actionBtn.dataset.id;
|
||||
if (id) this.#editTileSetSettings(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Tileset selection
|
||||
const tilesetItem = target.closest(".tileset-item");
|
||||
if (tilesetItem && tilesetItem.dataset.id) {
|
||||
this.#selectTileSet(tilesetItem.dataset.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Canvas tile selection
|
||||
this.previewCanvas.addEventListener("click", (e) => {
|
||||
if (!this.selectedTileSetId) return;
|
||||
|
||||
const tileSet = this.tileSets.get(this.selectedTileSetId);
|
||||
if (!tileSet || !tileSet.isLoaded) return;
|
||||
|
||||
const rect = this.previewCanvas.getBoundingClientRect();
|
||||
const x = Math.floor((e.clientX - rect.left) / (this.previewCanvas.width / tileSet.columns));
|
||||
const y = Math.floor((e.clientY - rect.top) / (this.previewCanvas.height / tileSet.rows));
|
||||
|
||||
this.selectedTileIndex = y * tileSet.columns + x;
|
||||
this.#renderPreview();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new tileset.
|
||||
*/
|
||||
async #addTileSet() {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/png";
|
||||
|
||||
input.addEventListener("change", async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const name = file.name.replace(/\.[^/.]+$/, "");
|
||||
|
||||
let filePath;
|
||||
let nativePath = "";
|
||||
|
||||
// If running in Electron, save the file to app data
|
||||
if (window.electronAPI?.saveTileset) {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
const result = await window.electronAPI.saveTileset(uint8Array, file.name);
|
||||
nativePath = result.filePath;
|
||||
// Convert to file:// URL for loading in Image element
|
||||
filePath = `file:///${result.filePath.replace(/\\/g, "/")}`;
|
||||
} else {
|
||||
// Fallback for non-Electron environments (dev/testing)
|
||||
filePath = URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
const tileSet = new TileSet({
|
||||
name,
|
||||
filePath,
|
||||
nativePath,
|
||||
tileWidth: 8,
|
||||
tileHeight: 8
|
||||
});
|
||||
|
||||
await tileSet.load();
|
||||
|
||||
this.tileSets.set(tileSet.id, tileSet);
|
||||
this.#render();
|
||||
this.#selectTileSet(tileSet.id);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to add tileset:", error);
|
||||
alert(`Failed to load tileset:\n${error.message || error}`);
|
||||
}
|
||||
});
|
||||
|
||||
input.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens tileset in external editor.
|
||||
*
|
||||
* @param {string} id Tileset ID.
|
||||
*/
|
||||
#openExternal(id) {
|
||||
const tileSet = this.tileSets.get(id);
|
||||
if (!tileSet) return;
|
||||
|
||||
// In Electron, show the file in explorer so user can open with their preferred editor
|
||||
if (window.electronAPI?.showTilesetInFolder && tileSet.nativePath) {
|
||||
window.electronAPI.showTilesetInFolder(tileSet.nativePath);
|
||||
} else {
|
||||
// Fallback: show message
|
||||
const pathToShow = tileSet.nativePath || tileSet.filePath;
|
||||
alert(`To edit this tileset, open:\n${pathToShow}\n\nIn your image editor of choice.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads a tileset from disk.
|
||||
*
|
||||
* @param {string} id Tileset ID.
|
||||
*/
|
||||
async #reloadTileSet(id) {
|
||||
const tileSet = this.tileSets.get(id);
|
||||
if (!tileSet) return;
|
||||
|
||||
try {
|
||||
await tileSet.reload();
|
||||
this.#render();
|
||||
if (this.selectedTileSetId === id) {
|
||||
this.#renderPreview();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to reload tileset:", error);
|
||||
alert(`Failed to reload tileset:\n${error.message || error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a tileset.
|
||||
*
|
||||
* @param {string} id Tileset ID.
|
||||
*/
|
||||
#removeTileSet(id) {
|
||||
if (!confirm("Remove this tileset?")) return;
|
||||
|
||||
this.tileSets.delete(id);
|
||||
|
||||
if (this.selectedTileSetId === id) {
|
||||
this.selectedTileSetId = null;
|
||||
this.selectedTileIndex = null;
|
||||
}
|
||||
|
||||
this.#render();
|
||||
this.#renderPreview();
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits tileset settings.
|
||||
*
|
||||
* @param {string} id Tileset ID.
|
||||
*/
|
||||
#editTileSetSettings(id) {
|
||||
const tileSet = this.tileSets.get(id);
|
||||
if (!tileSet) return;
|
||||
|
||||
const name = prompt("Tileset name:", tileSet.name);
|
||||
if (!name) return;
|
||||
|
||||
tileSet.name = name;
|
||||
this.#render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects a tileset.
|
||||
*
|
||||
* @param {string} id Tileset ID.
|
||||
*/
|
||||
#selectTileSet(id) {
|
||||
this.selectedTileSetId = id;
|
||||
this.selectedTileIndex = null;
|
||||
this.#render();
|
||||
this.#renderPreview();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the tileset list.
|
||||
*/
|
||||
#render() {
|
||||
if (this.tileSets.size === 0) {
|
||||
this.#renderEmpty();
|
||||
return;
|
||||
}
|
||||
|
||||
this.tileSetList.innerHTML = "";
|
||||
|
||||
this.tileSets.forEach((tileSet) => {
|
||||
const item = document.createElement("div");
|
||||
item.className = "tileset-item";
|
||||
item.dataset.id = tileSet.id;
|
||||
|
||||
if (this.selectedTileSetId === tileSet.id) {
|
||||
item.classList.add("selected");
|
||||
}
|
||||
|
||||
const preview = document.createElement("div");
|
||||
preview.className = "tileset-item-preview";
|
||||
|
||||
if (tileSet.isLoaded && tileSet.image) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
// Draw scaled down version
|
||||
const scale = Math.min(64 / tileSet.image.width, 64 / tileSet.image.height);
|
||||
const w = tileSet.image.width * scale;
|
||||
const h = tileSet.image.height * scale;
|
||||
ctx.drawImage(tileSet.image, 0, 0, w, h);
|
||||
|
||||
preview.appendChild(canvas);
|
||||
} else {
|
||||
preview.textContent = "?";
|
||||
}
|
||||
|
||||
item.appendChild(preview);
|
||||
|
||||
const info = document.createElement("div");
|
||||
info.className = "tileset-item-info";
|
||||
|
||||
const nameEl = document.createElement("div");
|
||||
nameEl.className = "tileset-item-name";
|
||||
nameEl.textContent = tileSet.name;
|
||||
info.appendChild(nameEl);
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "tileset-item-meta";
|
||||
meta.textContent = `128×128px • ${tileSet.getTileCount()} tiles (${tileSet.tileWidth}×${tileSet.tileHeight}px each)`;
|
||||
info.appendChild(meta);
|
||||
|
||||
item.appendChild(info);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "tileset-item-actions";
|
||||
|
||||
const openBtn = document.createElement("button");
|
||||
openBtn.className = "tileset-item-btn";
|
||||
openBtn.title = "Open in external editor";
|
||||
openBtn.dataset.action = "open";
|
||||
openBtn.dataset.id = tileSet.id;
|
||||
openBtn.textContent = "✏️";
|
||||
actions.appendChild(openBtn);
|
||||
|
||||
const reloadBtn = document.createElement("button");
|
||||
reloadBtn.className = "tileset-item-btn";
|
||||
reloadBtn.title = "Reload from disk";
|
||||
reloadBtn.dataset.action = "reload";
|
||||
reloadBtn.dataset.id = tileSet.id;
|
||||
reloadBtn.textContent = "🔄";
|
||||
actions.appendChild(reloadBtn);
|
||||
|
||||
const settingsBtn = document.createElement("button");
|
||||
settingsBtn.className = "tileset-item-btn";
|
||||
settingsBtn.title = "Settings";
|
||||
settingsBtn.dataset.action = "edit-settings";
|
||||
settingsBtn.dataset.id = tileSet.id;
|
||||
settingsBtn.textContent = "⚙️";
|
||||
actions.appendChild(settingsBtn);
|
||||
|
||||
const removeBtn = document.createElement("button");
|
||||
removeBtn.className = "tileset-item-btn tileset-item-btn--danger";
|
||||
removeBtn.title = "Remove tileset";
|
||||
removeBtn.dataset.action = "remove";
|
||||
removeBtn.dataset.id = tileSet.id;
|
||||
removeBtn.textContent = "🗑️";
|
||||
actions.appendChild(removeBtn);
|
||||
|
||||
item.appendChild(actions);
|
||||
|
||||
this.tileSetList.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the preview panel.
|
||||
*/
|
||||
#renderPreview() {
|
||||
if (!this.selectedTileSetId) {
|
||||
this.previewInfo.textContent = "Select a tileset to preview";
|
||||
this.previewInfo.style.display = "block";
|
||||
this.previewCanvas.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
const tileSet = this.tileSets.get(this.selectedTileSetId);
|
||||
if (!tileSet || !tileSet.isLoaded || !tileSet.image) {
|
||||
this.previewInfo.textContent = "Tileset not loaded";
|
||||
this.previewInfo.style.display = "block";
|
||||
this.previewCanvas.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
this.previewInfo.style.display = "none";
|
||||
this.previewCanvas.style.display = "block";
|
||||
|
||||
// Set canvas size
|
||||
const scale = 2;
|
||||
this.previewCanvas.width = tileSet.getWidth() * scale;
|
||||
this.previewCanvas.height = tileSet.getHeight() * scale;
|
||||
|
||||
this.previewCtx.imageSmoothingEnabled = false;
|
||||
|
||||
// Draw tileset
|
||||
this.previewCtx.drawImage(
|
||||
tileSet.image,
|
||||
0, 0,
|
||||
tileSet.image.width, tileSet.image.height,
|
||||
0, 0,
|
||||
this.previewCanvas.width, this.previewCanvas.height
|
||||
);
|
||||
|
||||
// Draw grid
|
||||
this.previewCtx.strokeStyle = "rgba(255, 255, 255, 0.3)";
|
||||
this.previewCtx.lineWidth = 1;
|
||||
|
||||
for (let x = 0; x <= tileSet.columns; x++) {
|
||||
const px = x * tileSet.tileWidth * scale;
|
||||
this.previewCtx.beginPath();
|
||||
this.previewCtx.moveTo(px, 0);
|
||||
this.previewCtx.lineTo(px, this.previewCanvas.height);
|
||||
this.previewCtx.stroke();
|
||||
}
|
||||
|
||||
for (let y = 0; y <= tileSet.rows; y++) {
|
||||
const py = y * tileSet.tileHeight * scale;
|
||||
this.previewCtx.beginPath();
|
||||
this.previewCtx.moveTo(0, py);
|
||||
this.previewCtx.lineTo(this.previewCanvas.width, py);
|
||||
this.previewCtx.stroke();
|
||||
}
|
||||
|
||||
// Highlight selected tile
|
||||
if (this.selectedTileIndex !== null) {
|
||||
const rect = tileSet.getTileRect(this.selectedTileIndex);
|
||||
this.previewCtx.strokeStyle = "rgba(255, 127, 17, 1)";
|
||||
this.previewCtx.lineWidth = 2;
|
||||
this.previewCtx.strokeRect(
|
||||
rect.x * scale,
|
||||
rect.y * scale,
|
||||
rect.width * scale,
|
||||
rect.height * scale
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders empty state.
|
||||
*/
|
||||
#renderEmpty() {
|
||||
this.tileSetList.innerHTML = `
|
||||
<div class="tileset-empty">
|
||||
<div class="tileset-empty-icon">🎨</div>
|
||||
<div class="tileset-empty-text">No tilesets added</div>
|
||||
<div class="tileset-empty-hint">Click "+ Add Tileset" to import a 128x128px PNG</div>
|
||||
</div>
|
||||
`;
|
||||
this.#renderPreview();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all tilesets.
|
||||
*
|
||||
* @returns {Array<TileSet>}
|
||||
*/
|
||||
getTileSets() {
|
||||
return Array.from(this.tileSets.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a tileset programmatically.
|
||||
*
|
||||
* @param {TileSet} tileSet Tileset to add.
|
||||
*/
|
||||
addTileSet(tileSet) {
|
||||
this.tileSets.set(tileSet.id, tileSet);
|
||||
this.#render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes all tilesets to JSON.
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
toJSON() {
|
||||
return {
|
||||
tileSets: Array.from(this.tileSets.values()).map((ts) => ts.toJSON())
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads tilesets from JSON.
|
||||
*
|
||||
* @param {Object} data Serialized data.
|
||||
*/
|
||||
async fromJSON(data) {
|
||||
if (!data.tileSets) return;
|
||||
|
||||
this.tileSets.clear();
|
||||
|
||||
for (const tsData of data.tileSets) {
|
||||
try {
|
||||
const tileSet = TileSet.fromJSON(tsData);
|
||||
await tileSet.load();
|
||||
this.tileSets.set(tileSet.id, tileSet);
|
||||
} catch (error) {
|
||||
console.error("Failed to load tileset:", error);
|
||||
}
|
||||
}
|
||||
|
||||
this.#render();
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ export class WorkspaceDragManager {
|
||||
|
||||
/**
|
||||
* Updates the palette ghost transform to match the supplied position.
|
||||
* The ghost is offset so the cursor appears at its center.
|
||||
*
|
||||
* @param {{ x: number, y: number }} position Snapped workspace position.
|
||||
*/
|
||||
@@ -57,8 +58,14 @@ export class WorkspaceDragManager {
|
||||
if (!paletteDragGhost) {
|
||||
return;
|
||||
}
|
||||
const width = paletteDragGhost.width || 220;
|
||||
const height = paletteDragGhost.height || 140;
|
||||
const centered = {
|
||||
x: position.x - width / 2,
|
||||
y: position.y - height / 2,
|
||||
};
|
||||
paletteDragGhost.element.style.transform =
|
||||
WorkspaceGeometry.positionToTransform(position);
|
||||
WorkspaceGeometry.positionToTransform(centered);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,7 +217,7 @@ export class WorkspaceDragManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerWorld = this.#clientToWorkspace(
|
||||
const pointerWorld = this.clientToWorkspace(
|
||||
hasClientX ? pointer.clientX : context.previousClientX,
|
||||
hasClientY ? pointer.clientY : context.previousClientY
|
||||
);
|
||||
@@ -526,7 +533,7 @@ export class WorkspaceDragManager {
|
||||
* @returns {{ x: number, y: number }}
|
||||
*/
|
||||
#eventToWorkspacePoint(event) {
|
||||
return this.#clientToWorkspace(event.clientX, event.clientY);
|
||||
return this.clientToWorkspace(event.clientX, event.clientY);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -560,14 +567,20 @@ export class WorkspaceDragManager {
|
||||
* @param {number | undefined} clientY Viewport Y coordinate.
|
||||
* @returns {{ x: number, y: number }}
|
||||
*/
|
||||
#clientToWorkspace(clientX, clientY) {
|
||||
clientToWorkspace(clientX, clientY) {
|
||||
const rect = this.workspace.workspaceElement.getBoundingClientRect();
|
||||
const zoom = this.workspace.zoomLevel || 1;
|
||||
const zoom = this.workspace.navigationManager?.getZoomLevel() ?? this.workspace.zoomLevel ?? 1;
|
||||
const clampedZoom = Math.max(0.01, zoom);
|
||||
const safeX = Number.isFinite(clientX) ? clientX : rect.left;
|
||||
const safeY = Number.isFinite(clientY) ? clientY : rect.top;
|
||||
const effectiveOffset = this.workspace.navigationManager?.getEffectiveOffset?.();
|
||||
const offset = {
|
||||
x: Number.isFinite(effectiveOffset?.x) ? effectiveOffset.x : 0,
|
||||
y: Number.isFinite(effectiveOffset?.y) ? effectiveOffset.y : 0,
|
||||
};
|
||||
return {
|
||||
x: (safeX - rect.left) / zoom,
|
||||
y: (safeY - rect.top) / zoom,
|
||||
x: (safeX - rect.left) / clampedZoom - offset.x,
|
||||
y: (safeY - rect.top) / clampedZoom - offset.y,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -188,10 +188,14 @@ export class WorkspaceDropManager {
|
||||
|
||||
this.#workspace.paletteManager.handleDragEnd();
|
||||
|
||||
const spawn = WorkspaceGeometry.snapPositionToGrid(
|
||||
this.#workspace,
|
||||
WorkspaceGeometry.eventToWorkspacePoint(this.#workspace, event)
|
||||
);
|
||||
// Snap to grid first, then offset for centering (consistent with ghost positioning)
|
||||
const cursorWorld = WorkspaceGeometry.eventToWorkspacePoint(this.#workspace, event);
|
||||
const snapped = WorkspaceGeometry.snapPositionToGrid(this.#workspace, cursorWorld);
|
||||
// Offset spawn to match the centered ghost position (roughly half ghost size)
|
||||
const spawn = {
|
||||
x: snapped.x - 110,
|
||||
y: snapped.y - 70,
|
||||
};
|
||||
|
||||
this.#workspace.addNode(definitionId, spawn);
|
||||
}
|
||||
@@ -214,10 +218,12 @@ export class WorkspaceDropManager {
|
||||
|
||||
const definitions = this.#getVariableSpawnDefinitions(variableId);
|
||||
if (!definitions.length) {
|
||||
const spawn = WorkspaceGeometry.snapPositionToGrid(
|
||||
this.#workspace,
|
||||
WorkspaceGeometry.eventToWorkspacePoint(this.#workspace, event)
|
||||
);
|
||||
const cursorWorld = WorkspaceGeometry.eventToWorkspacePoint(this.#workspace, event);
|
||||
const snapped = WorkspaceGeometry.snapPositionToGrid(this.#workspace, cursorWorld);
|
||||
const spawn = {
|
||||
x: snapped.x - 110,
|
||||
y: snapped.y - 70,
|
||||
};
|
||||
const fallback = this.#workspace.addNode("get_var", spawn);
|
||||
if (fallback) {
|
||||
const nodeName = variable.name || variable.id;
|
||||
|
||||
@@ -29,20 +29,11 @@ export class WorkspaceGeometry {
|
||||
* Converts viewport coordinates into workspace-relative coordinates.
|
||||
*
|
||||
* @param {import('../BlueprintWorkspace.js').BlueprintWorkspace} workspace Workspace instance.
|
||||
* @param {{ clientX: number, clientY: number }} event Pointer-like event payload.
|
||||
* @param {{ clientX?: number, clientY?: number }} event Pointer-like event payload.
|
||||
* @returns {{ x: number, y: number }}
|
||||
*/
|
||||
static eventToWorkspacePoint(workspace, event) {
|
||||
const rect = workspace.workspaceElement.getBoundingClientRect();
|
||||
const rawX = event?.clientX ?? 0;
|
||||
const rawY = event?.clientY ?? 0;
|
||||
const x = rawX - rect.left;
|
||||
const y = rawY - rect.top;
|
||||
const zoom = workspace.zoomLevel || 1;
|
||||
return {
|
||||
x: Math.max(0, Math.min(rect.width, x)) / zoom,
|
||||
y: Math.max(0, Math.min(rect.height, y)) / zoom,
|
||||
};
|
||||
return workspace.dragManager.clientToWorkspace(event?.clientX, event?.clientY);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,8 +45,9 @@ export class WorkspaceGeometry {
|
||||
*/
|
||||
static snapPositionToGrid(workspace, position) {
|
||||
const size = workspace.gridSize || DEFAULT_GRID_SIZE;
|
||||
const offsetX = workspace.workspaceBackgroundOffset?.x ?? 0;
|
||||
const offsetY = workspace.workspaceBackgroundOffset?.y ?? 0;
|
||||
const effectiveOffset = workspace.navigationManager?.getEffectiveOffset?.();
|
||||
const offsetX = Number.isFinite(effectiveOffset?.x) ? effectiveOffset.x : 0;
|
||||
const offsetY = Number.isFinite(effectiveOffset?.y) ? effectiveOffset.y : 0;
|
||||
const snap = (value, offset) => {
|
||||
const local = value - offset;
|
||||
const snapped = Math.round(local / size) * size;
|
||||
|
||||
@@ -302,7 +302,6 @@ export class WorkspaceHistoryManager {
|
||||
this.#pendingReason = null;
|
||||
|
||||
this.#workspace.applySerializedWorkspace(snapshot.workspace);
|
||||
this.#workspace.applyHistoryView(snapshot.view);
|
||||
this.#workspace.applyHistorySelection(snapshot.selection);
|
||||
|
||||
this.isReady = wasReady;
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { createColorSwatchPicker } from '../ColorSwatchPicker.js';
|
||||
import { getTypeColor } from '../../types/TypeRegistry.js';
|
||||
|
||||
/** @typedef {import('../../core/BlueprintNode.js').BlueprintNode} BlueprintNode */
|
||||
/** @typedef {import('../../core/BlueprintNode.js').PinDescriptor} PinDescriptor */
|
||||
/** @typedef {import('../../core/NodeGraph.js').NodeGraph} NodeGraph */
|
||||
@@ -75,6 +78,7 @@ export class WorkspaceInlineEditorManager {
|
||||
"number_literal",
|
||||
"string_literal",
|
||||
"boolean_literal",
|
||||
"color_literal",
|
||||
]);
|
||||
if (!supported.has(node.type)) {
|
||||
return null;
|
||||
@@ -100,6 +104,27 @@ export class WorkspaceInlineEditorManager {
|
||||
editor.appendChild(label);
|
||||
|
||||
const graph = this.#getGraph();
|
||||
|
||||
if (node.type === "color_literal") {
|
||||
const picker = createColorSwatchPicker({
|
||||
id: controlId,
|
||||
value: node.properties.value ?? 7,
|
||||
onChange: (index) => {
|
||||
graph.setNodeProperty(node.id, "value", index);
|
||||
},
|
||||
});
|
||||
editor.appendChild(picker.element);
|
||||
|
||||
const setValue = (rawValue) => {
|
||||
const n = Number(rawValue);
|
||||
picker.setValue(Number.isFinite(n) ? n : 7);
|
||||
};
|
||||
|
||||
const entry = this.#ensureEntry(node.id);
|
||||
entry.literal = { setValue };
|
||||
return entry.literal;
|
||||
}
|
||||
|
||||
let control;
|
||||
const commitNumber = () => {
|
||||
const numeric = Number(control.value);
|
||||
@@ -176,6 +201,7 @@ export class WorkspaceInlineEditorManager {
|
||||
{ value: "string", label: "String" },
|
||||
{ value: "boolean", label: "Boolean" },
|
||||
{ value: "table", label: "Table" },
|
||||
{ value: "color", label: "Color" },
|
||||
];
|
||||
|
||||
const ensureBody = () => {
|
||||
@@ -274,6 +300,8 @@ export class WorkspaceInlineEditorManager {
|
||||
return "valueBoolean";
|
||||
case "table":
|
||||
return "valueTable";
|
||||
case "color":
|
||||
return "valueColor";
|
||||
case "number":
|
||||
default:
|
||||
return "valueNumber";
|
||||
@@ -288,6 +316,8 @@ export class WorkspaceInlineEditorManager {
|
||||
return "string";
|
||||
case "table":
|
||||
return "table";
|
||||
case "color":
|
||||
return "color";
|
||||
default:
|
||||
return "number";
|
||||
}
|
||||
@@ -301,6 +331,8 @@ export class WorkspaceInlineEditorManager {
|
||||
return false;
|
||||
case "table":
|
||||
return "{}";
|
||||
case "color":
|
||||
return 7;
|
||||
case "number":
|
||||
default:
|
||||
return 0;
|
||||
@@ -313,6 +345,7 @@ export class WorkspaceInlineEditorManager {
|
||||
const normalized = normalizeType(type);
|
||||
activeType = normalized;
|
||||
typeButton.dataset.variableType = normalized;
|
||||
typeButton.style.setProperty("--overview-variable-color", getTypeColor(normalized));
|
||||
const label = formatType(normalized);
|
||||
typeButton.title = `Type: ${label}`;
|
||||
typeButton.setAttribute(
|
||||
@@ -378,6 +411,20 @@ export class WorkspaceInlineEditorManager {
|
||||
graph.setNodeProperty(currentNode.id, key, textarea.value);
|
||||
});
|
||||
control = textarea;
|
||||
} else if (desiredKind === "color") {
|
||||
const rawColor = currentNode.properties?.[key];
|
||||
const n = Number(rawColor);
|
||||
const initialIndex = Number.isFinite(n) ? Math.max(0, Math.min(15, Math.round(n))) : 7;
|
||||
const picker = createColorSwatchPicker({
|
||||
value: initialIndex,
|
||||
onChange: (index) => {
|
||||
graph.setNodeProperty(currentNode.id, key, index);
|
||||
},
|
||||
});
|
||||
picker.element.dataset.localValueKind = "color";
|
||||
/** @type {any} */ (picker.element)._setColorValue = picker.setValue;
|
||||
valueField.appendChild(picker.element);
|
||||
return picker.element;
|
||||
} else {
|
||||
const numberInput = document.createElement("input");
|
||||
numberInput.type = "number";
|
||||
@@ -407,6 +454,10 @@ export class WorkspaceInlineEditorManager {
|
||||
if (control.value !== nextValue) {
|
||||
control.value = nextValue;
|
||||
}
|
||||
} else if (/** @type {any} */ (control)._setColorValue) {
|
||||
const n = Number(current);
|
||||
const index = Number.isFinite(n) ? Math.max(0, Math.min(15, Math.round(n))) : 7;
|
||||
/** @type {any} */ (control)._setColorValue(index);
|
||||
} else if (control instanceof HTMLTextAreaElement) {
|
||||
const nextValue = typeof current === "string" ? current : "{}";
|
||||
if (control.value !== nextValue) {
|
||||
@@ -534,18 +585,10 @@ export class WorkspaceInlineEditorManager {
|
||||
}
|
||||
|
||||
const isIfCondition = node.type === "if" && pin.id === "condition";
|
||||
const isExtcmdCommand =
|
||||
node.type === "system_extcmd" && pin.id === "command";
|
||||
const isNamedButtonSelector =
|
||||
node.type === "input_button_constants" && pin.id === "button";
|
||||
const isNamedPlayerSelector =
|
||||
node.type === "input_button_constants" && pin.id === "player";
|
||||
if (
|
||||
!isIfCondition &&
|
||||
!isExtcmdCommand &&
|
||||
!isNamedButtonSelector &&
|
||||
!isNamedPlayerSelector
|
||||
) {
|
||||
const isColorPin = pin.kind === "color";
|
||||
const pinOptions =
|
||||
Array.isArray(pin.options) && pin.options.length > 0 ? pin.options : null;
|
||||
if (!isIfCondition && !pinOptions && !isColorPin) {
|
||||
container.classList.remove("has-inline-dropdown");
|
||||
return;
|
||||
}
|
||||
@@ -555,31 +598,14 @@ export class WorkspaceInlineEditorManager {
|
||||
const graph = this.#getGraph();
|
||||
|
||||
if (!(propertyKey in node.properties)) {
|
||||
if (isExtcmdCommand || isNamedButtonSelector || isNamedPlayerSelector) {
|
||||
const definition = this.registry.get(node.type);
|
||||
const targetKey = isExtcmdCommand
|
||||
? "command"
|
||||
: isNamedButtonSelector
|
||||
? "button"
|
||||
: "player";
|
||||
const schema = definition?.properties?.find(
|
||||
(prop) => prop.key === targetKey
|
||||
);
|
||||
const options = Array.isArray(schema?.options) ? schema.options : [];
|
||||
const defaultOption =
|
||||
typeof node.properties[targetKey] === "string"
|
||||
? node.properties[targetKey]
|
||||
: String(schema?.defaultValue ?? options[0]?.value ?? "");
|
||||
if (typeof node.properties[targetKey] !== "string") {
|
||||
node.properties[targetKey] = defaultOption;
|
||||
}
|
||||
if (isExtcmdCommand) {
|
||||
node.properties[propertyKey] = defaultOption;
|
||||
} else {
|
||||
const numericDefault = Number.parseInt(defaultOption, 10);
|
||||
node.properties[propertyKey] = Number.isFinite(numericDefault)
|
||||
? numericDefault
|
||||
: 0;
|
||||
if (pinOptions) {
|
||||
const defaultStr =
|
||||
pin.defaultValue != null
|
||||
? String(pin.defaultValue)
|
||||
: String(pinOptions[0]?.value ?? "");
|
||||
node.properties[propertyKey] = defaultStr;
|
||||
if (!(pin.id in node.properties)) {
|
||||
node.properties[pin.id] = defaultStr;
|
||||
}
|
||||
} else {
|
||||
node.properties[propertyKey] = this.#defaultValueForPin(pin);
|
||||
@@ -591,7 +617,7 @@ export class WorkspaceInlineEditorManager {
|
||||
});
|
||||
|
||||
let wrapper;
|
||||
if (isExtcmdCommand || isNamedButtonSelector || isNamedPlayerSelector) {
|
||||
if (pinOptions) {
|
||||
wrapper = document.createElement("div");
|
||||
wrapper.className = "pin-inline-control pin-inline-dropdown";
|
||||
container.appendChild(wrapper);
|
||||
@@ -631,29 +657,14 @@ export class WorkspaceInlineEditorManager {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isExtcmdCommand || isNamedButtonSelector || isNamedPlayerSelector) {
|
||||
const definition = this.registry.get(node.type);
|
||||
const targetKey = isExtcmdCommand
|
||||
? "command"
|
||||
: isNamedButtonSelector
|
||||
? "button"
|
||||
: "player";
|
||||
const schema = definition?.properties?.find(
|
||||
(prop) => prop.key === targetKey
|
||||
);
|
||||
const options = Array.isArray(schema?.options) ? schema.options : [];
|
||||
if (pinOptions) {
|
||||
const optionValues = pinOptions.map((o) => String(o.value));
|
||||
|
||||
const select = document.createElement("select");
|
||||
select.className = "pin-inline-select";
|
||||
const ariaLabel = isExtcmdCommand
|
||||
? "Command"
|
||||
: isNamedButtonSelector
|
||||
? "Button"
|
||||
: "Player";
|
||||
select.setAttribute("aria-label", ariaLabel);
|
||||
select.setAttribute("aria-label", pin.name || pin.id);
|
||||
|
||||
const values = options.map((option) => String(option.value));
|
||||
options.forEach((option) => {
|
||||
pinOptions.forEach((option) => {
|
||||
const element = document.createElement("option");
|
||||
element.value = String(option.value);
|
||||
element.textContent = option.label ?? String(option.value);
|
||||
@@ -661,30 +672,14 @@ export class WorkspaceInlineEditorManager {
|
||||
});
|
||||
|
||||
const normalizeValue = (value) => {
|
||||
const candidate =
|
||||
typeof value === "string" ? value : String(value ?? "");
|
||||
if (values.length === 0) {
|
||||
return candidate;
|
||||
}
|
||||
if (values.includes(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
const schemaDefault =
|
||||
schema?.defaultValue != null ? String(schema.defaultValue) : null;
|
||||
if (schemaDefault && values.includes(schemaDefault)) {
|
||||
return schemaDefault;
|
||||
}
|
||||
return values[0];
|
||||
const candidate = value == null ? "" : String(value);
|
||||
return optionValues.includes(candidate)
|
||||
? candidate
|
||||
: (optionValues[0] ?? "");
|
||||
};
|
||||
|
||||
const setValue = (value) => {
|
||||
const normalized = normalizeValue(
|
||||
(isNamedButtonSelector || isNamedPlayerSelector) &&
|
||||
typeof node.properties[targetKey] === "string"
|
||||
? node.properties[targetKey]
|
||||
: value
|
||||
);
|
||||
select.value = normalized;
|
||||
select.value = normalizeValue(value);
|
||||
};
|
||||
|
||||
const setConnected = (connected) => {
|
||||
@@ -694,23 +689,42 @@ export class WorkspaceInlineEditorManager {
|
||||
|
||||
select.addEventListener("change", () => {
|
||||
const nextValue = select.value;
|
||||
if (isExtcmdCommand) {
|
||||
graph.setNodeProperty(node.id, propertyKey, nextValue);
|
||||
graph.setNodeProperty(node.id, "command", nextValue);
|
||||
} else {
|
||||
const normalized = normalizeValue(nextValue);
|
||||
const numeric = Number.parseInt(normalized, 10);
|
||||
graph.setNodeProperty(
|
||||
node.id,
|
||||
propertyKey,
|
||||
Number.isFinite(numeric) ? numeric : 0
|
||||
);
|
||||
graph.setNodeProperty(node.id, targetKey, normalized);
|
||||
}
|
||||
graph.setNodeProperty(node.id, propertyKey, nextValue);
|
||||
graph.setNodeProperty(node.id, pin.id, nextValue);
|
||||
});
|
||||
|
||||
wrapper.appendChild(select);
|
||||
|
||||
entry.pinEditors.set(pin.id, { setValue, setConnected });
|
||||
setValue(node.properties[propertyKey]);
|
||||
setConnected(this.#hasConnection(node.id, pin.id));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isColorPin) {
|
||||
const rawDefault = node.properties[propertyKey];
|
||||
const n = Number(rawDefault);
|
||||
const initialIndex = Number.isFinite(n)
|
||||
? Math.max(0, Math.min(15, Math.round(n)))
|
||||
: 7;
|
||||
|
||||
const picker = createColorSwatchPicker({
|
||||
value: initialIndex,
|
||||
onChange: (index) => {
|
||||
graph.setNodeProperty(node.id, propertyKey, index);
|
||||
},
|
||||
});
|
||||
wrapper.appendChild(picker.element);
|
||||
|
||||
const setValue = (value) => {
|
||||
const num = Number(value);
|
||||
picker.setValue(Number.isFinite(num) ? num : 7);
|
||||
};
|
||||
|
||||
const setConnected = (connected) => {
|
||||
wrapper.classList.toggle("is-hidden", connected);
|
||||
};
|
||||
|
||||
entry.pinEditors.set(pin.id, { setValue, setConnected });
|
||||
setValue(node.properties[propertyKey]);
|
||||
setConnected(this.#hasConnection(node.id, pin.id));
|
||||
@@ -765,45 +779,12 @@ export class WorkspaceInlineEditorManager {
|
||||
const propertyKey = this.#pinPropertyKey(pinId);
|
||||
let value;
|
||||
|
||||
if (node.type === "system_extcmd" && pinId === "command") {
|
||||
const commandValue =
|
||||
typeof node.properties.command === "string"
|
||||
? node.properties.command
|
||||
: null;
|
||||
if (
|
||||
commandValue !== null &&
|
||||
node.properties[propertyKey] !== commandValue
|
||||
) {
|
||||
node.properties[propertyKey] = commandValue;
|
||||
}
|
||||
} else if (node.type === "input_button_constants") {
|
||||
if (pinId === "button") {
|
||||
const rawButton =
|
||||
typeof node.properties.button === "string"
|
||||
? node.properties.button
|
||||
: null;
|
||||
if (rawButton !== null) {
|
||||
const numericButton = Number.parseInt(rawButton, 10);
|
||||
const safeButton = Number.isFinite(numericButton)
|
||||
? numericButton
|
||||
: 0;
|
||||
if (node.properties[propertyKey] !== safeButton) {
|
||||
node.properties[propertyKey] = safeButton;
|
||||
}
|
||||
}
|
||||
} else if (pinId === "player") {
|
||||
const rawPlayer =
|
||||
typeof node.properties.player === "string"
|
||||
? node.properties.player
|
||||
: null;
|
||||
if (rawPlayer !== null) {
|
||||
const numericPlayer = Number.parseInt(rawPlayer, 10);
|
||||
const safePlayer = Number.isFinite(numericPlayer)
|
||||
? numericPlayer
|
||||
: 0;
|
||||
if (node.properties[propertyKey] !== safePlayer) {
|
||||
node.properties[propertyKey] = safePlayer;
|
||||
}
|
||||
if (Array.isArray(pin?.options) && pin.options.length > 0) {
|
||||
const canonical = node.properties[pinId];
|
||||
if (canonical != null) {
|
||||
const strValue = String(canonical);
|
||||
if (node.properties[propertyKey] !== strValue) {
|
||||
node.properties[propertyKey] = strValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -905,6 +886,8 @@ export class WorkspaceInlineEditorManager {
|
||||
return 0;
|
||||
case "string":
|
||||
return "";
|
||||
case "color":
|
||||
return 7;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ export class WorkspaceNavigationManager {
|
||||
#zoomConfig;
|
||||
#panState;
|
||||
#lastPanTimestamp;
|
||||
#targetZoomLevel;
|
||||
#smoothZoomFocus;
|
||||
#smoothZoomRafId;
|
||||
#pendingLayerOffset;
|
||||
|
||||
/**
|
||||
* @param {import("../BlueprintWorkspace.js").BlueprintWorkspace} workspace Owning workspace instance.
|
||||
@@ -39,6 +43,10 @@ export class WorkspaceNavigationManager {
|
||||
this.#zoomConfig = { min: 0.25, max: 2.5, step: 0.1 };
|
||||
this.#panState = null;
|
||||
this.#lastPanTimestamp = 0;
|
||||
this.#targetZoomLevel = 1;
|
||||
this.#smoothZoomFocus = null;
|
||||
this.#smoothZoomRafId = null;
|
||||
this.#pendingLayerOffset = { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,18 +56,29 @@ export class WorkspaceNavigationManager {
|
||||
return this.#zoomLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the accumulated node-layer translate offset that is pending commit
|
||||
* during a smooth zoom animation. This must be accounted for when converting
|
||||
* screen/client coordinates to world coordinates mid-lerp.
|
||||
*
|
||||
* @returns {{ x: number, y: number }}
|
||||
*/
|
||||
getPendingLayerOffset() {
|
||||
return { x: this.#pendingLayerOffset.x, y: this.#pendingLayerOffset.y };
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjusts the zoom level within configured bounds.
|
||||
*
|
||||
* @param {number} nextZoom Requested zoom multiplier.
|
||||
* @param {{ silent?: boolean }} [options] Behaviour overrides.
|
||||
* @param {{ silent?: boolean, skipConnectionRender?: boolean }} [options] Behaviour overrides.
|
||||
* @returns {boolean} Whether the zoom value changed.
|
||||
*/
|
||||
setZoomLevel(nextZoom, options = {}) {
|
||||
if (!Number.isFinite(nextZoom)) {
|
||||
return false;
|
||||
}
|
||||
const { silent = false } = options;
|
||||
const { silent = false, skipConnectionRender = false } = options;
|
||||
const clamped = Math.max(
|
||||
this.#zoomConfig.min,
|
||||
Math.min(this.#zoomConfig.max, nextZoom)
|
||||
@@ -68,12 +87,25 @@ export class WorkspaceNavigationManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.#zoomLevel = clamped;
|
||||
this.updateZoomDisplay();
|
||||
if (!silent) {
|
||||
this.#markViewDirty("view", 200);
|
||||
this.#schedulePersist();
|
||||
// Cancel any running smooth zoom so programmatic calls take immediate effect.
|
||||
if (this.#smoothZoomRafId !== null) {
|
||||
cancelAnimationFrame(this.#smoothZoomRafId);
|
||||
this.#smoothZoomRafId = null;
|
||||
this.#smoothZoomFocus = null;
|
||||
// Commit any pending layer offset before the snap.
|
||||
const pending = this.#pendingLayerOffset;
|
||||
this.#pendingLayerOffset = { x: 0, y: 0 };
|
||||
if (Math.abs(pending.x) > 0.0001 || Math.abs(pending.y) > 0.0001) {
|
||||
this.setBackgroundOffset({
|
||||
x: this.#backgroundOffset.x - pending.x,
|
||||
y: this.#backgroundOffset.y - pending.y,
|
||||
});
|
||||
this.translateWorkspace(pending);
|
||||
}
|
||||
}
|
||||
this.#targetZoomLevel = clamped;
|
||||
this.#zoomLevel = clamped;
|
||||
this.updateZoomDisplay(skipConnectionRender);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -86,28 +118,38 @@ export class WorkspaceNavigationManager {
|
||||
|
||||
/**
|
||||
* Updates workspace transforms to match the stored zoom level.
|
||||
*
|
||||
* @param {boolean} [skipConnectionRender] When true, skips the connection re-render pass.
|
||||
*/
|
||||
updateZoomDisplay() {
|
||||
updateZoomDisplay(skipConnectionRender = false) {
|
||||
const zoom = this.#zoomLevel || 1;
|
||||
const { nodeLayer, connectionLayer, workspaceElement } = this.#workspace;
|
||||
|
||||
const tx = (this.#backgroundOffset.x + this.#pendingLayerOffset.x) * zoom;
|
||||
const ty = (this.#backgroundOffset.y + this.#pendingLayerOffset.y) * zoom;
|
||||
|
||||
if (nodeLayer) {
|
||||
nodeLayer.style.transformOrigin = "0 0";
|
||||
nodeLayer.style.transform = `scale(${zoom})`;
|
||||
nodeLayer.style.transform = `translate(${tx}px, ${ty}px) scale(${zoom})`;
|
||||
}
|
||||
|
||||
if (connectionLayer) {
|
||||
connectionLayer.style.transformOrigin = "0 0";
|
||||
connectionLayer.style.transform = `scale(${zoom})`;
|
||||
connectionLayer.style.transform = `translate(${tx}px, ${ty}px) scale(${zoom})`;
|
||||
}
|
||||
|
||||
workspaceElement.style.setProperty("--workspace-zoom", `${zoom}`);
|
||||
this.#applyBackgroundOffset();
|
||||
this.#renderConnections();
|
||||
if (!skipConnectionRender) {
|
||||
this.#renderConnections();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a screen-space point to workspace coordinates.
|
||||
* Note: This returns screen coordinates divided by zoom only, without subtracting
|
||||
* the background offset. This is intentional for zoom pivot calculations.
|
||||
* For true world coordinates, use dragManager.clientToWorkspace().
|
||||
*
|
||||
* @param {{ x: number, y: number }} point Screen-space point relative to the workspace element.
|
||||
* @returns {{ x: number, y: number }}
|
||||
@@ -134,6 +176,107 @@ export class WorkspaceNavigationManager {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues a smooth animated zoom step centred on a screen-space point.
|
||||
* Subsequent calls before the animation settles accumulate the target zoom.
|
||||
*
|
||||
* @param {{ x: number, y: number }} screenPoint Focus point in workspace-relative screen coordinates.
|
||||
* @param {number} direction Positive to zoom in, negative to zoom out.
|
||||
* @param {{ clientX?: number, clientY?: number }} [pointer] Pointer coordinates relative to the viewport.
|
||||
*/
|
||||
pushSmoothZoom(screenPoint, direction, pointer = {}) {
|
||||
if (!direction) {
|
||||
return;
|
||||
}
|
||||
|
||||
const step = direction * this.#zoomConfig.step;
|
||||
this.#targetZoomLevel = Math.max(
|
||||
this.#zoomConfig.min,
|
||||
Math.min(this.#zoomConfig.max, this.#targetZoomLevel + step)
|
||||
);
|
||||
this.#smoothZoomFocus = { screenPoint, pointer };
|
||||
|
||||
if (this.#smoothZoomRafId === null) {
|
||||
this.#smoothZoomRafId = requestAnimationFrame(() => this.#tickSmoothZoom());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Advances one frame of the smooth zoom animation, easing toward the target.
|
||||
*/
|
||||
#tickSmoothZoom() {
|
||||
this.#smoothZoomRafId = null;
|
||||
|
||||
if (!this.#smoothZoomFocus) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = this.#targetZoomLevel;
|
||||
const current = this.#zoomLevel;
|
||||
const diff = target - current;
|
||||
const settled = Math.abs(diff) < 0.001;
|
||||
const newZoom = settled
|
||||
? target
|
||||
: Math.max(this.#zoomConfig.min, Math.min(this.#zoomConfig.max, current + diff * 0.25));
|
||||
|
||||
const { screenPoint, pointer } = this.#smoothZoomFocus;
|
||||
const previousZoom = current;
|
||||
|
||||
// screenPointToWorld still uses the old zoom at this point, which is correct.
|
||||
const worldPoint = this.screenPointToWorld(screenPoint);
|
||||
this.#zoomLevel = newZoom;
|
||||
|
||||
const scale = previousZoom / newZoom;
|
||||
const shift = {
|
||||
x: worldPoint.x * (scale - 1),
|
||||
y: worldPoint.y * (scale - 1),
|
||||
};
|
||||
|
||||
// Accumulate into a pending layer offset — O(1), no per-node work each frame.
|
||||
this.#pendingLayerOffset.x += shift.x;
|
||||
this.#pendingLayerOffset.y += shift.y;
|
||||
|
||||
// Rebase active interactions if needed.
|
||||
if (Math.abs(shift.x) > 0.0001 || Math.abs(shift.y) > 0.0001) {
|
||||
const { dragManager } = this.#workspace;
|
||||
if (dragManager?.rebaseActiveDragPointer) {
|
||||
dragManager.rebaseActiveDragPointer(pointer);
|
||||
}
|
||||
this.#rebasePanGestureAfterZoom(pointer);
|
||||
}
|
||||
|
||||
// Apply zoom + pending pivot as a single CSS transform on the node layer — O(1).
|
||||
// Combined with background offset for pan.
|
||||
const { nodeLayer, connectionLayer, workspaceElement } = this.#workspace;
|
||||
const ox = (this.#backgroundOffset.x + this.#pendingLayerOffset.x) * newZoom;
|
||||
const oy = (this.#backgroundOffset.y + this.#pendingLayerOffset.y) * newZoom;
|
||||
if (nodeLayer) {
|
||||
nodeLayer.style.transformOrigin = "0 0";
|
||||
nodeLayer.style.transform = `translate(${ox}px, ${oy}px) scale(${newZoom})`;
|
||||
}
|
||||
if (connectionLayer) {
|
||||
connectionLayer.style.transformOrigin = "0 0";
|
||||
connectionLayer.style.transform = `translate(${ox}px, ${oy}px) scale(${newZoom})`;
|
||||
}
|
||||
workspaceElement.style.setProperty("--workspace-zoom", `${newZoom}`);
|
||||
this.#applyBackgroundOffset();
|
||||
this.#renderConnections();
|
||||
|
||||
if (settled) {
|
||||
this.#smoothZoomFocus = null;
|
||||
// Commit accumulated pivot to node positions in one pass, then restore clean transforms.
|
||||
const finalOffset = this.#pendingLayerOffset;
|
||||
this.#pendingLayerOffset = { x: 0, y: 0 };
|
||||
if (Math.abs(finalOffset.x) > 0.0001 || Math.abs(finalOffset.y) > 0.0001) {
|
||||
this.translateWorkspace(finalOffset);
|
||||
}
|
||||
// Restore clean scale-only transform now that node positions are committed.
|
||||
this.updateZoomDisplay(false);
|
||||
} else {
|
||||
this.#smoothZoomRafId = requestAnimationFrame(() => this.#tickSmoothZoom());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies zoom centred at a screen-space location.
|
||||
*
|
||||
@@ -149,7 +292,7 @@ export class WorkspaceNavigationManager {
|
||||
const previousZoom = this.#zoomLevel || 1;
|
||||
const worldPoint = this.screenPointToWorld(screenPoint);
|
||||
const nextZoom = previousZoom + direction * this.#zoomConfig.step;
|
||||
const changed = this.setZoomLevel(nextZoom);
|
||||
const changed = this.setZoomLevel(nextZoom, { skipConnectionRender: true });
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
@@ -172,8 +315,10 @@ export class WorkspaceNavigationManager {
|
||||
dragManager.rebaseActiveDragPointer(pointer);
|
||||
}
|
||||
this.#rebasePanGestureAfterZoom(pointer);
|
||||
this.#renderConnections();
|
||||
}
|
||||
|
||||
this.updateZoomDisplay(true);
|
||||
this.#renderConnections();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -197,6 +342,18 @@ export class WorkspaceNavigationManager {
|
||||
return this.#backgroundOffset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the total effective viewport offset, including any pending zoom pivot.
|
||||
*
|
||||
* @returns {{ x: number, y: number }}
|
||||
*/
|
||||
getEffectiveOffset() {
|
||||
return {
|
||||
x: this.#backgroundOffset.x + this.#pendingLayerOffset.x,
|
||||
y: this.#backgroundOffset.y + this.#pendingLayerOffset.y,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a workspace translation to all nodes and the background.
|
||||
*
|
||||
@@ -212,22 +369,7 @@ export class WorkspaceNavigationManager {
|
||||
y: this.#backgroundOffset.y + delta.y,
|
||||
});
|
||||
|
||||
const { graph, dragManager } = this.#workspace;
|
||||
const panState = this.#panState;
|
||||
const panDelta = panState ? panState.lastDelta : null;
|
||||
|
||||
graph.nodes.forEach((node) => {
|
||||
const origin = panState?.nodeOrigins.get(node.id) ?? node.position;
|
||||
const baseDelta = panDelta ?? { x: 0, y: 0 };
|
||||
const next = {
|
||||
x: origin.x + baseDelta.x + delta.x,
|
||||
y: origin.y + baseDelta.y + delta.y,
|
||||
};
|
||||
graph.setNodePosition(node.id, next);
|
||||
if (dragManager?.updateNodeDomPosition) {
|
||||
dragManager.updateNodeDomPosition(node.id, next);
|
||||
}
|
||||
});
|
||||
const { dragManager } = this.#workspace;
|
||||
|
||||
if (dragManager?.applyActiveDragShift) {
|
||||
dragManager.applyActiveDragShift(delta);
|
||||
@@ -273,6 +415,7 @@ export class WorkspaceNavigationManager {
|
||||
});
|
||||
|
||||
this.translateWorkspace(delta);
|
||||
this.updateZoomDisplay(false);
|
||||
this.#workspace.selectNode(nodeId);
|
||||
}
|
||||
|
||||
@@ -345,30 +488,29 @@ export class WorkspaceNavigationManager {
|
||||
}
|
||||
|
||||
targetZoom = Math.max(this.#zoomConfig.min, Math.min(this.#zoomConfig.max, targetZoom));
|
||||
this.setZoomLevel(targetZoom);
|
||||
|
||||
const contentCenter = {
|
||||
x: minX + boundsWidth / 2,
|
||||
y: minY + boundsHeight / 2,
|
||||
};
|
||||
|
||||
const viewportCenter = this.screenPointToWorld({
|
||||
x: rect.width / 2,
|
||||
y: rect.height / 2,
|
||||
});
|
||||
// Directly compute the background offset that places contentCenter at screen center,
|
||||
// then set zoom + offset together so a single updateZoomDisplay call applies both.
|
||||
const newOffsetX = rect.width / 2 / targetZoom - contentCenter.x;
|
||||
const newOffsetY = rect.height / 2 / targetZoom - contentCenter.y;
|
||||
|
||||
const delta = {
|
||||
x: viewportCenter.x - contentCenter.x,
|
||||
y: viewportCenter.y - contentCenter.y,
|
||||
};
|
||||
|
||||
if (Math.abs(delta.x) > 0.0001 || Math.abs(delta.y) > 0.0001) {
|
||||
this.translateWorkspace(delta);
|
||||
this.#renderConnections();
|
||||
// Cancel any running smooth zoom before snapping.
|
||||
if (this.#smoothZoomRafId !== null) {
|
||||
cancelAnimationFrame(this.#smoothZoomRafId);
|
||||
this.#smoothZoomRafId = null;
|
||||
this.#smoothZoomFocus = null;
|
||||
this.#pendingLayerOffset = { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
this.#markViewDirty("view", 200);
|
||||
this.#schedulePersist();
|
||||
this.#targetZoomLevel = targetZoom;
|
||||
this.#zoomLevel = targetZoom;
|
||||
this.setBackgroundOffset({ x: newOffsetX, y: newOffsetY });
|
||||
this.updateZoomDisplay(false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -381,25 +523,18 @@ export class WorkspaceNavigationManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const { dragManager, contextMenuManager, graph, nodeElements } =
|
||||
this.#workspace;
|
||||
const { dragManager, contextMenuManager } = this.#workspace;
|
||||
|
||||
dragManager?.removeNodeGhost();
|
||||
dragManager?.removePaletteGhost();
|
||||
|
||||
const nodeOrigins = new Map();
|
||||
graph.nodes.forEach((node) => {
|
||||
nodeOrigins.set(node.id, { x: node.position.x, y: node.position.y });
|
||||
});
|
||||
|
||||
const state = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
lastDelta: { x: 0, y: 0 },
|
||||
hasMoved: false,
|
||||
nodeOrigins,
|
||||
backgroundOrigin: { ...this.#backgroundOffset },
|
||||
lastDelta: { x: 0, y: 0 },
|
||||
};
|
||||
|
||||
const handlePointerMove = (moveEvent) => {
|
||||
@@ -413,7 +548,6 @@ export class WorkspaceNavigationManager {
|
||||
x: deltaXScreen,
|
||||
y: deltaYScreen,
|
||||
});
|
||||
this.#panState.lastDelta = worldDelta;
|
||||
|
||||
if (!this.#panState.hasMoved) {
|
||||
const distance = Math.hypot(deltaXScreen, deltaYScreen);
|
||||
@@ -422,32 +556,21 @@ export class WorkspaceNavigationManager {
|
||||
}
|
||||
moveEvent.preventDefault();
|
||||
this.#panState.hasMoved = true;
|
||||
contextMenuManager?.hide();
|
||||
contextMenuManager?.hide();
|
||||
}
|
||||
|
||||
if (!this.#panState.hasMoved) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#panState.lastDelta = worldDelta;
|
||||
|
||||
const backgroundOffset = {
|
||||
x: this.#panState.backgroundOrigin.x + worldDelta.x,
|
||||
y: this.#panState.backgroundOrigin.y + worldDelta.y,
|
||||
};
|
||||
this.setBackgroundOffset(backgroundOffset);
|
||||
|
||||
this.#panState.nodeOrigins.forEach((origin, nodeId) => {
|
||||
const element = nodeElements.get(nodeId);
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
const next = {
|
||||
x: origin.x + worldDelta.x,
|
||||
y: origin.y + worldDelta.y,
|
||||
};
|
||||
element.style.transform = WorkspaceGeometry.positionToTransform(next);
|
||||
});
|
||||
|
||||
this.#renderConnections();
|
||||
this.updateZoomDisplay(true);
|
||||
};
|
||||
|
||||
const handlePointerUp = (upEvent) => {
|
||||
@@ -467,17 +590,7 @@ export class WorkspaceNavigationManager {
|
||||
}
|
||||
|
||||
this.#lastPanTimestamp = this.#timestamp();
|
||||
const delta = finalState.lastDelta;
|
||||
this.setBackgroundOffset({
|
||||
x: finalState.backgroundOrigin.x + delta.x,
|
||||
y: finalState.backgroundOrigin.y + delta.y,
|
||||
});
|
||||
finalState.nodeOrigins.forEach((origin, nodeId) => {
|
||||
graph.setNodePosition(nodeId, {
|
||||
x: origin.x + delta.x,
|
||||
y: origin.y + delta.y,
|
||||
});
|
||||
});
|
||||
this.#renderConnections();
|
||||
};
|
||||
|
||||
this.#panState = state;
|
||||
@@ -527,8 +640,8 @@ export class WorkspaceNavigationManager {
|
||||
*/
|
||||
#applyBackgroundOffset() {
|
||||
const zoom = this.#zoomLevel || 1;
|
||||
const scaledX = this.#backgroundOffset.x * zoom;
|
||||
const scaledY = this.#backgroundOffset.y * zoom;
|
||||
const scaledX = (this.#backgroundOffset.x + this.#pendingLayerOffset.x) * zoom;
|
||||
const scaledY = (this.#backgroundOffset.y + this.#pendingLayerOffset.y) * zoom;
|
||||
const position = `${scaledX}px ${scaledY}px`;
|
||||
this.#workspace.workspaceElement.style.backgroundPosition = `${position}, ${position}, ${position}, ${position}`;
|
||||
}
|
||||
@@ -551,13 +664,6 @@ export class WorkspaceNavigationManager {
|
||||
x: this.#panState.backgroundOrigin.x + shift.x,
|
||||
y: this.#panState.backgroundOrigin.y + shift.y,
|
||||
};
|
||||
|
||||
this.#panState.nodeOrigins.forEach((origin, nodeId, map) => {
|
||||
map.set(nodeId, {
|
||||
x: origin.x + shift.x,
|
||||
y: origin.y + shift.y,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
227
scripts/ui/workspace/WorkspaceNodeContextMenu.js
Normal file
227
scripts/ui/workspace/WorkspaceNodeContextMenu.js
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace
|
||||
*/
|
||||
|
||||
/**
|
||||
* Handles node-specific context menu interactions (right-click on nodes).
|
||||
*/
|
||||
export class WorkspaceNodeContextMenu {
|
||||
#workspace;
|
||||
/** @type {HTMLDivElement | null} */
|
||||
#container;
|
||||
/** @type {boolean} */
|
||||
#isVisible;
|
||||
/** @type {string | null} */
|
||||
#targetNodeId;
|
||||
|
||||
/**
|
||||
* @param {BlueprintWorkspace} workspace Owning workspace instance.
|
||||
*/
|
||||
constructor(workspace) {
|
||||
this.#workspace = workspace;
|
||||
this.#container = null;
|
||||
this.#isVisible = false;
|
||||
this.#targetNodeId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the node context menu DOM if it does not already exist.
|
||||
*/
|
||||
initialize() {
|
||||
if (this.#container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.className = "node-context-menu";
|
||||
container.setAttribute("role", "menu");
|
||||
container.setAttribute("aria-label", "Node actions");
|
||||
|
||||
const deleteItem = this.#createMenuItem("Delete", () => {
|
||||
this.#handleDelete();
|
||||
});
|
||||
deleteItem.dataset.action = "delete";
|
||||
container.appendChild(deleteItem);
|
||||
|
||||
const copyItem = this.#createMenuItem("Copy", () => {
|
||||
this.#handleCopy();
|
||||
});
|
||||
copyItem.dataset.action = "copy";
|
||||
container.appendChild(copyItem);
|
||||
|
||||
const refreshItem = this.#createMenuItem("Refresh", () => {
|
||||
this.#handleRefresh();
|
||||
});
|
||||
refreshItem.dataset.action = "refresh";
|
||||
container.appendChild(refreshItem);
|
||||
|
||||
this.#workspace.workspaceElement.appendChild(container);
|
||||
|
||||
document.addEventListener("pointerdown", (event) => {
|
||||
if (!this.#isVisible) {
|
||||
return;
|
||||
}
|
||||
if (!(event.target instanceof Node)) {
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
if (!container.contains(event.target)) {
|
||||
this.hide();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (!this.#isVisible) {
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
this.hide();
|
||||
}
|
||||
});
|
||||
|
||||
this.#container = container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a menu item button with the specified label and click handler.
|
||||
*
|
||||
* @param {string} label Menu item label.
|
||||
* @param {() => void} onClick Click handler.
|
||||
* @returns {HTMLButtonElement}
|
||||
*/
|
||||
#createMenuItem(label, onClick) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "node-context-menu-item";
|
||||
button.setAttribute("role", "menuitem");
|
||||
button.textContent = label;
|
||||
button.addEventListener("click", () => {
|
||||
onClick();
|
||||
this.hide();
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the context menu is currently displayed.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isVisible() {
|
||||
return this.#isVisible;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the provided target node resides inside the context menu.
|
||||
*
|
||||
* @param {Node | null} target Target node.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isTargetInside(target) {
|
||||
return Boolean(
|
||||
this.#container && target && this.#container.contains(target)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the node context menu at the given viewport coordinates.
|
||||
*
|
||||
* @param {number} clientX Viewport X coordinate.
|
||||
* @param {number} clientY Viewport Y coordinate.
|
||||
* @param {string} nodeId Target node identifier.
|
||||
*/
|
||||
show(clientX, clientY, nodeId) {
|
||||
this.initialize();
|
||||
|
||||
if (!this.#container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceRect =
|
||||
this.#workspace.workspaceElement.getBoundingClientRect();
|
||||
const relativeX = Math.max(0, clientX - workspaceRect.left);
|
||||
const relativeY = Math.max(0, clientY - workspaceRect.top);
|
||||
|
||||
this.#targetNodeId = nodeId;
|
||||
|
||||
this.#container.classList.add("is-visible");
|
||||
this.#container.style.left = `${relativeX}px`;
|
||||
this.#container.style.top = `${relativeY}px`;
|
||||
this.#isVisible = true;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!this.#container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const menuRect = this.#container.getBoundingClientRect();
|
||||
let adjustedLeft = relativeX;
|
||||
let adjustedTop = relativeY;
|
||||
|
||||
if (menuRect.right > workspaceRect.right) {
|
||||
adjustedLeft -= menuRect.right - workspaceRect.right;
|
||||
}
|
||||
if (menuRect.bottom > workspaceRect.bottom) {
|
||||
adjustedTop -= menuRect.bottom - workspaceRect.bottom;
|
||||
}
|
||||
|
||||
adjustedLeft = Math.max(0, adjustedLeft);
|
||||
adjustedTop = Math.max(0, adjustedTop);
|
||||
|
||||
this.#container.style.left = `${adjustedLeft}px`;
|
||||
this.#container.style.top = `${adjustedTop}px`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Conceals the node context menu and clears its state.
|
||||
*/
|
||||
hide() {
|
||||
if (!this.#container || !this.#isVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#container.classList.remove("is-visible");
|
||||
this.#isVisible = false;
|
||||
this.#targetNodeId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the delete action for the target node.
|
||||
*/
|
||||
#handleDelete() {
|
||||
if (!this.#targetNodeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#workspace.removeNode(this.#targetNodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the copy action for the target node.
|
||||
*/
|
||||
#handleCopy() {
|
||||
if (!this.#targetNodeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wasSelected = this.#workspace.selectedNodeIds.has(this.#targetNodeId);
|
||||
if (!wasSelected) {
|
||||
this.#workspace.selectNode(this.#targetNodeId);
|
||||
}
|
||||
|
||||
this.#workspace.clipboardManager.copySelection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the refresh action for the target node.
|
||||
*/
|
||||
#handleRefresh() {
|
||||
if (!this.#targetNodeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#workspace.refreshNode(this.#targetNodeId);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { WorkspaceGeometry } from "./WorkspaceGeometry.js";
|
||||
import { getTypeColor } from "../../types/TypeRegistry.js";
|
||||
|
||||
/**
|
||||
* @typedef {"input"|"output"} PinDirection
|
||||
@@ -152,6 +153,36 @@ export class WorkspaceNodeRenderer {
|
||||
article.classList.remove("is-hovered");
|
||||
});
|
||||
|
||||
article.addEventListener("pointerdown", (event) => {
|
||||
if (event.button === 2) {
|
||||
this.#workspace.navigationManager?.beginPan(event);
|
||||
}
|
||||
});
|
||||
|
||||
article.addEventListener("contextmenu", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (this.#workspace.navigationManager?.isPanRecent(300)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = /** @type {EventTarget | null} */ (event.target);
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.closest(".pin")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.#workspace.selectedNodeIds.has(node.id)) {
|
||||
this.#callbacks.selectNode(node.id);
|
||||
}
|
||||
|
||||
this.#workspace.nodeContextMenu.show(event.clientX, event.clientY, node.id);
|
||||
});
|
||||
|
||||
this.#workspace.nodeLayer.appendChild(article);
|
||||
this.#workspace.nodeElements.set(node.id, article);
|
||||
this.#inlineEditors.syncNode(node.id);
|
||||
@@ -270,6 +301,7 @@ export class WorkspaceNodeRenderer {
|
||||
container.dataset.pinId = pin.id;
|
||||
container.dataset.nodeId = node.id;
|
||||
container.dataset.type = pin.kind;
|
||||
container.style.setProperty("--pin-kind-color", getTypeColor(pin.kind));
|
||||
const isStandardExec =
|
||||
pin.kind === "exec" && (pin.id === "exec_in" || pin.id === "exec_out");
|
||||
if (isStandardExec) {
|
||||
|
||||
@@ -54,28 +54,48 @@ export class WorkspacePersistenceManager {
|
||||
}
|
||||
|
||||
this.cancelScheduledPersist();
|
||||
this.#workspace.handlePersistenceStateChange?.({
|
||||
status: "pending",
|
||||
source: "auto",
|
||||
});
|
||||
this.#timerId = window.setTimeout(() => {
|
||||
this.#timerId = null;
|
||||
this.persistImmediately();
|
||||
this.persistImmediately("auto");
|
||||
}, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes current workspace state and writes it to localStorage immediately.
|
||||
*
|
||||
* @param {'manual'|'auto'} [source]
|
||||
*/
|
||||
persistImmediately() {
|
||||
persistImmediately(source = "manual") {
|
||||
if (!this.#storageAvailable || typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cancelScheduledPersist();
|
||||
this.#workspace.handlePersistenceStateChange?.({
|
||||
status: "saving",
|
||||
source,
|
||||
});
|
||||
|
||||
try {
|
||||
const payload = this.serializeWorkspace();
|
||||
window.localStorage.setItem(
|
||||
WORKSPACE_STORAGE_KEY,
|
||||
JSON.stringify(payload)
|
||||
);
|
||||
this.#workspace.handlePersistenceStateChange?.({
|
||||
status: "saved",
|
||||
source,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to persist workspace state", error);
|
||||
this.#workspace.handlePersistenceStateChange?.({
|
||||
status: "error",
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +110,7 @@ export class WorkspacePersistenceManager {
|
||||
.filter((entry) => Boolean(entry))
|
||||
.map((entry) => {
|
||||
const variable =
|
||||
/** @type {{ id: string, name: string, type: 'any'|'number'|'string'|'boolean'|'table', defaultValue: import('../BlueprintWorkspace.js').VariableDefaultValue }} */ (
|
||||
/** @type {{ id: string, name: string, type: 'any'|'number'|'string'|'boolean'|'table'|'color', defaultValue: import('../BlueprintWorkspace.js').VariableDefaultValue }} */ (
|
||||
entry
|
||||
);
|
||||
return {
|
||||
|
||||
259
scripts/ui/workspace/WorkspaceWireCutManager.js
Normal file
259
scripts/ui/workspace/WorkspaceWireCutManager.js
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{ x: number, y: number }} Point
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* pointerId: number,
|
||||
* startClient: Point,
|
||||
* currentClient: Point,
|
||||
* cutLine: SVGLineElement,
|
||||
* hasMoved: boolean,
|
||||
* }} CutState
|
||||
*/
|
||||
|
||||
/**
|
||||
* Drives the middle-mouse wire-cut gesture.
|
||||
* Hold middle mouse and drag a line across wires to disconnect them.
|
||||
*/
|
||||
export class WorkspaceWireCutManager {
|
||||
/** @type {BlueprintWorkspace} */
|
||||
#workspace;
|
||||
/** @type {CutState | null} */
|
||||
#state;
|
||||
|
||||
/**
|
||||
* @param {BlueprintWorkspace} workspace Owning workspace instance.
|
||||
*/
|
||||
constructor(workspace) {
|
||||
this.#workspace = workspace;
|
||||
this.#state = null;
|
||||
|
||||
this.handleMove = this.handleMove.bind(this);
|
||||
this.handleUp = this.handleUp.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a cut gesture from a middle-mouse pointerdown event.
|
||||
*
|
||||
* @param {PointerEvent} event Middle-mouse pointerdown event.
|
||||
*/
|
||||
beginCut(event) {
|
||||
if (this.#state) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const cutLine = document.createElementNS("http://www.w3.org/2000/svg", "line");
|
||||
cutLine.classList.add("wire-cut-line");
|
||||
|
||||
const svgPos = this.#clientToSvg(event.clientX, event.clientY);
|
||||
cutLine.setAttribute("x1", String(svgPos.x));
|
||||
cutLine.setAttribute("y1", String(svgPos.y));
|
||||
cutLine.setAttribute("x2", String(svgPos.x));
|
||||
cutLine.setAttribute("y2", String(svgPos.y));
|
||||
this.#workspace.connectionLayer.appendChild(cutLine);
|
||||
|
||||
this.#state = {
|
||||
pointerId: event.pointerId,
|
||||
startClient: { x: event.clientX, y: event.clientY },
|
||||
currentClient: { x: event.clientX, y: event.clientY },
|
||||
cutLine,
|
||||
hasMoved: false,
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", this.handleMove);
|
||||
window.addEventListener("pointerup", this.handleUp);
|
||||
window.addEventListener("pointercancel", this.handleUp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a cut gesture is currently active.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isCutting() {
|
||||
return this.#state !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts viewport client coordinates to SVG-layer space.
|
||||
*
|
||||
* @param {number} clientX
|
||||
* @param {number} clientY
|
||||
* @returns {Point}
|
||||
*/
|
||||
#clientToSvg(clientX, clientY) {
|
||||
const zoom = this.#workspace.zoomLevel || 1;
|
||||
const offset = this.#workspace.navigationManager?.getEffectiveOffset() ??
|
||||
this.#workspace.workspaceBackgroundOffset ?? { x: 0, y: 0 };
|
||||
const rect = this.#workspace.workspaceElement.getBoundingClientRect();
|
||||
return {
|
||||
x: (clientX - rect.left) / zoom - offset.x,
|
||||
y: (clientY - rect.top) / zoom - offset.y,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} event
|
||||
*/
|
||||
handleMove(event) {
|
||||
if (!this.#state || event.pointerId !== this.#state.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#state.currentClient = { x: event.clientX, y: event.clientY };
|
||||
|
||||
const dx = event.clientX - this.#state.startClient.x;
|
||||
const dy = event.clientY - this.#state.startClient.y;
|
||||
if (!this.#state.hasMoved && Math.hypot(dx, dy) >= 4) {
|
||||
this.#state.hasMoved = true;
|
||||
}
|
||||
|
||||
const start = this.#clientToSvg(this.#state.startClient.x, this.#state.startClient.y);
|
||||
const end = this.#clientToSvg(event.clientX, event.clientY);
|
||||
|
||||
this.#state.cutLine.setAttribute("x1", String(start.x));
|
||||
this.#state.cutLine.setAttribute("y1", String(start.y));
|
||||
this.#state.cutLine.setAttribute("x2", String(end.x));
|
||||
this.#state.cutLine.setAttribute("y2", String(end.y));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} event
|
||||
*/
|
||||
handleUp(event) {
|
||||
if (!this.#state || event.pointerId !== this.#state.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.removeEventListener("pointermove", this.handleMove);
|
||||
window.removeEventListener("pointerup", this.handleUp);
|
||||
window.removeEventListener("pointercancel", this.handleUp);
|
||||
|
||||
const state = this.#state;
|
||||
this.#state = null;
|
||||
state.cutLine.remove();
|
||||
|
||||
if (!state.hasMoved) {
|
||||
return;
|
||||
}
|
||||
|
||||
const p1 = this.#clientToSvg(state.startClient.x, state.startClient.y);
|
||||
const p2 = this.#clientToSvg(state.currentClient.x, state.currentClient.y);
|
||||
this.#cutIntersecting(p1, p2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all connections whose bezier curves intersect the given line segment.
|
||||
*
|
||||
* @param {Point} p1 Start of cut segment in SVG space.
|
||||
* @param {Point} p2 End of cut segment in SVG space.
|
||||
*/
|
||||
#cutIntersecting(p1, p2) {
|
||||
const { connectionElements, graph, historyManager } = this.#workspace;
|
||||
/** @type {string[]} */
|
||||
const toRemove = [];
|
||||
|
||||
connectionElements.forEach((pathEl, connectionId) => {
|
||||
const d = pathEl.getAttribute("d");
|
||||
if (!d) {
|
||||
return;
|
||||
}
|
||||
if (this.#bezierIntersectsSegment(d, p1, p2)) {
|
||||
toRemove.push(connectionId);
|
||||
}
|
||||
});
|
||||
|
||||
if (!toRemove.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Batch all removals into a single undo step.
|
||||
historyManager?.withSuspended(() => {
|
||||
toRemove.forEach((id) => graph.removeConnection(id));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the cubic bezier described by a path `d` attribute crosses the segment p1→p2.
|
||||
*
|
||||
* @param {string} d SVG path `d` attribute value.
|
||||
* @param {Point} p1
|
||||
* @param {Point} p2
|
||||
* @returns {boolean}
|
||||
*/
|
||||
#bezierIntersectsSegment(d, p1, p2) {
|
||||
const m = d.match(
|
||||
/M\s*([\d.\-e]+)\s+([\d.\-e]+)\s+C\s*([\d.\-e]+)\s+([\d.\-e]+)\s+([\d.\-e]+)\s+([\d.\-e]+)\s+([\d.\-e]+)\s+([\d.\-e]+)/i
|
||||
);
|
||||
if (!m) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bp0 = { x: parseFloat(m[1]), y: parseFloat(m[2]) };
|
||||
const bc1 = { x: parseFloat(m[3]), y: parseFloat(m[4]) };
|
||||
const bc2 = { x: parseFloat(m[5]), y: parseFloat(m[6]) };
|
||||
const bp3 = { x: parseFloat(m[7]), y: parseFloat(m[8]) };
|
||||
|
||||
const SAMPLES = 32;
|
||||
let prev = bp0;
|
||||
for (let i = 1; i <= SAMPLES; i++) {
|
||||
const t = i / SAMPLES;
|
||||
const curr = this.#cubicBezierPoint(bp0, bc1, bc2, bp3, t);
|
||||
if (this.#segmentsIntersect(prev, curr, p1, p2)) {
|
||||
return true;
|
||||
}
|
||||
prev = curr;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates a cubic bezier at parameter t.
|
||||
*
|
||||
* @param {Point} p0
|
||||
* @param {Point} c1
|
||||
* @param {Point} c2
|
||||
* @param {Point} p3
|
||||
* @param {number} t
|
||||
* @returns {Point}
|
||||
*/
|
||||
#cubicBezierPoint(p0, c1, c2, p3, t) {
|
||||
const mt = 1 - t;
|
||||
return {
|
||||
x: mt * mt * mt * p0.x + 3 * mt * mt * t * c1.x + 3 * mt * t * t * c2.x + t * t * t * p3.x,
|
||||
y: mt * mt * mt * p0.y + 3 * mt * mt * t * c1.y + 3 * mt * t * t * c2.y + t * t * t * p3.y,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if line segments a1→a2 and b1→b2 cross each other.
|
||||
*
|
||||
* @param {Point} a1
|
||||
* @param {Point} a2
|
||||
* @param {Point} b1
|
||||
* @param {Point} b2
|
||||
* @returns {boolean}
|
||||
*/
|
||||
#segmentsIntersect(a1, a2, b1, b2) {
|
||||
const dx1 = a2.x - a1.x;
|
||||
const dy1 = a2.y - a1.y;
|
||||
const dx2 = b2.x - b1.x;
|
||||
const dy2 = b2.y - b1.y;
|
||||
const denom = dx1 * dy2 - dy1 * dx2;
|
||||
if (Math.abs(denom) < 1e-10) {
|
||||
return false;
|
||||
}
|
||||
const dx3 = b1.x - a1.x;
|
||||
const dy3 = b1.y - a1.y;
|
||||
const t = (dx3 * dy2 - dy3 * dx2) / denom;
|
||||
const u = (dx3 * dy1 - dy3 * dx1) / denom;
|
||||
return t >= 0 && t <= 1 && u >= 0 && u <= 1;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { VARIABLE_SORT_MIME } from "../BlueprintWorkspaceConstants.js";
|
||||
import { getTypeColor } from "../../types/TypeRegistry.js";
|
||||
|
||||
/**
|
||||
* Renders the global variable summary list.
|
||||
@@ -225,6 +226,7 @@ export function renderVariableList({
|
||||
row.setAttribute("role", "button");
|
||||
row.dataset.variableId = variable.id;
|
||||
row.dataset.variableType = variable.type;
|
||||
row.style.setProperty("--overview-variable-color", getTypeColor(variable.type));
|
||||
row.draggable = true;
|
||||
row.setAttribute("aria-pressed", "false");
|
||||
|
||||
@@ -330,6 +332,7 @@ export function renderVariableList({
|
||||
typeButton.type = "button";
|
||||
typeButton.className = "variable-type-button";
|
||||
typeButton.dataset.variableType = variable.type;
|
||||
typeButton.style.setProperty("--overview-variable-color", getTypeColor(variable.type));
|
||||
typeButton.setAttribute("aria-haspopup", "menu");
|
||||
typeButton.setAttribute("aria-expanded", "false");
|
||||
typeButton.title = `Type: ${formatVariableType(variable.type)}`;
|
||||
|
||||
176
sprite-editor.html
Normal file
176
sprite-editor.html
Normal file
@@ -0,0 +1,176 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PICO-8 Sprite Editor - Picograph</title>
|
||||
<link rel="stylesheet" href="styles/main.css">
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#editor-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sprite-editor-header {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--surface-2);
|
||||
border-bottom: 1px solid var(--edge);
|
||||
}
|
||||
|
||||
.sprite-editor-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sprite-editor-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sprite-editor-btn {
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="sprite-editor-header">
|
||||
<div class="sprite-editor-title">🎨 Sprite Editor</div>
|
||||
<div></div>
|
||||
<div class="sprite-editor-actions">
|
||||
<button class="sprite-editor-btn" id="export-btn">Export to .p8</button>
|
||||
<button class="sprite-editor-btn" id="import-btn">Import from .p8</button>
|
||||
<button class="sprite-editor-btn" id="save-json-btn">Save JSON</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="editor-container"></div>
|
||||
|
||||
<script type="module">
|
||||
import { SpriteEditor } from './scripts/ui/SpriteEditor.js';
|
||||
import { SpriteSheet } from './scripts/core/SpriteSheet.js';
|
||||
|
||||
// Initialize the sprite editor
|
||||
const container = document.getElementById('editor-container');
|
||||
const spriteSheet = new SpriteSheet();
|
||||
const editor = new SpriteEditor(container, spriteSheet);
|
||||
|
||||
// Export to .p8 format
|
||||
document.getElementById('export-btn').addEventListener('click', () => {
|
||||
const gfxData = editor.getSpriteSheet().toP8Gfx();
|
||||
const flagData = editor.getSpriteSheet().toP8Flags();
|
||||
|
||||
const p8Content = `pico-8 cartridge // http://www.pico-8.com
|
||||
version 41
|
||||
__lua__
|
||||
-- your code here
|
||||
__gfx__
|
||||
${gfxData}
|
||||
__gff__
|
||||
${flagData}
|
||||
`;
|
||||
|
||||
// Download the .p8 file
|
||||
const blob = new Blob([p8Content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'sprites.p8';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
// Import from .p8 format
|
||||
document.getElementById('import-btn').addEventListener('click', () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.p8';
|
||||
|
||||
input.addEventListener('change', async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const text = await file.text();
|
||||
|
||||
// Extract __gfx__ section
|
||||
const gfxMatch = text.match(/__gfx__\s*\n([\s\S]*?)(?=\n__|$)/);
|
||||
if (gfxMatch) {
|
||||
editor.getSpriteSheet().fromP8Gfx(gfxMatch[1]);
|
||||
}
|
||||
|
||||
// Extract __gff__ section
|
||||
const gffMatch = text.match(/__gff__\s*\n([\s\S]*?)(?=\n__|$)/);
|
||||
if (gffMatch) {
|
||||
editor.getSpriteSheet().fromP8Flags(gffMatch[1]);
|
||||
}
|
||||
|
||||
// Re-render
|
||||
editor.setSpriteSheet(editor.getSpriteSheet());
|
||||
});
|
||||
|
||||
input.click();
|
||||
});
|
||||
|
||||
// Save as JSON
|
||||
document.getElementById('save-json-btn').addEventListener('click', () => {
|
||||
const json = JSON.stringify(editor.getSpriteSheet().toJSON(), null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'sprites.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
// Example: Draw a smiley face on sprite 0
|
||||
function drawExampleSprite() {
|
||||
const sheet = editor.getSpriteSheet();
|
||||
|
||||
// Draw a simple smiley face
|
||||
// Face outline (yellow)
|
||||
for (let x = 1; x < 7; x++) {
|
||||
sheet.setPixel(x, 1, 10); // Top
|
||||
sheet.setPixel(x, 6, 10); // Bottom
|
||||
}
|
||||
for (let y = 2; y < 6; y++) {
|
||||
sheet.setPixel(1, y, 10); // Left
|
||||
sheet.setPixel(6, y, 10); // Right
|
||||
}
|
||||
|
||||
// Eyes (black)
|
||||
sheet.setPixel(2, 3, 0);
|
||||
sheet.setPixel(5, 3, 0);
|
||||
|
||||
// Smile (black)
|
||||
sheet.setPixel(2, 5, 0);
|
||||
sheet.setPixel(3, 5, 0);
|
||||
sheet.setPixel(4, 5, 0);
|
||||
sheet.setPixel(5, 5, 0);
|
||||
|
||||
editor.setSpriteSheet(sheet);
|
||||
}
|
||||
|
||||
// Uncomment to draw example sprite on load
|
||||
// drawExampleSprite();
|
||||
|
||||
console.log('Sprite Editor initialized!');
|
||||
console.log('Available tools: Pen, Fill, Line, Rect, Circle, Erase');
|
||||
console.log('Click sprites in the sheet to edit them in the preview panel');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1059
styles/main.css
1059
styles/main.css
File diff suppressed because it is too large
Load Diff
302
test2.p8
Normal file
302
test2.p8
Normal file
@@ -0,0 +1,302 @@
|
||||
pico-8 cartridge // http://www.pico-8.com
|
||||
version 8
|
||||
__lua__
|
||||
print("0.1.10c")
|
||||
|
||||
__gfx__
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00700700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00077000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00077000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00700700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
|
||||
__gff__
|
||||
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
__map__
|
||||
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
__sfx__
|
||||
000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
__music__
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
00 41424344
|
||||
|
||||
24
test_cart.p8
Normal file
24
test_cart.p8
Normal file
@@ -0,0 +1,24 @@
|
||||
pico-8 cartridge // http://www.pico-8.com
|
||||
version 41
|
||||
__lua__
|
||||
function _init()
|
||||
x=64
|
||||
y=64
|
||||
end
|
||||
|
||||
function _update()
|
||||
end
|
||||
|
||||
function _draw()
|
||||
cls()
|
||||
print("picograph ok",2,60,7)
|
||||
end
|
||||
__gfx__
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
8
test_unix.p8
Normal file
8
test_unix.p8
Normal file
@@ -0,0 +1,8 @@
|
||||
pico-8 cartridge // http://www.pico-8.com
|
||||
version 41
|
||||
__lua__
|
||||
function _init()
|
||||
x=64
|
||||
end
|
||||
__gfx__
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
Reference in New Issue
Block a user