mirror of
https://github.com/litruv/picoGraph.git
synced 2026-07-24 02:36:04 +10:00
Compare commits
4 Commits
v2025-10-1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d56c7ce04 | |||
| f917cf6ad5 | |||
| 71354ad463 | |||
| ae374db9fb |
7
InstallationLog.txt
Normal file
7
InstallationLog.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
************************************* Invoked: Sat Mar 21 02:32:52 2026
|
||||||
|
[0] Arguments: C:\Users\litru\AppData\Local\Temp\WinGet\MSYS2.MSYS2.20251213\msys2-x86_64-20251213.exe, install, --confirm-command, --root, C:\msys64
|
||||||
|
[10] Operations sanity check succeeded.
|
||||||
|
[22] Using metadata cache from "C:/Users/litru/AppData/Local/cache\\qt-installer-framework\\d75f1c19-3379-3717-ae8d-1404b51494a9"
|
||||||
|
[22] Found 0 cached items.
|
||||||
|
[29] Loaded control script ":/metadata/installer-config/control_js"
|
||||||
|
[30] TargetDirectoryInUse : Error : The directory you selected already exists and contains an installation. Choose a different target for installation.
|
||||||
BIN
all_star_croquet-0.p8.png
Normal file
BIN
all_star_croquet-0.p8.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
226
electron-main.js
226
electron-main.js
@@ -1,6 +1,24 @@
|
|||||||
const { app, BrowserWindow, ipcMain, Menu } = 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 fs = require("node:fs/promises");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
|
const { spawn } = require("node:child_process");
|
||||||
|
|
||||||
const OUTPUT_FILENAME = "compiled.lua";
|
const OUTPUT_FILENAME = "compiled.lua";
|
||||||
|
|
||||||
@@ -23,6 +41,16 @@ function getOutputPath() {
|
|||||||
return path.join(getOutputDirectory(), OUTPUT_FILENAME);
|
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} */
|
/** @type {import("electron").BrowserWindow | null} */
|
||||||
let mainWindow = null;
|
let mainWindow = null;
|
||||||
|
|
||||||
@@ -37,6 +65,7 @@ function createMainWindow() {
|
|||||||
height: 900,
|
height: 900,
|
||||||
minWidth: 960,
|
minWidth: 960,
|
||||||
minHeight: 640,
|
minHeight: 640,
|
||||||
|
frame: false,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
@@ -51,6 +80,23 @@ function createMainWindow() {
|
|||||||
|
|
||||||
window.loadFile(path.join(__dirname, "index.html"));
|
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", () => {
|
window.on("closed", () => {
|
||||||
mainWindow = null;
|
mainWindow = null;
|
||||||
});
|
});
|
||||||
@@ -75,6 +121,184 @@ function registerIpcHandlers() {
|
|||||||
|
|
||||||
return { filePath: outputPath };
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
118
index.html
118
index.html
@@ -3,16 +3,23 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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>
|
<title>picoGraph</title>
|
||||||
<link rel="stylesheet" href="styles/main.css" />
|
<link rel="stylesheet" href="styles/main.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="header-primary-actions">
|
<div class="header-actions header-actions--left" aria-label="Workspace tools">
|
||||||
<button id="compileButton" class="header-primary-button" type="button">Compile</button>
|
<button
|
||||||
</div>
|
id="tileManagerButton"
|
||||||
<div class="header-title">picoGraph</div>
|
class="header-icon-button"
|
||||||
<div class="header-actions">
|
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
|
<button
|
||||||
id="projectSettingsButton"
|
id="projectSettingsButton"
|
||||||
class="header-icon-button"
|
class="header-icon-button"
|
||||||
@@ -22,6 +29,25 @@
|
|||||||
>
|
>
|
||||||
<span class="header-icon header-icon--settings" aria-hidden="true"></span>
|
<span class="header-icon header-icon--settings" aria-hidden="true"></span>
|
||||||
</button>
|
</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
|
<button
|
||||||
id="paletteToggleButton"
|
id="paletteToggleButton"
|
||||||
class="header-icon-button"
|
class="header-icon-button"
|
||||||
@@ -32,24 +58,58 @@
|
|||||||
>
|
>
|
||||||
<span class="header-icon header-icon--palette" aria-hidden="true"></span>
|
<span class="header-icon header-icon--palette" aria-hidden="true"></span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="header-titlebar">
|
||||||
|
<div class="header-title">picoGraph</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-primary-actions">
|
||||||
<button
|
<button
|
||||||
id="frameSelectionButton"
|
id="saveButton"
|
||||||
class="header-icon-button"
|
class="header-icon-button"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Frame selection"
|
aria-label="Save workspace"
|
||||||
title="Frame selection"
|
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>
|
||||||
<button
|
<button
|
||||||
id="exportLuaButton"
|
id="previewButton"
|
||||||
class="header-icon-button"
|
class="header-icon-button header-icon-button--accent"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Export Lua"
|
aria-label="Preview cart"
|
||||||
title="Export Lua"
|
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>
|
</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main id="appBody" class="app-body">
|
<main id="appBody" class="app-body">
|
||||||
@@ -127,6 +187,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<template id="nodeTemplate">
|
||||||
<article class="blueprint-node" draggable="false">
|
<article class="blueprint-node" draggable="false">
|
||||||
<header class="node-header">
|
<header class="node-header">
|
||||||
|
|||||||
232
package-lock.json
generated
232
package-lock.json
generated
@@ -10,7 +10,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^30.0.0",
|
"electron": "^30.0.0",
|
||||||
"electron-builder": "^24.6.3",
|
"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": {
|
"node_modules/@babel/runtime": {
|
||||||
@@ -1318,6 +1319,20 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/app-builder-bin": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-4.0.0.tgz",
|
||||||
@@ -1729,6 +1744,19 @@
|
|||||||
"tweetnacl": "^0.14.3"
|
"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": {
|
"node_modules/bl": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||||
@@ -2017,6 +2045,31 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"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": {
|
"node_modules/chownr": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
|
||||||
@@ -2375,6 +2428,19 @@
|
|||||||
"node": ">=0.10"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -2881,6 +2947,13 @@
|
|||||||
"node": ">= 10.0.0"
|
"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": {
|
"node_modules/electron-publish": {
|
||||||
"version": "24.13.1",
|
"version": "24.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-24.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-24.13.1.tgz",
|
||||||
@@ -2935,6 +3008,50 @@
|
|||||||
"node": ">= 10.0.0"
|
"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": {
|
"node_modules/emoji-regex": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
@@ -3369,6 +3486,21 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@@ -3993,6 +4125,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/is-ci": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz",
|
||||||
@@ -4495,6 +4640,22 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/lodash": {
|
||||||
"version": "4.17.21",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
@@ -4802,7 +4963,6 @@
|
|||||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -4891,6 +5051,38 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/p-map": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
|
||||||
@@ -5564,6 +5756,19 @@
|
|||||||
"minimatch": "^5.1.0"
|
"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": {
|
"node_modules/regenerator-runtime": {
|
||||||
"version": "0.13.11",
|
"version": "0.13.11",
|
||||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||||
@@ -6449,6 +6654,16 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/timm": {
|
||||||
"version": "1.7.1",
|
"version": "1.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/timm/-/timm-1.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/timm/-/timm-1.7.1.tgz",
|
||||||
@@ -6869,6 +7084,19 @@
|
|||||||
"fd-slicer": "~1.1.0"
|
"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": {
|
"node_modules/zip-stream": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz",
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^30.0.0",
|
"electron": "^30.0.0",
|
||||||
"electron-builder": "^24.6.3",
|
"electron-builder": "^24.6.3",
|
||||||
"electron-icon-builder": "^2.0.1"
|
"electron-icon-builder": "^2.0.1",
|
||||||
|
"electron-reloader": "^1.2.3"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.litruv.picograph",
|
"appId": "com.litruv.picograph",
|
||||||
@@ -22,7 +23,8 @@
|
|||||||
"output": "dist"
|
"output": "dist"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"**/*"
|
"**/*",
|
||||||
|
"!picotool/**/*"
|
||||||
],
|
],
|
||||||
"win": {
|
"win": {
|
||||||
"icon": "build/icons/icon.ico",
|
"icon": "build/icons/icon.ico",
|
||||||
|
|||||||
3416
pico8-edu-page.html
Normal file
3416
pico8-edu-page.html
Normal file
File diff suppressed because it is too large
Load Diff
1
picotool
Submodule
1
picotool
Submodule
Submodule picotool added at 49808e5ddb
158
preload.js
158
preload.js
@@ -17,6 +17,164 @@ const api = {
|
|||||||
|
|
||||||
return ipcRenderer.invoke("lua:compile", source);
|
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);
|
contextBridge.exposeInMainWorld("electronAPI", api);
|
||||||
|
|||||||
51
public/pico8-cart.p8
Normal file
51
public/pico8-cart.p8
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
pico-8 cartridge // http://www.pico-8.com
|
||||||
|
version 41
|
||||||
|
__lua__
|
||||||
|
-- Generated with picoGraph
|
||||||
|
|
||||||
|
zzzz = ""
|
||||||
|
offset = 0
|
||||||
|
color = 5
|
||||||
|
var1 = nil
|
||||||
|
|
||||||
|
function _update()
|
||||||
|
-- sequence a
|
||||||
|
cls()
|
||||||
|
offset = 0
|
||||||
|
-- sequence b
|
||||||
|
if btn(0, 0) then
|
||||||
|
print("left", 0, offset, rnd((16) + (1)))
|
||||||
|
custom_addoffset()
|
||||||
|
end
|
||||||
|
-- sequence c
|
||||||
|
if btn(1, 0) then
|
||||||
|
print("right", 0, offset, color)
|
||||||
|
local itsalocalvariable = true
|
||||||
|
if itsalocalvariable then
|
||||||
|
custom_addoffset()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- sequence d
|
||||||
|
if btn(2, 0) then
|
||||||
|
print("up", 0, offset, color)
|
||||||
|
custom_addoffset()
|
||||||
|
end
|
||||||
|
-- sequence e
|
||||||
|
if btn(3, 0) then
|
||||||
|
print("down", 0, offset, color)
|
||||||
|
custom_addoffset()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function custom_addoffset()
|
||||||
|
offset = (offset) + (7)
|
||||||
|
end
|
||||||
|
__gfx__
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
3304
public/pico8-player.html
Normal file
3304
public/pico8-player.html
Normal file
File diff suppressed because it is too large
Load Diff
95
public/pico8-temp-player.html
Normal file
95
public/pico8-temp-player.html
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { background: #1a1a2e; display: flex; align-items: center; justify-content: center; width: 100vw; height: 100vh; overflow: hidden; }
|
||||||
|
#canvas { image-rendering: pixelated; image-rendering: crisp-edges; width: 512px !important; height: 512px !important; border: 8px solid #000; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<canvas id="canvas" width="128" height="128"></canvas>
|
||||||
|
<script>
|
||||||
|
var pico8_state = [];
|
||||||
|
var pico8_buttons = [0,0,0,0,0,0,0,0];
|
||||||
|
var codo_command = 0;
|
||||||
|
var _cartname = ['untitled.p8'];
|
||||||
|
var codo_key_buffer = [];
|
||||||
|
var p8_keyboard_state = 0;
|
||||||
|
var pico8_mouse = [];
|
||||||
|
var pico8_gamepads = { count: 0 };
|
||||||
|
var pico8_gpio = new Array(128).fill(0);
|
||||||
|
var p8_dropped_cart = null;
|
||||||
|
var p8_dropped_cart_name = '';
|
||||||
|
|
||||||
|
var CART_B64 = 'cGljby04IGNhcnRyaWRnZSAvLyBodHRwOi8vd3d3LnBpY28tOC5jb20KdmVyc2lvbiA0MQpfX2x1YV9fCi0tIEdlbmVyYXRlZCB3aXRoIHBpY29HcmFwaAoKZnVuY3Rpb24gX2RyYXcoKQogIGNscygwKQogIGNpcmNmaWxsKDMyLCAzMiwgMjQsIDgpCmVuZApmdW5jdGlvbiBfdXBkYXRlKCkKZW5kCgpfX2dmeF9fCjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwCjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwCjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwCjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwCjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwCjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwCjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwCjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwCg==';
|
||||||
|
|
||||||
|
// Silence all XHR — PICO-8 makes BBS metadata calls that crash from file:// origin.
|
||||||
|
(function() {
|
||||||
|
function MockXHR() {
|
||||||
|
this.onload = null; this.onerror = null; this.onreadystatechange = null;
|
||||||
|
this.readyState = 0; this.status = 0; this.statusText = '';
|
||||||
|
this.response = null; this.responseText = ''; this.responseType = '';
|
||||||
|
}
|
||||||
|
MockXHR.UNSENT=0; MockXHR.OPENED=1; MockXHR.HEADERS_RECEIVED=2; MockXHR.LOADING=3; MockXHR.DONE=4;
|
||||||
|
MockXHR.prototype.open = function() { this.readyState = 1; };
|
||||||
|
MockXHR.prototype.send = function() {
|
||||||
|
var self = this;
|
||||||
|
setTimeout(function() {
|
||||||
|
self.readyState = 4; self.status = 200; self.statusText = 'OK';
|
||||||
|
self.response = self.responseType === 'arraybuffer' ? new ArrayBuffer(0) : '';
|
||||||
|
self.responseText = '';
|
||||||
|
if (typeof self.onload === 'function') self.onload({ target: self });
|
||||||
|
if (typeof self.onreadystatechange === 'function') self.onreadystatechange();
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
MockXHR.prototype.setRequestHeader = function() {};
|
||||||
|
MockXHR.prototype.getResponseHeader = function() { return null; };
|
||||||
|
MockXHR.prototype.getAllResponseHeaders = function() { return ''; };
|
||||||
|
MockXHR.prototype.abort = function() {};
|
||||||
|
MockXHR.prototype.addEventListener = function(e, fn) {
|
||||||
|
if (e==='load') this.onload=fn;
|
||||||
|
if (e==='error') this.onerror=fn;
|
||||||
|
if (e==='readystatechange') this.onreadystatechange=fn;
|
||||||
|
};
|
||||||
|
MockXHR.prototype.removeEventListener = function() {};
|
||||||
|
window.XMLHttpRequest = MockXHR;
|
||||||
|
})();
|
||||||
|
|
||||||
|
var Module = {
|
||||||
|
arguments: [],
|
||||||
|
canvas: document.getElementById('canvas'),
|
||||||
|
preRun: [function() {
|
||||||
|
if (typeof IDBFS !== 'undefined' && typeof MEMFS !== 'undefined') {
|
||||||
|
var orig = FS.mount.bind(FS);
|
||||||
|
FS.mount = function(type, opts, mp) { return orig(type===IDBFS?MEMFS:type, opts, mp); };
|
||||||
|
}
|
||||||
|
FS.syncfs = function(populate, callback) {
|
||||||
|
var cb = typeof callback==='function' ? callback : typeof populate==='function' ? populate : null;
|
||||||
|
if (cb) setTimeout(function(){ cb(null); }, 0);
|
||||||
|
};
|
||||||
|
}],
|
||||||
|
postRun: [function() {
|
||||||
|
setTimeout(function() {
|
||||||
|
p8_dropped_cart = 'data:application/octet-stream;base64,' + CART_B64;
|
||||||
|
p8_dropped_cart_name = 'picograph.p8';
|
||||||
|
codo_command = 9;
|
||||||
|
|
||||||
|
// After cart loads, inject "run" + Enter to execute it
|
||||||
|
setTimeout(function() {
|
||||||
|
// Push keystrokes: r u n Enter (lowercase ASCII)
|
||||||
|
codo_key_buffer.push(114, 117, 110, 13);
|
||||||
|
}, 300);
|
||||||
|
}, 200);
|
||||||
|
}],
|
||||||
|
print: function(t) { console.log('P8:', t); },
|
||||||
|
printErr: function(t) { console.error('P8:', t); },
|
||||||
|
setStatus: function() {}
|
||||||
|
};
|
||||||
|
|
||||||
|
function pico8_audio_context_suspended() { return false; }
|
||||||
|
</script>
|
||||||
|
<script src="./pico8_edu.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
25
public/pico8_edu.js
Normal file
25
public/pico8_edu.js
Normal file
File diff suppressed because one or more lines are too long
25
public/pico8_player.js
Normal file
25
public/pico8_player.js
Normal file
File diff suppressed because one or more lines are too long
55
public/player.html
Normal file
55
public/player.html
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>PICO-8 Player</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: #000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#canvas {
|
||||||
|
image-rendering: pixelated;
|
||||||
|
image-rendering: crisp-edges;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
#p8_container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="p8_container">
|
||||||
|
<canvas id="canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
<script type="text/javascript">
|
||||||
|
// Cart will be injected here
|
||||||
|
window.PICO8_CART_DATA = null;
|
||||||
|
</script>
|
||||||
|
<script src="pico8_player.js"></script>
|
||||||
|
<script>
|
||||||
|
Module = {
|
||||||
|
arguments: ['-run'],
|
||||||
|
canvas: document.getElementById('canvas'),
|
||||||
|
preRun: [],
|
||||||
|
postRun: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wait for cart data to be set
|
||||||
|
if (window.PICO8_CART_DATA) {
|
||||||
|
// Create virtual file system entry
|
||||||
|
Module.preRun.push(function() {
|
||||||
|
FS.createDataFile('/', 'cart.p8', window.PICO8_CART_DATA, true, true, true);
|
||||||
|
});
|
||||||
|
Module.arguments.push('/cart.p8');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
55
scripts/ci/find-edu-js.js
Normal file
55
scripts/ci/find-edu-js.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Downloads the latest PICO-8 Education Edition player JS from pico-8-edu.com.
|
||||||
|
* Run with: node scripts/ci/find-edu-js.js
|
||||||
|
*/
|
||||||
|
const https = require('https');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const INDEX_URL = 'https://www.pico-8-edu.com/';
|
||||||
|
const PUBLIC_DIR = path.join(__dirname, '../../public');
|
||||||
|
|
||||||
|
function fetchText(url) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
https.get(url, res => {
|
||||||
|
const chunks = [];
|
||||||
|
res.on('data', d => chunks.push(d));
|
||||||
|
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||||
|
}).on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchBinary(url) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
https.get(url, res => {
|
||||||
|
const chunks = [];
|
||||||
|
res.on('data', d => chunks.push(d));
|
||||||
|
res.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
}).on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Fetching index to find current edu player version...');
|
||||||
|
const html = await fetchText(INDEX_URL);
|
||||||
|
|
||||||
|
const match = html.match(/['"]\/play\/(pico8_edu[^'"]+\.js)['"]/);
|
||||||
|
if (!match) {
|
||||||
|
console.error('Could not find edu player JS path in page HTML.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsPath = match[1];
|
||||||
|
const jsUrl = `https://www.pico-8-edu.com/play/${jsPath}`;
|
||||||
|
const outFile = path.join(PUBLIC_DIR, 'pico8_edu.js');
|
||||||
|
|
||||||
|
console.log(`Found: ${jsPath}`);
|
||||||
|
console.log(`Downloading from ${jsUrl}...`);
|
||||||
|
|
||||||
|
const buf = await fetchBinary(jsUrl);
|
||||||
|
fs.writeFileSync(outFile, buf);
|
||||||
|
console.log(`Saved to public/pico8_edu.js (${buf.length} bytes)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => { console.error(e); process.exit(1); });
|
||||||
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
* @property {string} id Unique pin identifier scoped to the node.
|
* @property {string} id Unique pin identifier scoped to the node.
|
||||||
* @property {string} name Public display name.
|
* @property {string} name Public display name.
|
||||||
* @property {('input'|'output')} direction Pin direction relative to the node.
|
* @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 {string} [description] Optional tooltip/description.
|
||||||
* @property {unknown} [defaultValue] Default value for data pins.
|
* @property {unknown} [defaultValue] Default value for data pins.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export class Connection {
|
|||||||
/**
|
/**
|
||||||
* @param {PinReference} from Origin pin (must be output).
|
* @param {PinReference} from Origin pin (must be output).
|
||||||
* @param {PinReference} to Target pin (must be input).
|
* @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.
|
* @param {string} [id] Optional connection identifier.
|
||||||
*/
|
*/
|
||||||
constructor(from, to, kind, id) {
|
constructor(from, to, kind, id) {
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { formatLiteral, defaultLiteralForKind } from './lua/LuaLiteralTypes.js';
|
||||||
|
import { normalizeVariableType, getTypeConverter } from '../types/TypeRegistry.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {import('./NodeGraph.js').NodeGraph} NodeGraph
|
* @typedef {import('./NodeGraph.js').NodeGraph} NodeGraph
|
||||||
*/
|
*/
|
||||||
@@ -278,7 +281,7 @@ export class LuaGenerator {
|
|||||||
findExecTargets: (pinId) => this.#findExecTargets(node.id, pinId),
|
findExecTargets: (pinId) => this.#findExecTargets(node.id, pinId),
|
||||||
sanitizeIdentifier: (value) => this.#sanitizeIdentifier(String(value)),
|
sanitizeIdentifier: (value) => this.#sanitizeIdentifier(String(value)),
|
||||||
sanitizeOperator: (value) => this.#sanitizeOperator(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),
|
this.#resolveValueInput(node, inputPinId, fallback),
|
||||||
sanitizeIdentifier: (value) => this.#sanitizeIdentifier(String(value)),
|
sanitizeIdentifier: (value) => this.#sanitizeIdentifier(String(value)),
|
||||||
sanitizeOperator: (value) => this.#sanitizeOperator(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 argEntries = signature.map((entry) => {
|
||||||
const fallback = entry.optional
|
const fallback = entry.optional
|
||||||
? "nil"
|
? "nil"
|
||||||
: this.#defaultLiteralForKind(entry.kind);
|
: defaultLiteralForKind(entry.kind);
|
||||||
const value = this.#resolveValueInput(
|
const value = this.#resolveValueInput(
|
||||||
node,
|
node,
|
||||||
entry.inputPinId,
|
entry.inputPinId,
|
||||||
@@ -602,6 +605,49 @@ export class LuaGenerator {
|
|||||||
.map((connection) => connection.to);
|
.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.
|
* Resolves the expression bound to an input pin.
|
||||||
*
|
*
|
||||||
@@ -613,7 +659,17 @@ export class LuaGenerator {
|
|||||||
#resolveValueInput(node, pinId, fallback) {
|
#resolveValueInput(node, pinId, fallback) {
|
||||||
const connection = this.#getConnectionTo(node.id, pinId);
|
const connection = this.#getConnectionTo(node.id, pinId);
|
||||||
if (connection) {
|
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);
|
const pin = this.#findPin(node, pinId);
|
||||||
@@ -621,12 +677,12 @@ export class LuaGenerator {
|
|||||||
if (Object.prototype.hasOwnProperty.call(node.properties, inlineKey)) {
|
if (Object.prototype.hasOwnProperty.call(node.properties, inlineKey)) {
|
||||||
const inlineValue = node.properties[inlineKey];
|
const inlineValue = node.properties[inlineKey];
|
||||||
if (inlineValue !== undefined && inlineValue !== null) {
|
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) {
|
if (pin && pin.defaultValue !== undefined && pin.defaultValue !== null) {
|
||||||
return this.#formatLiteral(pin.kind, pin.defaultValue);
|
return formatLiteral(pin.kind, pin.defaultValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
return fallback;
|
return fallback;
|
||||||
@@ -691,24 +747,6 @@ export class LuaGenerator {
|
|||||||
|
|
||||||
if (expression === undefined) {
|
if (expression === undefined) {
|
||||||
switch (node.type) {
|
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": {
|
case "get_var": {
|
||||||
const name = this.#resolveWorkspaceVariableName(node);
|
const name = this.#resolveWorkspaceVariableName(node);
|
||||||
expression = name;
|
expression = name;
|
||||||
@@ -721,18 +759,32 @@ export class LuaGenerator {
|
|||||||
expression = parameterName ?? "nil";
|
expression = parameterName ?? "nil";
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "add_number": {
|
case "add_number":
|
||||||
|
case "math_add": {
|
||||||
const a = this.#resolveValueInput(node, "a", "0");
|
const a = this.#resolveValueInput(node, "a", "0");
|
||||||
const b = this.#resolveValueInput(node, "b", "0");
|
const b = this.#resolveValueInput(node, "b", "0");
|
||||||
expression = `(${a}) + (${b})`;
|
expression = `(${a}) + (${b})`;
|
||||||
break;
|
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 a = this.#resolveValueInput(node, "a", "1");
|
||||||
const b = this.#resolveValueInput(node, "b", "1");
|
const b = this.#resolveValueInput(node, "b", "1");
|
||||||
expression = `(${a}) * (${b})`;
|
expression = `(${a}) * (${b})`;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "math_divide": {
|
||||||
|
const a = this.#resolveValueInput(node, "a", "0");
|
||||||
|
const b = this.#resolveValueInput(node, "b", "1");
|
||||||
|
expression = `(${a}) / (${b})`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "compare": {
|
case "compare": {
|
||||||
const a = this.#resolveValueInput(node, "a", "0");
|
const a = this.#resolveValueInput(node, "a", "0");
|
||||||
const b = this.#resolveValueInput(node, "b", "0");
|
const b = this.#resolveValueInput(node, "b", "0");
|
||||||
@@ -788,60 +840,6 @@ export class LuaGenerator {
|
|||||||
return "==";
|
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.
|
* Determines the display name for a custom event node.
|
||||||
*
|
*
|
||||||
@@ -1012,14 +1010,11 @@ export class LuaGenerator {
|
|||||||
const value = variable?.defaultValue;
|
const value = variable?.defaultValue;
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "number": {
|
case "number":
|
||||||
const numeric = Number(value);
|
|
||||||
return Number.isFinite(numeric) ? String(numeric) : "0";
|
|
||||||
}
|
|
||||||
case "boolean":
|
case "boolean":
|
||||||
return value ? "true" : "false";
|
|
||||||
case "string":
|
case "string":
|
||||||
return JSON.stringify(String(value ?? ""));
|
case "color":
|
||||||
|
return formatLiteral(type, value);
|
||||||
case "table": {
|
case "table": {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
const fragments = value
|
const fragments = value
|
||||||
@@ -1137,6 +1132,38 @@ export class LuaGenerator {
|
|||||||
return this.#sanitizeIdentifier(fallback);
|
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.
|
* Normalizes a variable type descriptor.
|
||||||
*
|
*
|
||||||
@@ -1144,19 +1171,7 @@ export class LuaGenerator {
|
|||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
#normalizeVariableType(value) {
|
#normalizeVariableType(value) {
|
||||||
if (typeof value === "string") {
|
return normalizeVariableType(value);
|
||||||
const normalized = value.trim().toLowerCase();
|
|
||||||
switch (normalized) {
|
|
||||||
case "number":
|
|
||||||
case "string":
|
|
||||||
case "boolean":
|
|
||||||
case "table":
|
|
||||||
return normalized;
|
|
||||||
default:
|
|
||||||
return "any";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "any";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BlueprintNode } from "./BlueprintNode.js";
|
import { BlueprintNode } from "./BlueprintNode.js";
|
||||||
import { Connection } from "./Connection.js";
|
import { Connection } from "./Connection.js";
|
||||||
|
import { areTypesCompatible } from "../types/TypeRegistry.js";
|
||||||
|
|
||||||
/** @typedef {import('./BlueprintNode.js').BlueprintNodeInit} BlueprintNodeInit */
|
/** @typedef {import('./BlueprintNode.js').BlueprintNodeInit} BlueprintNodeInit */
|
||||||
/** @typedef {import('./BlueprintNode.js').PinDescriptor} PinDescriptor */
|
/** @typedef {import('./BlueprintNode.js').PinDescriptor} PinDescriptor */
|
||||||
@@ -321,11 +322,7 @@ export class NodeGraph extends EventTarget {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (!areTypesCompatible(fromPin.kind, toPin.kind)) {
|
||||||
toPin.kind !== "any" &&
|
|
||||||
fromPin.kind !== "any" &&
|
|
||||||
fromPin.kind !== toPin.kind
|
|
||||||
) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
268
scripts/core/SpriteSheet.js
Normal file
268
scripts/core/SpriteSheet.js
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
/**
|
||||||
|
* Manages PICO-8 sprite sheet data with 256 8x8 sprites.
|
||||||
|
* Total sheet size: 128x128 pixels, 16-color palette.
|
||||||
|
*/
|
||||||
|
export class SpriteSheet {
|
||||||
|
/**
|
||||||
|
* @param {Object} [options={}] Configuration options.
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
/** @type {Uint8Array} Pixel data (128x128, each byte is a palette index 0-15) */
|
||||||
|
this.pixels = new Uint8Array(128 * 128);
|
||||||
|
|
||||||
|
/** @type {Uint8Array} Sprite flags (256 sprites, 8 flags each) */
|
||||||
|
this.flags = new Uint8Array(256);
|
||||||
|
|
||||||
|
/** @type {Array<string>} PICO-8 default 16-color palette */
|
||||||
|
this.palette = [
|
||||||
|
"#000000", "#1D2B53", "#7E2553", "#008751",
|
||||||
|
"#AB5236", "#5F574F", "#C2C3C7", "#FFF1E8",
|
||||||
|
"#FF004D", "#FFA300", "#FFEC27", "#00E436",
|
||||||
|
"#29ADFF", "#83769C", "#FF77A8", "#FFCCAA"
|
||||||
|
];
|
||||||
|
|
||||||
|
this.#initialize(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes sprite sheet data.
|
||||||
|
*
|
||||||
|
* @param {Object} options Configuration options.
|
||||||
|
*/
|
||||||
|
#initialize(options) {
|
||||||
|
if (options.pixels instanceof Uint8Array && options.pixels.length === 128 * 128) {
|
||||||
|
this.pixels.set(options.pixels);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.flags instanceof Uint8Array && options.flags.length === 256) {
|
||||||
|
this.flags.set(options.flags);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets pixel color at coordinates.
|
||||||
|
*
|
||||||
|
* @param {number} x X coordinate (0-127).
|
||||||
|
* @param {number} y Y coordinate (0-127).
|
||||||
|
* @returns {number} Palette index (0-15).
|
||||||
|
*/
|
||||||
|
getPixel(x, y) {
|
||||||
|
if (x < 0 || x >= 128 || y < 0 || y >= 128) return 0;
|
||||||
|
return this.pixels[y * 128 + x];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets pixel color at coordinates.
|
||||||
|
*
|
||||||
|
* @param {number} x X coordinate (0-127).
|
||||||
|
* @param {number} y Y coordinate (0-127).
|
||||||
|
* @param {number} color Palette index (0-15).
|
||||||
|
*/
|
||||||
|
setPixel(x, y, color) {
|
||||||
|
if (x < 0 || x >= 128 || y < 0 || y >= 128) return;
|
||||||
|
this.pixels[y * 128 + x] = Math.max(0, Math.min(15, color));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all pixels for a specific sprite.
|
||||||
|
*
|
||||||
|
* @param {number} spriteIndex Sprite number (0-255).
|
||||||
|
* @returns {Uint8Array} 8x8 pixel array.
|
||||||
|
*/
|
||||||
|
getSprite(spriteIndex) {
|
||||||
|
const spriteData = new Uint8Array(8 * 8);
|
||||||
|
const sx = (spriteIndex % 16) * 8;
|
||||||
|
const sy = Math.floor(spriteIndex / 16) * 8;
|
||||||
|
|
||||||
|
for (let y = 0; y < 8; y++) {
|
||||||
|
for (let x = 0; x < 8; x++) {
|
||||||
|
spriteData[y * 8 + x] = this.getPixel(sx + x, sy + y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return spriteData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets all pixels for a specific sprite.
|
||||||
|
*
|
||||||
|
* @param {number} spriteIndex Sprite number (0-255).
|
||||||
|
* @param {Uint8Array} data 8x8 pixel array.
|
||||||
|
*/
|
||||||
|
setSprite(spriteIndex, data) {
|
||||||
|
if (data.length !== 64) return;
|
||||||
|
|
||||||
|
const sx = (spriteIndex % 16) * 8;
|
||||||
|
const sy = Math.floor(spriteIndex / 16) * 8;
|
||||||
|
|
||||||
|
for (let y = 0; y < 8; y++) {
|
||||||
|
for (let x = 0; x < 8; x++) {
|
||||||
|
this.setPixel(sx + x, sy + y, data[y * 8 + x]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets flags for a sprite.
|
||||||
|
*
|
||||||
|
* @param {number} spriteIndex Sprite number (0-255).
|
||||||
|
* @returns {number} 8-bit flag value.
|
||||||
|
*/
|
||||||
|
getFlags(spriteIndex) {
|
||||||
|
if (spriteIndex < 0 || spriteIndex >= 256) return 0;
|
||||||
|
return this.flags[spriteIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets flags for a sprite.
|
||||||
|
*
|
||||||
|
* @param {number} spriteIndex Sprite number (0-255).
|
||||||
|
* @param {number} flagValue 8-bit flag value.
|
||||||
|
*/
|
||||||
|
setFlags(spriteIndex, flagValue) {
|
||||||
|
if (spriteIndex < 0 || spriteIndex >= 256) return;
|
||||||
|
this.flags[spriteIndex] = flagValue & 0xFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a specific flag is set.
|
||||||
|
*
|
||||||
|
* @param {number} spriteIndex Sprite number (0-255).
|
||||||
|
* @param {number} flagBit Flag bit (0-7).
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
hasFlag(spriteIndex, flagBit) {
|
||||||
|
if (flagBit < 0 || flagBit > 7) return false;
|
||||||
|
return (this.getFlags(spriteIndex) & (1 << flagBit)) !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a specific flag.
|
||||||
|
*
|
||||||
|
* @param {number} spriteIndex Sprite number (0-255).
|
||||||
|
* @param {number} flagBit Flag bit (0-7).
|
||||||
|
* @param {boolean} value Flag state.
|
||||||
|
*/
|
||||||
|
setFlag(spriteIndex, flagBit, value) {
|
||||||
|
if (flagBit < 0 || flagBit > 7) return;
|
||||||
|
|
||||||
|
const currentFlags = this.getFlags(spriteIndex);
|
||||||
|
const mask = 1 << flagBit;
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
this.setFlags(spriteIndex, currentFlags | mask);
|
||||||
|
} else {
|
||||||
|
this.setFlags(spriteIndex, currentFlags & ~mask);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all sprite data.
|
||||||
|
*/
|
||||||
|
clear() {
|
||||||
|
this.pixels.fill(0);
|
||||||
|
this.flags.fill(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializes sprite sheet to JSON.
|
||||||
|
*
|
||||||
|
* @returns {Object}
|
||||||
|
*/
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
pixels: Array.from(this.pixels),
|
||||||
|
flags: Array.from(this.flags)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates sprite sheet from JSON data.
|
||||||
|
*
|
||||||
|
* @param {Object} data Serialized sprite data.
|
||||||
|
* @returns {SpriteSheet}
|
||||||
|
*/
|
||||||
|
static fromJSON(data) {
|
||||||
|
return new SpriteSheet({
|
||||||
|
pixels: new Uint8Array(data.pixels || []),
|
||||||
|
flags: new Uint8Array(data.flags || [])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exports to PICO-8 .p8 format gfx section.
|
||||||
|
*
|
||||||
|
* @returns {string} Hex string for __gfx__ section.
|
||||||
|
*/
|
||||||
|
toP8Gfx() {
|
||||||
|
const lines = [];
|
||||||
|
|
||||||
|
for (let row = 0; row < 128; row++) {
|
||||||
|
let line = "";
|
||||||
|
for (let col = 0; col < 128; col += 2) {
|
||||||
|
const p1 = this.getPixel(col, row);
|
||||||
|
const p2 = this.getPixel(col + 1, row);
|
||||||
|
line += p1.toString(16) + p2.toString(16);
|
||||||
|
}
|
||||||
|
lines.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports from PICO-8 .p8 format gfx section.
|
||||||
|
*
|
||||||
|
* @param {string} gfxData Hex string from __gfx__ section.
|
||||||
|
*/
|
||||||
|
fromP8Gfx(gfxData) {
|
||||||
|
const lines = gfxData.trim().split("\n");
|
||||||
|
|
||||||
|
for (let row = 0; row < 128 && row < lines.length; row++) {
|
||||||
|
const line = lines[row].trim();
|
||||||
|
for (let col = 0; col < 128; col += 2) {
|
||||||
|
const hexPair = line.substring(col, col + 2);
|
||||||
|
if (hexPair.length === 2) {
|
||||||
|
this.setPixel(col, row, parseInt(hexPair[0], 16));
|
||||||
|
this.setPixel(col + 1, row, parseInt(hexPair[1], 16));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exports sprite flags to PICO-8 .p8 format.
|
||||||
|
*
|
||||||
|
* @returns {string} Hex string for __gff__ section.
|
||||||
|
*/
|
||||||
|
toP8Flags() {
|
||||||
|
const lines = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 256; i += 2) {
|
||||||
|
const f1 = this.getFlags(i).toString(16).padStart(2, "0");
|
||||||
|
const f2 = this.getFlags(i + 1).toString(16).padStart(2, "0");
|
||||||
|
lines.push(f1 + f2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports sprite flags from PICO-8 .p8 format.
|
||||||
|
*
|
||||||
|
* @param {string} flagData Hex string from __gff__ section.
|
||||||
|
*/
|
||||||
|
fromP8Flags(flagData) {
|
||||||
|
const lines = flagData.trim().split("\n");
|
||||||
|
|
||||||
|
for (let i = 0; i < 128 && i < lines.length; i++) {
|
||||||
|
const line = lines[i].trim();
|
||||||
|
if (line.length >= 2) {
|
||||||
|
this.setFlags(i * 2, parseInt(line.substring(0, 2), 16));
|
||||||
|
}
|
||||||
|
if (line.length >= 4) {
|
||||||
|
this.setFlags(i * 2 + 1, parseInt(line.substring(2, 4), 16));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
167
scripts/core/TileSet.js
Normal file
167
scripts/core/TileSet.js
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* Represents a single tileset with 8x8 tiles (128x128px total, 16x16 grid).
|
||||||
|
*/
|
||||||
|
export class TileSet {
|
||||||
|
/**
|
||||||
|
* @param {Object} options Configuration options.
|
||||||
|
* @param {string} options.id Unique identifier.
|
||||||
|
* @param {string} options.name Display name.
|
||||||
|
* @param {string} options.filePath Path or URL to PNG file (file:// or blob:).
|
||||||
|
* @param {string} [options.nativePath] Native file system path (for Electron).
|
||||||
|
* @param {number} [options.tileWidth=8] Tile width in pixels.
|
||||||
|
* @param {number} [options.tileHeight=8] Tile height in pixels.
|
||||||
|
* @param {number} [options.columns=16] Number of tiles per row (128px / 8px = 16).
|
||||||
|
* @param {number} [options.rows=16] Number of tile rows (128px / 8px = 16).
|
||||||
|
*/
|
||||||
|
constructor(options) {
|
||||||
|
this.id = options.id || this.#generateId();
|
||||||
|
this.name = options.name || "Untitled Tileset";
|
||||||
|
this.filePath = options.filePath || "";
|
||||||
|
this.nativePath = options.nativePath || "";
|
||||||
|
this.tileWidth = options.tileWidth || 8;
|
||||||
|
this.tileHeight = options.tileHeight || 8;
|
||||||
|
this.columns = options.columns || 16;
|
||||||
|
this.rows = options.rows || 16;
|
||||||
|
|
||||||
|
/** @type {HTMLImageElement | null} */
|
||||||
|
this.image = null;
|
||||||
|
|
||||||
|
/** @type {boolean} */
|
||||||
|
this.isLoaded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a unique identifier.
|
||||||
|
*
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
#generateId() {
|
||||||
|
return `tileset_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the total number of tiles.
|
||||||
|
*
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
getTileCount() {
|
||||||
|
return this.columns * this.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the total width of the tileset image.
|
||||||
|
*
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
getWidth() {
|
||||||
|
return this.columns * this.tileWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the total height of the tileset image.
|
||||||
|
*
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
getHeight() {
|
||||||
|
return this.rows * this.tileHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the tileset image from file path.
|
||||||
|
*
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async load() {
|
||||||
|
if (!this.filePath) {
|
||||||
|
throw new Error("No file path specified");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
// Validate image is exactly 128x128px
|
||||||
|
if (img.width !== 128 || img.height !== 128) {
|
||||||
|
reject(new Error(`Tileset must be exactly 128x128px (found ${img.width}x${img.height}px)`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.image = img;
|
||||||
|
this.isLoaded = true;
|
||||||
|
|
||||||
|
// Fixed grid: 16x16 tiles for 128x128 image with 8x8 tiles
|
||||||
|
this.columns = 16;
|
||||||
|
this.rows = 16;
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = () => {
|
||||||
|
reject(new Error(`Failed to load image: ${this.filePath}`));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add cache-busting for file:// URLs to ensure reload works
|
||||||
|
const cacheBuster = this.filePath.startsWith("file://")
|
||||||
|
? `?t=${Date.now()}`
|
||||||
|
: "";
|
||||||
|
img.src = this.filePath + cacheBuster;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reloads the tileset image.
|
||||||
|
*
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async reload() {
|
||||||
|
this.isLoaded = false;
|
||||||
|
this.image = null;
|
||||||
|
return this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets tile coordinates for a specific tile index.
|
||||||
|
*
|
||||||
|
* @param {number} tileIndex Tile index (0-based).
|
||||||
|
* @returns {{x: number, y: number, width: number, height: number}}
|
||||||
|
*/
|
||||||
|
getTileRect(tileIndex) {
|
||||||
|
const col = tileIndex % this.columns;
|
||||||
|
const row = Math.floor(tileIndex / this.columns);
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: col * this.tileWidth,
|
||||||
|
y: row * this.tileHeight,
|
||||||
|
width: this.tileWidth,
|
||||||
|
height: this.tileHeight
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializes tileset to JSON.
|
||||||
|
*
|
||||||
|
* @returns {Object}
|
||||||
|
*/
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
name: this.name,
|
||||||
|
filePath: this.filePath,
|
||||||
|
nativePath: this.nativePath,
|
||||||
|
tileWidth: this.tileWidth,
|
||||||
|
tileHeight: this.tileHeight,
|
||||||
|
columns: this.columns,
|
||||||
|
rows: this.rows
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates tileset from JSON data.
|
||||||
|
*
|
||||||
|
* @param {Object} data Serialized tileset.
|
||||||
|
* @returns {TileSet}
|
||||||
|
*/
|
||||||
|
static fromJSON(data) {
|
||||||
|
return new TileSet(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
scripts/core/lua/LuaLiteralBoolean.js
Normal file
41
scripts/core/lua/LuaLiteralBoolean.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* @typedef {import('./LuaLiteralTypes.js').LuaLiteralHandler} LuaLiteralHandler
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {LuaLiteralHandler} */
|
||||||
|
export const LuaLiteralBoolean = {
|
||||||
|
/**
|
||||||
|
* @param {unknown} value
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
format(value) {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value === "false" ? "false" : "true";
|
||||||
|
}
|
||||||
|
return value ? "true" : "false";
|
||||||
|
},
|
||||||
|
|
||||||
|
/** @returns {string} */
|
||||||
|
defaultLiteral() {
|
||||||
|
return "false";
|
||||||
|
},
|
||||||
|
|
||||||
|
spawnableByDefault: true,
|
||||||
|
|
||||||
|
nodeModule: {
|
||||||
|
definition: {
|
||||||
|
id: "boolean_literal",
|
||||||
|
title: "Boolean",
|
||||||
|
category: "Values",
|
||||||
|
description: "Constant boolean literal.",
|
||||||
|
searchTags: ["boolean", "literal", "true", "false"],
|
||||||
|
inputs: [],
|
||||||
|
outputs: [{ id: "value", name: "Value", direction: "output", kind: "boolean" }],
|
||||||
|
properties: [{ key: "value", label: "Value", type: "boolean", defaultValue: true }],
|
||||||
|
},
|
||||||
|
behavior: {
|
||||||
|
evaluateValue: ({ node, formatLiteral }) =>
|
||||||
|
formatLiteral("boolean", node.properties.value ?? true),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
39
scripts/core/lua/LuaLiteralColor.js
Normal file
39
scripts/core/lua/LuaLiteralColor.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* @typedef {import('./LuaLiteralTypes.js').LuaLiteralHandler} LuaLiteralHandler
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {LuaLiteralHandler} */
|
||||||
|
export const LuaLiteralColor = {
|
||||||
|
/**
|
||||||
|
* @param {unknown} value
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
format(value) {
|
||||||
|
const n = Math.max(0, Math.min(15, Math.floor(Number(value) || 0)));
|
||||||
|
return String(n);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** @returns {string} */
|
||||||
|
defaultLiteral() {
|
||||||
|
return "7";
|
||||||
|
},
|
||||||
|
|
||||||
|
spawnableByDefault: true,
|
||||||
|
|
||||||
|
nodeModule: {
|
||||||
|
definition: {
|
||||||
|
id: "color_literal",
|
||||||
|
title: "Color",
|
||||||
|
category: "Values",
|
||||||
|
description: "Constant color literal (0-15).",
|
||||||
|
searchTags: ["color", "literal", "constant", "value"],
|
||||||
|
inputs: [],
|
||||||
|
outputs: [{ id: "value", name: "Value", direction: "output", kind: "color" }],
|
||||||
|
properties: [{ key: "value", label: "Color", type: "number", defaultValue: 7 }],
|
||||||
|
},
|
||||||
|
behavior: {
|
||||||
|
evaluateValue: ({ node, formatLiteral }) =>
|
||||||
|
formatLiteral("color", node.properties.value ?? 7),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
39
scripts/core/lua/LuaLiteralNumber.js
Normal file
39
scripts/core/lua/LuaLiteralNumber.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* @typedef {import('./LuaLiteralTypes.js').LuaLiteralHandler} LuaLiteralHandler
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {LuaLiteralHandler} */
|
||||||
|
export const LuaLiteralNumber = {
|
||||||
|
/**
|
||||||
|
* @param {unknown} value
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
format(value) {
|
||||||
|
const numeric = Number(value);
|
||||||
|
return Number.isFinite(numeric) ? String(numeric) : "0";
|
||||||
|
},
|
||||||
|
|
||||||
|
/** @returns {string} */
|
||||||
|
defaultLiteral() {
|
||||||
|
return "0";
|
||||||
|
},
|
||||||
|
|
||||||
|
spawnableByDefault: true,
|
||||||
|
|
||||||
|
nodeModule: {
|
||||||
|
definition: {
|
||||||
|
id: "number_literal",
|
||||||
|
title: "Number",
|
||||||
|
category: "Values",
|
||||||
|
description: "Constant numeric literal.",
|
||||||
|
searchTags: ["number", "literal", "constant", "value"],
|
||||||
|
inputs: [],
|
||||||
|
outputs: [{ id: "value", name: "Value", direction: "output", kind: "number" }],
|
||||||
|
properties: [{ key: "value", label: "Number", type: "number", defaultValue: 0 }],
|
||||||
|
},
|
||||||
|
behavior: {
|
||||||
|
evaluateValue: ({ node, formatLiteral }) =>
|
||||||
|
formatLiteral("number", node.properties.value ?? 0),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
38
scripts/core/lua/LuaLiteralString.js
Normal file
38
scripts/core/lua/LuaLiteralString.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* @typedef {import('./LuaLiteralTypes.js').LuaLiteralHandler} LuaLiteralHandler
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {LuaLiteralHandler} */
|
||||||
|
export const LuaLiteralString = {
|
||||||
|
/**
|
||||||
|
* @param {unknown} value
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
format(value) {
|
||||||
|
return JSON.stringify(String(value ?? ""));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** @returns {string} */
|
||||||
|
defaultLiteral() {
|
||||||
|
return '""';
|
||||||
|
},
|
||||||
|
|
||||||
|
spawnableByDefault: true,
|
||||||
|
|
||||||
|
nodeModule: {
|
||||||
|
definition: {
|
||||||
|
id: "string_literal",
|
||||||
|
title: "String",
|
||||||
|
category: "Values",
|
||||||
|
description: "Constant string literal.",
|
||||||
|
searchTags: ["string", "literal", "text", "value"],
|
||||||
|
inputs: [],
|
||||||
|
outputs: [{ id: "value", name: "Value", direction: "output", kind: "string" }],
|
||||||
|
properties: [{ key: "value", label: "Text", type: "string", defaultValue: "hello" }],
|
||||||
|
},
|
||||||
|
behavior: {
|
||||||
|
evaluateValue: ({ node, formatLiteral }) =>
|
||||||
|
formatLiteral("string", node.properties.value ?? ""),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
27
scripts/core/lua/LuaLiteralTable.js
Normal file
27
scripts/core/lua/LuaLiteralTable.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* @typedef {import('./LuaLiteralTypes.js').LuaLiteralHandler} LuaLiteralHandler
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {LuaLiteralHandler} */
|
||||||
|
export const LuaLiteralTable = {
|
||||||
|
/**
|
||||||
|
* @param {unknown} value
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
format(value) {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length ? trimmed : "{}";
|
||||||
|
}
|
||||||
|
return "{}";
|
||||||
|
},
|
||||||
|
|
||||||
|
/** @returns {string} */
|
||||||
|
defaultLiteral() {
|
||||||
|
return "{}";
|
||||||
|
},
|
||||||
|
|
||||||
|
spawnableByDefault: false,
|
||||||
|
|
||||||
|
nodeModule: null,
|
||||||
|
};
|
||||||
56
scripts/core/lua/LuaLiteralTypes.js
Normal file
56
scripts/core/lua/LuaLiteralTypes.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { LuaLiteralNumber } from "./LuaLiteralNumber.js";
|
||||||
|
import { LuaLiteralBoolean } from "./LuaLiteralBoolean.js";
|
||||||
|
import { LuaLiteralString } from "./LuaLiteralString.js";
|
||||||
|
import { LuaLiteralTable } from "./LuaLiteralTable.js";
|
||||||
|
import { LuaLiteralColor } from "./LuaLiteralColor.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} LuaLiteralHandler
|
||||||
|
* @property {(value: unknown) => string} format Formats a raw value into a Lua literal string.
|
||||||
|
* @property {() => string} defaultLiteral Returns the default Lua literal string for this type.
|
||||||
|
* @property {boolean} [spawnableByDefault] Whether this type creates a spawnable literal node in the graph.
|
||||||
|
* @property {import('../../nodes/nodeTypes.js').NodeModule | null} [nodeModule] Node module definition, or null if no literal node exists.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {Map<string, LuaLiteralHandler>} */
|
||||||
|
const HANDLERS = new Map([
|
||||||
|
["number", LuaLiteralNumber],
|
||||||
|
["boolean", LuaLiteralBoolean],
|
||||||
|
["string", LuaLiteralString],
|
||||||
|
["table", LuaLiteralTable],
|
||||||
|
["color", LuaLiteralColor],
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a raw value into a Lua literal string for the given pin kind.
|
||||||
|
*
|
||||||
|
* @param {string} kind Pin kind.
|
||||||
|
* @param {unknown} value Raw value.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function formatLiteral(kind, value) {
|
||||||
|
const handler = HANDLERS.get(kind);
|
||||||
|
return handler ? handler.format(value) : LuaLiteralString.format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the default Lua literal string for the given pin kind.
|
||||||
|
*
|
||||||
|
* @param {string} kind Pin kind.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function defaultLiteralForKind(kind) {
|
||||||
|
const handler = HANDLERS.get(kind);
|
||||||
|
return handler ? handler.defaultLiteral() : "nil";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all node modules defined by literal handlers that have a nodeModule.
|
||||||
|
*
|
||||||
|
* @returns {Array<import('../../nodes/nodeTypes.js').NodeModule>}
|
||||||
|
*/
|
||||||
|
export function getLiteralNodeModules() {
|
||||||
|
return [...HANDLERS.values()]
|
||||||
|
.map((handler) => handler.nodeModule)
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
271
scripts/main.js
271
scripts/main.js
@@ -1,9 +1,31 @@
|
|||||||
import { BlueprintWorkspace } from "./ui/BlueprintWorkspace.js";
|
import { BlueprintWorkspace } from "./ui/BlueprintWorkspace.js";
|
||||||
import { NodeRegistry } from "./nodes/NodeRegistry.js";
|
import { NodeRegistry } from "./nodes/NodeRegistry.js";
|
||||||
import { LuaGenerator } from "./core/LuaGenerator.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"
|
"deleteNodeButton"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const compileButton = /** @type {HTMLButtonElement} */ (
|
const saveButton = /** @type {HTMLButtonElement} */ (
|
||||||
requireElement(document.getElementById("compileButton"), "compileButton")
|
requireElement(document.getElementById("saveButton"), "saveButton")
|
||||||
|
);
|
||||||
|
const previewButton = /** @type {HTMLButtonElement} */ (
|
||||||
|
requireElement(document.getElementById("previewButton"), "previewButton")
|
||||||
);
|
);
|
||||||
const exportLuaButton = /** @type {HTMLButtonElement} */ (
|
const exportLuaButton = /** @type {HTMLButtonElement} */ (
|
||||||
requireElement(document.getElementById("exportLuaButton"), "exportLuaButton")
|
requireElement(document.getElementById("exportLuaButton"), "exportLuaButton")
|
||||||
@@ -94,6 +119,24 @@ const frameSelectionButton = /** @type {HTMLButtonElement} */ (
|
|||||||
"frameSelectionButton"
|
"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} */ (
|
const luaModal = /** @type {HTMLElement} */ (
|
||||||
requireElement(document.getElementById("luaModal"), "luaModal")
|
requireElement(document.getElementById("luaModal"), "luaModal")
|
||||||
);
|
);
|
||||||
@@ -115,6 +158,12 @@ const closeLuaModalButton = /** @type {HTMLButtonElement} */ (
|
|||||||
"closeLuaModalButton"
|
"closeLuaModalButton"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
const tileManagerButton = /** @type {HTMLButtonElement} */ (
|
||||||
|
requireElement(
|
||||||
|
document.getElementById("tileManagerButton"),
|
||||||
|
"tileManagerButton"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const registry = new NodeRegistry();
|
const registry = new NodeRegistry();
|
||||||
const generator = new LuaGenerator(registry);
|
const generator = new LuaGenerator(registry);
|
||||||
@@ -133,6 +182,7 @@ const workspace = new BlueprintWorkspace({
|
|||||||
eventList,
|
eventList,
|
||||||
variableList,
|
variableList,
|
||||||
addVariableButton,
|
addVariableButton,
|
||||||
|
saveButton,
|
||||||
projectSettingsButton,
|
projectSettingsButton,
|
||||||
paletteToggleButton,
|
paletteToggleButton,
|
||||||
appBodyElement,
|
appBodyElement,
|
||||||
@@ -141,12 +191,106 @@ const workspace = new BlueprintWorkspace({
|
|||||||
|
|
||||||
workspace.initialize();
|
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} */
|
/** @type {ElectronAPI | undefined} */
|
||||||
const electronAPI =
|
const electronAPI =
|
||||||
typeof window !== "undefined" && "electronAPI" in window
|
typeof window !== "undefined" && "electronAPI" in window
|
||||||
? /** @type {ElectronAPI} */ (window.electronAPI)
|
? /** @type {ElectronAPI} */ (window.electronAPI)
|
||||||
: undefined;
|
: 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) => {
|
document.addEventListener("contextmenu", (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
});
|
});
|
||||||
@@ -212,7 +356,6 @@ let isLuaModalOpen = false;
|
|||||||
let lastFocusedElement = /** @type {HTMLElement | null} */ (null);
|
let lastFocusedElement = /** @type {HTMLElement | null} */ (null);
|
||||||
let copyFeedbackTimeout = /** @type {number | null} */ (null);
|
let copyFeedbackTimeout = /** @type {number | null} */ (null);
|
||||||
let currentLuaSource = "";
|
let currentLuaSource = "";
|
||||||
let compileFeedbackTimeout = /** @type {number | null} */ (null);
|
|
||||||
|
|
||||||
const scheduleMicrotask = (callback) => {
|
const scheduleMicrotask = (callback) => {
|
||||||
if (typeof queueMicrotask === "function") {
|
if (typeof queueMicrotask === "function") {
|
||||||
@@ -354,77 +497,67 @@ const closeLuaModal = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
previewButton.addEventListener("click", () => {
|
||||||
* Restores the compile button to its idle appearance.
|
embeddedPreview.open();
|
||||||
*
|
});
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
const resetCompileButton = () => {
|
|
||||||
if (compileFeedbackTimeout !== null) {
|
|
||||||
window.clearTimeout(compileFeedbackTimeout);
|
|
||||||
compileFeedbackTimeout = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
compileButton.classList.remove("is-success", "is-error", "is-busy");
|
const setMaximizeButtonState = (isMaximized) => {
|
||||||
compileButton.disabled = false;
|
maximizeWindowButton.classList.toggle("is-active", Boolean(isMaximized));
|
||||||
compileButton.textContent = "Compile";
|
maximizeWindowButton.setAttribute(
|
||||||
|
"title",
|
||||||
|
isMaximized ? "Restore" : "Maximize"
|
||||||
|
);
|
||||||
|
maximizeWindowButton.setAttribute(
|
||||||
|
"aria-label",
|
||||||
|
isMaximized ? "Restore window" : "Maximize window"
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
if (
|
||||||
* Handles compile button clicks by requesting a Lua export from Electron.
|
electronAPI &&
|
||||||
*
|
typeof electronAPI.minimizeWindow === "function" &&
|
||||||
* @returns {Promise<void>}
|
typeof electronAPI.toggleMaximizeWindow === "function" &&
|
||||||
*/
|
typeof electronAPI.closeWindow === "function"
|
||||||
const handleCompileClick = async () => {
|
) {
|
||||||
if (!electronAPI) {
|
minimizeWindowButton.addEventListener("click", () => {
|
||||||
return;
|
electronAPI.minimizeWindow().catch((error) => {
|
||||||
}
|
console.error("Failed to minimize window", error);
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 {
|
} else {
|
||||||
compileButton.disabled = true;
|
windowControls.hidden = true;
|
||||||
compileButton.title = "Compile is available in the desktop app";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
exportLuaButton.addEventListener("click", () => {
|
exportLuaButton.addEventListener("click", () => {
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
import { createNodeModule } from "../nodeTypes.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Constant boolean literal.
|
|
||||||
*/
|
|
||||||
export const booleanLiteralNode = createNodeModule(
|
|
||||||
{
|
|
||||||
id: "boolean_literal",
|
|
||||||
title: "Boolean",
|
|
||||||
category: "Values",
|
|
||||||
description: "Constant boolean literal.",
|
|
||||||
searchTags: ["boolean", "literal", "true", "false"],
|
|
||||||
inputs: [],
|
|
||||||
outputs: [
|
|
||||||
{ id: "value", name: "Value", direction: "output", kind: "boolean" },
|
|
||||||
],
|
|
||||||
properties: [
|
|
||||||
{ key: "value", label: "Value", type: "boolean", defaultValue: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
evaluateValue: ({ node, formatLiteral }) => {
|
|
||||||
return formatLiteral("boolean", node.properties.value ?? true);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
26
scripts/nodes/library/converters/colorToNumber.js
Normal file
26
scripts/nodes/library/converters/colorToNumber.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { createNodeModule } from "../../nodeTypes.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a color value to a numeric value.
|
||||||
|
*/
|
||||||
|
export const colorToNumberNode = createNodeModule(
|
||||||
|
{
|
||||||
|
id: "convert_color_to_number",
|
||||||
|
title: "Color to Number",
|
||||||
|
category: "Converters",
|
||||||
|
description: "Convert a color value to a numeric value.",
|
||||||
|
searchTags: ["color", "number", "convert", "cast"],
|
||||||
|
inputs: [
|
||||||
|
{ id: "color", name: "Color", direction: "input", kind: "color" }
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ id: "number", name: "Number", direction: "output", kind: "number" }
|
||||||
|
],
|
||||||
|
properties: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
evaluateValue: ({ resolveValueInput }) => {
|
||||||
|
return resolveValueInput("color", "0");
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
7
scripts/nodes/library/converters/index.js
Normal file
7
scripts/nodes/library/converters/index.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { colorToNumberNode } from "./colorToNumber.js";
|
||||||
|
import { numberToColorNode } from "./numberToColor.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All type conversion nodes.
|
||||||
|
*/
|
||||||
|
export const converterNodes = [colorToNumberNode, numberToColorNode];
|
||||||
26
scripts/nodes/library/converters/numberToColor.js
Normal file
26
scripts/nodes/library/converters/numberToColor.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { createNodeModule } from "../../nodeTypes.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a numeric value to a color value.
|
||||||
|
*/
|
||||||
|
export const numberToColorNode = createNodeModule(
|
||||||
|
{
|
||||||
|
id: "convert_number_to_color",
|
||||||
|
title: "Number to Color",
|
||||||
|
category: "Converters",
|
||||||
|
description: "Convert a numeric value to a color value.",
|
||||||
|
searchTags: ["number", "color", "convert", "cast"],
|
||||||
|
inputs: [
|
||||||
|
{ id: "number", name: "Number", direction: "input", kind: "number" }
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ id: "color", name: "Color", direction: "output", kind: "color" }
|
||||||
|
],
|
||||||
|
properties: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
evaluateValue: ({ resolveValueInput }) => {
|
||||||
|
return resolveValueInput("number", "0");
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -33,7 +33,7 @@ export const circNode = createNodeModule(
|
|||||||
kind: "number",
|
kind: "number",
|
||||||
defaultValue: 4,
|
defaultValue: 4,
|
||||||
},
|
},
|
||||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||||
],
|
],
|
||||||
outputs: [
|
outputs: [
|
||||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export const circfillNode = createNodeModule(
|
|||||||
kind: "number",
|
kind: "number",
|
||||||
defaultValue: 4,
|
defaultValue: 4,
|
||||||
},
|
},
|
||||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||||
],
|
],
|
||||||
outputs: [
|
outputs: [
|
||||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export const clsNode = createNodeModule(
|
|||||||
searchTags: ["cls", "clear", "screen", "background"],
|
searchTags: ["cls", "clear", "screen", "background"],
|
||||||
inputs: [
|
inputs: [
|
||||||
{ id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
|
{ 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: [
|
outputs: [
|
||||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export const colorNode = createNodeModule(
|
|||||||
searchTags: ["color", "color", "ink", "draw"],
|
searchTags: ["color", "color", "ink", "draw"],
|
||||||
inputs: [
|
inputs: [
|
||||||
{ id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
|
{ 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: [
|
outputs: [
|
||||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const cursorNode = createNodeModule(
|
|||||||
{ id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
|
{ id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
|
||||||
{ id: "x", name: "X", direction: "input", kind: "number" },
|
{ id: "x", name: "X", direction: "input", kind: "number" },
|
||||||
{ id: "y", name: "Y", 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: [
|
outputs: [
|
||||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const lineNode = createNodeModule(
|
|||||||
},
|
},
|
||||||
{ id: "x1", name: "X1", direction: "input", kind: "number" },
|
{ id: "x1", name: "X1", direction: "input", kind: "number" },
|
||||||
{ id: "y1", name: "Y1", 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: [
|
outputs: [
|
||||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const ovalNode = createNodeModule(
|
|||||||
kind: "number",
|
kind: "number",
|
||||||
defaultValue: 8,
|
defaultValue: 8,
|
||||||
},
|
},
|
||||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||||
],
|
],
|
||||||
outputs: [
|
outputs: [
|
||||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const ovalfillNode = createNodeModule(
|
|||||||
kind: "number",
|
kind: "number",
|
||||||
defaultValue: 8,
|
defaultValue: 8,
|
||||||
},
|
},
|
||||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||||
],
|
],
|
||||||
outputs: [
|
outputs: [
|
||||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ export const palNode = createNodeModule(
|
|||||||
searchTags: ["pal", "palette", "color", "remap"],
|
searchTags: ["pal", "palette", "color", "remap"],
|
||||||
inputs: [
|
inputs: [
|
||||||
{ id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
|
{ 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",
|
id: "c1",
|
||||||
name: "Color 1",
|
name: "Color 1",
|
||||||
direction: "input",
|
direction: "input",
|
||||||
kind: "number",
|
kind: "color",
|
||||||
defaultValue: 0,
|
defaultValue: 0,
|
||||||
},
|
},
|
||||||
{ id: "p", name: "Palette", direction: "input", kind: "number" },
|
{ id: "p", name: "Palette", direction: "input", kind: "number" },
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export const paltNode = createNodeModule(
|
|||||||
searchTags: ["palt", "transparency", "palette", "sprite"],
|
searchTags: ["palt", "transparency", "palette", "sprite"],
|
||||||
inputs: [
|
inputs: [
|
||||||
{ id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
|
{ 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",
|
id: "transparent",
|
||||||
name: "Transparent",
|
name: "Transparent",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export const pgetNode = createNodeModule(
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
outputs: [
|
outputs: [
|
||||||
{ id: "value", name: "Color", direction: "output", kind: "number" },
|
{ id: "value", name: "Color", direction: "output", kind: "color" },
|
||||||
],
|
],
|
||||||
properties: [],
|
properties: [],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const psetNode = createNodeModule(
|
|||||||
kind: "number",
|
kind: "number",
|
||||||
defaultValue: 0,
|
defaultValue: 0,
|
||||||
},
|
},
|
||||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||||
],
|
],
|
||||||
outputs: [
|
outputs: [
|
||||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const rectNode = createNodeModule(
|
|||||||
kind: "number",
|
kind: "number",
|
||||||
defaultValue: 8,
|
defaultValue: 8,
|
||||||
},
|
},
|
||||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||||
],
|
],
|
||||||
outputs: [
|
outputs: [
|
||||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const rectfillNode = createNodeModule(
|
|||||||
kind: "number",
|
kind: "number",
|
||||||
defaultValue: 8,
|
defaultValue: 8,
|
||||||
},
|
},
|
||||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||||
],
|
],
|
||||||
outputs: [
|
outputs: [
|
||||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const rrectNode = createNodeModule(
|
|||||||
kind: "number",
|
kind: "number",
|
||||||
defaultValue: 2,
|
defaultValue: 2,
|
||||||
},
|
},
|
||||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||||
],
|
],
|
||||||
outputs: [
|
outputs: [
|
||||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const rrectfillNode = createNodeModule(
|
|||||||
kind: "number",
|
kind: "number",
|
||||||
defaultValue: 2,
|
defaultValue: 2,
|
||||||
},
|
},
|
||||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||||
],
|
],
|
||||||
outputs: [
|
outputs: [
|
||||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const sgetNode = createNodeModule(
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
outputs: [
|
outputs: [
|
||||||
{ id: "value", name: "Color", direction: "output", kind: "number" },
|
{ id: "value", name: "Color", direction: "output", kind: "color" },
|
||||||
],
|
],
|
||||||
properties: [],
|
properties: [],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export const ssetNode = createNodeModule(
|
|||||||
id: "color",
|
id: "color",
|
||||||
name: "Color",
|
name: "Color",
|
||||||
direction: "input",
|
direction: "input",
|
||||||
kind: "number",
|
kind: "color",
|
||||||
defaultValue: 0,
|
defaultValue: 0,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
|
import { getLiteralNodeModules } from "../../core/lua/LuaLiteralTypes.js";
|
||||||
import { eventInitNode } from "./eventStart.js";
|
import { eventInitNode } from "./eventStart.js";
|
||||||
import { eventUpdateNode } from "./eventUpdate.js";
|
import { eventUpdateNode } from "./eventUpdate.js";
|
||||||
import { eventDrawNode } from "./eventDraw.js";
|
import { eventDrawNode } from "./eventDraw.js";
|
||||||
import { printNode } from "./print.js";
|
import { printNode } from "./print.js";
|
||||||
import { setVariableNode } from "./setVariable.js";
|
import { setVariableNode } from "./setVariable.js";
|
||||||
import { getVariableNode } from "./getVariable.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 { compareNode } from "./compare.js";
|
||||||
import { sequenceNode } from "./sequence.js";
|
import { sequenceNode } from "./sequence.js";
|
||||||
import { ifNode } from "./ifNode.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 { devkitNodes, devkitFunctionChecklist } from "./devkit/index.js";
|
||||||
import { luaNodes, luaFunctionChecklist } from "./lua/index.js";
|
import { luaNodes, luaFunctionChecklist } from "./lua/index.js";
|
||||||
import { localVariableNodes } from "./localVariables.js";
|
import { localVariableNodes } from "./localVariables.js";
|
||||||
|
import { converterNodes } from "./converters/index.js";
|
||||||
|
import { rerouteNode, rerouteExecNode } from "./reroute.js";
|
||||||
|
|
||||||
export const nodeModules = [
|
export const nodeModules = [
|
||||||
|
...getLiteralNodeModules(),
|
||||||
eventInitNode,
|
eventInitNode,
|
||||||
eventUpdateNode,
|
eventUpdateNode,
|
||||||
eventDrawNode,
|
eventDrawNode,
|
||||||
printNode,
|
printNode,
|
||||||
setVariableNode,
|
setVariableNode,
|
||||||
getVariableNode,
|
getVariableNode,
|
||||||
numberLiteralNode,
|
|
||||||
stringLiteralNode,
|
|
||||||
booleanLiteralNode,
|
|
||||||
addNumberNode,
|
|
||||||
multiplyNumberNode,
|
|
||||||
compareNode,
|
compareNode,
|
||||||
sequenceNode,
|
sequenceNode,
|
||||||
ifNode,
|
ifNode,
|
||||||
@@ -51,6 +45,9 @@ export const nodeModules = [
|
|||||||
customEventNode,
|
customEventNode,
|
||||||
callCustomEventNode,
|
callCustomEventNode,
|
||||||
...localVariableNodes,
|
...localVariableNodes,
|
||||||
|
...converterNodes,
|
||||||
|
rerouteNode,
|
||||||
|
rerouteExecNode,
|
||||||
...graphicsNodes,
|
...graphicsNodes,
|
||||||
...systemNodes,
|
...systemNodes,
|
||||||
...tableNodes,
|
...tableNodes,
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ export const controllerButtonsNode = createNodeModule(
|
|||||||
name: "Button",
|
name: "Button",
|
||||||
direction: "input",
|
direction: "input",
|
||||||
kind: "number",
|
kind: "number",
|
||||||
|
defaultValue: DEFAULT_BUTTON_VALUE,
|
||||||
|
options: BUTTON_OPTIONS,
|
||||||
description: "Optional button index override",
|
description: "Optional button index override",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -97,6 +99,8 @@ export const controllerButtonsNode = createNodeModule(
|
|||||||
name: "Player",
|
name: "Player",
|
||||||
direction: "input",
|
direction: "input",
|
||||||
kind: "number",
|
kind: "number",
|
||||||
|
defaultValue: DEFAULT_PLAYER_VALUE,
|
||||||
|
options: PLAYER_OPTIONS,
|
||||||
description: "Optional player index override",
|
description: "Optional player index override",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
import { createNodeModule } from "../nodeTypes.js";
|
import { createNodeModule } from "../nodeTypes.js";
|
||||||
|
import {
|
||||||
|
getLocalVariableTypeOptions,
|
||||||
|
variablePropertyKey,
|
||||||
|
localVariableDefault,
|
||||||
|
LOCAL_VARIABLE_TYPE_IDS,
|
||||||
|
} from "../../types/TypeRegistry.js";
|
||||||
|
|
||||||
const LOCAL_TYPES = [
|
const LOCAL_TYPES = getLocalVariableTypeOptions();
|
||||||
{ value: "number", label: "Number" },
|
|
||||||
{ value: "string", label: "String" },
|
|
||||||
{ value: "boolean", label: "Boolean" },
|
|
||||||
{ value: "table", label: "Table" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const DEFAULT_LOCAL_TYPE = LOCAL_TYPES[0]?.value ?? "number";
|
const DEFAULT_LOCAL_TYPE = LOCAL_TYPES[0]?.value ?? "number";
|
||||||
|
|
||||||
const sanitizeType = (raw) => {
|
const sanitizeType = (raw) => {
|
||||||
const candidate = typeof raw === "string" ? raw.toLowerCase() : DEFAULT_LOCAL_TYPE;
|
const candidate = typeof raw === "string" ? raw.trim().toLowerCase() : "";
|
||||||
return LOCAL_TYPES.some((entry) => entry.value === candidate)
|
return LOCAL_VARIABLE_TYPE_IDS.has(candidate) ? candidate : DEFAULT_LOCAL_TYPE;
|
||||||
? candidate
|
|
||||||
: DEFAULT_LOCAL_TYPE;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensureName = (raw, fallback = "localVar") => {
|
const ensureName = (raw, fallback = "localVar") => {
|
||||||
@@ -21,33 +19,10 @@ const ensureName = (raw, fallback = "localVar") => {
|
|||||||
return value.length ? value : fallback;
|
return value.length ? value : fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveValuePropertyKey = (type) => {
|
const resolveValuePropertyKey = variablePropertyKey;
|
||||||
switch (type) {
|
|
||||||
case "string":
|
const defaultValueForType = localVariableDefault;
|
||||||
return "valueString";
|
|
||||||
case "boolean":
|
|
||||||
return "valueBoolean";
|
|
||||||
case "table":
|
|
||||||
return "valueTable";
|
|
||||||
case "number":
|
|
||||||
default:
|
|
||||||
return "valueNumber";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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 initializeLocalProperties = (properties, { includeValue }) => {
|
||||||
const type = sanitizeType(properties.variableType);
|
const type = sanitizeType(properties.variableType);
|
||||||
@@ -68,6 +43,15 @@ const initializeLocalProperties = (properties, { includeValue }) => {
|
|||||||
if (typeof properties.valueTable !== "string") {
|
if (typeof properties.valueTable !== "string") {
|
||||||
properties.valueTable = String(properties.valueTable ?? "{}");
|
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) {
|
if (includeValue) {
|
||||||
const key = resolveValuePropertyKey(type);
|
const key = resolveValuePropertyKey(type);
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { createNodeModule } from "../nodeTypes.js";
|
import { createNodeModule } from "../../nodeTypes.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds two numeric values.
|
* Adds two numeric values.
|
||||||
*/
|
*/
|
||||||
export const addNumberNode = createNodeModule(
|
export const mathAddNode = createNodeModule(
|
||||||
{
|
{
|
||||||
id: "add_number",
|
id: "math_add",
|
||||||
title: "Add",
|
title: "Add",
|
||||||
category: "Math",
|
category: "Math",
|
||||||
description: "Add two numbers.",
|
description: "Add two numbers.",
|
||||||
searchTags: ["add", "math", "sum", "number"],
|
searchTags: ["add", "math", "sum", "plus", "number", "+"],
|
||||||
inputs: [
|
inputs: [
|
||||||
{
|
{
|
||||||
id: "a",
|
id: "a",
|
||||||
41
scripts/nodes/library/math/divide.js
Normal file
41
scripts/nodes/library/math/divide.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { createNodeModule } from "../../nodeTypes.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Divides one numeric value by another.
|
||||||
|
*/
|
||||||
|
export const mathDivideNode = createNodeModule(
|
||||||
|
{
|
||||||
|
id: "math_divide",
|
||||||
|
title: "Divide",
|
||||||
|
category: "Math",
|
||||||
|
description: "Divide one number by another (floating point division).",
|
||||||
|
searchTags: ["divide", "division", "math", "slash", "number", "/"],
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
id: "a",
|
||||||
|
name: "A",
|
||||||
|
direction: "input",
|
||||||
|
kind: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "b",
|
||||||
|
name: "B",
|
||||||
|
direction: "input",
|
||||||
|
kind: "number",
|
||||||
|
defaultValue: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ id: "res", name: "Result", direction: "output", kind: "number" },
|
||||||
|
],
|
||||||
|
properties: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
evaluateValue: ({ resolveValueInput }) => {
|
||||||
|
const a = resolveValueInput("a", "0");
|
||||||
|
const b = resolveValueInput("b", "1");
|
||||||
|
return `(${a}) / (${b})`;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { mathAddNode } from "./add.js";
|
||||||
|
import { mathSubtractNode } from "./subtract.js";
|
||||||
|
import { mathMultiplyNode } from "./multiply.js";
|
||||||
|
import { mathDivideNode } from "./divide.js";
|
||||||
import { mathMaxNode } from "./max.js";
|
import { mathMaxNode } from "./max.js";
|
||||||
import { mathMinNode } from "./min.js";
|
import { mathMinNode } from "./min.js";
|
||||||
import { mathMidNode } from "./mid.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.
|
* All math-related node modules backed by PICO-8 helpers.
|
||||||
*/
|
*/
|
||||||
export const mathNodes = [
|
export const mathNodes = [
|
||||||
|
mathAddNode,
|
||||||
|
mathSubtractNode,
|
||||||
|
mathMultiplyNode,
|
||||||
|
mathDivideNode,
|
||||||
mathMaxNode,
|
mathMaxNode,
|
||||||
mathMinNode,
|
mathMinNode,
|
||||||
mathMidNode,
|
mathMidNode,
|
||||||
@@ -54,6 +62,10 @@ export const mathNodes = [
|
|||||||
* @type {Array<{ function: string, nodeId: string, implemented: boolean }>}
|
* @type {Array<{ function: string, nodeId: string, implemented: boolean }>}
|
||||||
*/
|
*/
|
||||||
export const mathFunctionChecklist = [
|
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: "MAX", nodeId: "math_max", implemented: true },
|
||||||
{ function: "MIN", nodeId: "math_min", implemented: true },
|
{ function: "MIN", nodeId: "math_min", implemented: true },
|
||||||
{ function: "MID", nodeId: "math_mid", implemented: true },
|
{ function: "MID", nodeId: "math_mid", implemented: true },
|
||||||
|
|||||||
@@ -1,29 +1,29 @@
|
|||||||
import { createNodeModule } from "../nodeTypes.js";
|
import { createNodeModule } from "../../nodeTypes.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Multiplies two numeric values.
|
* Multiplies two numeric values.
|
||||||
*/
|
*/
|
||||||
export const multiplyNumberNode = createNodeModule(
|
export const mathMultiplyNode = createNodeModule(
|
||||||
{
|
{
|
||||||
id: "multiply_number",
|
id: "math_multiply",
|
||||||
title: "Multiply",
|
title: "Multiply",
|
||||||
category: "Math",
|
category: "Math",
|
||||||
description: "Multiply two numbers.",
|
description: "Multiply two numbers.",
|
||||||
searchTags: ["multiply", "product", "math", "number"],
|
searchTags: ["multiply", "product", "math", "times", "*"],
|
||||||
inputs: [
|
inputs: [
|
||||||
{
|
{
|
||||||
id: "a",
|
id: "a",
|
||||||
name: "A",
|
name: "A",
|
||||||
direction: "input",
|
direction: "input",
|
||||||
kind: "number",
|
kind: "number",
|
||||||
defaultValue: 1,
|
defaultValue: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "b",
|
id: "b",
|
||||||
name: "B",
|
name: "B",
|
||||||
direction: "input",
|
direction: "input",
|
||||||
kind: "number",
|
kind: "number",
|
||||||
defaultValue: 1,
|
defaultValue: 0,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
outputs: [
|
outputs: [
|
||||||
@@ -33,8 +33,8 @@ export const multiplyNumberNode = createNodeModule(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
evaluateValue: ({ resolveValueInput }) => {
|
evaluateValue: ({ resolveValueInput }) => {
|
||||||
const a = resolveValueInput("a", "1");
|
const a = resolveValueInput("a", "0");
|
||||||
const b = resolveValueInput("b", "1");
|
const b = resolveValueInput("b", "0");
|
||||||
return `(${a}) * (${b})`;
|
return `(${a}) * (${b})`;
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
41
scripts/nodes/library/math/subtract.js
Normal file
41
scripts/nodes/library/math/subtract.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { createNodeModule } from "../../nodeTypes.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subtracts one numeric value from another.
|
||||||
|
*/
|
||||||
|
export const mathSubtractNode = createNodeModule(
|
||||||
|
{
|
||||||
|
id: "math_subtract",
|
||||||
|
title: "Subtract",
|
||||||
|
category: "Math",
|
||||||
|
description: "Subtract one number from another.",
|
||||||
|
searchTags: ["subtract", "math", "minus", "difference", "-"],
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
id: "a",
|
||||||
|
name: "A",
|
||||||
|
direction: "input",
|
||||||
|
kind: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "b",
|
||||||
|
name: "B",
|
||||||
|
direction: "input",
|
||||||
|
kind: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ id: "res", name: "Result", direction: "output", kind: "number" },
|
||||||
|
],
|
||||||
|
properties: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
evaluateValue: ({ resolveValueInput }) => {
|
||||||
|
const a = resolveValueInput("a", "0");
|
||||||
|
const b = resolveValueInput("b", "0");
|
||||||
|
return `(${a}) - (${b})`;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { createNodeModule } from "../nodeTypes.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Constant numeric literal.
|
|
||||||
*/
|
|
||||||
export const numberLiteralNode = createNodeModule(
|
|
||||||
{
|
|
||||||
id: "number_literal",
|
|
||||||
title: "Number",
|
|
||||||
category: "Values",
|
|
||||||
description: "Constant numeric literal.",
|
|
||||||
searchTags: ["number", "literal", "constant", "value"],
|
|
||||||
inputs: [],
|
|
||||||
outputs: [
|
|
||||||
{ id: "value", name: "Value", direction: "output", kind: "number" },
|
|
||||||
],
|
|
||||||
properties: [
|
|
||||||
{ key: "value", label: "Number", type: "number", defaultValue: 0 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
evaluateValue: ({ node, formatLiteral }) => {
|
|
||||||
return formatLiteral("number", node.properties.value ?? 0);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@@ -37,7 +37,7 @@ export const printNode = createNodeModule(
|
|||||||
id: "color",
|
id: "color",
|
||||||
name: "Color",
|
name: "Color",
|
||||||
direction: "input",
|
direction: "input",
|
||||||
kind: "number",
|
kind: "color",
|
||||||
defaultValue: 7,
|
defaultValue: 7,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
53
scripts/nodes/library/reroute.js
Normal file
53
scripts/nodes/library/reroute.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { createNodeModule } from "../nodeTypes.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A transparent pass-through node for redirecting data wires.
|
||||||
|
* Emits no code on its own — the value resolves directly through the input.
|
||||||
|
*/
|
||||||
|
export const rerouteNode = createNodeModule(
|
||||||
|
{
|
||||||
|
id: "reroute",
|
||||||
|
title: "Reroute",
|
||||||
|
category: "Utility",
|
||||||
|
description: "Redirects a data wire without affecting the value.",
|
||||||
|
searchTags: ["reroute", "redirect", "wire", "relay", "pass", "through"],
|
||||||
|
inputs: [
|
||||||
|
{ id: "value", name: "", direction: "input", kind: "any" },
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ id: "value", name: "", direction: "output", kind: "any" },
|
||||||
|
],
|
||||||
|
properties: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
evaluateValue: ({ resolveValueInput }) => {
|
||||||
|
return resolveValueInput("value", "nil");
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A transparent pass-through node for redirecting exec wires.
|
||||||
|
* Emits no code on its own — execution continues directly to the next node.
|
||||||
|
*/
|
||||||
|
export const rerouteExecNode = createNodeModule(
|
||||||
|
{
|
||||||
|
id: "reroute_exec",
|
||||||
|
title: "Reroute",
|
||||||
|
category: "Utility",
|
||||||
|
description: "Redirects an exec wire without generating any code.",
|
||||||
|
searchTags: ["reroute", "redirect", "exec", "wire", "relay", "pass", "through"],
|
||||||
|
inputs: [
|
||||||
|
{ id: "exec_in", name: "", direction: "input", kind: "exec" },
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ id: "exec_out", name: "", direction: "output", kind: "exec" },
|
||||||
|
],
|
||||||
|
properties: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
emitExec: ({ emitNextExec }) => {
|
||||||
|
return emitNextExec("exec_out");
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { createNodeModule } from "../nodeTypes.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Constant string literal.
|
|
||||||
*/
|
|
||||||
export const stringLiteralNode = createNodeModule(
|
|
||||||
{
|
|
||||||
id: "string_literal",
|
|
||||||
title: "String",
|
|
||||||
category: "Values",
|
|
||||||
description: "Constant string literal.",
|
|
||||||
searchTags: ["string", "literal", "text", "value"],
|
|
||||||
inputs: [],
|
|
||||||
outputs: [
|
|
||||||
{ id: "value", name: "Value", direction: "output", kind: "string" },
|
|
||||||
],
|
|
||||||
properties: [
|
|
||||||
{ key: "value", label: "Text", type: "string", defaultValue: "hello" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
evaluateValue: ({ node, formatLiteral }) => {
|
|
||||||
return formatLiteral("string", node.properties.value ?? "");
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@@ -41,7 +41,7 @@ export const extcmdNode = createNodeModule(
|
|||||||
],
|
],
|
||||||
inputs: [
|
inputs: [
|
||||||
{ id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
|
{ 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: "param1", name: "Param 1", direction: "input", kind: "number" },
|
||||||
{ id: "param2", name: "Param 2", direction: "input", kind: "number" },
|
{ id: "param2", name: "Param 2", direction: "input", kind: "number" },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -13,9 +13,11 @@
|
|||||||
* @property {string} id Stable identifier unique within the node definition.
|
* @property {string} id Stable identifier unique within the node definition.
|
||||||
* @property {string} name Display name.
|
* @property {string} name Display name.
|
||||||
* @property {('input'|'output')} direction Pin direction relative to the node.
|
* @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 {string} [description] Optional tooltip text.
|
||||||
* @property {unknown} [defaultValue] Default literal for data pins.
|
* @property {unknown} [defaultValue] Default literal for data pins.
|
||||||
|
* @property {Array<{label: string, value: string}>} [options] Renders an inline select dropdown when the pin is unconnected.
|
||||||
|
* @property {boolean} [spawnLiteralNode] Whether dragging off this pin offers the default literal node. Defaults to the handler's spawnableByDefault.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
13
scripts/types/AnyType.js
Normal file
13
scripts/types/AnyType.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* @typedef {import('./TypeRegistry.js').TypeDescriptor} TypeDescriptor
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {TypeDescriptor} */
|
||||||
|
export const AnyType = {
|
||||||
|
id: "any",
|
||||||
|
label: "Any",
|
||||||
|
color: "#ff7f11",
|
||||||
|
isVariable: true,
|
||||||
|
isLocalVariable: false,
|
||||||
|
defaultValue: null,
|
||||||
|
};
|
||||||
19
scripts/types/BooleanType.js
Normal file
19
scripts/types/BooleanType.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* @typedef {import('./TypeRegistry.js').TypeDescriptor} TypeDescriptor
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {TypeDescriptor} */
|
||||||
|
export const BooleanType = {
|
||||||
|
id: "boolean",
|
||||||
|
label: "Boolean",
|
||||||
|
color: "#ff4f4f",
|
||||||
|
isVariable: true,
|
||||||
|
isLocalVariable: true,
|
||||||
|
defaultValue: false,
|
||||||
|
variablePropertyKey: "valueBoolean",
|
||||||
|
compatibleWith: [
|
||||||
|
{ type: "string", convert: (v) => v ? "true" : "false" },
|
||||||
|
{ type: "number", convert: (v) => v ? 1 : 0 },
|
||||||
|
{ type: "color", convert: (v) => v ? 7 : 0 }
|
||||||
|
],
|
||||||
|
};
|
||||||
18
scripts/types/ColorType.js
Normal file
18
scripts/types/ColorType.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* @typedef {import('./TypeRegistry.js').TypeDescriptor} TypeDescriptor
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {TypeDescriptor} */
|
||||||
|
export const ColorType = {
|
||||||
|
id: "color",
|
||||||
|
label: "Color",
|
||||||
|
color: "#5b8ef5",
|
||||||
|
isVariable: true,
|
||||||
|
isLocalVariable: true,
|
||||||
|
defaultValue: 7,
|
||||||
|
variablePropertyKey: "valueColor",
|
||||||
|
compatibleWith: [
|
||||||
|
{ type: "number", convert: (v) => Number(v) },
|
||||||
|
{ type: "boolean", convert: (v) => v !== 0 }
|
||||||
|
],
|
||||||
|
};
|
||||||
13
scripts/types/ExecType.js
Normal file
13
scripts/types/ExecType.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Type descriptor for execution flow pins.
|
||||||
|
*
|
||||||
|
* @type {import('./TypeRegistry.js').TypeDescriptor}
|
||||||
|
*/
|
||||||
|
export const ExecType = {
|
||||||
|
id: "exec",
|
||||||
|
label: "Exec",
|
||||||
|
color: "#ffffff",
|
||||||
|
isVariable: false,
|
||||||
|
isLocalVariable: false,
|
||||||
|
defaultValue: null,
|
||||||
|
};
|
||||||
17
scripts/types/NumberType.js
Normal file
17
scripts/types/NumberType.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* @typedef {import('./TypeRegistry.js').TypeDescriptor} TypeDescriptor
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {TypeDescriptor} */
|
||||||
|
export const NumberType = {
|
||||||
|
id: "number",
|
||||||
|
label: "Number",
|
||||||
|
color: "#3ee581",
|
||||||
|
isVariable: true,
|
||||||
|
isLocalVariable: true,
|
||||||
|
defaultValue: 0,
|
||||||
|
variablePropertyKey: "valueNumber",
|
||||||
|
compatibleWith: [
|
||||||
|
{ type: "color", convert: (v) => Math.floor(Number(v)) % 16 },
|
||||||
|
],
|
||||||
|
};
|
||||||
14
scripts/types/StringType.js
Normal file
14
scripts/types/StringType.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* @typedef {import('./TypeRegistry.js').TypeDescriptor} TypeDescriptor
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {TypeDescriptor} */
|
||||||
|
export const StringType = {
|
||||||
|
id: "string",
|
||||||
|
label: "String",
|
||||||
|
color: "#ff66ff",
|
||||||
|
isVariable: true,
|
||||||
|
isLocalVariable: true,
|
||||||
|
defaultValue: "",
|
||||||
|
variablePropertyKey: "valueString",
|
||||||
|
};
|
||||||
15
scripts/types/TableType.js
Normal file
15
scripts/types/TableType.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* @typedef {import('./TypeRegistry.js').TypeDescriptor} TypeDescriptor
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {TypeDescriptor} */
|
||||||
|
export const TableType = {
|
||||||
|
id: "table",
|
||||||
|
label: "Table",
|
||||||
|
color: "#5ec4ff",
|
||||||
|
isVariable: true,
|
||||||
|
isLocalVariable: true,
|
||||||
|
defaultValue: [],
|
||||||
|
localDefaultValue: "{}",
|
||||||
|
variablePropertyKey: "valueTable",
|
||||||
|
};
|
||||||
165
scripts/types/TypeRegistry.js
Normal file
165
scripts/types/TypeRegistry.js
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { AnyType } from "./AnyType.js";
|
||||||
|
import { NumberType } from "./NumberType.js";
|
||||||
|
import { StringType } from "./StringType.js";
|
||||||
|
import { BooleanType } from "./BooleanType.js";
|
||||||
|
import { TableType } from "./TableType.js";
|
||||||
|
import { ColorType } from "./ColorType.js";
|
||||||
|
import { ExecType } from "./ExecType.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {'any'|'number'|'string'|'boolean'|'table'|'color'} VariableType
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} TypeCompatibility
|
||||||
|
* @property {string} type Target type ID this type can connect to.
|
||||||
|
* @property {(value: unknown) => unknown} [convert] Optional conversion function.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} TypeDescriptor
|
||||||
|
* @property {string} id Type identifier.
|
||||||
|
* @property {string} label Display label.
|
||||||
|
* @property {string} color Accent color used for pins, wires, and variable labels (hex or CSS variable).
|
||||||
|
* @property {string} [icon] Optional icon character or symbol shown in type labels.
|
||||||
|
* @property {boolean} isVariable Whether usable as a workspace variable type.
|
||||||
|
* @property {boolean} isLocalVariable Whether usable as a local variable node type.
|
||||||
|
* @property {unknown} defaultValue Default value for workspace variables.
|
||||||
|
* @property {unknown} [localDefaultValue] Default for local variable nodes when different from defaultValue.
|
||||||
|
* @property {string} [variablePropertyKey] Property key used in local variable nodes.
|
||||||
|
* @property {ReadonlyArray<TypeCompatibility>} [compatibleWith] Types this type can implicitly convert to.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {ReadonlyArray<TypeDescriptor>} */
|
||||||
|
export const ALL_TYPES = [
|
||||||
|
ExecType,
|
||||||
|
AnyType,
|
||||||
|
NumberType,
|
||||||
|
StringType,
|
||||||
|
BooleanType,
|
||||||
|
TableType,
|
||||||
|
ColorType,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @type {Map<string, TypeDescriptor>} */
|
||||||
|
const TYPE_MAP = new Map(ALL_TYPES.map((t) => [t.id, t]));
|
||||||
|
|
||||||
|
/** @type {ReadonlySet<string>} */
|
||||||
|
export const LOCAL_VARIABLE_TYPE_IDS = new Set(
|
||||||
|
ALL_TYPES.filter((t) => t.isLocalVariable).map((t) => t.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns type options available for workspace variable type dropdowns.
|
||||||
|
*
|
||||||
|
* @returns {Array<{ value: string, label: string }>}
|
||||||
|
*/
|
||||||
|
export function getVariableTypeOptions() {
|
||||||
|
return ALL_TYPES.filter((t) => t.isVariable).map((t) => ({ value: t.id, label: t.label }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns type options available for local variable node type dropdowns.
|
||||||
|
*
|
||||||
|
* @returns {Array<{ value: string, label: string }>}
|
||||||
|
*/
|
||||||
|
export function getLocalVariableTypeOptions() {
|
||||||
|
return ALL_TYPES.filter((t) => t.isLocalVariable).map((t) => ({ value: t.id, label: t.label }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes an arbitrary value to a valid registered type id, falling back to "any".
|
||||||
|
*
|
||||||
|
* @param {unknown} value Candidate type value.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function normalizeVariableType(value) {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (TYPE_MAP.has(normalized)) return normalized;
|
||||||
|
}
|
||||||
|
return "any";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the default value for a workspace variable of the given type.
|
||||||
|
*
|
||||||
|
* @param {string} type Type identifier.
|
||||||
|
* @returns {unknown}
|
||||||
|
*/
|
||||||
|
export function defaultVariableValue(type) {
|
||||||
|
return TYPE_MAP.get(type)?.defaultValue ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the default value for a local variable node property of the given type.
|
||||||
|
*
|
||||||
|
* @param {string} type Type identifier.
|
||||||
|
* @returns {unknown}
|
||||||
|
*/
|
||||||
|
export function localVariableDefault(type) {
|
||||||
|
const desc = TYPE_MAP.get(type);
|
||||||
|
if (!desc) return 0;
|
||||||
|
return desc.localDefaultValue !== undefined ? desc.localDefaultValue : desc.defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the property key used to store the typed value in a local variable node.
|
||||||
|
*
|
||||||
|
* @param {string} type Type identifier.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function variablePropertyKey(type) {
|
||||||
|
return TYPE_MAP.get(type)?.variablePropertyKey ?? "valueNumber";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the accent color for the given type (used for pins, wires, and variable labels).
|
||||||
|
*
|
||||||
|
* @param {string} type Type identifier.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function getTypeColor(type) {
|
||||||
|
return TYPE_MAP.get(type)?.color ?? "#ffffff";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if two types are compatible for pin connections.
|
||||||
|
*
|
||||||
|
* @param {string} outputType The output pin type.
|
||||||
|
* @param {string} inputType The input pin type.
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function areTypesCompatible(outputType, inputType) {
|
||||||
|
if (inputType === "any" || outputType === "any") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (outputType === inputType) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const outputDesc = TYPE_MAP.get(outputType);
|
||||||
|
if (outputDesc?.compatibleWith?.some((c) => c.type === inputType)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const inputDesc = TYPE_MAP.get(inputType);
|
||||||
|
if (inputDesc?.compatibleWith?.some((c) => c.type === outputType)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the conversion function for converting from one type to another.
|
||||||
|
*
|
||||||
|
* @param {string} fromType Source type ID.
|
||||||
|
* @param {string} toType Target type ID.
|
||||||
|
* @returns {((value: unknown) => unknown) | undefined}
|
||||||
|
*/
|
||||||
|
export function getTypeConverter(fromType, toType) {
|
||||||
|
if (fromType === toType) {
|
||||||
|
return (v) => v;
|
||||||
|
}
|
||||||
|
const fromDesc = TYPE_MAP.get(fromType);
|
||||||
|
const compat = fromDesc?.compatibleWith?.find((c) => c.type === toType);
|
||||||
|
return compat?.convert;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NodeGraph } from "../core/NodeGraph.js";
|
import { NodeGraph } from "../core/NodeGraph.js";
|
||||||
|
import { createColorSwatchPicker } from "./ColorSwatchPicker.js";
|
||||||
import { WorkspaceGeometry } from "./workspace/WorkspaceGeometry.js";
|
import { WorkspaceGeometry } from "./workspace/WorkspaceGeometry.js";
|
||||||
import { WorkspaceDragManager } from "./workspace/WorkspaceDragManager.js";
|
import { WorkspaceDragManager } from "./workspace/WorkspaceDragManager.js";
|
||||||
import { WorkspaceHistoryManager } from "./workspace/WorkspaceHistoryManager.js";
|
import { WorkspaceHistoryManager } from "./workspace/WorkspaceHistoryManager.js";
|
||||||
@@ -6,34 +7,30 @@ import { WorkspaceClipboardManager } from "./workspace/WorkspaceClipboardManager
|
|||||||
import { WorkspacePersistenceManager } from "./workspace/WorkspacePersistenceManager.js";
|
import { WorkspacePersistenceManager } from "./workspace/WorkspacePersistenceManager.js";
|
||||||
import { WorkspaceMarqueeManager } from "./workspace/WorkspaceMarqueeManager.js";
|
import { WorkspaceMarqueeManager } from "./workspace/WorkspaceMarqueeManager.js";
|
||||||
import { WorkspaceContextMenuManager } from "./workspace/WorkspaceContextMenuManager.js";
|
import { WorkspaceContextMenuManager } from "./workspace/WorkspaceContextMenuManager.js";
|
||||||
|
import { WorkspaceNodeContextMenu } from "./workspace/WorkspaceNodeContextMenu.js";
|
||||||
import { WorkspacePaletteManager } from "./workspace/WorkspacePaletteManager.js";
|
import { WorkspacePaletteManager } from "./workspace/WorkspacePaletteManager.js";
|
||||||
import { WorkspaceDropManager } from "./workspace/WorkspaceDropManager.js";
|
import { WorkspaceDropManager } from "./workspace/WorkspaceDropManager.js";
|
||||||
import { WorkspaceInlineEditorManager } from "./workspace/WorkspaceInlineEditorManager.js";
|
import { WorkspaceInlineEditorManager } from "./workspace/WorkspaceInlineEditorManager.js";
|
||||||
import { WorkspaceNodeRenderer } from "./workspace/WorkspaceNodeRenderer.js";
|
import { WorkspaceNodeRenderer } from "./workspace/WorkspaceNodeRenderer.js";
|
||||||
import { WorkspaceNavigationManager } from "./workspace/WorkspaceNavigationManager.js";
|
import { WorkspaceNavigationManager } from "./workspace/WorkspaceNavigationManager.js";
|
||||||
|
import { WorkspaceWireCutManager } from "./workspace/WorkspaceWireCutManager.js";
|
||||||
import { renderEventList } from "./workspace/renderEventList.js";
|
import { renderEventList } from "./workspace/renderEventList.js";
|
||||||
import { renderVariableList } from "./workspace/renderVariableList.js";
|
import { renderVariableList } from "./workspace/renderVariableList.js";
|
||||||
import {
|
import {
|
||||||
WORKSPACE_SHORTCUTS,
|
WORKSPACE_SHORTCUTS,
|
||||||
} from "./WorkspaceSpawnShortcuts.js";
|
} 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').WorkspaceShortcut} WorkspaceShortcut */
|
||||||
/** @typedef {import('./WorkspaceSpawnShortcuts.js').WorkspaceShortcutModifier} WorkspaceShortcutModifier */
|
/** @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} eventList Event summary list element.
|
||||||
* @property {HTMLUListElement} variableList Variable summary list element.
|
* @property {HTMLUListElement} variableList Variable summary list element.
|
||||||
* @property {HTMLButtonElement} addVariableButton Global variable creation button.
|
* @property {HTMLButtonElement} addVariableButton Global variable creation button.
|
||||||
|
* @property {HTMLButtonElement} saveButton Save action button.
|
||||||
* @property {HTMLButtonElement} projectSettingsButton Project settings toggle.
|
* @property {HTMLButtonElement} projectSettingsButton Project settings toggle.
|
||||||
* @property {HTMLButtonElement} paletteToggleButton Palette toggle control.
|
* @property {HTMLButtonElement} paletteToggleButton Palette toggle control.
|
||||||
* @property {HTMLElement} appBodyElement Layout container hosting the workspace panes.
|
* @property {HTMLElement} appBodyElement Layout container hosting the workspace panes.
|
||||||
@@ -143,6 +141,8 @@ export class BlueprintWorkspace {
|
|||||||
this.variableListElement = options.variableList;
|
this.variableListElement = options.variableList;
|
||||||
this.addVariableButton = options.addVariableButton;
|
this.addVariableButton = options.addVariableButton;
|
||||||
/** @type {HTMLButtonElement} */
|
/** @type {HTMLButtonElement} */
|
||||||
|
this.saveButton = options.saveButton;
|
||||||
|
/** @type {HTMLButtonElement} */
|
||||||
this.projectSettingsButton = options.projectSettingsButton;
|
this.projectSettingsButton = options.projectSettingsButton;
|
||||||
/** @type {HTMLButtonElement} */
|
/** @type {HTMLButtonElement} */
|
||||||
this.frameSelectionButton = options.frameSelectionButton;
|
this.frameSelectionButton = options.frameSelectionButton;
|
||||||
@@ -175,6 +175,12 @@ export class BlueprintWorkspace {
|
|||||||
this.nodeDragRotationDecayFrame = null;
|
this.nodeDragRotationDecayFrame = null;
|
||||||
/** @type {number | null} */
|
/** @type {number | null} */
|
||||||
this.connectionRefreshFrame = 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();
|
this.graph = new NodeGraph();
|
||||||
/** @type {Map<string, WorkspaceVariable>} */
|
/** @type {Map<string, WorkspaceVariable>} */
|
||||||
@@ -240,6 +246,8 @@ export class BlueprintWorkspace {
|
|||||||
this.paletteManager = null;
|
this.paletteManager = null;
|
||||||
/** @type {WorkspaceContextMenuManager | null} */
|
/** @type {WorkspaceContextMenuManager | null} */
|
||||||
this.contextMenuManager = null;
|
this.contextMenuManager = null;
|
||||||
|
/** @type {WorkspaceNodeContextMenu | null} */
|
||||||
|
this.nodeContextMenu = null;
|
||||||
/** @type {WorkspaceDropManager | null} */
|
/** @type {WorkspaceDropManager | null} */
|
||||||
this.dropManager = null;
|
this.dropManager = null;
|
||||||
/** @type {WorkspaceMarqueeManager | null} */
|
/** @type {WorkspaceMarqueeManager | null} */
|
||||||
@@ -252,6 +260,8 @@ export class BlueprintWorkspace {
|
|||||||
this.navigationManager = null;
|
this.navigationManager = null;
|
||||||
/** @type {WorkspaceDragManager | null} */
|
/** @type {WorkspaceDragManager | null} */
|
||||||
this.dragManager = null;
|
this.dragManager = null;
|
||||||
|
/** @type {WorkspaceWireCutManager | null} */
|
||||||
|
this.wireCutManager = null;
|
||||||
/** @type {HTMLTemplateElement} */
|
/** @type {HTMLTemplateElement} */
|
||||||
this.nodeTemplate = BlueprintWorkspace.#requireTemplate(
|
this.nodeTemplate = BlueprintWorkspace.#requireTemplate(
|
||||||
"nodeTemplate",
|
"nodeTemplate",
|
||||||
@@ -349,6 +359,7 @@ export class BlueprintWorkspace {
|
|||||||
isTypeDropdownOpen: (context) => this.#isTypeDropdownOpen(context),
|
isTypeDropdownOpen: (context) => this.#isTypeDropdownOpen(context),
|
||||||
});
|
});
|
||||||
this.contextMenuManager = new WorkspaceContextMenuManager(this);
|
this.contextMenuManager = new WorkspaceContextMenuManager(this);
|
||||||
|
this.nodeContextMenu = new WorkspaceNodeContextMenu(this);
|
||||||
this.paletteManager = new WorkspacePaletteManager(this);
|
this.paletteManager = new WorkspacePaletteManager(this);
|
||||||
this.dropManager = new WorkspaceDropManager(this);
|
this.dropManager = new WorkspaceDropManager(this);
|
||||||
this.marqueeManager = new WorkspaceMarqueeManager(this);
|
this.marqueeManager = new WorkspaceMarqueeManager(this);
|
||||||
@@ -356,6 +367,7 @@ export class BlueprintWorkspace {
|
|||||||
renderConnections: () => this.#renderConnections(),
|
renderConnections: () => this.#renderConnections(),
|
||||||
schedulePersist: () => this.persistenceManager.schedulePersist(),
|
schedulePersist: () => this.persistenceManager.schedulePersist(),
|
||||||
});
|
});
|
||||||
|
this.wireCutManager = new WorkspaceWireCutManager(this);
|
||||||
this.nodeRenderer = new WorkspaceNodeRenderer(this, {
|
this.nodeRenderer = new WorkspaceNodeRenderer(this, {
|
||||||
inlineEditorManager: this.inlineEditorManager,
|
inlineEditorManager: this.inlineEditorManager,
|
||||||
applySelectionState: () => this.#applySelectionState(),
|
applySelectionState: () => this.#applySelectionState(),
|
||||||
@@ -380,12 +392,14 @@ export class BlueprintWorkspace {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.contextMenuManager.initialize();
|
this.contextMenuManager.initialize();
|
||||||
|
this.nodeContextMenu.initialize();
|
||||||
this.paletteManager.initialize();
|
this.paletteManager.initialize();
|
||||||
this.dropManager.initialize();
|
this.dropManager.initialize();
|
||||||
this.marqueeManager.ensureElement();
|
this.marqueeManager.ensureElement();
|
||||||
|
|
||||||
this.navigationManager.updateZoomDisplay();
|
this.navigationManager.updateZoomDisplay();
|
||||||
this.navigationManager.setBackgroundOffset({ x: 0, y: 0 });
|
this.navigationManager.setBackgroundOffset({ x: 0, y: 0 });
|
||||||
|
this.#resetSaveButtonAppearance();
|
||||||
|
|
||||||
if (typeof this.generator.setProjectSettings === "function") {
|
if (typeof this.generator.setProjectSettings === "function") {
|
||||||
this.generator.setProjectSettings({ ...this.projectSettings });
|
this.generator.setProjectSettings({ ...this.projectSettings });
|
||||||
@@ -405,6 +419,127 @@ export class BlueprintWorkspace {
|
|||||||
|
|
||||||
this.historyManager.initialize();
|
this.historyManager.initialize();
|
||||||
this.isInitialized = true;
|
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();
|
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.
|
* Subscribes to graph lifecycle changes.
|
||||||
*/
|
*/
|
||||||
@@ -661,6 +831,12 @@ export class BlueprintWorkspace {
|
|||||||
* Registers DOM event listeners for interactivity.
|
* Registers DOM event listeners for interactivity.
|
||||||
*/
|
*/
|
||||||
#bindUiEvents() {
|
#bindUiEvents() {
|
||||||
|
if (this.saveButton) {
|
||||||
|
this.saveButton.addEventListener("click", () => {
|
||||||
|
this.saveWorkspace();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (this.paletteToggleButton) {
|
if (this.paletteToggleButton) {
|
||||||
this.paletteToggleButton.addEventListener("click", () => {
|
this.paletteToggleButton.addEventListener("click", () => {
|
||||||
this.#togglePaletteVisibility();
|
this.#togglePaletteVisibility();
|
||||||
@@ -726,7 +902,7 @@ export class BlueprintWorkspace {
|
|||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const direction = event.deltaY < 0 ? 1 : -1;
|
const direction = event.deltaY < 0 ? 1 : -1;
|
||||||
this.navigationManager.zoomAt(pointer, direction, {
|
this.navigationManager.pushSmoothZoom(pointer, direction, {
|
||||||
clientX: event.clientX,
|
clientX: event.clientX,
|
||||||
clientY: event.clientY,
|
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)) {
|
if (!(rawTarget instanceof Element)) {
|
||||||
return;
|
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)) {
|
if (!this.#isWorkspaceBackgroundTarget(rawTarget)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -789,6 +980,7 @@ export class BlueprintWorkspace {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
this.nodeContextMenu?.hide();
|
||||||
this.contextMenuManager.show(event.clientX, event.clientY);
|
this.contextMenuManager.show(event.clientX, event.clientY);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -921,7 +1113,7 @@ export class BlueprintWorkspace {
|
|||||||
this.clipboardManager?.clearPointerPosition();
|
this.clipboardManager?.clearPointerPosition();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.connectionLayer.addEventListener("pointerdown", (event) => {
|
this.workspaceElement.addEventListener("pointerdown", (event) => {
|
||||||
this.#handleConnectionPointerDown(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.
|
* Clears the current node selection.
|
||||||
*/
|
*/
|
||||||
@@ -1635,18 +1893,7 @@ export class BlueprintWorkspace {
|
|||||||
* @returns {VariableDefaultValue}
|
* @returns {VariableDefaultValue}
|
||||||
*/
|
*/
|
||||||
#defaultVariableValue(type) {
|
#defaultVariableValue(type) {
|
||||||
switch (type) {
|
return /** @type {VariableDefaultValue} */ (getDefaultVariableValue(type));
|
||||||
case "number":
|
|
||||||
return 0;
|
|
||||||
case "string":
|
|
||||||
return "";
|
|
||||||
case "table":
|
|
||||||
return [];
|
|
||||||
case "boolean":
|
|
||||||
return false;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1679,6 +1926,11 @@ export class BlueprintWorkspace {
|
|||||||
}
|
}
|
||||||
return 0;
|
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": {
|
case "boolean": {
|
||||||
if (typeof value === "boolean") {
|
if (typeof value === "boolean") {
|
||||||
return value;
|
return value;
|
||||||
@@ -2013,6 +2265,18 @@ export class BlueprintWorkspace {
|
|||||||
this.#setVariableDefault(variable.id, defaultToggle.checked);
|
this.#setVariableDefault(variable.id, defaultToggle.checked);
|
||||||
});
|
});
|
||||||
defaultField.appendChild(defaultToggle);
|
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") {
|
} else if (variable.type === "number") {
|
||||||
const defaultInput = document.createElement("input");
|
const defaultInput = document.createElement("input");
|
||||||
defaultInput.type = "number";
|
defaultInput.type = "number";
|
||||||
@@ -2487,6 +2751,7 @@ export class BlueprintWorkspace {
|
|||||||
option.type = "button";
|
option.type = "button";
|
||||||
option.className = "variable-type-option";
|
option.className = "variable-type-option";
|
||||||
option.dataset.variableType = entry.value;
|
option.dataset.variableType = entry.value;
|
||||||
|
option.style.setProperty("--overview-variable-color", getTypeColor(entry.value));
|
||||||
option.textContent = entry.label;
|
option.textContent = entry.label;
|
||||||
option.setAttribute("role", "menuitemradio");
|
option.setAttribute("role", "menuitemradio");
|
||||||
option.setAttribute(
|
option.setAttribute(
|
||||||
@@ -2908,12 +3173,8 @@ export class BlueprintWorkspace {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rect = workspaceElement.getBoundingClientRect();
|
// Use dragManager.clientToWorkspace for proper coordinate conversion
|
||||||
const screenPoint = {
|
const worldPoint = this.dragManager.clientToWorkspace(event.clientX, event.clientY);
|
||||||
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);
|
|
||||||
return this.addNode(shortcut.definitionId, worldPoint);
|
return this.addNode(shortcut.definitionId, worldPoint);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3286,9 +3547,7 @@ export class BlueprintWorkspace {
|
|||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
#formatVariableType(type) {
|
#formatVariableType(type) {
|
||||||
const entry = VARIABLE_TYPE_DEFINITIONS.find(
|
const entry = getVariableTypeOptions().find((opt) => opt.value === type);
|
||||||
(definition) => definition.value === type
|
|
||||||
);
|
|
||||||
return entry ? entry.label : "Any";
|
return entry ? entry.label : "Any";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3299,17 +3558,7 @@ export class BlueprintWorkspace {
|
|||||||
* @returns {VariableType}
|
* @returns {VariableType}
|
||||||
*/
|
*/
|
||||||
#normalizeVariableType(value) {
|
#normalizeVariableType(value) {
|
||||||
if (typeof value === "string") {
|
return /** @type {VariableType} */ (normalizeVariableType(value));
|
||||||
const normalized = value.trim().toLowerCase();
|
|
||||||
if (
|
|
||||||
SUPPORTED_VARIABLE_TYPES.has(
|
|
||||||
/** @type {VariableType} */ (normalized)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return /** @type {VariableType} */ (normalized);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "any";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -3318,7 +3567,7 @@ export class BlueprintWorkspace {
|
|||||||
* @returns {Array<{ value: VariableType, label: string }>}
|
* @returns {Array<{ value: VariableType, label: string }>}
|
||||||
*/
|
*/
|
||||||
#getVariableTypeOptions() {
|
#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";
|
node.type === "set_local_var" || node.type === "get_local_var";
|
||||||
article.classList.add("blueprint-node--variable");
|
article.classList.add("blueprint-node--variable");
|
||||||
article.dataset.variableType = binding.type;
|
article.dataset.variableType = binding.type;
|
||||||
|
article.style.setProperty("--overview-variable-color", getTypeColor(binding.type));
|
||||||
article.dataset.variableRole = role;
|
article.dataset.variableRole = role;
|
||||||
article.dataset.variableScope = isLocal ? "local" : "global";
|
article.dataset.variableScope = isLocal ? "local" : "global";
|
||||||
|
|
||||||
header.classList.add("node-header--variable");
|
header.classList.add("node-header--variable");
|
||||||
header.setAttribute("data-variable-role", role);
|
header.setAttribute("data-variable-role", role);
|
||||||
header.setAttribute("data-variable-type", binding.type);
|
header.setAttribute("data-variable-type", binding.type);
|
||||||
|
header.style.setProperty("--overview-variable-color", getTypeColor(binding.type));
|
||||||
header.setAttribute("data-variable-scope", isLocal ? "local" : "global");
|
header.setAttribute("data-variable-scope", isLocal ? "local" : "global");
|
||||||
const prefixBase = role === "set" ? "Set" : "Get";
|
const prefixBase = role === "set" ? "Set" : "Get";
|
||||||
const prefix = isLocal ? `${prefixBase} Local` : prefixBase;
|
const prefix = isLocal ? `${prefixBase} Local` : prefixBase;
|
||||||
@@ -4739,15 +4990,24 @@ export class BlueprintWorkspace {
|
|||||||
* @param {PointerEvent} event Pointer interaction payload.
|
* @param {PointerEvent} event Pointer interaction payload.
|
||||||
*/
|
*/
|
||||||
#handleConnectionPointerDown(event) {
|
#handleConnectionPointerDown(event) {
|
||||||
if (!(event.target instanceof SVGPathElement)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.button !== 0 || !event.altKey) {
|
if (event.button !== 0 || !event.altKey) {
|
||||||
return;
|
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;
|
const connectionId = path.dataset.connectionId;
|
||||||
if (!connectionId) {
|
if (!connectionId) {
|
||||||
return;
|
return;
|
||||||
@@ -4828,7 +5088,7 @@ export class BlueprintWorkspace {
|
|||||||
* Spawns a node at the requested context menu position.
|
* Spawns a node at the requested context menu position.
|
||||||
*
|
*
|
||||||
* @param {PaletteNodeDefinition} definition Node definition to instantiate.
|
* @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}
|
* @returns {import('../core/BlueprintNode.js').BlueprintNode | null}
|
||||||
*/
|
*/
|
||||||
spawnNodeFromContextMenu(definition, spawnPosition) {
|
spawnNodeFromContextMenu(definition, spawnPosition) {
|
||||||
@@ -4836,7 +5096,14 @@ export class BlueprintWorkspace {
|
|||||||
x: Math.max(0, spawnPosition.x),
|
x: Math.max(0, spawnPosition.x),
|
||||||
y: Math.max(0, spawnPosition.y),
|
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 spawn = WorkspaceGeometry.snapPositionToGrid(this, worldSpawn);
|
||||||
const pendingConnectionSnapshot = this.pendingConnectionSpawn
|
const pendingConnectionSnapshot = this.pendingConnectionSpawn
|
||||||
? {
|
? {
|
||||||
@@ -5179,23 +5446,20 @@ export class BlueprintWorkspace {
|
|||||||
const workspaceRect = this.workspaceElement.getBoundingClientRect();
|
const workspaceRect = this.workspaceElement.getBoundingClientRect();
|
||||||
const startRect = startHandle.getBoundingClientRect();
|
const startRect = startHandle.getBoundingClientRect();
|
||||||
const zoom = this.zoomLevel || 1;
|
const zoom = this.zoomLevel || 1;
|
||||||
|
const offset = this.navigationManager?.getEffectiveOffset() ?? this.workspaceBackgroundOffset ?? { x: 0, y: 0 };
|
||||||
const anchor = {
|
const anchor = {
|
||||||
x:
|
x: (startRect.left - workspaceRect.left + startRect.width / 2) / zoom - offset.x,
|
||||||
(startRect.left - workspaceRect.left + startRect.width / 2) /
|
y: (startRect.top - workspaceRect.top + startRect.height / 2) / zoom - offset.y,
|
||||||
zoom,
|
|
||||||
y:
|
|
||||||
(startRect.top - workspaceRect.top + startRect.height / 2) /
|
|
||||||
zoom,
|
|
||||||
};
|
};
|
||||||
const pointer = {
|
const pointer = {
|
||||||
x: (clientX - workspaceRect.left) / zoom,
|
x: (clientX - workspaceRect.left) / zoom - offset.x,
|
||||||
y: (clientY - workspaceRect.top) / zoom,
|
y: (clientY - workspaceRect.top) / zoom - offset.y,
|
||||||
};
|
};
|
||||||
|
|
||||||
const start = direction === "output" ? anchor : pointer;
|
const start = direction === "output" ? anchor : pointer;
|
||||||
const end = direction === "output" ? pointer : anchor;
|
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} ${
|
const d = `M ${start.x} ${start.y} C ${start.x + controlOffset} ${
|
||||||
start.y
|
start.y
|
||||||
} ${end.x - controlOffset} ${end.y} ${end.x} ${end.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");
|
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||||
path.classList.add("connection-path");
|
path.classList.add("connection-path");
|
||||||
path.dataset.type = kind;
|
path.dataset.type = kind;
|
||||||
|
path.style.stroke = getTypeColor(kind);
|
||||||
this.connectionLayer.appendChild(path);
|
this.connectionLayer.appendChild(path);
|
||||||
return 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.
|
* 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);
|
const targetHeight = Math.max(1, workspaceRect.height / zoom);
|
||||||
this.connectionLayer.style.width = `${targetWidth}px`;
|
this.connectionLayer.style.width = `${targetWidth}px`;
|
||||||
this.connectionLayer.style.height = `${targetHeight}px`;
|
this.connectionLayer.style.height = `${targetHeight}px`;
|
||||||
|
this.connectionLayer.setAttribute("overflow", "visible");
|
||||||
if (this.connectionLayer.hasAttribute("viewBox")) {
|
if (this.connectionLayer.hasAttribute("viewBox")) {
|
||||||
this.connectionLayer.removeAttribute("viewBox");
|
this.connectionLayer.removeAttribute("viewBox");
|
||||||
}
|
}
|
||||||
@@ -5452,6 +5795,7 @@ export class BlueprintWorkspace {
|
|||||||
this.connectionLayer.removeAttribute("preserveAspectRatio");
|
this.connectionLayer.removeAttribute("preserveAspectRatio");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const defs = this.#getOrCreateSvgDefs();
|
||||||
const activeIds = new Set();
|
const activeIds = new Set();
|
||||||
this.graph.getConnections().forEach((connection) => {
|
this.graph.getConnections().forEach((connection) => {
|
||||||
activeIds.add(connection.id);
|
activeIds.add(connection.id);
|
||||||
@@ -5461,7 +5805,6 @@ export class BlueprintWorkspace {
|
|||||||
this.connectionElements.set(connection.id, path);
|
this.connectionElements.set(connection.id, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
path.dataset.type = connection.kind;
|
|
||||||
path.dataset.connectionId = connection.id;
|
path.dataset.connectionId = connection.id;
|
||||||
const geometry = this.#computeConnectionGeometry(
|
const geometry = this.#computeConnectionGeometry(
|
||||||
connection.from,
|
connection.from,
|
||||||
@@ -5472,18 +5815,53 @@ export class BlueprintWorkspace {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { start, end } = geometry;
|
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} ${
|
const d = `M ${start.x} ${start.y} C ${start.x + controlOffset} ${
|
||||||
start.y
|
start.y
|
||||||
} ${end.x - controlOffset} ${end.y} ${end.x} ${end.y}`;
|
} ${end.x - controlOffset} ${end.y} ${end.x} ${end.y}`;
|
||||||
path.dataset.active = "false";
|
path.dataset.active = "false";
|
||||||
path.setAttribute("d", d);
|
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) => {
|
this.connectionElements.forEach((path, id) => {
|
||||||
if (!activeIds.has(id)) {
|
if (!activeIds.has(id)) {
|
||||||
path.remove();
|
path.remove();
|
||||||
this.connectionElements.delete(id);
|
this.connectionElements.delete(id);
|
||||||
|
defs.querySelector(`#cgrad-${id}`)?.remove();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -5504,22 +5882,17 @@ export class BlueprintWorkspace {
|
|||||||
|
|
||||||
const workspaceRect = this.workspaceElement.getBoundingClientRect();
|
const workspaceRect = this.workspaceElement.getBoundingClientRect();
|
||||||
const zoom = this.zoomLevel || 1;
|
const zoom = this.zoomLevel || 1;
|
||||||
|
const offset = this.navigationManager?.getEffectiveOffset() ?? this.workspaceBackgroundOffset ?? { x: 0, y: 0 };
|
||||||
const startRect = startHandle.getBoundingClientRect();
|
const startRect = startHandle.getBoundingClientRect();
|
||||||
const endRect = endHandle.getBoundingClientRect();
|
const endRect = endHandle.getBoundingClientRect();
|
||||||
return {
|
return {
|
||||||
start: {
|
start: {
|
||||||
x:
|
x: (startRect.left - workspaceRect.left + startRect.width / 2) / zoom - offset.x,
|
||||||
(startRect.left - workspaceRect.left + startRect.width / 2) /
|
y: (startRect.top - workspaceRect.top + startRect.height / 2) / zoom - offset.y,
|
||||||
zoom,
|
|
||||||
y:
|
|
||||||
(startRect.top - workspaceRect.top + startRect.height / 2) /
|
|
||||||
zoom,
|
|
||||||
},
|
},
|
||||||
end: {
|
end: {
|
||||||
x:
|
x: (endRect.left - workspaceRect.left + endRect.width / 2) / zoom - offset.x,
|
||||||
(endRect.left - workspaceRect.left + endRect.width / 2) / zoom,
|
y: (endRect.top - workspaceRect.top + endRect.height / 2) / zoom - offset.y,
|
||||||
y:
|
|
||||||
(endRect.top - workspaceRect.top + endRect.height / 2) / zoom,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -5560,14 +5933,11 @@ export class BlueprintWorkspace {
|
|||||||
const rect = handle.getBoundingClientRect();
|
const rect = handle.getBoundingClientRect();
|
||||||
const workspaceRect = this.workspaceElement.getBoundingClientRect();
|
const workspaceRect = this.workspaceElement.getBoundingClientRect();
|
||||||
const zoom = this.zoomLevel || 1;
|
const zoom = this.zoomLevel || 1;
|
||||||
|
const offset = this.navigationManager?.getEffectiveOffset() ?? this.workspaceBackgroundOffset ?? { x: 0, y: 0 };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
x:
|
x: (rect.left - workspaceRect.left + rect.width / 2) / zoom - offset.x,
|
||||||
(rect.left - workspaceRect.left + rect.width / 2) /
|
y: (rect.top - workspaceRect.top + rect.height / 2) / zoom - offset.y,
|
||||||
zoom,
|
|
||||||
y:
|
|
||||||
(rect.top - workspaceRect.top + rect.height / 2) /
|
|
||||||
zoom,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5767,10 +6137,7 @@ export class BlueprintWorkspace {
|
|||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
#arePinKindsCompatible(outputKind, inputKind) {
|
#arePinKindsCompatible(outputKind, inputKind) {
|
||||||
if (inputKind === "any" || outputKind === "any") {
|
return areTypesCompatible(outputKind, inputKind);
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return outputKind === inputKind;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -6078,12 +6445,14 @@ export class BlueprintWorkspace {
|
|||||||
row.className = "custom-event-parameter-row";
|
row.className = "custom-event-parameter-row";
|
||||||
row.dataset.parameterId = parameter.id;
|
row.dataset.parameterId = parameter.id;
|
||||||
row.dataset.parameterType = parameter.type;
|
row.dataset.parameterType = parameter.type;
|
||||||
|
row.style.setProperty("--overview-variable-color", getTypeColor(parameter.type));
|
||||||
|
|
||||||
const typeButton = document.createElement("button");
|
const typeButton = document.createElement("button");
|
||||||
typeButton.type = "button";
|
typeButton.type = "button";
|
||||||
typeButton.className =
|
typeButton.className =
|
||||||
"variable-type-button custom-event-parameter-type-button";
|
"variable-type-button custom-event-parameter-type-button";
|
||||||
typeButton.dataset.variableType = parameter.type;
|
typeButton.dataset.variableType = parameter.type;
|
||||||
|
typeButton.style.setProperty("--overview-variable-color", getTypeColor(parameter.type));
|
||||||
typeButton.setAttribute("aria-haspopup", "menu");
|
typeButton.setAttribute("aria-haspopup", "menu");
|
||||||
typeButton.setAttribute("aria-expanded", "false");
|
typeButton.setAttribute("aria-expanded", "false");
|
||||||
row.appendChild(typeButton);
|
row.appendChild(typeButton);
|
||||||
@@ -6099,7 +6468,9 @@ export class BlueprintWorkspace {
|
|||||||
const display = this.#formatVariableType(normalized);
|
const display = this.#formatVariableType(normalized);
|
||||||
const referenceName = nameInputElement.value.trim() || parameter.id;
|
const referenceName = nameInputElement.value.trim() || parameter.id;
|
||||||
row.dataset.parameterType = normalized;
|
row.dataset.parameterType = normalized;
|
||||||
|
row.style.setProperty("--overview-variable-color", getTypeColor(normalized));
|
||||||
typeButton.dataset.variableType = normalized;
|
typeButton.dataset.variableType = normalized;
|
||||||
|
typeButton.style.setProperty("--overview-variable-color", getTypeColor(normalized));
|
||||||
typeButton.title = `Type: ${display}`;
|
typeButton.title = `Type: ${display}`;
|
||||||
typeButton.setAttribute(
|
typeButton.setAttribute(
|
||||||
"aria-label",
|
"aria-label",
|
||||||
@@ -6356,10 +6727,12 @@ export class BlueprintWorkspace {
|
|||||||
*/
|
*/
|
||||||
applySerializedWorkspace(payload) {
|
applySerializedWorkspace(payload) {
|
||||||
this.isRestoring = true;
|
this.isRestoring = true;
|
||||||
|
this.#stopPeriodicUpdate();
|
||||||
try {
|
try {
|
||||||
this.persistenceManager.cancelScheduledPersist();
|
this.persistenceManager.cancelScheduledPersist();
|
||||||
|
|
||||||
this.contextMenuManager.hide();
|
this.contextMenuManager.hide();
|
||||||
|
this.nodeContextMenu?.hide();
|
||||||
this.#closeTypeDropdown();
|
this.#closeTypeDropdown();
|
||||||
this.dragManager.removePaletteGhost();
|
this.dragManager.removePaletteGhost();
|
||||||
this.dragManager.removeNodeGhost();
|
this.dragManager.removeNodeGhost();
|
||||||
@@ -6368,6 +6741,7 @@ export class BlueprintWorkspace {
|
|||||||
this.connectionElements.forEach((path) => path.remove());
|
this.connectionElements.forEach((path) => path.remove());
|
||||||
this.nodeElements.clear();
|
this.nodeElements.clear();
|
||||||
this.connectionElements.clear();
|
this.connectionElements.clear();
|
||||||
|
this.connectionLayer.querySelector("defs")?.remove();
|
||||||
this.inlineEditorManager.clear();
|
this.inlineEditorManager.clear();
|
||||||
this.nodeDragRotation.clear();
|
this.nodeDragRotation.clear();
|
||||||
this.dragManager.stopNodeRotationDecay();
|
this.dragManager.stopNodeRotationDecay();
|
||||||
@@ -6410,7 +6784,8 @@ export class BlueprintWorkspace {
|
|||||||
entry.type === "string" ||
|
entry.type === "string" ||
|
||||||
entry.type === "boolean" ||
|
entry.type === "boolean" ||
|
||||||
entry.type === "any" ||
|
entry.type === "any" ||
|
||||||
entry.type === "table"
|
entry.type === "table" ||
|
||||||
|
entry.type === "color"
|
||||||
? entry.type
|
? entry.type
|
||||||
: "any";
|
: "any";
|
||||||
const defaultValue = this.#normalizeVariableDefault(
|
const defaultValue = this.#normalizeVariableDefault(
|
||||||
@@ -6480,7 +6855,6 @@ export class BlueprintWorkspace {
|
|||||||
this.isPaletteVisible = paletteVisible;
|
this.isPaletteVisible = paletteVisible;
|
||||||
this.#applyPaletteVisibility(this.isPaletteVisible);
|
this.#applyPaletteVisibility(this.isPaletteVisible);
|
||||||
|
|
||||||
this.navigationManager.setBackgroundOffset({ x: 0, y: 0 });
|
|
||||||
this.#updateProjectSettingsToggle();
|
this.#updateProjectSettingsToggle();
|
||||||
this.#renderOverview();
|
this.#renderOverview();
|
||||||
this.#setInspectorView("default");
|
this.#setInspectorView("default");
|
||||||
@@ -6489,6 +6863,7 @@ export class BlueprintWorkspace {
|
|||||||
this.#refreshLuaOutput();
|
this.#refreshLuaOutput();
|
||||||
} finally {
|
} finally {
|
||||||
this.isRestoring = false;
|
this.isRestoring = false;
|
||||||
|
this.#startPeriodicUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
184
scripts/ui/ColorSwatchPicker.js
Normal file
184
scripts/ui/ColorSwatchPicker.js
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* Ordered PICO-8 palette hex strings, indices 0–15.
|
||||||
|
*
|
||||||
|
* @type {ReadonlyArray<string>}
|
||||||
|
*/
|
||||||
|
const PICO8_COLORS = Object.freeze([
|
||||||
|
'#000002', '#1D2B53', '#7E2553', '#008751',
|
||||||
|
'#AB5236', '#5F574F', '#C2C3C7', '#FFF1E8',
|
||||||
|
'#FF004D', '#FFA300', '#FFEC27', '#00E436',
|
||||||
|
'#29ADFF', '#83769C', '#FF77A8', '#FFCCAA',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {{ element: HTMLElement, setValue: (index: number) => void }} ColorSwatchPickerInstance
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds an inline PICO-8 color swatch picker control.
|
||||||
|
*
|
||||||
|
* Renders a colored trigger button that opens a 4×4 grid of all 16 PICO-8
|
||||||
|
* palette swatches. Clicking a swatch fires `onChange` with the selected
|
||||||
|
* palette index (0–15) and closes the dropdown.
|
||||||
|
*
|
||||||
|
* @param {{
|
||||||
|
* id?: string,
|
||||||
|
* value: number,
|
||||||
|
* onChange: (index: number) => void,
|
||||||
|
* }} options Picker configuration.
|
||||||
|
* @returns {ColorSwatchPickerInstance}
|
||||||
|
*/
|
||||||
|
export function createColorSwatchPicker({ id, value, onChange }) {
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'color-swatch-picker';
|
||||||
|
|
||||||
|
const trigger = document.createElement('button');
|
||||||
|
trigger.type = 'button';
|
||||||
|
trigger.className = 'color-swatch-trigger';
|
||||||
|
if (id) {
|
||||||
|
trigger.id = id;
|
||||||
|
}
|
||||||
|
trigger.setAttribute('aria-haspopup', 'menu');
|
||||||
|
trigger.setAttribute('aria-expanded', 'false');
|
||||||
|
wrapper.appendChild(trigger);
|
||||||
|
|
||||||
|
const dropdown = document.createElement('div');
|
||||||
|
dropdown.className = 'color-swatch-dropdown';
|
||||||
|
dropdown.setAttribute('role', 'menu');
|
||||||
|
// Appended to body on open so it escapes overflow:hidden on node cards.
|
||||||
|
|
||||||
|
/** @type {Array<HTMLButtonElement>} */
|
||||||
|
const swatchButtons = [];
|
||||||
|
|
||||||
|
/** @type {((event: MouseEvent) => void) | null} */
|
||||||
|
let outsideClickHandler = null;
|
||||||
|
|
||||||
|
/** @type {(() => void) | null} */
|
||||||
|
let scrollAbortHandler = null;
|
||||||
|
|
||||||
|
const repositionDropdown = () => {
|
||||||
|
const rect = trigger.getBoundingClientRect();
|
||||||
|
dropdown.style.top = `${rect.bottom + 4}px`;
|
||||||
|
dropdown.style.left = `${rect.left}px`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDropdown = () => {
|
||||||
|
if (dropdown.isConnected) {
|
||||||
|
dropdown.remove();
|
||||||
|
}
|
||||||
|
trigger.setAttribute('aria-expanded', 'false');
|
||||||
|
if (outsideClickHandler) {
|
||||||
|
document.removeEventListener('click', outsideClickHandler, true);
|
||||||
|
outsideClickHandler = null;
|
||||||
|
}
|
||||||
|
if (scrollAbortHandler) {
|
||||||
|
document.removeEventListener('scroll', scrollAbortHandler, true);
|
||||||
|
scrollAbortHandler = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDropdown = () => {
|
||||||
|
repositionDropdown();
|
||||||
|
document.body.appendChild(dropdown);
|
||||||
|
trigger.setAttribute('aria-expanded', 'true');
|
||||||
|
outsideClickHandler = (event) => {
|
||||||
|
if (
|
||||||
|
!(event.target instanceof Node) ||
|
||||||
|
(!wrapper.contains(event.target) && !dropdown.contains(event.target))
|
||||||
|
) {
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
scrollAbortHandler = () => closeDropdown();
|
||||||
|
document.addEventListener('click', outsideClickHandler, true);
|
||||||
|
document.addEventListener('scroll', scrollAbortHandler, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentIndex = clampColorIndex(value);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the trigger button to reflect the supplied palette index.
|
||||||
|
*
|
||||||
|
* @param {number} index Palette index (0–15).
|
||||||
|
*/
|
||||||
|
const updateTrigger = (index) => {
|
||||||
|
const hex = PICO8_COLORS[index] ?? '#000000';
|
||||||
|
trigger.style.backgroundColor = hex;
|
||||||
|
trigger.title = `Color ${index}`;
|
||||||
|
trigger.setAttribute('aria-label', `Color ${index}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
PICO8_COLORS.forEach((hex, index) => {
|
||||||
|
const swatch = document.createElement('button');
|
||||||
|
swatch.type = 'button';
|
||||||
|
swatch.className = 'color-swatch-option';
|
||||||
|
swatch.style.backgroundColor = hex;
|
||||||
|
swatch.dataset.colorIndex = String(index);
|
||||||
|
swatch.title = String(index);
|
||||||
|
swatch.setAttribute('role', 'menuitemradio');
|
||||||
|
swatch.setAttribute('aria-label', `Color ${index}`);
|
||||||
|
swatch.setAttribute('aria-checked', 'false');
|
||||||
|
|
||||||
|
swatch.addEventListener('pointerdown', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
});
|
||||||
|
swatch.addEventListener('click', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const selected = index;
|
||||||
|
closeDropdown();
|
||||||
|
if (selected !== currentIndex) {
|
||||||
|
currentIndex = selected;
|
||||||
|
updateTrigger(selected);
|
||||||
|
swatchButtons.forEach((btn, i) => {
|
||||||
|
btn.setAttribute('aria-checked', i === selected ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
onChange(selected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dropdown.appendChild(swatch);
|
||||||
|
swatchButtons.push(swatch);
|
||||||
|
});
|
||||||
|
|
||||||
|
trigger.addEventListener('pointerdown', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
});
|
||||||
|
trigger.addEventListener('click', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (dropdown.isConnected) {
|
||||||
|
closeDropdown();
|
||||||
|
} else {
|
||||||
|
openDropdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the picker display without firing `onChange`.
|
||||||
|
*
|
||||||
|
* @param {number} newIndex Palette index (0–15).
|
||||||
|
*/
|
||||||
|
const setValue = (newIndex) => {
|
||||||
|
currentIndex = clampColorIndex(newIndex);
|
||||||
|
updateTrigger(currentIndex);
|
||||||
|
swatchButtons.forEach((btn, i) => {
|
||||||
|
btn.setAttribute('aria-checked', i === currentIndex ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
setValue(currentIndex);
|
||||||
|
|
||||||
|
return { element: wrapper, setValue };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clamps an arbitrary value to a valid PICO-8 palette index (0–15).
|
||||||
|
*
|
||||||
|
* @param {unknown} value Raw index candidate.
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function clampColorIndex(value) {
|
||||||
|
const n = typeof value === 'number' ? value : Number(value);
|
||||||
|
const safe = Number.isFinite(n) ? Math.round(n) : 0;
|
||||||
|
return Math.max(0, Math.min(15, safe));
|
||||||
|
}
|
||||||
385
scripts/ui/EmbeddedPreview.js
Normal file
385
scripts/ui/EmbeddedPreview.js
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
/**
|
||||||
|
* Manages embedded Pico-8 cart preview within the application.
|
||||||
|
*/
|
||||||
|
export class EmbeddedPreview {
|
||||||
|
/**
|
||||||
|
* @param {object} options Configuration options
|
||||||
|
* @param {() => string} options.getLuaCode Function to get generated Lua code
|
||||||
|
*/
|
||||||
|
constructor(options) {
|
||||||
|
this.getLuaCode = options.getLuaCode;
|
||||||
|
this.modal = null;
|
||||||
|
this.canvas = null;
|
||||||
|
this.isOpen = false;
|
||||||
|
this.isRunning = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the preview modal if it doesn't exist
|
||||||
|
*/
|
||||||
|
#ensureModal() {
|
||||||
|
if (this.modal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create modal structure
|
||||||
|
this.modal = document.createElement('div');
|
||||||
|
this.modal.className = 'preview-modal';
|
||||||
|
this.modal.setAttribute('role', 'dialog');
|
||||||
|
this.modal.setAttribute('aria-modal', 'true');
|
||||||
|
this.modal.setAttribute('aria-labelledby', 'previewModalTitle');
|
||||||
|
this.modal.hidden = true;
|
||||||
|
|
||||||
|
const backdrop = document.createElement('div');
|
||||||
|
backdrop.className = 'preview-modal__backdrop';
|
||||||
|
backdrop.addEventListener('click', () => this.close());
|
||||||
|
|
||||||
|
const panel = document.createElement('div');
|
||||||
|
panel.className = 'preview-modal__panel';
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'preview-modal__header';
|
||||||
|
|
||||||
|
// Header controls (left side)
|
||||||
|
const headerControls = document.createElement('div');
|
||||||
|
headerControls.className = 'preview-header-controls';
|
||||||
|
|
||||||
|
const refreshButton = document.createElement('button');
|
||||||
|
refreshButton.type = 'button';
|
||||||
|
refreshButton.className = 'preview-header-btn';
|
||||||
|
refreshButton.setAttribute('aria-label', 'Reload cart');
|
||||||
|
refreshButton.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>';
|
||||||
|
refreshButton.addEventListener('click', () => this.#runCart());
|
||||||
|
|
||||||
|
const stopButton = document.createElement('button');
|
||||||
|
stopButton.type = 'button';
|
||||||
|
stopButton.className = 'preview-header-btn preview-header-btn--stop';
|
||||||
|
stopButton.setAttribute('aria-label', 'Stop cart');
|
||||||
|
stopButton.innerHTML = '<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><rect x="4" y="4" width="16" height="16" rx="2"/></svg>';
|
||||||
|
stopButton.addEventListener('click', () => this.#stopCart());
|
||||||
|
|
||||||
|
const saveButton = document.createElement('button');
|
||||||
|
saveButton.type = 'button';
|
||||||
|
saveButton.className = 'preview-header-btn preview-header-btn--save';
|
||||||
|
saveButton.setAttribute('aria-label', 'Save .p8');
|
||||||
|
saveButton.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
|
||||||
|
saveButton.addEventListener('click', () => this.#downloadCart());
|
||||||
|
|
||||||
|
headerControls.appendChild(refreshButton);
|
||||||
|
headerControls.appendChild(stopButton);
|
||||||
|
headerControls.appendChild(saveButton);
|
||||||
|
|
||||||
|
const closeButton = document.createElement('button');
|
||||||
|
closeButton.type = 'button';
|
||||||
|
closeButton.className = 'preview-modal__close';
|
||||||
|
closeButton.setAttribute('aria-label', 'Close preview');
|
||||||
|
closeButton.textContent = '×';
|
||||||
|
closeButton.addEventListener('click', () => this.close());
|
||||||
|
|
||||||
|
header.appendChild(headerControls);
|
||||||
|
header.appendChild(closeButton);
|
||||||
|
|
||||||
|
const content = document.createElement('div');
|
||||||
|
content.className = 'preview-modal__content';
|
||||||
|
|
||||||
|
// Create iframe for PICO-8 player
|
||||||
|
this.iframe = document.createElement('iframe');
|
||||||
|
this.iframe.className = 'preview-iframe';
|
||||||
|
this.iframe.allow = 'gamepad';
|
||||||
|
this.iframe.style.display = 'none';
|
||||||
|
|
||||||
|
// Create the canvas for rendering placeholder
|
||||||
|
this.canvas = document.createElement('canvas');
|
||||||
|
this.canvas.width = 128;
|
||||||
|
this.canvas.height = 128;
|
||||||
|
this.canvas.className = 'preview-canvas';
|
||||||
|
|
||||||
|
content.appendChild(this.iframe);
|
||||||
|
content.appendChild(this.canvas);
|
||||||
|
|
||||||
|
// Refocus iframe when clicking in the modal to ensure input keeps working
|
||||||
|
panel.addEventListener('click', (e) => {
|
||||||
|
if (this.iframe.style.display !== 'none') {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.iframe.focus();
|
||||||
|
if (this.iframe.contentWindow) {
|
||||||
|
this.iframe.contentWindow.focus();
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
panel.appendChild(header);
|
||||||
|
panel.appendChild(content);
|
||||||
|
|
||||||
|
this.modal.appendChild(backdrop);
|
||||||
|
this.modal.appendChild(panel);
|
||||||
|
document.body.appendChild(this.modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the cart in the embedded preview
|
||||||
|
*/
|
||||||
|
async #runCart() {
|
||||||
|
try {
|
||||||
|
const luaCode = this.getLuaCode();
|
||||||
|
|
||||||
|
if (!luaCode || !luaCode.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isRunning = true;
|
||||||
|
|
||||||
|
// Generate cart content
|
||||||
|
const cartContent = this.#generateCartContent(luaCode);
|
||||||
|
|
||||||
|
// Load cart in iframe using PICO-8 web player
|
||||||
|
await this.#loadCartInPlayer(cartContent);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error running cart:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a cart into the PICO-8 web player
|
||||||
|
*
|
||||||
|
* @param {string} cartData The .p8 cart file content as a string
|
||||||
|
*/
|
||||||
|
async #loadCartInPlayer(cartData) {
|
||||||
|
try {
|
||||||
|
const electronAPI = window.electronAPI;
|
||||||
|
if (!electronAPI?.writePlayerHtml) {
|
||||||
|
throw new Error('Player IPC not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cartB64 = btoa(unescape(encodeURIComponent(cartData)));
|
||||||
|
|
||||||
|
// HTML references pico8_edu.js by relative path — both files live in public/.
|
||||||
|
const playerHtml = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { background: #1a1a2e; display: flex; align-items: center; justify-content: center; width: 100vw; height: 100vh; overflow: hidden; }
|
||||||
|
#canvas { image-rendering: pixelated; image-rendering: crisp-edges; width: 512px !important; height: 512px !important; border: 8px solid #000; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<canvas id="canvas" width="128" height="128"></canvas>
|
||||||
|
<script>
|
||||||
|
var pico8_state = [];
|
||||||
|
var pico8_buttons = [0,0,0,0,0,0,0,0];
|
||||||
|
var codo_command = 0;
|
||||||
|
var _cartname = ['untitled.p8'];
|
||||||
|
var codo_key_buffer = [];
|
||||||
|
var p8_keyboard_state = 0;
|
||||||
|
var pico8_mouse = [];
|
||||||
|
var pico8_gamepads = { count: 0 };
|
||||||
|
var pico8_gpio = new Array(128).fill(0);
|
||||||
|
var p8_dropped_cart = null;
|
||||||
|
var p8_dropped_cart_name = '';
|
||||||
|
|
||||||
|
var CART_B64 = '${cartB64}';
|
||||||
|
|
||||||
|
// Silence all XHR — PICO-8 makes BBS metadata calls that crash from file:// origin.
|
||||||
|
(function() {
|
||||||
|
function MockXHR() {
|
||||||
|
this.onload = null; this.onerror = null; this.onreadystatechange = null;
|
||||||
|
this.readyState = 0; this.status = 0; this.statusText = '';
|
||||||
|
this.response = null; this.responseText = ''; this.responseType = '';
|
||||||
|
}
|
||||||
|
MockXHR.UNSENT=0; MockXHR.OPENED=1; MockXHR.HEADERS_RECEIVED=2; MockXHR.LOADING=3; MockXHR.DONE=4;
|
||||||
|
MockXHR.prototype.open = function() { this.readyState = 1; };
|
||||||
|
MockXHR.prototype.send = function() {
|
||||||
|
var self = this;
|
||||||
|
setTimeout(function() {
|
||||||
|
self.readyState = 4; self.status = 200; self.statusText = 'OK';
|
||||||
|
self.response = self.responseType === 'arraybuffer' ? new ArrayBuffer(0) : '';
|
||||||
|
self.responseText = '';
|
||||||
|
if (typeof self.onload === 'function') self.onload({ target: self });
|
||||||
|
if (typeof self.onreadystatechange === 'function') self.onreadystatechange();
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
MockXHR.prototype.setRequestHeader = function() {};
|
||||||
|
MockXHR.prototype.getResponseHeader = function() { return null; };
|
||||||
|
MockXHR.prototype.getAllResponseHeaders = function() { return ''; };
|
||||||
|
MockXHR.prototype.abort = function() {};
|
||||||
|
MockXHR.prototype.addEventListener = function(e, fn) {
|
||||||
|
if (e==='load') this.onload=fn;
|
||||||
|
if (e==='error') this.onerror=fn;
|
||||||
|
if (e==='readystatechange') this.onreadystatechange=fn;
|
||||||
|
};
|
||||||
|
MockXHR.prototype.removeEventListener = function() {};
|
||||||
|
window.XMLHttpRequest = MockXHR;
|
||||||
|
})();
|
||||||
|
|
||||||
|
var Module = {
|
||||||
|
arguments: [],
|
||||||
|
canvas: document.getElementById('canvas'),
|
||||||
|
preRun: [function() {
|
||||||
|
if (typeof IDBFS !== 'undefined' && typeof MEMFS !== 'undefined') {
|
||||||
|
var orig = FS.mount.bind(FS);
|
||||||
|
FS.mount = function(type, opts, mp) { return orig(type===IDBFS?MEMFS:type, opts, mp); };
|
||||||
|
}
|
||||||
|
FS.syncfs = function(populate, callback) {
|
||||||
|
var cb = typeof callback==='function' ? callback : typeof populate==='function' ? populate : null;
|
||||||
|
if (cb) setTimeout(function(){ cb(null); }, 0);
|
||||||
|
};
|
||||||
|
}],
|
||||||
|
postRun: [function() {
|
||||||
|
setTimeout(function() {
|
||||||
|
p8_dropped_cart = 'data:application/octet-stream;base64,' + CART_B64;
|
||||||
|
p8_dropped_cart_name = 'picograph.p8';
|
||||||
|
codo_command = 9;
|
||||||
|
|
||||||
|
// After cart loads, inject "run" + Enter to execute it
|
||||||
|
setTimeout(function() {
|
||||||
|
// Push keystrokes: r u n Enter (lowercase ASCII)
|
||||||
|
codo_key_buffer.push(114, 117, 110, 13);
|
||||||
|
}, 300);
|
||||||
|
}, 200);
|
||||||
|
}],
|
||||||
|
print: function(t) { console.log('P8:', t); },
|
||||||
|
printErr: function(t) { console.error('P8:', t); },
|
||||||
|
setStatus: function() {}
|
||||||
|
};
|
||||||
|
|
||||||
|
function pico8_audio_context_suspended() { return false; }
|
||||||
|
</script>
|
||||||
|
<script src="./pico8_edu.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
const { filePath } = await electronAPI.writePlayerHtml(playerHtml);
|
||||||
|
const fileUrl = 'file:///' + filePath.replace(/\\/g, '/') + '?t=' + Date.now();
|
||||||
|
|
||||||
|
this.iframe.src = 'about:blank';
|
||||||
|
this.iframe.src = fileUrl;
|
||||||
|
this.iframe.style.display = 'block';
|
||||||
|
this.canvas.style.display = 'none';
|
||||||
|
|
||||||
|
// Focus iframe once loaded so it captures keyboard/gamepad input
|
||||||
|
this.iframe.onload = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.iframe.focus();
|
||||||
|
if (this.iframe.contentWindow) {
|
||||||
|
this.iframe.contentWindow.focus();
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load player:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the running cart
|
||||||
|
*/
|
||||||
|
#stopCart() {
|
||||||
|
this.isRunning = false;
|
||||||
|
|
||||||
|
// Hide iframe and show canvas
|
||||||
|
if (this.iframe) {
|
||||||
|
this.iframe.src = 'about:blank';
|
||||||
|
this.iframe.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.canvas) {
|
||||||
|
this.canvas.style.display = 'block';
|
||||||
|
const ctx = this.canvas.getContext('2d');
|
||||||
|
if (ctx) {
|
||||||
|
ctx.fillStyle = '#000';
|
||||||
|
ctx.fillRect(0, 0, 128, 128);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads the current cart as a .p8 file
|
||||||
|
*/
|
||||||
|
#downloadCart() {
|
||||||
|
try {
|
||||||
|
const luaCode = this.getLuaCode();
|
||||||
|
const cartContent = this.#generateCartContent(luaCode);
|
||||||
|
|
||||||
|
const blob = new Blob([cartContent], { type: 'text/plain' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `picograph_${Date.now()}.p8`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error downloading cart:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the .p8 cart file content
|
||||||
|
*
|
||||||
|
* @param {string} luaCode Generated Lua code
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
#generateCartContent(luaCode) {
|
||||||
|
const hasUpdateCallback = /function\s+_update(?:60)?\b|_update(?:60)?\s*=/.test(luaCode);
|
||||||
|
const hasDrawCallback = /function\s+_draw\b|_draw\s*=/.test(luaCode);
|
||||||
|
const fallback = (hasUpdateCallback && hasDrawCallback) ? '' :
|
||||||
|
(!hasUpdateCallback ? '\nfunction _update()\nend\n' : '') +
|
||||||
|
(!hasDrawCallback ? '\nfunction _draw()\n cls()\n print("picograph ok",2,60,7)\nend\n' : '');
|
||||||
|
const content = `pico-8 cartridge // http://www.pico-8.com
|
||||||
|
version 41
|
||||||
|
__lua__
|
||||||
|
${luaCode}${fallback}
|
||||||
|
__gfx__
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
`;
|
||||||
|
// Ensure Unix line endings (PICO-8 tools expect \n, not \r\n)
|
||||||
|
return content.replace(/\r\n/g, '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the preview modal
|
||||||
|
*/
|
||||||
|
open() {
|
||||||
|
this.#ensureModal();
|
||||||
|
this.modal.hidden = false;
|
||||||
|
this.isOpen = true;
|
||||||
|
|
||||||
|
// Auto-run the cart
|
||||||
|
this.#runCart();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the preview modal
|
||||||
|
*/
|
||||||
|
close() {
|
||||||
|
this.#stopCart();
|
||||||
|
if (this.modal) {
|
||||||
|
this.modal.hidden = true;
|
||||||
|
this.isOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles the preview modal
|
||||||
|
*/
|
||||||
|
toggle() {
|
||||||
|
if (this.isOpen) {
|
||||||
|
this.close();
|
||||||
|
} else {
|
||||||
|
this.open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
102
scripts/ui/PreviewManager.js
Normal file
102
scripts/ui/PreviewManager.js
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { spawn } from 'child_process';
|
||||||
|
import { writeFile } from 'fs/promises';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages cart preview functionality
|
||||||
|
*/
|
||||||
|
export class PreviewManager {
|
||||||
|
/**
|
||||||
|
* @param {object} options Configuration options
|
||||||
|
* @param {() => string} options.getLuaCode Function to get generated Lua code
|
||||||
|
* @param {string} options.fake08Path Path to fake-08 executable
|
||||||
|
*/
|
||||||
|
constructor(options) {
|
||||||
|
this.getLuaCode = options.getLuaCode;
|
||||||
|
this.fake08Path = options.fake08Path || null;
|
||||||
|
this.currentProcess = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exports the current graph as a .p8 file
|
||||||
|
*
|
||||||
|
* @param {string} outputPath Path to save the .p8 file
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async exportCart(outputPath) {
|
||||||
|
const luaCode = this.getLuaCode();
|
||||||
|
|
||||||
|
// Create basic .p8 cart format
|
||||||
|
const cartContent = `pico-8 cartridge // http://www.pico-8.com
|
||||||
|
version 41
|
||||||
|
__lua__
|
||||||
|
${luaCode}
|
||||||
|
__gfx__
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
`;
|
||||||
|
|
||||||
|
await writeFile(outputPath, cartContent, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launches the preview with fake-08
|
||||||
|
*
|
||||||
|
* @param {string} cartPath Path to the .p8 file
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async launchPreview(cartPath) {
|
||||||
|
if (!this.fake08Path) {
|
||||||
|
throw new Error('fake-08 path not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kill existing preview if running
|
||||||
|
if (this.currentProcess) {
|
||||||
|
this.currentProcess.kill();
|
||||||
|
this.currentProcess = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.currentProcess = spawn(this.fake08Path, [cartPath]);
|
||||||
|
|
||||||
|
this.currentProcess.on('error', (err) => {
|
||||||
|
console.error('Failed to launch preview:', err);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.currentProcess.on('exit', (code) => {
|
||||||
|
console.log(`Preview exited with code ${code}`);
|
||||||
|
this.currentProcess = null;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exports and previews the current cart
|
||||||
|
*
|
||||||
|
* @param {string} [tempPath] Optional path for temporary cart file
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async exportAndPreview(tempPath = null) {
|
||||||
|
const outputPath = tempPath || join(process.cwd(), 'temp_preview.p8');
|
||||||
|
|
||||||
|
await this.exportCart(outputPath);
|
||||||
|
|
||||||
|
if (this.fake08Path) {
|
||||||
|
await this.launchPreview(outputPath);
|
||||||
|
} else {
|
||||||
|
console.log(`Cart exported to: ${outputPath}`);
|
||||||
|
console.log('fake-08 not configured - cannot launch preview');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops any running preview
|
||||||
|
*/
|
||||||
|
stopPreview() {
|
||||||
|
if (this.currentProcess) {
|
||||||
|
this.currentProcess.kill();
|
||||||
|
this.currentProcess = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
622
scripts/ui/SpriteEditor.js
Normal file
622
scripts/ui/SpriteEditor.js
Normal file
@@ -0,0 +1,622 @@
|
|||||||
|
import { SpriteSheet } from "../core/SpriteSheet.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interactive sprite sheet editor for PICO-8.
|
||||||
|
*/
|
||||||
|
export class SpriteEditor {
|
||||||
|
/**
|
||||||
|
* @param {HTMLElement} container Parent element for the editor.
|
||||||
|
* @param {SpriteSheet} [spriteSheet] Existing sprite sheet or creates new one.
|
||||||
|
*/
|
||||||
|
constructor(container, spriteSheet = null) {
|
||||||
|
this.container = container;
|
||||||
|
this.spriteSheet = spriteSheet || new SpriteSheet();
|
||||||
|
|
||||||
|
/** @type {number} Currently selected color (0-15) */
|
||||||
|
this.selectedColor = 7;
|
||||||
|
|
||||||
|
/** @type {number} Currently selected sprite (0-255) */
|
||||||
|
this.selectedSprite = 0;
|
||||||
|
|
||||||
|
/** @type {string} Current tool: "pen", "fill", "line", "rect", "circle", "erase" */
|
||||||
|
this.currentTool = "pen";
|
||||||
|
|
||||||
|
/** @type {number} Zoom level for sprite sheet canvas */
|
||||||
|
this.sheetZoom = 2;
|
||||||
|
|
||||||
|
/** @type {number} Zoom level for sprite preview */
|
||||||
|
this.previewZoom = 8;
|
||||||
|
|
||||||
|
/** @type {Object|null} Drawing state for line/rect tools */
|
||||||
|
this.drawStart = null;
|
||||||
|
|
||||||
|
this.#createUI();
|
||||||
|
this.#attachEvents();
|
||||||
|
this.#render();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the UI structure.
|
||||||
|
*/
|
||||||
|
#createUI() {
|
||||||
|
this.container.innerHTML = "";
|
||||||
|
this.container.className = "sprite-editor";
|
||||||
|
|
||||||
|
// Toolbar
|
||||||
|
const toolbar = document.createElement("div");
|
||||||
|
toolbar.className = "sprite-toolbar";
|
||||||
|
|
||||||
|
const tools = [
|
||||||
|
{ id: "pen", label: "Pen", icon: "✏️" },
|
||||||
|
{ id: "fill", label: "Fill", icon: "🪣" },
|
||||||
|
{ id: "line", label: "Line", icon: "📏" },
|
||||||
|
{ id: "rect", label: "Rect", icon: "⬜" },
|
||||||
|
{ id: "circle", label: "Circle", icon: "⭕" },
|
||||||
|
{ id: "erase", label: "Erase", icon: "🧹" }
|
||||||
|
];
|
||||||
|
|
||||||
|
tools.forEach((tool) => {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.className = "sprite-tool-btn";
|
||||||
|
btn.dataset.tool = tool.id;
|
||||||
|
btn.title = tool.label;
|
||||||
|
btn.textContent = tool.icon;
|
||||||
|
if (tool.id === this.currentTool) {
|
||||||
|
btn.classList.add("active");
|
||||||
|
}
|
||||||
|
toolbar.appendChild(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.container.appendChild(toolbar);
|
||||||
|
|
||||||
|
// Main content area
|
||||||
|
const content = document.createElement("div");
|
||||||
|
content.className = "sprite-content";
|
||||||
|
|
||||||
|
// Left panel: Sprite sheet
|
||||||
|
const leftPanel = document.createElement("div");
|
||||||
|
leftPanel.className = "sprite-panel sprite-sheet-panel";
|
||||||
|
|
||||||
|
const sheetLabel = document.createElement("div");
|
||||||
|
sheetLabel.className = "sprite-panel-label";
|
||||||
|
sheetLabel.textContent = "Sprite Sheet (128x128)";
|
||||||
|
leftPanel.appendChild(sheetLabel);
|
||||||
|
|
||||||
|
this.sheetCanvas = document.createElement("canvas");
|
||||||
|
this.sheetCanvas.className = "sprite-sheet-canvas";
|
||||||
|
this.sheetCanvas.width = 128 * this.sheetZoom;
|
||||||
|
this.sheetCanvas.height = 128 * this.sheetZoom;
|
||||||
|
this.sheetCtx = this.sheetCanvas.getContext("2d", { alpha: false });
|
||||||
|
this.sheetCtx.imageSmoothingEnabled = false;
|
||||||
|
leftPanel.appendChild(this.sheetCanvas);
|
||||||
|
|
||||||
|
content.appendChild(leftPanel);
|
||||||
|
|
||||||
|
// Right panel: Preview and palette
|
||||||
|
const rightPanel = document.createElement("div");
|
||||||
|
rightPanel.className = "sprite-panel sprite-controls-panel";
|
||||||
|
|
||||||
|
// Sprite preview
|
||||||
|
const previewContainer = document.createElement("div");
|
||||||
|
previewContainer.className = "sprite-preview-container";
|
||||||
|
|
||||||
|
const previewLabel = document.createElement("div");
|
||||||
|
previewLabel.className = "sprite-panel-label";
|
||||||
|
previewLabel.textContent = "Sprite Preview";
|
||||||
|
previewContainer.appendChild(previewLabel);
|
||||||
|
|
||||||
|
this.previewCanvas = document.createElement("canvas");
|
||||||
|
this.previewCanvas.className = "sprite-preview-canvas";
|
||||||
|
this.previewCanvas.width = 8 * this.previewZoom;
|
||||||
|
this.previewCanvas.height = 8 * this.previewZoom;
|
||||||
|
this.previewCtx = this.previewCanvas.getContext("2d", { alpha: false });
|
||||||
|
this.previewCtx.imageSmoothingEnabled = false;
|
||||||
|
previewContainer.appendChild(this.previewCanvas);
|
||||||
|
|
||||||
|
this.spriteIndexLabel = document.createElement("div");
|
||||||
|
this.spriteIndexLabel.className = "sprite-index-label";
|
||||||
|
this.spriteIndexLabel.textContent = `Sprite: ${this.selectedSprite}`;
|
||||||
|
previewContainer.appendChild(this.spriteIndexLabel);
|
||||||
|
|
||||||
|
rightPanel.appendChild(previewContainer);
|
||||||
|
|
||||||
|
// Color palette
|
||||||
|
const paletteContainer = document.createElement("div");
|
||||||
|
paletteContainer.className = "sprite-palette-container";
|
||||||
|
|
||||||
|
const paletteLabel = document.createElement("div");
|
||||||
|
paletteLabel.className = "sprite-panel-label";
|
||||||
|
paletteLabel.textContent = "Palette";
|
||||||
|
paletteContainer.appendChild(paletteLabel);
|
||||||
|
|
||||||
|
this.paletteElement = document.createElement("div");
|
||||||
|
this.paletteElement.className = "sprite-palette";
|
||||||
|
|
||||||
|
this.spriteSheet.palette.forEach((color, index) => {
|
||||||
|
const swatch = document.createElement("div");
|
||||||
|
swatch.className = "sprite-palette-swatch";
|
||||||
|
swatch.style.backgroundColor = color;
|
||||||
|
swatch.dataset.color = index;
|
||||||
|
swatch.title = `Color ${index}`;
|
||||||
|
if (index === this.selectedColor) {
|
||||||
|
swatch.classList.add("selected");
|
||||||
|
}
|
||||||
|
this.paletteElement.appendChild(swatch);
|
||||||
|
});
|
||||||
|
|
||||||
|
paletteContainer.appendChild(this.paletteElement);
|
||||||
|
rightPanel.appendChild(paletteContainer);
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const actionsContainer = document.createElement("div");
|
||||||
|
actionsContainer.className = "sprite-actions";
|
||||||
|
|
||||||
|
const clearBtn = document.createElement("button");
|
||||||
|
clearBtn.className = "sprite-action-btn";
|
||||||
|
clearBtn.textContent = "Clear Sprite";
|
||||||
|
clearBtn.dataset.action = "clear-sprite";
|
||||||
|
actionsContainer.appendChild(clearBtn);
|
||||||
|
|
||||||
|
const clearAllBtn = document.createElement("button");
|
||||||
|
clearAllBtn.className = "sprite-action-btn";
|
||||||
|
clearAllBtn.textContent = "Clear All";
|
||||||
|
clearAllBtn.dataset.action = "clear-all";
|
||||||
|
actionsContainer.appendChild(clearAllBtn);
|
||||||
|
|
||||||
|
rightPanel.appendChild(actionsContainer);
|
||||||
|
|
||||||
|
content.appendChild(rightPanel);
|
||||||
|
this.container.appendChild(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attaches event listeners.
|
||||||
|
*/
|
||||||
|
#attachEvents() {
|
||||||
|
// Tool selection
|
||||||
|
this.container.querySelectorAll(".sprite-tool-btn").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", (e) => {
|
||||||
|
this.currentTool = btn.dataset.tool;
|
||||||
|
this.container.querySelectorAll(".sprite-tool-btn").forEach((b) => {
|
||||||
|
b.classList.remove("active");
|
||||||
|
});
|
||||||
|
btn.classList.add("active");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Color palette
|
||||||
|
this.paletteElement.querySelectorAll(".sprite-palette-swatch").forEach((swatch) => {
|
||||||
|
swatch.addEventListener("click", (e) => {
|
||||||
|
this.selectedColor = parseInt(swatch.dataset.color);
|
||||||
|
this.paletteElement.querySelectorAll(".sprite-palette-swatch").forEach((s) => {
|
||||||
|
s.classList.remove("selected");
|
||||||
|
});
|
||||||
|
swatch.classList.add("selected");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
this.container.querySelectorAll(".sprite-action-btn").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", (e) => {
|
||||||
|
const action = btn.dataset.action;
|
||||||
|
if (action === "clear-sprite") {
|
||||||
|
this.#clearCurrentSprite();
|
||||||
|
} else if (action === "clear-all") {
|
||||||
|
if (confirm("Clear all sprites? This cannot be undone.")) {
|
||||||
|
this.spriteSheet.clear();
|
||||||
|
this.#render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sheet canvas interactions
|
||||||
|
let isDrawing = false;
|
||||||
|
|
||||||
|
this.sheetCanvas.addEventListener("mousedown", (e) => {
|
||||||
|
isDrawing = true;
|
||||||
|
const coords = this.#getSheetCoords(e);
|
||||||
|
this.#handleToolStart(coords);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.sheetCanvas.addEventListener("mousemove", (e) => {
|
||||||
|
if (!isDrawing) return;
|
||||||
|
const coords = this.#getSheetCoords(e);
|
||||||
|
this.#handleToolMove(coords);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.sheetCanvas.addEventListener("mouseup", (e) => {
|
||||||
|
if (!isDrawing) return;
|
||||||
|
isDrawing = false;
|
||||||
|
const coords = this.#getSheetCoords(e);
|
||||||
|
this.#handleToolEnd(coords);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.sheetCanvas.addEventListener("mouseleave", () => {
|
||||||
|
isDrawing = false;
|
||||||
|
this.drawStart = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sprite selection
|
||||||
|
this.sheetCanvas.addEventListener("click", (e) => {
|
||||||
|
const coords = this.#getSheetCoords(e);
|
||||||
|
const spriteX = Math.floor(coords.x / 8);
|
||||||
|
const spriteY = Math.floor(coords.y / 8);
|
||||||
|
this.selectedSprite = spriteY * 16 + spriteX;
|
||||||
|
this.spriteIndexLabel.textContent = `Sprite: ${this.selectedSprite}`;
|
||||||
|
this.#renderPreview();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Preview canvas editing
|
||||||
|
let isDrawingPreview = false;
|
||||||
|
|
||||||
|
this.previewCanvas.addEventListener("mousedown", (e) => {
|
||||||
|
isDrawingPreview = true;
|
||||||
|
const coords = this.#getPreviewCoords(e);
|
||||||
|
this.#drawPixelInPreview(coords);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.previewCanvas.addEventListener("mousemove", (e) => {
|
||||||
|
if (!isDrawingPreview) return;
|
||||||
|
const coords = this.#getPreviewCoords(e);
|
||||||
|
this.#drawPixelInPreview(coords);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.previewCanvas.addEventListener("mouseup", () => {
|
||||||
|
isDrawingPreview = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.previewCanvas.addEventListener("mouseleave", () => {
|
||||||
|
isDrawingPreview = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets sheet coordinates from mouse event.
|
||||||
|
*
|
||||||
|
* @param {MouseEvent} e Mouse event.
|
||||||
|
* @returns {{x: number, y: number}}
|
||||||
|
*/
|
||||||
|
#getSheetCoords(e) {
|
||||||
|
const rect = this.sheetCanvas.getBoundingClientRect();
|
||||||
|
const x = Math.floor((e.clientX - rect.left) / this.sheetZoom);
|
||||||
|
const y = Math.floor((e.clientY - rect.top) / this.sheetZoom);
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets preview coordinates from mouse event.
|
||||||
|
*
|
||||||
|
* @param {MouseEvent} e Mouse event.
|
||||||
|
* @returns {{x: number, y: number}}
|
||||||
|
*/
|
||||||
|
#getPreviewCoords(e) {
|
||||||
|
const rect = this.previewCanvas.getBoundingClientRect();
|
||||||
|
const x = Math.floor((e.clientX - rect.left) / this.previewZoom);
|
||||||
|
const y = Math.floor((e.clientY - rect.top) / this.previewZoom);
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles tool start.
|
||||||
|
*
|
||||||
|
* @param {{x: number, y: number}} coords Coordinates.
|
||||||
|
*/
|
||||||
|
#handleToolStart(coords) {
|
||||||
|
if (this.currentTool === "pen") {
|
||||||
|
this.#drawPixel(coords);
|
||||||
|
} else if (this.currentTool === "erase") {
|
||||||
|
this.#erasePixel(coords);
|
||||||
|
} else if (this.currentTool === "fill") {
|
||||||
|
this.#floodFill(coords);
|
||||||
|
} else if (["line", "rect", "circle"].includes(this.currentTool)) {
|
||||||
|
this.drawStart = coords;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles tool move.
|
||||||
|
*
|
||||||
|
* @param {{x: number, y: number}} coords Coordinates.
|
||||||
|
*/
|
||||||
|
#handleToolMove(coords) {
|
||||||
|
if (this.currentTool === "pen") {
|
||||||
|
this.#drawPixel(coords);
|
||||||
|
} else if (this.currentTool === "erase") {
|
||||||
|
this.#erasePixel(coords);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles tool end.
|
||||||
|
*
|
||||||
|
* @param {{x: number, y: number}} coords Coordinates.
|
||||||
|
*/
|
||||||
|
#handleToolEnd(coords) {
|
||||||
|
if (!this.drawStart) return;
|
||||||
|
|
||||||
|
if (this.currentTool === "line") {
|
||||||
|
this.#drawLine(this.drawStart, coords);
|
||||||
|
} else if (this.currentTool === "rect") {
|
||||||
|
this.#drawRect(this.drawStart, coords);
|
||||||
|
} else if (this.currentTool === "circle") {
|
||||||
|
this.#drawCircle(this.drawStart, coords);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.drawStart = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws a pixel.
|
||||||
|
*
|
||||||
|
* @param {{x: number, y: number}} coords Coordinates.
|
||||||
|
*/
|
||||||
|
#drawPixel(coords) {
|
||||||
|
this.spriteSheet.setPixel(coords.x, coords.y, this.selectedColor);
|
||||||
|
this.#render();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erases a pixel.
|
||||||
|
*
|
||||||
|
* @param {{x: number, y: number}} coords Coordinates.
|
||||||
|
*/
|
||||||
|
#erasePixel(coords) {
|
||||||
|
this.spriteSheet.setPixel(coords.x, coords.y, 0);
|
||||||
|
this.#render();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flood fill from a point.
|
||||||
|
*
|
||||||
|
* @param {{x: number, y: number}} coords Starting coordinates.
|
||||||
|
*/
|
||||||
|
#floodFill(coords) {
|
||||||
|
const targetColor = this.spriteSheet.getPixel(coords.x, coords.y);
|
||||||
|
if (targetColor === this.selectedColor) return;
|
||||||
|
|
||||||
|
const stack = [coords];
|
||||||
|
const visited = new Set();
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const { x, y } = stack.pop();
|
||||||
|
const key = `${x},${y}`;
|
||||||
|
|
||||||
|
if (visited.has(key)) continue;
|
||||||
|
if (x < 0 || x >= 128 || y < 0 || y >= 128) continue;
|
||||||
|
if (this.spriteSheet.getPixel(x, y) !== targetColor) continue;
|
||||||
|
|
||||||
|
visited.add(key);
|
||||||
|
this.spriteSheet.setPixel(x, y, this.selectedColor);
|
||||||
|
|
||||||
|
stack.push({ x: x + 1, y });
|
||||||
|
stack.push({ x: x - 1, y });
|
||||||
|
stack.push({ x, y: y + 1 });
|
||||||
|
stack.push({ x, y: y - 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#render();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws a line.
|
||||||
|
*
|
||||||
|
* @param {{x: number, y: number}} start Start coordinates.
|
||||||
|
* @param {{x: number, y: number}} end End coordinates.
|
||||||
|
*/
|
||||||
|
#drawLine(start, end) {
|
||||||
|
const dx = Math.abs(end.x - start.x);
|
||||||
|
const dy = Math.abs(end.y - start.y);
|
||||||
|
const sx = start.x < end.x ? 1 : -1;
|
||||||
|
const sy = start.y < end.y ? 1 : -1;
|
||||||
|
let err = dx - dy;
|
||||||
|
|
||||||
|
let x = start.x;
|
||||||
|
let y = start.y;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
this.spriteSheet.setPixel(x, y, this.selectedColor);
|
||||||
|
|
||||||
|
if (x === end.x && y === end.y) break;
|
||||||
|
|
||||||
|
const e2 = 2 * err;
|
||||||
|
if (e2 > -dy) {
|
||||||
|
err -= dy;
|
||||||
|
x += sx;
|
||||||
|
}
|
||||||
|
if (e2 < dx) {
|
||||||
|
err += dx;
|
||||||
|
y += sy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#render();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws a rectangle.
|
||||||
|
*
|
||||||
|
* @param {{x: number, y: number}} start Start coordinates.
|
||||||
|
* @param {{x: number, y: number}} end End coordinates.
|
||||||
|
*/
|
||||||
|
#drawRect(start, end) {
|
||||||
|
const x1 = Math.min(start.x, end.x);
|
||||||
|
const y1 = Math.min(start.y, end.y);
|
||||||
|
const x2 = Math.max(start.x, end.x);
|
||||||
|
const y2 = Math.max(start.y, end.y);
|
||||||
|
|
||||||
|
for (let x = x1; x <= x2; x++) {
|
||||||
|
this.spriteSheet.setPixel(x, y1, this.selectedColor);
|
||||||
|
this.spriteSheet.setPixel(x, y2, this.selectedColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let y = y1; y <= y2; y++) {
|
||||||
|
this.spriteSheet.setPixel(x1, y, this.selectedColor);
|
||||||
|
this.spriteSheet.setPixel(x2, y, this.selectedColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#render();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws a circle.
|
||||||
|
*
|
||||||
|
* @param {{x: number, y: number}} center Center coordinates.
|
||||||
|
* @param {{x: number, y: number}} edge Edge point.
|
||||||
|
*/
|
||||||
|
#drawCircle(center, edge) {
|
||||||
|
const radius = Math.round(
|
||||||
|
Math.sqrt((edge.x - center.x) ** 2 + (edge.y - center.y) ** 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
let x = radius;
|
||||||
|
let y = 0;
|
||||||
|
let err = 0;
|
||||||
|
|
||||||
|
while (x >= y) {
|
||||||
|
this.spriteSheet.setPixel(center.x + x, center.y + y, this.selectedColor);
|
||||||
|
this.spriteSheet.setPixel(center.x + y, center.y + x, this.selectedColor);
|
||||||
|
this.spriteSheet.setPixel(center.x - y, center.y + x, this.selectedColor);
|
||||||
|
this.spriteSheet.setPixel(center.x - x, center.y + y, this.selectedColor);
|
||||||
|
this.spriteSheet.setPixel(center.x - x, center.y - y, this.selectedColor);
|
||||||
|
this.spriteSheet.setPixel(center.x - y, center.y - x, this.selectedColor);
|
||||||
|
this.spriteSheet.setPixel(center.x + y, center.y - x, this.selectedColor);
|
||||||
|
this.spriteSheet.setPixel(center.x + x, center.y - y, this.selectedColor);
|
||||||
|
|
||||||
|
y += 1;
|
||||||
|
err += 1 + 2 * y;
|
||||||
|
if (2 * (err - x) + 1 > 0) {
|
||||||
|
x -= 1;
|
||||||
|
err += 1 - 2 * x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#render();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws a pixel in the preview canvas.
|
||||||
|
*
|
||||||
|
* @param {{x: number, y: number}} coords Coordinates (0-7).
|
||||||
|
*/
|
||||||
|
#drawPixelInPreview(coords) {
|
||||||
|
if (coords.x < 0 || coords.x >= 8 || coords.y < 0 || coords.y >= 8) return;
|
||||||
|
|
||||||
|
const sx = (this.selectedSprite % 16) * 8;
|
||||||
|
const sy = Math.floor(this.selectedSprite / 16) * 8;
|
||||||
|
|
||||||
|
const color = this.currentTool === "erase" ? 0 : this.selectedColor;
|
||||||
|
this.spriteSheet.setPixel(sx + coords.x, sy + coords.y, color);
|
||||||
|
|
||||||
|
this.#render();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the currently selected sprite.
|
||||||
|
*/
|
||||||
|
#clearCurrentSprite() {
|
||||||
|
const clearData = new Uint8Array(64);
|
||||||
|
this.spriteSheet.setSprite(this.selectedSprite, clearData);
|
||||||
|
this.#render();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders all canvases.
|
||||||
|
*/
|
||||||
|
#render() {
|
||||||
|
this.#renderSheet();
|
||||||
|
this.#renderPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the sprite sheet canvas.
|
||||||
|
*/
|
||||||
|
#renderSheet() {
|
||||||
|
for (let y = 0; y < 128; y++) {
|
||||||
|
for (let x = 0; x < 128; x++) {
|
||||||
|
const color = this.spriteSheet.getPixel(x, y);
|
||||||
|
this.sheetCtx.fillStyle = this.spriteSheet.palette[color];
|
||||||
|
this.sheetCtx.fillRect(
|
||||||
|
x * this.sheetZoom,
|
||||||
|
y * this.sheetZoom,
|
||||||
|
this.sheetZoom,
|
||||||
|
this.sheetZoom
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw grid
|
||||||
|
this.sheetCtx.strokeStyle = "rgba(255, 255, 255, 0.2)";
|
||||||
|
this.sheetCtx.lineWidth = 1;
|
||||||
|
|
||||||
|
for (let i = 0; i <= 16; i++) {
|
||||||
|
const pos = i * 8 * this.sheetZoom;
|
||||||
|
this.sheetCtx.beginPath();
|
||||||
|
this.sheetCtx.moveTo(pos, 0);
|
||||||
|
this.sheetCtx.lineTo(pos, 128 * this.sheetZoom);
|
||||||
|
this.sheetCtx.stroke();
|
||||||
|
|
||||||
|
this.sheetCtx.beginPath();
|
||||||
|
this.sheetCtx.moveTo(0, pos);
|
||||||
|
this.sheetCtx.lineTo(128 * this.sheetZoom, pos);
|
||||||
|
this.sheetCtx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the sprite preview canvas.
|
||||||
|
*/
|
||||||
|
#renderPreview() {
|
||||||
|
const sx = (this.selectedSprite % 16) * 8;
|
||||||
|
const sy = Math.floor(this.selectedSprite / 16) * 8;
|
||||||
|
|
||||||
|
for (let y = 0; y < 8; y++) {
|
||||||
|
for (let x = 0; x < 8; x++) {
|
||||||
|
const color = this.spriteSheet.getPixel(sx + x, sy + y);
|
||||||
|
this.previewCtx.fillStyle = this.spriteSheet.palette[color];
|
||||||
|
this.previewCtx.fillRect(
|
||||||
|
x * this.previewZoom,
|
||||||
|
y * this.previewZoom,
|
||||||
|
this.previewZoom,
|
||||||
|
this.previewZoom
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw grid
|
||||||
|
this.previewCtx.strokeStyle = "rgba(255, 255, 255, 0.3)";
|
||||||
|
this.previewCtx.lineWidth = 1;
|
||||||
|
|
||||||
|
for (let i = 0; i <= 8; i++) {
|
||||||
|
const pos = i * this.previewZoom;
|
||||||
|
this.previewCtx.beginPath();
|
||||||
|
this.previewCtx.moveTo(pos, 0);
|
||||||
|
this.previewCtx.lineTo(pos, 8 * this.previewZoom);
|
||||||
|
this.previewCtx.stroke();
|
||||||
|
|
||||||
|
this.previewCtx.beginPath();
|
||||||
|
this.previewCtx.moveTo(0, pos);
|
||||||
|
this.previewCtx.lineTo(8 * this.previewZoom, pos);
|
||||||
|
this.previewCtx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the sprite sheet data.
|
||||||
|
*
|
||||||
|
* @returns {SpriteSheet}
|
||||||
|
*/
|
||||||
|
getSpriteSheet() {
|
||||||
|
return this.spriteSheet;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets sprite sheet data.
|
||||||
|
*
|
||||||
|
* @param {SpriteSheet} spriteSheet New sprite sheet.
|
||||||
|
*/
|
||||||
|
setSpriteSheet(spriteSheet) {
|
||||||
|
this.spriteSheet = spriteSheet;
|
||||||
|
this.#render();
|
||||||
|
}
|
||||||
|
}
|
||||||
205
scripts/ui/SpriteEditorManager.js
Normal file
205
scripts/ui/SpriteEditorManager.js
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { SpriteEditor } from './SpriteEditor.js';
|
||||||
|
import { SpriteSheet } from '../core/SpriteSheet.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the sprite editor modal integration.
|
||||||
|
*/
|
||||||
|
export class SpriteEditorManager {
|
||||||
|
/**
|
||||||
|
* @param {HTMLElement} modal Modal element.
|
||||||
|
* @param {HTMLElement} container Container for sprite editor.
|
||||||
|
*/
|
||||||
|
constructor(modal, container) {
|
||||||
|
this.modal = modal;
|
||||||
|
this.container = container;
|
||||||
|
this.spriteSheet = new SpriteSheet();
|
||||||
|
this.editor = null;
|
||||||
|
this.isOpen = false;
|
||||||
|
|
||||||
|
this.#setupModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up modal event listeners.
|
||||||
|
*/
|
||||||
|
#setupModal() {
|
||||||
|
// Close modal on backdrop click
|
||||||
|
const backdrop = this.modal.querySelector('.modal__backdrop');
|
||||||
|
backdrop?.addEventListener('click', () => this.close());
|
||||||
|
|
||||||
|
// Close modal on close button click
|
||||||
|
const closeButtons = this.modal.querySelectorAll('[data-modal-close]');
|
||||||
|
closeButtons.forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => this.close());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export to .p8
|
||||||
|
const exportBtn = document.getElementById('exportP8Button');
|
||||||
|
exportBtn?.addEventListener('click', () => this.#exportToP8());
|
||||||
|
|
||||||
|
// Import from .p8
|
||||||
|
const importBtn = document.getElementById('importP8Button');
|
||||||
|
importBtn?.addEventListener('click', () => this.#importFromP8());
|
||||||
|
|
||||||
|
// Initialize editor on first open
|
||||||
|
this.modal.addEventListener('transitionend', () => {
|
||||||
|
if (this.isOpen && !this.editor) {
|
||||||
|
this.#initializeEditor();
|
||||||
|
}
|
||||||
|
}, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the sprite editor.
|
||||||
|
*/
|
||||||
|
#initializeEditor() {
|
||||||
|
if (this.editor) return;
|
||||||
|
|
||||||
|
this.editor = new SpriteEditor(this.container, this.spriteSheet);
|
||||||
|
console.log('Sprite editor initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the sprite editor modal.
|
||||||
|
*/
|
||||||
|
open() {
|
||||||
|
if (this.isOpen) return;
|
||||||
|
|
||||||
|
this.isOpen = true;
|
||||||
|
this.modal.removeAttribute('hidden');
|
||||||
|
this.modal.setAttribute('aria-hidden', 'false');
|
||||||
|
document.body.classList.add('modal-open');
|
||||||
|
|
||||||
|
// Initialize editor immediately if not already done
|
||||||
|
if (!this.editor) {
|
||||||
|
this.#initializeEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus trap
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const closeBtn = this.modal.querySelector('[data-modal-close]');
|
||||||
|
closeBtn?.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the sprite editor modal.
|
||||||
|
*/
|
||||||
|
close() {
|
||||||
|
if (!this.isOpen) return;
|
||||||
|
|
||||||
|
this.isOpen = false;
|
||||||
|
this.modal.setAttribute('hidden', '');
|
||||||
|
this.modal.setAttribute('aria-hidden', 'true');
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exports sprite data to .p8 format.
|
||||||
|
*/
|
||||||
|
#exportToP8() {
|
||||||
|
if (!this.editor) return;
|
||||||
|
|
||||||
|
const gfxData = this.editor.getSpriteSheet().toP8Gfx();
|
||||||
|
const flagData = this.editor.getSpriteSheet().toP8Flags();
|
||||||
|
|
||||||
|
const p8Content = `pico-8 cartridge // http://www.pico-8.com
|
||||||
|
version 41
|
||||||
|
__lua__
|
||||||
|
-- generated by picoGraph
|
||||||
|
-- paste your blueprinted code here
|
||||||
|
|
||||||
|
__gfx__
|
||||||
|
${gfxData}
|
||||||
|
__gff__
|
||||||
|
${flagData}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const blob = new Blob([p8Content], { type: 'text/plain' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'sprites.p8';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
// Show success feedback
|
||||||
|
const exportBtn = document.getElementById('exportP8Button');
|
||||||
|
if (exportBtn) {
|
||||||
|
exportBtn.classList.add('is-success');
|
||||||
|
setTimeout(() => {
|
||||||
|
exportBtn.classList.remove('is-success');
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports sprite data from .p8 format.
|
||||||
|
*/
|
||||||
|
#importFromP8() {
|
||||||
|
if (!this.editor) return;
|
||||||
|
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = '.p8';
|
||||||
|
|
||||||
|
input.addEventListener('change', async (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
|
||||||
|
// Extract __gfx__ section
|
||||||
|
const gfxMatch = text.match(/__gfx__\s*\n([\s\S]*?)(?=\n__|$)/);
|
||||||
|
if (gfxMatch) {
|
||||||
|
this.editor.getSpriteSheet().fromP8Gfx(gfxMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract __gff__ section
|
||||||
|
const gffMatch = text.match(/__gff__\s*\n([\s\S]*?)(?=\n__|$)/);
|
||||||
|
if (gffMatch) {
|
||||||
|
this.editor.getSpriteSheet().fromP8Flags(gffMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-render
|
||||||
|
this.editor.setSpriteSheet(this.editor.getSpriteSheet());
|
||||||
|
|
||||||
|
// Show success feedback
|
||||||
|
const importBtn = document.getElementById('importP8Button');
|
||||||
|
if (importBtn) {
|
||||||
|
importBtn.classList.add('is-success');
|
||||||
|
setTimeout(() => {
|
||||||
|
importBtn.classList.remove('is-success');
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to import sprite data:', error);
|
||||||
|
alert('Failed to import sprite data. Please ensure the file is a valid .p8 file.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the current sprite sheet data.
|
||||||
|
*
|
||||||
|
* @returns {SpriteSheet}
|
||||||
|
*/
|
||||||
|
getSpriteSheet() {
|
||||||
|
return this.spriteSheet;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets sprite sheet data.
|
||||||
|
*
|
||||||
|
* @param {SpriteSheet} spriteSheet New sprite sheet.
|
||||||
|
*/
|
||||||
|
setSpriteSheet(spriteSheet) {
|
||||||
|
this.spriteSheet = spriteSheet;
|
||||||
|
if (this.editor) {
|
||||||
|
this.editor.setSpriteSheet(spriteSheet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
114
scripts/ui/TileManager.js
Normal file
114
scripts/ui/TileManager.js
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { TileSetBrowser } from "./TileSetBrowser.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the tile browser modal and provides access to tileset management.
|
||||||
|
*/
|
||||||
|
export class TileManager {
|
||||||
|
constructor() {
|
||||||
|
this.modal = null;
|
||||||
|
this.browser = null;
|
||||||
|
this.isOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the tile manager.
|
||||||
|
*/
|
||||||
|
initialize() {
|
||||||
|
this.modal = document.getElementById("tileModal");
|
||||||
|
if (!this.modal) {
|
||||||
|
console.error("Tile modal element not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = this.modal.querySelector(".tile-browser-container");
|
||||||
|
if (!container) {
|
||||||
|
console.error("Tile browser container not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.browser = new TileSetBrowser(container);
|
||||||
|
|
||||||
|
const closeBtn = this.modal.querySelector(".close-modal");
|
||||||
|
if (closeBtn) {
|
||||||
|
closeBtn.addEventListener("click", () => this.close());
|
||||||
|
}
|
||||||
|
|
||||||
|
this.modal.addEventListener("click", (e) => {
|
||||||
|
if (e.target === this.modal) {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape" && this.isOpen) {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const tileBtn = document.getElementById("tileManagerButton");
|
||||||
|
if (tileBtn) {
|
||||||
|
tileBtn.addEventListener("click", () => this.open());
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#loadFromLocalStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the tile manager modal.
|
||||||
|
*/
|
||||||
|
open() {
|
||||||
|
if (!this.modal) return;
|
||||||
|
this.modal.style.display = "flex";
|
||||||
|
this.isOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the tile manager modal.
|
||||||
|
*/
|
||||||
|
close() {
|
||||||
|
if (!this.modal) return;
|
||||||
|
this.modal.style.display = "none";
|
||||||
|
this.isOpen = false;
|
||||||
|
this.#saveToLocalStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all tilesets.
|
||||||
|
*
|
||||||
|
* @returns {Array<TileSet>}
|
||||||
|
*/
|
||||||
|
getTileSets() {
|
||||||
|
return this.browser ? this.browser.getTileSets() : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves tileset data to localStorage.
|
||||||
|
*/
|
||||||
|
#saveToLocalStorage() {
|
||||||
|
if (!this.browser) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = this.browser.toJSON();
|
||||||
|
localStorage.setItem("picograph_tilesets", JSON.stringify(data));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save tilesets:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads tileset data from localStorage.
|
||||||
|
*/
|
||||||
|
async #loadFromLocalStorage() {
|
||||||
|
if (!this.browser) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem("picograph_tilesets");
|
||||||
|
if (stored) {
|
||||||
|
const data = JSON.parse(stored);
|
||||||
|
await this.browser.fromJSON(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load tilesets:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
511
scripts/ui/TileSetBrowser.js
Normal file
511
scripts/ui/TileSetBrowser.js
Normal file
@@ -0,0 +1,511 @@
|
|||||||
|
import { TileSet } from "../core/TileSet.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages multiple tilesets (8x8 tiles in 128x128px sheets) and provides UI for browsing and editing externally.
|
||||||
|
*/
|
||||||
|
export class TileSetBrowser {
|
||||||
|
/**
|
||||||
|
* @param {HTMLElement} container Parent element for the browser.
|
||||||
|
*/
|
||||||
|
constructor(container) {
|
||||||
|
this.container = container;
|
||||||
|
|
||||||
|
/** @type {Map<string, TileSet>} */
|
||||||
|
this.tileSets = new Map();
|
||||||
|
|
||||||
|
/** @type {string | null} */
|
||||||
|
this.selectedTileSetId = null;
|
||||||
|
|
||||||
|
/** @type {number | null} */
|
||||||
|
this.selectedTileIndex = null;
|
||||||
|
|
||||||
|
this.#createUI();
|
||||||
|
this.#attachEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the UI structure.
|
||||||
|
*/
|
||||||
|
#createUI() {
|
||||||
|
this.container.innerHTML = "";
|
||||||
|
this.container.className = "tileset-browser";
|
||||||
|
|
||||||
|
// Header with actions
|
||||||
|
const header = document.createElement("div");
|
||||||
|
header.className = "tileset-header";
|
||||||
|
|
||||||
|
const title = document.createElement("div");
|
||||||
|
title.className = "tileset-title";
|
||||||
|
title.textContent = "Tilesets";
|
||||||
|
header.appendChild(title);
|
||||||
|
|
||||||
|
const actions = document.createElement("div");
|
||||||
|
actions.className = "tileset-actions";
|
||||||
|
|
||||||
|
const addBtn = document.createElement("button");
|
||||||
|
addBtn.className = "tileset-action-btn";
|
||||||
|
addBtn.textContent = "+ Add Tileset";
|
||||||
|
addBtn.dataset.action = "add";
|
||||||
|
actions.appendChild(addBtn);
|
||||||
|
|
||||||
|
header.appendChild(actions);
|
||||||
|
this.container.appendChild(header);
|
||||||
|
|
||||||
|
// Tileset list
|
||||||
|
this.tileSetList = document.createElement("div");
|
||||||
|
this.tileSetList.className = "tileset-list";
|
||||||
|
this.container.appendChild(this.tileSetList);
|
||||||
|
|
||||||
|
// Tile preview panel
|
||||||
|
this.previewPanel = document.createElement("div");
|
||||||
|
this.previewPanel.className = "tileset-preview-panel";
|
||||||
|
this.previewPanel.innerHTML = `
|
||||||
|
<div class="tileset-preview-header">
|
||||||
|
<span class="tileset-preview-title">Tile Preview</span>
|
||||||
|
</div>
|
||||||
|
<div class="tileset-preview-content">
|
||||||
|
<canvas class="tileset-preview-canvas"></canvas>
|
||||||
|
<div class="tileset-preview-info">Select a tileset to preview</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
this.container.appendChild(this.previewPanel);
|
||||||
|
|
||||||
|
this.previewCanvas = this.previewPanel.querySelector(".tileset-preview-canvas");
|
||||||
|
this.previewCtx = this.previewCanvas.getContext("2d", { alpha: false });
|
||||||
|
this.previewInfo = this.previewPanel.querySelector(".tileset-preview-info");
|
||||||
|
|
||||||
|
this.#renderEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attaches event listeners.
|
||||||
|
*/
|
||||||
|
#attachEvents() {
|
||||||
|
// Add tileset button
|
||||||
|
this.container.addEventListener("click", (e) => {
|
||||||
|
const target = e.target;
|
||||||
|
if (!(target instanceof HTMLElement)) return;
|
||||||
|
|
||||||
|
const actionBtn = target.closest("[data-action]");
|
||||||
|
if (actionBtn) {
|
||||||
|
const action = actionBtn.dataset.action;
|
||||||
|
|
||||||
|
if (action === "add") {
|
||||||
|
this.#addTileSet();
|
||||||
|
} else if (action === "open") {
|
||||||
|
const id = actionBtn.dataset.id;
|
||||||
|
if (id) this.#openExternal(id);
|
||||||
|
} else if (action === "reload") {
|
||||||
|
const id = actionBtn.dataset.id;
|
||||||
|
if (id) this.#reloadTileSet(id);
|
||||||
|
} else if (action === "remove") {
|
||||||
|
const id = actionBtn.dataset.id;
|
||||||
|
if (id) this.#removeTileSet(id);
|
||||||
|
} else if (action === "edit-settings") {
|
||||||
|
const id = actionBtn.dataset.id;
|
||||||
|
if (id) this.#editTileSetSettings(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tileset selection
|
||||||
|
const tilesetItem = target.closest(".tileset-item");
|
||||||
|
if (tilesetItem && tilesetItem.dataset.id) {
|
||||||
|
this.#selectTileSet(tilesetItem.dataset.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Canvas tile selection
|
||||||
|
this.previewCanvas.addEventListener("click", (e) => {
|
||||||
|
if (!this.selectedTileSetId) return;
|
||||||
|
|
||||||
|
const tileSet = this.tileSets.get(this.selectedTileSetId);
|
||||||
|
if (!tileSet || !tileSet.isLoaded) return;
|
||||||
|
|
||||||
|
const rect = this.previewCanvas.getBoundingClientRect();
|
||||||
|
const x = Math.floor((e.clientX - rect.left) / (this.previewCanvas.width / tileSet.columns));
|
||||||
|
const y = Math.floor((e.clientY - rect.top) / (this.previewCanvas.height / tileSet.rows));
|
||||||
|
|
||||||
|
this.selectedTileIndex = y * tileSet.columns + x;
|
||||||
|
this.#renderPreview();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a new tileset.
|
||||||
|
*/
|
||||||
|
async #addTileSet() {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "file";
|
||||||
|
input.accept = "image/png";
|
||||||
|
|
||||||
|
input.addEventListener("change", async (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const name = file.name.replace(/\.[^/.]+$/, "");
|
||||||
|
|
||||||
|
let filePath;
|
||||||
|
let nativePath = "";
|
||||||
|
|
||||||
|
// If running in Electron, save the file to app data
|
||||||
|
if (window.electronAPI?.saveTileset) {
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const uint8Array = new Uint8Array(arrayBuffer);
|
||||||
|
const result = await window.electronAPI.saveTileset(uint8Array, file.name);
|
||||||
|
nativePath = result.filePath;
|
||||||
|
// Convert to file:// URL for loading in Image element
|
||||||
|
filePath = `file:///${result.filePath.replace(/\\/g, "/")}`;
|
||||||
|
} else {
|
||||||
|
// Fallback for non-Electron environments (dev/testing)
|
||||||
|
filePath = URL.createObjectURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tileSet = new TileSet({
|
||||||
|
name,
|
||||||
|
filePath,
|
||||||
|
nativePath,
|
||||||
|
tileWidth: 8,
|
||||||
|
tileHeight: 8
|
||||||
|
});
|
||||||
|
|
||||||
|
await tileSet.load();
|
||||||
|
|
||||||
|
this.tileSets.set(tileSet.id, tileSet);
|
||||||
|
this.#render();
|
||||||
|
this.#selectTileSet(tileSet.id);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to add tileset:", error);
|
||||||
|
alert(`Failed to load tileset:\n${error.message || error}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens tileset in external editor.
|
||||||
|
*
|
||||||
|
* @param {string} id Tileset ID.
|
||||||
|
*/
|
||||||
|
#openExternal(id) {
|
||||||
|
const tileSet = this.tileSets.get(id);
|
||||||
|
if (!tileSet) return;
|
||||||
|
|
||||||
|
// In Electron, show the file in explorer so user can open with their preferred editor
|
||||||
|
if (window.electronAPI?.showTilesetInFolder && tileSet.nativePath) {
|
||||||
|
window.electronAPI.showTilesetInFolder(tileSet.nativePath);
|
||||||
|
} else {
|
||||||
|
// Fallback: show message
|
||||||
|
const pathToShow = tileSet.nativePath || tileSet.filePath;
|
||||||
|
alert(`To edit this tileset, open:\n${pathToShow}\n\nIn your image editor of choice.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reloads a tileset from disk.
|
||||||
|
*
|
||||||
|
* @param {string} id Tileset ID.
|
||||||
|
*/
|
||||||
|
async #reloadTileSet(id) {
|
||||||
|
const tileSet = this.tileSets.get(id);
|
||||||
|
if (!tileSet) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await tileSet.reload();
|
||||||
|
this.#render();
|
||||||
|
if (this.selectedTileSetId === id) {
|
||||||
|
this.#renderPreview();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to reload tileset:", error);
|
||||||
|
alert(`Failed to reload tileset:\n${error.message || error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a tileset.
|
||||||
|
*
|
||||||
|
* @param {string} id Tileset ID.
|
||||||
|
*/
|
||||||
|
#removeTileSet(id) {
|
||||||
|
if (!confirm("Remove this tileset?")) return;
|
||||||
|
|
||||||
|
this.tileSets.delete(id);
|
||||||
|
|
||||||
|
if (this.selectedTileSetId === id) {
|
||||||
|
this.selectedTileSetId = null;
|
||||||
|
this.selectedTileIndex = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#render();
|
||||||
|
this.#renderPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edits tileset settings.
|
||||||
|
*
|
||||||
|
* @param {string} id Tileset ID.
|
||||||
|
*/
|
||||||
|
#editTileSetSettings(id) {
|
||||||
|
const tileSet = this.tileSets.get(id);
|
||||||
|
if (!tileSet) return;
|
||||||
|
|
||||||
|
const name = prompt("Tileset name:", tileSet.name);
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
tileSet.name = name;
|
||||||
|
this.#render();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selects a tileset.
|
||||||
|
*
|
||||||
|
* @param {string} id Tileset ID.
|
||||||
|
*/
|
||||||
|
#selectTileSet(id) {
|
||||||
|
this.selectedTileSetId = id;
|
||||||
|
this.selectedTileIndex = null;
|
||||||
|
this.#render();
|
||||||
|
this.#renderPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the tileset list.
|
||||||
|
*/
|
||||||
|
#render() {
|
||||||
|
if (this.tileSets.size === 0) {
|
||||||
|
this.#renderEmpty();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tileSetList.innerHTML = "";
|
||||||
|
|
||||||
|
this.tileSets.forEach((tileSet) => {
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.className = "tileset-item";
|
||||||
|
item.dataset.id = tileSet.id;
|
||||||
|
|
||||||
|
if (this.selectedTileSetId === tileSet.id) {
|
||||||
|
item.classList.add("selected");
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview = document.createElement("div");
|
||||||
|
preview.className = "tileset-item-preview";
|
||||||
|
|
||||||
|
if (tileSet.isLoaded && tileSet.image) {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = 64;
|
||||||
|
canvas.height = 64;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
ctx.imageSmoothingEnabled = false;
|
||||||
|
|
||||||
|
// Draw scaled down version
|
||||||
|
const scale = Math.min(64 / tileSet.image.width, 64 / tileSet.image.height);
|
||||||
|
const w = tileSet.image.width * scale;
|
||||||
|
const h = tileSet.image.height * scale;
|
||||||
|
ctx.drawImage(tileSet.image, 0, 0, w, h);
|
||||||
|
|
||||||
|
preview.appendChild(canvas);
|
||||||
|
} else {
|
||||||
|
preview.textContent = "?";
|
||||||
|
}
|
||||||
|
|
||||||
|
item.appendChild(preview);
|
||||||
|
|
||||||
|
const info = document.createElement("div");
|
||||||
|
info.className = "tileset-item-info";
|
||||||
|
|
||||||
|
const nameEl = document.createElement("div");
|
||||||
|
nameEl.className = "tileset-item-name";
|
||||||
|
nameEl.textContent = tileSet.name;
|
||||||
|
info.appendChild(nameEl);
|
||||||
|
|
||||||
|
const meta = document.createElement("div");
|
||||||
|
meta.className = "tileset-item-meta";
|
||||||
|
meta.textContent = `128×128px • ${tileSet.getTileCount()} tiles (${tileSet.tileWidth}×${tileSet.tileHeight}px each)`;
|
||||||
|
info.appendChild(meta);
|
||||||
|
|
||||||
|
item.appendChild(info);
|
||||||
|
|
||||||
|
const actions = document.createElement("div");
|
||||||
|
actions.className = "tileset-item-actions";
|
||||||
|
|
||||||
|
const openBtn = document.createElement("button");
|
||||||
|
openBtn.className = "tileset-item-btn";
|
||||||
|
openBtn.title = "Open in external editor";
|
||||||
|
openBtn.dataset.action = "open";
|
||||||
|
openBtn.dataset.id = tileSet.id;
|
||||||
|
openBtn.textContent = "✏️";
|
||||||
|
actions.appendChild(openBtn);
|
||||||
|
|
||||||
|
const reloadBtn = document.createElement("button");
|
||||||
|
reloadBtn.className = "tileset-item-btn";
|
||||||
|
reloadBtn.title = "Reload from disk";
|
||||||
|
reloadBtn.dataset.action = "reload";
|
||||||
|
reloadBtn.dataset.id = tileSet.id;
|
||||||
|
reloadBtn.textContent = "🔄";
|
||||||
|
actions.appendChild(reloadBtn);
|
||||||
|
|
||||||
|
const settingsBtn = document.createElement("button");
|
||||||
|
settingsBtn.className = "tileset-item-btn";
|
||||||
|
settingsBtn.title = "Settings";
|
||||||
|
settingsBtn.dataset.action = "edit-settings";
|
||||||
|
settingsBtn.dataset.id = tileSet.id;
|
||||||
|
settingsBtn.textContent = "⚙️";
|
||||||
|
actions.appendChild(settingsBtn);
|
||||||
|
|
||||||
|
const removeBtn = document.createElement("button");
|
||||||
|
removeBtn.className = "tileset-item-btn tileset-item-btn--danger";
|
||||||
|
removeBtn.title = "Remove tileset";
|
||||||
|
removeBtn.dataset.action = "remove";
|
||||||
|
removeBtn.dataset.id = tileSet.id;
|
||||||
|
removeBtn.textContent = "🗑️";
|
||||||
|
actions.appendChild(removeBtn);
|
||||||
|
|
||||||
|
item.appendChild(actions);
|
||||||
|
|
||||||
|
this.tileSetList.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the preview panel.
|
||||||
|
*/
|
||||||
|
#renderPreview() {
|
||||||
|
if (!this.selectedTileSetId) {
|
||||||
|
this.previewInfo.textContent = "Select a tileset to preview";
|
||||||
|
this.previewInfo.style.display = "block";
|
||||||
|
this.previewCanvas.style.display = "none";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tileSet = this.tileSets.get(this.selectedTileSetId);
|
||||||
|
if (!tileSet || !tileSet.isLoaded || !tileSet.image) {
|
||||||
|
this.previewInfo.textContent = "Tileset not loaded";
|
||||||
|
this.previewInfo.style.display = "block";
|
||||||
|
this.previewCanvas.style.display = "none";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.previewInfo.style.display = "none";
|
||||||
|
this.previewCanvas.style.display = "block";
|
||||||
|
|
||||||
|
// Set canvas size
|
||||||
|
const scale = 2;
|
||||||
|
this.previewCanvas.width = tileSet.getWidth() * scale;
|
||||||
|
this.previewCanvas.height = tileSet.getHeight() * scale;
|
||||||
|
|
||||||
|
this.previewCtx.imageSmoothingEnabled = false;
|
||||||
|
|
||||||
|
// Draw tileset
|
||||||
|
this.previewCtx.drawImage(
|
||||||
|
tileSet.image,
|
||||||
|
0, 0,
|
||||||
|
tileSet.image.width, tileSet.image.height,
|
||||||
|
0, 0,
|
||||||
|
this.previewCanvas.width, this.previewCanvas.height
|
||||||
|
);
|
||||||
|
|
||||||
|
// Draw grid
|
||||||
|
this.previewCtx.strokeStyle = "rgba(255, 255, 255, 0.3)";
|
||||||
|
this.previewCtx.lineWidth = 1;
|
||||||
|
|
||||||
|
for (let x = 0; x <= tileSet.columns; x++) {
|
||||||
|
const px = x * tileSet.tileWidth * scale;
|
||||||
|
this.previewCtx.beginPath();
|
||||||
|
this.previewCtx.moveTo(px, 0);
|
||||||
|
this.previewCtx.lineTo(px, this.previewCanvas.height);
|
||||||
|
this.previewCtx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let y = 0; y <= tileSet.rows; y++) {
|
||||||
|
const py = y * tileSet.tileHeight * scale;
|
||||||
|
this.previewCtx.beginPath();
|
||||||
|
this.previewCtx.moveTo(0, py);
|
||||||
|
this.previewCtx.lineTo(this.previewCanvas.width, py);
|
||||||
|
this.previewCtx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight selected tile
|
||||||
|
if (this.selectedTileIndex !== null) {
|
||||||
|
const rect = tileSet.getTileRect(this.selectedTileIndex);
|
||||||
|
this.previewCtx.strokeStyle = "rgba(255, 127, 17, 1)";
|
||||||
|
this.previewCtx.lineWidth = 2;
|
||||||
|
this.previewCtx.strokeRect(
|
||||||
|
rect.x * scale,
|
||||||
|
rect.y * scale,
|
||||||
|
rect.width * scale,
|
||||||
|
rect.height * scale
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders empty state.
|
||||||
|
*/
|
||||||
|
#renderEmpty() {
|
||||||
|
this.tileSetList.innerHTML = `
|
||||||
|
<div class="tileset-empty">
|
||||||
|
<div class="tileset-empty-icon">🎨</div>
|
||||||
|
<div class="tileset-empty-text">No tilesets added</div>
|
||||||
|
<div class="tileset-empty-hint">Click "+ Add Tileset" to import a 128x128px PNG</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
this.#renderPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all tilesets.
|
||||||
|
*
|
||||||
|
* @returns {Array<TileSet>}
|
||||||
|
*/
|
||||||
|
getTileSets() {
|
||||||
|
return Array.from(this.tileSets.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a tileset programmatically.
|
||||||
|
*
|
||||||
|
* @param {TileSet} tileSet Tileset to add.
|
||||||
|
*/
|
||||||
|
addTileSet(tileSet) {
|
||||||
|
this.tileSets.set(tileSet.id, tileSet);
|
||||||
|
this.#render();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializes all tilesets to JSON.
|
||||||
|
*
|
||||||
|
* @returns {Object}
|
||||||
|
*/
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
tileSets: Array.from(this.tileSets.values()).map((ts) => ts.toJSON())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads tilesets from JSON.
|
||||||
|
*
|
||||||
|
* @param {Object} data Serialized data.
|
||||||
|
*/
|
||||||
|
async fromJSON(data) {
|
||||||
|
if (!data.tileSets) return;
|
||||||
|
|
||||||
|
this.tileSets.clear();
|
||||||
|
|
||||||
|
for (const tsData of data.tileSets) {
|
||||||
|
try {
|
||||||
|
const tileSet = TileSet.fromJSON(tsData);
|
||||||
|
await tileSet.load();
|
||||||
|
this.tileSets.set(tileSet.id, tileSet);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load tileset:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#render();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ export class WorkspaceDragManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the palette ghost transform to match the supplied position.
|
* 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.
|
* @param {{ x: number, y: number }} position Snapped workspace position.
|
||||||
*/
|
*/
|
||||||
@@ -57,8 +58,14 @@ export class WorkspaceDragManager {
|
|||||||
if (!paletteDragGhost) {
|
if (!paletteDragGhost) {
|
||||||
return;
|
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 =
|
paletteDragGhost.element.style.transform =
|
||||||
WorkspaceGeometry.positionToTransform(position);
|
WorkspaceGeometry.positionToTransform(centered);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -210,7 +217,7 @@ export class WorkspaceDragManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pointerWorld = this.#clientToWorkspace(
|
const pointerWorld = this.clientToWorkspace(
|
||||||
hasClientX ? pointer.clientX : context.previousClientX,
|
hasClientX ? pointer.clientX : context.previousClientX,
|
||||||
hasClientY ? pointer.clientY : context.previousClientY
|
hasClientY ? pointer.clientY : context.previousClientY
|
||||||
);
|
);
|
||||||
@@ -526,7 +533,7 @@ export class WorkspaceDragManager {
|
|||||||
* @returns {{ x: number, y: number }}
|
* @returns {{ x: number, y: number }}
|
||||||
*/
|
*/
|
||||||
#eventToWorkspacePoint(event) {
|
#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.
|
* @param {number | undefined} clientY Viewport Y coordinate.
|
||||||
* @returns {{ x: number, y: number }}
|
* @returns {{ x: number, y: number }}
|
||||||
*/
|
*/
|
||||||
#clientToWorkspace(clientX, clientY) {
|
clientToWorkspace(clientX, clientY) {
|
||||||
const rect = this.workspace.workspaceElement.getBoundingClientRect();
|
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 safeX = Number.isFinite(clientX) ? clientX : rect.left;
|
||||||
const safeY = Number.isFinite(clientY) ? clientY : rect.top;
|
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 {
|
return {
|
||||||
x: (safeX - rect.left) / zoom,
|
x: (safeX - rect.left) / clampedZoom - offset.x,
|
||||||
y: (safeY - rect.top) / zoom,
|
y: (safeY - rect.top) / clampedZoom - offset.y,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -188,10 +188,14 @@ export class WorkspaceDropManager {
|
|||||||
|
|
||||||
this.#workspace.paletteManager.handleDragEnd();
|
this.#workspace.paletteManager.handleDragEnd();
|
||||||
|
|
||||||
const spawn = WorkspaceGeometry.snapPositionToGrid(
|
// Snap to grid first, then offset for centering (consistent with ghost positioning)
|
||||||
this.#workspace,
|
const cursorWorld = WorkspaceGeometry.eventToWorkspacePoint(this.#workspace, event);
|
||||||
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);
|
this.#workspace.addNode(definitionId, spawn);
|
||||||
}
|
}
|
||||||
@@ -214,10 +218,12 @@ export class WorkspaceDropManager {
|
|||||||
|
|
||||||
const definitions = this.#getVariableSpawnDefinitions(variableId);
|
const definitions = this.#getVariableSpawnDefinitions(variableId);
|
||||||
if (!definitions.length) {
|
if (!definitions.length) {
|
||||||
const spawn = WorkspaceGeometry.snapPositionToGrid(
|
const cursorWorld = WorkspaceGeometry.eventToWorkspacePoint(this.#workspace, event);
|
||||||
this.#workspace,
|
const snapped = WorkspaceGeometry.snapPositionToGrid(this.#workspace, cursorWorld);
|
||||||
WorkspaceGeometry.eventToWorkspacePoint(this.#workspace, event)
|
const spawn = {
|
||||||
);
|
x: snapped.x - 110,
|
||||||
|
y: snapped.y - 70,
|
||||||
|
};
|
||||||
const fallback = this.#workspace.addNode("get_var", spawn);
|
const fallback = this.#workspace.addNode("get_var", spawn);
|
||||||
if (fallback) {
|
if (fallback) {
|
||||||
const nodeName = variable.name || variable.id;
|
const nodeName = variable.name || variable.id;
|
||||||
|
|||||||
@@ -29,20 +29,11 @@ export class WorkspaceGeometry {
|
|||||||
* Converts viewport coordinates into workspace-relative coordinates.
|
* Converts viewport coordinates into workspace-relative coordinates.
|
||||||
*
|
*
|
||||||
* @param {import('../BlueprintWorkspace.js').BlueprintWorkspace} workspace Workspace instance.
|
* @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 }}
|
* @returns {{ x: number, y: number }}
|
||||||
*/
|
*/
|
||||||
static eventToWorkspacePoint(workspace, event) {
|
static eventToWorkspacePoint(workspace, event) {
|
||||||
const rect = workspace.workspaceElement.getBoundingClientRect();
|
return workspace.dragManager.clientToWorkspace(event?.clientX, event?.clientY);
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,8 +45,9 @@ export class WorkspaceGeometry {
|
|||||||
*/
|
*/
|
||||||
static snapPositionToGrid(workspace, position) {
|
static snapPositionToGrid(workspace, position) {
|
||||||
const size = workspace.gridSize || DEFAULT_GRID_SIZE;
|
const size = workspace.gridSize || DEFAULT_GRID_SIZE;
|
||||||
const offsetX = workspace.workspaceBackgroundOffset?.x ?? 0;
|
const effectiveOffset = workspace.navigationManager?.getEffectiveOffset?.();
|
||||||
const offsetY = workspace.workspaceBackgroundOffset?.y ?? 0;
|
const offsetX = Number.isFinite(effectiveOffset?.x) ? effectiveOffset.x : 0;
|
||||||
|
const offsetY = Number.isFinite(effectiveOffset?.y) ? effectiveOffset.y : 0;
|
||||||
const snap = (value, offset) => {
|
const snap = (value, offset) => {
|
||||||
const local = value - offset;
|
const local = value - offset;
|
||||||
const snapped = Math.round(local / size) * size;
|
const snapped = Math.round(local / size) * size;
|
||||||
|
|||||||
@@ -302,7 +302,6 @@ export class WorkspaceHistoryManager {
|
|||||||
this.#pendingReason = null;
|
this.#pendingReason = null;
|
||||||
|
|
||||||
this.#workspace.applySerializedWorkspace(snapshot.workspace);
|
this.#workspace.applySerializedWorkspace(snapshot.workspace);
|
||||||
this.#workspace.applyHistoryView(snapshot.view);
|
|
||||||
this.#workspace.applyHistorySelection(snapshot.selection);
|
this.#workspace.applyHistorySelection(snapshot.selection);
|
||||||
|
|
||||||
this.isReady = wasReady;
|
this.isReady = wasReady;
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { createColorSwatchPicker } from '../ColorSwatchPicker.js';
|
||||||
|
import { getTypeColor } from '../../types/TypeRegistry.js';
|
||||||
|
|
||||||
/** @typedef {import('../../core/BlueprintNode.js').BlueprintNode} BlueprintNode */
|
/** @typedef {import('../../core/BlueprintNode.js').BlueprintNode} BlueprintNode */
|
||||||
/** @typedef {import('../../core/BlueprintNode.js').PinDescriptor} PinDescriptor */
|
/** @typedef {import('../../core/BlueprintNode.js').PinDescriptor} PinDescriptor */
|
||||||
/** @typedef {import('../../core/NodeGraph.js').NodeGraph} NodeGraph */
|
/** @typedef {import('../../core/NodeGraph.js').NodeGraph} NodeGraph */
|
||||||
@@ -75,6 +78,7 @@ export class WorkspaceInlineEditorManager {
|
|||||||
"number_literal",
|
"number_literal",
|
||||||
"string_literal",
|
"string_literal",
|
||||||
"boolean_literal",
|
"boolean_literal",
|
||||||
|
"color_literal",
|
||||||
]);
|
]);
|
||||||
if (!supported.has(node.type)) {
|
if (!supported.has(node.type)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -100,6 +104,27 @@ export class WorkspaceInlineEditorManager {
|
|||||||
editor.appendChild(label);
|
editor.appendChild(label);
|
||||||
|
|
||||||
const graph = this.#getGraph();
|
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;
|
let control;
|
||||||
const commitNumber = () => {
|
const commitNumber = () => {
|
||||||
const numeric = Number(control.value);
|
const numeric = Number(control.value);
|
||||||
@@ -176,6 +201,7 @@ export class WorkspaceInlineEditorManager {
|
|||||||
{ value: "string", label: "String" },
|
{ value: "string", label: "String" },
|
||||||
{ value: "boolean", label: "Boolean" },
|
{ value: "boolean", label: "Boolean" },
|
||||||
{ value: "table", label: "Table" },
|
{ value: "table", label: "Table" },
|
||||||
|
{ value: "color", label: "Color" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ensureBody = () => {
|
const ensureBody = () => {
|
||||||
@@ -274,6 +300,8 @@ export class WorkspaceInlineEditorManager {
|
|||||||
return "valueBoolean";
|
return "valueBoolean";
|
||||||
case "table":
|
case "table":
|
||||||
return "valueTable";
|
return "valueTable";
|
||||||
|
case "color":
|
||||||
|
return "valueColor";
|
||||||
case "number":
|
case "number":
|
||||||
default:
|
default:
|
||||||
return "valueNumber";
|
return "valueNumber";
|
||||||
@@ -288,6 +316,8 @@ export class WorkspaceInlineEditorManager {
|
|||||||
return "string";
|
return "string";
|
||||||
case "table":
|
case "table":
|
||||||
return "table";
|
return "table";
|
||||||
|
case "color":
|
||||||
|
return "color";
|
||||||
default:
|
default:
|
||||||
return "number";
|
return "number";
|
||||||
}
|
}
|
||||||
@@ -301,6 +331,8 @@ export class WorkspaceInlineEditorManager {
|
|||||||
return false;
|
return false;
|
||||||
case "table":
|
case "table":
|
||||||
return "{}";
|
return "{}";
|
||||||
|
case "color":
|
||||||
|
return 7;
|
||||||
case "number":
|
case "number":
|
||||||
default:
|
default:
|
||||||
return 0;
|
return 0;
|
||||||
@@ -313,6 +345,7 @@ export class WorkspaceInlineEditorManager {
|
|||||||
const normalized = normalizeType(type);
|
const normalized = normalizeType(type);
|
||||||
activeType = normalized;
|
activeType = normalized;
|
||||||
typeButton.dataset.variableType = normalized;
|
typeButton.dataset.variableType = normalized;
|
||||||
|
typeButton.style.setProperty("--overview-variable-color", getTypeColor(normalized));
|
||||||
const label = formatType(normalized);
|
const label = formatType(normalized);
|
||||||
typeButton.title = `Type: ${label}`;
|
typeButton.title = `Type: ${label}`;
|
||||||
typeButton.setAttribute(
|
typeButton.setAttribute(
|
||||||
@@ -378,6 +411,20 @@ export class WorkspaceInlineEditorManager {
|
|||||||
graph.setNodeProperty(currentNode.id, key, textarea.value);
|
graph.setNodeProperty(currentNode.id, key, textarea.value);
|
||||||
});
|
});
|
||||||
control = textarea;
|
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 {
|
} else {
|
||||||
const numberInput = document.createElement("input");
|
const numberInput = document.createElement("input");
|
||||||
numberInput.type = "number";
|
numberInput.type = "number";
|
||||||
@@ -407,6 +454,10 @@ export class WorkspaceInlineEditorManager {
|
|||||||
if (control.value !== nextValue) {
|
if (control.value !== nextValue) {
|
||||||
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) {
|
} else if (control instanceof HTMLTextAreaElement) {
|
||||||
const nextValue = typeof current === "string" ? current : "{}";
|
const nextValue = typeof current === "string" ? current : "{}";
|
||||||
if (control.value !== nextValue) {
|
if (control.value !== nextValue) {
|
||||||
@@ -534,18 +585,10 @@ export class WorkspaceInlineEditorManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isIfCondition = node.type === "if" && pin.id === "condition";
|
const isIfCondition = node.type === "if" && pin.id === "condition";
|
||||||
const isExtcmdCommand =
|
const isColorPin = pin.kind === "color";
|
||||||
node.type === "system_extcmd" && pin.id === "command";
|
const pinOptions =
|
||||||
const isNamedButtonSelector =
|
Array.isArray(pin.options) && pin.options.length > 0 ? pin.options : null;
|
||||||
node.type === "input_button_constants" && pin.id === "button";
|
if (!isIfCondition && !pinOptions && !isColorPin) {
|
||||||
const isNamedPlayerSelector =
|
|
||||||
node.type === "input_button_constants" && pin.id === "player";
|
|
||||||
if (
|
|
||||||
!isIfCondition &&
|
|
||||||
!isExtcmdCommand &&
|
|
||||||
!isNamedButtonSelector &&
|
|
||||||
!isNamedPlayerSelector
|
|
||||||
) {
|
|
||||||
container.classList.remove("has-inline-dropdown");
|
container.classList.remove("has-inline-dropdown");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -555,31 +598,14 @@ export class WorkspaceInlineEditorManager {
|
|||||||
const graph = this.#getGraph();
|
const graph = this.#getGraph();
|
||||||
|
|
||||||
if (!(propertyKey in node.properties)) {
|
if (!(propertyKey in node.properties)) {
|
||||||
if (isExtcmdCommand || isNamedButtonSelector || isNamedPlayerSelector) {
|
if (pinOptions) {
|
||||||
const definition = this.registry.get(node.type);
|
const defaultStr =
|
||||||
const targetKey = isExtcmdCommand
|
pin.defaultValue != null
|
||||||
? "command"
|
? String(pin.defaultValue)
|
||||||
: isNamedButtonSelector
|
: String(pinOptions[0]?.value ?? "");
|
||||||
? "button"
|
node.properties[propertyKey] = defaultStr;
|
||||||
: "player";
|
if (!(pin.id in node.properties)) {
|
||||||
const schema = definition?.properties?.find(
|
node.properties[pin.id] = defaultStr;
|
||||||
(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;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
node.properties[propertyKey] = this.#defaultValueForPin(pin);
|
node.properties[propertyKey] = this.#defaultValueForPin(pin);
|
||||||
@@ -591,7 +617,7 @@ export class WorkspaceInlineEditorManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let wrapper;
|
let wrapper;
|
||||||
if (isExtcmdCommand || isNamedButtonSelector || isNamedPlayerSelector) {
|
if (pinOptions) {
|
||||||
wrapper = document.createElement("div");
|
wrapper = document.createElement("div");
|
||||||
wrapper.className = "pin-inline-control pin-inline-dropdown";
|
wrapper.className = "pin-inline-control pin-inline-dropdown";
|
||||||
container.appendChild(wrapper);
|
container.appendChild(wrapper);
|
||||||
@@ -631,29 +657,14 @@ export class WorkspaceInlineEditorManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isExtcmdCommand || isNamedButtonSelector || isNamedPlayerSelector) {
|
if (pinOptions) {
|
||||||
const definition = this.registry.get(node.type);
|
const optionValues = pinOptions.map((o) => String(o.value));
|
||||||
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 select = document.createElement("select");
|
const select = document.createElement("select");
|
||||||
select.className = "pin-inline-select";
|
select.className = "pin-inline-select";
|
||||||
const ariaLabel = isExtcmdCommand
|
select.setAttribute("aria-label", pin.name || pin.id);
|
||||||
? "Command"
|
|
||||||
: isNamedButtonSelector
|
|
||||||
? "Button"
|
|
||||||
: "Player";
|
|
||||||
select.setAttribute("aria-label", ariaLabel);
|
|
||||||
|
|
||||||
const values = options.map((option) => String(option.value));
|
pinOptions.forEach((option) => {
|
||||||
options.forEach((option) => {
|
|
||||||
const element = document.createElement("option");
|
const element = document.createElement("option");
|
||||||
element.value = String(option.value);
|
element.value = String(option.value);
|
||||||
element.textContent = option.label ?? String(option.value);
|
element.textContent = option.label ?? String(option.value);
|
||||||
@@ -661,30 +672,14 @@ export class WorkspaceInlineEditorManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const normalizeValue = (value) => {
|
const normalizeValue = (value) => {
|
||||||
const candidate =
|
const candidate = value == null ? "" : String(value);
|
||||||
typeof value === "string" ? value : String(value ?? "");
|
return optionValues.includes(candidate)
|
||||||
if (values.length === 0) {
|
? candidate
|
||||||
return candidate;
|
: (optionValues[0] ?? "");
|
||||||
}
|
|
||||||
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 setValue = (value) => {
|
const setValue = (value) => {
|
||||||
const normalized = normalizeValue(
|
select.value = normalizeValue(value);
|
||||||
(isNamedButtonSelector || isNamedPlayerSelector) &&
|
|
||||||
typeof node.properties[targetKey] === "string"
|
|
||||||
? node.properties[targetKey]
|
|
||||||
: value
|
|
||||||
);
|
|
||||||
select.value = normalized;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const setConnected = (connected) => {
|
const setConnected = (connected) => {
|
||||||
@@ -694,23 +689,42 @@ export class WorkspaceInlineEditorManager {
|
|||||||
|
|
||||||
select.addEventListener("change", () => {
|
select.addEventListener("change", () => {
|
||||||
const nextValue = select.value;
|
const nextValue = select.value;
|
||||||
if (isExtcmdCommand) {
|
graph.setNodeProperty(node.id, propertyKey, nextValue);
|
||||||
graph.setNodeProperty(node.id, propertyKey, nextValue);
|
graph.setNodeProperty(node.id, pin.id, 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);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
wrapper.appendChild(select);
|
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 });
|
entry.pinEditors.set(pin.id, { setValue, setConnected });
|
||||||
setValue(node.properties[propertyKey]);
|
setValue(node.properties[propertyKey]);
|
||||||
setConnected(this.#hasConnection(node.id, pin.id));
|
setConnected(this.#hasConnection(node.id, pin.id));
|
||||||
@@ -765,45 +779,12 @@ export class WorkspaceInlineEditorManager {
|
|||||||
const propertyKey = this.#pinPropertyKey(pinId);
|
const propertyKey = this.#pinPropertyKey(pinId);
|
||||||
let value;
|
let value;
|
||||||
|
|
||||||
if (node.type === "system_extcmd" && pinId === "command") {
|
if (Array.isArray(pin?.options) && pin.options.length > 0) {
|
||||||
const commandValue =
|
const canonical = node.properties[pinId];
|
||||||
typeof node.properties.command === "string"
|
if (canonical != null) {
|
||||||
? node.properties.command
|
const strValue = String(canonical);
|
||||||
: null;
|
if (node.properties[propertyKey] !== strValue) {
|
||||||
if (
|
node.properties[propertyKey] = strValue;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -905,6 +886,8 @@ export class WorkspaceInlineEditorManager {
|
|||||||
return 0;
|
return 0;
|
||||||
case "string":
|
case "string":
|
||||||
return "";
|
return "";
|
||||||
|
case "color":
|
||||||
|
return 7;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ export class WorkspaceNavigationManager {
|
|||||||
#zoomConfig;
|
#zoomConfig;
|
||||||
#panState;
|
#panState;
|
||||||
#lastPanTimestamp;
|
#lastPanTimestamp;
|
||||||
|
#targetZoomLevel;
|
||||||
|
#smoothZoomFocus;
|
||||||
|
#smoothZoomRafId;
|
||||||
|
#pendingLayerOffset;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("../BlueprintWorkspace.js").BlueprintWorkspace} workspace Owning workspace instance.
|
* @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.#zoomConfig = { min: 0.25, max: 2.5, step: 0.1 };
|
||||||
this.#panState = null;
|
this.#panState = null;
|
||||||
this.#lastPanTimestamp = 0;
|
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;
|
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.
|
* Adjusts the zoom level within configured bounds.
|
||||||
*
|
*
|
||||||
* @param {number} nextZoom Requested zoom multiplier.
|
* @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.
|
* @returns {boolean} Whether the zoom value changed.
|
||||||
*/
|
*/
|
||||||
setZoomLevel(nextZoom, options = {}) {
|
setZoomLevel(nextZoom, options = {}) {
|
||||||
if (!Number.isFinite(nextZoom)) {
|
if (!Number.isFinite(nextZoom)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const { silent = false } = options;
|
const { silent = false, skipConnectionRender = false } = options;
|
||||||
const clamped = Math.max(
|
const clamped = Math.max(
|
||||||
this.#zoomConfig.min,
|
this.#zoomConfig.min,
|
||||||
Math.min(this.#zoomConfig.max, nextZoom)
|
Math.min(this.#zoomConfig.max, nextZoom)
|
||||||
@@ -68,12 +87,25 @@ export class WorkspaceNavigationManager {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#zoomLevel = clamped;
|
// Cancel any running smooth zoom so programmatic calls take immediate effect.
|
||||||
this.updateZoomDisplay();
|
if (this.#smoothZoomRafId !== null) {
|
||||||
if (!silent) {
|
cancelAnimationFrame(this.#smoothZoomRafId);
|
||||||
this.#markViewDirty("view", 200);
|
this.#smoothZoomRafId = null;
|
||||||
this.#schedulePersist();
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,28 +118,38 @@ export class WorkspaceNavigationManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates workspace transforms to match the stored zoom level.
|
* 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 zoom = this.#zoomLevel || 1;
|
||||||
const { nodeLayer, connectionLayer, workspaceElement } = this.#workspace;
|
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) {
|
if (nodeLayer) {
|
||||||
nodeLayer.style.transformOrigin = "0 0";
|
nodeLayer.style.transformOrigin = "0 0";
|
||||||
nodeLayer.style.transform = `scale(${zoom})`;
|
nodeLayer.style.transform = `translate(${tx}px, ${ty}px) scale(${zoom})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (connectionLayer) {
|
if (connectionLayer) {
|
||||||
connectionLayer.style.transformOrigin = "0 0";
|
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}`);
|
workspaceElement.style.setProperty("--workspace-zoom", `${zoom}`);
|
||||||
this.#applyBackgroundOffset();
|
this.#applyBackgroundOffset();
|
||||||
this.#renderConnections();
|
if (!skipConnectionRender) {
|
||||||
|
this.#renderConnections();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts a screen-space point to workspace coordinates.
|
* 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.
|
* @param {{ x: number, y: number }} point Screen-space point relative to the workspace element.
|
||||||
* @returns {{ x: number, y: number }}
|
* @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.
|
* Applies zoom centred at a screen-space location.
|
||||||
*
|
*
|
||||||
@@ -149,7 +292,7 @@ export class WorkspaceNavigationManager {
|
|||||||
const previousZoom = this.#zoomLevel || 1;
|
const previousZoom = this.#zoomLevel || 1;
|
||||||
const worldPoint = this.screenPointToWorld(screenPoint);
|
const worldPoint = this.screenPointToWorld(screenPoint);
|
||||||
const nextZoom = previousZoom + direction * this.#zoomConfig.step;
|
const nextZoom = previousZoom + direction * this.#zoomConfig.step;
|
||||||
const changed = this.setZoomLevel(nextZoom);
|
const changed = this.setZoomLevel(nextZoom, { skipConnectionRender: true });
|
||||||
if (!changed) {
|
if (!changed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -172,8 +315,10 @@ export class WorkspaceNavigationManager {
|
|||||||
dragManager.rebaseActiveDragPointer(pointer);
|
dragManager.rebaseActiveDragPointer(pointer);
|
||||||
}
|
}
|
||||||
this.#rebasePanGestureAfterZoom(pointer);
|
this.#rebasePanGestureAfterZoom(pointer);
|
||||||
this.#renderConnections();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.updateZoomDisplay(true);
|
||||||
|
this.#renderConnections();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -197,6 +342,18 @@ export class WorkspaceNavigationManager {
|
|||||||
return this.#backgroundOffset;
|
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.
|
* Applies a workspace translation to all nodes and the background.
|
||||||
*
|
*
|
||||||
@@ -212,22 +369,7 @@ export class WorkspaceNavigationManager {
|
|||||||
y: this.#backgroundOffset.y + delta.y,
|
y: this.#backgroundOffset.y + delta.y,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { graph, dragManager } = this.#workspace;
|
const { 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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (dragManager?.applyActiveDragShift) {
|
if (dragManager?.applyActiveDragShift) {
|
||||||
dragManager.applyActiveDragShift(delta);
|
dragManager.applyActiveDragShift(delta);
|
||||||
@@ -273,6 +415,7 @@ export class WorkspaceNavigationManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.translateWorkspace(delta);
|
this.translateWorkspace(delta);
|
||||||
|
this.updateZoomDisplay(false);
|
||||||
this.#workspace.selectNode(nodeId);
|
this.#workspace.selectNode(nodeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,30 +488,29 @@ export class WorkspaceNavigationManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
targetZoom = Math.max(this.#zoomConfig.min, Math.min(this.#zoomConfig.max, targetZoom));
|
targetZoom = Math.max(this.#zoomConfig.min, Math.min(this.#zoomConfig.max, targetZoom));
|
||||||
this.setZoomLevel(targetZoom);
|
|
||||||
|
|
||||||
const contentCenter = {
|
const contentCenter = {
|
||||||
x: minX + boundsWidth / 2,
|
x: minX + boundsWidth / 2,
|
||||||
y: minY + boundsHeight / 2,
|
y: minY + boundsHeight / 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
const viewportCenter = this.screenPointToWorld({
|
// Directly compute the background offset that places contentCenter at screen center,
|
||||||
x: rect.width / 2,
|
// then set zoom + offset together so a single updateZoomDisplay call applies both.
|
||||||
y: rect.height / 2,
|
const newOffsetX = rect.width / 2 / targetZoom - contentCenter.x;
|
||||||
});
|
const newOffsetY = rect.height / 2 / targetZoom - contentCenter.y;
|
||||||
|
|
||||||
const delta = {
|
// Cancel any running smooth zoom before snapping.
|
||||||
x: viewportCenter.x - contentCenter.x,
|
if (this.#smoothZoomRafId !== null) {
|
||||||
y: viewportCenter.y - contentCenter.y,
|
cancelAnimationFrame(this.#smoothZoomRafId);
|
||||||
};
|
this.#smoothZoomRafId = null;
|
||||||
|
this.#smoothZoomFocus = null;
|
||||||
if (Math.abs(delta.x) > 0.0001 || Math.abs(delta.y) > 0.0001) {
|
this.#pendingLayerOffset = { x: 0, y: 0 };
|
||||||
this.translateWorkspace(delta);
|
|
||||||
this.#renderConnections();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#markViewDirty("view", 200);
|
this.#targetZoomLevel = targetZoom;
|
||||||
this.#schedulePersist();
|
this.#zoomLevel = targetZoom;
|
||||||
|
this.setBackgroundOffset({ x: newOffsetX, y: newOffsetY });
|
||||||
|
this.updateZoomDisplay(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -381,25 +523,18 @@ export class WorkspaceNavigationManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { dragManager, contextMenuManager, graph, nodeElements } =
|
const { dragManager, contextMenuManager } = this.#workspace;
|
||||||
this.#workspace;
|
|
||||||
|
|
||||||
dragManager?.removeNodeGhost();
|
dragManager?.removeNodeGhost();
|
||||||
dragManager?.removePaletteGhost();
|
dragManager?.removePaletteGhost();
|
||||||
|
|
||||||
const nodeOrigins = new Map();
|
|
||||||
graph.nodes.forEach((node) => {
|
|
||||||
nodeOrigins.set(node.id, { x: node.position.x, y: node.position.y });
|
|
||||||
});
|
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
pointerId: event.pointerId,
|
pointerId: event.pointerId,
|
||||||
startX: event.clientX,
|
startX: event.clientX,
|
||||||
startY: event.clientY,
|
startY: event.clientY,
|
||||||
lastDelta: { x: 0, y: 0 },
|
|
||||||
hasMoved: false,
|
hasMoved: false,
|
||||||
nodeOrigins,
|
|
||||||
backgroundOrigin: { ...this.#backgroundOffset },
|
backgroundOrigin: { ...this.#backgroundOffset },
|
||||||
|
lastDelta: { x: 0, y: 0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePointerMove = (moveEvent) => {
|
const handlePointerMove = (moveEvent) => {
|
||||||
@@ -413,7 +548,6 @@ export class WorkspaceNavigationManager {
|
|||||||
x: deltaXScreen,
|
x: deltaXScreen,
|
||||||
y: deltaYScreen,
|
y: deltaYScreen,
|
||||||
});
|
});
|
||||||
this.#panState.lastDelta = worldDelta;
|
|
||||||
|
|
||||||
if (!this.#panState.hasMoved) {
|
if (!this.#panState.hasMoved) {
|
||||||
const distance = Math.hypot(deltaXScreen, deltaYScreen);
|
const distance = Math.hypot(deltaXScreen, deltaYScreen);
|
||||||
@@ -422,32 +556,21 @@ export class WorkspaceNavigationManager {
|
|||||||
}
|
}
|
||||||
moveEvent.preventDefault();
|
moveEvent.preventDefault();
|
||||||
this.#panState.hasMoved = true;
|
this.#panState.hasMoved = true;
|
||||||
contextMenuManager?.hide();
|
contextMenuManager?.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.#panState.hasMoved) {
|
if (!this.#panState.hasMoved) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.#panState.lastDelta = worldDelta;
|
||||||
|
|
||||||
const backgroundOffset = {
|
const backgroundOffset = {
|
||||||
x: this.#panState.backgroundOrigin.x + worldDelta.x,
|
x: this.#panState.backgroundOrigin.x + worldDelta.x,
|
||||||
y: this.#panState.backgroundOrigin.y + worldDelta.y,
|
y: this.#panState.backgroundOrigin.y + worldDelta.y,
|
||||||
};
|
};
|
||||||
this.setBackgroundOffset(backgroundOffset);
|
this.setBackgroundOffset(backgroundOffset);
|
||||||
|
this.updateZoomDisplay(true);
|
||||||
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();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePointerUp = (upEvent) => {
|
const handlePointerUp = (upEvent) => {
|
||||||
@@ -467,17 +590,7 @@ export class WorkspaceNavigationManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.#lastPanTimestamp = this.#timestamp();
|
this.#lastPanTimestamp = this.#timestamp();
|
||||||
const delta = finalState.lastDelta;
|
this.#renderConnections();
|
||||||
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.#panState = state;
|
this.#panState = state;
|
||||||
@@ -527,8 +640,8 @@ export class WorkspaceNavigationManager {
|
|||||||
*/
|
*/
|
||||||
#applyBackgroundOffset() {
|
#applyBackgroundOffset() {
|
||||||
const zoom = this.#zoomLevel || 1;
|
const zoom = this.#zoomLevel || 1;
|
||||||
const scaledX = this.#backgroundOffset.x * zoom;
|
const scaledX = (this.#backgroundOffset.x + this.#pendingLayerOffset.x) * zoom;
|
||||||
const scaledY = this.#backgroundOffset.y * zoom;
|
const scaledY = (this.#backgroundOffset.y + this.#pendingLayerOffset.y) * zoom;
|
||||||
const position = `${scaledX}px ${scaledY}px`;
|
const position = `${scaledX}px ${scaledY}px`;
|
||||||
this.#workspace.workspaceElement.style.backgroundPosition = `${position}, ${position}, ${position}, ${position}`;
|
this.#workspace.workspaceElement.style.backgroundPosition = `${position}, ${position}, ${position}, ${position}`;
|
||||||
}
|
}
|
||||||
@@ -551,13 +664,6 @@ export class WorkspaceNavigationManager {
|
|||||||
x: this.#panState.backgroundOrigin.x + shift.x,
|
x: this.#panState.backgroundOrigin.x + shift.x,
|
||||||
y: this.#panState.backgroundOrigin.y + shift.y,
|
y: this.#panState.backgroundOrigin.y + shift.y,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.#panState.nodeOrigins.forEach((origin, nodeId, map) => {
|
|
||||||
map.set(nodeId, {
|
|
||||||
x: origin.x + shift.x,
|
|
||||||
y: origin.y + shift.y,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
227
scripts/ui/workspace/WorkspaceNodeContextMenu.js
Normal file
227
scripts/ui/workspace/WorkspaceNodeContextMenu.js
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
/**
|
||||||
|
* @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles node-specific context menu interactions (right-click on nodes).
|
||||||
|
*/
|
||||||
|
export class WorkspaceNodeContextMenu {
|
||||||
|
#workspace;
|
||||||
|
/** @type {HTMLDivElement | null} */
|
||||||
|
#container;
|
||||||
|
/** @type {boolean} */
|
||||||
|
#isVisible;
|
||||||
|
/** @type {string | null} */
|
||||||
|
#targetNodeId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {BlueprintWorkspace} workspace Owning workspace instance.
|
||||||
|
*/
|
||||||
|
constructor(workspace) {
|
||||||
|
this.#workspace = workspace;
|
||||||
|
this.#container = null;
|
||||||
|
this.#isVisible = false;
|
||||||
|
this.#targetNodeId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the node context menu DOM if it does not already exist.
|
||||||
|
*/
|
||||||
|
initialize() {
|
||||||
|
if (this.#container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.createElement("div");
|
||||||
|
container.className = "node-context-menu";
|
||||||
|
container.setAttribute("role", "menu");
|
||||||
|
container.setAttribute("aria-label", "Node actions");
|
||||||
|
|
||||||
|
const deleteItem = this.#createMenuItem("Delete", () => {
|
||||||
|
this.#handleDelete();
|
||||||
|
});
|
||||||
|
deleteItem.dataset.action = "delete";
|
||||||
|
container.appendChild(deleteItem);
|
||||||
|
|
||||||
|
const copyItem = this.#createMenuItem("Copy", () => {
|
||||||
|
this.#handleCopy();
|
||||||
|
});
|
||||||
|
copyItem.dataset.action = "copy";
|
||||||
|
container.appendChild(copyItem);
|
||||||
|
|
||||||
|
const refreshItem = this.#createMenuItem("Refresh", () => {
|
||||||
|
this.#handleRefresh();
|
||||||
|
});
|
||||||
|
refreshItem.dataset.action = "refresh";
|
||||||
|
container.appendChild(refreshItem);
|
||||||
|
|
||||||
|
this.#workspace.workspaceElement.appendChild(container);
|
||||||
|
|
||||||
|
document.addEventListener("pointerdown", (event) => {
|
||||||
|
if (!this.#isVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(event.target instanceof Node)) {
|
||||||
|
this.hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!container.contains(event.target)) {
|
||||||
|
this.hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (!this.#isVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
this.hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.#container = container;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a menu item button with the specified label and click handler.
|
||||||
|
*
|
||||||
|
* @param {string} label Menu item label.
|
||||||
|
* @param {() => void} onClick Click handler.
|
||||||
|
* @returns {HTMLButtonElement}
|
||||||
|
*/
|
||||||
|
#createMenuItem(label, onClick) {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "node-context-menu-item";
|
||||||
|
button.setAttribute("role", "menuitem");
|
||||||
|
button.textContent = label;
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
onClick();
|
||||||
|
this.hide();
|
||||||
|
});
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether the context menu is currently displayed.
|
||||||
|
*
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
isVisible() {
|
||||||
|
return this.#isVisible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether the provided target node resides inside the context menu.
|
||||||
|
*
|
||||||
|
* @param {Node | null} target Target node.
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
isTargetInside(target) {
|
||||||
|
return Boolean(
|
||||||
|
this.#container && target && this.#container.contains(target)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays the node context menu at the given viewport coordinates.
|
||||||
|
*
|
||||||
|
* @param {number} clientX Viewport X coordinate.
|
||||||
|
* @param {number} clientY Viewport Y coordinate.
|
||||||
|
* @param {string} nodeId Target node identifier.
|
||||||
|
*/
|
||||||
|
show(clientX, clientY, nodeId) {
|
||||||
|
this.initialize();
|
||||||
|
|
||||||
|
if (!this.#container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceRect =
|
||||||
|
this.#workspace.workspaceElement.getBoundingClientRect();
|
||||||
|
const relativeX = Math.max(0, clientX - workspaceRect.left);
|
||||||
|
const relativeY = Math.max(0, clientY - workspaceRect.top);
|
||||||
|
|
||||||
|
this.#targetNodeId = nodeId;
|
||||||
|
|
||||||
|
this.#container.classList.add("is-visible");
|
||||||
|
this.#container.style.left = `${relativeX}px`;
|
||||||
|
this.#container.style.top = `${relativeY}px`;
|
||||||
|
this.#isVisible = true;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!this.#container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuRect = this.#container.getBoundingClientRect();
|
||||||
|
let adjustedLeft = relativeX;
|
||||||
|
let adjustedTop = relativeY;
|
||||||
|
|
||||||
|
if (menuRect.right > workspaceRect.right) {
|
||||||
|
adjustedLeft -= menuRect.right - workspaceRect.right;
|
||||||
|
}
|
||||||
|
if (menuRect.bottom > workspaceRect.bottom) {
|
||||||
|
adjustedTop -= menuRect.bottom - workspaceRect.bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
adjustedLeft = Math.max(0, adjustedLeft);
|
||||||
|
adjustedTop = Math.max(0, adjustedTop);
|
||||||
|
|
||||||
|
this.#container.style.left = `${adjustedLeft}px`;
|
||||||
|
this.#container.style.top = `${adjustedTop}px`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conceals the node context menu and clears its state.
|
||||||
|
*/
|
||||||
|
hide() {
|
||||||
|
if (!this.#container || !this.#isVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#container.classList.remove("is-visible");
|
||||||
|
this.#isVisible = false;
|
||||||
|
this.#targetNodeId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the delete action for the target node.
|
||||||
|
*/
|
||||||
|
#handleDelete() {
|
||||||
|
if (!this.#targetNodeId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#workspace.removeNode(this.#targetNodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the copy action for the target node.
|
||||||
|
*/
|
||||||
|
#handleCopy() {
|
||||||
|
if (!this.#targetNodeId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasSelected = this.#workspace.selectedNodeIds.has(this.#targetNodeId);
|
||||||
|
if (!wasSelected) {
|
||||||
|
this.#workspace.selectNode(this.#targetNodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#workspace.clipboardManager.copySelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the refresh action for the target node.
|
||||||
|
*/
|
||||||
|
#handleRefresh() {
|
||||||
|
if (!this.#targetNodeId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#workspace.refreshNode(this.#targetNodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { WorkspaceGeometry } from "./WorkspaceGeometry.js";
|
import { WorkspaceGeometry } from "./WorkspaceGeometry.js";
|
||||||
|
import { getTypeColor } from "../../types/TypeRegistry.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {"input"|"output"} PinDirection
|
* @typedef {"input"|"output"} PinDirection
|
||||||
@@ -152,6 +153,36 @@ export class WorkspaceNodeRenderer {
|
|||||||
article.classList.remove("is-hovered");
|
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.nodeLayer.appendChild(article);
|
||||||
this.#workspace.nodeElements.set(node.id, article);
|
this.#workspace.nodeElements.set(node.id, article);
|
||||||
this.#inlineEditors.syncNode(node.id);
|
this.#inlineEditors.syncNode(node.id);
|
||||||
@@ -270,6 +301,7 @@ export class WorkspaceNodeRenderer {
|
|||||||
container.dataset.pinId = pin.id;
|
container.dataset.pinId = pin.id;
|
||||||
container.dataset.nodeId = node.id;
|
container.dataset.nodeId = node.id;
|
||||||
container.dataset.type = pin.kind;
|
container.dataset.type = pin.kind;
|
||||||
|
container.style.setProperty("--pin-kind-color", getTypeColor(pin.kind));
|
||||||
const isStandardExec =
|
const isStandardExec =
|
||||||
pin.kind === "exec" && (pin.id === "exec_in" || pin.id === "exec_out");
|
pin.kind === "exec" && (pin.id === "exec_in" || pin.id === "exec_out");
|
||||||
if (isStandardExec) {
|
if (isStandardExec) {
|
||||||
|
|||||||
@@ -54,28 +54,48 @@ export class WorkspacePersistenceManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.cancelScheduledPersist();
|
this.cancelScheduledPersist();
|
||||||
|
this.#workspace.handlePersistenceStateChange?.({
|
||||||
|
status: "pending",
|
||||||
|
source: "auto",
|
||||||
|
});
|
||||||
this.#timerId = window.setTimeout(() => {
|
this.#timerId = window.setTimeout(() => {
|
||||||
this.#timerId = null;
|
this.#timerId = null;
|
||||||
this.persistImmediately();
|
this.persistImmediately("auto");
|
||||||
}, 200);
|
}, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serializes current workspace state and writes it to localStorage immediately.
|
* Serializes current workspace state and writes it to localStorage immediately.
|
||||||
|
*
|
||||||
|
* @param {'manual'|'auto'} [source]
|
||||||
*/
|
*/
|
||||||
persistImmediately() {
|
persistImmediately(source = "manual") {
|
||||||
if (!this.#storageAvailable || typeof window === "undefined") {
|
if (!this.#storageAvailable || typeof window === "undefined") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.cancelScheduledPersist();
|
||||||
|
this.#workspace.handlePersistenceStateChange?.({
|
||||||
|
status: "saving",
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = this.serializeWorkspace();
|
const payload = this.serializeWorkspace();
|
||||||
window.localStorage.setItem(
|
window.localStorage.setItem(
|
||||||
WORKSPACE_STORAGE_KEY,
|
WORKSPACE_STORAGE_KEY,
|
||||||
JSON.stringify(payload)
|
JSON.stringify(payload)
|
||||||
);
|
);
|
||||||
|
this.#workspace.handlePersistenceStateChange?.({
|
||||||
|
status: "saved",
|
||||||
|
source,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to persist workspace state", 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))
|
.filter((entry) => Boolean(entry))
|
||||||
.map((entry) => {
|
.map((entry) => {
|
||||||
const variable =
|
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
|
entry
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
|
|||||||
259
scripts/ui/workspace/WorkspaceWireCutManager.js
Normal file
259
scripts/ui/workspace/WorkspaceWireCutManager.js
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
/**
|
||||||
|
* @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {{ x: number, y: number }} Point
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {{
|
||||||
|
* pointerId: number,
|
||||||
|
* startClient: Point,
|
||||||
|
* currentClient: Point,
|
||||||
|
* cutLine: SVGLineElement,
|
||||||
|
* hasMoved: boolean,
|
||||||
|
* }} CutState
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the middle-mouse wire-cut gesture.
|
||||||
|
* Hold middle mouse and drag a line across wires to disconnect them.
|
||||||
|
*/
|
||||||
|
export class WorkspaceWireCutManager {
|
||||||
|
/** @type {BlueprintWorkspace} */
|
||||||
|
#workspace;
|
||||||
|
/** @type {CutState | null} */
|
||||||
|
#state;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {BlueprintWorkspace} workspace Owning workspace instance.
|
||||||
|
*/
|
||||||
|
constructor(workspace) {
|
||||||
|
this.#workspace = workspace;
|
||||||
|
this.#state = null;
|
||||||
|
|
||||||
|
this.handleMove = this.handleMove.bind(this);
|
||||||
|
this.handleUp = this.handleUp.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a cut gesture from a middle-mouse pointerdown event.
|
||||||
|
*
|
||||||
|
* @param {PointerEvent} event Middle-mouse pointerdown event.
|
||||||
|
*/
|
||||||
|
beginCut(event) {
|
||||||
|
if (this.#state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const cutLine = document.createElementNS("http://www.w3.org/2000/svg", "line");
|
||||||
|
cutLine.classList.add("wire-cut-line");
|
||||||
|
|
||||||
|
const svgPos = this.#clientToSvg(event.clientX, event.clientY);
|
||||||
|
cutLine.setAttribute("x1", String(svgPos.x));
|
||||||
|
cutLine.setAttribute("y1", String(svgPos.y));
|
||||||
|
cutLine.setAttribute("x2", String(svgPos.x));
|
||||||
|
cutLine.setAttribute("y2", String(svgPos.y));
|
||||||
|
this.#workspace.connectionLayer.appendChild(cutLine);
|
||||||
|
|
||||||
|
this.#state = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
startClient: { x: event.clientX, y: event.clientY },
|
||||||
|
currentClient: { x: event.clientX, y: event.clientY },
|
||||||
|
cutLine,
|
||||||
|
hasMoved: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("pointermove", this.handleMove);
|
||||||
|
window.addEventListener("pointerup", this.handleUp);
|
||||||
|
window.addEventListener("pointercancel", this.handleUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether a cut gesture is currently active.
|
||||||
|
*
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
isCutting() {
|
||||||
|
return this.#state !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts viewport client coordinates to SVG-layer space.
|
||||||
|
*
|
||||||
|
* @param {number} clientX
|
||||||
|
* @param {number} clientY
|
||||||
|
* @returns {Point}
|
||||||
|
*/
|
||||||
|
#clientToSvg(clientX, clientY) {
|
||||||
|
const zoom = this.#workspace.zoomLevel || 1;
|
||||||
|
const offset = this.#workspace.navigationManager?.getEffectiveOffset() ??
|
||||||
|
this.#workspace.workspaceBackgroundOffset ?? { x: 0, y: 0 };
|
||||||
|
const rect = this.#workspace.workspaceElement.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: (clientX - rect.left) / zoom - offset.x,
|
||||||
|
y: (clientY - rect.top) / zoom - offset.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {PointerEvent} event
|
||||||
|
*/
|
||||||
|
handleMove(event) {
|
||||||
|
if (!this.#state || event.pointerId !== this.#state.pointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#state.currentClient = { x: event.clientX, y: event.clientY };
|
||||||
|
|
||||||
|
const dx = event.clientX - this.#state.startClient.x;
|
||||||
|
const dy = event.clientY - this.#state.startClient.y;
|
||||||
|
if (!this.#state.hasMoved && Math.hypot(dx, dy) >= 4) {
|
||||||
|
this.#state.hasMoved = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = this.#clientToSvg(this.#state.startClient.x, this.#state.startClient.y);
|
||||||
|
const end = this.#clientToSvg(event.clientX, event.clientY);
|
||||||
|
|
||||||
|
this.#state.cutLine.setAttribute("x1", String(start.x));
|
||||||
|
this.#state.cutLine.setAttribute("y1", String(start.y));
|
||||||
|
this.#state.cutLine.setAttribute("x2", String(end.x));
|
||||||
|
this.#state.cutLine.setAttribute("y2", String(end.y));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {PointerEvent} event
|
||||||
|
*/
|
||||||
|
handleUp(event) {
|
||||||
|
if (!this.#state || event.pointerId !== this.#state.pointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.removeEventListener("pointermove", this.handleMove);
|
||||||
|
window.removeEventListener("pointerup", this.handleUp);
|
||||||
|
window.removeEventListener("pointercancel", this.handleUp);
|
||||||
|
|
||||||
|
const state = this.#state;
|
||||||
|
this.#state = null;
|
||||||
|
state.cutLine.remove();
|
||||||
|
|
||||||
|
if (!state.hasMoved) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const p1 = this.#clientToSvg(state.startClient.x, state.startClient.y);
|
||||||
|
const p2 = this.#clientToSvg(state.currentClient.x, state.currentClient.y);
|
||||||
|
this.#cutIntersecting(p1, p2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes all connections whose bezier curves intersect the given line segment.
|
||||||
|
*
|
||||||
|
* @param {Point} p1 Start of cut segment in SVG space.
|
||||||
|
* @param {Point} p2 End of cut segment in SVG space.
|
||||||
|
*/
|
||||||
|
#cutIntersecting(p1, p2) {
|
||||||
|
const { connectionElements, graph, historyManager } = this.#workspace;
|
||||||
|
/** @type {string[]} */
|
||||||
|
const toRemove = [];
|
||||||
|
|
||||||
|
connectionElements.forEach((pathEl, connectionId) => {
|
||||||
|
const d = pathEl.getAttribute("d");
|
||||||
|
if (!d) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.#bezierIntersectsSegment(d, p1, p2)) {
|
||||||
|
toRemove.push(connectionId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!toRemove.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch all removals into a single undo step.
|
||||||
|
historyManager?.withSuspended(() => {
|
||||||
|
toRemove.forEach((id) => graph.removeConnection(id));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the cubic bezier described by a path `d` attribute crosses the segment p1→p2.
|
||||||
|
*
|
||||||
|
* @param {string} d SVG path `d` attribute value.
|
||||||
|
* @param {Point} p1
|
||||||
|
* @param {Point} p2
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
#bezierIntersectsSegment(d, p1, p2) {
|
||||||
|
const m = d.match(
|
||||||
|
/M\s*([\d.\-e]+)\s+([\d.\-e]+)\s+C\s*([\d.\-e]+)\s+([\d.\-e]+)\s+([\d.\-e]+)\s+([\d.\-e]+)\s+([\d.\-e]+)\s+([\d.\-e]+)/i
|
||||||
|
);
|
||||||
|
if (!m) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bp0 = { x: parseFloat(m[1]), y: parseFloat(m[2]) };
|
||||||
|
const bc1 = { x: parseFloat(m[3]), y: parseFloat(m[4]) };
|
||||||
|
const bc2 = { x: parseFloat(m[5]), y: parseFloat(m[6]) };
|
||||||
|
const bp3 = { x: parseFloat(m[7]), y: parseFloat(m[8]) };
|
||||||
|
|
||||||
|
const SAMPLES = 32;
|
||||||
|
let prev = bp0;
|
||||||
|
for (let i = 1; i <= SAMPLES; i++) {
|
||||||
|
const t = i / SAMPLES;
|
||||||
|
const curr = this.#cubicBezierPoint(bp0, bc1, bc2, bp3, t);
|
||||||
|
if (this.#segmentsIntersect(prev, curr, p1, p2)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
prev = curr;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates a cubic bezier at parameter t.
|
||||||
|
*
|
||||||
|
* @param {Point} p0
|
||||||
|
* @param {Point} c1
|
||||||
|
* @param {Point} c2
|
||||||
|
* @param {Point} p3
|
||||||
|
* @param {number} t
|
||||||
|
* @returns {Point}
|
||||||
|
*/
|
||||||
|
#cubicBezierPoint(p0, c1, c2, p3, t) {
|
||||||
|
const mt = 1 - t;
|
||||||
|
return {
|
||||||
|
x: mt * mt * mt * p0.x + 3 * mt * mt * t * c1.x + 3 * mt * t * t * c2.x + t * t * t * p3.x,
|
||||||
|
y: mt * mt * mt * p0.y + 3 * mt * mt * t * c1.y + 3 * mt * t * t * c2.y + t * t * t * p3.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if line segments a1→a2 and b1→b2 cross each other.
|
||||||
|
*
|
||||||
|
* @param {Point} a1
|
||||||
|
* @param {Point} a2
|
||||||
|
* @param {Point} b1
|
||||||
|
* @param {Point} b2
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
#segmentsIntersect(a1, a2, b1, b2) {
|
||||||
|
const dx1 = a2.x - a1.x;
|
||||||
|
const dy1 = a2.y - a1.y;
|
||||||
|
const dx2 = b2.x - b1.x;
|
||||||
|
const dy2 = b2.y - b1.y;
|
||||||
|
const denom = dx1 * dy2 - dy1 * dx2;
|
||||||
|
if (Math.abs(denom) < 1e-10) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const dx3 = b1.x - a1.x;
|
||||||
|
const dy3 = b1.y - a1.y;
|
||||||
|
const t = (dx3 * dy2 - dy3 * dx2) / denom;
|
||||||
|
const u = (dx3 * dy1 - dy3 * dx1) / denom;
|
||||||
|
return t >= 0 && t <= 1 && u >= 0 && u <= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { VARIABLE_SORT_MIME } from "../BlueprintWorkspaceConstants.js";
|
import { VARIABLE_SORT_MIME } from "../BlueprintWorkspaceConstants.js";
|
||||||
|
import { getTypeColor } from "../../types/TypeRegistry.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the global variable summary list.
|
* Renders the global variable summary list.
|
||||||
@@ -225,6 +226,7 @@ export function renderVariableList({
|
|||||||
row.setAttribute("role", "button");
|
row.setAttribute("role", "button");
|
||||||
row.dataset.variableId = variable.id;
|
row.dataset.variableId = variable.id;
|
||||||
row.dataset.variableType = variable.type;
|
row.dataset.variableType = variable.type;
|
||||||
|
row.style.setProperty("--overview-variable-color", getTypeColor(variable.type));
|
||||||
row.draggable = true;
|
row.draggable = true;
|
||||||
row.setAttribute("aria-pressed", "false");
|
row.setAttribute("aria-pressed", "false");
|
||||||
|
|
||||||
@@ -330,6 +332,7 @@ export function renderVariableList({
|
|||||||
typeButton.type = "button";
|
typeButton.type = "button";
|
||||||
typeButton.className = "variable-type-button";
|
typeButton.className = "variable-type-button";
|
||||||
typeButton.dataset.variableType = variable.type;
|
typeButton.dataset.variableType = variable.type;
|
||||||
|
typeButton.style.setProperty("--overview-variable-color", getTypeColor(variable.type));
|
||||||
typeButton.setAttribute("aria-haspopup", "menu");
|
typeButton.setAttribute("aria-haspopup", "menu");
|
||||||
typeButton.setAttribute("aria-expanded", "false");
|
typeButton.setAttribute("aria-expanded", "false");
|
||||||
typeButton.title = `Type: ${formatVariableType(variable.type)}`;
|
typeButton.title = `Type: ${formatVariableType(variable.type)}`;
|
||||||
|
|||||||
176
sprite-editor.html
Normal file
176
sprite-editor.html
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>PICO-8 Sprite Editor - Picograph</title>
|
||||||
|
<link rel="stylesheet" href="styles/main.css">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#editor-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sprite-editor-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-bottom: 1px solid var(--edge);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sprite-editor-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sprite-editor-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sprite-editor-btn {
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="sprite-editor-header">
|
||||||
|
<div class="sprite-editor-title">🎨 Sprite Editor</div>
|
||||||
|
<div></div>
|
||||||
|
<div class="sprite-editor-actions">
|
||||||
|
<button class="sprite-editor-btn" id="export-btn">Export to .p8</button>
|
||||||
|
<button class="sprite-editor-btn" id="import-btn">Import from .p8</button>
|
||||||
|
<button class="sprite-editor-btn" id="save-json-btn">Save JSON</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="editor-container"></div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import { SpriteEditor } from './scripts/ui/SpriteEditor.js';
|
||||||
|
import { SpriteSheet } from './scripts/core/SpriteSheet.js';
|
||||||
|
|
||||||
|
// Initialize the sprite editor
|
||||||
|
const container = document.getElementById('editor-container');
|
||||||
|
const spriteSheet = new SpriteSheet();
|
||||||
|
const editor = new SpriteEditor(container, spriteSheet);
|
||||||
|
|
||||||
|
// Export to .p8 format
|
||||||
|
document.getElementById('export-btn').addEventListener('click', () => {
|
||||||
|
const gfxData = editor.getSpriteSheet().toP8Gfx();
|
||||||
|
const flagData = editor.getSpriteSheet().toP8Flags();
|
||||||
|
|
||||||
|
const p8Content = `pico-8 cartridge // http://www.pico-8.com
|
||||||
|
version 41
|
||||||
|
__lua__
|
||||||
|
-- your code here
|
||||||
|
__gfx__
|
||||||
|
${gfxData}
|
||||||
|
__gff__
|
||||||
|
${flagData}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Download the .p8 file
|
||||||
|
const blob = new Blob([p8Content], { type: 'text/plain' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'sprites.p8';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Import from .p8 format
|
||||||
|
document.getElementById('import-btn').addEventListener('click', () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = '.p8';
|
||||||
|
|
||||||
|
input.addEventListener('change', async (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const text = await file.text();
|
||||||
|
|
||||||
|
// Extract __gfx__ section
|
||||||
|
const gfxMatch = text.match(/__gfx__\s*\n([\s\S]*?)(?=\n__|$)/);
|
||||||
|
if (gfxMatch) {
|
||||||
|
editor.getSpriteSheet().fromP8Gfx(gfxMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract __gff__ section
|
||||||
|
const gffMatch = text.match(/__gff__\s*\n([\s\S]*?)(?=\n__|$)/);
|
||||||
|
if (gffMatch) {
|
||||||
|
editor.getSpriteSheet().fromP8Flags(gffMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-render
|
||||||
|
editor.setSpriteSheet(editor.getSpriteSheet());
|
||||||
|
});
|
||||||
|
|
||||||
|
input.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save as JSON
|
||||||
|
document.getElementById('save-json-btn').addEventListener('click', () => {
|
||||||
|
const json = JSON.stringify(editor.getSpriteSheet().toJSON(), null, 2);
|
||||||
|
const blob = new Blob([json], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'sprites.json';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Example: Draw a smiley face on sprite 0
|
||||||
|
function drawExampleSprite() {
|
||||||
|
const sheet = editor.getSpriteSheet();
|
||||||
|
|
||||||
|
// Draw a simple smiley face
|
||||||
|
// Face outline (yellow)
|
||||||
|
for (let x = 1; x < 7; x++) {
|
||||||
|
sheet.setPixel(x, 1, 10); // Top
|
||||||
|
sheet.setPixel(x, 6, 10); // Bottom
|
||||||
|
}
|
||||||
|
for (let y = 2; y < 6; y++) {
|
||||||
|
sheet.setPixel(1, y, 10); // Left
|
||||||
|
sheet.setPixel(6, y, 10); // Right
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eyes (black)
|
||||||
|
sheet.setPixel(2, 3, 0);
|
||||||
|
sheet.setPixel(5, 3, 0);
|
||||||
|
|
||||||
|
// Smile (black)
|
||||||
|
sheet.setPixel(2, 5, 0);
|
||||||
|
sheet.setPixel(3, 5, 0);
|
||||||
|
sheet.setPixel(4, 5, 0);
|
||||||
|
sheet.setPixel(5, 5, 0);
|
||||||
|
|
||||||
|
editor.setSpriteSheet(sheet);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uncomment to draw example sprite on load
|
||||||
|
// drawExampleSprite();
|
||||||
|
|
||||||
|
console.log('Sprite Editor initialized!');
|
||||||
|
console.log('Available tools: Pen, Fill, Line, Rect, Circle, Erase');
|
||||||
|
console.log('Click sprites in the sheet to edit them in the preview panel');
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1059
styles/main.css
1059
styles/main.css
File diff suppressed because it is too large
Load Diff
302
test2.p8
Normal file
302
test2.p8
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
pico-8 cartridge // http://www.pico-8.com
|
||||||
|
version 8
|
||||||
|
__lua__
|
||||||
|
print("0.1.10c")
|
||||||
|
|
||||||
|
__gfx__
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00700700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00077000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00077000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00700700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
|
||||||
|
__gff__
|
||||||
|
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
__map__
|
||||||
|
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
__sfx__
|
||||||
|
000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
__music__
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
00 41424344
|
||||||
|
|
||||||
24
test_cart.p8
Normal file
24
test_cart.p8
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
pico-8 cartridge // http://www.pico-8.com
|
||||||
|
version 41
|
||||||
|
__lua__
|
||||||
|
function _init()
|
||||||
|
x=64
|
||||||
|
y=64
|
||||||
|
end
|
||||||
|
|
||||||
|
function _update()
|
||||||
|
end
|
||||||
|
|
||||||
|
function _draw()
|
||||||
|
cls()
|
||||||
|
print("picograph ok",2,60,7)
|
||||||
|
end
|
||||||
|
__gfx__
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
8
test_unix.p8
Normal file
8
test_unix.p8
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
pico-8 cartridge // http://www.pico-8.com
|
||||||
|
version 41
|
||||||
|
__lua__
|
||||||
|
function _init()
|
||||||
|
x=64
|
||||||
|
end
|
||||||
|
__gfx__
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||||
Reference in New Issue
Block a user