7 Commits

Author SHA1 Message Date
3d56c7ce04 Remove arithmetic node modules: add, multiply, and subtract 2026-03-21 18:39:19 +11:00
f917cf6ad5 Add initial Pico-8 cartridges for testing
- Created test_cart.p8 with basic drawing functionality and a print statement.
- Created test_unix.p8 with a simple initialization function.
2026-03-21 18:38:43 +11:00
71354ad463 Remove unused literal node modules: boolean, color, number, and string 2026-03-21 18:38:22 +11:00
ae374db9fb updated graph rendering to be more efficient. added smooth scrolling, added color type, mmb drag for cutting 2026-03-21 06:57:26 +11:00
1f5da03474 Merge pull request #5 from litruv/copilot/remove-menu-bar-electron
Remove menu bar from Electron application
2025-10-20 02:53:11 +11:00
copilot-swe-agent[bot]
9261def7cd Remove menu bar from Electron application
Co-authored-by: litruv <3798007+litruv@users.noreply.github.com>
2025-10-19 15:48:48 +00:00
copilot-swe-agent[bot]
b9712954bf Initial plan 2025-10-19 15:44:17 +00:00
97 changed files with 14167 additions and 872 deletions

7
InstallationLog.txt Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -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();

View File

@@ -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
View File

@@ -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",

View File

@@ -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

File diff suppressed because it is too large Load Diff

1
picotool Submodule

Submodule picotool added at 49808e5ddb

View File

@@ -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
View 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

File diff suppressed because it is too large Load Diff

View 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

File diff suppressed because one or more lines are too long

25
public/pico8_player.js Normal file

File diff suppressed because one or more lines are too long

55
public/player.html Normal file
View 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
View 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); });

View File

@@ -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.
*/

View File

@@ -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) {

View File

@@ -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);
}
/**

View File

@@ -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
View 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
View 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);
}
}

View 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),
},
},
};

View 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),
},
},
};

View 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),
},
},
};

View 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 ?? ""),
},
},
};

View 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,
};

View 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);
}

View File

@@ -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", () => {

View File

@@ -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);
},
}
);

View 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");
},
}
);

View File

@@ -0,0 +1,7 @@
import { colorToNumberNode } from "./colorToNumber.js";
import { numberToColorNode } from "./numberToColor.js";
/**
* All type conversion nodes.
*/
export const converterNodes = [colorToNumberNode, numberToColorNode];

View 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");
},
}
);

View File

@@ -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" },

View File

@@ -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" },

View File

@@ -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" },

View File

@@ -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" },

View File

@@ -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" },

View File

@@ -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" },

View File

@@ -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" },

View File

@@ -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" },

View File

@@ -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" },

View File

@@ -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",

View File

@@ -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: [],
},

View File

@@ -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" },

View File

@@ -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" },

View File

@@ -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" },

View File

@@ -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" },

View File

@@ -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" },

View File

@@ -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: [],
},

View File

@@ -30,7 +30,7 @@ export const ssetNode = createNodeModule(
id: "color",
name: "Color",
direction: "input",
kind: "number",
kind: "color",
defaultValue: 0,
},
],

View File

@@ -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,

View File

@@ -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",
},
],

View File

@@ -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);

View File

@@ -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",

View 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})`;
},
}
);

View File

@@ -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 },

View File

@@ -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})`;
},
}

View 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})`;
},
}
);

View File

@@ -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);
},
}
);

View File

@@ -37,7 +37,7 @@ export const printNode = createNodeModule(
id: "color",
name: "Color",
direction: "input",
kind: "number",
kind: "color",
defaultValue: 7,
},
],

View 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");
},
}
);

View File

@@ -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 ?? "");
},
}
);

View File

@@ -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" },
],

View File

@@ -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
View 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,
};

View 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 }
],
};

View 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
View 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,
};

View 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 },
],
};

View 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",
};

View 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",
};

View 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;
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,184 @@
/**
* Ordered PICO-8 palette hex strings, indices 015.
*
* @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 (015) 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 (015).
*/
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 (015).
*/
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 (015).
*
* @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));
}

View 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();
}
}
}

View 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
View 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();
}
}

View 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
View 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);
}
}
}

View 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();
}
}

View File

@@ -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,
};
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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,
});
});
}
/**

View 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);
}
}

View File

@@ -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) {

View File

@@ -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 {

View 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;
}
}

View File

@@ -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
View 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>

File diff suppressed because it is too large Load Diff

302
test2.p8 Normal file
View 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
View 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
View File

@@ -0,0 +1,8 @@
pico-8 cartridge // http://www.pico-8.com
version 41
__lua__
function _init()
x=64
end
__gfx__
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000