diff --git a/CHANGELOG.md b/CHANGELOG.md index fbcc1982..ae953a5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,68 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v1.2.0] - 2026-05-19 - The one with the shader and particles playground + +### Added + +- **Shader Graph** — node-based visual GLSL editor for authoring Love2D pixel and vertex shaders without writing code. + - 40+ built-in nodes across 9 categories: Input, Math, Vector, Color, UV, Noise, Effect, Vertex, and Output. + - Input nodes: Texture Color, Texture Coords, Screen Coords, Vertex Color, Time, Resolution, Float, Vec2, Vec3, Vec4 constants. + - Math nodes: Add, Subtract, Multiply, Divide, Power, Sqrt, Abs, Clamp, Lerp, Step, Smoothstep, Fract, Floor, Ceil, Mod, Min, Max, Sign, Sin, Cos, Atan2. + - Vector nodes: Combine2, Combine3, SplitVec2, SplitVec3, Dot, Cross, Normalize, Length, MatVecMul, TransformMatrix. + - Color nodes: Desaturate, OneMinus, HueShift, InvertColor, Contrast, PosterizeColor, MultiplyColor. + - UV nodes: TilingOffset, RotateUV, TwirlUV, PolarCoordinates. + - Noise nodes: SimpleNoise, Ripple, VoronoiCells, Checkerboard. + - Vertex nodes: VertexPosition, VertexWave2D with a dedicated VertexOutput node for vertex shader authoring. + - Type-safe port connections — incompatible types are rejected; handle colors reflect the GLSL type. + - GLSL code generation with pixel and vertex shader output, extern declarations, and shared noise helpers. + - Live GLSL preview panel with syntax highlighting, copy to clipboard, line numbers, and light/dark theme support. + - Validate against the running game via the `shader-graph` Lua plugin (`compile-shader` action). + - Apply generated shader directly to a Particle System Playground composite target. + - Undo/redo history (up to 100 snapshots), Delete/Backspace to remove selected nodes or edges. + - Import/export graph as `.feathershgh` JSON files. + - Bundled preset graphs (chromatic aberration, pixelation, grayscale, vignette, wave distortion, and more). + - Enable/disable toggle integrated with the plugin control system; empty state when the plugin is not active. + - New `shader-graph` Lua plugin for in-game GLSL compilation and validation. + +- **Particle System Playground** — live editor for Love2D composite particle systems with real-time in-game preview. + - Side-by-side emitter list and full properties panel covering all 30+ Love2D `ParticleSystem` parameters. + - Visual property editors: color gradient with alpha curve lane, size curve with draggable Catmull-Rom spline, direction/spread gizmo, rotation/spin gizmo, circular force gizmo, linear acceleration plane, damping range editor, and texture offset gizmo. + - Range pair fields for all min/max properties (speed, spin, rotation, acceleration, damping) with a connecting bar visual. + - 30 built-in motion presets: Explosion, Whirlpool, Tornado, Wind Drift, Smoke Rise, Gravity Fall, Orbit, Shockwave, Fountain, Fire Sparks, Ember Float, Rain, Snow Drift, Magic Swirl, Portal Inhale, Portal Burst, Spiral Up, Spiral Down, Jet Thruster, Muzzle Flash, Blood Spray, Debris Arc, Dust Puff, Steam Vent, Bubbles, Water Splash, Leaf Gust, Sparks Shower, Energy Beam, Implosion — each with an intensify variant. + - Texture importer with base64 transport and live preview inside the editor. + - Shader editor panel with preset shader library and live apply to game. + - Composite and system selector for multi-emitter setups. + - Export panel for saving the full composite configuration. + - New `particle-system-playground` Lua plugin with `draw` and `filesystem` capabilities. + +- **Session page** — dedicated `/session` route showing an overview of the active game session. + - Session card: name, device ID, session ID, insecure connection warning. + - Environment card: OS, architecture, CPU cores, LÖVE version (when reported by the runtime), Feather runtime version, and API version. + - Plugins section: all plugins reported by the session with name, version, and enabled/disabled/incompatible badge. + - Packages section: reads `feather.lock.json` from the project root via Tauri file API; shows package name, version, and trust level badge (`verified` / `known` / `experimental`). Gracefully unavailable for file sessions, web mode, and remote machines. + +- **`feather init` capability inference** — when installing plugins in `auto` or `manual` mode, the generated `feather.config.lua` automatically includes the `capabilities` required by the selected plugins (e.g. installing `console` adds `filesystem`). + - New `--allow-insecure-connection` flag for non-interactive `--yes` flows: `__DANGEROUS_INSECURE_CONNECTION__` is no longer written by default; users must opt in explicitly. + +- **Lua config parser** — `feather run` now correctly strips Lua comments before parsing `feather.config.lua`, so commented-out example options no longer interfere with loaded values. + +- **`feather run` shim** — CLI-only projects now get an auto-shim that drives `DEBUGGER:update(dt)` without requiring changes to `main.lua`. + +### Changed + +- `feather doctor` downgrades missing `Desktop App ID` from `fail` to `warn` when `__DANGEROUS_INSECURE_CONNECTION__` is present, reflecting that it is an intentional development override rather than a misconfiguration. + +### Fixed + +- `feather run` no longer errors on CLI-only projects that have no embedded Feather runtime directory. + +### Tests + +- `init`: added tests for `--yes` without `--allow-insecure-connection` (config stays clean, doctor fails on appId), `--yes --allow-insecure-connection` (flag written, doctor downgrades to warn), plugin capability propagation into generated config, and interactive mode capability inference. +- `doctor`: added test verifying the missing appId warning when the insecure dev override is enabled. +- `run`: added tests for the CLI-only shim update hook and comment-stripping in the Lua config parser. + ## [v1.1.1] - 2026-05-17 - The one with better defaults ### Changes @@ -430,6 +492,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - LuaRocks package. - GitHub Actions CI. +[v1.2.0]: https://github.com/Kyonru/feather/compare/v1.1.1...v1.2.0 +[v1.1.1]: https://github.com/Kyonru/feather/compare/v1.1.0...v1.1.1 [v1.1.0]: https://github.com/Kyonru/feather/compare/v1.0.1...v1.1.0 [v1.0.1]: https://github.com/Kyonru/feather/compare/v1.0.0...v1.0.1 [v1.0.0]: https://github.com/Kyonru/feather/compare/v0.10.0...v1.0.0 diff --git a/cli/package.json b/cli/package.json index 0d48415e..49b0a51a 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@kyonru/feather", - "version": "1.1.1", + "version": "1.2.0", "description": "CLI for Feather — run and debug Love2D games without touching your game code", "license": "EPL-2.0", "keywords": [ diff --git a/cli/src/commands/doctor/index.ts b/cli/src/commands/doctor/index.ts index 4066145a..85c084fd 100644 --- a/cli/src/commands/doctor/index.ts +++ b/cli/src/commands/doctor/index.ts @@ -461,12 +461,13 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) const appId = typeof config?.appId === 'string' ? config.appId.trim() : ''; appIdMissing = mode !== 'disk' && !appId; + const appIdSeverity = appIdMissing ? (insecureConnection ? productionSeverity(true) : 'fail') : 'pass'; add( checks, 'Safety', 'Desktop App ID', - appIdMissing ? 'fail' : 'pass', - appIdMissing ? 'missing' : mode === 'disk' ? 'not needed for disk mode' : 'configured', + appIdSeverity, + appIdMissing && insecureConnection ? 'missing; insecure development override enabled' : appIdMissing ? 'missing' : mode === 'disk' ? 'not needed for disk mode' : 'configured', appIdMissing ? 'Set appId in feather.config.lua before shipping socket/network builds.' : undefined, ); diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index 0332813b..1a694a5e 100644 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -12,6 +12,7 @@ import { } from '../lib/install.js'; import { configTemplate, luaKey, luaValue } from '../lib/config.js'; import { chooseInitMode, type InitMode, type InitSetup } from '../ui/init/index.js'; +import { mergeCapabilities } from '../ui/init/config.js'; import { pluginCatalog } from '../generated/plugin-catalog.js'; import { resolveLocalLuaRoot } from '../lib/paths.js'; import { fail } from '../lib/command.js'; @@ -27,10 +28,19 @@ export interface InitOptions { installDir?: string; yes?: boolean; mode?: InitMode; + allowInsecureConnection?: boolean; } const knownPlugins = pluginCatalog.map((plugin) => plugin.id); +function addPluginCapabilities(config: Record, pluginIds: Iterable): void { + const current = config.capabilities as string[] | 'all' | undefined; + const merged = mergeCapabilities(current, pluginIds); + if (merged && merged !== 'all') { + config.capabilities = merged; + } +} + const toLocalName = (id: string) => id .split(/[-.]/) @@ -98,7 +108,7 @@ export async function initCommand(dir: string, opts: InitOptions): Promise branch: opts.branch ?? 'main', installDir: opts.installDir ?? 'feather', installPlugins: opts.noPlugins ? false : true, - config: {}, + config: opts.allowInsecureConnection ? { __DANGEROUS_INSECURE_CONNECTION__: true } : {}, exclude: [], }; const mode = setup.mode; @@ -206,6 +216,7 @@ export async function initCommand(dir: string, opts: InitOptions): Promise } if (mode === 'auto') { + addPluginCapabilities(setup.config, installedPluginIds); const patched = patchMainLua(mainPath, installDir); if (patched) { printLine(`${icon.success} Patched main.lua with feather.auto require`); @@ -219,6 +230,7 @@ export async function initCommand(dir: string, opts: InitOptions): Promise : pluginsDisabled ? [] : (opts.plugins ?? knownPlugins).filter((id) => !setup.exclude.includes(id)); + addPluginCapabilities(setup.config, pluginIds); const created = writeManualDebugger(target, setup.config, pluginIds, installDir); const patched = patchMainLuaForManual(mainPath); diff --git a/cli/src/generated/plugin-catalog.ts b/cli/src/generated/plugin-catalog.ts index c5008a7f..7535e3cb 100644 --- a/cli/src/generated/plugin-catalog.ts +++ b/cli/src/generated/plugin-catalog.ts @@ -203,6 +203,19 @@ export const pluginCatalog = [ "optIn": false, "disabled": true }, + { + "id": "particle-system-playground", + "sourceDir": "particle-system-playground", + "name": "Particles Playground", + "description": "Author composite particle effects with live in-game preview", + "version": "1.0.0", + "capabilities": [ + "draw", + "filesystem" + ], + "optIn": false, + "disabled": false + }, { "id": "physics-debug", "sourceDir": "physics-debug", @@ -248,6 +261,16 @@ export const pluginCatalog = [ "optIn": false, "disabled": true }, + { + "id": "shader-graph", + "sourceDir": "shader-graph", + "name": "Shader Graph", + "description": "Validate GLSL shaders authored in the Feather shader graph editor", + "version": "1.0.0", + "capabilities": [], + "optIn": false, + "disabled": false + }, { "id": "time-travel", "sourceDir": "time-travel", diff --git a/cli/src/index.ts b/cli/src/index.ts index c3af89a4..05b11d48 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -104,6 +104,7 @@ program .option('--plugins ', 'Comma-separated list of plugins to install') .option('--mode ', 'Setup mode: cli, auto, or manual', parseInitMode) .option('-y, --yes', 'Skip confirmation prompts') + .option('--allow-insecure-connection', 'Set __DANGEROUS_INSECURE_CONNECTION__ in feather.config.lua (required with --yes if appId is not configured)') .action((dir: string | undefined, opts) => runCliAction(() => initCommand(dir ?? '.', { branch: opts.branch as string, remote: opts.remote as boolean | undefined, @@ -116,6 +117,7 @@ program : undefined, mode: opts.mode as InitMode | undefined, yes: opts.yes as boolean, + allowInsecureConnection: opts.allowInsecureConnection as boolean | undefined, }))); program diff --git a/cli/src/lib/build/config.ts b/cli/src/lib/build/config.ts index c3a0a4ee..1677d31b 100644 --- a/cli/src/lib/build/config.ts +++ b/cli/src/lib/build/config.ts @@ -163,7 +163,7 @@ export function readBuildConfig(projectDir: string, configPath?: string): Feathe } return parsed as FeatherBuildConfig; } catch (err) { - throw new Error(`Invalid feather.build.json: ${(err as Error).message}`); + throw new Error(`Invalid feather.build.json: ${(err as Error).message}`, { cause: err }); } } diff --git a/cli/src/lib/config.ts b/cli/src/lib/config.ts index ca8e8a18..2c36af5f 100644 --- a/cli/src/lib/config.ts +++ b/cli/src/lib/config.ts @@ -11,9 +11,58 @@ export interface FeatherConfig { [key: string]: unknown; } +function stripLuaComments(src: string): string { + let out = ""; + let i = 0; + let quote: '"' | "'" | null = null; + + while (i < src.length) { + const ch = src[i]; + const next = src[i + 1]; + + if (quote) { + out += ch; + if (ch === "\\" && next) { + out += next; + i += 2; + continue; + } + if (ch === quote) quote = null; + i += 1; + continue; + } + + if (ch === '"' || ch === "'") { + quote = ch; + out += ch; + i += 1; + continue; + } + + if (ch === "-" && next === "-") { + if (src[i + 2] === "[" && src[i + 3] === "[") { + const end = src.indexOf("]]", i + 4); + if (end === -1) break; + i = end + 2; + continue; + } + + while (i < src.length && src[i] !== "\n") i += 1; + continue; + } + + out += ch; + i += 1; + } + + return out; +} + // Minimal Lua table parser for simple `return { key = value, ... }` configs. // Handles strings, numbers, booleans, and flat string arrays. function parseLuaTable(src: string): Record { + src = stripLuaComments(src); + // Strip the outer `return { ... }` wrapper const match = src.match(/return\s*\{([\s\S]*)\}/); if (!match) return {}; diff --git a/cli/src/lib/shim.ts b/cli/src/lib/shim.ts index ece98565..1e7dfd8a 100644 --- a/cli/src/lib/shim.ts +++ b/cli/src/lib/shim.ts @@ -108,6 +108,29 @@ if gamePath then end chunk() end + +-- CLI mode should not require game code to call DEBUGGER:update(dt). +-- Wrap after loading the game so we preserve the user's love.update callback. +-- If the game already calls DEBUGGER:update(dt), avoid a second update in the same frame. +if DEBUGGER and type(DEBUGGER.update) == "function" and not DEBUGGER.__cliAutoUpdateInstalled then + DEBUGGER.__cliAutoUpdateInstalled = true + local featherUpdate = DEBUGGER.update + DEBUGGER.update = function(self, dt) + self.__cliAutoUpdatedThisFrame = true + return featherUpdate(self, dt) + end + + local gameUpdate = love.update + love.update = function(dt) + DEBUGGER.__cliAutoUpdatedThisFrame = false + if gameUpdate then + gameUpdate(dt) + end + if not DEBUGGER.__cliAutoUpdatedThisFrame then + featherUpdate(DEBUGGER, dt) + end + end +end `; } diff --git a/cli/src/ui/init/config.ts b/cli/src/ui/init/config.ts index 6df1cadd..ba779b88 100644 --- a/cli/src/ui/init/config.ts +++ b/cli/src/ui/init/config.ts @@ -5,6 +5,7 @@ import { type InitMode, type InitSetup, } from "./model.js"; +import { pluginCatalog } from "../../generated/plugin-catalog.js"; export type InitSetupState = { mode: InitMode; @@ -37,6 +38,31 @@ export type InitSetupState = { appIdInput: string; }; +const pluginCapabilities = new Map(pluginCatalog.map((plugin) => [plugin.id, plugin.capabilities])); + +export function capabilitiesForPlugins(pluginIds: Iterable): string[] { + const capabilities = new Set(); + + for (const id of pluginIds) { + for (const capability of pluginCapabilities.get(id) ?? []) { + capabilities.add(capability); + } + } + + return [...capabilities].sort(); +} + +export function mergeCapabilities(base: string[] | "all" | undefined, pluginIds: Iterable): string[] | "all" | undefined { + const pluginCaps = capabilitiesForPlugins(pluginIds); + if (pluginCaps.length === 0) return base; + + if (!base || base === "all") { + return pluginCaps; + } + + return [...new Set([...base, ...pluginCaps])].sort(); +} + export function buildInitSetup(input: InitSetupState): InitSetup { const config: Record = {}; if (input.sessionName.trim()) config.sessionName = input.sessionName.trim(); @@ -68,6 +94,14 @@ export function buildInitSetup(input: InitSetupState): InitSetup { if (input.deviceId.trim()) config.deviceId = input.deviceId.trim(); } + const mergedCapabilities = mergeCapabilities( + config.capabilities as string[] | "all" | undefined, + input.pluginPromptsEnabled ? input.include : [], + ); + if (mergedCapabilities && mergedCapabilities !== "all") { + config.capabilities = mergedCapabilities; + } + if (input.needsApiKey) { config.apiKey = input.apiKey; config.pluginOptions = { diff --git a/cli/test/commands/doctor.test.mjs b/cli/test/commands/doctor.test.mjs index 2b1f75bc..a981b326 100644 --- a/cli/test/commands/doctor.test.mjs +++ b/cli/test/commands/doctor.test.mjs @@ -151,7 +151,7 @@ test('doctor --production fails unsafe remote-control and production settings', } }); -test('doctor --json makes missing Desktop App ID fail outside production', () => { +test('doctor --json warns for missing Desktop App ID when insecure dev override is enabled', () => { const dir = makeTmp(); writeGame(dir); writeFileSync( @@ -166,10 +166,11 @@ test('doctor --json makes missing Desktop App ID fail outside production', () => ); const { result, parsed } = parseDoctorJsonResult(dir); - assert.equal(result.exitCode, 1, outputOf(result)); + assert.equal(result.exitCode, 0, outputOf(result)); assert.equal(parsed.production, false); const labels = new Map(parsed.checks.map((check) => [check.label, check])); - assert.equal(labels.get('Desktop App ID')?.severity, 'fail'); + assert.equal(labels.get('Desktop App ID')?.severity, 'warn'); + assert.equal(labels.get('Desktop App ID')?.detail, 'missing; insecure development override enabled'); assert.equal(labels.get('__DANGEROUS_INSECURE_CONNECTION__')?.severity, 'warn'); assert.equal(labels.get('Console API key')?.severity, 'warn'); assert.equal(labels.get('Network host exposure')?.severity, 'warn'); diff --git a/cli/test/commands/init.test.mjs b/cli/test/commands/init.test.mjs index 10b57d53..ddb5d255 100644 --- a/cli/test/commands/init.test.mjs +++ b/cli/test/commands/init.test.mjs @@ -7,6 +7,7 @@ import { makeTmp, mkdirSync, outputOf, + parseDoctorJsonResult, readFileSync, rmSync, run, @@ -60,6 +61,7 @@ test('init/remove e2e: auto mode installs runtime, doctor passes, and remove cle 'feather', '--no-plugins', '--yes', + '--allow-insecure-connection', ]); assert.equal(existsSync(join(project, 'feather', 'init.lua')), true); @@ -99,6 +101,7 @@ test('init e2e: defaults to cli mode and creates config without embedding runtim 'feather', '--no-plugins', '--yes', + '--allow-insecure-connection', ]); assert.equal(existsSync(join(project, 'feather.config.lua')), true); @@ -115,3 +118,103 @@ test('init e2e: defaults to cli mode and creates config without embedding runtim } } }); + +test('init --yes without --allow-insecure-connection: config omits __DANGEROUS_INSECURE_CONNECTION__ and doctor fails on missing appId', () => { + const workspace = makeTmp(); + const project = join(workspace, 'secure-game'); + + try { + writeE2eGame(project); + + runOk([ + 'init', + project, + '--local-src', + LOCAL_SRC, + '--install-dir', + 'feather', + '--no-plugins', + '--yes', + ]); + + const config = readFileSync(join(project, 'feather.config.lua'), 'utf8'); + const activeConfig = config.split('\n').filter((l) => !l.trimStart().startsWith('--')).join('\n'); + assert.ok(!/__DANGEROUS_INSECURE_CONNECTION__\s*=\s*true/.test(activeConfig), 'config must not enable __DANGEROUS_INSECURE_CONNECTION__'); + + const { parsed: report } = parseDoctorJsonResult(project); + const insecureCheck = report.checks.find((c) => c.label === '__DANGEROUS_INSECURE_CONNECTION__'); + const appIdCheck = report.checks.find((c) => c.label === 'Desktop App ID'); + assert.equal(insecureCheck?.detail, 'disabled'); + assert.equal(appIdCheck?.severity, 'fail'); + assert.ok(report.failures >= 1); + } finally { + if (process.env.FEATHER_KEEP_E2E_TMP !== '1') { + rmSync(workspace, { recursive: true, force: true }); + } + } +}); + +test('init --yes --allow-insecure-connection: config sets __DANGEROUS_INSECURE_CONNECTION__ and doctor downgrades missing appId to warn', () => { + const workspace = makeTmp(); + const project = join(workspace, 'insecure-game'); + + try { + writeE2eGame(project); + + runOk([ + 'init', + project, + '--local-src', + LOCAL_SRC, + '--install-dir', + 'feather', + '--no-plugins', + '--yes', + '--allow-insecure-connection', + ]); + + const config = readFileSync(join(project, 'feather.config.lua'), 'utf8'); + assert.match(config, /__DANGEROUS_INSECURE_CONNECTION__\s*=\s*true/); + + const report = doctorJson(project); + const insecureCheck = report.checks.find((c) => c.label === '__DANGEROUS_INSECURE_CONNECTION__'); + const appIdCheck = report.checks.find((c) => c.label === 'Desktop App ID'); + assert.equal(insecureCheck?.detail, 'enabled'); + assert.equal(appIdCheck?.severity, 'warn'); + assert.equal(report.failures, 0); + } finally { + if (process.env.FEATHER_KEEP_E2E_TMP !== '1') { + rmSync(workspace, { recursive: true, force: true }); + } + } +}); + +test('init e2e: installed plugins add their capabilities to generated config', () => { + const workspace = makeTmp(); + const project = join(workspace, 'plugin-capability-game'); + + try { + writeE2eGame(project); + + runOk([ + 'init', + project, + '--mode', + 'auto', + '--local-src', + LOCAL_SRC, + '--install-dir', + 'feather', + '--plugins', + 'collision-debug,console,input-replay', + '--yes', + ]); + + const config = readFileSync(join(project, 'feather.config.lua'), 'utf8'); + assert.match(config, /capabilities\s*=\s*\{\s*"draw",\s*"filesystem",\s*"input"\s*\}/); + } finally { + if (process.env.FEATHER_KEEP_E2E_TMP !== '1') { + rmSync(workspace, { recursive: true, force: true }); + } + } +}); diff --git a/cli/test/commands/package.test.mjs b/cli/test/commands/package.test.mjs index 70e38cc1..ce77ff5b 100644 --- a/cli/test/commands/package.test.mjs +++ b/cli/test/commands/package.test.mjs @@ -1191,13 +1191,29 @@ test('init mode: config builder preserves cli and advanced setup values', async assert.deepEqual(setup.config.include, ['console']); assert.equal(setup.config.port, 5000); assert.equal(setup.config.mode, 'disk'); - assert.deepEqual(setup.config.capabilities, ['logs', 'assets']); + assert.deepEqual(setup.config.capabilities, ['assets', 'filesystem', 'logs']); assert.equal(setup.config.captureScreenshot, true); assert.equal(setup.config.apiKey, 'StrongSecret123!'); assert.deepEqual(setup.config.pluginOptions, { console: { evalEnabled: true } }); assert.equal(setup.config.__DANGEROUS_INSECURE_CONNECTION__, true); }); +test('init mode: selected plugins add required capabilities automatically', async () => { + const { buildInitSetup } = await import('../../dist/ui/init/config.js'); + const setup = buildInitSetup( + initSetupState({ + include: new Set(['collision-debug', 'console', 'input-replay']), + exclude: new Set(), + advanced: false, + needsApiKey: true, + apiKey: 'StrongSecret123!', + }), + ); + + assert.deepEqual(setup.config.include, ['collision-debug', 'console', 'input-replay']); + assert.deepEqual(setup.config.capabilities, ['draw', 'filesystem', 'input']); +}); + test('custom add: repo install writes selected files and lockfile metadata', async () => { const dir = makeTmp(); const { installCustomRepoPackage } = await import('../../dist/lib/package/custom-add.js'); diff --git a/cli/test/commands/run.test.mjs b/cli/test/commands/run.test.mjs index e42347a9..30e7e668 100644 --- a/cli/test/commands/run.test.mjs +++ b/cli/test/commands/run.test.mjs @@ -141,6 +141,57 @@ test('run: shim preloads config before requiring feather.auto', () => { assert.ok(record.shimMain.includes('require("feather.auto")')); }); +test('run: shim auto-drives DEBUGGER update after loading the game', () => { + const dir = makeTmp(); + const gameDir = join(dir, 'game'); + writeGame(gameDir); + const { fakePath, recordPath } = writeFakeLove(dir); + + const result = run(['run', '--love', fakePath, gameDir]); + + assert.equal(result.exitCode, 0, outputOf(result)); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.ok(record.shimMain.includes('not DEBUGGER.__cliAutoUpdateInstalled')); + assert.ok(record.shimMain.includes('local gameUpdate = love.update')); + assert.ok(record.shimMain.includes('gameUpdate(dt)')); + assert.ok(record.shimMain.includes('featherUpdate(DEBUGGER, dt)')); +}); + +test('run: config parser ignores commented example options', () => { + const dir = makeTmp(); + const gameDir = join(dir, 'game'); + writeGame(gameDir); + const configPath = join(gameDir, 'feather.config.lua'); + writeFileSync(configPath, ` +return { + sessionName = "Real Config", + include = { "console" }, + __DANGEROUS_INSECURE_CONNECTION__ = true, + apiKey = "real-key", + + -- appId = "feather-app-commented-placeholder", + -- include = { "hot-reload" }, + -- exclude = { "hump.signal" }, + -- mode = "disk", + -- debugger = { + -- enabled = true, + -- }, +} +`); + const { fakePath, recordPath } = writeFakeLove(dir); + + const result = run(['run', '--love', fakePath, gameDir, '--config', configPath]); + + assert.equal(result.exitCode, 0, outputOf(result)); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + assert.equal(record.env.FEATHER_SESSION_NAME, 'Real Config'); + assert.ok(record.shimMain.includes('sessionName = "Real Config"')); + assert.ok(record.shimMain.includes('include = { "console" }')); + assert.equal(record.shimMain.includes('feather-app-commented-placeholder'), false); + assert.equal(record.shimMain.includes('include = { "hot-reload" }'), false); + assert.equal(record.shimMain.includes('mode = "disk"'), false); +}); + test('run --no-debugger: launches game directly without Feather shim', () => { const dir = makeTmp(); const gameDir = join(dir, 'game'); diff --git a/docs/particle-system-playground.md b/docs/particle-system-playground.md new file mode 120000 index 00000000..592b9438 --- /dev/null +++ b/docs/particle-system-playground.md @@ -0,0 +1 @@ +../src-lua/plugins/particle-system-playground/README.md \ No newline at end of file diff --git a/docs/plugins/ingame-overlay.md b/docs/plugins/ingame-overlay.md new file mode 120000 index 00000000..ff6ffa14 --- /dev/null +++ b/docs/plugins/ingame-overlay.md @@ -0,0 +1 @@ +../src-lua/plugins/ingame-overlay/README.md \ No newline at end of file diff --git a/docs/plugins/runtime-snapshot.md b/docs/plugins/runtime-snapshot.md new file mode 120000 index 00000000..dfe9cd83 --- /dev/null +++ b/docs/plugins/runtime-snapshot.md @@ -0,0 +1 @@ +../src-lua/plugins/runtime-snapshot/README.md \ No newline at end of file diff --git a/docs/shader-graph.md b/docs/shader-graph.md new file mode 120000 index 00000000..0947eb5e --- /dev/null +++ b/docs/shader-graph.md @@ -0,0 +1 @@ +../src-lua/plugins/shader-graph/README.md \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 9dd5cdb0..1ac52f5e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -29,16 +29,18 @@ theme: nav: - Home: index.md - Installation: installation.md - - Configuration: configuration.md + - CLI: + - Overview: cli.md + - Package Management: packages.md - Usage: usage.md + - Configuration: configuration.md - Debugger: debugger.md - Hot Reload: hot-reload.md + - Console: console.md - Assets: assets.md - - CLI: - - Overview: cli.md - - Package Management: packages.md + - Particle System Playground: particle-system-playground.md + - Shader Graph: shader-graph.md - Time Travel: time-travel.md - - Console: console.md - Recommendations: recommendations.md - Plugins: - Overview: plugins.md @@ -52,6 +54,7 @@ nav: - Entity Inspector: plugins/entity-inspector.md - Filesystem: plugins/filesystem.md - HUMP Signal: plugins/hump-signal.md + - In-game overlay: plugins/ingame-overlay.md - Input Replay: plugins/input-replay.md - Lua State Machine: plugins/lua-state-machine.md - Memory Snapshot: plugins/memory-snapshot.md @@ -59,6 +62,7 @@ nav: - Particle Editor: plugins/particle-editor.md - Physics Debug: plugins/physics-debug.md - Profiler: plugins/profiler.md + - Runtime Snapshot: plugins/runtime-snapshot.md - Screenshots: plugins/screenshots.md - Timer Inspector: plugins/timer-inspector.md diff --git a/package-lock.json b/package-lock.json index 3f34ede0..134770e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "feather", - "version": "1.1.1", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "feather", - "version": "1.1.1", + "version": "1.2.0", "workspaces": [ "cli" ], @@ -34,12 +34,14 @@ "@tanstack/react-query": "5.100.10", "@tanstack/react-query-persist-client": "5.100.10", "@tanstack/react-table": "8.21.3", - "@tauri-apps/api": "^2.11.0", - "@tauri-apps/plugin-dialog": "^2.7.1", - "@tauri-apps/plugin-fs": "^2.5.1", - "@tauri-apps/plugin-opener": "^2.5.4", + "@tauri-apps/api": "2.11.0", + "@tauri-apps/plugin-dialog": "2.7.1", + "@tauri-apps/plugin-fs": "2.5.1", + "@tauri-apps/plugin-opener": "2.5.4", + "@xyflow/react": "12.10.2", "class-variance-authority": "0.7.1", "clsx": "2.1.1", + "fflate": "0.8.3", "gif.js": "0.2.0", "gif.js.optimized": "1.0.1", "lucide-react": "1.16.0", @@ -64,7 +66,7 @@ "@eslint/js": "10.0.1", "@playwright/test": "1.60.0", "@tanstack/eslint-plugin-query": "5.100.10", - "@tauri-apps/cli": "^2.11.2", + "@tauri-apps/cli": "2.11.2", "@types/node": "25.8.0", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", @@ -81,7 +83,7 @@ }, "cli": { "name": "@kyonru/feather", - "version": "1.1.0", + "version": "1.2.0", "license": "EPL-2.0", "dependencies": { "chalk": "5.3.0", @@ -5269,6 +5271,15 @@ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "license": "MIT" }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, "node_modules/@types/d3-ease": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", @@ -5299,6 +5310,12 @@ "@types/d3-time": "*" } }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, "node_modules/@types/d3-shape": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", @@ -5320,6 +5337,25 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -5681,6 +5717,66 @@ } } }, + "node_modules/@xyflow/react": { + "version": "12.10.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz", + "integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.76", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/react/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.76", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.76.tgz", + "integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -5875,6 +5971,12 @@ "url": "https://polar.sh/cva" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/cli-boxes": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-4.0.1.tgz", @@ -6113,6 +6215,28 @@ "node": ">=12" } }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -6168,6 +6292,15 @@ "node": ">=12" } }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -6213,6 +6346,41 @@ "node": ">=12" } }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -6565,6 +6733,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -7835,9 +8009,10 @@ } }, "node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", + "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", + "license": "MIT", "peer": true }, "node_modules/react-redux": { diff --git a/package.json b/package.json index 67d94696..bb5e0258 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "feather", "description": "Feather is a LÖVE game development toolkit with a desktop inspector, zero-config CLI, plugin system, build/upload helpers, and package management.", "private": true, - "version": "1.1.1", + "version": "1.2.0", "type": "module", "workspaces": [ "cli" @@ -63,12 +63,14 @@ "@tanstack/react-query": "5.100.10", "@tanstack/react-query-persist-client": "5.100.10", "@tanstack/react-table": "8.21.3", - "@tauri-apps/api": "^2.11.0", - "@tauri-apps/plugin-dialog": "^2.7.1", - "@tauri-apps/plugin-fs": "^2.5.1", - "@tauri-apps/plugin-opener": "^2.5.4", + "@tauri-apps/api": "2.11.0", + "@tauri-apps/plugin-dialog": "2.7.1", + "@tauri-apps/plugin-fs": "2.5.1", + "@tauri-apps/plugin-opener": "2.5.4", + "@xyflow/react": "12.10.2", "class-variance-authority": "0.7.1", "clsx": "2.1.1", + "fflate": "0.8.3", "gif.js": "0.2.0", "gif.js.optimized": "1.0.1", "lucide-react": "1.16.0", @@ -93,7 +95,7 @@ "@eslint/js": "10.0.1", "@playwright/test": "1.60.0", "@tanstack/eslint-plugin-query": "5.100.10", - "@tauri-apps/cli": "^2.11.2", + "@tauri-apps/cli": "2.11.2", "@types/node": "25.8.0", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", diff --git a/src-lua/example/test_auto/main.lua b/src-lua/example/test_auto/main.lua index f31a0c91..eca1572b 100644 --- a/src-lua/example/test_auto/main.lua +++ b/src-lua/example/test_auto/main.lua @@ -34,6 +34,7 @@ function love.load() "audio-debug", "coroutine-monitor", "filesystem", + "particle-system-playground", "runtime-snapshot", } local missing = {} diff --git a/src-lua/example/test_cli/feather.config.lua b/src-lua/example/test_cli/feather.config.lua index 87995c82..a4c59504 100644 --- a/src-lua/example/test_cli/feather.config.lua +++ b/src-lua/example/test_cli/feather.config.lua @@ -3,7 +3,8 @@ return { sessionName = "CLI Example", - include = { "screenshots", "profiler", "console" }, + include = { "screenshots", "profiler", "console", "particle-system-playground" }, + capabilities = { "draw", "filesystem", "particle-system-playground" }, exclude = { "hump.signal", "lua-state-machine", "animation-inspector" }, sampleRate = 1, captureScreenshot = false, diff --git a/src-lua/feather/init.lua b/src-lua/feather/init.lua index ba1f0006..d057e26d 100644 --- a/src-lua/feather/init.lua +++ b/src-lua/feather/init.lua @@ -20,7 +20,7 @@ local FeatherUI = require(FEATHER_PATH .. ".ui") local get_current_dir = require(FEATHER_PATH .. ".utils").get_current_dir local format = require(FEATHER_PATH .. ".utils").format -local FEATHER_VERSION_NAME = "1.1.1" +local FEATHER_VERSION_NAME = "1.2.0" local FEATHER_API = 5 local FEATHER_VERSION = { @@ -471,6 +471,12 @@ function Feather:__handleCommand(msg) if type(result) == "table" and result.clipboard then response.clipboard = result.clipboard end + if type(result) == "table" and result.zipAssets then + response.zipAssets = result.zipAssets + end + if type(result) == "table" then + response.data = result + end self:__sendWs(json.encode(response)) end -- Re-send config so desktop picks up dynamic label changes diff --git a/src-lua/manifest.txt b/src-lua/manifest.txt index d4e9d417..771c303f 100644 --- a/src-lua/manifest.txt +++ b/src-lua/manifest.txt @@ -54,6 +54,8 @@ plugin:network-inspector:init.lua plugin:network-inspector:manifest.lua plugin:particle-editor:init.lua plugin:particle-editor:manifest.lua +plugin:particle-system-playground:init.lua +plugin:particle-system-playground:manifest.lua plugin:physics-debug:init.lua plugin:physics-debug:manifest.lua plugin:profiler:init.lua @@ -62,6 +64,8 @@ plugin:runtime-snapshot:init.lua plugin:runtime-snapshot:manifest.lua plugin:screenshots:init.lua plugin:screenshots:manifest.lua +plugin:shader-graph:init.lua +plugin:shader-graph:manifest.lua plugin:time-travel:init.lua plugin:time-travel:manifest.lua plugin:timer-inspector:init.lua diff --git a/src-lua/plugins/particle-system-playground/README.md b/src-lua/plugins/particle-system-playground/README.md new file mode 100644 index 00000000..de420ac3 --- /dev/null +++ b/src-lua/plugins/particle-system-playground/README.md @@ -0,0 +1,139 @@ +# Particle System Playground Runtime Plugin + +This built-in plugin backs Feather's Particle System Playground page. It lets the desktop create scratch particle composites, preview them in the running game, edit registered game-owned composites, and export a Lua module/ZIP. + +The feature is inspired by [ReFreezed/HotParticles](https://github.com/ReFreezed/HotParticles), a particle effect editor/exporter for LÖVE. Feather's implementation is clean-room and does not vendor upstream code or assets. + +The plugin id is: + +```lua +particle-system-playground +``` + +## Game API + +Game code can register an existing composite for inspection and live editing: + +```lua +local playground = DEBUGGER.pluginManager:getPlugin("particle-system-playground").instance + +playground:addComposite("Fire", function() + return fireParticles +end) + +playground:removeComposite("Fire") +``` + +`addComposite(name, getter)` expects `getter()` to return a HotParticles-style table: + +```lua +{ + x = 400, + y = 300, + [1] = { + system = love.graphics.newParticleSystem(image, 1000), + blendMode = "alpha", + shader = nil, + texturePath = "gfx/fire.png", + texturePreset = "", + shaderPath = "", + shaderFilename = "", + x = 0, + y = 0, + kickStartSteps = 0, + kickStartDt = 1 / 60, + emitAtStart = 0, + }, +} +``` + +Game composites are not drawn by this plugin. The game remains responsible for drawing them. Feather edits their `ParticleSystem` values and metadata best-effort in place. + +## Scratch Composites + +Scratch composites are created from the desktop playground. The plugin owns their `love.ParticleSystem` instances, updates them, and draws them in the running game. + +Composite preview `x`/`y` and preview movement patterns change emitter positions via `ParticleSystem:setPosition(...)`. They do not translate the whole particle cloud during draw. Already-emitted particles keep moving naturally. + +Supported preview movement patterns: + +- `none` +- `circle` +- `figure-eight` +- `irregular` + +Movement is preview-only behavior and is not exported as runtime logic. + +## Desktop Protocol + +The desktop uses Feather's existing plugin protocol: + +- `handleRequest()` returns the current composite list, active composite, active emitter, metadata, and particle properties. +- `handleParamsUpdate()` receives debounced property updates from the UI. +- `handleActionRequest()` handles selection, emitter/composite mutations, asset changes, and exports. + +Important actions: + +| Action | Effect | +| --- | --- | +| `new-composite` | Create a scratch composite | +| `delete-composite` | Delete the active scratch composite | +| `add-system` | Add an emitter to a scratch composite | +| `remove-system` | Remove an emitter from a scratch composite | +| `select-composite` | Select the active composite | +| `select-system` | Select the active emitter | +| `set-texture` | Apply a texture preset, game path, or uploaded base64 texture | +| `set-shader` | Apply inline shader source or a shader path | +| `emit` / `emit-all` | Burst particles | +| `reset` / `reset-all` | Reset particle systems | +| `kick-start` | Advance the active emitter by configured kick-start steps | +| `export-code` | Return/copy generated Lua module code | +| `export-zip` | Return generated `init.lua` plus referenced assets | + +## Export + +Exports produce a Lua module shaped like: + +```lua +local particles = { + x = 400, + y = 300, + [1] = { + system = ps1, + blendMode = "alpha", + shader = shader1, + texturePath = "gfx/fire.png", + texturePreset = "", + shaderPath = "", + shaderFilename = "shader.glsl", + x = 0, + y = 0, + kickStartSteps = 0, + kickStartDt = 1 / 60, + emitAtStart = 0, + }, +} + +return particles +``` + +ZIP export returns `init.lua` and any available texture/shader assets. Imported texture bytes are preserved for ZIP output. Game-path assets are read through `love.filesystem.read` when possible. + +## Shaders + +The desktop editor includes built-in particle shader presets such as glow, smoke, sparkle, dissolve, ring, and energy effects. Presets are plain LÖVE pixel shader source and can be edited before applying. + +Animated presets may declare: + +```glsl +extern number u_time; +``` + +When a scratch composite is drawn, the plugin sends `u_time` with `love.timer.getTime()` if the active shader accepts it. Shaders that do not declare `u_time` are unaffected. + +## Notes + +- This is a Feather-owned clean-room runtime/plugin implementation. +- It does not depend on upstream HotParticles code or assets. +- It does not import `.hotparticles` project files. +- Registered game composites can be edited, but exports are only complete when the plugin has enough texture/shader metadata or uploaded asset bytes. diff --git a/src-lua/plugins/particle-system-playground/init.lua b/src-lua/plugins/particle-system-playground/init.lua new file mode 100644 index 00000000..d5d9852a --- /dev/null +++ b/src-lua/plugins/particle-system-playground/init.lua @@ -0,0 +1,1744 @@ +local Class = require(FEATHER_PATH .. ".lib.class") +local Base = require(FEATHER_PATH .. ".core.base") +local base64 = require(FEATHER_PATH .. ".lib.base64") + +local ParticleSystemPlaygroundPlugin = Class({ __includes = Base }) + +local DEFAULT_BUFFER_SIZE = 1000 +local DEFAULT_X = 400 +local DEFAULT_Y = 300 +local TWO_PI = math.pi * 2 + +local B64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" +local B64_LOOKUP = {} +for i = 1, #B64_CHARS do + B64_LOOKUP[B64_CHARS:sub(i, i)] = i - 1 +end + +local function decodeBase64(data) + data = tostring(data or ""):gsub("%s+", "") + local out = {} + local index = 1 + + for i = 1, #data, 4 do + local c1 = data:sub(i, i) + local c2 = data:sub(i + 1, i + 1) + local c3 = data:sub(i + 2, i + 2) + local c4 = data:sub(i + 3, i + 3) + local n1 = B64_LOOKUP[c1] + local n2 = B64_LOOKUP[c2] + local n3 = c3 ~= "=" and B64_LOOKUP[c3] or nil + local n4 = c4 ~= "=" and B64_LOOKUP[c4] or nil + + if not n1 or not n2 then + return nil + end + + out[index] = string.char(n1 * 4 + math.floor(n2 / 16)) + index = index + 1 + if n3 then + out[index] = string.char((n2 % 16) * 16 + math.floor(n3 / 4)) + index = index + 1 + end + if n3 and n4 then + out[index] = string.char((n3 % 4) * 64 + n4) + index = index + 1 + end + end + + return table.concat(out) +end + +local function safeNumber(value, fallback) + local n = tonumber(value) + if n == nil then + return fallback + end + return n +end + +local function safeString(value, fallback) + if value == nil then + return fallback or "" + end + return tostring(value) +end + +local function sanitizeFilename(name, fallback) + name = tostring(name or fallback or "asset"):gsub("\\", "/"):match("([^/]+)$") or fallback or "asset" + name = name:gsub("[^%w%._%-]", "_") + if name == "" then + name = fallback or "asset" + end + return name +end + +local function archivePath(path, fallback) + path = tostring(path or ""):gsub("\\", "/") + path = path:gsub("^%a:/+", ""):gsub("^/+", "") + local parts = {} + for part in path:gmatch("[^/]+") do + if part ~= "." and part ~= ".." and part ~= "" then + parts[#parts + 1] = part:gsub("[^%w%._%-]", "_") + end + end + if #parts == 0 then + return sanitizeFilename(fallback, "asset") + end + return table.concat(parts, "/") +end + +local function quote(value) + return string.format("%q", tostring(value or "")) +end + +local function fmt(n) + n = tonumber(n) or 0 + if n == 0 then + return "0" + end + local s = string.format("%.6f", n) + s = s:gsub("(%..-)0+$", "%1"):gsub("%.$", "") + return s +end + +local function getImageDataPng(imageData) + if not imageData or not imageData.encode then + return nil + end + local ok, fileData = pcall(imageData.encode, imageData, "png") + if ok and fileData and fileData.getString then + return fileData:getString() + end + return nil +end + +local function generatePresetImage(name) + if not love or not love.image or not love.graphics then + return nil + end + + local size = 64 + local data = love.image.newImageData(size, size) + local cx = (size - 1) / 2 + local cy = (size - 1) / 2 + local radius = size / 2 - 1 + + local function setPixel(x, y, alpha) + if x >= 0 and x < size and y >= 0 and y < size then + data:setPixel(x, y, 1, 1, 1, math.max(0, math.min(1, alpha))) + end + end + + data:mapPixel(function(x, y) + local dx = x - cx + local dy = y - cy + local dist = math.sqrt(dx * dx + dy * dy) + local angle = math.atan2 and math.atan2(dy, dx) or math.atan(dy, dx) + local alpha + + if name == "ring" then + alpha = dist > radius * 0.62 and dist < radius * 0.9 and 1 or 0 + elseif name == "light" then + local t = math.min(1, dist / radius) + alpha = (1 - t) * (1 - t) + elseif name == "star" then + local points = 5 + local wave = (math.cos(angle * points) + 1) / 2 + local edge = radius * (0.48 + wave * 0.42) + alpha = dist <= edge and 1 or 0 + elseif name == "spiral" then + local t = dist / radius + local target = t * TWO_PI * 2.6 + local delta = math.abs(((angle - target + math.pi) % TWO_PI) - math.pi) + alpha = delta < 0.2 and t < 0.96 and 1 or 0 + else + local t = dist / radius + alpha = t <= 1 and 1 or 0 + end + + return 1, 1, 1, alpha + end) + + if name == "spiral" then + for i = 0, 80 do + local t = i / 80 + local angle = t * TWO_PI * 2.6 + local r = t * radius + setPixel(math.floor(cx + math.cos(angle) * r), math.floor(cy + math.sin(angle) * r), 1) + end + end + + return love.graphics.newImage(data), getImageDataPng(data) +end + +local function readFileBase64(path) + if not path or path == "" or not love or not love.filesystem then + return nil + end + local ok, data = pcall(love.filesystem.read, path) + if ok and data then + return base64.encode(data) + end + return nil +end + +local function copyParticleProperties(from, to) + if not from or not to then + return + end + + local function copy(methodGet, methodSet) + local ok, values = pcall(function() + return { from[methodGet](from) } + end) + if ok then + pcall(to[methodSet], to, unpack(values)) + end + end + + copy("getColors", "setColors") + copy("getDirection", "setDirection") + copy("getEmissionRate", "setEmissionRate") + copy("getEmitterLifetime", "setEmitterLifetime") + copy("getInsertMode", "setInsertMode") + copy("getLinearAcceleration", "setLinearAcceleration") + copy("getLinearDamping", "setLinearDamping") + copy("getOffset", "setOffset") + copy("getParticleLifetime", "setParticleLifetime") + copy("getRadialAcceleration", "setRadialAcceleration") + copy("getRotation", "setRotation") + copy("getSizes", "setSizes") + copy("getSizeVariation", "setSizeVariation") + copy("getSpeed", "setSpeed") + copy("getSpin", "setSpin") + copy("getSpinVariation", "setSpinVariation") + copy("getSpread", "setSpread") + copy("getTangentialAcceleration", "setTangentialAcceleration") + + local okRelative, relative = pcall(from.hasRelativeRotation, from) + if okRelative then + pcall(to.setRelativeRotation, to, relative) + end + + local okArea, dist, dx, dy, angle, relativeArea = pcall(from.getEmissionArea, from) + if okArea then + pcall(to.setEmissionArea, to, dist, dx, dy, angle, relativeArea) + end + + if from.getQuads and to.setQuads then + local okQuads, quads = pcall(function() + return { from:getQuads() } + end) + if okQuads and #quads > 0 then + pcall(to.setQuads, to, unpack(quads)) + end + end +end + +local PS_PROPERTIES = { + { + key = "emissionRate", + get = function(ps) + return ps:getEmissionRate() + end, + set = function(ps, v) + ps:setEmissionRate(v) + end, + type = "number", + min = 0, + max = 10000, + }, + { + key = "emitterLifetime", + get = function(ps) + return ps:getEmitterLifetime() + end, + set = function(ps, v) + ps:setEmitterLifetime(v) + end, + type = "number", + min = -1, + max = 600, + }, + { + key = "particleLifetimeMin", + get = function(ps) + local a = ps:getParticleLifetime() + return a + end, + set = function(ps, v) + local _, b = ps:getParticleLifetime() + ps:setParticleLifetime(v, b) + end, + type = "number", + min = 0, + }, + { + key = "particleLifetimeMax", + get = function(ps) + local _, b = ps:getParticleLifetime() + return b + end, + set = function(ps, v) + local a = ps:getParticleLifetime() + ps:setParticleLifetime(a, v) + end, + type = "number", + min = 0, + }, + { + key = "direction", + get = function(ps) + return ps:getDirection() + end, + set = function(ps, v) + ps:setDirection(v) + end, + type = "number", + }, + { + key = "spread", + get = function(ps) + return ps:getSpread() + end, + set = function(ps, v) + ps:setSpread(v) + end, + type = "number", + min = 0, + }, + { + key = "speedMin", + get = function(ps) + local a = ps:getSpeed() + return a + end, + set = function(ps, v) + local _, b = ps:getSpeed() + ps:setSpeed(v, b) + end, + type = "number", + }, + { + key = "speedMax", + get = function(ps) + local _, b = ps:getSpeed() + return b + end, + set = function(ps, v) + local a = ps:getSpeed() + ps:setSpeed(a, v) + end, + type = "number", + }, + { + key = "linearAccelXMin", + get = function(ps) + local a = ps:getLinearAcceleration() + return a + end, + set = function(ps, v) + local _, b, c, d = ps:getLinearAcceleration() + ps:setLinearAcceleration(v, b, c, d) + end, + type = "number", + }, + { + key = "linearAccelYMin", + get = function(ps) + local _, b = ps:getLinearAcceleration() + return b + end, + set = function(ps, v) + local a, _, c, d = ps:getLinearAcceleration() + ps:setLinearAcceleration(a, v, c, d) + end, + type = "number", + }, + { + key = "linearAccelXMax", + get = function(ps) + local _, _, c = ps:getLinearAcceleration() + return c + end, + set = function(ps, v) + local a, b, _, d = ps:getLinearAcceleration() + ps:setLinearAcceleration(a, b, v, d) + end, + type = "number", + }, + { + key = "linearAccelYMax", + get = function(ps) + local _, _, _, d = ps:getLinearAcceleration() + return d + end, + set = function(ps, v) + local a, b, c = ps:getLinearAcceleration() + ps:setLinearAcceleration(a, b, c, v) + end, + type = "number", + }, + { + key = "radialAccelMin", + get = function(ps) + local a = ps:getRadialAcceleration() + return a + end, + set = function(ps, v) + local _, b = ps:getRadialAcceleration() + ps:setRadialAcceleration(v, b) + end, + type = "number", + }, + { + key = "radialAccelMax", + get = function(ps) + local _, b = ps:getRadialAcceleration() + return b + end, + set = function(ps, v) + local a = ps:getRadialAcceleration() + ps:setRadialAcceleration(a, v) + end, + type = "number", + }, + { + key = "tangentialAccelMin", + get = function(ps) + local a = ps:getTangentialAcceleration() + return a + end, + set = function(ps, v) + local _, b = ps:getTangentialAcceleration() + ps:setTangentialAcceleration(v, b) + end, + type = "number", + }, + { + key = "tangentialAccelMax", + get = function(ps) + local _, b = ps:getTangentialAcceleration() + return b + end, + set = function(ps, v) + local a = ps:getTangentialAcceleration() + ps:setTangentialAcceleration(a, v) + end, + type = "number", + }, + { + key = "linearDampingMin", + get = function(ps) + local a = ps:getLinearDamping() + return a + end, + set = function(ps, v) + local _, b = ps:getLinearDamping() + ps:setLinearDamping(v, b) + end, + type = "number", + min = 0, + }, + { + key = "linearDampingMax", + get = function(ps) + local _, b = ps:getLinearDamping() + return b + end, + set = function(ps, v) + local a = ps:getLinearDamping() + ps:setLinearDamping(a, v) + end, + type = "number", + min = 0, + }, + { + key = "sizes", + get = function(ps) + local parts = {} + for _, v in ipairs({ ps:getSizes() }) do + parts[#parts + 1] = fmt(v) + end + return table.concat(parts, ", ") + end, + set = function(ps, value) + local sizes = {} + for raw in tostring(value):gmatch("[^,]+") do + local n = tonumber(raw) + if n then + sizes[#sizes + 1] = n + end + end + if #sizes > 0 then + ps:setSizes(unpack(sizes)) + end + end, + type = "string", + }, + { + key = "sizeVariation", + get = function(ps) + return ps:getSizeVariation() + end, + set = function(ps, v) + ps:setSizeVariation(v) + end, + type = "number", + min = 0, + max = 1, + }, + { + key = "rotationMin", + get = function(ps) + local a = ps:getRotation() + return a + end, + set = function(ps, v) + local _, b = ps:getRotation() + ps:setRotation(v, b) + end, + type = "number", + }, + { + key = "rotationMax", + get = function(ps) + local _, b = ps:getRotation() + return b + end, + set = function(ps, v) + local a = ps:getRotation() + ps:setRotation(a, v) + end, + type = "number", + }, + { + key = "relativeRotation", + get = function(ps) + return ps:hasRelativeRotation() + end, + set = function(ps, v) + ps:setRelativeRotation(v) + end, + type = "boolean", + }, + { + key = "spinMin", + get = function(ps) + local a = ps:getSpin() + return a + end, + set = function(ps, v) + local _, b = ps:getSpin() + ps:setSpin(v, b) + end, + type = "number", + }, + { + key = "spinMax", + get = function(ps) + local _, b = ps:getSpin() + return b + end, + set = function(ps, v) + local a = ps:getSpin() + ps:setSpin(a, v) + end, + type = "number", + }, + { + key = "spinVariation", + get = function(ps) + return ps:getSpinVariation() + end, + set = function(ps, v) + ps:setSpinVariation(v) + end, + type = "number", + min = 0, + max = 1, + }, + { + key = "offsetX", + get = function(ps) + local a = ps:getOffset() + return a + end, + set = function(ps, v) + local _, b = ps:getOffset() + ps:setOffset(v, b) + end, + type = "number", + }, + { + key = "offsetY", + get = function(ps) + local _, b = ps:getOffset() + return b + end, + set = function(ps, v) + local a = ps:getOffset() + ps:setOffset(a, v) + end, + type = "number", + }, + { + key = "insertMode", + get = function(ps) + return ps:getInsertMode() + end, + set = function(ps, v) + ps:setInsertMode(v) + end, + type = "string", + }, + { + key = "colors", + get = function(ps) + local parts = {} + for _, c in ipairs({ ps:getColors() }) do + if type(c) == "table" then + for _, v in ipairs(c) do + parts[#parts + 1] = fmt(v) + end + else + parts[#parts + 1] = fmt(c) + end + end + return table.concat(parts, ", ") + end, + set = function(ps, value) + local colors = {} + for raw in tostring(value):gmatch("[^,]+") do + local n = tonumber(raw) + if n then + colors[#colors + 1] = n + end + end + if #colors >= 4 then + ps:setColors(unpack(colors)) + end + end, + type = "string", + }, + { + key = "emissionAreaDist", + get = function(ps) + local a = ps:getEmissionArea() + return a + end, + set = function(ps, v) + local _, b, c, d, e = ps:getEmissionArea() + ps:setEmissionArea(v, b, c, d, e) + end, + type = "string", + }, + { + key = "emissionAreaDx", + get = function(ps) + local _, b = ps:getEmissionArea() + return b + end, + set = function(ps, v) + local a, _, c, d, e = ps:getEmissionArea() + ps:setEmissionArea(a, v, c, d, e) + end, + type = "number", + min = 0, + }, + { + key = "emissionAreaDy", + get = function(ps) + local _, _, c = ps:getEmissionArea() + return c + end, + set = function(ps, v) + local a, b, _, d, e = ps:getEmissionArea() + ps:setEmissionArea(a, b, v, d, e) + end, + type = "number", + min = 0, + }, + { + key = "emissionAreaAngle", + get = function(ps) + local _, _, _, d = ps:getEmissionArea() + return d + end, + set = function(ps, v) + local a, b, c, _, e = ps:getEmissionArea() + ps:setEmissionArea(a, b, c, v, e) + end, + type = "number", + }, + { + key = "emissionAreaRelative", + get = function(ps) + local _, _, _, _, e = ps:getEmissionArea() + return e + end, + set = function(ps, v) + local a, b, c, d = ps:getEmissionArea() + ps:setEmissionArea(a, b, c, d, v) + end, + type = "boolean", + }, +} + +local PS_PROP_MAP = {} +for _, prop in ipairs(PS_PROPERTIES) do + PS_PROP_MAP[prop.key] = prop +end + +local function setProp(ps, key, raw) + local prop = PS_PROP_MAP[key] + if not prop or not ps then + return false + end + + local value + if prop.type == "number" then + value = tonumber(raw) + if value == nil then + return false + end + if prop.min and value < prop.min then + value = prop.min + end + if prop.max and value > prop.max then + value = prop.max + end + elseif prop.type == "boolean" then + value = raw == true or raw == "true" or raw == "1" + else + value = tostring(raw) + end + + return pcall(prop.set, ps, value) +end + +local function snapshotPS(ps) + local snapshot = {} + if not ps then + return snapshot + end + for _, prop in ipairs(PS_PROPERTIES) do + local ok, value = pcall(prop.get, ps) + if ok then + snapshot[prop.key] = value + end + end + local okCount, count = pcall(ps.getCount, ps) + if okCount then + snapshot.count = count + end + local okBuffer, buffer = pcall(ps.getBufferSize, ps) + if okBuffer then + snapshot.bufferSize = buffer + end + return snapshot +end + +local function createDefaultSystem(index, template) + template = template or "fire" + local texturePreset = "circle" + if template == "smoke" then + texturePreset = "light" + end + if template == "sparkles" then + texturePreset = "star" + end + if template == "explosion-smoke" then + texturePreset = "light" + end + + local image, png = generatePresetImage(texturePreset) + local ps = love.graphics.newParticleSystem(image, DEFAULT_BUFFER_SIZE) + ps:setEmitterLifetime(-1) + ps:setEmissionRate(100) + ps:setParticleLifetime(0.35, 1.3) + ps:setSpeed(40, 140) + ps:setDirection(-math.pi / 2) + ps:setSpread(math.pi / 3) + ps:setColors(1, 0.5, 0.1, 1, 1, 0.1, 0, 0) + ps:setSizes(1, 0) + + if template == "smoke" then + ps:setEmissionRate(45) + ps:setParticleLifetime(1.2, 3.2) + ps:setSpeed(12, 45) + ps:setDirection(-math.pi / 2) + ps:setSpread(math.pi / 5) + ps:setLinearAcceleration(-8, -18, 8, -55) + ps:setLinearDamping(0.4, 1.2) + ps:setColors(0.35, 0.35, 0.38, 0.45, 0.15, 0.15, 0.17, 0) + ps:setSizes(0.35, 1.2, 2.2) + ps:setSizeVariation(0.6) + elseif template == "sparkles" then + ps:setEmissionRate(70) + ps:setParticleLifetime(0.35, 0.9) + ps:setSpeed(80, 220) + ps:setDirection(-math.pi / 2) + ps:setSpread(math.pi * 2) + ps:setLinearDamping(1.5, 3) + ps:setColors(1, 0.95, 0.55, 1, 0.35, 0.75, 1, 0) + ps:setSizes(0.45, 0.1) + ps:setSizeVariation(0.8) + elseif template == "explosion-core" then + ps:setEmitterLifetime(0.12) + ps:setEmissionRate(900) + ps:setParticleLifetime(0.25, 0.75) + ps:setSpeed(90, 330) + ps:setDirection(0) + ps:setSpread(math.pi * 2) + ps:setLinearDamping(2, 5) + ps:setColors(1, 0.95, 0.35, 1, 1, 0.25, 0.05, 0.65, 0.08, 0.02, 0.01, 0) + ps:setSizes(0.25, 1.25, 0) + ps:setSizeVariation(0.75) + elseif template == "explosion-smoke" then + ps:setEmitterLifetime(0.2) + ps:setEmissionRate(180) + ps:setParticleLifetime(0.8, 2.2) + ps:setSpeed(30, 120) + ps:setDirection(-math.pi / 2) + ps:setSpread(math.pi * 2) + ps:setLinearAcceleration(-15, -25, 15, -70) + ps:setLinearDamping(0.8, 1.8) + ps:setColors(0.28, 0.26, 0.24, 0.55, 0.12, 0.11, 0.1, 0) + ps:setSizes(0.4, 1.8, 2.6) + ps:setSizeVariation(0.7) + end + + ps:start() + + return { + system = ps, + title = "Emitter " .. tostring(index), + blendMode = "alpha", + shader = nil, + shaderPath = "", + shaderFilename = "", + shaderSource = "", + texturePath = "", + texturePreset = texturePreset, + textureFilename = texturePreset .. ".png", + textureAssetBase64 = png and base64.encode(png) or nil, + x = 0, + y = 0, + kickStartSteps = 0, + kickStartDt = 1 / 60, + emitAtStart = 0, + _ownedImage = image, + } +end + +local function createDefaultSystems(template) + if template == "explosion" then + local core = createDefaultSystem(1, "explosion-core") + core.title = "Core Blast" + core.blendMode = "add" + core.emitAtStart = 220 + + local smoke = createDefaultSystem(2, "explosion-smoke") + smoke.title = "Smoke Bloom" + smoke.blendMode = "alpha" + smoke.emitAtStart = 90 + + local sparks = createDefaultSystem(3, "sparkles") + sparks.title = "Sparks" + sparks.blendMode = "add" + sparks.emitAtStart = 140 + return { core, smoke, sparks } + end + + if template == "smoke" then + local smoke = createDefaultSystem(1, "smoke") + smoke.title = "Smoke" + return { smoke } + end + + if template == "sparkles" then + local sparks = createDefaultSystem(1, "sparkles") + sparks.title = "Sparkles" + sparks.blendMode = "add" + return { sparks } + end + + local fire = createDefaultSystem(1, "fire") + fire.title = "Flame" + return { fire } +end + +local function computeMovementOffset(movement, dt) + if type(movement) ~= "table" or movement.pattern == nil or movement.pattern == "none" then + return 0, 0 + end + movement.t = (movement.t or 0) + dt + local t = movement.t * (movement.speed or 1) + + if movement.pattern == "circle" then + local radius = movement.radius or 50 + return math.cos(t) * radius, math.sin(t) * radius + end + + if movement.pattern == "figure-eight" then + local rx = movement.radiusX or 80 + local ry = movement.radiusY or 40 + return math.sin(t) * rx, math.sin(t * 2) * ry * 0.5 + end + + if movement.pattern == "irregular" then + local scale = movement.scale or 50 + return (math.sin(t * 0.7 + 1.2) * 0.55 + math.sin(t * 1.9) * 0.3 + math.sin(t * 3.1) * 0.15) * scale, + (math.sin(t * 0.9 + 2.4) * 0.55 + math.sin(t * 1.7 + 0.3) * 0.3 + math.sin(t * 2.9) * 0.15) * scale + end + + return 0, 0 +end + +function ParticleSystemPlaygroundPlugin:init(config) + Base.init(self, config) + self.composites = {} + self.compositeOrder = {} + self.activeComposite = nil + self.activeSystem = 1 +end + +function ParticleSystemPlaygroundPlugin:addComposite(name, getter) + if type(name) ~= "string" or name == "" or type(getter) ~= "function" then + return false + end + if self.composites[name] then + return false + end + self.composites[name] = { + kind = "game", + name = name, + getter = getter, + meta = {}, + } + self.compositeOrder[#self.compositeOrder + 1] = name + if not self.activeComposite then + self.activeComposite = name + end + return true +end + +function ParticleSystemPlaygroundPlugin:removeComposite(name) + local entry = self.composites[name] + if not entry then + return false + end + self.composites[name] = nil + for i, item in ipairs(self.compositeOrder) do + if item == name then + table.remove(self.compositeOrder, i) + break + end + end + if self.activeComposite == name then + self.activeComposite = self.compositeOrder[1] + self.activeSystem = 1 + end + return true +end + +function ParticleSystemPlaygroundPlugin:_newComposite(name, template) + local base = safeString(name, "") + if base == "" then + base = "Effect " .. tostring(#self.compositeOrder + 1) + end + local final = base + local suffix = 2 + while self.composites[final] do + final = base .. " " .. tostring(suffix) + suffix = suffix + 1 + end + + self.composites[final] = { + kind = "scratch", + name = final, + x = DEFAULT_X, + y = DEFAULT_Y, + movement = { pattern = "none", radius = 50, radiusX = 80, radiusY = 40, speed = 1, scale = 50 }, + offsetX = 0, + offsetY = 0, + systems = createDefaultSystems(template), + } + self.compositeOrder[#self.compositeOrder + 1] = final + self.activeComposite = final + self.activeSystem = 1 + return final +end + +function ParticleSystemPlaygroundPlugin:_getCompositeTable(name) + local entry = self.composites[name or self.activeComposite] + if not entry then + return nil + end + if entry.kind == "game" then + local ok, composite = pcall(entry.getter) + if ok and type(composite) == "table" then + return composite + end + return nil + end + return entry +end + +function ParticleSystemPlaygroundPlugin:_getSystemEntry(name, index) + local entry = self.composites[name or self.activeComposite] + if not entry then + return nil + end + if entry.kind == "scratch" then + return entry.systems and entry.systems[index] + end + local composite = self:_getCompositeTable(name) + return composite and composite[index] or nil +end + +function ParticleSystemPlaygroundPlugin:_systemCount(name) + local entry = self.composites[name or self.activeComposite] + if not entry then + return 0 + end + if entry.kind == "scratch" then + return entry.systems and #entry.systems or 0 + end + local composite = self:_getCompositeTable(name) + if not composite then + return 0 + end + local count = 0 + while composite[count + 1] do + count = count + 1 + end + return count +end + +function ParticleSystemPlaygroundPlugin:_meta(name, index) + local entry = self.composites[name or self.activeComposite] + if not entry then + return {} + end + if entry.kind == "scratch" then + return entry.systems and entry.systems[index] or {} + end + entry.meta[index] = entry.meta[index] or {} + return entry.meta[index] +end + +function ParticleSystemPlaygroundPlugin:update(dt) + for _, name in ipairs(self.compositeOrder) do + local entry = self.composites[name] + if entry and entry.kind == "scratch" then + local offsetX, offsetY = computeMovementOffset(entry.movement, dt) + entry.offsetX, entry.offsetY = offsetX, offsetY + local x = (entry.x or DEFAULT_X) + offsetX + local y = (entry.y or DEFAULT_Y) + offsetY + for _, system in ipairs(entry.systems or {}) do + if system.system then + pcall(system.system.setPosition, system.system, x + (system.x or 0), y + (system.y or 0)) + pcall(system.system.update, system.system, dt) + end + end + end + end +end + +function ParticleSystemPlaygroundPlugin:onDraw() + if not love or not love.graphics then + return + end + + local previousBlend, previousAlphaMode = love.graphics.getBlendMode() + local previousShader = love.graphics.getShader() + local r, g, b, a = love.graphics.getColor() + + for _, name in ipairs(self.compositeOrder) do + local entry = self.composites[name] + if entry and entry.kind == "scratch" then + for _, system in ipairs(entry.systems or {}) do + if system.system then + pcall(love.graphics.setBlendMode, system.blendMode or "alpha") + if system.shader and system.shader.send and love.timer then + pcall(system.shader.send, system.shader, "u_time", love.timer.getTime()) + end + love.graphics.setShader(system.shader) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(system.system, 0, 0) + end + end + end + end + + love.graphics.setBlendMode(previousBlend, previousAlphaMode) + love.graphics.setShader(previousShader) + love.graphics.setColor(r, g, b, a) +end + +function ParticleSystemPlaygroundPlugin:handleRequest() + local names = {} + for _, name in ipairs(self.compositeOrder) do + names[#names + 1] = name + end + + local active = self.activeComposite + local data = nil + local entry = active and self.composites[active] + if entry then + local composite = self:_getCompositeTable(active) + local systems = {} + for i = 1, self:_systemCount(active) do + local sys = self:_getSystemEntry(active, i) or {} + local meta = self:_meta(active, i) + local ps = sys.system + systems[#systems + 1] = { + index = i, + title = safeString(meta.title or sys.title, "Emitter " .. tostring(i)), + blendMode = safeString(sys.blendMode, "alpha"), + x = safeNumber(sys.x, 0), + y = safeNumber(sys.y, 0), + kickStartSteps = safeNumber(sys.kickStartSteps, 0), + kickStartDt = safeNumber(sys.kickStartDt, 1 / 60), + emitAtStart = safeNumber(sys.emitAtStart, 0), + texturePath = safeString(meta.texturePath or sys.texturePath, ""), + texturePreset = safeString(meta.texturePreset or sys.texturePreset, ""), + textureFilename = safeString(meta.textureFilename or sys.textureFilename, ""), + shaderPath = safeString(meta.shaderPath or sys.shaderPath, ""), + shaderFilename = safeString(meta.shaderFilename or sys.shaderFilename, ""), + shaderSource = safeString(meta.shaderSource or sys.shaderSource, ""), + exportReady = ( + meta.texturePath + or sys.texturePath + or meta.textureAssetBase64 + or sys.textureAssetBase64 + or meta.texturePreset + or sys.texturePreset + ) + and true + or false, + properties = snapshotPS(ps), + } + end + data = { + compositeType = entry.kind, + x = entry.kind == "scratch" and safeNumber(entry.x, DEFAULT_X) or safeNumber(composite and composite.x, 0), + y = entry.kind == "scratch" and safeNumber(entry.y, DEFAULT_Y) or safeNumber(composite and composite.y, 0), + movement = entry.kind == "scratch" and entry.movement or { pattern = "none" }, + systems = systems, + } + end + + return { + type = "particle-system-playground", + loading = false, + composites = names, + activeComposite = active, + activeSystem = self.activeSystem, + data = data, + } +end + +function ParticleSystemPlaygroundPlugin:handleParamsUpdate(request) + local params = request.params or {} + if params.composite and self.composites[params.composite] then + self.activeComposite = params.composite + end + if params.systemIndex then + self.activeSystem = math.max(1, safeNumber(params.systemIndex, self.activeSystem)) + end + + local name = params.composite or self.activeComposite + local entry = self.composites[name] + if not entry then + return + end + + if entry.kind == "scratch" then + if params.compositeX ~= nil then + entry.x = safeNumber(params.compositeX, entry.x) + end + if params.compositeY ~= nil then + entry.y = safeNumber(params.compositeY, entry.y) + end + entry.movement = entry.movement or { pattern = "none" } + if params["movement.pattern"] ~= nil then + entry.movement.pattern = tostring(params["movement.pattern"]) + end + for _, key in ipairs({ "radius", "radiusX", "radiusY", "speed", "scale" }) do + local paramKey = "movement." .. key + if params[paramKey] ~= nil then + entry.movement[key] = safeNumber(params[paramKey], entry.movement[key]) + end + end + end + + local index = math.max(1, safeNumber(params.systemIndex, self.activeSystem)) + local sys = self:_getSystemEntry(name, index) + if not sys then + return + end + local meta = self:_meta(name, index) + + if params.title ~= nil then + meta.title = tostring(params.title) + sys.title = tostring(params.title) + end + if params.blendMode ~= nil then + sys.blendMode = tostring(params.blendMode) + end + if params.emitterOffsetX ~= nil then + sys.x = safeNumber(params.emitterOffsetX, sys.x) + end + if params.emitterOffsetY ~= nil then + sys.y = safeNumber(params.emitterOffsetY, sys.y) + end + if params.kickStartSteps ~= nil then + sys.kickStartSteps = safeNumber(params.kickStartSteps, sys.kickStartSteps) + end + if params.kickStartDt ~= nil then + sys.kickStartDt = safeNumber(params.kickStartDt, sys.kickStartDt) + end + if params.emitAtStart ~= nil then + sys.emitAtStart = safeNumber(params.emitAtStart, sys.emitAtStart) + end + + for key, value in pairs(params) do + setProp(sys.system, key, value) + end +end + +function ParticleSystemPlaygroundPlugin:_replaceTexture(sys, image) + if not sys or not image then + return + end + local old = sys.system + if old and old.setTexture then + local ok = pcall(old.setTexture, old, image) + if ok then + sys._ownedImage = image + return + end + end + + local new = love.graphics.newParticleSystem(image, old and old:getBufferSize() or DEFAULT_BUFFER_SIZE) + copyParticleProperties(old, new) + sys.system = new + sys._ownedImage = image + new:start() +end + +function ParticleSystemPlaygroundPlugin:_applyTexture(name, index, params) + local sys = self:_getSystemEntry(name, index) + if not sys then + return nil, "System not found" + end + local meta = self:_meta(name, index) + + if params.dataBase64 then + local raw = decodeBase64(params.dataBase64) + if not raw then + return nil, "Texture data is not valid base64" + end + local filename = sanitizeFilename(params.filename, "texture.png") + local okData, fileData = pcall(love.filesystem.newFileData, raw, filename) + if not okData or not fileData then + return nil, "Could not create texture file data" + end + local okImage, image = pcall(love.graphics.newImage, fileData) + if not okImage or not image then + return nil, "Could not create image from uploaded texture" + end + self:_replaceTexture(sys, image) + sys.texturePath = "" + sys.texturePreset = "" + sys.textureFilename = filename + sys.textureAssetBase64 = params.dataBase64 + meta.texturePath = "" + meta.texturePreset = "" + meta.textureFilename = filename + meta.textureAssetBase64 = params.dataBase64 + return true + end + + if params.texturePath and params.texturePath ~= "" then + local ok, image = pcall(love.graphics.newImage, params.texturePath) + if not ok or not image then + return nil, "Could not load texture: " .. tostring(params.texturePath) + end + self:_replaceTexture(sys, image) + sys.texturePath = tostring(params.texturePath) + sys.texturePreset = "" + sys.textureFilename = sanitizeFilename(params.texturePath, "texture.png") + sys.textureAssetBase64 = nil + meta.texturePath = sys.texturePath + meta.texturePreset = "" + meta.textureFilename = sys.textureFilename + meta.textureAssetBase64 = nil + return true + end + + local preset = safeString(params.preset, "") + if preset ~= "" then + local image, png = generatePresetImage(preset) + if not image then + return nil, "Could not generate texture preset" + end + self:_replaceTexture(sys, image) + sys.texturePath = "" + sys.texturePreset = preset + sys.textureFilename = preset .. ".png" + sys.textureAssetBase64 = png and base64.encode(png) or nil + meta.texturePath = "" + meta.texturePreset = preset + meta.textureFilename = sys.textureFilename + meta.textureAssetBase64 = sys.textureAssetBase64 + return true + end + + return nil, "No texture supplied" +end + +function ParticleSystemPlaygroundPlugin:_applyShader(name, index, params) + local sys = self:_getSystemEntry(name, index) + if not sys then + return nil, "System not found" + end + local meta = self:_meta(name, index) + + if params.clearShader then + sys.shader = nil + sys.shaderPath = "" + sys.shaderFilename = "" + sys.shaderSource = "" + meta.shaderPath = "" + meta.shaderFilename = "" + meta.shaderSource = "" + return true + end + + local source = params.shaderSource + local filename = sanitizeFilename(params.filename, "shader.glsl") + local path = safeString(params.shaderPath, "") + if path ~= "" then + local okRead, readSource = pcall(love.filesystem.read, path) + if not okRead or not readSource then + return nil, "Could not read shader: " .. path + end + source = readSource + filename = sanitizeFilename(path, "shader.glsl") + end + + if not source or source == "" then + return nil, "No shader source supplied" + end + + local okShader, shader = pcall(love.graphics.newShader, source) + if not okShader then + return nil, tostring(shader) + end + + sys.shader = shader + sys.shaderPath = path + sys.shaderFilename = filename + sys.shaderSource = source + meta.shaderPath = path + meta.shaderFilename = filename + meta.shaderSource = source + return true +end + +function ParticleSystemPlaygroundPlugin:handleActionRequest(request) + local params = request.params or {} + local action = params.action + local name = params.composite or self.activeComposite + local index = math.max(1, safeNumber(params.systemIndex, self.activeSystem)) + + if action == "new-composite" then + return { composite = self:_newComposite(params.name, params.template) } + end + + if action == "select-composite" then + if params.composite and self.composites[params.composite] then + self.activeComposite = params.composite + self.activeSystem = 1 + end + return true + end + + if action == "select-system" then + self.activeSystem = index + return true + end + + local entry = name and self.composites[name] + if not entry then + return nil, "Composite not found" + end + + if action == "delete-composite" then + if entry.kind ~= "scratch" then + return nil, "Only scratch composites can be deleted" + end + self:removeComposite(name) + return true + end + + if action == "add-system" then + if entry.kind ~= "scratch" then + return nil, "Only scratch composites can add emitters" + end + local newIndex = #entry.systems + 1 + entry.systems[newIndex] = createDefaultSystem(newIndex, params.template) + self.activeSystem = newIndex + return { systemIndex = newIndex } + end + + if action == "remove-system" then + if entry.kind ~= "scratch" then + return nil, "Only scratch composites can remove emitters" + end + if #entry.systems <= 1 then + return nil, "A composite needs at least one emitter" + end + local sys = entry.systems[index] + if sys and sys.system and sys.system.release then + pcall(sys.system.release, sys.system) + end + table.remove(entry.systems, index) + self.activeSystem = math.min(self.activeSystem, #entry.systems) + return true + end + + if action == "set-texture" then + return self:_applyTexture(name, index, params) + end + + if action == "set-shader" then + return self:_applyShader(name, index, params) + end + + local sys = self:_getSystemEntry(name, index) + if not sys or not sys.system then + return nil, "System not found" + end + + if action == "emit" then + sys.system:emit(math.max(1, safeNumber(params.count, 100))) + return true + end + + if action == "emit-all" then + for i = 1, self:_systemCount(name) do + local item = self:_getSystemEntry(name, i) + if item and item.system then + item.system:emit(math.max(1, safeNumber(params.count, 100))) + end + end + return true + end + + if action == "reset" then + sys.system:reset() + sys.system:start() + return true + end + + if action == "reset-all" then + for i = 1, self:_systemCount(name) do + local item = self:_getSystemEntry(name, i) + if item and item.system then + item.system:reset() + item.system:start() + end + end + return true + end + + if action == "kick-start" then + local steps = math.max(0, safeNumber(sys.kickStartSteps, 0)) + local dt = safeNumber(sys.kickStartDt, 1 / 60) + for _ = 1, steps do + sys.system:update(dt) + end + return true + end + + if action == "export-code" then + return { clipboard = self:_generateCode(name), exportCode = self:_generateCode(name) } + end + + if action == "export-zip" then + return { zipAssets = self:_buildZip(name) } + end + + return true +end + +function ParticleSystemPlaygroundPlugin:_assetInfo(name, index, sys) + local meta = self:_meta(name, index) + local texturePath = safeString(meta.texturePath or sys.texturePath, "") + local texturePreset = safeString(meta.texturePreset or sys.texturePreset, "") + local textureFilename = sanitizeFilename( + meta.textureFilename or sys.textureFilename or texturePath or texturePreset .. ".png", + "texture.png" + ) + local textureAssetBase64 = meta.textureAssetBase64 or sys.textureAssetBase64 + + if textureAssetBase64 == nil and texturePath ~= "" then + textureAssetBase64 = readFileBase64(texturePath) + end + + if textureAssetBase64 == nil and texturePreset ~= "" then + local _, png = generatePresetImage(texturePreset) + textureAssetBase64 = png and base64.encode(png) or nil + textureFilename = sanitizeFilename(texturePreset .. ".png", "texture.png") + end + + local shaderPath = safeString(meta.shaderPath or sys.shaderPath, "") + local shaderSource = safeString(meta.shaderSource or sys.shaderSource, "") + local shaderFilename = sanitizeFilename( + meta.shaderFilename or sys.shaderFilename or shaderPath or ("shader_" .. tostring(index) .. ".glsl"), + "shader.glsl" + ) + if shaderSource == "" and shaderPath ~= "" then + local ok, source = pcall(love.filesystem.read, shaderPath) + if ok and source then + shaderSource = source + end + end + + return { + texturePath = texturePath, + texturePreset = texturePreset, + textureFilename = textureFilename, + textureAssetBase64 = textureAssetBase64, + shaderPath = shaderPath, + shaderSource = shaderSource, + shaderFilename = shaderFilename, + } +end + +function ParticleSystemPlaygroundPlugin:_textureLoadPath(asset) + if asset.texturePath ~= "" then + return asset.texturePath + end + if asset.texturePreset ~= "" then + return "particles/" .. sanitizeFilename(asset.textureFilename, "texture.png") + end + return sanitizeFilename(asset.textureFilename, "texture.png") +end + +function ParticleSystemPlaygroundPlugin:_generateCode(name) + local count = self:_systemCount(name) + local entry = self.composites[name] + if not entry or count == 0 then + return "-- No Particles Playground composite selected" + end + + local imageVars = {} + local shaderVars = {} + local imageCount = 0 + local shaderCount = 0 + local lines = { + "-- Generated by Feather Particles Playground", + "-- " .. os.date("%Y-%m-%d %H:%M:%S"), + "local LG = love.graphics", + "local particles = {x = " .. fmt(entry.x or 0) .. ", y = " .. fmt(entry.y or 0) .. "}", + "", + } + + for i = 1, count do + local sys = self:_getSystemEntry(name, i) + if sys then + local asset = self:_assetInfo(name, i, sys) + local imageKey = self:_textureLoadPath(asset) + if not imageVars[imageKey] then + imageCount = imageCount + 1 + local var = "image" .. tostring(imageCount) + imageVars[imageKey] = var + local loadPath = self:_textureLoadPath(asset) + lines[#lines + 1] = "local " .. var .. " = LG.newImage(" .. quote(loadPath) .. ")" + lines[#lines + 1] = var .. ':setFilter("nearest", "nearest")' + end + + if asset.shaderSource ~= "" or asset.shaderPath ~= "" then + local shaderKey = asset.shaderPath ~= "" and asset.shaderPath or asset.shaderFilename + if not shaderVars[shaderKey] then + shaderCount = shaderCount + 1 + local var = "shader" .. tostring(shaderCount) + shaderVars[shaderKey] = var + local loadPath = asset.shaderPath ~= "" and asset.shaderPath or asset.shaderFilename + lines[#lines + 1] = "local " .. var .. " = LG.newShader(love.filesystem.read(" .. quote(loadPath) .. "))" + end + end + end + end + + for i = 1, count do + local sys = self:_getSystemEntry(name, i) + if sys and sys.system then + local ps = sys.system + local asset = self:_assetInfo(name, i, sys) + local imageKey = self:_textureLoadPath(asset) + local imageVar = imageVars[imageKey] + local psVar = "ps" .. tostring(i) + lines[#lines + 1] = "" + lines[#lines + 1] = "local " + .. psVar + .. " = LG.newParticleSystem(" + .. imageVar + .. ", " + .. tostring(ps:getBufferSize()) + .. ")" + + local colors = { ps:getColors() } + if #colors > 0 then + local parts = {} + for _, value in ipairs(colors) do + if type(value) == "table" then + for _, item in ipairs(value) do + parts[#parts + 1] = fmt(item) + end + else + parts[#parts + 1] = fmt(value) + end + end + lines[#lines + 1] = psVar .. ":setColors(" .. table.concat(parts, ", ") .. ")" + end + + local dist, dx, dy, angle, rel = ps:getEmissionArea() + local xmin, ymin, xmax, ymax = ps:getLinearAcceleration() + local dampMin, dampMax = ps:getLinearDamping() + local lifeMin, lifeMax = ps:getParticleLifetime() + local radialMin, radialMax = ps:getRadialAcceleration() + local rotMin, rotMax = ps:getRotation() + local speedMin, speedMax = ps:getSpeed() + local spinMin, spinMax = ps:getSpin() + local tangentMin, tangentMax = ps:getTangentialAcceleration() + local offsetX, offsetY = ps:getOffset() + local sizes = {} + for _, value in ipairs({ ps:getSizes() }) do + sizes[#sizes + 1] = fmt(value) + end + + lines[#lines + 1] = psVar .. ":setDirection(" .. fmt(ps:getDirection()) .. ")" + lines[#lines + 1] = psVar + .. ":setEmissionArea(" + .. quote(dist) + .. ", " + .. fmt(dx) + .. ", " + .. fmt(dy) + .. ", " + .. fmt(angle) + .. ", " + .. tostring(rel) + .. ")" + lines[#lines + 1] = psVar .. ":setEmissionRate(" .. fmt(ps:getEmissionRate()) .. ")" + lines[#lines + 1] = psVar .. ":setEmitterLifetime(" .. fmt(ps:getEmitterLifetime()) .. ")" + lines[#lines + 1] = psVar .. ":setInsertMode(" .. quote(ps:getInsertMode()) .. ")" + lines[#lines + 1] = psVar + .. ":setLinearAcceleration(" + .. table.concat({ fmt(xmin), fmt(ymin), fmt(xmax), fmt(ymax) }, ", ") + .. ")" + lines[#lines + 1] = psVar .. ":setLinearDamping(" .. fmt(dampMin) .. ", " .. fmt(dampMax) .. ")" + lines[#lines + 1] = psVar .. ":setOffset(" .. fmt(offsetX) .. ", " .. fmt(offsetY) .. ")" + lines[#lines + 1] = psVar .. ":setParticleLifetime(" .. fmt(lifeMin) .. ", " .. fmt(lifeMax) .. ")" + lines[#lines + 1] = psVar .. ":setRadialAcceleration(" .. fmt(radialMin) .. ", " .. fmt(radialMax) .. ")" + lines[#lines + 1] = psVar .. ":setRelativeRotation(" .. tostring(ps:hasRelativeRotation()) .. ")" + lines[#lines + 1] = psVar .. ":setRotation(" .. fmt(rotMin) .. ", " .. fmt(rotMax) .. ")" + if #sizes > 0 then + lines[#lines + 1] = psVar .. ":setSizes(" .. table.concat(sizes, ", ") .. ")" + end + lines[#lines + 1] = psVar .. ":setSizeVariation(" .. fmt(ps:getSizeVariation()) .. ")" + lines[#lines + 1] = psVar .. ":setSpeed(" .. fmt(speedMin) .. ", " .. fmt(speedMax) .. ")" + lines[#lines + 1] = psVar .. ":setSpin(" .. fmt(spinMin) .. ", " .. fmt(spinMax) .. ")" + lines[#lines + 1] = psVar .. ":setSpinVariation(" .. fmt(ps:getSpinVariation()) .. ")" + lines[#lines + 1] = psVar .. ":setSpread(" .. fmt(ps:getSpread()) .. ")" + lines[#lines + 1] = psVar .. ":setTangentialAcceleration(" .. fmt(tangentMin) .. ", " .. fmt(tangentMax) .. ")" + + local shaderKey = asset.shaderPath ~= "" and asset.shaderPath or asset.shaderFilename + local shaderValue = shaderVars[shaderKey] or "nil" + lines[#lines + 1] = "particles[" + .. tostring(i) + .. "] = {system = " + .. psVar + .. ", kickStartSteps = " + .. tostring(math.floor(safeNumber(sys.kickStartSteps, 0))) + .. ", kickStartDt = " + .. fmt(sys.kickStartDt or (1 / 60)) + .. ", emitAtStart = " + .. tostring(math.floor(safeNumber(sys.emitAtStart, 0))) + .. ", blendMode = " + .. quote(sys.blendMode or "alpha") + .. ", shader = " + .. shaderValue + .. ", texturePreset = " + .. quote(asset.texturePreset) + .. ", texturePath = " + .. quote(asset.texturePath) + .. ", shaderPath = " + .. quote(asset.shaderPath) + .. ", shaderFilename = " + .. quote(asset.shaderSource ~= "" and asset.shaderFilename or "") + .. ", x = " + .. fmt(sys.x or 0) + .. ", y = " + .. fmt(sys.y or 0) + .. "}" + end + end + + lines[#lines + 1] = "" + lines[#lines + 1] = "return particles" + return table.concat(lines, "\n") +end + +function ParticleSystemPlaygroundPlugin:_buildZip(name) + local files = { + { name = "init.lua", data = self:_generateCode(name), encoding = "text" }, + } + local seen = { ["init.lua"] = true } + + for i = 1, self:_systemCount(name) do + local sys = self:_getSystemEntry(name, i) + if sys then + local asset = self:_assetInfo(name, i, sys) + if asset.textureAssetBase64 then + local filename = self:_textureLoadPath(asset) + if asset.texturePath ~= "" then + filename = archivePath(asset.texturePath, asset.textureFilename) + end + if not seen[filename] then + seen[filename] = true + files[#files + 1] = { name = filename, data = asset.textureAssetBase64, encoding = "base64" } + end + end + if asset.shaderSource ~= "" then + local filename = sanitizeFilename(asset.shaderFilename, "shader_" .. tostring(i) .. ".glsl") + if not seen[filename] then + seen[filename] = true + files[#files + 1] = { name = filename, data = asset.shaderSource, encoding = "text" } + end + end + end + end + + return { + filename = sanitizeFilename(name or "particle-system-playground", "particle-system-playground") .. ".zip", + files = files, + } +end + +function ParticleSystemPlaygroundPlugin:getConfig() + return { + type = "particle-system-playground", + icon = "sparkles", + } +end + +return ParticleSystemPlaygroundPlugin diff --git a/src-lua/plugins/particle-system-playground/manifest.lua b/src-lua/plugins/particle-system-playground/manifest.lua new file mode 100644 index 00000000..7a197c13 --- /dev/null +++ b/src-lua/plugins/particle-system-playground/manifest.lua @@ -0,0 +1,10 @@ +return { + id = "particle-system-playground", + name = "Particles Playground", + description = "Author composite particle effects with live in-game preview", + version = "1.0.0", + capabilities = { "draw", "filesystem" }, + optIn = false, + disabled = false, + api = 5, +} diff --git a/src-lua/plugins/shader-graph/README.md b/src-lua/plugins/shader-graph/README.md new file mode 100644 index 00000000..9f09c0d4 --- /dev/null +++ b/src-lua/plugins/shader-graph/README.md @@ -0,0 +1,277 @@ +# Shader Graph + +Feather plugin that validates GLSL shaders authored in the Shader Graph visual editor. + +The desktop editor owns the graph UI and GLSL code generation. This runtime plugin only compiles the generated LÖVE shader source inside the connected game process so validation matches the user's graphics driver and LÖVE version. + +The node library is inspired by common visual shader graph systems, including Unity Shader Graph's category model: Artistic, Channel, Input, Math, Procedural, Utility, and UV. Feather's implementation is intentionally smaller and LÖVE-focused. + +Several higher-level nodes and presets are also inspired by common VFX Shader Graph recipes, including texture strength, opacity, dissolve masks, and vertex displacement. The water displacement nodes are inspired by Alex Griffith's LÖVE shader write-up, adapted to use procedural noise so Feather graphs stay self-contained. Unity-specific camera depth effects are not copied directly because LÖVE 2D shaders do not expose Unity's scene depth buffer in the same way. + +## Workflow + +1. Open **Shader Graph** in Feather. +2. On first open, Feather loads the **Water Shimmer** example so there is a complete graph ready to validate, edit, and apply. +3. Drag nodes from the palette onto the canvas. +4. Connect compatible ports by type. +5. Connect the final `vec4` color into **Fragment Output**. +6. Use **Validate** to compile in the running LÖVE game. +7. Use **Apply** to send the generated shader to the selected Particle System Playground emitter. +8. Export/import `.feathershgh` files when you want to save or share editable graph projects. + +Select a node and edit **Node Name** in the inspector when a graph needs more descriptive labels. Renaming a node changes the canvas label only; the original node type stays visible in the inspector and code generation is unchanged. + +## Node Types + +### Input + +Use input nodes as the raw data source for a shader. + +| Node | Output | Use | +|---|---|---| +| Texture Color | `vec4` | Current texture sample: `Texel(tex, texture_coords)` | +| Texture Coords | `vec2` | UV coordinates, usually the starting point for distortion effects | +| Screen Coords | `vec2` | Pixel/screen position, useful for screen-space patterns | +| Vertex Color | `vec4` | LÖVE color/tint passed into the shader | +| Time | `float` | Animated effects; emits `extern number u_time` | +| Resolution | `vec2` | Uses `love_ScreenSize.xy` | +| Float / Vec2 / Vec3 / Vec4 | typed constants | Editable values for colors, thresholds, speed, etc. | + +### Math + +Math nodes shape scalar values. They are most useful for masks, thresholds, animation curves, and mixing amounts. + +- `Add`, `Subtract`, `Multiply`, `Divide`: combine scalar values. +- `Power`: sharpen or soften masks. +- `Clamp`: keep values inside a range. +- `Lerp`: interpolate between two scalar values. +- `Step`, `Smoothstep`: build hard or soft thresholds. +- `Sin`, `Cos`: oscillation for wave and pulse effects. +- `Abs`, `Fract`, `Floor`: repeating patterns, bands, pixel stepping. +- `Min`, `Max`, `Modulo`, `Negate`, `Saturate`: scalar shaping and bounds. +- `Remap`: convert one scalar range into another, useful for mask tuning. + +### Vector + +Vector nodes convert between packed colors/vectors and scalar channels. + +- `Split Vec4`: split a `vec4` into RGBA floats. +- `Combine4`: combine RGBA floats into a `vec4`. +- `Split RGB` / `Combine RGB`: unpack and repack `vec3` values. +- `Combine2`, `Combine3`: build `vec2` or `vec3`. +- `SplitVec2`, `SplitVec3`: unpack vector channels. +- `Swizzle Vec2`: get `xy` and `yx` variants. +- `Normalize`, `Length`, `Dot`: `vec4` RGB utility operations. +- `Distance Vec2`, `Length Vec2`, `Normalize Vec2`, `Dot Vec2`: UV/vector math for 2D masks. + +### Color + +Color nodes transform an existing `vec4`. + +- `Desaturate`: mix a color toward grayscale. +- `One Minus`: invert a scalar mask. +- `Hue Shift`: rotate color hue. +- `Invert Color`: invert RGB by an editable amount. +- `Contrast`: adjust contrast around 0.5. +- `Posterize`: reduce colors to a fixed number of bands. +- `Multiply Color`: multiply two `vec4` values. + +### Noise + +Noise nodes create procedural variation. + +- `Simple Noise`: deterministic hash noise from UV. +- `Ripple`: UV distortion using sine waves. +- `Voronoi Cells`: cellular mask for sparks, shields, energy, and organic breakup. +- `Checkerboard`: alternating square mask. + +### UV + +UV nodes transform texture coordinates before sampling. + +- `Tiling And Offset`: scale and offset UVs. +- `Rotate UV`: rotate UVs around the center. +- `Twirl UV`: swirl UVs toward the center. +- `Polar Coordinates`: convert UVs into radius/angle space. + +### Effect + +Effect nodes are higher-level building blocks for common 2D game shaders. + +| Node | Use | +|---|---| +| Sample Texture | Samples the texture at a supplied UV, useful after UV distortion | +| Texture Strength | Sharpens texture alpha and boosts color intensity, useful for cracks, glows, and impact marks | +| Opacity | Multiplies texture alpha by a scalar fade value | +| Centered UV | Converts UV into `uv - 0.5` and distance from center | +| Fresnel / Rim 2D | Radial edge mask for glow/rim effects on sprites and particles | +| Alpha Outline | Samples neighboring alpha to create sprite outlines | +| Wave Distort | Animated UV wave for water, heat, magic, flags | +| Water Displace | Procedural animated noise displacement for water, heat shimmer, and magic surfaces | +| Masked Water | Water displacement constrained by texture alpha, useful when only opaque regions should move | +| Dissolve | Noise threshold dissolve with a colored edge | +| Hit Flash | Mixes texture color toward a flash color | +| Vignette | Darkens or fades toward UV edges | +| Pixelate | Snaps UVs to a lower-resolution grid | +| Chromatic Aberration | Splits red/blue samples away from center | + +### Output + +`Fragment Output` is the required final node for pixel shaders. If no color is connected, codegen falls back to `Texel(tex, texture_coords)`. + +### Vertex + +Vertex nodes are optional. Use them only when you need to change vertex positions. + +- `Vertex Position`: original vertex position. +- `Vertex Wave 2D`: offsets vertex XY positions with a time-driven sine/cosine wave. +- `Transform Matrix`: LÖVE transform/projection matrix. +- `Mat x Vec`: matrix/vector multiplication. +- `Vertex Output`: final vertex position. + +## Preset Flows + +The Shader Graph page includes complete preset graphs: + +- **Texture Pass**: baseline texture output. +- **Outline**: alpha outline around a sprite. +- **Wave**: animated UV distortion. +- **Dissolve**: noise dissolve with edge color. +- **Hit Flash**: damage/selection flash. +- **Rim Glow**: 2D fresnel-style edge glow. +- **Pixelate**: retro low-resolution sampling. +- **Chromatic Aberration**: RGB channel split. +- **Posterize**: toon/retro color bands. +- **Twirl Portal**: centered UV swirl. +- **Rotating Texture**: time-driven UV rotation. +- **Checker Flash**: checker mask mixed into a flash color. +- **Voronoi Energy**: cellular energy/shield mask. +- **Tiled Offset**: tiled UV sampling. +- **Texture Strength**: alpha/intensity shaping for marks, cracks, and glows. +- **Opacity Fade**: straightforward alpha fade control. +- **Vertex Wave**: vertex-stage wobble with a normal texture pass. +- **Water Shimmer**: self-contained procedural UV displacement. +- **Masked Water Shimmer**: alpha-constrained displacement that keeps transparent edges stable. + +Load a preset, validate it, then inspect how the nodes are connected. Presets are intended as editable starting points, not black boxes. + +## Practical Tips + +- Start most shaders with `Texture Coords` and `Texture Color`. +- Distort UV first, then use `Sample Texture` to read the texture at the modified UV. +- Use `Vec4Constant` for editable colors like outline, flash, and dissolve edge color. +- Use `FloatConstant` for user-tunable controls such as thickness, amount, speed, cutoff, and softness. +- Prefer `Smoothstep` over `Step` for effects that should have soft edges. +- Keep masks as `float` values until the final color mix. +- For particle shaders, preserve alpha unless you intentionally want to change particle fade behavior. +- Validate often. Some shader mistakes only appear on the active LÖVE runtime or graphics driver. + +## Common Recipes + +### Texture Pass + +`Texture Color -> Fragment Output` + +Use this as the baseline when debugging. + +### UV Distortion + +`Texture Coords -> Wave Distort -> Sample Texture -> Fragment Output` + +Add `Time`, `FloatConstant` amplitude, frequency, and speed inputs to animate it. + +### Water Shimmer + +`Texture Coords + Time + speed + amplitude + scale -> Water Displace -> Sample Texture -> Fragment Output` + +This is the self-contained version of the classic LÖVE water displacement pattern: scroll noise, convert it into an XY offset, then sample the texture at the displaced UV. Use low amplitude values such as `0.01` to `0.04`; texture-space displacement gets strong quickly. + +### Masked Water + +`Texture Color + Texture Coords + Time + speed + amplitude + scale + mask threshold -> Masked Water -> Fragment Output` + +The masked version only uses the displaced sample when both the current pixel alpha and displaced source alpha are above the threshold. It is useful for water tiles, shoreline sprites, and particle textures where transparent edges should not smear. + +### UV Transform + +`Texture Coords -> Rotate UV/Twirl UV/Tiling And Offset -> Sample Texture -> Fragment Output` + +Use this for portals, rotating particles, scrolling texture strips, and tiled materials. + +### Outline + +`Texture Color + Texture Coords + Float thickness + Vec4 outline color -> Alpha Outline -> Fragment Output` + +Good for sprites, interactable objects, and particle silhouettes. + +### Dissolve + +`Texture Color + Texture Coords + cutoff + softness + edge color -> Dissolve -> Fragment Output` + +Animate the cutoff value externally or edit it in the graph while prototyping. + +### Texture Strength + +`Texture Color + Power + Strength -> Texture Strength -> Fragment Output` + +Use `Power` to make alpha thinner or wider and `Strength` to brighten the visible color. This is useful for impact decals, ground cracks, soft glows, and particle textures where you want to tune intensity without editing the source image. + +### Opacity + +`Texture Color + Opacity -> Opacity -> Fragment Output` + +Keep this near the end of a graph when you want a simple overall fade. + +### Vertex Wave + +`Vertex Position + Time + amplitude + frequency -> Vertex Wave 2D -> Vertex Output` + +Pair it with a normal fragment path such as `Texture Color -> Fragment Output`. It works best on sprites or meshes with enough vertices to show deformation; a single quad will wobble at its corners. + +### Rim Glow + +`Texture Coords -> Fresnel / Rim 2D -> Hit Flash amount` + +Feed texture color and a glow color into `Hit Flash`. + +### Procedural Mask Mix + +`Texture Coords -> Checkerboard/Voronoi Cells -> Hit Flash amount` + +Feed texture color and a highlight color into `Hit Flash`. This is a quick way to prototype shield, scan, grid, glitch, or energy effects. + +### About Depth Fade + +Depth fade is a useful Unity particle technique for softening intersections against scene geometry. Feather does not include a depth fade node yet because LÖVE's standard 2D shader path does not provide the same camera depth texture. For now, approximate soft edges with texture alpha, `Opacity`, `Vignette`, dissolve masks, or custom game-provided shader uniforms. + +## Actions + +### `compile-shader` + +Attempts to compile the provided GLSL source using `love.graphics.newShader`. Returns whether each stage compiled successfully, along with the driver error string if it failed. + +**Params** + +| Field | Type | Description | +|-------|------|-------------| +| `pixelSource` | `string` | GLSL pixel (fragment) shader source | +| `vertexSource` | `string` | GLSL vertex shader source (optional) | + +**Response** + +```lua +-- success +{ status = "ok" } + +-- failure +{ status = "error", pixelError = "...", vertexError = "..." } +``` + +`pixelError` / `vertexError` are `nil` when that stage compiled successfully. + +## Notes + +- Validation runs on the game process — a live LÖVE session must be connected. +- The plugin uses `pcall` so a bad shader never crashes the game. +- No draw calls are made; the shader object is discarded immediately after compilation. +- When vertex source is provided, validation compiles the combined pixel + vertex source because Feather applies shader graph output as a single LÖVE shader source. diff --git a/src-lua/plugins/shader-graph/init.lua b/src-lua/plugins/shader-graph/init.lua new file mode 100644 index 00000000..8e4adc05 --- /dev/null +++ b/src-lua/plugins/shader-graph/init.lua @@ -0,0 +1,51 @@ +local Class = require(FEATHER_PATH .. ".lib.class") +local Base = require(FEATHER_PATH .. ".core.base") + +local ShaderGraphPlugin = Class({ __includes = Base }) + +function ShaderGraphPlugin:initialize(feather) + Base.initialize(self, feather) +end + +local function compileShader(params) + local pixelSource = params.pixelSource or "" + local vertexSource = params.vertexSource or "" + + local pixelError = nil + local vertexError = nil + + local pixelOk, pixelErr = pcall(function() + love.graphics.newShader(pixelSource) + end) + if not pixelOk then + pixelError = tostring(pixelErr) + end + + if pixelOk and vertexSource and vertexSource ~= "" then + local vertOk, vertErr = pcall(function() + love.graphics.newShader(pixelSource .. "\n" .. vertexSource) + end) + if not vertOk then + vertexError = tostring(vertErr) + end + end + + if pixelError or vertexError then + return { status = "error", pixelError = pixelError, vertexError = vertexError } + end + + return { status = "ok" } +end + +function ShaderGraphPlugin:handleActionRequest(request) + local params = request.params or {} + local action = params.action + + if action == "compile-shader" then + return compileShader(params) + end + + return { status = "error", pixelError = "Unknown shader graph action: " .. tostring(action) } +end + +return ShaderGraphPlugin diff --git a/src-lua/plugins/shader-graph/manifest.lua b/src-lua/plugins/shader-graph/manifest.lua new file mode 100644 index 00000000..e1b47c8b --- /dev/null +++ b/src-lua/plugins/shader-graph/manifest.lua @@ -0,0 +1,10 @@ +return { + id = "shader-graph", + name = "Shader Graph", + description = "Validate GLSL shaders authored in the Feather shader graph editor", + version = "1.0.0", + capabilities = {}, + optIn = false, + disabled = false, + api = 5, +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6ea89c86..8d970c88 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1008,7 +1008,7 @@ dependencies = [ [[package]] name = "feather" -version = "1.1.1" +version = "1.2.0" dependencies = [ "axum", "futures-util", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f5325873..203e0d8e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "feather" -version = "1.1.1" +version = "1.2.0" description = "Feather is a Love2D devtool to debug and inspect your game in real-time. It provides a web-based interface to view and manipulate game state, visualize physics, and more." authors = ["kyonru"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7a93da33..8496f747 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Feather", - "version": "1.1.1", + "version": "1.2.0", "identifier": "com.kyonru.love.feather", "build": { "beforeDevCommand": "npm run dev", @@ -17,7 +17,8 @@ "minHeight": 600, "width": 950, "height": 670, - "resizable": true + "resizable": true, + "dragDropEnabled": false } ], "security": { diff --git a/src/components/app-sidebar/nav-main.tsx b/src/components/app-sidebar/nav-main.tsx index 046e75e5..ca492422 100644 --- a/src/components/app-sidebar/nav-main.tsx +++ b/src/components/app-sidebar/nav-main.tsx @@ -7,14 +7,26 @@ import { } from '@/components/ui/sidebar'; import { cn } from '@/utils/styles'; import { NavLink, useLocation } from 'react-router'; -import { BugIcon, ClockIcon, GaugeIcon, GitCompareArrowsIcon, ImagesIcon, LogsIcon, TelescopeIcon, TerminalIcon } from 'lucide-react'; +import { + BlendIcon, + BugIcon, + CableIcon, + ClockIcon, + GaugeIcon, + GitCompareArrowsIcon, + ImagesIcon, + LogsIcon, + SparklesIcon, + TelescopeIcon, + TerminalIcon, +} from 'lucide-react'; import { useDebuggerStore } from '@/store/debugger'; import { useSessionStore } from '@/store/session'; export function NavMain() { const sessionId = useSessionStore((state) => state.sessionId); const hasSession = !!sessionId; - const isPaused = useDebuggerStore((state) => sessionId ? !!state.pausedState[sessionId] : false); + const isPaused = useDebuggerStore((state) => (sessionId ? !!state.pausedState[sessionId] : false)); const items = [ { @@ -32,31 +44,46 @@ export function NavMain() { url: '/observability', icon: TelescopeIcon, }, + { + title: 'Debugger', + url: '/debugger', + icon: BugIcon, + }, { title: 'Console', url: '/console', icon: TerminalIcon, }, { - title: 'Debugger', - url: '/debugger', - icon: BugIcon, + title: 'Particles Playground', + url: '/particle-system-playground', + icon: SparklesIcon, }, { - title: 'Time Travel', - url: '/time-travel', - icon: ClockIcon, + title: 'Shader Graph', + url: '/shader-graph', + icon: BlendIcon, }, { title: 'Assets', url: '/assets', icon: ImagesIcon, }, + { + title: 'Time Travel', + url: '/time-travel', + icon: ClockIcon, + }, { title: 'Compare', url: '/compare', icon: GitCompareArrowsIcon, }, + { + title: 'Session', + url: '/session', + icon: CableIcon, + }, ]; const location = useLocation(); return ( diff --git a/src/hooks/use-particle-system-playground.ts b/src/hooks/use-particle-system-playground.ts new file mode 100644 index 00000000..83f811a2 --- /dev/null +++ b/src/hooks/use-particle-system-playground.ts @@ -0,0 +1,316 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { sendCommand } from '@/lib/send-command'; +import { useConfigStore } from '@/store/config'; +import { useSessionStore } from '@/store/session'; +import { debounce } from '@/utils/timers'; +import { sessionQueryKey } from './use-ws-connection'; +import type { + ParticleSystemPlaygroundData, + ParticleSystemPlaygroundSystem, + ParticleSystemPlaygroundTemplate, +} from '@/types/particle-system-playground'; + +const PLUGIN_ID = 'particle-system-playground'; + +const EMPTY_DATA: ParticleSystemPlaygroundData = { + type: 'particle-system-playground', + composites: [], + activeComposite: null, + activeSystem: 1, + data: null, +}; + +type ParamValue = string | number | boolean; + +function updateSystemDraft( + system: ParticleSystemPlaygroundSystem, + key: string, + value: ParamValue, +): ParticleSystemPlaygroundSystem { + if (key === 'title') return { ...system, title: String(value) }; + if (key === 'blendMode') return { ...system, blendMode: String(value) }; + if (key === 'emitterOffsetX') return { ...system, x: Number(value) }; + if (key === 'emitterOffsetY') return { ...system, y: Number(value) }; + if (key === 'kickStartSteps') return { ...system, kickStartSteps: Number(value) }; + if (key === 'kickStartDt') return { ...system, kickStartDt: Number(value) }; + if (key === 'emitAtStart') return { ...system, emitAtStart: Number(value) }; + return { ...system, properties: { ...system.properties, [key]: value } }; +} + +function updateDraft( + data: ParticleSystemPlaygroundData | undefined, + params: Record, +): ParticleSystemPlaygroundData | undefined { + if (!data?.data) return data; + const targetComposite = String(params.composite ?? data.activeComposite ?? ''); + if (targetComposite && targetComposite !== data.activeComposite) return data; + + const next: ParticleSystemPlaygroundData = { + ...data, + activeSystem: Number(params.systemIndex ?? data.activeSystem), + data: { + ...data.data, + movement: { ...data.data.movement }, + systems: data.data.systems.map((system) => ({ ...system, properties: { ...system.properties } })), + }, + }; + const nextData = next.data; + if (!nextData) return next; + + if (params.compositeX !== undefined) nextData.x = Number(params.compositeX); + if (params.compositeY !== undefined) nextData.y = Number(params.compositeY); + + for (const [key, value] of Object.entries(params)) { + if (key.startsWith('movement.')) { + const movementKey = key.slice('movement.'.length); + nextData.movement = { ...nextData.movement, [movementKey]: value }; + } + } + + const systemIndex = Number(params.systemIndex ?? data.activeSystem); + nextData.systems = nextData.systems.map((system) => { + if (system.index !== systemIndex) return system; + let draft = system; + for (const [key, value] of Object.entries(params)) { + if ( + key === 'composite' || + key === 'systemIndex' || + key === 'compositeX' || + key === 'compositeY' || + key.startsWith('movement.') + ) { + continue; + } + draft = updateSystemDraft(draft, key, value); + } + return draft; + }); + + return next; +} + +function updateTextureDraft( + data: ParticleSystemPlaygroundData | undefined, + systemIndex: number, + texture: Partial< + Pick + >, +): ParticleSystemPlaygroundData | undefined { + if (!data?.data) return data; + return { + ...data, + data: { + ...data.data, + systems: data.data.systems.map((system) => (system.index === systemIndex ? { ...system, ...texture } : system)), + }, + }; +} + +function valuesEqual(a: unknown, b: unknown) { + if (typeof a === 'number' || typeof b === 'number') { + return Math.abs(Number(a) - Number(b)) < 0.0001; + } + return a === b; +} + +function valueForParam( + data: ParticleSystemPlaygroundData | undefined, + params: Record, + key: string, +): ParamValue | undefined { + if (!data?.data) return undefined; + if (key === 'composite') return data.activeComposite ?? ''; + if (key === 'systemIndex') return data.activeSystem; + if (key === 'compositeX') return data.data.x; + if (key === 'compositeY') return data.data.y; + if (key.startsWith('movement.')) { + const movementKey = key.slice('movement.'.length) as keyof typeof data.data.movement; + return data.data.movement[movementKey] as ParamValue | undefined; + } + + const systemIndex = Number(params.systemIndex ?? data.activeSystem); + const system = data.data.systems.find((item) => item.index === systemIndex); + if (!system) return undefined; + if (key === 'title') return system.title; + if (key === 'blendMode') return system.blendMode; + if (key === 'emitterOffsetX') return system.x; + if (key === 'emitterOffsetY') return system.y; + if (key === 'kickStartSteps') return system.kickStartSteps; + if (key === 'kickStartDt') return system.kickStartDt; + if (key === 'emitAtStart') return system.emitAtStart; + return system.properties[key as keyof typeof system.properties] as ParamValue | undefined; +} + +export function useParticleSystemPlayground() { + const sessionId = useSessionStore((state) => state.sessionId); + const plugin = useConfigStore((state) => state.config?.plugins?.[PLUGIN_ID]); + const queryClient = useQueryClient(); + const pendingParams = useRef>({}); + const [optimisticParams, setOptimisticParams] = useState>({}); + + const { data: serverData } = useQuery({ + queryKey: sessionQueryKey.plugin(sessionId ?? '', PLUGIN_ID), + queryFn: () => EMPTY_DATA, + enabled: false, + initialData: EMPTY_DATA, + }); + + const data = useMemo(() => updateDraft(serverData, optimisticParams) ?? serverData, [serverData, optimisticParams]); + + const lastShaderResponse = useQuery<{ status?: string; message?: string }>({ + queryKey: sessionQueryKey.pluginAction(sessionId ?? '', PLUGIN_ID, 'set-shader'), + queryFn: () => ({}), + enabled: false, + }).data; + + const send = useCallback( + (message: Record) => { + if (!sessionId) return Promise.resolve(); + return sendCommand(sessionId, message).catch((error: unknown) => { + toast.error(error instanceof Error ? error.message : 'Particles Playground command failed'); + }); + }, + [sessionId], + ); + + const pluginQueryKey = sessionQueryKey.plugin(sessionId ?? '', PLUGIN_ID); + const setOptimisticData = useCallback((params: Record) => { + setOptimisticParams((current) => ({ ...current, ...params })); + }, []); + + useEffect(() => { + setOptimisticParams((current) => { + const entries = Object.entries(current).filter(([key, value]) => { + const serverValue = valueForParam(serverData, current, key); + return serverValue === undefined || !valuesEqual(serverValue, value); + }); + return entries.length === Object.keys(current).length ? current : Object.fromEntries(entries); + }); + }, [serverData]); + + const flushParams = useMemo( + () => + debounce(() => { + const params = pendingParams.current; + pendingParams.current = {}; + if (Object.keys(params).length === 0) return; + send({ type: 'cmd:plugin:params', plugin: PLUGIN_ID, params }); + }, 150), + [send], + ); + + const updateParam = useCallback( + (key: string, value: ParamValue) => { + setOptimisticData({ composite: data.activeComposite ?? '', systemIndex: data.activeSystem, [key]: value }); + pendingParams.current[key] = value; + flushParams(); + }, + [data.activeComposite, data.activeSystem, flushParams, setOptimisticData], + ); + + const updateActiveParam = useCallback( + (key: string, value: ParamValue) => { + pendingParams.current.composite = data.activeComposite ?? ''; + pendingParams.current.systemIndex = data.activeSystem; + setOptimisticData({ composite: data.activeComposite ?? '', systemIndex: data.activeSystem, [key]: value }); + pendingParams.current[key] = value; + flushParams(); + }, + [data.activeComposite, data.activeSystem, flushParams, setOptimisticData], + ); + + const sendAction = useCallback( + (action: string, extra: Record = {}) => { + return send({ + type: 'cmd:plugin:action', + plugin: PLUGIN_ID, + action, + params: { + composite: data.activeComposite ?? '', + systemIndex: data.activeSystem, + ...extra, + }, + }); + }, + [data.activeComposite, data.activeSystem, send], + ); + + const setTextureFromUpload = useCallback( + (filename: string, dataBase64: string) => { + queryClient.setQueryData(pluginQueryKey, (current) => + updateTextureDraft(current, data.activeSystem, { + texturePath: '', + texturePreset: '', + textureFilename: filename, + exportReady: true, + }), + ); + return sendAction('set-texture', { filename, dataBase64 }); + }, + [data.activeSystem, pluginQueryKey, queryClient, sendAction], + ); + + const refreshAfterAction = useCallback( + (action: string, extra?: Record) => { + sendAction(action, extra).then(() => { + queryClient.invalidateQueries({ queryKey: sessionQueryKey.plugin(sessionId ?? '', PLUGIN_ID) }).catch(() => {}); + }); + }, + [queryClient, sendAction, sessionId], + ); + + return { + plugin, + available: !!plugin, + enabled: !!plugin && !plugin.disabled && !plugin.incompatible, + data, + composites: data.composites, + activeComposite: data.activeComposite, + activeSystemIndex: data.activeSystem, + composite: data.data, + activeSystem: data.data?.systems.find((system) => system.index === data.activeSystem) ?? null, + shaderError: lastShaderResponse?.status === 'error' ? lastShaderResponse.message : '', + updateActiveParam, + updateParam, + sendAction, + refreshAfterAction, + selectComposite: (name: string) => refreshAfterAction('select-composite', { composite: name }), + selectSystem: (index: number) => refreshAfterAction('select-system', { systemIndex: index }), + createComposite: (name?: string, template?: ParticleSystemPlaygroundTemplate) => + refreshAfterAction('new-composite', { ...(name ? { name } : {}), ...(template ? { template } : {}) }), + deleteComposite: () => refreshAfterAction('delete-composite'), + addSystem: () => refreshAfterAction('add-system'), + removeSystem: (systemIndex: number) => refreshAfterAction('remove-system', { systemIndex }), + emit: (all = false, count = 100) => refreshAfterAction(all ? 'emit-all' : 'emit', { count }), + reset: (all = false) => refreshAfterAction(all ? 'reset-all' : 'reset'), + kickStart: () => refreshAfterAction('kick-start'), + setTexturePreset: (preset: string) => { + queryClient.setQueryData(pluginQueryKey, (current) => + updateTextureDraft(current, data.activeSystem, { + texturePath: '', + texturePreset: preset, + textureFilename: `${preset}.png`, + exportReady: true, + }), + ); + refreshAfterAction('set-texture', { preset }); + }, + setTexturePath: (texturePath: string) => { + queryClient.setQueryData(pluginQueryKey, (current) => + updateTextureDraft(current, data.activeSystem, { + texturePath, + texturePreset: '', + textureFilename: texturePath.split(/[\\/]/).pop() || 'texture.png', + exportReady: true, + }), + ); + refreshAfterAction('set-texture', { texturePath }); + }, + setTextureFromUpload, + setShader: (params: Record) => refreshAfterAction('set-shader', params), + exportCode: () => refreshAfterAction('export-code'), + exportZip: () => refreshAfterAction('export-zip'), + }; +} diff --git a/src/hooks/use-shader-graph.ts b/src/hooks/use-shader-graph.ts new file mode 100644 index 00000000..d50c2b99 --- /dev/null +++ b/src/hooks/use-shader-graph.ts @@ -0,0 +1,99 @@ +import { useCallback, useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { sendCommand } from '@/lib/send-command'; +import { useSessionStore } from '@/store/session'; +import { useShaderGraphStore } from '@/store/shader-graph'; +import { sessionQueryKey } from './use-ws-connection'; +import { codegen } from '@/pages/shader-graph/codegen'; + +const PLUGIN_ID = 'particle-system-playground'; +const SHADER_GRAPH_PLUGIN = 'shader-graph'; + +type CompilePayload = { status: 'ok' | 'error'; pixelError?: string; vertexError?: string }; +type CompileResponse = { + status?: 'success' | 'error' | 'ok'; + message?: string; + data?: CompilePayload; + pixelError?: string; + vertexError?: string; +}; + +export function useShaderGraph() { + const store = useShaderGraphStore(); + const sessionId = useSessionStore((s) => s.sessionId); + + const compileQuery = useQuery({ + queryKey: sessionQueryKey.pluginAction(sessionId ?? '', SHADER_GRAPH_PLUGIN, 'compile-shader'), + queryFn: () => ({ status: 'ok' as const }), + enabled: false, + }); + + const generateAndStore = useCallback(() => { + const glsl = codegen(store.nodes, store.edges); + store.setLastGlsl(glsl); + return glsl; + }, [store]); + + const validateShader = useCallback(async () => { + if (!sessionId) return; + const glsl = generateAndStore(); + store.setValidationStatus('validating'); + store.setValidationErrors({}); + try { + await sendCommand(sessionId, { + type: 'cmd:plugin:action', + plugin: SHADER_GRAPH_PLUGIN, + action: 'compile-shader', + params: { pixelSource: glsl.pixel, vertexSource: glsl.vertex ?? '' }, + }); + } catch { + store.setValidationStatus('error'); + store.setValidationErrors({ pixelError: 'Failed to reach game process' }); + } + }, [generateAndStore, sessionId, store]); + + const applyToPlayground = useCallback(async () => { + const target = store.playgroundTarget; + if (!target || !sessionId) return; + const glsl = generateAndStore(); + const shaderSource = glsl.vertex ? `${glsl.pixel}\n${glsl.vertex}` : glsl.pixel; + await sendCommand(sessionId, { + type: 'cmd:plugin:action', + plugin: PLUGIN_ID, + action: 'set-shader', + params: { + composite: target.composite, + systemIndex: target.systemIndex, + shaderSource, + filename: `${store.shaderName}.glsl`, + }, + }); + }, [generateAndStore, sessionId, store]); + + const compileResult = compileQuery.data; + + useEffect(() => { + if (!compileResult || store.validationStatus !== 'validating') return; + + if (compileResult.status === 'error' && !compileResult.data) { + store.setValidationStatus('error'); + store.setValidationErrors({ pixelError: compileResult.message ?? 'Shader validation failed' }); + return; + } + + const payload = compileResult.data ?? compileResult; + if (payload.status === 'ok') { + store.setValidationStatus('ok'); + store.setValidationErrors({}); + return; + } + + store.setValidationStatus('error'); + store.setValidationErrors({ + pixelError: payload.pixelError, + vertexError: payload.vertexError, + }); + }, [compileResult, store]); + + return { ...store, generateAndStore, validateShader, applyToPlayground }; +} diff --git a/src/hooks/use-ws-connection.ts b/src/hooks/use-ws-connection.ts index 349db7c6..2d990fe4 100644 --- a/src/hooks/use-ws-connection.ts +++ b/src/hooks/use-ws-connection.ts @@ -2,8 +2,9 @@ import { useEffect, useRef } from 'react'; import { listen } from '@tauri-apps/api/event'; import { invoke } from '@tauri-apps/api/core'; import { save as saveFileDialog } from '@tauri-apps/plugin-dialog'; -import { writeTextFile } from '@tauri-apps/plugin-fs'; +import { writeFile, writeTextFile } from '@tauri-apps/plugin-fs'; import { useQueryClient } from '@tanstack/react-query'; +import { zipSync, strToU8 } from 'fflate'; import { useConfigStore } from '@/store/config'; import { useSessionStore } from '@/store/session'; import { useSettingsStore } from '@/store/settings'; @@ -18,6 +19,7 @@ import { isWeb } from '@/utils/platform'; import { useDebuggerStore, type PausedState } from '@/store/debugger'; import { FEATHER_PLUGIN_API } from '@/constants/feather-api'; import { sendCommand } from '@/lib/send-command'; +import { base64ToUint8Array } from '@/utils/arrays'; // Cache key helpers — all indexed by the Rust-assigned session ID export const sessionQueryKey = { @@ -27,6 +29,7 @@ export const sessionQueryKey = { observers: (sessionId: string) => [sessionId, 'observers'], assets: (sessionId: string) => [sessionId, 'assets'], plugin: (sessionId: string, pluginId: string) => [sessionId, 'plugin', pluginId], + pluginAction: (sessionId: string, pluginId: string, action: string) => [sessionId, 'plugin-action', pluginId, action], console: (sessionId: string) => [sessionId, 'console'], timeTravel: (sessionId: string) => [sessionId, 'time-travel'], timeTravelFrames: (sessionId: string) => [sessionId, 'time-travel-frames'], @@ -468,8 +471,34 @@ export const useWsConnection = () => { case 'plugin:action:response': { // eslint-disable-next-line @typescript-eslint/no-explicit-any const response = msg as any; + const pluginId = response.plugin; + const action = response.action; + if (pluginId && action) { + queryClient.setQueryData(sessionQueryKey.pluginAction(sessionId, pluginId, action), response); + } if (response.status === 'error') { toast.error(`Plugin action failed: ${response.message || 'Unknown error'}`); + } else if (response.zipAssets && isWeb()) { + toast.error('ZIP export is available in the desktop app'); + } else if (response.zipAssets && !isWeb()) { + const archive: Record = {}; + for (const file of response.zipAssets.files ?? []) { + archive[file.name] = + file.encoding === 'base64' ? base64ToUint8Array(file.data) : strToU8(String(file.data ?? '')); + } + const bytes = zipSync(archive); + saveFileDialog({ + defaultPath: response.zipAssets.filename ?? 'particle-system-playground.zip', + filters: [{ name: 'ZIP archive', extensions: ['zip'] }], + }) + .then(async (path) => { + if (!path) return; + await writeFile(path, bytes); + toast.success(`Saved to ${path}`); + }) + .catch(() => { + toast.error('Failed to save ZIP'); + }); } else if (response.download && !isWeb()) { // Generic file download: plugin returned data to save const { filename, content, extension } = response.download; diff --git a/src/pages/particle-system-playground/components/AccelerationEditor.tsx b/src/pages/particle-system-playground/components/AccelerationEditor.tsx new file mode 100644 index 00000000..8f5d9791 --- /dev/null +++ b/src/pages/particle-system-playground/components/AccelerationEditor.tsx @@ -0,0 +1,50 @@ +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import type { ParticleSystemPlaygroundSystem } from '@/types/particle-system-playground'; +import { CircularForceGizmo } from './CircularForceGizmo'; +import { DampingRangeEditor } from './DampingRangeEditor'; +import { LinearAccelPlane } from './LinearAccelPlane'; +import { RangePairField } from './RangePairField'; + +type Props = { + system: ParticleSystemPlaygroundSystem; + onChange: (key: string, value: string | number | boolean) => void; +}; + +export function AccelerationEditor({ system, onChange }: Props) { + return ( +
+

Acceleration And Damping

+ + + + Linear + Radial + Damping + Raw + + + + + + + + + + + + + + + +
+ + + + + +
+
+
+
+ ); +} diff --git a/src/pages/particle-system-playground/components/CircularForceGizmo.tsx b/src/pages/particle-system-playground/components/CircularForceGizmo.tsx new file mode 100644 index 00000000..508e0d21 --- /dev/null +++ b/src/pages/particle-system-playground/components/CircularForceGizmo.tsx @@ -0,0 +1,363 @@ +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import type { ParticleSystemPlaygroundSystem } from '@/types/particle-system-playground'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +const H = 260; +const EMITTER_R = 22; + +type Props = { + system: ParticleSystemPlaygroundSystem; + onChange: (key: string, value: string | number | boolean) => void; +}; + +// Build a curved arrow path along a circle arc (for tangential indicators) +function arcArrow(cx: number, cy: number, r: number, startAngle: number, endAngle: number): string { + const sx = cx + Math.cos(startAngle) * r; + const sy = cy + Math.sin(startAngle) * r; + const ex = cx + Math.cos(endAngle) * r; + const ey = cy + Math.sin(endAngle) * r; + const largeArc = Math.abs(endAngle - startAngle) > Math.PI ? 1 : 0; + const sweep = endAngle > startAngle ? 1 : 0; + return `M ${sx} ${sy} A ${r} ${r} 0 ${largeArc} ${sweep} ${ex} ${ey}`; +} + +function clamp(value: number, range: number): number { + return Math.max(-range, Math.min(range, value)); +} + +export function CircularForceGizmo({ system, onChange }: Props) { + const p = system.properties; + const radialMin = p.radialAccelMin ?? 0; + const radialMax = p.radialAccelMax ?? 0; + const tangMin = p.tangentialAccelMin ?? 0; + const tangMax = p.tangentialAccelMax ?? 0; + + const svgRef = useRef(null); + const [svgW, setSvgW] = useState(300); + + useEffect(() => { + const el = svgRef.current; + if (!el) return; + setSvgW(el.clientWidth); + const ro = new ResizeObserver(([e]) => setSvgW(e.contentRect.width)); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const cx = svgW / 2; + const cy = H / 2; + + const maxAbs = Math.max(Math.abs(radialMin), Math.abs(radialMax), Math.abs(tangMin), Math.abs(tangMax), 50); + const [range, setRange] = useState(() => Math.ceil((maxAbs * 1.5) / 50) * 50); + const rangeWasEditedRef = useRef(false); + + useEffect(() => { + if (rangeWasEditedRef.current) return; + const nextRange = Math.ceil((maxAbs * 1.35) / 50) * 50; + setRange((current) => (nextRange > current ? nextRange : current)); + }, [maxAbs]); + + const halfSide = Math.min(svgW, H) / 2 - 30; + const scale = halfSide / range; + + const stateRef = useRef({ cx, cy, scale, range }); + stateRef.current = { cx, cy, scale, range }; + + type DragTarget = 'radialMin' | 'radialMax' | 'tangMin' | 'tangMax'; + const [dragging, setDragging] = useState(null); + + const onPointerMove = useCallback( + (e: React.PointerEvent) => { + if (!dragging || !svgRef.current) return; + const { cx, cy, scale } = stateRef.current; + const rect = svgRef.current.getBoundingClientRect(); + const dx = e.clientX - rect.left - cx; + const dy = e.clientY - rect.top - cy; + const round = (v: number) => Math.round(v * 10) / 10; + + const { range } = stateRef.current; + if (dragging === 'radialMin' || dragging === 'radialMax') { + const v = round(clamp(dx / scale, range)); + onChange(dragging === 'radialMin' ? 'radialAccelMin' : 'radialAccelMax', v); + } else { + const v = round(clamp(dy / scale, range)); + onChange(dragging === 'tangMin' ? 'tangentialAccelMin' : 'tangentialAccelMax', v); + } + }, + [dragging, onChange], + ); + + const stopDrag = useCallback(() => setDragging(null), []); + + const updateRange = useCallback( + (nextRange: number) => { + if (nextRange <= 0) return; + rangeWasEditedRef.current = true; + setRange(nextRange); + const nextValues = { + radialAccelMin: clamp(radialMin, nextRange), + radialAccelMax: clamp(radialMax, nextRange), + tangentialAccelMin: clamp(tangMin, nextRange), + tangentialAccelMax: clamp(tangMax, nextRange), + }; + Object.entries(nextValues).forEach(([key, value]) => { + const current = system.properties[key as keyof typeof system.properties] as number; + if (current !== value) onChange(key, value); + }); + }, + [onChange, radialMax, radialMin, system.properties, tangMax, tangMin], + ); + + // Radial handle positions: along horizontal axis + const radialMinX = cx + clamp(radialMin, range) * scale; + const radialMaxX = cx + clamp(radialMax, range) * scale; + // Tangential handle positions: along vertical axis + const tangMinY = cy + clamp(tangMin, range) * scale; + const tangMaxY = cy + clamp(tangMax, range) * scale; + + // Decorative: show 8 radial arrows around the emitter circle + const avgRadial = (radialMin + radialMax) / 2; + const avgTang = (tangMin + tangMax) / 2; + + const arrows8 = Array.from({ length: 8 }, (_, i) => { + const angle = (i / 8) * 2 * Math.PI; + const baseR = EMITTER_R + 4; + const tipR = baseR + Math.min(Math.abs(avgRadial) * scale * 0.4, 28) + 4; + const dir = avgRadial >= 0 ? 1 : -1; // outward or inward + const bx = cx + Math.cos(angle) * baseR; + const by = cy + Math.sin(angle) * baseR; + const ax = cx + Math.cos(angle) * (EMITTER_R + 4 + (dir > 0 ? tipR - baseR : 0)); + const ay = cy + Math.sin(angle) * (EMITTER_R + 4 + (dir > 0 ? tipR - baseR : 0)); + return { bx, by, tx: ax, ty: ay, angle: dir > 0 ? angle : angle + Math.PI }; + }); + + // Tangential arc display + const tangArcR = EMITTER_R + 12; + const tangStrength = Math.abs(avgTang); + const tangArcSpan = Math.min((tangStrength / range) * Math.PI * 1.5 + 0.3, Math.PI * 1.8); + const tangCW = avgTang >= 0; + + return ( +
+
+ + Radial & Tangential + +
+ Range ± + updateRange(parseFloat(e.target.value))} + /> +
+
+ + + {/* Reference circles */} + {[0.33, 0.66, 1].map((t) => ( + + ))} + + {/* Axes */} + + + + outward + + + inward + + + CW + + + CCW + + + {/* Radial arrows around emitter */} + {arrows8.map(({ bx, by, tx, ty, angle }, i) => ( + + + + + ))} + + {/* Tangential arc arrows */} + {tangStrength > 1 && ( + <> + + {/* arrowhead at end of arc */} + {(() => { + const endAngle = -Math.PI / 2 + (tangCW ? tangArcSpan : -tangArcSpan); + const ex = cx + Math.cos(endAngle) * tangArcR; + const ey = cy + Math.sin(endAngle) * tangArcR; + const ta = endAngle + (tangCW ? Math.PI / 2 : -Math.PI / 2); + return ( + + ); + })()} + + )} + + {/* Emitter circle */} + + + + {/* Radial handles (horizontal axis) */} + { + rangeWasEditedRef.current = true; + e.currentTarget.setPointerCapture(e.pointerId); + setDragging('radialMin'); + }} + /> + + min + + { + rangeWasEditedRef.current = true; + e.currentTarget.setPointerCapture(e.pointerId); + setDragging('radialMax'); + }} + /> + + max + + + {/* Tangential handles (vertical axis) */} + { + rangeWasEditedRef.current = true; + e.currentTarget.setPointerCapture(e.pointerId); + setDragging('tangMin'); + }} + /> + + min + + { + rangeWasEditedRef.current = true; + e.currentTarget.setPointerCapture(e.pointerId); + setDragging('tangMax'); + }} + /> + + max + + + {/* Legends */} + + + radial (± outward) + + + + tang (± CW) + + + +
+ {[ + { label: 'Radial Min', key: 'radialAccelMin', val: radialMin }, + { label: 'Radial Max', key: 'radialAccelMax', val: radialMax }, + { label: 'Tang Min', key: 'tangentialAccelMin', val: tangMin }, + { label: 'Tang Max', key: 'tangentialAccelMax', val: tangMax }, + ].map(({ label, key, val }) => ( +
+ + onChange(key, Number(e.target.value))} + /> +
+ ))} +
+
+ ); +} diff --git a/src/pages/particle-system-playground/components/ColorGradientEditor.tsx b/src/pages/particle-system-playground/components/ColorGradientEditor.tsx new file mode 100644 index 00000000..79a75435 --- /dev/null +++ b/src/pages/particle-system-playground/components/ColorGradientEditor.tsx @@ -0,0 +1,253 @@ +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { PlusIcon, Trash2Icon } from 'lucide-react'; +import { useCallback, useEffect, useId, useRef, useState } from 'react'; +import { catmullRomPath } from './curveUtils'; + +type Stop = { r: number; g: number; b: number; a: number }; + +const ALPHA_H = 64; +const ALPHA_PAD = { top: 8, right: 8, bottom: 8, left: 8 }; + +function clamp(value: number) { + return Math.max(0, Math.min(1, Number.isFinite(value) ? value : 0)); +} + +function parse(value: string): Stop[] { + const nums = value + .split(',') + .map((part) => Number(part.trim())) + .filter(Number.isFinite) + .map(clamp); + const stops: Stop[] = []; + for (let i = 0; i + 3 < nums.length; i += 4) { + stops.push({ r: nums[i], g: nums[i + 1], b: nums[i + 2], a: nums[i + 3] }); + } + return stops.length ? stops : [{ r: 1, g: 1, b: 1, a: 1 }]; +} + +function serialize(stops: Stop[]) { + return stops.flatMap((stop) => [stop.r, stop.g, stop.b, stop.a].map((value) => clamp(value).toFixed(3))).join(', '); +} + +function hex(stop: Stop) { + const part = (value: number) => + Math.round(clamp(value) * 255) + .toString(16) + .padStart(2, '0'); + return `#${part(stop.r)}${part(stop.g)}${part(stop.b)}`; +} + +function fromHex(value: string) { + const match = value.match(/^#?([0-9a-f]{6})$/i); + if (!match) return null; + const int = Number.parseInt(match[1], 16); + return { r: ((int >> 16) & 255) / 255, g: ((int >> 8) & 255) / 255, b: (int & 255) / 255 }; +} + +export function ColorGradientEditor({ value, onChange }: { value: string; onChange: (value: string) => void }) { + const stops = parse(value); + const [selected, setSelected] = useState(0); + const [draggingAlpha, setDraggingAlpha] = useState(null); + const alphaRef = useRef(null); + const gradientId = useId(); + const [svgW, setSvgW] = useState(300); + + useEffect(() => { + const el = alphaRef.current; + if (!el) return; + setSvgW(el.clientWidth); + const ro = new ResizeObserver(([e]) => setSvgW(e.contentRect.width)); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const stop = stops[Math.min(selected, stops.length - 1)]; + + const update = (index: number, patch: Partial) => { + onChange(serialize(stops.map((item, itemIndex) => (itemIndex === index ? { ...item, ...patch } : item)))); + }; + + const innerW = svgW - ALPHA_PAD.left - ALPHA_PAD.right; + const innerH = ALPHA_H - ALPHA_PAD.top - ALPHA_PAD.bottom; + + const alphaPoints = stops.map((s, i) => ({ + x: ALPHA_PAD.left + (stops.length === 1 ? innerW / 2 : (i / (stops.length - 1)) * innerW), + y: ALPHA_PAD.top + (1 - s.a) * innerH, + })); + const alphaPath = catmullRomPath(alphaPoints); + const baselineY = ALPHA_PAD.top + innerH; + + const onAlphaPointerMove = useCallback( + (e: React.PointerEvent) => { + if (draggingAlpha === null || !alphaRef.current) return; + const rect = alphaRef.current.getBoundingClientRect(); + const relY = e.clientY - rect.top; + const a = clamp(1 - (relY - ALPHA_PAD.top) / innerH); + update(draggingAlpha, { a }); + }, + [draggingAlpha, stops, innerH], + ); + + const stopAlphaDrag = useCallback(() => setDraggingAlpha(null), []); + + // CSS gradient string for the color bar (with alpha, shown over checkerboard) + const gradientCss = stops + .map((s, i) => { + const pct = stops.length === 1 ? '50%' : `${((i / (stops.length - 1)) * 100).toFixed(1)}%`; + return `rgba(${Math.round(s.r * 255)},${Math.round(s.g * 255)},${Math.round(s.b * 255)},${s.a.toFixed(3)}) ${pct}`; + }) + .join(', '); + + return ( +
+ {/* Alpha curve */} + + + + + + {stops.map((s, i) => ( + + ))} + + + + + {alphaPoints.map((pt, i) => ( + { + e.currentTarget.setPointerCapture(e.pointerId); + setDraggingAlpha(i); + setSelected(i); + }} + /> + ))} + + + {/* Color gradient bar with checkerboard for alpha visibility */} +
+
+ {stops.map((_, index) => ( +
+
+ +
+
+ + { + const color = fromHex(event.target.value); + if (color) update(selected, color); + }} + /> +
+
+ + { + const color = fromHex(event.target.value); + if (color) update(selected, color); + }} + /> +
+
+ + update(selected, { a: clamp(Number(event.target.value)) })} + /> +
+
+ + +
+
+
+ ); +} diff --git a/src/pages/particle-system-playground/components/CompositeSelector.tsx b/src/pages/particle-system-playground/components/CompositeSelector.tsx new file mode 100644 index 00000000..830edaef --- /dev/null +++ b/src/pages/particle-system-playground/components/CompositeSelector.tsx @@ -0,0 +1,101 @@ +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { PlusIcon, Trash2Icon } from 'lucide-react'; +import { useState } from 'react'; + +type Props = { + composites: string[]; + activeComposite: string | null; + compositeType?: 'scratch' | 'game'; + onSelect: (name: string) => void; + onCreate: (name?: string) => void; + onDelete: () => void; +}; + +export function CompositeSelector({ composites, activeComposite, compositeType, onSelect, onCreate, onDelete }: Props) { + const [name, setName] = useState(''); + const [createOpen, setCreateOpen] = useState(false); + + const createComposite = () => { + const trimmed = name.trim(); + onCreate(trimmed || undefined); + setName(''); + setCreateOpen(false); + }; + + return ( +
+
+ + + +
+ + + + + New Composite + Name the particle effect you want to create. + +
{ + event.preventDefault(); + createComposite(); + }} + > + setName(event.target.value)} + /> + + + + +
+
+
+
+ ); +} diff --git a/src/pages/particle-system-playground/components/DampingRangeEditor.tsx b/src/pages/particle-system-playground/components/DampingRangeEditor.tsx new file mode 100644 index 00000000..10ec9122 --- /dev/null +++ b/src/pages/particle-system-playground/components/DampingRangeEditor.tsx @@ -0,0 +1,185 @@ +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import type { ParticleSystemPlaygroundSystem } from '@/types/particle-system-playground'; +import { useEffect, useRef, useState } from 'react'; + +const H = 100; +const PAD = { top: 10, right: 12, bottom: 10, left: 12 }; +const STEPS = 60; + +type Props = { + system: ParticleSystemPlaygroundSystem; + onChange: (key: string, value: string | number | boolean) => void; +}; + +function decayCurve(svgW: number, dampingValue: number): string { + const innerW = svgW - PAD.left - PAD.right; + const innerH = H - PAD.top - PAD.bottom; + const points: string[] = []; + for (let i = 0; i <= STEPS; i++) { + const t = i / STEPS; + const v = Math.exp(-dampingValue * t * 3); + const x = PAD.left + t * innerW; + const y = PAD.top + (1 - v) * innerH; + points.push(`${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`); + } + return points.join(' '); +} + +export function DampingRangeEditor({ system, onChange }: Props) { + const p = system.properties; + const dampMin = p.linearDampingMin ?? 0; + const dampMax = p.linearDampingMax ?? 0; + + const svgRef = useRef(null); + const [svgW, setSvgW] = useState(300); + + useEffect(() => { + const el = svgRef.current; + if (!el) return; + setSvgW(el.clientWidth); + const ro = new ResizeObserver(([e]) => setSvgW(e.contentRect.width)); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const innerW = svgW - PAD.left - PAD.right; + const innerH = H - PAD.top - PAD.bottom; + + const minPath = decayCurve(svgW, dampMin); + const maxPath = decayCurve(svgW, dampMax); + + // Fill area between the two curves + const minPoints: { x: number; y: number }[] = []; + const maxPointsRev: { x: number; y: number }[] = []; + for (let i = 0; i <= STEPS; i++) { + const t = i / STEPS; + const x = PAD.left + t * innerW; + minPoints.push({ x, y: PAD.top + (1 - Math.exp(-dampMin * t * 3)) * innerH }); + maxPointsRev.push({ x, y: PAD.top + (1 - Math.exp(-dampMax * t * 3)) * innerH }); + } + const bandPath = + minPoints.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(' ') + + ' ' + + maxPointsRev + .slice() + .reverse() + .map((p) => `L ${p.x.toFixed(1)} ${p.y.toFixed(1)}`) + .join(' ') + + ' Z'; + + return ( +
+ Damping + + + {/* Guide lines at 25%, 50%, 75% velocity */} + {[0.25, 0.5, 0.75].map((t) => ( + + ))} + + {/* Baseline (velocity = 0) */} + + + {/* Band between min and max curves */} + + + {/* Min curve */} + + + min + + + {/* Max curve */} + + + max + + + {/* Axis labels */} + + 1.0 + + + 0.5 + + + t + + + velocity decay + + + +
+
+ + onChange('linearDampingMin', Number(e.target.value))} + /> +
+
+ + onChange('linearDampingMax', Number(e.target.value))} + /> +
+
+
+ ); +} diff --git a/src/pages/particle-system-playground/components/DirectionSpreadGizmo.tsx b/src/pages/particle-system-playground/components/DirectionSpreadGizmo.tsx new file mode 100644 index 00000000..9be8fc38 --- /dev/null +++ b/src/pages/particle-system-playground/components/DirectionSpreadGizmo.tsx @@ -0,0 +1,237 @@ +import { Label } from '@/components/ui/label'; +import type { ParticleSystemPlaygroundSystem } from '@/types/particle-system-playground'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +const H = 260; +const PAD = 24; +const TAU = Math.PI * 2; + +type Props = { + system: ParticleSystemPlaygroundSystem; + onChange: (key: string, value: string | number | boolean) => void; +}; + +type DragTarget = 'direction' | 'spread-start' | 'spread-end'; + +function clampSpread(value: number): number { + return Math.max(0, Math.min(TAU, value)); +} + +function round(value: number): number { + return Math.round(value * 100) / 100; +} + +function normalizeDelta(value: number): number { + let delta = value; + while (delta > Math.PI) delta -= TAU; + while (delta < -Math.PI) delta += TAU; + return delta; +} + +function polar(cx: number, cy: number, radius: number, angle: number) { + return { + x: cx + Math.cos(angle) * radius, + y: cy + Math.sin(angle) * radius, + }; +} + +function rayPath(cx: number, cy: number, radius: number, angle: number): string { + const point = polar(cx, cy, radius, angle); + return `M ${cx} ${cy} L ${point.x} ${point.y}`; +} + +function sectorPath(cx: number, cy: number, radius: number, start: number, end: number, spread: number): string { + if (spread >= TAU - 0.001) { + return [ + `M ${cx} ${cy}`, + `m ${-radius} 0`, + `a ${radius} ${radius} 0 1 0 ${radius * 2} 0`, + `a ${radius} ${radius} 0 1 0 ${-radius * 2} 0`, + 'Z', + ].join(' '); + } + + const startPoint = polar(cx, cy, radius, start); + const endPoint = polar(cx, cy, radius, end); + const largeArc = spread > Math.PI ? 1 : 0; + return `M ${cx} ${cy} L ${startPoint.x} ${startPoint.y} A ${radius} ${radius} 0 ${largeArc} 1 ${endPoint.x} ${endPoint.y} Z`; +} + +function angleFromPointer( + event: React.PointerEvent, + svg: SVGSVGElement, + cx: number, + cy: number, +): number { + const rect = svg.getBoundingClientRect(); + return Math.atan2(event.clientY - rect.top - cy, event.clientX - rect.left - cx); +} + +export function DirectionSpreadGizmo({ system, onChange }: Props) { + const direction = system.properties.direction ?? 0; + const spread = clampSpread(system.properties.spread ?? 0); + const svgRef = useRef(null); + const [svgW, setSvgW] = useState(360); + const [dragging, setDragging] = useState(null); + + useEffect(() => { + const el = svgRef.current; + if (!el) return; + setSvgW(el.clientWidth); + const ro = new ResizeObserver(([entry]) => setSvgW(entry.contentRect.width)); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const cx = svgW / 2; + const cy = H / 2; + const radius = Math.max(40, Math.min(svgW - PAD * 2, H - PAD * 2) / 2); + const startAngle = direction - spread / 2; + const endAngle = direction + spread / 2; + const directionPoint = polar(cx, cy, radius, direction); + const startPoint = polar(cx, cy, radius, startAngle); + const endPoint = polar(cx, cy, radius, endAngle); + const stateRef = useRef({ cx, cy }); + stateRef.current = { cx, cy }; + + const updateFromPointer = useCallback( + (event: React.PointerEvent, target: DragTarget) => { + if (!svgRef.current) return; + const { cx, cy } = stateRef.current; + const angle = angleFromPointer(event, svgRef.current, cx, cy); + + if (target === 'direction') { + onChange('direction', round(angle)); + return; + } + + const delta = Math.abs(normalizeDelta(angle - direction)); + onChange('spread', round(clampSpread(delta * 2))); + }, + [direction, onChange], + ); + + const onPointerMove = useCallback( + (event: React.PointerEvent) => { + if (!dragging) return; + updateFromPointer(event, dragging); + }, + [dragging, updateFromPointer], + ); + + const stopDrag = useCallback(() => setDragging(null), []); + + const beginDrag = (event: React.PointerEvent, target: DragTarget) => { + event.currentTarget.setPointerCapture(event.pointerId); + setDragging(target); + }; + + return ( +
+
+ +
+ {round(direction)} rad + {round(spread)} spread +
+
+ + + + + + + 0 + + + +π/2 + + + + + + + + + beginDrag(event, 'direction')} + /> + = cx ? 10 : -10)} + y={directionPoint.y - 5} + fontSize={8} + fill="var(--chart-1)" + fillOpacity={0.8} + textAnchor={directionPoint.x >= cx ? 'start' : 'end'} + > + dir + + beginDrag(event, 'spread-start')} + /> + = cx ? 9 : -9)} + y={startPoint.y - 4} + fontSize={8} + fill="var(--chart-2)" + fillOpacity={0.7} + textAnchor={startPoint.x >= cx ? 'start' : 'end'} + > + start + + beginDrag(event, 'spread-end')} + /> + = cx ? 9 : -9)} + y={endPoint.y - 4} + fontSize={8} + fill="var(--chart-2)" + fillOpacity={0.7} + textAnchor={endPoint.x >= cx ? 'start' : 'end'} + > + end + + +
+ ); +} diff --git a/src/pages/particle-system-playground/components/EmitterList.tsx b/src/pages/particle-system-playground/components/EmitterList.tsx new file mode 100644 index 00000000..55fb1cd4 --- /dev/null +++ b/src/pages/particle-system-playground/components/EmitterList.tsx @@ -0,0 +1,72 @@ +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/utils/styles'; +import type { ParticleSystemPlaygroundSystem } from '@/types/particle-system-playground'; +import { PlusIcon, Trash2Icon } from 'lucide-react'; + +type Props = { + systems: ParticleSystemPlaygroundSystem[]; + activeIndex: number; + isGameComposite: boolean; + onSelect: (index: number) => void; + onAdd: () => void; + onRemove: (index: number) => void; +}; + +export function EmitterList({ systems, activeIndex, isGameComposite, onSelect, onAdd, onRemove }: Props) { + return ( +
+ {systems.map((system) => { + const active = system.index === activeIndex; + return ( +
+ + {!isGameComposite && ( + + )} +
+ ); + })} + {!isGameComposite && ( + + )} +
+ ); +} diff --git a/src/pages/particle-system-playground/components/ExportPanel.tsx b/src/pages/particle-system-playground/components/ExportPanel.tsx new file mode 100644 index 00000000..d65920c3 --- /dev/null +++ b/src/pages/particle-system-playground/components/ExportPanel.tsx @@ -0,0 +1,17 @@ +import { Button } from '@/components/ui/button'; +import { CodeIcon, FileArchiveIcon } from 'lucide-react'; + +export function ExportPanel({ onExportCode, onExportZip }: { onExportCode: () => void; onExportZip: () => void }) { + return ( +
+ + +
+ ); +} diff --git a/src/pages/particle-system-playground/components/LinearAccelPlane.tsx b/src/pages/particle-system-playground/components/LinearAccelPlane.tsx new file mode 100644 index 00000000..18d88486 --- /dev/null +++ b/src/pages/particle-system-playground/components/LinearAccelPlane.tsx @@ -0,0 +1,390 @@ +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; +import type { ParticleSystemPlaygroundSystem } from '@/types/particle-system-playground'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +const H = 280; +const PAD = 28; + +type Props = { + system: ParticleSystemPlaygroundSystem; + onChange: (key: string, value: string | number | boolean) => void; +}; + +type ArtistState = { direction: number; spread: number; speedMin: number; speedMax: number }; + +function rawToArtist(minX: number, minY: number, maxX: number, maxY: number): ArtistState { + const minAngle = Math.atan2(minY, minX); + const maxAngle = Math.atan2(maxY, maxX); + let diff = maxAngle - minAngle; + while (diff > Math.PI) diff -= 2 * Math.PI; + while (diff < -Math.PI) diff += 2 * Math.PI; + return { + direction: minAngle + diff / 2, + spread: Math.abs(diff), + speedMin: Math.hypot(minX, minY), + speedMax: Math.hypot(maxX, maxY), + }; +} + +function artistToRaw({ direction, spread, speedMin, speedMax }: ArtistState) { + const a0 = direction - spread / 2; + const a1 = direction + spread / 2; + return { + linearAccelXMin: Math.cos(a0) * speedMin, + linearAccelYMin: Math.sin(a0) * speedMin, + linearAccelXMax: Math.cos(a1) * speedMax, + linearAccelYMax: Math.sin(a1) * speedMax, + }; +} + +function niceInterval(range: number): number { + const raw = range / 3; + const mag = Math.pow(10, Math.floor(Math.log10(raw))); + const norm = raw / mag; + if (norm < 2) return mag; + if (norm < 5) return 2 * mag; + return 5 * mag; +} + +function arrowHead(x: number, y: number, angle: number, size = 6): string { + const a1 = angle + 2.5; + const a2 = angle - 2.5; + return `M ${x} ${y} L ${x + Math.cos(a1) * size} ${y + Math.sin(a1) * size} M ${x} ${y} L ${x + Math.cos(a2) * size} ${y + Math.sin(a2) * size}`; +} + +function clampVector(x: number, y: number, range: number): { x: number; y: number } { + const magnitude = Math.hypot(x, y); + if (magnitude <= range || magnitude === 0) return { x, y }; + const ratio = range / magnitude; + return { x: x * ratio, y: y * ratio }; +} + +export function LinearAccelPlane({ system, onChange }: Props) { + const p = system.properties; + const minX = p.linearAccelXMin ?? 0; + const minY = p.linearAccelYMin ?? 0; + const maxX = p.linearAccelXMax ?? 0; + const maxY = p.linearAccelYMax ?? 0; + + const svgRef = useRef(null); + const [svgW, setSvgW] = useState(300); + + useEffect(() => { + const el = svgRef.current; + if (!el) return; + setSvgW(el.clientWidth); + const ro = new ResizeObserver(([e]) => setSvgW(e.contentRect.width)); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const [inputMode, setInputMode] = useState<'raw' | 'artist'>('raw'); + const [artist, setArtist] = useState(() => rawToArtist(minX, minY, maxX, maxY)); + + const maxVal = Math.max(Math.abs(minX), Math.abs(minY), Math.abs(maxX), Math.abs(maxY), 50); + const [range, setRange] = useState(() => Math.ceil((maxVal * 1.5) / 50) * 50); + const rangeWasEditedRef = useRef(false); + + useEffect(() => { + if (rangeWasEditedRef.current) return; + const nextRange = Math.ceil((maxVal * 1.35) / 50) * 50; + setRange((current) => (nextRange > current ? nextRange : current)); + }, [maxVal]); + + const cx = svgW / 2; + const cy = H / 2; + const innerR = Math.min(svgW - PAD * 2, H - PAD * 2) / 2; + const scale = innerR / range; + + const stateRef = useRef({ cx, cy, scale, range }); + stateRef.current = { cx, cy, scale, range }; + + const [dragging, setDragging] = useState<'min' | 'max' | null>(null); + + const onPointerMove = useCallback( + (e: React.PointerEvent) => { + if (!dragging || !svgRef.current) return; + const { cx, cy, scale, range } = stateRef.current; + const rect = svgRef.current.getBoundingClientRect(); + const pointer = clampVector((e.clientX - rect.left - cx) / scale, (e.clientY - rect.top - cy) / scale, range); + const round = (v: number) => Math.round(v * 10) / 10; + if (dragging === 'min') { + onChange('linearAccelXMin', round(pointer.x)); + onChange('linearAccelYMin', round(pointer.y)); + } else { + onChange('linearAccelXMax', round(pointer.x)); + onChange('linearAccelYMax', round(pointer.y)); + } + }, + [dragging, onChange], + ); + + const stopDrag = useCallback(() => setDragging(null), []); + + const updateRange = useCallback( + (nextRange: number) => { + if (nextRange <= 0) return; + rangeWasEditedRef.current = true; + setRange(nextRange); + const minVector = clampVector(minX, minY, nextRange); + const maxVector = clampVector(maxX, maxY, nextRange); + const nextValues = { + linearAccelXMin: minVector.x, + linearAccelYMin: minVector.y, + linearAccelXMax: maxVector.x, + linearAccelYMax: maxVector.y, + }; + Object.entries(nextValues).forEach(([key, value]) => { + const current = system.properties[key as keyof typeof system.properties] as number; + if (current !== value) onChange(key, value); + }); + }, + [maxX, maxY, minX, minY, onChange, system.properties], + ); + + const toSvg = (px: number, py: number) => { + const point = clampVector(px, py, range); + return { x: cx + point.x * scale, y: cy + point.y * scale }; + }; + + const minPt = toSvg(minX, minY); + const maxPt = toSvg(maxX, maxY); + const origin = toSvg(0, 0); + + const interval = niceInterval(range); + const gridVals: number[] = []; + for (let v = interval; v < range; v += interval) { + gridVals.push(v); + gridVals.push(-v); + } + + const minAngle = Math.atan2(minY, minX); + const maxAngle = Math.atan2(maxY, maxX); + + return ( +
+
+ + Linear Acceleration + +
+ Range ± + updateRange(parseFloat(e.target.value))} + /> +
+
+ + + {/* Grid lines */} + {gridVals.map((v) => ( + + + + + {v} + + + ))} + + {/* Axes */} + + + + X + + + Y + + + LÖVE +Y + + + {/* Min vector */} + + + + {/* Max vector */} + + + + {/* Origin */} + + + {/* Handles */} + { + rangeWasEditedRef.current = true; + e.currentTarget.setPointerCapture(e.pointerId); + setDragging('min'); + }} + /> + = cx ? 9 : -9)} + y={minPt.y - 6} + fontSize={8} + fill="var(--chart-1)" + fillOpacity={0.8} + textAnchor={minPt.x >= cx ? 'start' : 'end'} + > + min + + { + rangeWasEditedRef.current = true; + e.currentTarget.setPointerCapture(e.pointerId); + setDragging('max'); + }} + /> + = cx ? 9 : -9)} + y={maxPt.y - 6} + fontSize={8} + fill="var(--chart-2)" + fillOpacity={0.8} + textAnchor={maxPt.x >= cx ? 'start' : 'end'} + > + max + + + {/* Legend */} + + + min + + + + max + + + + {/* Input mode tabs */} + { + if (!v) return; + if (v === 'artist') setArtist(rawToArtist(minX, minY, maxX, maxY)); + setInputMode(v as 'raw' | 'artist'); + }} + className="h-6 w-fit" + > + + Raw + + + Artist + + + + {inputMode === 'raw' ? ( +
+ {[ + { label: 'Min X', key: 'linearAccelXMin', val: minX }, + { label: 'Min Y', key: 'linearAccelYMin', val: minY }, + { label: 'Max X', key: 'linearAccelXMax', val: maxX }, + { label: 'Max Y', key: 'linearAccelYMax', val: maxY }, + ].map(({ label, key, val }) => ( +
+ + onChange(key, Number(e.target.value))} + /> +
+ ))} +
+ ) : ( +
+ {( + [ + { label: 'Direction (rad)', key: 'direction', step: 0.01 }, + { label: 'Spread (rad)', key: 'spread', step: 0.01, min: 0 }, + { label: 'Speed Min', key: 'speedMin', step: 1, min: 0 }, + { label: 'Speed Max', key: 'speedMax', step: 1, min: 0 }, + ] as { label: string; key: keyof ArtistState; step: number; min?: number }[] + ).map(({ label, key, step, min }) => ( +
+ + { + const nextValue = Number(e.target.value); + const updated = { + ...artist, + [key]: key === 'speedMin' || key === 'speedMax' ? Math.min(nextValue, range) : nextValue, + }; + setArtist(updated); + const raw = artistToRaw(updated); + Object.entries(raw).forEach(([k, v]) => onChange(k, Math.round(v * 10) / 10)); + }} + /> +
+ ))} +
+ )} +
+ ); +} diff --git a/src/pages/particle-system-playground/components/MotionPresets.tsx b/src/pages/particle-system-playground/components/MotionPresets.tsx new file mode 100644 index 00000000..467fecf9 --- /dev/null +++ b/src/pages/particle-system-playground/components/MotionPresets.tsx @@ -0,0 +1,258 @@ +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Separator } from '@/components/ui/separator'; +import type { ParticleSystemPlaygroundSystem } from '@/types/particle-system-playground'; +import { Trash2Icon } from 'lucide-react'; +import { useRef, useState } from 'react'; +import { + NAMED_PRESETS, + type MotionPreset, + applyValues, + extractValues, + loadCustomPresets, + mixValues, + randomizeValues, + saveCustomPresets, +} from '../presets'; + +type Props = { + system: ParticleSystemPlaygroundSystem; + onChange: (key: string, value: string | number | boolean) => void; +}; + +export function MotionPresets({ system, onChange }: Props) { + const [open, setOpen] = useState(true); + const [mixA, setMixA] = useState(NAMED_PRESETS[0].name); + const [mixB, setMixB] = useState(NAMED_PRESETS[1].name); + const [customPresets, setCustomPresets] = useState(loadCustomPresets); + const [saveName, setSaveName] = useState(''); + const [renamingIndex, setRenamingIndex] = useState(null); + const [renameValue, setRenameValue] = useState(''); + const importRef = useRef(null); + + const allPresets = [...NAMED_PRESETS, ...customPresets]; + + function apply(preset: MotionPreset) { + applyValues(preset.values, onChange); + } + + function handleMix() { + const a = allPresets.find((p) => p.name === mixA); + const b = allPresets.find((p) => p.name === mixB); + if (a && b) applyValues(mixValues(a.values, b.values), onChange); + } + + function handleRandomize() { + applyValues(randomizeValues(extractValues(system)), onChange); + } + + function updateCustom(presets: MotionPreset[]) { + setCustomPresets(presets); + saveCustomPresets(presets); + } + + function handleSave() { + const name = saveName.trim() || `Custom ${customPresets.length + 1}`; + updateCustom([...customPresets, { name, values: extractValues(system) }]); + setSaveName(''); + } + + function handleDelete(i: number) { + updateCustom(customPresets.filter((_, idx) => idx !== i)); + } + + function commitRename(i: number) { + const name = renameValue.trim(); + if (name) { + updateCustom(customPresets.map((p, idx) => (idx === i ? { ...p, name } : p))); + } + setRenamingIndex(null); + } + + function handleExport() { + const blob = new Blob([JSON.stringify(customPresets, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'particle-system-playground-presets.json'; + a.click(); + URL.revokeObjectURL(url); + } + + function handleImport(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + file.text().then((text) => { + try { + const imported = JSON.parse(text) as MotionPreset[]; + if (Array.isArray(imported)) updateCustom([...customPresets, ...imported]); + } catch { + return; + } + }); + e.target.value = ''; + } + + return ( +
+
+ + +
+ + {open && ( +
+
+ {NAMED_PRESETS.map((preset) => ( + + ))} +
+ + + + + +
+ +
+ + + + + +
+
+ + + +
+ + +
+ setSaveName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSave()} + /> + +
+ + {customPresets.length > 0 && ( +
+ {customPresets.map((preset, i) => ( +
+ {renamingIndex === i ? ( + <> + setRenameValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') commitRename(i); + if (e.key === 'Escape') setRenamingIndex(null); + }} + onBlur={() => commitRename(i)} + /> + + ) : ( + <> + + + + )} +
+ ))} +
+ )} + +
+ + + +
+
+
+ )} +
+ ); +} diff --git a/src/pages/particle-system-playground/components/MovementPatternEditor.tsx b/src/pages/particle-system-playground/components/MovementPatternEditor.tsx new file mode 100644 index 00000000..2a7e887f --- /dev/null +++ b/src/pages/particle-system-playground/components/MovementPatternEditor.tsx @@ -0,0 +1,62 @@ +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import type { ParticleSystemPlaygroundMovement, MovementPattern } from '@/types/particle-system-playground'; + +type Props = { + movement: ParticleSystemPlaygroundMovement; + disabled?: boolean; + onChange: (key: string, value: string | number) => void; +}; + +function NumberField({ label, value, onChange }: { label: string; value?: number; onChange: (value: number) => void }) { + return ( +
+ + onChange(Number(event.target.value))} /> +
+ ); +} + +export function MovementPatternEditor({ movement, disabled, onChange }: Props) { + const pattern = movement.pattern ?? 'none'; + + return ( +
+ + {pattern === 'circle' && ( +
+ onChange('movement.radius', value)} /> + onChange('movement.speed', value)} /> +
+ )} + {pattern === 'figure-eight' && ( +
+ onChange('movement.radiusX', value)} /> + onChange('movement.radiusY', value)} /> + onChange('movement.speed', value)} /> +
+ )} + {pattern === 'irregular' && ( +
+ onChange('movement.scale', value)} /> + onChange('movement.speed', value)} /> +
+ )} +
+ ); +} diff --git a/src/pages/particle-system-playground/components/PropertiesPanel.tsx b/src/pages/particle-system-playground/components/PropertiesPanel.tsx new file mode 100644 index 00000000..27683b0b --- /dev/null +++ b/src/pages/particle-system-playground/components/PropertiesPanel.tsx @@ -0,0 +1,263 @@ +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Separator } from '@/components/ui/separator'; +import { + BLEND_MODES, + EMISSION_AREA_DISTRIBUTIONS, + type ParticleSystemPlaygroundSystem, +} from '@/types/particle-system-playground'; +import { AccelerationEditor } from './AccelerationEditor'; +import { ColorGradientEditor } from './ColorGradientEditor'; +import { DirectionSpreadGizmo } from './DirectionSpreadGizmo'; +import { MotionPresets } from './MotionPresets'; +import { RangePairField } from './RangePairField'; +import { RotationSpinGizmo } from './RotationSpinGizmo'; +import { SizeCurveEditor } from './SizeCurveEditor'; +import { TextureOffsetGizmo } from './TextureOffsetGizmo'; + +type Props = { + system: ParticleSystemPlaygroundSystem; + onChange: (key: string, value: string | number | boolean) => void; +}; + +type NumberFieldDef = { + type?: 'number'; + key: string; + label: string; + min?: number; + max?: number; + step?: number; +}; + +type RangeFieldDef = { + type: 'range'; + minKey: string; + maxKey: string; + label: string; + step?: number; +}; + +type FieldDef = NumberFieldDef | RangeFieldDef; + +const groups: Array<{ title: string; fields: FieldDef[] }> = [ + { + title: 'Emission', + fields: [ + { key: 'emissionRate', label: 'Rate', min: 0, step: 1 }, + { key: 'emitterLifetime', label: 'Emitter Lifetime', min: -1, step: 0.1 }, + { + type: 'range', + minKey: 'particleLifetimeMin', + maxKey: 'particleLifetimeMax', + label: 'Particle Life', + step: 0.01, + }, + { key: 'emitAtStart', label: 'Emit At Start', min: 0, step: 1 }, + { key: 'kickStartSteps', label: 'Kick Steps', min: 0, step: 1 }, + { key: 'kickStartDt', label: 'Kick Dt', min: 0, step: 0.001 }, + ], + }, + { + title: 'Direction And Speed', + fields: [ + { key: 'direction', label: 'Direction', step: 0.01 }, + { key: 'spread', label: 'Spread', min: 0, step: 0.01 }, + { type: 'range', minKey: 'speedMin', maxKey: 'speedMax', label: 'Speed', step: 1 }, + ], + }, + { + title: 'Shape', + fields: [ + { key: 'sizeVariation', label: 'Size Variation', min: 0, max: 1, step: 0.01 }, + { key: 'offsetX', label: 'Texture Offset X', step: 1 }, + { key: 'offsetY', label: 'Texture Offset Y', step: 1 }, + ], + }, + { + title: 'Rotation', + fields: [ + { type: 'range', minKey: 'rotationMin', maxKey: 'rotationMax', label: 'Rotation', step: 0.01 }, + { type: 'range', minKey: 'spinMin', maxKey: 'spinMax', label: 'Spin', step: 0.01 }, + { key: 'spinVariation', label: 'Spin Variation', min: 0, max: 1, step: 0.01 }, + ], + }, + { + title: 'Emitter Offset', + fields: [ + { key: 'emitterOffsetX', label: 'Emitter X', step: 1 }, + { key: 'emitterOffsetY', label: 'Emitter Y', step: 1 }, + ], + }, +]; + +function valueFor(system: ParticleSystemPlaygroundSystem, key: string): string | number | boolean { + if (key === 'emitterOffsetX') return system.x; + if (key === 'emitterOffsetY') return system.y; + if (key === 'kickStartSteps') return system.kickStartSteps; + if (key === 'kickStartDt') return system.kickStartDt; + if (key === 'emitAtStart') return system.emitAtStart; + return system.properties[key as keyof typeof system.properties] ?? ''; +} + +function NumberField({ + field, + system, + onChange, +}: { + field: NumberFieldDef; + system: ParticleSystemPlaygroundSystem; + onChange: Props['onChange']; +}) { + return ( +
+ + onChange(field.key, Number(event.target.value))} + /> +
+ ); +} + +export function PropertiesPanel({ system, onChange }: Props) { + return ( +
+
+
+
+ + onChange('title', event.target.value)} + /> +
+
+ + +
+
+ onChange('relativeRotation', checked === true)} + /> + +
+
+
+ + onChange('sizes', v)} + /> +
+
+ + onChange('colors', value)} + /> +
+
+ + + + {groups.map((group) => ( +
+
+

{group.title}

+ +
+ {group.title === 'Direction And Speed' && } + {group.title === 'Shape' && } + {group.title === 'Rotation' && } +
+ {group.fields.map((field) => + field.type === 'range' ? ( + + ) : ( + + ), + )} +
+
+ ))} + + + +
+
+

Emission Area

+ +
+
+
+ + +
+ {[ + { key: 'emissionAreaDx', label: 'Width' }, + { key: 'emissionAreaDy', label: 'Height' }, + { key: 'emissionAreaAngle', label: 'Angle' }, + ].map((field) => ( + + ))} +
+ onChange('emissionAreaRelative', checked === true)} + /> + +
+
+
+
+ ); +} diff --git a/src/pages/particle-system-playground/components/RangePairField.tsx b/src/pages/particle-system-playground/components/RangePairField.tsx new file mode 100644 index 00000000..a958a60b --- /dev/null +++ b/src/pages/particle-system-playground/components/RangePairField.tsx @@ -0,0 +1,47 @@ +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import type { ParticleSystemPlaygroundSystem } from '@/types/particle-system-playground'; + +type Props = { + label: string; + minKey: string; + maxKey: string; + system: ParticleSystemPlaygroundSystem; + onChange: (key: string, value: string | number | boolean) => void; + step?: number; +}; + +function getValue(system: ParticleSystemPlaygroundSystem, key: string): number { + return (system.properties[key as keyof typeof system.properties] as number) ?? 0; +} + +export function RangePairField({ label, minKey, maxKey, system, onChange, step = 1 }: Props) { + const minVal = getValue(system, minKey); + const maxVal = getValue(system, maxKey); + + return ( +
+ +
+ Min + onChange(minKey, Number(e.target.value))} + /> + Max + onChange(maxKey, Number(e.target.value))} + /> +
+
+ ); +} diff --git a/src/pages/particle-system-playground/components/RotationSpinGizmo.tsx b/src/pages/particle-system-playground/components/RotationSpinGizmo.tsx new file mode 100644 index 00000000..cce0226f --- /dev/null +++ b/src/pages/particle-system-playground/components/RotationSpinGizmo.tsx @@ -0,0 +1,311 @@ +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import type { ParticleSystemPlaygroundSystem } from '@/types/particle-system-playground'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +const H = 260; +const PAD = 24; +const TAU = Math.PI * 2; + +type Props = { + system: ParticleSystemPlaygroundSystem; + onChange: (key: string, value: string | number | boolean) => void; +}; + +type DragTarget = 'rotationMin' | 'rotationMax' | 'spinMin' | 'spinMax'; + +function clamp(value: number, range: number): number { + return Math.max(-range, Math.min(range, value)); +} + +function round(value: number): number { + return Math.round(value * 100) / 100; +} + +function polar(cx: number, cy: number, radius: number, angle: number) { + return { + x: cx + Math.cos(angle) * radius, + y: cy + Math.sin(angle) * radius, + }; +} + +function angleFromPointer( + event: React.PointerEvent, + svg: SVGSVGElement, + cx: number, + cy: number, +): number { + const rect = svg.getBoundingClientRect(); + return Math.atan2(event.clientY - rect.top - cy, event.clientX - rect.left - cx); +} + +function arcPath(cx: number, cy: number, radius: number, start: number, end: number) { + const startPoint = polar(cx, cy, radius, start); + const endPoint = polar(cx, cy, radius, end); + let span = end - start; + while (span < 0) span += TAU; + const largeArc = span > Math.PI ? 1 : 0; + return `M ${startPoint.x} ${startPoint.y} A ${radius} ${radius} 0 ${largeArc} 1 ${endPoint.x} ${endPoint.y}`; +} + +export function RotationSpinGizmo({ system, onChange }: Props) { + const p = system.properties; + const rotationMin = p.rotationMin ?? 0; + const rotationMax = p.rotationMax ?? 0; + const spinMin = p.spinMin ?? 0; + const spinMax = p.spinMax ?? 0; + const spinVariation = p.spinVariation ?? 0; + const svgRef = useRef(null); + const [svgW, setSvgW] = useState(360); + const [dragging, setDragging] = useState(null); + const maxAbsSpin = Math.max(Math.abs(spinMin), Math.abs(spinMax), 1); + const [spinRange, setSpinRange] = useState(() => Math.ceil((maxAbsSpin * 1.35) / 5) * 5); + const spinRangeWasEditedRef = useRef(false); + + useEffect(() => { + const el = svgRef.current; + if (!el) return; + setSvgW(el.clientWidth); + const ro = new ResizeObserver(([entry]) => setSvgW(entry.contentRect.width)); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + useEffect(() => { + if (spinRangeWasEditedRef.current) return; + const nextRange = Math.ceil((maxAbsSpin * 1.35) / 5) * 5; + setSpinRange((current) => (nextRange > current ? nextRange : current)); + }, [maxAbsSpin]); + + const cx = svgW / 2; + const cy = 96; + const radius = Math.max(54, Math.min(svgW - PAD * 2, 150) / 2); + const minPoint = polar(cx, cy, radius, rotationMin); + const maxPoint = polar(cx, cy, radius, rotationMax); + const avgRotation = (rotationMin + rotationMax) / 2; + const avgPoint = polar(cx, cy, radius * 0.72, avgRotation); + const trackY = H - 42; + const trackX1 = PAD; + const trackX2 = svgW - PAD; + const trackW = Math.max(1, trackX2 - trackX1); + const spinToX = (value: number) => trackX1 + ((clamp(value, spinRange) + spinRange) / (spinRange * 2)) * trackW; + const stateRef = useRef({ cx, cy, spinRange, trackX1, trackW }); + stateRef.current = { cx, cy, spinRange, trackX1, trackW }; + + const updateSpinRange = useCallback( + (nextRange: number) => { + if (nextRange <= 0) return; + spinRangeWasEditedRef.current = true; + setSpinRange(nextRange); + const values = { + spinMin: clamp(spinMin, nextRange), + spinMax: clamp(spinMax, nextRange), + }; + Object.entries(values).forEach(([key, value]) => { + const current = system.properties[key as keyof typeof system.properties] as number; + if (current !== value) onChange(key, value); + }); + }, + [onChange, spinMax, spinMin, system.properties], + ); + + const onPointerMove = useCallback( + (event: React.PointerEvent) => { + if (!dragging || !svgRef.current) return; + const state = stateRef.current; + const rect = svgRef.current.getBoundingClientRect(); + + if (dragging === 'rotationMin' || dragging === 'rotationMax') { + const angle = angleFromPointer(event, svgRef.current, state.cx, state.cy); + onChange(dragging, round(angle)); + return; + } + + const t = (event.clientX - rect.left - state.trackX1) / state.trackW; + const value = clamp((t * 2 - 1) * state.spinRange, state.spinRange); + onChange(dragging, round(value)); + }, + [dragging, onChange], + ); + + const beginDrag = (event: React.PointerEvent, target: DragTarget) => { + if (target === 'spinMin' || target === 'spinMax') spinRangeWasEditedRef.current = true; + event.currentTarget.setPointerCapture(event.pointerId); + setDragging(target); + }; + + return ( +
+
+ +
+ Spin ± + updateSpinRange(Number(event.target.value))} + /> +
+
+ + setDragging(null)} + onPointerLeave={() => setDragging(null)} + > + + + + + + + + beginDrag(event, 'rotationMin')} + /> + = cx ? 9 : -9)} + y={minPoint.y - 4} + fontSize={8} + fill="var(--chart-2)" + fillOpacity={0.75} + textAnchor={minPoint.x >= cx ? 'start' : 'end'} + > + min + + beginDrag(event, 'rotationMax')} + /> + = cx ? 9 : -9)} + y={maxPoint.y - 4} + fontSize={8} + fill="var(--chart-2)" + fillOpacity={0.75} + textAnchor={maxPoint.x >= cx ? 'start' : 'end'} + > + max + + + + + + CCW + + + 0 + + + CW + + beginDrag(event, 'spinMin')} + /> + + min + + beginDrag(event, 'spinMax')} + /> + + max + + +
+ ); +} diff --git a/src/pages/particle-system-playground/components/ShaderCodeInput.tsx b/src/pages/particle-system-playground/components/ShaderCodeInput.tsx new file mode 100644 index 00000000..0f6699df --- /dev/null +++ b/src/pages/particle-system-playground/components/ShaderCodeInput.tsx @@ -0,0 +1,115 @@ +import { useEffect, useRef } from 'react'; +import SyntaxHighlighter from 'react-syntax-highlighter'; +import { useTheme } from '@/hooks/use-theme'; +import oneLight from '@/assets/theme/light'; +import onDark from '@/assets/theme/dark'; +import { cn } from '@/utils/styles'; + +type Props = { + value: string; + onChange: (value: string) => void; + placeholder?: string; + className?: string; +}; + +const codeStyle: React.CSSProperties = { + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', + fontSize: '0.75rem', + lineHeight: '1.5', + padding: '8px 12px 8px 52px', + tabSize: 2, +}; + +export function ShaderCodeInput({ value, onChange, placeholder, className }: Props) { + const textareaRef = useRef(null); + const backdropRef = useRef(null); + const theme = useTheme(); + const highlightTheme = theme === 'dark' ? onDark : oneLight; + const lineCount = Math.max(1, value.split('\n').length); + + const syncScroll = () => { + if (!textareaRef.current || !backdropRef.current) return; + backdropRef.current.scrollTop = textareaRef.current.scrollTop; + backdropRef.current.scrollLeft = textareaRef.current.scrollLeft; + }; + + useEffect(() => { + syncScroll(); + }, [value]); + + return ( +
+
+
+ {Array.from({ length: lineCount }, (_, index) => ( + {index + 1} + ))} +
+ ).hljs, + background: 'transparent', + padding: 0, + }, + }} + customStyle={{ + ...codeStyle, + minHeight: '12rem', + background: 'transparent', + margin: 0, + whiteSpace: 'pre', + overflow: 'visible', + }} + showLineNumbers={false} + > + {value + ' '} + +
+