diff --git a/.github/workflows/cli-e2e.yml b/.github/workflows/cli-e2e.yml index 62ff6db1..d9a84aec 100644 --- a/.github/workflows/cli-e2e.yml +++ b/.github/workflows/cli-e2e.yml @@ -51,9 +51,15 @@ jobs: sudo apt-get update sudo apt-get install -y love squashfs-tools - - name: Install squashfs-tools (macOS) + - name: Install LÖVE and squashfs-tools (macOS) if: runner.os == 'macOS' - run: brew install squashfs + run: | + curl -L https://github.com/love2d/love/releases/download/11.5/love-11.5-macos.zip -o love-macos.zip + ditto -x -k love-macos.zip love-macos + sudo cp -R love-macos/love.app /Applications/love.app + sudo ln -sf /Applications/love.app/Contents/MacOS/love /usr/local/bin/love + love --version + brew install squashfs - name: Install dependencies run: npm ci diff --git a/.gitignore b/.gitignore index c655af86..d8351015 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ lerna-debug.log* node_modules dist +dist-showcase +.showcase-vendor dist-ssr *.local diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 00000000..4d5a02ac --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,12 @@ +#!/usr/bin/env sh +set -eu + +if [ "${SKIP_PRE_PUSH_TESTS:-}" = "1" ]; then + echo "[feather] SKIP_PRE_PUSH_TESTS=1 set; skipping pre-push tests." + exit 0 +fi + +npm run test:cli:e2e +npm run test:lua:e2e +npm run test:app:e2e +npm run test:showcase:e2e diff --git a/CHANGELOG.md b/CHANGELOG.md index da4fd9bf..d2e537e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - CLI Fastlane draft support - Lua plugin unit tests - Lua callback bus with priority overrides +- Standalone Showcase build for trying Shader Graph and Particle System Playground in the browser without connecting a game. +- Static love.js preview bridge for standalone shader and particle previews, including showcase build/preview scripts and a publishing workflow. +- Shader Graph custom function, texture input, texture uniform color, texture upload, and preview texture support. +- Expanded Shader Graph node library with complex math, quaternion operations, symmetry, random, pixel-perfect primitives, patterns, halftone, Lab color helpers, composite, Truchet tiles, improved SDF nodes, and broader vertex shader authoring. +- Added Shader Graph vector math nodes for component-wise `vec2`/`vec3`/`vec4` add, subtract, multiply, divide, and scalar scaling. +- Added Shader Graph right-click node insertion, preset insertion, dirty preset replacement confirmation, graph-local subgraph instances, and deterministic link suggestions. +- Shader Graph preset and UI improvements for graph authoring, node inspection, code preview, and standalone preview controls. +- Session Replay checkpoints, seeking, baseline restore, sparse state deltas, and Lua E2E coverage for replay flows. +- Pre-push hook that runs CLI E2E, Lua E2E, app Playwright E2E, and showcase Playwright E2E before pushing. ### Changed @@ -29,6 +38,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved CLI Linux vendoring - Updated roadmap - Shim hooks on load +- Improved standalone Shader Graph and Particle System Playground ergonomics, including local in-browser state for showcase mode. +- Improved Shader Graph WebGL preview adaptation for LÖVE shader syntax and precision-qualified uniforms. +- Improved Particle System Playground preview payload handling and emitter list behavior. +- Improved Lua E2E reliability in headless CI by disabling audio for E2E runs and making the Lua E2E timeout configurable. +- Improved audio-debug behavior when `love.audio` is unavailable, allowing headless smoke tests to run. +- Improved doctor reporting for dangerous installed plugins even when bundled catalog data is unavailable or stale. +- Moved standalone showcase deployment toward Railway hosting so love.js previews can be served with COOP/COEP/CSP headers. ### Fixed @@ -36,6 +52,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed optimistic plugin updates - Fixed input replay - Fixed stack overflow on LÖVE hooks +- Fixed Shader Graph `u_time` precision mismatch between vertex and fragment shaders in love.js/WebGL. +- Fixed Shader Graph rounded functions and generated GLSL/WebGL preview handling. +- Fixed standalone showcase Playwright coverage so the showcase test runs only in the dedicated showcase config and uses current preview iframe selectors. +- Fixed CLI Linux AppImage vendor extraction fallback to use `unsquashfs -offset` when direct AppImage execution fails or is non-native. +- Fixed CI-only Lua E2E ALSA/audio startup failures in xvfb environments. +- Fixed doctor E2E fixture setup for dangerous bundled plugin trust checks. + +### Tests + +- Added standalone showcase Playwright smoke test and dedicated showcase Playwright config. +- Added Lua plugin E2E smoke coverage, including session replay flows. +- Added CLI doctor trust coverage for unknown and dangerous installed plugins. +- Added CLI Linux AppImage fallback tests for `unsquashfs` offset detection and actionable extraction errors. +- Added pre-push local verification for CLI, Lua, app, and showcase E2E lanes. ## [v1.2.0] - 2026-05-19 - The one with the shader and particles playground diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ccfa8b30..4914dec6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -183,6 +183,17 @@ Focused guidance: If `love`, `luacheck`, Android SDK, Xcode, Fastlane, or other local tooling is missing, document that in the PR or final handoff. Do not pretend the check passed. +The pre-push hook runs the heavier local test lanes before pushing: + +```sh +npm run test:cli:e2e +npm run test:lua:e2e +npm run test:app:e2e +npm run test:showcase:e2e +``` + +When you need to push from a machine that cannot run those tools, use `SKIP_PRE_PUSH_TESTS=1 git push` and call out the skipped checks. + ## Generated Files Some files are generated and must stay in sync: diff --git a/cli/src/commands/doctor/index.ts b/cli/src/commands/doctor/index.ts index 8b61f31a..451828ef 100644 --- a/cli/src/commands/doctor/index.ts +++ b/cli/src/commands/doctor/index.ts @@ -257,7 +257,8 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) add(checks, 'Project', 'feather.config.lua', 'warn', 'missing', 'Run `feather init` to create a shared config.'); } - const managedMode = configSource ? parseManagedValue(configSource, 'mode') : null; + const configManagedMode = typeof config?.managed === 'string' ? config.managed : null; + const managedMode = (configSource ? parseManagedValue(configSource, 'mode') : null) ?? configManagedMode; const managedInstallDir = configSource ? parseManagedValue(configSource, 'installDir') : null; const effectiveInstallDir = normalizeInstallDir(opts.installDir ?? managedInstallDir ?? installDir); const mode = typeof config?.mode === 'string' ? config.mode : 'socket'; @@ -386,7 +387,7 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) } for (const dir of pluginDirs) { const manifest = readPluginManifest(dir); - if (!manifest?.id || !knownPluginIds.has(manifest.id)) continue; + if (!manifest?.id) continue; const trust = classifyPluginTrust(manifest, pluginCatalogById.get(manifest.id)); if (dangerousPluginIds.has(manifest.id)) { add( @@ -398,6 +399,7 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {}) `Remove before shipping with \`feather plugin remove ${manifest.id} --dir ${projectDirArg} --install-dir ${installDirArg} --yes\`.`, ); } else { + if (!knownPluginIds.has(manifest.id)) continue; add(checks, 'Plugins', `Plugin ${manifest.id} trust`, trust === 'bundled-opt-in' ? 'info' : 'pass', pluginTrustLabel(trust)); } } diff --git a/cli/src/lib/build/vendor.ts b/cli/src/lib/build/vendor.ts index ab22c31a..75319234 100644 --- a/cli/src/lib/build/vendor.ts +++ b/cli/src/lib/build/vendor.ts @@ -445,18 +445,27 @@ async function installDesktopRuntime(target: 'windows' | 'macos' | 'linux' | 'st const result = spawnSync(loveAppImage, ['--appimage-extract'], { cwd: targetPath, encoding: 'utf8' }); if (!result.error) { if (result.status === 0 && existsSync(join(squashfsRoot, 'bin', 'love'))) return; - throw new Error((result.stderr || result.stdout || 'Failed to extract LÖVE AppImage.').trim()); + const offset = findSquashFsOffset(loveAppImage); + if (offset < 0) { + throw new Error((result.stderr || result.stdout || 'Failed to extract LÖVE AppImage.').trim()); + } + tryExtractAppImageWithUnsquashfs(loveAppImage, squashfsRoot, targetPath, offset); + return; } const code = (result.error as NodeJS.ErrnoException).code; - if (code !== 'ENOEXEC' && code !== 'ENOENT' && code !== 'EACCES') { + const offset = findSquashFsOffset(loveAppImage); + if (code !== 'ENOEXEC' && code !== 'ENOENT' && code !== 'EACCES' && offset < 0) { throw new Error(`Failed to extract LÖVE AppImage: ${result.error.message}`); } // Not on Linux — try unsquashfs with explicit SquashFS offset (squashfs-tools >= 4.4). // The macOS port doesn't auto-detect the offset, so we scan for the magic bytes ourselves. + tryExtractAppImageWithUnsquashfs(loveAppImage, squashfsRoot, targetPath, offset); +} + +function tryExtractAppImageWithUnsquashfs(loveAppImage: string, squashfsRoot: string, targetPath: string, offset: number): void { if (existsSync(squashfsRoot)) rmSync(squashfsRoot, { recursive: true }); - const offset = findSquashFsOffset(loveAppImage); const unsquashArgs = offset >= 0 ? ['-offset', String(offset), '-d', squashfsRoot, loveAppImage] : ['-d', squashfsRoot, loveAppImage]; diff --git a/cli/test/commands/doctor.test.mjs b/cli/test/commands/doctor.test.mjs index 43e250e2..86e45221 100644 --- a/cli/test/commands/doctor.test.mjs +++ b/cli/test/commands/doctor.test.mjs @@ -46,8 +46,7 @@ test('doctor --json reports dangerous bundled plugin trust', () => { writeGame(dir); writeMinimalRuntime(dir); writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890" }\n'); - const result = run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]); - assert.equal(result.exitCode, 0, outputOf(result)); + writeLocalPluginSource(join(dir, 'feather'), 'console', { name: 'Console' }); const parsed = parseDoctorJson(dir); const labels = new Map(parsed.checks.map((check) => [check.label, check])); @@ -105,6 +104,28 @@ return { assert.equal(labels.has('Plugin shader-graph'), false); }); +test('doctor --json treats managed = cli as bundled without legacy metadata comments', () => { + const dir = makeTmp(); + writeGame(dir); + writeFileSync( + join(dir, 'feather.config.lua'), + `return { + appId = "feather-app-test-1234567890", + managed = "cli", + include = { "console", "shader-graph" }, +} +`, + ); + + const parsed = parseDoctorJson(dir); + const labels = new Map(parsed.checks.map((check) => [check.label, check])); + assert.equal(labels.get('Embedded Feather runtime').severity, 'info'); + assert.equal(labels.get('CLI-managed plugins').severity, 'pass'); + assert.equal(labels.get('CLI-managed plugins').detail, '2 included from bundled runtime'); + assert.equal(labels.has('Plugin console'), false); + assert.equal(labels.has('Plugin shader-graph'), false); +}); + test('doctor --json reports malformed plugin manifests with recovery text', () => { const dir = makeTmp(); writeGame(dir); diff --git a/docs/standalone-showcase.md b/docs/standalone-showcase.md new file mode 100644 index 00000000..996344ba --- /dev/null +++ b/docs/standalone-showcase.md @@ -0,0 +1,12 @@ +# Standalone Showcase + +The standalone showcase is a browser-hosted version of Feather's visual authoring +tools. It lets you try the Shader Graph and Particle System Playground without +starting the desktop app or connecting a local LÖVE project. + +[Open the standalone showcase](https://feather-showcase.up.railway.app/) + +Use it to experiment with shader nodes, preview generated GLSL, tune particle +emitters, and see how the tools feel before wiring them into a running game. The +showcase runs separately from the documentation site so its LÖVE preview can use +the browser isolation headers required by love.js. diff --git a/e2e/showcase.spec.ts b/e2e/showcase.spec.ts new file mode 100644 index 00000000..cbf8cfce --- /dev/null +++ b/e2e/showcase.spec.ts @@ -0,0 +1,56 @@ +import { expect, test } from '@playwright/test'; + +test('standalone showcase loads the landing page and tools', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('heading', { name: /browser-native authoring tools/i })).toBeVisible(); + + await page.locator('header').getByRole('button', { name: /^shader graph$/i }).click(); + await expect(page.getByRole('heading', { name: 'Shader Graph' })).toBeVisible(); + await expect(page.getByText('GLSL Output')).toBeVisible(); + await expect(page.frameLocator('iframe[title="Shader Preview"]').locator('canvas')).toBeVisible(); + + const nodesBeforeInsert = await page.locator('.react-flow__node').count(); + await page.getByTestId('shader-canvas').click({ button: 'right', position: { x: 300, y: 220 } }); + await expect(page.getByTestId('shader-node-picker').getByPlaceholder('Search nodes')).toBeVisible(); + await page.getByTestId('shader-node-picker').getByPlaceholder('Search nodes').fill('time'); + await page.getByTestId('shader-node-picker').getByRole('button', { name: /^time input$/i }).click(); + await expect(page.locator('.react-flow__node')).toHaveCount(nodesBeforeInsert + 1); + await page.getByTestId('shader-canvas').click({ button: 'right', position: { x: 420, y: 220 } }); + await page.getByTestId('shader-node-picker').getByPlaceholder('Search nodes').fill('float'); + await page.getByTestId('shader-node-picker').getByRole('button', { name: /^float input$/i }).click(); + await expect(page.locator('.react-flow__node')).toHaveCount(nodesBeforeInsert + 2); + await page.getByTestId('shader-canvas').click({ button: 'right', position: { x: 260, y: 320 } }); + await page.getByTestId('shader-node-picker').getByPlaceholder('Search nodes').fill('float parameter'); + await page.getByTestId('shader-node-picker').getByRole('button', { name: /^float parameter input$/i }).click(); + await expect(page.locator('.react-flow__node')).toHaveCount(nodesBeforeInsert + 3); + await expect(page.getByText(/extern number u_param_/i)).toBeVisible(); + await page.getByTestId('shader-canvas').click({ button: 'right', position: { x: 380, y: 320 } }); + await page.getByTestId('shader-node-picker').getByPlaceholder('Search nodes').fill('color parameter'); + await page.getByTestId('shader-node-picker').getByRole('button', { name: /^color parameter input$/i }).click(); + await expect(page.locator('.react-flow__node')).toHaveCount(nodesBeforeInsert + 4); + await expect(page.getByText(/extern vec4 u_param_/i)).toBeVisible(); + await page.keyboard.press('Control+C'); + await page.keyboard.press('Control+V'); + await expect(page.locator('.react-flow__node')).toHaveCount(nodesBeforeInsert + 5); + await expect(page.getByRole('button', { name: /unlink linked node/i })).toHaveCount(1); + await page.keyboard.press('Control+D'); + await expect(page.locator('.react-flow__node')).toHaveCount(nodesBeforeInsert + 6); + await expect(page.getByRole('button', { name: /unlink linked node/i })).toHaveCount(2); + page.once('dialog', async (dialog) => { + expect(dialog.message()).toContain('Unlink this node?'); + await dialog.accept(); + }); + await page.getByRole('button', { name: /unlink linked node/i }).first().click(); + await expect(page.getByRole('button', { name: /unlink linked node/i })).toHaveCount(1); + + const nodesBeforePreset = await page.locator('.react-flow__node').count(); + await page.getByText('Insert preset').click(); + await page.getByRole('option', { name: /texture pass/i }).click(); + await expect.poll(() => page.locator('.react-flow__node').count()).toBeGreaterThan(nodesBeforePreset); + + await page.locator('header').getByRole('button', { name: /particle playground/i }).click(); + await expect(page.getByRole('heading', { name: 'Particles Playground' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Emitter Properties' })).toBeVisible(); + await expect(page.getByText('Rate')).toBeVisible(); + await expect(page.frameLocator('iframe[title="Particle Preview"]').locator('canvas')).toBeVisible(); +}); diff --git a/mkdocs.yml b/mkdocs.yml index 241a102d..c1655d47 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -41,6 +41,7 @@ nav: - Assets: assets.md - Particle System Playground: particle-system-playground.md - Shader Graph: shader-graph.md + - Standalone Showcase: standalone-showcase.md - Time Travel: time-travel.md - Session Replay: session-replay.md - Recommendations: recommendations.md diff --git a/package-lock.json b/package-lock.json index 8e65ea81..d2a239d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -84,7 +84,7 @@ }, "cli": { "name": "@kyonru/feather", - "version": "1.2.0", + "version": "1.3.0", "license": "EPL-2.0", "dependencies": { "chalk": "5.3.0", @@ -13003,7 +13003,7 @@ }, "vscode-extension": { "name": "feather-vscode", - "version": "1.2.0", + "version": "1.3.0", "license": "EPL-2.0", "devDependencies": { "@types/node": "22.15.0", diff --git a/package.json b/package.json index 2bf38c40..282e7469 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "test:cli:e2e": "npm run cli:build && node --test cli/test/commands/*.test.mjs", "test:lua:e2e": "node scripts/lua-e2e.mjs", "test:app:e2e": "playwright test", + "test:showcase:e2e": "playwright test -c playwright.showcase.config.ts", "test:tauri:e2e": "cargo test --manifest-path src-tauri/Cargo.toml ws_server", "extension:prepare": "npm run cli:build && npm run bundle:lua --workspace=cli && node vscode-extension/scripts/prepare.mjs", "extension:build": "npm run extension:prepare && tsc -p vscode-extension/tsconfig.json", @@ -29,6 +30,11 @@ "lua": "sh ./scripts/watch-lua.sh", "web": "vite", "build": "tsc && vite build", + "showcase:dev": "vite --config vite.showcase.config.ts", + "showcase:build": "tsc --noEmit -p tsconfig.json --composite false && vite build --config vite.showcase.config.ts && node scripts/prepare-showcase-lovejs.mjs && node scripts/build-showcase-love.mjs", + "showcase:prepare-lovejs": "node scripts/prepare-showcase-lovejs.mjs", + "showcase:preview": "vite preview --config vite.showcase.config.ts", + "showcase:start": "node scripts/serve-showcase.mjs", "preview": "vite preview", "typecheck:web": "tsc --noEmit -p tsconfig.json --composite false", "typecheck:lua": "luacheck src-lua", diff --git a/playwright.config.ts b/playwright.config.ts index 0455e794..70434348 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,6 +2,7 @@ import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './e2e', + testIgnore: /showcase\.spec\.ts/, timeout: 30_000, expect: { timeout: 5_000, diff --git a/playwright.showcase.config.ts b/playwright.showcase.config.ts new file mode 100644 index 00000000..a0fb49fd --- /dev/null +++ b/playwright.showcase.config.ts @@ -0,0 +1,31 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + testMatch: /showcase\.spec\.ts/, + timeout: 30_000, + expect: { + timeout: 5_000, + }, + fullyParallel: true, + // @ts-expect-error process is only defined in Node.js environment, and this config file is not bundled + reporter: process?.env.CI ? [['github'], ['list']] : 'list', + use: { + baseURL: 'http://127.0.0.1:4174', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + webServer: { + command: 'npm run showcase:build && npm run showcase:preview -- --host 127.0.0.1', + url: 'http://127.0.0.1:4174', + // @ts-expect-error process is only defined in Node.js environment, and this config file is not bundled + reuseExistingServer: !process?.env.CI, + timeout: 120_000, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/public/showcase-lovejs/index.html b/public/showcase-lovejs/index.html new file mode 100644 index 00000000..268c62f5 --- /dev/null +++ b/public/showcase-lovejs/index.html @@ -0,0 +1,12 @@ + + + + + + Feather love.js Preview + + + + + + diff --git a/public/showcase-lovejs/player.js b/public/showcase-lovejs/player.js new file mode 100644 index 00000000..dda9fa5f --- /dev/null +++ b/public/showcase-lovejs/player.js @@ -0,0 +1,116 @@ +(() => { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + document.body.appendChild(canvas); + + let payload = { tool: 'idle' }; + let tick = 0; + + function resize() { + const scale = window.devicePixelRatio || 1; + canvas.width = Math.max(1, Math.floor(window.innerWidth * scale)); + canvas.height = Math.max(1, Math.floor(window.innerHeight * scale)); + canvas.style.width = '100%'; + canvas.style.height = '100%'; + ctx.setTransform(scale, 0, 0, scale, 0, 0); + } + + function drawGrid(width, height) { + ctx.strokeStyle = 'rgba(148, 163, 184, 0.13)'; + ctx.lineWidth = 1; + for (let x = 0; x < width; x += 32) { + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, height); + ctx.stroke(); + } + for (let y = 0; y < height; y += 32) { + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(width, y); + ctx.stroke(); + } + } + + function drawShaderPreview(width, height) { + const size = Math.min(width, height) * 0.44; + const x = width * 0.5; + const y = height * 0.5; + const shape = payload.previewShape || 'circle'; + const color = payload.previewColor || '#38bdf8'; + const pad = size * 0.22; + + ctx.fillStyle = color; + ctx.strokeStyle = color; + + if (shape === 'rectangle') { + ctx.fillRect(x - size / 2 + pad, y - size / 2 + pad, size - pad * 2, size - pad * 2); + } else if (shape === 'line') { + ctx.lineWidth = Math.max(6, size * 0.12); + ctx.beginPath(); + ctx.moveTo(x - size / 2 + pad, y + size / 2 - pad); + ctx.lineTo(x + size / 2 - pad, y - size / 2 + pad); + ctx.stroke(); + } else { + ctx.beginPath(); + ctx.arc(x, y, size * 0.3, 0, Math.PI * 2); + ctx.fill(); + } + + ctx.fillStyle = 'rgba(255,255,255,0.9)'; + ctx.font = '12px ui-monospace, SFMono-Regular, Menlo, monospace'; + ctx.textAlign = 'center'; + ctx.fillText(String(payload.shaderName || 'shader graph'), x, y + size * 0.3 + 28); + } + + function drawParticles(width, height) { + const system = payload.activeSystem || {}; + const rate = Number(system.properties?.emissionRate || 80); + const count = Math.max(24, Math.min(180, Math.floor(rate))); + const cx = width * 0.5 + Number(system.x || 0); + const cy = height * 0.56 + Number(system.y || 0); + const speed = Number(system.properties?.speedMax || 140); + ctx.globalCompositeOperation = system.blendMode === 'add' ? 'lighter' : 'source-over'; + for (let i = 0; i < count; i += 1) { + const p = (i / count + tick * 0.00008 * (speed / 120)) % 1; + const angle = i * 2.399 + tick * 0.001; + const spread = 26 + p * Math.min(220, speed); + const x = cx + Math.cos(angle) * spread * (0.25 + p); + const y = cy - p * 210 + Math.sin(angle * 1.7) * spread * 0.18; + const alpha = Math.max(0, 1 - p); + ctx.fillStyle = `rgba(255, ${Math.floor(140 + 90 * alpha)}, ${Math.floor(40 + 120 * p)}, ${alpha})`; + ctx.beginPath(); + ctx.arc(x, y, 2 + 8 * alpha, 0, Math.PI * 2); + ctx.fill(); + } + ctx.globalCompositeOperation = 'source-over'; + } + + function draw() { + tick += 16; + const width = window.innerWidth; + const height = window.innerHeight; + ctx.clearRect(0, 0, width, height); + ctx.fillStyle = '#05070a'; + ctx.fillRect(0, 0, width, height); + drawGrid(width, height); + + if (payload.tool === 'particle-system-playground') drawParticles(width, height); + else drawShaderPreview(width, height); + + ctx.fillStyle = 'rgba(215, 221, 232, 0.72)'; + ctx.font = '11px Inter, ui-sans-serif, system-ui'; + ctx.textAlign = 'left'; + ctx.fillText('Feather standalone preview bridge', 12, 22); + requestAnimationFrame(draw); + } + + window.addEventListener('resize', resize); + window.addEventListener('message', (event) => { + if (event.data?.source !== 'feather-showcase' || event.data?.type !== 'preview:update') return; + payload = event.data.payload || payload; + }); + + resize(); + draw(); +})(); diff --git a/public/showcase-lovejs/style.css b/public/showcase-lovejs/style.css new file mode 100644 index 00000000..3e72ea99 --- /dev/null +++ b/public/showcase-lovejs/style.css @@ -0,0 +1,18 @@ +html, +body { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; + background: #05070a; + color: #d7dde8; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; +} + +canvas { + display: block; + width: 100%; + height: 100%; +} diff --git a/railway.toml b/railway.toml new file mode 100644 index 00000000..64afcdcc --- /dev/null +++ b/railway.toml @@ -0,0 +1,8 @@ +[build] +builder = "RAILPACK" +buildCommand = "npm run showcase:build" + +[deploy] +startCommand = "npm run showcase:start" +healthcheckPath = "/" +restartPolicyType = "ON_FAILURE" diff --git a/scripts/build-showcase-love.mjs b/scripts/build-showcase-love.mjs new file mode 100644 index 00000000..77c1cb76 --- /dev/null +++ b/scripts/build-showcase-love.mjs @@ -0,0 +1,40 @@ +import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { zipSync, strToU8 } from 'fflate'; + +const root = process.cwd(); +const sourceDir = path.join(root, 'src-lua/example/showcase_preview'); +const outDir = path.join(root, 'dist-showcase/showcase-lovejs'); + +const files = { + 'main.lua': await readFile(path.join(sourceDir, 'main.lua'), 'utf8'), + 'conf.lua': await readFile(path.join(sourceDir, 'conf.lua'), 'utf8'), +}; + +await mkdir(outDir, { recursive: true }); +await copyFile(path.join(root, 'dist-showcase/showcase.html'), path.join(root, 'dist-showcase/index.html')); +await copyFile(path.join(root, 'dist-showcase/showcase.html'), path.join(root, 'dist-showcase/404.html')); +await writeFile( + path.join(outDir, 'showcase.love'), + zipSync( + Object.fromEntries(Object.entries(files).map(([name, content]) => [name, strToU8(content)])), + { level: 9 }, + ), +); +await writeFile( + path.join(outDir, 'README.md'), + [ + '# Feather Showcase Static Build', + '', + 'This branch contains generated static files for the Feather standalone showcase.', + '', + 'The `./showcase-lovejs/` path is the isolated LÖVE preview boundary. When replacing the included bridge with the full 2dengine love.js player, serve this path with:', + '', + '- `Cross-Origin-Opener-Policy: same-origin`', + '- `Cross-Origin-Embedder-Policy: require-corp`', + "- `Content-Security-Policy: script-src 'self' 'unsafe-eval'` or an equivalent policy that allows love.js WASM startup.", + '', + 'Sources: https://github.com/2dengine/love.js/ and https://2dengine.com/doc/lovejs.html', + '', + ].join('\n'), +); diff --git a/scripts/lua-e2e.mjs b/scripts/lua-e2e.mjs index 6bebcec1..e4d5063c 100644 --- a/scripts/lua-e2e.mjs +++ b/scripts/lua-e2e.mjs @@ -29,6 +29,7 @@ if (!love) { const pluginSelector = process.argv[2]; const args = [gamePath, '--e2e']; +const timeoutMs = Number.parseInt(process.env.LUA_E2E_TIMEOUT_MS ?? '45000', 10); if (pluginSelector) { args.push(`--plugin-e2e=${pluginSelector}`); @@ -52,7 +53,7 @@ const result = spawnSync(command, commandArgs, { FEATHER_GAME_PATH: gamePath, }, encoding: 'utf8', - timeout: 15000, + timeout: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 45000, }); const output = `${result.stdout ?? ''}${result.stderr ?? ''}`; diff --git a/scripts/prepare-showcase-lovejs.mjs b/scripts/prepare-showcase-lovejs.mjs new file mode 100644 index 00000000..e99fd217 --- /dev/null +++ b/scripts/prepare-showcase-lovejs.mjs @@ -0,0 +1,109 @@ +/* eslint-disable no-undef */ +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +const root = process.cwd(); +const outDir = path.join(root, 'dist-showcase/showcase-lovejs'); +const cacheDir = path.join(root, '.showcase-vendor/love.js'); +const repo = 'https://github.com/2dengine/love.js'; + +const envVendor = process.env.SHOWCASE_LOVEJS_DIR ? path.resolve(root, process.env.SHOWCASE_LOVEJS_DIR) : undefined; + +const candidates = [envVendor, path.join(root, 'vendor/love.js'), cacheDir].filter(Boolean); + +let vendorDir = candidates.find((candidate) => isLoveJsVendor(candidate)); + +if (!vendorDir && process.env.SHOWCASE_LOVEJS_SKIP_FETCH !== '1') { + await mkdir(path.dirname(cacheDir), { recursive: true }); + if (!existsSync(cacheDir)) { + const result = spawnSync('git', ['clone', '--depth', '1', repo, cacheDir], { + cwd: root, + stdio: 'pipe', + encoding: 'utf8', + }); + if (result.status !== 0) { + const detail = result.stderr.trim() || result.stdout.trim() || 'git clone failed'; + console.warn(`[showcase] Could not fetch love.js automatically: ${detail}`); + } + } + if (isLoveJsVendor(cacheDir)) { + vendorDir = cacheDir; + } +} + +if (!vendorDir) { + console.warn('[showcase] Real love.js player not found; keeping the bridge preview from public/showcase-lovejs/.'); + console.warn( + '[showcase] Set SHOWCASE_LOVEJS_DIR=/path/to/love.js or run `feather build vendor add web --dir .` to use the real player.', + ); + process.exit(0); +} + +await rm(outDir, { recursive: true, force: true }); +await mkdir(path.dirname(outDir), { recursive: true }); +await cp(vendorDir, outDir, { + recursive: true, + dereference: true, + filter: (source) => !source.includes(`${path.sep}.git${path.sep}`) && !source.endsWith(`${path.sep}.git`), +}); +await patchIndex(outDir); +await patchPlayer(outDir); +console.log(`[showcase] Copied love.js player from ${path.relative(root, vendorDir) || vendorDir}`); + +function isLoveJsVendor(dir) { + return ( + Boolean(dir) && + existsSync(path.join(dir, 'index.html')) && + (existsSync(path.join(dir, 'player.js')) || + existsSync(path.join(dir, 'player.min.js')) || + existsSync(path.join(dir, 'love.js'))) + ); +} + +async function patchPlayer(dir) { + const bridge = [ + '', + '// Feather showcase bridge: store postMessage preview payload for love.js.eval() polling', + 'window._featherPayload = null;', + 'window.addEventListener(\'message\', function(e) {', + ' if (!e.data || e.data.source !== \'feather-showcase\' || e.data.type !== \'preview:update\') return;', + ' window._featherPayload = e.data.payload || null;', + '});', + '', + ].join('\n'); + const candidates = ['player.min.js', 'player.js'].map((f) => path.join(dir, f)); + const target = candidates.find((f) => existsSync(f)); + if (!target) return; + const existing = await readFile(target, 'utf8'); + if (existing.includes('_featherPayload')) return; + await writeFile(target, existing + bridge); +} + +async function patchIndex(dir) { + const indexPath = path.join(dir, 'index.html'); + const playerScript = existsSync(path.join(dir, 'player.min.js')) ? 'player.min.js' : 'player.js'; + const fallback = [ + '', + '', + 'Feather love.js Preview', + ``, + '', + '', + ].join('\n'); + + const existing = existsSync(indexPath) ? await readFile(indexPath, 'utf8') : fallback; + let next = existing + .replace(/[\s\S]*?<\/title>/i, '<title>Feather love.js Preview') + .replace(//i, '') + .replace(/player(?:\.min)?\.js(?:\?g=[^"']*)?/g, `${playerScript}?g=showcase.love&v=11.5`); + + if (!//i.test(next)) { + next = next.replace(/<head[^>]*>/i, (match) => `${match}<title>Feather love.js Preview`); + } + if (!/\?g=showcase\.love/.test(next)) { + next = next.replace('', ``); + } + await writeFile(indexPath, next); +} diff --git a/scripts/serve-showcase.mjs b/scripts/serve-showcase.mjs new file mode 100644 index 00000000..41ce989d --- /dev/null +++ b/scripts/serve-showcase.mjs @@ -0,0 +1,100 @@ +/* eslint-disable no-undef */ +import { createReadStream, existsSync } from 'node:fs'; +import { stat } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const publicDir = path.join(root, 'dist-showcase'); +const port = Number(process.env.PORT || 3000); +const host = process.env.HOST || '0.0.0.0'; + +const contentTypes = new Map([ + ['.css', 'text/css; charset=utf-8'], + ['.gif', 'image/gif'], + ['.html', 'text/html; charset=utf-8'], + ['.ico', 'image/x-icon'], + ['.jpg', 'image/jpeg'], + ['.jpeg', 'image/jpeg'], + ['.js', 'text/javascript; charset=utf-8'], + ['.json', 'application/json; charset=utf-8'], + ['.love', 'application/octet-stream'], + ['.map', 'application/json; charset=utf-8'], + ['.png', 'image/png'], + ['.svg', 'image/svg+xml; charset=utf-8'], + ['.wasm', 'application/wasm'], + ['.webp', 'image/webp'], + ['.worker.js', 'text/javascript; charset=utf-8'], +]); + +const sharedHeaders = { + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', + 'Cross-Origin-Resource-Policy': 'same-origin', + 'Content-Security-Policy': + "default-src 'self'; script-src 'self' 'unsafe-eval' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' blob: data:; worker-src 'self' blob:; child-src 'self' blob:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'", +}; + +if (!existsSync(publicDir)) { + console.error('[showcase] dist-showcase does not exist. Run `npm run showcase:build` before starting the server.'); + process.exit(1); +} + +const server = createServer(async (request, response) => { + try { + const filePath = await resolvePath(request.url || '/'); + const extension = path.extname(filePath); + const type = filePath.endsWith('.worker.js') + ? contentTypes.get('.worker.js') + : contentTypes.get(extension) || 'application/octet-stream'; + + response.writeHead(200, { + ...sharedHeaders, + 'Content-Type': type, + 'Cache-Control': extension === '.html' ? 'no-cache' : 'public, max-age=31536000, immutable', + }); + createReadStream(filePath).pipe(response); + } catch (error) { + const status = error?.code === 'ENOENT' ? 404 : 500; + response.writeHead(status, { + ...sharedHeaders, + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'no-cache', + }); + response.end(status === 404 ? 'Not found\n' : 'Internal server error\n'); + } +}); + +server.listen(port, host, () => { + console.log(`[showcase] Serving ${publicDir} on http://${host}:${port}`); +}); + +async function resolvePath(rawUrl) { + const pathname = new URL(rawUrl, 'http://localhost').pathname; + const decoded = decodeURIComponent(pathname); + const requested = decoded.endsWith('/') ? `${decoded}index.html` : decoded; + const candidate = path.resolve(publicDir, `.${requested}`); + + if (!candidate.startsWith(`${publicDir}${path.sep}`) && candidate !== publicDir) { + const error = new Error('Path escapes public directory'); + error.code = 'ENOENT'; + throw error; + } + + try { + const info = await stat(candidate); + if (info.isFile()) return candidate; + } catch { + // Fall through to SPA fallback. + } + + if (!path.extname(candidate)) { + const fallback = path.join(publicDir, 'index.html'); + if (existsSync(fallback)) return fallback; + } + + const error = new Error('File not found'); + error.code = 'ENOENT'; + throw error; +} diff --git a/showcase.html b/showcase.html new file mode 100644 index 00000000..eeed532c --- /dev/null +++ b/showcase.html @@ -0,0 +1,12 @@ + + + + + + Feather Showcase + + +
+ + + diff --git a/src-lua/conf.lua b/src-lua/conf.lua index 71e3643f..df024124 100644 --- a/src-lua/conf.lua +++ b/src-lua/conf.lua @@ -22,4 +22,11 @@ function love.conf(t) t.window.width = 800 t.window.height = 600 t.console = false + + for _, value in ipairs(arg or {}) do + if value == "--e2e" then + t.modules.audio = false + break + end + end end diff --git a/src-lua/example/e2e/conf.lua b/src-lua/example/e2e/conf.lua index e40a0e95..fd7b5859 100644 --- a/src-lua/example/e2e/conf.lua +++ b/src-lua/example/e2e/conf.lua @@ -3,4 +3,5 @@ function love.conf(t) t.window.width = 640 t.window.height = 480 t.console = false + t.modules.audio = false end diff --git a/src-lua/example/showcase_preview/conf.lua b/src-lua/example/showcase_preview/conf.lua new file mode 100644 index 00000000..94fcb24a --- /dev/null +++ b/src-lua/example/showcase_preview/conf.lua @@ -0,0 +1,6 @@ +function love.conf(t) + t.identity = "feather_showcase_preview" + t.window.title = "Feather Showcase Preview" + t.window.width = 960 + t.window.height = 540 +end diff --git a/src-lua/example/showcase_preview/main.lua b/src-lua/example/showcase_preview/main.lua new file mode 100644 index 00000000..03ca9b0d --- /dev/null +++ b/src-lua/example/showcase_preview/main.lua @@ -0,0 +1,629 @@ +-- Standalone LÖVE preview target for the Feather showcase. +-- Polls window._featherPayload (set by the postMessage bridge in player.js) +-- via love.js.eval and renders live shader graph and particle system previews. + +pcall(require, "normalize1") +pcall(require, "normalize2") + +local _loveJs = type(love) == "table" and rawget(love, "js") or nil +local isLoveJs = type(_loveJs) == "table" and type(rawget(_loveJs, "eval")) == "function" +local _jsEval = isLoveJs and _loveJs.eval or nil + +-- Base64 decoder + +local B64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" +local B64_LUT = {} +for i = 1, #B64_CHARS do + B64_LUT[B64_CHARS:sub(i, i)] = i - 1 +end + +local function decodeBase64(data) + data = tostring(data or ""):gsub("%s+", "") + local out = {} + local n = 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_LUT[c1] + local n2 = B64_LUT[c2] + local n3 = c3 ~= "=" and B64_LUT[c3] or nil + local n4 = c4 ~= "=" and B64_LUT[c4] or nil + if not n1 or not n2 then + break + end + out[n] = string.char(n1 * 4 + math.floor(n2 / 16)) + n = n + 1 + if n3 then + out[n] = string.char((n2 % 16) * 16 + math.floor(n3 / 4)) + n = n + 1 + end + if n3 and n4 then + out[n] = string.char((n3 % 4) * 64 + n4) + n = n + 1 + end + end + return table.concat(out) +end + +-- Minimal JSON parser + +local function parseJson(src) + local pos = 1 + local function skip() + while pos <= #src and src:byte(pos) <= 32 do + pos = pos + 1 + end + end + local parseValue + local function parseStr() + pos = pos + 1 + local buf = {} + while pos <= #src do + local c = src:sub(pos, pos) + if c == '"' then + pos = pos + 1 + break + elseif c == "\\" then + local e = src:sub(pos + 1, pos + 1) + if e == "n" then + buf[#buf + 1] = "\n" + elseif e == "t" then + buf[#buf + 1] = "\t" + elseif e == "r" then + buf[#buf + 1] = "\r" + elseif e == '"' then + buf[#buf + 1] = '"' + elseif e == "\\" then + buf[#buf + 1] = "\\" + elseif e == "/" then + buf[#buf + 1] = "/" + else + buf[#buf + 1] = e + end + pos = pos + 2 + else + buf[#buf + 1] = c + pos = pos + 1 + end + end + return table.concat(buf) + end + local function parseObj() + pos = pos + 1 + skip() + local obj = {} + if src:sub(pos, pos) == "}" then + pos = pos + 1 + return obj + end + while true do + skip() + local k = parseStr() + skip() + pos = pos + 1 + skip() + obj[k] = parseValue() + skip() + if src:sub(pos, pos) == "}" then + pos = pos + 1 + break + end + pos = pos + 1 + end + return obj + end + local function parseArr() + pos = pos + 1 + skip() + local arr = {} + if src:sub(pos, pos) == "]" then + pos = pos + 1 + return arr + end + while true do + skip() + arr[#arr + 1] = parseValue() + skip() + if src:sub(pos, pos) == "]" then + pos = pos + 1 + break + end + pos = pos + 1 + end + return arr + end + parseValue = function() + skip() + local c = src:sub(pos, pos) + if c == '"' then + return parseStr() + elseif c == "{" then + return parseObj() + elseif c == "[" then + return parseArr() + elseif src:sub(pos, pos + 3) == "true" then + pos = pos + 4 + return true + elseif src:sub(pos, pos + 4) == "false" then + pos = pos + 5 + return false + elseif src:sub(pos, pos + 3) == "null" then + pos = pos + 4 + return nil + else + local num, np = src:match("^(-?%d+%.?%d*[eE]?[+-]?%d*)()", pos) + if num then + pos = np + return tonumber(num) + end + end + end + local ok, v = pcall(parseValue) + return ok and v or nil +end + +-- Utilities + +local function hexToRgba(hex) + hex = tostring(hex or ""):gsub("^#", "") + if #hex >= 6 then + return tonumber(hex:sub(1, 2), 16) / 255, tonumber(hex:sub(3, 4), 16) / 255, tonumber(hex:sub(5, 6), 16) / 255, 1 + end + return 1, 1, 1, 1 +end + +local function parseCsvNumbers(s) + local vals = {} + for part in tostring(s or ""):gmatch("[^,]+") do + local n = tonumber(part) + if n then + vals[#vals + 1] = n + end + end + return vals +end + +-- Grid + +local function drawGrid(w, h) + love.graphics.setColor(0.58, 0.64, 0.72, 0.12) + love.graphics.setLineWidth(1) + for x = 0, w, 32 do + love.graphics.line(x, 0, x, h) + end + for y = 0, h, 32 do + love.graphics.line(0, y, w, y) + end +end + +-- Preset particle textures + +local TWO_PI = math.pi * 2 +local presetImageCache = {} + +local function generatePresetImage(name) + if presetImageCache[name] then + return presetImageCache[name] + end + local size = 64 + local data = love.image.newImageData(size, size) + local cx, cy = (size - 1) / 2, (size - 1) / 2 + local radius = size / 2 - 1 + data:mapPixel(function(x, y) + local dx, dy = x - cx, y - cy + local dist = math.sqrt(dx * dx + dy * dy) + local angle = math.atan2(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 wave = (math.cos(angle * 5) + 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 + alpha = dist <= radius and 1 or 0 + end + return 1, 1, 1, math.max(0, math.min(1, alpha or 0)) + end) + local img = love.graphics.newImage(data) + presetImageCache[name] = img + return img +end + +-- Shader preview + +local shaderState = { + shader = nil, + drawable = nil, + shape = "circle", + name = "shader graph", +} + +local function makePreviewCanvas(shape, r, g, b, a) + local size = 256 + local canvas = love.graphics.newCanvas(size, size) + local prevCanvas = love.graphics.getCanvas() + local prevShader = love.graphics.getShader() + local prevBlend, prevAlpha = love.graphics.getBlendMode() + local pr, pg, pb, pa = love.graphics.getColor() + local prevLW = love.graphics.getLineWidth() + + love.graphics.setCanvas(canvas) + love.graphics.origin() + love.graphics.clear(0, 0, 0, 0) + love.graphics.setShader() + love.graphics.setBlendMode("alpha") + love.graphics.setColor(r, g, b, a) + + local pad = size * 0.22 + if shape == "rectangle" then + love.graphics.rectangle("fill", pad, pad, size - pad * 2, size - pad * 2) + elseif shape == "line" then + love.graphics.setLineWidth(math.max(6, size * 0.12)) + love.graphics.line(pad, size - pad, size - pad, pad) + else + love.graphics.circle("fill", size / 2, size / 2, size * 0.3) + end + + love.graphics.setCanvas(prevCanvas) + love.graphics.setShader(prevShader) + love.graphics.setBlendMode(prevBlend, prevAlpha) + love.graphics.setColor(pr, pg, pb, pa) + love.graphics.setLineWidth(prevLW) + return canvas +end + +local function applyShaderPayload(payload) + local pixel = tostring(payload.pixel or "") + local vertex = tostring(payload.vertex or "") + local shape = tostring(payload.previewShape or "circle") + local r, g, b, a = hexToRgba(payload.previewColor or "#ffffff") + local name = tostring(payload.shaderName or "shader graph") + + local shader = nil + if pixel ~= "" then + if vertex ~= "" then + local ok, s = pcall(love.graphics.newShader, pixel .. "\n" .. vertex) + if ok then + shader = s + else + local ok2, s2 = pcall(love.graphics.newShader, pixel) + if ok2 then + shader = s2 + end + end + else + local ok, s = pcall(love.graphics.newShader, pixel) + if ok then + shader = s + end + end + end + + local drawable = nil + local bt = payload.baseTexture + if type(bt) == "table" and type(bt.dataBase64) == "string" and bt.dataBase64 ~= "" then + local raw = decodeBase64(bt.dataBase64) + if raw and raw ~= "" then + local fn = tostring(bt.filename or "preview-texture.png") + local okFd, fd = pcall(love.filesystem.newFileData, raw, fn) + if okFd and fd then + local okImg, img = pcall(love.graphics.newImage, fd) + if okImg and img then + pcall(img.setFilter, img, "nearest", "nearest") + drawable = img + end + end + end + end + + if not drawable then + local ok, cv = pcall(makePreviewCanvas, shape, r, g, b, a) + if ok then + drawable = cv + end + end + + shaderState.shader = shader + shaderState.drawable = drawable + shaderState.shape = shape + shaderState.name = name +end + +local function drawShaderPreview(w, h) + local drawable = shaderState.drawable + local shader = shaderState.shader + + if not drawable then + local r = math.min(w, h) * 0.22 + local x, y = w * 0.5, h * 0.5 + love.graphics.setColor(0.22, 0.74, 0.97, 1) + love.graphics.circle("fill", x, y, r) + love.graphics.setColor(0.49, 0.23, 0.93, 0.55) + love.graphics.circle("fill", x + r * 0.18, y + r * 0.12, r * 0.7) + love.graphics.setColor(1, 1, 1, 0.9) + love.graphics.printf(shaderState.name, x - r, y + r + 18, r * 2, "center") + return + end + + local prevBlend, prevAlphaMode = love.graphics.getBlendMode() + local prevShader = love.graphics.getShader() + local pr, pg, pb, pa = love.graphics.getColor() + local prevLW = love.graphics.getLineWidth() + + local previewSize = math.min(280, math.max(128, math.min(w, h) * 0.42)) + local dw = drawable:getWidth() + local scale = previewSize / (dw > 0 and dw or 256) + local x = (w - previewSize) / 2 + local y = (h - previewSize) / 2 + + love.graphics.push() + love.graphics.origin() + love.graphics.setShader() + love.graphics.setBlendMode("alpha") + love.graphics.setColor(0.04, 0.05, 0.07, 0.74) + love.graphics.rectangle("fill", x - 10, y - 10, previewSize + 20, previewSize + 42, 6, 6) + love.graphics.setColor(1, 1, 1, 0.22) + love.graphics.setLineWidth(1) + love.graphics.rectangle("line", x - 10, y - 10, previewSize + 20, previewSize + 42, 6, 6) + + if shader then + if love.timer then + pcall(shader.send, shader, "u_time", love.timer.getTime()) + end + love.graphics.setShader(shader) + end + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(drawable, x, y, 0, scale, scale) + + love.graphics.setShader() + love.graphics.setColor(1, 1, 1, 0.72) + love.graphics.print("Shader Preview: " .. shaderState.shape, x - 4, y + previewSize + 12) + + love.graphics.setBlendMode(prevBlend, prevAlphaMode) + love.graphics.setShader(prevShader) + love.graphics.setColor(pr, pg, pb, pa) + love.graphics.setLineWidth(prevLW) + love.graphics.pop() +end + +-- Particle system preview + +local particleState = { + systems = {}, +} + +local function applySystemProperties(ps, props) + pcall(ps.setEmissionRate, ps, tonumber(props.emissionRate) or 100) + pcall(ps.setEmitterLifetime, ps, tonumber(props.emitterLifetime) or -1) + pcall( + ps.setParticleLifetime, + ps, + tonumber(props.particleLifetimeMin) or 0.35, + tonumber(props.particleLifetimeMax) or 1.3 + ) + pcall(ps.setDirection, ps, tonumber(props.direction) or (-math.pi / 2)) + pcall(ps.setSpread, ps, tonumber(props.spread) or (math.pi / 3)) + pcall(ps.setSpeed, ps, tonumber(props.speedMin) or 40, tonumber(props.speedMax) or 140) + pcall( + ps.setLinearAcceleration, + ps, + tonumber(props.linearAccelXMin) or 0, + tonumber(props.linearAccelYMin) or 0, + tonumber(props.linearAccelXMax) or 0, + tonumber(props.linearAccelYMax) or 0 + ) + pcall(ps.setRadialAcceleration, ps, tonumber(props.radialAccelMin) or 0, tonumber(props.radialAccelMax) or 0) + pcall( + ps.setTangentialAcceleration, + ps, + tonumber(props.tangentialAccelMin) or 0, + tonumber(props.tangentialAccelMax) or 0 + ) + pcall(ps.setLinearDamping, ps, tonumber(props.linearDampingMin) or 0, tonumber(props.linearDampingMax) or 0) + pcall(ps.setSizeVariation, ps, tonumber(props.sizeVariation) or 0) + pcall(ps.setRotation, ps, tonumber(props.rotationMin) or 0, tonumber(props.rotationMax) or 0) + pcall(ps.setRelativeRotation, ps, props.relativeRotation == true) + pcall(ps.setSpin, ps, tonumber(props.spinMin) or 0, tonumber(props.spinMax) or 0) + pcall(ps.setSpinVariation, ps, tonumber(props.spinVariation) or 0) + pcall(ps.setOffset, ps, tonumber(props.offsetX) or 0, tonumber(props.offsetY) or 0) + pcall(ps.setInsertMode, ps, tostring(props.insertMode or "top")) + + local sizes = parseCsvNumbers(props.sizes) + if #sizes > 0 then + pcall(ps.setSizes, ps, unpack(sizes)) + end + + local colors = parseCsvNumbers(props.colors) + if #colors >= 4 then + pcall(ps.setColors, ps, unpack(colors)) + end + + local emDist = tostring(props.emissionAreaDist or "none") + if emDist ~= "none" and emDist ~= "" then + pcall( + ps.setEmissionArea, + ps, + emDist, + tonumber(props.emissionAreaDx) or 0, + tonumber(props.emissionAreaDy) or 0, + tonumber(props.emissionAreaAngle) or 0, + props.emissionAreaRelative == true + ) + end +end + +local function applyParticlePayload(payload) + for _, entry in ipairs(particleState.systems) do + if entry.ps then + pcall(entry.ps.release, entry.ps) + end + end + particleState.systems = {} + + local composite = payload.composite + if type(composite) ~= "table" then + return + end + + local systems = composite.systems + if type(systems) ~= "table" then + return + end + + for _, sysData in ipairs(systems) do + if type(sysData) == "table" then + local preset = tostring(sysData.texturePreset or "circle") + local ok0, img = pcall(generatePresetImage, preset) + if not ok0 or not img then + ok0, img = pcall(generatePresetImage, "circle") + end + if ok0 and img then + local bufSize = math.max( + 100, + math.min( + 5000, + tonumber((type(sysData.properties) == "table" and sysData.properties.bufferSize) or 1000) or 1000 + ) + ) + local okPs, ps = pcall(love.graphics.newParticleSystem, img, bufSize) + if okPs and ps then + if type(sysData.properties) == "table" then + pcall(applySystemProperties, ps, sysData.properties) + end + pcall(ps.start, ps) + local emitAtStart = tonumber(sysData.emitAtStart) or 0 + if emitAtStart > 0 then + pcall(ps.emit, ps, emitAtStart) + end + particleState.systems[#particleState.systems + 1] = { + ps = ps, + blendMode = tostring(sysData.blendMode or "alpha"), + x = tonumber(sysData.x) or 0, + y = tonumber(sysData.y) or 0, + } + end + end + end + end +end + +local function drawParticlePreview(w, h) + local cx = w * 0.5 + local cy = h * 0.56 + + if #particleState.systems == 0 then + love.graphics.setColor(1, 0.45 + 0.35, 0.12, 0.6) + local r = math.min(w, h) * 0.04 + for i = 1, 24 do + local angle = i * 2.399 + local spread = 30 + r * 2 + local px = cx + math.cos(angle) * spread + local py = cy - spread * 0.5 + math.sin(angle * 1.7) * spread * 0.18 + love.graphics.circle("fill", px, py, r) + end + return + end + + local prevBlend, prevAlphaMode = love.graphics.getBlendMode() + local pr, pg, pb, pa = love.graphics.getColor() + + for _, entry in ipairs(particleState.systems) do + if entry.ps then + if entry.blendMode == "add" then + love.graphics.setBlendMode("add") + else + love.graphics.setBlendMode("alpha") + end + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(entry.ps, cx + entry.x, cy + entry.y) + end + end + + love.graphics.setBlendMode(prevBlend, prevAlphaMode) + love.graphics.setColor(pr, pg, pb, pa) +end + +-- Payload polling + +local pollState = { + tool = "idle", + time = 0, + pollTimer = 0, + lastPayloadJson = "", +} + +local function pollPayload() + if not _jsEval then + return + end + local ok, json = pcall(_jsEval, "JSON.stringify(window._featherPayload || null)") + if not ok or not json or json == "" or json == "null" then + return + end + if json == pollState.lastPayloadJson then + return + end + pollState.lastPayloadJson = json + + local payload = parseJson(json) + if type(payload) ~= "table" then + return + end + + local tool = tostring(payload.tool or "idle") + pollState.tool = tool + if tool == "shader-graph" then + pcall(applyShaderPayload, payload) + elseif tool == "particle-system-playground" then + pcall(applyParticlePayload, payload) + end +end + +-- love callbacks + +---@diagnostic disable-next-line: duplicate-set-field +function love.update(dt) + pollState.time = pollState.time + dt + pollState.pollTimer = pollState.pollTimer + dt + if pollState.pollTimer >= 0.12 then + pollState.pollTimer = 0 + pollPayload() + end + for _, entry in ipairs(particleState.systems) do + if entry.ps then + pcall(entry.ps.update, entry.ps, dt) + end + end +end + +---@diagnostic disable-next-line: duplicate-set-field +function love.draw() + local w, h = love.graphics.getDimensions() + love.graphics.clear(0.02, 0.03, 0.05, 1) + drawGrid(w, h) + + if pollState.tool == "particle-system-playground" then + drawParticlePreview(w, h) + elseif pollState.tool == "shader-graph" then + drawShaderPreview(w, h) + else + love.graphics.setColor(0.84, 0.87, 0.91, 0.35) + local r = math.min(w, h) * 0.22 + love.graphics.setLineWidth(1) + love.graphics.circle("line", w * 0.5, h * 0.5, r) + end + + love.graphics.setColor(0.84, 0.87, 0.91, 0.72) + love.graphics.print("Feather standalone preview target", 12, 12) +end diff --git a/src-lua/plugins/audio-debug/init.lua b/src-lua/plugins/audio-debug/init.lua index 40a0be7e..0c7d404a 100644 --- a/src-lua/plugins/audio-debug/init.lua +++ b/src-lua/plugins/audio-debug/init.lua @@ -27,9 +27,9 @@ local AudioDebugPlugin = Class({ self._hooked = false self._origNewSource = nil self.showStopped = self.options.showStopped ~= false - self.masterVolume = love.audio.getVolume() + self.masterVolume = love.audio and love.audio.getVolume() or 1 - if self.options.autoHook ~= false then + if love.audio and self.options.autoHook ~= false then self:hookNewSource() end end, @@ -37,7 +37,7 @@ local AudioDebugPlugin = Class({ --- Hook love.audio.newSource to automatically track all created sources. function AudioDebugPlugin:hookNewSource() - if self._hooked then + if self._hooked or not love.audio then return end self._origNewSource = love.audio.newSource @@ -55,7 +55,7 @@ function AudioDebugPlugin:unhookNewSource() if not self._hooked then return end - if self._origNewSource then + if self._origNewSource and love.audio then love.audio.newSource = self._origNewSource self._origNewSource = nil end @@ -147,6 +147,10 @@ function AudioDebugPlugin:handleRequest() end function AudioDebugPlugin:handleActionRequest(request) + if not love.audio then + return nil, "love.audio is not available" + end + local action = request.params and request.params.action if action == "stop-all" then love.audio.stop() @@ -160,7 +164,7 @@ end function AudioDebugPlugin:handleParamsUpdate(request) local params = request.params or {} - if params.masterVolume ~= nil then + if params.masterVolume ~= nil and love.audio then local v = tonumber(params.masterVolume) if v then v = math.max(0, math.min(1, v)) @@ -175,11 +179,15 @@ function AudioDebugPlugin:handleParamsUpdate(request) end function AudioDebugPlugin:getConfig() - local activeCount = love.audio.getActiveSourceCount() - local lx, ly, lz = love.audio.getPosition() - local distModel = love.audio.getDistanceModel() - local dopplerScale = love.audio.getDopplerScale() - local effectsSupported = love.audio.isEffectsSupported() + local audioAvailable = love.audio ~= nil + local activeCount = audioAvailable and love.audio.getActiveSourceCount() or 0 + local lx, ly, lz = 0, 0, 0 + if audioAvailable then + lx, ly, lz = love.audio.getPosition() + end + local distModel = audioAvailable and love.audio.getDistanceModel() or "unavailable" + local dopplerScale = audioAvailable and love.audio.getDopplerScale() or 0 + local effectsSupported = audioAvailable and love.audio.isEffectsSupported() or false local maxSceneEffects = 0 local maxSourceEffects = 0 if effectsSupported then @@ -260,8 +268,8 @@ function AudioDebugPlugin:getConfig() key = "masterVolume", icon = "volume-2", type = "input", - value = string.format("%.2f", love.audio.getVolume()), - props = { type = "number", min = 0, max = 1, step = 0.05 }, + value = string.format("%.2f", audioAvailable and love.audio.getVolume() or self.masterVolume), + props = { type = "number", min = 0, max = 1, step = 0.05, disabled = not audioAvailable }, group = "Settings", }, diff --git a/src-lua/plugins/particle-system-playground/init.lua b/src-lua/plugins/particle-system-playground/init.lua index d5d9852a..a21c3314 100644 --- a/src-lua/plugins/particle-system-playground/init.lua +++ b/src-lua/plugins/particle-system-playground/init.lua @@ -817,6 +817,7 @@ local function createDefaultSystem(index, template) title = "Emitter " .. tostring(index), blendMode = "alpha", shader = nil, + shaderTextures = {}, shaderPath = "", shaderFilename = "", shaderSource = "", @@ -1065,6 +1066,11 @@ function ParticleSystemPlaygroundPlugin:onDraw() 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()) + if type(system.shaderTextures) == "table" then + for uniform, image in pairs(system.shaderTextures) do + pcall(system.shader.send, system.shader, uniform, image) + end + end end love.graphics.setShader(system.shader) love.graphics.setColor(1, 1, 1, 1) @@ -1341,7 +1347,63 @@ function ParticleSystemPlaygroundPlugin:_applyShader(name, index, params) return nil, tostring(shader) end + local shaderTextures = {} + if type(params.textures) == "table" then + for _, texture in ipairs(params.textures) do + if type(texture) == "table" then + local uniform = safeString(texture.uniform, "") + local dataBase64 = safeString(texture.dataBase64, "") + if uniform ~= "" and dataBase64 ~= "" then + local raw = decodeBase64(dataBase64) + if not raw then + return nil, "Shader texture data is not valid base64" + end + local texFilename = sanitizeFilename(texture.filename, uniform .. ".png") + local okData, fileData = pcall(love.filesystem.newFileData, raw, texFilename) + if not okData or not fileData then + return nil, "Could not create shader texture file data" + end + local okImage, image = pcall(love.graphics.newImage, fileData) + if not okImage or not image then + return nil, "Could not create shader texture image" + end + pcall(image.setFilter, image, "nearest", "nearest") + shaderTextures[uniform] = image + pcall(shader.send, shader, uniform, image) + end + end + end + end + + if type(params.parameters) == "table" then + for _, parameter in ipairs(params.parameters) do + if type(parameter) == "table" then + local uniform = safeString(parameter.uniform, "") + local parameterType = safeString(parameter.type, "") + local value = parameter.defaultValue + if uniform ~= "" and parameterType ~= "texture" then + if parameterType == "boolean" then + value = value and tonumber(value) ~= 0 and 1 or 0 + elseif parameterType == "float" then + value = tonumber(value) or 0 + elseif parameterType == "vec2" then + value = type(value) == "table" and value or {} + value = { tonumber(value[1]) or 0, tonumber(value[2]) or 0 } + elseif parameterType == "vec3" then + value = type(value) == "table" and value or {} + value = { tonumber(value[1]) or 0, tonumber(value[2]) or 0, tonumber(value[3]) or 0 } + elseif parameterType == "vec4" or parameterType == "color" then + value = type(value) == "table" and value or {} + value = { tonumber(value[1]) or 0, tonumber(value[2]) or 0, tonumber(value[3]) or 0, tonumber(value[4]) or 1 } + end + pcall(shader.send, shader, uniform, value) + end + end + end + end + sys.shader = shader + sys.shaderTextures = shaderTextures sys.shaderPath = path sys.shaderFilename = filename sys.shaderSource = source @@ -1413,6 +1475,20 @@ function ParticleSystemPlaygroundPlugin:handleActionRequest(request) return true end + if action == "reorder-system" then + if entry.kind ~= "scratch" then + return nil, "Only scratch composites can be reordered" + end + local fromIdx = math.max(1, safeNumber(params.fromIndex, 0)) + local toIdx = math.max(1, safeNumber(params.toIndex, 0)) + if fromIdx == toIdx or fromIdx < 1 or toIdx < 1 or fromIdx > #entry.systems or toIdx > #entry.systems then + return true + end + local moved = table.remove(entry.systems, fromIdx) + table.insert(entry.systems, toIdx, moved) + return true + end + if action == "set-texture" then return self:_applyTexture(name, index, params) end diff --git a/src-lua/plugins/shader-graph/README.md b/src-lua/plugins/shader-graph/README.md index 9f09c0d4..5c07d964 100644 --- a/src-lua/plugins/shader-graph/README.md +++ b/src-lua/plugins/shader-graph/README.md @@ -6,7 +6,7 @@ The desktop editor owns the graph UI and GLSL code generation. This runtime plug 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. +Several higher-level nodes and presets are also inspired by common VFX Shader Graph recipes, including texture strength, opacity, dissolve masks, water displacement, and vertex displacement. Feather includes both a self-contained procedural water preset and a texture-noise water preset that uses an uploaded color noise texture. 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 @@ -15,27 +15,52 @@ Several higher-level nodes and presets are also inspired by common VFX Shader Gr 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. +6. Use **Custom Function** when a graph needs a small hand-written GLSL function. Function parameters become input ports; the return value and `out` parameters become output ports. +7. Use **Validate** to compile in the running LÖVE game. +8. Toggle **Preview On** to draw the shader on a temporary circle, line, or rectangle in the center of the running game when you are not ready to apply it to particles. Upload a preview texture when the shader should run against a real sprite instead of a generated shape. While preview is enabled, graph edits, shape changes, preview color changes, and uploaded texture changes re-apply automatically. +9. Use **Apply** to send the generated shader to the selected Particle System Playground emitter. +10. 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 +### Custom + +Use **Custom Function** for small GLSL snippets that are easier to express as code than as node chains. + +```glsl +vec4 custom_tint(vec4 color, float strength) { + return vec4(color.rgb * strength, color.a); +} +``` + +The editor parses the function signature in the node modal. Regular parameters become inputs, a non-`void` return value becomes a `Result` output, and `out` parameters become additional outputs: + +```glsl +void custom_mask(vec2 uv, out float mask, out vec4 color) { + mask = smoothstep(0.45, 0.5, length(uv - vec2(0.5))); + color = vec4(vec3(mask), 1.0); +} +``` + +Keep custom functions self-contained and pass values in through ports. The node validates the signature and braces before saving, then the final shader should still be compiled with **Validate** against the connected LÖVE runtime. + ### 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. | +| Node | Output | Use | +| -------------------------- | --------------- | ---------------------------------------------------------------------------------------- | +| Texture Color | `vec4` | Current texture sample: `Texel(tex, texture_coords)` | +| Texture Input | `Image` | Uploaded auxiliary texture uniform; add one node per extra texture | +| Texture Uniform Color | `vec4` | Compatibility helper that samples an uploaded auxiliary `Image` uniform at a supplied UV | +| 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 highp number u_time` | +| Resolution | `vec2` | Uses `love_ScreenSize.xy` | +| Float / Vec2 / Vec3 / Vec4 | typed constants | Editable values for colors, thresholds, speed, etc. | ### Math @@ -51,6 +76,49 @@ Math nodes shape scalar values. They are most useful for masks, thresholds, anim - `Min`, `Max`, `Modulo`, `Negate`, `Saturate`: scalar shaping and bounds. - `Remap`: convert one scalar range into another, useful for mask tuning. +### Complex + +Complex nodes treat a `vec2` as a complex number, where `x` is the real component and `y` is the imaginary component. They are useful for procedural UV warps, conformal-style transforms, spirals, inversions, and math-driven pattern generation. + +- `Complex Conjugate`: outputs `(x, -y)`. +- `Complex Reciprocal`: outputs `1 / A`. +- `Complex Multiply`: multiplies two complex values. +- `Complex Divide`: divides one complex value by another. +- `Complex Exp`: complex exponential. +- `Complex Log`: complex logarithm. +- `Complex Power`: raises one complex value to another. + +### Quaternion + +Quaternion nodes treat a `vec4` as a quaternion, where `xyz` is the vector component and `w` is the scalar component. They are useful for building orientation math, rotating 3D vectors, and driving procedural 2D UV rotation through a Z-axis quaternion. + +- `Quaternion Inverse`: inverse/conjugate for undoing a rotation. +- `Quaternion From Euler`: builds a quaternion from Euler angles in degrees. +- `Quaternion From Angle Axis`: builds a quaternion from an angle in degrees and an axis vector. +- `Quaternion To Angle Axis`: extracts angle and axis values from a quaternion. +- `Quaternion From To Rotation`: builds the rotation from one direction vector to another. +- `Quaternion Multiply`: combines two quaternion rotations. +- `Quaternion Rotate Vector`: rotates a `vec3` by a quaternion. +- `Quaternion Slerp`: spherical interpolation between two quaternions. + +### Symmetry + +Symmetry nodes fold or tile UV coordinates before sampling. They are useful for kaleidoscope effects, mirrored sprites, repeating motifs, procedural tiles, and compact pattern authoring. + +- `Reflection Symmetry`: mirrors UVs across a line defined by position and direction, with optional glide offset. +- `Rotation Symmetry`: folds UVs into radial sectors around a position. `Order` controls the number of repeated sectors. +- `Tiling Symmetry`: maps UVs into local tile coordinates and also outputs cell index and cell position. `Mode` is stepped from `0` to `3`: `0` Square, `1` Hexagon, `2` Triangle, `3` Herringbone. + +### Random + +Random nodes generate deterministic pseudo-random values from a `vec2` seed. They are useful when combined with tile indices, screen positions, UV cells, or other stable values. + +- `Random Integer Range`: deterministic integer-like float between `Min` and `Max`. +- `Random Circle`: random point inside or on a unit circle. `Mode` is stepped from `0` to `1`: `0` In, `1` On. +- `Random Sphere`: random point inside or on a unit sphere. `Mode` is stepped from `0` to `1`: `0` In, `1` On. +- `Random Rotation`: random quaternion rotation. +- `Random Color`: random HSV color within editable min/max HSV ranges; outputs both RGB and HSV. + ### Vector Vector nodes convert between packed colors/vectors and scalar channels. @@ -63,6 +131,8 @@ Vector nodes convert between packed colors/vectors and scalar 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. +- `Add`, `Subtract`, `Multiply`, and `Divide` variants for `vec2`, `vec3`, and `vec4`: component-wise vector math. +- `Scale Vec2`, `Scale Vec3`, `Scale Vec4`: multiply a vector by a scalar. ### Color @@ -75,16 +145,59 @@ Color nodes transform an existing `vec4`. - `Contrast`: adjust contrast around 0.5. - `Posterize`: reduce colors to a fixed number of bands. - `Multiply Color`: multiply two `vec4` values. +- `Lab Convert`: converts a `vec4` between RGB, Lab, and LCH. `From` and `To` use `0` RGB, `1` Lab, `2` LCH. +- `Lab Complementary`: returns the perceptual complementary color by flipping the Lab `a` and `b` axes. +- `Lab Split Scheme`: outputs two colors offset around the LCH hue circle by an editable angle. +- `Lab Dual Scheme`: outputs a rectangle/tetradic color scheme from the source color and angle. + +### Composite + +Composite nodes combine two straight-alpha `vec4` colors using Porter-Duff alpha compositing equations. + +- `Composite`: combines color `A` and color `B`. `Mode` is stepped from `0` to `4`: `0` Over, `1` In, `2` Out, `3` Atop, `4` Xor. ### Noise Noise nodes create procedural variation. - `Simple Noise`: deterministic hash noise from UV. +- `Truchet Tiles`: procedural Truchet tiling with square, triangle, and hex layout modes; supports randomized rotation, reflection, and optional time-based scrolling; outputs tile UV, tile index, and a line mask. - `Ripple`: UV distortion using sine waves. - `Voronoi Cells`: cellular mask for sparks, shields, energy, and organic breakup. - `Checkerboard`: alternating square mask. +### Pattern + +Pattern nodes create anti-aliased geometric masks from UVs. + +- `Zig Zag`: stripe mask bent into triangular zig zags. +- `Sine Waves`: stripe mask following sine waves. +- `Round Waves`: stripe mask made from arc segments. +- `Dots`: repeating dot grid with row offset and radius controls. +- `Spiral`: Archimedean spiral mask. +- `Whirl`: radial stripe mask twisted around the center. + +### Halftone + +Halftone nodes create print-style dot patterns from grayscale or RGB input. + +- `Halftone Mono`: converts a scalar base value into a halftone mask. +- `Halftone Color`: applies separate red, green, and blue halftone screens. `Mode` is stepped from `0` to `2`: `0` Circle, `1` Smooth, `2` Square. + `Scale` controls dot density: higher values make smaller, denser dots; lower values make larger, more separated dots. + +### Pixel Perfect + +Pixel Perfect nodes create derivative-based one-pixel primitive masks. They are useful for crisp procedural guides, outlines, reticles, scan marks, debug overlays, and pixel-art effects. Use `Pixelate` when you want chunky texture sampling; use these nodes when you want exact single-pixel marks. + +- `Pixel Point`: one-pixel mask at a UV position. +- `Pixel Point Grid`: repeating one-pixel point grid. +- `Pixel Ray`: one-pixel infinite ray. +- `Pixel Rays`: repeated parallel one-pixel rays. +- `Pixel Line`: one-pixel line segment between two UV points. +- `Pixel Lines`: repeated one-pixel line segments. +- `Pixel Circle`: one-pixel circular outline. +- `Pixel Polygon`: one-pixel regular polygon outline. + ### UV UV nodes transform texture coordinates before sampling. @@ -94,26 +207,51 @@ UV nodes transform texture coordinates before sampling. - `Twirl UV`: swirl UVs toward the center. - `Polar Coordinates`: convert UVs into radius/angle space. +### SDF + +Signed distance field nodes create crisp procedural shape masks from UVs. Feather's SDF set follows the common primitive/sample/boolean structure used by Shader Graph SDF node collections. + +**Primitives** + +- `SDF Line`: vertical line mask with position and width controls. +- `SDF Circle`: circle centered at a position with a radius. +- `SDF Rectangle`: rectangle centered at a position with width, height, and corner radius. +- `SDF Polygon`: regular polygon with position, radius, side count, and corner radius. + +**Sampling** + +- `SDF Sample`: converts an SDF value into an anti-aliased filled mask. +- `SDF Sample Strip`: converts a distance range into an anti-aliased outline/ring mask. + +**Booleans** + +- `SDF Boolean`: outputs hard union, intersection, and difference. +- `SDF Soft Boolean`: outputs smoothed union, intersection, and difference with a smoothing control. + +SDF primitives include centered defaults, so a newly dropped circle, rectangle, polygon, or sampled strip produces a visible shape before every input is wired manually. + ### 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 | +| 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 | +| Sprite Outline | Samples neighboring alpha around the current sprite to create a configurable outline color | +| 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 | +| Water Noise UV | Scrolls tiled noise UVs using sprite width, noise width, speed, and time | +| Water Displace V2 | Displaces UVs from RGB noise texture channels | +| 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 an adjustable center offset | ### Output @@ -140,17 +278,33 @@ The Shader Graph page includes complete preset graphs: - **Hit Flash**: damage/selection flash. - **Rim Glow**: 2D fresnel-style edge glow. - **Pixelate**: retro low-resolution sampling. -- **Chromatic Aberration**: RGB channel split. +- **Pixel Perfect Circle**: one-pixel circular ink line over the source texture. +- **Chromatic Aberration**: RGB channel split with an editable center offset. +- **Alpha Composite**: Porter-Duff compositing between overlapping procedural shapes. +- **Lab Complementary**: perceptual complementary color mix using Lab/LCH color space. - **Posterize**: toon/retro color bands. +- **Halftone Dots**: comic-print treatment with posterized color, RGB dot screens, warm paper tint, and vignette. - **Twirl Portal**: centered UV swirl. +- **Complex Power Warp**: bends centered UVs with complex-number power math. - **Rotating Texture**: time-driven UV rotation. +- **Quaternion UV Rotate**: rotates centered UVs through a generated Z-axis quaternion. +- **Symmetry Kaleidoscope**: folds UVs through rotation and reflection symmetry before sampling. +- **Random Color Tiles**: uses tile cell indices as stable random seeds for deterministic HSV colors. - **Checker Flash**: checker mask mixed into a flash color. +- **Pattern Whirl**: radial procedural whirl mask mixed into the texture. - **Voronoi Energy**: cellular energy/shield mask. +- **Truchet Tiles**: animated procedural randomized arc tiles mixed into the texture. - **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. +- **Texture Noise Water**: texture-noise water shader with uploaded `water.png` as the preview texture and uploaded `simplex-noise-64.png` bound to `simplex`. +- **SDF Circle Mask**: soft procedural circle clip. +- **SDF Rounded Rectangle**: rounded rectangle clip using an SDF primitive. +- **SDF Ring Glow**: procedural ring highlight using SDF strip sampling. +- **SDF Hex Badge**: visibly clips the texture with a smaller regular polygon mask. +- **SDF Crescent**: difference of two circle fields. - **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. @@ -186,6 +340,16 @@ Add `Time`, `FloatConstant` amplitude, frequency, and speed inputs to animate it 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. +### Texture Noise Water + +Load the **Texture Noise Water** preset to reproduce a texture-noise water shader. Upload `water.png` as the preview texture, then upload `simplex-noise-64.png` for the `simplex` texture uniform. The graph follows this structure: + +`Texture Coords + Time + Noise Width + Sprite Width + Speed -> Water Noise UV -> Texture Input(simplex) -> Sample Texture -> Water Displace V2 -> Sample Texture -> Fragment Output` + +This preset is based on Alex J. Griffith's LÖVE water shader post: [alexjgriffith.com/3.html](https://alexjgriffith.com/3.html). Feather keeps the node and preset names generic, while preserving the article's `water.png` plus `simplex-noise-64.png` texture-noise approach. + +The original `simplex-noise-64.png` is a color noise texture, so Feather samples it through a `Texture Input` node instead of trying to replace it with scalar procedural noise. That keeps the red, green, and blue channel displacement behavior intact, and the same pattern works for future multi-texture shaders. + ### Masked Water `Texture Color + Texture Coords + Time + speed + amplitude + scale + mask threshold -> Masked Water -> Fragment Output` @@ -200,9 +364,9 @@ Use this for portals, rotating particles, scrolling texture strips, and tiled ma ### Outline -`Texture Color + Texture Coords + Float thickness + Vec4 outline color -> Alpha Outline -> Fragment Output` +`Texture Color + Texture Coords + Float thickness + Vec4 outline color -> Sprite Outline -> Fragment Output` -Good for sprites, interactable objects, and particle silhouettes. +Good for sprites, interactable objects, and particle silhouettes. The outline node paints transparent pixels adjacent to sprite alpha, so the original sprite remains unchanged while the outline color fills the silhouette edge. ### Dissolve @@ -226,7 +390,7 @@ Keep this near the end of a graph when you want a simple overall fade. `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. +Pair it with a normal fragment path such as `Texture Color -> Fragment Output`. Vertex Output expects local/object-space vertex positions; Shader Graph applies LÖVE's `transform_projection` matrix automatically. It works best on sprites or meshes with enough vertices to show deformation; a single quad will wobble at its corners. ### Rim Glow @@ -240,6 +404,18 @@ Feed texture color and a glow color into `Hit Flash`. Feed texture color and a highlight color into `Hit Flash`. This is a quick way to prototype shield, scan, grid, glitch, or energy effects. +### Truchet Tiles + +`Texture Coords + tile size + mode + seed + line width + time + scroll speed -> Truchet Tiles mask -> Hit Flash amount` + +The Truchet node creates randomized procedural tiles. `Tile Mode` follows the source-style Truchet layout options: `0` square, `1` triangle, `2` hexagon. The default preset stays on `0` because that is the classic quarter-circle Truchet pattern shown in most references. The preset wires `Time` and a `Scroll Speed` vector so the pattern moves over time. It also outputs per-tile UVs and a tile index for more advanced procedural recipes, but the `Mask` output is the fastest path for visible arc patterns. + +### Chromatic Aberration + +`Texture Coords + amount + center offset -> Chromatic Aberration -> Fragment Output` + +The offset input shifts the split center from `vec2(0.5)`. Use small values such as `vec2(0.08, 0.0)` to bias the red/blue separation toward one side of a sprite or screen effect. + ### 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. @@ -252,9 +428,9 @@ Attempts to compile the provided GLSL source using `love.graphics.newShader`. Re **Params** -| Field | Type | Description | -|-------|------|-------------| -| `pixelSource` | `string` | GLSL pixel (fragment) shader source | +| Field | Type | Description | +| -------------- | -------- | ------------------------------------ | +| `pixelSource` | `string` | GLSL pixel (fragment) shader source | | `vertexSource` | `string` | GLSL vertex shader source (optional) | **Response** @@ -269,9 +445,39 @@ Attempts to compile the provided GLSL source using `love.graphics.newShader`. Re `pixelError` / `vertexError` are `nil` when that stage compiled successfully. +### `preview-shader` + +Compiles the provided GLSL source, creates a temporary padded shape texture, and draws it in-game with the shader until preview is cleared. This is useful for sprite shaders, especially outline graphs, before applying the shader to a Particle System Playground emitter. + +**Params** + +| Field | Type | Description | +| -------------- | ---------- | ---------------------------------------------------------------- | +| `pixelSource` | `string` | GLSL pixel (fragment) shader source | +| `vertexSource` | `string` | GLSL vertex shader source (optional) | +| `shape` | `string` | `circle`, `line`, or `rectangle`; defaults to `circle` | +| `color` | `number[]` | Preview element RGBA color, normalized `0..1`; defaults to white | +| `size` | `number` | Temporary texture size in pixels; defaults to `128` | + +**Response** + +```lua +-- success +{ status = "ok", shape = "circle", color = { 1, 1, 1, 1 } } + +-- failure +{ status = "error", pixelError = "...", vertexError = "..." } +``` + +### `clear-preview` + +Clears the active temporary shader preview. + ## 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. +- Validation discards shader objects immediately; previews keep the shader and temporary shape canvas only for the preview window. - 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. +- Runtime preview is drawn by the Shader Graph plugin itself, so it does not require a Particle System Playground target. +- Shader graph input definitions may provide `defaultValue`, `min`, `max`, and `step` metadata; the inspector uses those values for disconnected input editors. diff --git a/src-lua/plugins/shader-graph/init.lua b/src-lua/plugins/shader-graph/init.lua index 4b4fb1f6..87b02f1d 100644 --- a/src-lua/plugins/shader-graph/init.lua +++ b/src-lua/plugins/shader-graph/init.lua @@ -1,10 +1,275 @@ local Class = require(FEATHER_PATH .. ".lib.class") local Base = require(FEATHER_PATH .. ".core.base") +local base64 = require(FEATHER_PATH .. ".lib.base64") local ShaderGraphPlugin = Class({ __includes = Base }) -function ShaderGraphPlugin:initialize(feather) - Base.initialize(self, feather) +local PREVIEW_SHAPES = { + circle = true, + line = true, + rectangle = true, +} + +local DEFAULT_PREVIEW_SIZE = 128 +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+", "") + if data == "" then + return nil + end + + if type(base64.decode) == "function" then + local ok, decoded = pcall(base64.decode, data) + if ok then + return decoded + end + end + + 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 restoreCanvas(canvas) + if canvas then + love.graphics.setCanvas(canvas) + else + love.graphics.setCanvas() + end +end + +local function clamp01(value, fallback) + value = tonumber(value) + if value == nil then + return fallback + end + if value < 0 then + return 0 + end + if value > 1 then + return 1 + end + return value +end + +local function previewColor(value) + if type(value) ~= "table" then + return { 1, 1, 1, 1 } + end + return { + clamp01(value[1] or value.r, 1), + clamp01(value[2] or value.g, 1), + clamp01(value[3] or value.b, 1), + clamp01(value[4] or value.a, 1), + } +end + +local function buildShader(pixelSource, vertexSource) + pixelSource = pixelSource or "" + vertexSource = vertexSource or "" + + local pixelOk, pixelShaderOrErr = pcall(function() + return love.graphics.newShader(pixelSource) + end) + + if not pixelOk then + return nil, tostring(pixelShaderOrErr), nil + end + + if vertexSource ~= "" then + local combinedOk, combinedShaderOrErr = pcall(function() + return love.graphics.newShader(pixelSource .. "\n" .. vertexSource) + end) + if not combinedOk then + return nil, nil, tostring(combinedShaderOrErr) + end + return combinedShaderOrErr + end + + return pixelShaderOrErr +end + +local function makePreviewCanvas(shape, size, color) + size = tonumber(size) or DEFAULT_PREVIEW_SIZE + if size < 32 then + size = 32 + elseif size > 512 then + size = 512 + end + + local canvas = love.graphics.newCanvas(size, size) + local previousCanvas = love.graphics.getCanvas() + local previousShader = love.graphics.getShader() + local previousBlend, previousAlphaMode = love.graphics.getBlendMode() + local previousLineWidth = love.graphics.getLineWidth() + local r, g, b, a = love.graphics.getColor() + local pad = size * 0.22 + + love.graphics.push() + love.graphics.setCanvas(canvas) + love.graphics.origin() + love.graphics.clear(0, 0, 0, 0) + love.graphics.setShader() + love.graphics.setBlendMode("alpha") + love.graphics.setColor(color[1], color[2], color[3], color[4]) + + if shape == "rectangle" then + love.graphics.rectangle("fill", pad, pad, size - pad * 2, size - pad * 2) + elseif shape == "line" then + love.graphics.setLineWidth(math.max(6, size * 0.12)) + love.graphics.line(pad, size - pad, size - pad, pad) + else + love.graphics.circle("fill", size / 2, size / 2, size * 0.3) + end + + restoreCanvas(previousCanvas) + love.graphics.pop() + love.graphics.setShader(previousShader) + love.graphics.setBlendMode(previousBlend, previousAlphaMode) + love.graphics.setLineWidth(previousLineWidth) + love.graphics.setColor(r, g, b, a) + + return canvas +end + +local function imageFromUpload(upload, fallbackName) + if type(upload) ~= "table" then + return nil, "Texture upload is missing" + end + local raw = decodeBase64(upload.dataBase64) + if not raw then + return nil, "Texture data is not valid base64" + end + local filename = tostring(upload.filename or fallbackName or "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 + pcall(image.setFilter, image, "nearest", "nearest") + return image +end + +local function makeFallbackTexture(size) + size = size or 64 + local imageData = love.image.newImageData(size, size) + for y = 0, size - 1 do + for x = 0, size - 1 do + local v = ((x * 37 + y * 17) % 255) / 255 + imageData:setPixel(x, y, v, 1 - v, ((x + y) % 255) / 255, 1) + end + end + local image = love.graphics.newImage(imageData) + pcall(image.setFilter, image, "nearest", "nearest") + return image +end + +local function sendTextureUniforms(shader, uniforms, uploads) + local retained = {} + local byUniform = {} + if type(uploads) == "table" then + for _, upload in ipairs(uploads) do + if type(upload) == "table" and upload.uniform then + byUniform[tostring(upload.uniform)] = upload + end + end + end + + if type(uniforms) == "table" then + for _, info in ipairs(uniforms) do + local uniform = type(info) == "table" and tostring(info.uniform or "") or "" + if uniform ~= "" then + local image = nil + local upload = byUniform[uniform] + if upload then + image = imageFromUpload(upload, tostring(upload.filename or uniform .. ".png")) + end + if not image then + image = makeFallbackTexture(64) + end + local ok, err = pcall(shader.send, shader, uniform, image) + if not ok then + return nil, "Could not bind texture uniform `" .. uniform .. "`: " .. tostring(err) + end + retained[#retained + 1] = image + end + end + end + + return retained +end + +local function cloneShaderParameterValue(value, parameterType) + if parameterType == "boolean" then + return value and tonumber(value) ~= 0 and 1 or 0 + end + if parameterType == "float" then + return tonumber(value) or 0 + end + if parameterType == "vec2" then + value = type(value) == "table" and value or {} + return { tonumber(value[1]) or 0, tonumber(value[2]) or 0 } + end + if parameterType == "vec3" then + value = type(value) == "table" and value or {} + return { tonumber(value[1]) or 0, tonumber(value[2]) or 0, tonumber(value[3]) or 0 } + end + if parameterType == "vec4" or parameterType == "color" then + value = type(value) == "table" and value or {} + return { tonumber(value[1]) or 0, tonumber(value[2]) or 0, tonumber(value[3]) or 0, tonumber(value[4]) or 1 } + end + return value +end + +local function sendShaderParameters(shader, parameters) + if type(parameters) ~= "table" then + return true + end + for _, parameter in ipairs(parameters) do + if type(parameter) == "table" then + local uniform = tostring(parameter.uniform or "") + local parameterType = tostring(parameter.type or "") + if uniform ~= "" and parameterType ~= "texture" then + local value = cloneShaderParameterValue(parameter.defaultValue, parameterType) + pcall(shader.send, shader, uniform, value) + end + end + end + return true end local function compileShader(params) @@ -37,6 +302,114 @@ local function compileShader(params) return { status = "ok" } end +function ShaderGraphPlugin:init(config) + Base.init(self, config) + self.preview = nil +end + +function ShaderGraphPlugin:_previewShader(params) + if not love or not love.graphics then + return { status = "error", pixelError = "Shader preview requires love.graphics." } + end + + local shape = tostring(params.shape or "circle") + if not PREVIEW_SHAPES[shape] then + shape = "circle" + end + + local shader, pixelError, vertexError = buildShader(params.pixelSource, params.vertexSource) + if not shader then + return { status = "error", pixelError = pixelError, vertexError = vertexError } + end + + local color = previewColor(params.color) + local drawable + local baseTexture = params.baseTexture + if type(baseTexture) == "table" and baseTexture.dataBase64 then + local image, imageErr = imageFromUpload(baseTexture, "preview-texture.png") + if not image then + return { status = "error", pixelError = imageErr } + end + drawable = image + else + local okCanvas, canvasOrErr = pcall(makePreviewCanvas, shape, params.size, color) + if not okCanvas then + return { status = "error", pixelError = tostring(canvasOrErr) } + end + drawable = canvasOrErr + end + + local textures, textureErr = sendTextureUniforms(shader, params.textureUniforms, params.textures) + if not textures then + return { status = "error", pixelError = textureErr } + end + local parametersOk, parameterErr = sendShaderParameters(shader, params.parameters) + if not parametersOk then + return { status = "error", pixelError = parameterErr } + end + + self.preview = { + shader = shader, + drawable = drawable, + shape = shape, + color = color, + baseTexture = type(baseTexture) == "table" and baseTexture.filename or nil, + textures = textures, + updatedAt = love.timer and love.timer.getTime() or os.clock(), + } + + return { status = "ok", shape = shape, color = color } +end + +function ShaderGraphPlugin:onDraw() + if not self.preview or not love or not love.graphics then + return + end + + local drawable = self.preview.drawable + local shader = self.preview.shader + if not drawable or not shader then + return + end + + local previousBlend, previousAlphaMode = love.graphics.getBlendMode() + local previousShader = love.graphics.getShader() + local r, g, b, a = love.graphics.getColor() + local previousLineWidth = love.graphics.getLineWidth() + local width = love.graphics.getWidth() + local height = love.graphics.getHeight() + local previewSize = math.min(280, math.max(128, math.min(width, height) * 0.42)) + local scale = previewSize / drawable:getWidth() + local x = (width - previewSize) / 2 + local y = (height - previewSize) / 2 + + love.graphics.push() + love.graphics.origin() + love.graphics.setShader() + love.graphics.setBlendMode("alpha") + love.graphics.setColor(0.04, 0.05, 0.07, 0.74) + love.graphics.rectangle("fill", x - 10, y - 10, previewSize + 20, previewSize + 42, 6, 6) + love.graphics.setColor(1, 1, 1, 0.22) + love.graphics.setLineWidth(1) + love.graphics.rectangle("line", x - 10, y - 10, previewSize + 20, previewSize + 42, 6, 6) + love.graphics.setColor(1, 1, 1, 0.72) + local label = self.preview.baseTexture or self.preview.shape + love.graphics.print("Shader Preview: " .. label, x - 4, y + previewSize + 12) + + if shader.send and love.timer then + pcall(shader.send, shader, "u_time", love.timer.getTime()) + end + love.graphics.setShader(shader) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(drawable, x, y, 0, scale, scale) + + love.graphics.setBlendMode(previousBlend, previousAlphaMode) + love.graphics.setShader(previousShader) + love.graphics.setColor(r, g, b, a) + love.graphics.setLineWidth(previousLineWidth) + love.graphics.pop() +end + function ShaderGraphPlugin:handleActionRequest(request) local params = request.params or {} local action = params.action @@ -45,6 +418,15 @@ function ShaderGraphPlugin:handleActionRequest(request) return compileShader(params) end + if action == "preview-shader" then + return self:_previewShader(params) + end + + if action == "clear-preview" then + self.preview = nil + return { status = "ok" } + end + return { status = "error", pixelError = "Unknown shader graph action: " .. tostring(action) } end @@ -52,6 +434,12 @@ function ShaderGraphPlugin:getConfig() return { type = "shader-graph", icon = "blend", + preview = self.preview and { + shape = self.preview.shape, + color = self.preview.color, + baseTexture = self.preview.baseTexture, + active = true, + } or nil, } end diff --git a/src/components/code.tsx b/src/components/code.tsx index 41ca5d41..b0bde172 100644 --- a/src/components/code.tsx +++ b/src/components/code.tsx @@ -46,7 +46,6 @@ export function TraceViewer({ onFileClick?: (file: string, line?: number) => void; trace: string; }) { - const highlightLine = (line: string, index: number) => { // Clickable file:line pattern const filePattern = /([\w./\\-]+\.lua):(\d+)/g; diff --git a/src/components/ui/glsl-code-input.tsx b/src/components/ui/glsl-code-input.tsx new file mode 100644 index 00000000..78024975 --- /dev/null +++ b/src/components/ui/glsl-code-input.tsx @@ -0,0 +1,102 @@ +import { useEffect, useRef, type CSSProperties } 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'; + +interface GlslCodeInputProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + autoFocus?: boolean; + className?: string; + maxHeight?: number; +} + +const sharedStyle: CSSProperties = { + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', + fontSize: '0.75rem', + lineHeight: '1.55', + padding: '10px 12px', +}; + +export function GlslCodeInput({ + value, + onChange, + placeholder, + autoFocus, + className, + maxHeight = 420, +}: GlslCodeInputProps) { + const textareaRef = useRef(null); + const theme = useTheme(); + const highlightTheme = theme === 'dark' ? onDark : oneLight; + + const resize = () => { + const el = textareaRef.current; + if (!el) return; + el.style.height = 'auto'; + el.style.height = Math.min(el.scrollHeight, maxHeight) + 'px'; + }; + + useEffect(() => { + resize(); + }, [value, maxHeight]); + + return ( +
+
+ ).hljs, + background: 'transparent', + padding: 0, + }, + }} + customStyle={{ + ...sharedStyle, + background: 'transparent', + margin: 0, + minHeight: 180, + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + overflow: 'visible', + }} + showLineNumbers={false} + wrapLines + > + {value + ' '} + +
+