Add initial Pico-8 cartridges for testing

- Created test_cart.p8 with basic drawing functionality and a print statement.
- Created test_unix.p8 with a simple initialization function.
This commit is contained in:
2026-03-21 18:38:43 +11:00
parent 71354ad463
commit f917cf6ad5
56 changed files with 5200 additions and 688 deletions

View File

@@ -15,9 +15,10 @@ try {
// Ignore errors in production (when electron-reloader isn't installed)
}
const { app, BrowserWindow, ipcMain, Menu } = require("electron");
const { app, BrowserWindow, ipcMain, Menu, shell, dialog } = require("electron");
const fs = require("node:fs/promises");
const path = require("node:path");
const { spawn } = require("node:child_process");
const OUTPUT_FILENAME = "compiled.lua";
@@ -40,6 +41,16 @@ function getOutputPath() {
return path.join(getOutputDirectory(), OUTPUT_FILENAME);
}
/**
* Resolves the absolute directory where tileset images are stored.
*
* @returns {string}
*/
function getTilesetsDirectory() {
const applicationData = app.getPath("appData");
return path.join(applicationData, "pico-8", "carts", "picograph", "tilesets");
}
/** @type {import("electron").BrowserWindow | null} */
let mainWindow = null;
@@ -54,6 +65,7 @@ function createMainWindow() {
height: 900,
minWidth: 960,
minHeight: 640,
frame: false,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
@@ -78,6 +90,13 @@ function createMainWindow() {
}
});
const emitMaximizedState = () => {
window.webContents.send("window:maximized-changed", window.isMaximized());
};
window.on("maximize", emitMaximizedState);
window.on("unmaximize", emitMaximizedState);
window.on("closed", () => {
mainWindow = null;
});
@@ -135,6 +154,151 @@ function registerIpcHandlers() {
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;
}
});
}
/**

View File

@@ -3,18 +3,23 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self' data:;" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; font-src 'self' data:;" />
<title>picoGraph</title>
<link rel="stylesheet" href="styles/main.css" />
</head>
<body>
<header class="app-header">
<div class="header-primary-actions">
<button id="compileButton" class="header-primary-button" type="button">Compile</button>
<button id="previewButton" class="header-primary-button" type="button" title="Preview cart">Preview</button>
</div>
<div class="header-title">picoGraph</div>
<div class="header-actions">
<div class="header-actions header-actions--left" aria-label="Workspace tools">
<button
id="tileManagerButton"
class="header-icon-button"
type="button"
aria-label="Tile manager"
title="Tile manager"
>
<span class="header-icon header-icon--tiles" aria-hidden="true"></span>
</button>
<span class="header-icon-spacer" aria-hidden="true"></span>
<button
id="projectSettingsButton"
class="header-icon-button"
@@ -24,6 +29,25 @@
>
<span class="header-icon header-icon--settings" aria-hidden="true"></span>
</button>
<button
id="exportLuaButton"
class="header-icon-button"
type="button"
aria-label="Export Lua"
title="Export Lua"
>
<span class="header-icon header-icon--export" aria-hidden="true"></span>
</button>
<span class="header-icon-spacer" aria-hidden="true"></span>
<button
id="frameSelectionButton"
class="header-icon-button"
type="button"
aria-label="Frame selection"
title="Frame selection"
>
<span class="header-icon header-icon--frame" aria-hidden="true"></span>
</button>
<button
id="paletteToggleButton"
class="header-icon-button"
@@ -34,24 +58,58 @@
>
<span class="header-icon header-icon--palette" aria-hidden="true"></span>
</button>
</div>
<div class="header-titlebar">
<div class="header-title">picoGraph</div>
</div>
<div class="header-primary-actions">
<button
id="frameSelectionButton"
id="saveButton"
class="header-icon-button"
type="button"
aria-label="Frame selection"
title="Frame selection"
aria-label="Save workspace"
title="Save workspace"
>
<span class="header-icon header-icon--frame" aria-hidden="true"></span>
<span class="header-icon header-icon--save" aria-hidden="true"></span>
</button>
<button
id="exportLuaButton"
class="header-icon-button"
id="previewButton"
class="header-icon-button header-icon-button--accent"
type="button"
aria-label="Export Lua"
title="Export Lua"
aria-label="Preview cart"
title="Preview cart"
>
<span class="header-icon header-icon--export" aria-hidden="true"></span>
<span class="header-icon header-icon--play" aria-hidden="true"></span>
</button>
<div id="windowControls" class="window-controls" aria-label="Window controls">
<button
id="minimizeWindowButton"
class="window-control-button"
type="button"
aria-label="Minimize window"
title="Minimize"
>
<span class="header-icon header-icon--minimize" aria-hidden="true"></span>
</button>
<button
id="maximizeWindowButton"
class="window-control-button"
type="button"
aria-label="Maximize window"
title="Maximize"
>
<span class="header-icon header-icon--maximize" aria-hidden="true"></span>
</button>
<button
id="closeWindowButton"
class="window-control-button window-control-button--close"
type="button"
aria-label="Close window"
title="Close"
>
<span class="header-icon header-icon--close-window" aria-hidden="true"></span>
</button>
</div>
</div>
</header>
<main id="appBody" class="app-body">
@@ -129,6 +187,36 @@
</div>
</div>
</div>
<div
id="tileModal"
class="modal"
aria-hidden="true"
role="dialog"
aria-modal="true"
aria-labelledby="tileModalTitle"
hidden
>
<div class="modal__backdrop" data-modal-close></div>
<div class="modal__panel modal__panel--tile" role="document">
<header class="modal__header">
<h2 id="tileModalTitle">Tile Manager</h2>
<div class="modal__actions">
<button
type="button"
class="modal__icon-button close-modal"
aria-label="Close tile manager"
title="Close"
data-modal-close
>
<span class="modal-icon modal-icon--close" aria-hidden="true"></span>
</button>
</div>
</header>
<div class="modal__body">
<div class="tile-browser-container"></div>
</div>
</div>
</div>
<template id="nodeTemplate">
<article class="blueprint-node" draggable="false">
<header class="node-header">

View File

@@ -59,6 +59,122 @@ const api = {
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);

View File

@@ -23,7 +23,7 @@
var p8_dropped_cart = null;
var p8_dropped_cart_name = '';
var CART_B64 = 'cGljby04IGNhcnRyaWRnZSAvLyBodHRwOi8vd3d3LnBpY28tOC5jb20KdmVyc2lvbiA0MQpfX2x1YV9fCi0tIEdlbmVyYXRlZCB3aXRoIHBpY29HcmFwaAoKb2Zmc2V0ID0gMApjb2xvciA9IDUKCmZ1bmN0aW9uIF91cGRhdGUoKQogIC0tIHNlcXVlbmNlIGEKICBjbHMoKQogIG9mZnNldCA9IDAKICAtLSBzZXF1ZW5jZSBiCiAgaWYgYnRuKDAsIDApIHRoZW4KICAgIHByaW50KCJsZWZ0IiwgMCwgb2Zmc2V0LCBybmQoKDE2KSArICgxKSkpCiAgICBjdXN0b21fYWRkb2Zmc2V0KCkKICBlbmQKICAtLSBzZXF1ZW5jZSBjCiAgaWYgYnRuKDEsIDApIHRoZW4KICAgIHByaW50KCJyaWdodCIsIDAsIG9mZnNldCwgY29sb3IpCiAgICBsb2NhbCBpdHNhbG9jYWx2YXJpYWJsZSA9IHRydWUKICAgIGlmIGl0c2Fsb2NhbHZhcmlhYmxlIHRoZW4KICAgICAgY3VzdG9tX2FkZG9mZnNldCgpCiAgICBlbmQKICBlbmQKICAtLSBzZXF1ZW5jZSBkCiAgaWYgYnRuKDIsIDApIHRoZW4KICAgIHByaW50KCJ1cCIsIDAsIG9mZnNldCwgY29sb3IpCiAgICBjdXN0b21fYWRkb2Zmc2V0KCkKICBlbmQKICAtLSBzZXF1ZW5jZSBlCiAgaWYgYnRuKDMsIDApIHRoZW4KICAgIHByaW50KCJkb3duIiwgMCwgb2Zmc2V0LCBjb2xvcikKICAgIGN1c3RvbV9hZGRvZmZzZXQoKQogIGVuZAplbmQKCmZ1bmN0aW9uIF9kcmF3KCkKICAtLSBzZXF1ZW5jZSBhCiAgY2xzKCkKICBvZmZzZXQgPSAwCiAgLS0gc2VxdWVuY2UgYgogIGlmIGJ0bigwLCAwKSB0aGVuCiAgICBwcmludCgibGVmdCIsIDAsIG9mZnNldCwgcm5kKCgxNikgKyAoMSkpKQogICAgY3VzdG9tX2FkZG9mZnNldCgpCiAgZW5kCiAgLS0gc2VxdWVuY2UgYwogIGlmIGJ0bigxLCAwKSB0aGVuCiAgICBwcmludCgicmlnaHQiLCAwLCBvZmZzZXQsIGNvbG9yKQogICAgbG9jYWwgaXRzYWxvY2FsdmFyaWFibGUgPSB0cnVlCiAgICBpZiBpdHNhbG9jYWx2YXJpYWJsZSB0aGVuCiAgICAgIGN1c3RvbV9hZGRvZmZzZXQoKQogICAgZW5kCiAgZW5kCiAgLS0gc2VxdWVuY2UgZAogIGlmIGJ0bigyLCAwKSB0aGVuCiAgICBwcmludCgidXAiLCAwLCBvZmZzZXQsIGNvbG9yKQogICAgY3VzdG9tX2FkZG9mZnNldCgpCiAgZW5kCiAgLS0gc2VxdWVuY2UgZQogIGlmIGJ0bigzLCAwKSB0aGVuCiAgICBwcmludCgiZG93biIsIDAsIG9mZnNldCwgY29sb3IpCiAgICBjdXN0b21fYWRkb2Zmc2V0KCkKICBlbmQKZW5kCgpmdW5jdGlvbiBjdXN0b21fYWRkb2Zmc2V0KCkKICBvZmZzZXQgPSAob2Zmc2V0KSArICg3KQplbmQKX19nZnhfXwowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMAowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMAowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMAowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMAowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMAowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMAowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMAowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMAo=';
var CART_B64 = 'cGljby04IGNhcnRyaWRnZSAvLyBodHRwOi8vd3d3LnBpY28tOC5jb20KdmVyc2lvbiA0MQpfX2x1YV9fCi0tIEdlbmVyYXRlZCB3aXRoIHBpY29HcmFwaAoKZnVuY3Rpb24gX2RyYXcoKQogIGNscygwKQogIGNpcmNmaWxsKDMyLCAzMiwgMjQsIDgpCmVuZApmdW5jdGlvbiBfdXBkYXRlKCkKZW5kCgpfX2dmeF9fCjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwCjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwCjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwCjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwCjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwCjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwCjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwCjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwCg==';
// Silence all XHR — PICO-8 makes BBS metadata calls that crash from file:// origin.
(function() {

View File

@@ -1,3 +1,6 @@
import { formatLiteral, defaultLiteralForKind } from './lua/LuaLiteralTypes.js';
import { normalizeVariableType, getTypeConverter } from '../types/TypeRegistry.js';
/**
* @typedef {import('./NodeGraph.js').NodeGraph} NodeGraph
*/
@@ -278,7 +281,7 @@ export class LuaGenerator {
findExecTargets: (pinId) => this.#findExecTargets(node.id, pinId),
sanitizeIdentifier: (value) => this.#sanitizeIdentifier(String(value)),
sanitizeOperator: (value) => this.#sanitizeOperator(String(value)),
formatLiteral: (kind, value) => this.#formatLiteral(kind, value),
formatLiteral,
};
}
@@ -338,7 +341,7 @@ export class LuaGenerator {
this.#resolveValueInput(node, inputPinId, fallback),
sanitizeIdentifier: (value) => this.#sanitizeIdentifier(String(value)),
sanitizeOperator: (value) => this.#sanitizeOperator(String(value)),
formatLiteral: (kind, value) => this.#formatLiteral(kind, value),
formatLiteral,
};
}
@@ -516,7 +519,7 @@ export class LuaGenerator {
const argEntries = signature.map((entry) => {
const fallback = entry.optional
? "nil"
: this.#defaultLiteralForKind(entry.kind);
: defaultLiteralForKind(entry.kind);
const value = this.#resolveValueInput(
node,
entry.inputPinId,
@@ -602,6 +605,49 @@ export class LuaGenerator {
.map((connection) => connection.to);
}
/**
* Applies Lua type conversion when source and target types differ.
*
* @param {string} expression Source expression to convert.
* @param {string} fromType Source type identifier.
* @param {string} toType Target type identifier.
* @returns {string}
*/
#applyTypeConversion(expression, fromType, toType) {
if (fromType === toType || toType === "any" || fromType === "any") {
return expression;
}
const converter = getTypeConverter(fromType, toType);
if (!converter) {
return expression;
}
// Generate Lua code for type conversions
if ((fromType === "number" || fromType === "color") && toType === "boolean") {
return `(${expression}) ~= 0`;
}
if (fromType === "boolean" && toType === "number") {
return `((${expression}) and 1 or 0)`;
}
if (fromType === "boolean" && toType === "color") {
return `((${expression}) and 7 or 0)`;
}
if (fromType === "boolean" && toType === "string") {
return `((${expression}) and "true" or "false")`;
}
if (fromType === "number" && toType === "color") {
return `flr(${expression}) % 16`;
}
// No conversion needed for color to number (colors are numbers in PICO-8)
return expression;
}
/**
* Resolves the expression bound to an input pin.
*
@@ -613,7 +659,17 @@ export class LuaGenerator {
#resolveValueInput(node, pinId, fallback) {
const connection = this.#getConnectionTo(node.id, pinId);
if (connection) {
return this.#evaluateValue(connection.from.nodeId, connection.from.pinId);
const sourceExpression = this.#evaluateValue(connection.from.nodeId, connection.from.pinId);
const targetPin = this.#findPin(node, pinId);
const targetType = targetPin?.kind ?? "any";
// Get the actual source type, handling special cases like get_var
const sourceNode = this.nodes.get(connection.from.nodeId);
const sourceType = sourceNode
? this.#getOutputPinType(sourceNode, connection.from.pinId)
: connection.kind ?? "any";
return this.#applyTypeConversion(sourceExpression, sourceType, targetType);
}
const pin = this.#findPin(node, pinId);
@@ -621,12 +677,12 @@ export class LuaGenerator {
if (Object.prototype.hasOwnProperty.call(node.properties, inlineKey)) {
const inlineValue = node.properties[inlineKey];
if (inlineValue !== undefined && inlineValue !== null) {
return this.#formatLiteral(pin?.kind ?? "any", inlineValue);
return formatLiteral(pin?.kind ?? "any", inlineValue);
}
}
if (pin && pin.defaultValue !== undefined && pin.defaultValue !== null) {
return this.#formatLiteral(pin.kind, pin.defaultValue);
return formatLiteral(pin.kind, pin.defaultValue);
}
return fallback;
@@ -691,24 +747,6 @@ export class LuaGenerator {
if (expression === undefined) {
switch (node.type) {
case "number_literal":
expression = this.#formatLiteral(
"number",
node.properties.value ?? 0
);
break;
case "string_literal":
expression = this.#formatLiteral(
"string",
node.properties.value ?? ""
);
break;
case "boolean_literal":
expression = this.#formatLiteral(
"boolean",
node.properties.value ?? "true"
);
break;
case "get_var": {
const name = this.#resolveWorkspaceVariableName(node);
expression = name;
@@ -721,18 +759,32 @@ export class LuaGenerator {
expression = parameterName ?? "nil";
break;
}
case "add_number": {
case "add_number":
case "math_add": {
const a = this.#resolveValueInput(node, "a", "0");
const b = this.#resolveValueInput(node, "b", "0");
expression = `(${a}) + (${b})`;
break;
}
case "multiply_number": {
case "math_subtract": {
const a = this.#resolveValueInput(node, "a", "0");
const b = this.#resolveValueInput(node, "b", "0");
expression = `(${a}) - (${b})`;
break;
}
case "multiply_number":
case "math_multiply": {
const a = this.#resolveValueInput(node, "a", "1");
const b = this.#resolveValueInput(node, "b", "1");
expression = `(${a}) * (${b})`;
break;
}
case "math_divide": {
const a = this.#resolveValueInput(node, "a", "0");
const b = this.#resolveValueInput(node, "b", "1");
expression = `(${a}) / (${b})`;
break;
}
case "compare": {
const a = this.#resolveValueInput(node, "a", "0");
const b = this.#resolveValueInput(node, "b", "0");
@@ -788,60 +840,6 @@ export class LuaGenerator {
return "==";
}
/**
* Formats a literal based on pin kind.
*
* @param {string} kind Pin kind.
* @param {unknown} value Raw value.
* @returns {string}
*/
#formatLiteral(kind, value) {
switch (kind) {
case "number": {
const numeric = Number(value);
return Number.isFinite(numeric) ? String(numeric) : "0";
}
case "boolean": {
if (typeof value === "string") {
return value === "false" ? "false" : "true";
}
return value ? "true" : "false";
}
case "table": {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed.length ? trimmed : "{}";
}
return "{}";
}
case "string":
return JSON.stringify(String(value ?? ""));
default:
return JSON.stringify(String(value ?? ""));
}
}
/**
* Provides a default literal for the supplied pin kind.
*
* @param {string} kind Pin kind reference.
* @returns {string}
*/
#defaultLiteralForKind(kind) {
switch (kind) {
case "number":
return "0";
case "boolean":
return "false";
case "string":
return '""';
case "table":
return "{}";
default:
return "nil";
}
}
/**
* Determines the display name for a custom event node.
*
@@ -1012,14 +1010,11 @@ export class LuaGenerator {
const value = variable?.defaultValue;
switch (type) {
case "number": {
const numeric = Number(value);
return Number.isFinite(numeric) ? String(numeric) : "0";
}
case "number":
case "boolean":
return value ? "true" : "false";
case "string":
return JSON.stringify(String(value ?? ""));
case "color":
return formatLiteral(type, value);
case "table": {
if (Array.isArray(value)) {
const fragments = value
@@ -1137,6 +1132,38 @@ export class LuaGenerator {
return this.#sanitizeIdentifier(fallback);
}
/**
* Gets the type of a workspace variable by its ID.
*
* @param {string} variableId Variable identifier.
* @returns {string}
*/
#getVariableType(variableId) {
const variable = this.variables.find((v) => v.id === variableId);
return variable?.type ?? "any";
}
/**
* Gets the output type for a node's output pin.
*
* @param {BlueprintNode} node Source node.
* @param {string} pinId Output pin identifier.
* @returns {string}
*/
#getOutputPinType(node, pinId) {
// For get_var nodes, return the actual variable type
if (node.type === "get_var") {
const variableId = node.properties?.variableId;
if (variableId) {
return this.#getVariableType(variableId);
}
}
// Otherwise, use the pin's declared kind
const pin = this.#findPin(node, pinId);
return pin?.kind ?? "any";
}
/**
* Normalizes a variable type descriptor.
*
@@ -1144,19 +1171,7 @@ export class LuaGenerator {
* @returns {string}
*/
#normalizeVariableType(value) {
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
switch (normalized) {
case "number":
case "string":
case "boolean":
case "table":
return normalized;
default:
return "any";
}
}
return "any";
return normalizeVariableType(value);
}
/**

View File

@@ -1,5 +1,6 @@
import { BlueprintNode } from "./BlueprintNode.js";
import { Connection } from "./Connection.js";
import { areTypesCompatible } from "../types/TypeRegistry.js";
/** @typedef {import('./BlueprintNode.js').BlueprintNodeInit} BlueprintNodeInit */
/** @typedef {import('./BlueprintNode.js').PinDescriptor} PinDescriptor */
@@ -321,11 +322,7 @@ export class NodeGraph extends EventTarget {
return false;
}
if (
toPin.kind !== "any" &&
fromPin.kind !== "any" &&
fromPin.kind !== toPin.kind
) {
if (!areTypesCompatible(fromPin.kind, toPin.kind)) {
return false;
}

268
scripts/core/SpriteSheet.js Normal file
View File

@@ -0,0 +1,268 @@
/**
* Manages PICO-8 sprite sheet data with 256 8x8 sprites.
* Total sheet size: 128x128 pixels, 16-color palette.
*/
export class SpriteSheet {
/**
* @param {Object} [options={}] Configuration options.
*/
constructor(options = {}) {
/** @type {Uint8Array} Pixel data (128x128, each byte is a palette index 0-15) */
this.pixels = new Uint8Array(128 * 128);
/** @type {Uint8Array} Sprite flags (256 sprites, 8 flags each) */
this.flags = new Uint8Array(256);
/** @type {Array<string>} PICO-8 default 16-color palette */
this.palette = [
"#000000", "#1D2B53", "#7E2553", "#008751",
"#AB5236", "#5F574F", "#C2C3C7", "#FFF1E8",
"#FF004D", "#FFA300", "#FFEC27", "#00E436",
"#29ADFF", "#83769C", "#FF77A8", "#FFCCAA"
];
this.#initialize(options);
}
/**
* Initializes sprite sheet data.
*
* @param {Object} options Configuration options.
*/
#initialize(options) {
if (options.pixels instanceof Uint8Array && options.pixels.length === 128 * 128) {
this.pixels.set(options.pixels);
}
if (options.flags instanceof Uint8Array && options.flags.length === 256) {
this.flags.set(options.flags);
}
}
/**
* Gets pixel color at coordinates.
*
* @param {number} x X coordinate (0-127).
* @param {number} y Y coordinate (0-127).
* @returns {number} Palette index (0-15).
*/
getPixel(x, y) {
if (x < 0 || x >= 128 || y < 0 || y >= 128) return 0;
return this.pixels[y * 128 + x];
}
/**
* Sets pixel color at coordinates.
*
* @param {number} x X coordinate (0-127).
* @param {number} y Y coordinate (0-127).
* @param {number} color Palette index (0-15).
*/
setPixel(x, y, color) {
if (x < 0 || x >= 128 || y < 0 || y >= 128) return;
this.pixels[y * 128 + x] = Math.max(0, Math.min(15, color));
}
/**
* Gets all pixels for a specific sprite.
*
* @param {number} spriteIndex Sprite number (0-255).
* @returns {Uint8Array} 8x8 pixel array.
*/
getSprite(spriteIndex) {
const spriteData = new Uint8Array(8 * 8);
const sx = (spriteIndex % 16) * 8;
const sy = Math.floor(spriteIndex / 16) * 8;
for (let y = 0; y < 8; y++) {
for (let x = 0; x < 8; x++) {
spriteData[y * 8 + x] = this.getPixel(sx + x, sy + y);
}
}
return spriteData;
}
/**
* Sets all pixels for a specific sprite.
*
* @param {number} spriteIndex Sprite number (0-255).
* @param {Uint8Array} data 8x8 pixel array.
*/
setSprite(spriteIndex, data) {
if (data.length !== 64) return;
const sx = (spriteIndex % 16) * 8;
const sy = Math.floor(spriteIndex / 16) * 8;
for (let y = 0; y < 8; y++) {
for (let x = 0; x < 8; x++) {
this.setPixel(sx + x, sy + y, data[y * 8 + x]);
}
}
}
/**
* Gets flags for a sprite.
*
* @param {number} spriteIndex Sprite number (0-255).
* @returns {number} 8-bit flag value.
*/
getFlags(spriteIndex) {
if (spriteIndex < 0 || spriteIndex >= 256) return 0;
return this.flags[spriteIndex];
}
/**
* Sets flags for a sprite.
*
* @param {number} spriteIndex Sprite number (0-255).
* @param {number} flagValue 8-bit flag value.
*/
setFlags(spriteIndex, flagValue) {
if (spriteIndex < 0 || spriteIndex >= 256) return;
this.flags[spriteIndex] = flagValue & 0xFF;
}
/**
* Checks if a specific flag is set.
*
* @param {number} spriteIndex Sprite number (0-255).
* @param {number} flagBit Flag bit (0-7).
* @returns {boolean}
*/
hasFlag(spriteIndex, flagBit) {
if (flagBit < 0 || flagBit > 7) return false;
return (this.getFlags(spriteIndex) & (1 << flagBit)) !== 0;
}
/**
* Sets a specific flag.
*
* @param {number} spriteIndex Sprite number (0-255).
* @param {number} flagBit Flag bit (0-7).
* @param {boolean} value Flag state.
*/
setFlag(spriteIndex, flagBit, value) {
if (flagBit < 0 || flagBit > 7) return;
const currentFlags = this.getFlags(spriteIndex);
const mask = 1 << flagBit;
if (value) {
this.setFlags(spriteIndex, currentFlags | mask);
} else {
this.setFlags(spriteIndex, currentFlags & ~mask);
}
}
/**
* Clears all sprite data.
*/
clear() {
this.pixels.fill(0);
this.flags.fill(0);
}
/**
* Serializes sprite sheet to JSON.
*
* @returns {Object}
*/
toJSON() {
return {
pixels: Array.from(this.pixels),
flags: Array.from(this.flags)
};
}
/**
* Creates sprite sheet from JSON data.
*
* @param {Object} data Serialized sprite data.
* @returns {SpriteSheet}
*/
static fromJSON(data) {
return new SpriteSheet({
pixels: new Uint8Array(data.pixels || []),
flags: new Uint8Array(data.flags || [])
});
}
/**
* Exports to PICO-8 .p8 format gfx section.
*
* @returns {string} Hex string for __gfx__ section.
*/
toP8Gfx() {
const lines = [];
for (let row = 0; row < 128; row++) {
let line = "";
for (let col = 0; col < 128; col += 2) {
const p1 = this.getPixel(col, row);
const p2 = this.getPixel(col + 1, row);
line += p1.toString(16) + p2.toString(16);
}
lines.push(line);
}
return lines.join("\n");
}
/**
* Imports from PICO-8 .p8 format gfx section.
*
* @param {string} gfxData Hex string from __gfx__ section.
*/
fromP8Gfx(gfxData) {
const lines = gfxData.trim().split("\n");
for (let row = 0; row < 128 && row < lines.length; row++) {
const line = lines[row].trim();
for (let col = 0; col < 128; col += 2) {
const hexPair = line.substring(col, col + 2);
if (hexPair.length === 2) {
this.setPixel(col, row, parseInt(hexPair[0], 16));
this.setPixel(col + 1, row, parseInt(hexPair[1], 16));
}
}
}
}
/**
* Exports sprite flags to PICO-8 .p8 format.
*
* @returns {string} Hex string for __gff__ section.
*/
toP8Flags() {
const lines = [];
for (let i = 0; i < 256; i += 2) {
const f1 = this.getFlags(i).toString(16).padStart(2, "0");
const f2 = this.getFlags(i + 1).toString(16).padStart(2, "0");
lines.push(f1 + f2);
}
return lines.join("\n");
}
/**
* Imports sprite flags from PICO-8 .p8 format.
*
* @param {string} flagData Hex string from __gff__ section.
*/
fromP8Flags(flagData) {
const lines = flagData.trim().split("\n");
for (let i = 0; i < 128 && i < lines.length; i++) {
const line = lines[i].trim();
if (line.length >= 2) {
this.setFlags(i * 2, parseInt(line.substring(0, 2), 16));
}
if (line.length >= 4) {
this.setFlags(i * 2 + 1, parseInt(line.substring(2, 4), 16));
}
}
}
}

167
scripts/core/TileSet.js Normal file
View File

@@ -0,0 +1,167 @@
/**
* Represents a single tileset with 8x8 tiles (128x128px total, 16x16 grid).
*/
export class TileSet {
/**
* @param {Object} options Configuration options.
* @param {string} options.id Unique identifier.
* @param {string} options.name Display name.
* @param {string} options.filePath Path or URL to PNG file (file:// or blob:).
* @param {string} [options.nativePath] Native file system path (for Electron).
* @param {number} [options.tileWidth=8] Tile width in pixels.
* @param {number} [options.tileHeight=8] Tile height in pixels.
* @param {number} [options.columns=16] Number of tiles per row (128px / 8px = 16).
* @param {number} [options.rows=16] Number of tile rows (128px / 8px = 16).
*/
constructor(options) {
this.id = options.id || this.#generateId();
this.name = options.name || "Untitled Tileset";
this.filePath = options.filePath || "";
this.nativePath = options.nativePath || "";
this.tileWidth = options.tileWidth || 8;
this.tileHeight = options.tileHeight || 8;
this.columns = options.columns || 16;
this.rows = options.rows || 16;
/** @type {HTMLImageElement | null} */
this.image = null;
/** @type {boolean} */
this.isLoaded = false;
}
/**
* Generates a unique identifier.
*
* @returns {string}
*/
#generateId() {
return `tileset_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
/**
* Gets the total number of tiles.
*
* @returns {number}
*/
getTileCount() {
return this.columns * this.rows;
}
/**
* Gets the total width of the tileset image.
*
* @returns {number}
*/
getWidth() {
return this.columns * this.tileWidth;
}
/**
* Gets the total height of the tileset image.
*
* @returns {number}
*/
getHeight() {
return this.rows * this.tileHeight;
}
/**
* Loads the tileset image from file path.
*
* @returns {Promise<void>}
*/
async load() {
if (!this.filePath) {
throw new Error("No file path specified");
}
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
// Validate image is exactly 128x128px
if (img.width !== 128 || img.height !== 128) {
reject(new Error(`Tileset must be exactly 128x128px (found ${img.width}x${img.height}px)`));
return;
}
this.image = img;
this.isLoaded = true;
// Fixed grid: 16x16 tiles for 128x128 image with 8x8 tiles
this.columns = 16;
this.rows = 16;
resolve();
};
img.onerror = () => {
reject(new Error(`Failed to load image: ${this.filePath}`));
};
// Add cache-busting for file:// URLs to ensure reload works
const cacheBuster = this.filePath.startsWith("file://")
? `?t=${Date.now()}`
: "";
img.src = this.filePath + cacheBuster;
});
}
/**
* Reloads the tileset image.
*
* @returns {Promise<void>}
*/
async reload() {
this.isLoaded = false;
this.image = null;
return this.load();
}
/**
* Gets tile coordinates for a specific tile index.
*
* @param {number} tileIndex Tile index (0-based).
* @returns {{x: number, y: number, width: number, height: number}}
*/
getTileRect(tileIndex) {
const col = tileIndex % this.columns;
const row = Math.floor(tileIndex / this.columns);
return {
x: col * this.tileWidth,
y: row * this.tileHeight,
width: this.tileWidth,
height: this.tileHeight
};
}
/**
* Serializes tileset to JSON.
*
* @returns {Object}
*/
toJSON() {
return {
id: this.id,
name: this.name,
filePath: this.filePath,
nativePath: this.nativePath,
tileWidth: this.tileWidth,
tileHeight: this.tileHeight,
columns: this.columns,
rows: this.rows
};
}
/**
* Creates tileset from JSON data.
*
* @param {Object} data Serialized tileset.
* @returns {TileSet}
*/
static fromJSON(data) {
return new TileSet(data);
}
}

View File

@@ -0,0 +1,41 @@
/**
* @typedef {import('./LuaLiteralTypes.js').LuaLiteralHandler} LuaLiteralHandler
*/
/** @type {LuaLiteralHandler} */
export const LuaLiteralBoolean = {
/**
* @param {unknown} value
* @returns {string}
*/
format(value) {
if (typeof value === "string") {
return value === "false" ? "false" : "true";
}
return value ? "true" : "false";
},
/** @returns {string} */
defaultLiteral() {
return "false";
},
spawnableByDefault: true,
nodeModule: {
definition: {
id: "boolean_literal",
title: "Boolean",
category: "Values",
description: "Constant boolean literal.",
searchTags: ["boolean", "literal", "true", "false"],
inputs: [],
outputs: [{ id: "value", name: "Value", direction: "output", kind: "boolean" }],
properties: [{ key: "value", label: "Value", type: "boolean", defaultValue: true }],
},
behavior: {
evaluateValue: ({ node, formatLiteral }) =>
formatLiteral("boolean", node.properties.value ?? true),
},
},
};

View File

@@ -0,0 +1,39 @@
/**
* @typedef {import('./LuaLiteralTypes.js').LuaLiteralHandler} LuaLiteralHandler
*/
/** @type {LuaLiteralHandler} */
export const LuaLiteralColor = {
/**
* @param {unknown} value
* @returns {string}
*/
format(value) {
const n = Math.max(0, Math.min(15, Math.floor(Number(value) || 0)));
return String(n);
},
/** @returns {string} */
defaultLiteral() {
return "7";
},
spawnableByDefault: true,
nodeModule: {
definition: {
id: "color_literal",
title: "Color",
category: "Values",
description: "Constant color literal (0-15).",
searchTags: ["color", "literal", "constant", "value"],
inputs: [],
outputs: [{ id: "value", name: "Value", direction: "output", kind: "color" }],
properties: [{ key: "value", label: "Color", type: "number", defaultValue: 7 }],
},
behavior: {
evaluateValue: ({ node, formatLiteral }) =>
formatLiteral("color", node.properties.value ?? 7),
},
},
};

View File

@@ -0,0 +1,39 @@
/**
* @typedef {import('./LuaLiteralTypes.js').LuaLiteralHandler} LuaLiteralHandler
*/
/** @type {LuaLiteralHandler} */
export const LuaLiteralNumber = {
/**
* @param {unknown} value
* @returns {string}
*/
format(value) {
const numeric = Number(value);
return Number.isFinite(numeric) ? String(numeric) : "0";
},
/** @returns {string} */
defaultLiteral() {
return "0";
},
spawnableByDefault: true,
nodeModule: {
definition: {
id: "number_literal",
title: "Number",
category: "Values",
description: "Constant numeric literal.",
searchTags: ["number", "literal", "constant", "value"],
inputs: [],
outputs: [{ id: "value", name: "Value", direction: "output", kind: "number" }],
properties: [{ key: "value", label: "Number", type: "number", defaultValue: 0 }],
},
behavior: {
evaluateValue: ({ node, formatLiteral }) =>
formatLiteral("number", node.properties.value ?? 0),
},
},
};

View File

@@ -0,0 +1,38 @@
/**
* @typedef {import('./LuaLiteralTypes.js').LuaLiteralHandler} LuaLiteralHandler
*/
/** @type {LuaLiteralHandler} */
export const LuaLiteralString = {
/**
* @param {unknown} value
* @returns {string}
*/
format(value) {
return JSON.stringify(String(value ?? ""));
},
/** @returns {string} */
defaultLiteral() {
return '""';
},
spawnableByDefault: true,
nodeModule: {
definition: {
id: "string_literal",
title: "String",
category: "Values",
description: "Constant string literal.",
searchTags: ["string", "literal", "text", "value"],
inputs: [],
outputs: [{ id: "value", name: "Value", direction: "output", kind: "string" }],
properties: [{ key: "value", label: "Text", type: "string", defaultValue: "hello" }],
},
behavior: {
evaluateValue: ({ node, formatLiteral }) =>
formatLiteral("string", node.properties.value ?? ""),
},
},
};

View File

@@ -0,0 +1,27 @@
/**
* @typedef {import('./LuaLiteralTypes.js').LuaLiteralHandler} LuaLiteralHandler
*/
/** @type {LuaLiteralHandler} */
export const LuaLiteralTable = {
/**
* @param {unknown} value
* @returns {string}
*/
format(value) {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed.length ? trimmed : "{}";
}
return "{}";
},
/** @returns {string} */
defaultLiteral() {
return "{}";
},
spawnableByDefault: false,
nodeModule: null,
};

View File

@@ -0,0 +1,56 @@
import { LuaLiteralNumber } from "./LuaLiteralNumber.js";
import { LuaLiteralBoolean } from "./LuaLiteralBoolean.js";
import { LuaLiteralString } from "./LuaLiteralString.js";
import { LuaLiteralTable } from "./LuaLiteralTable.js";
import { LuaLiteralColor } from "./LuaLiteralColor.js";
/**
* @typedef {Object} LuaLiteralHandler
* @property {(value: unknown) => string} format Formats a raw value into a Lua literal string.
* @property {() => string} defaultLiteral Returns the default Lua literal string for this type.
* @property {boolean} [spawnableByDefault] Whether this type creates a spawnable literal node in the graph.
* @property {import('../../nodes/nodeTypes.js').NodeModule | null} [nodeModule] Node module definition, or null if no literal node exists.
*/
/** @type {Map<string, LuaLiteralHandler>} */
const HANDLERS = new Map([
["number", LuaLiteralNumber],
["boolean", LuaLiteralBoolean],
["string", LuaLiteralString],
["table", LuaLiteralTable],
["color", LuaLiteralColor],
]);
/**
* Formats a raw value into a Lua literal string for the given pin kind.
*
* @param {string} kind Pin kind.
* @param {unknown} value Raw value.
* @returns {string}
*/
export function formatLiteral(kind, value) {
const handler = HANDLERS.get(kind);
return handler ? handler.format(value) : LuaLiteralString.format(value);
}
/**
* Returns the default Lua literal string for the given pin kind.
*
* @param {string} kind Pin kind.
* @returns {string}
*/
export function defaultLiteralForKind(kind) {
const handler = HANDLERS.get(kind);
return handler ? handler.defaultLiteral() : "nil";
}
/**
* Returns all node modules defined by literal handlers that have a nodeModule.
*
* @returns {Array<import('../../nodes/nodeTypes.js').NodeModule>}
*/
export function getLiteralNodeModules() {
return [...HANDLERS.values()]
.map((handler) => handler.nodeModule)
.filter(Boolean);
}

View File

@@ -2,6 +2,7 @@ import { BlueprintWorkspace } from "./ui/BlueprintWorkspace.js";
import { NodeRegistry } from "./nodes/NodeRegistry.js";
import { LuaGenerator } from "./core/LuaGenerator.js";
import { EmbeddedPreview } from "./ui/EmbeddedPreview.js";
import { TileManager } from "./ui/TileManager.js";
// Global error handlers
window.addEventListener('error', (event) => {
@@ -13,7 +14,18 @@ window.addEventListener('unhandledrejection', (event) => {
});
/**
* @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
*/
/**
@@ -80,8 +92,8 @@ const deleteNodeButton = /** @type {HTMLButtonElement} */ (
"deleteNodeButton"
)
);
const compileButton = /** @type {HTMLButtonElement} */ (
requireElement(document.getElementById("compileButton"), "compileButton")
const saveButton = /** @type {HTMLButtonElement} */ (
requireElement(document.getElementById("saveButton"), "saveButton")
);
const previewButton = /** @type {HTMLButtonElement} */ (
requireElement(document.getElementById("previewButton"), "previewButton")
@@ -107,6 +119,24 @@ const frameSelectionButton = /** @type {HTMLButtonElement} */ (
"frameSelectionButton"
)
);
const windowControls = /** @type {HTMLElement} */ (
requireElement(document.getElementById("windowControls"), "windowControls")
);
const minimizeWindowButton = /** @type {HTMLButtonElement} */ (
requireElement(
document.getElementById("minimizeWindowButton"),
"minimizeWindowButton"
)
);
const maximizeWindowButton = /** @type {HTMLButtonElement} */ (
requireElement(
document.getElementById("maximizeWindowButton"),
"maximizeWindowButton"
)
);
const closeWindowButton = /** @type {HTMLButtonElement} */ (
requireElement(document.getElementById("closeWindowButton"), "closeWindowButton")
);
const luaModal = /** @type {HTMLElement} */ (
requireElement(document.getElementById("luaModal"), "luaModal")
);
@@ -128,6 +158,12 @@ const closeLuaModalButton = /** @type {HTMLButtonElement} */ (
"closeLuaModalButton"
)
);
const tileManagerButton = /** @type {HTMLButtonElement} */ (
requireElement(
document.getElementById("tileManagerButton"),
"tileManagerButton"
)
);
const registry = new NodeRegistry();
const generator = new LuaGenerator(registry);
@@ -146,6 +182,7 @@ const workspace = new BlueprintWorkspace({
eventList,
variableList,
addVariableButton,
saveButton,
projectSettingsButton,
paletteToggleButton,
appBodyElement,
@@ -161,12 +198,99 @@ const embeddedPreview = new EmbeddedPreview({
},
});
// Initialize tile manager
const tileManager = new TileManager();
tileManager.initialize();
/** @type {ElectronAPI | undefined} */
const electronAPI =
typeof window !== "undefined" && "electronAPI" in window
? /** @type {ElectronAPI} */ (window.electronAPI)
: undefined;
// Get titlebar element
const titleBarElement = document.querySelector('.header-title');
/**
* Updates the window titlebar with token count information.
*
* @param {number} tokenCount Number of tokens in the cart
*/
function updateTitleBarWithTokens(tokenCount) {
if (titleBarElement) {
const maxTokens = 8192;
const percentage = ((tokenCount / maxTokens) * 100).toFixed(1);
titleBarElement.textContent = `picoGraph - ${tokenCount} / ${maxTokens} tokens (${percentage}%)`;
}
}
/**
* Generates cart content from Lua code.
*
* @param {string} luaCode Generated Lua code
* @returns {string}
*/
function generateCartContent(luaCode) {
const hasUpdateCallback = /function\s+_update(?:60)?\b|_update(?:60)?\s*=/.test(luaCode);
const hasDrawCallback = /function\s+_draw\b|_draw\s*=/.test(luaCode);
const fallback = (hasUpdateCallback && hasDrawCallback) ? '' :
(!hasUpdateCallback ? '\nfunction _update()\nend\n' : '') +
(!hasDrawCallback ? '\nfunction _draw()\n cls()\n print("picograph ok",2,60,7)\nend\n' : '');
return `pico-8 cartridge // http://www.pico-8.com
version 41
__lua__
${luaCode}${fallback}
__gfx__
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
`;
}
/**
* Updates token count display after workspace changes.
*/
async function updateTokenCount() {
if (!electronAPI?.getCartStats) {
return;
}
try {
const luaCode = workspace.exportLua();
let cartContent = generateCartContent(luaCode);
// Ensure Unix line endings (p8tool requires \n, not \r\n)
cartContent = cartContent.replace(/\r\n/g, '\n');
const stats = await electronAPI.getCartStats(cartContent);
updateTitleBarWithTokens(stats.tokens);
} catch (error) {
console.error("Failed to update token count:", error);
if (titleBarElement) {
titleBarElement.textContent = "picoGraph";
}
}
}
// Override handlePersistenceStateChange to also update token count
const originalHandlePersistenceStateChange = workspace.handlePersistenceStateChange.bind(workspace);
workspace.handlePersistenceStateChange = function(state) {
originalHandlePersistenceStateChange(state);
// Update token count when save completes (both auto and manual)
if (state?.status === "saved") {
updateTokenCount();
}
};
// Initial token count update after workspace loads
if (electronAPI?.getCartStats) {
updateTokenCount();
}
document.addEventListener("contextmenu", (event) => {
event.preventDefault();
});
@@ -232,7 +356,6 @@ let isLuaModalOpen = false;
let lastFocusedElement = /** @type {HTMLElement | null} */ (null);
let copyFeedbackTimeout = /** @type {number | null} */ (null);
let currentLuaSource = "";
let compileFeedbackTimeout = /** @type {number | null} */ (null);
const scheduleMicrotask = (callback) => {
if (typeof queueMicrotask === "function") {
@@ -374,83 +497,69 @@ const closeLuaModal = () => {
}
};
/**
* Restores the compile button to its idle appearance.
*
* @returns {void}
*/
const resetCompileButton = () => {
if (compileFeedbackTimeout !== null) {
window.clearTimeout(compileFeedbackTimeout);
compileFeedbackTimeout = null;
}
compileButton.classList.remove("is-success", "is-error", "is-busy");
compileButton.disabled = false;
compileButton.textContent = "Compile";
};
/**
* Handles compile button clicks by requesting a Lua export from Electron.
*
* @returns {Promise<void>}
*/
const handleCompileClick = async () => {
if (!electronAPI) {
return;
}
if (compileFeedbackTimeout !== null) {
window.clearTimeout(compileFeedbackTimeout);
compileFeedbackTimeout = null;
}
compileButton.classList.remove("is-success", "is-error");
compileButton.classList.add("is-busy");
compileButton.disabled = true;
compileButton.textContent = "Compiling...";
const source = workspace.exportLua();
try {
const result = await electronAPI.compileLua(source);
console.info("Lua compiled to", result.filePath);
compileButton.classList.remove("is-busy");
compileButton.classList.add("is-success");
compileButton.disabled = false;
compileButton.textContent = "Compiled!";
compileFeedbackTimeout = window.setTimeout(() => {
resetCompileButton();
}, 1800);
} catch (error) {
console.error("Failed to compile Lua", error);
compileButton.classList.remove("is-busy");
compileButton.classList.add("is-error");
compileButton.disabled = false;
compileButton.textContent = "Failed";
compileFeedbackTimeout = window.setTimeout(() => {
resetCompileButton();
}, 2200);
}
};
if (electronAPI && typeof electronAPI.compileLua === "function") {
compileButton.disabled = false;
compileButton.addEventListener("click", () => {
handleCompileClick().catch((error) => {
console.error("Unhandled compile error", error);
resetCompileButton();
});
});
} else {
compileButton.disabled = true;
compileButton.title = "Compile is available in the desktop app";
}
previewButton.addEventListener("click", () => {
embeddedPreview.open();
});
const setMaximizeButtonState = (isMaximized) => {
maximizeWindowButton.classList.toggle("is-active", Boolean(isMaximized));
maximizeWindowButton.setAttribute(
"title",
isMaximized ? "Restore" : "Maximize"
);
maximizeWindowButton.setAttribute(
"aria-label",
isMaximized ? "Restore window" : "Maximize window"
);
};
if (
electronAPI &&
typeof electronAPI.minimizeWindow === "function" &&
typeof electronAPI.toggleMaximizeWindow === "function" &&
typeof electronAPI.closeWindow === "function"
) {
minimizeWindowButton.addEventListener("click", () => {
electronAPI.minimizeWindow().catch((error) => {
console.error("Failed to minimize window", error);
});
});
maximizeWindowButton.addEventListener("click", () => {
electronAPI.toggleMaximizeWindow()
.then((isMaximized) => {
setMaximizeButtonState(isMaximized);
})
.catch((error) => {
console.error("Failed to toggle maximize state", error);
});
});
closeWindowButton.addEventListener("click", () => {
electronAPI.closeWindow().catch((error) => {
console.error("Failed to close window", error);
});
});
if (typeof electronAPI.getWindowMaximized === "function") {
electronAPI.getWindowMaximized()
.then((isMaximized) => {
setMaximizeButtonState(isMaximized);
})
.catch((error) => {
console.error("Failed to query maximize state", error);
});
}
if (typeof electronAPI.onWindowMaximizedChanged === "function") {
electronAPI.onWindowMaximizedChanged((isMaximized) => {
setMaximizeButtonState(isMaximized);
});
}
} else {
windowControls.hidden = true;
}
exportLuaButton.addEventListener("click", () => {
openLuaModal();
});

View File

@@ -1,15 +1,10 @@
import { getLiteralNodeModules } from "../../core/lua/LuaLiteralTypes.js";
import { eventInitNode } from "./eventStart.js";
import { eventUpdateNode } from "./eventUpdate.js";
import { eventDrawNode } from "./eventDraw.js";
import { printNode } from "./print.js";
import { setVariableNode } from "./setVariable.js";
import { getVariableNode } from "./getVariable.js";
import { numberLiteralNode } from "./numberLiteral.js";
import { stringLiteralNode } from "./stringLiteral.js";
import { booleanLiteralNode } from "./booleanLiteral.js";
import { colorLiteralNode } from "./colorLiteral.js";
import { addNumberNode } from "./addNumber.js";
import { multiplyNumberNode } from "./multiplyNumber.js";
import { compareNode } from "./compare.js";
import { sequenceNode } from "./sequence.js";
import { ifNode } from "./ifNode.js";
@@ -33,20 +28,16 @@ import { devkitNodes, devkitFunctionChecklist } from "./devkit/index.js";
import { luaNodes, luaFunctionChecklist } from "./lua/index.js";
import { localVariableNodes } from "./localVariables.js";
import { converterNodes } from "./converters/index.js";
import { rerouteNode, rerouteExecNode } from "./reroute.js";
export const nodeModules = [
...getLiteralNodeModules(),
eventInitNode,
eventUpdateNode,
eventDrawNode,
printNode,
setVariableNode,
getVariableNode,
numberLiteralNode,
stringLiteralNode,
booleanLiteralNode,
colorLiteralNode,
addNumberNode,
multiplyNumberNode,
compareNode,
sequenceNode,
ifNode,
@@ -55,6 +46,8 @@ export const nodeModules = [
callCustomEventNode,
...localVariableNodes,
...converterNodes,
rerouteNode,
rerouteExecNode,
...graphicsNodes,
...systemNodes,
...tableNodes,

View File

@@ -90,6 +90,8 @@ export const controllerButtonsNode = createNodeModule(
name: "Button",
direction: "input",
kind: "number",
defaultValue: DEFAULT_BUTTON_VALUE,
options: BUTTON_OPTIONS,
description: "Optional button index override",
},
{
@@ -97,6 +99,8 @@ export const controllerButtonsNode = createNodeModule(
name: "Player",
direction: "input",
kind: "number",
defaultValue: DEFAULT_PLAYER_VALUE,
options: PLAYER_OPTIONS,
description: "Optional player index override",
},
],

View File

@@ -1,19 +1,17 @@
import { createNodeModule } from "../nodeTypes.js";
import {
getLocalVariableTypeOptions,
variablePropertyKey,
localVariableDefault,
LOCAL_VARIABLE_TYPE_IDS,
} from "../../types/TypeRegistry.js";
const LOCAL_TYPES = [
{ value: "number", label: "Number" },
{ value: "string", label: "String" },
{ value: "boolean", label: "Boolean" },
{ value: "table", label: "Table" },
];
const LOCAL_TYPES = getLocalVariableTypeOptions();
const DEFAULT_LOCAL_TYPE = LOCAL_TYPES[0]?.value ?? "number";
const sanitizeType = (raw) => {
const candidate = typeof raw === "string" ? raw.toLowerCase() : DEFAULT_LOCAL_TYPE;
return LOCAL_TYPES.some((entry) => entry.value === candidate)
? candidate
: DEFAULT_LOCAL_TYPE;
const candidate = typeof raw === "string" ? raw.trim().toLowerCase() : "";
return LOCAL_VARIABLE_TYPE_IDS.has(candidate) ? candidate : DEFAULT_LOCAL_TYPE;
};
const ensureName = (raw, fallback = "localVar") => {
@@ -21,33 +19,10 @@ const ensureName = (raw, fallback = "localVar") => {
return value.length ? value : fallback;
};
const resolveValuePropertyKey = (type) => {
switch (type) {
case "string":
return "valueString";
case "boolean":
return "valueBoolean";
case "table":
return "valueTable";
case "number":
default:
return "valueNumber";
}
};
const resolveValuePropertyKey = variablePropertyKey;
const defaultValueForType = localVariableDefault;
const defaultValueForType = (type) => {
switch (type) {
case "string":
return "";
case "boolean":
return false;
case "table":
return "{}";
case "number":
default:
return 0;
}
};
const initializeLocalProperties = (properties, { includeValue }) => {
const type = sanitizeType(properties.variableType);
@@ -68,6 +43,15 @@ const initializeLocalProperties = (properties, { includeValue }) => {
if (typeof properties.valueTable !== "string") {
properties.valueTable = String(properties.valueTable ?? "{}");
}
if (
typeof properties.valueColor !== "number" ||
!Number.isFinite(properties.valueColor)
) {
const n = Number(properties.valueColor);
properties.valueColor = Number.isFinite(n)
? Math.max(0, Math.min(15, Math.round(n)))
: 7;
}
if (includeValue) {
const key = resolveValuePropertyKey(type);

View File

@@ -0,0 +1,41 @@
import { createNodeModule } from "../../nodeTypes.js";
/**
* Adds two numeric values.
*/
export const mathAddNode = createNodeModule(
{
id: "math_add",
title: "Add",
category: "Math",
description: "Add two numbers.",
searchTags: ["add", "math", "sum", "plus", "number", "+"],
inputs: [
{
id: "a",
name: "A",
direction: "input",
kind: "number",
defaultValue: 0,
},
{
id: "b",
name: "B",
direction: "input",
kind: "number",
defaultValue: 0,
},
],
outputs: [
{ id: "res", name: "Result", direction: "output", kind: "number" },
],
properties: [],
},
{
evaluateValue: ({ resolveValueInput }) => {
const a = resolveValueInput("a", "0");
const b = resolveValueInput("b", "0");
return `(${a}) + (${b})`;
},
}
);

View File

@@ -0,0 +1,41 @@
import { createNodeModule } from "../../nodeTypes.js";
/**
* Divides one numeric value by another.
*/
export const mathDivideNode = createNodeModule(
{
id: "math_divide",
title: "Divide",
category: "Math",
description: "Divide one number by another (floating point division).",
searchTags: ["divide", "division", "math", "slash", "number", "/"],
inputs: [
{
id: "a",
name: "A",
direction: "input",
kind: "number",
defaultValue: 0,
},
{
id: "b",
name: "B",
direction: "input",
kind: "number",
defaultValue: 1,
},
],
outputs: [
{ id: "res", name: "Result", direction: "output", kind: "number" },
],
properties: [],
},
{
evaluateValue: ({ resolveValueInput }) => {
const a = resolveValueInput("a", "0");
const b = resolveValueInput("b", "1");
return `(${a}) / (${b})`;
},
}
);

View File

@@ -1,3 +1,7 @@
import { mathAddNode } from "./add.js";
import { mathSubtractNode } from "./subtract.js";
import { mathMultiplyNode } from "./multiply.js";
import { mathDivideNode } from "./divide.js";
import { mathMaxNode } from "./max.js";
import { mathMinNode } from "./min.js";
import { mathMidNode } from "./mid.js";
@@ -25,6 +29,10 @@ import { mathDivNode } from "./div.js";
* All math-related node modules backed by PICO-8 helpers.
*/
export const mathNodes = [
mathAddNode,
mathSubtractNode,
mathMultiplyNode,
mathDivideNode,
mathMaxNode,
mathMinNode,
mathMidNode,
@@ -54,6 +62,10 @@ export const mathNodes = [
* @type {Array<{ function: string, nodeId: string, implemented: boolean }>}
*/
export const mathFunctionChecklist = [
{ function: "+", nodeId: "math_add", implemented: true },
{ function: "-", nodeId: "math_subtract", implemented: true },
{ function: "*", nodeId: "math_multiply", implemented: true },
{ function: "/", nodeId: "math_divide", implemented: true },
{ function: "MAX", nodeId: "math_max", implemented: true },
{ function: "MIN", nodeId: "math_min", implemented: true },
{ function: "MID", nodeId: "math_mid", implemented: true },

View File

@@ -0,0 +1,41 @@
import { createNodeModule } from "../../nodeTypes.js";
/**
* Multiplies two numeric values.
*/
export const mathMultiplyNode = createNodeModule(
{
id: "math_multiply",
title: "Multiply",
category: "Math",
description: "Multiply two numbers.",
searchTags: ["multiply", "product", "math", "times", "*"],
inputs: [
{
id: "a",
name: "A",
direction: "input",
kind: "number",
defaultValue: 0,
},
{
id: "b",
name: "B",
direction: "input",
kind: "number",
defaultValue: 0,
},
],
outputs: [
{ id: "res", name: "Result", direction: "output", kind: "number" },
],
properties: [],
},
{
evaluateValue: ({ resolveValueInput }) => {
const a = resolveValueInput("a", "0");
const b = resolveValueInput("b", "0");
return `(${a}) * (${b})`;
},
}
);

View File

@@ -0,0 +1,41 @@
import { createNodeModule } from "../../nodeTypes.js";
/**
* Subtracts one numeric value from another.
*/
export const mathSubtractNode = createNodeModule(
{
id: "math_subtract",
title: "Subtract",
category: "Math",
description: "Subtract one number from another.",
searchTags: ["subtract", "math", "minus", "difference", "-"],
inputs: [
{
id: "a",
name: "A",
direction: "input",
kind: "number",
defaultValue: 0,
},
{
id: "b",
name: "B",
direction: "input",
kind: "number",
defaultValue: 0,
},
],
outputs: [
{ id: "res", name: "Result", direction: "output", kind: "number" },
],
properties: [],
},
{
evaluateValue: ({ resolveValueInput }) => {
const a = resolveValueInput("a", "0");
const b = resolveValueInput("b", "0");
return `(${a}) - (${b})`;
},
}
);

View File

@@ -0,0 +1,53 @@
import { createNodeModule } from "../nodeTypes.js";
/**
* A transparent pass-through node for redirecting data wires.
* Emits no code on its own — the value resolves directly through the input.
*/
export const rerouteNode = createNodeModule(
{
id: "reroute",
title: "Reroute",
category: "Utility",
description: "Redirects a data wire without affecting the value.",
searchTags: ["reroute", "redirect", "wire", "relay", "pass", "through"],
inputs: [
{ id: "value", name: "", direction: "input", kind: "any" },
],
outputs: [
{ id: "value", name: "", direction: "output", kind: "any" },
],
properties: [],
},
{
evaluateValue: ({ resolveValueInput }) => {
return resolveValueInput("value", "nil");
},
}
);
/**
* A transparent pass-through node for redirecting exec wires.
* Emits no code on its own — execution continues directly to the next node.
*/
export const rerouteExecNode = createNodeModule(
{
id: "reroute_exec",
title: "Reroute",
category: "Utility",
description: "Redirects an exec wire without generating any code.",
searchTags: ["reroute", "redirect", "exec", "wire", "relay", "pass", "through"],
inputs: [
{ id: "exec_in", name: "", direction: "input", kind: "exec" },
],
outputs: [
{ id: "exec_out", name: "", direction: "output", kind: "exec" },
],
properties: [],
},
{
emitExec: ({ emitNextExec }) => {
return emitNextExec("exec_out");
},
}
);

View File

@@ -0,0 +1,41 @@
import { createNodeModule } from "../nodeTypes.js";
/**
* Subtracts one numeric value from another.
*/
export const subtractNumberNode = createNodeModule(
{
id: "subtract_number",
title: "Subtract",
category: "Math",
description: "Subtract one number from another.",
searchTags: ["subtract", "math", "minus", "difference", "number"],
inputs: [
{
id: "a",
name: "A",
direction: "input",
kind: "number",
defaultValue: 0,
},
{
id: "b",
name: "B",
direction: "input",
kind: "number",
defaultValue: 0,
},
],
outputs: [
{ id: "res", name: "Result", direction: "output", kind: "number" },
],
properties: [],
},
{
evaluateValue: ({ resolveValueInput }) => {
const a = resolveValueInput("a", "0");
const b = resolveValueInput("b", "0");
return `(${a}) - (${b})`;
},
}
);

View File

@@ -41,7 +41,7 @@ export const extcmdNode = createNodeModule(
],
inputs: [
{ id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
{ id: "command", name: "Command", direction: "input", kind: "string" },
{ id: "command", name: "Command", direction: "input", kind: "string", defaultValue: EXT_CMD_DEFAULT, options: EXT_CMD_OPTIONS },
{ id: "param1", name: "Param 1", direction: "input", kind: "number" },
{ id: "param2", name: "Param 2", direction: "input", kind: "number" },
],

View File

@@ -16,6 +16,8 @@
* @property {('exec'|'number'|'boolean'|'string'|'table'|'any'|'color')} kind Blueprint connection kind.
* @property {string} [description] Optional tooltip text.
* @property {unknown} [defaultValue] Default literal for data pins.
* @property {Array<{label: string, value: string}>} [options] Renders an inline select dropdown when the pin is unconnected.
* @property {boolean} [spawnLiteralNode] Whether dragging off this pin offers the default literal node. Defaults to the handler's spawnableByDefault.
*/
/**

13
scripts/types/AnyType.js Normal file
View File

@@ -0,0 +1,13 @@
/**
* @typedef {import('./TypeRegistry.js').TypeDescriptor} TypeDescriptor
*/
/** @type {TypeDescriptor} */
export const AnyType = {
id: "any",
label: "Any",
color: "#ff7f11",
isVariable: true,
isLocalVariable: false,
defaultValue: null,
};

View File

@@ -0,0 +1,19 @@
/**
* @typedef {import('./TypeRegistry.js').TypeDescriptor} TypeDescriptor
*/
/** @type {TypeDescriptor} */
export const BooleanType = {
id: "boolean",
label: "Boolean",
color: "#ff4f4f",
isVariable: true,
isLocalVariable: true,
defaultValue: false,
variablePropertyKey: "valueBoolean",
compatibleWith: [
{ type: "string", convert: (v) => v ? "true" : "false" },
{ type: "number", convert: (v) => v ? 1 : 0 },
{ type: "color", convert: (v) => v ? 7 : 0 }
],
};

View File

@@ -0,0 +1,18 @@
/**
* @typedef {import('./TypeRegistry.js').TypeDescriptor} TypeDescriptor
*/
/** @type {TypeDescriptor} */
export const ColorType = {
id: "color",
label: "Color",
color: "#5b8ef5",
isVariable: true,
isLocalVariable: true,
defaultValue: 7,
variablePropertyKey: "valueColor",
compatibleWith: [
{ type: "number", convert: (v) => Number(v) },
{ type: "boolean", convert: (v) => v !== 0 }
],
};

13
scripts/types/ExecType.js Normal file
View File

@@ -0,0 +1,13 @@
/**
* Type descriptor for execution flow pins.
*
* @type {import('./TypeRegistry.js').TypeDescriptor}
*/
export const ExecType = {
id: "exec",
label: "Exec",
color: "#ffffff",
isVariable: false,
isLocalVariable: false,
defaultValue: null,
};

View File

@@ -0,0 +1,17 @@
/**
* @typedef {import('./TypeRegistry.js').TypeDescriptor} TypeDescriptor
*/
/** @type {TypeDescriptor} */
export const NumberType = {
id: "number",
label: "Number",
color: "#3ee581",
isVariable: true,
isLocalVariable: true,
defaultValue: 0,
variablePropertyKey: "valueNumber",
compatibleWith: [
{ type: "color", convert: (v) => Math.floor(Number(v)) % 16 },
],
};

View File

@@ -0,0 +1,14 @@
/**
* @typedef {import('./TypeRegistry.js').TypeDescriptor} TypeDescriptor
*/
/** @type {TypeDescriptor} */
export const StringType = {
id: "string",
label: "String",
color: "#ff66ff",
isVariable: true,
isLocalVariable: true,
defaultValue: "",
variablePropertyKey: "valueString",
};

View File

@@ -0,0 +1,15 @@
/**
* @typedef {import('./TypeRegistry.js').TypeDescriptor} TypeDescriptor
*/
/** @type {TypeDescriptor} */
export const TableType = {
id: "table",
label: "Table",
color: "#5ec4ff",
isVariable: true,
isLocalVariable: true,
defaultValue: [],
localDefaultValue: "{}",
variablePropertyKey: "valueTable",
};

View File

@@ -0,0 +1,165 @@
import { AnyType } from "./AnyType.js";
import { NumberType } from "./NumberType.js";
import { StringType } from "./StringType.js";
import { BooleanType } from "./BooleanType.js";
import { TableType } from "./TableType.js";
import { ColorType } from "./ColorType.js";
import { ExecType } from "./ExecType.js";
/**
* @typedef {'any'|'number'|'string'|'boolean'|'table'|'color'} VariableType
*/
/**
* @typedef {Object} TypeCompatibility
* @property {string} type Target type ID this type can connect to.
* @property {(value: unknown) => unknown} [convert] Optional conversion function.
*/
/**
* @typedef {Object} TypeDescriptor
* @property {string} id Type identifier.
* @property {string} label Display label.
* @property {string} color Accent color used for pins, wires, and variable labels (hex or CSS variable).
* @property {string} [icon] Optional icon character or symbol shown in type labels.
* @property {boolean} isVariable Whether usable as a workspace variable type.
* @property {boolean} isLocalVariable Whether usable as a local variable node type.
* @property {unknown} defaultValue Default value for workspace variables.
* @property {unknown} [localDefaultValue] Default for local variable nodes when different from defaultValue.
* @property {string} [variablePropertyKey] Property key used in local variable nodes.
* @property {ReadonlyArray<TypeCompatibility>} [compatibleWith] Types this type can implicitly convert to.
*/
/** @type {ReadonlyArray<TypeDescriptor>} */
export const ALL_TYPES = [
ExecType,
AnyType,
NumberType,
StringType,
BooleanType,
TableType,
ColorType,
];
/** @type {Map<string, TypeDescriptor>} */
const TYPE_MAP = new Map(ALL_TYPES.map((t) => [t.id, t]));
/** @type {ReadonlySet<string>} */
export const LOCAL_VARIABLE_TYPE_IDS = new Set(
ALL_TYPES.filter((t) => t.isLocalVariable).map((t) => t.id)
);
/**
* Returns type options available for workspace variable type dropdowns.
*
* @returns {Array<{ value: string, label: string }>}
*/
export function getVariableTypeOptions() {
return ALL_TYPES.filter((t) => t.isVariable).map((t) => ({ value: t.id, label: t.label }));
}
/**
* Returns type options available for local variable node type dropdowns.
*
* @returns {Array<{ value: string, label: string }>}
*/
export function getLocalVariableTypeOptions() {
return ALL_TYPES.filter((t) => t.isLocalVariable).map((t) => ({ value: t.id, label: t.label }));
}
/**
* Normalizes an arbitrary value to a valid registered type id, falling back to "any".
*
* @param {unknown} value Candidate type value.
* @returns {string}
*/
export function normalizeVariableType(value) {
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (TYPE_MAP.has(normalized)) return normalized;
}
return "any";
}
/**
* Returns the default value for a workspace variable of the given type.
*
* @param {string} type Type identifier.
* @returns {unknown}
*/
export function defaultVariableValue(type) {
return TYPE_MAP.get(type)?.defaultValue ?? null;
}
/**
* Returns the default value for a local variable node property of the given type.
*
* @param {string} type Type identifier.
* @returns {unknown}
*/
export function localVariableDefault(type) {
const desc = TYPE_MAP.get(type);
if (!desc) return 0;
return desc.localDefaultValue !== undefined ? desc.localDefaultValue : desc.defaultValue;
}
/**
* Returns the property key used to store the typed value in a local variable node.
*
* @param {string} type Type identifier.
* @returns {string}
*/
export function variablePropertyKey(type) {
return TYPE_MAP.get(type)?.variablePropertyKey ?? "valueNumber";
}
/**
* Returns the accent color for the given type (used for pins, wires, and variable labels).
*
* @param {string} type Type identifier.
* @returns {string}
*/
export function getTypeColor(type) {
return TYPE_MAP.get(type)?.color ?? "#ffffff";
}
/**
* Determines if two types are compatible for pin connections.
*
* @param {string} outputType The output pin type.
* @param {string} inputType The input pin type.
* @returns {boolean}
*/
export function areTypesCompatible(outputType, inputType) {
if (inputType === "any" || outputType === "any") {
return true;
}
if (outputType === inputType) {
return true;
}
const outputDesc = TYPE_MAP.get(outputType);
if (outputDesc?.compatibleWith?.some((c) => c.type === inputType)) {
return true;
}
const inputDesc = TYPE_MAP.get(inputType);
if (inputDesc?.compatibleWith?.some((c) => c.type === outputType)) {
return true;
}
return false;
}
/**
* Gets the conversion function for converting from one type to another.
*
* @param {string} fromType Source type ID.
* @param {string} toType Target type ID.
* @returns {((value: unknown) => unknown) | undefined}
*/
export function getTypeConverter(fromType, toType) {
if (fromType === toType) {
return (v) => v;
}
const fromDesc = TYPE_MAP.get(fromType);
const compat = fromDesc?.compatibleWith?.find((c) => c.type === toType);
return compat?.convert;
}

View File

@@ -1,4 +1,5 @@
import { NodeGraph } from "../core/NodeGraph.js";
import { createColorSwatchPicker } from "./ColorSwatchPicker.js";
import { WorkspaceGeometry } from "./workspace/WorkspaceGeometry.js";
import { WorkspaceDragManager } from "./workspace/WorkspaceDragManager.js";
import { WorkspaceHistoryManager } from "./workspace/WorkspaceHistoryManager.js";
@@ -18,25 +19,18 @@ import { renderVariableList } from "./workspace/renderVariableList.js";
import {
WORKSPACE_SHORTCUTS,
} from "./WorkspaceSpawnShortcuts.js";
import {
normalizeVariableType,
getVariableTypeOptions,
defaultVariableValue as getDefaultVariableValue,
getTypeColor,
areTypesCompatible,
} from "../types/TypeRegistry.js";
/**
* @typedef {'any'|'number'|'string'|'boolean'|'table'|'color'} 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" },
{ value: "color", label: "Color" },
];
const SUPPORTED_VARIABLE_TYPES = new Set(
VARIABLE_TYPE_DEFINITIONS.map((entry) => entry.value)
);
/** @typedef {import('./WorkspaceSpawnShortcuts.js').WorkspaceShortcut} WorkspaceShortcut */
/** @typedef {import('./WorkspaceSpawnShortcuts.js').WorkspaceShortcutModifier} WorkspaceShortcutModifier */
@@ -88,6 +82,7 @@ const SUPPORTED_VARIABLE_TYPES = new Set(
* @property {HTMLUListElement} eventList Event summary list element.
* @property {HTMLUListElement} variableList Variable summary list element.
* @property {HTMLButtonElement} addVariableButton Global variable creation button.
* @property {HTMLButtonElement} saveButton Save action button.
* @property {HTMLButtonElement} projectSettingsButton Project settings toggle.
* @property {HTMLButtonElement} paletteToggleButton Palette toggle control.
* @property {HTMLElement} appBodyElement Layout container hosting the workspace panes.
@@ -146,6 +141,8 @@ export class BlueprintWorkspace {
this.variableListElement = options.variableList;
this.addVariableButton = options.addVariableButton;
/** @type {HTMLButtonElement} */
this.saveButton = options.saveButton;
/** @type {HTMLButtonElement} */
this.projectSettingsButton = options.projectSettingsButton;
/** @type {HTMLButtonElement} */
this.frameSelectionButton = options.frameSelectionButton;
@@ -180,6 +177,10 @@ export class BlueprintWorkspace {
this.connectionRefreshFrame = null;
/** @type {number | null} */
this.periodicUpdateTimer = null;
/** @type {number | null} */
this.saveFeedbackTimer = null;
/** @type {boolean} */
this.hasPendingSave = false;
this.graph = new NodeGraph();
/** @type {Map<string, WorkspaceVariable>} */
@@ -398,6 +399,7 @@ export class BlueprintWorkspace {
this.navigationManager.updateZoomDisplay();
this.navigationManager.setBackgroundOffset({ x: 0, y: 0 });
this.#resetSaveButtonAppearance();
if (typeof this.generator.setProjectSettings === "function") {
this.generator.setProjectSettings({ ...this.projectSettings });
@@ -445,6 +447,101 @@ export class BlueprintWorkspace {
}
}
/**
* 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();
}
/**
* Reverts the most recent checkpoint.
*/
@@ -466,6 +563,41 @@ export class BlueprintWorkspace {
return this.#refreshLuaOutput();
}
/**
* Persists the current workspace state immediately.
*
* @returns {boolean}
*/
saveWorkspace() {
if (!this.persistenceManager?.isStorageAvailable) {
return false;
}
this.persistenceManager.persistImmediately("manual");
return true;
}
/**
* Reflects persistence lifecycle changes in the save button state.
*
* @param {{ status: 'idle'|'pending'|'saving'|'saved'|'error', source: 'manual'|'auto' }} state
*/
handlePersistenceStateChange(state) {
if (!state) {
return;
}
if (state.status === "pending") {
this.hasPendingSave = true;
} else if (state.status === "saved") {
this.hasPendingSave = false;
} else if (state.status === "error") {
this.hasPendingSave = true;
}
this.#updateSaveButtonAppearance(state);
}
/**
* Subscribes to graph lifecycle changes.
*/
@@ -699,6 +831,12 @@ export class BlueprintWorkspace {
* Registers DOM event listeners for interactivity.
*/
#bindUiEvents() {
if (this.saveButton) {
this.saveButton.addEventListener("click", () => {
this.saveWorkspace();
});
}
if (this.paletteToggleButton) {
this.paletteToggleButton.addEventListener("click", () => {
this.#togglePaletteVisibility();
@@ -1755,18 +1893,7 @@ export class BlueprintWorkspace {
* @returns {VariableDefaultValue}
*/
#defaultVariableValue(type) {
switch (type) {
case "number":
return 0;
case "string":
return "";
case "table":
return [];
case "boolean":
return false;
default:
return null;
}
return /** @type {VariableDefaultValue} */ (getDefaultVariableValue(type));
}
/**
@@ -1799,6 +1926,11 @@ export class BlueprintWorkspace {
}
return 0;
}
case "color": {
const n = typeof value === "number" ? value : Number(value);
const safe = Number.isFinite(n) ? Math.round(n) : 7;
return Math.max(0, Math.min(15, safe));
}
case "boolean": {
if (typeof value === "boolean") {
return value;
@@ -2133,6 +2265,18 @@ export class BlueprintWorkspace {
this.#setVariableDefault(variable.id, defaultToggle.checked);
});
defaultField.appendChild(defaultToggle);
} else if (variable.type === "color") {
const rawDefault = variable.defaultValue;
const n = typeof rawDefault === "number" ? rawDefault : Number(rawDefault);
const initialIndex = Number.isFinite(n) ? Math.max(0, Math.min(15, Math.round(n))) : 7;
const picker = createColorSwatchPicker({
id: defaultId,
value: initialIndex,
onChange: (index) => {
this.#setVariableDefault(variable.id, index);
},
});
defaultField.appendChild(picker.element);
} else if (variable.type === "number") {
const defaultInput = document.createElement("input");
defaultInput.type = "number";
@@ -2607,6 +2751,7 @@ export class BlueprintWorkspace {
option.type = "button";
option.className = "variable-type-option";
option.dataset.variableType = entry.value;
option.style.setProperty("--overview-variable-color", getTypeColor(entry.value));
option.textContent = entry.label;
option.setAttribute("role", "menuitemradio");
option.setAttribute(
@@ -3028,12 +3173,8 @@ export class BlueprintWorkspace {
return null;
}
const rect = workspaceElement.getBoundingClientRect();
const screenPoint = {
x: Math.max(0, Math.min(rect.width, event.clientX - rect.left)),
y: Math.max(0, Math.min(rect.height, event.clientY - rect.top)),
};
const worldPoint = navigator.screenPointToWorld(screenPoint);
// Use dragManager.clientToWorkspace for proper coordinate conversion
const worldPoint = this.dragManager.clientToWorkspace(event.clientX, event.clientY);
return this.addNode(shortcut.definitionId, worldPoint);
}
@@ -3406,9 +3547,7 @@ export class BlueprintWorkspace {
* @returns {string}
*/
#formatVariableType(type) {
const entry = VARIABLE_TYPE_DEFINITIONS.find(
(definition) => definition.value === type
);
const entry = getVariableTypeOptions().find((opt) => opt.value === type);
return entry ? entry.label : "Any";
}
@@ -3419,17 +3558,7 @@ export class BlueprintWorkspace {
* @returns {VariableType}
*/
#normalizeVariableType(value) {
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (
SUPPORTED_VARIABLE_TYPES.has(
/** @type {VariableType} */ (normalized)
)
) {
return /** @type {VariableType} */ (normalized);
}
}
return "any";
return /** @type {VariableType} */ (normalizeVariableType(value));
}
/**
@@ -3438,7 +3567,7 @@ export class BlueprintWorkspace {
* @returns {Array<{ value: VariableType, label: string }>}
*/
#getVariableTypeOptions() {
return VARIABLE_TYPE_DEFINITIONS.map((entry) => ({ ...entry }));
return getVariableTypeOptions();
}
/**
@@ -3641,12 +3770,14 @@ export class BlueprintWorkspace {
node.type === "set_local_var" || node.type === "get_local_var";
article.classList.add("blueprint-node--variable");
article.dataset.variableType = binding.type;
article.style.setProperty("--overview-variable-color", getTypeColor(binding.type));
article.dataset.variableRole = role;
article.dataset.variableScope = isLocal ? "local" : "global";
header.classList.add("node-header--variable");
header.setAttribute("data-variable-role", role);
header.setAttribute("data-variable-type", binding.type);
header.style.setProperty("--overview-variable-color", getTypeColor(binding.type));
header.setAttribute("data-variable-scope", isLocal ? "local" : "global");
const prefixBase = role === "set" ? "Set" : "Get";
const prefix = isLocal ? `${prefixBase} Local` : prefixBase;
@@ -4957,7 +5088,7 @@ export class BlueprintWorkspace {
* Spawns a node at the requested context menu position.
*
* @param {PaletteNodeDefinition} definition Node definition to instantiate.
* @param {{ x: number, y: number }} spawnPosition Workspace-relative spawn coordinates.
* @param {{ x: number, y: number }} spawnPosition Screen-relative spawn coordinates (relative to workspace element).
* @returns {import('../core/BlueprintNode.js').BlueprintNode | null}
*/
spawnNodeFromContextMenu(definition, spawnPosition) {
@@ -4965,7 +5096,14 @@ export class BlueprintWorkspace {
x: Math.max(0, spawnPosition.x),
y: Math.max(0, spawnPosition.y),
};
const worldSpawn = this.navigationManager.screenPointToWorld(clamped);
// Convert screen-relative position to world coordinates
const zoom = this.navigationManager?.getZoomLevel() ?? this.zoomLevel ?? 1;
const clampedZoom = Math.max(0.01, zoom);
const offset = this.navigationManager?.getEffectiveOffset() ?? { x: 0, y: 0 };
const worldSpawn = {
x: clamped.x / clampedZoom - offset.x,
y: clamped.y / clampedZoom - offset.y,
};
const spawn = WorkspaceGeometry.snapPositionToGrid(this, worldSpawn);
const pendingConnectionSnapshot = this.pendingConnectionSpawn
? {
@@ -5500,10 +5638,88 @@ export class BlueprintWorkspace {
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.classList.add("connection-path");
path.dataset.type = kind;
path.style.stroke = getTypeColor(kind);
this.connectionLayer.appendChild(path);
return path;
}
/**
* Returns the SVG defs element on the connection layer, creating it if absent.
*
* @returns {SVGDefsElement}
*/
#getOrCreateSvgDefs() {
let defs = this.connectionLayer.querySelector("defs");
if (!defs) {
defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
this.connectionLayer.prepend(defs);
}
return /** @type {SVGDefsElement} */ (defs);
}
/**
* Creates or updates a linearGradient element for a cross-type wire.
*
* @param {SVGDefsElement} defs Defs container.
* @param {string} connectionId Connection identifier.
* @param {string} fromKind Source pin kind.
* @param {string} toKind Target pin kind.
* @param {{x:number,y:number}} start Gradient start point.
* @param {{x:number,y:number}} end Gradient end point.
* @returns {string} The gradient element id.
*/
#upsertConnectionGradient(defs, connectionId, fromKind, toKind, start, end) {
const gradId = `cgrad-${connectionId}`;
let grad = defs.querySelector(`#${CSS.escape(gradId)}`);
if (!grad) {
grad = document.createElementNS(
"http://www.w3.org/2000/svg",
"linearGradient"
);
grad.id = gradId;
grad.setAttribute("gradientUnits", "userSpaceOnUse");
const stop1 = document.createElementNS(
"http://www.w3.org/2000/svg",
"stop"
);
stop1.setAttribute("offset", "0%");
const stop2 = document.createElementNS(
"http://www.w3.org/2000/svg",
"stop"
);
stop2.setAttribute("offset", "100%");
grad.appendChild(stop1);
grad.appendChild(stop2);
defs.appendChild(grad);
}
grad.setAttribute("x1", String(start.x));
grad.setAttribute("y1", String(start.y));
grad.setAttribute("x2", String(end.x));
grad.setAttribute("y2", String(end.y));
const style = getComputedStyle(this.workspaceElement);
const stops = grad.querySelectorAll("stop");
stops[0].setAttribute(
"stop-color",
this.#getPinKindColor(style, fromKind)
);
stops[1].setAttribute("stop-color", this.#getPinKindColor(style, toKind));
return gradId;
}
/**
* Resolves the color for a given pin kind.
*
* @param {CSSStyleDeclaration} _style Computed style (unused, kept for API compatibility).
* @param {string} kind Pin kind identifier.
* @returns {string}
*/
#getPinKindColor(_style, kind) {
return getTypeColor(kind);
}
/**
* Requests a connection re-render on the next animation frame.
*/
@@ -5579,6 +5795,7 @@ export class BlueprintWorkspace {
this.connectionLayer.removeAttribute("preserveAspectRatio");
}
}
const defs = this.#getOrCreateSvgDefs();
const activeIds = new Set();
this.graph.getConnections().forEach((connection) => {
activeIds.add(connection.id);
@@ -5588,7 +5805,6 @@ export class BlueprintWorkspace {
this.connectionElements.set(connection.id, path);
}
path.dataset.type = connection.kind;
path.dataset.connectionId = connection.id;
const geometry = this.#computeConnectionGeometry(
connection.from,
@@ -5605,12 +5821,47 @@ export class BlueprintWorkspace {
} ${end.x - controlOffset} ${end.y} ${end.x} ${end.y}`;
path.dataset.active = "false";
path.setAttribute("d", d);
const fromPin = this.graph.nodes
.get(connection.from.nodeId)
?.getPin(connection.from.pinId);
const toPin = this.graph.nodes
.get(connection.to.nodeId)
?.getPin(connection.to.pinId);
const fromKind = fromPin?.kind;
const toKind = toPin?.kind;
if (
fromKind &&
toKind &&
fromKind !== toKind &&
fromKind !== "any" &&
toKind !== "any"
) {
path.dataset.type = "mixed";
const gradId = this.#upsertConnectionGradient(
defs,
connection.id,
fromKind,
toKind,
start,
end
);
path.style.stroke = "";
path.setAttribute("stroke", `url(#${gradId})`);
} else {
path.dataset.type = connection.kind;
path.removeAttribute("stroke");
path.style.stroke = getTypeColor(connection.kind);
defs.querySelector(`#cgrad-${connection.id}`)?.remove();
}
});
this.connectionElements.forEach((path, id) => {
if (!activeIds.has(id)) {
path.remove();
this.connectionElements.delete(id);
defs.querySelector(`#cgrad-${id}`)?.remove();
}
});
}
@@ -5886,10 +6137,7 @@ export class BlueprintWorkspace {
* @returns {boolean}
*/
#arePinKindsCompatible(outputKind, inputKind) {
if (inputKind === "any" || outputKind === "any") {
return true;
}
return outputKind === inputKind;
return areTypesCompatible(outputKind, inputKind);
}
/**
@@ -6197,12 +6445,14 @@ export class BlueprintWorkspace {
row.className = "custom-event-parameter-row";
row.dataset.parameterId = parameter.id;
row.dataset.parameterType = parameter.type;
row.style.setProperty("--overview-variable-color", getTypeColor(parameter.type));
const typeButton = document.createElement("button");
typeButton.type = "button";
typeButton.className =
"variable-type-button custom-event-parameter-type-button";
typeButton.dataset.variableType = parameter.type;
typeButton.style.setProperty("--overview-variable-color", getTypeColor(parameter.type));
typeButton.setAttribute("aria-haspopup", "menu");
typeButton.setAttribute("aria-expanded", "false");
row.appendChild(typeButton);
@@ -6218,7 +6468,9 @@ export class BlueprintWorkspace {
const display = this.#formatVariableType(normalized);
const referenceName = nameInputElement.value.trim() || parameter.id;
row.dataset.parameterType = normalized;
row.style.setProperty("--overview-variable-color", getTypeColor(normalized));
typeButton.dataset.variableType = normalized;
typeButton.style.setProperty("--overview-variable-color", getTypeColor(normalized));
typeButton.title = `Type: ${display}`;
typeButton.setAttribute(
"aria-label",
@@ -6489,6 +6741,7 @@ export class BlueprintWorkspace {
this.connectionElements.forEach((path) => path.remove());
this.nodeElements.clear();
this.connectionElements.clear();
this.connectionLayer.querySelector("defs")?.remove();
this.inlineEditorManager.clear();
this.nodeDragRotation.clear();
this.dragManager.stopNodeRotationDecay();
@@ -6531,7 +6784,8 @@ export class BlueprintWorkspace {
entry.type === "string" ||
entry.type === "boolean" ||
entry.type === "any" ||
entry.type === "table"
entry.type === "table" ||
entry.type === "color"
? entry.type
: "any";
const defaultValue = this.#normalizeVariableDefault(

View File

@@ -0,0 +1,184 @@
/**
* Ordered PICO-8 palette hex strings, indices 015.
*
* @type {ReadonlyArray<string>}
*/
const PICO8_COLORS = Object.freeze([
'#000002', '#1D2B53', '#7E2553', '#008751',
'#AB5236', '#5F574F', '#C2C3C7', '#FFF1E8',
'#FF004D', '#FFA300', '#FFEC27', '#00E436',
'#29ADFF', '#83769C', '#FF77A8', '#FFCCAA',
]);
/**
* @typedef {{ element: HTMLElement, setValue: (index: number) => void }} ColorSwatchPickerInstance
*/
/**
* Builds an inline PICO-8 color swatch picker control.
*
* Renders a colored trigger button that opens a 4×4 grid of all 16 PICO-8
* palette swatches. Clicking a swatch fires `onChange` with the selected
* palette index (015) and closes the dropdown.
*
* @param {{
* id?: string,
* value: number,
* onChange: (index: number) => void,
* }} options Picker configuration.
* @returns {ColorSwatchPickerInstance}
*/
export function createColorSwatchPicker({ id, value, onChange }) {
const wrapper = document.createElement('div');
wrapper.className = 'color-swatch-picker';
const trigger = document.createElement('button');
trigger.type = 'button';
trigger.className = 'color-swatch-trigger';
if (id) {
trigger.id = id;
}
trigger.setAttribute('aria-haspopup', 'menu');
trigger.setAttribute('aria-expanded', 'false');
wrapper.appendChild(trigger);
const dropdown = document.createElement('div');
dropdown.className = 'color-swatch-dropdown';
dropdown.setAttribute('role', 'menu');
// Appended to body on open so it escapes overflow:hidden on node cards.
/** @type {Array<HTMLButtonElement>} */
const swatchButtons = [];
/** @type {((event: MouseEvent) => void) | null} */
let outsideClickHandler = null;
/** @type {(() => void) | null} */
let scrollAbortHandler = null;
const repositionDropdown = () => {
const rect = trigger.getBoundingClientRect();
dropdown.style.top = `${rect.bottom + 4}px`;
dropdown.style.left = `${rect.left}px`;
};
const closeDropdown = () => {
if (dropdown.isConnected) {
dropdown.remove();
}
trigger.setAttribute('aria-expanded', 'false');
if (outsideClickHandler) {
document.removeEventListener('click', outsideClickHandler, true);
outsideClickHandler = null;
}
if (scrollAbortHandler) {
document.removeEventListener('scroll', scrollAbortHandler, true);
scrollAbortHandler = null;
}
};
const openDropdown = () => {
repositionDropdown();
document.body.appendChild(dropdown);
trigger.setAttribute('aria-expanded', 'true');
outsideClickHandler = (event) => {
if (
!(event.target instanceof Node) ||
(!wrapper.contains(event.target) && !dropdown.contains(event.target))
) {
closeDropdown();
}
};
scrollAbortHandler = () => closeDropdown();
document.addEventListener('click', outsideClickHandler, true);
document.addEventListener('scroll', scrollAbortHandler, true);
};
let currentIndex = clampColorIndex(value);
/**
* Updates the trigger button to reflect the supplied palette index.
*
* @param {number} index Palette index (015).
*/
const updateTrigger = (index) => {
const hex = PICO8_COLORS[index] ?? '#000000';
trigger.style.backgroundColor = hex;
trigger.title = `Color ${index}`;
trigger.setAttribute('aria-label', `Color ${index}`);
};
PICO8_COLORS.forEach((hex, index) => {
const swatch = document.createElement('button');
swatch.type = 'button';
swatch.className = 'color-swatch-option';
swatch.style.backgroundColor = hex;
swatch.dataset.colorIndex = String(index);
swatch.title = String(index);
swatch.setAttribute('role', 'menuitemradio');
swatch.setAttribute('aria-label', `Color ${index}`);
swatch.setAttribute('aria-checked', 'false');
swatch.addEventListener('pointerdown', (event) => {
event.stopPropagation();
});
swatch.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
const selected = index;
closeDropdown();
if (selected !== currentIndex) {
currentIndex = selected;
updateTrigger(selected);
swatchButtons.forEach((btn, i) => {
btn.setAttribute('aria-checked', i === selected ? 'true' : 'false');
});
onChange(selected);
}
});
dropdown.appendChild(swatch);
swatchButtons.push(swatch);
});
trigger.addEventListener('pointerdown', (event) => {
event.stopPropagation();
});
trigger.addEventListener('click', (event) => {
event.stopPropagation();
if (dropdown.isConnected) {
closeDropdown();
} else {
openDropdown();
}
});
/**
* Updates the picker display without firing `onChange`.
*
* @param {number} newIndex Palette index (015).
*/
const setValue = (newIndex) => {
currentIndex = clampColorIndex(newIndex);
updateTrigger(currentIndex);
swatchButtons.forEach((btn, i) => {
btn.setAttribute('aria-checked', i === currentIndex ? 'true' : 'false');
});
};
setValue(currentIndex);
return { element: wrapper, setValue };
}
/**
* Clamps an arbitrary value to a valid PICO-8 palette index (015).
*
* @param {unknown} value Raw index candidate.
* @returns {number}
*/
function clampColorIndex(value) {
const n = typeof value === 'number' ? value : Number(value);
const safe = Number.isFinite(n) ? Math.round(n) : 0;
return Math.max(0, Math.min(15, safe));
}

View File

@@ -331,7 +331,7 @@ export class EmbeddedPreview {
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
const content = `pico-8 cartridge // http://www.pico-8.com
version 41
__lua__
${luaCode}${fallback}
@@ -345,6 +345,8 @@ __gfx__
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
`;
// Ensure Unix line endings (PICO-8 tools expect \n, not \r\n)
return content.replace(/\r\n/g, '\n');
}
/**

622
scripts/ui/SpriteEditor.js Normal file
View File

@@ -0,0 +1,622 @@
import { SpriteSheet } from "../core/SpriteSheet.js";
/**
* Interactive sprite sheet editor for PICO-8.
*/
export class SpriteEditor {
/**
* @param {HTMLElement} container Parent element for the editor.
* @param {SpriteSheet} [spriteSheet] Existing sprite sheet or creates new one.
*/
constructor(container, spriteSheet = null) {
this.container = container;
this.spriteSheet = spriteSheet || new SpriteSheet();
/** @type {number} Currently selected color (0-15) */
this.selectedColor = 7;
/** @type {number} Currently selected sprite (0-255) */
this.selectedSprite = 0;
/** @type {string} Current tool: "pen", "fill", "line", "rect", "circle", "erase" */
this.currentTool = "pen";
/** @type {number} Zoom level for sprite sheet canvas */
this.sheetZoom = 2;
/** @type {number} Zoom level for sprite preview */
this.previewZoom = 8;
/** @type {Object|null} Drawing state for line/rect tools */
this.drawStart = null;
this.#createUI();
this.#attachEvents();
this.#render();
}
/**
* Creates the UI structure.
*/
#createUI() {
this.container.innerHTML = "";
this.container.className = "sprite-editor";
// Toolbar
const toolbar = document.createElement("div");
toolbar.className = "sprite-toolbar";
const tools = [
{ id: "pen", label: "Pen", icon: "✏️" },
{ id: "fill", label: "Fill", icon: "🪣" },
{ id: "line", label: "Line", icon: "📏" },
{ id: "rect", label: "Rect", icon: "⬜" },
{ id: "circle", label: "Circle", icon: "⭕" },
{ id: "erase", label: "Erase", icon: "🧹" }
];
tools.forEach((tool) => {
const btn = document.createElement("button");
btn.className = "sprite-tool-btn";
btn.dataset.tool = tool.id;
btn.title = tool.label;
btn.textContent = tool.icon;
if (tool.id === this.currentTool) {
btn.classList.add("active");
}
toolbar.appendChild(btn);
});
this.container.appendChild(toolbar);
// Main content area
const content = document.createElement("div");
content.className = "sprite-content";
// Left panel: Sprite sheet
const leftPanel = document.createElement("div");
leftPanel.className = "sprite-panel sprite-sheet-panel";
const sheetLabel = document.createElement("div");
sheetLabel.className = "sprite-panel-label";
sheetLabel.textContent = "Sprite Sheet (128x128)";
leftPanel.appendChild(sheetLabel);
this.sheetCanvas = document.createElement("canvas");
this.sheetCanvas.className = "sprite-sheet-canvas";
this.sheetCanvas.width = 128 * this.sheetZoom;
this.sheetCanvas.height = 128 * this.sheetZoom;
this.sheetCtx = this.sheetCanvas.getContext("2d", { alpha: false });
this.sheetCtx.imageSmoothingEnabled = false;
leftPanel.appendChild(this.sheetCanvas);
content.appendChild(leftPanel);
// Right panel: Preview and palette
const rightPanel = document.createElement("div");
rightPanel.className = "sprite-panel sprite-controls-panel";
// Sprite preview
const previewContainer = document.createElement("div");
previewContainer.className = "sprite-preview-container";
const previewLabel = document.createElement("div");
previewLabel.className = "sprite-panel-label";
previewLabel.textContent = "Sprite Preview";
previewContainer.appendChild(previewLabel);
this.previewCanvas = document.createElement("canvas");
this.previewCanvas.className = "sprite-preview-canvas";
this.previewCanvas.width = 8 * this.previewZoom;
this.previewCanvas.height = 8 * this.previewZoom;
this.previewCtx = this.previewCanvas.getContext("2d", { alpha: false });
this.previewCtx.imageSmoothingEnabled = false;
previewContainer.appendChild(this.previewCanvas);
this.spriteIndexLabel = document.createElement("div");
this.spriteIndexLabel.className = "sprite-index-label";
this.spriteIndexLabel.textContent = `Sprite: ${this.selectedSprite}`;
previewContainer.appendChild(this.spriteIndexLabel);
rightPanel.appendChild(previewContainer);
// Color palette
const paletteContainer = document.createElement("div");
paletteContainer.className = "sprite-palette-container";
const paletteLabel = document.createElement("div");
paletteLabel.className = "sprite-panel-label";
paletteLabel.textContent = "Palette";
paletteContainer.appendChild(paletteLabel);
this.paletteElement = document.createElement("div");
this.paletteElement.className = "sprite-palette";
this.spriteSheet.palette.forEach((color, index) => {
const swatch = document.createElement("div");
swatch.className = "sprite-palette-swatch";
swatch.style.backgroundColor = color;
swatch.dataset.color = index;
swatch.title = `Color ${index}`;
if (index === this.selectedColor) {
swatch.classList.add("selected");
}
this.paletteElement.appendChild(swatch);
});
paletteContainer.appendChild(this.paletteElement);
rightPanel.appendChild(paletteContainer);
// Actions
const actionsContainer = document.createElement("div");
actionsContainer.className = "sprite-actions";
const clearBtn = document.createElement("button");
clearBtn.className = "sprite-action-btn";
clearBtn.textContent = "Clear Sprite";
clearBtn.dataset.action = "clear-sprite";
actionsContainer.appendChild(clearBtn);
const clearAllBtn = document.createElement("button");
clearAllBtn.className = "sprite-action-btn";
clearAllBtn.textContent = "Clear All";
clearAllBtn.dataset.action = "clear-all";
actionsContainer.appendChild(clearAllBtn);
rightPanel.appendChild(actionsContainer);
content.appendChild(rightPanel);
this.container.appendChild(content);
}
/**
* Attaches event listeners.
*/
#attachEvents() {
// Tool selection
this.container.querySelectorAll(".sprite-tool-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
this.currentTool = btn.dataset.tool;
this.container.querySelectorAll(".sprite-tool-btn").forEach((b) => {
b.classList.remove("active");
});
btn.classList.add("active");
});
});
// Color palette
this.paletteElement.querySelectorAll(".sprite-palette-swatch").forEach((swatch) => {
swatch.addEventListener("click", (e) => {
this.selectedColor = parseInt(swatch.dataset.color);
this.paletteElement.querySelectorAll(".sprite-palette-swatch").forEach((s) => {
s.classList.remove("selected");
});
swatch.classList.add("selected");
});
});
// Actions
this.container.querySelectorAll(".sprite-action-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
const action = btn.dataset.action;
if (action === "clear-sprite") {
this.#clearCurrentSprite();
} else if (action === "clear-all") {
if (confirm("Clear all sprites? This cannot be undone.")) {
this.spriteSheet.clear();
this.#render();
}
}
});
});
// Sheet canvas interactions
let isDrawing = false;
this.sheetCanvas.addEventListener("mousedown", (e) => {
isDrawing = true;
const coords = this.#getSheetCoords(e);
this.#handleToolStart(coords);
});
this.sheetCanvas.addEventListener("mousemove", (e) => {
if (!isDrawing) return;
const coords = this.#getSheetCoords(e);
this.#handleToolMove(coords);
});
this.sheetCanvas.addEventListener("mouseup", (e) => {
if (!isDrawing) return;
isDrawing = false;
const coords = this.#getSheetCoords(e);
this.#handleToolEnd(coords);
});
this.sheetCanvas.addEventListener("mouseleave", () => {
isDrawing = false;
this.drawStart = null;
});
// Sprite selection
this.sheetCanvas.addEventListener("click", (e) => {
const coords = this.#getSheetCoords(e);
const spriteX = Math.floor(coords.x / 8);
const spriteY = Math.floor(coords.y / 8);
this.selectedSprite = spriteY * 16 + spriteX;
this.spriteIndexLabel.textContent = `Sprite: ${this.selectedSprite}`;
this.#renderPreview();
});
// Preview canvas editing
let isDrawingPreview = false;
this.previewCanvas.addEventListener("mousedown", (e) => {
isDrawingPreview = true;
const coords = this.#getPreviewCoords(e);
this.#drawPixelInPreview(coords);
});
this.previewCanvas.addEventListener("mousemove", (e) => {
if (!isDrawingPreview) return;
const coords = this.#getPreviewCoords(e);
this.#drawPixelInPreview(coords);
});
this.previewCanvas.addEventListener("mouseup", () => {
isDrawingPreview = false;
});
this.previewCanvas.addEventListener("mouseleave", () => {
isDrawingPreview = false;
});
}
/**
* Gets sheet coordinates from mouse event.
*
* @param {MouseEvent} e Mouse event.
* @returns {{x: number, y: number}}
*/
#getSheetCoords(e) {
const rect = this.sheetCanvas.getBoundingClientRect();
const x = Math.floor((e.clientX - rect.left) / this.sheetZoom);
const y = Math.floor((e.clientY - rect.top) / this.sheetZoom);
return { x, y };
}
/**
* Gets preview coordinates from mouse event.
*
* @param {MouseEvent} e Mouse event.
* @returns {{x: number, y: number}}
*/
#getPreviewCoords(e) {
const rect = this.previewCanvas.getBoundingClientRect();
const x = Math.floor((e.clientX - rect.left) / this.previewZoom);
const y = Math.floor((e.clientY - rect.top) / this.previewZoom);
return { x, y };
}
/**
* Handles tool start.
*
* @param {{x: number, y: number}} coords Coordinates.
*/
#handleToolStart(coords) {
if (this.currentTool === "pen") {
this.#drawPixel(coords);
} else if (this.currentTool === "erase") {
this.#erasePixel(coords);
} else if (this.currentTool === "fill") {
this.#floodFill(coords);
} else if (["line", "rect", "circle"].includes(this.currentTool)) {
this.drawStart = coords;
}
}
/**
* Handles tool move.
*
* @param {{x: number, y: number}} coords Coordinates.
*/
#handleToolMove(coords) {
if (this.currentTool === "pen") {
this.#drawPixel(coords);
} else if (this.currentTool === "erase") {
this.#erasePixel(coords);
}
}
/**
* Handles tool end.
*
* @param {{x: number, y: number}} coords Coordinates.
*/
#handleToolEnd(coords) {
if (!this.drawStart) return;
if (this.currentTool === "line") {
this.#drawLine(this.drawStart, coords);
} else if (this.currentTool === "rect") {
this.#drawRect(this.drawStart, coords);
} else if (this.currentTool === "circle") {
this.#drawCircle(this.drawStart, coords);
}
this.drawStart = null;
}
/**
* Draws a pixel.
*
* @param {{x: number, y: number}} coords Coordinates.
*/
#drawPixel(coords) {
this.spriteSheet.setPixel(coords.x, coords.y, this.selectedColor);
this.#render();
}
/**
* Erases a pixel.
*
* @param {{x: number, y: number}} coords Coordinates.
*/
#erasePixel(coords) {
this.spriteSheet.setPixel(coords.x, coords.y, 0);
this.#render();
}
/**
* Flood fill from a point.
*
* @param {{x: number, y: number}} coords Starting coordinates.
*/
#floodFill(coords) {
const targetColor = this.spriteSheet.getPixel(coords.x, coords.y);
if (targetColor === this.selectedColor) return;
const stack = [coords];
const visited = new Set();
while (stack.length > 0) {
const { x, y } = stack.pop();
const key = `${x},${y}`;
if (visited.has(key)) continue;
if (x < 0 || x >= 128 || y < 0 || y >= 128) continue;
if (this.spriteSheet.getPixel(x, y) !== targetColor) continue;
visited.add(key);
this.spriteSheet.setPixel(x, y, this.selectedColor);
stack.push({ x: x + 1, y });
stack.push({ x: x - 1, y });
stack.push({ x, y: y + 1 });
stack.push({ x, y: y - 1 });
}
this.#render();
}
/**
* Draws a line.
*
* @param {{x: number, y: number}} start Start coordinates.
* @param {{x: number, y: number}} end End coordinates.
*/
#drawLine(start, end) {
const dx = Math.abs(end.x - start.x);
const dy = Math.abs(end.y - start.y);
const sx = start.x < end.x ? 1 : -1;
const sy = start.y < end.y ? 1 : -1;
let err = dx - dy;
let x = start.x;
let y = start.y;
while (true) {
this.spriteSheet.setPixel(x, y, this.selectedColor);
if (x === end.x && y === end.y) break;
const e2 = 2 * err;
if (e2 > -dy) {
err -= dy;
x += sx;
}
if (e2 < dx) {
err += dx;
y += sy;
}
}
this.#render();
}
/**
* Draws a rectangle.
*
* @param {{x: number, y: number}} start Start coordinates.
* @param {{x: number, y: number}} end End coordinates.
*/
#drawRect(start, end) {
const x1 = Math.min(start.x, end.x);
const y1 = Math.min(start.y, end.y);
const x2 = Math.max(start.x, end.x);
const y2 = Math.max(start.y, end.y);
for (let x = x1; x <= x2; x++) {
this.spriteSheet.setPixel(x, y1, this.selectedColor);
this.spriteSheet.setPixel(x, y2, this.selectedColor);
}
for (let y = y1; y <= y2; y++) {
this.spriteSheet.setPixel(x1, y, this.selectedColor);
this.spriteSheet.setPixel(x2, y, this.selectedColor);
}
this.#render();
}
/**
* Draws a circle.
*
* @param {{x: number, y: number}} center Center coordinates.
* @param {{x: number, y: number}} edge Edge point.
*/
#drawCircle(center, edge) {
const radius = Math.round(
Math.sqrt((edge.x - center.x) ** 2 + (edge.y - center.y) ** 2)
);
let x = radius;
let y = 0;
let err = 0;
while (x >= y) {
this.spriteSheet.setPixel(center.x + x, center.y + y, this.selectedColor);
this.spriteSheet.setPixel(center.x + y, center.y + x, this.selectedColor);
this.spriteSheet.setPixel(center.x - y, center.y + x, this.selectedColor);
this.spriteSheet.setPixel(center.x - x, center.y + y, this.selectedColor);
this.spriteSheet.setPixel(center.x - x, center.y - y, this.selectedColor);
this.spriteSheet.setPixel(center.x - y, center.y - x, this.selectedColor);
this.spriteSheet.setPixel(center.x + y, center.y - x, this.selectedColor);
this.spriteSheet.setPixel(center.x + x, center.y - y, this.selectedColor);
y += 1;
err += 1 + 2 * y;
if (2 * (err - x) + 1 > 0) {
x -= 1;
err += 1 - 2 * x;
}
}
this.#render();
}
/**
* Draws a pixel in the preview canvas.
*
* @param {{x: number, y: number}} coords Coordinates (0-7).
*/
#drawPixelInPreview(coords) {
if (coords.x < 0 || coords.x >= 8 || coords.y < 0 || coords.y >= 8) return;
const sx = (this.selectedSprite % 16) * 8;
const sy = Math.floor(this.selectedSprite / 16) * 8;
const color = this.currentTool === "erase" ? 0 : this.selectedColor;
this.spriteSheet.setPixel(sx + coords.x, sy + coords.y, color);
this.#render();
}
/**
* Clears the currently selected sprite.
*/
#clearCurrentSprite() {
const clearData = new Uint8Array(64);
this.spriteSheet.setSprite(this.selectedSprite, clearData);
this.#render();
}
/**
* Renders all canvases.
*/
#render() {
this.#renderSheet();
this.#renderPreview();
}
/**
* Renders the sprite sheet canvas.
*/
#renderSheet() {
for (let y = 0; y < 128; y++) {
for (let x = 0; x < 128; x++) {
const color = this.spriteSheet.getPixel(x, y);
this.sheetCtx.fillStyle = this.spriteSheet.palette[color];
this.sheetCtx.fillRect(
x * this.sheetZoom,
y * this.sheetZoom,
this.sheetZoom,
this.sheetZoom
);
}
}
// Draw grid
this.sheetCtx.strokeStyle = "rgba(255, 255, 255, 0.2)";
this.sheetCtx.lineWidth = 1;
for (let i = 0; i <= 16; i++) {
const pos = i * 8 * this.sheetZoom;
this.sheetCtx.beginPath();
this.sheetCtx.moveTo(pos, 0);
this.sheetCtx.lineTo(pos, 128 * this.sheetZoom);
this.sheetCtx.stroke();
this.sheetCtx.beginPath();
this.sheetCtx.moveTo(0, pos);
this.sheetCtx.lineTo(128 * this.sheetZoom, pos);
this.sheetCtx.stroke();
}
}
/**
* Renders the sprite preview canvas.
*/
#renderPreview() {
const sx = (this.selectedSprite % 16) * 8;
const sy = Math.floor(this.selectedSprite / 16) * 8;
for (let y = 0; y < 8; y++) {
for (let x = 0; x < 8; x++) {
const color = this.spriteSheet.getPixel(sx + x, sy + y);
this.previewCtx.fillStyle = this.spriteSheet.palette[color];
this.previewCtx.fillRect(
x * this.previewZoom,
y * this.previewZoom,
this.previewZoom,
this.previewZoom
);
}
}
// Draw grid
this.previewCtx.strokeStyle = "rgba(255, 255, 255, 0.3)";
this.previewCtx.lineWidth = 1;
for (let i = 0; i <= 8; i++) {
const pos = i * this.previewZoom;
this.previewCtx.beginPath();
this.previewCtx.moveTo(pos, 0);
this.previewCtx.lineTo(pos, 8 * this.previewZoom);
this.previewCtx.stroke();
this.previewCtx.beginPath();
this.previewCtx.moveTo(0, pos);
this.previewCtx.lineTo(8 * this.previewZoom, pos);
this.previewCtx.stroke();
}
}
/**
* Gets the sprite sheet data.
*
* @returns {SpriteSheet}
*/
getSpriteSheet() {
return this.spriteSheet;
}
/**
* Sets sprite sheet data.
*
* @param {SpriteSheet} spriteSheet New sprite sheet.
*/
setSpriteSheet(spriteSheet) {
this.spriteSheet = spriteSheet;
this.#render();
}
}

View File

@@ -0,0 +1,205 @@
import { SpriteEditor } from './SpriteEditor.js';
import { SpriteSheet } from '../core/SpriteSheet.js';
/**
* Manages the sprite editor modal integration.
*/
export class SpriteEditorManager {
/**
* @param {HTMLElement} modal Modal element.
* @param {HTMLElement} container Container for sprite editor.
*/
constructor(modal, container) {
this.modal = modal;
this.container = container;
this.spriteSheet = new SpriteSheet();
this.editor = null;
this.isOpen = false;
this.#setupModal();
}
/**
* Sets up modal event listeners.
*/
#setupModal() {
// Close modal on backdrop click
const backdrop = this.modal.querySelector('.modal__backdrop');
backdrop?.addEventListener('click', () => this.close());
// Close modal on close button click
const closeButtons = this.modal.querySelectorAll('[data-modal-close]');
closeButtons.forEach((btn) => {
btn.addEventListener('click', () => this.close());
});
// Export to .p8
const exportBtn = document.getElementById('exportP8Button');
exportBtn?.addEventListener('click', () => this.#exportToP8());
// Import from .p8
const importBtn = document.getElementById('importP8Button');
importBtn?.addEventListener('click', () => this.#importFromP8());
// Initialize editor on first open
this.modal.addEventListener('transitionend', () => {
if (this.isOpen && !this.editor) {
this.#initializeEditor();
}
}, { once: true });
}
/**
* Initializes the sprite editor.
*/
#initializeEditor() {
if (this.editor) return;
this.editor = new SpriteEditor(this.container, this.spriteSheet);
console.log('Sprite editor initialized');
}
/**
* Opens the sprite editor modal.
*/
open() {
if (this.isOpen) return;
this.isOpen = true;
this.modal.removeAttribute('hidden');
this.modal.setAttribute('aria-hidden', 'false');
document.body.classList.add('modal-open');
// Initialize editor immediately if not already done
if (!this.editor) {
this.#initializeEditor();
}
// Focus trap
requestAnimationFrame(() => {
const closeBtn = this.modal.querySelector('[data-modal-close]');
closeBtn?.focus();
});
}
/**
* Closes the sprite editor modal.
*/
close() {
if (!this.isOpen) return;
this.isOpen = false;
this.modal.setAttribute('hidden', '');
this.modal.setAttribute('aria-hidden', 'true');
document.body.classList.remove('modal-open');
}
/**
* Exports sprite data to .p8 format.
*/
#exportToP8() {
if (!this.editor) return;
const gfxData = this.editor.getSpriteSheet().toP8Gfx();
const flagData = this.editor.getSpriteSheet().toP8Flags();
const p8Content = `pico-8 cartridge // http://www.pico-8.com
version 41
__lua__
-- generated by picoGraph
-- paste your blueprinted code here
__gfx__
${gfxData}
__gff__
${flagData}
`;
const blob = new Blob([p8Content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'sprites.p8';
a.click();
URL.revokeObjectURL(url);
// Show success feedback
const exportBtn = document.getElementById('exportP8Button');
if (exportBtn) {
exportBtn.classList.add('is-success');
setTimeout(() => {
exportBtn.classList.remove('is-success');
}, 1500);
}
}
/**
* Imports sprite data from .p8 format.
*/
#importFromP8() {
if (!this.editor) return;
const input = document.createElement('input');
input.type = 'file';
input.accept = '.p8';
input.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const text = await file.text();
// Extract __gfx__ section
const gfxMatch = text.match(/__gfx__\s*\n([\s\S]*?)(?=\n__|$)/);
if (gfxMatch) {
this.editor.getSpriteSheet().fromP8Gfx(gfxMatch[1]);
}
// Extract __gff__ section
const gffMatch = text.match(/__gff__\s*\n([\s\S]*?)(?=\n__|$)/);
if (gffMatch) {
this.editor.getSpriteSheet().fromP8Flags(gffMatch[1]);
}
// Re-render
this.editor.setSpriteSheet(this.editor.getSpriteSheet());
// Show success feedback
const importBtn = document.getElementById('importP8Button');
if (importBtn) {
importBtn.classList.add('is-success');
setTimeout(() => {
importBtn.classList.remove('is-success');
}, 1500);
}
} catch (error) {
console.error('Failed to import sprite data:', error);
alert('Failed to import sprite data. Please ensure the file is a valid .p8 file.');
}
});
input.click();
}
/**
* Gets the current sprite sheet data.
*
* @returns {SpriteSheet}
*/
getSpriteSheet() {
return this.spriteSheet;
}
/**
* Sets sprite sheet data.
*
* @param {SpriteSheet} spriteSheet New sprite sheet.
*/
setSpriteSheet(spriteSheet) {
this.spriteSheet = spriteSheet;
if (this.editor) {
this.editor.setSpriteSheet(spriteSheet);
}
}
}

114
scripts/ui/TileManager.js Normal file
View File

@@ -0,0 +1,114 @@
import { TileSetBrowser } from "./TileSetBrowser.js";
/**
* Manages the tile browser modal and provides access to tileset management.
*/
export class TileManager {
constructor() {
this.modal = null;
this.browser = null;
this.isOpen = false;
}
/**
* Initializes the tile manager.
*/
initialize() {
this.modal = document.getElementById("tileModal");
if (!this.modal) {
console.error("Tile modal element not found");
return;
}
const container = this.modal.querySelector(".tile-browser-container");
if (!container) {
console.error("Tile browser container not found");
return;
}
this.browser = new TileSetBrowser(container);
const closeBtn = this.modal.querySelector(".close-modal");
if (closeBtn) {
closeBtn.addEventListener("click", () => this.close());
}
this.modal.addEventListener("click", (e) => {
if (e.target === this.modal) {
this.close();
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && this.isOpen) {
this.close();
}
});
const tileBtn = document.getElementById("tileManagerButton");
if (tileBtn) {
tileBtn.addEventListener("click", () => this.open());
}
this.#loadFromLocalStorage();
}
/**
* Opens the tile manager modal.
*/
open() {
if (!this.modal) return;
this.modal.style.display = "flex";
this.isOpen = true;
}
/**
* Closes the tile manager modal.
*/
close() {
if (!this.modal) return;
this.modal.style.display = "none";
this.isOpen = false;
this.#saveToLocalStorage();
}
/**
* Gets all tilesets.
*
* @returns {Array<TileSet>}
*/
getTileSets() {
return this.browser ? this.browser.getTileSets() : [];
}
/**
* Saves tileset data to localStorage.
*/
#saveToLocalStorage() {
if (!this.browser) return;
try {
const data = this.browser.toJSON();
localStorage.setItem("picograph_tilesets", JSON.stringify(data));
} catch (error) {
console.error("Failed to save tilesets:", error);
}
}
/**
* Loads tileset data from localStorage.
*/
async #loadFromLocalStorage() {
if (!this.browser) return;
try {
const stored = localStorage.getItem("picograph_tilesets");
if (stored) {
const data = JSON.parse(stored);
await this.browser.fromJSON(data);
}
} catch (error) {
console.error("Failed to load tilesets:", error);
}
}
}

View File

@@ -0,0 +1,511 @@
import { TileSet } from "../core/TileSet.js";
/**
* Manages multiple tilesets (8x8 tiles in 128x128px sheets) and provides UI for browsing and editing externally.
*/
export class TileSetBrowser {
/**
* @param {HTMLElement} container Parent element for the browser.
*/
constructor(container) {
this.container = container;
/** @type {Map<string, TileSet>} */
this.tileSets = new Map();
/** @type {string | null} */
this.selectedTileSetId = null;
/** @type {number | null} */
this.selectedTileIndex = null;
this.#createUI();
this.#attachEvents();
}
/**
* Creates the UI structure.
*/
#createUI() {
this.container.innerHTML = "";
this.container.className = "tileset-browser";
// Header with actions
const header = document.createElement("div");
header.className = "tileset-header";
const title = document.createElement("div");
title.className = "tileset-title";
title.textContent = "Tilesets";
header.appendChild(title);
const actions = document.createElement("div");
actions.className = "tileset-actions";
const addBtn = document.createElement("button");
addBtn.className = "tileset-action-btn";
addBtn.textContent = "+ Add Tileset";
addBtn.dataset.action = "add";
actions.appendChild(addBtn);
header.appendChild(actions);
this.container.appendChild(header);
// Tileset list
this.tileSetList = document.createElement("div");
this.tileSetList.className = "tileset-list";
this.container.appendChild(this.tileSetList);
// Tile preview panel
this.previewPanel = document.createElement("div");
this.previewPanel.className = "tileset-preview-panel";
this.previewPanel.innerHTML = `
<div class="tileset-preview-header">
<span class="tileset-preview-title">Tile Preview</span>
</div>
<div class="tileset-preview-content">
<canvas class="tileset-preview-canvas"></canvas>
<div class="tileset-preview-info">Select a tileset to preview</div>
</div>
`;
this.container.appendChild(this.previewPanel);
this.previewCanvas = this.previewPanel.querySelector(".tileset-preview-canvas");
this.previewCtx = this.previewCanvas.getContext("2d", { alpha: false });
this.previewInfo = this.previewPanel.querySelector(".tileset-preview-info");
this.#renderEmpty();
}
/**
* Attaches event listeners.
*/
#attachEvents() {
// Add tileset button
this.container.addEventListener("click", (e) => {
const target = e.target;
if (!(target instanceof HTMLElement)) return;
const actionBtn = target.closest("[data-action]");
if (actionBtn) {
const action = actionBtn.dataset.action;
if (action === "add") {
this.#addTileSet();
} else if (action === "open") {
const id = actionBtn.dataset.id;
if (id) this.#openExternal(id);
} else if (action === "reload") {
const id = actionBtn.dataset.id;
if (id) this.#reloadTileSet(id);
} else if (action === "remove") {
const id = actionBtn.dataset.id;
if (id) this.#removeTileSet(id);
} else if (action === "edit-settings") {
const id = actionBtn.dataset.id;
if (id) this.#editTileSetSettings(id);
}
}
// Tileset selection
const tilesetItem = target.closest(".tileset-item");
if (tilesetItem && tilesetItem.dataset.id) {
this.#selectTileSet(tilesetItem.dataset.id);
}
});
// Canvas tile selection
this.previewCanvas.addEventListener("click", (e) => {
if (!this.selectedTileSetId) return;
const tileSet = this.tileSets.get(this.selectedTileSetId);
if (!tileSet || !tileSet.isLoaded) return;
const rect = this.previewCanvas.getBoundingClientRect();
const x = Math.floor((e.clientX - rect.left) / (this.previewCanvas.width / tileSet.columns));
const y = Math.floor((e.clientY - rect.top) / (this.previewCanvas.height / tileSet.rows));
this.selectedTileIndex = y * tileSet.columns + x;
this.#renderPreview();
});
}
/**
* Adds a new tileset.
*/
async #addTileSet() {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/png";
input.addEventListener("change", async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const name = file.name.replace(/\.[^/.]+$/, "");
let filePath;
let nativePath = "";
// If running in Electron, save the file to app data
if (window.electronAPI?.saveTileset) {
const arrayBuffer = await file.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
const result = await window.electronAPI.saveTileset(uint8Array, file.name);
nativePath = result.filePath;
// Convert to file:// URL for loading in Image element
filePath = `file:///${result.filePath.replace(/\\/g, "/")}`;
} else {
// Fallback for non-Electron environments (dev/testing)
filePath = URL.createObjectURL(file);
}
const tileSet = new TileSet({
name,
filePath,
nativePath,
tileWidth: 8,
tileHeight: 8
});
await tileSet.load();
this.tileSets.set(tileSet.id, tileSet);
this.#render();
this.#selectTileSet(tileSet.id);
} catch (error) {
console.error("Failed to add tileset:", error);
alert(`Failed to load tileset:\n${error.message || error}`);
}
});
input.click();
}
/**
* Opens tileset in external editor.
*
* @param {string} id Tileset ID.
*/
#openExternal(id) {
const tileSet = this.tileSets.get(id);
if (!tileSet) return;
// In Electron, show the file in explorer so user can open with their preferred editor
if (window.electronAPI?.showTilesetInFolder && tileSet.nativePath) {
window.electronAPI.showTilesetInFolder(tileSet.nativePath);
} else {
// Fallback: show message
const pathToShow = tileSet.nativePath || tileSet.filePath;
alert(`To edit this tileset, open:\n${pathToShow}\n\nIn your image editor of choice.`);
}
}
/**
* Reloads a tileset from disk.
*
* @param {string} id Tileset ID.
*/
async #reloadTileSet(id) {
const tileSet = this.tileSets.get(id);
if (!tileSet) return;
try {
await tileSet.reload();
this.#render();
if (this.selectedTileSetId === id) {
this.#renderPreview();
}
} catch (error) {
console.error("Failed to reload tileset:", error);
alert(`Failed to reload tileset:\n${error.message || error}`);
}
}
/**
* Removes a tileset.
*
* @param {string} id Tileset ID.
*/
#removeTileSet(id) {
if (!confirm("Remove this tileset?")) return;
this.tileSets.delete(id);
if (this.selectedTileSetId === id) {
this.selectedTileSetId = null;
this.selectedTileIndex = null;
}
this.#render();
this.#renderPreview();
}
/**
* Edits tileset settings.
*
* @param {string} id Tileset ID.
*/
#editTileSetSettings(id) {
const tileSet = this.tileSets.get(id);
if (!tileSet) return;
const name = prompt("Tileset name:", tileSet.name);
if (!name) return;
tileSet.name = name;
this.#render();
}
/**
* Selects a tileset.
*
* @param {string} id Tileset ID.
*/
#selectTileSet(id) {
this.selectedTileSetId = id;
this.selectedTileIndex = null;
this.#render();
this.#renderPreview();
}
/**
* Renders the tileset list.
*/
#render() {
if (this.tileSets.size === 0) {
this.#renderEmpty();
return;
}
this.tileSetList.innerHTML = "";
this.tileSets.forEach((tileSet) => {
const item = document.createElement("div");
item.className = "tileset-item";
item.dataset.id = tileSet.id;
if (this.selectedTileSetId === tileSet.id) {
item.classList.add("selected");
}
const preview = document.createElement("div");
preview.className = "tileset-item-preview";
if (tileSet.isLoaded && tileSet.image) {
const canvas = document.createElement("canvas");
canvas.width = 64;
canvas.height = 64;
const ctx = canvas.getContext("2d");
ctx.imageSmoothingEnabled = false;
// Draw scaled down version
const scale = Math.min(64 / tileSet.image.width, 64 / tileSet.image.height);
const w = tileSet.image.width * scale;
const h = tileSet.image.height * scale;
ctx.drawImage(tileSet.image, 0, 0, w, h);
preview.appendChild(canvas);
} else {
preview.textContent = "?";
}
item.appendChild(preview);
const info = document.createElement("div");
info.className = "tileset-item-info";
const nameEl = document.createElement("div");
nameEl.className = "tileset-item-name";
nameEl.textContent = tileSet.name;
info.appendChild(nameEl);
const meta = document.createElement("div");
meta.className = "tileset-item-meta";
meta.textContent = `128×128px • ${tileSet.getTileCount()} tiles (${tileSet.tileWidth}×${tileSet.tileHeight}px each)`;
info.appendChild(meta);
item.appendChild(info);
const actions = document.createElement("div");
actions.className = "tileset-item-actions";
const openBtn = document.createElement("button");
openBtn.className = "tileset-item-btn";
openBtn.title = "Open in external editor";
openBtn.dataset.action = "open";
openBtn.dataset.id = tileSet.id;
openBtn.textContent = "✏️";
actions.appendChild(openBtn);
const reloadBtn = document.createElement("button");
reloadBtn.className = "tileset-item-btn";
reloadBtn.title = "Reload from disk";
reloadBtn.dataset.action = "reload";
reloadBtn.dataset.id = tileSet.id;
reloadBtn.textContent = "🔄";
actions.appendChild(reloadBtn);
const settingsBtn = document.createElement("button");
settingsBtn.className = "tileset-item-btn";
settingsBtn.title = "Settings";
settingsBtn.dataset.action = "edit-settings";
settingsBtn.dataset.id = tileSet.id;
settingsBtn.textContent = "⚙️";
actions.appendChild(settingsBtn);
const removeBtn = document.createElement("button");
removeBtn.className = "tileset-item-btn tileset-item-btn--danger";
removeBtn.title = "Remove tileset";
removeBtn.dataset.action = "remove";
removeBtn.dataset.id = tileSet.id;
removeBtn.textContent = "🗑️";
actions.appendChild(removeBtn);
item.appendChild(actions);
this.tileSetList.appendChild(item);
});
}
/**
* Renders the preview panel.
*/
#renderPreview() {
if (!this.selectedTileSetId) {
this.previewInfo.textContent = "Select a tileset to preview";
this.previewInfo.style.display = "block";
this.previewCanvas.style.display = "none";
return;
}
const tileSet = this.tileSets.get(this.selectedTileSetId);
if (!tileSet || !tileSet.isLoaded || !tileSet.image) {
this.previewInfo.textContent = "Tileset not loaded";
this.previewInfo.style.display = "block";
this.previewCanvas.style.display = "none";
return;
}
this.previewInfo.style.display = "none";
this.previewCanvas.style.display = "block";
// Set canvas size
const scale = 2;
this.previewCanvas.width = tileSet.getWidth() * scale;
this.previewCanvas.height = tileSet.getHeight() * scale;
this.previewCtx.imageSmoothingEnabled = false;
// Draw tileset
this.previewCtx.drawImage(
tileSet.image,
0, 0,
tileSet.image.width, tileSet.image.height,
0, 0,
this.previewCanvas.width, this.previewCanvas.height
);
// Draw grid
this.previewCtx.strokeStyle = "rgba(255, 255, 255, 0.3)";
this.previewCtx.lineWidth = 1;
for (let x = 0; x <= tileSet.columns; x++) {
const px = x * tileSet.tileWidth * scale;
this.previewCtx.beginPath();
this.previewCtx.moveTo(px, 0);
this.previewCtx.lineTo(px, this.previewCanvas.height);
this.previewCtx.stroke();
}
for (let y = 0; y <= tileSet.rows; y++) {
const py = y * tileSet.tileHeight * scale;
this.previewCtx.beginPath();
this.previewCtx.moveTo(0, py);
this.previewCtx.lineTo(this.previewCanvas.width, py);
this.previewCtx.stroke();
}
// Highlight selected tile
if (this.selectedTileIndex !== null) {
const rect = tileSet.getTileRect(this.selectedTileIndex);
this.previewCtx.strokeStyle = "rgba(255, 127, 17, 1)";
this.previewCtx.lineWidth = 2;
this.previewCtx.strokeRect(
rect.x * scale,
rect.y * scale,
rect.width * scale,
rect.height * scale
);
}
}
/**
* Renders empty state.
*/
#renderEmpty() {
this.tileSetList.innerHTML = `
<div class="tileset-empty">
<div class="tileset-empty-icon">🎨</div>
<div class="tileset-empty-text">No tilesets added</div>
<div class="tileset-empty-hint">Click "+ Add Tileset" to import a 128x128px PNG</div>
</div>
`;
this.#renderPreview();
}
/**
* Gets all tilesets.
*
* @returns {Array<TileSet>}
*/
getTileSets() {
return Array.from(this.tileSets.values());
}
/**
* Adds a tileset programmatically.
*
* @param {TileSet} tileSet Tileset to add.
*/
addTileSet(tileSet) {
this.tileSets.set(tileSet.id, tileSet);
this.#render();
}
/**
* Serializes all tilesets to JSON.
*
* @returns {Object}
*/
toJSON() {
return {
tileSets: Array.from(this.tileSets.values()).map((ts) => ts.toJSON())
};
}
/**
* Loads tilesets from JSON.
*
* @param {Object} data Serialized data.
*/
async fromJSON(data) {
if (!data.tileSets) return;
this.tileSets.clear();
for (const tsData of data.tileSets) {
try {
const tileSet = TileSet.fromJSON(tsData);
await tileSet.load();
this.tileSets.set(tileSet.id, tileSet);
} catch (error) {
console.error("Failed to load tileset:", error);
}
}
this.#render();
}
}

View File

@@ -49,6 +49,7 @@ export class WorkspaceDragManager {
/**
* Updates the palette ghost transform to match the supplied position.
* The ghost is offset so the cursor appears at its center.
*
* @param {{ x: number, y: number }} position Snapped workspace position.
*/
@@ -57,8 +58,14 @@ export class WorkspaceDragManager {
if (!paletteDragGhost) {
return;
}
const width = paletteDragGhost.width || 220;
const height = paletteDragGhost.height || 140;
const centered = {
x: position.x - width / 2,
y: position.y - height / 2,
};
paletteDragGhost.element.style.transform =
WorkspaceGeometry.positionToTransform(position);
WorkspaceGeometry.positionToTransform(centered);
}
/**
@@ -210,7 +217,7 @@ export class WorkspaceDragManager {
return;
}
const pointerWorld = this.#clientToWorkspace(
const pointerWorld = this.clientToWorkspace(
hasClientX ? pointer.clientX : context.previousClientX,
hasClientY ? pointer.clientY : context.previousClientY
);
@@ -526,7 +533,7 @@ export class WorkspaceDragManager {
* @returns {{ x: number, y: number }}
*/
#eventToWorkspacePoint(event) {
return this.#clientToWorkspace(event.clientX, event.clientY);
return this.clientToWorkspace(event.clientX, event.clientY);
}
/**
@@ -560,15 +567,20 @@ export class WorkspaceDragManager {
* @param {number | undefined} clientY Viewport Y coordinate.
* @returns {{ x: number, y: number }}
*/
#clientToWorkspace(clientX, clientY) {
clientToWorkspace(clientX, clientY) {
const rect = this.workspace.workspaceElement.getBoundingClientRect();
const zoom = this.workspace.zoomLevel || 1;
const zoom = this.workspace.navigationManager?.getZoomLevel() ?? this.workspace.zoomLevel ?? 1;
const clampedZoom = Math.max(0.01, zoom);
const safeX = Number.isFinite(clientX) ? clientX : rect.left;
const safeY = Number.isFinite(clientY) ? clientY : rect.top;
const pending = this.workspace.navigationManager?.getPendingLayerOffset?.() ?? { x: 0, y: 0 };
const effectiveOffset = this.workspace.navigationManager?.getEffectiveOffset?.();
const offset = {
x: Number.isFinite(effectiveOffset?.x) ? effectiveOffset.x : 0,
y: Number.isFinite(effectiveOffset?.y) ? effectiveOffset.y : 0,
};
return {
x: (safeX - rect.left) / zoom - pending.x,
y: (safeY - rect.top) / zoom - pending.y,
x: (safeX - rect.left) / clampedZoom - offset.x,
y: (safeY - rect.top) / clampedZoom - offset.y,
};
}

View File

@@ -188,10 +188,14 @@ export class WorkspaceDropManager {
this.#workspace.paletteManager.handleDragEnd();
const spawn = WorkspaceGeometry.snapPositionToGrid(
this.#workspace,
WorkspaceGeometry.eventToWorkspacePoint(this.#workspace, event)
);
// Snap to grid first, then offset for centering (consistent with ghost positioning)
const cursorWorld = WorkspaceGeometry.eventToWorkspacePoint(this.#workspace, event);
const snapped = WorkspaceGeometry.snapPositionToGrid(this.#workspace, cursorWorld);
// Offset spawn to match the centered ghost position (roughly half ghost size)
const spawn = {
x: snapped.x - 110,
y: snapped.y - 70,
};
this.#workspace.addNode(definitionId, spawn);
}
@@ -214,10 +218,12 @@ export class WorkspaceDropManager {
const definitions = this.#getVariableSpawnDefinitions(variableId);
if (!definitions.length) {
const spawn = WorkspaceGeometry.snapPositionToGrid(
this.#workspace,
WorkspaceGeometry.eventToWorkspacePoint(this.#workspace, event)
);
const cursorWorld = WorkspaceGeometry.eventToWorkspacePoint(this.#workspace, event);
const snapped = WorkspaceGeometry.snapPositionToGrid(this.#workspace, cursorWorld);
const spawn = {
x: snapped.x - 110,
y: snapped.y - 70,
};
const fallback = this.#workspace.addNode("get_var", spawn);
if (fallback) {
const nodeName = variable.name || variable.id;

View File

@@ -29,20 +29,11 @@ export class WorkspaceGeometry {
* Converts viewport coordinates into workspace-relative coordinates.
*
* @param {import('../BlueprintWorkspace.js').BlueprintWorkspace} workspace Workspace instance.
* @param {{ clientX: number, clientY: number }} event Pointer-like event payload.
* @param {{ clientX?: number, clientY?: number }} event Pointer-like event payload.
* @returns {{ x: number, y: number }}
*/
static eventToWorkspacePoint(workspace, event) {
const rect = workspace.workspaceElement.getBoundingClientRect();
const rawX = event?.clientX ?? 0;
const rawY = event?.clientY ?? 0;
const x = rawX - rect.left;
const y = rawY - rect.top;
const zoom = workspace.zoomLevel || 1;
return {
x: Math.max(0, Math.min(rect.width, x)) / zoom,
y: Math.max(0, Math.min(rect.height, y)) / zoom,
};
return workspace.dragManager.clientToWorkspace(event?.clientX, event?.clientY);
}
/**
@@ -54,8 +45,9 @@ export class WorkspaceGeometry {
*/
static snapPositionToGrid(workspace, position) {
const size = workspace.gridSize || DEFAULT_GRID_SIZE;
const offsetX = workspace.workspaceBackgroundOffset?.x ?? 0;
const offsetY = workspace.workspaceBackgroundOffset?.y ?? 0;
const effectiveOffset = workspace.navigationManager?.getEffectiveOffset?.();
const offsetX = Number.isFinite(effectiveOffset?.x) ? effectiveOffset.x : 0;
const offsetY = Number.isFinite(effectiveOffset?.y) ? effectiveOffset.y : 0;
const snap = (value, offset) => {
const local = value - offset;
const snapped = Math.round(local / size) * size;

View File

@@ -1,3 +1,6 @@
import { createColorSwatchPicker } from '../ColorSwatchPicker.js';
import { getTypeColor } from '../../types/TypeRegistry.js';
/** @typedef {import('../../core/BlueprintNode.js').BlueprintNode} BlueprintNode */
/** @typedef {import('../../core/BlueprintNode.js').PinDescriptor} PinDescriptor */
/** @typedef {import('../../core/NodeGraph.js').NodeGraph} NodeGraph */
@@ -75,6 +78,7 @@ export class WorkspaceInlineEditorManager {
"number_literal",
"string_literal",
"boolean_literal",
"color_literal",
]);
if (!supported.has(node.type)) {
return null;
@@ -100,6 +104,27 @@ export class WorkspaceInlineEditorManager {
editor.appendChild(label);
const graph = this.#getGraph();
if (node.type === "color_literal") {
const picker = createColorSwatchPicker({
id: controlId,
value: node.properties.value ?? 7,
onChange: (index) => {
graph.setNodeProperty(node.id, "value", index);
},
});
editor.appendChild(picker.element);
const setValue = (rawValue) => {
const n = Number(rawValue);
picker.setValue(Number.isFinite(n) ? n : 7);
};
const entry = this.#ensureEntry(node.id);
entry.literal = { setValue };
return entry.literal;
}
let control;
const commitNumber = () => {
const numeric = Number(control.value);
@@ -176,6 +201,7 @@ export class WorkspaceInlineEditorManager {
{ value: "string", label: "String" },
{ value: "boolean", label: "Boolean" },
{ value: "table", label: "Table" },
{ value: "color", label: "Color" },
];
const ensureBody = () => {
@@ -274,6 +300,8 @@ export class WorkspaceInlineEditorManager {
return "valueBoolean";
case "table":
return "valueTable";
case "color":
return "valueColor";
case "number":
default:
return "valueNumber";
@@ -288,6 +316,8 @@ export class WorkspaceInlineEditorManager {
return "string";
case "table":
return "table";
case "color":
return "color";
default:
return "number";
}
@@ -301,6 +331,8 @@ export class WorkspaceInlineEditorManager {
return false;
case "table":
return "{}";
case "color":
return 7;
case "number":
default:
return 0;
@@ -313,6 +345,7 @@ export class WorkspaceInlineEditorManager {
const normalized = normalizeType(type);
activeType = normalized;
typeButton.dataset.variableType = normalized;
typeButton.style.setProperty("--overview-variable-color", getTypeColor(normalized));
const label = formatType(normalized);
typeButton.title = `Type: ${label}`;
typeButton.setAttribute(
@@ -378,6 +411,20 @@ export class WorkspaceInlineEditorManager {
graph.setNodeProperty(currentNode.id, key, textarea.value);
});
control = textarea;
} else if (desiredKind === "color") {
const rawColor = currentNode.properties?.[key];
const n = Number(rawColor);
const initialIndex = Number.isFinite(n) ? Math.max(0, Math.min(15, Math.round(n))) : 7;
const picker = createColorSwatchPicker({
value: initialIndex,
onChange: (index) => {
graph.setNodeProperty(currentNode.id, key, index);
},
});
picker.element.dataset.localValueKind = "color";
/** @type {any} */ (picker.element)._setColorValue = picker.setValue;
valueField.appendChild(picker.element);
return picker.element;
} else {
const numberInput = document.createElement("input");
numberInput.type = "number";
@@ -407,6 +454,10 @@ export class WorkspaceInlineEditorManager {
if (control.value !== nextValue) {
control.value = nextValue;
}
} else if (/** @type {any} */ (control)._setColorValue) {
const n = Number(current);
const index = Number.isFinite(n) ? Math.max(0, Math.min(15, Math.round(n))) : 7;
/** @type {any} */ (control)._setColorValue(index);
} else if (control instanceof HTMLTextAreaElement) {
const nextValue = typeof current === "string" ? current : "{}";
if (control.value !== nextValue) {
@@ -534,18 +585,10 @@ export class WorkspaceInlineEditorManager {
}
const isIfCondition = node.type === "if" && pin.id === "condition";
const isExtcmdCommand =
node.type === "system_extcmd" && pin.id === "command";
const isNamedButtonSelector =
node.type === "input_button_constants" && pin.id === "button";
const isNamedPlayerSelector =
node.type === "input_button_constants" && pin.id === "player";
if (
!isIfCondition &&
!isExtcmdCommand &&
!isNamedButtonSelector &&
!isNamedPlayerSelector
) {
const isColorPin = pin.kind === "color";
const pinOptions =
Array.isArray(pin.options) && pin.options.length > 0 ? pin.options : null;
if (!isIfCondition && !pinOptions && !isColorPin) {
container.classList.remove("has-inline-dropdown");
return;
}
@@ -555,31 +598,14 @@ export class WorkspaceInlineEditorManager {
const graph = this.#getGraph();
if (!(propertyKey in node.properties)) {
if (isExtcmdCommand || isNamedButtonSelector || isNamedPlayerSelector) {
const definition = this.registry.get(node.type);
const targetKey = isExtcmdCommand
? "command"
: isNamedButtonSelector
? "button"
: "player";
const schema = definition?.properties?.find(
(prop) => prop.key === targetKey
);
const options = Array.isArray(schema?.options) ? schema.options : [];
const defaultOption =
typeof node.properties[targetKey] === "string"
? node.properties[targetKey]
: String(schema?.defaultValue ?? options[0]?.value ?? "");
if (typeof node.properties[targetKey] !== "string") {
node.properties[targetKey] = defaultOption;
}
if (isExtcmdCommand) {
node.properties[propertyKey] = defaultOption;
} else {
const numericDefault = Number.parseInt(defaultOption, 10);
node.properties[propertyKey] = Number.isFinite(numericDefault)
? numericDefault
: 0;
if (pinOptions) {
const defaultStr =
pin.defaultValue != null
? String(pin.defaultValue)
: String(pinOptions[0]?.value ?? "");
node.properties[propertyKey] = defaultStr;
if (!(pin.id in node.properties)) {
node.properties[pin.id] = defaultStr;
}
} else {
node.properties[propertyKey] = this.#defaultValueForPin(pin);
@@ -591,7 +617,7 @@ export class WorkspaceInlineEditorManager {
});
let wrapper;
if (isExtcmdCommand || isNamedButtonSelector || isNamedPlayerSelector) {
if (pinOptions) {
wrapper = document.createElement("div");
wrapper.className = "pin-inline-control pin-inline-dropdown";
container.appendChild(wrapper);
@@ -631,29 +657,14 @@ export class WorkspaceInlineEditorManager {
return;
}
if (isExtcmdCommand || isNamedButtonSelector || isNamedPlayerSelector) {
const definition = this.registry.get(node.type);
const targetKey = isExtcmdCommand
? "command"
: isNamedButtonSelector
? "button"
: "player";
const schema = definition?.properties?.find(
(prop) => prop.key === targetKey
);
const options = Array.isArray(schema?.options) ? schema.options : [];
if (pinOptions) {
const optionValues = pinOptions.map((o) => String(o.value));
const select = document.createElement("select");
select.className = "pin-inline-select";
const ariaLabel = isExtcmdCommand
? "Command"
: isNamedButtonSelector
? "Button"
: "Player";
select.setAttribute("aria-label", ariaLabel);
select.setAttribute("aria-label", pin.name || pin.id);
const values = options.map((option) => String(option.value));
options.forEach((option) => {
pinOptions.forEach((option) => {
const element = document.createElement("option");
element.value = String(option.value);
element.textContent = option.label ?? String(option.value);
@@ -661,30 +672,14 @@ export class WorkspaceInlineEditorManager {
});
const normalizeValue = (value) => {
const candidate =
typeof value === "string" ? value : String(value ?? "");
if (values.length === 0) {
return candidate;
}
if (values.includes(candidate)) {
return candidate;
}
const schemaDefault =
schema?.defaultValue != null ? String(schema.defaultValue) : null;
if (schemaDefault && values.includes(schemaDefault)) {
return schemaDefault;
}
return values[0];
const candidate = value == null ? "" : String(value);
return optionValues.includes(candidate)
? candidate
: (optionValues[0] ?? "");
};
const setValue = (value) => {
const normalized = normalizeValue(
(isNamedButtonSelector || isNamedPlayerSelector) &&
typeof node.properties[targetKey] === "string"
? node.properties[targetKey]
: value
);
select.value = normalized;
select.value = normalizeValue(value);
};
const setConnected = (connected) => {
@@ -694,23 +689,42 @@ export class WorkspaceInlineEditorManager {
select.addEventListener("change", () => {
const nextValue = select.value;
if (isExtcmdCommand) {
graph.setNodeProperty(node.id, propertyKey, nextValue);
graph.setNodeProperty(node.id, "command", nextValue);
} else {
const normalized = normalizeValue(nextValue);
const numeric = Number.parseInt(normalized, 10);
graph.setNodeProperty(
node.id,
propertyKey,
Number.isFinite(numeric) ? numeric : 0
);
graph.setNodeProperty(node.id, targetKey, normalized);
}
graph.setNodeProperty(node.id, pin.id, nextValue);
});
wrapper.appendChild(select);
entry.pinEditors.set(pin.id, { setValue, setConnected });
setValue(node.properties[propertyKey]);
setConnected(this.#hasConnection(node.id, pin.id));
return;
}
if (isColorPin) {
const rawDefault = node.properties[propertyKey];
const n = Number(rawDefault);
const initialIndex = Number.isFinite(n)
? Math.max(0, Math.min(15, Math.round(n)))
: 7;
const picker = createColorSwatchPicker({
value: initialIndex,
onChange: (index) => {
graph.setNodeProperty(node.id, propertyKey, index);
},
});
wrapper.appendChild(picker.element);
const setValue = (value) => {
const num = Number(value);
picker.setValue(Number.isFinite(num) ? num : 7);
};
const setConnected = (connected) => {
wrapper.classList.toggle("is-hidden", connected);
};
entry.pinEditors.set(pin.id, { setValue, setConnected });
setValue(node.properties[propertyKey]);
setConnected(this.#hasConnection(node.id, pin.id));
@@ -765,45 +779,12 @@ export class WorkspaceInlineEditorManager {
const propertyKey = this.#pinPropertyKey(pinId);
let value;
if (node.type === "system_extcmd" && pinId === "command") {
const commandValue =
typeof node.properties.command === "string"
? node.properties.command
: null;
if (
commandValue !== null &&
node.properties[propertyKey] !== commandValue
) {
node.properties[propertyKey] = commandValue;
}
} else if (node.type === "input_button_constants") {
if (pinId === "button") {
const rawButton =
typeof node.properties.button === "string"
? node.properties.button
: null;
if (rawButton !== null) {
const numericButton = Number.parseInt(rawButton, 10);
const safeButton = Number.isFinite(numericButton)
? numericButton
: 0;
if (node.properties[propertyKey] !== safeButton) {
node.properties[propertyKey] = safeButton;
}
}
} else if (pinId === "player") {
const rawPlayer =
typeof node.properties.player === "string"
? node.properties.player
: null;
if (rawPlayer !== null) {
const numericPlayer = Number.parseInt(rawPlayer, 10);
const safePlayer = Number.isFinite(numericPlayer)
? numericPlayer
: 0;
if (node.properties[propertyKey] !== safePlayer) {
node.properties[propertyKey] = safePlayer;
}
if (Array.isArray(pin?.options) && pin.options.length > 0) {
const canonical = node.properties[pinId];
if (canonical != null) {
const strValue = String(canonical);
if (node.properties[propertyKey] !== strValue) {
node.properties[propertyKey] = strValue;
}
}
}
@@ -905,6 +886,8 @@ export class WorkspaceInlineEditorManager {
return 0;
case "string":
return "";
case "color":
return 7;
default:
return null;
}

View File

@@ -106,10 +106,6 @@ export class WorkspaceNavigationManager {
this.#targetZoomLevel = clamped;
this.#zoomLevel = clamped;
this.updateZoomDisplay(skipConnectionRender);
if (!silent) {
this.#markViewDirty("view", 200);
this.#schedulePersist();
}
return true;
}
@@ -151,6 +147,9 @@ export class WorkspaceNavigationManager {
/**
* Converts a screen-space point to workspace coordinates.
* Note: This returns screen coordinates divided by zoom only, without subtracting
* the background offset. This is intentional for zoom pivot calculations.
* For true world coordinates, use dragManager.clientToWorkspace().
*
* @param {{ x: number, y: number }} point Screen-space point relative to the workspace element.
* @returns {{ x: number, y: number }}
@@ -273,8 +272,6 @@ export class WorkspaceNavigationManager {
}
// Restore clean scale-only transform now that node positions are committed.
this.updateZoomDisplay(false);
this.#markViewDirty("view", 0);
this.#schedulePersist();
} else {
this.#smoothZoomRafId = requestAnimationFrame(() => this.#tickSmoothZoom());
}
@@ -320,6 +317,7 @@ export class WorkspaceNavigationManager {
this.#rebasePanGestureAfterZoom(pointer);
}
this.updateZoomDisplay(true);
this.#renderConnections();
}
@@ -417,6 +415,7 @@ export class WorkspaceNavigationManager {
});
this.translateWorkspace(delta);
this.updateZoomDisplay(false);
this.#workspace.selectNode(nodeId);
}
@@ -489,30 +488,29 @@ export class WorkspaceNavigationManager {
}
targetZoom = Math.max(this.#zoomConfig.min, Math.min(this.#zoomConfig.max, targetZoom));
this.setZoomLevel(targetZoom);
const contentCenter = {
x: minX + boundsWidth / 2,
y: minY + boundsHeight / 2,
};
const viewportCenter = this.screenPointToWorld({
x: rect.width / 2,
y: rect.height / 2,
});
// Directly compute the background offset that places contentCenter at screen center,
// then set zoom + offset together so a single updateZoomDisplay call applies both.
const newOffsetX = rect.width / 2 / targetZoom - contentCenter.x;
const newOffsetY = rect.height / 2 / targetZoom - contentCenter.y;
const delta = {
x: viewportCenter.x - contentCenter.x,
y: viewportCenter.y - contentCenter.y,
};
if (Math.abs(delta.x) > 0.0001 || Math.abs(delta.y) > 0.0001) {
this.translateWorkspace(delta);
this.#renderConnections();
// Cancel any running smooth zoom before snapping.
if (this.#smoothZoomRafId !== null) {
cancelAnimationFrame(this.#smoothZoomRafId);
this.#smoothZoomRafId = null;
this.#smoothZoomFocus = null;
this.#pendingLayerOffset = { x: 0, y: 0 };
}
this.#markViewDirty("view", 200);
this.#schedulePersist();
this.#targetZoomLevel = targetZoom;
this.#zoomLevel = targetZoom;
this.setBackgroundOffset({ x: newOffsetX, y: newOffsetY });
this.updateZoomDisplay(false);
}
/**
@@ -536,6 +534,7 @@ export class WorkspaceNavigationManager {
startY: event.clientY,
hasMoved: false,
backgroundOrigin: { ...this.#backgroundOffset },
lastDelta: { x: 0, y: 0 },
};
const handlePointerMove = (moveEvent) => {
@@ -564,12 +563,14 @@ export class WorkspaceNavigationManager {
return;
}
this.#panState.lastDelta = worldDelta;
const backgroundOffset = {
x: this.#panState.backgroundOrigin.x + worldDelta.x,
y: this.#panState.backgroundOrigin.y + worldDelta.y,
};
this.setBackgroundOffset(backgroundOffset);
this.updateZoomDisplay();
this.updateZoomDisplay(true);
};
const handlePointerUp = (upEvent) => {
@@ -589,6 +590,7 @@ export class WorkspaceNavigationManager {
}
this.#lastPanTimestamp = this.#timestamp();
this.#renderConnections();
};
this.#panState = state;

View File

@@ -6,6 +6,7 @@
*/
import { WorkspaceGeometry } from "./WorkspaceGeometry.js";
import { getTypeColor } from "../../types/TypeRegistry.js";
/**
* @typedef {"input"|"output"} PinDirection
@@ -300,6 +301,7 @@ export class WorkspaceNodeRenderer {
container.dataset.pinId = pin.id;
container.dataset.nodeId = node.id;
container.dataset.type = pin.kind;
container.style.setProperty("--pin-kind-color", getTypeColor(pin.kind));
const isStandardExec =
pin.kind === "exec" && (pin.id === "exec_in" || pin.id === "exec_out");
if (isStandardExec) {

View File

@@ -54,28 +54,48 @@ export class WorkspacePersistenceManager {
}
this.cancelScheduledPersist();
this.#workspace.handlePersistenceStateChange?.({
status: "pending",
source: "auto",
});
this.#timerId = window.setTimeout(() => {
this.#timerId = null;
this.persistImmediately();
this.persistImmediately("auto");
}, 200);
}
/**
* Serializes current workspace state and writes it to localStorage immediately.
*
* @param {'manual'|'auto'} [source]
*/
persistImmediately() {
persistImmediately(source = "manual") {
if (!this.#storageAvailable || typeof window === "undefined") {
return;
}
this.cancelScheduledPersist();
this.#workspace.handlePersistenceStateChange?.({
status: "saving",
source,
});
try {
const payload = this.serializeWorkspace();
window.localStorage.setItem(
WORKSPACE_STORAGE_KEY,
JSON.stringify(payload)
);
this.#workspace.handlePersistenceStateChange?.({
status: "saved",
source,
});
} catch (error) {
console.error("Failed to persist workspace state", error);
this.#workspace.handlePersistenceStateChange?.({
status: "error",
source,
});
}
}
@@ -90,7 +110,7 @@ export class WorkspacePersistenceManager {
.filter((entry) => Boolean(entry))
.map((entry) => {
const variable =
/** @type {{ id: string, name: string, type: 'any'|'number'|'string'|'boolean'|'table', defaultValue: import('../BlueprintWorkspace.js').VariableDefaultValue }} */ (
/** @type {{ id: string, name: string, type: 'any'|'number'|'string'|'boolean'|'table'|'color', defaultValue: import('../BlueprintWorkspace.js').VariableDefaultValue }} */ (
entry
);
return {

View File

@@ -90,10 +90,12 @@ export class WorkspaceWireCutManager {
*/
#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,
y: (clientY - rect.top) / zoom,
x: (clientX - rect.left) / zoom - offset.x,
y: (clientY - rect.top) / zoom - offset.y,
};
}

View File

@@ -1,4 +1,5 @@
import { VARIABLE_SORT_MIME } from "../BlueprintWorkspaceConstants.js";
import { getTypeColor } from "../../types/TypeRegistry.js";
/**
* Renders the global variable summary list.
@@ -225,6 +226,7 @@ export function renderVariableList({
row.setAttribute("role", "button");
row.dataset.variableId = variable.id;
row.dataset.variableType = variable.type;
row.style.setProperty("--overview-variable-color", getTypeColor(variable.type));
row.draggable = true;
row.setAttribute("aria-pressed", "false");
@@ -330,6 +332,7 @@ export function renderVariableList({
typeButton.type = "button";
typeButton.className = "variable-type-button";
typeButton.dataset.variableType = variable.type;
typeButton.style.setProperty("--overview-variable-color", getTypeColor(variable.type));
typeButton.setAttribute("aria-haspopup", "menu");
typeButton.setAttribute("aria-expanded", "false");
typeButton.title = `Type: ${formatVariableType(variable.type)}`;

176
sprite-editor.html Normal file
View File

@@ -0,0 +1,176 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PICO-8 Sprite Editor - Picograph</title>
<link rel="stylesheet" href="styles/main.css">
<style>
body {
margin: 0;
padding: 0;
width: 100vw;
height: 100vh;
overflow: hidden;
}
#editor-container {
width: 100%;
height: 100%;
}
.sprite-editor-header {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
background: var(--surface-2);
border-bottom: 1px solid var(--edge);
}
.sprite-editor-title {
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.sprite-editor-actions {
display: flex;
gap: 0.5rem;
}
.sprite-editor-btn {
padding: 0.4rem 0.75rem;
font-size: 0.8rem;
}
</style>
</head>
<body>
<div class="sprite-editor-header">
<div class="sprite-editor-title">🎨 Sprite Editor</div>
<div></div>
<div class="sprite-editor-actions">
<button class="sprite-editor-btn" id="export-btn">Export to .p8</button>
<button class="sprite-editor-btn" id="import-btn">Import from .p8</button>
<button class="sprite-editor-btn" id="save-json-btn">Save JSON</button>
</div>
</div>
<div id="editor-container"></div>
<script type="module">
import { SpriteEditor } from './scripts/ui/SpriteEditor.js';
import { SpriteSheet } from './scripts/core/SpriteSheet.js';
// Initialize the sprite editor
const container = document.getElementById('editor-container');
const spriteSheet = new SpriteSheet();
const editor = new SpriteEditor(container, spriteSheet);
// Export to .p8 format
document.getElementById('export-btn').addEventListener('click', () => {
const gfxData = editor.getSpriteSheet().toP8Gfx();
const flagData = editor.getSpriteSheet().toP8Flags();
const p8Content = `pico-8 cartridge // http://www.pico-8.com
version 41
__lua__
-- your code here
__gfx__
${gfxData}
__gff__
${flagData}
`;
// Download the .p8 file
const blob = new Blob([p8Content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'sprites.p8';
a.click();
URL.revokeObjectURL(url);
});
// Import from .p8 format
document.getElementById('import-btn').addEventListener('click', () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.p8';
input.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
const text = await file.text();
// Extract __gfx__ section
const gfxMatch = text.match(/__gfx__\s*\n([\s\S]*?)(?=\n__|$)/);
if (gfxMatch) {
editor.getSpriteSheet().fromP8Gfx(gfxMatch[1]);
}
// Extract __gff__ section
const gffMatch = text.match(/__gff__\s*\n([\s\S]*?)(?=\n__|$)/);
if (gffMatch) {
editor.getSpriteSheet().fromP8Flags(gffMatch[1]);
}
// Re-render
editor.setSpriteSheet(editor.getSpriteSheet());
});
input.click();
});
// Save as JSON
document.getElementById('save-json-btn').addEventListener('click', () => {
const json = JSON.stringify(editor.getSpriteSheet().toJSON(), null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'sprites.json';
a.click();
URL.revokeObjectURL(url);
});
// Example: Draw a smiley face on sprite 0
function drawExampleSprite() {
const sheet = editor.getSpriteSheet();
// Draw a simple smiley face
// Face outline (yellow)
for (let x = 1; x < 7; x++) {
sheet.setPixel(x, 1, 10); // Top
sheet.setPixel(x, 6, 10); // Bottom
}
for (let y = 2; y < 6; y++) {
sheet.setPixel(1, y, 10); // Left
sheet.setPixel(6, y, 10); // Right
}
// Eyes (black)
sheet.setPixel(2, 3, 0);
sheet.setPixel(5, 3, 0);
// Smile (black)
sheet.setPixel(2, 5, 0);
sheet.setPixel(3, 5, 0);
sheet.setPixel(4, 5, 0);
sheet.setPixel(5, 5, 0);
editor.setSpriteSheet(sheet);
}
// Uncomment to draw example sprite on load
// drawExampleSprite();
console.log('Sprite Editor initialized!');
console.log('Available tools: Pen, Fill, Line, Rect, Circle, Erase');
console.log('Click sprites in the sheet to edit them in the preview panel');
</script>
</body>
</html>

View File

@@ -108,88 +108,74 @@ input[type='search'] {
grid-template-columns: auto 1fr auto;
align-items: center;
justify-content: stretch;
gap: 1rem;
padding: 0.75rem 1.25rem;
background: linear-gradient(90deg, var(--surface-1), var(--surface-2));
gap: 0.75rem;
min-height: 46px;
padding: 0.375rem 0.5rem 0.375rem 0.625rem;
background: linear-gradient(180deg, color-mix(in srgb, var(--surface-1) 88%, #0a0b10 12%), var(--surface-2));
border-bottom: 1px solid var(--edge);
box-shadow: var(--shadow-0);
z-index: 10;
-webkit-app-region: drag;
}
.header-primary-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.45rem;
-webkit-app-region: no-drag;
}
.header-titlebar {
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
}
.header-title {
font-size: 1.1rem;
letter-spacing: 0.04em;
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
justify-self: center;
text-align: center;
opacity: 0.9;
}
.header-primary-button {
min-width: 7rem;
padding: 0.55rem 1.25rem;
border: none;
border-radius: 999px;
font-size: 0.9rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
background: var(--accent);
color: #1e1e28;
cursor: pointer;
transition: background-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
box-shadow: 0 0 0 0 rgba(255, 127, 17, 0.45);
.header-icon-button.is-pending {
opacity: 0.8;
}
.header-primary-button:hover:enabled,
.header-primary-button:focus-visible:enabled {
transform: translateY(-1px);
box-shadow: 0 10px 18px rgba(255, 127, 17, 0.35);
outline: none;
}
.header-primary-button:active:enabled {
transform: translateY(0);
box-shadow: 0 4px 10px rgba(255, 127, 17, 0.28);
}
.header-primary-button:disabled {
opacity: 0.6;
cursor: not-allowed;
box-shadow: none;
}
.header-primary-button.is-busy:disabled {
.header-icon-button.is-busy {
opacity: 0.7;
cursor: progress;
}
.header-primary-button.is-success {
background: #3ee581;
color: #06100a;
box-shadow: 0 0 0 0 rgba(62, 229, 129, 0.45);
.header-icon-button.is-success {
opacity: 1;
}
.header-primary-button.is-error {
background: #ff4f4f;
color: #1e0a0a;
box-shadow: 0 0 0 0 rgba(255, 79, 79, 0.45);
.header-icon-button.is-error {
opacity: 0.8;
}
.header-actions {
display: inline-flex;
gap: 0.5rem;
align-items: center;
gap: 0.35rem;
-webkit-app-region: no-drag;
}
.header-actions--left {
justify-self: start;
}
.header-actions .header-icon-button {
min-width: 0;
padding: 0;
width: 2.5rem;
height: 2.5rem;
border-radius: 12px;
width: 2.1rem;
height: 2.1rem;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
@@ -197,16 +183,36 @@ input[type='search'] {
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
}
.header-icon-button--accent {
background: var(--accent);
color: #1e1e28;
box-shadow: 0 10px 18px rgba(255, 127, 17, 0.18);
}
.header-icon-button--accent:hover:enabled,
.header-icon-button--accent:focus-visible:enabled {
color: #1e1e28;
background: color-mix(in srgb, var(--accent) 84%, white 16%);
}
.header-icon-button .header-icon {
display: inline-block;
width: 1.3rem;
height: 1.3rem;
width: 1.05rem;
height: 1.05rem;
background-color: currentColor;
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
}
.header-icon-spacer {
width: 1px;
height: 1.2rem;
margin: 0 0.2rem;
background: color-mix(in srgb, var(--edge) 78%, transparent 22%);
border-radius: 999px;
}
.header-icon--settings {
mask-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"%3E%3Cpath d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z"/%3E%3Cpath d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9c0 .69.4 1.31 1.02 1.58.21.09.44.14.67.14H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"/%3E%3C/svg%3E');
}
@@ -219,10 +225,34 @@ input[type='search'] {
mask-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"%3E%3Cpath d="M12 4a8 8 0 1 0 7.54 5.14 1.5 1.5 0 0 0-1.54-1.14h-1.38a1.5 1.5 0 0 1-1.5-1.5V5.5A1.5 1.5 0 0 0 12.62 4Z"/%3E%3Ccircle cx="7.5" cy="10.5" r="1"/%3E%3Ccircle cx="12" cy="7.5" r="1"/%3E%3Ccircle cx="16.5" cy="10.5" r="1"/%3E%3Ccircle cx="11" cy="14.5" r="1"/%3E%3C/svg%3E');
}
.header-icon--tiles {
mask-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"%3E%3Crect x="3" y="3" width="7" height="7"/%3E%3Crect x="14" y="3" width="7" height="7"/%3E%3Crect x="14" y="14" width="7" height="7"/%3E%3Crect x="3" y="14" width="7" height="7"/%3E%3C/svg%3E');
}
.header-icon--export {
mask-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"%3E%3Cpath d="M12 3v12"/%3E%3Cpath d="m8 11 4 4 4-4"/%3E%3Cpath d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/%3E%3C/svg%3E');
}
.header-icon--save {
mask-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"%3E%3Cpath d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2zM7 3v6h10V3M7 13h10v8H7"%2F%3E%3C%2Fsvg%3E');
}
.header-icon--play {
mask-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"%3E%3Cpath d="M8 5.14v13.72c0 .8.87 1.28 1.54.85l10.03-6.86a1.03 1.03 0 0 0 0-1.7L9.54 4.29A1.03 1.03 0 0 0 8 5.14Z"/%3E%3C/svg%3E');
}
.header-icon--minimize {
mask-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"%3E%3Cpath d="M6 12h12"/%3E%3C/svg%3E');
}
.header-icon--maximize {
mask-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"%3E%3Crect x="5" y="5" width="14" height="14" rx="1.5"/%3E%3C/svg%3E');
}
.header-icon--close-window {
mask-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"%3E%3Cpath d="M6 6l12 12M18 6L6 18"/%3E%3C/svg%3E');
}
.header-icon-button.is-active {
background: rgba(255, 127, 17, 0.2);
border-color: var(--accent);
@@ -243,11 +273,49 @@ input[type='search'] {
border-color: var(--accent);
}
.window-controls {
display: inline-flex;
align-items: center;
gap: 0.1rem;
margin-left: 0.3rem;
padding-left: 0.25rem;
border-left: 1px solid color-mix(in srgb, var(--edge) 72%, transparent 28%);
}
.window-control-button {
min-width: 0;
width: 2rem;
height: 2rem;
padding: 0;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
-webkit-app-region: no-drag;
}
.window-control-button:hover,
.window-control-button:focus-visible {
background: var(--surface-3);
color: var(--text-primary);
outline: none;
}
.window-control-button--close:hover,
.window-control-button--close:focus-visible {
background: #e04a4a;
color: #fff5f5;
}
.app-body {
flex: 1;
display: grid;
grid-template-columns: 280px minmax(0, 1fr) 280px 280px;
grid-template-rows: 1fr;
grid-template-areas: "overview workspace palette inspector";
gap: 0;
min-height: 0;
overflow: hidden;
@@ -261,12 +329,29 @@ input[type='search'] {
.app-body--palette-hidden {
grid-template-columns: 240px minmax(0, 1fr) 280px;
grid-template-areas: "overview workspace inspector";
}
.app-body--palette-hidden .palette {
display: none;
}
.overview {
grid-area: overview;
}
.workspace {
grid-area: workspace;
}
.palette {
grid-area: palette;
}
.inspector {
grid-area: inspector;
}
.overview,
.palette,
.inspector {
@@ -587,10 +672,10 @@ input[type='search'] {
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25);
background-color: transparent;
background-image:
linear-gradient(var(--variable-color-table), var(--variable-color-table)),
linear-gradient(var(--variable-color-table), var(--variable-color-table)),
linear-gradient(var(--variable-color-table), var(--variable-color-table)),
linear-gradient(var(--variable-color-table), var(--variable-color-table));
linear-gradient(var(--overview-variable-color), var(--overview-variable-color)),
linear-gradient(var(--overview-variable-color), var(--overview-variable-color)),
linear-gradient(var(--overview-variable-color), var(--overview-variable-color)),
linear-gradient(var(--overview-variable-color), var(--overview-variable-color));
background-size: calc(50% - 1px) calc(50% - 1px);
background-repeat: no-repeat;
background-position: left top, right top, left bottom, right bottom;
@@ -617,29 +702,7 @@ input[type='search'] {
color: var(--text-primary);
}
.overview-item[data-variable-type='number'] {
--overview-variable-color: var(--variable-color-number);
}
.overview-item[data-variable-type='string'] {
--overview-variable-color: var(--variable-color-string);
}
.overview-item[data-variable-type='boolean'] {
--overview-variable-color: var(--variable-color-boolean);
}
.overview-item[data-variable-type='any'] {
--overview-variable-color: var(--variable-color-any);
}
.overview-item[data-variable-type='table'] {
--overview-variable-color: var(--variable-color-table);
}
.overview-item[data-variable-type='color'] {
--overview-variable-color: var(--variable-color-color);
}
/* --overview-variable-color is set inline from JS via getTypeColor() */
.overview-item[data-variable-type] .overview-item-label {
cursor: text;
@@ -686,44 +749,24 @@ input[type='search'] {
width: 0.55rem;
height: 0.55rem;
border-radius: 999px;
background: var(--variable-color-any);
background: var(--overview-variable-color, var(--variable-color-any));
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25);
}
.variable-type-option[data-variable-type='number']::before {
background: var(--variable-color-number);
}
.variable-type-option[data-variable-type='string']::before {
background: var(--variable-color-string);
}
.variable-type-option[data-variable-type='boolean']::before {
background: var(--variable-color-boolean);
}
.variable-type-option[data-variable-type='any']::before {
background: var(--variable-color-any);
}
.variable-type-option[data-variable-type='table']::before {
border-radius: 0;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25);
background-color: transparent;
background-image:
linear-gradient(var(--variable-color-table), var(--variable-color-table)),
linear-gradient(var(--variable-color-table), var(--variable-color-table)),
linear-gradient(var(--variable-color-table), var(--variable-color-table)),
linear-gradient(var(--variable-color-table), var(--variable-color-table));
linear-gradient(var(--overview-variable-color), var(--overview-variable-color)),
linear-gradient(var(--overview-variable-color), var(--overview-variable-color)),
linear-gradient(var(--overview-variable-color), var(--overview-variable-color)),
linear-gradient(var(--overview-variable-color), var(--overview-variable-color));
background-size: calc(50% - 1px) calc(50% - 1px);
background-repeat: no-repeat;
background-position: left top, right top, left bottom, right bottom;
}
.variable-type-option[data-variable-type='color']::before {
background: var(--variable-color-color);
}
.variable-type-option:hover,
.variable-type-option:focus-visible {
background: var(--surface-3);
@@ -1272,7 +1315,7 @@ input[type='search'] {
}
.blueprint-node--variable {
--node-variable-color: var(--variable-color-any);
--overview-variable-color: var(--variable-color-any);
}
.blueprint-node[data-variable-scope='local'] {
@@ -1280,29 +1323,7 @@ input[type='search'] {
z-index: 12;
}
.blueprint-node[data-variable-type='number'] {
--node-variable-color: var(--variable-color-number);
}
.blueprint-node[data-variable-type='string'] {
--node-variable-color: var(--variable-color-string);
}
.blueprint-node[data-variable-type='boolean'] {
--node-variable-color: var(--variable-color-boolean);
}
.blueprint-node[data-variable-type='any'] {
--node-variable-color: var(--variable-color-any);
}
.blueprint-node[data-variable-type='table'] {
--node-variable-color: var(--variable-color-table);
}
.blueprint-node[data-variable-type='color'] {
--node-variable-color: var(--variable-color-color);
}
/* --overview-variable-color is set inline from JS via getTypeColor() */
.blueprint-node--variable .node-header {
padding-left: 1.3rem;
@@ -1316,18 +1337,83 @@ input[type='search'] {
top: 0;
bottom: 0;
width: 6px;
background: var(--node-variable-color);
background: var(--overview-variable-color);
}
.blueprint-node--variable .node-title-type {
border-color: var(--node-variable-color);
color: var(--node-variable-color);
border-color: var(--overview-variable-color);
color: var(--overview-variable-color);
background: rgba(255, 255, 255, 0.08);
}
.blueprint-node--variable.is-selected {
border-color: var(--node-variable-color);
box-shadow: 0 0 0 1px var(--node-variable-color);
border-color: var(--overview-variable-color);
box-shadow: 0 0 0 1px var(--overview-variable-color);
}
/* ── Reroute nodes ── */
.blueprint-node[data-node-type='reroute'],
.blueprint-node[data-node-type='reroute_exec'] {
min-width: 0;
width: 36px;
height: 36px;
border-radius: 50%;
grid-template-rows: 1fr;
grid-template-areas: 'inputs outputs';
overflow: visible;
}
.blueprint-node[data-node-type='reroute']::before,
.blueprint-node[data-node-type='reroute_exec']::before {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
background: var(--surface-3);
border: 1px solid var(--edge);
pointer-events: none;
}
.blueprint-node[data-node-type='reroute'].is-hovered::before,
.blueprint-node[data-node-type='reroute_exec'].is-hovered::before {
border-color: rgba(255, 127, 17, 0.4);
}
.blueprint-node[data-node-type='reroute'].is-selected::before,
.blueprint-node[data-node-type='reroute_exec'].is-selected::before {
border-color: var(--accent);
}
.blueprint-node[data-node-type='reroute']::after,
.blueprint-node[data-node-type='reroute_exec']::after {
border-radius: 50%;
}
.blueprint-node[data-node-type='reroute'] .node-header,
.blueprint-node[data-node-type='reroute_exec'] .node-header {
display: none;
}
.blueprint-node[data-node-type='reroute'] .node-inputs,
.blueprint-node[data-node-type='reroute_exec'] .node-inputs,
.blueprint-node[data-node-type='reroute'] .node-outputs,
.blueprint-node[data-node-type='reroute_exec'] .node-outputs {
padding: 0;
min-height: 0;
justify-content: center;
align-items: center;
background: transparent;
border: none;
}
.blueprint-node[data-node-type='reroute'] .pin-label,
.blueprint-node[data-node-type='reroute_exec'] .pin-label {
display: none;
}
.blueprint-node[data-node-type='reroute'] .pin,
.blueprint-node[data-node-type='reroute_exec'] .pin {
background: transparent;
}
.node-body {
@@ -1485,6 +1571,73 @@ input[type='search'] {
background: var(--surface-3);
}
/* ── Color swatch picker ──────────────────────────────────────── */
.color-swatch-picker {
position: relative;
display: inline-flex;
}
.color-swatch-trigger {
width: 1.25rem;
height: 1.25rem;
border: 2px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
cursor: pointer;
padding: 0;
flex-shrink: 0;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4);
transition: border-color 0.1s;
}
.color-swatch-trigger:hover,
.color-swatch-trigger:focus-visible {
border-color: rgba(255, 255, 255, 0.65);
outline: none;
}
.color-swatch-dropdown {
position: fixed;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 3px;
padding: 6px;
background: var(--surface-2);
border: 1px solid var(--edge);
border-radius: 8px;
box-shadow: var(--shadow-0);
z-index: 20;
}
.color-swatch-option {
width: 18px;
height: 18px;
border: 2px solid transparent;
border-radius: 3px;
cursor: pointer;
padding: 0;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3);
}
.color-swatch-option:hover,
.color-swatch-option:focus-visible {
border-color: rgba(255, 255, 255, 0.8);
outline: none;
}
.color-swatch-option[aria-checked='true'] {
border-color: #fff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5), 0 0 0 3px rgba(255, 255, 255, 0.25);
}
/* Align picker inside the local-variable and inspector default fields */
.local-variable-inline-value-field .color-swatch-picker,
.inspector-field .color-swatch-picker {
margin-top: 0.2rem;
}
.pin-inline-checkbox {
width: 16px;
height: 16px;
@@ -1753,32 +1906,16 @@ input[type='search'] {
z-index: 0;
}
.pin[data-type='exec'] .pin-handle {
color: var(--pin-exec);
}
.pin[data-type='number'] .pin-handle {
color: var(--variable-color-number);
}
.pin[data-type='boolean'] .pin-handle {
color: var(--variable-color-boolean);
}
.pin[data-type='string'] .pin-handle {
color: var(--variable-color-string);
/* --pin-kind-color is set inline from JS via getTypeColor() */
.pin .pin-handle {
color: var(--pin-kind-color, var(--variable-color-any));
}
.pin[data-type='table'] .pin-handle {
color: var(--variable-color-table);
border-radius: 5px;
position: relative;
}
.pin[data-type='color'] .pin-handle {
color: var(--variable-color-color);
}
.pin[data-type='table']:not(.is-disconnected) .pin-handle {
border-color: var(--edge);
border-width: 1px;
@@ -1860,6 +1997,8 @@ input[type='search'] {
position: relative;
}
/* --overview-variable-color is set inline from JS via getTypeColor() */
.custom-event-parameter-row .custom-event-parameter-action {
width: 1.35rem;
height: 1.35rem;
@@ -1870,22 +2009,6 @@ input[type='search'] {
height: 0.9rem;
}
.custom-event-parameter-row[data-parameter-type='number'] {
--overview-variable-color: var(--variable-color-number);
}
.custom-event-parameter-row[data-parameter-type='string'] {
--overview-variable-color: var(--variable-color-string);
}
.custom-event-parameter-row[data-parameter-type='boolean'] {
--overview-variable-color: var(--variable-color-boolean);
}
.custom-event-parameter-row[data-parameter-type='table'] {
--overview-variable-color: var(--variable-color-table);
}
.custom-event-parameter-row input[type='text'] {
width: 100%;
padding: 0.2rem 0.35rem;
@@ -2225,6 +2348,21 @@ body.modal-open {
z-index: 1;
}
.modal__panel--sprite {
width: min(1200px, calc(100vw - 4rem));
max-height: calc(100vh - 3rem);
}
.modal__panel--sprite .modal__body {
min-height: 600px;
}
.modal__panel--sprite #spriteEditorContainer {
width: 100%;
height: 100%;
display: flex;
}
.modal__header {
display: flex;
align-items: center;
@@ -2291,6 +2429,14 @@ body.modal-open {
mask-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"%3E%3Crect x="9" y="9" width="13" height="13" rx="2" ry="2"/%3E%3Cpath d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/%3E%3C/svg%3E');
}
.modal-icon--import {
mask-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"%3E%3Cpath d="M12 3v12"/%3E%3Cpath d="m16 11-4-4-4 4"/%3E%3Cpath d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/%3E%3C/svg%3E');
}
.modal-icon--export {
mask-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"%3E%3Cpath d="M12 3v12"/%3E%3Cpath d="m8 11 4 4 4-4"/%3E%3Cpath d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/%3E%3C/svg%3E');
}
.modal__body {
position: relative;
flex: 1;
@@ -2356,33 +2502,7 @@ body.modal-open {
pointer-events: stroke;
}
.connection-path[data-type='exec'] {
stroke: var(--pin-exec);
}
.connection-path[data-type='number'] {
stroke: var(--variable-color-number);
}
.connection-path[data-type='boolean'] {
stroke: var(--variable-color-boolean);
}
.connection-path[data-type='string'] {
stroke: var(--variable-color-string);
}
.connection-path[data-type='table'] {
stroke: var(--variable-color-table);
}
.connection-path[data-type='color'] {
stroke: var(--variable-color-color);
}
.connection-path[data-type='any'] {
stroke: var(--variable-color-any);
}
/* stroke is set inline from JS via getTypeColor() */
.connection-path[data-active='true'] {
stroke-dasharray: 6;
@@ -2408,6 +2528,20 @@ body.modal-open {
@media (max-width: 1200px) {
.app-body {
grid-template-columns: 220px minmax(0, 1fr) 220px;
grid-template-rows: 1fr 1fr;
grid-template-areas:
"overview workspace inspector"
"overview workspace palette";
}
.app-body--palette-hidden {
grid-template-columns: 220px minmax(0, 1fr) 220px;
grid-template-rows: 1fr;
grid-template-areas: "overview workspace inspector";
}
.inspector {
border-bottom: 1px solid var(--edge);
}
.blueprint-node {
@@ -2625,3 +2759,266 @@ body.modal-open {
outline-offset: 2px;
}
/* ── Tile Browser ────────────────────────────────────────────── */
.tile-browser-container {
height: 600px;
min-height: 400px;
}
.tileset-browser {
display: grid;
grid-template-columns: 300px 1fr;
grid-template-rows: auto 1fr;
height: 100%;
background: var(--surface-1);
overflow: hidden;
gap: 0;
}
.tileset-header {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--edge);
background: var(--surface-2);
}
.tileset-title {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-primary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.tileset-actions {
display: flex;
gap: 0.5rem;
}
.tileset-action-btn {
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 0.75rem;
background: var(--accent);
color: white;
border: none;
cursor: pointer;
transition: all 0.15s ease;
font-weight: 500;
}
.tileset-action-btn:hover {
background: var(--accent-hover);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(255, 127, 17, 0.3);
}
.tileset-action-btn:active {
transform: translateY(0);
}
.tileset-list {
grid-column: 1;
grid-row: 2;
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
background: var(--surface-0);
border-right: 1px solid var(--edge);
overflow-y: auto;
}
.tileset-item {
display: grid;
grid-template-columns: 64px 1fr auto;
gap: 0.75rem;
padding: 0.75rem;
background: var(--surface-1);
border: 1px solid var(--edge);
border-radius: 8px;
cursor: pointer;
transition: all 0.15s ease;
}
.tileset-item:hover {
background: var(--surface-2);
border-color: var(--accent);
transform: translateX(2px);
}
.tileset-item.selected {
background: var(--surface-3);
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(255, 127, 17, 0.2);
}
.tileset-item-preview {
width: 64px;
height: 64px;
border-radius: 4px;
border: 1px solid var(--edge);
background: repeating-conic-gradient(
rgba(255, 255, 255, 0.05) 0% 25%,
rgba(0, 0, 0, 0.1) 0% 50%
) 50% / 16px 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
color: var(--text-secondary);
overflow: hidden;
}
.tileset-item-preview canvas {
image-rendering: pixelated;
image-rendering: crisp-edges;
}
.tileset-item-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
justify-content: center;
min-width: 0;
}
.tileset-item-name {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tileset-item-meta {
font-size: 0.7rem;
color: var(--text-secondary);
}
.tileset-item-actions {
display: flex;
gap: 0.25rem;
align-items: center;
}
.tileset-item-btn {
width: 2rem;
height: 2rem;
padding: 0;
border-radius: 4px;
font-size: 1rem;
background: var(--surface-2);
border: 1px solid var(--edge);
cursor: pointer;
transition: all 0.15s ease;
display: inline-flex;
align-items: center;
justify-content: center;
}
.tileset-item-btn:hover {
background: var(--surface-3);
border-color: var(--accent);
transform: scale(1.05);
}
.tileset-item-btn--danger:hover {
background: var(--error);
border-color: var(--error);
color: white;
}
.tileset-preview-panel {
grid-column: 2;
grid-row: 2;
display: flex;
flex-direction: column;
background: var(--surface-0);
overflow: hidden;
}
.tileset-preview-header {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--edge);
background: var(--surface-1);
}
.tileset-preview-title {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.tileset-preview-content {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
overflow: auto;
position: relative;
}
.tileset-preview-canvas {
border: 2px solid var(--edge);
border-radius: 4px;
background: repeating-conic-gradient(
rgba(255, 255, 255, 0.05) 0% 25%,
rgba(0, 0, 0, 0.1) 0% 50%
) 50% / 16px 16px;
image-rendering: pixelated;
image-rendering: crisp-edges;
cursor: crosshair;
}
.tileset-preview-canvas:hover {
border-color: var(--accent);
}
.tileset-preview-info {
font-size: 0.85rem;
color: var(--text-secondary);
text-align: center;
}
.tileset-empty {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 3rem;
color: var(--text-secondary);
}
.tileset-empty-icon {
font-size: 3rem;
opacity: 0.5;
}
.tileset-empty-text {
font-size: 1rem;
font-weight: 600;
}
.tileset-empty-hint {
font-size: 0.8rem;
opacity: 0.7;
}
.modal__panel--tile {
width: 90vw;
max-width: 1200px;
height: 85vh;
max-height: 800px;
}

302
test2.p8 Normal file
View File

@@ -0,0 +1,302 @@
pico-8 cartridge // http://www.pico-8.com
version 8
__lua__
print("0.1.10c")
__gfx__
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00700700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00077000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00077000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00700700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
__gff__
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
__map__
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
__sfx__
000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
__music__
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344
00 41424344

24
test_cart.p8 Normal file
View File

@@ -0,0 +1,24 @@
pico-8 cartridge // http://www.pico-8.com
version 41
__lua__
function _init()
x=64
y=64
end
function _update()
end
function _draw()
cls()
print("picograph ok",2,60,7)
end
__gfx__
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000

8
test_unix.p8 Normal file
View File

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