diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 37acb1e9..b77c5725 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -13,7 +13,7 @@ "name": "accessibility-kanban", "source": "extensions/accessibility-kanban", "description": "Kanban board to manage accessibility issues, allow you to plan, track, and complete remediation work.", - "version": "1.0.1" + "version": "1.0.2" }, { "name": "acreadiness-cockpit", @@ -85,13 +85,13 @@ "name": "apng-studio", "source": "extensions/apng-studio", "description": "Interactive GitHub Copilot app canvas extension for building Animated PNG (APNG) files from frames. Draw or upload frames, tune per-frame timing and compositing, preview live, send the result to your phone by QR, and export an animated .png.", - "version": "1.0.1" + "version": "1.0.2" }, { "name": "arcade-canvas", "source": "extensions/arcade-canvas", "description": "Play five retro Phaser mini-games in a Copilot canvas while agents work.", - "version": "1.0.1" + "version": "1.0.2" }, { "name": "arch", @@ -158,7 +158,7 @@ "name": "backlog-swipe-triage", "source": "extensions/backlog-swipe-triage", "description": "Quickly swipe through backlog issues to triage decisions like assign, needs-info, defer, close, or ignore.", - "version": "1.0.1" + "version": "1.0.2" }, { "name": "cast-imaging", @@ -195,7 +195,7 @@ "name": "chromium-control-canvas", "source": "extensions/chromium-control-canvas", "description": "Opens a real Chromium window you can navigate and interact with from a Copilot canvas control panel and agent actions.", - "version": "1.0.1" + "version": "1.0.2" }, { "name": "clojure-interactive-programming", @@ -239,13 +239,13 @@ "name": "color-orb", "source": "extensions/color-orb", "description": "A visual orb that users can ask the agent to recolor while showing a live activity log in the canvas.", - "version": "1.0.1" + "version": "1.0.2" }, { "name": "connector-namespaces", "source": "extensions/connector-namespaces", "description": "Browse, connect, and open MCP connectors from an Azure Connector Namespace.", - "version": "1.1.1" + "version": "1.1.2" }, { "name": "context-engineering", @@ -373,7 +373,7 @@ "name": "diagram-viewer", "source": "extensions/diagram-viewer", "description": "Render diagrams, click nodes to drill down, and view agent-generated explanations directly in the canvas.", - "version": "1.0.1" + "version": "1.0.2" }, { "name": "dotnet", @@ -520,7 +520,7 @@ "name": "feedback-themes", "source": "extensions/feedback-themes", "description": "Explore grouped customer feedback signals by impact and drill into a theme to guide product next steps.", - "version": "1.0.1" + "version": "1.0.2" }, { "name": "figma", @@ -593,7 +593,7 @@ "name": "gesture-review", "source": "extensions/gesture-review", "description": "Review pull requests with a live camera feed and approve or reject using thumbs-up/thumbs-down gestures.", - "version": "1.0.1" + "version": "1.0.2" }, { "name": "gh-skills-builder", @@ -701,7 +701,7 @@ "name": "java-modernization-studio", "source": "extensions/java-modernization-studio", "description": "Drive the GitHub Copilot App Modernization for Java workflow from an interactive canvas: environment readiness, repo assessment, prioritized plan and progress, validation gates, and one-click predefined-task runs grounded in the repo's real artifacts.", - "version": "1.0.1" + "version": "1.0.2" }, { "name": "kotlin-mcp-development", @@ -980,13 +980,13 @@ "name": "release-notes-showcase", "source": "extensions/release-notes-showcase", "description": "Compose and refine launch-ready release notes with contributor callouts and export-friendly output.", - "version": "1.0.1" + "version": "1.0.2" }, { "name": "repo-actions-hub", "source": "extensions/repo-actions-hub", "description": "Browse repository GitHub Actions workflows, inspect recent runs, and trigger manual workflow_dispatch runs from a Copilot canvas.", - "version": "1.0.1" + "version": "1.0.2" }, { "name": "roundup", @@ -1028,7 +1028,7 @@ "name": "site-studio", "source": "extensions/site-studio", "description": "Plan, draft, and track a personal website section by section — a shared canvas where you and your agent author content, watch progress, and review every change.", - "version": "1.0.1" + "version": "1.0.2" }, { "name": "skill-image-gen", @@ -1118,13 +1118,13 @@ "name": "tiny-tool-town-submitter", "source": "extensions/tiny-tool-town-submitter", "description": "Inspect a repository, improve Tiny Tool Town readiness, submit its listing issue, and launch remediation work.", - "version": "1.0.1" + "version": "1.0.2" }, { "name": "token-pacman", "source": "extensions/token-pacman", "description": "Visualizes live session AI-credit usage as a Pac-Man board with pellets, ghosts, fruit milestones, and game-over limits.", - "version": "1.0.1" + "version": "1.0.2" }, { "name": "typescript-mcp-development", @@ -1335,7 +1335,7 @@ "name": "where-was-i", "source": "extensions/where-was-i", "description": "Reconstruct your dev context (branch, commits, uncommitted work, PR clues) and trigger a resume prompt to continue quickly.", - "version": "1.0.1" + "version": "1.0.2" }, { "name": "winappcli", @@ -1399,7 +1399,7 @@ "name": "work-hub", "source": "extensions/work-hub", "description": "Generic cross-repo command center canvas for GitHub Copilot with onboarding, focus planning, repo health, work signals, and session cleanup.", - "version": "1.0.1" + "version": "1.0.2" } ] } diff --git a/eng/clean-materialized-plugins.mjs b/eng/clean-materialized-plugins.mjs index 2fcbacc2..6b034276 100644 --- a/eng/clean-materialized-plugins.mjs +++ b/eng/clean-materialized-plugins.mjs @@ -28,6 +28,41 @@ const MATERIALIZED_SPECS = { }, }; +function copyDirRecursive(src, dest) { + fs.mkdirSync(dest, { recursive: true }); + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + if (entry.isDirectory()) { + copyDirRecursive(srcPath, destPath); + } else { + fs.copyFileSync(srcPath, destPath); + } + } +} + +function moveEntry(srcPath, destPath) { + fs.mkdirSync(path.dirname(destPath), { recursive: true }); + try { + fs.renameSync(srcPath, destPath); + return; + } catch (error) { + if (!["EXDEV", "EEXIST", "ENOTEMPTY", "EPERM"].includes(error?.code)) { + throw error; + } + } + + const stats = fs.statSync(srcPath); + if (stats.isDirectory()) { + copyDirRecursive(srcPath, destPath); + fs.rmSync(srcPath, { recursive: true, force: true }); + return; + } + + fs.copyFileSync(srcPath, destPath); + fs.rmSync(srcPath, { force: true }); +} + export function restoreManifestFromMaterializedFiles(pluginPath) { const pluginJsonPath = path.join(pluginPath, ".github/plugin", "plugin.json"); if (!fs.existsSync(pluginJsonPath)) { @@ -90,15 +125,22 @@ function cleanPlugin(pluginPath) { return { removed, manifestUpdated }; } -function cleanMaterializedExtensionPlugin(extensionPath) { +export function cleanMaterializedExtensionPlugin(extensionPath) { const pluginJsonPath = path.join(extensionPath, ".github", "plugin", "plugin.json"); let manifestUpdated = false; if (fs.existsSync(pluginJsonPath)) { const plugin = JSON.parse(fs.readFileSync(pluginJsonPath, "utf8")); + const extensionBundlePrefix = `extensions/${path.basename(extensionPath)}/`; if (plugin.extensions === "extensions") { plugin.extensions = "."; - fs.writeFileSync(pluginJsonPath, JSON.stringify(plugin, null, 2) + "\n", "utf8"); manifestUpdated = true; + } + if (typeof plugin.logo === "string" && plugin.logo.startsWith(extensionBundlePrefix)) { + plugin.logo = plugin.logo.slice(extensionBundlePrefix.length); + manifestUpdated = true; + } + if (manifestUpdated) { + fs.writeFileSync(pluginJsonPath, JSON.stringify(plugin, null, 2) + "\n", "utf8"); console.log(` Updated ${path.basename(extensionPath)}/.github/plugin/plugin.json`); } } @@ -108,12 +150,43 @@ function cleanMaterializedExtensionPlugin(extensionPath) { return { removed: 0, manifestUpdated }; } + const bundleRoot = path.join(target, path.basename(extensionPath)); const count = countFiles(target); + if (fs.existsSync(bundleRoot) && fs.statSync(bundleRoot).isDirectory()) { + for (const entry of fs.readdirSync(bundleRoot, { withFileTypes: true })) { + moveEntry(path.join(bundleRoot, entry.name), path.join(extensionPath, entry.name)); + } + console.log(` Restored ${path.basename(extensionPath)}/ from materialized extensions bundle`); + } + fs.rmSync(target, { recursive: true, force: true }); console.log(` Removed ${path.basename(extensionPath)}/extensions/ (${count} files)`); return { removed: count, manifestUpdated }; } +function isExtensionPluginDirectory(extensionPath) { + if (fs.existsSync(path.join(extensionPath, "extension.mjs"))) { + return true; + } + + const bundleEntry = path.join(extensionPath, "extensions", path.basename(extensionPath), "extension.mjs"); + if (fs.existsSync(bundleEntry)) { + return true; + } + + const pluginJsonPath = path.join(extensionPath, ".github", "plugin", "plugin.json"); + if (!fs.existsSync(pluginJsonPath)) { + return false; + } + + try { + const plugin = JSON.parse(fs.readFileSync(pluginJsonPath, "utf8")); + return plugin.extensions === "extensions"; + } catch { + return false; + } +} + function countFiles(dir) { let count = 0; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { @@ -204,7 +277,7 @@ function main() { for (const dirName of extensionDirs) { const extensionPath = path.join(EXTENSIONS_DIR, dirName); - if (!fs.existsSync(path.join(extensionPath, "extension.mjs"))) { + if (!isExtensionPluginDirectory(extensionPath)) { continue; } const { removed, manifestUpdated } = cleanMaterializedExtensionPlugin(extensionPath); diff --git a/eng/generate-marketplace.mjs b/eng/generate-marketplace.mjs index 45a54b21..6c40c463 100755 --- a/eng/generate-marketplace.mjs +++ b/eng/generate-marketplace.mjs @@ -62,6 +62,16 @@ function collectLocalPluginsFromRoot(rootDir, sourcePrefix, includeEntry = () => return plugins; } +function hasExtensionEntryPoint(extensionDir, extensionName) { + const candidateEntryPoints = [ + path.join(extensionDir, "extension.mjs"), + path.join(extensionDir, "extensions", "extension.mjs"), + path.join(extensionDir, "extensions", extensionName, "extension.mjs"), + ]; + + return candidateEntryPoints.some((entryPointPath) => fs.existsSync(entryPointPath)); +} + /** * Generate marketplace.json from plugin directories */ @@ -78,7 +88,7 @@ function generateMarketplace() { ...collectLocalPluginsFromRoot( EXTENSIONS_DIR, "extensions", - (entryName) => fs.existsSync(path.join(EXTENSIONS_DIR, entryName, "extension.mjs")) + (entryName) => hasExtensionEntryPoint(path.join(EXTENSIONS_DIR, entryName), entryName) ) ]; diff --git a/eng/generate-website-data.mjs b/eng/generate-website-data.mjs index ef052693..f6fc2082 100755 --- a/eng/generate-website-data.mjs +++ b/eng/generate-website-data.mjs @@ -35,6 +35,16 @@ const WEBSITE_SOURCE_DATA_DIR = path.join(WEBSITE_DIR, "data"); const EXTERNAL_CANVAS_KEYWORD = "canvas"; const EXTERNAL_CANVAS_PREVIEW_PATH = "assets/preview.png"; +function hasExtensionEntryPoint(extensionDir, extensionName) { + const candidateEntryPoints = [ + path.join(extensionDir, "extension.mjs"), + path.join(extensionDir, "extensions", "extension.mjs"), + path.join(extensionDir, "extensions", extensionName, "extension.mjs"), + ]; + + return candidateEntryPoints.some((entryPointPath) => fs.existsSync(entryPointPath)); +} + /** * Ensure the output directory exists */ @@ -544,7 +554,7 @@ function generatePluginsData(gitDates, resourceIndex = {}) { const extensionDirs = fs.readdirSync(EXTENSIONS_DIR, { withFileTypes: true }) .filter((entry) => { if (!entry.isDirectory()) return false; - return fs.existsSync(path.join(EXTENSIONS_DIR, entry.name, "extension.mjs")); + return hasExtensionEntryPoint(path.join(EXTENSIONS_DIR, entry.name), entry.name); }) .map((entry) => entry.name) .sort((a, b) => a.localeCompare(b)); @@ -1235,12 +1245,7 @@ function generateCanvasManifest(gitDates, commitSha) { .readdirSync(EXTENSIONS_DIR, { withFileTypes: true }) .filter((entry) => { if (!entry.isDirectory()) return false; - const extensionEntryPoint = path.join( - EXTENSIONS_DIR, - entry.name, - "extension.mjs" - ); - return fs.existsSync(extensionEntryPoint); + return hasExtensionEntryPoint(path.join(EXTENSIONS_DIR, entry.name), entry.name); }) .sort((a, b) => a.name.localeCompare(b.name)); diff --git a/eng/materialize-plugins.mjs b/eng/materialize-plugins.mjs index 2d91cb61..905b2faf 100644 --- a/eng/materialize-plugins.mjs +++ b/eng/materialize-plugins.mjs @@ -24,15 +24,34 @@ function copyDirRecursive(src, dest) { } } -function copyEntryRecursive(srcPath, destPath) { +function moveEntry(srcPath, destPath) { + fs.mkdirSync(path.dirname(destPath), { recursive: true }); + try { + fs.renameSync(srcPath, destPath); + return; + } catch (error) { + if (error?.code !== "EXDEV") { + throw error; + } + } + const stats = fs.statSync(srcPath); if (stats.isDirectory()) { copyDirRecursive(srcPath, destPath); + fs.rmSync(srcPath, { recursive: true, force: true }); return; } - fs.mkdirSync(path.dirname(destPath), { recursive: true }); fs.copyFileSync(srcPath, destPath); + fs.rmSync(srcPath, { force: true }); +} + +function isRelativeAssetPath(assetPath) { + return typeof assetPath === "string" && + assetPath.length > 0 && + !/^(?:[a-z][a-z0-9+.-]*:)?\/\//i.test(assetPath) && + !assetPath.startsWith("data:") && + !path.isAbsolute(assetPath); } /** @@ -61,7 +80,7 @@ function resolveSource(relPath) { export function materializeExtensionPlugin(extensionPath) { const pluginJsonPath = path.join(extensionPath, ".github", "plugin", "plugin.json"); if (!fs.existsSync(pluginJsonPath)) { - return { copiedEntries: 0, manifestUpdated: false, skipped: true }; + return { movedEntries: 0, manifestUpdated: false, skipped: true }; } let metadata; @@ -72,20 +91,31 @@ export function materializeExtensionPlugin(extensionPath) { } const extensionContainerPath = path.join(extensionPath, "extensions"); + const extensionBundlePath = path.join(extensionContainerPath, path.basename(extensionPath)); fs.rmSync(extensionContainerPath, { recursive: true, force: true }); - fs.mkdirSync(extensionContainerPath, { recursive: true }); + fs.mkdirSync(extensionBundlePath, { recursive: true }); - let copiedEntries = 0; + let movedEntries = 0; for (const entry of fs.readdirSync(extensionPath, { withFileTypes: true })) { if (entry.name === ".github" || entry.name === "extensions") { continue; } - copyEntryRecursive( + moveEntry( path.join(extensionPath, entry.name), - path.join(extensionContainerPath, entry.name) + path.join(extensionBundlePath, entry.name) ); - copiedEntries++; + movedEntries++; + } + + if (isRelativeAssetPath(metadata.logo)) { + const normalizedLogoPath = metadata.logo.replace(/\\/g, "/").replace(/^\.\//, ""); + const bundledLogoPath = path.join(extensionBundlePath, normalizedLogoPath); + if (fs.existsSync(bundledLogoPath)) { + const rootLogoPath = path.join(extensionPath, normalizedLogoPath); + fs.mkdirSync(path.dirname(rootLogoPath), { recursive: true }); + fs.copyFileSync(bundledLogoPath, rootLogoPath); + } } let manifestUpdated = false; @@ -97,7 +127,7 @@ export function materializeExtensionPlugin(extensionPath) { fs.writeFileSync(pluginJsonPath, JSON.stringify(metadata, null, 2) + "\n", "utf8"); } - return { copiedEntries, manifestUpdated, skipped: false }; + return { movedEntries, manifestUpdated, skipped: false }; } function materializePlugins() { @@ -261,8 +291,8 @@ function materializePlugins() { } totalExtensionPlugins++; - totalExtensionPluginEntries += result.copiedEntries; - console.log(`✓ ${dirName}: materialized extension bundle into ./extensions (${result.copiedEntries} entries)`); + totalExtensionPluginEntries += result.movedEntries; + console.log(`✓ ${dirName}: materialized extension bundle into ./extensions (${result.movedEntries} entries)`); } catch (err) { console.error(`Error: Failed to materialize extension plugin ${dirName}: ${err.message}`); errors++; diff --git a/eng/materialize-plugins.test.mjs b/eng/materialize-plugins.test.mjs index 7f42981c..d0bce457 100644 --- a/eng/materialize-plugins.test.mjs +++ b/eng/materialize-plugins.test.mjs @@ -4,6 +4,7 @@ import os from "os"; import path from "path"; import { after, test } from "node:test"; import { materializeExtensionPlugin } from "./materialize-plugins.mjs"; +import { cleanMaterializedExtensionPlugin } from "./clean-materialized-plugins.mjs"; const tempDirs = []; @@ -13,7 +14,7 @@ after(() => { } }); -test("materializeExtensionPlugin writes extension bundles to ./extensions and rewrites manifest", () => { +test("materializeExtensionPlugin writes extension bundles to ./extensions and preserves root logo assets", () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "materialize-extension-plugin-")); tempDirs.push(tempDir); @@ -32,17 +33,57 @@ test("materializeExtensionPlugin writes extension bundles to ./extensions and re fs.writeFileSync(path.join(pluginDir, "assets", "preview.png"), "fake-image-bytes"); const result = materializeExtensionPlugin(pluginDir); + const bundleRoot = path.join(pluginDir, "extensions", "extension-plugin"); assert.equal(result.skipped, false); assert.equal(result.manifestUpdated, true); - assert.equal(result.copiedEntries, 3); - assert.equal(fs.existsSync(path.join(pluginDir, "extensions", "extension.mjs")), true); - assert.equal(fs.existsSync(path.join(pluginDir, "extensions", "assets", "preview.png")), true); - assert.equal(fs.existsSync(path.join(pluginDir, "extensions", "README.md")), true); + assert.equal(result.movedEntries, 3); + assert.equal(fs.existsSync(path.join(bundleRoot, "extension.mjs")), true); + assert.equal(fs.existsSync(path.join(bundleRoot, "assets", "preview.png")), true); + assert.equal(fs.existsSync(path.join(bundleRoot, "README.md")), true); assert.equal(fs.existsSync(path.join(pluginDir, "extensions", ".github")), false); + assert.equal(fs.existsSync(path.join(pluginDir, "extension.mjs")), false); + assert.equal(fs.existsSync(path.join(pluginDir, "README.md")), false); + assert.equal(fs.existsSync(path.join(pluginDir, "assets", "preview.png")), true); const pluginManifest = JSON.parse( fs.readFileSync(path.join(pluginDir, ".github", "plugin", "plugin.json"), "utf8") ); assert.equal(pluginManifest.extensions, "extensions"); + assert.equal(pluginManifest.logo, "assets/preview.png"); +}); + +test("cleanMaterializedExtensionPlugin restores moved extension files to root", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "clean-materialized-extension-plugin-")); + tempDirs.push(tempDir); + + const pluginDir = path.join(tempDir, "extension-plugin"); + fs.mkdirSync(path.join(pluginDir, ".github", "plugin"), { recursive: true }); + fs.mkdirSync(path.join(pluginDir, "assets"), { recursive: true }); + fs.writeFileSync(path.join(pluginDir, ".github", "plugin", "plugin.json"), JSON.stringify({ + name: "test-extension-plugin", + description: "test plugin", + version: "1.0.0", + logo: "assets/preview.png", + extensions: ".", + }, null, 2)); + fs.writeFileSync(path.join(pluginDir, "extension.mjs"), "export default {};\n"); + fs.writeFileSync(path.join(pluginDir, "README.md"), "# test\n"); + fs.writeFileSync(path.join(pluginDir, "assets", "preview.png"), "fake-image-bytes"); + + materializeExtensionPlugin(pluginDir); + const result = cleanMaterializedExtensionPlugin(pluginDir); + + assert.equal(result.removed, 3); + assert.equal(result.manifestUpdated, true); + assert.equal(fs.existsSync(path.join(pluginDir, "extension.mjs")), true); + assert.equal(fs.existsSync(path.join(pluginDir, "README.md")), true); + assert.equal(fs.existsSync(path.join(pluginDir, "assets", "preview.png")), true); + assert.equal(fs.existsSync(path.join(pluginDir, "extensions")), false); + + const pluginManifest = JSON.parse( + fs.readFileSync(path.join(pluginDir, ".github", "plugin", "plugin.json"), "utf8") + ); + assert.equal(pluginManifest.extensions, "."); + assert.equal(pluginManifest.logo, "assets/preview.png"); }); diff --git a/extensions/accessibility-kanban/.github/plugin/plugin.json b/extensions/accessibility-kanban/.github/plugin/plugin.json index 87e98225..02a083cb 100644 --- a/extensions/accessibility-kanban/.github/plugin/plugin.json +++ b/extensions/accessibility-kanban/.github/plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "accessibility-kanban", "description": "Kanban board to manage accessibility issues, allow you to plan, track, and complete remediation work.", - "version": "1.0.1", + "version": "1.0.2", "author": { "name": "Aaron Powell", "url": "https://github.com/aaronpowell" diff --git a/extensions/accessibility-kanban/extensions/assets/preview.png b/extensions/accessibility-kanban/extensions/accessibility-kanban/assets/preview.png similarity index 100% rename from extensions/accessibility-kanban/extensions/assets/preview.png rename to extensions/accessibility-kanban/extensions/accessibility-kanban/assets/preview.png diff --git a/extensions/accessibility-kanban/extension.mjs b/extensions/accessibility-kanban/extensions/accessibility-kanban/extension.mjs similarity index 100% rename from extensions/accessibility-kanban/extension.mjs rename to extensions/accessibility-kanban/extensions/accessibility-kanban/extension.mjs diff --git a/extensions/accessibility-kanban/extensions/package.json b/extensions/accessibility-kanban/extensions/accessibility-kanban/package.json similarity index 100% rename from extensions/accessibility-kanban/extensions/package.json rename to extensions/accessibility-kanban/extensions/accessibility-kanban/package.json diff --git a/extensions/accessibility-kanban/extensions/public/index.html b/extensions/accessibility-kanban/extensions/accessibility-kanban/public/index.html similarity index 100% rename from extensions/accessibility-kanban/extensions/public/index.html rename to extensions/accessibility-kanban/extensions/accessibility-kanban/public/index.html diff --git a/extensions/accessibility-kanban/extensions/extension.mjs b/extensions/accessibility-kanban/extensions/extension.mjs deleted file mode 100644 index 777f205a..00000000 --- a/extensions/accessibility-kanban/extensions/extension.mjs +++ /dev/null @@ -1,728 +0,0 @@ -import { CanvasError, createCanvas, joinSession } from "@github/copilot-sdk/extension"; -import http from "node:http"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { spawnSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const EXTENSION_NAME = "accessibility-kanban"; -const STATE_FILE_PREFIX = "repository-issues-kanban-state"; -const COLUMNS = ["backlog", "plan", "ready", "implement", "done"]; -const VALID_COLUMNS = new Set(COLUMNS); -const REFRESH_ISSUES_ERROR = "Unable to refresh issues right now. Please try again."; - -let repoInfoCache = null; -let githubTokenCache; -let workspaceCwd = null; - -// The canvas request context reports the active session's *working directory* -// (i.e. the repo checkout). Note this is different from `session.workspacePath`, -// which points at the session-state folder (~/.copilot/session-state/) and is -// NOT a git repo — using it for git resolution is what caused the board to fail -// with "Unable to detect the current repository from this workspace." -function setWorkspaceCwd(dir) { - if (typeof dir !== "string" || !dir.trim() || dir === workspaceCwd) return; - workspaceCwd = dir; - repoInfoCache = null; - githubTokenCache = undefined; -} - -function captureCwd(ctx) { - setWorkspaceCwd(ctx?.session?.workingDirectory); -} - -function getWorkspaceCwd() { - return workspaceCwd || process.cwd(); -} - -// ─── Repo resolution ─── - -function runCommand(command, args, cwd = process.cwd()) { - try { - const result = spawnSync(command, args, { cwd, encoding: "utf8" }); - if (result.status === 0 && !result.error) { - return (result.stdout || "").trim(); - } - } catch { - // Ignore and fall through to empty string. - } - return ""; -} - -function normalizeRepo(repo) { - if (typeof repo !== "string") return null; - const cleaned = repo - .trim() - .replace(/^https?:\/\/github\.com\//i, "") - .replace(/\.git$/i, ""); - if (!/^[^/\s]+\/[^/\s]+$/.test(cleaned)) return null; - return cleaned; -} - -function parseRepoFromRemoteUrl(remoteUrl) { - if (!remoteUrl) return null; - const cleaned = remoteUrl.trim().replace(/\.git$/i, ""); - - const sshMatch = cleaned.match(/^[^@]+@[^:]+:([^/]+\/[^/]+)$/); - if (sshMatch) return sshMatch[1]; - - const httpMatch = cleaned.match(/^https?:\/\/[^/]+\/([^/]+\/[^/]+)$/i); - if (httpMatch) return httpMatch[1]; - - const fallbackMatch = cleaned.match(/[:/]([^/:]+\/[^/:]+)$/); - return fallbackMatch ? fallbackMatch[1] : null; -} - -function candidateCwds(preferredCwd) { - const candidates = [ - preferredCwd, - workspaceCwd, - process.cwd(), - __dirname, - path.dirname(__dirname), - path.dirname(path.dirname(__dirname)), - path.dirname(path.dirname(path.dirname(__dirname))), - ].filter(Boolean); - return [...new Set(candidates)]; -} - -function resolveRepoFromGit(cwd) { - const gitRoot = runCommand("git", ["rev-parse", "--show-toplevel"], cwd); - if (!gitRoot) return null; - - const fromGh = normalizeRepo(runCommand("gh", ["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"], gitRoot)); - if (fromGh) return fromGh; - - const remoteUrl = runCommand("git", ["remote", "get-url", "origin"], gitRoot) || runCommand("git", ["config", "--get", "remote.origin.url"], gitRoot); - return normalizeRepo(parseRepoFromRemoteUrl(remoteUrl)); -} - -function resolveCurrentRepoInfo(cwd = getWorkspaceCwd()) { - const fromEnv = normalizeRepo(process.env.GITHUB_REPOSITORY || ""); - if (fromEnv) return { repo: fromEnv, error: null }; - - for (const candidate of candidateCwds(cwd)) { - const repo = resolveRepoFromGit(candidate); - if (repo) return { repo, error: null }; - } - - return { - repo: "unknown/unknown", - error: "Unable to detect the current repository from this workspace.", - }; -} - -function getRepoInfo() { - const cwd = getWorkspaceCwd(); - if (!repoInfoCache || repoInfoCache.cwd !== cwd) { - const resolved = resolveCurrentRepoInfo(cwd); - repoInfoCache = { ...resolved, cwd }; - } - return repoInfoCache; -} - -// ─── State persistence ─── - -function copilotHome() { - return process.env.COPILOT_HOME || path.join(os.homedir(), ".copilot"); -} - -function stateFileName(repo) { - const key = String(repo || "unknown-unknown") - .toLowerCase() - .replace(/[^\w.-]+/g, "-"); - return `${STATE_FILE_PREFIX}-${key}.json`; -} - -function getStatePath(repo) { - return path.join(copilotHome(), "extensions", EXTENSION_NAME, "artifacts", stateFileName(repo)); -} - -function ensureStateDirectory(repo) { - fs.mkdirSync(path.dirname(getStatePath(repo)), { recursive: true }); -} - -function defaultState(repoInfo = getRepoInfo()) { - return { - repo: repoInfo.repo, - error: repoInfo.error, - updatedAt: new Date().toISOString(), - generation: Date.now(), - columns: COLUMNS, - availableLabels: [], - selectedLabels: [], - issues: [], - }; -} - -function normalizeLabelList(labels) { - const unique = new Set(); - for (const label of Array.isArray(labels) ? labels : []) { - if (typeof label === "string" && label.trim()) unique.add(label.trim()); - } - return [...unique]; -} - -function computeAvailableLabels(issues) { - const labels = new Set(); - for (const issue of Array.isArray(issues) ? issues : []) { - for (const label of normalizeLabelList(issue.labels)) labels.add(label); - } - return [...labels].sort((a, b) => a.localeCompare(b)); -} - -function normalizeIssue(issue, repo, idx) { - if (!issue || !Number.isInteger(issue.number) || !issue.title) return null; - return { - number: issue.number, - title: issue.title, - url: issue.url || `https://github.com/${repo}/issues/${issue.number}`, - labels: normalizeLabelList(issue.labels), - column: VALID_COLUMNS.has(issue.column) ? issue.column : "backlog", - priority: issue.priority || "medium", - order: Number.isInteger(issue.order) ? issue.order : idx, - agentStatus: typeof issue.agentStatus === "string" ? issue.agentStatus : "", - agentActive: Boolean(issue.agentActive), - logs: Array.isArray(issue.logs) ? issue.logs : [], - }; -} - -function normalizeState(rawState, repoInfo = getRepoInfo()) { - const repo = repoInfo.repo; - const issues = Array.isArray(rawState?.issues) - ? rawState.issues.map((issue, idx) => normalizeIssue(issue, repo, idx)).filter(Boolean) - : []; - const availableLabels = computeAvailableLabels(issues); - - return { - repo, - error: repoInfo.error || (rawState?.error === REFRESH_ISSUES_ERROR ? REFRESH_ISSUES_ERROR : null), - updatedAt: rawState?.updatedAt || new Date().toISOString(), - generation: rawState?.generation || Date.now(), - columns: Array.isArray(rawState?.columns) && rawState.columns.length ? rawState.columns : COLUMNS, - availableLabels, - selectedLabels: normalizeLabelList(rawState?.selectedLabels).filter((label) => availableLabels.includes(label)), - issues, - }; -} - -function loadState(repo) { - try { - return JSON.parse(fs.readFileSync(getStatePath(repo), "utf8")); - } catch { - return null; - } -} - -function saveState(state) { - ensureStateDirectory(state.repo); - fs.writeFileSync( - getStatePath(state.repo), - JSON.stringify({ ...state, updatedAt: new Date().toISOString() }, null, 2), - ); -} - -function currentState() { - const repoInfo = getRepoInfo(); - const loaded = loadState(repoInfo.repo); - const normalized = normalizeState(loaded || defaultState(repoInfo), repoInfo); - if (!loaded) saveState(normalized); - return normalized; -} - -// ─── Issue operations ─── - -function moveIssue(issueNumber, column) { - if (!VALID_COLUMNS.has(column)) { - throw new CanvasError("invalid_column", `Column must be one of: ${COLUMNS.join(", ")}`); - } - const state = currentState(); - const issue = state.issues.find((i) => i.number === issueNumber); - if (!issue) { - throw new CanvasError("not_found", `Issue #${issueNumber} not found on the board`); - } - - const prevColumn = issue.column; - issue.column = column; - issue.order = state.issues.filter((i) => i.column === column).length; - - if (column === "done" || column === "backlog") { - issue.agentActive = false; - issue.agentStatus = column === "done" ? "Complete" : ""; - } - - saveState(state); - broadcast("state", state); - return { issue, prevColumn }; -} - -function updateIssueStatus(issueNumber, status, logEntry) { - const state = currentState(); - const issue = state.issues.find((i) => i.number === issueNumber); - if (!issue) { - throw new CanvasError("not_found", `Issue #${issueNumber} not found on the board`); - } - - if (issue.column === "backlog") return issue; - - if (status !== undefined) issue.agentStatus = status; - if (logEntry) { - if (!issue.logs) issue.logs = []; - issue.logs.push({ timestamp: new Date().toISOString(), message: logEntry }); - } - issue.agentActive = true; - saveState(state); - broadcast("state", state); - return issue; -} - -function clearAgentStatus(issueNumber) { - const state = currentState(); - const issue = state.issues.find((i) => i.number === issueNumber); - if (!issue) return; - issue.agentActive = false; - saveState(state); - broadcast("state", state); -} - -function replaceIssues(issues) { - const existing = currentState(); - const existingByNumber = new Map(existing.issues.map((i) => [i.number, i])); - - const nextIssues = (Array.isArray(issues) ? issues : []) - .filter((i) => i && Number.isInteger(i.number) && i.title) - .map((issue, idx) => { - const prev = existingByNumber.get(issue.number); - const labels = Array.isArray(issue.labels) - ? issue.labels.map((l) => (typeof l === "string" ? l : l?.name)).filter(Boolean) - : []; - return { - number: issue.number, - title: issue.title, - url: issue.url || `https://github.com/${existing.repo}/issues/${issue.number}`, - labels: normalizeLabelList(labels), - column: VALID_COLUMNS.has(issue.column) ? issue.column : prev?.column || "backlog", - priority: issue.priority || prev?.priority || "medium", - order: Number.isInteger(issue.order) ? issue.order : prev?.order ?? idx, - agentStatus: prev?.agentStatus || "", - agentActive: Boolean(prev?.agentActive), - logs: Array.isArray(prev?.logs) ? prev.logs : [], - }; - }); - - const availableLabels = computeAvailableLabels(nextIssues); - const next = { - ...existing, - issues: nextIssues, - availableLabels, - selectedLabels: normalizeLabelList(existing.selectedLabels).filter((label) => availableLabels.includes(label)), - error: getRepoInfo().error, - }; - saveState(next); - broadcast("state", next); - return next; -} - -function setSelectedLabels(labels) { - const state = currentState(); - state.selectedLabels = normalizeLabelList(labels).filter((label) => state.availableLabels.includes(label)); - saveState(state); - broadcast("state", state); - return state; -} - -function resetBoard() { - const state = currentState(); - const reset = { - ...state, - selectedLabels: [], - issues: state.issues.map((issue, idx) => ({ - ...issue, - column: "backlog", - order: idx, - agentStatus: "", - agentActive: false, - logs: [], - })), - }; - saveState(reset); - broadcast("state", reset); - return reset; -} - -// ─── GitHub issue sync ─── - -function resolveGitHubToken(cwd = getWorkspaceCwd()) { - if (githubTokenCache !== undefined) return githubTokenCache; - githubTokenCache = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || runCommand("gh", ["auth", "token"], cwd) || ""; - return githubTokenCache; -} - -function mapGitHubIssue(issue) { - return { - number: issue.number, - title: issue.title, - url: issue.html_url, - labels: (issue.labels || []).map((label) => (typeof label === "string" ? label : label.name)).filter(Boolean), - }; -} - -async function fetchOpenIssues(repo) { - if (!repo || repo === "unknown/unknown") { - throw new CanvasError("repo_unavailable", "Current repository could not be detected."); - } - - const [owner, repoName] = repo.split("/"); - const token = resolveGitHubToken(); - const headers = { - Accept: "application/vnd.github+json", - "User-Agent": "repository-issues-kanban", - }; - if (token) headers.Authorization = `token ${token}`; - - const allIssues = []; - let page = 1; - while (page <= 10) { - const params = new URLSearchParams({ - state: "open", - per_page: "100", - page: String(page), - }); - - const response = await fetch(`https://api.github.com/repos/${owner}/${repoName}/issues?${params}`, { headers }); - if (!response.ok) { - const body = await response.text(); - throw new CanvasError("github_api_error", `GitHub API request failed (${response.status}): ${body.slice(0, 200)}`); - } - - const pageItems = await response.json(); - const mapped = pageItems - .filter((item) => !item.pull_request) - .map(mapGitHubIssue); - allIssues.push(...mapped); - - if (pageItems.length < 100) break; - page += 1; - } - - return allIssues; -} - -function mergeFetchedIssues(existingState, fetchedIssues) { - const existingByNumber = new Map(existingState.issues.map((issue) => [issue.number, issue])); - - const mergedIssues = fetchedIssues.map((issue, idx) => { - const prev = existingByNumber.get(issue.number); - return { - number: issue.number, - title: issue.title, - url: issue.url || `https://github.com/${existingState.repo}/issues/${issue.number}`, - labels: normalizeLabelList(issue.labels), - column: VALID_COLUMNS.has(prev?.column) ? prev.column : "backlog", - priority: prev?.priority || "medium", - order: Number.isInteger(prev?.order) ? prev.order : idx, - agentStatus: prev?.agentStatus || "", - agentActive: Boolean(prev?.agentActive), - logs: Array.isArray(prev?.logs) ? prev.logs : [], - }; - }); - - const availableLabels = computeAvailableLabels(mergedIssues); - return { - ...existingState, - issues: mergedIssues, - availableLabels, - selectedLabels: normalizeLabelList(existingState.selectedLabels).filter((label) => availableLabels.includes(label)), - error: getRepoInfo().error, - }; -} - -async function refreshIssuesSafe() { - const state = currentState(); - if (state.repo === "unknown/unknown") { - saveState(state); - broadcast("state", state); - return state; - } - - try { - const fetchedIssues = await fetchOpenIssues(state.repo); - const merged = mergeFetchedIssues(state, fetchedIssues); - saveState(merged); - broadcast("state", merged); - return merged; - } catch (error) { - console.error("[accessibility-kanban] Failed to refresh issues", error); - const failed = { - ...state, - error: REFRESH_ISSUES_ERROR, - }; - saveState(failed); - broadcast("state", failed); - return failed; - } -} - -// ─── SSE ─── - -const sseClients = new Set(); - -function broadcast(event, data) { - const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; - for (const res of sseClients) res.write(msg); -} - -// ─── HTTP helpers ─── - -function readJson(req) { - return new Promise((resolve, reject) => { - let body = ""; - req.on("data", (c) => (body += c)); - req.on("end", () => resolve(body ? JSON.parse(body) : {})); - req.on("error", reject); - }); -} - -function json(res, code, data) { - res.writeHead(code, { "Content-Type": "application/json" }); - res.end(JSON.stringify(data)); -} - -// ─── HTTP server ─── - -const server = http.createServer(async (req, res) => { - const url = new URL(req.url, `http://${req.headers.host}`); - - if (url.pathname === "/events") { - res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" }); - sseClients.add(res); - req.on("close", () => sseClients.delete(res)); - res.write(`event: state\ndata: ${JSON.stringify(currentState())}\n\n`); - return; - } - - if (req.method === "GET" && url.pathname === "/api/state") { - json(res, 200, await refreshIssuesSafe()); - return; - } - - if (req.method === "POST" && url.pathname === "/api/move") { - const input = await readJson(req); - const { issue, prevColumn } = moveIssue(input.issue_number, input.column); - - if (input.column === "plan" && prevColumn !== "plan") { - const repo = currentState().repo; - session.send({ - prompt: `The Repository Issues Kanban just moved issue #${issue.number} ("${issue.title}") in ${repo} into the Plan column. Start planning the implementation for this issue in a background agent. Read the GitHub issue details, analyze the repository, and produce a concrete implementation plan. Use the kanban_update_status tool to post progress and then move the issue to "ready" with kanban_move_issue when planning is complete.`, - }); - } - - json(res, 200, { issue, state: currentState() }); - return; - } - - if (req.method === "POST" && url.pathname === "/api/update-status") { - const input = await readJson(req); - const issue = updateIssueStatus(input.issue_number, input.status, input.log); - if (input.done) clearAgentStatus(input.issue_number); - json(res, 200, { issue, state: currentState() }); - return; - } - - if (req.method === "POST" && url.pathname === "/api/filters") { - const input = await readJson(req); - const state = setSelectedLabels(input.labels); - json(res, 200, state); - return; - } - - if (req.method === "GET" && url.pathname.startsWith("/api/logs/")) { - const num = parseInt(url.pathname.split("/").pop(), 10); - const state = currentState(); - const issue = state.issues.find((i) => i.number === num); - if (!issue) { - json(res, 404, { error: "not found" }); - return; - } - json(res, 200, { issue_number: num, title: issue.title, logs: issue.logs || [] }); - return; - } - - if (req.method === "POST" && url.pathname === "/api/reset") { - resetBoard(); - json(res, 200, await refreshIssuesSafe()); - return; - } - - if (url.pathname === "/") { - res.writeHead(200, { "Content-Type": "text/html" }); - res.end(fs.readFileSync(path.join(__dirname, "public", "index.html"), "utf8")); - return; - } - - res.writeHead(404); - res.end("Not found"); -}); - -await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); -function getPort() { - return server.address().port; -} - -// ─── Canvas declaration ─── - -const canvas = createCanvas({ - id: "accessibility-kanban", - displayName: "Repository Issues Kanban", - description: "Kanban board for triaging open issues from the current repository into backlog, plan, ready, implement, and done lanes.", - actions: [ - { - name: "get_state", - description: "Get the current board state including open repository issues and selected label filters.", - inputSchema: { type: "object", properties: {}, additionalProperties: false }, - async handler(ctx) { - captureCwd(ctx); - return refreshIssuesSafe(); - }, - }, - { - name: "move_issue", - description: "Move an issue to a different column on the kanban board.", - inputSchema: { - type: "object", - properties: { - issue_number: { type: "number", description: "GitHub issue number" }, - column: { type: "string", enum: COLUMNS, description: "Target column" }, - }, - required: ["issue_number", "column"], - additionalProperties: false, - }, - handler(ctx) { - captureCwd(ctx); - const { issue } = moveIssue(ctx.input.issue_number, ctx.input.column); - return { issue, state: currentState() }; - }, - }, - { - name: "refresh_issues", - description: "Replace the board with issue data supplied by the agent.", - inputSchema: { - type: "object", - properties: { - issues: { - type: "array", - items: { - type: "object", - properties: { - number: { type: "number" }, - title: { type: "string" }, - url: { type: "string" }, - labels: { - type: "array", - items: { - oneOf: [ - { type: "string" }, - { type: "object", properties: { name: { type: "string" } }, required: ["name"] }, - ], - }, - }, - column: { type: "string", enum: COLUMNS }, - priority: { type: "string" }, - order: { type: "number" }, - }, - required: ["number", "title"], - additionalProperties: true, - }, - }, - }, - required: ["issues"], - additionalProperties: false, - }, - handler(ctx) { - captureCwd(ctx); - return replaceIssues(ctx.input.issues); - }, - }, - { - name: "set_filters", - description: "Set selected label filters (OR semantics).", - inputSchema: { - type: "object", - properties: { - labels: { type: "array", items: { type: "string" } }, - }, - required: ["labels"], - additionalProperties: false, - }, - handler(ctx) { - captureCwd(ctx); - return setSelectedLabels(ctx.input.labels); - }, - }, - { - name: "reset_state", - description: "Reset all cards to backlog and clear label filters, then refresh from live repo issues.", - inputSchema: { type: "object", properties: {}, additionalProperties: false }, - async handler(ctx) { - captureCwd(ctx); - resetBoard(); - return refreshIssuesSafe(); - }, - }, - ], - async open(ctx) { - captureCwd(ctx); - const state = await refreshIssuesSafe(); - broadcast("state", state); - return { - url: `http://127.0.0.1:${getPort()}`, - title: "Repository Issues Kanban", - status: `${state.issues.length} open issues in ${state.repo}`, - }; - }, -}); - -// ─── Join session (tools + canvas) ─── - -const session = await joinSession({ - canvases: [canvas], - tools: [ - { - name: "kanban_move_issue", - description: "Move an issue on the repository issues kanban board to a new column (backlog, plan, ready, implement, done).", - parameters: { - type: "object", - properties: { - issue_number: { type: "number", description: "GitHub issue number" }, - column: { type: "string", enum: COLUMNS, description: "Target column to move the issue to" }, - }, - required: ["issue_number", "column"], - }, - handler: async (args) => { - const { issue } = moveIssue(args.issue_number, args.column); - return JSON.stringify({ moved: true, issue, state: currentState() }); - }, - }, - { - name: "kanban_update_status", - description: "Update the agent status line and log on a kanban card while planning or implementing an issue.", - parameters: { - type: "object", - properties: { - issue_number: { type: "number", description: "GitHub issue number" }, - status: { type: "string", description: "Short status text shown on the card." }, - log: { type: "string", description: "Detailed log entry appended to the issue's agent log." }, - done: { type: "boolean", description: "Set true to stop the active glow." }, - }, - required: ["issue_number", "status"], - }, - handler: async (args) => { - const issue = updateIssueStatus(args.issue_number, args.status, args.log); - if (args.done) clearAgentStatus(args.issue_number); - return JSON.stringify({ updated: true, issue }); - }, - }, - ], -}); diff --git a/extensions/accessibility-kanban/package.json b/extensions/accessibility-kanban/package.json deleted file mode 100644 index 48b33dbd..00000000 --- a/extensions/accessibility-kanban/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "accessibility-kanban", - "version": "1.0.0", - "type": "module", - "main": "extension.mjs", - "dependencies": { - "@github/copilot-sdk": "latest" - }, - "description": "Users drag accessibility issues across kanban lanes to plan, track, and complete remediation work.", - "keywords": [ - "accessibility", - "kanban-board", - "issue-triage", - "planning-workflow", - "status-tracking", - "github-issues" - ] -} diff --git a/extensions/accessibility-kanban/public/index.html b/extensions/accessibility-kanban/public/index.html deleted file mode 100644 index 01338af9..00000000 --- a/extensions/accessibility-kanban/public/index.html +++ /dev/null @@ -1,754 +0,0 @@ - - - - - -Repository Issues Kanban - - - - - -
-
- - -
- - -
- -
-
- - - - - - - diff --git a/extensions/apng-studio/.github/plugin/plugin.json b/extensions/apng-studio/.github/plugin/plugin.json index a356e61c..789695bc 100644 --- a/extensions/apng-studio/.github/plugin/plugin.json +++ b/extensions/apng-studio/.github/plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "apng-studio", "description": "Interactive GitHub Copilot app canvas extension for building Animated PNG (APNG) files from frames. Draw or upload frames, tune per-frame timing and compositing, preview live, send the result to your phone by QR, and export an animated .png.", - "version": "1.0.1", + "version": "1.0.2", "author": { "name": "Andrea Griffiths", "url": "https://github.com/AndreaGriffiths11" diff --git a/extensions/apng-studio/extensions/.gitignore b/extensions/apng-studio/extensions/.gitignore deleted file mode 100644 index 5db7b303..00000000 --- a/extensions/apng-studio/extensions/.gitignore +++ /dev/null @@ -1,11 +0,0 @@ -# Runtime user data — per-project frames and exports the extension writes -# under its own directory at runtime. This is local state, not source. -artifacts/ - -# Exported animations -*.apng - -# Node / editor / OS cruft -node_modules/ -.DS_Store -*.log diff --git a/extensions/apng-studio/extensions/LICENSE b/extensions/apng-studio/extensions/LICENSE deleted file mode 100644 index da487c02..00000000 --- a/extensions/apng-studio/extensions/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 octobooth-1 - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/extensions/apng-studio/extensions/README.md b/extensions/apng-studio/extensions/README.md deleted file mode 100644 index 27a71767..00000000 --- a/extensions/apng-studio/extensions/README.md +++ /dev/null @@ -1,97 +0,0 @@ -# APNG Studio - -An interactive [GitHub Copilot app](https://github.com/features/ai/github-app) **canvas extension** for building [Animated PNG (APNG)](https://wiki.mozilla.org/APNG_Specification) files from frames — draw or upload frames, tune the full practical APNG spec surface, preview live, and export an animated `.png`. - -The canvas renders in a side panel; the agent can also drive it through callable actions. - -

- An animated walkthrough of the APNG Studio canvas building an animation from frames -

-

APNG Studio in action — an animated PNG built with the canvas itself.

- -## Background - -APNG Studio started as a hallway conversation. During a demo shift at the WeAreDevelopers Congress I got talking with [Jeff](https://github.com/GekkeBoyJeff) about APNG (animated PNG) versus GIF: APNG keeps real alpha and full color where GIF can't. We wanted an easy way to actually build one, so we made this small canvas wrapper for creating APNGs. - -## Features - -- **Frames** — upload images or draw them on a built‑in canvas (pen/eraser, fill, onion‑skin, "start from last frame"). Reorder, duplicate, and delete frames. -- **Per‑frame timing** — set the delay as an exact `numerator / denominator` fraction with a live `= N ms · N fps` readout. -- **Per‑frame compositing** — `dispose_op` (None / Background / Previous) and `blend_op` (Source / Over) dropdowns, straight from the APNG spec. -- **Apply to all** — set every frame's delay (ms), snap to an exact frame rate (fps), or apply dispose + blend in one click. -- **Loop count** — `0` = infinite, or a fixed number of plays. -- **Hidden first frame** — mark frame 1 as a static fallback: shown by non‑APNG viewers, excluded from the animation loop (encoded as a default image with no leading `fcTL`, `num_frames = N‑1`). -- **Live preview** — a real animated PNG is assembled on every change and served from `/preview.png`; a **Reload** button re‑syncs state and rebuilds the preview. -- **Send to phone** — a **Send to phone** button opens a QR code; scan it with your phone camera (same Wi‑Fi) to open the live animation in your phone's browser and save it. Served read‑only from a short‑lived, token‑gated LAN endpoint that shuts itself down after 10 minutes. -- **Export** — writes a valid animated `.png` (APNG) to disk and returns its path. The `.png` extension keeps the file byte‑compatible with every PNG viewer: APNG‑aware ones (browsers, macOS Quick Look) animate it, others show the first frame as a static fallback. - -## Install - -### From GitHub Copilot (recommended) - -Ask Copilot to install the committed extension URL: - -```text -Install this extension: https://github.com/github/awesome-copilot/tree/main/extensions/apng-studio -``` - -You can also copy the folder into one of these locations: - -- **User** — `~/.copilot/extensions/apng-studio/`, available in every project. -- **Project** — `.github/extensions/apng-studio/` inside a repo, committed and shared with your team. - -Reload extensions in the app, then open the `apng-studio` canvas. - -### Manual - -Copy the source files into one of the extension directories above, keeping the layout: - -``` -apng-studio/ -├── extension.mjs # entry point (required name) -├── apng.mjs # APNG codec + RGBA→PNG encoder -├── qr.mjs # dependency-free QR encoder (Send to phone) -└── web/ # canvas iframe renderer - ├── index.html - ├── app.js - └── styles.css -``` - -Then reload extensions. The `@github/copilot-sdk` import is resolved by the host — **do not** add a `package.json` or `node_modules` for it. - -## Open the canvas - -Once installed, open the **APNG Studio** canvas from Copilot. Optional open input: - -| field | type | description | -| ----------- | ------ | ---------------------------------------------- | -| `projectId` | string | Animation project id (defaults to `default`). | -| `name` | string | Optional display name for the animation. | - -Each project's frames persist on disk under `artifacts//`, so they survive reloads and are shared between every open panel and the agent actions. That folder is local user data and is **git‑ignored**. - -## Agent actions - -The extension exposes these callable actions on the `apng-studio` canvas: - -| action | what it does | -| ----------------- | ------------------------------------------------------------------------------------------------------- | -| `get_state` | Return project settings + per‑frame timing/compositing and total duration. | -| `set_settings` | Update `width`/`height` (only with 0 frames), `loops`, and `hiddenFirst`. | -| `add_color_frame` | Append a solid‑color frame; accepts `delayNum`/`delayDen`/`disposeOp`/`blendOp`. | -| `set_frame` | Update one frame (`frameId`) or all (`all: true`): timing via `delayMs`/`fps`/`delayNum`+`delayDen`, plus `disposeOp`/`blendOp`. | -| `clear_frames` | Remove every frame. | -| `export` | Assemble and write the animated `.png` (APNG) to disk; returns the absolute path. | - -All actions accept an optional `projectId` to target a specific animation. - -## How it works - -- **`extension.mjs`** — one loopback HTTP server per open canvas instance serves the renderer, JSON state, per‑frame PNGs, the live `/preview.png`, and mutation endpoints. Server‑Sent Events (`/events`) push a `changed` signal so every open panel and the preview stay in sync. **Send to phone** spins up a separate, read‑only LAN server that serves only a landing page and the preview image, gated by a short‑lived random token and torn down on expiry. -- **`apng.mjs`** — assembles the APNG chunk stream (`IHDR` / `acTL` / `fcTL` / `IDAT` / `fdAT` / `IEND`) with contiguous sequence numbers, plus a minimal RGBA→PNG encoder. -- **`qr.mjs`** — a small, dependency‑free QR encoder (byte mode, error‑correction level M) used to render the **Send to phone** code. The QR is drawn into a PNG with the `apng.mjs` encoder. -- **`web/`** — the iframe UI. It talks to its server over plain HTTP; there is no privileged host bridge. - -## License - -[MIT](./LICENSE) diff --git a/extensions/apng-studio/.gitignore b/extensions/apng-studio/extensions/apng-studio/.gitignore similarity index 100% rename from extensions/apng-studio/.gitignore rename to extensions/apng-studio/extensions/apng-studio/.gitignore diff --git a/extensions/apng-studio/LICENSE b/extensions/apng-studio/extensions/apng-studio/LICENSE similarity index 100% rename from extensions/apng-studio/LICENSE rename to extensions/apng-studio/extensions/apng-studio/LICENSE diff --git a/extensions/apng-studio/README.md b/extensions/apng-studio/extensions/apng-studio/README.md similarity index 100% rename from extensions/apng-studio/README.md rename to extensions/apng-studio/extensions/apng-studio/README.md diff --git a/extensions/apng-studio/apng.mjs b/extensions/apng-studio/extensions/apng-studio/apng.mjs similarity index 100% rename from extensions/apng-studio/apng.mjs rename to extensions/apng-studio/extensions/apng-studio/apng.mjs diff --git a/extensions/apng-studio/assets/demo.png b/extensions/apng-studio/extensions/apng-studio/assets/demo.png similarity index 100% rename from extensions/apng-studio/assets/demo.png rename to extensions/apng-studio/extensions/apng-studio/assets/demo.png diff --git a/extensions/apng-studio/extensions/assets/preview.png b/extensions/apng-studio/extensions/apng-studio/assets/preview.png similarity index 100% rename from extensions/apng-studio/extensions/assets/preview.png rename to extensions/apng-studio/extensions/apng-studio/assets/preview.png diff --git a/extensions/apng-studio/copilot-extension.json b/extensions/apng-studio/extensions/apng-studio/copilot-extension.json similarity index 100% rename from extensions/apng-studio/copilot-extension.json rename to extensions/apng-studio/extensions/apng-studio/copilot-extension.json diff --git a/extensions/apng-studio/extension.mjs b/extensions/apng-studio/extensions/apng-studio/extension.mjs similarity index 100% rename from extensions/apng-studio/extension.mjs rename to extensions/apng-studio/extensions/apng-studio/extension.mjs diff --git a/extensions/apng-studio/extensions/qr.mjs b/extensions/apng-studio/extensions/apng-studio/qr.mjs similarity index 100% rename from extensions/apng-studio/extensions/qr.mjs rename to extensions/apng-studio/extensions/apng-studio/qr.mjs diff --git a/extensions/apng-studio/extensions/web/app.js b/extensions/apng-studio/extensions/apng-studio/web/app.js similarity index 100% rename from extensions/apng-studio/extensions/web/app.js rename to extensions/apng-studio/extensions/apng-studio/web/app.js diff --git a/extensions/apng-studio/extensions/web/index.html b/extensions/apng-studio/extensions/apng-studio/web/index.html similarity index 100% rename from extensions/apng-studio/extensions/web/index.html rename to extensions/apng-studio/extensions/apng-studio/web/index.html diff --git a/extensions/apng-studio/extensions/web/styles.css b/extensions/apng-studio/extensions/apng-studio/web/styles.css similarity index 100% rename from extensions/apng-studio/extensions/web/styles.css rename to extensions/apng-studio/extensions/apng-studio/web/styles.css diff --git a/extensions/apng-studio/extensions/apng.mjs b/extensions/apng-studio/extensions/apng.mjs deleted file mode 100644 index 64fffc5b..00000000 --- a/extensions/apng-studio/extensions/apng.mjs +++ /dev/null @@ -1,310 +0,0 @@ -// APNG codec helpers (Node-side). -// -// - assembleApng(): repackages a list of already-encoded PNG frames into a -// single Animated PNG, following https://wiki.mozilla.org/APNG_Specification. -// Because every frame is already a valid PNG sharing one IHDR, we only need -// to lift each frame's IDAT stream into the default image (frame 0) or into -// `fdAT` chunks (later frames), wrapped by `acTL`/`fcTL` control chunks with -// freshly computed CRC-32s. No re-compression required. -// - encodeRgbaPng(): minimal RGBA8 PNG encoder used for agent-generated frames. - -import { deflateSync } from "node:zlib"; - -const PNG_SIGNATURE = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]); - -const CRC_TABLE = (() => { - const table = new Uint32Array(256); - for (let n = 0; n < 256; n++) { - let c = n; - for (let k = 0; k < 8; k++) { - c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; - } - table[n] = c >>> 0; - } - return table; -})(); - -function crc32(bytes) { - let c = 0xffffffff; - for (let i = 0; i < bytes.length; i++) { - c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8); - } - return (c ^ 0xffffffff) >>> 0; -} - -function concat(chunks) { - let total = 0; - for (const c of chunks) total += c.length; - const out = new Uint8Array(total); - let off = 0; - for (const c of chunks) { - out.set(c, off); - off += c.length; - } - return out; -} - -function readU32(bytes, off) { - return ( - ((bytes[off] << 24) | - (bytes[off + 1] << 16) | - (bytes[off + 2] << 8) | - bytes[off + 3]) >>> - 0 - ); -} - -// Build a PNG chunk: [length][type][data][crc], CRC over type+data. -function chunk(type, data) { - const len = data.length; - const out = new Uint8Array(12 + len); - const view = new DataView(out.buffer); - view.setUint32(0, len); - out[4] = type.charCodeAt(0); - out[5] = type.charCodeAt(1); - out[6] = type.charCodeAt(2); - out[7] = type.charCodeAt(3); - out.set(data, 8); - view.setUint32(8 + len, crc32(out.subarray(4, 8 + len))); - return out; -} - -// Parse the pieces of a PNG we care about: its IHDR data and the concatenated -// IDAT stream. Ancillary/color chunks are intentionally dropped — every frame -// shares a uniform RGBA8 IHDR so they are not needed. -function parsePng(bytes) { - for (let i = 0; i < PNG_SIGNATURE.length; i++) { - if (bytes[i] !== PNG_SIGNATURE[i]) { - throw new Error("Not a PNG (bad signature)"); - } - } - let off = 8; - let ihdrData = null; - let width = 0; - let height = 0; - const idatParts = []; - while (off + 8 <= bytes.length) { - const len = readU32(bytes, off); - const type = String.fromCharCode( - bytes[off + 4], - bytes[off + 5], - bytes[off + 6], - bytes[off + 7] - ); - const dataStart = off + 8; - const data = bytes.subarray(dataStart, dataStart + len); - if (type === "IHDR") { - ihdrData = data.slice(); - width = readU32(data, 0); - height = readU32(data, 4); - } else if (type === "IDAT") { - idatParts.push(data.slice()); - } else if (type === "IEND") { - break; - } - off = dataStart + len + 4; // skip data + CRC - } - if (!ihdrData) throw new Error("PNG missing IHDR"); - if (idatParts.length === 0) throw new Error("PNG missing IDAT"); - return { ihdrData, width, height, idat: concat(idatParts) }; -} - -function acTLChunk(numFrames, numPlays) { - const data = new Uint8Array(8); - const view = new DataView(data.buffer); - view.setUint32(0, numFrames >>> 0); - view.setUint32(4, numPlays >>> 0); - return chunk("acTL", data); -} - -// dispose_op: 0 = NONE (leave as-is), 1 = BACKGROUND (clear region to transparent -// black), 2 = PREVIOUS (revert region to what it was before this frame). -// blend_op: 0 = SOURCE (overwrite region, alpha included), 1 = OVER (alpha-blend -// this frame over the current canvas contents). -function fcTLChunk(sequence, width, height, params) { - const { - delayNum = 100, - delayDen = 1000, - disposeOp = 0, - blendOp = 0, - xOffset = 0, - yOffset = 0, - } = params || {}; - const data = new Uint8Array(26); - const view = new DataView(data.buffer); - view.setUint32(0, sequence >>> 0); // sequence_number - view.setUint32(4, width >>> 0); // width - view.setUint32(8, height >>> 0); // height - view.setUint32(12, xOffset >>> 0); // x_offset - view.setUint32(16, yOffset >>> 0); // y_offset - view.setUint16(20, delayNum & 0xffff); // delay_num - view.setUint16(22, delayDen & 0xffff); // delay_den - data[24] = disposeOp & 0xff; // dispose_op - data[25] = blendOp & 0xff; // blend_op - return chunk("fcTL", data); -} - -function fdATChunk(sequence, idat) { - const data = new Uint8Array(4 + idat.length); - new DataView(data.buffer).setUint32(0, sequence >>> 0); - data.set(idat, 4); - return chunk("fdAT", data); -} - -const clampU16 = (n, dflt = 0) => { - const v = Math.round(Number(n)); - return Number.isFinite(v) ? Math.max(0, Math.min(0xffff, v)) : dflt; -}; -const clampDen = (n) => { - const v = Math.round(Number(n)); - return Number.isFinite(v) && v >= 1 ? Math.min(0xffff, v) : 1000; -}; -const clampOp = (n, hi) => { - const v = Math.round(Number(n)); - return Number.isFinite(v) ? Math.max(0, Math.min(hi, v)) : 0; -}; - -// Normalize a caller-supplied frame descriptor into the exact fcTL fields. -// Timing accepts either delayNum/delayDen (exact) or a delayMs shorthand -// (treated as delayNum ms over a 1000 denominator). -function frameParams(f) { - let delayNum; - let delayDen; - if (f.delayNum != null) { - delayNum = clampU16(f.delayNum, 100); - delayDen = clampDen(f.delayDen); - } else { - delayNum = clampU16(f.delayMs, 100); - delayDen = 1000; - } - return { - delayNum, - delayDen, - disposeOp: clampOp(f.disposeOp, 2), - blendOp: clampOp(f.blendOp, 1), - }; -} - -// The APNG spec forbids APNG_DISPOSE_OP_PREVIOUS on the first fcTL (decoders -// must treat it as BACKGROUND). Normalize it for whichever frame is composited -// first so our output is well-defined instead of relying on decoder leniency. -function firstFrameParams(f) { - const p = frameParams(f); - if (p.disposeOp === 2) p.disposeOp = 1; - return p; -} - -/** - * Assemble an APNG from a list of PNG frames. - * - * @param {Array<{png: Uint8Array, delayMs?: number, delayNum?: number, delayDen?: number, disposeOp?: number, blendOp?: number}>} frames - * @param {{loops?: number, hiddenFirst?: boolean}} [options] - * loops: 0 = infinite. hiddenFirst: when true (and >=2 frames) the first frame - * becomes the static default image shown by non-APNG viewers and is NOT part - * of the animation; frames 2..N make up the loop. - * @returns {Uint8Array} APNG bytes. - */ -export function assembleApng(frames, options = {}) { - if (!Array.isArray(frames) || frames.length === 0) { - throw new Error("assembleApng requires at least one frame"); - } - const loops = Math.max(0, Math.round(Number(options.loops) || 0)); - const hiddenFirst = !!options.hiddenFirst && frames.length >= 2; - const parsed = frames.map((f) => parsePng(f.png)); - - const width = parsed[0].width; - const height = parsed[0].height; - for (const p of parsed) { - if (p.width !== width || p.height !== height) { - throw new Error( - `All frames must share dimensions (${width}x${height}); found ${p.width}x${p.height}` - ); - } - } - - const parts = [PNG_SIGNATURE, chunk("IHDR", parsed[0].ihdrData)]; - const numFrames = hiddenFirst ? parsed.length - 1 : parsed.length; - parts.push(acTLChunk(numFrames, loops)); - - let seq = 0; - if (hiddenFirst) { - // Default image = frame 0, with no fcTL, so it is not animated. - parts.push(chunk("IDAT", parsed[0].idat)); - for (let i = 1; i < parsed.length; i++) { - const params = i === 1 ? firstFrameParams(frames[i]) : frameParams(frames[i]); - parts.push(fcTLChunk(seq++, width, height, params)); - parts.push(fdATChunk(seq++, parsed[i].idat)); - } - } else { - // Frame 0 = default image AND first animation frame: fcTL then IDAT. - parts.push(fcTLChunk(seq++, width, height, firstFrameParams(frames[0]))); - parts.push(chunk("IDAT", parsed[0].idat)); - // Remaining frames: fcTL then fdAT (each carries a sequence number). - for (let i = 1; i < parsed.length; i++) { - parts.push(fcTLChunk(seq++, width, height, frameParams(frames[i]))); - parts.push(fdATChunk(seq++, parsed[i].idat)); - } - } - - parts.push(chunk("IEND", new Uint8Array(0))); - return concat(parts); -} - -/** - * Encode an 8-bit RGBA pixel buffer into a (non-animated) PNG. - * - * @param {number} width - * @param {number} height - * @param {Uint8Array} rgba length must be width*height*4 - * @returns {Uint8Array} PNG bytes. - */ -export function encodeRgbaPng(width, height, rgba) { - if (rgba.length !== width * height * 4) { - throw new Error("rgba length does not match width*height*4"); - } - const ihdr = new Uint8Array(13); - const view = new DataView(ihdr.buffer); - view.setUint32(0, width >>> 0); - view.setUint32(4, height >>> 0); - ihdr[8] = 8; // bit depth - ihdr[9] = 6; // color type: RGBA - ihdr[10] = 0; // compression - ihdr[11] = 0; // filter method - ihdr[12] = 0; // interlace - - // Filtered raw scanlines: one leading filter byte (0 = None) per row. - const stride = width * 4; - const raw = new Uint8Array((stride + 1) * height); - for (let y = 0; y < height; y++) { - const src = y * stride; - const dst = y * (stride + 1); - raw[dst] = 0; - raw.set(rgba.subarray(src, src + stride), dst + 1); - } - const compressed = deflateSync(raw, { level: 9 }); - - return concat([ - PNG_SIGNATURE, - chunk("IHDR", ihdr), - chunk("IDAT", new Uint8Array(compressed.buffer, compressed.byteOffset, compressed.length)), - chunk("IEND", new Uint8Array(0)), - ]); -} - -/** - * Convenience: encode a solid-color frame as a PNG. - * @param {number} width - * @param {number} height - * @param {{r:number,g:number,b:number,a:number}} color 0-255 components - */ -export function solidColorPng(width, height, color) { - const rgba = new Uint8Array(width * height * 4); - const { r, g, b, a } = color; - for (let i = 0; i < rgba.length; i += 4) { - rgba[i] = r; - rgba[i + 1] = g; - rgba[i + 2] = b; - rgba[i + 3] = a; - } - return encodeRgbaPng(width, height, rgba); -} diff --git a/extensions/apng-studio/extensions/assets/demo.png b/extensions/apng-studio/extensions/assets/demo.png deleted file mode 100644 index e36882c8..00000000 Binary files a/extensions/apng-studio/extensions/assets/demo.png and /dev/null differ diff --git a/extensions/apng-studio/extensions/copilot-extension.json b/extensions/apng-studio/extensions/copilot-extension.json deleted file mode 100644 index 5841b689..00000000 --- a/extensions/apng-studio/extensions/copilot-extension.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "apng-studio", - "version": 1 -} diff --git a/extensions/apng-studio/extensions/extension.mjs b/extensions/apng-studio/extensions/extension.mjs deleted file mode 100644 index 5dc4bb4a..00000000 --- a/extensions/apng-studio/extensions/extension.mjs +++ /dev/null @@ -1,1234 +0,0 @@ -// Extension: apng-studio -// Interactive studio to create Animated PNG (APNG) files from frames. -// -// Architecture: -// • Server-owned state: frames live on disk under artifacts// so -// they survive extension reloads and are shared between the interactive -// iframe UI and the agent-callable actions. -// • One loopback HTTP server per open canvas instance serves the renderer -// (web/), JSON state, per-frame PNGs, a live `/preview.png`, and mutation -// endpoints. Server-Sent Events push "changed" so every open panel and the -// preview stay in sync. -// • APNG assembly + a small RGBA→PNG encoder live in ./apng.mjs. - -import { createServer } from "node:http"; -import { fileURLToPath } from "node:url"; -import { join, extname } from "node:path"; -import { promises as fs } from "node:fs"; -import { randomBytes, timingSafeEqual, createHash } from "node:crypto"; -import { networkInterfaces } from "node:os"; - -import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension"; -import { assembleApng, solidColorPng, encodeRgbaPng } from "./apng.mjs"; -import { encodeQr } from "./qr.mjs"; - -const EXT_DIR = fileURLToPath(new URL(".", import.meta.url)); -const WEB_DIR = join(EXT_DIR, "web"); -const ARTIFACTS_DIR = join(EXT_DIR, "artifacts"); -const EXPORTS_DIR = join(ARTIFACTS_DIR, "exports"); -const DEFAULT_PROJECT = "default"; -const MAX_FRAMES = 600; // count cap so a project can't accumulate unbounded frames -const MAX_TOTAL_BYTES = 256 << 20; // 256 MiB of encoded frames — bounds assembly memory - -let session; - -// ---- helpers ------------------------------------------------------------ -const clampInt = (n, lo, hi, dflt) => { - const v = Math.round(Number(n)); - return Number.isFinite(v) ? Math.max(lo, Math.min(hi, v)) : dflt; -}; -const clampDim = (n) => clampInt(n, 1, 2048, 256); -const clampLoops = (n) => clampInt(n, 0, 65535, 0); -const clampDen = (n) => clampInt(n, 1, 65535, 1000); -const clampDispose = (n, dflt = 0) => clampInt(n, 0, 2, dflt); -const clampBlend = (n, dflt = 0) => clampInt(n, 0, 1, dflt); -const frameMs = (f) => Math.round((f.delayNum / f.delayDen) * 1000); -const usedBytes = (meta) => meta.frames.reduce((a, f) => a + (f.bytes || 0), 0); - -// Normalize a stored/incoming frame record to the full field set, migrating the -// legacy { id, delayMs } shape to explicit delay numerator/denominator plus the -// per-frame compositing ops. -function normalizeFrame(f) { - const num = f.delayNum != null ? f.delayNum : f.delayMs; - return { - id: String(f.id), - delayNum: clampInt(num, 0, 65535, 100), - delayDen: clampDen(f.delayDen), - disposeOp: clampDispose(f.disposeOp), - blendOp: clampBlend(f.blendOp), - bytes: Number.isFinite(f.bytes) && f.bytes > 0 ? Math.floor(f.bytes) : 0, - }; -} -// Map a project id to a filesystem-safe key. Ids that are already safe (including -// the legacy "default" and GUID-style ids) are used verbatim, so directories stay -// stable across restarts/upgrades and re-sanitizing is a no-op. Only ids that -// aren't filesystem-safe get a hash suffix — keyed on the raw id — so two distinct -// unsafe ids ("foo/bar" vs "foo?bar") can never share a directory, lock, or entry. -const SAFE_ID = /^[\w.-]{1,64}$/; -const sanitizeId = (s) => { - const raw = String(s ?? "") || DEFAULT_PROJECT; - if (raw !== "." && raw !== ".." && SAFE_ID.test(raw)) return raw; - const prefix = raw.replace(/[^\w.-]+/g, "_").slice(0, 40) || "p"; - return `${prefix}-${createHash("sha256").update(raw).digest("hex").slice(0, 12)}`; -}; -// Frame ids are internal monotonic counters, so a lightweight path-safe cleaner is -// enough (and keeps frame filenames readable). -const sanitizeFrameId = (s) => String(s).replace(/[^\w.-]+/g, "_").slice(0, 64) || "0"; -const sanitizeName = (s) => String(s || "animation").replace(/[^\w.-]+/g, "_").slice(0, 80) || "animation"; -const ensureDir = (d) => fs.mkdir(d, { recursive: true }); - -function log(message, level = "info") { - try { - session?.log(message, { level }); - } catch { - /* logging is best-effort */ - } -} - -// ---- project store (disk-backed, shared across instances) --------------- -const projects = new Map(); // projectId -> meta -const loadingProjects = new Map(); // projectId -> in-flight load Promise -const projectLocks = new Map(); // projectId -> tail of the mutation queue -const projectDir = (id) => join(ARTIFACTS_DIR, sanitizeId(id)); -const framePath = (id, fid) => join(projectDir(id), `frame-${sanitizeFrameId(fid)}.png`); - -// Serialize the full load–mutate–save cycle for a project so concurrent panels -// and agent actions cannot interleave (e.g. allocate the same counter value or -// persist stale snapshots out of order). -function withProjectLock(id, fn) { - const pid = sanitizeId(id); - const prev = projectLocks.get(pid) || Promise.resolve(); - const next = prev.then(() => fn()); - // Keep the chain going even if this task rejects; don't leak the rejection. - projectLocks.set(pid, next.then(() => {}, () => {})); - return next; -} - -async function loadProject(id) { - const pid = sanitizeId(id); - if (projects.has(pid)) return projects.get(pid); - // Dedupe concurrent first-time loads so every caller shares one meta object. - if (loadingProjects.has(pid)) return loadingProjects.get(pid); - const p = (async () => { - let meta = null; - try { - meta = JSON.parse(await fs.readFile(join(projectDir(pid), "project.json"), "utf8")); - } catch (err) { - // Only a missing file means "new project". Any other read/parse failure - // (I/O error, corrupt JSON) must surface rather than masquerade as an - // empty project, or the next save would overwrite real data and leave - // the frame files orphaned. - if (!err || err.code !== "ENOENT") { - throw new CanvasError("project_unreadable", `Could not read project "${pid}": ${err?.message || err}`); - } - } - if (!meta || typeof meta !== "object") { - meta = { id: pid, name: pid, width: 256, height: 256, loops: 0, hiddenFirst: false, counter: 0, frames: [] }; - } - meta.id = pid; - meta.frames = (Array.isArray(meta.frames) ? meta.frames : []).map(normalizeFrame); - // Backfill encoded sizes for frames persisted before byte-tracking so the - // aggregate budget reflects real disk usage after an upgrade. - for (const f of meta.frames) { - if (!f.bytes) { - try { - f.bytes = (await fs.stat(framePath(pid, f.id))).size; - } catch { - /* frame file gone: leave 0 */ - } - } - } - meta.counter = Number.isFinite(meta.counter) ? meta.counter : meta.frames.length; - meta.width = clampDim(meta.width); - meta.height = clampDim(meta.height); - meta.loops = clampLoops(meta.loops); - meta.hiddenFirst = !!meta.hiddenFirst; - projects.set(pid, meta); - return meta; - })(); - loadingProjects.set(pid, p); - try { - return await p; - } finally { - loadingProjects.delete(pid); - } -} - -async function saveProject(meta) { - const dir = projectDir(meta.id); - const target = join(dir, "project.json"); - const tmp = join(dir, `.project.${randomBytes(6).toString("hex")}.tmp`); - try { - await ensureDir(dir); - // Write to a temp file then rename, so an interrupted write can never leave - // a truncated project.json for the next load to misread as empty. - await fs.writeFile(tmp, JSON.stringify(meta, null, 2)); - await fs.rename(tmp, target); - } catch (err) { - // A failed persist must not leave the in-memory cache diverged from disk: - // evict it so the next access reloads authoritative state instead of the - // unsaved mutation, and remove any leftover temp file. - projects.delete(sanitizeId(meta.id)); - await fs.rm(tmp, { force: true }).catch(() => {}); - throw err; - } -} - -function publicState(meta) { - return { - id: meta.id, - name: meta.name, - width: meta.width, - height: meta.height, - loops: meta.loops, - hiddenFirst: meta.hiddenFirst, - frames: meta.frames.map((f) => ({ - id: f.id, - delayNum: f.delayNum, - delayDen: f.delayDen, - delayMs: frameMs(f), - disposeOp: f.disposeOp, - blendOp: f.blendOp, - })), - }; -} - -// ---- mutations ---------------------------------------------------------- -// Validate a PNG buffer and return its dimensions. Walks the chunk stream to -// confirm it is structurally a PNG (IHDR first with length 13 and non-zero -// dimensions, at least one IDAT, and IEND) so a malformed upload can't be -// stored and then blow up APNG assembly later. -const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10]; -function pngSize(buffer) { - const bad = () => new CanvasError("bad_frame", "Frame is not a valid PNG image."); - // The internal encoder returns plain Uint8Arrays while HTTP uploads arrive as - // Buffers; view any Uint8Array as a Buffer (no copy) so both paths validate. - if (buffer instanceof Uint8Array && !Buffer.isBuffer(buffer)) { - buffer = Buffer.from(buffer.buffer, buffer.byteOffset, buffer.length); - } - if (!Buffer.isBuffer(buffer) || buffer.length < 8) throw bad(); - for (let i = 0; i < 8; i++) { - if (buffer[i] !== PNG_SIGNATURE[i]) throw bad(); - } - let off = 8; - let width = 0; - let height = 0; - let sawIHDR = false; - let sawIDAT = false; - let sawIEND = false; - while (off + 8 <= buffer.length) { - const len = buffer.readUInt32BE(off); - const type = buffer.toString("latin1", off + 4, off + 8); - if (off + 12 + len > buffer.length) throw bad(); // length + type + data + CRC - if (!sawIHDR) { - if (type !== "IHDR" || len !== 13) throw bad(); - width = buffer.readUInt32BE(off + 8); - height = buffer.readUInt32BE(off + 8 + 4); - const bitDepth = buffer[off + 8 + 8]; - const colorType = buffer[off + 8 + 9]; - const compression = buffer[off + 8 + 10]; - const filterMethod = buffer[off + 8 + 11]; - const interlace = buffer[off + 8 + 12]; - // The codec only handles 8-bit truecolor-with-alpha, non-interlaced - // PNGs with the standard compression/filter methods (which the - // renderer and the RGBA encoder always produce). Reject anything - // else rather than store a frame assembleApng would mis-encode. - if (bitDepth !== 8 || colorType !== 6 || compression !== 0 || filterMethod !== 0 || interlace !== 0) { - throw new CanvasError( - "bad_frame", - "Frame must be an 8-bit RGBA (non-interlaced) PNG." - ); - } - sawIHDR = true; - } else if (type === "IDAT") { - sawIDAT = true; - } else if (type === "IEND") { - sawIEND = true; - break; - } - off += 12 + len; - } - if (!sawIHDR || !sawIDAT || !sawIEND || width < 1 || height < 1) throw bad(); - return { width, height }; -} - -// Lockless core: assumes the caller holds the project lock and passes the loaded -// meta. Writes the frame file and appends the record (does not save/broadcast). -async function addFrameToMeta(meta, buffer, opts = {}) { - if (meta.frames.length >= MAX_FRAMES) { - throw new CanvasError("too_many_frames", `An animation can have at most ${MAX_FRAMES} frames.`); - } - if (usedBytes(meta) + buffer.length > MAX_TOTAL_BYTES) { - throw new CanvasError("project_too_large", `Frames would exceed the ${MAX_TOTAL_BYTES >> 20} MiB total limit.`); - } - // Resolve (and validate) timing before writing anything so a rejected timing - // combination can't leave an orphaned frame file / advanced counter behind. - const timing = resolveTiming(opts, null) || { delayNum: 120, delayDen: 1000 }; - const { width, height } = pngSize(buffer); - if (width > 2048 || height > 2048) { - throw new CanvasError("frame_too_large", `Frame is ${width}×${height}; the maximum is 2048×2048.`); - } - if (meta.frames.length > 0 && (width !== meta.width || height !== meta.height)) { - throw new CanvasError( - "size_mismatch", - `Frame is ${width}×${height}, but the animation is ${meta.width}×${meta.height}. All frames must share dimensions.` - ); - } - // Write the file BEFORE mutating meta, so a failed write leaves the cached - // project untouched (nothing persisted, nothing to evict, counter intact). - const fid = String(meta.counter); - await ensureDir(projectDir(meta.id)); - await fs.writeFile(framePath(meta.id, fid), buffer); - if (meta.frames.length === 0) { - // The first frame defines the canvas size; store the real dimensions so - // metadata and the on-disk PNG always agree. - meta.width = width; - meta.height = height; - } - meta.counter++; - meta.frames.push( - normalizeFrame({ - id: fid, - delayNum: timing.delayNum, - delayDen: timing.delayDen, - disposeOp: opts.disposeOp, - blendOp: opts.blendOp, - bytes: buffer.length, - }) - ); - return fid; -} - -async function addFrameBuffer(id, buffer, opts = {}) { - return withProjectLock(id, async () => { - const meta = await loadProject(id); - const fid = await addFrameToMeta(meta, buffer, opts); - await saveProject(meta); - broadcast(meta.id); - return fid; - }); -} - -async function moveFrame(id, fid, delta) { - return withProjectLock(id, async () => { - const meta = await loadProject(id); - const i = meta.frames.findIndex((f) => f.id === String(fid)); - const step = Math.sign(Number(delta)); - if (!Number.isFinite(step) || step === 0) return; - const j = i + step; - if (i < 0 || j < 0 || j >= meta.frames.length) return; - [meta.frames[i], meta.frames[j]] = [meta.frames[j], meta.frames[i]]; - await saveProject(meta); - broadcast(meta.id); - }); -} - -async function deleteFrame(id, fid) { - return withProjectLock(id, async () => { - const meta = await loadProject(id); - const i = meta.frames.findIndex((f) => f.id === String(fid)); - if (i < 0) return; - meta.frames.splice(i, 1); - // Persist the removal before deleting the file (as clearFrames does) so an - // interrupted delete can't leave project.json referencing a missing PNG. - await saveProject(meta); - broadcast(meta.id); - await fs.rm(framePath(meta.id, fid), { force: true }); - }); -} - -async function duplicateFrame(id, fid) { - return withProjectLock(id, async () => { - const meta = await loadProject(id); - const i = meta.frames.findIndex((f) => f.id === String(fid)); - if (i < 0) return; - if (meta.frames.length >= MAX_FRAMES) { - throw new CanvasError("too_many_frames", `An animation can have at most ${MAX_FRAMES} frames.`); - } - const src = meta.frames[i]; - const srcBytes = src.bytes || (await fs.stat(framePath(meta.id, fid))).size; - if (usedBytes(meta) + srcBytes > MAX_TOTAL_BYTES) { - throw new CanvasError("project_too_large", `Frames would exceed the ${MAX_TOTAL_BYTES >> 20} MiB total limit.`); - } - const nid = String(meta.counter); - // Copy the file before advancing the counter / inserting the record, so a - // failed copy leaves the cached project unchanged. - await fs.copyFile(framePath(meta.id, fid), framePath(meta.id, nid)); - meta.counter++; - meta.frames.splice(i + 1, 0, normalizeFrame({ ...src, id: nid, bytes: srcBytes })); - await saveProject(meta); - broadcast(meta.id); - }); -} - -// Frame timing may be given exactly one way: fps, delayMs, or delayNum/delayDen. -// Combining modes (e.g. delayMs with delayDen, or fps with delayNum) used to be -// applied field-by-field and silently produced hybrid delays, so a mixed request -// is rejected here; the chosen mode resolves omitted parts against `base`. -function resolveTiming(props, base) { - const hasFps = props.fps != null; - const hasMs = props.delayMs != null; - const hasFrac = props.delayNum != null || props.delayDen != null; - if ([hasFps, hasMs, hasFrac].filter(Boolean).length > 1) { - throw new CanvasError( - "timing_conflict", - "Set frame timing one way only: delayMs, or fps, or delayNum/delayDen — not a combination." - ); - } - if (hasFps) return { delayNum: 1, delayDen: clampInt(props.fps, 1, 65535, base?.delayDen ?? 1000) }; - if (hasMs) return { delayNum: clampInt(props.delayMs, 0, 65535, base?.delayNum ?? 120), delayDen: 1000 }; - if (hasFrac) { - return { - delayNum: props.delayNum != null ? clampInt(props.delayNum, 0, 65535, base?.delayNum ?? 120) : base?.delayNum ?? 120, - delayDen: props.delayDen != null ? clampDen(props.delayDen) : base?.delayDen ?? 1000, - }; - } - return null; -} - -// Apply a partial set of frame properties. Timing (if any) is resolved as a single -// mutually-exclusive mode; only provided fields change. -function applyFrameProps(f, props) { - const t = resolveTiming(props, f); - if (t) { - f.delayNum = t.delayNum; - f.delayDen = t.delayDen; - } - if (props.disposeOp != null) f.disposeOp = clampDispose(props.disposeOp, f.disposeOp); - if (props.blendOp != null) f.blendOp = clampBlend(props.blendOp, f.blendOp); -} - -async function setFrameProps(id, fid, props) { - return withProjectLock(id, async () => { - const meta = await loadProject(id); - const f = meta.frames.find((x) => x.id === String(fid)); - if (!f) throw new CanvasError("frame_not_found", `No frame with id "${fid}".`); - applyFrameProps(f, props || {}); - await saveProject(meta); - broadcast(meta.id); - }); -} - -async function setFramePropsAll(id, props) { - return withProjectLock(id, async () => { - const meta = await loadProject(id); - for (const f of meta.frames) applyFrameProps(f, props || {}); - await saveProject(meta); - broadcast(meta.id); - }); -} - -async function clearFrames(id) { - return withProjectLock(id, async () => { - const meta = await loadProject(id); - // Clear the in-memory list and persist it before deleting files, so a - // concurrent reader never sees a frame id whose PNG is already gone. - const ids = meta.frames.map((f) => f.id); - meta.frames = []; - await saveProject(meta); - broadcast(meta.id); - await Promise.all(ids.map((fid) => fs.rm(framePath(meta.id, fid), { force: true }))); - }); -} - -async function applySettings(id, { width, height, loops, name, hiddenFirst }) { - return withProjectLock(id, async () => { - const meta = await loadProject(id); - if (meta.frames.length === 0) { - if (width != null) meta.width = clampDim(width); - if (height != null) meta.height = clampDim(height); - } - if (loops != null) meta.loops = clampLoops(loops); - if (typeof hiddenFirst === "boolean") meta.hiddenFirst = hiddenFirst; - if (typeof name === "string" && name.trim()) meta.name = name.trim().slice(0, 80); - await saveProject(meta); - broadcast(meta.id); - return meta; - }); -} - -// Assemble the APNG from a loaded project. Assumes the caller holds the project -// lock so frame files can't be deleted mid-read. -async function assembleFromMeta(meta) { - if (meta.frames.length === 0) return null; - const frames = []; - for (const f of meta.frames) { - frames.push({ - png: await fs.readFile(framePath(meta.id, f.id)), - delayNum: f.delayNum, - delayDen: f.delayDen, - disposeOp: f.disposeOp, - blendOp: f.blendOp, - }); - } - return assembleApng(frames, { loops: meta.loops, hiddenFirst: meta.hiddenFirst }); -} - -// Serialize assembly with mutations so a concurrent clear/delete can't remove a -// frame file while it is being read (which would otherwise 500 a preview, -// phone request, or export). -async function assemble(id) { - return withProjectLock(id, async () => assembleFromMeta(await loadProject(id))); -} - -async function exportApng(id, filename) { - return withProjectLock(id, async () => { - const meta = await loadProject(id); - const bytes = await assembleFromMeta(meta); - if (!bytes) throw new CanvasError("no_frames", "Nothing to export — add at least one frame first."); - await ensureDir(EXPORTS_DIR); - const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 23); - // Generated names include milliseconds + a random suffix so two exports in - // the same second don't collide and silently overwrite each other; an - // explicit caller filename keeps its (intentional) overwrite behavior. - const base = filename - ? sanitizeName(filename.replace(/\.(a?png)$/i, "")) - : `${sanitizeName(meta.name)}-${stamp}-${randomBytes(3).toString("hex")}`; - const outPath = join(EXPORTS_DIR, `${base}.png`); - await fs.writeFile(outPath, bytes); - return { path: outPath, name: `${base}.png`, bytes: bytes.length }; - }); -} - -// ---- colors (for agent-generated frames) -------------------------------- -const NAMED_COLORS = { - black: "000000", white: "ffffff", red: "ff0000", green: "00c853", lime: "00ff00", - blue: "2962ff", yellow: "ffeb3b", cyan: "00e5ff", magenta: "ff00ff", orange: "ff9100", - purple: "9c27b0", pink: "ff4081", gray: "808080", grey: "808080", teal: "009688", - transparent: "00000000", -}; -function parseColor(input) { - if (input && typeof input === "object") { - const c = (v) => clampInt(v, 0, 255, 0); - return { r: c(input.r), g: c(input.g), b: c(input.b), a: input.a == null ? 255 : c(input.a) }; - } - let s = String(input ?? "").trim().toLowerCase(); - if (NAMED_COLORS[s]) s = NAMED_COLORS[s]; - let hex = s.replace(/^#/, ""); - if (hex.length === 3) hex = hex.split("").map((c) => c + c).join(""); - if (!/^[0-9a-f]{6}([0-9a-f]{2})?$/.test(hex)) { - throw new CanvasError("bad_color", `Invalid color: ${input}. Use a hex value (#ff8800) or a name like "blue".`); - } - return { - r: parseInt(hex.slice(0, 2), 16), - g: parseInt(hex.slice(2, 4), 16), - b: parseInt(hex.slice(4, 6), 16), - a: hex.length === 8 ? parseInt(hex.slice(6, 8), 16) : 255, - }; -} - -// ---- HTTP server + SSE -------------------------------------------------- -const servers = new Map(); // instanceId -> { instanceId, server, url, projectId, token, sse:Set } - -// Resolve which project an action targets: an explicit projectId wins, else the -// project bound to the invoking canvas instance, else the default project. -function resolveProjectId(ctx) { - if (ctx?.input?.projectId) return sanitizeId(ctx.input.projectId); - const entry = servers.get(ctx?.instanceId); - if (entry) return entry.projectId; - return DEFAULT_PROJECT; -} - -// Constant-time compare for the per-server access token. -function tokenMatches(provided, expected) { - if (typeof provided !== "string" || provided.length !== expected.length) return false; - try { - return timingSafeEqual(Buffer.from(provided), Buffer.from(expected)); - } catch { - return false; - } -} - -// Push a "changed" event to every open panel of a project (across instances). -function broadcast(projectId) { - const pid = sanitizeId(projectId); - for (const entry of servers.values()) { - if (entry.projectId !== pid) continue; - for (const res of entry.sse) { - // Drop responses that have already ended/reset rather than writing - // to a dead stream. - if (res.writableEnded || res.destroyed) { - entry.sse.delete(res); - continue; - } - try { - res.write(`data: changed\n\n`); - } catch { - entry.sse.delete(res); - } - } - } -} - -const CONTENT_TYPES = { - ".html": "text/html; charset=utf-8", - ".js": "text/javascript; charset=utf-8", - ".css": "text/css; charset=utf-8", -}; - -function send(res, status, type, body, extraHeaders) { - res.writeHead(status, { "Content-Type": type, "Cache-Control": "no-store", ...(extraHeaders || {}) }); - res.end(body); -} - -// Error carrying an explicit HTTP status (e.g. 400 bad JSON, 413 too large). -class HttpError extends Error { - constructor(status, message) { - super(message); - this.status = status; - } -} - -const MAX_JSON_BYTES = 1 << 20; // 1 MiB — mutation payloads are tiny -const MAX_UPLOAD_BYTES = 40 << 20; // 40 MiB — a single decoded frame PNG - -function readBody(req, maxBytes) { - return new Promise((resolve, reject) => { - const chunks = []; - let total = 0; - let over = false; - req.on("data", (c) => { - if (over) return; // past the limit: drain without buffering - total += c.length; - if (total > maxBytes) { - over = true; - reject(new HttpError(413, "Request body too large.")); - return; - } - chunks.push(c); - }); - req.on("end", () => { - if (!over) resolve(Buffer.concat(chunks)); - }); - req.on("error", reject); - }); -} -async function readJson(req) { - const buf = await readBody(req, MAX_JSON_BYTES); - if (!buf.length) return {}; - try { - return JSON.parse(buf.toString("utf8")); - } catch { - throw new HttpError(400, "Invalid JSON body."); - } -} - -async function handleRequest(entry, req, res) { - const projectId = entry.projectId; - const url = new URL(req.url, "http://localhost"); - const path = url.pathname; - const method = req.method || "GET"; - - try { - // Static renderer assets are public (they carry no project data). The - // iframe is loaded with the token in its URL; app.js then reads it and - // attaches it to every data request below. - if (method === "GET" && (path === "/" || path === "/index.html")) { - return send(res, 200, CONTENT_TYPES[".html"], await fs.readFile(join(WEB_DIR, "index.html"))); - } - if (method === "GET" && (path === "/app.js" || path === "/styles.css")) { - const file = path.slice(1); - return send(res, 200, CONTENT_TYPES[extname(file)] || "text/plain", await fs.readFile(join(WEB_DIR, file))); - } - if (path === "/favicon.ico") return send(res, 204, "text/plain", ""); - - // Everything below reads or mutates project data. Require the per-server - // token so another local process or a cross-origin page that guesses the - // port cannot read state or drive mutations (e.g. /frames/clear). - if (!tokenMatches(url.searchParams.get("k") || "", entry.token)) { - return send(res, 403, "text/plain", "Forbidden"); - } - - // State. - if (method === "GET" && path === "/state") { - const meta = await loadProject(projectId); - return send(res, 200, "application/json", JSON.stringify(publicState(meta))); - } - - // Server-Sent Events. Track the client on this instance so its canvas - // can end just its own streams on close without disturbing other panels. - if (method === "GET" && path === "/events") { - res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-store", - Connection: "keep-alive", - }); - res.write(": connected\n\n"); - entry.sse.add(res); - const drop = () => entry.sse.delete(res); - req.on("close", drop); - res.on("close", drop); - res.on("error", drop); - return; - } - - // A single frame PNG (for thumbnails / onion skin / draw base). - if (method === "GET" && path === "/frame") { - const fid = url.searchParams.get("id"); - try { - const buf = await fs.readFile(framePath(projectId, fid)); - return send(res, 200, "image/png", buf); - } catch { - return send(res, 404, "text/plain", "frame not found"); - } - } - - // Live-assembled APNG preview (served as image/png — APNG is byte-compatible - // with PNG, so browsers animate it and default viewers still open it). - if (method === "GET" && path === "/preview.png") { - const bytes = await assemble(projectId); - if (!bytes) return send(res, 204, "image/png", ""); - return send(res, 200, "image/png", Buffer.from(bytes)); - } - - // Add a frame (raw PNG body). - if (method === "POST" && path === "/frames") { - const buf = await readBody(req, MAX_UPLOAD_BYTES); - if (!buf.length) return send(res, 400, "text/plain", "empty body"); - const delayMs = url.searchParams.get("delayMs"); - const id = await addFrameBuffer(projectId, buf, { delayMs }); - return send(res, 200, "application/json", JSON.stringify({ id })); - } - - // JSON mutation endpoints. - if (method === "POST") { - const body = await readJson(req); - switch (path) { - case "/frames/move": - await moveFrame(projectId, body.id, body.delta); - return send(res, 200, "application/json", "{}"); - case "/frames/delete": - await deleteFrame(projectId, body.id); - return send(res, 200, "application/json", "{}"); - case "/frames/duplicate": - await duplicateFrame(projectId, body.id); - return send(res, 200, "application/json", "{}"); - case "/frames/props": - await setFrameProps(projectId, body.id, body); - return send(res, 200, "application/json", "{}"); - case "/frames/props-all": - await setFramePropsAll(projectId, body); - return send(res, 200, "application/json", "{}"); - case "/frames/clear": - await clearFrames(projectId); - return send(res, 200, "application/json", "{}"); - case "/settings": - await applySettings(projectId, body); - return send(res, 200, "application/json", "{}"); - case "/export": { - const out = await exportApng(projectId, body.filename); - log(`APNG exported: ${out.path}`); - return send(res, 200, "application/json", JSON.stringify(out)); - } - } - } - - // ---- "Send to phone" control plane (loopback only) -------------- - if (method === "POST" && path === "/share/start") { - try { - const info = await startShare(projectId); - return send(res, 200, "application/json", JSON.stringify(info)); - } catch (err) { - const status = err instanceof CanvasError ? 400 : 500; - return send(res, status, "text/plain", err.message || "Could not start sharing."); - } - } - if (method === "POST" && path === "/share/stop") { - stopShare(projectId); - return send(res, 200, "application/json", "{}"); - } - if (method === "GET" && path === "/share/qr.png") { - const s = shares.get(projectId); - if (!s || Date.now() > s.expiresAt) { - return send(res, 409, "text/plain", "no active share"); - } - return send(res, 200, "image/png", Buffer.from(renderQrPng(shareUrlFor(projectId)))); - } - - return send(res, 404, "text/plain", "not found"); - } catch (err) { - if (err instanceof HttpError) return send(res, err.status, "text/plain", err.message); - // CanvasError is a user-facing validation error (bad frame, size - // mismatch, nothing to export), not a server fault. - if (err instanceof CanvasError) return send(res, 400, "text/plain", err.message); - return send(res, 500, "text/plain", String(err && err.message ? err.message : err)); - } -} - -async function startServer(entry) { - entry.token = randomBytes(16).toString("hex"); - entry.sse = new Set(); - const server = createServer((req, res) => handleRequest(entry, req, res)); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - server.removeListener("error", reject); - resolve(); - }); - }); - const { port } = server.address(); - entry.server = server; - // The token travels in the iframe URL; app.js reads it and attaches it to - // every data request so other local origins cannot reach project data. - entry.url = `http://127.0.0.1:${port}/?k=${entry.token}`; - return entry; -} - -// ---- "Send to phone" share server --------------------------------------- -// A separate, read-only HTTP server bound to the LAN so a phone can fetch the -// live animation. It exposes ONLY a landing page and the preview image, gated -// by a short-lived random token, and it shuts itself down when the token -// expires. Mutation endpoints stay on the loopback server and are never -// reachable from the network. -const SHARE_TTL_MS = 10 * 60 * 1000; -let shareServer = null; // single LAN-bound HTTP server, created on demand -let shareServerStarting = null; // in-flight startup promise (dedupe concurrent starts) -let shareServerBindIp = null; // the private IPv4 the server is actually bound to -const shares = new Map(); // projectId -> { token, expiresAt, timer } - -// Best-guess private (RFC1918) LAN IPv4 to bind the share server to, or null. -// Interfaces that are typically virtual (VPN/VM/container: utun, ipsec, tun/tap, -// bridge, vmnet, docker, veth, wg, awdl…) are skipped, and real NICs (en*, eth*, -// wl*) on common home/office ranges are preferred. This is a heuristic — on an -// unusual multi-homed host it can still pick the wrong interface. -function lanIPv4() { - const isPrivate = (ip) => - /^10\./.test(ip) || /^192\.168\./.test(ip) || /^172\.(1[6-9]|2\d|3[01])\./.test(ip); - const virtual = /^(utun|ipsec|ppp|tun|tap|awdl|llw|bridge|vmnet|vboxnet|docker|veth|wg|zt)/i; - const physical = /^(en|eth|wl|wlan)/i; - const cands = []; - for (const [name, addrs] of Object.entries(networkInterfaces())) { - for (const a of addrs || []) { - if (a.family !== "IPv4" || a.internal) continue; - if (!isPrivate(a.address)) continue; - if (virtual.test(name)) continue; - let score = 0; - if (physical.test(name)) score -= 100; - if (/^192\.168\./.test(a.address)) score -= 10; - else if (/^10\./.test(a.address)) score -= 5; // 172.16/12 is often Docker; least preferred - cands.push({ ip: a.address, score }); - } - } - cands.sort((a, b) => a.score - b.score); - return cands.length ? cands[0].ip : null; -} - -function shareUrlFor(projectId) { - const s = shares.get(projectId); - const { port } = shareServer.address(); - // Use the address the server is actually bound to, not a freshly resolved - // one, so the link always points where the listener is really accepting. - return `http://${shareServerBindIp}:${port}/s?t=${s.token}`; -} - -// Resolve an active share from its token (constant-time compare), so tokens from -// one project's panel can never address another project's share. -function shareForToken(token) { - if (typeof token !== "string" || !token) return null; - const tokenBuf = Buffer.from(token); - for (const [projectId, s] of shares) { - if (s.token.length !== token.length) continue; - let ok = false; - try { - ok = timingSafeEqual(tokenBuf, Buffer.from(s.token)); - } catch { - ok = false; - } - if (ok) return { projectId, share: s }; - } - return null; -} - -function shareLandingHtml(token) { - const src = `/s/preview.png?t=${encodeURIComponent(token)}`; - // Self-contained page: no external assets, checkerboard behind the image. - return ` - -APNG Studio - -

APNG Studio

-
Animated PNG preview
-Save to your device -

Tap and hold the image to save it. This link expires shortly.

-`; -} - -async function shareRequest(req, res) { - try { - const url = new URL(req.url, "http://localhost"); - const token = url.searchParams.get("t") || ""; - const match = shareForToken(token); - if (!match) return send(res, 403, "text/plain", "Invalid or expired link."); - if (Date.now() > match.share.expiresAt) { - stopShare(match.projectId); - return send(res, 410, "text/plain", "This link has expired."); - } - if (req.method === "GET" && (url.pathname === "/s" || url.pathname === "/s/")) { - return send(res, 200, CONTENT_TYPES[".html"], shareLandingHtml(token)); - } - if (req.method === "GET" && url.pathname === "/s/preview.png") { - const bytes = await assemble(match.projectId); - if (!bytes) return send(res, 204, "image/png", ""); - return send(res, 200, "image/png", Buffer.from(bytes)); - } - return send(res, 404, "text/plain", "not found"); - } catch (err) { - if (err instanceof HttpError) return send(res, err.status, "text/plain", err.message); - return send(res, 500, "text/plain", String(err && err.message ? err.message : err)); - } -} - -// Start (or reuse) the single LAN share server. Concurrent callers share one -// in-flight startup promise so two /share/start requests can't each bind a -// separate listener and leak one. -async function ensureShareServer(bindIp) { - if (shareServer) { - // Reuse the running server, unless the LAN address changed and nothing - // is currently being shared — then rebind to the new private address. - if (shareServerBindIp === bindIp || shares.size > 0) return shareServer; - const old = shareServer; - shareServer = null; - shareServerStarting = null; - shareServerBindIp = null; - try { - old.close(); - } catch { - /* already closing */ - } - } - if (!shareServerStarting) { - shareServerStarting = (async () => { - const server = createServer(shareRequest); - await new Promise((resolve, reject) => { - server.once("error", reject); - // Bind only to the private LAN address, not 0.0.0.0, so the - // listener is never exposed on public/VPN interfaces. - server.listen(0, bindIp, resolve); - }); - shareServer = server; - shareServerBindIp = bindIp; - return server; - })().catch((err) => { - shareServerStarting = null; - throw err; - }); - } - return shareServerStarting; -} - -async function startShare(projectId) { - const ip = lanIPv4(); - if (!ip) { - throw new CanvasError( - "no_network", - "No local Wi-Fi/LAN address found. Connect to a local network to share to your phone." - ); - } - await ensureShareServer(ip); - // The canvas may have closed while the server was binding. If no open panel - // still references this project, don't leave a share (or an idle LAN server) - // behind. - const stillOpen = [...servers.values()].some((e) => e.projectId === sanitizeId(projectId)); - if (!stillOpen) { - if (shares.size === 0 && shareServer) { - const server = shareServer; - shareServer = null; - shareServerStarting = null; - shareServerBindIp = null; - try { - server.close(); - } catch { - /* already closing */ - } - } - throw new CanvasError("canvas_closed", "The canvas was closed before sharing started."); - } - const existing = shares.get(projectId); - if (existing?.timer) clearTimeout(existing.timer); - const token = randomBytes(16).toString("hex"); - const expiresAt = Date.now() + SHARE_TTL_MS; - const timer = setTimeout(() => stopShare(projectId), SHARE_TTL_MS); - timer.unref?.(); - shares.set(projectId, { token, expiresAt, timer }); - return { url: shareUrlFor(projectId), expiresAt, ttlMs: SHARE_TTL_MS }; -} - -function stopShare(projectId) { - const s = shares.get(projectId); - if (!s) return; - if (s.timer) clearTimeout(s.timer); - shares.delete(projectId); - // Close the shared LAN server once nothing is being shared. - if (shares.size === 0 && shareServer) { - const server = shareServer; - shareServer = null; - shareServerStarting = null; - shareServerBindIp = null; - try { - server.close(); - } catch { - /* already closing */ - } - } -} - -// Render a QR matrix into a scannable PNG using the RGBA->PNG encoder. -function renderQrPng(text, scale = 8, quiet = 4) { - const { matrix, size } = encodeQr(text); - const dim = (size + quiet * 2) * scale; - const rgba = new Uint8Array(dim * dim * 4).fill(255); - for (let r = 0; r < size; r++) { - for (let c = 0; c < size; c++) { - if (!matrix[r][c]) continue; - for (let y = 0; y < scale; y++) { - for (let x = 0; x < scale; x++) { - const o = (((r + quiet) * scale + y) * dim + ((c + quiet) * scale + x)) * 4; - rgba[o] = 0; - rgba[o + 1] = 0; - rgba[o + 2] = 0; - rgba[o + 3] = 255; - } - } - } - } - return encodeRgbaPng(dim, dim, rgba); -} - -// ---- canvas declaration ------------------------------------------------- -const openInputSchema = { - type: "object", - properties: { - projectId: { type: "string", description: "Identifier for the animation project (defaults to 'default')." }, - name: { type: "string", description: "Optional display name for the animation." }, - }, - additionalProperties: false, -}; - -session = await joinSession({ - canvases: [ - createCanvas({ - id: "apng-studio", - displayName: "APNG Studio", - description: - "Build an Animated PNG (APNG) from frames: upload or draw frames, set per-frame delays and loop count, preview live, and export an animated .png file.", - inputSchema: openInputSchema, - actions: [ - { - name: "get_state", - description: "Return the current project's dimensions, loop count, frame count and per-frame delays.", - inputSchema: { - type: "object", - properties: { projectId: { type: "string" } }, - additionalProperties: false, - }, - handler: async (ctx) => { - const meta = await loadProject(resolveProjectId(ctx)); - // hiddenFirst only takes effect with >=2 frames (matches the - // encoder in apng.mjs), so a lone frame still counts as animated. - const hidden = meta.hiddenFirst && meta.frames.length >= 2; - const animated = hidden ? meta.frames.slice(1) : meta.frames; - // Sum exact numerator/denominator fractions, then convert - // once, so the total matches the encoded timing rather than - // accumulating per-frame rounding. - const totalMs = Math.round( - animated.reduce((a, f) => a + f.delayNum / f.delayDen, 0) * 1000 - ); - return { - ...publicState(meta), - frameCount: meta.frames.length, - totalDurationMs: totalMs, - exportsDir: EXPORTS_DIR, - }; - }, - }, - { - name: "set_settings", - description: - "Update project settings. width/height only apply when there are no frames yet. loops: 0 = infinite. hiddenFirst: make frame 1 a static fallback that is not part of the animation.", - inputSchema: { - type: "object", - properties: { - projectId: { type: "string" }, - width: { type: "integer", minimum: 1, maximum: 2048 }, - height: { type: "integer", minimum: 1, maximum: 2048 }, - loops: { type: "integer", minimum: 0, maximum: 65535 }, - hiddenFirst: { type: "boolean", description: "Frame 1 becomes a static, non-animated fallback image." }, - name: { type: "string" }, - }, - additionalProperties: false, - }, - handler: async (ctx) => { - const { projectId, ...settings } = ctx.input || {}; - const meta = await applySettings(resolveProjectId(ctx), settings); - return publicState(meta); - }, - }, - { - name: "add_color_frame", - description: - "Append a solid-color frame at the project's dimensions. Useful for building simple animations programmatically. Color accepts a hex value (#ff8800) or a name like 'blue'.", - inputSchema: { - type: "object", - properties: { - projectId: { type: "string" }, - color: { type: "string", description: "Hex (#rrggbb / #rrggbbaa) or a color name." }, - delayMs: { type: "integer", minimum: 0, maximum: 65535, description: "Frame delay in ms. Use one timing mode only." }, - delayNum: { type: "integer", minimum: 0, maximum: 65535, description: "Delay numerator; pair with delayDen. Use one timing mode only." }, - delayDen: { type: "integer", minimum: 1, maximum: 65535, description: "Delay denominator (default 1000). Use one timing mode only." }, - disposeOp: { type: "integer", minimum: 0, maximum: 2, description: "0=None, 1=Background, 2=Previous." }, - blendOp: { type: "integer", minimum: 0, maximum: 1, description: "0=Source, 1=Over." }, - }, - required: ["color"], - additionalProperties: false, - }, - handler: async (ctx) => { - const id = resolveProjectId(ctx); - const color = parseColor(ctx.input?.color); - const { color: _c, projectId: _p, ...opts } = ctx.input || {}; - if (opts.delayMs == null && opts.delayNum == null && opts.delayDen == null && opts.fps == null) opts.delayMs = 120; - // Read dimensions, render, and append under one lock so a - // concurrent set_settings can't change the size between - // rendering the PNG and recording the frame. - return withProjectLock(id, async () => { - const meta = await loadProject(id); - const png = solidColorPng(meta.width, meta.height, color); - const fid = await addFrameToMeta(meta, png, opts); - await saveProject(meta); - broadcast(meta.id); - return { frameId: fid, frameCount: meta.frames.length }; - }); - }, - }, - { - name: "set_frame", - description: - "Change timing/compositing for one frame (by frameId) or every frame (all: true). Timing (choose exactly one mode): delayMs, or fps (exact frame rate), or delayNum/delayDen — combining modes is rejected. Compositing: disposeOp (0=None,1=Background,2=Previous), blendOp (0=Source,1=Over).", - inputSchema: { - type: "object", - properties: { - projectId: { type: "string" }, - frameId: { type: "string", description: "Target frame id. Omit and set all:true to apply to every frame." }, - all: { type: "boolean", description: "Apply to all frames instead of a single frameId." }, - delayMs: { type: "integer", minimum: 0, maximum: 65535 }, - fps: { type: "integer", minimum: 1, maximum: 1000, description: "Exact frame rate; sets delay to 1/fps s." }, - delayNum: { type: "integer", minimum: 0, maximum: 65535 }, - delayDen: { type: "integer", minimum: 1, maximum: 65535 }, - disposeOp: { type: "integer", minimum: 0, maximum: 2 }, - blendOp: { type: "integer", minimum: 0, maximum: 1 }, - }, - additionalProperties: false, - }, - handler: async (ctx) => { - const id = resolveProjectId(ctx); - const { projectId: _p, frameId, all, ...props } = ctx.input || {}; - if (all) { - await setFramePropsAll(id, props); - } else if (frameId != null) { - await setFrameProps(id, frameId, props); - } else { - throw new CanvasError("no_target", "Provide a frameId, or set all:true to apply to every frame."); - } - return publicState(await loadProject(id)); - }, - }, - { - name: "clear_frames", - description: "Remove all frames from the project.", - inputSchema: { - type: "object", - properties: { projectId: { type: "string" } }, - additionalProperties: false, - }, - handler: async (ctx) => { - await clearFrames(resolveProjectId(ctx)); - return { ok: true }; - }, - }, - { - name: "export", - description: "Assemble the frames into an animated .png (APNG) file on disk and return its absolute path.", - inputSchema: { - type: "object", - properties: { - projectId: { type: "string" }, - filename: { type: "string", description: "Optional output filename (without directory)." }, - }, - additionalProperties: false, - }, - handler: async (ctx) => { - return exportApng(resolveProjectId(ctx), ctx.input?.filename); - }, - }, - ], - open: async (ctx) => { - const projectId = sanitizeId(ctx.input?.projectId ?? DEFAULT_PROJECT); - let meta = await loadProject(projectId); - if (ctx.input?.name && typeof ctx.input.name === "string") { - // Route the rename through the locked mutation path so it - // can't race a concurrent save or skip the panel broadcast. - meta = await applySettings(projectId, { name: ctx.input.name }); - } - let entry = servers.get(ctx.instanceId); - if (!entry) { - entry = { instanceId: ctx.instanceId, projectId, sse: new Set() }; - servers.set(ctx.instanceId, entry); - try { - await startServer(entry); - } catch (err) { - // Don't leave a half-open instance behind: a later open would - // skip startup and return an undefined URL, and onClose would - // dereference a missing server. Drop it so it can retry. - servers.delete(ctx.instanceId); - throw err; - } - } else { - // Re-open: repoint the existing server at the requested - // project in place so the loopback URL stays stable. - entry.projectId = projectId; - } - return { - title: `APNG Studio — ${meta.name}`, - url: entry.url, - status: `${meta.frames.length} frame${meta.frames.length === 1 ? "" : "s"}`, - }; - }, - onClose: async (ctx) => { - const entry = servers.get(ctx.instanceId); - if (!entry) return; - servers.delete(ctx.instanceId); - // End this instance's SSE streams first, otherwise server.close() - // waits on the open /events response and never resolves. - for (const res of entry.sse) { - try { - res.end(); - } catch { - /* already closed */ - } - } - entry.sse.clear(); - // Only stop this project's LAN share when no other open panel - // still references the project, so closing one of two panels - // doesn't invalidate the other's phone link. - const stillOpen = [...servers.values()].some((e) => e.projectId === entry.projectId); - if (!stillOpen) stopShare(entry.projectId); - if (entry.server) await new Promise((resolve) => entry.server.close(() => resolve())); - }, - }), - ], -}); - -await ensureDir(EXPORTS_DIR); -log("APNG Studio ready."); diff --git a/extensions/apng-studio/qr.mjs b/extensions/apng-studio/qr.mjs deleted file mode 100644 index e90e0703..00000000 --- a/extensions/apng-studio/qr.mjs +++ /dev/null @@ -1,438 +0,0 @@ -// Minimal, dependency-free QR Code encoder (byte mode, EC level M, versions -// 1-10). Enough to encode a short LAN URL for the "Send to phone" feature. -// -// Returns a square matrix of 0/1 modules. Rendering to PNG is done by the -// caller via the RGBA->PNG encoder in apng.mjs, so this file has no I/O. -// -// Reference: ISO/IEC 18004. Verified module-for-module against the python -// `qrcode` reference encoder (see eng verification) for forced masks 0-7. - -// ---- Galois field GF(256), primitive polynomial 0x11d -------------------- -const EXP = new Uint8Array(512); -const LOG = new Uint8Array(256); -(() => { - let x = 1; - for (let i = 0; i < 255; i++) { - EXP[i] = x; - LOG[x] = i; - x <<= 1; - if (x & 0x100) x ^= 0x11d; - } - for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255]; -})(); - -const gfMul = (a, b) => (a === 0 || b === 0 ? 0 : EXP[LOG[a] + LOG[b]]); - -// Reed-Solomon generator polynomial of the given degree. -function rsGenerator(degree) { - let poly = [1]; - for (let i = 0; i < degree; i++) { - const next = new Array(poly.length + 1).fill(0); - for (let j = 0; j < poly.length; j++) { - next[j] ^= gfMul(poly[j], EXP[i]); - next[j + 1] ^= poly[j]; - } - poly = next; - } - return poly; -} - -function rsEncode(data, ecLen) { - const gen = rsGenerator(ecLen); // constant-first; gen[ecLen] is the leading 1 - const res = new Array(ecLen).fill(0); - for (const byte of data) { - const factor = byte ^ res[0]; - res.shift(); - res.push(0); - // Use the non-leading generator coefficients in descending-degree order. - for (let i = 0; i < ecLen; i++) res[i] ^= gfMul(gen[ecLen - 1 - i], factor); - } - return res; -} - -// ---- Version tables (EC level M) ---------------------------------------- -// [ecPerBlock, [[blockCount, dataCodewordsPerBlock], ...]] -const EC_BLOCKS_M = { - 1: [10, [[1, 16]]], - 2: [16, [[1, 28]]], - 3: [26, [[1, 44]]], - 4: [18, [[2, 32]]], - 5: [24, [[2, 43]]], - 6: [16, [[4, 27]]], - 7: [18, [[4, 31]]], - 8: [22, [[2, 38], [2, 39]]], - 9: [22, [[3, 36], [2, 37]]], - 10: [26, [[4, 43], [1, 44]]], -}; - -const ALIGN_POS = { - 1: [], 2: [6, 18], 3: [6, 22], 4: [6, 26], 5: [6, 30], - 6: [6, 34], 7: [6, 22, 38], 8: [6, 24, 42], 9: [6, 26, 46], 10: [6, 28, 50], -}; - -const totalDataCodewords = (v) => - EC_BLOCKS_M[v][1].reduce((sum, [count, dc]) => sum + count * dc, 0); - -const charCountBits = (v) => (v <= 9 ? 8 : 16); - -function chooseVersion(dataLen) { - for (let v = 1; v <= 10; v++) { - const capacityBits = totalDataCodewords(v) * 8; - const needed = 4 + charCountBits(v) + dataLen * 8; - if (needed <= capacityBits) return v; - } - throw new Error("Data too long for QR versions 1-10 (byte mode, EC M)"); -} - -// ---- Bit buffer ---------------------------------------------------------- -class BitBuffer { - constructor() { - this.bits = []; - } - put(value, length) { - for (let i = length - 1; i >= 0; i--) this.bits.push((value >>> i) & 1); - } - get length() { - return this.bits.length; - } -} - -function buildCodewords(bytes, version) { - const buf = new BitBuffer(); - buf.put(0b0100, 4); // byte mode - buf.put(bytes.length, charCountBits(version)); - for (const b of bytes) buf.put(b, 8); - - const capacityBits = totalDataCodewords(version) * 8; - // Terminator (up to 4 zero bits). - const term = Math.min(4, capacityBits - buf.length); - buf.put(0, term); - // Pad to a byte boundary. - while (buf.length % 8 !== 0) buf.bits.push(0); - // Pad bytes. - const padBytes = [0xec, 0x11]; - let pi = 0; - while (buf.length < capacityBits) { - buf.put(padBytes[pi++ % 2], 8); - } - - // Pack bits into data codewords. - const data = []; - for (let i = 0; i < buf.length; i += 8) { - let byte = 0; - for (let j = 0; j < 8; j++) byte = (byte << 1) | buf.bits[i + j]; - data.push(byte); - } - - // Split into blocks, compute EC, then interleave. - const [ecPerBlock, groups] = EC_BLOCKS_M[version]; - const dataBlocks = []; - const ecBlocks = []; - let offset = 0; - for (const [count, dcPerBlock] of groups) { - for (let b = 0; b < count; b++) { - const block = data.slice(offset, offset + dcPerBlock); - offset += dcPerBlock; - dataBlocks.push(block); - ecBlocks.push(rsEncode(block, ecPerBlock)); - } - } - - const result = []; - const maxData = Math.max(...dataBlocks.map((b) => b.length)); - for (let i = 0; i < maxData; i++) { - for (const block of dataBlocks) if (i < block.length) result.push(block[i]); - } - for (let i = 0; i < ecPerBlock; i++) { - for (const block of ecBlocks) result.push(block[i]); - } - return result; -} - -// ---- Matrix construction ------------------------------------------------- -function makeBaseMatrix(size) { - const m = Array.from({ length: size }, () => new Array(size).fill(null)); - return m; -} - -function placeFinder(m, r, c) { - for (let i = -1; i <= 7; i++) { - for (let j = -1; j <= 7; j++) { - const rr = r + i; - const cc = c + j; - if (rr < 0 || cc < 0 || rr >= m.length || cc >= m.length) continue; - const inRing = - i >= 0 && i <= 6 && j >= 0 && j <= 6 && - (i === 0 || i === 6 || j === 0 || j === 6); - const inCore = i >= 2 && i <= 4 && j >= 2 && j <= 4; - m[rr][cc] = inRing || inCore ? 1 : 0; - } - } -} - -function placeAlignment(m, version) { - const pos = ALIGN_POS[version]; - for (const r of pos) { - for (const c of pos) { - // Skip the three finder corners. - if ((r === 6 && c === 6) || (r === 6 && c === m.length - 7) || (r === m.length - 7 && c === 6)) continue; - if (m[r][c] !== null) continue; - for (let i = -2; i <= 2; i++) { - for (let j = -2; j <= 2; j++) { - const ring = Math.max(Math.abs(i), Math.abs(j)); - m[r + i][c + j] = ring === 1 ? 0 : 1; - } - } - } - } -} - -function reserveFormat(m) { - const size = m.length; - // Marks format/version areas as reserved (use a sentinel we overwrite later). - // Handled implicitly: we set them during placement by skipping null-only. - return size; -} - -const FORMAT_MASK = 0x5412; - -function bchFormat(data5) { - let d = data5 << 10; - const g = 0b10100110111; - for (let i = 4; i >= 0; i--) { - if ((d >> (i + 10)) & 1) d ^= g << i; - } - return ((data5 << 10) | d) ^ FORMAT_MASK; -} - -function bchVersion(version) { - let d = version << 12; - const g = 0b1111100100101; - for (let i = 5; i >= 0; i--) { - if ((d >> (i + 12)) & 1) d ^= g << i; - } - return (version << 12) | d; -} - -const MASKS = [ - (r, c) => (r + c) % 2 === 0, - (r, c) => r % 2 === 0, - (r, c) => c % 3 === 0, - (r, c) => (r + c) % 3 === 0, - (r, c) => (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0, - (r, c) => ((r * c) % 2) + ((r * c) % 3) === 0, - (r, c) => (((r * c) % 2) + ((r * c) % 3)) % 2 === 0, - (r, c) => (((r + c) % 2) + ((r * c) % 3)) % 2 === 0, -]; - -function isFunctionModule(reserved, r, c) { - return reserved[r][c]; -} - -function buildReserved(size, version) { - const reserved = Array.from({ length: size }, () => new Array(size).fill(false)); - const mark = (r, c) => { - if (r >= 0 && c >= 0 && r < size && c < size) reserved[r][c] = true; - }; - // Finders + separators. - for (const [br, bc] of [[0, 0], [0, size - 7], [size - 7, 0]]) { - for (let i = -1; i <= 7; i++) for (let j = -1; j <= 7; j++) mark(br + i, bc + j); - } - // Timing. - for (let i = 0; i < size; i++) { - mark(6, i); - mark(i, 6); - } - // Alignment. - const pos = ALIGN_POS[version]; - for (const r of pos) for (const c of pos) { - if ((r === 6 && c === 6) || (r === 6 && c === size - 7) || (r === size - 7 && c === 6)) continue; - for (let i = -2; i <= 2; i++) for (let j = -2; j <= 2; j++) mark(r + i, c + j); - } - // Format info areas. - for (let i = 0; i < 9; i++) { - mark(8, i); - mark(i, 8); - } - for (let i = 0; i < 8; i++) { - mark(8, size - 1 - i); - mark(size - 1 - i, 8); - } - mark(size - 8, 8); // dark module - // Version info (v >= 7). - if (version >= 7) { - for (let i = 0; i < 6; i++) for (let j = 0; j < 3; j++) { - mark(i, size - 11 + j); - mark(size - 11 + j, i); - } - } - return reserved; -} - -function placeTiming(m) { - const size = m.length; - for (let i = 0; i < size; i++) { - if (m[6][i] === null) m[6][i] = i % 2 === 0 ? 1 : 0; - if (m[i][6] === null) m[i][6] = i % 2 === 0 ? 1 : 0; - } -} - -function placeData(m, reserved, codewords) { - const size = m.length; - const bits = []; - for (const cw of codewords) for (let i = 7; i >= 0; i--) bits.push((cw >> i) & 1); - let idx = 0; - let upward = true; - for (let col = size - 1; col > 0; col -= 2) { - if (col === 6) col--; // skip vertical timing column - for (let n = 0; n < size; n++) { - const row = upward ? size - 1 - n : n; - for (let k = 0; k < 2; k++) { - const c = col - k; - if (reserved[row][c]) continue; - m[row][c] = idx < bits.length ? bits[idx++] : 0; - } - } - upward = !upward; - } -} - -function applyMask(m, reserved, maskFn) { - const out = m.map((row) => row.slice()); - for (let r = 0; r < m.length; r++) { - for (let c = 0; c < m.length; c++) { - if (reserved[r][c]) continue; - if (maskFn(r, c)) out[r][c] ^= 1; - } - } - return out; -} - -function placeFormatBits(m, maskIndex) { - const size = m.length; - // EC level M = 0b00. Format data = (ecBits << 3) | maskIndex. - const format = bchFormat((0b00 << 3) | maskIndex); - const bits = []; - for (let i = 14; i >= 0; i--) bits.push((format >> i) & 1); - // Around top-left finder. - const coords1 = [ - [8, 0], [8, 1], [8, 2], [8, 3], [8, 4], [8, 5], [8, 7], [8, 8], - [7, 8], [5, 8], [4, 8], [3, 8], [2, 8], [1, 8], [0, 8], - ]; - coords1.forEach(([r, c], i) => (m[r][c] = bits[i])); - // Split across top-right and bottom-left. - const coords2 = [ - [size - 1, 8], [size - 2, 8], [size - 3, 8], [size - 4, 8], - [size - 5, 8], [size - 6, 8], [size - 7, 8], - [8, size - 8], [8, size - 7], [8, size - 6], [8, size - 5], - [8, size - 4], [8, size - 3], [8, size - 2], [8, size - 1], - ]; - coords2.forEach(([r, c], i) => (m[r][c] = bits[i])); - m[size - 8][8] = 1; // dark module -} - -function placeVersionBits(m, version) { - if (version < 7) return; - const size = m.length; - const v = bchVersion(version); - const bits = []; - for (let i = 0; i <= 17; i++) bits.push((v >> i) & 1); // least-significant bit first - let idx = 0; - for (let i = 0; i < 6; i++) { - for (let j = 0; j < 3; j++) { - const b = bits[idx++]; - m[i][size - 11 + j] = b; - m[size - 11 + j][i] = b; - } - } -} - -// Penalty scoring for mask selection (ISO 18004 rules 1-4). -function penalty(m) { - const size = m.length; - let score = 0; - // Rule 1: runs of 5+ same-color in rows/cols. - for (let r = 0; r < size; r++) { - for (const line of [m[r], m.map((row) => row[r])]) { - let run = 1; - for (let c = 1; c < size; c++) { - if (line[c] === line[c - 1]) { - run++; - if (run === 5) score += 3; - else if (run > 5) score += 1; - } else run = 1; - } - } - } - // Rule 2: 2x2 blocks. - for (let r = 0; r < size - 1; r++) { - for (let c = 0; c < size - 1; c++) { - const v = m[r][c]; - if (v === m[r][c + 1] && v === m[r + 1][c] && v === m[r + 1][c + 1]) score += 3; - } - } - // Rule 3: finder-like patterns. - const pat1 = [1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0]; - const pat2 = [0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1]; - const matchAt = (line, i, pat) => pat.every((p, k) => line[i + k] === p); - for (let r = 0; r < size; r++) { - const rowLine = m[r]; - const colLine = m.map((row) => row[r]); - for (let c = 0; c <= size - 11; c++) { - if (matchAt(rowLine, c, pat1) || matchAt(rowLine, c, pat2)) score += 40; - if (matchAt(colLine, c, pat1) || matchAt(colLine, c, pat2)) score += 40; - } - } - // Rule 4: dark/light balance. - let dark = 0; - for (let r = 0; r < size; r++) for (let c = 0; c < size; c++) dark += m[r][c]; - const percent = (dark * 100) / (size * size); - const prev = Math.floor(percent / 5) * 5; - const next = prev + 5; - score += Math.min(Math.abs(prev - 50), Math.abs(next - 50)) / 5 * 10; - return score; -} - -/** - * Encode a string into a QR matrix (array of rows of 0/1). - * @param {string} text - * @param {{forceMask?: number}} [opts] forceMask selects a specific mask (for tests). - * @returns {{matrix: number[][], version: number, size: number, mask: number}} - */ -export function encodeQr(text, opts = {}) { - const bytes = Array.from(new TextEncoder().encode(text)); - const version = chooseVersion(bytes.length); - const size = version * 4 + 17; - const codewords = buildCodewords(bytes, version); - const reserved = buildReserved(size, version); - - const base = makeBaseMatrix(size); - placeFinder(base, 0, 0); - placeFinder(base, 0, size - 7); - placeFinder(base, size - 7, 0); - placeAlignment(base, version); - placeTiming(base); - placeVersionBits(base, version); - placeData(base, reserved, codewords); - - let chosen = opts.forceMask; - let bestMatrix = null; - if (chosen == null) { - let bestScore = Infinity; - for (let mi = 0; mi < 8; mi++) { - const masked = applyMask(base, reserved, MASKS[mi]); - placeFormatBits(masked, mi); - const s = penalty(masked); - if (s < bestScore) { - bestScore = s; - chosen = mi; - bestMatrix = masked; - } - } - } else { - bestMatrix = applyMask(base, reserved, MASKS[chosen]); - placeFormatBits(bestMatrix, chosen); - } - - return { matrix: bestMatrix, version, size, mask: chosen }; -} diff --git a/extensions/apng-studio/web/app.js b/extensions/apng-studio/web/app.js deleted file mode 100644 index 7ace7abc..00000000 --- a/extensions/apng-studio/web/app.js +++ /dev/null @@ -1,669 +0,0 @@ -"use strict"; - -// ---- tiny helpers ------------------------------------------------------- -const $ = (id) => document.getElementById(id); -const nonce = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 6); - -// The server mints a per-session access token and passes it in this iframe's -// URL. Attach it to every data request so other local origins that guess the -// port cannot read state or drive mutations. -const ACCESS_KEY = new URLSearchParams(location.search).get("k") || ""; -function withKey(path) { - const u = new URL(path, location.origin); - if (ACCESS_KEY) u.searchParams.set("k", ACCESS_KEY); - return u.pathname + u.search; -} - -async function api(path, body) { - const res = await fetch(withKey(path), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body || {}), - }); - if (!res.ok) throw new Error((await res.text()) || res.statusText); - const text = await res.text(); - return text ? JSON.parse(text) : {}; -} - -function toast(msg, ms = 2600) { - const t = $("toast"); - t.textContent = msg; - t.hidden = false; - clearTimeout(toast._t); - toast._t = setTimeout(() => (t.hidden = true), ms); -} - -// Surface any otherwise-unhandled async-handler failure to the user instead of -// letting it become a silent unhandled rejection with no feedback. -if (typeof window !== "undefined") { - window.addEventListener("unhandledrejection", (e) => { - toast("Error: " + (e.reason?.message || e.reason || "something went wrong")); - }); -} - -function clampSize(w, h, max = 2048) { - w = Math.max(1, Math.round(w)); - h = Math.max(1, Math.round(h)); - const longer = Math.max(w, h); - if (longer > max) { - const s = max / longer; - w = Math.max(1, Math.round(w * s)); - h = Math.max(1, Math.round(h * s)); - } - return { w, h }; -} - -// ---- state -------------------------------------------------------------- -let state = { name: "animation", width: 256, height: 256, loops: 0, hiddenFirst: false, frames: [] }; -let assetNonce = nonce(); - -function defaultDelay() { - const v = parseInt($("in-delay-all").value, 10); - return Number.isFinite(v) && v >= 0 ? v : 120; -} - -async function refreshState() { - const res = await fetch(withKey("/state")); - if (!res.ok) throw new Error((await res.text()) || res.statusText); - state = await res.json(); - assetNonce = nonce(); - render(); -} - -// ---- rendering ---------------------------------------------------------- -function render() { - renderPreview(); - renderMeta(); - renderSettings(); - renderFrames(); - syncDrawStage(); -} - -function renderPreview() { - const img = $("preview"); - const empty = $("empty-preview"); - const has = state.frames.length > 0; - empty.hidden = has; - if (has) { - img.src = withKey(`/preview.png?n=${assetNonce}`); - img.hidden = false; - img.classList.toggle("pixelated", Math.max(state.width, state.height) < 96); - } else { - img.hidden = true; - } -} - -function renderMeta() { - const n = state.frames.length; - const hidden = state.hiddenFirst && n >= 2; - const animated = hidden ? state.frames.slice(1) : state.frames; - const totalMs = Math.round( - animated.reduce((a, f) => a + (f.delayNum || 0) / (f.delayDen || 1000), 0) * 1000 - ); - $("meta-frames").textContent = `${n} frame${n === 1 ? "" : "s"}${hidden ? " · 1 static" : ""}`; - $("meta-duration").textContent = `${(totalMs / 1000).toFixed(1)}s`; - $("meta-loops").textContent = state.loops === 0 ? "loops ∞" : `loops ${state.loops}`; - const busy = n === 0; - $("btn-export").disabled = busy; - $("btn-download").disabled = busy; - $("btn-restart").disabled = busy; - $("btn-share").disabled = busy; -} - -function renderSettings() { - const n = state.frames.length; - const hasFrames = n > 0; - const w = $("in-width"), - h = $("in-height"), - l = $("in-loops"); - if (document.activeElement !== w) w.value = state.width; - if (document.activeElement !== h) h.value = state.height; - if (document.activeElement !== l) l.value = state.loops; - w.disabled = hasFrames; - h.disabled = hasFrames; - $("dim-hint").style.display = hasFrames ? "block" : "none"; - - const hf = $("in-hidden-first"); - hf.checked = !!state.hiddenFirst; - hf.disabled = n < 2; - $("hidden-first-label").classList.toggle("disabled", n < 2); - $("hidden-first-hint").hidden = !(state.hiddenFirst && n >= 2); - - for (const id of ["btn-delay-all", "btn-fps", "btn-ops-all"]) $(id).disabled = !hasFrames; -} - -function renderFrames() { - const strip = $("frame-strip"); - const empty = $("empty-frames"); - strip.innerHTML = ""; - empty.hidden = state.frames.length > 0; - const hidden = state.hiddenFirst && state.frames.length >= 2; - state.frames.forEach((f, i) => { - const isStatic = hidden && i === 0; - const card = document.createElement("div"); - card.className = "frame-card" + (isStatic ? " is-static" : ""); - - const thumbWrap = document.createElement("div"); - thumbWrap.className = "checker frame-thumb-wrap"; - const thumb = document.createElement("img"); - thumb.className = "frame-thumb"; - thumb.alt = `Frame ${i + 1}`; - thumb.src = withKey(`/frame?id=${encodeURIComponent(f.id)}&n=${assetNonce}`); - thumbWrap.appendChild(thumb); - if (isStatic) { - const badge = document.createElement("span"); - badge.className = "static-badge"; - badge.textContent = "STATIC"; - thumbWrap.appendChild(badge); - } - - const body = document.createElement("div"); - body.className = "frame-body"; - - const idx = document.createElement("div"); - idx.className = "frame-index"; - idx.textContent = isStatic ? `#${i + 1} · fallback` : `#${i + 1}`; - - // Inputs (declared first so the shared commit closure can read them). - const numIn = numberInput(f.delayNum, 0, 65535, "Delay numerator"); - const denIn = numberInput(f.delayDen, 1, 65535, "Delay denominator"); - const disposeSel = selectEl( - [[0, "None"], [1, "Background"], [2, "Previous"]], - f.disposeOp, - "Dispose op — what to do with the canvas after this frame" - ); - const blendSel = selectEl( - [[0, "Source"], [1, "Over"]], - f.blendOp, - "Blend op — how this frame is drawn onto the canvas" - ); - const msHint = document.createElement("span"); - msHint.className = "ms-hint muted"; - - const updateHint = () => { - const num = parseInt(numIn.value, 10) || 0; - const den = parseInt(denIn.value, 10) || 1000; - const ms = Math.round((num / den) * 1000); - const fps = num > 0 ? den / num : 0; - const fpsTxt = fps ? ` · ${Number.isInteger(fps) ? fps : fps.toFixed(1)} fps` : ""; - msHint.textContent = `= ${ms} ms${fpsTxt}`; - }; - updateHint(); - const commit = () => - api("/frames/props", { - id: f.id, - delayNum: parseInt(numIn.value, 10) || 0, - delayDen: parseInt(denIn.value, 10) || 1000, - disposeOp: parseInt(disposeSel.value, 10) || 0, - blendOp: parseInt(blendSel.value, 10) || 0, - }).catch((e) => toast("Error: " + e.message)); - - numIn.addEventListener("input", updateHint); - denIn.addEventListener("input", updateHint); - numIn.addEventListener("change", commit); - denIn.addEventListener("change", commit); - disposeSel.addEventListener("change", commit); - blendSel.addEventListener("change", commit); - - const delayRow = document.createElement("div"); - delayRow.className = "frame-field"; - delayRow.append(fieldLabel("delay"), numIn, slash(), denIn, msHint); - - const opsRow = document.createElement("div"); - opsRow.className = "frame-field"; - opsRow.append(fieldLabel("dispose"), disposeSel); - const blendRow = document.createElement("div"); - blendRow.className = "frame-field"; - blendRow.append(fieldLabel("blend"), blendSel); - - const actions = document.createElement("div"); - actions.className = "frame-actions"; - actions.append( - iconBtn("◀", "Move left", i === 0, () => api("/frames/move", { id: f.id, delta: -1 })), - iconBtn("▶", "Move right", i === state.frames.length - 1, () => - api("/frames/move", { id: f.id, delta: 1 }) - ), - iconBtn("⧉", "Duplicate", false, () => api("/frames/duplicate", { id: f.id })), - iconBtn("✕", "Delete", false, () => api("/frames/delete", { id: f.id }), true) - ); - - if (isStatic) { - [numIn, denIn, disposeSel, blendSel].forEach((el) => (el.disabled = true)); - msHint.textContent = "not animated"; - } - - body.append(idx, delayRow, opsRow, blendRow, actions); - card.append(thumbWrap, body); - strip.appendChild(card); - }); -} - -function numberInput(value, min, max, title) { - const el = document.createElement("input"); - el.type = "number"; - el.className = "num-in"; - el.min = String(min); - el.max = String(max); - el.step = "1"; - el.value = value; - el.title = title; - return el; -} - -function selectEl(options, value, title) { - const s = document.createElement("select"); - s.className = "frame-select"; - s.title = title; - for (const [val, text] of options) { - const o = document.createElement("option"); - o.value = String(val); - o.textContent = text; - s.appendChild(o); - } - s.value = String(value); - return s; -} - -function fieldLabel(text) { - const s = document.createElement("span"); - s.className = "frame-label muted"; - s.textContent = text; - return s; -} - -function slash() { - const s = document.createElement("span"); - s.className = "slash muted"; - s.textContent = "/"; - return s; -} - -function iconBtn(label, title, disabled, onClick, danger) { - const b = document.createElement("button"); - b.className = "icon-btn" + (danger ? " danger" : ""); - b.textContent = label; - b.title = title; - b.setAttribute("aria-label", title); - b.disabled = !!disabled; - b.addEventListener("click", async () => { - try { - await onClick(); - } catch (e) { - toast("Error: " + e.message); - } - }); - return b; -} - -// ---- settings handlers -------------------------------------------------- -async function commitLoops() { - await api("/settings", { loops: parseInt($("in-loops").value, 10) || 0 }); -} -async function commitDim() { - if (state.frames.length > 0) return; - const w = parseInt($("in-width").value, 10); - const h = parseInt($("in-height").value, 10); - const { w: cw, h: ch } = clampSize(w || state.width, h || state.height); - await api("/settings", { width: cw, height: ch }); -} -function debounce(fn, ms) { - let t = null; - return () => { - clearTimeout(t); - t = setTimeout(fn, ms); - }; -} -// Commit on `input` (fires for stepper buttons and arrow keys in every engine, -// including the host WebKit view where `change` is unreliable for steppers) as -// well as `change` (final blur/Enter). The `input` path is debounced so holding -// an arrow or typing a value doesn't spam the server. -const commitDimSoon = debounce(commitDim, 300); -const commitLoopsSoon = debounce(commitLoops, 300); -for (const id of ["in-width", "in-height"]) { - $(id).addEventListener("input", commitDimSoon); - $(id).addEventListener("change", commitDim); -} -$("in-loops").addEventListener("input", commitLoopsSoon); -$("in-loops").addEventListener("change", commitLoops); -$("in-hidden-first").addEventListener("change", async (e) => { - await api("/settings", { hiddenFirst: e.target.checked }); -}); -$("btn-delay-all").addEventListener("click", async () => { - const ms = defaultDelay(); - await api("/frames/props-all", { delayMs: ms }); - toast(`All delays set to ${ms} ms`); -}); -$("btn-fps").addEventListener("click", async () => { - const fps = Math.max(1, Math.min(120, parseInt($("in-fps").value, 10) || 12)); - await api("/frames/props-all", { fps }); - toast(`All frames set to ${fps} fps`); -}); -$("btn-ops-all").addEventListener("click", async () => { - await api("/frames/props-all", { - disposeOp: parseInt($("in-dispose-all").value, 10) || 0, - blendOp: parseInt($("in-blend-all").value, 10) || 0, - }); - toast("Applied dispose & blend to all frames"); -}); -$("btn-clear").addEventListener("click", async () => { - if (!state.frames.length) return; - if (!confirm("Remove all frames?")) return; - await api("/frames/clear", {}); -}); - -// ---- export / download -------------------------------------------------- -$("btn-export").addEventListener("click", async () => { - try { - const r = await api("/export", {}); - $("saved-path").hidden = false; - $("saved-path").textContent = "Saved: " + r.path; - toast("Exported " + r.name); - } catch (e) { - toast("Export failed: " + e.message); - } -}); -$("btn-download").addEventListener("click", async () => { - const res = await fetch(withKey(`/preview.png?n=${nonce()}`)); - const blob = await res.blob(); - const a = document.createElement("a"); - a.href = URL.createObjectURL(blob); - a.download = (state.name || "animation").replace(/[^\w.-]+/g, "_") + ".png"; - document.body.appendChild(a); - a.click(); - a.remove(); - setTimeout(() => URL.revokeObjectURL(a.href), 4000); -}); -$("btn-restart").addEventListener("click", () => { - // Re-assigning an identical src is a no-op, so bump the nonce to force a - // fresh fetch and restart the animation from the first frame. - assetNonce = nonce(); - renderPreview(); -}); - -// ---- send to phone ------------------------------------------------------ -let shareCountdown = null; - -$("btn-share").addEventListener("click", async () => { - const btn = $("btn-share"); - btn.disabled = true; - try { - const info = await api("/share/start", {}); - openSharePanel(info); - } catch (e) { - toast("Couldn't start sharing: " + e.message); - } finally { - btn.disabled = state.frames.length === 0; - } -}); - -$("btn-share-stop").addEventListener("click", stopSharing); - -function openSharePanel(info) { - $("share-panel").hidden = false; - // Cache-bust the QR so a new token's code is fetched each time. - $("share-qr-img").src = withKey(`/share/qr.png?ts=${Date.now()}`); - const link = $("share-url"); - link.href = info.url; - link.textContent = info.url.replace(/^https?:\/\//, ""); - startShareCountdown(info.expiresAt); -} - -function startShareCountdown(expiresAt) { - clearInterval(shareCountdown); - const tick = () => { - const ms = expiresAt - Date.now(); - if (ms <= 0) { - clearInterval(shareCountdown); - $("share-panel").hidden = true; - toast("Phone link expired"); - api("/share/stop", {}).catch(() => {}); - return; - } - const m = Math.floor(ms / 60000); - const s = Math.floor((ms % 60000) / 1000); - $("share-expiry").textContent = `Expires in ${m}:${String(s).padStart(2, "0")}`; - }; - tick(); - shareCountdown = setInterval(tick, 1000); -} - -async function stopSharing() { - clearInterval(shareCountdown); - $("share-panel").hidden = true; - try { - await api("/share/stop", {}); - } catch (_) { - /* server may have already expired the share */ - } -} - -// ---- upload ------------------------------------------------------------- -$("btn-upload").addEventListener("click", () => $("file-input").click()); -$("file-input").addEventListener("change", async (e) => { - const files = [...e.target.files]; - e.target.value = ""; - if (files.length) await addFiles(files); -}); - -async function addFiles(files) { - try { - toast(`Adding ${files.length} image${files.length === 1 ? "" : "s"}…`); - if (state.frames.length === 0) { - const first = await createImageBitmap(files[0]); - const { w, h } = clampSize(first.width, first.height); - first.close?.(); - await api("/settings", { width: w, height: h }); - await refreshState(); - } - for (const f of files) await addImageFile(f); - toast("Frames added"); - } catch (e) { - toast("Upload error: " + e.message); - } -} - -async function addImageFile(file) { - const bmp = await createImageBitmap(file); - const c = document.createElement("canvas"); - c.width = state.width; - c.height = state.height; - try { - drawContain(c.getContext("2d"), bmp, state.width, state.height); - } finally { - // Release the decoded bitmap right after the synchronous draw so a large - // batch doesn't retain native image memory until GC. - bmp.close?.(); - } - const blob = await new Promise((r) => c.toBlob(r, "image/png")); - await postFrame(blob, defaultDelay()); -} - -function drawContain(ctx, img, W, H) { - const s = Math.min(W / img.width, H / img.height); - const dw = img.width * s; - const dh = img.height * s; - ctx.clearRect(0, 0, W, H); - ctx.drawImage(img, (W - dw) / 2, (H - dh) / 2, dw, dh); -} - -async function postFrame(blob, delayMs) { - const res = await fetch(withKey(`/frames?delayMs=${delayMs || 0}`), { method: "POST", body: blob }); - if (!res.ok) throw new Error(await res.text()); -} - -// ---- drawing ------------------------------------------------------------ -const drawCanvas = $("draw-canvas"); -const dctx = drawCanvas.getContext("2d"); -let drawTool = "pen"; -let drawing = false; -let lastPt = null; - -$("btn-toggle-draw").addEventListener("click", () => { - const panel = $("draw-panel"); - panel.hidden = !panel.hidden; - if (!panel.hidden) initDrawCanvas(); -}); -$("btn-cancel-draw").addEventListener("click", () => ($("draw-panel").hidden = true)); -$("tool-pen").addEventListener("click", () => setTool("pen")); -$("tool-eraser").addEventListener("click", () => setTool("eraser")); -function setTool(t) { - drawTool = t; - $("tool-pen").classList.toggle("active", t === "pen"); - $("tool-eraser").classList.toggle("active", t === "eraser"); - $("tool-pen").setAttribute("aria-pressed", String(t === "pen")); - $("tool-eraser").setAttribute("aria-pressed", String(t === "eraser")); -} -$("btn-clear-draw").addEventListener("click", () => { - dctx.clearRect(0, 0, drawCanvas.width, drawCanvas.height); -}); -$("btn-fill").addEventListener("click", () => { - dctx.save(); - dctx.globalCompositeOperation = "source-over"; - dctx.fillStyle = $("draw-color").value; - dctx.fillRect(0, 0, drawCanvas.width, drawCanvas.height); - dctx.restore(); -}); -$("opt-onion").addEventListener("change", syncOnion); -$("opt-from-last").addEventListener("change", initDrawCanvas); - -function syncDrawStage() { - // Size the drawing surface to the project dimensions and scale for display. - if (drawCanvas.width !== state.width || drawCanvas.height !== state.height) { - drawCanvas.width = state.width; - drawCanvas.height = state.height; - } - const longer = Math.max(state.width, state.height) || 1; - const scale = Math.min(360, Math.max(200, longer)) / longer; - const dispW = Math.max(1, Math.round(state.width * scale)); - // Drive the display size from the width plus the intrinsic aspect ratio and - // let height follow, so a narrow side panel (CSS max-width) shrinks the - // surface proportionally instead of stretching a fixed height. - const ratio = `${state.width} / ${state.height}`; - drawCanvas.style.width = dispW + "px"; - drawCanvas.style.height = "auto"; - drawCanvas.style.aspectRatio = ratio; - drawCanvas.style.imageRendering = scale > 1.4 ? "pixelated" : "auto"; - const onion = $("onion-img"); - onion.style.width = dispW + "px"; - onion.style.height = "auto"; - onion.style.aspectRatio = ratio; - if (!$("draw-panel").hidden) syncOnion(); -} - -function lastFrame() { - return state.frames.length ? state.frames[state.frames.length - 1] : null; -} - -function syncOnion() { - const onion = $("onion-img"); - const lf = lastFrame(); - if ($("opt-onion").checked && lf) { - onion.src = withKey(`/frame?id=${encodeURIComponent(lf.id)}&n=${assetNonce}`); - onion.hidden = false; - } else { - onion.hidden = true; - } -} - -function initDrawCanvas() { - syncDrawStage(); - dctx.clearRect(0, 0, drawCanvas.width, drawCanvas.height); - const lf = lastFrame(); - if ($("opt-from-last").checked && lf) { - const img = new Image(); - img.onload = () => dctx.drawImage(img, 0, 0, drawCanvas.width, drawCanvas.height); - img.src = withKey(`/frame?id=${encodeURIComponent(lf.id)}&n=${assetNonce}`); - } - syncOnion(); -} - -function canvasPoint(e) { - const rect = drawCanvas.getBoundingClientRect(); - return { - x: (e.clientX - rect.left) * (drawCanvas.width / rect.width), - y: (e.clientY - rect.top) * (drawCanvas.height / rect.height), - }; -} -function strokeTo(pt) { - dctx.globalCompositeOperation = drawTool === "eraser" ? "destination-out" : "source-over"; - dctx.strokeStyle = $("draw-color").value; - dctx.fillStyle = $("draw-color").value; - dctx.lineWidth = parseInt($("draw-size").value, 10) || 6; - dctx.lineCap = "round"; - dctx.lineJoin = "round"; - if (lastPt) { - dctx.beginPath(); - dctx.moveTo(lastPt.x, lastPt.y); - dctx.lineTo(pt.x, pt.y); - dctx.stroke(); - } else { - dctx.beginPath(); - dctx.arc(pt.x, pt.y, dctx.lineWidth / 2, 0, Math.PI * 2); - dctx.fill(); - } - lastPt = pt; -} -drawCanvas.addEventListener("pointerdown", (e) => { - drawing = true; - lastPt = null; - drawCanvas.setPointerCapture(e.pointerId); - strokeTo(canvasPoint(e)); -}); -drawCanvas.addEventListener("pointermove", (e) => { - if (drawing) strokeTo(canvasPoint(e)); -}); -function endStroke() { - drawing = false; - lastPt = null; -} -drawCanvas.addEventListener("pointerup", endStroke); -drawCanvas.addEventListener("pointerleave", endStroke); -drawCanvas.addEventListener("pointercancel", endStroke); - -$("btn-add-drawing").addEventListener("click", async () => { - const blob = await new Promise((r) => drawCanvas.toBlob(r, "image/png")); - await postFrame(blob, defaultDelay()); - toast("Frame added"); - if ($("opt-from-last").checked) { - // Pull the just-added frame into state before re-seeding the canvas, so - // "start from last frame" copies it instead of the previous frame (or a - // blank canvas on the first add), which the async SSE update may not - // have delivered yet. - await refreshState(); - initDrawCanvas(); - } -}); - -// ---- live updates ------------------------------------------------------- -let eventSource = null; -function connectEvents() { - try { - if (eventSource) eventSource.close(); - eventSource = new EventSource(withKey("/events")); - eventSource.onmessage = () => refreshState(); - } catch (_) { - eventSource = null; /* SSE unavailable; manual reload still works */ - } -} - -$("btn-reload").addEventListener("click", async () => { - const btn = $("btn-reload"); - const glyph = $("reload-glyph"); - btn.disabled = true; - glyph.classList.add("spinning"); - try { - // Recover a dropped live-update stream, then pull the latest state and - // rebuild the preview + thumbnails against a fresh cache-busting nonce. - if (!eventSource || eventSource.readyState === 2) connectEvents(); - await refreshState(); - toast("Reloaded"); - } catch (e) { - toast("Reload failed: " + e.message); - } finally { - glyph.classList.remove("spinning"); - btn.disabled = false; - } -}); - -connectEvents(); -refreshState(); diff --git a/extensions/apng-studio/web/index.html b/extensions/apng-studio/web/index.html deleted file mode 100644 index 2576082a..00000000 --- a/extensions/apng-studio/web/index.html +++ /dev/null @@ -1,189 +0,0 @@ - - - - - - APNG Studio - - - -
-
-

APNG Studio

-

Assemble an animated PNG from frames.

-
-
- - image/apng -
-
- - -
-
- APNG preview -
- No frames yet - Upload images or draw a frame below. -
-
-
- 0 frames - · - 0.0s - · - loops ∞ -
-
- - - - -
- - - - -
- - -
-
Settings
-
- - - -
- First frame - -
-
-

Canvas size can only change while there are no frames.

- - -
-
Apply to all frames
-
- - - - ms - - - - - - fps - - - - - - - - - -
-
- What do dispose & blend do? -
    -
  • Blend · Source replaces the canvas region with this frame (alpha included). Over composites this frame on top of what's already there — use it with transparency to build an image up frame by frame.
  • -
  • Dispose · None keeps the frame on screen for the next one. Background clears it to transparent first. Previous restores whatever was there before this frame.
  • -
-
-
-
- - -
-
- Frames - -
-
-
Frames you add appear here in order.
-
- - -
-
Add frames
-
- - - -
- - - -
- - - - - - diff --git a/extensions/apng-studio/web/styles.css b/extensions/apng-studio/web/styles.css deleted file mode 100644 index 9fc2a3d7..00000000 --- a/extensions/apng-studio/web/styles.css +++ /dev/null @@ -1,620 +0,0 @@ -:root { - --radius: 10px; - --radius-sm: 7px; - --gap: 12px; - --card-bg: var(--background-color-default, #ffffff); - --muted: var(--text-color-muted, #59636e); - --border: var(--border-color-default, #d1d9e0); - --fg: var(--text-color-default, #1f2328); - --accent: var(--true-color-blue, #0969da); - --danger: var(--true-color-red, #cf222e); -} - -* { - box-sizing: border-box; -} - -/* Ensure the `hidden` attribute always wins over element display rules. */ -[hidden] { - display: none !important; -} - -body { - margin: 0; - padding: 14px 14px 40px; - background: var(--background-color-default, #ffffff); - color: var(--fg); - font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif); - font-size: var(--text-body-medium, 14px); - line-height: var(--leading-body-medium, 20px); - -webkit-font-smoothing: antialiased; -} - -h1 { - font-family: var(--font-sans-display, var(--font-sans, sans-serif)); - font-size: var(--text-title-medium, 18px); - font-weight: var(--font-weight-semibold, 600); - line-height: 1.2; - margin: 0; -} - -.app-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: var(--gap); - margin-bottom: 14px; -} - -.header-actions { - display: flex; - align-items: center; - gap: 8px; - flex-shrink: 0; -} - -.reload-glyph { - display: inline-block; -} -.reload-glyph.spinning { - animation: apng-spin 0.6s linear infinite; -} -@keyframes apng-spin { - to { - transform: rotate(360deg); - } -} - -.muted { - color: var(--muted); -} -.tiny { - font-size: var(--text-caption, 11px); -} -.muted.tiny { - margin: 8px 0 0; -} - -.badge { - font-family: var(--font-mono, monospace); - font-size: var(--text-code-inline, 12px); - color: var(--muted); - border: 1px solid var(--border); - border-radius: 999px; - padding: 2px 9px; - white-space: nowrap; -} - -#subtitle { - margin: 3px 0 0; - font-size: var(--text-body-small, 12px); -} - -.card { - background: var(--card-bg); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 14px; - margin-bottom: 14px; -} - -.section-title { - font-weight: var(--font-weight-semibold, 600); - margin-bottom: 12px; -} - -.row { - display: flex; - align-items: center; -} -.row.gap { - gap: 8px; -} -.row.wrap { - flex-wrap: wrap; -} -.row.between, -.between { - justify-content: space-between; -} - -/* Buttons */ -.btn { - appearance: none; - border: 1px solid var(--border); - background: var(--background-color-default, #fff); - color: var(--fg); - border-radius: var(--radius-sm); - padding: 7px 12px; - font-size: var(--text-body-small, 13px); - font-weight: 500; - cursor: pointer; - transition: background 120ms ease, border-color 120ms ease, opacity 120ms ease; -} -.btn:hover { - background: var(--n-2-10, rgba(0, 0, 0, 0.04)); -} -.btn:active { - transform: translateY(0.5px); -} -.btn:disabled { - opacity: 0.45; - cursor: not-allowed; -} -.btn-sm { - padding: 4px 9px; - font-size: var(--text-caption, 12px); -} -.btn-primary { - background: var(--accent); - border-color: var(--accent); - color: var(--color-white, #fff); -} -.btn-primary:hover { - filter: brightness(1.06); - background: var(--accent); -} -.btn-ghost { - background: transparent; - border-color: transparent; -} -.btn-ghost:hover { - background: var(--n-2-10, rgba(0, 0, 0, 0.05)); -} -.danger { - color: var(--danger); -} - -/* Preview */ -.preview-wrap { - position: relative; - border-radius: var(--radius-sm); - border: 1px solid var(--border); - min-height: 160px; - max-height: 320px; - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; - padding: 10px; -} -#preview { - max-width: 100%; - max-height: 300px; - image-rendering: auto; - display: block; -} -#preview.pixelated { - image-rendering: pixelated; -} -.empty-preview { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - gap: 4px; - align-items: center; - justify-content: center; - background: var(--background-color-default, #fff); - text-align: center; -} -.preview-meta { - display: flex; - align-items: center; - gap: 7px; - justify-content: center; - color: var(--muted); - font-size: var(--text-body-small, 12px); - margin: 10px 0 12px; -} -.preview-meta .dot { - opacity: 0.5; -} -.saved-path { - margin: 10px 0 0; - font-family: var(--font-mono, monospace); - font-size: var(--text-caption, 11px); - word-break: break-all; -} - -/* Checkerboard for transparency */ -.checker { - --checker-base: var(--background-color-default, #fff); - --checker-square: var(--border-color-default, #d9dbe0); - background-color: var(--checker-base); - background-image: - linear-gradient(45deg, var(--checker-square) 25%, transparent 25%), - linear-gradient(-45deg, var(--checker-square) 25%, transparent 25%), - linear-gradient(45deg, transparent 75%, var(--checker-square) 75%), - linear-gradient(-45deg, transparent 75%, var(--checker-square) 75%); - background-size: 16px 16px; - background-position: 0 0, 0 8px, 8px -8px, -8px 0; -} - -/* Send to phone */ -.share-panel { - display: flex; - gap: var(--gap); - align-items: center; - margin-top: 12px; - padding: 12px; - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--card-bg); -} -.share-qr { - flex-shrink: 0; - padding: 8px; - border-radius: var(--radius-sm); - line-height: 0; -} -.share-qr img { - width: 132px; - height: 132px; - image-rendering: pixelated; - display: block; -} -.share-body { - display: flex; - flex-direction: column; - gap: 4px; - min-width: 0; -} -.share-title { - font-weight: var(--font-weight-semibold, 600); -} -.share-url { - font-family: var(--font-mono, monospace); - font-size: var(--text-code-inline, 12px); - color: var(--accent); - word-break: break-all; - text-decoration: none; - margin-top: 2px; -} -.share-url:hover { - text-decoration: underline; -} -.share-foot { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - margin-top: 6px; -} - -/* Settings */ -.settings-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; -} -.field { - display: flex; - flex-direction: column; - gap: 4px; - font-size: var(--text-body-small, 12px); - color: var(--muted); -} -.field .inline { - display: flex; - gap: 6px; -} -input[type="number"], -input[type="text"] { - width: 100%; - padding: 6px 8px; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - background: var(--background-color-default, #fff); - color: var(--fg); - font-size: var(--text-body-medium, 13px); - font-family: inherit; -} -input:focus-visible, -.btn:focus-visible, -select:focus-visible { - outline: 2px solid var(--color-focus-outline, var(--accent)); - outline-offset: 1px; -} - -/* hidden-first checkbox living inside the settings grid */ -.field .chk { - color: var(--fg); - font-size: var(--text-body-small, 12px); - padding: 4px 0; -} -.field .chk.disabled { - opacity: 0.45; - cursor: not-allowed; -} - -/* Settings subsection: apply-to-all controls */ -.subsection { - margin-top: 14px; - padding-top: 12px; - border-top: 1px dashed var(--border); -} -.subsection-title { - font-size: var(--text-caption, 11px); - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--muted); - font-weight: 600; - margin-bottom: 8px; -} -.apply-rows { - display: flex; - flex-direction: column; - gap: 8px; -} -.apply-rows .inline { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 6px; -} -.apply-rows .inline input[type="number"] { - width: 68px; -} -.apply-rows select { - padding: 5px 7px; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - background: var(--background-color-default, #fff); - color: var(--fg); - font-size: var(--text-body-small, 12px); - font-family: inherit; -} -.mini { - font-size: 11px; - color: var(--muted); -} -.help { - margin-top: 12px; - font-size: var(--text-body-small, 12px); -} -.help summary { - cursor: pointer; - color: var(--accent); - font-size: var(--text-caption, 11px); -} -.help ul { - margin: 8px 0 0; - padding-left: 18px; - color: var(--muted); -} -.help li { - margin-bottom: 5px; -} -.help b { - color: var(--fg); -} - -/* Frame strip */ -.frame-strip { - display: flex; - gap: 10px; - overflow-x: auto; - padding-bottom: 6px; -} -.frame-card { - flex: 0 0 auto; - width: 168px; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - overflow: hidden; - background: var(--card-bg); -} -.frame-card.is-static { - border-color: var(--accent); - border-style: dashed; -} -.frame-thumb-wrap { - position: relative; - border-bottom: 1px solid var(--border); -} -.frame-thumb { - width: 100%; - height: 88px; - object-fit: contain; - display: block; -} -.static-badge { - position: absolute; - top: 5px; - left: 5px; - background: var(--accent); - color: var(--color-white, #fff); - font-size: 9px; - font-weight: 700; - letter-spacing: 0.04em; - padding: 1px 5px; - border-radius: 4px; -} -.frame-body { - padding: 7px; - display: flex; - flex-direction: column; - gap: 6px; -} -.frame-index { - font-size: var(--text-caption, 11px); - color: var(--muted); - font-weight: 600; -} -.frame-field { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 4px; -} -.frame-label { - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.03em; - min-width: 46px; -} -.num-in { - width: 52px !important; - padding: 3px 4px !important; - font-size: var(--text-caption, 11px) !important; - text-align: center; -} -.slash { - font-size: 11px; -} -.ms-hint { - flex-basis: 100%; - font-size: 10px; -} -.frame-select { - flex: 1; - min-width: 0; - padding: 3px 4px; - border: 1px solid var(--border); - border-radius: 5px; - background: var(--background-color-default, #fff); - color: var(--fg); - font-size: var(--text-caption, 11px); - font-family: inherit; -} -.frame-actions { - display: flex; - gap: 3px; - margin-top: 1px; -} -.icon-btn { - flex: 1; - appearance: none; - border: 1px solid var(--border); - background: transparent; - color: var(--fg); - border-radius: 5px; - padding: 3px 0; - cursor: pointer; - font-size: 12px; - line-height: 1; -} -.icon-btn:hover { - background: var(--n-2-10, rgba(0, 0, 0, 0.05)); -} -.icon-btn.danger:hover { - background: var(--true-color-red-muted, rgba(207, 34, 46, 0.1)); -} -.icon-btn:disabled { - opacity: 0.35; - cursor: not-allowed; -} -.empty-frames { - font-size: var(--text-body-small, 12px); - padding: 4px 0 0; -} - -/* Draw panel */ -.draw-panel { - margin-top: 14px; - padding-top: 14px; - border-top: 1px dashed var(--border); -} -.draw-tools { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 10px; - margin-bottom: 10px; -} -.tool { - display: flex; - align-items: center; - gap: 5px; - font-size: var(--text-caption, 12px); - color: var(--muted); -} -.tool input[type="color"] { - width: 30px; - height: 26px; - padding: 0; - border: 1px solid var(--border); - border-radius: 6px; - background: none; - cursor: pointer; -} -.tool-group { - display: inline-flex; - gap: 0; -} -.tool-group .tool-btn { - border-radius: 0; - margin-left: -1px; -} -.tool-group .tool-btn:first-child { - border-radius: var(--radius-sm) 0 0 var(--radius-sm); - margin-left: 0; -} -.tool-group .tool-btn:last-child { - border-radius: 0 var(--radius-sm) var(--radius-sm) 0; -} -.tool-btn.active { - background: var(--accent); - border-color: var(--accent); - color: var(--color-white, #fff); -} -.draw-options { - display: flex; - gap: 16px; - margin-bottom: 10px; -} -.chk { - display: flex; - align-items: center; - gap: 6px; - font-size: var(--text-body-small, 12px); - cursor: pointer; -} -.canvas-stage { - position: relative; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - overflow: hidden; - margin-bottom: 10px; - display: flex; - justify-content: center; -} -.canvas-stage .onion, -.canvas-stage canvas { - position: relative; - max-width: 100%; - display: block; - touch-action: none; -} -.canvas-stage .onion { - position: absolute; - inset: 0; - margin: auto; - opacity: 0.28; - pointer-events: none; - object-fit: contain; -} -#draw-canvas { - cursor: crosshair; -} - -/* Toast */ -.toast { - position: fixed; - left: 50%; - bottom: 18px; - transform: translateX(-50%); - background: var(--fg); - color: var(--background-color-default, #fff); - padding: 8px 14px; - border-radius: 999px; - font-size: var(--text-body-small, 12px); - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.22); - z-index: 50; - max-width: 90%; - text-align: center; -} -.toast[hidden] { - display: none; -} diff --git a/extensions/arcade-canvas/.github/plugin/plugin.json b/extensions/arcade-canvas/.github/plugin/plugin.json index 4c372598..fbc98155 100644 --- a/extensions/arcade-canvas/.github/plugin/plugin.json +++ b/extensions/arcade-canvas/.github/plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "arcade-canvas", "description": "Play five retro Phaser mini-games in a Copilot canvas while agents work.", - "version": "1.0.1", + "version": "1.0.2", "author": { "name": "Dan Wahlin", "url": "https://github.com/DanWahlin" diff --git a/extensions/arcade-canvas/extensions/README.md b/extensions/arcade-canvas/extensions/README.md deleted file mode 100644 index 398ff9e2..00000000 --- a/extensions/arcade-canvas/extensions/README.md +++ /dev/null @@ -1,68 +0,0 @@ -# Agent Arcade Canvas - -A GitHub Copilot canvas that opens a retro arcade in the side panel. It serves the built Agent Arcade Phaser frontend and lets either the user or the agent switch between five mini-games. - -## Games - -- **Alien Onslaught** — Space Invaders-style arcade action with marching aliens, shields, and mystery ships. -- **Cosmic Rocks** — Asteroids-style vector shooter with thrust physics and splitting asteroids. -- **Galaxy Blaster** — Galaga-style space shooter with formation enemies, attack patterns, and dual-shot power-up. -- **Ninja Runner** — Classic platformer with double jumps, power-ups, warp pipes, and enemies. -- **Planet Guardian** — Defender-style side-scrolling shooter with humanoid rescues and six enemy types. - -## Files - -- `extension.mjs` — canvas declaration, loopback game server, static asset handling, and agent actions. -- `game/` — compiled Phaser game frontend served inside the canvas. -- `assets/` — game sprites, sounds, app icon, and `preview.png` for the extensions gallery. -- `package.json` — declares the Copilot SDK dependency and ESM entry point. -- `copilot-extension.json` — Copilot extension name/version metadata. -- `canvas.json` — Awesome Copilot gallery metadata. - -## Prerequisites - -- **Node.js 20.19 or newer** because the Copilot SDK requires `node ^20.19.0 || >=22.12.0`. -- The GitHub Copilot app canvas / UI-extensions experiment enabled. - -## Install - -Drop this folder at `~/.copilot/extensions/arcade-canvas/` for user scope, or in a repository at `.github/extensions/arcade-canvas/` for project scope. Then install dependencies from inside the copied folder: - -```sh -# User scope -cd ~/.copilot/extensions/arcade-canvas - -# Or project scope, from the repository root -cd .github/extensions/arcade-canvas - -npm install -``` - -Reload extensions in the GitHub Copilot app, then open the `arcade-canvas` canvas. The canvas accepts an optional `defaultGame` input with one of these keys: `cosmic-rocks`, `alien-onslaught`, `galaxy-blaster`, `ninja-runner`, or `defender`. - -## Agent actions - -- `list_games` — list available mini-games and the currently selected game. -- `select_game { gameKey }` — switch the open arcade canvas to a specific mini-game. -- `restart_game` — reload the open arcade canvas to restart the current game. - -## Development - -In the Agent Arcade repository, rebuild the committed canvas bundle after frontend or asset changes: - -```sh -npm run build:canvas -``` - -That command builds the frontend, copies `dist/game` into `game/`, copies `dist/assets` into `assets/`, writes `assets/preview.png` for the Awesome Copilot gallery, and bundles `assets/canvas-background.webp` for the canvas-only space backdrop. - -## Credits - -- Sprite assets: [Simple Platformer 16](https://juhosprite.itch.io/simple-platformer-16) by JuhoSprite. -- Space shooter assets: [Space Shooter Redux](https://opengameart.org/content/space-shooter-redux) by Kenney.nl. -- Galaga-style game mechanics: [WesleyEdwards/galaga](https://github.com/WesleyEdwards/galaga) by Wesley Edwards. -- Asteroids-style game mechanics: [phaser3-typescript](https://github.com/digitsensitive/phaser3-typescript) by digitsensitive. -- Defender-style game mechanics and sound effects: [OpenDefender](https://github.com/mkinney/Opendefender) by mkinney. -- Retro game sound effects: ["Retro game sound effects"](https://opengameart.org/content/retro-game-sound-effects) by Vircon32 (Carra), published at OpenGameArt under [CC-BY 4.0](https://creativecommons.org/licenses/by/4.0/). -- Thanks to [John Papa](https://github.com/johnpapa) for his Alien Onslaught game PR. -- Thanks to [Shayne Boyer](https://github.com/spboyer) for the initial PR to get Agent Arcade running in the GitHub App canvas. diff --git a/extensions/arcade-canvas/README.md b/extensions/arcade-canvas/extensions/arcade-canvas/README.md similarity index 100% rename from extensions/arcade-canvas/README.md rename to extensions/arcade-canvas/extensions/arcade-canvas/README.md diff --git a/extensions/arcade-canvas/assets/asset-.md b/extensions/arcade-canvas/extensions/arcade-canvas/assets/asset-.md similarity index 100% rename from extensions/arcade-canvas/assets/asset-.md rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/asset-.md diff --git a/extensions/arcade-canvas/assets/canvas-background.webp b/extensions/arcade-canvas/extensions/arcade-canvas/assets/canvas-background.webp similarity index 100% rename from extensions/arcade-canvas/assets/canvas-background.webp rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/canvas-background.webp diff --git a/extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_explosion.ogg b/extensions/arcade-canvas/extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_explosion.ogg similarity index 100% rename from extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_explosion.ogg rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_explosion.ogg diff --git a/extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_laser1.ogg b/extensions/arcade-canvas/extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_laser1.ogg similarity index 100% rename from extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_laser1.ogg rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_laser1.ogg diff --git a/extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_lose.ogg b/extensions/arcade-canvas/extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_lose.ogg similarity index 100% rename from extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_lose.ogg rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_lose.ogg diff --git a/extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_twoTone.ogg b/extensions/arcade-canvas/extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_twoTone.ogg similarity index 100% rename from extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_twoTone.ogg rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_twoTone.ogg diff --git a/extensions/arcade-canvas/assets/defender/baiter.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/baiter.png similarity index 100% rename from extensions/arcade-canvas/assets/defender/baiter.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/baiter.png diff --git a/extensions/arcade-canvas/assets/defender/bomber.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/bomber.png similarity index 100% rename from extensions/arcade-canvas/assets/defender/bomber.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/bomber.png diff --git a/extensions/arcade-canvas/assets/defender/humanoid.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/humanoid.png similarity index 100% rename from extensions/arcade-canvas/assets/defender/humanoid.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/humanoid.png diff --git a/extensions/arcade-canvas/assets/defender/lander.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/lander.png similarity index 100% rename from extensions/arcade-canvas/assets/defender/lander.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/lander.png diff --git a/extensions/arcade-canvas/assets/defender/mutant.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/mutant.png similarity index 100% rename from extensions/arcade-canvas/assets/defender/mutant.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/mutant.png diff --git a/extensions/arcade-canvas/assets/defender/planet-guard-sprites.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/planet-guard-sprites.png similarity index 100% rename from extensions/arcade-canvas/assets/defender/planet-guard-sprites.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/planet-guard-sprites.png diff --git a/extensions/arcade-canvas/assets/defender/pod.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/pod.png similarity index 100% rename from extensions/arcade-canvas/assets/defender/pod.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/pod.png diff --git a/extensions/arcade-canvas/assets/defender/ship.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/ship.png similarity index 100% rename from extensions/arcade-canvas/assets/defender/ship.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/ship.png diff --git a/extensions/arcade-canvas/assets/defender/ship_left.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/ship_left.png similarity index 100% rename from extensions/arcade-canvas/assets/defender/ship_left.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/ship_left.png diff --git a/extensions/arcade-canvas/assets/defender/sounds/sound_baiterwarning.ogg b/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_baiterwarning.ogg similarity index 100% rename from extensions/arcade-canvas/assets/defender/sounds/sound_baiterwarning.ogg rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_baiterwarning.ogg diff --git a/extensions/arcade-canvas/assets/defender/sounds/sound_bonus.ogg b/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_bonus.ogg similarity index 100% rename from extensions/arcade-canvas/assets/defender/sounds/sound_bonus.ogg rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_bonus.ogg diff --git a/extensions/arcade-canvas/assets/defender/sounds/sound_enemydead.ogg b/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_enemydead.ogg similarity index 100% rename from extensions/arcade-canvas/assets/defender/sounds/sound_enemydead.ogg rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_enemydead.ogg diff --git a/extensions/arcade-canvas/assets/defender/sounds/sound_enemyshoot.ogg b/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_enemyshoot.ogg similarity index 100% rename from extensions/arcade-canvas/assets/defender/sounds/sound_enemyshoot.ogg rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_enemyshoot.ogg diff --git a/extensions/arcade-canvas/assets/defender/sounds/sound_enemyshoot2.ogg b/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_enemyshoot2.ogg similarity index 100% rename from extensions/arcade-canvas/assets/defender/sounds/sound_enemyshoot2.ogg rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_enemyshoot2.ogg diff --git a/extensions/arcade-canvas/assets/defender/sounds/sound_explode.ogg b/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_explode.ogg similarity index 100% rename from extensions/arcade-canvas/assets/defender/sounds/sound_explode.ogg rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_explode.ogg diff --git a/extensions/arcade-canvas/assets/defender/sounds/sound_humanoiddead.ogg b/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_humanoiddead.ogg similarity index 100% rename from extensions/arcade-canvas/assets/defender/sounds/sound_humanoiddead.ogg rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_humanoiddead.ogg diff --git a/extensions/arcade-canvas/assets/defender/sounds/sound_laser.ogg b/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_laser.ogg similarity index 100% rename from extensions/arcade-canvas/assets/defender/sounds/sound_laser.ogg rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_laser.ogg diff --git a/extensions/arcade-canvas/assets/defender/sounds/sound_player1up.ogg b/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_player1up.ogg similarity index 100% rename from extensions/arcade-canvas/assets/defender/sounds/sound_player1up.ogg rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_player1up.ogg diff --git a/extensions/arcade-canvas/assets/defender/sounds/sound_playerdead.ogg b/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_playerdead.ogg similarity index 100% rename from extensions/arcade-canvas/assets/defender/sounds/sound_playerdead.ogg rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_playerdead.ogg diff --git a/extensions/arcade-canvas/assets/defender/sounds/sound_start.ogg b/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_start.ogg similarity index 100% rename from extensions/arcade-canvas/assets/defender/sounds/sound_start.ogg rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_start.ogg diff --git a/extensions/arcade-canvas/assets/defender/sounds/sound_thurst.ogg b/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_thurst.ogg similarity index 100% rename from extensions/arcade-canvas/assets/defender/sounds/sound_thurst.ogg rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_thurst.ogg diff --git a/extensions/arcade-canvas/assets/defender/sounds/sound_warning.ogg b/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_warning.ogg similarity index 100% rename from extensions/arcade-canvas/assets/defender/sounds/sound_warning.ogg rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_warning.ogg diff --git a/extensions/arcade-canvas/assets/defender/swarmer.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/swarmer.png similarity index 100% rename from extensions/arcade-canvas/assets/defender/swarmer.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/swarmer.png diff --git a/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_explosion.ogg b/extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_explosion.ogg similarity index 100% rename from extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_explosion.ogg rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_explosion.ogg diff --git a/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_laser1.ogg b/extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_laser1.ogg similarity index 100% rename from extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_laser1.ogg rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_laser1.ogg diff --git a/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_laser2.ogg b/extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_laser2.ogg similarity index 100% rename from extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_laser2.ogg rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_laser2.ogg diff --git a/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_lose.ogg b/extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_lose.ogg similarity index 100% rename from extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_lose.ogg rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_lose.ogg diff --git a/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_shieldDown.ogg b/extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_shieldDown.ogg similarity index 100% rename from extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_shieldDown.ogg rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_shieldDown.ogg diff --git a/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_shieldUp.ogg b/extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_shieldUp.ogg similarity index 100% rename from extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_shieldUp.ogg rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_shieldUp.ogg diff --git a/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_twoTone.ogg b/extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_twoTone.ogg similarity index 100% rename from extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_twoTone.ogg rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_twoTone.ogg diff --git a/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_zap.ogg b/extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_zap.ogg similarity index 100% rename from extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_zap.ogg rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_zap.ogg diff --git a/extensions/arcade-canvas/assets/galaxy-blaster/space_bg.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_bg.png similarity index 100% rename from extensions/arcade-canvas/assets/galaxy-blaster/space_bg.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_bg.png diff --git a/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet-2-black.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet-2-black.png similarity index 100% rename from extensions/arcade-canvas/assets/galaxy-blaster/space_sheet-2-black.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet-2-black.png diff --git a/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet-2.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet-2.png similarity index 100% rename from extensions/arcade-canvas/assets/galaxy-blaster/space_sheet-2.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet-2.png diff --git a/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet-2.xml b/extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet-2.xml similarity index 100% rename from extensions/arcade-canvas/assets/galaxy-blaster/space_sheet-2.xml rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet-2.xml diff --git a/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet.png similarity index 100% rename from extensions/arcade-canvas/assets/galaxy-blaster/space_sheet.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet.png diff --git a/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet.xml b/extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet.xml similarity index 100% rename from extensions/arcade-canvas/assets/galaxy-blaster/space_sheet.xml rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet.xml diff --git a/extensions/arcade-canvas/assets/icon.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/icon.png similarity index 100% rename from extensions/arcade-canvas/assets/icon.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/icon.png diff --git a/extensions/arcade-canvas/assets/ninja-runner/background.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/background.png similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/background.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/background.png diff --git a/extensions/arcade-canvas/assets/ninja-runner/big_bush.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/big_bush.png similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/big_bush.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/big_bush.png diff --git a/extensions/arcade-canvas/assets/ninja-runner/bridge.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/bridge.png similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/bridge.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/bridge.png diff --git a/extensions/arcade-canvas/assets/ninja-runner/brown_block.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/brown_block.png similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/brown_block.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/brown_block.png diff --git a/extensions/arcade-canvas/assets/ninja-runner/clouds.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/clouds.png similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/clouds.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/clouds.png diff --git a/extensions/arcade-canvas/assets/ninja-runner/coin_sheet.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/coin_sheet.png similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/coin_sheet.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/coin_sheet.png diff --git a/extensions/arcade-canvas/assets/ninja-runner/dirt_block.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/dirt_block.png similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/dirt_block.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/dirt_block.png diff --git a/extensions/arcade-canvas/assets/ninja-runner/enemy_short_strip.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/enemy_short_strip.png similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/enemy_short_strip.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/enemy_short_strip.png diff --git a/extensions/arcade-canvas/assets/ninja-runner/enemy_strip.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/enemy_strip.png similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/enemy_strip.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/enemy_strip.png diff --git a/extensions/arcade-canvas/assets/ninja-runner/enemy_tall_strip.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/enemy_tall_strip.png similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/enemy_tall_strip.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/enemy_tall_strip.png diff --git a/extensions/arcade-canvas/assets/ninja-runner/flag.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/flag.png similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/flag.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/flag.png diff --git a/extensions/arcade-canvas/assets/ninja-runner/grass_block.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/grass_block.png similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/grass_block.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/grass_block.png diff --git a/extensions/arcade-canvas/assets/ninja-runner/heart_sheet.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/heart_sheet.png similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/heart_sheet.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/heart_sheet.png diff --git a/extensions/arcade-canvas/assets/ninja-runner/hill_0.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/hill_0.png similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/hill_0.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/hill_0.png diff --git a/extensions/arcade-canvas/assets/ninja-runner/hill_1.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/hill_1.png similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/hill_1.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/hill_1.png diff --git a/extensions/arcade-canvas/assets/ninja-runner/impact_sheet.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/impact_sheet.png similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/impact_sheet.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/impact_sheet.png diff --git a/extensions/arcade-canvas/assets/ninja-runner/platform.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/platform.png similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/platform.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/platform.png diff --git a/extensions/arcade-canvas/assets/ninja-runner/player_strip.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/player_strip.png similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/player_strip.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/player_strip.png diff --git a/extensions/arcade-canvas/assets/ninja-runner/qblock_new.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/qblock_new.png similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/qblock_new.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/qblock_new.png diff --git a/extensions/arcade-canvas/assets/ninja-runner/small_bush.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/small_bush.png similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/small_bush.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/small_bush.png diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBlowClub.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBlowClub.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBlowClub.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBlowClub.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBlowDull.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBlowDull.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBlowDull.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBlowDull.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBonus.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBonus.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBonus.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBonus.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBounce.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBounce.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBounce.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBounce.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundClick.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundClick.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundClick.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundClick.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundCoin.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundCoin.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundCoin.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundCoin.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundCountdown.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundCountdown.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundCountdown.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundCountdown.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundDeath.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundDeath.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundDeath.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundDeath.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundEnemyDeath.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundEnemyDeath.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundEnemyDeath.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundEnemyDeath.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundEnemyHit.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundEnemyHit.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundEnemyHit.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundEnemyHit.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundEnemyShot.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundEnemyShot.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundEnemyShot.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundEnemyShot.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundExplosionLarge.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundExplosionLarge.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundExplosionLarge.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundExplosionLarge.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundExplosionSmall.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundExplosionSmall.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundExplosionSmall.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundExplosionSmall.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFallDull.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFallDull.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFallDull.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFallDull.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFallLoud.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFallLoud.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFallLoud.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFallLoud.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFlapHeavy.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFlapHeavy.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFlapHeavy.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFlapHeavy.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFlapLight.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFlapLight.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFlapLight.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFlapLight.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundGameOver.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundGameOver.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundGameOver.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundGameOver.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundHurryUp.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundHurryUp.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundHurryUp.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundHurryUp.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundJump1.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundJump1.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundJump1.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundJump1.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundJump2.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundJump2.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundJump2.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundJump2.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundJumpHah.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundJumpHah.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundJumpHah.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundJumpHah.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLand1.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLand1.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLand1.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLand1.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLand2.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLand2.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLand2.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLand2.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLandHeavy.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLandHeavy.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLandHeavy.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLandHeavy.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLaser.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLaser.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLaser.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLaser.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundMechanism.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundMechanism.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundMechanism.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundMechanism.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundMissile.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundMissile.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundMissile.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundMissile.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundObjectFall.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundObjectFall.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundObjectFall.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundObjectFall.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundOpenDoor.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundOpenDoor.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundOpenDoor.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundOpenDoor.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundPlayerHit.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundPlayerHit.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundPlayerHit.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundPlayerHit.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundReachGoal.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundReachGoal.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundReachGoal.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundReachGoal.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundShootDull.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundShootDull.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundShootDull.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundShootDull.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundShootRegular.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundShootRegular.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundShootRegular.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundShootRegular.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundSlide.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundSlide.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundSlide.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundSlide.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundSpecialSkill.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundSpecialSkill.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundSpecialSkill.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundSpecialSkill.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundStartLevel.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundStartLevel.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundStartLevel.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundStartLevel.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundSwim.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundSwim.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundSwim.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundSwim.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundWandMagic.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundWandMagic.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundWandMagic.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundWandMagic.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundWind.m4a b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundWind.m4a similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/sounds/SoundWind.m4a rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundWind.m4a diff --git a/extensions/arcade-canvas/assets/ninja-runner/spikes.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/spikes.png similarity index 100% rename from extensions/arcade-canvas/assets/ninja-runner/spikes.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/spikes.png diff --git a/extensions/arcade-canvas/extensions/assets/preview.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/preview.png similarity index 100% rename from extensions/arcade-canvas/extensions/assets/preview.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/preview.png diff --git a/extensions/arcade-canvas/assets/sounds/agent-arcade-voice.mp3 b/extensions/arcade-canvas/extensions/arcade-canvas/assets/sounds/agent-arcade-voice.mp3 similarity index 100% rename from extensions/arcade-canvas/assets/sounds/agent-arcade-voice.mp3 rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/sounds/agent-arcade-voice.mp3 diff --git a/extensions/arcade-canvas/assets/tray_icon.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/tray_icon.png similarity index 100% rename from extensions/arcade-canvas/assets/tray_icon.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/tray_icon.png diff --git a/extensions/arcade-canvas/assets/tray_icon_small.png b/extensions/arcade-canvas/extensions/arcade-canvas/assets/tray_icon_small.png similarity index 100% rename from extensions/arcade-canvas/assets/tray_icon_small.png rename to extensions/arcade-canvas/extensions/arcade-canvas/assets/tray_icon_small.png diff --git a/extensions/arcade-canvas/copilot-extension.json b/extensions/arcade-canvas/extensions/arcade-canvas/copilot-extension.json similarity index 100% rename from extensions/arcade-canvas/copilot-extension.json rename to extensions/arcade-canvas/extensions/arcade-canvas/copilot-extension.json diff --git a/extensions/arcade-canvas/extension.mjs b/extensions/arcade-canvas/extensions/arcade-canvas/extension.mjs similarity index 100% rename from extensions/arcade-canvas/extension.mjs rename to extensions/arcade-canvas/extensions/arcade-canvas/extension.mjs diff --git a/extensions/arcade-canvas/extensions/game/game.js b/extensions/arcade-canvas/extensions/arcade-canvas/game/game.js similarity index 100% rename from extensions/arcade-canvas/extensions/game/game.js rename to extensions/arcade-canvas/extensions/arcade-canvas/game/game.js diff --git a/extensions/arcade-canvas/extensions/game/hud.js b/extensions/arcade-canvas/extensions/arcade-canvas/game/hud.js similarity index 100% rename from extensions/arcade-canvas/extensions/game/hud.js rename to extensions/arcade-canvas/extensions/arcade-canvas/game/hud.js diff --git a/extensions/arcade-canvas/extensions/game/index.html b/extensions/arcade-canvas/extensions/arcade-canvas/game/index.html similarity index 100% rename from extensions/arcade-canvas/extensions/game/index.html rename to extensions/arcade-canvas/extensions/arcade-canvas/game/index.html diff --git a/extensions/arcade-canvas/extensions/game/phaser.min.js b/extensions/arcade-canvas/extensions/arcade-canvas/game/phaser.min.js similarity index 100% rename from extensions/arcade-canvas/extensions/game/phaser.min.js rename to extensions/arcade-canvas/extensions/arcade-canvas/game/phaser.min.js diff --git a/extensions/arcade-canvas/extensions/game/phaser.min.js.gz b/extensions/arcade-canvas/extensions/arcade-canvas/game/phaser.min.js.gz similarity index 100% rename from extensions/arcade-canvas/extensions/game/phaser.min.js.gz rename to extensions/arcade-canvas/extensions/arcade-canvas/game/phaser.min.js.gz diff --git a/extensions/arcade-canvas/extensions/game/scenes/AlienOnslaught.js b/extensions/arcade-canvas/extensions/arcade-canvas/game/scenes/AlienOnslaught.js similarity index 100% rename from extensions/arcade-canvas/extensions/game/scenes/AlienOnslaught.js rename to extensions/arcade-canvas/extensions/arcade-canvas/game/scenes/AlienOnslaught.js diff --git a/extensions/arcade-canvas/extensions/game/scenes/BaseScene.js b/extensions/arcade-canvas/extensions/arcade-canvas/game/scenes/BaseScene.js similarity index 100% rename from extensions/arcade-canvas/extensions/game/scenes/BaseScene.js rename to extensions/arcade-canvas/extensions/arcade-canvas/game/scenes/BaseScene.js diff --git a/extensions/arcade-canvas/extensions/game/scenes/CosmicRocks.js b/extensions/arcade-canvas/extensions/arcade-canvas/game/scenes/CosmicRocks.js similarity index 100% rename from extensions/arcade-canvas/extensions/game/scenes/CosmicRocks.js rename to extensions/arcade-canvas/extensions/arcade-canvas/game/scenes/CosmicRocks.js diff --git a/extensions/arcade-canvas/extensions/game/scenes/GalaxyBlaster.js b/extensions/arcade-canvas/extensions/arcade-canvas/game/scenes/GalaxyBlaster.js similarity index 100% rename from extensions/arcade-canvas/extensions/game/scenes/GalaxyBlaster.js rename to extensions/arcade-canvas/extensions/arcade-canvas/game/scenes/GalaxyBlaster.js diff --git a/extensions/arcade-canvas/extensions/game/scenes/NinjaRunner.js b/extensions/arcade-canvas/extensions/arcade-canvas/game/scenes/NinjaRunner.js similarity index 100% rename from extensions/arcade-canvas/extensions/game/scenes/NinjaRunner.js rename to extensions/arcade-canvas/extensions/arcade-canvas/game/scenes/NinjaRunner.js diff --git a/extensions/arcade-canvas/extensions/game/scenes/PlanetGuardian.js b/extensions/arcade-canvas/extensions/arcade-canvas/game/scenes/PlanetGuardian.js similarity index 100% rename from extensions/arcade-canvas/extensions/game/scenes/PlanetGuardian.js rename to extensions/arcade-canvas/extensions/arcade-canvas/game/scenes/PlanetGuardian.js diff --git a/extensions/arcade-canvas/extensions/package-lock.json b/extensions/arcade-canvas/extensions/arcade-canvas/package-lock.json similarity index 100% rename from extensions/arcade-canvas/extensions/package-lock.json rename to extensions/arcade-canvas/extensions/arcade-canvas/package-lock.json diff --git a/extensions/arcade-canvas/extensions/package.json b/extensions/arcade-canvas/extensions/arcade-canvas/package.json similarity index 100% rename from extensions/arcade-canvas/extensions/package.json rename to extensions/arcade-canvas/extensions/arcade-canvas/package.json diff --git a/extensions/arcade-canvas/extensions/assets/asset-.md b/extensions/arcade-canvas/extensions/assets/asset-.md deleted file mode 100644 index 88354f6f..00000000 --- a/extensions/arcade-canvas/extensions/assets/asset-.md +++ /dev/null @@ -1,6 +0,0 @@ -https://opengameart.org/content/space-shooter-redux -https://opengameart.org/content/2d-nature-platformer-tileset-16x16 -https://opengameart.org/content/retro-game-sound-effects -https://github.com/digitsensitive/phaser3-typescript -https://github.com/WesleyEdwards/galaga -https://juhosprite.itch.io/simple-platformer-16 \ No newline at end of file diff --git a/extensions/arcade-canvas/extensions/assets/canvas-background.webp b/extensions/arcade-canvas/extensions/assets/canvas-background.webp deleted file mode 100644 index 1c26723c..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/canvas-background.webp and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/cosmic-rocks/sounds/sfx_explosion.ogg b/extensions/arcade-canvas/extensions/assets/cosmic-rocks/sounds/sfx_explosion.ogg deleted file mode 100644 index 019e5366..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/cosmic-rocks/sounds/sfx_explosion.ogg and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/cosmic-rocks/sounds/sfx_laser1.ogg b/extensions/arcade-canvas/extensions/assets/cosmic-rocks/sounds/sfx_laser1.ogg deleted file mode 100644 index 7a9a4d2f..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/cosmic-rocks/sounds/sfx_laser1.ogg and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/cosmic-rocks/sounds/sfx_lose.ogg b/extensions/arcade-canvas/extensions/assets/cosmic-rocks/sounds/sfx_lose.ogg deleted file mode 100644 index 496968f8..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/cosmic-rocks/sounds/sfx_lose.ogg and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/cosmic-rocks/sounds/sfx_twoTone.ogg b/extensions/arcade-canvas/extensions/assets/cosmic-rocks/sounds/sfx_twoTone.ogg deleted file mode 100644 index 20274928..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/cosmic-rocks/sounds/sfx_twoTone.ogg and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/defender/baiter.png b/extensions/arcade-canvas/extensions/assets/defender/baiter.png deleted file mode 100644 index d52ec970..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/defender/baiter.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/defender/bomber.png b/extensions/arcade-canvas/extensions/assets/defender/bomber.png deleted file mode 100644 index 77608d79..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/defender/bomber.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/defender/humanoid.png b/extensions/arcade-canvas/extensions/assets/defender/humanoid.png deleted file mode 100644 index 7c9d97aa..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/defender/humanoid.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/defender/lander.png b/extensions/arcade-canvas/extensions/assets/defender/lander.png deleted file mode 100644 index d0c6b8b3..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/defender/lander.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/defender/mutant.png b/extensions/arcade-canvas/extensions/assets/defender/mutant.png deleted file mode 100644 index 959b5b77..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/defender/mutant.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/defender/planet-guard-sprites.png b/extensions/arcade-canvas/extensions/assets/defender/planet-guard-sprites.png deleted file mode 100644 index 8656e3e5..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/defender/planet-guard-sprites.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/defender/pod.png b/extensions/arcade-canvas/extensions/assets/defender/pod.png deleted file mode 100644 index 5b175afd..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/defender/pod.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/defender/ship.png b/extensions/arcade-canvas/extensions/assets/defender/ship.png deleted file mode 100644 index 7cd194d4..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/defender/ship.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/defender/ship_left.png b/extensions/arcade-canvas/extensions/assets/defender/ship_left.png deleted file mode 100644 index 5f613e6f..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/defender/ship_left.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_baiterwarning.ogg b/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_baiterwarning.ogg deleted file mode 100644 index 3e3f1677..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_baiterwarning.ogg and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_bonus.ogg b/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_bonus.ogg deleted file mode 100644 index 2e7766e2..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_bonus.ogg and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_enemydead.ogg b/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_enemydead.ogg deleted file mode 100644 index 48f06d10..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_enemydead.ogg and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_enemyshoot.ogg b/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_enemyshoot.ogg deleted file mode 100644 index 6ba909aa..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_enemyshoot.ogg and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_enemyshoot2.ogg b/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_enemyshoot2.ogg deleted file mode 100644 index b1cd5f52..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_enemyshoot2.ogg and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_explode.ogg b/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_explode.ogg deleted file mode 100644 index 004f63f5..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_explode.ogg and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_humanoiddead.ogg b/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_humanoiddead.ogg deleted file mode 100644 index c21e4be3..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_humanoiddead.ogg and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_laser.ogg b/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_laser.ogg deleted file mode 100644 index 7c88c7b0..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_laser.ogg and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_player1up.ogg b/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_player1up.ogg deleted file mode 100644 index 11d4015e..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_player1up.ogg and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_playerdead.ogg b/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_playerdead.ogg deleted file mode 100644 index 39e8f0a0..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_playerdead.ogg and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_start.ogg b/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_start.ogg deleted file mode 100644 index 7b6a4c47..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_start.ogg and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_thurst.ogg b/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_thurst.ogg deleted file mode 100644 index 92e18e0d..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_thurst.ogg and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_warning.ogg b/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_warning.ogg deleted file mode 100644 index 96a56290..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_warning.ogg and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/defender/swarmer.png b/extensions/arcade-canvas/extensions/assets/defender/swarmer.png deleted file mode 100644 index 41b439af..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/defender/swarmer.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/galaxy-blaster/sounds/sfx_explosion.ogg b/extensions/arcade-canvas/extensions/assets/galaxy-blaster/sounds/sfx_explosion.ogg deleted file mode 100644 index 019e5366..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/galaxy-blaster/sounds/sfx_explosion.ogg and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/galaxy-blaster/sounds/sfx_laser1.ogg b/extensions/arcade-canvas/extensions/assets/galaxy-blaster/sounds/sfx_laser1.ogg deleted file mode 100644 index 7a9a4d2f..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/galaxy-blaster/sounds/sfx_laser1.ogg and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/galaxy-blaster/sounds/sfx_laser2.ogg b/extensions/arcade-canvas/extensions/assets/galaxy-blaster/sounds/sfx_laser2.ogg deleted file mode 100644 index 6a2d4c5a..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/galaxy-blaster/sounds/sfx_laser2.ogg and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/galaxy-blaster/sounds/sfx_lose.ogg b/extensions/arcade-canvas/extensions/assets/galaxy-blaster/sounds/sfx_lose.ogg deleted file mode 100644 index 496968f8..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/galaxy-blaster/sounds/sfx_lose.ogg and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/galaxy-blaster/sounds/sfx_shieldDown.ogg b/extensions/arcade-canvas/extensions/assets/galaxy-blaster/sounds/sfx_shieldDown.ogg deleted file mode 100644 index e3a7a514..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/galaxy-blaster/sounds/sfx_shieldDown.ogg and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/galaxy-blaster/sounds/sfx_shieldUp.ogg b/extensions/arcade-canvas/extensions/assets/galaxy-blaster/sounds/sfx_shieldUp.ogg deleted file mode 100644 index 49fdb6cc..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/galaxy-blaster/sounds/sfx_shieldUp.ogg and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/galaxy-blaster/sounds/sfx_twoTone.ogg b/extensions/arcade-canvas/extensions/assets/galaxy-blaster/sounds/sfx_twoTone.ogg deleted file mode 100644 index 20274928..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/galaxy-blaster/sounds/sfx_twoTone.ogg and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/galaxy-blaster/sounds/sfx_zap.ogg b/extensions/arcade-canvas/extensions/assets/galaxy-blaster/sounds/sfx_zap.ogg deleted file mode 100644 index 3f6250d3..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/galaxy-blaster/sounds/sfx_zap.ogg and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/galaxy-blaster/space_bg.png b/extensions/arcade-canvas/extensions/assets/galaxy-blaster/space_bg.png deleted file mode 100644 index d9c3fd42..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/galaxy-blaster/space_bg.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/galaxy-blaster/space_sheet-2-black.png b/extensions/arcade-canvas/extensions/assets/galaxy-blaster/space_sheet-2-black.png deleted file mode 100644 index daad6d14..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/galaxy-blaster/space_sheet-2-black.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/galaxy-blaster/space_sheet-2.png b/extensions/arcade-canvas/extensions/assets/galaxy-blaster/space_sheet-2.png deleted file mode 100644 index 1dc51868..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/galaxy-blaster/space_sheet-2.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/galaxy-blaster/space_sheet-2.xml b/extensions/arcade-canvas/extensions/assets/galaxy-blaster/space_sheet-2.xml deleted file mode 100644 index c7751658..00000000 --- a/extensions/arcade-canvas/extensions/assets/galaxy-blaster/space_sheet-2.xml +++ /dev/null @@ -1,297 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/extensions/arcade-canvas/extensions/assets/galaxy-blaster/space_sheet.png b/extensions/arcade-canvas/extensions/assets/galaxy-blaster/space_sheet.png deleted file mode 100644 index 8c58b86c..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/galaxy-blaster/space_sheet.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/galaxy-blaster/space_sheet.xml b/extensions/arcade-canvas/extensions/assets/galaxy-blaster/space_sheet.xml deleted file mode 100644 index 71e1ccf1..00000000 --- a/extensions/arcade-canvas/extensions/assets/galaxy-blaster/space_sheet.xml +++ /dev/null @@ -1,296 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/extensions/arcade-canvas/extensions/assets/icon.png b/extensions/arcade-canvas/extensions/assets/icon.png deleted file mode 100644 index 43d071c5..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/icon.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/background.png b/extensions/arcade-canvas/extensions/assets/ninja-runner/background.png deleted file mode 100644 index 9715a333..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/background.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/big_bush.png b/extensions/arcade-canvas/extensions/assets/ninja-runner/big_bush.png deleted file mode 100644 index b1d517ed..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/big_bush.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/bridge.png b/extensions/arcade-canvas/extensions/assets/ninja-runner/bridge.png deleted file mode 100644 index b34648d2..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/bridge.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/brown_block.png b/extensions/arcade-canvas/extensions/assets/ninja-runner/brown_block.png deleted file mode 100644 index 5983f8d9..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/brown_block.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/clouds.png b/extensions/arcade-canvas/extensions/assets/ninja-runner/clouds.png deleted file mode 100644 index e69c8707..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/clouds.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/coin_sheet.png b/extensions/arcade-canvas/extensions/assets/ninja-runner/coin_sheet.png deleted file mode 100644 index 56166bb0..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/coin_sheet.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/dirt_block.png b/extensions/arcade-canvas/extensions/assets/ninja-runner/dirt_block.png deleted file mode 100644 index 0b7dd760..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/dirt_block.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/enemy_short_strip.png b/extensions/arcade-canvas/extensions/assets/ninja-runner/enemy_short_strip.png deleted file mode 100644 index 81d85d06..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/enemy_short_strip.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/enemy_strip.png b/extensions/arcade-canvas/extensions/assets/ninja-runner/enemy_strip.png deleted file mode 100644 index 50993a03..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/enemy_strip.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/enemy_tall_strip.png b/extensions/arcade-canvas/extensions/assets/ninja-runner/enemy_tall_strip.png deleted file mode 100644 index 7ea3effa..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/enemy_tall_strip.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/flag.png b/extensions/arcade-canvas/extensions/assets/ninja-runner/flag.png deleted file mode 100644 index 627396a3..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/flag.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/grass_block.png b/extensions/arcade-canvas/extensions/assets/ninja-runner/grass_block.png deleted file mode 100644 index 51c2a71d..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/grass_block.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/heart_sheet.png b/extensions/arcade-canvas/extensions/assets/ninja-runner/heart_sheet.png deleted file mode 100644 index 12c18859..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/heart_sheet.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/hill_0.png b/extensions/arcade-canvas/extensions/assets/ninja-runner/hill_0.png deleted file mode 100644 index f555564c..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/hill_0.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/hill_1.png b/extensions/arcade-canvas/extensions/assets/ninja-runner/hill_1.png deleted file mode 100644 index 41117d27..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/hill_1.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/impact_sheet.png b/extensions/arcade-canvas/extensions/assets/ninja-runner/impact_sheet.png deleted file mode 100644 index 6039534c..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/impact_sheet.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/platform.png b/extensions/arcade-canvas/extensions/assets/ninja-runner/platform.png deleted file mode 100644 index 65e01a5b..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/platform.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/player_strip.png b/extensions/arcade-canvas/extensions/assets/ninja-runner/player_strip.png deleted file mode 100644 index d283d3e4..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/player_strip.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/qblock_new.png b/extensions/arcade-canvas/extensions/assets/ninja-runner/qblock_new.png deleted file mode 100644 index 4248ee22..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/qblock_new.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/small_bush.png b/extensions/arcade-canvas/extensions/assets/ninja-runner/small_bush.png deleted file mode 100644 index 4537481b..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/small_bush.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundBlowClub.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundBlowClub.m4a deleted file mode 100644 index 64c74137..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundBlowClub.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundBlowDull.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundBlowDull.m4a deleted file mode 100644 index 70fa9200..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundBlowDull.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundBonus.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundBonus.m4a deleted file mode 100644 index fe27d29e..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundBonus.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundBounce.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundBounce.m4a deleted file mode 100644 index 17f68dc0..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundBounce.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundClick.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundClick.m4a deleted file mode 100644 index de0201b4..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundClick.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundCoin.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundCoin.m4a deleted file mode 100644 index a618d277..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundCoin.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundCountdown.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundCountdown.m4a deleted file mode 100644 index e60c1a1b..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundCountdown.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundDeath.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundDeath.m4a deleted file mode 100644 index 3169fd36..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundDeath.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundEnemyDeath.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundEnemyDeath.m4a deleted file mode 100644 index 52e7c450..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundEnemyDeath.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundEnemyHit.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundEnemyHit.m4a deleted file mode 100644 index 62dc862d..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundEnemyHit.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundEnemyShot.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundEnemyShot.m4a deleted file mode 100644 index e0f75433..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundEnemyShot.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundExplosionLarge.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundExplosionLarge.m4a deleted file mode 100644 index bd9ab8f9..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundExplosionLarge.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundExplosionSmall.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundExplosionSmall.m4a deleted file mode 100644 index a1deff03..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundExplosionSmall.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundFallDull.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundFallDull.m4a deleted file mode 100644 index 18bca3a2..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundFallDull.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundFallLoud.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundFallLoud.m4a deleted file mode 100644 index 985d0023..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundFallLoud.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundFlapHeavy.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundFlapHeavy.m4a deleted file mode 100644 index 1baec410..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundFlapHeavy.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundFlapLight.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundFlapLight.m4a deleted file mode 100644 index ed9d6e91..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundFlapLight.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundGameOver.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundGameOver.m4a deleted file mode 100644 index 6eef0fa1..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundGameOver.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundHurryUp.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundHurryUp.m4a deleted file mode 100644 index eecc63ff..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundHurryUp.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundJump1.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundJump1.m4a deleted file mode 100644 index dca05b48..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundJump1.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundJump2.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundJump2.m4a deleted file mode 100644 index 4ed62c9c..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundJump2.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundJumpHah.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundJumpHah.m4a deleted file mode 100644 index aa36cdb7..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundJumpHah.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundLand1.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundLand1.m4a deleted file mode 100644 index 280d6f13..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundLand1.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundLand2.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundLand2.m4a deleted file mode 100644 index e29c317b..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundLand2.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundLandHeavy.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundLandHeavy.m4a deleted file mode 100644 index 890d6192..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundLandHeavy.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundLaser.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundLaser.m4a deleted file mode 100644 index 8b3d9179..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundLaser.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundMechanism.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundMechanism.m4a deleted file mode 100644 index 7a54bc32..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundMechanism.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundMissile.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundMissile.m4a deleted file mode 100644 index 2f5a5f50..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundMissile.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundObjectFall.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundObjectFall.m4a deleted file mode 100644 index 8713726d..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundObjectFall.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundOpenDoor.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundOpenDoor.m4a deleted file mode 100644 index ea0833a8..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundOpenDoor.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundPlayerHit.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundPlayerHit.m4a deleted file mode 100644 index f7171133..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundPlayerHit.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundReachGoal.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundReachGoal.m4a deleted file mode 100644 index 7120a4ba..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundReachGoal.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundShootDull.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundShootDull.m4a deleted file mode 100644 index e0e6b35a..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundShootDull.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundShootRegular.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundShootRegular.m4a deleted file mode 100644 index 11d19112..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundShootRegular.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundSlide.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundSlide.m4a deleted file mode 100644 index 513fe130..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundSlide.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundSpecialSkill.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundSpecialSkill.m4a deleted file mode 100644 index 0b0cae7d..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundSpecialSkill.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundStartLevel.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundStartLevel.m4a deleted file mode 100644 index d67a38a7..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundStartLevel.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundSwim.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundSwim.m4a deleted file mode 100644 index a44d81a5..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundSwim.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundWandMagic.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundWandMagic.m4a deleted file mode 100644 index df2c7687..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundWandMagic.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundWind.m4a b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundWind.m4a deleted file mode 100644 index 106c546e..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundWind.m4a and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/spikes.png b/extensions/arcade-canvas/extensions/assets/ninja-runner/spikes.png deleted file mode 100644 index 08a63516..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/ninja-runner/spikes.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/sounds/agent-arcade-voice.mp3 b/extensions/arcade-canvas/extensions/assets/sounds/agent-arcade-voice.mp3 deleted file mode 100644 index 9d6881b9..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/sounds/agent-arcade-voice.mp3 and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/tray_icon.png b/extensions/arcade-canvas/extensions/assets/tray_icon.png deleted file mode 100644 index ec26e637..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/tray_icon.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/assets/tray_icon_small.png b/extensions/arcade-canvas/extensions/assets/tray_icon_small.png deleted file mode 100644 index 4348d753..00000000 Binary files a/extensions/arcade-canvas/extensions/assets/tray_icon_small.png and /dev/null differ diff --git a/extensions/arcade-canvas/extensions/copilot-extension.json b/extensions/arcade-canvas/extensions/copilot-extension.json deleted file mode 100644 index c980a34e..00000000 --- a/extensions/arcade-canvas/extensions/copilot-extension.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "arcade-canvas", - "version": 1 -} diff --git a/extensions/arcade-canvas/extensions/extension.mjs b/extensions/arcade-canvas/extensions/extension.mjs deleted file mode 100644 index c8c56b0c..00000000 --- a/extensions/arcade-canvas/extensions/extension.mjs +++ /dev/null @@ -1,546 +0,0 @@ -import { createReadStream } from "node:fs"; -import { readFile, stat } from "node:fs/promises"; -import { createServer } from "node:http"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { CanvasError, createCanvas, joinSession } from "@github/copilot-sdk/extension"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const gameRoot = path.join(__dirname, "game"); -const assetsRoot = path.join(__dirname, "assets"); -const indexPath = path.join(gameRoot, "index.html"); -const gameJsPath = path.join(gameRoot, "game.js"); -const alienOnslaughtJsPath = path.join(gameRoot, "scenes", "AlienOnslaught.js"); -const galaxyBlasterJsPath = path.join(gameRoot, "scenes", "GalaxyBlaster.js"); - -const games = [ - { key: "cosmic-rocks", label: "Cosmic Rocks", icon: "☄️" }, - { key: "alien-onslaught", label: "Alien Onslaught", icon: "👾" }, - { key: "galaxy-blaster", label: "Galaxy Blaster", icon: "🚀" }, - { key: "ninja-runner", label: "Ninja Runner", icon: "🥷" }, - { key: "defender", label: "Planet Guardian", icon: "🛡️" }, -]; - -const gameKeys = new Set(games.map((game) => game.key)); -const defaultGame = "ninja-runner"; -const canvasBackgroundGames = ["cosmic-rocks", "alien-onslaught", "galaxy-blaster", "defender"]; -const servers = new Map(); - -function normalizeGameKey(value) { - return typeof value === "string" && gameKeys.has(value) ? value : defaultGame; -} - -function contentType(filePath) { - switch (path.extname(filePath).toLowerCase()) { - case ".html": - return "text/html; charset=utf-8"; - case ".js": - return "text/javascript; charset=utf-8"; - case ".css": - return "text/css; charset=utf-8"; - case ".json": - return "application/json; charset=utf-8"; - case ".png": - return "image/png"; - case ".webp": - return "image/webp"; - case ".xml": - return "application/xml; charset=utf-8"; - case ".mp3": - return "audio/mpeg"; - case ".ogg": - return "audio/ogg"; - case ".m4a": - return "audio/mp4"; - case ".wav": - return "audio/wav"; - default: - return "application/octet-stream"; - } -} - -function resolveUnder(root, requestPath) { - const resolved = path.resolve(root, `.${requestPath}`); - if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) { - throw new CanvasError("invalid_path", "Requested path is outside the arcade assets."); - } - return resolved; -} - -function sendJson(res, value) { - res.writeHead(200, { - "content-type": "application/json; charset=utf-8", - "cache-control": "no-store", - }); - res.end(JSON.stringify(value)); -} - -function sendNotFound(res) { - res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }); - res.end("Not found"); -} - -function sendSse(res, event, data) { - res.write(`event: ${event}\n`); - res.write(`data: ${JSON.stringify(data)}\n\n`); -} - -function broadcast(entry, event, data) { - for (const client of entry.clients) { - sendSse(client, event, data); - } -} - -async function renderIndex(entry) { - const html = await readFile(indexPath, "utf8"); - const bootstrap = ``; - return html.replace('', `${bootstrap}\n `); -} - -async function renderGameJs() { - const js = await readFile(gameJsPath, "utf8"); - return js - .replaceAll("newW > 800 && newH > 400", "newW > 320 && newH > 220") - .replaceAll("game && newH > 400", "game && newH > 220") - .replaceAll("window.innerWidth > 800 && window.innerHeight > 400", "window.innerWidth > 320 && window.innerHeight > 220"); -} - -async function renderAlienOnslaughtJs() { - const js = await readFile(alienOnslaughtJsPath, "utf8"); - const layoutH = "Math.min(H, W * 3 / 4)"; - const layoutY = `((H - ${layoutH}) / 2)`; - return js - .replace("this.playerY = H * 0.92;", `this.playerY = ${layoutY} + ${layoutH} * 0.95;`) - .replace("this.alienGridY = Math.max(H * 0.20, 120);", `this.alienGridY = Math.max(${layoutY} + ${layoutH} * 0.10, 80);`) - .replace("const targetShieldH = H * 0.055;", `const targetShieldH = ${layoutH} * 0.065;`) - .replace("SCALE = Math.min(W / 1920, H / 1080);", "SCALE = Math.max(1.25, Math.min(W / 1920, H / 1080));") - .replace("this.alienCellW = Math.round(W * 0.055);", "this.alienCellW = Math.round(W * 0.068);"); -} - -async function renderGalaxyBlasterJs() { - const js = await readFile(galaxyBlasterJsPath, "utf8"); - return js - .replaceAll("SCALE = Math.min(CONV_X, CONV_Y);", "SCALE = Math.max(1.7, Math.min(CONV_X, CONV_Y));") - .replaceAll("OPPONENT_SIZE = Math.min(32 * SCALE, W / 35);", "OPPONENT_SIZE = Math.max(54, Math.min(32 * SCALE, W / 24));"); -} - -async function streamFile(res, filePath) { - const fileStat = await stat(filePath).catch(() => undefined); - if (!fileStat?.isFile()) { - sendNotFound(res); - return; - } - - res.writeHead(200, { - "content-type": contentType(filePath), - "cache-control": "no-cache", - }); - const stream = createReadStream(filePath); - stream.on("error", () => { - if (!res.headersSent) { - sendNotFound(res); - } else { - res.destroy(); - } - }); - stream.pipe(res); -} - -async function handleSelectGame(entry, req, res) { - let body = ""; - req.setEncoding("utf8"); - req.on("data", (chunk) => { - body += chunk; - }); - req.on("end", () => { - let input; - try { - input = JSON.parse(body || "{}"); - } catch { - res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }); - res.end("Invalid JSON request body"); - return; - } - entry.selectedGame = normalizeGameKey(input.gameKey); - broadcast(entry, "selectGame", { gameKey: entry.selectedGame }); - sendJson(res, { selectedGame: entry.selectedGame }); - }); -} - -async function handleRequest(entry, req, res) { - const url = new URL(req.url ?? "/", entry.url); - - if (url.pathname === "/events") { - res.writeHead(200, { - "content-type": "text/event-stream; charset=utf-8", - "cache-control": "no-cache", - connection: "keep-alive", - }); - entry.clients.add(res); - sendSse(res, "selectGame", { gameKey: entry.selectedGame }); - req.on("close", () => entry.clients.delete(res)); - return; - } - - if (url.pathname === "/state") { - sendJson(res, { games, selectedGame: entry.selectedGame }); - return; - } - - if (url.pathname === "/favicon.ico") { - await streamFile(res, path.join(assetsRoot, "icon.png")); - return; - } - - if (url.pathname === "/select-game" && req.method === "POST") { - await handleSelectGame(entry, req, res); - return; - } - - try { - if (url.pathname === "/" || url.pathname === "/index.html" || url.pathname === "/game" || url.pathname === "/game/") { - res.writeHead(200, { - "content-type": "text/html; charset=utf-8", - "cache-control": "no-cache", - }); - res.end(await renderIndex(entry)); - return; - } - - if (url.pathname === "/game.js" || url.pathname === "/game/game.js") { - res.writeHead(200, { - "content-type": "text/javascript; charset=utf-8", - "cache-control": "no-cache", - }); - res.end(await renderGameJs()); - return; - } - - if (url.pathname === "/scenes/AlienOnslaught.js" || url.pathname === "/game/scenes/AlienOnslaught.js") { - res.writeHead(200, { - "content-type": "text/javascript; charset=utf-8", - "cache-control": "no-cache", - }); - res.end(await renderAlienOnslaughtJs()); - return; - } - - if (url.pathname === "/scenes/GalaxyBlaster.js" || url.pathname === "/game/scenes/GalaxyBlaster.js") { - res.writeHead(200, { - "content-type": "text/javascript; charset=utf-8", - "cache-control": "no-cache", - }); - res.end(await renderGalaxyBlasterJs()); - return; - } - - const staticPath = url.pathname.startsWith("/assets/") - ? resolveUnder(assetsRoot, url.pathname.slice("/assets".length)) - : resolveUnder(gameRoot, url.pathname.startsWith("/game/") ? url.pathname.slice("/game".length) : url.pathname); - await streamFile(res, staticPath); - } catch (error) { - if (error instanceof CanvasError) { - res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }); - res.end(error.message); - return; - } - throw error; - } -} - -async function startServer(instanceId, selectedGame) { - const entry = { - clients: new Set(), - selectedGame, - server: undefined, - url: undefined, - }; - const server = createServer((req, res) => { - handleRequest(entry, req, res).catch((error) => { - res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }); - res.end(error instanceof Error ? error.message : "Arcade canvas server error"); - }); - }); - entry.server = server; - - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - const address = server.address(); - const port = typeof address === "object" && address ? address.port : 0; - entry.url = `http://127.0.0.1:${port}/`; - servers.set(instanceId, entry); - return entry; -} - -function getOpenEntry(instanceId) { - const entry = servers.get(instanceId); - if (!entry) { - throw new CanvasError("arcade_not_open", "Open the Arcade canvas before invoking this action."); - } - return entry; -} - -await joinSession({ - canvases: [ - createCanvas({ - id: "arcade-canvas", - displayName: "Agent Arcade", - description: "A retro arcade canvas with five mini-games for waiting while agents work.", - inputSchema: { - type: "object", - properties: { - defaultGame: { - type: "string", - enum: games.map((game) => game.key), - description: "Game to show first.", - }, - }, - additionalProperties: false, - }, - actions: [ - { - name: "list_games", - description: "List the mini-games available in the arcade canvas.", - handler: (ctx) => { - const entry = servers.get(ctx.instanceId); - return { - games, - selectedGame: entry?.selectedGame ?? defaultGame, - }; - }, - }, - { - name: "select_game", - description: "Switch the open arcade canvas to a specific mini-game.", - inputSchema: { - type: "object", - properties: { - gameKey: { - type: "string", - enum: games.map((game) => game.key), - }, - }, - required: ["gameKey"], - additionalProperties: false, - }, - handler: (ctx) => { - const entry = getOpenEntry(ctx.instanceId); - entry.selectedGame = normalizeGameKey(ctx.input?.gameKey); - broadcast(entry, "selectGame", { gameKey: entry.selectedGame }); - return { - selectedGame: entry.selectedGame, - }; - }, - }, - { - name: "restart_game", - description: "Reload the open arcade canvas to restart the selected game.", - handler: (ctx) => { - const entry = getOpenEntry(ctx.instanceId); - broadcast(entry, "reload", {}); - return { - selectedGame: entry.selectedGame, - }; - }, - }, - ], - open: async (ctx) => { - let entry = servers.get(ctx.instanceId); - if (!entry) { - entry = await startServer(ctx.instanceId, normalizeGameKey(ctx.input?.defaultGame)); - } else if (ctx.input?.defaultGame) { - entry.selectedGame = normalizeGameKey(ctx.input.defaultGame); - } - return { - title: "Agent Arcade", - status: games.find((game) => game.key === entry.selectedGame)?.label ?? "Ready", - url: entry.url, - }; - }, - onClose: async (ctx) => { - const entry = servers.get(ctx.instanceId); - if (!entry) return; - - servers.delete(ctx.instanceId); - for (const client of entry.clients) { - client.end(); - } - await new Promise((resolve) => entry.server.close(() => resolve())); - }, - }), - ], -}); diff --git a/extensions/arcade-canvas/game/game.js b/extensions/arcade-canvas/game/game.js deleted file mode 100644 index 7ed254bf..00000000 --- a/extensions/arcade-canvas/game/game.js +++ /dev/null @@ -1,178 +0,0 @@ -// Agent Arcade — game bootstrap and scene registry. -// Each mini-game is a Phaser Scene extending BaseScene. -import { W, H, refreshDimensions } from './scenes/BaseScene.js'; -import { NinjaRunnerScene } from './scenes/NinjaRunner.js'; -import { GalaxyBlasterScene } from './scenes/GalaxyBlaster.js'; -import { CosmicRocksScene } from './scenes/CosmicRocks.js'; -import { AlienOnslaughtScene } from './scenes/AlienOnslaught.js'; -import { PlanetGuardianScene } from './scenes/PlanetGuardian.js'; -// Registry of available games -const GAMES = [ - { key: 'cosmic-rocks', scene: CosmicRocksScene, label: '☄️ Cosmic Rocks' }, - { key: 'alien-onslaught', scene: AlienOnslaughtScene, label: '👾 Alien Onslaught' }, - { key: 'galaxy-blaster', scene: GalaxyBlasterScene, label: '🚀 Galaxy Blaster' }, - { key: 'ninja-runner', scene: NinjaRunnerScene, label: '🥷 Ninja Runner' }, - { key: 'defender', scene: PlanetGuardianScene, label: '🛡️ Planet Guardian' }, -]; -let currentGameKey; -try { - // Migrate localStorage from old "galaxy-shooter" name - const lastGame = localStorage.getItem('agentArcade_lastGame'); - if (lastGame === 'galaxy-shooter') - localStorage.setItem('agentArcade_lastGame', 'galaxy-blaster'); - const oldHi = localStorage.getItem('agentArcade_hi_galaxy-shooter'); - if (oldHi) { - localStorage.setItem('agentArcade_hi_galaxy-blaster', oldHi); - localStorage.removeItem('agentArcade_hi_galaxy-shooter'); - } - currentGameKey = localStorage.getItem('agentArcade_lastGame') || 'ninja-runner'; -} -catch { - currentGameKey = 'ninja-runner'; -} -// Validate stored key exists in registry -if (!GAMES.find(g => g.key === currentGameKey)) - currentGameKey = 'ninja-runner'; -// Create the Phaser game once the window is full-screen. -// Tauri's Rust backend resizes the window after setup — we listen for the -// `resize` event so we create the game at the correct dimensions. -let game = null; -function initGame() { - refreshDimensions(); - game = new Phaser.Game({ - type: Phaser.AUTO, - parent: 'game', - width: W, - height: H, - transparent: true, - backgroundColor: 'rgba(0,0,0,0)', - scene: GAMES.map(g => g.scene), - physics: { - default: 'arcade', - arcade: { gravity: { y: 1800 }, debug: false }, - }, - render: { pixelArt: true, antialias: false, transparent: true }, - fps: { target: 60 }, - }); - // Expose game instance for Playwright testing (no production impact) - window.__phaserGame = game; - // Start the saved game (stop the default first scene if it's different) - if (currentGameKey !== GAMES[0].key) { - game.events.once('ready', () => { - game.scene.stop(GAMES[0].key); - game.scene.start(currentGameKey); - }); - } - setupGameSwitcher(); -} -function setupGameSwitcher() { - // Expose game switcher for the HUD dropdown - window.__agentArcadeSwitchGame = (key) => { - const entry = GAMES.find(g => g.key === key); - if (!entry || key === currentGameKey) - return; - const wasPaused = document.getElementById('hud')?.classList.contains('paused') ?? false; - // Set skip flag BEFORE anything else so the Rust-triggered onResume - // won't fire scene resume callbacks on the new scene. - if (wasPaused) - window.__agentArcadeSkipResume = true; - // Stop all audio globally (covers paused sounds too) - if (game.sound) - game.sound.stopAll(); - // Remove DOM overlays from the previous scene (game-over, wave banner, ready screen) - for (const id of ['gameover-overlay', 'wave-banner', 'ready-overlay']) { - const el = document.getElementById(id); - if (el) - el.remove(); - } - // Stop current scene, start new one - game.scene.stop(currentGameKey); - game.scene.start(key); - currentGameKey = key; - try { - localStorage.setItem('agentArcade_lastGame', key); - } - catch { /* ignore */ } - // Tell Rust we're unpaused so the window expands back to full-screen. - const ab = window.agentArcade; - if (wasPaused && ab && ab.setPaused) - ab.setPaused(false); - // The cursor was over the HUD to trigger this switch, so click-through should - // stay OFF. Calling setClickThrough(false) also triggers set_focus() in Rust, - // restoring OS keyboard focus after the native - - - -
-
- ❤️ - 3 -
-
-
- - 0 -
-
-
- 🏆 - 0 -
-
-
- - - - -
- - - - -
- -
- -
- -
- -
- 🚀 - Version is available! - View Release - -
- - - -
- - - - - diff --git a/extensions/arcade-canvas/game/phaser.min.js b/extensions/arcade-canvas/game/phaser.min.js deleted file mode 100644 index 7647718e..00000000 --- a/extensions/arcade-canvas/game/phaser.min.js +++ /dev/null @@ -1,17 +0,0 @@ -(() => { - window.__agentArcadePhaserReady = (async () => { - if (!("DecompressionStream" in window)) { - throw new Error("This browser cannot load the compressed Phaser bundle."); - } - - const response = await fetch("./phaser.min.js.gz"); - if (!response.ok || !response.body) { - throw new Error("Failed to load the Phaser bundle."); - } - - const stream = response.body.pipeThrough(new DecompressionStream("gzip")); - const source = await new Response(stream).text(); - (0, eval)(`${source}\n//# sourceURL=phaser.min.bundle.js`); - return window.Phaser; - })(); -})(); diff --git a/extensions/arcade-canvas/game/phaser.min.js.gz b/extensions/arcade-canvas/game/phaser.min.js.gz deleted file mode 100644 index 53c42968..00000000 Binary files a/extensions/arcade-canvas/game/phaser.min.js.gz and /dev/null differ diff --git a/extensions/arcade-canvas/game/scenes/AlienOnslaught.js b/extensions/arcade-canvas/game/scenes/AlienOnslaught.js deleted file mode 100644 index c8f77f82..00000000 --- a/extensions/arcade-canvas/game/scenes/AlienOnslaught.js +++ /dev/null @@ -1,812 +0,0 @@ -// AlienOnslaught — Space Invaders-style arcade shooter. -// Rows of aliens march across the screen, descending as they reach the -// edges. The player defends from the bottom with destructible shields. -// All graphics are procedural (Phaser Graphics) — no external sprite sheets. -import { BaseScene, W, H } from './BaseScene.js'; -/* ------------------------------------------------------------------ */ -/* Constants — recalculated in create() for responsive sizing */ -/* ------------------------------------------------------------------ */ -let SCALE = Math.min(W / 1920, H / 1080); -// Grid layout -const ALIEN_COLS = 11; -const ALIEN_ROWS = 5; -// Alien types per row (top → bottom): squid, crab, crab, octopus, octopus -const ALIEN_TYPES = [ - { name: 'squid', points: 30, color: 0xff4444 }, // row 0 (top) - { name: 'crab', points: 20, color: 0x44ff44 }, // rows 1-2 - { name: 'octopus', points: 10, color: 0x44aaff }, // rows 3-4 -]; -// Timing / speeds -const BASE_MARCH_INTERVAL = 700; // ms between march steps at full grid -const MIN_MARCH_INTERVAL = 60; // fastest march with few aliens left -const MARCH_DROP = 0; // calculated in create() -const PLAYER_SPEED = 350; // px/s -const PLAYER_BULLET_SPEED = 500; // px/s -const ALIEN_BULLET_SPEED = 250; // px/s -const ALIEN_FIRE_INTERVAL = 1200; // ms between alien shots (base) -const MYSTERY_INTERVAL_MIN = 15000; -const MYSTERY_INTERVAL_MAX = 30000; -const MYSTERY_SPEED = 150; // px/s -const INVINCIBLE_TIME = 2000; // ms -// Shield config -const SHIELD_COUNT = 4; -const SHIELD_BLOCK_COLS = 22; -const SHIELD_BLOCK_ROWS = 16; -/* ------------------------------------------------------------------ */ -/* Scene */ -/* ------------------------------------------------------------------ */ -export class AlienOnslaughtScene extends BaseScene { - /* player */ - playerGfx; - playerX = 0; - playerY = 0; - playerAlive = true; - /* aliens */ - aliens = []; - alienCellW = 0; - alienCellH = 0; - alienGridX = 0; // grid origin - alienGridY = 0; - marchDir = 1; // 1 = right, -1 = left - marchTimer = 0; - marchInterval = BASE_MARCH_INTERVAL; - marchStepX = 0; - marchDrop = 0; - /* bullets */ - playerBullets = []; - alienBullets = []; - alienFireTimer = 0; - /* mystery ship */ - mystery = null; - mysteryTimer = 0; - /* shields */ - shields = []; // [shieldIdx][blockIdx] - /* starfield */ - stars = []; - /* game state */ - wave = 0; - invincibleTimer = 0; - respawnTimer = 0; - gameOverFlag = false; - waveDelay = 0; - /* input */ - cursors; - spaceKey; - spaceWasDown = false; - /* sizing (calculated in create) */ - alienW = 0; - alienH = 0; - playerW = 0; - playerH = 0; - bulletW = 0; - bulletH = 0; - constructor() { super('alien-onslaught'); } - get displayName() { return 'Alien Onslaught'; } - getDescription() { - return 'Blast waves of descending aliens before they reach the bottom!'; - } - getControls() { - return [ - { key: '← →', action: 'Move Left / Right' }, - { key: 'SPACE', action: 'Fire' }, - ]; - } - /* ================================================================ - LIFECYCLE - ================================================================ */ - preload() { - // Reuse existing sound effects - this.load.audio('ao_laser', '../assets/galaxy-blaster/sounds/sfx_laser1.ogg'); - this.load.audio('ao_explosion', '../assets/galaxy-blaster/sounds/sfx_explosion.ogg'); - this.load.audio('ao_lose', '../assets/cosmic-rocks/sounds/sfx_lose.ogg'); - this.load.audio('ao_twoTone', '../assets/cosmic-rocks/sounds/sfx_twoTone.ogg'); - this.load.audio('ao_shieldHit', '../assets/galaxy-blaster/sounds/sfx_zap.ogg'); - this.load.audio('ao_mystery', '../assets/galaxy-blaster/sounds/sfx_twoTone.ogg'); - } - create() { - this.initBase(); - // Responsive sizing — scale the grid to fill ~70% of screen width - SCALE = Math.min(W / 1920, H / 1080); - const s = Math.max(SCALE, 0.5); - // Size the grid relative to screen, not a fixed pixel size - this.alienCellW = Math.round(W * 0.055); // ~85% of original — tighter grid - this.alienCellH = Math.round(this.alienCellW * 0.8); - this.alienW = Math.round(this.alienCellW * 0.6); - this.alienH = Math.round(this.alienCellH * 0.55); - this.playerW = Math.round(this.alienCellW * 0.85); - this.playerH = Math.round(this.playerW * 0.55); - this.bulletW = Math.round(4 * s); - this.bulletH = Math.round(12 * s); - this.marchStepX = Math.round(this.alienCellW * 0.25); // bigger steps → hit edges sooner - this.marchDrop = Math.round(this.alienCellH * 0.6); // bigger drops → descend faster - // Reset state - this.score = 0; - this.lives = 3; - this.wave = 0; - this.playerAlive = true; - this.gameOverFlag = false; - this.invincibleTimer = INVINCIBLE_TIME; - this.respawnTimer = 0; - this.waveDelay = 0; - this.marchDir = 1; - this.marchTimer = 0; - this.marchInterval = BASE_MARCH_INTERVAL; - this.playerBullets = []; - this.alienBullets = []; - this.aliens = []; - this.shields = []; - this.mystery = null; - this.mysteryTimer = MYSTERY_INTERVAL_MIN + Math.random() * (MYSTERY_INTERVAL_MAX - MYSTERY_INTERVAL_MIN); - this.stars = []; - this.ensureSparkTexture(); - // Starfield - this.stars = this.createStarfield([ - { count: 40, speed: 10, size: 1, alpha: 0.2 }, - { count: 25, speed: 20, size: 1.5, alpha: 0.3 }, - { count: 10, speed: 40, size: 2, alpha: 0.4 }, - ]); - // Player position — bottom of screen with padding - this.playerX = W / 2; - this.playerY = H * 0.92; - this.playerGfx = this.add.graphics().setDepth(10); - this.drawPlayer(); - // Input - this.cursors = this.input.keyboard.createCursorKeys(); - this.spaceKey = this.input.keyboard.addKey('SPACE'); - this.spaceWasDown = false; - // HUD - this.syncLivesToHUD(); - this.syncScoreToHUD(); - this.loadHighScore(); - this.startWithReadyScreen(() => this.startWave()); - } - update(_t, dtMs) { - if (this.gameOverFlag || !this.cursors) - return; - const dt = Math.min(dtMs, 33); - const dtSec = dt / 1000; - this.updateStarfield(this.stars, dt); - // Respawn delay - if (this.respawnTimer > 0) { - this.respawnTimer -= dt; - if (this.respawnTimer <= 0) - this.respawnPlayer(); - } - // Player input - if (this.playerAlive) { - this.updatePlayerInput(dtSec); - } - // Invincibility flicker - if (this.invincibleTimer > 0) { - this.invincibleTimer -= dt; - if (this.playerGfx) { - this.playerGfx.setAlpha(Math.sin(performance.now() / 80) > 0 ? 1 : 0.2); - } - } - else if (this.playerGfx) { - this.playerGfx.setAlpha(1); - } - // Alien march - this.marchTimer += dt; - if (this.marchTimer >= this.marchInterval) { - this.marchTimer = 0; - this.marchAliens(); - } - // Alien shooting - this.alienFireTimer += dt; - const fireInterval = Math.max(400, ALIEN_FIRE_INTERVAL - this.wave * 80); - if (this.alienFireTimer >= fireInterval) { - this.alienFireTimer = 0; - this.alienShoot(); - } - // Update bullets - this.updatePlayerBullets(dtSec); - this.updateAlienBullets(dtSec); - // Mystery ship - this.updateMystery(dt, dtSec); - // Collisions - this.checkCollisions(); - // Wave clear - if (this.waveDelay > 0) { - this.waveDelay -= dt; - if (this.waveDelay <= 0) - this.startWave(); - } - else if (this.aliens.filter(a => a.alive).length === 0 && this.waveDelay <= 0) { - this.waveDelay = 1500; - } - } - /* ================================================================ - PLAYER - ================================================================ */ - drawPlayer() { - const g = this.playerGfx; - g.clear(); - g.setPosition(this.playerX, this.playerY); - const hw = this.playerW / 2; - const hh = this.playerH / 2; - const turretW = hw * 0.2; - const turretH = hh * 0.6; - // Shadow - g.fillStyle(0x000000, 0.5); - g.fillRect(-hw - 1, -hh - 1, this.playerW + 2, this.playerH + 2); - g.fillRect(-turretW - 1, -hh - turretH - 1, turretW * 2 + 2, turretH + 2); - // Body (bright green) - g.fillStyle(0x00ff66, 1); - g.fillRect(-hw, -hh, this.playerW, this.playerH); - // Turret - g.fillStyle(0x00ff66, 1); - g.fillRect(-turretW, -hh - turretH, turretW * 2, turretH); - // Cockpit highlight - g.fillStyle(0xaaffcc, 0.6); - g.fillRect(-hw * 0.3, -hh * 0.5, hw * 0.6, hh * 0.6); - } - updatePlayerInput(dtSec) { - if (!this.cursors) - return; - if (this.cursors.left.isDown) { - this.playerX -= PLAYER_SPEED * dtSec; - } - if (this.cursors.right.isDown) { - this.playerX += PLAYER_SPEED * dtSec; - } - // Clamp to screen - const hw = this.playerW / 2; - this.playerX = Math.max(hw, Math.min(W - hw, this.playerX)); - this.drawPlayer(); - // Fire - const spaceDown = this.spaceKey.isDown; - if (spaceDown && !this.spaceWasDown && this.playerBullets.length < 2) { - this.firePlayerBullet(); - } - this.spaceWasDown = spaceDown; - } - respawnPlayer() { - this.playerX = W / 2; - this.playerAlive = true; - this.invincibleTimer = INVINCIBLE_TIME; - if (this.playerGfx) { - this.playerGfx.setVisible(true); - } - this.drawPlayer(); - } - /* ================================================================ - PLAYER BULLETS - ================================================================ */ - firePlayerBullet() { - this.sound.play('ao_laser', { volume: 0.3 }); - const gfx = this.add.graphics().setDepth(8); - const bx = this.playerX; - const by = this.playerY - this.playerH / 2; - // Glow - gfx.fillStyle(0x00ffff, 0.3); - gfx.fillRect(-this.bulletW, -this.bulletH, this.bulletW * 2, this.bulletH * 2); - // Solid - gfx.fillStyle(0x00ffff, 1); - gfx.fillRect(-this.bulletW / 2, -this.bulletH / 2, this.bulletW, this.bulletH); - gfx.setPosition(bx, by); - this.playerBullets.push({ gfx, x: bx, y: by }); - } - updatePlayerBullets(dtSec) { - for (let i = this.playerBullets.length - 1; i >= 0; i--) { - const b = this.playerBullets[i]; - b.y -= PLAYER_BULLET_SPEED * dtSec; - b.gfx.setPosition(b.x, b.y); - if (b.y < -this.bulletH) { - b.gfx.destroy(); - this.playerBullets.splice(i, 1); - } - } - } - /* ================================================================ - ALIENS - ================================================================ */ - startWave() { - this.wave++; - this.level = this.wave; - this.syncLevelToHUD(); - this.showWaveBanner(this.wave); - // Clear leftover bullets - for (const b of this.playerBullets) - b.gfx.destroy(); - this.playerBullets = []; - for (const b of this.alienBullets) - b.gfx.destroy(); - this.alienBullets = []; - // Reset march - this.marchDir = 1; - this.marchTimer = 0; - this.alienFireTimer = 0; - // Calculate grid start position (centered) - const gridW = ALIEN_COLS * this.alienCellW; - this.alienGridX = (W - gridW) / 2; - this.alienGridY = Math.max(H * 0.20, 120); - // Create aliens - for (const a of this.aliens) - a.gfx.destroy(); - this.aliens = []; - for (let row = 0; row < ALIEN_ROWS; row++) { - const typeIdx = row === 0 ? 0 : row <= 2 ? 1 : 2; - for (let col = 0; col < ALIEN_COLS; col++) { - const x = this.alienGridX + col * this.alienCellW + this.alienCellW / 2; - const y = this.alienGridY + row * this.alienCellH + this.alienCellH / 2; - const gfx = this.add.graphics().setDepth(5); - const alien = { gfx, row, col, type: typeIdx, alive: true, x, y, frame: 0 }; - this.drawAlien(alien); - this.aliens.push(alien); - } - } - this.updateMarchInterval(); - // Recreate shields on first wave only - if (this.wave === 1) { - this.createShields(); - } - } - drawAlien(alien) { - const g = alien.gfx; - g.clear(); - g.setPosition(alien.x, alien.y); - const type = ALIEN_TYPES[alien.type]; - const hw = this.alienW / 2; - const hh = this.alienH / 2; - const px = Math.max(2, Math.round(this.alienW / 10)); // pixel unit size - // Draw pixel-art alien based on type - g.fillStyle(type.color, 1); - if (type.name === 'squid') { - // Squid alien — narrow top, wider middle - g.fillRect(-px, -hh, px * 2, px); // top antenna - g.fillRect(-px * 2, -hh + px, px * 4, px); // head top - g.fillRect(-px * 3, -hh + px * 2, px * 6, px * 2); // head body - g.fillRect(-px * 4, -hh + px * 4, px * 8, px); // wider - g.fillRect(-px * 3, -hh + px * 5, px * 6, px); // middle - if (alien.frame === 0) { - // legs out - g.fillRect(-px * 4, -hh + px * 6, px * 2, px); - g.fillRect(px * 2, -hh + px * 6, px * 2, px); - } - else { - // legs in - g.fillRect(-px * 2, -hh + px * 6, px * 2, px); - g.fillRect(0, -hh + px * 6, px * 2, px); - } - } - else if (type.name === 'crab') { - // Crab alien — classic shape with claws - g.fillRect(-px, -hh, px * 2, px); // antenna - g.fillRect(-px * 3, -hh + px, px * 6, px); // top - g.fillRect(-px * 4, -hh + px * 2, px * 8, px * 2); // body - g.fillRect(-px * 5, -hh + px * 4, px * 10, px); // wide row - g.fillRect(-px * 4, -hh + px * 5, px * 8, px); // narrower - // Eyes (dark cutouts) - g.fillStyle(0x000000, 1); - g.fillRect(-px * 2, -hh + px * 2, px, px); - g.fillRect(px, -hh + px * 2, px, px); - g.fillStyle(type.color, 1); - if (alien.frame === 0) { - g.fillRect(-px * 5, -hh + px * 5, px, px * 2); - g.fillRect(px * 4, -hh + px * 5, px, px * 2); - } - else { - g.fillRect(-px * 3, -hh + px * 6, px * 2, px); - g.fillRect(px, -hh + px * 6, px * 2, px); - } - } - else { - // Octopus alien — round with tentacles - g.fillRect(-px * 2, -hh, px * 4, px); // top - g.fillRect(-px * 4, -hh + px, px * 8, px * 2); // upper body - g.fillRect(-px * 5, -hh + px * 3, px * 10, px * 2); // body - g.fillRect(-px * 4, -hh + px * 5, px * 8, px); // lower - // Eyes - g.fillStyle(0x000000, 1); - g.fillRect(-px * 3, -hh + px * 2, px * 2, px); - g.fillRect(px, -hh + px * 2, px * 2, px); - g.fillStyle(type.color, 1); - if (alien.frame === 0) { - // tentacles down/out - g.fillRect(-px * 5, -hh + px * 6, px * 2, px); - g.fillRect(-px * 2, -hh + px * 6, px, px); - g.fillRect(px, -hh + px * 6, px, px); - g.fillRect(px * 3, -hh + px * 6, px * 2, px); - } - else { - // tentacles up/in - g.fillRect(-px * 4, -hh + px * 6, px * 2, px); - g.fillRect(-px, -hh + px * 6, px * 2, px); - g.fillRect(px * 2, -hh + px * 6, px * 2, px); - } - } - } - marchAliens() { - const alive = this.aliens.filter(a => a.alive); - if (alive.length === 0) - return; - // Check if any alien hit the edge - let hitEdge = false; - const margin = this.alienCellW * 0.3; - for (const a of alive) { - if (this.marchDir === 1 && a.x + this.alienW / 2 + this.marchStepX > W - margin) { - hitEdge = true; - break; - } - if (this.marchDir === -1 && a.x - this.alienW / 2 - this.marchStepX < margin) { - hitEdge = true; - break; - } - } - if (hitEdge) { - // Drop down and reverse - this.marchDir *= -1; - for (const a of alive) { - a.y += this.marchDrop; - a.frame = 1 - a.frame; - this.drawAlien(a); - // Check if aliens reached player row — instant game over (classic rules) - if (a.y + this.alienH / 2 >= this.playerY - this.playerH / 2) { - this.triggerGameOver(); - return; - } - } - } - else { - // March sideways - for (const a of alive) { - a.x += this.marchStepX * this.marchDir; - a.frame = 1 - a.frame; - this.drawAlien(a); - } - } - // Play march sound (alternate tone) - this.sound.play('ao_twoTone', { volume: 0.15 }); - } - updateMarchInterval() { - const aliveCount = this.aliens.filter(a => a.alive).length; - const total = ALIEN_COLS * ALIEN_ROWS; - if (total === 0) - return; - // Exponential speed-up as aliens are destroyed - const ratio = aliveCount / total; - this.marchInterval = MIN_MARCH_INTERVAL + (BASE_MARCH_INTERVAL - MIN_MARCH_INTERVAL) * ratio; - // Wave speed bonus - this.marchInterval = Math.max(MIN_MARCH_INTERVAL, this.marchInterval - this.wave * 20); - } - /* ================================================================ - ALIEN SHOOTING - ================================================================ */ - alienShoot() { - const alive = this.aliens.filter(a => a.alive); - if (alive.length === 0) - return; - // Find bottommost alien in each column, then pick one at random - const bottomAliens = []; - for (let col = 0; col < ALIEN_COLS; col++) { - const colAliens = alive.filter(a => a.col === col); - if (colAliens.length > 0) { - colAliens.sort((a, b) => b.row - a.row); - bottomAliens.push(colAliens[0]); - } - } - if (bottomAliens.length === 0) - return; - const shooter = bottomAliens[Math.floor(Math.random() * bottomAliens.length)]; - const gfx = this.add.graphics().setDepth(7); - // Alien bullet — different color (yellow/red) - gfx.fillStyle(0xffaa00, 0.4); - gfx.fillRect(-this.bulletW, -this.bulletH / 2, this.bulletW * 2, this.bulletH); - gfx.fillStyle(0xff4444, 1); - gfx.fillRect(-this.bulletW / 2, -this.bulletH / 2, this.bulletW, this.bulletH); - gfx.setPosition(shooter.x, shooter.y + this.alienH / 2); - this.alienBullets.push({ gfx, x: shooter.x, y: shooter.y + this.alienH / 2 }); - } - updateAlienBullets(dtSec) { - for (let i = this.alienBullets.length - 1; i >= 0; i--) { - const b = this.alienBullets[i]; - b.y += ALIEN_BULLET_SPEED * dtSec; - b.gfx.setPosition(b.x, b.y); - if (b.y > H + this.bulletH) { - b.gfx.destroy(); - this.alienBullets.splice(i, 1); - } - } - } - /* ================================================================ - MYSTERY SHIP - ================================================================ */ - spawnMystery() { - const dir = Math.random() < 0.5 ? 1 : -1; - const x = dir === 1 ? -40 : W + 40; - // Position just above the alien grid, below the HUD - const y = this.alienGridY - this.alienCellH * 1.2; - const gfx = this.add.graphics().setDepth(12); - this.mystery = { gfx, x, y, direction: dir, active: true }; - this.drawMystery(); - this.sound.play('ao_mystery', { volume: 0.2 }); - } - drawMystery() { - if (!this.mystery) - return; - const g = this.mystery.gfx; - g.clear(); - g.setPosition(this.mystery.x, this.mystery.y); - const s = Math.max(SCALE, 0.5); - const w = 30 * s; - const h = 12 * s; - // Saucer shape - g.fillStyle(0x000000, 0.5); - g.fillEllipse(0, 0, w * 2 + 2, h + 2); - g.fillStyle(0xff00ff, 0.8); - g.fillEllipse(0, 0, w * 2, h); - // Dome - g.fillStyle(0xff66ff, 1); - g.fillEllipse(0, -h * 0.4, w, h * 0.7); - // Lights - g.fillStyle(0xffff00, 1); - g.fillCircle(-w * 0.5, 0, 2 * s); - g.fillCircle(0, 0, 2 * s); - g.fillCircle(w * 0.5, 0, 2 * s); - } - updateMystery(dt, dtSec) { - if (this.mystery && this.mystery.active) { - this.mystery.x += MYSTERY_SPEED * this.mystery.direction * dtSec; - this.drawMystery(); - // Off screen? - if ((this.mystery.direction === 1 && this.mystery.x > W + 60) || - (this.mystery.direction === -1 && this.mystery.x < -60)) { - this.mystery.gfx.destroy(); - this.mystery = null; - } - } - else { - this.mysteryTimer -= dt; - if (this.mysteryTimer <= 0) { - this.mysteryTimer = MYSTERY_INTERVAL_MIN + Math.random() * (MYSTERY_INTERVAL_MAX - MYSTERY_INTERVAL_MIN); - this.spawnMystery(); - } - } - } - /* ================================================================ - SHIELDS - ================================================================ */ - createShields() { - // Clear existing - for (const shield of this.shields) { - for (const block of shield) { - if (block.gfx) - block.gfx.destroy(); - } - } - this.shields = []; - const s = Math.max(SCALE, 0.5); - // Original shields were ~6% of screen height tall; derive block size from that - const targetShieldH = H * 0.055; - const blockH = Math.max(2, Math.round(targetShieldH / SHIELD_BLOCK_ROWS)); - const blockW = blockH; - const shieldW = SHIELD_BLOCK_COLS * blockW; - const shieldH = SHIELD_BLOCK_ROWS * blockH; - const totalShieldsW = SHIELD_COUNT * shieldW; - const gap = (W - totalShieldsW) / (SHIELD_COUNT + 1); - const shieldY = this.playerY - this.playerH - shieldH - 20; - // Classic shield shape mask (inverted U) - const shieldMask = this.generateShieldMask(); - for (let si = 0; si < SHIELD_COUNT; si++) { - const shieldX = gap + si * (shieldW + gap); - const blocks = []; - for (let r = 0; r < SHIELD_BLOCK_ROWS; r++) { - for (let c = 0; c < SHIELD_BLOCK_COLS; c++) { - if (!shieldMask[r][c]) - continue; - const bx = shieldX + c * blockW; - const by = shieldY + r * blockH; - const gfx = this.add.graphics().setDepth(6); - gfx.fillStyle(0x00ff66, 1); - gfx.fillRect(0, 0, blockW, blockH); - gfx.setPosition(bx, by); - blocks.push({ gfx, x: bx, y: by, w: blockW, h: blockH, alive: true }); - } - } - this.shields.push(blocks); - } - } - generateShieldMask() { - const mask = []; - for (let r = 0; r < SHIELD_BLOCK_ROWS; r++) { - mask[r] = []; - for (let c = 0; c < SHIELD_BLOCK_COLS; c++) { - // Round top - if (r < 4) { - const center = SHIELD_BLOCK_COLS / 2; - const dist = Math.abs(c - center + 0.5); - const maxDist = (SHIELD_BLOCK_COLS / 2) * (1 - r * 0.05); - mask[r][c] = dist < maxDist; - } - // Middle — solid - else if (r < SHIELD_BLOCK_ROWS - 5) { - mask[r][c] = true; - } - // Bottom — cut out arch - else { - const center = SHIELD_BLOCK_COLS / 2; - const dist = Math.abs(c - center + 0.5); - const archRow = r - (SHIELD_BLOCK_ROWS - 5); - const archWidth = 3 + archRow * 0.8; - mask[r][c] = dist > archWidth; - } - } - } - return mask; - } - /* ================================================================ - COLLISIONS - ================================================================ */ - checkCollisions() { - // Player bullets vs aliens - for (let bi = this.playerBullets.length - 1; bi >= 0; bi--) { - const b = this.playerBullets[bi]; - let hit = false; - for (const a of this.aliens) { - if (!a.alive) - continue; - if (this.rectOverlap(b.x - this.bulletW / 2, b.y - this.bulletH / 2, this.bulletW, this.bulletH, a.x - this.alienW / 2, a.y - this.alienH / 2, this.alienW, this.alienH)) { - a.alive = false; - a.gfx.setVisible(false); - this.addScore(ALIEN_TYPES[a.type].points, a.x, a.y); - this.spawnExplosion(a.x, a.y, ALIEN_TYPES[a.type].color); - this.sound.play('ao_explosion', { volume: 0.25 }); - this.updateMarchInterval(); - hit = true; - break; - } - } - // Player bullets vs mystery - if (!hit && this.mystery && this.mystery.active) { - const mw = 30 * Math.max(SCALE, 0.5); - const mh = 12 * Math.max(SCALE, 0.5); - if (this.rectOverlap(b.x - this.bulletW / 2, b.y - this.bulletH / 2, this.bulletW, this.bulletH, this.mystery.x - mw, this.mystery.y - mh / 2, mw * 2, mh)) { - const mysteryPoints = [50, 100, 150, 300][Math.floor(Math.random() * 4)]; - this.addScore(mysteryPoints, this.mystery.x, this.mystery.y); - this.spawnExplosion(this.mystery.x, this.mystery.y, 0xff00ff); - this.sound.play('ao_explosion', { volume: 0.3 }); - this.mystery.gfx.destroy(); - this.mystery = null; - hit = true; - } - } - // Player bullets vs shields - if (!hit) { - for (const shield of this.shields) { - for (const block of shield) { - if (!block.alive) - continue; - if (this.rectOverlap(b.x - this.bulletW / 2, b.y - this.bulletH / 2, this.bulletW, this.bulletH, block.x, block.y, block.w, block.h)) { - block.alive = false; - block.gfx.destroy(); - hit = true; - break; - } - } - if (hit) - break; - } - } - if (hit) { - b.gfx.destroy(); - this.playerBullets.splice(bi, 1); - } - } - // Alien bullets vs player - if (this.playerAlive && this.invincibleTimer <= 0) { - for (let bi = this.alienBullets.length - 1; bi >= 0; bi--) { - const b = this.alienBullets[bi]; - if (this.rectOverlap(b.x - this.bulletW / 2, b.y - this.bulletH / 2, this.bulletW, this.bulletH, this.playerX - this.playerW / 2, this.playerY - this.playerH / 2, this.playerW, this.playerH)) { - b.gfx.destroy(); - this.alienBullets.splice(bi, 1); - this.playerHit(); - break; - } - } - } - // Alien bullets vs shields - for (let bi = this.alienBullets.length - 1; bi >= 0; bi--) { - const b = this.alienBullets[bi]; - let hit = false; - for (const shield of this.shields) { - for (const block of shield) { - if (!block.alive) - continue; - if (this.rectOverlap(b.x - this.bulletW / 2, b.y - this.bulletH / 2, this.bulletW, this.bulletH, block.x, block.y, block.w, block.h)) { - block.alive = false; - block.gfx.destroy(); - hit = true; - break; - } - } - if (hit) - break; - } - if (hit) { - b.gfx.destroy(); - this.alienBullets.splice(bi, 1); - } - } - // Aliens vs shields (aliens marching into shields) - for (const a of this.aliens) { - if (!a.alive) - continue; - for (const shield of this.shields) { - for (const block of shield) { - if (!block.alive) - continue; - if (this.rectOverlap(a.x - this.alienW / 2, a.y - this.alienH / 2, this.alienW, this.alienH, block.x, block.y, block.w, block.h)) { - block.alive = false; - block.gfx.destroy(); - } - } - } - } - } - rectOverlap(x1, y1, w1, h1, x2, y2, w2, h2) { - return x1 < x2 + w2 && x1 + w1 > x2 && y1 < y2 + h2 && y1 + h1 > y2; - } - playerHit() { - if (this.gameOverFlag) - return; - this.lives--; - this.syncLivesToHUD(); - this.sound.play('ao_lose', { volume: 0.4 }); - this.spawnExplosion(this.playerX, this.playerY, 0x00ff66); - if (this.lives <= 0) { - this.triggerGameOver(); - } - else { - this.playerAlive = false; - this.playerGfx.setVisible(false); - this.respawnTimer = 1200; - } - } - triggerGameOver() { - this.gameOverFlag = true; - this.playerAlive = false; - this.playerGfx.setVisible(false); - // Clear all bullets - for (const b of this.playerBullets) - b.gfx.destroy(); - this.playerBullets = []; - for (const b of this.alienBullets) - b.gfx.destroy(); - this.alienBullets = []; - this.showGameOver(this.score, () => { - this.gameOverFlag = false; - this.scene.restart(); - }); - } - /* ================================================================ - EFFECTS - ================================================================ */ - spawnExplosion(x, y, color) { - this.spawnParticleExplosion(x, y, color, 10); - } - /* ================================================================ - SHUTDOWN - ================================================================ */ - shutdown() { - super.shutdown(); - // Clean up transient DOM - const banner = document.getElementById('wave-banner'); - if (banner) - banner.remove(); - // Clean up graphics - for (const a of this.aliens) - a.gfx?.destroy(); - for (const b of this.playerBullets) - b.gfx?.destroy(); - for (const b of this.alienBullets) - b.gfx?.destroy(); - if (this.mystery) - this.mystery.gfx?.destroy(); - for (const shield of this.shields) { - for (const block of shield) - block.gfx?.destroy(); - } - } -} -//# sourceMappingURL=AlienOnslaught.js.map \ No newline at end of file diff --git a/extensions/arcade-canvas/game/scenes/BaseScene.js b/extensions/arcade-canvas/game/scenes/BaseScene.js deleted file mode 100644 index b5b89731..00000000 --- a/extensions/arcade-canvas/game/scenes/BaseScene.js +++ /dev/null @@ -1,785 +0,0 @@ -// BaseScene — shared contract for all Agent Arcade mini-games. -// Provides score bridge to the HTML HUD, pause/resume hooks, and -// a consistent lifecycle so the game bootstrap can swap scenes. -export let W = window.innerWidth; -export let H = window.innerHeight; -/** Call before creating the Phaser game to ensure dimensions are current. */ -export function refreshDimensions() { - W = window.innerWidth; - H = window.innerHeight; -} -export class BaseScene extends Phaser.Scene { - score = 0; - highScore = 0; - lives = 3; - level = 0; - scoreAnimTimer; - gameOverKeyListener; - /** Full-screen dark backdrop controlled by the transparency slider. */ - _backdrop = null; - /** Ready-screen state */ - _readyOverlay = null; - _readyKeyListener; - _readyOnStart; - _wasOnReadyScreen = false; - /** Timer for game-over delayed callback (cancel on shutdown to prevent leaks). */ - _gameOverDelayTimer = null; - /** Tracked particle emitters for cleanup on shutdown. */ - activeEmitters = []; - constructor(key) { - super(key); - } - /** Safe localStorage helpers */ - storageGet(key) { - try { - return localStorage.getItem(key); - } - catch { - return null; - } - } - storageSet(key, value) { - try { - localStorage.setItem(key, value); - } - catch { /* quota exceeded or disabled */ } - } - storageRemove(key) { - try { - localStorage.removeItem(key); - } - catch { /* ignore */ } - } - /** Safely destroy a Phaser game object and return null for assignment. */ - destroyObj(obj) { - if (obj) { - try { - obj.destroy(); - } - catch { } - } - return null; - } - /** Spawn a particle explosion, track the emitter, and auto-cleanup. */ - spawnParticleExplosion(x, y, color, count, lifespan = 400) { - try { - const emitter = this.add.particles(x, y, 'spark', { - speed: { min: 60, max: 180 }, - angle: { min: 0, max: 360 }, - scale: { start: 1.2, end: 0 }, - lifespan, - quantity: count, - tint: color, - emitting: false, - }); - emitter.setDepth(20); - emitter.explode(count); - this.activeEmitters.push(emitter); - this.time.delayedCall(lifespan + 100, () => { - const idx = this.activeEmitters.indexOf(emitter); - if (idx >= 0) - this.activeEmitters.splice(idx, 1); - emitter.destroy(); - }); - } - catch { - // Particle system unavailable, skip - } - } - /** Load high score for this scene from localStorage. */ - loadHighScore() { - // Clean up old agentBreak keys (from before rename) - this.storageRemove(`agentBreak_board_${this.scene.key}`); - this.storageRemove(`agentBreak_hi_${this.scene.key}`); - const stored = this.storageGet(`agentArcade_hi_${this.scene.key}`); - this.highScore = stored ? parseInt(stored, 10) || 0 : 0; - this.gameOverShown = false; - this.syncHighScoreToHUD(); - } - /** - * Common create() setup. Call at the start of every scene's create(). - * Registers pause bridge, shutdown listener, and resets shared state. - */ - initBase() { - this.setupPauseBridge(); - this.events.once('shutdown', () => this.shutdown()); - this.createBackdrop(); - } - /** Create a full-screen dark backdrop whose alpha is controlled by the settings slider. */ - createBackdrop() { - const g = this.add.graphics().setDepth(-100); - g.fillStyle(0x000000, 1); - g.fillRect(0, 0, W, H); - g.setScrollFactor(0); - // Read saved transparency (1–100 → alpha 0.01–1.0) - let alpha = 1; - try { - const saved = localStorage.getItem('agentArcade_bgTransparency'); - if (saved !== null) - alpha = Math.max(0.01, Math.min(1, parseInt(saved, 10) / 100)); - } - catch { /* ignore */ } - g.setAlpha(alpha); - this._backdrop = g; - } - /** Called by the HUD slider to update the backdrop opacity in real time. */ - setBackdropAlpha(percent) { - if (this._backdrop) { - this._backdrop.setAlpha(Math.max(0.01, Math.min(1, percent / 100))); - } - } - /** Save high score if current score exceeds it. */ - checkHighScore() { - if (this.score > this.highScore) { - this.highScore = this.score; - this.storageSet(`agentArcade_hi_${this.scene.key}`, String(this.highScore)); - this.syncHighScoreToHUD(); - } - } - /** Push current score into the HTML HUD element. */ - syncScoreToHUD() { - const el = document.getElementById('score-value'); - if (el) - el.textContent = String(this.score); - } - /** Push high score into the HTML HUD element. */ - syncHighScoreToHUD() { - const el = document.getElementById('hi-value'); - if (el) - el.textContent = String(this.highScore); - } - /** Push lives count into the HTML HUD element. */ - syncLivesToHUD() { - const el = document.getElementById('lives-value'); - if (el) - el.textContent = String(this.lives); - } - /** Push level/wave number into the HTML HUD element. */ - syncLevelToHUD(value) { - const el = document.getElementById('level-value'); - if (el) - el.textContent = String(value ?? this.level); - } - /** Animated score bump (count-up + pop class). */ - addScore(points, worldX, worldY) { - const prev = this.score; - this.score += points; - // Floating "+N" text at world position - if (worldX !== undefined && worldY !== undefined) { - const txt = this.add.text(worldX, worldY, `+${points}`, { - fontFamily: '"Press Start 2P", monospace', - fontSize: '14px', - color: '#ffff00', - stroke: '#000', - strokeThickness: 3, - }); - txt.setOrigin(0.5, 0.5).setDepth(900); - this.tweens.add({ - targets: txt, - y: worldY - 50, - alpha: 0, - duration: 800, - onComplete: () => txt.destroy(), - }); - } - // Count-up animation in HUD - const el = document.getElementById('score-value'); - if (!el) - return; - if (this.scoreAnimTimer) - clearInterval(this.scoreAnimTimer); - const start = prev; - const end = this.score; - const duration = 450; - const startTime = performance.now(); - this.scoreAnimTimer = window.setInterval(() => { - const t = Math.min(1, (performance.now() - startTime) / duration); - const ease = 1 - Math.pow(1 - t, 3); - el.textContent = String(Math.round(start + (end - start) * ease)); - if (t >= 1) { - clearInterval(this.scoreAnimTimer); - this.scoreAnimTimer = undefined; - el.classList.remove('pop'); - void el.offsetWidth; - el.classList.add('pop'); - } - }, 16); - this.checkHighScore(); - } - /** Get top 10 scores for this game from localStorage. */ - getLeaderboard() { - const stored = this.storageGet(`agentArcade_board_${this.scene.key}`); - if (!stored) - return []; - try { - const parsed = JSON.parse(stored); - if (!Array.isArray(parsed)) - return []; - return parsed.filter((n) => typeof n === 'number'); - } - catch { - return []; - } - } - /** Add a score to the leaderboard, keep top 10, return rank (1-based, 0 = not in top 10). */ - addToLeaderboard(score) { - if (score <= 0) - return 0; - const board = this.getLeaderboard(); - board.push(score); - board.sort((a, b) => b - a); - const trimmed = board.slice(0, 10); - this.storageSet(`agentArcade_board_${this.scene.key}`, JSON.stringify(trimmed)); - this.checkHighScore(); - const rank = trimmed.indexOf(score) + 1; - return rank <= 10 ? rank : 0; - } - gameOverShown = false; - /** Show game over overlay with leaderboard. Call restartFn when dismissed. */ - showGameOver(finalScore, restartFn) { - if (this.gameOverShown) - return; - this.gameOverShown = true; - const rank = this.addToLeaderboard(finalScore); - let board = this.getLeaderboard(); - // Reconcile: if stored high score isn't on the board, add it - if (this.highScore > 0 && (board.length === 0 || this.highScore > board[0])) { - board.push(this.highScore); - board.sort((a, b) => b - a); - board = board.slice(0, 10); - this.storageSet(`agentArcade_board_${this.scene.key}`, JSON.stringify(board)); - } - const overlay = document.createElement('div'); - overlay.id = 'gameover-overlay'; - overlay.style.cssText = ` - position: fixed; inset: 0; z-index: 9999; - display: flex; align-items: center; justify-content: center; - background: rgba(0,0,0,0.75); pointer-events: auto; - animation: fadeIn 0.4s ease-out; - `; - const modal = document.createElement('div'); - modal.style.cssText = ` - background: linear-gradient(145deg, #0d1b2a 0%, #1b2838 50%, #0d1b2a 100%); - border: 2px solid rgba(255,215,0,0.4); - border-radius: 20px; padding: 36px 48px; - text-align: center; min-width: 460px; max-width: 540px; - box-shadow: 0 0 60px rgba(255,215,0,0.15), 0 0 100px rgba(0,0,0,0.8), inset 0 1px 0 rgba(255,255,255,0.05); - font-family: 'Press Start 2P', 'SF Mono', monospace; - animation: scaleIn 0.3s ease-out; - `; - // Title - const title = document.createElement('h2'); - title.textContent = 'GAME OVER'; - title.style.cssText = ` - color: #ff4444; font-size: 28px; margin: 0 0 20px; - text-shadow: 0 0 20px rgba(255,68,68,0.6), 0 0 40px rgba(255,0,0,0.3); - letter-spacing: 4px; - `; - modal.appendChild(title); - // Divider - const div1 = document.createElement('div'); - div1.style.cssText = 'height: 1px; background: linear-gradient(90deg, transparent, rgba(255,215,0,0.3), transparent); margin: 0 0 20px;'; - modal.appendChild(div1); - // Score - const scoreLine = document.createElement('p'); - scoreLine.innerHTML = `YOUR SCORE
${finalScore.toLocaleString()}`; - scoreLine.style.cssText = 'color: #8899aa; font-size: 10px; margin: 0 0 12px; letter-spacing: 2px; line-height: 2.2;'; - modal.appendChild(scoreLine); - // Rank badge - if (rank === 1) { - const badge = document.createElement('div'); - badge.innerHTML = '🏆 NEW HIGH SCORE!'; - badge.style.cssText = ` - color: #ffd700; font-size: 13px; margin: 8px 0 16px; - padding: 8px 16px; border-radius: 8px; - background: rgba(255,215,0,0.1); border: 1px solid rgba(255,215,0,0.3); - display: inline-block; - text-shadow: 0 0 8px rgba(255,215,0,0.4); - `; - modal.appendChild(badge); - } - else if (rank > 0) { - const badge = document.createElement('div'); - badge.textContent = `#${rank} ON LEADERBOARD`; - badge.style.cssText = ` - color: #4fc3f7; font-size: 11px; margin: 8px 0 16px; - padding: 6px 14px; border-radius: 8px; - background: rgba(79,195,247,0.1); border: 1px solid rgba(79,195,247,0.2); - display: inline-block; - `; - modal.appendChild(badge); - } - else { - const spacer = document.createElement('div'); - spacer.style.cssText = 'height: 12px;'; - modal.appendChild(spacer); - } - // Divider - const div2 = document.createElement('div'); - div2.style.cssText = 'height: 1px; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.1), transparent); margin: 12px 0 16px;'; - modal.appendChild(div2); - // Leaderboard header - const boardTitle = document.createElement('p'); - boardTitle.textContent = '─── TOP 10 ───'; - boardTitle.style.cssText = 'color: #667; font-size: 9px; margin: 0 0 10px; letter-spacing: 3px;'; - modal.appendChild(boardTitle); - // Score list - const table = document.createElement('div'); - table.style.cssText = 'margin: 0 auto; display: inline-block; width: 100%;'; - board.forEach((s, i) => { - const isMe = (i === rank - 1); - const row = document.createElement('div'); - row.style.cssText = ` - display: flex; justify-content: space-between; align-items: center; - font-size: 16px; padding: 8px 16px; margin: 3px 0; - border-radius: 8px; - background: ${isMe ? 'rgba(255,235,59,0.12)' : (i % 2 === 0 ? 'rgba(255,255,255,0.03)' : 'transparent')}; - ${isMe ? 'border: 1px solid rgba(255,235,59,0.25);' : ''} - `; - const rankEl = document.createElement('span'); - const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${i + 1}.`; - rankEl.textContent = medal; - rankEl.style.cssText = ` - color: ${isMe ? '#ffeb3b' : '#778'}; - min-width: 42px; text-align: left; - font-size: ${i < 3 ? '20px' : '16px'}; - `; - const scoreEl = document.createElement('span'); - scoreEl.textContent = s.toLocaleString(); - scoreEl.style.cssText = ` - color: ${isMe ? '#ffeb3b' : '#bcc'}; - font-size: ${i < 3 ? '20px' : '16px'}; - font-weight: ${i < 3 ? '900' : '700'}; - ${isMe ? 'text-shadow: 0 0 10px rgba(255,235,59,0.5);' : ''} - `; - if (isMe) { - const youTag = document.createElement('span'); - youTag.textContent = '◄'; - youTag.style.cssText = 'color: #ffeb3b; font-size: 10px; margin-left: 6px;'; - scoreEl.appendChild(youTag); - } - row.appendChild(rankEl); - row.appendChild(scoreEl); - table.appendChild(row); - }); - // Fill empty slots - for (let i = board.length; i < 10; i++) { - const row = document.createElement('div'); - row.style.cssText = ` - display: flex; justify-content: space-between; - font-size: 16px; padding: 8px 16px; margin: 3px 0; - color: #334; - `; - row.innerHTML = `${i + 1}.---`; - table.appendChild(row); - } - modal.appendChild(table); - // Restart button — matches .help-close style from settings/help dialogs - const restartBtn = document.createElement('button'); - restartBtn.textContent = 'RESTART'; - restartBtn.style.cssText = ` - display: block; margin: 22px auto 0; width: 100%; padding: 9px; - background: linear-gradient(180deg, #ffd54a 0%, #c9a020 100%); - border: 1px solid rgba(255, 255, 255, 0.25); border-radius: 8px; - color: #1a1a1a; font-weight: 700; letter-spacing: 1px; font-size: 13px; - cursor: pointer; transition: filter 120ms; - `; - restartBtn.addEventListener('mouseenter', () => { restartBtn.style.filter = 'brightness(1.15)'; }); - restartBtn.addEventListener('mouseleave', () => { restartBtn.style.filter = ''; }); - modal.appendChild(restartBtn); - overlay.appendChild(modal); - document.body.appendChild(overlay); - // Disable click-through so the overlay is interactive - const ti = window.__TAURI_INTERNALS__; - if (ti) - ti.invoke('set_click_through', { enabled: false }); - const dismiss = () => { - this.gameOverShown = false; - document.removeEventListener('keydown', onKey); - overlay.remove(); - // Re-enable click-through - if (ti) - ti.invoke('set_click_through', { enabled: true }); - restartFn(); - }; - const onKey = (ev) => { - if (ev.code === 'Space' || ev.code === 'Enter') { - ev.preventDefault(); - dismiss(); - } - }; - this.gameOverKeyListener = onKey; - // Brief delay before accepting input (prevent accidental dismiss). - // Guard against the scene being stopped during the delay. - this._gameOverDelayTimer = this.time.delayedCall(500, () => { - if (!this.scene.isActive()) - return; - document.addEventListener('keydown', onKey); - restartBtn.addEventListener('click', dismiss); - }); - } - // ── Ready screen ─────────────────────────────────────────────────────────── - /** - * Freeze the scene and show the "Press any key to start" screen. - * Call as the LAST statement in every scene's create() so all game objects - * exist but nothing moves until the player is ready. - * @param onStart Optional callback invoked the moment the player presses a - * key and the scene resumes — use this to defer first-wave setup so it - * doesn't render on top of the ready screen. - */ - startWithReadyScreen(onStart) { - this._readyOnStart = onStart; - this.scene.pause(); - this.sound.stopAll(); // stop any sounds that fired during create() - this._showPressAnyKey(); - } - _showPressAnyKey() { - this._cleanupReadyScreen(); - if (!document.getElementById('ready-screen-style')) { - const style = document.createElement('style'); - style.id = 'ready-screen-style'; - style.textContent = ` - @keyframes readyBlink { 0%,100%{opacity:1} 50%{opacity:.3} } - @keyframes readyGlow { 0%,100%{text-shadow:0 0 10px rgba(0,200,255,0.6),0 0 30px rgba(0,200,255,0.3)} 50%{text-shadow:0 0 20px rgba(0,200,255,0.9),0 0 50px rgba(0,200,255,0.5),0 0 80px rgba(0,100,255,0.2)} } - @keyframes titleShimmer { 0%{background-position:200% center} 100%{background-position:-200% center} } - @keyframes titleFloat { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-6px)} } - @keyframes neonPulse { 0%,100%{filter:drop-shadow(0 0 15px rgba(0,255,136,0.8)) drop-shadow(0 0 40px rgba(0,255,136,0.4)) drop-shadow(0 0 80px rgba(0,255,136,0.2))} 50%{filter:drop-shadow(0 0 25px rgba(0,255,136,1)) drop-shadow(0 0 60px rgba(0,255,136,0.6)) drop-shadow(0 0 120px rgba(0,255,136,0.3))} } - @keyframes dividerPulse { 0%,100%{opacity:0.6;width:280px} 50%{opacity:1;width:360px} } - @keyframes fadeSlideUp { from{opacity:0;transform:translateY(20px)} to{opacity:1;transform:translateY(0)} } - @keyframes starTwinkle { 0%,100%{opacity:0.2} 50%{opacity:1} } - `; - document.head.appendChild(style); - } - const overlay = document.createElement('div'); - overlay.id = 'ready-overlay'; - overlay.style.cssText = ` - position:fixed;inset:0;z-index:8000;pointer-events:none; - display:flex;flex-direction:column;align-items:center;justify-content:center; - background:radial-gradient(ellipse at 50% 40%,rgba(0,15,60,0.80) 0%,rgba(0,5,20,0.92) 60%,rgba(0,0,0,0.95) 100%); - `; - // Decorative star particles - for (let i = 0; i < 40; i++) { - const star = document.createElement('div'); - const size = Math.random() < 0.3 ? 3 : 2; - const x = Math.random() * 100; - const y = Math.random() * 100; - const delay = Math.random() * 3; - const dur = 1.5 + Math.random() * 2; - star.style.cssText = ` - position:absolute;left:${x}%;top:${y}%;width:${size}px;height:${size}px; - background:#fff;border-radius:50%; - animation:starTwinkle ${dur}s ease-in-out ${delay}s infinite; - opacity:0.3; - `; - overlay.appendChild(star); - } - // Main content wrapper — styled panel matching the game-over dialog - const content = document.createElement('div'); - content.style.cssText = ` - display:flex;flex-direction:column;align-items:center; - animation:fadeSlideUp 0.6s ease-out both; - position:relative;z-index:1; - background:linear-gradient(145deg,#0d1b2a 0%,#1b2838 50%,#0d1b2a 100%); - border:2px solid rgba(0,200,255,0.25); - border-radius:20px;padding:42px 56px; - box-shadow:0 0 60px rgba(0,200,255,0.1),0 0 100px rgba(0,0,0,0.8),inset 0 1px 0 rgba(255,255,255,0.05); - max-width:700px; - `; - const title = document.createElement('div'); - title.textContent = this.displayName.toUpperCase(); - title.style.cssText = ` - font-family:'Press Start 2P',monospace;font-size:48px;letter-spacing:6px; - -webkit-text-stroke:2px rgba(0,255,136,0.3); - background:linear-gradient(90deg,#00ff88,#ffffff,#00ff88,#ffffff,#00ff88); - background-size:200% auto; - -webkit-background-clip:text;-webkit-text-fill-color:transparent; - background-clip:text; - animation:titleShimmer 8s linear infinite,titleFloat 4s ease-in-out infinite,neonPulse 3s ease-in-out infinite; - margin-bottom:22px; - `; - const divider = document.createElement('div'); - divider.style.cssText = ` - width:320px;height:2px;margin-bottom:20px; - background:linear-gradient(90deg,transparent 0%,#00c8ff 20%,#ff6b35 50%,#00c8ff 80%,transparent 100%); - border-radius:1px;box-shadow:0 0 12px rgba(0,200,255,0.4); - animation:dividerPulse 3s ease-in-out infinite; - `; - const prompt = document.createElement('div'); - prompt.textContent = 'PRESS ANY KEY TO START'; - prompt.style.cssText = ` - font-family:'Press Start 2P',monospace;font-size:16px;letter-spacing:4px; - color:#fff; - animation:readyBlink 1.4s ease-in-out infinite,readyGlow 2s ease-in-out infinite; - text-shadow:0 0 15px rgba(0,200,255,0.8); - `; - content.appendChild(title); - const desc = this.getDescription(); - if (desc) { - const descEl = document.createElement('div'); - descEl.textContent = desc; - descEl.style.cssText = ` - font-family:'Press Start 2P',monospace;font-size:14px;letter-spacing:1px; - color:#d0e8ff;max-width:700px;text-align:center;line-height:2; - margin-bottom:18px; - text-shadow:0 0 10px rgba(150,210,255,0.4); - `; - content.appendChild(descEl); - } - content.appendChild(divider); - // Show control hints if the scene provides them - const controls = this.getControls(); - if (controls.length > 0) { - const controlsDiv = document.createElement('div'); - controlsDiv.style.cssText = ` - margin-top:24px;padding:18px 28px; - background:linear-gradient(135deg,rgba(0,20,60,0.6) 0%,rgba(0,10,40,0.7) 100%); - border:1px solid rgba(0,200,255,0.2); - border-radius:12px;display:inline-block; - box-shadow:0 4px 20px rgba(0,0,0,0.3),inset 0 1px 0 rgba(255,255,255,0.05); - backdrop-filter:blur(4px); - `; - const controlsTitle = document.createElement('div'); - controlsTitle.textContent = 'CONTROLS'; - controlsTitle.style.cssText = ` - font-family:'Press Start 2P',monospace;font-size:13px;letter-spacing:5px; - color:rgba(200,230,255,0.9);margin-bottom:16px;text-align:center; - text-shadow:0 0 8px rgba(150,200,255,0.4); - `; - controlsDiv.appendChild(controlsTitle); - for (const { key, action } of controls) { - const row = document.createElement('div'); - row.style.cssText = ` - display:flex;justify-content:space-between;align-items:center; - margin:8px 0;gap:28px; - `; - const keyEl = document.createElement('span'); - keyEl.textContent = key; - keyEl.style.cssText = ` - font-family:'Press Start 2P',monospace;font-size:15px; - color:#ffd54a;background:rgba(255,213,74,0.08); - padding:7px 16px;border-radius:6px;border:1px solid rgba(255,213,74,0.25); - min-width:90px;text-align:center; - box-shadow:0 2px 6px rgba(0,0,0,0.2),inset 0 1px 0 rgba(255,255,255,0.05); - text-shadow:0 0 6px rgba(255,213,74,0.3); - `; - const actionEl = document.createElement('span'); - actionEl.textContent = action; - actionEl.style.cssText = ` - font-family:'Press Start 2P',monospace;font-size:14px; - color:#d0dde8;text-align:left; - `; - row.appendChild(keyEl); - row.appendChild(actionEl); - controlsDiv.appendChild(row); - } - content.appendChild(controlsDiv); - } - prompt.style.cssText += 'margin-top:32px;'; - content.appendChild(prompt); - overlay.appendChild(content); - document.body.appendChild(overlay); - this._readyOverlay = overlay; - const onKey = (e) => { - if (['Meta', 'Alt', 'Control', 'Shift'].includes(e.key)) - return; - document.removeEventListener('keydown', onKey); - this._readyKeyListener = undefined; - this._cleanupReadyScreen(); - if (e.key === 'Escape') { - // Let the normal pause system take over; re-show ready screen on resume - this._wasOnReadyScreen = true; - return; - } - e.preventDefault(); - this.scene.resume(); - this._fireReadyOnStart(); - }; - this._readyKeyListener = onKey; - document.addEventListener('keydown', onKey); - } - _cleanupReadyScreen() { - if (this._readyOverlay) { - this._readyOverlay.remove(); - this._readyOverlay = null; - } - if (this._readyKeyListener) { - document.removeEventListener('keydown', this._readyKeyListener); - this._readyKeyListener = undefined; - } - } - _fireReadyOnStart() { - if (this._readyOnStart) { - const fn = this._readyOnStart; - this._readyOnStart = undefined; - fn(); - } - } - /** Called by the pause system. Override if the scene needs custom cleanup. */ - pauseGame() { - this.scene.pause(); - this.sound.pauseAll(); - } - /** Called by the resume system. Override if needed. */ - resumeGame() { - if (this._wasOnReadyScreen) { - // Re-show the ready screen instead of resuming gameplay - this._wasOnReadyScreen = false; - this._showPressAnyKey(); - return; - } - this.scene.resume(); - this.sound.resumeAll(); - this._fireReadyOnStart(); - } - /** - * Wire up the pause/resume bridge between the HUD and the Phaser scene. - * Call from create() — replaces the per-scene boilerplate that was duplicated - * in every scene previously. - */ - setupPauseBridge() { - // __agentArcadePauseScene: pauses/resumes the Phaser scene ONLY (no Rust call). - // Used by Rust-originated pause/resume to avoid feedback loops. - window.__agentArcadePauseScene = (shouldPause) => { - if (shouldPause) - this.pauseGame(); - else - this.resumeGame(); - }; - // __agentArcadePause: called from in-page UI (HUD buttons, game-switcher). - // Pauses scene AND notifies Rust to shrink/expand window. - window.__agentArcadePause = (shouldPause) => { - const ab = window.agentArcade; - if (shouldPause) - this.pauseGame(); - else - this.resumeGame(); - if (ab && ab.setClickThrough) - ab.setClickThrough(shouldPause); - if (ab && ab.setPaused) - ab.setPaused(shouldPause); - }; - const ab = window.agentArcade; - if (ab && ab.onResumeRequest) { - ab.onResumeRequest(() => { - const hud = document.getElementById('hud'); - if (hud) - hud.classList.remove('paused'); - this.resumeGame(); - }); - } - } - /** - * Show a "WAVE N" banner overlay — shared by space game scenes. - * Auto-animates in/out and removes itself after ~2.2 seconds. - */ - showWaveBanner(waveNum) { - const existing = document.getElementById('wave-banner'); - if (existing) - existing.remove(); - const banner = document.createElement('div'); - banner.id = 'wave-banner'; - banner.style.cssText = ` - position: fixed; top: 45%; left: 50%; transform: translate(-50%, -50%); - padding: 12px 36px; - background: linear-gradient(180deg, #1a1f3a 0%, #0a0e22 100%); - border: 2px solid #ffd54a; - border-radius: 12px; - box-shadow: - 0 0 0 1px rgba(255, 255, 255, 0.08) inset, - 0 6px 24px rgba(0, 0, 0, 0.7), - 0 0 22px rgba(255, 213, 74, 0.45); - font-family: -apple-system, system-ui, 'Helvetica Neue', sans-serif; - font-size: 22px; font-weight: 700; letter-spacing: 2px; - color: #ffd54a; - text-shadow: 0 0 8px rgba(255, 213, 74, 0.6); - z-index: 50; pointer-events: none; user-select: none; - animation: waveBannerIn 0.3s ease-out; - `; - banner.textContent = `WAVE ${waveNum}`; - document.body.appendChild(banner); - if (!document.getElementById('wave-banner-style')) { - const style = document.createElement('style'); - style.id = 'wave-banner-style'; - style.textContent = ` - @keyframes waveBannerIn { from { opacity: 0; transform: translate(-50%, -50%) scale(0.85); } to { opacity: 1; transform: translate(-50%, -50%) scale(1); } } - @keyframes waveBannerOut { from { opacity: 1; } to { opacity: 0; } } - `; - document.head.appendChild(style); - } - setTimeout(() => { - banner.style.animation = 'waveBannerOut 0.6s ease-in forwards'; - setTimeout(() => banner.remove(), 700); - }, 1500); - } - /** Create the shared 'spark' texture used for particle effects. */ - ensureSparkTexture() { - if (this.textures.exists('spark')) - return; - const g = this.add.graphics(); - g.fillStyle(0xffffff); - g.fillCircle(4, 4, 4); - g.generateTexture('spark', 8, 8); - g.destroy(); - } - /** - * Create a parallax starfield. Returns the Star array for use with updateStarfield(). - * Each scene provides its own layer config (count, speed, size, alpha per layer). - */ - createStarfield(layers) { - const stars = []; - for (const l of layers) { - for (let i = 0; i < l.count; i++) { - const gfx = this.add.graphics(); - const x = Math.random() * W; - const y = Math.random() * H; - gfx.fillStyle(0xffffff, l.alpha); - gfx.fillCircle(0, 0, l.size); - gfx.setPosition(x, y).setDepth(-9); - stars.push({ x, y, speed: l.speed, size: l.size, alpha: l.alpha, gfx }); - } - } - return stars; - } - /** Update parallax starfield positions (call from update). */ - updateStarfield(stars, dt) { - for (const s of stars) { - s.y += s.speed * (dt / 1000); - if (s.y > H) - s.y -= H; - s.gfx.setPosition(s.x, s.y); - } - } - /** Clean up timers and listeners on scene shutdown. */ - shutdown() { - if (this.scoreAnimTimer) { - clearInterval(this.scoreAnimTimer); - this.scoreAnimTimer = undefined; - } - if (this.gameOverKeyListener) { - document.removeEventListener('keydown', this.gameOverKeyListener); - this.gameOverKeyListener = undefined; - } - if (this._gameOverDelayTimer) { - this._gameOverDelayTimer.remove(); - this._gameOverDelayTimer = null; - } - this._cleanupReadyScreen(); - this._readyOnStart = undefined; - this._wasOnReadyScreen = false; - this.time.removeAllEvents(); - this.activeEmitters.forEach(e => this.destroyObj(e)); - this.activeEmitters = []; - const overlay = document.getElementById('gameover-overlay'); - if (overlay) - overlay.remove(); - } - /** Return a one-line description for the ready screen. Override in each scene. */ - getDescription() { - return ''; - } - /** Return control hints for the ready screen. Override in each scene. */ - getControls() { - return []; - } -} -//# sourceMappingURL=BaseScene.js.map \ No newline at end of file diff --git a/extensions/arcade-canvas/game/scenes/CosmicRocks.js b/extensions/arcade-canvas/game/scenes/CosmicRocks.js deleted file mode 100644 index 0518d7ca..00000000 --- a/extensions/arcade-canvas/game/scenes/CosmicRocks.js +++ /dev/null @@ -1,697 +0,0 @@ -// CosmicRocks — Asteroids-style space shooter. -// Ship rotates and thrusts through space, destroying asteroids that split -// into smaller fragments. Vector-style graphics drawn with Phaser Graphics. -import { BaseScene, W, H } from './BaseScene.js'; -/* ------------------------------------------------------------------ */ -/* Constants — SCALE/SHIP_SIZE recalculated in create() */ -/* ------------------------------------------------------------------ */ -let SCALE = Math.min(W / 1920, H / 1080); -let SHIP_SIZE = 20 * Math.max(SCALE, 0.6); -const ROTATE_SPEED = 4; // rad/s -const THRUST = 400; // px/s² -const FRICTION = 0.98; -const BULLET_SPEED = 600; -const BULLET_LIFE = 3000; // ms -const MAX_BULLETS = 4; -const INITIAL_ASTEROIDS = 5; -const INVINCIBLE_TIME = 2000; // ms -const RESPAWN_DELAY = 800; // ms before respawn -const ASTEROID_SIZES = [ - { radius: [40, 60], speed: [40, 80], score: 20 }, // large (size index 0) - { radius: [25, 40], speed: [60, 120], score: 50 }, // medium (size index 1) - { radius: [12, 20], speed: [80, 160], score: 100 }, // small (size index 2) -]; -const BULLET_COLORS = [0x00ff88, 0xff8800, 0x00ccff]; -/* ------------------------------------------------------------------ */ -/* Scene */ -/* ------------------------------------------------------------------ */ -export class CosmicRocksScene extends BaseScene { - /* ship state */ - shipGfx; - shipX = 0; - shipY = 0; - shipVx = 0; - shipVy = 0; - shipAngle = -Math.PI / 2; // pointing up - thrustGfx; - /* game objects */ - asteroids = []; - bullets = []; - stars = []; - /* UFO */ - ufo = null; - ufoBullets = []; - ufoTimer = 0; - /* game state */ - wave = 0; - invincibleTimer = 0; - respawnTimer = 0; - shipAlive = true; - gameOver = false; - waveDelay = 0; - /* input */ - cursors; - spaceKey; - spaceWasDown = false; - constructor() { super('cosmic-rocks'); } - get displayName() { return 'Cosmic Rocks'; } - getDescription() { - return 'Survive the asteroid field. Shoot rocks to break them apart!'; - } - getControls() { - return [ - { key: '← →', action: 'Rotate' }, - { key: '↑', action: 'Thrust' }, - { key: 'SPACE', action: 'Fire' }, - ]; - } - /* ================================================================ - LIFECYCLE - ================================================================ */ - preload() { - this.load.audio('sfx_laser', '../assets/cosmic-rocks/sounds/sfx_laser1.ogg'); - this.load.audio('sfx_zap', '../assets/cosmic-rocks/sounds/sfx_explosion.ogg'); - this.load.audio('sfx_lose', '../assets/cosmic-rocks/sounds/sfx_lose.ogg'); - this.load.audio('sfx_twoTone', '../assets/cosmic-rocks/sounds/sfx_twoTone.ogg'); - } - create() { - this.initBase(); - // Recalculate screen-dependent constants - SCALE = Math.min(W / 1920, H / 1080); - SHIP_SIZE = 20 * Math.max(SCALE, 0.6); - this.score = 0; - this.lives = 3; - this.wave = 0; - this.shipX = W / 2; - this.shipY = H / 2; - this.shipVx = 0; - this.shipVy = 0; - this.shipAngle = -Math.PI / 2; - this.invincibleTimer = 0; - this.respawnTimer = 0; - this.shipAlive = true; - this.gameOver = false; - this.waveDelay = 0; - this.asteroids = []; - this.bullets = []; - this.stars = []; - this.activeEmitters = []; - this.ufo = null; - this.ufoBullets = []; - this.ufoTimer = 15000 + Math.random() * 10000; - this.ensureSparkTexture(); - this.stars = this.createStarfield([ - { count: 40, speed: 15, size: 1, alpha: 0.25 }, - { count: 25, speed: 30, size: 1.5, alpha: 0.35 }, - { count: 15, speed: 55, size: 2, alpha: 0.45 }, - ]); - this.createShip(); - this.cursors = this.input.keyboard.createCursorKeys(); - this.spaceKey = this.input.keyboard.addKey('SPACE'); - this.spaceWasDown = false; - this.syncLivesToHUD(); - this.syncScoreToHUD(); - this.loadHighScore(); - this.startWithReadyScreen(() => this.startWave()); - } - update(_t, dtMs) { - if (this.gameOver || !this.cursors) - return; - const dt = Math.min(dtMs, 33); - const dtSec = dt / 1000; - this.updateStarfield(this.stars, dt); - if (this.respawnTimer > 0) { - this.respawnTimer -= dt; - if (this.respawnTimer <= 0) - this.respawnShip(); - } - if (this.shipAlive) { - this.updateShipInput(dtSec); - this.updateShipPhysics(dtSec); - this.drawShip(); - } - this.updateBullets(dtSec); - this.updateAsteroids(dtSec); - this.updateUfo(dt, dtSec); - this.checkCollisions(); - this.checkUfoCollisions(); - if (this.waveDelay > 0) { - this.waveDelay -= dt; - if (this.waveDelay <= 0 && this.asteroids.length === 0) - this.startWave(); - } - if (this.invincibleTimer > 0) { - this.invincibleTimer -= dt; - if (this.shipGfx) { - this.shipGfx.setAlpha(Math.sin(performance.now() / 80) > 0 ? 1 : 0.2); - } - } - else if (this.shipGfx) { - this.shipGfx.setAlpha(1); - } - } - /* ================================================================ - SHIP - ================================================================ */ - createShip() { - this.shipGfx = this.add.graphics().setDepth(10); - this.thrustGfx = this.add.graphics().setDepth(9); - this.drawShip(); - } - updateShipInput(dtSec) { - if (!this.cursors) - return; - if (this.cursors.left.isDown) - this.shipAngle -= ROTATE_SPEED * dtSec; - if (this.cursors.right.isDown) - this.shipAngle += ROTATE_SPEED * dtSec; - if (this.cursors.up.isDown) { - this.shipVx += Math.cos(this.shipAngle) * THRUST * dtSec; - this.shipVy += Math.sin(this.shipAngle) * THRUST * dtSec; - } - // Fire - const spaceDown = this.spaceKey.isDown; - if (spaceDown && !this.spaceWasDown && this.bullets.length < MAX_BULLETS) { - this.fireBullet(); - } - this.spaceWasDown = spaceDown; - } - updateShipPhysics(dtSec) { - // Friction (time-based) - const friction = Math.pow(FRICTION, dtSec / (1 / 60)); - this.shipVx *= friction; - this.shipVy *= friction; - this.shipX += this.shipVx * dtSec; - this.shipY += this.shipVy * dtSec; - // Screen wrap - if (this.shipX < -SHIP_SIZE) - this.shipX = W + SHIP_SIZE; - else if (this.shipX > W + SHIP_SIZE) - this.shipX = -SHIP_SIZE; - if (this.shipY < -SHIP_SIZE) - this.shipY = H + SHIP_SIZE; - else if (this.shipY > H + SHIP_SIZE) - this.shipY = -SHIP_SIZE; - } - drawShip() { - const g = this.shipGfx; - g.clear(); - g.setPosition(this.shipX, this.shipY); - const cos = Math.cos(this.shipAngle); - const sin = Math.sin(this.shipAngle); - const s = SHIP_SIZE; - // Triangle ship - const nose = { x: cos * s, y: sin * s }; - const leftWing = { x: Math.cos(this.shipAngle + 2.4) * s * 0.85, y: Math.sin(this.shipAngle + 2.4) * s * 0.85 }; - const rightWing = { x: Math.cos(this.shipAngle - 2.4) * s * 0.85, y: Math.sin(this.shipAngle - 2.4) * s * 0.85 }; - // Dark shadow backdrop for visibility on light backgrounds - g.lineStyle(6, 0x000000, 0.5); - g.beginPath(); - g.moveTo(nose.x, nose.y); - g.lineTo(leftWing.x, leftWing.y); - g.lineTo(rightWing.x, rightWing.y); - g.closePath(); - g.strokePath(); - // Outer glow (soft cyan) - g.lineStyle(4, 0x00ffff, 0.2); - g.beginPath(); - g.moveTo(nose.x, nose.y); - g.lineTo(leftWing.x, leftWing.y); - g.lineTo(rightWing.x, rightWing.y); - g.closePath(); - g.strokePath(); - // Solid ship outline (bright cyan) - g.lineStyle(2.5, 0x00ffff, 1); - g.beginPath(); - g.moveTo(nose.x, nose.y); - g.lineTo(leftWing.x, leftWing.y); - g.lineTo(rightWing.x, rightWing.y); - g.closePath(); - g.strokePath(); - // Thrust flame - const tg = this.thrustGfx; - tg.clear(); - if (this.cursors && this.cursors.up.isDown) { - tg.setPosition(this.shipX, this.shipY); - const tailLen = s * (0.6 + Math.random() * 0.4); - const tailX = -cos * tailLen; - const tailY = -sin * tailLen; - const spread = 0.4; - const tl = { x: Math.cos(this.shipAngle + Math.PI - spread) * s * 0.35, y: Math.sin(this.shipAngle + Math.PI - spread) * s * 0.35 }; - const tr = { x: Math.cos(this.shipAngle + Math.PI + spread) * s * 0.35, y: Math.sin(this.shipAngle + Math.PI + spread) * s * 0.35 }; - // Dark shadow for thrust - tg.lineStyle(5, 0x000000, 0.3); - tg.beginPath(); - tg.moveTo(tl.x, tl.y); - tg.lineTo(tailX, tailY); - tg.lineTo(tr.x, tr.y); - tg.strokePath(); - tg.lineStyle(3, 0xff8800, 0.25); - tg.beginPath(); - tg.moveTo(tl.x, tl.y); - tg.lineTo(tailX, tailY); - tg.lineTo(tr.x, tr.y); - tg.strokePath(); - tg.lineStyle(2.5, 0xff8800, 0.9); - tg.beginPath(); - tg.moveTo(tl.x, tl.y); - tg.lineTo(tailX, tailY); - tg.lineTo(tr.x, tr.y); - tg.strokePath(); - } - } - respawnShip() { - this.shipX = W / 2; - this.shipY = H / 2; - this.shipVx = 0; - this.shipVy = 0; - this.shipAngle = -Math.PI / 2; - this.shipAlive = true; - this.invincibleTimer = INVINCIBLE_TIME; - if (this.shipGfx) - this.shipGfx.setVisible(true); - if (this.thrustGfx) - this.thrustGfx.setVisible(true); - } - /* ================================================================ - BULLETS - ================================================================ */ - fireBullet() { - this.sound.play('sfx_laser', { volume: 0.3 }); - const color = BULLET_COLORS[Math.floor(Math.random() * BULLET_COLORS.length)]; - const gfx = this.add.graphics().setDepth(8); - // Dark backdrop - gfx.fillStyle(0x000000, 0.5); - gfx.fillCircle(0, 0, 10); - // Glow - gfx.fillStyle(color, 0.3); - gfx.fillCircle(0, 0, 8); - // Solid center - gfx.fillStyle(color, 1); - gfx.fillCircle(0, 0, 4); - const bx = this.shipX + Math.cos(this.shipAngle) * SHIP_SIZE; - const by = this.shipY + Math.sin(this.shipAngle) * SHIP_SIZE; - gfx.setPosition(bx, by); - this.bullets.push({ - gfx, - x: bx, y: by, - vx: Math.cos(this.shipAngle) * BULLET_SPEED, - vy: Math.sin(this.shipAngle) * BULLET_SPEED, - life: BULLET_LIFE, - color, - }); - } - updateBullets(dtSec) { - for (let i = this.bullets.length - 1; i >= 0; i--) { - const b = this.bullets[i]; - b.x += b.vx * dtSec; - b.y += b.vy * dtSec; - b.life -= dtSec * 1000; - b.gfx.setPosition(b.x, b.y); - // Destroy bullet when it leaves the screen or expires - if (b.life <= 0 || b.x < 0 || b.x > W || b.y < 0 || b.y > H) { - b.gfx.destroy(); - this.bullets.splice(i, 1); - } - } - } - /* ================================================================ - ASTEROIDS - ================================================================ */ - generateAsteroidVertices(radius) { - const verts = []; - const sides = 12; - for (let i = 0; i < sides; i++) { - const angle = (i / sides) * Math.PI * 2; - const r = radius * (0.7 + Math.random() * 0.3); - verts.push({ x: Math.cos(angle) * r, y: Math.sin(angle) * r }); - } - return verts; - } - spawnAsteroid(sizeIdx, x, y, aimAtShip = false) { - const info = ASTEROID_SIZES[sizeIdx]; - const radius = info.radius[0] + Math.random() * (info.radius[1] - info.radius[0]); - const scaledRadius = radius * Math.max(SCALE, 0.5); - // Position: at edges if not specified - let ax, ay; - if (x !== undefined && y !== undefined) { - ax = x; - ay = y; - } - else { - const edge = Math.floor(Math.random() * 4); - if (edge === 0) { - ax = Math.random() * W; - ay = -scaledRadius; - } - else if (edge === 1) { - ax = Math.random() * W; - ay = H + scaledRadius; - } - else if (edge === 2) { - ax = -scaledRadius; - ay = Math.random() * H; - } - else { - ax = W + scaledRadius; - ay = Math.random() * H; - } - // Make sure not too close to player - const dx = ax - this.shipX; - const dy = ay - this.shipY; - if (Math.sqrt(dx * dx + dy * dy) < 150) { - ax = (ax + W / 2) % W; - ay = (ay + H / 2) % H; - } - } - const speed = info.speed[0] + Math.random() * (info.speed[1] - info.speed[0]); - const speedBoost = Math.random() < 0.4 ? 1.5 : 1.0; // 40% chance of fast asteroid - // Aim toward the ship if requested, otherwise random direction - let angle; - let finalSpeed = speed * speedBoost; - if (aimAtShip) { - angle = Math.atan2(this.shipY - ay, this.shipX - ax); - // Add slight random spread (±15°) so it's not a perfect snipe - angle += (Math.random() - 0.5) * (Math.PI / 6); - // Ensure it arrives in ~3-4s regardless of base speed - const dist = Math.sqrt((this.shipX - ax) ** 2 + (this.shipY - ay) ** 2); - const minSpeed = dist / (3 + Math.random()); - finalSpeed = Math.max(finalSpeed, minSpeed); - } - else { - angle = Math.random() * Math.PI * 2; - } - const vertices = this.generateAsteroidVertices(scaledRadius); - const gfx = this.add.graphics().setDepth(5); - this.drawAsteroid(gfx, vertices); - this.asteroids.push({ - gfx, - x: ax, y: ay, - vx: Math.cos(angle) * finalSpeed, - vy: Math.sin(angle) * finalSpeed, - radius: scaledRadius, - sizeIdx, - rotation: 0, - rotSpeed: (Math.random() - 0.5) * 2, - vertices, - }); - } - drawAsteroid(gfx, vertices) { - gfx.clear(); - // Dark shadow backdrop for visibility on light backgrounds - gfx.lineStyle(5, 0x000000, 0.5); - gfx.beginPath(); - gfx.moveTo(vertices[0].x, vertices[0].y); - for (let i = 1; i < vertices.length; i++) { - gfx.lineTo(vertices[i].x, vertices[i].y); - } - gfx.closePath(); - gfx.strokePath(); - // Outer glow (soft green) - gfx.lineStyle(3, 0x44ff44, 0.25); - gfx.beginPath(); - gfx.moveTo(vertices[0].x, vertices[0].y); - for (let i = 1; i < vertices.length; i++) { - gfx.lineTo(vertices[i].x, vertices[i].y); - } - gfx.closePath(); - gfx.strokePath(); - // Solid outline (bright green-white) - gfx.lineStyle(2.5, 0x88ff88, 1); - gfx.beginPath(); - gfx.moveTo(vertices[0].x, vertices[0].y); - for (let i = 1; i < vertices.length; i++) { - gfx.lineTo(vertices[i].x, vertices[i].y); - } - gfx.closePath(); - gfx.strokePath(); - } - updateAsteroids(dtSec) { - for (const a of this.asteroids) { - a.x += a.vx * dtSec; - a.y += a.vy * dtSec; - a.rotation += a.rotSpeed * dtSec; - // Screen wrap - if (a.x < -a.radius) - a.x = W + a.radius; - else if (a.x > W + a.radius) - a.x = -a.radius; - if (a.y < -a.radius) - a.y = H + a.radius; - else if (a.y > H + a.radius) - a.y = -a.radius; - a.gfx.setPosition(a.x, a.y); - a.gfx.setRotation(a.rotation); - } - } - destroyAsteroid(idx) { - const a = this.asteroids[idx]; - const info = ASTEROID_SIZES[a.sizeIdx]; - this.addScore(info.score, a.x, a.y - 10); - this.spawnExplosion(a.x, a.y); - this.sound.play('sfx_zap', { volume: 0.3 }); - // Spawn children - if (a.sizeIdx < 2) { - const childSize = a.sizeIdx + 1; - for (let i = 0; i < 3; i++) { - this.spawnAsteroid(childSize, a.x, a.y); - } - } - a.gfx.destroy(); - this.asteroids.splice(idx, 1); - // Check if wave cleared - if (this.asteroids.length === 0 && this.waveDelay <= 0) { - this.waveDelay = 2000; - } - } - /* ================================================================ - COLLISIONS (manual rect/circle overlap — same pattern as Galaxy) - ================================================================ */ - checkCollisions() { - // Bullets vs asteroids - for (let bi = this.bullets.length - 1; bi >= 0; bi--) { - const b = this.bullets[bi]; - for (let ai = this.asteroids.length - 1; ai >= 0; ai--) { - const a = this.asteroids[ai]; - const dx = b.x - a.x; - const dy = b.y - a.y; - if (dx * dx + dy * dy < a.radius * a.radius) { - b.gfx.destroy(); - this.bullets.splice(bi, 1); - this.destroyAsteroid(ai); - break; - } - } - } - // Ship vs asteroids - if (this.shipAlive && this.invincibleTimer <= 0) { - for (let ai = this.asteroids.length - 1; ai >= 0; ai--) { - const a = this.asteroids[ai]; - const dx = this.shipX - a.x; - const dy = this.shipY - a.y; - const dist = Math.sqrt(dx * dx + dy * dy); - if (dist < a.radius + SHIP_SIZE * 0.6) { - this.hitShip(); - break; - } - } - } - } - /* ================================================================ - SHIP DEATH / LIVES - ================================================================ */ - hitShip() { - this.lives--; - this.syncLivesToHUD(); - this.spawnExplosion(this.shipX, this.shipY); - this.sound.play('sfx_zap', { volume: 0.5 }); - this.sound.play('sfx_lose', { volume: 0.4 }); - if (this.lives <= 0) { - this.shipAlive = false; - if (this.shipGfx) - this.shipGfx.setVisible(false); - if (this.thrustGfx) - this.thrustGfx.setVisible(false); - this.gameOver = true; - this.time.delayedCall(1000, () => { - this.showGameOver(this.score, () => this.scene.restart()); - }); - } - else { - this.shipAlive = false; - if (this.shipGfx) - this.shipGfx.setVisible(false); - if (this.thrustGfx) - this.thrustGfx.setVisible(false); - this.respawnTimer = RESPAWN_DELAY; - } - } - /* ================================================================ - PARTICLES - ================================================================ */ - spawnExplosion(x, y) { - this.spawnParticleExplosion(x, y, 0xffffff, 8); - } - /* ================================================================ - UFO ENEMY - ================================================================ */ - spawnUfo() { - const fromRight = Math.random() < 0.5; - const x = fromRight ? W + 30 : -30; - const y = H * (0.15 + Math.random() * 0.3); - const vx = (fromRight ? -1 : 1) * (120 + Math.random() * 80); - const gfx = this.add.graphics().setDepth(12); - this.drawUfo(gfx); - gfx.setPosition(x, y); - this.ufo = { gfx, x, y, vx, shootTimer: 1500 + Math.random() * 1000, active: true }; - } - drawUfo(gfx) { - gfx.clear(); - const s = SHIP_SIZE * 1.2; - // Dark shadow backdrop - gfx.lineStyle(5, 0x000000, 0.5); - gfx.strokeEllipse(0, 0, s * 2, s * 0.7); - gfx.strokeEllipse(0, -s * 0.2, s, s * 0.5); - // Outer glow (soft magenta) - gfx.lineStyle(3, 0xff44ff, 0.25); - gfx.strokeEllipse(0, 0, s * 2, s * 0.7); - gfx.strokeEllipse(0, -s * 0.2, s, s * 0.5); - // Solid - gfx.lineStyle(2.5, 0xff88ff, 1); - gfx.strokeEllipse(0, 0, s * 2, s * 0.7); - gfx.strokeEllipse(0, -s * 0.2, s, s * 0.5); - } - updateUfo(dt, dtSec) { - // Spawn timer - if (!this.ufo) { - this.ufoTimer -= dt; - if (this.ufoTimer <= 0) { - this.spawnUfo(); - this.ufoTimer = 15000 + Math.random() * 10000; - } - // Update UFO bullets even when no UFO - this.updateUfoBullets(dtSec); - return; - } - const u = this.ufo; - u.x += u.vx * dtSec; - u.gfx.setPosition(u.x, u.y); - // Off-screen — remove - if ((u.vx > 0 && u.x > W + 60) || (u.vx < 0 && u.x < -60)) { - u.gfx.destroy(); - this.ufo = null; - return; - } - // Shoot at player - u.shootTimer -= dt; - if (u.shootTimer <= 0 && this.shipAlive) { - u.shootTimer = 1200 + Math.random() * 800; - const angle = Math.atan2(this.shipY - u.y, this.shipX - u.x); - const speed = 250; - const bGfx = this.add.graphics().setDepth(8); - bGfx.fillStyle(0x000000, 0.5); - bGfx.fillCircle(0, 0, 9); - bGfx.fillStyle(0xff44ff, 0.3); - bGfx.fillCircle(0, 0, 7); - bGfx.fillStyle(0xff88ff, 1); - bGfx.fillCircle(0, 0, 3); - bGfx.setPosition(u.x, u.y); - this.ufoBullets.push({ - gfx: bGfx, x: u.x, y: u.y, - vx: Math.cos(angle) * speed, - vy: Math.sin(angle) * speed, - life: 3000, - }); - } - this.updateUfoBullets(dtSec); - } - updateUfoBullets(dtSec) { - for (let i = this.ufoBullets.length - 1; i >= 0; i--) { - const b = this.ufoBullets[i]; - b.x += b.vx * dtSec; - b.y += b.vy * dtSec; - b.life -= dtSec * 1000; - b.gfx.setPosition(b.x, b.y); - if (b.life <= 0 || b.x < -50 || b.x > W + 50 || b.y < -50 || b.y > H + 50) { - b.gfx.destroy(); - this.ufoBullets.splice(i, 1); - } - } - } - checkUfoCollisions() { - if (!this.ufo) - return; - const u = this.ufo; - // Player bullets vs UFO - for (let bi = this.bullets.length - 1; bi >= 0; bi--) { - const b = this.bullets[bi]; - const dx = b.x - u.x; - const dy = b.y - u.y; - if (dx * dx + dy * dy < (SHIP_SIZE * 1.5) ** 2) { - b.gfx.destroy(); - this.bullets.splice(bi, 1); - this.addScore(500, u.x, u.y - 10); - this.spawnExplosion(u.x, u.y); - this.sound.play('sfx_zap', { volume: 0.4 }); - u.gfx.destroy(); - this.ufo = null; - return; - } - } - // UFO bullets vs player - if (this.shipAlive && this.invincibleTimer <= 0) { - for (let i = this.ufoBullets.length - 1; i >= 0; i--) { - const b = this.ufoBullets[i]; - const dx = b.x - this.shipX; - const dy = b.y - this.shipY; - if (dx * dx + dy * dy < (SHIP_SIZE * 0.8) ** 2) { - b.gfx.destroy(); - this.ufoBullets.splice(i, 1); - this.hitShip(); - return; - } - } - } - // UFO body vs player - if (this.shipAlive && this.invincibleTimer <= 0) { - const dx = this.shipX - u.x; - const dy = this.shipY - u.y; - if (dx * dx + dy * dy < (SHIP_SIZE * 1.8) ** 2) { - this.spawnExplosion(u.x, u.y); - u.gfx.destroy(); - this.ufo = null; - this.hitShip(); - } - } - } - /* ================================================================ - WAVE SYSTEM - ================================================================ */ - startWave() { - this.wave++; - this.syncLevelToHUD(this.wave); - this.sound.play('sfx_twoTone', { volume: 0.3 }); - const count = INITIAL_ASTEROIDS + (this.wave - 1) * 2; - // Aim the first 2 asteroids at the ship so the player must act quickly - for (let i = 0; i < count; i++) { - this.spawnAsteroid(0, undefined, undefined, i < 2); - } - this.showWaveBanner(this.wave); - } - /* ================================================================ - CLEANUP - ================================================================ */ - shutdown() { - super.shutdown(); - if (this.ufo) { - this.ufo.gfx.destroy(); - this.ufo = null; - } - this.ufoBullets.forEach(b => b.gfx.destroy()); - this.ufoBullets = []; - const banner = document.getElementById('wave-banner'); - if (banner) - banner.remove(); - } -} -//# sourceMappingURL=CosmicRocks.js.map \ No newline at end of file diff --git a/extensions/arcade-canvas/game/scenes/GalaxyBlaster.js b/extensions/arcade-canvas/game/scenes/GalaxyBlaster.js deleted file mode 100644 index 4575b583..00000000 --- a/extensions/arcade-canvas/game/scenes/GalaxyBlaster.js +++ /dev/null @@ -1,1307 +0,0 @@ -// GalaxyBlaster — Galaga-style space shooter. -// Direct port of WesleyEdwards/galaga mechanics: manual position math, -// De Casteljau bezier smoothing, hop+figure-eight attack patterns. -// Phaser sprites used ONLY for rendering (setPosition, setRotation, destroy). -import { BaseScene, W, H } from './BaseScene.js'; -function overlap(a, b) { - return a.x < b.x + b.w && a.x + a.w > b.x && - a.y < b.y + b.h && a.y + a.h > b.y; -} -function computeDistance(a, b) { - return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2); -} -/* ------------------------------------------------------------------ */ -/* De Casteljau bezier — exact port from PathFollower.ts */ -/* ------------------------------------------------------------------ */ -function getBezierPoint(t, points) { - if (points.length === 1) - return { x: points[0].x, y: points[0].y }; - const next = []; - for (let i = 0; i < points.length - 1; i++) { - next.push({ - x: (1 - t) * points[i].x + t * points[i + 1].x, - y: (1 - t) * points[i].y + t * points[i + 1].y, - }); - } - return getBezierPoint(t, next); -} -function generatePointsOnBezierCurve(points, numOfPoints) { - const bezierPoints = []; - for (let i = 0; i <= numOfPoints; i++) { - const t = i / numOfPoints; - bezierPoints.push(getBezierPoint(t, points)); - } - return bezierPoints; -} -const ENEMY_INFO = { - bug: { tex: 'space', frame: 'enemyRed1.png', hp: 1, formPts: 50, divePts: 100 }, - drone: { tex: 'space', frame: 'enemyBlack4.png', hp: 1, formPts: 60, divePts: 120 }, - moth: { tex: 'space', frame: 'enemyBlue3.png', hp: 2, formPts: 80, divePts: 160 }, - scout: { tex: 'space', frame: 'enemyRed5.png', hp: 2, formPts: 90, divePts: 180 }, - heavy: { tex: 'space', frame: 'enemyBlue5.png', hp: 3, formPts: 120, divePts: 300 }, - boss: { tex: 'space', frame: 'enemyGreen2.png', hp: 4, formPts: 150, divePts: 400 }, - commander: { tex: 'space', frame: 'enemyGreen2.png', hp: 2, formPts: 250, divePts: 500 }, -}; -function waveDef(n) { - const cycle = ((n - 1) % 5) + 1; - const tier = Math.floor((n - 1) / 5); - const extra = tier; - const cmds = n >= 2 ? Math.min(1 + Math.floor(n / 4), 2) : 0; - if (cycle === 1) - return { bugs: 8 + extra, drones: 0, moths: 0, scouts: 0, heavies: 0, bosses: 0, commanders: cmds }; - if (cycle === 2) - return { bugs: 4 + extra, drones: 4, moths: 0, scouts: 0, heavies: 0, bosses: 0, commanders: cmds }; - if (cycle === 3) - return { bugs: 4, drones: 2, moths: 4 + extra, scouts: 0, heavies: 0, bosses: 0, commanders: cmds }; - if (cycle === 4) - return { bugs: 3, drones: 2, moths: 2, scouts: 2 + extra, heavies: 2, bosses: 0, commanders: cmds }; - return { bugs: 3, drones: 2, moths: 2, scouts: 2, heavies: 1 + extra, bosses: 2, commanders: cmds }; -} -/* ------------------------------------------------------------------ */ -/* Constants — ref uses 500×500 design grid */ -/* Recalculated in create() to pick up correct W/H after Tauri resize */ -/* ------------------------------------------------------------------ */ -let CONV_X = W / 500; -let CONV_Y = H / 500; -let SCALE = Math.min(CONV_X, CONV_Y); -let OPPONENT_SIZE = Math.min(32 * SCALE, W / 35); -let ENTRY_SPEED = 0.4 * SCALE; // px/ms -let ATTACK_SPEED = 0.3 * SCALE; // px/ms -const ENTRANCE_INTERVAL = 100; // ms between spawns in a trail -let SHIP_SPEED = 0.25 * 1000 * SCALE; -let BULLET_SPEED = 0.45 * 1000 * SCALE; -const MAX_BULLETS = 3; -let ENEMY_BULLET_SPEED = 0.3 * 1000 * SCALE; -const BASE_MAX_DIVERS = 4; -const MAX_ENEMY_BULLETS = 3; // authentic Galaga: max 3 enemy bullets on screen -/* Formation: 5 rows × 10 cols, centered, in design coords scaled to screen */ -const FORM_COLS = 10; -const FORM_ROWS = 5; -let COL_SPACING = OPPONENT_SIZE + 10 * CONV_X; -function formationSlot(row, col) { - const totalW = (FORM_COLS - 1) * COL_SPACING; - const startX = (W - totalW) / 2; - return { - x: startX + col * COL_SPACING, - y: (row + 1) * OPPONENT_SIZE, - }; -} -/* Build full grid: row 0 = bosses, 1-2 = moths, 3-4 = bugs */ -function buildFormationGrid() { - const slots = []; - for (let r = 0; r < FORM_ROWS; r++) { - for (let c = 0; c < FORM_COLS; c++) { - slots.push(formationSlot(r, c)); - } - } - return slots; -} -/* ------------------------------------------------------------------ */ -/* Entry path generation (ref waveOneInfo.ts style) */ -/* ------------------------------------------------------------------ */ -/** Bee-style entry: top-center, swoop through bottom-left, spiral up */ -function beeEntryControlPoints(targetX, targetY, mirror) { - const s = mirror ? -1 : 1; - const cx = CONV_X; - const cy = CONV_Y; - return [ - { x: 300 * cx, y: -32 * cy }, - { x: (300 + s * 30) * cx, y: 50 * cy }, - { x: (300 + s * 80) * cx, y: 130 * cy }, - { x: (250 + s * 150) * cx, y: 220 * cy }, - { x: (250 + s * 180) * cx, y: 290 * cy }, - { x: (250 + s * 140) * cx, y: 340 * cy }, - { x: (250 + s * 80) * cx, y: 330 * cy }, - { x: (250 + s * 20) * cx, y: 300 * cy }, - { x: (250 - s * 30) * cx, y: 260 * cy }, - { x: (250 - s * 50) * cx, y: 210 * cy }, - { x: (250 - s * 30) * cx, y: 170 * cy }, - { x: targetX, y: targetY }, - ]; -} -/** Moth-style entry: top-center other side, swoop through bottom-right, spiral up */ -function mothEntryControlPoints(targetX, targetY, mirror) { - const s = mirror ? -1 : 1; - const cx = CONV_X; - const cy = CONV_Y; - return [ - { x: 200 * cx, y: -32 * cy }, - { x: (200 - s * 30) * cx, y: 50 * cy }, - { x: (200 - s * 80) * cx, y: 130 * cy }, - { x: (250 - s * 150) * cx, y: 220 * cy }, - { x: (250 - s * 180) * cx, y: 290 * cy }, - { x: (250 - s * 140) * cx, y: 340 * cy }, - { x: (250 - s * 80) * cx, y: 330 * cy }, - { x: (250 - s * 20) * cx, y: 300 * cy }, - { x: (250 + s * 30) * cx, y: 260 * cy }, - { x: (250 + s * 50) * cx, y: 210 * cy }, - { x: (250 + s * 30) * cx, y: 170 * cy }, - { x: targetX, y: targetY }, - ]; -} -/** Boss entry: center spiral down */ -function bossEntryControlPoints(targetX, targetY, mirror) { - const s = mirror ? -1 : 1; - const cx = CONV_X; - const cy = CONV_Y; - return [ - { x: 250 * cx, y: -32 * cy }, - { x: (250 + s * 60) * cx, y: 40 * cy }, - { x: (250 + s * 120) * cx, y: 120 * cy }, - { x: (250 + s * 100) * cx, y: 200 * cy }, - { x: (250 + s * 40) * cx, y: 280 * cy }, - { x: (250 - s * 30) * cx, y: 330 * cy }, - { x: (250 - s * 80) * cx, y: 310 * cy }, - { x: (250 - s * 60) * cx, y: 260 * cy }, - { x: (250 - s * 20) * cx, y: 200 * cy }, - { x: (250 + s * 10) * cx, y: 150 * cy }, - { x: targetX, y: targetY }, - ]; -} -/** Side-sweep entry for variety */ -function sideEntryControlPoints(targetX, targetY, fromRight) { - const cx = CONV_X; - const cy = CONV_Y; - const sx = fromRight ? 530 * cx : -30 * cx; - const mid = 250 * cx; - return [ - { x: sx, y: 200 * cy }, - { x: fromRight ? 420 * cx : 80 * cx, y: 150 * cy }, - { x: fromRight ? 350 * cx : 150 * cx, y: 100 * cy }, - { x: mid, y: 80 * cy }, - { x: fromRight ? 150 * cx : 350 * cx, y: 120 * cy }, - { x: fromRight ? 100 * cx : 400 * cx, y: 200 * cy }, - { x: fromRight ? 80 * cx : 420 * cx, y: 280 * cy }, - { x: fromRight ? 120 * cx : 380 * cx, y: 330 * cy }, - { x: fromRight ? 200 * cx : 300 * cx, y: 310 * cy }, - { x: mid, y: 260 * cy }, - { x: targetX, y: targetY }, - ]; -} -/** Bottom-loop entry for variety */ -function bottomLoopControlPoints(targetX, targetY, fromRight) { - const cx = CONV_X; - const cy = CONV_Y; - const sx = fromRight ? 530 * cx : -30 * cx; - return [ - { x: sx, y: 250 * cy }, - { x: fromRight ? 400 * cx : 100 * cx, y: 300 * cy }, - { x: fromRight ? 350 * cx : 150 * cx, y: 340 * cy }, - { x: 250 * cx, y: 340 * cy }, - { x: fromRight ? 150 * cx : 350 * cx, y: 320 * cy }, - { x: fromRight ? 100 * cx : 400 * cx, y: 280 * cy }, - { x: fromRight ? 80 * cx : 420 * cx, y: 230 * cy }, - { x: fromRight ? 120 * cx : 380 * cx, y: 170 * cy }, - { x: 250 * cx, y: 130 * cy }, - { x: targetX, y: targetY }, - ]; -} -/* ------------------------------------------------------------------ */ -/* Attack path — exact port from AttackPatterns.ts */ -/* ------------------------------------------------------------------ */ -function hop(currPos, path) { - const cx = CONV_X; - const cy = CONV_Y; - // 8 points: move up then arc right (in design coords scaled) - path.push({ x: currPos.x, y: currPos.y }); - path.push({ x: currPos.x + 5 * cx, y: currPos.y - 10 * cy }); - path.push({ x: currPos.x + 10 * cx, y: currPos.y - 25 * cy }); - path.push({ x: currPos.x + 15 * cx, y: currPos.y - 40 * cy }); - path.push({ x: currPos.x + 25 * cx, y: currPos.y - 50 * cy }); - path.push({ x: currPos.x + 35 * cx, y: currPos.y - 45 * cy }); - path.push({ x: currPos.x + 40 * cx, y: currPos.y - 30 * cy }); - path.push({ x: currPos.x + 35 * cx, y: currPos.y - 15 * cy }); -} -function leftAttackPattern(path) { - const cx = CONV_X; - const cy = CONV_Y; - const last = path[path.length - 1]; - const bx = last.x; - const by = last.y; - // Wide figure-eight pattern (~30 points) — convX/convY scaled - path.push({ x: bx + 20 * cx, y: by + 10 * cy }); - path.push({ x: bx + 40 * cx, y: by + 30 * cy }); - path.push({ x: bx + 60 * cx, y: by + 60 * cy }); - path.push({ x: bx + 80 * cx, y: by + 100 * cy }); - path.push({ x: bx + 90 * cx, y: by + 140 * cy }); - path.push({ x: bx + 85 * cx, y: by + 180 * cy }); - path.push({ x: bx + 70 * cx, y: by + 210 * cy }); - path.push({ x: bx + 45 * cx, y: by + 230 * cy }); - path.push({ x: bx + 15 * cx, y: by + 235 * cy }); - path.push({ x: bx - 15 * cx, y: by + 225 * cy }); - path.push({ x: bx - 40 * cx, y: by + 200 * cy }); - path.push({ x: bx - 55 * cx, y: by + 170 * cy }); - path.push({ x: bx - 60 * cx, y: by + 135 * cy }); - path.push({ x: bx - 55 * cx, y: by + 100 * cy }); - path.push({ x: bx - 40 * cx, y: by + 70 * cy }); - path.push({ x: bx - 20 * cx, y: by + 50 * cy }); - path.push({ x: bx, y: by + 40 * cy }); - path.push({ x: bx + 20 * cx, y: by + 50 * cy }); - path.push({ x: bx + 45 * cx, y: by + 70 * cy }); - path.push({ x: bx + 65 * cx, y: by + 100 * cy }); - path.push({ x: bx + 75 * cx, y: by + 135 * cy }); - path.push({ x: bx + 70 * cx, y: by + 170 * cy }); - path.push({ x: bx + 55 * cx, y: by + 200 * cy }); - path.push({ x: bx + 30 * cx, y: by + 220 * cy }); - path.push({ x: bx, y: by + 230 * cy }); - path.push({ x: bx - 30 * cx, y: by + 220 * cy }); - path.push({ x: bx - 55 * cx, y: by + 195 * cy }); - path.push({ x: bx - 70 * cx, y: by + 160 * cy }); - path.push({ x: bx - 75 * cx, y: by + 120 * cy }); - path.push({ x: bx - 65 * cx, y: by + 80 * cy }); - path.push({ x: bx - 40 * cx, y: by + 50 * cy }); - path.push({ x: bx - 10 * cx, y: by + 30 * cy }); -} -function rightAttackPattern(path) { - const cx = CONV_X; - const cy = CONV_Y; - const last = path[path.length - 1]; - const bx = last.x; - const by = last.y; - // Mirror of left pattern - path.push({ x: bx - 20 * cx, y: by + 10 * cy }); - path.push({ x: bx - 40 * cx, y: by + 30 * cy }); - path.push({ x: bx - 60 * cx, y: by + 60 * cy }); - path.push({ x: bx - 80 * cx, y: by + 100 * cy }); - path.push({ x: bx - 90 * cx, y: by + 140 * cy }); - path.push({ x: bx - 85 * cx, y: by + 180 * cy }); - path.push({ x: bx - 70 * cx, y: by + 210 * cy }); - path.push({ x: bx - 45 * cx, y: by + 230 * cy }); - path.push({ x: bx - 15 * cx, y: by + 235 * cy }); - path.push({ x: bx + 15 * cx, y: by + 225 * cy }); - path.push({ x: bx + 40 * cx, y: by + 200 * cy }); - path.push({ x: bx + 55 * cx, y: by + 170 * cy }); - path.push({ x: bx + 60 * cx, y: by + 135 * cy }); - path.push({ x: bx + 55 * cx, y: by + 100 * cy }); - path.push({ x: bx + 40 * cx, y: by + 70 * cy }); - path.push({ x: bx + 20 * cx, y: by + 50 * cy }); - path.push({ x: bx, y: by + 40 * cy }); - path.push({ x: bx - 20 * cx, y: by + 50 * cy }); - path.push({ x: bx - 45 * cx, y: by + 70 * cy }); - path.push({ x: bx - 65 * cx, y: by + 100 * cy }); - path.push({ x: bx - 75 * cx, y: by + 135 * cy }); - path.push({ x: bx - 70 * cx, y: by + 170 * cy }); - path.push({ x: bx - 55 * cx, y: by + 200 * cy }); - path.push({ x: bx - 30 * cx, y: by + 220 * cy }); - path.push({ x: bx, y: by + 230 * cy }); - path.push({ x: bx + 30 * cx, y: by + 220 * cy }); - path.push({ x: bx + 55 * cx, y: by + 195 * cy }); - path.push({ x: bx + 70 * cx, y: by + 160 * cy }); - path.push({ x: bx + 75 * cx, y: by + 120 * cy }); - path.push({ x: bx + 65 * cx, y: by + 80 * cy }); - path.push({ x: bx + 40 * cx, y: by + 50 * cy }); - path.push({ x: bx + 10 * cx, y: by + 30 * cy }); -} -function getAttackPath(currPos) { - const path = []; - hop(currPos, path); - if (currPos.x < W / 2) { - leftAttackPattern(path); - } - else { - rightAttackPattern(path); - } - path.push({ x: currPos.x, y: currPos.y }); // return to formation - // Scale the dive deeper so enemies reach the player's zone. - // Find how deep the pattern goes vs how deep it SHOULD go (near the ship). - const targetY = H - OPPONENT_SIZE * 4; // just above the player ship - let maxY = -Infinity; - for (const p of path) { - if (p.y > maxY) - maxY = p.y; - } - if (maxY > currPos.y && maxY < targetY) { - const yScale = (targetY - currPos.y) / (maxY - currPos.y); - for (const p of path) { - if (p !== path[path.length - 1]) { // don't scale the return-to-formation point - p.y = currPos.y + (p.y - currPos.y) * yScale; - } - } - } - return generatePointsOnBezierCurve(path, 75); -} -/* ================================================================== */ -/* SCENE */ -/* ================================================================== */ -export class GalaxyBlasterScene extends BaseScene { - /* player */ - ship; - shipX = W / 2; - shipVx = 0; - shipY = H - OPPONENT_SIZE * 3; - bullets = []; - invincible = 0; - /* shield */ - shieldActive = false; - shieldSprite; - shieldPickups = []; - /* dual-shot power-up */ - dualShot = false; - dualShotTimer = 0; - dualShotPickups = []; - dualShotGlow; - normalShipWidth = 0; - normalShipHeight = 0; - /* enemies */ - enemies = []; - enemyBullets = []; - formation = []; - enemyOffset = -50 * CONV_X; - driftDirection = 1; - driftTimer = 0; - allStationary = false; - breatheTimer = 0; - breathePhase = 'breathe-in'; - attackTimer = 0; - offsetLerping = false; - /* wave / spawn */ - wave = 0; - waveDelay = 0; - spawnQueue = []; - spawnTimer = 0; - waveTextSprite = null; - /* starfield */ - stars = []; - /* input */ - cursors; - spaceKey; - spaceWasDown = false; - /* meteors */ - meteors = []; - meteorTimer = 0; - /* game over */ - gameOver = false; - constructor() { super('galaxy-blaster'); } - get displayName() { return 'Galaxy Blaster'; } - getDescription() { - return 'Battle alien formations in deep space. Clear each wave to advance!'; - } - getControls() { - return [ - { key: '← →', action: 'Move Left / Right' }, - { key: 'SPACE', action: 'Fire' }, - ]; - } - /* ================================================================ - LIFECYCLE - ================================================================ */ - preload() { - this.load.atlasXML('space', '../assets/galaxy-blaster/space_sheet-2.png', '../assets/galaxy-blaster/space_sheet-2.xml'); - this.load.image('space_bg', '../assets/galaxy-blaster/space_bg.png'); - this.load.audio('sfx_laser', '../assets/galaxy-blaster/sounds/sfx_laser1.ogg'); - this.load.audio('sfx_zap', '../assets/galaxy-blaster/sounds/sfx_explosion.ogg'); - this.load.audio('sfx_lose', '../assets/galaxy-blaster/sounds/sfx_lose.ogg'); - this.load.audio('sfx_shieldUp', '../assets/galaxy-blaster/sounds/sfx_shieldUp.ogg'); - this.load.audio('sfx_shieldDown', '../assets/galaxy-blaster/sounds/sfx_shieldDown.ogg'); - this.load.audio('sfx_twoTone', '../assets/galaxy-blaster/sounds/sfx_twoTone.ogg'); - } - create() { - this.initBase(); - // Recalculate screen-dependent constants now that W/H are correct - CONV_X = W / 500; - CONV_Y = H / 500; - SCALE = Math.min(CONV_X, CONV_Y); - OPPONENT_SIZE = Math.min(32 * SCALE, W / 35); - ENTRY_SPEED = 0.4 * SCALE; - ATTACK_SPEED = 0.3 * SCALE; - SHIP_SPEED = 0.25 * 1000 * SCALE; - BULLET_SPEED = 0.45 * 1000 * SCALE; - ENEMY_BULLET_SPEED = 0.3 * 1000 * SCALE; - COL_SPACING = OPPONENT_SIZE + 10 * CONV_X; - this.score = 0; - this.lives = 3; - this.wave = 0; - this.waveDelay = 0; - this.enemies = []; - this.bullets = []; - this.enemyBullets = []; - this.spawnQueue = []; - this.activeEmitters = []; - this.enemyOffset = -50 * CONV_X; - this.driftDirection = 1; - this.driftTimer = 0; - this.allStationary = false; - this.breatheTimer = 0; - this.breathePhase = 'breathe-in'; - this.attackTimer = 0; - this.offsetLerping = false; - this.invincible = 0; - this.gameOver = false; - this.shipX = W / 2; - this.shipVx = 0; - this.shieldActive = false; - if (this.shieldSprite && this.shieldSprite.active) { - this.shieldSprite.destroy(); - this.shieldSprite = undefined; - } - this.shieldPickups.forEach(p => { if (p.sprite && p.sprite.active) - p.sprite.destroy(); }); - this.shieldPickups = []; - this.meteors.forEach(m => { if (m.sprite && m.sprite.active) - m.sprite.destroy(); }); - this.meteors = []; - this.meteorTimer = 0; - this.ensureSparkTexture(); - this.createGalaxyStarfield(); - this.formation = buildFormationGrid(); - this.ship = this.add.sprite(this.shipX, this.shipY, 'space', 'playerShip1_blue.png').setDepth(10); - this.ship.setDisplaySize(OPPONENT_SIZE * 1.2, OPPONENT_SIZE * 0.9); - this.normalShipWidth = OPPONENT_SIZE * 1.2; - this.normalShipHeight = OPPONENT_SIZE * 0.9; - this.dualShot = false; - this.dualShotTimer = 0; - this.dualShotPickups = []; - this.dualShotGlow = undefined; - this.cursors = this.input.keyboard.createCursorKeys(); - this.spaceKey = this.input.keyboard.addKey('SPACE'); - this.spaceWasDown = false; - this.syncLivesToHUD(); - this.syncLevelToHUD(this.wave); - this.syncScoreToHUD(); - this.loadHighScore(); - this.startWithReadyScreen(() => this.startWave()); - } - update(_t, dtMs) { - if (this.gameOver) - return; - const dt = Math.min(dtMs, 33); - this.updateGalaxyStarfield(dt); - this.updateShip(dt); - this.updateBullets(dt); - this.updateEnemies(dt); - this.updateEnemyBullets(dt); - this.checkCollisions(); - this.updateShieldPickups(dt); - this.updateDualShotPickups(dt); - this.updateDualShot(dt); - this.updateMeteors(dt); - this.updateWave(dt); - } - /* ================================================================ - STARFIELD - ================================================================ */ - createGalaxyStarfield() { - const bgTile = 256; - const cols = Math.ceil(W / bgTile) + 1; - const rows = Math.ceil(H / bgTile) + 1; - for (let r = 0; r < rows; r++) { - for (let c = 0; c < cols; c++) { - this.add.image(c * bgTile + bgTile / 2, r * bgTile + bgTile / 2, 'space_bg') - .setAlpha(0.25) - .setDepth(-10); - } - } - this.stars = this.createStarfield([ - { count: 30, speed: 20, size: 1, alpha: 0.3 }, - { count: 20, speed: 40, size: 1.5, alpha: 0.4 }, - { count: 15, speed: 70, size: 2, alpha: 0.5 }, - ]); - } - updateGalaxyStarfield(dt) { - this.updateStarfield(this.stars, dt); - } - /* ================================================================ - WAVE SYSTEM - ================================================================ */ - startWave() { - this.wave++; - this.syncLevelToHUD(this.wave); - const def = waveDef(this.wave); - this.spawnQueue = []; - const usedSlots = new Set(this.enemies.map(e => { - // Find slot index from resting pos - for (let i = 0; i < this.formation.length; i++) { - if (this.formation[i].x === e.restingPosX && this.formation[i].y === e.restingPosY) - return i; - } - return -1; - })); - // Determine which entry path style based on wave for variety - const waveStyle = (this.wave - 1) % 5; - // Helper: find next free slot in given rows - const findSlot = (preferredRows) => { - for (const row of preferredRows) { - for (let c = 0; c < FORM_COLS; c++) { - const idx = row * FORM_COLS + c; - if (!usedSlots.has(idx)) { - usedSlots.add(idx); - return idx; - } - } - } - // Fallback: any free slot - for (let i = 0; i < this.formation.length; i++) { - if (!usedSlots.has(i)) { - usedSlots.add(i); - return i; - } - } - return -1; - }; - // Build trails: bosses → moths → bugs, each group with its own entry curve - const addTrail = (kind, count, rows, pathFn, mirror) => { - for (let i = 0; i < count; i++) { - const slotIdx = findSlot(rows); - if (slotIdx === -1) - continue; - const target = this.formation[slotIdx]; - const controlPts = pathFn(target.x, target.y, mirror); - const entryPath = generatePointsOnBezierCurve(controlPts, 25); - this.spawnQueue.push({ kind, entryPath, slotIdx }); - } - }; - // Pick entry curve variants based on wave style - if (def.bosses > 0) { - addTrail('boss', def.bosses, [0], bossEntryControlPoints, waveStyle % 2 === 1); - } - if (def.heavies > 0) { - addTrail('heavy', def.heavies, [0, 1], sideEntryControlPoints, waveStyle % 2 === 0); - } - if (def.moths > 0) { - const mothPath = waveStyle >= 3 ? sideEntryControlPoints : mothEntryControlPoints; - addTrail('moth', def.moths, [1, 2], mothPath, waveStyle % 2 === 0); - } - if (def.scouts > 0) { - addTrail('scout', def.scouts, [2, 3], bottomLoopControlPoints, waveStyle % 2 === 1); - } - if (def.drones > 0) { - addTrail('drone', def.drones, [3, 4], beeEntryControlPoints, waveStyle % 2 === 0); - } - if (def.bugs > 0) { - const bugPath = waveStyle >= 4 ? bottomLoopControlPoints : beeEntryControlPoints; - addTrail('bug', def.bugs, [3, 4], bugPath, waveStyle % 2 === 1); - } - if (def.commanders > 0) { - addTrail('commander', def.commanders, [1, 2], sideEntryControlPoints, waveStyle % 2 === 0); - } - this.spawnTimer = 0; - this.attackTimer = 0; - this.enemyOffset = -50 * CONV_X; - this.driftDirection = 1; - this.driftTimer = 0; - this.allStationary = false; - this.offsetLerping = false; - // Clean up old wave text sprite if any - if (this.waveTextSprite) { - this.tweens.killTweensOf(this.waveTextSprite); - this.waveTextSprite.destroy(); - this.waveTextSprite = null; - } - this.showWaveBanner(this.wave); - } - updateWave(dt) { - // Spawn queued enemies with entrance interval timing - if (this.spawnQueue.length > 0) { - this.spawnTimer -= dt; - if (this.spawnTimer <= 0) { - const next = this.spawnQueue.shift(); - this.spawnEnemy(next.kind, next.entryPath, next.slotIdx); - this.spawnTimer = ENTRANCE_INTERVAL; - } - } - // Next wave when all enemies gone and spawn queue empty - if (this.enemies.length === 0 && this.spawnQueue.length === 0) { - this.waveDelay -= dt; - if (this.waveDelay <= 0) { - this.waveDelay = 1500; - this.sound.play('sfx_twoTone', { volume: 0.3 }); - this.startWave(); - } - } - else { - this.waveDelay = 1500; - } - } - /* ================================================================ - ENEMY SPAWN — manual path following, NO PathFollower - ================================================================ */ - spawnEnemy(kind, entryPath, slotIdx) { - const info = ENEMY_INFO[kind]; - const startPos = entryPath[0]; - const target = this.formation[slotIdx]; - const sprite = this.add.sprite(startPos.x, startPos.y, info.tex, info.frame).setDepth(5); - sprite.setDisplaySize(OPPONENT_SIZE, OPPONENT_SIZE * 0.85); - if (kind === 'commander') - sprite.setTint(0xffd700); - const e = { - sprite, - kind, - hp: info.hp, - pos: { x: startPos.x, y: startPos.y }, - restingPosX: target.x, - restingPosY: target.y, - state: 'entrance', - secondaryState: 'breathe-in', - activePath: entryPath, - pathIndex: 0, - speed: ENTRY_SPEED, - breathTimer: 0, - breathingOffsetX: 0, - breathingOffsetY: 0, - attackPath: [], - shotsFired: 0, - shotTimer: 0, - }; - this.enemies.push(e); - } - /* ================================================================ - followPath — exact port from Opponent.ts - ================================================================ */ - followPath(e, dt, onCompletion) { - if (e.pathIndex >= e.activePath.length - 1) - return; - let distTraveled = e.speed * dt; - // Consume distance through multiple waypoints if needed (handles lag spikes) - let distRemaining = computeDistance(e.pos, e.activePath[e.pathIndex + 1]); - while (distTraveled > distRemaining && e.pathIndex < e.activePath.length - 1) { - distTraveled -= distRemaining; - e.pos.x = e.activePath[e.pathIndex + 1].x; - e.pos.y = e.activePath[e.pathIndex + 1].y; - e.pathIndex++; - if (e.pathIndex < e.activePath.length - 1) { - distRemaining = computeDistance(e.pos, e.activePath[e.pathIndex + 1]); - } - } - if (e.pathIndex < e.activePath.length - 1) { - let dirX = e.activePath[e.pathIndex + 1].x - e.pos.x; - let dirY = e.activePath[e.pathIndex + 1].y - e.pos.y; - const dirMag = Math.sqrt(dirX * dirX + dirY * dirY); - if (dirMag > 0.001) { - dirX /= dirMag; - dirY /= dirMag; - e.pos.x += distTraveled * dirX; - e.pos.y += distTraveled * dirY; - } - } - else { - onCompletion(); - } - } - /* ================================================================ - UPDATE ENEMIES — exact port of state machine - ================================================================ */ - updateEnemies(dt) { - const dtScale = dt / 16.67; // frame-rate independence (ref ~60fps) - // Determine whether all enemies have finished entering - const hasEntering = this.enemies.some(e => e.state === 'entrance'); - const wasAllStationary = this.allStationary; - this.allStationary = !hasEntering && this.spawnQueue.length === 0 && this.enemies.length > 0; - if (!this.allStationary) { - // Phase 1: Drifting (before breathing starts) - this.driftTimer += dt; - if (this.driftTimer >= 2000) { - this.driftTimer -= 2000; - this.driftDirection *= -1; - } - this.enemyOffset += this.driftDirection * 0.05 * CONV_X * dt; - } - else { - // Phase 2: Lerp enemyOffset to 0, then start breathing - if (!wasAllStationary) { - this.offsetLerping = true; - } - if (this.offsetLerping) { - const lerpRate = 0.05 * CONV_X * dt; - if (Math.abs(this.enemyOffset) <= lerpRate) { - this.enemyOffset = 0; - this.offsetLerping = false; - this.breathePhase = 'breathe-in'; - this.breatheTimer = 0; - for (const e of this.enemies) { - if (e.state === 'stationary') { - e.state = 'breathe-in'; - e.breathingOffsetX = 0; - e.breathingOffsetY = 0; - } - } - } - else { - this.enemyOffset -= Math.sign(this.enemyOffset) * lerpRate; - } - } - else { - this.breatheTimer += dt; - if (this.breatheTimer >= 2000) { - this.breatheTimer -= 2000; - this.breathePhase = this.breathePhase === 'breathe-in' ? 'breathe-out' : 'breathe-in'; - for (const e of this.enemies) { - if (e.state === 'breathe-in' || e.state === 'breathe-out') { - e.state = this.breathePhase; - } - if (e.state === 'attack') { - e.secondaryState = this.breathePhase; - } - } - } - } - } - // Attack coordination: scale max divers with wave (4 base, +1 per 2 waves, cap at 8) - const maxDivers = Math.min(BASE_MAX_DIVERS + Math.floor((this.wave - 1) / 2), 8); - this.attackTimer += dt; - if (this.attackTimer >= 600) { - this.attackTimer -= 600; - const attackers = this.enemies.filter(e => e.state === 'attack').length; - if (attackers < maxDivers) { - this.triggerDive(); - } - } - // Update each enemy - for (let i = this.enemies.length - 1; i >= 0; i--) { - const e = this.enemies[i]; - if (e.state === 'entrance') { - this.followPath(e, dt, () => { - e.pos.x = e.restingPosX + this.enemyOffset; - e.pos.y = e.restingPosY; - e.state = 'stationary'; - }); - } - else if (e.state === 'stationary') { - e.pos.x = e.restingPosX + this.enemyOffset; - e.pos.y = e.restingPosY; - } - else if (e.state === 'breathe-in' || e.state === 'breathe-out') { - const dir = e.state === 'breathe-in' ? 1 : -1; - e.breathingOffsetX += dir * ((e.restingPosX - W / 2) / (W / 2)) * 0.3 * dtScale; - e.breathingOffsetY += dir * (e.restingPosY / (H / 2)) * 0.4 * dtScale; - const maxOff = OPPONENT_SIZE * 1.5; - e.breathingOffsetX = Math.max(-maxOff, Math.min(maxOff, e.breathingOffsetX)); - e.breathingOffsetY = Math.max(-maxOff, Math.min(maxOff, e.breathingOffsetY)); - e.pos.x = e.restingPosX + e.breathingOffsetX; - e.pos.y = e.restingPosY + e.breathingOffsetY; - } - else if (e.state === 'attack') { - // Continue breathing independently via secondaryState - if (e.secondaryState === 'breathe-in' || e.secondaryState === 'breathe-out') { - const dir = e.secondaryState === 'breathe-in' ? 1 : -1; - e.breathingOffsetX += dir * ((e.restingPosX - W / 2) / (W / 2)) * 0.3 * dtScale; - e.breathingOffsetY += dir * (e.restingPosY / (H / 2)) * 0.4 * dtScale; - } - // activePath is set to attackPath when dive starts - this.followPath(e, dt, () => { - // Attack complete — return to formation / breathing state - e.pos.x = e.restingPosX + e.breathingOffsetX; - e.pos.y = e.restingPosY + e.breathingOffsetY; - e.state = (e.secondaryState === 'breathe-in' || e.secondaryState === 'breathe-out') - ? e.secondaryState - : (this.allStationary ? this.breathePhase : 'stationary'); - e.activePath = []; - e.pathIndex = 0; - e.shotsFired = 0; - e.shotTimer = 0; - }); - // Shooting during attack: fire on start, then every ~1200ms during dive - if (e.state === 'attack') { - e.shotTimer += dt; - if (e.shotsFired >= 1 && e.shotTimer >= 400 + (e.shotsFired - 1) * 1200) { - this.fireEnemyBullet(e.pos.x, e.pos.y); - e.shotsFired++; - } - } - } - // Rendering: position sprite from manual pos - e.sprite.setPosition(e.pos.x + OPPONENT_SIZE / 2, e.pos.y + OPPONENT_SIZE / 2); - // Rotation from path direction - if ((e.state === 'entrance' || e.state === 'attack') && e.pathIndex < e.activePath.length - 1) { - const next = e.activePath[e.pathIndex + 1]; - e.sprite.setRotation(Math.atan2(next.y - e.pos.y, next.x - e.pos.x) + Math.PI / 2); - } - else { - e.sprite.setRotation(0); - } - } - } - /* ================================================================ - ATTACK DIVE - ================================================================ */ - triggerDive() { - const candidates = this.enemies.filter(e => e.state === 'stationary' || e.state === 'breathe-in' || e.state === 'breathe-out'); - if (candidates.length === 0) - return; - const e = candidates[Math.floor(Math.random() * candidates.length)]; - if (e.state === 'entrance') - return; // guard against race condition - // Remember breathing state as secondary so it continues independently - if (e.state === 'breathe-in' || e.state === 'breathe-out') { - e.secondaryState = e.state; - } - else { - e.secondaryState = this.breathePhase; - } - e.state = 'attack'; - const atkPath = getAttackPath({ x: e.pos.x, y: e.pos.y }); - e.attackPath = atkPath; - e.activePath = atkPath; - e.pathIndex = 0; - e.speed = ATTACK_SPEED; - // Fire 1 bullet immediately on attack start - this.fireEnemyBullet(e.pos.x, e.pos.y); - e.shotsFired = 1; - e.shotTimer = 0; - } - fireEnemyBullet(x, y) { - // Cap on-screen enemy bullets (authentic Galaga: max 3) - if (this.enemyBullets.length >= MAX_ENEMY_BULLETS) - return; - // Don't fire if enemy is below or at the player's level - if (y >= this.shipY) - return; - // Galaga-authentic: bullets go nearly straight down with discrete - // 3-direction aiming (straight, slight-left, slight-right). - const dx = this.shipX - x; - const horizontalBias = 0.18; - let vx = 0; - if (dx < -OPPONENT_SIZE) - vx = -ENEMY_BULLET_SPEED * horizontalBias; - else if (dx > OPPONENT_SIZE) - vx = ENEMY_BULLET_SPEED * horizontalBias; - const vy = ENEMY_BULLET_SPEED; - const sprite = this.add.sprite(x, y + 8, 'space', 'laserRed01.png').setDepth(5); - sprite.setDisplaySize(OPPONENT_SIZE * 0.15, OPPONENT_SIZE * 0.5); - this.enemyBullets.push({ sprite, vx, vy }); - } - /* ================================================================ - SHIP - ================================================================ */ - updateShip(dt) { - if (this.invincible > 0) { - this.invincible -= dt; - this.ship.setAlpha(Math.sin(this.invincible * 0.02) > 0 ? 1 : 0.3); - if (this.invincible <= 0) - this.ship.setAlpha(1); - } - const left = this.cursors.left.isDown; - const right = this.cursors.right.isDown; - const accel = SHIP_SPEED * 4; // accelerate to full speed quickly - const friction = 0.88; // smooth deceleration when no key held - if (left) - this.shipVx -= accel * (dt / 1000); - if (right) - this.shipVx += accel * (dt / 1000); - if (!left && !right) - this.shipVx *= friction; - // Clamp velocity - this.shipVx = Math.max(-SHIP_SPEED, Math.min(SHIP_SPEED, this.shipVx)); - if (Math.abs(this.shipVx) < 1) - this.shipVx = 0; - this.shipX += this.shipVx * (dt / 1000); - this.shipX = Math.max(10, Math.min(W - 10, this.shipX)); - this.ship.setPosition(this.shipX, this.shipY); - if (this.shieldSprite && this.shieldActive) { - this.shieldSprite.setPosition(this.shipX, this.shipY); - this.shieldSprite.setAlpha(0.4 + Math.sin(this.time.now / 200) * 0.2); - } - // fire (edge-detect) - const spaceDown = this.spaceKey.isDown; - if (spaceDown && !this.spaceWasDown && this.bullets.length < MAX_BULLETS) { - if (this.dualShot) { - // Dual shot — fire two parallel bullets - const offset = this.normalShipWidth * 0.3; - const s1 = this.add.sprite(this.shipX - offset, this.shipY - 12, 'space', 'laserBlue01.png').setDepth(5); - s1.setDisplaySize(OPPONENT_SIZE * 0.15, OPPONENT_SIZE * 0.55); - const s2 = this.add.sprite(this.shipX + offset, this.shipY - 12, 'space', 'laserBlue01.png').setDepth(5); - s2.setDisplaySize(OPPONENT_SIZE * 0.15, OPPONENT_SIZE * 0.55); - this.bullets.push({ sprite: s1, vx: 0, vy: -BULLET_SPEED }); - this.bullets.push({ sprite: s2, vx: 0, vy: -BULLET_SPEED }); - } - else { - const s = this.add.sprite(this.shipX, this.shipY - 12, 'space', 'laserBlue01.png').setDepth(5); - s.setDisplaySize(OPPONENT_SIZE * 0.15, OPPONENT_SIZE * 0.55); - this.bullets.push({ sprite: s, vx: 0, vy: -BULLET_SPEED }); - } - this.sound.play('sfx_laser', { volume: 0.3 }); - } - this.spaceWasDown = spaceDown; - } - /* ================================================================ - BULLETS - ================================================================ */ - updateBullets(dt) { - for (let i = this.bullets.length - 1; i >= 0; i--) { - const b = this.bullets[i]; - b.sprite.x += b.vx * (dt / 1000); - b.sprite.y += b.vy * (dt / 1000); - if (b.sprite.y < -10) { - b.sprite.destroy(); - this.bullets.splice(i, 1); - } - } - } - updateEnemyBullets(dt) { - for (let i = this.enemyBullets.length - 1; i >= 0; i--) { - const b = this.enemyBullets[i]; - b.sprite.x += b.vx * (dt / 1000); - b.sprite.y += b.vy * (dt / 1000); - if (b.sprite.y > H + 10 || b.sprite.y < -10 || b.sprite.x < -10 || b.sprite.x > W + 10) { - b.sprite.destroy(); - this.enemyBullets.splice(i, 1); - } - } - } - /* ================================================================ - COLLISIONS - ================================================================ */ - checkCollisions() { - const halfSize = OPPONENT_SIZE / 2; - const halfH = OPPONENT_SIZE * 0.85 / 2; - // Player bullets vs enemies - for (let bi = this.bullets.length - 1; bi >= 0; bi--) { - const b = this.bullets[bi]; - const bRect = { x: b.sprite.x - 3, y: b.sprite.y - OPPONENT_SIZE * 0.25, w: 6, h: OPPONENT_SIZE * 0.5 }; - for (let ei = this.enemies.length - 1; ei >= 0; ei--) { - const e = this.enemies[ei]; - const eRect = { - x: e.sprite.x - halfSize, - y: e.sprite.y - halfH, - w: OPPONENT_SIZE, - h: OPPONENT_SIZE * 0.85, - }; - if (overlap(bRect, eRect)) { - b.sprite.destroy(); - this.bullets.splice(bi, 1); - e.hp--; - if (e.hp <= 0) { - const info = ENEMY_INFO[e.kind]; - const inFormation = e.state === 'stationary' || e.state === 'breathe-in' || e.state === 'breathe-out'; - const pts = inFormation ? info.formPts : info.divePts; - this.spawnExplosion(e.sprite.x, e.sprite.y, e.kind); - this.addScore(pts, e.sprite.x, e.sprite.y - 10); - this.sound.play('sfx_zap', { volume: 0.3 }); - const ex = e.sprite.x; - const ey = e.sprite.y; - e.sprite.destroy(); - this.enemies.splice(ei, 1); - // Shield pickup chance (not from commanders) - if (e.kind !== 'commander' && Math.random() < 0.08) { - const pu = this.add.sprite(ex, ey, 'space', 'powerupBlue_shield.png').setDepth(5); - pu.setDisplaySize(OPPONENT_SIZE * 0.6, OPPONENT_SIZE * 0.6); - this.shieldPickups.push({ sprite: pu, vy: 180 * SCALE }); - } - // Commanders always drop dual-shot pickup - if (e.kind === 'commander') { - this.spawnDualShotPickup(ex, ey); - } - } - else { - e.sprite.setTint(0xffffff); - this.time.delayedCall(80, () => { if (e.sprite && e.sprite.active) - e.sprite.clearTint(); }); - } - break; - } - } - } - // Player bullets vs meteors - for (let bi = this.bullets.length - 1; bi >= 0; bi--) { - const b = this.bullets[bi]; - const bRect = { x: b.sprite.x - 3, y: b.sprite.y - OPPONENT_SIZE * 0.25, w: 6, h: OPPONENT_SIZE * 0.5 }; - for (let mi = this.meteors.length - 1; mi >= 0; mi--) { - const m = this.meteors[mi]; - const mSize = m.sprite.displayWidth * 0.4; - const mRect = { x: m.sprite.x - mSize, y: m.sprite.y - mSize, w: mSize * 2, h: mSize * 2 }; - if (overlap(bRect, mRect)) { - b.sprite.destroy(); - this.bullets.splice(bi, 1); - m.hp--; - if (m.hp <= 0) { - const pts = m.sprite.displayWidth > OPPONENT_SIZE ? 150 : 75; - this.spawnExplosion(m.sprite.x, m.sprite.y, 'bug'); - this.addScore(pts, m.sprite.x, m.sprite.y - 10); - this.sound.play('sfx_zap', { volume: 0.2 }); - m.sprite.destroy(); - this.meteors.splice(mi, 1); - } - else { - m.sprite.setTint(0xffffff); - this.time.delayedCall(80, () => { if (m.sprite && m.sprite.active) - m.sprite.clearTint(); }); - } - break; - } - } - } - // Enemy bullets vs player - if (this.invincible <= 0) { - const pRect = { x: this.shipX - OPPONENT_SIZE * 0.5, y: this.shipY - OPPONENT_SIZE * 0.4, w: OPPONENT_SIZE, h: OPPONENT_SIZE * 0.8 }; - for (let i = this.enemyBullets.length - 1; i >= 0; i--) { - const b = this.enemyBullets[i]; - const bRect = { x: b.sprite.x - 3, y: b.sprite.y - 3, w: 6, h: 6 }; - if (overlap(pRect, bRect)) { - b.sprite.destroy(); - this.enemyBullets.splice(i, 1); - this.hitPlayer(); - break; - } - } - } - // Enemies vs player (dive collision) - if (this.invincible <= 0) { - const pRect = { x: this.shipX - OPPONENT_SIZE * 0.5, y: this.shipY - OPPONENT_SIZE * 0.4, w: OPPONENT_SIZE, h: OPPONENT_SIZE * 0.8 }; - for (let ei = this.enemies.length - 1; ei >= 0; ei--) { - const e = this.enemies[ei]; - if (e.state === 'entrance') - continue; - const eRect = { - x: e.sprite.x - halfSize, - y: e.sprite.y - halfH, - w: OPPONENT_SIZE, - h: OPPONENT_SIZE * 0.85, - }; - if (overlap(pRect, eRect)) { - this.spawnExplosion(e.sprite.x, e.sprite.y, e.kind); - e.sprite.destroy(); - this.enemies.splice(ei, 1); - this.hitPlayer(); - break; - } - } - } - } - /* ================================================================ - PLAYER HIT / GAME OVER - ================================================================ */ - hitPlayer() { - if (this.shieldActive) { - this.shieldActive = false; - this.sound.play('sfx_shieldDown', { volume: 0.4 }); - if (this.shieldSprite) { - this.shieldSprite.destroy(); - this.shieldSprite = undefined; - } - this.invincible = 500; - return; - } - // Cancel dual-shot on hit - if (this.dualShot) - this.deactivateDualShot(); - this.lives--; - this.syncLivesToHUD(); - this.spawnExplosion(this.shipX, this.shipY, 'player'); - this.sound.play('sfx_lose', { volume: 0.4 }); - if (this.lives <= 0) { - this.ship.setVisible(false); - this.gameOver = true; - this.showGameOver(this.score, () => { - this.scene.restart(); - }); - } - else { - this.invincible = 2000; - } - } - /* ================================================================ - SHIELD PICKUPS - ================================================================ */ - updateShieldPickups(dt) { - for (let i = this.shieldPickups.length - 1; i >= 0; i--) { - const pu = this.shieldPickups[i]; - pu.sprite.y += pu.vy * (dt / 1000); - if (pu.sprite.y > H) { - pu.sprite.destroy(); - this.shieldPickups.splice(i, 1); - continue; - } - const dx = Math.abs(pu.sprite.x - this.shipX); - const dy = Math.abs(pu.sprite.y - this.shipY); - if (dx < OPPONENT_SIZE * 0.8 && dy < OPPONENT_SIZE * 0.8) { - pu.sprite.destroy(); - this.shieldPickups.splice(i, 1); - this.activateShield(); - } - } - } - activateShield() { - if (this.shieldActive) - return; - this.shieldActive = true; - this.sound.play('sfx_shieldUp', { volume: 0.4 }); - this.shieldSprite = this.add.sprite(this.shipX, this.shipY, 'space', 'shield1.png').setDepth(11); - this.shieldSprite.setDisplaySize(OPPONENT_SIZE * 1.6, OPPONENT_SIZE * 1.4); - this.shieldSprite.setAlpha(0.6); - } - /* ================================================================ - DUAL-SHOT POWER-UP - ================================================================ */ - spawnDualShotPickup(x, y) { - const pu = this.add.sprite(x, y, 'space', 'powerupYellow_bolt.png').setDepth(5); - pu.setDisplaySize(OPPONENT_SIZE * 0.6, OPPONENT_SIZE * 0.6); - pu.setTint(0xffd700); - // Pulsing glow - this.tweens.add({ - targets: pu, alpha: { from: 1, to: 0.5 }, - duration: 400, yoyo: true, repeat: -1, - }); - this.dualShotPickups.push({ sprite: pu, vy: 160 * SCALE }); - } - updateDualShotPickups(dt) { - for (let i = this.dualShotPickups.length - 1; i >= 0; i--) { - const pu = this.dualShotPickups[i]; - pu.sprite.y += pu.vy * (dt / 1000); - if (pu.sprite.y > H) { - pu.sprite.destroy(); - this.dualShotPickups.splice(i, 1); - continue; - } - const dx = Math.abs(pu.sprite.x - this.shipX); - const dy = Math.abs(pu.sprite.y - this.shipY); - if (dx < OPPONENT_SIZE * 0.8 && dy < OPPONENT_SIZE * 0.8) { - pu.sprite.destroy(); - this.dualShotPickups.splice(i, 1); - this.activateDualShot(); - } - } - } - activateDualShot() { - this.dualShot = true; - this.dualShotTimer = 15000; // 15 seconds - this.sound.play('sfx_shieldUp', { volume: 0.4 }); - // Widen ship - this.ship.setDisplaySize(this.normalShipWidth * 1.5, this.normalShipHeight); - // Add glow effect - if (this.dualShotGlow) - this.dualShotGlow.destroy(); - this.dualShotGlow = this.add.sprite(this.shipX, this.shipY, 'space', 'playerShip1_blue.png').setDepth(9); - this.dualShotGlow.setDisplaySize(this.normalShipWidth * 1.8, this.normalShipHeight * 1.3); - this.dualShotGlow.setTint(0xffd700); - this.dualShotGlow.setAlpha(0.25); - } - updateDualShot(dt) { - if (!this.dualShot) - return; - this.dualShotTimer -= dt; - // Update glow position - if (this.dualShotGlow) { - this.dualShotGlow.setPosition(this.shipX, this.shipY); - this.dualShotGlow.setAlpha(0.15 + Math.sin(this.time.now / 200) * 0.1); - } - // Flash warning when about to expire - if (this.dualShotTimer < 3000 && this.dualShotTimer > 0) { - this.ship.setAlpha(Math.sin(this.dualShotTimer * 0.01) > 0 ? 1 : 0.6); - } - if (this.dualShotTimer <= 0) { - this.deactivateDualShot(); - } - } - deactivateDualShot() { - this.dualShot = false; - this.dualShotTimer = 0; - this.ship.setDisplaySize(this.normalShipWidth, this.normalShipHeight); - this.ship.setAlpha(1); - if (this.dualShotGlow) { - this.dualShotGlow.destroy(); - this.dualShotGlow = undefined; - } - } - /* ================================================================ - METEORS - ================================================================ */ - static METEOR_FRAMES = [ - 'meteorBrown_big1.png', 'meteorBrown_big2.png', 'meteorBrown_big3.png', 'meteorBrown_big4.png', - 'meteorGrey_big1.png', 'meteorGrey_big2.png', 'meteorGrey_big3.png', 'meteorGrey_big4.png', - 'meteorBrown_med1.png', 'meteorBrown_med3.png', - 'meteorGrey_med1.png', 'meteorGrey_med2.png', - ]; - updateMeteors(dt) { - // Spawn timer — one every 3-6 seconds - this.meteorTimer -= dt; - if (this.meteorTimer <= 0) { - this.meteorTimer = 3000 + Math.random() * 3000; - this.spawnMeteor(); - } - // Move meteors - const dtS = dt / 1000; - for (let i = this.meteors.length - 1; i >= 0; i--) { - const m = this.meteors[i]; - m.sprite.y += m.vy * dtS; - m.sprite.x += m.vx * dtS; - m.sprite.rotation += m.rotSpeed * dtS; - if (m.sprite.y > H + 80 || m.sprite.x < -80 || m.sprite.x > W + 80) { - m.sprite.destroy(); - this.meteors.splice(i, 1); - } - } - // Collision with player - if (this.invincible <= 0) { - const pRect = { x: this.shipX - OPPONENT_SIZE * 0.5, y: this.shipY - OPPONENT_SIZE * 0.4, w: OPPONENT_SIZE, h: OPPONENT_SIZE * 0.8 }; - for (let i = this.meteors.length - 1; i >= 0; i--) { - const m = this.meteors[i]; - const mSize = m.sprite.displayWidth * 0.4; - const mRect = { x: m.sprite.x - mSize, y: m.sprite.y - mSize, w: mSize * 2, h: mSize * 2 }; - if (overlap(pRect, mRect)) { - this.spawnExplosion(m.sprite.x, m.sprite.y, 'bug'); - m.sprite.destroy(); - this.meteors.splice(i, 1); - this.hitPlayer(); - break; - } - } - } - } - spawnMeteor() { - const frame = GalaxyBlasterScene.METEOR_FRAMES[Math.floor(Math.random() * GalaxyBlasterScene.METEOR_FRAMES.length)]; - const isBig = frame.includes('big'); - const size = isBig ? OPPONENT_SIZE * (1.2 + Math.random() * 0.8) : OPPONENT_SIZE * (0.6 + Math.random() * 0.4); - const x = Math.random() * W; - const sprite = this.add.sprite(x, -size, 'space', frame).setDepth(3); - sprite.setDisplaySize(size, size); - sprite.setAlpha(0.85); - const vy = 60 + Math.random() * 80; - const vx = (Math.random() - 0.5) * 40; - const rotSpeed = (Math.random() - 0.5) * 2; - const hp = isBig ? 2 : 1; - this.meteors.push({ sprite, vy, vx, rotSpeed, hp }); - } - /* ================================================================ - EXPLOSIONS — Phaser Particle Emitter - ================================================================ */ - spawnExplosion(x, y, kind) { - const tintMap = { - bug: 0xffff00, - drone: 0x888888, - moth: 0x4444ff, - scout: 0xff0000, - heavy: 0x0066ff, - boss: 0x00ff00, - player: 0xffffff, - }; - const tint = tintMap[kind] || tintMap.player; - const count = kind === 'player' ? 40 : 25; - this.spawnParticleExplosion(x, y, tint, count); - } - shutdown() { - super.shutdown(); - // Destroy player ship - this.destroyObj(this.ship); - // Destroy bullet sprites - for (const b of this.bullets) - this.destroyObj(b.sprite); - this.bullets = []; - // Destroy enemy sprites - for (const e of this.enemies) - this.destroyObj(e.sprite); - this.enemies = []; - // Destroy enemy bullet sprites - for (const b of this.enemyBullets) - this.destroyObj(b.sprite); - this.enemyBullets = []; - // Destroy shield and pickups - this.destroyObj(this.shieldSprite); - this.shieldSprite = undefined; - for (const p of this.shieldPickups) - this.destroyObj(p); - this.shieldPickups = []; - // Destroy dual-shot pickups and glow - for (const p of this.dualShotPickups) - this.destroyObj(p.sprite); - this.dualShotPickups = []; - this.destroyObj(this.dualShotGlow); - this.dualShotGlow = undefined; - // Destroy meteors - for (const m of this.meteors) - this.destroyObj(m.sprite); - this.meteors = []; - // Destroy wave text - this.destroyObj(this.waveTextSprite); - this.waveTextSprite = null; - } -} -//# sourceMappingURL=GalaxyBlaster.js.map \ No newline at end of file diff --git a/extensions/arcade-canvas/game/scenes/NinjaRunner.js b/extensions/arcade-canvas/game/scenes/NinjaRunner.js deleted file mode 100644 index 64424a0b..00000000 --- a/extensions/arcade-canvas/game/scenes/NinjaRunner.js +++ /dev/null @@ -1,2811 +0,0 @@ -// NinjaRunner — side-scrolling platformer with free JuhoSprite assets. -// Extracted from the original monolithic game.ts and refactored to -// extend BaseScene for the multi-game architecture. -import { BaseScene, W, H } from './BaseScene.js'; -const BLOCK = 48; // logical world tile size -const PLAYER_W = 48; // player draw size -const PLAYER_H = 48; // player draw size -const SPAWN_X = 600; -// Computed dynamically so it uses the correct H after refreshDimensions() -function getGroundY() { return H - BLOCK; } -let GROUND_Y = H - BLOCK; // will be updated in create() -export class NinjaRunnerScene extends BaseScene { - // Input - cursors; - keys; - // Player state - player; - isBig = false; - facingRight = true; - invincible = 1500; // ms - shrinkTimer = 0; - stompGrace = 0; - dead = false; - deadTimer = 0; - lastSafeX = SPAWN_X; - fireCooldown = 0; - // Jump tracking — manual edge detection is more reliable on macOS than - // Phaser's JustDown when multiple keys are held simultaneously. - jumpKeyWasDown = false; - coyoteTime = 0; // ms left where we can still jump after leaving ground - jumpBuffer = 0; // ms left where a queued jump press will fire on landing - canDoubleJump = false; - hasDoubleJumped = false; - // Animation: cycle the run frame based on distance traveled, not wall time, - // so step rhythm matches actual movement speed. - runDistance = 0; - // Generation - genX = 0; - // Groups - groundGroup; - brickGroup; - qblockGroup; - pipeGroup; - coinGroup; - mushroomGroup; - heartGroup; - fireballGroup; - enemyGroup; - gaps = []; - bridgeGroup; - bounceGroup; - flagGroup; - currentLevel = 1; - currentBiome = 0; - distanceSinceFlag = 0; - piranhaGroup; - fireGroup; - crocGroup; - fishGroup; - warping = false; - parachuteMode = false; - parachuteSprite; - parachuteFlyingEnemies = []; - parachuteTimer = 0; - windSound; - glowSprite; - constructor() { super('ninja-runner'); } - get displayName() { return 'Ninja Runner'; } - getDescription() { - return 'Run, jump, and dash through endless obstacles. How far can you go?'; - } - getControls() { - return [ - { key: '← →', action: 'Move Left / Right' }, - { key: 'SPACE', action: 'Jump' }, - { key: 'SHIFT', action: 'Run' }, - { key: 'F', action: 'Fireball' }, - { key: 'Z', action: 'Stomp Attack' }, - ]; - } - sfx(key, volume = 0.3) { - try { - this.sound.play(key, { volume }); - } - catch { /* ignore audio errors */ } - } - preload() { - // Player spritesheet: 7 frames of 16×16 - this.load.spritesheet('player', '../assets/ninja-runner/player_strip.png', { frameWidth: 16, frameHeight: 16 }); - // Enemy spritesheet: 5 frames of 16×16 - this.load.spritesheet('enemy', '../assets/ninja-runner/enemy_strip.png', { frameWidth: 16, frameHeight: 16 }); - // Coin animation: 4 frames of 16×16 - this.load.spritesheet('coin_anim', '../assets/ninja-runner/coin_sheet.png', { frameWidth: 16, frameHeight: 16 }); - // Heart pickup - this.load.spritesheet('heart_anim', '../assets/ninja-runner/heart_sheet.png', { frameWidth: 16, frameHeight: 16 }); - // Tile textures - this.load.image('grass_block', '../assets/ninja-runner/grass_block.png'); - this.load.image('dirt_block', '../assets/ninja-runner/dirt_block.png'); - this.load.image('brown_block', '../assets/ninja-runner/brown_block.png'); - this.load.image('qblock_img', '../assets/ninja-runner/qblock_new.png'); - this.load.image('platform_tile', '../assets/ninja-runner/platform.png'); - this.load.image('spikes_tile', '../assets/ninja-runner/spikes.png'); - this.load.image('flag_tile', '../assets/ninja-runner/flag.png'); - this.load.image('bridge_tile', '../assets/ninja-runner/bridge.png'); - this.load.image('impact', '../assets/ninja-runner/impact_sheet.png'); - this.load.image('clouds', '../assets/ninja-runner/clouds.png'); - this.load.image('hill_0', '../assets/ninja-runner/hill_0.png'); - this.load.image('hill_1', '../assets/ninja-runner/hill_1.png'); - this.load.image('big_bush', '../assets/ninja-runner/big_bush.png'); - this.load.image('small_bush', '../assets/ninja-runner/small_bush.png'); - this.load.image('background', '../assets/ninja-runner/background.png'); - this.load.spritesheet('enemy_tall', '../assets/ninja-runner/enemy_tall_strip.png', { frameWidth: 16, frameHeight: 32 }); - this.load.spritesheet('enemy_short', '../assets/ninja-runner/enemy_short_strip.png', { frameWidth: 16, frameHeight: 16 }); - // Sound effects - this.load.audio('nr_jump', '../assets/ninja-runner/sounds/SoundJump1.m4a'); - this.load.audio('nr_coin', '../assets/ninja-runner/sounds/SoundCoin.m4a'); - this.load.audio('nr_stomp', '../assets/ninja-runner/sounds/SoundEnemyDeath.m4a'); - this.load.audio('nr_powerup', '../assets/ninja-runner/sounds/SoundBonus.m4a'); - this.load.audio('nr_hit', '../assets/ninja-runner/sounds/SoundPlayerHit.m4a'); - this.load.audio('nr_die', '../assets/ninja-runner/sounds/SoundDeath.m4a'); - this.load.audio('nr_flag', '../assets/ninja-runner/sounds/SoundReachGoal.m4a'); - this.load.audio('nr_bounce', '../assets/ninja-runner/sounds/SoundBounce.m4a'); - this.load.audio('nr_startlevel', '../assets/ninja-runner/sounds/SoundStartLevel.m4a'); - this.load.audio('nr_gameover', '../assets/ninja-runner/sounds/SoundGameOver.m4a'); - this.load.audio('nr_land', '../assets/ninja-runner/sounds/SoundLand1.m4a'); - this.load.audio('nr_flap', '../assets/ninja-runner/sounds/SoundFlapLight.m4a'); - this.load.audio('nr_warp', '../assets/ninja-runner/sounds/SoundOpenDoor.m4a'); - this.load.audio('nr_fireball', '../assets/ninja-runner/sounds/SoundShootRegular.m4a'); - this.load.audio('nr_explosion', '../assets/ninja-runner/sounds/SoundExplosionSmall.m4a'); - this.load.audio('nr_extralife', '../assets/ninja-runner/sounds/SoundSpecialSkill.m4a'); - this.load.audio('nr_wind', '../assets/ninja-runner/sounds/SoundWind.m4a'); - } - create() { - this.initBase(); - // Recompute GROUND_Y from the actual game height (H may have been - // refreshed after module load by refreshDimensions in game.ts) - GROUND_Y = H - BLOCK; - this.makeBlockTextures(); - this.physics.world.setBounds(0, 0, 1_000_000, H); - this.groundGroup = this.physics.add.staticGroup(); - this.brickGroup = this.physics.add.staticGroup(); - this.qblockGroup = this.physics.add.staticGroup(); - this.pipeGroup = this.physics.add.staticGroup(); - this.coinGroup = this.physics.add.group({ allowGravity: false }); - this.mushroomGroup = this.physics.add.group(); - this.heartGroup = this.physics.add.group({ allowGravity: false }); - this.fireballGroup = this.physics.add.group(); - this.enemyGroup = this.physics.add.group(); - this.piranhaGroup = this.physics.add.group({ allowGravity: false }); - this.bridgeGroup = this.physics.add.staticGroup(); - this.bounceGroup = this.physics.add.staticGroup(); - this.flagGroup = this.physics.add.staticGroup(); - this.fireGroup = this.physics.add.group({ allowGravity: false }); - this.crocGroup = this.physics.add.group({ allowGravity: false }); - this.fishGroup = this.physics.add.group({ allowGravity: false }); - // Initial ground - this.extendGround(0, W * 2); - // Player — spritesheet frame 0 = idle - this.player = this.physics.add.sprite(SPAWN_X, GROUND_Y - 200, 'player', 0); - this.player.setOrigin(0.5, 1); - this.player.setDisplaySize(PLAYER_W, PLAYER_H); - // Physics body fills the full cell so player's head hits blocks above. - this.player.body.setSize(12, 16); - this.player.body.setOffset(2, 0); - this.player.setMaxVelocity(700, 900); - this.player.body.setGravityY(1800); - this.player.setDepth(10); - // Player animations - this.anims.create({ - key: 'player_walk', - frames: this.anims.generateFrameNumbers('player', { frames: [1, 2, 3] }), - frameRate: 10, - repeat: -1, - }); - this.anims.create({ - key: 'player_idle', - frames: [{ key: 'player', frame: 0 }], - frameRate: 1, - }); - // Coin spin animation - this.anims.create({ - key: 'coin_spin', - frames: this.anims.generateFrameNumbers('coin_anim', { start: 0, end: 3 }), - frameRate: 8, - repeat: -1, - }); - // Heart pulse animation - this.anims.create({ - key: 'heart_pulse', - frames: this.anims.generateFrameNumbers('heart_anim', { start: 0, end: 3 }), - frameRate: 4, - repeat: -1, - }); - // Enemy walk - this.anims.create({ - key: 'enemy_walk', - frames: this.anims.generateFrameNumbers('enemy', { frames: [0, 1, 2, 3] }), - frameRate: 6, - repeat: -1, - }); - this.anims.create({ - key: 'enemy_tall_walk', - frames: this.anims.generateFrameNumbers('enemy_tall', { frames: [0, 1, 2, 3] }), - frameRate: 6, - repeat: -1, - }); - this.anims.create({ - key: 'enemy_short_walk', - frames: this.anims.generateFrameNumbers('enemy_short', { frames: [0, 1, 2, 3] }), - frameRate: 8, - repeat: -1, - }); - // Camera - this.cameras.main.setBounds(0, 0, 1_000_000, H); - this.cameras.main.startFollow(this.player, true, 0.15, 0.05, -W * 0.2, 0); - this.cameras.main.setBackgroundColor('rgba(0,0,0,0)'); - // Colliders - this.physics.add.collider(this.player, this.groundGroup); - this.physics.add.collider(this.player, this.brickGroup, this.onPlayerHitBrick, undefined, this); - this.physics.add.collider(this.player, this.qblockGroup, this.onPlayerHitQBlock, undefined, this); - this.physics.add.collider(this.player, this.pipeGroup); - this.physics.add.collider(this.enemyGroup, this.groundGroup); - this.physics.add.collider(this.enemyGroup, this.brickGroup); - this.physics.add.collider(this.enemyGroup, this.qblockGroup); - this.physics.add.collider(this.enemyGroup, this.pipeGroup); - this.physics.add.overlap(this.enemyGroup, this.enemyGroup, this.onEnemyVsEnemy, undefined, this); - this.physics.add.collider(this.mushroomGroup, this.groundGroup); - this.physics.add.collider(this.mushroomGroup, this.brickGroup); - this.physics.add.collider(this.mushroomGroup, this.qblockGroup); - this.physics.add.collider(this.mushroomGroup, this.pipeGroup); - this.physics.add.collider(this.fireballGroup, this.groundGroup, this.onFireballHitSolid, undefined, this); - this.physics.add.collider(this.fireballGroup, this.brickGroup, this.onFireballHitSolid, undefined, this); - this.physics.add.collider(this.fireballGroup, this.qblockGroup, this.onFireballHitSolid, undefined, this); - this.physics.add.collider(this.fireballGroup, this.pipeGroup, this.onFireballHitSolid, undefined, this); - this.physics.add.overlap(this.player, this.coinGroup, this.onPlayerCoin, undefined, this); - this.physics.add.overlap(this.player, this.mushroomGroup, this.onPlayerMushroom, undefined, this); - this.physics.add.overlap(this.player, this.heartGroup, this.onPlayerHeart, undefined, this); - this.physics.add.overlap(this.player, this.enemyGroup, this.onPlayerEnemy, undefined, this); - this.physics.add.overlap(this.fireballGroup, this.enemyGroup, this.onFireballEnemy, undefined, this); - this.physics.add.overlap(this.player, this.piranhaGroup, this.onPlayerPiranha, undefined, this); - this.physics.add.overlap(this.player, this.fireGroup, this.onPlayerFire, undefined, this); - this.physics.add.overlap(this.player, this.crocGroup, this.onPlayerCroc, undefined, this); - this.physics.add.overlap(this.player, this.fishGroup, this.onPlayerFish, undefined, this); - this.physics.add.collider(this.player, this.bridgeGroup, this.onPlayerBridge, undefined, this); - this.physics.add.collider(this.enemyGroup, this.bridgeGroup); - this.physics.add.overlap(this.player, this.flagGroup, this.onPlayerFlag, undefined, this); - this.physics.add.collider(this.player, this.bounceGroup, this.onPlayerBounce, undefined, this); - this.physics.add.collider(this.enemyGroup, this.bounceGroup); - // Input - this.input.keyboard.addCapture('UP,DOWN,LEFT,RIGHT,SPACE,SHIFT,F,Z'); - this.cursors = this.input.keyboard.createCursorKeys(); - this.keys = { - space: this.input.keyboard.addKey('SPACE'), - shift: this.input.keyboard.addKey('SHIFT'), - f: this.input.keyboard.addKey('F'), - z: this.input.keyboard.addKey('Z'), - }; - this.addDecorations(); - this.generateLevel(SPAWN_X + 400, W + 600); - this.syncLivesToHUD(); - this.loadHighScore(); - this.distanceSinceFlag = 0; - this.currentLevel = 1; - this.syncLevelToHUD(this.currentLevel); - this.sfx('nr_startlevel', 0.25); - this.startWithReadyScreen(); - } - // ---------- Brick / block textures generated at runtime via Graphics ---------- - makeBlockTextures() { - const g = this.add.graphics(); - // Used Q-block (brown/empty) — 16×16 to match source tile size - g.clear(); - g.fillStyle(0xa56a26); - g.fillRect(0, 0, 16, 16); - g.fillStyle(0x6e4715); - g.fillRect(0, 0, 16, 1); - g.fillRect(0, 15, 16, 1); - g.fillRect(0, 0, 1, 16); - g.fillRect(15, 0, 1, 16); - g.generateTexture('qblock_used', 16, 16); - // Pipe body (2 blocks wide) - g.clear(); - g.fillStyle(0x20a010); - g.fillRect(0, 0, BLOCK * 2, BLOCK); - g.fillStyle(0x00680c); - g.lineStyle(2, 0x00680c); - g.strokeRect(0, 0, BLOCK * 2, BLOCK); - g.fillStyle(0x80e080); - g.fillRect(BLOCK / 3 + 4, 0, 4, BLOCK); - g.generateTexture('pipe_body', BLOCK * 2, BLOCK); - // Mushroom - g.clear(); - g.fillStyle(0xd02020); - g.fillRect(2, 2, 28, 16); - g.fillStyle(0xffffff); - g.fillRect(8, 6, 6, 6); - g.fillRect(18, 6, 6, 6); - g.fillStyle(0xf0d8a0); - g.fillRect(6, 18, 20, 12); - g.fillStyle(0x000000); - g.fillRect(11, 22, 3, 4); - g.fillRect(18, 22, 3, 4); - g.generateTexture('mushroom', 32, 32); - // Fireball - g.clear(); - g.fillStyle(0xff8000); - g.fillCircle(8, 8, 7); - g.fillStyle(0xffe080); - g.fillCircle(6, 6, 3); - g.generateTexture('fireball', 16, 16); - // Fire eruption — organic flame shape with layered colors - g.clear(); - // Outer flame (dark red) - g.fillStyle(0xcc2200); - g.fillEllipse(8, 24, 14, 16); - g.fillEllipse(8, 14, 10, 14); - g.fillEllipse(8, 6, 6, 10); - // Middle flame (orange) - g.fillStyle(0xff6600); - g.fillEllipse(8, 26, 10, 12); - g.fillEllipse(8, 16, 8, 12); - g.fillEllipse(8, 8, 4, 8); - // Inner flame (yellow core) - g.fillStyle(0xffcc00); - g.fillEllipse(8, 28, 6, 8); - g.fillEllipse(8, 20, 4, 8); - // Hot white tip - g.fillStyle(0xffffaa); - g.fillEllipse(8, 28, 3, 5); - g.generateTexture('fire_column', 16, 32); - // Piranha plant frame 0 (mouth closed) - g.clear(); - g.fillStyle(0x22aa22); - g.fillRect(11, 16, 10, 16); - g.fillStyle(0xdd2020); - g.fillEllipse(16, 10, 24, 16); - g.fillStyle(0xffffff); - g.fillCircle(10, 8, 2); - g.fillCircle(16, 6, 2); - g.fillCircle(22, 8, 2); - g.generateTexture('piranha_0', 32, 32); - // Piranha plant frame 1 (mouth open) - g.clear(); - g.fillStyle(0x22aa22); - g.fillRect(11, 18, 10, 14); - g.fillStyle(0xdd2020); - g.fillEllipse(16, 10, 26, 18); - g.fillStyle(0xffffff); - g.fillCircle(10, 7, 2); - g.fillCircle(16, 5, 2); - g.fillCircle(22, 7, 2); - g.fillStyle(0x000000); - g.fillRect(8, 12, 16, 3); - g.generateTexture('piranha_1', 32, 32); - // Power-up glow effect - g.clear(); - g.fillStyle(0xffdd00, 0.3); - g.fillCircle(20, 20, 20); - g.fillStyle(0xffff88, 0.2); - g.fillCircle(20, 20, 14); - g.generateTexture('glow', 40, 40); - // Individual cloud puffs (3 sizes for variety) - g.clear(); - g.fillStyle(0xffffff); - g.fillCircle(10, 10, 8); - g.fillCircle(20, 8, 10); - g.fillCircle(32, 10, 9); - g.fillCircle(16, 14, 7); - g.fillCircle(26, 14, 8); - g.generateTexture('cloud_sm', 42, 22); - g.clear(); - g.fillStyle(0xffffff); - g.fillCircle(14, 14, 12); - g.fillCircle(30, 10, 14); - g.fillCircle(48, 14, 11); - g.fillCircle(22, 18, 10); - g.fillCircle(38, 18, 12); - g.generateTexture('cloud_md', 60, 28); - g.clear(); - g.fillStyle(0xffffff); - g.fillCircle(16, 16, 14); - g.fillCircle(36, 12, 16); - g.fillCircle(58, 14, 13); - g.fillCircle(24, 22, 12); - g.fillCircle(46, 20, 14); - g.fillCircle(70, 16, 10); - g.generateTexture('cloud_lg', 82, 32); - // Green bat enemy — flies in a wave pattern - g.clear(); - g.fillStyle(0x22aa44); - g.fillEllipse(8, 9, 8, 8); - g.fillStyle(0x44dd66); - g.fillTriangle(1, 6, 6, 8, 3, 12); // left wing - g.fillTriangle(15, 6, 10, 8, 13, 12); // right wing - g.fillStyle(0xff0000); - g.fillCircle(6, 8, 1); - g.fillCircle(10, 8, 1); - g.generateTexture('bat_0', 16, 16); - g.clear(); - g.fillStyle(0x22aa44); - g.fillEllipse(8, 9, 8, 8); - g.fillStyle(0x44dd66); - g.fillTriangle(1, 10, 6, 8, 3, 4); // wings up - g.fillTriangle(15, 10, 10, 8, 13, 4); - g.fillStyle(0xff0000); - g.fillCircle(6, 8, 1); - g.fillCircle(10, 8, 1); - g.generateTexture('bat_1', 16, 16); - // Warp pipe (lighter green with down arrow) - g.clear(); - g.fillStyle(0x30c030); - g.fillRect(0, 0, BLOCK * 2, BLOCK); - g.fillStyle(0x10a010); - g.lineStyle(2, 0x10a010); - g.strokeRect(0, 0, BLOCK * 2, BLOCK); - g.fillStyle(0xa0ffa0); - g.fillRect(BLOCK / 3 + 4, 0, 4, BLOCK); - g.fillStyle(0xffffff); - g.fillTriangle(BLOCK, 4, BLOCK - 6, BLOCK / 2 - 4, BLOCK + 6, BLOCK / 2 - 4); - g.generateTexture('pipe_warp', BLOCK * 2, BLOCK); - // Golden pipe (parachute trigger) - g.clear(); - g.fillStyle(0xdaa520); - g.fillRect(0, 0, BLOCK * 2, BLOCK); - g.fillStyle(0xb8860b); - g.lineStyle(2, 0xb8860b); - g.strokeRect(0, 0, BLOCK * 2, BLOCK); - g.fillStyle(0xffd700); - g.fillRect(BLOCK / 3 + 4, 0, 4, BLOCK); - g.fillStyle(0xffffff); - g.fillTriangle(BLOCK, BLOCK / 2 - 2, BLOCK - 5, BLOCK / 2 + 6, BLOCK + 5, BLOCK / 2 + 6); - g.generateTexture('pipe_gold', BLOCK * 2, BLOCK); - // Parachute canopy — half-dome with red/white panels, scalloped rim, strings - g.clear(); - const cw = 64, ch = 80; - const domeBottom = 36; // y where the canopy ends - // Draw dome as upper half only — fill a tall ellipse then cover the bottom half - g.fillStyle(0xff2020); - g.fillEllipse(cw / 2, domeBottom, cw - 4, 56); // tall ellipse centered at rim - // Cover lower half so only the dome (upper half) remains - g.fillStyle(0x000000, 0.0); - // We can't erase, so draw the dome differently: - // Use a filled arc approach — draw overlapping circles for dome shape - g.clear(); - // Red canopy dome — build with filled upper-half ellipse - // Panel 1 (red) — left - g.fillStyle(0xff2020); - g.fillRoundedRect(2, 4, 14, domeBottom - 4, { tl: 10, tr: 4, bl: 0, br: 0 }); - // Panel 2 (white) - g.fillStyle(0xffffff); - g.fillRoundedRect(16, 2, 10, domeBottom - 2, { tl: 6, tr: 6, bl: 0, br: 0 }); - // Panel 3 (red) — center - g.fillStyle(0xff2020); - g.fillRoundedRect(26, 1, 12, domeBottom - 1, { tl: 8, tr: 8, bl: 0, br: 0 }); - // Panel 4 (white) - g.fillStyle(0xffffff); - g.fillRoundedRect(38, 2, 10, domeBottom - 2, { tl: 6, tr: 6, bl: 0, br: 0 }); - // Panel 5 (red) — right - g.fillStyle(0xff2020); - g.fillRoundedRect(48, 4, 14, domeBottom - 4, { tl: 4, tr: 10, bl: 0, br: 0 }); - // Top cap to round off the top - g.fillStyle(0xff2020); - g.fillEllipse(cw / 2, 6, 36, 12); - // Scalloped bottom edge — small arcs to suggest billowy fabric - g.fillStyle(0xff2020); - for (let sx = 5; sx < cw - 4; sx += 12) { - g.fillEllipse(sx + 6, domeBottom, 13, 6); - } - // Dark rim outline along bottom edge - g.lineStyle(2, 0x880000); - g.lineBetween(2, domeBottom, cw - 2, domeBottom); - // Panel divider lines - g.lineStyle(1, 0xaa0000); - g.lineBetween(16, 6, 16, domeBottom); - g.lineBetween(26, 4, 26, domeBottom); - g.lineBetween(38, 4, 38, domeBottom); - g.lineBetween(48, 6, 48, domeBottom); - // Outer rim outline - g.lineStyle(2, 0x880000); - g.strokeRoundedRect(2, 2, cw - 4, domeBottom, { tl: 14, tr: 14, bl: 0, br: 0 }); - // Strings — fan out from canopy rim to a gather point near player - g.lineStyle(1, 0x654321); - const gatherY = ch - 2; - const gatherX = cw / 2; - g.lineBetween(4, domeBottom + 2, gatherX - 4, gatherY); - g.lineBetween(16, domeBottom + 2, gatherX - 2, gatherY); - g.lineBetween(cw / 2, domeBottom + 2, gatherX, gatherY); - g.lineBetween(48, domeBottom + 2, gatherX + 2, gatherY); - g.lineBetween(cw - 4, domeBottom + 2, gatherX + 4, gatherY); - g.generateTexture('parachute', cw, ch); - // Coin frame 0 (circle) - g.clear(); - g.fillStyle(0xffd24a); - g.fillCircle(12, 12, 9); - g.fillStyle(0xb88a1f); - g.fillRect(11, 3, 2, 18); - g.generateTexture('coin0', 24, 24); - // Coin frame 1 (thin) - g.clear(); - g.fillStyle(0xffd24a); - g.fillRect(9, 3, 6, 18); - g.fillStyle(0xb88a1f); - g.fillRect(11, 3, 2, 18); - g.generateTexture('coin1', 24, 24); - // Water tile — blue gradient with a subtle wave highlight - g.clear(); - g.fillStyle(0x1a5276); - g.fillRect(0, 0, BLOCK, BLOCK); - g.fillStyle(0x2471a3); - g.fillRect(0, 0, BLOCK, BLOCK * 0.3); - g.fillStyle(0x85c1e9, 0.5); - g.fillRect(4, 2, BLOCK * 0.3, 3); - g.fillStyle(0x85c1e9, 0.4); - g.fillRect(BLOCK * 0.55, 6, BLOCK * 0.25, 2); - g.generateTexture('water', BLOCK, BLOCK); - // Crocodile — Side view with tail, head poking above water - // Both textures share the same back/body y-positions so swapping doesn't - // make the croc rise out of the water. - const crW = 64, crH = 22; - const backY = 6; // top of back ridge — same in both states - // Mouth closed (safe to stomp) - g.clear(); - // Tail — tapers to the left - g.fillStyle(0x3d5c1e); - g.fillTriangle(0, backY + 4, 14, backY + 2, 14, backY + 8); - g.fillStyle(0x2d4a14); - g.fillTriangle(0, backY + 4, 8, backY + 3, 8, backY + 6); // darker tip - // Tail ridges - g.lineStyle(1, 0x2d4a14); - g.lineBetween(4, backY + 3, 4, backY + 6); - g.lineBetween(8, backY + 2, 8, backY + 7); - // Body/back — long green shape - g.fillStyle(0x3d5c1e); - g.fillRoundedRect(12, backY, crW - 12, 12, { tl: 3, tr: 2, bl: 3, br: 2 }); - // Snout — extends forward (right side) - g.fillStyle(0x4a6e23); - g.fillRoundedRect(crW - 18, backY + 2, 18, 8, { tl: 0, tr: 3, bl: 0, br: 3 }); - // Darker dorsal ridge with bumps - g.fillStyle(0x2d4a14); - g.fillRect(14, backY, crW - 32, 3); - for (let bx = 16; bx < crW - 20; bx += 6) { - g.fillRect(bx, backY + 1, 3, 2); - } - // Nostril - g.fillStyle(0x1a2e0a); - g.fillCircle(crW - 4, backY + 5, 1); - // Eye — yellow with black pupil - g.fillStyle(0xffdd00); - g.fillCircle(crW - 20, backY + 4, 3); - g.fillStyle(0x111111); - g.fillCircle(crW - 19, backY + 4, 1.5); - // Jaw line - g.lineStyle(1, 0x2d4a14); - g.lineBetween(crW - 18, backY + 8, crW - 2, backY + 8); - // Teeth hints along closed jaw - g.fillStyle(0xeeeeee); - for (let tx = crW - 16; tx < crW - 2; tx += 4) { - g.fillTriangle(tx, backY + 8, tx + 2, backY + 8, tx + 1, backY + 10); - } - g.generateTexture('croc_closed', crW, crH); - // Mouth open (danger!) — back stays at same y, only jaws move - g.clear(); - // Tail — same as closed - g.fillStyle(0x3d5c1e); - g.fillTriangle(0, backY + 4, 14, backY + 2, 14, backY + 8); - g.fillStyle(0x2d4a14); - g.fillTriangle(0, backY + 4, 8, backY + 3, 8, backY + 6); - g.lineStyle(1, 0x2d4a14); - g.lineBetween(4, backY + 3, 4, backY + 6); - g.lineBetween(8, backY + 2, 8, backY + 7); - // Body/back — same position as closed - g.fillStyle(0x3d5c1e); - g.fillRoundedRect(12, backY, crW - 30, 10, { tl: 3, tr: 2, bl: 3, br: 2 }); - // Dorsal ridge — same - g.fillStyle(0x2d4a14); - g.fillRect(14, backY, crW - 32, 3); - for (let bx = 16; bx < crW - 20; bx += 6) { - g.fillRect(bx, backY + 1, 3, 2); - } - // Upper jaw — tilted up from back line - g.fillStyle(0x4a6e23); - g.fillRoundedRect(crW - 18, backY - 2, 18, 6, { tl: 0, tr: 3, bl: 0, br: 0 }); - // Lower jaw — drops down into water - g.fillStyle(0x4a6e23); - g.fillRoundedRect(crW - 18, backY + 10, 18, 6, { tl: 0, tr: 0, bl: 0, br: 3 }); - // Red mouth interior - g.fillStyle(0xcc2222); - g.fillRect(crW - 16, backY + 4, 14, 6); - // Upper teeth - g.fillStyle(0xffffff); - for (let tx = crW - 16; tx < crW - 2; tx += 4) { - g.fillTriangle(tx, backY + 4, tx + 2, backY + 4, tx + 1, backY + 6); - } - // Lower teeth - for (let tx = crW - 16; tx < crW - 2; tx += 4) { - g.fillTriangle(tx, backY + 10, tx + 2, backY + 10, tx + 1, backY + 8); - } - // Nostril - g.fillStyle(0x1a2e0a); - g.fillCircle(crW - 4, backY - 1, 1); - // Eye — yellow with black pupil (same as closed) - g.fillStyle(0xffdd00); - g.fillCircle(crW - 20, backY + 1, 3); - g.fillStyle(0x111111); - g.fillCircle(crW - 19, backY + 1, 1.5); - g.generateTexture('croc_open', crW, crH); - // Fish — small side-view fish for bridge gaps - const fW = 20, fH = 14; - g.clear(); - // Body — orange/gold oval - g.fillStyle(0xff8800); - g.fillRoundedRect(2, 3, fW - 6, fH - 6, 4); - // Belly highlight - g.fillStyle(0xffbb44); - g.fillRoundedRect(4, 6, fW - 10, 4, 2); - // Tail fin - g.fillStyle(0xff6600); - g.fillTriangle(0, 3, 0, fH - 3, 5, fH / 2); - // Dorsal fin - g.fillStyle(0xff6600); - g.fillTriangle(8, 3, 14, 3, 11, 0); - // Eye - g.fillStyle(0xffffff); - g.fillCircle(fW - 7, 6, 2); - g.fillStyle(0x111111); - g.fillCircle(fW - 6, 6, 1); - // Mouth - g.lineStyle(1, 0xcc4400); - g.lineBetween(fW - 3, 7, fW - 1, 7); - g.generateTexture('fish', fW, fH); - // Bounce pad (spring block) - g.clear(); - g.fillStyle(0xff6600); - g.fillRect(0, 0, BLOCK, BLOCK); - g.fillStyle(0xff9933); - g.fillRect(4, 4, BLOCK - 8, BLOCK / 3); - g.fillStyle(0xcc4400); - g.fillRect(0, 0, BLOCK, 2); - g.fillRect(0, BLOCK - 2, BLOCK, 2); - g.fillRect(0, 0, 2, BLOCK); - g.fillRect(BLOCK - 2, 0, 2, BLOCK); - g.fillStyle(0xffcc00); - g.fillRect(BLOCK / 4, BLOCK / 3, BLOCK / 2, 4); - g.fillRect(BLOCK / 4, BLOCK / 3 + 8, BLOCK / 2, 4); - g.generateTexture('bounce_pad', BLOCK, BLOCK); - g.destroy(); - } - addDecorations() { - // Semi-transparent tiled background — mountains peeking through - // The image is 320×180, tile it across a wide area with slow parallax - for (let i = 0; i < 30; i++) { - this.add.image(i * W * 0.5, H / 2, 'background') - .setDisplaySize(W * 0.5, H) - .setAlpha(0.18) - .setScrollFactor(0.05) - .setDepth(-5); - } - // Hills behind the ground — very subtle - for (let i = 0; i < 20; i++) { - const hx = i * 500 + Math.random() * 300; - const isSmall = Math.random() < 0.5; - const tex = isSmall ? 'hill_0' : 'hill_1'; - const hh = isSmall ? 64 : 96; - this.add.image(hx, GROUND_Y - hh / 2 + 10, tex) - .setDisplaySize(isSmall ? 64 : 64, hh) - .setAlpha(0.12) - .setScrollFactor(0.3) - .setDepth(-2); - } - // Bushes at ground level — decorative - for (let i = 0; i < 25; i++) { - const bx = i * 400 + Math.random() * 200; - const isBig = Math.random() < 0.4; - const tex = isBig ? 'big_bush' : 'small_bush'; - this.add.image(bx, GROUND_Y - 8, tex) - .setDisplaySize(isBig ? 96 : 64, 32) - .setAlpha(0.2) - .setScrollFactor(0.5) - .setDepth(-1); - } - } - extendGround(fromX, toX) { - for (let x = Math.floor(fromX / BLOCK) * BLOCK; x < toX; x += BLOCK) { - if (this.isInGap(x + BLOCK / 2)) - continue; - // skip if already there - const exists = this.groundGroup.getChildren().some((g) => Math.abs(g.x - (x + BLOCK / 2)) < 1); - if (exists) - continue; - const g = this.groundGroup.create(x + BLOCK / 2, GROUND_Y + BLOCK / 2, 'grass_block'); - g.setDisplaySize(BLOCK, BLOCK); - g.refreshBody(); - const BIOME_TINTS = [0xffffff, 0xdec487, 0xb39ddb, 0xb3e5fc]; - g.setTint(BIOME_TINTS[this.currentBiome % 4]); - } - } - isInGap(wx) { - for (const gap of this.gaps) { - if (wx >= gap.start && wx < gap.end) - return true; - } - return false; - } - /** Returns true if wx is near any solid obstacle (pipe, brick, qblock, bounce pad). */ - isNearObstacle(wx) { - const check = (group) => { - const children = group.getChildren(); - for (const p of children) { - if (!p.active) - continue; - if (Math.abs(wx - p.x) < BLOCK * 1.2) - return true; - } - return false; - }; - return check(this.pipeGroup) || check(this.brickGroup) || check(this.qblockGroup) || check(this.bounceGroup) || check(this.fireGroup); - } - /** Fill a gap with decorative water tiles. */ - fillWater(gapX, gapW) { - const startY = GROUND_Y + BLOCK * 0.1; - const rows = Math.ceil((H - startY) / BLOCK) + 1; - // Place water tiles at the exact same grid positions where ground blocks were removed - for (let gx = Math.floor(gapX / BLOCK) * BLOCK; gx < gapX + gapW; gx += BLOCK) { - const cx = gx + BLOCK / 2; - // Only place if this position is inside the gap - if (!this.isInGap(cx)) - continue; - for (let row = 0; row < rows; row++) { - const w = this.add.image(cx, startY + row * BLOCK + BLOCK / 2, 'water'); - w.setDisplaySize(BLOCK, BLOCK); - w.setDepth(-1); - } - } - } - generateLevel(lo, hi) { - let x = Math.max(lo, this.genX); - let lastPattern = -1; - while (x < hi) { - // Varied spacing: mix short (2-3), medium (4-6), and occasional long (7-10) gaps - const spacingRoll = Math.random(); - const spacing = spacingRoll < 0.3 ? (2 + Math.floor(Math.random() * 2)) - : spacingRoll < 0.75 ? (4 + Math.floor(Math.random() * 3)) - : (7 + Math.floor(Math.random() * 4)); - x += spacing * BLOCK; - // Pick a pattern using shuffle-style selection (avoid repeating last pattern) - let pattern; - do { - pattern = Math.floor(Math.random() * 20); - } while (pattern === lastPattern); - lastPattern = pattern; - if (pattern === 0) { - // Coin arch — 5-6 coins in a parabolic arc - const arcLen = 5 + Math.floor(Math.random() * 2); - for (let i = 0; i < arcLen; i++) { - const t = i / (arcLen - 1); - const arcY = GROUND_Y - BLOCK * 1.5 - Math.sin(t * Math.PI) * BLOCK * 2; - const c = this.coinGroup.create(x + i * BLOCK + BLOCK / 2, arcY, 'coin0'); - c.setDisplaySize(BLOCK * 0.5, BLOCK * 0.65); - c.body.setAllowGravity(false); - c.body.setSize(12, 18); - } - x += arcLen * BLOCK; - } - else if (pattern === 1) { - // Block row with ?-block - const n = 3 + Math.floor(Math.random() * 2); - const y = GROUND_Y - BLOCK * 2; - const qi = Math.floor(Math.random() * n); - for (let i = 0; i < n; i++) { - const bx = x + i * BLOCK; - if (i === qi) { - const q = this.qblockGroup.create(bx + BLOCK / 2, y + BLOCK / 2, 'qblock_img'); - q.setData('hit', false); - q.setData('reward', 'coin'); - q.setDisplaySize(BLOCK, BLOCK); - q.refreshBody(); - } - else { - const b = this.brickGroup.create(bx + BLOCK / 2, y + BLOCK / 2, 'brown_block'); - b.setDisplaySize(BLOCK, BLOCK); - b.refreshBody(); - } - } - // Coins above block row - for (let i = 0; i < n; i++) { - if (Math.random() < 0.4) { - const c = this.coinGroup.create(x + i * BLOCK + BLOCK / 2, y - BLOCK / 2, 'coin0'); - c.setDisplaySize(BLOCK * 0.5, BLOCK * 0.65); - c.body.setAllowGravity(false); - c.body.setSize(12, 18); - } - } - x += n * BLOCK; - // Enemy patrolling on top of the block row (~60% chance, ground types only) - if (Math.random() < 0.6) { - const enemyX = x - Math.floor(n / 2) * BLOCK; - const e = this.spawnEnemyAt('goomba', enemyX, y - BLOCK, true); - if (e) { - e.setVelocityX(0); - e.setData('patrolAwait', true); - e.setData('patrolLeft', enemyX - BLOCK * (n / 2 - 0.5)); - e.setData('patrolRight', enemyX + BLOCK * (n / 2 - 0.5)); - } - } - } - else if (pattern === 2) { - // Bounce pad — spring block that launches the player - const pad = this.bounceGroup.create(x + BLOCK / 2, GROUND_Y - BLOCK / 2, 'bounce_pad'); - pad.setDisplaySize(BLOCK, BLOCK); - pad.refreshBody(); - // Coins high above the pad as reward - for (let i = 0; i < 3; i++) { - const c = this.coinGroup.create(x + BLOCK / 2, GROUND_Y - BLOCK * (4 + i), 'coin0'); - c.setDisplaySize(BLOCK * 0.5, BLOCK * 0.65); - c.body.setAllowGravity(false); - c.body.setSize(12, 18); - } - x += BLOCK * 2; - } - else if (pattern === 3) { - // Pipe — 1-2 blocks tall (jumpable) - const pipeBlocks = 1 + Math.floor(Math.random() * 2); - const ph = pipeBlocks * BLOCK; - const pw = 2 * BLOCK; - const py = GROUND_Y - ph; - const isGold = Math.random() < 0.25; - const isWarp = !isGold && Math.random() < 0.4; - let topSeg = null; - for (let yy = py; yy < GROUND_Y; yy += BLOCK) { - const isTop = yy === py; - const tex = isTop && isGold ? 'pipe_gold' : isTop && isWarp ? 'pipe_warp' : 'pipe_body'; - const seg = this.pipeGroup.create(x + pw / 2, yy + BLOCK / 2, tex); - seg.setDisplaySize(BLOCK * 2, BLOCK); - seg.refreshBody(); - if (isTop) - topSeg = seg; - } - if (topSeg) { - if (isWarp) - topSeg.setData('warp', true); - if (isGold) - topSeg.setData('gold', true); - } - // Piranha plant on regular pipes (~60% chance) - if (!isWarp && !isGold && Math.random() < 0.6) { - const p = this.piranhaGroup.create(x + pw / 2, py - 8, 'piranha_0'); - p.setOrigin(0.5, 1); - p.setDisplaySize(BLOCK * 0.8, BLOCK); - p.body.setAllowGravity(false); - p.setData('pipeX', x + pw / 2); - p.setData('pipeTopY', py); - p.setData('timer', Math.random() * 4000); - p.setData('exposed', false); - p.setVisible(false); - } - x += pw; - } - else if (pattern === 4) { - // Enemy — single (combined enemy types, random pick) - const types = ['goomba', 'goomba', 'koopa', 'rkoopa']; - this.spawnEnemy(types[Math.floor(Math.random() * types.length)], x); - x += BLOCK * 2; - } - else if (pattern === 5) { - // Enemy pair — two different enemies spawned close together - const types = ['goomba', 'koopa', 'rkoopa']; - const t1 = types[Math.floor(Math.random() * types.length)]; - let t2 = types[Math.floor(Math.random() * types.length)]; - while (t2 === t1) - t2 = types[Math.floor(Math.random() * types.length)]; - this.spawnEnemy(t1, x); - this.spawnEnemy(t2, x + BLOCK * 2); - x += BLOCK * 4; - } - else if (pattern === 6) { - // Combined ?-block — mushroom or coin reward - const reward = Math.random() < 0.35 ? 'mushroom' : 'coin'; - const q = this.qblockGroup.create(x + BLOCK / 2, GROUND_Y - BLOCK * 2 + BLOCK / 2, 'qblock_img'); - q.setData('hit', false); - q.setData('reward', reward); - q.setDisplaySize(BLOCK, BLOCK); - q.refreshBody(); - x += BLOCK; - } - else if (pattern === 7) { - // Ascending staircase with enemy on top (max 3 steps for reachability) - const h = 2 + Math.floor(Math.random() * 2); - for (let step = 0; step < h; step++) { - const b = this.brickGroup.create(x + step * BLOCK + BLOCK / 2, GROUND_Y - (step + 1) * BLOCK + BLOCK / 2, 'brown_block'); - b.setDisplaySize(BLOCK, BLOCK); - b.refreshBody(); - } - // 50% chance enemy on the top step - if (Math.random() < 0.5) { - const topX = x + (h - 1) * BLOCK + BLOCK / 2; - const topY = GROUND_Y - h * BLOCK - BLOCK; - this.spawnEnemyAt('goomba', topX, topY); - } - x += h * BLOCK; - } - else if (pattern === 8) { - // Water gap - const gapW = (3 + Math.floor(Math.random() * 2)) * BLOCK; - this.gaps.push({ start: x, end: x + gapW }); - this.groundGroup.getChildren().forEach((g) => { - if (g.x >= x && g.x < x + gapW) - g.destroy(); - }); - this.fillWater(x, gapW); - // 50% chance: fire eruption hazard in the gap - if (Math.random() < 0.5) { - const fireX = x + gapW / 2; - const f = this.fireGroup.create(fireX, GROUND_Y + BLOCK * 2, 'fire_column'); - f.setDisplaySize(BLOCK * 0.8, BLOCK * 2); - f.setOrigin(0.5, 1); - f.body.setAllowGravity(false); - f.setData('baseY', GROUND_Y + BLOCK * 2); - f.setData('gapX', fireX); - f.setData('active', false); - f.setVisible(false); - f.body.enable = false; - } - x += gapW; - } - else if (pattern === 9) { - // Collapsing bridge over gap - const bridgeLen = 4 + Math.floor(Math.random() * 4); - const gapW = bridgeLen * BLOCK; - this.gaps.push({ start: x, end: x + gapW }); - this.groundGroup.getChildren().forEach((g) => { - if (g.x >= x && g.x < x + gapW) - g.destroy(); - }); - this.fillWater(x, gapW); - // Decide which tiles are unstable: first & last always stable, - // never two consecutive unstable, max ~40% unstable - const unstableMap = new Array(bridgeLen).fill(false); - const maxUnstable = Math.floor(bridgeLen * 0.4); - let unstableCount = 0; - for (let i = 1; i < bridgeLen - 1; i++) { - if (unstableCount >= maxUnstable) - break; - if (unstableMap[i - 1]) - continue; // previous was unstable, skip - if (Math.random() < 0.35) { - unstableMap[i] = true; - unstableCount++; - } - } - for (let i = 0; i < bridgeLen; i++) { - const bx = x + i * BLOCK + BLOCK / 2; - const bt = this.bridgeGroup.create(bx, GROUND_Y + BLOCK / 2, 'bridge_tile'); - bt.setDisplaySize(BLOCK, BLOCK); - bt.refreshBody(); - bt.setData('unstable', unstableMap[i]); - bt.setData('collapsing', false); - // Spawn a fish under each unstable tile - if (unstableMap[i]) { - const fish = this.fishGroup.create(bx, GROUND_Y + BLOCK * 2, 'fish'); - fish.setOrigin(0.5, 0.5); - fish.body.setAllowGravity(false); - fish.setVisible(false); - fish.body.enable = false; - fish.setData('homeX', bx); - fish.setData('jumped', false); - } - } - x += gapW; - } - else if (pattern === 10) { - // Multi-tier platform — 2 levels with room to run - const lowerY = GROUND_Y - BLOCK * 2; - for (let i = 0; i < 4; i++) { - if (i === 1) { - const q = this.qblockGroup.create(x + i * BLOCK + BLOCK / 2, lowerY + BLOCK / 2, 'qblock_img'); - q.setData('hit', false); - q.setData('reward', 'coin'); - q.setDisplaySize(BLOCK, BLOCK); - q.refreshBody(); - } - else { - const b = this.brickGroup.create(x + i * BLOCK + BLOCK / 2, lowerY + BLOCK / 2, 'brown_block'); - b.setDisplaySize(BLOCK, BLOCK); - b.refreshBody(); - } - } - const upperY = GROUND_Y - BLOCK * 5.5; - for (let i = 1; i <= 2; i++) { - const b = this.brickGroup.create(x + i * BLOCK + BLOCK / 2, upperY + BLOCK / 2, 'brown_block'); - b.setDisplaySize(BLOCK, BLOCK); - b.refreshBody(); - } - const c = this.coinGroup.create(x + 1.5 * BLOCK + BLOCK / 2, upperY - BLOCK / 2, 'coin0'); - c.setDisplaySize(BLOCK * 0.5, BLOCK * 0.65); - c.body.setAllowGravity(false); - c.body.setSize(12, 18); - x += 4 * BLOCK; - } - else if (pattern === 11) { - // Mixed brick/qblock cluster with enemy - const clusterLen = 5; - const clusterY = GROUND_Y - BLOCK * 2; - const qPositions = new Set(); - const qCount = 1 + Math.floor(Math.random() * 2); - while (qPositions.size < qCount) { - qPositions.add(Math.floor(Math.random() * clusterLen)); - } - for (let i = 0; i < clusterLen; i++) { - if (qPositions.has(i)) { - const q = this.qblockGroup.create(x + i * BLOCK + BLOCK / 2, clusterY + BLOCK / 2, 'qblock_img'); - q.setData('hit', false); - q.setData('reward', 'coin'); - q.setDisplaySize(BLOCK, BLOCK); - q.refreshBody(); - } - else { - const b = this.brickGroup.create(x + i * BLOCK + BLOCK / 2, clusterY + BLOCK / 2, 'brown_block'); - b.setDisplaySize(BLOCK, BLOCK); - b.refreshBody(); - } - } - x += clusterLen * BLOCK; - // Enemy on top of the cluster - if (Math.random() < 0.5) { - this.spawnEnemyAt('goomba', x - 2 * BLOCK, clusterY - BLOCK); - } - } - else if (pattern === 12) { - // Elevated bridge with enemy - const bridgeLen = 4 + Math.floor(Math.random() * 3); - const bridgeY = GROUND_Y - BLOCK * 2; - for (let i = 0; i < bridgeLen; i++) { - const b = this.brickGroup.create(x + i * BLOCK + BLOCK / 2, bridgeY + BLOCK / 2, 'brown_block'); - b.setDisplaySize(BLOCK, BLOCK); - b.refreshBody(); - } - // Coins along the bridge - for (let i = 0; i < bridgeLen; i += 2) { - const c = this.coinGroup.create(x + i * BLOCK + BLOCK / 2, bridgeY - BLOCK / 2, 'coin0'); - c.setDisplaySize(BLOCK * 0.5, BLOCK * 0.65); - c.body.setAllowGravity(false); - c.body.setSize(12, 18); - } - this.spawnEnemyAt('goomba', x + BLOCK, bridgeY - BLOCK); - x += bridgeLen * BLOCK; - } - else if (pattern === 13) { - // Descending staircase - const h = 2 + Math.floor(Math.random() * 2); - for (let step = 0; step < h; step++) { - const b = this.brickGroup.create(x + step * BLOCK + BLOCK / 2, GROUND_Y - (h - step) * BLOCK + BLOCK / 2, 'brown_block'); - b.setDisplaySize(BLOCK, BLOCK); - b.refreshBody(); - } - // Enemy on top - if (Math.random() < 0.4) { - this.spawnEnemyAt('goomba', x + BLOCK / 2, GROUND_Y - h * BLOCK - BLOCK); - } - x += h * BLOCK; - } - else if (pattern === 14) { - // Floating coins — zigzag pattern - const zigLen = 4 + Math.floor(Math.random() * 2); - for (let i = 0; i < zigLen; i++) { - const zigY = GROUND_Y - BLOCK * 2 - (i % 2 === 0 ? 0 : BLOCK); - const c = this.coinGroup.create(x + i * BLOCK + BLOCK / 2, zigY, 'coin0'); - c.setDisplaySize(BLOCK * 0.5, BLOCK * 0.65); - c.body.setAllowGravity(false); - c.body.setSize(12, 18); - } - x += zigLen * BLOCK; - } - else if (pattern === 15) { - // Spike gauntlet — spike, platform, spike, platform pattern - const pairs = 2 + Math.floor(Math.random() * 2); // 2-3 spike-platform pairs - const spacing = BLOCK * 1.6; - for (let i = 0; i < pairs * 2 + 1; i++) { - const sx = x + i * spacing; - if (i % 2 === 0) { - // Spike - const spike = this.add.image(sx + BLOCK / 2, GROUND_Y - BLOCK * 0.3, 'spikes_tile'); - spike.setDisplaySize(BLOCK, BLOCK * 0.6); - spike.setDepth(2); - const hitZone = this.fireGroup.create(sx + BLOCK / 2, GROUND_Y - BLOCK * 0.2, 'spikes_tile'); - hitZone.setDisplaySize(BLOCK * 0.9, BLOCK * 0.4); - hitZone.setAlpha(0); - hitZone.body.setAllowGravity(false); - hitZone.body.enable = true; - // Warm glow behind spikes (Ellipse Shape — WebGL batched) - const spikeGlow = this.add.ellipse(sx + BLOCK / 2, GROUND_Y - BLOCK * 0.4, BLOCK * 1.8, BLOCK * 1.6, 0xff4400, 1.0); - spikeGlow.setDepth(1); - spikeGlow.setAlpha(0.2); - spikeGlow.setBlendMode(Phaser.BlendModes.ADD); - hitZone.setData('manualGlow', spikeGlow); - hitZone.setData('hasGlow', true); - // Sparks that shoot UP high above the spikes - const sparks = this.add.particles(sx + BLOCK / 2, GROUND_Y - BLOCK * 0.6, 'coin0', { - speed: { min: 40, max: 100 }, - angle: { min: 250, max: 290 }, - scale: { start: 0.2, end: 0 }, - alpha: { start: 0.9, end: 0 }, - lifespan: { min: 500, max: 1200 }, - frequency: 120, - quantity: 2, - tint: [0xff2200, 0xff4400, 0xff6600, 0xffaa00, 0xffff00], - blendMode: 'ADD', - gravityY: 30, - }); - sparks.setDepth(3); - hitZone.setData('sparks', sparks); - } - else { - // Small raised platform to land on between spikes - const plat = this.groundGroup.create(sx + BLOCK / 2, GROUND_Y - BLOCK * 0.5 + BLOCK / 2, 'grass_block'); - plat.setDisplaySize(BLOCK, BLOCK); - plat.refreshBody(); - } - } - x += (pairs * 2 + 1) * spacing; - } - else if (pattern === 16) { - // Staircase up — 3 tiers ascending, enough clearance to run on each - for (let tier = 0; tier < 3; tier++) { - const tierY = GROUND_Y - BLOCK * (2 + tier * 3); - const tierX = x + tier * BLOCK * 2.5; - const width = 4 - tier; // wider at bottom - for (let i = 0; i < width; i++) { - // Top tier, first block → ?-block with reward - if (tier === 2 && i === 0) { - const q = this.qblockGroup.create(tierX + i * BLOCK + BLOCK / 2, tierY + BLOCK / 2, 'qblock_img'); - q.setData('hit', false); - q.setData('reward', Math.random() < 0.4 ? 'mushroom' : 'coin'); - q.setDisplaySize(BLOCK, BLOCK); - q.refreshBody(); - } - else { - const b = this.brickGroup.create(tierX + i * BLOCK + BLOCK / 2, tierY + BLOCK / 2, 'brown_block'); - b.setDisplaySize(BLOCK, BLOCK); - b.refreshBody(); - } - } - // Coin on each tier - const c = this.coinGroup.create(tierX + BLOCK / 2, tierY - BLOCK / 2, 'coin0'); - c.setDisplaySize(BLOCK * 0.5, BLOCK * 0.65); - c.body.setAllowGravity(false); - c.body.setSize(12, 18); - } - x += 8 * BLOCK; - } - else if (pattern === 17) { - // Tower — 3-high column with platforms branching off, room to run - const towerX = x + BLOCK * 3; - for (let level = 0; level < 3; level++) { - const ly = GROUND_Y - BLOCK * (2 + level * 3); - // Central column block - const b = this.brickGroup.create(towerX + BLOCK / 2, ly + BLOCK / 2, 'brown_block'); - b.setDisplaySize(BLOCK, BLOCK); - b.refreshBody(); - // Side platform (alternating left/right) - const side = level % 2 === 0 ? -1 : 1; - for (let s = 1; s <= 2; s++) { - const sb = this.brickGroup.create(towerX + side * s * BLOCK + BLOCK / 2, ly + BLOCK / 2, 'brown_block'); - sb.setDisplaySize(BLOCK, BLOCK); - sb.refreshBody(); - } - // Coin on the platform - const c = this.coinGroup.create(towerX + side * BLOCK + BLOCK / 2, ly - BLOCK / 2, 'coin0'); - c.setDisplaySize(BLOCK * 0.5, BLOCK * 0.65); - c.body.setAllowGravity(false); - c.body.setSize(12, 18); - } - x += 8 * BLOCK; - } - else if (pattern === 18) { - // Pyramid — wide base narrowing up, 3 levels with running room - const baseW = 5; - for (let level = 0; level < 3; level++) { - const ly = GROUND_Y - BLOCK * (2 + level * 3); - const lw = baseW - level * 2; - const lx = x + level * BLOCK; - for (let i = 0; i < lw; i++) { - const isQ = level === 2 && i === Math.floor(lw / 2); - if (isQ) { - const q = this.qblockGroup.create(lx + i * BLOCK + BLOCK / 2, ly + BLOCK / 2, 'qblock_img'); - q.setData('hit', false); - q.setData('reward', Math.random() < 0.3 ? 'mushroom' : 'coin'); - q.setDisplaySize(BLOCK, BLOCK); - q.refreshBody(); - } - else { - const b = this.brickGroup.create(lx + i * BLOCK + BLOCK / 2, ly + BLOCK / 2, 'brown_block'); - b.setDisplaySize(BLOCK, BLOCK); - b.refreshBody(); - } - } - } - // Enemy patrolling the base - if (Math.random() < 0.5) { - this.spawnEnemyAt('goomba', x + BLOCK, GROUND_Y - BLOCK); - } - x += (baseW + 1) * BLOCK; - } - else if (pattern === 19) { - // Small croc pond — narrow water gap with 1-2 crocodiles - const gapW = (2 + Math.floor(Math.random() * 2)) * BLOCK; // 2-3 blocks wide - this.gaps.push({ start: x, end: x + gapW }); - this.groundGroup.getChildren().forEach((g) => { - if (g.x >= x && g.x < x + gapW) - g.destroy(); - }); - this.fillWater(x, gapW); - const numCrocs = gapW >= BLOCK * 3 ? 2 : 1; - const spacing = gapW / (numCrocs + 1); - for (let ci = 0; ci < numCrocs; ci++) { - const cx = x + spacing * (ci + 1); - const cy = GROUND_Y + 8; // Sit on water surface - const croc = this.crocGroup.create(cx, cy, 'croc_closed'); - croc.setOrigin(0.5, 1); - croc.body.setAllowGravity(false); - croc.body.setSize(58, 16); - croc.setData('mouthOpen', false); - croc.setData('timer', this.time.now + 2000 + Math.random() * 2000); - croc.setData('gapStart', x); - croc.setData('gapEnd', x + gapW); - croc.setData('swimDir', Math.random() < 0.5 ? 1 : -1); - croc.setVelocityX(croc.getData('swimDir') * 30); - } - x += gapW; - } - } - this.genX = Math.max(this.genX, x); - this.extendGround(0, this.genX + W); - // Scatter decorative bushes in the new section - for (let bx = lo; bx < hi; bx += BLOCK * 6 + Math.floor(Math.random() * BLOCK * 4)) { - if (this.isInGap(bx)) - continue; - const isBig = Math.random() < 0.3; - const tex = isBig ? 'big_bush' : 'small_bush'; - const bush = this.add.image(bx, GROUND_Y, tex); - bush.setDisplaySize(isBig ? BLOCK * 1.5 : BLOCK, BLOCK * 0.5); - bush.setOrigin(0.5, 1); - bush.setDepth(1); - bush.setAlpha(0.8); - } - // Scatter ground-level coin trails between obstacles (skip coins near pipes) - for (let cx = lo; cx < hi; cx += BLOCK * 8 + Math.floor(Math.random() * BLOCK * 6)) { - if (this.isInGap(cx)) - continue; - if (Math.random() < 0.4) - continue; // skip some - const trailLen = 2 + Math.floor(Math.random() * 3); - for (let i = 0; i < trailLen; i++) { - const coinX = cx + i * BLOCK; - if (this.isInGap(coinX)) - break; - if (this.isNearObstacle(coinX)) - break; - const c = this.coinGroup.create(coinX + BLOCK / 2, GROUND_Y - BLOCK * 0.7, 'coin0'); - c.setDisplaySize(BLOCK * 0.5, BLOCK * 0.65); - c.body.setAllowGravity(false); - c.body.setSize(12, 18); - } - } - // Rare heart pickup — extra life, hard to reach (2% chance per section) - if (Math.random() < 0.02) { - // Place high above a random non-gap spot — needs bounce pad or double-jump - const hx = lo + Math.floor(Math.random() * (hi - lo - BLOCK * 4)) + BLOCK * 2; - if (!this.isInGap(hx)) { - const hy = GROUND_Y - BLOCK * (3 + Math.random() * 1.5); // high but reachable with a good jump - const h = this.heartGroup.create(hx, hy, 'heart_anim', 0); - h.setDisplaySize(BLOCK * 0.7, BLOCK * 0.7); - h.body.setAllowGravity(false); - h.anims.play('heart_pulse', true); - // Subtle float animation - this.tweens.add({ - targets: h, - y: hy - BLOCK * 0.3, - duration: 1200, - yoyo: true, - repeat: -1, - ease: 'Sine.easeInOut', - }); - } - } - // Flag checkpoint every ~2500px - this.distanceSinceFlag += (hi - lo); - if (this.distanceSinceFlag > 2500) { - this.distanceSinceFlag = 0; - const flagX = this.genX - BLOCK * 2; - if (!this.isInGap(flagX)) { - for (let i = 0; i < 3; i++) { - const pole = this.add.image(flagX, GROUND_Y - i * BLOCK - BLOCK / 2, 'brown_block'); - pole.setDisplaySize(BLOCK * 0.3, BLOCK); - pole.setDepth(1); - } - const flag = this.flagGroup.create(flagX, GROUND_Y - BLOCK * 3 + BLOCK / 2, 'flag_tile'); - flag.setDisplaySize(BLOCK, BLOCK * 1.5); - flag.setOrigin(0.5, 1); - flag.refreshBody(); - } - } - } - spawnEnemy(kind, x) { - this.spawnEnemyAt(kind, x + BLOCK / 2, GROUND_Y); - } - spawnEnemyAt(kind, x, y, groundOnly = false) { - let roll = Math.random(); - // When spawning on blocks, re-roll if we get a bat (bats fly away) - if (groundOnly && roll >= 0.80) - roll = Math.random() * 0.80; - let tex; - let animKey; - let enemyType; - let speed; - let displayH = BLOCK; - if (roll < 0.30) { - tex = 'enemy'; - animKey = 'enemy_walk'; - enemyType = 'monster'; - speed = -100; - } - else if (roll < 0.55) { - tex = 'enemy_short'; - animKey = 'enemy_short_walk'; - enemyType = 'bulldog'; - speed = -140; - } - else if (roll < 0.80) { - tex = 'enemy_tall'; - animKey = 'enemy_tall_walk'; - enemyType = 'snake'; - speed = -80; - displayH = BLOCK * 1.5; - } - else { - tex = 'bat_0'; - animKey = ''; - enemyType = 'bat'; - speed = -120; - } - const isBat = enemyType === 'bat'; - const e = this.enemyGroup.create(x, y, tex, 0); - e.setOrigin(0.5, 1); - e.setDisplaySize(BLOCK, displayH); - e.body.setGravityY(isBat ? 0 : 1800); - e.body.setAllowGravity(!isBat); - e.setVelocityX(speed); - e.setBounceX(1); - e.setCollideWorldBounds(false); - e.setData('kind', kind); - e.setData('enemyType', enemyType); - e.setData('state', 'walk'); - e.setData('timer', 0); - e.setData('baseY', y); - if (animKey) { - e.anims.play(animKey, true); - } - // Tighten hitbox for tall enemies (snake) — default body is too wide - if (enemyType === 'snake') { - e.body.setSize(10, 28); - e.body.setOffset(3, 4); - } - return e; - } - update(_t, dtMs) { - if (this.dead) { - this.deadTimer -= dtMs; - this.player.setVelocityX(0); - if (this.deadTimer <= 0 && !this.gameOverShown) - this.respawn(); - return; - } - if (this.warping) - return; - if (this.parachuteMode) { - this.updateParachute(dtMs); - return; - } - if (this.invincible > 0) - this.invincible -= dtMs; - if (this.shrinkTimer > 0) - this.shrinkTimer -= dtMs; - if (this.stompGrace > 0) - this.stompGrace -= dtMs; - if (this.fireCooldown > 0) - this.fireCooldown -= dtMs; - this.updatePlayerMovement(dtMs); - if (this.player.y > H + 50) { - this.die(); - return; - } - const edge = this.cameras.main.scrollX + W + 600; - if (edge > this.genX) - this.generateLevel(this.genX, edge); - this.updatePlayerAnimation(); - const camLeft = this.cameras.main.scrollX; - this.updateCoins(camLeft); - this.updateBridges(camLeft); - this.updateFireEruptions(camLeft); - this.updatePiranhas(dtMs, camLeft); - this.enemyGroup.getChildren().forEach(e => this.updateEnemy(e, camLeft)); - this.updateCrocs(camLeft); - this.cleanupOffscreen(camLeft); - } - updateParachute(dtMs) { - // Stop camera from following — terrain stays fixed - this.cameras.main.stopFollow(); - if (this.parachuteSprite) { - this.parachuteSprite.x = this.player.x; - const playerH = PLAYER_H; - this.parachuteSprite.y = this.player.y - playerH + 8; - } - // Full directional control with arrow keys - const camX = this.cameras.main.scrollX; - if (this.cursors.left.isDown) { - this.player.setVelocityX(-200); - } - else if (this.cursors.right.isDown) { - this.player.setVelocityX(200); - } - else { - this.player.setVelocityX(Math.sin(this.time.now / 1200) * 40); - } - if (this.cursors.up.isDown) { - this.player.setVelocityY(-180); - } - else if (this.cursors.down.isDown) { - this.player.setVelocityY(300); - } - // Keep Player within visible screen - if (this.player.x < camX + 30) - this.player.x = camX + 30; - if (this.player.x > camX + W - 30) - this.player.x = camX + W - 30; - if (this.player.y < 40) - this.player.y = 40; - this.parachuteTimer += dtMs; - if (this.parachuteTimer > 1500 && this.parachuteFlyingEnemies.length < 15) { - this.parachuteTimer = 0; - const fromLeft = Math.random() < 0.5; - const camX = this.cameras.main.scrollX; - const ex = fromLeft ? camX - 20 : camX + W + 20; - const ey = this.player.y + (Math.random() - 0.3) * 200; - const fe = this.enemyGroup.create(ex, ey, 'enemy', 0); - fe.setOrigin(0.5, 0.5); - fe.setDisplaySize(BLOCK, BLOCK); - fe.body.setAllowGravity(false); - fe.setVelocityX(fromLeft ? 150 : -150); - fe.setData('kind', 'goomba'); - fe.setData('state', 'flying'); - fe.setData('timer', 0); - this.parachuteFlyingEnemies.push(fe); - } - const pCamLeft = this.cameras.main.scrollX; - this.parachuteFlyingEnemies = this.parachuteFlyingEnemies.filter(e => { - if (!e.active) - return false; - if (e.x < pCamLeft - 100 || e.x > pCamLeft + W + 100) { - e.destroy(); - return false; - } - return true; - }); - const pOnGround = this.player.body.blocked.down; - const falling = this.player.body.velocity.y >= 0; - if (pOnGround && falling && !this.cursors.up.isDown) { - this.endParachute(); - } - // Die if player drifts into water/gap below ground level - if (this.player.y > GROUND_Y + BLOCK) { - this.endParachute(); - this.die(); - return; - } - this.player.anims.stop(); - this.player.setFrame(4); // jump frame while parachuting - if (this.cursors.left.isDown) - this.player.flipX = true; - else if (this.cursors.right.isDown) - this.player.flipX = false; - this.player.setDisplaySize(PLAYER_W, PLAYER_H); - // Check enemy collisions during parachute - this.enemyGroup.getChildren().forEach((e) => { - if (!e.active || e.getData('state') === 'dead') - return; - const dx = Math.abs(this.player.x - e.x); - const dy = this.player.y - e.y; - if (dx < BLOCK * 0.8 && Math.abs(dy) < BLOCK * 0.8) { - if (dy < 0) { - // Player is above enemy — stomp kill - e.setVelocityY(-300); - e.flipY = true; - e.setData('state', 'dead'); - this.time.delayedCall(600, () => { if (e.active) - e.destroy(); }); - this.addScore(300, e.x, e.y - 10); - } - else if (this.invincible <= 0) { - // Enemy hit player from side/below — lose a life - this.endParachute(); - this.die(); - } - } - }); - this.syncScoreToHUD(); - return; - } - updatePlayerMovement(dtMs) { - const running = this.keys.shift.isDown; - // Powered up = faster speed + higher acceleration - const speedMult = this.isBig ? 1.5 : 1; - const maxSpeed = (running ? 320 : 200) * speedMult; - const accel = (running ? 1100 : 800) * speedMult; - if (this.cursors.left.isDown) { - this.player.setAccelerationX(-accel); - this.facingRight = false; - if (this.player.body.velocity.x > 0) - this.player.setVelocityX(this.player.body.velocity.x * 0.7); - } - else if (this.cursors.right.isDown) { - this.player.setAccelerationX(accel); - this.facingRight = true; - if (this.player.body.velocity.x < 0) - this.player.setVelocityX(this.player.body.velocity.x * 0.7); - } - else { - this.player.setAccelerationX(0); - const v = this.player.body.velocity.x; - if (Math.abs(v) < 12) - this.player.setVelocityX(0); - else - this.player.setVelocityX(v * 0.9); - } - if (this.player.body.velocity.x > maxSpeed) - this.player.setVelocityX(maxSpeed); - if (this.player.body.velocity.x < -maxSpeed) - this.player.setVelocityX(-maxSpeed); - const onGround = this.player.body.blocked.down || this.player.body.touching.down; - const touchingWall = this.player.body.blocked.left || this.player.body.blocked.right; - if (onGround) { - this.coyoteTime = 120; // generous coyote time, especially helps near walls - this.hasDoubleJumped = false; - this.canDoubleJump = false; - } - else { - this.coyoteTime = Math.max(0, this.coyoteTime - dtMs); - if (!this.canDoubleJump && this.coyoteTime <= 0) - this.canDoubleJump = true; - } - this.jumpBuffer = Math.max(0, this.jumpBuffer - dtMs); - const jumpKeyDown = this.keys.space.isDown || this.cursors.up.isDown; - const jumpJustPressed = jumpKeyDown && !this.jumpKeyWasDown; - this.jumpKeyWasDown = jumpKeyDown; - if (jumpJustPressed) - this.jumpBuffer = 150; - // Allow jump when on ground OR when pressed against a wall and recently on ground - const canJump = this.coyoteTime > 0 || (touchingWall && this.coyoteTime > -50); - if (this.jumpBuffer > 0 && canJump) { - // Normal jump — higher when powered up - this.player.setVelocityY(this.isBig ? -950 : -820); - this.jumpBuffer = 0; - this.coyoteTime = 0; - this.sfx('nr_jump', 0.2); - } - else if (jumpJustPressed && !onGround && this.canDoubleJump && !this.hasDoubleJumped) { - // Double jump — also boosted when powered - this.player.setVelocityY(this.isBig ? -800 : -700); - this.hasDoubleJumped = true; - this.sfx('nr_jump', 0.15); - } - // Variable jump height: low gravity while ascending and key held. - if (jumpKeyDown && this.player.body.velocity.y < 0) { - this.player.body.setGravityY(900); - } - else { - this.player.body.setGravityY(1800); - } - if (this.isBig && this.fireCooldown <= 0 && - (Phaser.Input.Keyboard.JustDown(this.keys.f) || Phaser.Input.Keyboard.JustDown(this.keys.z))) { - this.throwFireball(); - this.fireCooldown = 200; - } - const camLeft = this.cameras.main.scrollX; - if (this.player.x < camLeft) { - this.player.x = camLeft; - this.player.setVelocityX(0); - } - if (onGround && !this.isInGap(this.player.x)) { - this.lastSafeX = this.player.x; - } - // Warp / golden pipe check — Player must be standing ON TOP of the pipe - if (onGround && this.cursors.down.isDown && !this.warping) { - const pipes = this.pipeGroup.getChildren(); - for (const p of pipes) { - if (!p.getData('warp') && !p.getData('gold')) - continue; - const pdx = Math.abs(this.player.x - p.x); - // Player's feet (y with origin 0.5,1) should be at the pipe top edge - const pipeTop = p.y - BLOCK / 2; - const feetDelta = Math.abs(this.player.y - pipeTop); - // Also accept Player standing at ground level next to a short pipe - if (pdx < BLOCK * 1.5 && feetDelta < BLOCK) { - if (p.getData('gold') && !this.parachuteMode) { - this.startParachute(p); - } - else if (p.getData('warp')) { - this.startWarp(p); - } - break; - } - } - } - } - updatePlayerAnimation() { - const onGround = this.player.body.blocked.down || this.player.body.touching.down; - const vx = this.player.body.velocity.x; - const speed = Math.abs(vx); - // Player-intent direction this frame (from input). Used to detect skid. - const left = this.cursors.left.isDown; - const right = this.cursors.right.isDown; - const intent = right ? 1 : left ? -1 : 0; - const moveDir = vx > 5 ? 1 : vx < -5 ? -1 : 0; - // Animation: use Phaser's anims system with the spritesheet. - const sheetKey = 'player'; - const walkAnim = 'player_walk'; - // Scale — always same size, glow indicates power-up - this.player.setDisplaySize(PLAYER_W, PLAYER_H); - // Pulse the built-in glow FX when powered up - if (this.player.getData('hasGlow')) { - const glowFx = this.player.getData('glowFx'); - if (glowFx) { - glowFx.outerStrength = this.isBig ? 2 + Math.sin(this.time.now / 200) * 1.5 : 0; - } - } - // Ensure correct texture - if (this.player.texture.key !== sheetKey) { - this.player.setTexture(sheetKey, 0); - } - if (!onGround) { - this.player.anims.stop(); - this.player.setFrame(4); // jump - } - else if (intent !== 0 && moveDir !== 0 && intent !== moveDir && speed > 60) { - this.player.anims.stop(); - this.player.setFrame(0); // no skid frame in new set, use idle - } - else if (speed > 20) { - this.player.anims.play(walkAnim, true); - const animFps = Math.max(6, Math.min(20, speed / 20)); - this.player.anims.msPerFrame = 1000 / animFps; - } - else { - this.player.anims.stop(); - this.player.setFrame(0); // idle - } - // Face the input direction while skidding (so skid sprite looks "back" - // toward old motion); otherwise face current motion / last facing. - if (intent !== 0) - this.facingRight = intent > 0; - else if (moveDir !== 0) - this.facingRight = moveDir > 0; - this.player.flipX = !this.facingRight; - const blink = (this.invincible > 0 || this.shrinkTimer > 0) && Math.floor(this.time.now / 80) % 2 === 0; - this.player.setVisible(!blink); - // Track player glow (mushroom powerup) — centered on player body, not feet - const playerGlow = this.player.getData('glowFx'); - if (playerGlow) { - if (this.isBig && this.player.visible) { - playerGlow.setPosition(this.player.x, this.player.y - PLAYER_H / 2); - playerGlow.setAlpha(0.15 + Math.sin(this.time.now / 100) * 0.1); - playerGlow.setVisible(true); - } - else if (!this.isBig) { - playerGlow.destroy(); - this.player.setData('glowFx', null); - this.player.setData('hasGlow', false); - } - else { - playerGlow.setVisible(false); - } - } - } - updateCoins(camLeft) { - this.coinGroup.getChildren().forEach(c => { - const i = Math.floor(this.time.now / 120) % 2; - c.setTexture(i === 0 ? 'coin0' : 'coin1'); - if (c.x < camLeft - 100) - c.destroy(); - }); - } - updateBridges(camLeft) { - // Bridge collapse — unstable tiles start falling when player approaches - this.bridgeGroup.getChildren().forEach((bt) => { - if (!bt.active || !bt.getData('unstable') || bt.getData('collapsing')) - return; - const dx = bt.x - this.player.x; - // Trigger when player is within 6 blocks ahead or 2 blocks behind - if (dx < BLOCK * 6 && dx > -BLOCK * 2) { - bt.setData('collapsing', true); - const tileX = bt.x; - // ~1 second shake warning before falling - this.tweens.add({ - targets: bt, - x: bt.x + 3, - duration: 60, - yoyo: true, - repeat: 8, - onComplete: () => { - bt.body.enable = false; - this.tweens.add({ - targets: bt, - y: bt.y + 300, - alpha: 0, - duration: 500, - onComplete: () => bt.destroy(), - }); - // Launch fish from the gap where tile fell - this.fishGroup.getChildren().forEach((fish) => { - if (!fish.active || fish.getData('jumped')) - return; - if (Math.abs(fish.getData('homeX') - tileX) < BLOCK) { - fish.setData('jumped', true); - fish.setVisible(true); - fish.body.enable = true; - fish.setPosition(tileX, GROUND_Y + BLOCK); - // Arc jump: up just above bridge level, then back down - this.tweens.add({ - targets: fish, - y: GROUND_Y - BLOCK * 0.8, - duration: 400, - ease: 'Sine.easeOut', - onComplete: () => { - this.tweens.add({ - targets: fish, - y: GROUND_Y + BLOCK * 2, - duration: 400, - ease: 'Sine.easeIn', - onComplete: () => { - fish.body.enable = false; - fish.setVisible(false); - // Reset for possible re-jump after a delay - this.time.delayedCall(1500 + Math.random() * 2000, () => { - if (fish.active) { - fish.setData('jumped', false); - } - }); - }, - }); - }, - }); - } - }); - }, - }); - } - }); - } - updateFireEruptions(camLeft) { - // Fire eruptions — shoot up from gaps when player approaches - // Warning glow/smoke appears first; fire ONLY erupts after warning has been - // visible for a minimum duration so the player always gets fair notice. - const WARN_MIN_MS = 800; // warning must show for at least this long before fire - this.fireGroup.getChildren().forEach((f) => { - if (!f.active) - return; - const dx = Math.abs(this.player.x - f.getData('gapX')); - const baseY = f.getData('baseY'); - const isActive = f.getData('active'); - const isWarning = f.getData('warning'); - // Warning phase — show smoke/glow when player is within 10 blocks - if (dx < BLOCK * 10 && !isActive && !isWarning) { - f.setData('warning', true); - f.setData('warnStart', this.time.now); - // Rising smoke/ember particles - const warnEmbers = this.add.particles(f.getData('gapX'), GROUND_Y, 'coin0', { - speed: { min: 20, max: 60 }, - angle: { min: 255, max: 285 }, - scale: { start: 0.2, end: 0 }, - alpha: { start: 0.5, end: 0 }, - lifespan: { min: 400, max: 800 }, - frequency: 40, - quantity: 2, - tint: [0xff4400, 0xff6600, 0x888888, 0x666666], - blendMode: 'ADD', - }); - warnEmbers.setDepth(f.depth + 1); - f.setData('warnEmbers', warnEmbers); - // Pulsing orange glow at gap base - const warnGlow = this.add.ellipse(f.getData('gapX'), GROUND_Y + BLOCK * 0.5, BLOCK * 2, BLOCK * 1.5, 0xff4400, 1.0); - warnGlow.setAlpha(0); - warnGlow.setBlendMode(Phaser.BlendModes.ADD); - warnGlow.setDepth(f.depth - 1); - f.setData('warnGlow', warnGlow); - this.tweens.add({ - targets: warnGlow, - alpha: { from: 0, to: 0.35 }, - duration: 350, - ease: 'Sine.easeInOut', - yoyo: true, - repeat: -1, - }); - } - // Fire only erupts after warning has been visible long enough - const warnStart = f.getData('warnStart') || 0; - const warnElapsed = this.time.now - warnStart; - if (dx < BLOCK * 4 && !isActive && isWarning && warnElapsed >= WARN_MIN_MS) { - // Erupt! - f.setData('active', true); - // Clean up warning effects - const we = f.getData('warnEmbers'); - if (we) { - we.stop(); - this.time.delayedCall(800, () => { if (we) - we.destroy(); }); - } - const wg = f.getData('warnGlow'); - if (wg) { - this.tweens.killTweensOf(wg); - wg.destroy(); - } - f.setData('warnEmbers', null); - f.setData('warnGlow', null); - f.setData('warning', false); - f.setVisible(true); - f.body.enable = true; - f.y = baseY; - this.tweens.add({ - targets: f, - y: GROUND_Y - BLOCK * 2, - duration: 300, - ease: 'Quad.easeOut', - onComplete: () => { - // Hold briefly then retract - this.time.delayedCall(800, () => { - if (!f.active) - return; - this.tweens.add({ - targets: f, - y: baseY, - duration: 400, - onComplete: () => { - f.setVisible(false); - f.body.enable = false; - // Reset after cooldown - this.time.delayedCall(2000, () => { - if (f.active) - f.setData('active', false); - }); - }, - }); - }); - }, - }); - } - // Flicker effect while visible + full fire FX - const fireVisible = f.visible && f.alpha > 0; - if (fireVisible) { - f.setAlpha(0.8 + Math.sin(this.time.now / 50) * 0.2); - if (!f.getData('hasGlow')) { - // Tall glow column from fire down to bottom of scene — additive blend - const glowH = H - f.y + BLOCK * 2; - const columnGlow = this.add.ellipse(f.x, f.y + glowH / 2, BLOCK * 1.4, glowH, 0xff4400, 1.0); - columnGlow.setDepth(f.depth - 1); - columnGlow.setAlpha(0.15); - columnGlow.setBlendMode(Phaser.BlendModes.ADD); - f.setData('hasGlow', true); - f.setData('manualGlow', columnGlow); - // Rising ember particles - const embers = this.add.particles(f.x, f.y, 'coin0', { - speed: { min: 15, max: 50 }, - angle: { min: 250, max: 290 }, - scale: { start: 0.15, end: 0 }, - alpha: { start: 0.7, end: 0 }, - lifespan: { min: 300, max: 600 }, - frequency: 60, - quantity: 1, - tint: [0xff2200, 0xff6600, 0xffaa00, 0xffff00], - blendMode: 'ADD', - }); - embers.setDepth(f.depth + 1); - f.setData('embers', embers); - } - } - // Animate fire glow + embers — resize/reposition column as fire moves - const mg = f.getData('manualGlow'); - const em = f.getData('embers'); - if (mg) { - if (fireVisible) { - const glowH = H - f.y + BLOCK * 2; - mg.setPosition(f.x, f.y + glowH / 2); - mg.setSize(BLOCK * 1.4, glowH); - mg.setAlpha(0.12 + Math.sin(this.time.now / 60) * 0.08); - } - else { - mg.setAlpha(0); - } - } - if (em) { - em.setPosition(f.x, f.y); - if (fireVisible) { - em.start(); - } - else { - em.stop(); - } - } - if (f.x < camLeft - 200) { - const gl = f.getData('manualGlow'); - if (gl) - gl.destroy(); - const sp = f.getData('sparks'); - if (sp) - sp.destroy(); - const emb = f.getData('embers'); - if (emb) - emb.destroy(); - const we = f.getData('warnEmbers'); - if (we) - we.destroy(); - const wg = f.getData('warnGlow'); - if (wg) { - this.tweens.killTweensOf(wg); - wg.destroy(); - } - f.destroy(); - } - }); - } - updatePiranhas(dtMs, camLeft) { - // Piranha plant animation - this.piranhaGroup.getChildren().forEach((p) => { - if (!p.active) - return; - let timer = p.getData('timer') + dtMs; - const pipeTopY = p.getData('pipeTopY'); - const cycle = 4000; - const phase = (timer % cycle) / cycle; - // Only suppress if the player is directly on top of the pipe - const pipeX = p.getData('pipeX'); - const dx = Math.abs(this.player.x - pipeX); - const onPipe = dx < BLOCK * 0.8 && this.player.y < pipeTopY && this.player.body.velocity.y >= 0; - if (onPipe) { - p.setVisible(false); - p.body.enable = false; - p.setData('timer', timer); - return; - } - if (phase < 0.25) { - const t = phase / 0.25; - p.y = pipeTopY + BLOCK * (1 - t); - p.setVisible(true); - p.body.enable = true; - } - else if (phase < 0.5) { - p.y = pipeTopY; - p.setVisible(true); - p.body.enable = true; - p.setTexture(Math.floor(timer / 200) % 2 === 0 ? 'piranha_0' : 'piranha_1'); - } - else if (phase < 0.75) { - const t = (phase - 0.5) / 0.25; - p.y = pipeTopY + BLOCK * t; - p.setVisible(true); - p.body.enable = true; - } - else { - p.setVisible(false); - p.body.enable = false; - } - p.setData('timer', timer); - if (p.x < camLeft - 200) - p.destroy(); - }); - } - updateCrocs(camLeft) { - // Croc update — swim back and forth, cycle mouth open/closed - const now = this.time.now; - this.crocGroup.getChildren().forEach((croc) => { - if (croc.x < camLeft - 200) { - croc.destroy(); - return; - } - // Mouth state cycling - const timer = croc.getData('timer'); - if (now >= timer) { - const wasOpen = croc.getData('mouthOpen'); - croc.setData('mouthOpen', !wasOpen); - croc.setTexture(wasOpen ? 'croc_closed' : 'croc_open'); - // Closed longer than open (2-3s closed, 1-1.5s open) - croc.setData('timer', now + (wasOpen ? 2000 + Math.random() * 1000 : 1000 + Math.random() * 500)); - } - // Swim within gap bounds - const gapStart = croc.getData('gapStart'); - const gapEnd = croc.getData('gapEnd'); - const margin = 24; - if (croc.x <= gapStart + margin) { - croc.setData('swimDir', 1); - croc.setVelocityX(30); - } - else if (croc.x >= gapEnd - margin) { - croc.setData('swimDir', -1); - croc.setVelocityX(-30); - } - }); - } - cleanupOffscreen(camLeft) { - this.mushroomGroup.getChildren().forEach(m => { - if (m.x < camLeft - 100 || m.y > H + 100) - m.destroy(); - }); - this.fireballGroup.getChildren().forEach(fb => { - if (fb.x < camLeft - 100 || fb.x > camLeft + W + 200 || fb.y > H + 50) - fb.destroy(); - }); - // Fish cleanup — destroy when scrolled offscreen - this.fishGroup.getChildren().forEach((fish) => { - if (fish.x < camLeft - 200) - fish.destroy(); - }); - } - updateEnemy(e, camLeft) { - if (!e.active) - return; - const state = e.getData('state'); - const kind = e.getData('kind'); - const enemyType = e.getData('enemyType') || 'monster'; - if (e.x < camLeft - BLOCK * 3) { - e.destroy(); - return; - } - if (e.y > H + 50) { - e.destroy(); - return; - } - if (!e.body) - return; - // Block-row patrol: idle until player approaches, then bounce within bounds - if (e.getData('patrolAwait')) { - if (Math.abs(this.player.x - e.x) < W) { - e.setData('patrolAwait', false); - e.setVelocityX(-80); - } - else { - e.setVelocityX(0); - return; - } - } - const pLeft = e.getData('patrolLeft'); - if (pLeft !== undefined && pLeft !== null) { - const pRight = e.getData('patrolRight'); - if (e.x <= pLeft) { - e.setVelocityX(80); - } - else if (e.x >= pRight) { - e.setVelocityX(-80); - } - } - if (state === 'walk' || state === 'flying') { - // Animate based on enemy type - if (enemyType === 'bat') { - const frame = Math.floor(this.time.now / 150) % 2; - e.setTexture(frame === 0 ? 'bat_0' : 'bat_1'); - e.setDisplaySize(BLOCK, BLOCK); - const baseY = e.getData('baseY') || GROUND_Y - BLOCK * 2; - e.y = baseY + Math.sin(this.time.now / 400 + e.x * 0.01) * 40; - } - // monster, bulldog, snake all use anims — no manual texture swap needed - if (kind === 'rkoopa' && (e.body.blocked.down || e.body.touching.down)) { - const ahead = e.x + (e.body.velocity.x > 0 ? BLOCK : -BLOCK); - if (this.isInGap(ahead)) { - e.setVelocityX(-e.body.velocity.x); - } - } - e.flipX = e.body.velocity.x > 0; - } - else if (state === 'shell_still') { - let timer = e.getData('timer') - 1; - e.setData('timer', timer); - if (timer <= 0) { - e.setData('state', 'walk'); - e.setVelocityX(-90); - } - } - } - onPlayerHitBrick(_player, brick) { - if (!this.player.body.touching.up) - return; - if (Math.abs(brick.x - this.player.x) > BLOCK * 0.55) - return; - this.collectCoinsAbove(brick.x, brick.y); - this.knockEnemiesAbove(brick.x, brick.y); - if (this.isBig) { - brick.destroy(); - this.addScore(50, brick.x, brick.y - 20); - } - else { - // Small player: bump animation only (no destruction) - if (!brick.getData('bumping')) { - brick.setData('bumping', true); - const origY = brick.y; - this.tweens.add({ - targets: brick, y: origY - 6, yoyo: true, duration: 80, - onComplete: () => { brick.y = origY; brick.setData('bumping', false); } - }); - } - } - } - onPlayerHitQBlock(_player, q) { - if (q.getData('hit')) - return; - if (!this.player.body.touching.up) - return; - if (Math.abs(q.x - this.player.x) > BLOCK * 0.55) - return; - this.collectCoinsAbove(q.x, q.y); - this.knockEnemiesAbove(q.x, q.y); - q.setData('hit', true); - q.setTexture('qblock_used'); - q.setDisplaySize(BLOCK, BLOCK); - this.tweens.add({ targets: q, y: q.y - 6, yoyo: true, duration: 100 }); - const reward = q.getData('reward'); - if (reward === 'mushroom' && !this.isBig) { - const m = this.mushroomGroup.create(q.x, q.y - BLOCK, 'mushroom'); - m.body.setSize(28, 28); - m.setVelocityX(120); - m.setBounceX(1); - m.body.setMaxVelocity(200, 600); - } - else { - this.popCoin(q.x, q.y); - // Height bonus — higher ?-blocks reward more points for the effort - const heightAboveGround = GROUND_Y - q.y; - const heightBonus = heightAboveGround > BLOCK * 4 ? 300 : heightAboveGround > BLOCK * 2 ? 100 : 0; - this.addScore(200 + heightBonus, q.x, q.y - 20); - } - } - popCoin(x, y) { - const c = this.add.image(x, y, 'coin0').setDepth(50); - c.setDisplaySize(BLOCK * 0.7, BLOCK * 0.9); - this.tweens.add({ - targets: c, - y: y - BLOCK * 2.2, - duration: 350, - ease: 'Sine.easeOut', - onComplete: () => { - this.tweens.add({ - targets: c, y: y - BLOCK * 1.6, alpha: 0, - duration: 200, onComplete: () => c.destroy(), - }); - }, - }); - } - onPlayerCoin(_player, c) { - c.destroy(); - this.addScore(100, c.x, c.y); - this.sfx('nr_coin', 0.2); - } - /** Collect any coins sitting directly above a block (within 1 block). */ - collectCoinsAbove(blockX, blockY) { - this.coinGroup.getChildren().forEach((c) => { - if (!c.active) - return; - const dx = Math.abs(c.x - blockX); - const dy = blockY - c.y; // coin should be above (positive = above) - if (dx < BLOCK * 0.7 && dy > 0 && dy < BLOCK * 1.5) { - // Pop the coin upward then destroy - this.tweens.add({ - targets: c, - y: c.y - BLOCK, - alpha: 0, - duration: 300, - onComplete: () => c.destroy(), - }); - this.addScore(100, c.x, c.y); - this.sfx('nr_coin', 0.2); - } - }); - } - /** Knock out any enemy standing on top of a block that was hit from below. */ - knockEnemiesAbove(blockX, blockY) { - this.enemyGroup.getChildren().forEach((e) => { - if (!e.active) - return; - const dx = Math.abs(e.x - blockX); - const dy = blockY - e.y; // enemy should be above (positive = above) - if (dx < BLOCK * 1.0 && dy > 0 && dy < BLOCK * 2) { - this.addScore(200, e.x, e.y - 10); - this.sfx('nr_stomp', 0.25); - // Launch enemy upward then destroy - e.setVelocityY(-400); - e.setVelocityX((Math.random() - 0.5) * 200); - e.flipY = true; - e.body.setAllowGravity(true); - e.setData('state', 'dead'); - this.time.delayedCall(800, () => { if (e.active) - e.destroy(); }); - } - }); - } - onPlayerMushroom(_player, m) { - m.destroy(); - if (!this.isBig) { - this.isBig = true; - this.addScore(1000, this.player.x, this.player.y - 20); - this.sfx('nr_powerup'); - // Growth flash — briefly golden then normal - this.player.setTint(0xffdd00); - this.time.delayedCall(300, () => { - if (this.isBig) - this.player.clearTint(); - }); - // Add visible glow effect around the player (Ellipse — preFX doesn't render in WebKit) - if (!this.player.getData('hasGlow')) { - const glow = this.add.ellipse(this.player.x, this.player.y - PLAYER_H / 2, PLAYER_W * 1.4, PLAYER_H * 1.4, 0xffdd00, 1.0); - glow.setDepth(this.player.depth - 1); - glow.setAlpha(0.2); - glow.setBlendMode(Phaser.BlendModes.ADD); - this.player.setData('hasGlow', true); - this.player.setData('glowFx', glow); - } - } - } - onPlayerHeart(_player, h) { - h.destroy(); - this.lives++; - this.syncLivesToHUD(); - this.addScore(2000, h.x, h.y - 10); - this.sfx('nr_extralife'); - // Green flash to indicate extra life - this.cameras.main.flash(300, 100, 255, 100, false); - } - onPlayerEnemy(_player, e) { - if (this.invincible > 0 || this.stompGrace > 0 || this.shrinkTimer > 0) - return; - const state = e.getData('state'); - const kind = e.getData('kind'); - const playerBottom = this.player.y; - const enemyTop = e.y - e.displayHeight; - const stomping = this.player.body.velocity.y > 50 && - playerBottom < enemyTop + e.displayHeight * 0.5; - if (stomping) { - this.player.setVelocityY(-450); - this.stompGrace = 417; - this.sfx('nr_stomp', 0.25); - if (kind === 'goomba') { - this.killGoomba(e); - } - else if (state === 'walk') { - this.becomeShell(e); - this.addScore(200, e.x, e.y - 20); - } - else if (state === 'shell_still') { - const dir = this.player.x < e.x ? 1 : -1; - e.setData('state', 'shell'); - e.setVelocityX(dir * 400); - this.addScore(100, e.x, e.y - 20); - } - else if (state === 'shell') { - e.setData('state', 'shell_still'); - e.setData('timer', 300); - e.setVelocityX(0); - this.addScore(100, e.x, e.y - 20); - } - } - else if (state === 'shell_still') { - const dir = this.player.x < e.x ? 1 : -1; - e.setData('state', 'shell'); - e.setVelocityX(dir * 400); - this.stompGrace = 250; - this.addScore(100, e.x, e.y - 20); - } - else { - this.takeHit(); - } - } - // Replace the enemy with a shell sprite using the dead frame. - becomeShell(e) { - const kind = e.getData('kind'); - const x = e.x; - e.destroy(); - const shell = this.enemyGroup.create(x, GROUND_Y, 'enemy', 4); - shell.setOrigin(0.5, 1); - shell.setDisplaySize(BLOCK, BLOCK * 0.7); - shell.body.setGravityY(1800); - shell.body.setAllowGravity(true); - shell.setVelocityX(0); - shell.setBounceX(1); - shell.setCollideWorldBounds(false); - shell.setData('kind', kind); - shell.setData('state', 'shell_still'); - shell.setData('timer', 300); - } - // Goomba "death": disable the body so nothing collides with it again, fade - // and shrink it visually, then destroy. No state-machine, no body resizing - // hacks — this avoids the floating/misaligned-body bugs. - killGoomba(e) { - e.setData('state', 'dying'); - e.disableBody(false, false); - e.anims.stop(); - if (e.getData('enemyType') === 'snake') { - e.setFrame(4); - e.setDisplaySize(BLOCK, BLOCK * 0.5); // squished - } - else { - e.setFrame(4); // dead frame in all strips - } - this.addScore(200, e.x, e.y - 20); - this.tweens.add({ - targets: e, - scaleY: 0.3, - alpha: 0, - duration: 250, - onComplete: () => e.destroy(), - }); - } - onEnemyVsEnemy(a, b) { - const aState = a.getData('state'); - const bState = b.getData('state'); - if (aState === 'shell' && bState !== 'dying' && bState !== 'shell') { - this.killByShell(b); - } - else if (bState === 'shell' && aState !== 'dying' && aState !== 'shell') { - this.killByShell(a); - } - } - killByShell(e) { - if (e.getData('kind') === 'goomba') { - this.killGoomba(e); - } - else { - // Koopa hit by shell: knock it offscreen with an upward arc. - e.setData('state', 'dying'); - e.disableBody(false, false); - this.addScore(100, e.x, e.y - 20); - this.tweens.add({ - targets: e, y: e.y - 80, alpha: 0, angle: 360, - duration: 500, onComplete: () => e.destroy(), - }); - } - } - onFireballHitSolid(fb, _solid) { - if (fb.body.blocked.down) { - fb.setVelocityY(-350); - } - else { - fb.destroy(); - } - } - onFireballEnemy(fb, e) { - const st = e.getData('state'); - if (st === 'dying') - return; - fb.destroy(); - this.killByShell(e); - } - throwFireball() { - const dir = this.facingRight ? 1 : -1; - const fb = this.fireballGroup.create(this.player.x + dir * 20, this.player.y + 20, 'fireball'); - fb.body.setSize(14, 14); - fb.setVelocityX(dir * 450); - fb.setVelocityY(-100); - fb.setBounceY(0.6); - this.sfx('nr_fireball', 0.2); - } - takeHit() { - if (this.isBig) { - this.isBig = false; - this.player.clearTint(); - this.shrinkTimer = 1000; - this.sfx('nr_hit'); - // glow handled by preFX - } - else { - this.die(); - } - } - die() { - if (this.dead) - return; - this.lives--; - this.syncLivesToHUD(); - this.dead = true; - this.deadTimer = 1200; - this.sfx('nr_die', 0.4); - this.player.setVelocity(0, -500); - this.player.body.checkCollision.none = true; - this.isBig = false; - this.player.clearTint(); - // glow handled by preFX - if (this.parachuteMode) - this.endParachute(); - } - doRespawn() { - this.dead = false; - const deathX = Math.max(this.lastSafeX, this.cameras.main.scrollX + 200); - // Find a safe spot — search BACKWARD first to respawn before the hazard - const isSafe = (wx) => { - if (this.isInGap(wx)) - return false; - if (this.isNearObstacle(wx)) - return false; - const fires = this.fireGroup.getChildren(); - for (const f of fires) { - if (f.active && Math.abs(wx - f.x) < BLOCK * 1.5) - return false; - } - const enemies = this.enemyGroup.getChildren(); - for (const e of enemies) { - if (e.active && Math.abs(wx - e.x) < BLOCK * 3) - return false; - } - return true; - }; - // Search backward first (up to 15 blocks behind death point) - let x = deathX; - const minX = Math.max(this.cameras.main.scrollX + 100, deathX - BLOCK * 15); - let backX = deathX - BLOCK; - while (backX >= minX) { - if (isSafe(backX)) { - x = backX; - break; - } - backX -= BLOCK; - } - // If no safe spot behind, search forward as fallback - if (x === deathX && !isSafe(x)) { - let tries = 0; - while (!isSafe(x) && tries < 50) { - x += BLOCK; - tries++; - } - } - this.player.setPosition(x, GROUND_Y - 100); - this.player.setVelocity(0, 0); - this.player.body.checkCollision.none = false; - this.player.clearTint(); - this.invincible = 1500; - this.shrinkTimer = 0; - this.stompGrace = 0; - // glow handled by preFX - } - respawn() { - if (this.lives <= 0) { - this.sfx('nr_gameover'); - // Keep dead=true so update() doesn't run while overlay is showing - this.player.setVisible(false); - this.player.setVelocity(0, 0); - this.player.body.checkCollision.none = true; - this.showGameOver(this.score, () => { - this.sfx('nr_startlevel'); - this.lives = 3; - this.score = 0; - this.syncScoreToHUD(); - this.syncLivesToHUD(); - this.player.setVisible(true); - this.doRespawn(); - }); - return; - } - this.doRespawn(); - } - onPlayerBridge(_player, _tile) { - // Collision still needed for standing — collapse is handled by proximity in update - } - onPlayerBounce(_player, pad) { - if (!this.player.body.touching.down) - return; - this.player.setVelocityY(-1200); - // Compress animation on the pad - this.tweens.add({ - targets: pad, - scaleY: 0.5, - duration: 100, - yoyo: true, - ease: 'Power2', - }); - this.addScore(50, pad.x, pad.y - 20); - this.sfx('nr_bounce', 0.3); - } - onPlayerFlag(_player, flag) { - flag.destroy(); - this.currentLevel++; - this.currentBiome = (this.currentBiome + 1) % 4; - this.syncLevelToHUD(this.currentLevel); - this.addScore(5000, flag.x, flag.y - 30); - this.sfx('nr_flag'); - const cam = this.cameras.main; - cam.flash(500, 255, 255, 255, false); - const txt = this.add.text(this.player.x, this.player.y - 80, `LEVEL ${this.currentLevel}!`, { - fontFamily: '"Press Start 2P", monospace', - fontSize: '24px', - color: '#ffdd00', - stroke: '#000', - strokeThickness: 4, - }).setOrigin(0.5).setDepth(1000); - this.tweens.add({ - targets: txt, - y: txt.y - 60, - alpha: 0, - duration: 2000, - onComplete: () => txt.destroy(), - }); - } - onPlayerPiranha(_player, _p) { - if (this.invincible > 0 || this.shrinkTimer > 0) - return; - if (this.isBig) { - this.isBig = false; - this.shrinkTimer = 1000; - this.invincible = 1500; - // glow handled by preFX - } - else { - this.die(); - } - } - onPlayerFire(_player, _f) { - if (this.invincible > 0 || this.shrinkTimer > 0) - return; - if (this.isBig) { - this.isBig = false; - this.shrinkTimer = 1000; - this.invincible = 1500; - // glow handled by preFX - } - else { - this.die(); - } - } - onPlayerCroc(_player, croc) { - if (this.invincible > 0 || this.shrinkTimer > 0) - return; - const pBody = this.player.body; - const stomping = pBody.velocity.y > 0 && pBody.bottom <= croc.body.top + 10; - if (stomping && !croc.getData('mouthOpen')) { - this.addScore(200); - croc.destroy(); - pBody.setVelocityY(-500); - this.sfx('nr_stomp'); - } - else { - if (this.isBig) { - this.isBig = false; - this.shrinkTimer = 1000; - this.invincible = 1500; - } - else { - this.die(); - } - } - } - onPlayerFish(_player, fish) { - if (this.invincible > 0 || this.shrinkTimer > 0) - return; - if (!fish.visible) - return; - if (this.isBig) { - this.isBig = false; - this.shrinkTimer = 1000; - this.invincible = 1500; - } - else { - this.die(); - } - } - startWarp(sourcePipe) { - this.warping = true; - this.sfx('nr_warp'); - this.player.setVelocity(0, 0); - this.player.body.setAllowGravity(false); - // Sparkle particle burst at pipe entrance - const particles = this.add.particles(this.player.x, this.player.y, 'coin0', { - speed: { min: 40, max: 120 }, - angle: { min: 200, max: 340 }, - scale: { start: 0.3, end: 0 }, - lifespan: 600, - quantity: 12, - emitting: false, - tint: [0x00ff00, 0x44ff44, 0xffff00, 0xffffff], - }); - particles.setDepth(15); - particles.explode(12); - this.time.delayedCall(800, () => particles.destroy()); - // Fade + shrink player as they enter the pipe - this.tweens.add({ - targets: this.player, - y: sourcePipe.y + BLOCK, - scaleX: 0.3, - scaleY: 0.3, - alpha: 0, - duration: 500, - onComplete: () => { - // Reset player scale/alpha for exit - this.player.setScale(1); - this.player.setAlpha(1); - // Ensure terrain is generated far enough ahead for a destination - const aheadX = sourcePipe.x + BLOCK * 30; - if (this.genX < aheadX) { - this.generateLevel(this.genX, aheadX); - this.extendGround(this.genX, aheadX + W); - } - // Safety check — is a landing spot free of hazards? - const isLandingSafe = (wx) => { - if (this.isInGap(wx)) - return false; - if (this.isNearObstacle(wx)) - return false; - const fires = this.fireGroup.getChildren(); - for (const f of fires) { - if (f.active && Math.abs(wx - f.x) < BLOCK * 2) - return false; - } - const enemies = this.enemyGroup.getChildren(); - for (const e of enemies) { - if (e.active && Math.abs(wx - e.x) < BLOCK * 3) - return false; - } - return true; - }; - // Find a warp-eligible pipe well ahead of the source in a safe spot - const minX = sourcePipe.x + BLOCK * 15; - const pipes = this.pipeGroup.getChildren() - .filter((p) => p.x > minX && !p.getData('warp') && !p.getData('gold')) - .sort((a, b) => a.x - b.x); - // Group pipes by x-position to find distinct pipe columns - let destPipe = null; - const visited = new Set(); - for (const p of pipes) { - const col = Math.round(p.x / BLOCK); - if (visited.has(col)) - continue; - visited.add(col); - if (isLandingSafe(p.x)) { - destPipe = p; - break; - } - } - if (destPipe) { - // Find the topmost segment at this pipe's x position - const topSeg = pipes.filter((p) => Math.abs(p.x - destPipe.x) < BLOCK) - .sort((a, b) => a.y - b.y)[0]; - const destTop = topSeg.y - BLOCK / 2; - this.player.setPosition(topSeg.x, destTop + BLOCK); - this.player.setVisible(false); - this.tweens.add({ - targets: this.player, - y: destTop - 10, - duration: 400, - onStart: () => { - this.player.setVisible(true); - // Sparkle burst at exit pipe - const exitParticles = this.add.particles(this.player.x, this.player.y, 'coin0', { - speed: { min: 40, max: 120 }, - angle: { min: 200, max: 340 }, - scale: { start: 0.3, end: 0 }, - lifespan: 600, - quantity: 10, - emitting: false, - tint: [0x00ff00, 0x44ff44, 0xffff00, 0xffffff], - }); - exitParticles.setDepth(15); - exitParticles.explode(10); - this.time.delayedCall(800, () => exitParticles.destroy()); - }, - onComplete: () => { - this.player.body.setAllowGravity(true); - this.warping = false; - this.addScore(200, this.player.x, this.player.y - 20); - }, - }); - } - else { - // No safe pipe found — warp to safe ground ahead - let landX = sourcePipe.x + BLOCK * 18; - let tries = 0; - while (!isLandingSafe(landX) && tries < 30) { - landX += BLOCK; - tries++; - } - this.player.setPosition(landX, GROUND_Y - BLOCK); - this.player.setVisible(true); - this.player.body.setAllowGravity(true); - this.warping = false; - this.addScore(200, this.player.x, this.player.y - 20); - } - }, - }); - } - startParachute(pipe) { - this.warping = true; - this.parachuteMode = true; - this.sfx('nr_warp'); - this.player.setVelocity(0, 0); - this.player.body.setAllowGravity(false); - // Sparkle particle burst at golden pipe entrance - const particles = this.add.particles(this.player.x, this.player.y, 'coin0', { - speed: { min: 50, max: 140 }, - angle: { min: 200, max: 340 }, - scale: { start: 0.4, end: 0 }, - lifespan: 700, - quantity: 16, - emitting: false, - tint: [0xffdd00, 0xffaa00, 0xffffff, 0xff8800], - }); - particles.setDepth(15); - particles.explode(16); - this.time.delayedCall(900, () => particles.destroy()); - // Fade + shrink into pipe - this.tweens.add({ - targets: this.player, - y: pipe.y + BLOCK, - scaleX: 0.3, - scaleY: 0.3, - alpha: 0, - duration: 500, - onComplete: () => { - // Reset scale and alpha from pipe entry animation - this.player.setScale(1); - this.player.setAlpha(1); - const targetX = this.cameras.main.scrollX + W / 2; - this.player.setPosition(targetX, 60); - this.player.setVisible(true); - this.player.body.setAllowGravity(true); - this.player.body.setGravityY(42); - this.player.setMaxVelocity(200, 144); - this.warping = false; - this.parachuteSprite = this.add.sprite(this.player.x, this.player.y - 80, 'parachute'); - this.parachuteSprite.setDisplaySize(96, 120); - this.parachuteSprite.setOrigin(0.5, 1); // bottom-center anchored to player's head - this.parachuteSprite.setDepth(9); - for (let i = 0; i < 8; i++) { - const cx = targetX + (Math.random() - 0.5) * W * 0.6; - const cy = 100 + Math.random() * (GROUND_Y - 200); - const c = this.coinGroup.create(cx, cy, 'coin0'); - c.setDisplaySize(BLOCK * 0.5, BLOCK * 0.65); - c.body.setAllowGravity(false); - c.body.setSize(12, 18); - c.setData('parachuteCoin', true); - } - this.parachuteTimer = 0; - this.parachuteFlyingEnemies = []; - // Start looping wind sound - try { - this.windSound = this.sound.add('nr_wind', { volume: 0.15, loop: true }); - this.windSound.play(); - } - catch { } - }, - }); - } - endParachute() { - this.parachuteMode = false; - // Stop wind sound - if (this.windSound) { - try { - this.windSound.stop(); - } - catch { } - this.windSound = undefined; - } - if (this.parachuteSprite) { - this.parachuteSprite.destroy(); - this.parachuteSprite = undefined; - } - this.player.body.setGravityY(1800); - this.player.setMaxVelocity(700, 900); - this.player.setAccelerationX(0); - // Re-enable camera follow after parachute - this.cameras.main.startFollow(this.player, true, 0.15, 0.05, -W * 0.2, 0); - this.parachuteFlyingEnemies.forEach(e => { if (e.active) - e.destroy(); }); - this.parachuteFlyingEnemies = []; - this.addScore(500, this.player.x, this.player.y - 30); - } - shutdown() { - super.shutdown(); - // Destroy all physics groups and their children - const groups = [ - this.groundGroup, this.brickGroup, this.qblockGroup, this.pipeGroup, - this.coinGroup, this.mushroomGroup, this.heartGroup, this.fireballGroup, - this.enemyGroup, this.bridgeGroup, this.bounceGroup, this.flagGroup, - this.piranhaGroup, this.fireGroup, this.crocGroup, this.fishGroup, - ]; - for (const g of groups) { - if (g && g.clear) - try { - g.clear(true, true); - } - catch { } - } - // Destroy player and extra sprites - this.destroyObj(this.player); - this.destroyObj(this.parachuteSprite); - this.parachuteSprite = undefined; - this.destroyObj(this.glowSprite); - this.glowSprite = undefined; - // Stop wind sound - if (this.windSound) { - try { - this.windSound.stop(); - } - catch { } - this.windSound = undefined; - } - // Clean up flying enemies from parachute mode - this.parachuteFlyingEnemies.forEach(e => { if (e.active) - e.destroy(); }); - this.parachuteFlyingEnemies = []; - } -} -//# sourceMappingURL=NinjaRunner.js.map \ No newline at end of file diff --git a/extensions/arcade-canvas/game/scenes/PlanetGuardian.js b/extensions/arcade-canvas/game/scenes/PlanetGuardian.js deleted file mode 100644 index bdcddeea..00000000 --- a/extensions/arcade-canvas/game/scenes/PlanetGuardian.js +++ /dev/null @@ -1,1818 +0,0 @@ -// Defender — Classic 1981 Williams side-scrolling shooter. -// Protect humanoids from alien landers across a scrolling terrain world. -import { BaseScene, W, H } from './BaseScene.js'; -/* ------------------------------------------------------------------ */ -/* Constants */ -/* ------------------------------------------------------------------ */ -let SCALE = Math.min(W / 1920, H / 1080); -let PX = Math.max(3, Math.round(4 * SCALE)); -const WORLD_W_SCREENS = 6; -let WORLD_W = W * WORLD_W_SCREENS; -const PLAYER_THRUST = 1400; -const PLAYER_MAX_VX = 900; -const PLAYER_VY_SPEED = 500; -const PLAYER_FRICTION = 0.985; // high inertia — ship coasts like original -const BULLET_SPEED = 1200; -const MAX_BULLETS = 8; -const INVINCIBLE_TIME = 2000; -const RESPAWN_DELAY = 800; -const EXTRA_LIFE_SCORE = 10000; -const TERRAIN_SAMPLE = 20; // pixels between terrain height samples -const TERRAIN_MIN_Y = 0.65; // fraction of H for highest peak -const TERRAIN_MAX_Y = 0.88; // fraction of H for lowest valley -const RADAR_H = 50; // taller for visibility -const RADAR_Y = 105; // well below HUD bar (~91px tall) -const ENEMY_BULLET_SPEED = 400; -const RESPAWN_SAFE_RADIUS = 300; -const RESPAWN_SAFE_RADIUS_BAITER = 600; -const RESPAWN_PUSH_OFFSET = 150; -/* ------------------------------------------------------------------ */ -/* Pixel Art Data — dimensions matched to original ROM sprite list */ -/* Reference: https://www.seanriddle.com/defendersprites.txt */ -/* ------------------------------------------------------------------ */ -// Ship: ROM = 16×6 px (8 bytes × 6 rows) -// From MAME screenshots: sleek profile facing right -// - Tapers at top and bottom (rows 0,5 are narrow) -// - Widest at center rows (1-4) -// - Magenta engine block at rear left -// - White body, cyan nose tip at right -// - Green exhaust pixels at bottom-left -const SHIP_PIXELS = [ - // Row 0 — top taper (narrow, no engine visible) - [6, 0, 0xffffff], [7, 0, 0xffffff], [8, 0, 0xffffff], [9, 0, 0xffffff], - [10, 0, 0xffffff], [11, 0, 0xffffff], [12, 0, 0xffffff], [13, 0, 0xffffff], - // Row 1 — wider, engine appears - [2, 1, 0xff00ff], [3, 1, 0xff44ff], - [4, 1, 0xffffff], [5, 1, 0xffffff], [6, 1, 0xffffff], [7, 1, 0xffffff], - [8, 1, 0xffffff], [9, 1, 0xffffff], [10, 1, 0xffffff], [11, 1, 0xffffff], - [12, 1, 0xffffff], [13, 1, 0xffffff], [14, 1, 0xffffff], - // Row 2 — full width (widest), engine + body + nose tip - [0, 2, 0xff00ff], [1, 2, 0xff00ff], [2, 2, 0xff00ff], [3, 2, 0xff44ff], - [4, 2, 0xffffff], [5, 2, 0xffffff], [6, 2, 0xffffff], [7, 2, 0xffffff], - [8, 2, 0xffffff], [9, 2, 0xffffff], [10, 2, 0xffffff], [11, 2, 0xffffff], - [12, 2, 0xffffff], [13, 2, 0xffffff], [14, 2, 0xffffff], [15, 2, 0x00ccff], - // Row 3 — full width (widest), engine + body + nose tip - [0, 3, 0xff00ff], [1, 3, 0xff00ff], [2, 3, 0xff00ff], [3, 3, 0xff44ff], - [4, 3, 0xffffff], [5, 3, 0xffffff], [6, 3, 0xffffff], [7, 3, 0xffffff], - [8, 3, 0xffffff], [9, 3, 0xffffff], [10, 3, 0xffffff], [11, 3, 0xffffff], - [12, 3, 0xffffff], [13, 3, 0xffffff], [14, 3, 0xffffff], [15, 3, 0x00ccff], - // Row 4 — wider, engine appears - [2, 4, 0xff00ff], [3, 4, 0xff44ff], - [4, 4, 0xffffff], [5, 4, 0xffffff], [6, 4, 0xffffff], [7, 4, 0xffffff], - [8, 4, 0xffffff], [9, 4, 0xffffff], [10, 4, 0xffffff], [11, 4, 0xffffff], - [12, 4, 0xffffff], [13, 4, 0xffffff], [14, 4, 0xffffff], - // Row 5 — bottom taper + green exhaust trail - [4, 5, 0xffffff], [5, 5, 0xffffff], [6, 5, 0xffffff], [7, 5, 0xffffff], - [8, 5, 0xffffff], [9, 5, 0xffffff], [10, 5, 0xffffff], [11, 5, 0xffffff], - [0, 5, 0x00ff00], [1, 5, 0x00ff00], -]; -// Lander: ROM = 10×8 px (5 bytes × 8 rows) -// H-shaped: diamond body with grabber legs below -const LANDER_PIXELS = [ - // Row 0 — top center - [4, 0, 0x00ff00], [5, 0, 0x00ff00], - // Row 1 — upper diamond - [3, 1, 0x00ff00], [4, 1, 0xffff00], [5, 1, 0xffff00], [6, 1, 0x00ff00], - // Row 2 — widest body - [2, 2, 0x00ff00], [3, 2, 0x00ff00], [4, 2, 0x00ff00], [5, 2, 0x00ff00], [6, 2, 0x00ff00], [7, 2, 0x00ff00], - // Row 3 — full width with side detail - [1, 3, 0x00ff00], [2, 3, 0x00ff00], [3, 3, 0xffff00], [4, 3, 0x00ff00], [5, 3, 0x00ff00], [6, 3, 0xffff00], [7, 3, 0x00ff00], [8, 3, 0x00ff00], - // Row 4 — lower body - [2, 4, 0x00ff00], [3, 4, 0x00ff00], [4, 4, 0x00ff00], [5, 4, 0x00ff00], [6, 4, 0x00ff00], [7, 4, 0x00ff00], - // Row 5 — narrowing - [3, 5, 0x00ff00], [4, 5, 0x00ff00], [5, 5, 0x00ff00], [6, 5, 0x00ff00], - // Row 6 — legs - [1, 6, 0xffff00], [2, 6, 0xffff00], [7, 6, 0xffff00], [8, 6, 0xffff00], - // Row 7 — leg tips - [0, 7, 0xffff00], [1, 7, 0xffff00], [8, 7, 0xffff00], [9, 7, 0xffff00], -]; -// Mutant: ROM = 10×8 px (5 bytes × 8 rows) -// Composite of lander + humanoid overlay, blobby organic look -const MUTANT_PIXELS = [ - // Row 0 - [3, 0, 0xff00ff], [4, 0, 0xff00ff], [5, 0, 0xff00ff], [6, 0, 0xff00ff], - // Row 1 - [2, 1, 0xff00ff], [3, 1, 0xcc00cc], [4, 1, 0xcc00cc], [5, 1, 0xcc00cc], [6, 1, 0xcc00cc], [7, 1, 0xff00ff], - // Row 2 — yellow-green eyes - [1, 2, 0xff00ff], [2, 2, 0xff00ff], [3, 2, 0xaaff00], [4, 2, 0xff00ff], [5, 2, 0xff00ff], [6, 2, 0xaaff00], [7, 2, 0xff00ff], [8, 2, 0xff00ff], - // Row 3 — widest - [0, 3, 0xff00ff], [1, 3, 0xff00ff], [2, 3, 0xff00ff], [3, 3, 0xff00ff], [4, 3, 0xff00ff], [5, 3, 0xff00ff], [6, 3, 0xff00ff], [7, 3, 0xff00ff], [8, 3, 0xff00ff], [9, 3, 0xff00ff], - // Row 4 — widest - [0, 4, 0xff00ff], [1, 4, 0xff00ff], [2, 4, 0xff00ff], [3, 4, 0xff00ff], [4, 4, 0xff00ff], [5, 4, 0xff00ff], [6, 4, 0xff00ff], [7, 4, 0xff00ff], [8, 4, 0xff00ff], [9, 4, 0xff00ff], - // Row 5 - [1, 5, 0xcc00cc], [2, 5, 0xff00ff], [3, 5, 0xff00ff], [4, 5, 0xff00ff], [5, 5, 0xff00ff], [6, 5, 0xff00ff], [7, 5, 0xff00ff], [8, 5, 0xcc00cc], - // Row 6 - [2, 6, 0xcc00cc], [3, 6, 0xff00ff], [4, 6, 0xff00ff], [5, 6, 0xff00ff], [6, 6, 0xff00ff], [7, 6, 0xcc00cc], - // Row 7 - [3, 7, 0xcc00cc], [4, 7, 0xcc00cc], [5, 7, 0xcc00cc], [6, 7, 0xcc00cc], -]; -// Humanoid: ROM = 4×8 px (2 bytes × 8 rows) -// Multi-colored: green upper body, magenta/pink lower half -const HUMANOID_PIXELS = [ - // Row 0 — head (green) - [1, 0, 0x00ff00], [2, 0, 0x00ff00], - // Row 1 — neck (green) - [1, 1, 0x00ff00], [2, 1, 0x00ff00], - // Row 2 — arms + torso (green) - [0, 2, 0x00ff00], [1, 2, 0x00ff00], [2, 2, 0x00ff00], [3, 2, 0x00ff00], - // Row 3 — torso (green) - [1, 3, 0x00ff00], [2, 3, 0x00ff00], - // Row 4 — waist (magenta transition) - [1, 4, 0xff00ff], [2, 4, 0xff00ff], - // Row 5 — hips (magenta) - [1, 5, 0xff00ff], [2, 5, 0xff00ff], - // Row 6 — legs (magenta) - [0, 6, 0xff00ff], [3, 6, 0xff00ff], - // Row 7 — feet (magenta) - [0, 7, 0xff00ff], [3, 7, 0xff00ff], -]; -// Bomber: ROM = 8×8 px (4 bytes × 8 rows) -// Compact square block with segmented look, NOT a wide rectangle -const BOMBER_PIXELS = [ - // Row 0 — top edge - [1, 0, 0xffff00], [2, 0, 0xffff00], [3, 0, 0xffff00], [4, 0, 0xffff00], [5, 0, 0xffff00], [6, 0, 0xffff00], - // Row 1 — top stripe with detail - [0, 1, 0xffff00], [1, 1, 0xff4400], [2, 1, 0xffff00], [3, 1, 0xff4400], [4, 1, 0xffff00], [5, 1, 0xff4400], [6, 1, 0xffff00], [7, 1, 0xffff00], - // Row 2 — solid - [0, 2, 0xffff00], [1, 2, 0xffff00], [2, 2, 0xffff00], [3, 2, 0xffff00], [4, 2, 0xffff00], [5, 2, 0xffff00], [6, 2, 0xffff00], [7, 2, 0xffff00], - // Row 3 — center detail - [0, 3, 0xffff00], [1, 3, 0xffff00], [2, 3, 0xff4400], [3, 3, 0xffff00], [4, 3, 0xffff00], [5, 3, 0xff4400], [6, 3, 0xffff00], [7, 3, 0xffff00], - // Row 4 — center detail - [0, 4, 0xffff00], [1, 4, 0xffff00], [2, 4, 0xff4400], [3, 4, 0xffff00], [4, 4, 0xffff00], [5, 4, 0xff4400], [6, 4, 0xffff00], [7, 4, 0xffff00], - // Row 5 — solid - [0, 5, 0xffff00], [1, 5, 0xffff00], [2, 5, 0xffff00], [3, 5, 0xffff00], [4, 5, 0xffff00], [5, 5, 0xffff00], [6, 5, 0xffff00], [7, 5, 0xffff00], - // Row 6 — bottom stripe - [0, 6, 0xffff00], [1, 6, 0xff4400], [2, 6, 0xffff00], [3, 6, 0xff4400], [4, 6, 0xffff00], [5, 6, 0xff4400], [6, 6, 0xffff00], [7, 6, 0xffff00], - // Row 7 — bottom edge - [1, 7, 0xffff00], [2, 7, 0xffff00], [3, 7, 0xffff00], [4, 7, 0xffff00], [5, 7, 0xffff00], [6, 7, 0xffff00], -]; -// Baiter: ROM = 12×4 px (6 bytes × 4 rows) -// Thin horseshoe/C shape — narrow and aggressive -const BAITER_PIXELS = [ - // Row 0 — top bar - [0, 0, 0x00ff44], [1, 0, 0x00ff44], [2, 0, 0x00ff44], [3, 0, 0x00ff44], [4, 0, 0x00ff44], [5, 0, 0x00ff44], [6, 0, 0x00ff44], [7, 0, 0x00ff44], [8, 0, 0x00ff44], [9, 0, 0x00ff44], [10, 0, 0x00ff44], [11, 0, 0x00ff44], - // Row 1 — gap in middle - [0, 1, 0x00ff44], [1, 1, 0x00ff44], [10, 1, 0x00ff44], [11, 1, 0x00ff44], - // Row 2 — gap in middle - [0, 2, 0x00ff44], [1, 2, 0x00ff44], [10, 2, 0x00ff44], [11, 2, 0x00ff44], - // Row 3 — bottom bar - [0, 3, 0x00ff44], [1, 3, 0x00ff44], [2, 3, 0x00ff44], [3, 3, 0x00ff44], [4, 3, 0x00ff44], [5, 3, 0x00ff44], [6, 3, 0x00ff44], [7, 3, 0x00ff44], [8, 3, 0x00ff44], [9, 3, 0x00ff44], [10, 3, 0x00ff44], [11, 3, 0x00ff44], -]; -// Pod: ROM = 8×8 px (4 bytes × 8 rows) -// Compact oval/circle shape, not a large egg -const POD_PIXELS = [ - // Row 0 - [2, 0, 0xcc00cc], [3, 0, 0xcc00cc], [4, 0, 0xcc00cc], [5, 0, 0xcc00cc], - // Row 1 - [1, 1, 0xcc00cc], [2, 1, 0xff00ff], [3, 1, 0xff00ff], [4, 1, 0xff00ff], [5, 1, 0xff00ff], [6, 1, 0xcc00cc], - // Row 2 - [0, 2, 0xcc00cc], [1, 2, 0xff00ff], [2, 2, 0xff00ff], [3, 2, 0xff44ff], [4, 2, 0xff44ff], [5, 2, 0xff00ff], [6, 2, 0xff00ff], [7, 2, 0xcc00cc], - // Row 3 - [0, 3, 0xcc00cc], [1, 3, 0xff00ff], [2, 3, 0xff44ff], [3, 3, 0xff00ff], [4, 3, 0xff00ff], [5, 3, 0xff44ff], [6, 3, 0xff00ff], [7, 3, 0xcc00cc], - // Row 4 - [0, 4, 0xcc00cc], [1, 4, 0xff00ff], [2, 4, 0xff44ff], [3, 4, 0xff00ff], [4, 4, 0xff00ff], [5, 4, 0xff44ff], [6, 4, 0xff00ff], [7, 4, 0xcc00cc], - // Row 5 - [0, 5, 0xcc00cc], [1, 5, 0xff00ff], [2, 5, 0xff00ff], [3, 5, 0xff44ff], [4, 5, 0xff44ff], [5, 5, 0xff00ff], [6, 5, 0xff00ff], [7, 5, 0xcc00cc], - // Row 6 - [1, 6, 0xcc00cc], [2, 6, 0xff00ff], [3, 6, 0xff00ff], [4, 6, 0xff00ff], [5, 6, 0xff00ff], [6, 6, 0xcc00cc], - // Row 7 - [2, 7, 0xcc00cc], [3, 7, 0xcc00cc], [4, 7, 0xcc00cc], [5, 7, 0xcc00cc], -]; -// Swarmer: ROM = 6×4 px (3 bytes × 4 rows) -// Wider than tall cross/star shape -const SWARMER_PIXELS = [ - // Row 0 - [2, 0, 0xffff00], [3, 0, 0xffff00], - // Row 1 — full width - [0, 1, 0xffff00], [1, 1, 0xffff00], [2, 1, 0xffff00], [3, 1, 0xffff00], [4, 1, 0xffff00], [5, 1, 0xffff00], - // Row 2 — full width - [0, 2, 0xffff00], [1, 2, 0xffff00], [2, 2, 0xffff00], [3, 2, 0xffff00], [4, 2, 0xffff00], [5, 2, 0xffff00], - // Row 3 - [2, 3, 0xffff00], [3, 3, 0xffff00], -]; -/* ------------------------------------------------------------------ */ -/* Scene */ -/* ------------------------------------------------------------------ */ -export class PlanetGuardianScene extends BaseScene { - /* Player state */ - playerX = 0; - playerY = 0; - playerVx = 0; - playerVy = 0; - facingRight = true; - shipAlive = true; - invincibleTimer = 0; - respawnTimer = 0; - smartBombs = 3; - carriedHumanoid = -1; // index of humanoid being carried, -1 = none - nextExtraLife = EXTRA_LIFE_SCORE; - /* Game objects */ - enemies = []; - humanoids = []; - bullets = []; - mines = []; - stars = []; - /* Terrain */ - terrainHeights = []; - planetDestroyed = false; - /* Camera / scroll */ - cameraX = 0; - spriteScale = 1; // calculated in create() - /* Game state */ - wave = 0; - gameOver = false; - waveTimer = 0; // time elapsed in current wave (for baiter spawning) - waveDelay = 0; - baiterSpawned = false; - /* Graphics objects */ - gameGfx; // main game graphics - radarGfx; // radar minimap - terrainGfx; // terrain graphics - hudExtraGfx; // smart bomb display - shipSprite; // player ship sprite - /* Input */ - cursors; - fireKey; - bombKey; - fireWasDown = false; - bombWasDown = false; - fireCooldown = 0; // rapid-fire rate limiter - thrustSoundPlaying = false; - constructor() { super('defender'); } - get displayName() { return 'Planet Guardian'; } - getDescription() { - return 'Defend humanoids from alien landers. Rescue the falling and destroy all enemies!'; - } - getControls() { - return [ - { key: '← →', action: 'Thrust / Reverse' }, - { key: '↑ ↓', action: 'Move Up / Down' }, - { key: 'SPACE', action: 'Fire Laser (hold)' }, - { key: 'Z', action: 'Smart Bomb' }, - ]; - } - /* ================================================================ - LIFECYCLE - ================================================================ */ - preload() { - // Load sprite PNGs (generated pixel art, CC0-compatible original designs) - this.load.image('def-ship-r', '../assets/defender/ship.png'); - this.load.image('def-ship-l', '../assets/defender/ship_left.png'); - this.load.image('def-lander', '../assets/defender/lander.png'); - this.load.image('def-mutant', '../assets/defender/mutant.png'); - this.load.image('def-humanoid', '../assets/defender/humanoid.png'); - this.load.image('def-bomber', '../assets/defender/bomber.png'); - this.load.image('def-pod', '../assets/defender/pod.png'); - this.load.image('def-swarmer', '../assets/defender/swarmer.png'); - this.load.image('def-baiter', '../assets/defender/baiter.png'); - // Sounds from OpenDefender - this.load.audio('snd_laser', '../assets/defender/sounds/sound_laser.ogg'); - this.load.audio('snd_enemydead', '../assets/defender/sounds/sound_enemydead.ogg'); - this.load.audio('snd_explode', '../assets/defender/sounds/sound_explode.ogg'); - this.load.audio('snd_playerdead', '../assets/defender/sounds/sound_playerdead.ogg'); - this.load.audio('snd_bonus', '../assets/defender/sounds/sound_bonus.ogg'); - this.load.audio('snd_humanoiddead', '../assets/defender/sounds/sound_humanoiddead.ogg'); - this.load.audio('snd_start', '../assets/defender/sounds/sound_start.ogg'); - this.load.audio('snd_thrust', '../assets/defender/sounds/sound_thurst.ogg'); - this.load.audio('snd_warning', '../assets/defender/sounds/sound_warning.ogg'); - this.load.audio('snd_baiterwarning', '../assets/defender/sounds/sound_baiterwarning.ogg'); - this.load.audio('snd_player1up', '../assets/defender/sounds/sound_player1up.ogg'); - this.load.audio('snd_enemyshoot', '../assets/defender/sounds/sound_enemyshoot.ogg'); - this.load.audio('snd_enemyshoot2', '../assets/defender/sounds/sound_enemyshoot2.ogg'); - } - create() { - this.initBase(); - // Switch Planet Guardian textures to linear filtering for smoother scaling - const defKeys = ['def-ship-r', 'def-ship-l', 'def-lander', 'def-mutant', - 'def-humanoid', 'def-bomber', 'def-pod', 'def-swarmer', 'def-baiter']; - for (const k of defKeys) { - const tex = this.textures.get(k); - if (tex && tex.source[0]?.glTexture) { - tex.setFilter(Phaser.Textures.FilterMode.LINEAR); - } - } - // Recalculate screen-dependent constants - SCALE = Math.min(W / 1920, H / 1080); - PX = Math.max(3, Math.round(4 * SCALE)); - WORLD_W = W * WORLD_W_SCREENS; - // Reset state - this.score = 0; - this.lives = 3; - this.wave = 0; - this.gameOver = false; - this.planetDestroyed = false; - this.smartBombs = 3; - this.carriedHumanoid = -1; - this.nextExtraLife = EXTRA_LIFE_SCORE; - this.playerX = WORLD_W / 2; - this.playerY = H * 0.4; - this.playerVx = 0; - this.playerVy = 0; - this.facingRight = true; - this.shipAlive = true; - this.invincibleTimer = 0; - this.respawnTimer = 0; - this.enemies = []; - this.humanoids = []; - this.bullets = []; - this.mines = []; - this.stars = []; - this.activeEmitters = []; - this.waveTimer = 0; - this.waveDelay = 0; - this.baiterSpawned = false; - this.ensureSparkTexture(); - // Starfield - this.stars = this.createStarfield([ - { count: 50, speed: 0, size: 1, alpha: 0.25 }, - { count: 30, speed: 0, size: 1.5, alpha: 0.35 }, - { count: 15, speed: 0, size: 2, alpha: 0.45 }, - ]); - // Generate terrain - this.generateTerrain(); - // Sprite scale — ensure sprites are visible across all monitor sizes - // At 1080p (SCALE=1.0): scale ~0.8 → ship 94px, enemies 55-64px - // At 720p (SCALE=0.67): scale ~0.6 → ship 71px, enemies 40-50px - // Floor of 0.55 ensures minimum ~46px ship, ~28px swarmer on small monitors - this.spriteScale = Math.max(0.35, 0.55 * SCALE); - // Graphics layers - this.terrainGfx = this.add.graphics().setDepth(5); - this.gameGfx = this.add.graphics().setDepth(10); - this.radarGfx = this.add.graphics().setDepth(800); - this.hudExtraGfx = this.add.graphics().setDepth(801); - // Player ship sprite (scale to match screen) - this.shipSprite = this.add.image(0, 0, 'def-ship-r').setDepth(10).setOrigin(0.5, 0.5).setScale(this.spriteScale); - // Input — set up references but don't capture yet (ready screen needs keydown) - this.cursors = this.input.keyboard.createCursorKeys(); - this.fireKey = this.input.keyboard.addKey('SPACE'); - this.bombKey = this.input.keyboard.addKey('Z'); - this.fireWasDown = false; - this.bombWasDown = false; - this.fireCooldown = 0; - this.thrustSoundPlaying = false; - this.syncLivesToHUD(); - this.syncScoreToHUD(); - this.loadHighScore(); - this.startWithReadyScreen(() => { - // Capture keys only after ready screen dismisses - this.input.keyboard.addCapture('UP,DOWN,LEFT,RIGHT,SPACE,Z'); - this.startWave(); - }); - } - update(_t, dtMs) { - if (this.gameOver || !this.cursors) - return; - const dt = Math.min(dtMs, 33); - const dtSec = dt / 1000; - // Respawn timer - if (this.respawnTimer > 0) { - this.respawnTimer -= dt; - if (this.respawnTimer <= 0) - this.respawnPlayer(); - } - // Fire cooldown - if (this.fireCooldown > 0) - this.fireCooldown -= dt; - // Player input & physics - if (this.shipAlive) { - this.updatePlayerInput(dtSec); - this.updatePlayerPhysics(dtSec); - } - // Update camera to follow player - this.updateCamera(dtSec); - // Update entities - this.updateEnemies(dtSec); - this.updateHumanoids(dtSec); - this.updateBulletsPhysics(dtSec); - this.updateMines(dt); - this.checkCollisions(); - // Wave management - this.waveTimer += dt; - if (!this.baiterSpawned && this.wave >= 2 && this.waveTimer > 30000) { - this.spawnBaiter(); - this.baiterSpawned = true; - } - if (this.waveDelay > 0) { - this.waveDelay -= dt; - if (this.waveDelay <= 0) - this.startWave(); - } - else if (this.enemies.filter(e => e.alive).length === 0 && this.mines.length === 0 && this.waveDelay <= 0 && this.wave > 0) { - // Wave complete - this.onWaveComplete(); - } - // Invincibility blink - if (this.invincibleTimer > 0) { - this.invincibleTimer -= dt; - } - // Clean up expired emitters (handled by delayed destroy in spawnExplosion) - // Render everything - this.renderGame(); - } - /* ================================================================ - TERRAIN - ================================================================ */ - generateTerrain() { - const numSamples = Math.ceil(WORLD_W / TERRAIN_SAMPLE) + 1; - this.terrainHeights = []; - // Generate raw heights - for (let i = 0; i < numSamples; i++) { - const t = i / numSamples; - const base = H * (TERRAIN_MIN_Y + (TERRAIN_MAX_Y - TERRAIN_MIN_Y) * 0.5); - const variation = H * (TERRAIN_MAX_Y - TERRAIN_MIN_Y) * 0.5; - const h = base + - Math.sin(t * Math.PI * 12) * variation * 0.4 + - Math.sin(t * Math.PI * 25 + 1.3) * variation * 0.3 + - Math.sin(t * Math.PI * 50 + 2.7) * variation * 0.2 + - (Math.random() - 0.5) * variation * 0.3; - this.terrainHeights.push(h); - } - // Smooth - for (let pass = 0; pass < 3; pass++) { - const smoothed = [...this.terrainHeights]; - for (let i = 1; i < smoothed.length - 1; i++) { - smoothed[i] = (this.terrainHeights[i - 1] + this.terrainHeights[i] + this.terrainHeights[i + 1]) / 3; - } - // Wrap edges - smoothed[0] = (this.terrainHeights[this.terrainHeights.length - 1] + this.terrainHeights[0] + this.terrainHeights[1]) / 3; - smoothed[smoothed.length - 1] = (this.terrainHeights[this.terrainHeights.length - 2] + this.terrainHeights[this.terrainHeights.length - 1] + this.terrainHeights[0]) / 3; - this.terrainHeights = smoothed; - } - } - getTerrainY(worldX) { - // Wrap x into world range - let wx = this.wrapWorldX(worldX); - const idx = wx / TERRAIN_SAMPLE; - const i0 = Math.floor(idx) % this.terrainHeights.length; - const i1 = (i0 + 1) % this.terrainHeights.length; - const frac = idx - Math.floor(idx); - return this.terrainHeights[i0] * (1 - frac) + this.terrainHeights[i1] * frac; - } - wrapWorldX(x) { - return ((x % WORLD_W) + WORLD_W) % WORLD_W; - } - /* ================================================================ - PLAYER - ================================================================ */ - updatePlayerInput(dtSec) { - // Original Defender controls: - // - UP/DOWN = vertical movement (joystick) - // - LEFT = reverse (flip ship facing) - // - RIGHT = thrust (forward in facing direction) - // Adapted for keyboard: LEFT/RIGHT still control direction, - // but pressing opposite to facing FIRST reverses, THEN thrusts - // with a brief acceleration delay to simulate reverse→thrust feel. - const leftDown = this.cursors.left.isDown; - const rightDown = this.cursors.right.isDown; - if (rightDown && !leftDown) { - if (!this.facingRight) { - // Reversing: flip first, apply reduced thrust - this.facingRight = true; - this.playerVx += PLAYER_THRUST * dtSec * 0.3; - } - else { - // Thrusting forward - this.playerVx += PLAYER_THRUST * dtSec; - } - } - else if (leftDown && !rightDown) { - if (this.facingRight) { - // Reversing: flip first, apply reduced thrust - this.facingRight = false; - this.playerVx -= PLAYER_THRUST * dtSec * 0.3; - } - else { - // Thrusting forward - this.playerVx -= PLAYER_THRUST * dtSec; - } - } - // Vertical movement (direct, like original joystick) - if (this.cursors.up.isDown) { - this.playerVy = -PLAYER_VY_SPEED; - } - else if (this.cursors.down.isDown) { - this.playerVy = PLAYER_VY_SPEED; - } - else { - this.playerVy *= 0.9; - } - // Fire — RAPID-FIRE when held down (original Defender behavior) - if (this.fireKey.isDown) { - this.fireBullet(); - } - // Smart bomb — single press - const bombDown = this.bombKey.isDown; - if (bombDown && !this.bombWasDown) { - this.useSmartBomb(); - } - this.bombWasDown = bombDown; - // Thrust sound - const isThrusting = this.cursors.left.isDown || this.cursors.right.isDown; - if (isThrusting && !this.thrustSoundPlaying) { - try { - this.sound.play('snd_thrust', { volume: 0.15, loop: true }); - } - catch { } - this.thrustSoundPlaying = true; - } - else if (!isThrusting && this.thrustSoundPlaying) { - try { - this.sound.stopByKey('snd_thrust'); - } - catch { } - this.thrustSoundPlaying = false; - } - } - updatePlayerPhysics(dtSec) { - // Friction on horizontal - this.playerVx *= Math.pow(PLAYER_FRICTION, dtSec * 60); - // Clamp - if (this.playerVx > PLAYER_MAX_VX) - this.playerVx = PLAYER_MAX_VX; - if (this.playerVx < -PLAYER_MAX_VX) - this.playerVx = -PLAYER_MAX_VX; - this.playerX += this.playerVx * dtSec; - this.playerY += this.playerVy * dtSec; - // World wrap X - this.playerX = this.wrapWorldX(this.playerX); - // Clamp Y — only prevent going off-screen, NOT above terrain - // In original Defender, ship can fly below the mountain line - const topLimit = RADAR_Y + RADAR_H + 10; - if (this.playerY < topLimit) - this.playerY = topLimit; - if (this.playerY > H - 10) - this.playerY = H - 10; - // Carry humanoid - if (this.carriedHumanoid >= 0) { - const h = this.humanoids[this.carriedHumanoid]; - if (h && h.state === 'rescued') { - h.x = this.playerX; - h.y = this.playerY + 10 * PX / 3; - // Check if touching terrain to return humanoid - if (!this.planetDestroyed) { - const tY = this.getTerrainY(h.x); - if (h.y >= tY - 5) { - h.y = tY - 3; - h.state = 'walking'; - h.vx = (Math.random() > 0.5 ? 1 : -1) * 15; - this.carriedHumanoid = -1; - this.addScore(500, this.worldToScreenX(h.x), h.y); - } - } - } - } - } - respawnPlayer() { - this.shipAlive = true; - this.invincibleTimer = INVINCIBLE_TIME; - this.smartBombs = 3; - this.carriedHumanoid = -1; - this.playerVx = 0; - this.playerVy = 0; - this.playerY = H * 0.4; - // Safety: push nearby enemies away from spawn point - // Baiters get pushed much further since they home aggressively - for (const e of this.enemies) { - if (!e.alive) - continue; - const safeRadius = e.type === 'baiter' ? RESPAWN_SAFE_RADIUS_BAITER : RESPAWN_SAFE_RADIUS; - const d = this.worldDist(e.x, e.y, this.playerX, this.playerY); - if (d < safeRadius) { - const angle = Math.atan2(e.y - this.playerY, e.x - this.playerX) || Math.random() * Math.PI * 2; - e.x = this.playerX + Math.cos(angle) * (safeRadius + RESPAWN_PUSH_OFFSET); - e.y = this.playerY + Math.sin(angle) * (safeRadius * 0.4); - e.x = this.wrapWorldX(e.x); - // Kill velocity so they don't rush back immediately - e.vx *= 0.1; - e.vy *= 0.1; - // Reset baiter to dormant phase so player has time to orient - if (e.type === 'baiter') { - e.zigPhase = 0; - } - } - } - } - killPlayer() { - if (!this.shipAlive || this.invincibleTimer > 0) - return; - this.shipAlive = false; - if (this.shipSprite) - this.shipSprite.setVisible(false); - try { - this.sound.play('snd_playerdead', { volume: 0.5 }); - } - catch { } - // Stop thrust sound - try { - this.sound.stopByKey('snd_thrust'); - } - catch { } - this.thrustSoundPlaying = false; - // Drop carried humanoid - if (this.carriedHumanoid >= 0) { - const h = this.humanoids[this.carriedHumanoid]; - if (h) { - h.state = 'falling'; - h.vy = 0; - } - this.carriedHumanoid = -1; - } - // Explosion - this.spawnExplosion(this.playerX, this.playerY, 0xff00ff, 16); - this.lives--; - this.syncLivesToHUD(); - if (this.lives <= 0) { - this.gameOver = true; - this.checkHighScore(); - // Release keyboard captures so game-over overlay can receive key events - try { - this.input.keyboard.removeCapture('SPACE,Z,UP,DOWN,LEFT,RIGHT'); - } - catch { } - this.time.delayedCall(1000, () => { - this.showGameOver(this.score, () => this.scene.restart()); - }); - } - else { - this.respawnTimer = RESPAWN_DELAY; - } - } - /* ================================================================ - CAMERA - ================================================================ */ - updateCamera(dtSec) { - // Camera tries to keep player slightly off-center in the direction of movement - let targetCamX = this.playerX - W * 0.35; - if (!this.facingRight) { - targetCamX = this.playerX - W * 0.65; - } - // Lerp - const lerpSpeed = 5; - let diff = targetCamX - this.cameraX; - // Handle wrapping - if (diff > WORLD_W / 2) - diff -= WORLD_W; - if (diff < -WORLD_W / 2) - diff += WORLD_W; - this.cameraX += diff * lerpSpeed * dtSec; - this.cameraX = this.wrapWorldX(this.cameraX); - } - worldToScreenX(worldX) { - let sx = worldX - this.cameraX; - if (sx > WORLD_W / 2) - sx -= WORLD_W; - if (sx < -WORLD_W / 2) - sx += WORLD_W; - return sx; - } - isOnScreen(worldX, margin = 100) { - const sx = this.worldToScreenX(worldX); - return sx > -margin && sx < W + margin; - } - /* ================================================================ - BULLETS - ================================================================ */ - fireBullet() { - if (this.fireCooldown > 0) - return; - const playerBullets = this.bullets.filter(b => !b.isEnemy); - if (playerBullets.length >= MAX_BULLETS) - return; - this.fireCooldown = 80; // ms between shots (rapid fire ~12/sec) - const dir = this.facingRight ? 1 : -1; - // Spawn bullet at the nose of the ship (half the rendered ship width ahead) - const shipHalfW = 118 * this.spriteScale / 2; - const bx = this.playerX + dir * (shipHalfW + 5); - try { - this.sound.play('snd_laser', { volume: 0.3 }); - } - catch { } - this.bullets.push({ - x: bx, y: this.playerY, - vx: BULLET_SPEED * dir + this.playerVx * 0.5, - vy: 0, - life: 1500, - isEnemy: false, - }); - } - fireEnemyBullet(ex, ey) { - if (!this.shipAlive) - return; - let adjDx = this.playerX - ex; - if (adjDx > WORLD_W / 2) - adjDx -= WORLD_W; - if (adjDx < -WORLD_W / 2) - adjDx += WORLD_W; - const dy = this.playerY - ey; - const dist = Math.sqrt(adjDx * adjDx + dy * dy) || 1; - // Predictive lead: compensate for player velocity - const leadTime = dist / ENEMY_BULLET_SPEED; - const predictX = adjDx + this.playerVx * leadTime * 0.5; - const predictY = dy + this.playerVy * leadTime * 0.5; - // Add slight random spread (±10°) - const spread = (Math.random() - 0.5) * 0.35; - const angle = Math.atan2(predictY, predictX) + spread; - try { - this.sound.play('snd_enemyshoot', { volume: 0.2 }); - } - catch { } - this.bullets.push({ - x: ex, y: ey, - vx: Math.cos(angle) * ENEMY_BULLET_SPEED, - vy: Math.sin(angle) * ENEMY_BULLET_SPEED, - life: 3000, - isEnemy: true, - }); - } - updateBulletsPhysics(dtSec) { - for (let i = this.bullets.length - 1; i >= 0; i--) { - const b = this.bullets[i]; - b.x += b.vx * dtSec; - b.y += b.vy * dtSec; - b.life -= dtSec * 1000; - // World wrap - b.x = this.wrapWorldX(b.x); - if (b.life <= 0 || b.y < 0 || b.y > H) { - this.bullets.splice(i, 1); - } - } - } - /* ================================================================ - SMART BOMB - ================================================================ */ - useSmartBomb() { - if (this.smartBombs <= 0) - return; - this.smartBombs--; - try { - this.sound.play('snd_explode', { volume: 0.5 }); - } - catch { } - // Destroy all on-screen enemies - for (const e of this.enemies) { - if (!e.alive) - continue; - if (this.isOnScreen(e.x)) { - this.destroyEnemy(e); - } - } - // Destroy on-screen mines - for (let i = this.mines.length - 1; i >= 0; i--) { - if (this.isOnScreen(this.mines[i].x)) { - this.mines.splice(i, 1); - } - } - // Screen flash - const flash = this.add.graphics().setDepth(900); - flash.fillStyle(0xffffff, 0.7); - flash.fillRect(0, 0, W, H); - this.tweens.add({ - targets: flash, - alpha: 0, - duration: 400, - onComplete: () => flash.destroy(), - }); - } - /* ================================================================ - ENEMIES - ================================================================ */ - createEnemy(type, x, y) { - const textureKey = 'def-' + type; - const sprite = this.add.image(0, 0, textureKey).setDepth(10).setOrigin(0.5, 0.5).setScale(this.spriteScale); - return { - type, x, y, - vx: 0, vy: 0, - alive: true, - shootTimer: 2000 + Math.random() * 3000, - targetHumanoid: -1, - hasHumanoid: false, - zigTimer: 0, - mineTimer: 3000 + Math.random() * 1000, - zigPhase: Math.random() * Math.PI * 2, - sprite, - }; - } - spawnLanders(count) { - for (let i = 0; i < count; i++) { - const x = Math.random() * WORLD_W; - const y = 50 + Math.random() * 80; - const e = this.createEnemy('lander', x, y); - e.vy = 30 + Math.random() * 20; - e.vx = (Math.random() - 0.5) * 60; - this.enemies.push(e); - } - } - spawnBombers(count) { - for (let i = 0; i < count; i++) { - const x = Math.random() * WORLD_W; - const y = 100 + Math.random() * (H * 0.3); - const e = this.createEnemy('bomber', x, y); - e.vx = (Math.random() > 0.5 ? 1 : -1) * (40 + Math.random() * 30); - e.vy = (Math.random() - 0.5) * 10; - this.enemies.push(e); - } - } - spawnPods(count) { - for (let i = 0; i < count; i++) { - const x = Math.random() * WORLD_W; - const y = 80 + Math.random() * (H * 0.3); - const e = this.createEnemy('pod', x, y); - e.vx = (Math.random() - 0.5) * 40; - e.vy = (Math.random() - 0.5) * 20; - this.enemies.push(e); - } - } - spawnSwarmers(x, y, count) { - for (let i = 0; i < count; i++) { - const e = this.createEnemy('swarmer', x + (Math.random() - 0.5) * 30, y + (Math.random() - 0.5) * 30); - e.vx = (Math.random() - 0.5) * 200; - e.vy = (Math.random() - 0.5) * 200; - this.enemies.push(e); - } - } - spawnBaiter() { - // Spawn off-screen - const x = (this.playerX + W * (Math.random() > 0.5 ? 1 : -1)) % WORLD_W; - const y = 80 + Math.random() * (H * 0.3); - const e = this.createEnemy('baiter', x, y); - e.zigPhase = 0; // Start in dormant phase - e.shootTimer = 1500; // Don't shoot during dormant phase - try { - this.sound.play('snd_baiterwarning', { volume: 0.4 }); - } - catch { } - this.enemies.push(e); - } - updateEnemies(dtSec) { - const speedMult = 1 + (Math.min(this.wave, 15) - 1) * 0.12; // OpenDefender-style: 1.0 at wave 1, ~2.7 at wave 15 - for (const e of this.enemies) { - if (!e.alive) - continue; - switch (e.type) { - case 'lander': - this.updateLander(e, dtSec, speedMult); - break; - case 'mutant': - this.updateMutant(e, dtSec, speedMult); - break; - case 'bomber': - this.updateBomber(e, dtSec, speedMult); - break; - case 'pod': - this.updatePod(e, dtSec, speedMult); - break; - case 'swarmer': - this.updateSwarmer(e, dtSec, speedMult); - break; - case 'baiter': - this.updateBaiter(e, dtSec, speedMult); - break; - } - // World wrap - e.x = this.wrapWorldX(e.x); - // Clamp Y — keep enemies in playable area (not below terrain line) - if (e.y < RADAR_Y + RADAR_H + 10) - e.y = RADAR_Y + RADAR_H + 10; - const maxEnemyY = this.planetDestroyed ? H - 40 : H * 0.75; - if (e.y > maxEnemyY) - e.y = maxEnemyY; - // Shooting (lander, mutant, baiter, bomber) - if (e.type !== 'pod' && e.type !== 'swarmer') { - e.shootTimer -= dtSec * 1000; - if (e.shootTimer <= 0 && this.isOnScreen(e.x, 200)) { - this.fireEnemyBullet(e.x, e.y); - const dif = Math.min(this.wave, 15); - let baseInterval; - if (e.type === 'lander') { - baseInterval = e.hasHumanoid ? Math.max(500, 1500 - dif * 80) : Math.max(800, 2500 - dif * 100); - } - else if (e.type === 'mutant') { - baseInterval = Math.max(400, 1200 - dif * 60); - } - else if (e.type === 'baiter') { - baseInterval = Math.max(300, 1500 - dif * 80); - } - else { - baseInterval = Math.max(600, 2000 - dif * 80); - } - e.shootTimer = baseInterval + Math.random() * 500; - } - } - } - } - updateLander(e, dtSec, speedMult) { - if (!e.hasHumanoid) { - // Find a target humanoid if none - if (e.targetHumanoid < 0 || this.humanoids[e.targetHumanoid]?.state !== 'walking') { - e.targetHumanoid = -1; - const walkingIdxs = this.humanoids.map((h, i) => h.state === 'walking' ? i : -1).filter(i => i >= 0); - if (walkingIdxs.length > 0) { - e.targetHumanoid = walkingIdxs[Math.floor(Math.random() * walkingIdxs.length)]; - } - } - // Descend toward target humanoid - if (e.targetHumanoid >= 0) { - const h = this.humanoids[e.targetHumanoid]; - if (!h || h.state === 'dead') { - e.targetHumanoid = -1; - } - else { - let dx = this.wrapDx(h.x - e.x); - e.vx += (dx > 0 ? 1 : -1) * 200 * dtSec * speedMult; - // Only descend if ABOVE the humanoid, otherwise hover at humanoid height - const dy = h.y - e.y; - if (dy > 30) { - e.vy = 120 * speedMult; // descend toward humanoid - } - else if (dy < -20) { - e.vy = -60 * speedMult; // rise back up if too low - } - else { - e.vy *= 0.9; // hover near humanoid height - } - // Zig-zag - e.zigTimer += dtSec; - e.vx += Math.sin(e.zigTimer * 3) * 120 * dtSec; - // Check grab — generous radius - if (Math.abs(dx) < 25 && Math.abs(dy) < 25 && h.state === 'walking') { - e.hasHumanoid = true; - h.state = 'grabbed'; - h.vx = 0; - h.vy = 0; - try { - this.sound.play('snd_warning', { volume: 0.3 }); - } - catch { } - } - } - } - else { - // No humanoid to target — patrol at mid-height - e.zigTimer += dtSec; - e.vx += Math.sin(e.zigTimer * 2) * 100 * dtSec; - // Maintain patrol altitude around 30% of screen height - const patrolY = H * 0.3; - if (e.y < patrolY - 50) - e.vy = 40 * speedMult; - else if (e.y > patrolY + 50) - e.vy = -40 * speedMult; - else - e.vy += (Math.random() - 0.5) * 80 * dtSec; - } - } - else { - // Ascend with humanoid — fast! - e.vy = -180 * speedMult; - e.vx *= 0.98; - // Move humanoid with lander - const hIdx = e.targetHumanoid; - if (hIdx >= 0 && this.humanoids[hIdx]) { - this.humanoids[hIdx].x = e.x; - this.humanoids[hIdx].y = e.y + 12 * PX / 3; - } - // If reached top → mutate - if (e.y <= 40) { - // Humanoid dies - if (hIdx >= 0 && this.humanoids[hIdx]) { - this.humanoids[hIdx].state = 'dead'; - this.humanoids[hIdx].sprite = this.destroyObj(this.humanoids[hIdx].sprite); - try { - this.sound.play('snd_humanoiddead', { volume: 0.3 }); - } - catch { } - } - // Lander becomes mutant — swap sprite texture - e.type = 'mutant'; - if (e.sprite) - e.sprite.setTexture('def-mutant'); - try { - this.sound.play('snd_explode', { volume: 0.4 }); - } - catch { } - e.hasHumanoid = false; - e.targetHumanoid = -1; - this.checkPlanetDestroyed(); - } - } - // Apply velocity with clamping - e.vx = Math.max(-280 * speedMult, Math.min(280 * speedMult, e.vx)); - e.x += e.vx * dtSec; - e.y += e.vy * dtSec; - } - updateMutant(e, dtSec, speedMult) { - // Home toward player - const dx = this.wrapDx(this.playerX - e.x); - const dy = this.playerY - e.y; - const dist = Math.sqrt(dx * dx + dy * dy) || 1; - const speed = 300 * speedMult; - e.vx += (dx / dist) * speed * dtSec * 3; - e.vy += (dy / dist) * speed * dtSec * 3; - // Random jitter - e.vx += (Math.random() - 0.5) * 400 * dtSec; - e.vy += (Math.random() - 0.5) * 400 * dtSec; - // Clamp speed - const maxV = speed * 1.5; - const curSpeed = Math.sqrt(e.vx * e.vx + e.vy * e.vy); - if (curSpeed > maxV) { - e.vx = (e.vx / curSpeed) * maxV; - e.vy = (e.vy / curSpeed) * maxV; - } - e.x += e.vx * dtSec; - e.y += e.vy * dtSec; - } - updateBomber(e, dtSec, speedMult) { - // Slow horizontal drift - e.x += e.vx * dtSec * speedMult; - e.y += Math.sin(e.zigPhase) * 15 * dtSec; - e.zigPhase += dtSec; - // Drop mines - e.mineTimer -= dtSec * 1000; - if (e.mineTimer <= 0) { - this.mines.push({ - x: e.x, - y: e.y + 10, - life: 15000, - blinkTimer: 0, - }); - e.mineTimer = 3000 + Math.random() * 1000; - } - } - updatePod(e, dtSec, speedMult) { - // Slow drift - e.x += e.vx * dtSec * speedMult; - e.y += e.vy * dtSec * speedMult; - // Gentle bounce at vertical boundaries - if (e.y < 60 || e.y > H * 0.6) - e.vy = -e.vy; - } - updateSwarmer(e, dtSec, speedMult) { - // Fast zig-zag toward player - const dx = this.wrapDx(this.playerX - e.x); - const dy = this.playerY - e.y; - const dist = Math.sqrt(dx * dx + dy * dy) || 1; - const speed = 400 * speedMult; - e.vx += (dx / dist) * speed * dtSec * 2; - e.vy += (dy / dist) * speed * dtSec * 2; - // Erratic zig-zag - e.zigPhase += dtSec * 10; - e.vx += Math.sin(e.zigPhase) * 300 * dtSec; - e.vy += Math.cos(e.zigPhase * 1.3) * 200 * dtSec; - // Clamp - const maxV = speed * 1.8; - const curSpeed = Math.sqrt(e.vx * e.vx + e.vy * e.vy); - if (curSpeed > maxV) { - e.vx = (e.vx / curSpeed) * maxV; - e.vy = (e.vy / curSpeed) * maxV; - } - e.x += e.vx * dtSec; - e.y += e.vy * dtSec; - // Smart direction change when far from player (OpenDefender: 200px) - let sdx = this.playerX - e.x; - if (sdx > WORLD_W / 2) - sdx -= WORLD_W; - if (sdx < -WORLD_W / 2) - sdx += WORLD_W; - if (Math.abs(sdx) > 300) { - e.vx += (sdx > 0 ? 1 : -1) * 500 * dtSec; - } - } - updateBaiter(e, dtSec, speedMult) { - e.zigPhase += dtSec; - // Phase 1: Brief dormant hover (first 1.5 seconds) - if (e.zigPhase < 1.5) { - e.vx *= 0.95; - e.vy *= 0.95; - e.x += e.vx * dtSec; - e.y += e.vy * dtSec; - return; - } - const dx = this.wrapDx(this.playerX - e.x); - const dy = this.playerY - e.y; - const dist = Math.sqrt(dx * dx + dy * dy) || 1; - const speed = 280 * speedMult; - // Orbit behavior: if close to player, strafe around instead of sitting on top - const minDist = 150; - if (dist < minDist) { - // Too close — veer away perpendicular + strafe - const perpX = -dy / dist; - const perpY = dx / dist; - e.vx += perpX * speed * dtSec * 3; - e.vy += perpY * speed * dtSec * 3; - // Push away slightly - e.vx -= (dx / dist) * speed * dtSec * 1.5; - e.vy -= (dy / dist) * speed * dtSec * 1.5; - } - else { - // Approach but not too aggressively - e.vx += (dx / dist) * speed * dtSec * 1.5; - e.vy += (dy / dist) * speed * dtSec * 1.5; - } - // Strafing oscillation - e.vx += Math.sin(e.zigPhase * 4) * 180 * dtSec; - e.vy += Math.cos(e.zigPhase * 3) * 120 * dtSec; - // Clamp to max speed - const maxV = speed * 0.7; - const curSpeed = Math.sqrt(e.vx * e.vx + e.vy * e.vy); - if (curSpeed > maxV) { - e.vx = (e.vx / curSpeed) * maxV; - e.vy = (e.vy / curSpeed) * maxV; - } - e.x += e.vx * dtSec; - e.y += e.vy * dtSec; - } - destroyEnemy(e) { - if (!e.alive) - return; - e.alive = false; - e.sprite = this.destroyObj(e.sprite); - try { - this.sound.play('snd_enemydead', { volume: 0.4 }); - } - catch { } - const colorMap = { - lander: 0x00ff00, mutant: 0xff00ff, bomber: 0xffff00, - pod: 0xcc00cc, swarmer: 0xffff00, baiter: 0x00ff44, - }; - const scoreMap = { - lander: 150, mutant: 150, bomber: 250, - pod: 1000, swarmer: 150, baiter: 200, - }; - const sx = this.worldToScreenX(e.x); - this.addScore(scoreMap[e.type], sx, e.y); - this.spawnExplosion(e.x, e.y, colorMap[e.type], 10); - this.checkExtraLife(); - // Release humanoid if lander was carrying one - if (e.type === 'lander' && e.hasHumanoid && e.targetHumanoid >= 0) { - const h = this.humanoids[e.targetHumanoid]; - if (h && h.state === 'grabbed') { - h.state = 'falling'; - h.vy = 0; - } - } - // Pod splits into swarmers - if (e.type === 'pod') { - const count = 3 + Math.floor(Math.random() * 3); - this.spawnSwarmers(e.x, e.y, count); - } - } - /* ================================================================ - HUMANOIDS - ================================================================ */ - spawnHumanoids(count) { - // Destroy existing humanoid sprites before respawning - for (const h of this.humanoids) { - h.sprite = this.destroyObj(h.sprite); - } - this.humanoids = []; - for (let i = 0; i < count; i++) { - const x = Math.random() * WORLD_W; - const tY = this.getTerrainY(x); - const sprite = this.add.image(0, 0, 'def-humanoid').setDepth(10).setOrigin(0.5, 0.5).setScale(this.spriteScale); - this.humanoids.push({ - x, - y: tY - 3, - vx: (Math.random() > 0.5 ? 1 : -1) * (10 + Math.random() * 10), - vy: 0, - state: 'walking', - walkDir: Math.random() > 0.5 ? 1 : -1, - sprite, - }); - } - } - updateHumanoids(dtSec) { - for (let i = 0; i < this.humanoids.length; i++) { - const h = this.humanoids[i]; - switch (h.state) { - case 'walking': - if (this.planetDestroyed) { - // Planet destroyed — humanoids fall - h.state = 'falling'; - h.vy = 0; - break; - } - h.x += h.vx * dtSec; - h.x = this.wrapWorldX(h.x); - const tY = this.getTerrainY(h.x); - h.y = tY - 3; - // Randomly change direction - if (Math.random() < 0.005) - h.vx = -h.vx; - break; - case 'grabbed': - // Moved by lander in updateLander - break; - case 'falling': - // Gentle gravity matching OpenDefender (fallspeed=0.01, terminal=8px/frame) - // Scaled for our coordinate system: slow accel, capped terminal velocity - h.vy += 60 * dtSec; // gentle gravity (~10× slower than before) - if (h.vy > 120) - h.vy = 120; // terminal velocity cap — keeps it catchable - h.y += h.vy * dtSec; - if (!this.planetDestroyed) { - const groundY = this.getTerrainY(h.x); - if (h.y >= groundY - 3) { - if (h.vy > 100) { - // Splat — only if falling fast (dropped from very high without catching) - h.state = 'dead'; - h.sprite = this.destroyObj(h.sprite); - this.spawnExplosion(h.x, h.y, 0xffffff, 6); - try { - this.sound.play('snd_humanoiddead', { volume: 0.3 }); - } - catch { } - this.checkPlanetDestroyed(); - } - else { - // Soft landing - h.y = groundY - 3; - h.vy = 0; - h.state = 'walking'; - h.vx = (Math.random() > 0.5 ? 1 : -1) * 15; - } - } - } - else { - // No terrain — fall to death - if (h.y > H + 50) { - h.state = 'dead'; - h.sprite = this.destroyObj(h.sprite); - try { - this.sound.play('snd_humanoiddead', { volume: 0.3 }); - } - catch { } - } - } - break; - case 'rescued': - // Moved by player in updatePlayerPhysics - break; - case 'dead': - break; - } - } - } - checkPlanetDestroyed() { - if (this.planetDestroyed) - return; - const alive = this.humanoids.filter(h => h.state !== 'dead').length; - if (alive === 0) { - this.planetDestroyed = true; - // All remaining landers become mutants - for (const e of this.enemies) { - if (e.alive && e.type === 'lander') { - e.type = 'mutant'; - if (e.sprite) - e.sprite.setTexture('def-mutant'); - e.hasHumanoid = false; - e.targetHumanoid = -1; - } - } - } - } - /* ================================================================ - MINES - ================================================================ */ - updateMines(dt) { - for (let i = this.mines.length - 1; i >= 0; i--) { - const m = this.mines[i]; - m.life -= dt; - m.blinkTimer += dt; - if (m.life <= 0) { - this.mines.splice(i, 1); - } - } - } - /* ================================================================ - COLLISIONS - ================================================================ */ - worldDist(x1, y1, x2, y2) { - const dx = this.wrapDx(x1 - x2); - const dy = y1 - y2; - return Math.sqrt(dx * dx + dy * dy); - } - /** Wrap a delta-X value for the toroidal world. */ - wrapDx(dx) { - if (dx > WORLD_W / 2) - dx -= WORLD_W; - if (dx < -WORLD_W / 2) - dx += WORLD_W; - return dx; - } - checkCollisions() { - // Use half the ship's rendered height so the hitbox matches the visible sprite - const playerRadius = 53 * this.spriteScale / 2; - // Player bullets vs enemies - for (let bi = this.bullets.length - 1; bi >= 0; bi--) { - const b = this.bullets[bi]; - if (b.isEnemy) - continue; - for (const e of this.enemies) { - if (!e.alive) - continue; - const hitR = e.type === 'swarmer' ? 12 * PX / 3 : 18 * PX / 3; - if (this.worldDist(b.x, b.y, e.x, e.y) < hitR) { - this.destroyEnemy(e); - this.bullets.splice(bi, 1); - break; - } - } - } - // Player bullets vs mines - for (let bi = this.bullets.length - 1; bi >= 0; bi--) { - const b = this.bullets[bi]; - if (b.isEnemy) - continue; - for (let mi = this.mines.length - 1; mi >= 0; mi--) { - const m = this.mines[mi]; - if (this.worldDist(b.x, b.y, m.x, m.y) < 10 * PX / 3) { - this.mines.splice(mi, 1); - this.bullets.splice(bi, 1); - this.addScore(25, this.worldToScreenX(m.x), m.y); - break; - } - } - } - // Player bullets vs humanoids (friendly fire) - for (let bi = this.bullets.length - 1; bi >= 0; bi--) { - const b = this.bullets[bi]; - if (b.isEnemy) - continue; - for (const h of this.humanoids) { - if (h.state !== 'walking') - continue; - const hitR = 14 * PX / 3; - if (this.worldDist(b.x, b.y, h.x, h.y) < hitR) { - if (this.carriedHumanoid >= 0 && this.humanoids[this.carriedHumanoid] === h) { - this.carriedHumanoid = -1; - } - h.state = 'dead'; - try { - this.sound.play('snd_humanoiddead', { volume: 0.3 }); - } - catch { } - this.spawnExplosion(h.x, h.y, 0x00ffff, 8); - this.bullets.splice(bi, 1); - this.checkPlanetDestroyed(); - break; - } - } - } - if (!this.shipAlive) - return; - // Enemy bullets vs player - for (let bi = this.bullets.length - 1; bi >= 0; bi--) { - const b = this.bullets[bi]; - if (!b.isEnemy) - continue; - if (this.worldDist(b.x, b.y, this.playerX, this.playerY) < playerRadius) { - this.bullets.splice(bi, 1); - this.killPlayer(); - return; - } - } - // Enemies body vs player - if (this.invincibleTimer <= 0) { - for (const e of this.enemies) { - if (!e.alive) - continue; - const hitR = e.type === 'swarmer' ? 10 * PX / 3 : 18 * PX / 3; - if (this.worldDist(e.x, e.y, this.playerX, this.playerY) < hitR) { - this.killPlayer(); - return; - } - } - } - // Mines vs player - if (this.invincibleTimer <= 0) { - for (let mi = this.mines.length - 1; mi >= 0; mi--) { - const m = this.mines[mi]; - if (this.worldDist(m.x, m.y, this.playerX, this.playerY) < 10 * PX / 3) { - this.mines.splice(mi, 1); - this.killPlayer(); - return; - } - } - } - // Falling humanoids — catch by player - for (let i = 0; i < this.humanoids.length; i++) { - const h = this.humanoids[i]; - if (h.state !== 'falling') - continue; - if (this.carriedHumanoid >= 0) - continue; // already carrying one - if (this.worldDist(h.x, h.y, this.playerX, this.playerY) < 40 * PX / 3) { - h.state = 'rescued'; - h.vy = 0; - this.carriedHumanoid = i; - this.addScore(250, this.worldToScreenX(h.x), h.y); - try { - this.sound.play('snd_bonus', { volume: 0.4 }); - } - catch { } - } - } - } - /* ================================================================ - WAVES - ================================================================ */ - startWave() { - this.wave++; - this.waveTimer = 0; - this.baiterSpawned = false; - this.syncLevelToHUD(this.wave); - this.showWaveBanner(this.wave); - try { - this.sound.play('snd_start', { volume: 0.3 }); - } - catch { } - // Clear old dead enemies — destroy their sprites - for (const e of this.enemies) { - if (!e.alive) { - e.sprite = this.destroyObj(e.sprite); - } - } - this.enemies = this.enemies.filter(e => e.alive); - this.bullets = []; - this.mines = []; - // Humanoids persist across waves — only spawn on wave 1 or if planet was destroyed - if (this.wave === 1) { - this.spawnHumanoids(10); - } - // Don't respawn humanoids on subsequent waves — they carry over! - // Spawn enemies - const landerCount = 5 + (this.wave - 1) * 2; - this.spawnLanders(landerCount); - if (this.wave >= 3) { - this.spawnBombers(Math.min(this.wave - 2, 4)); - } - if (this.wave >= 4) { - this.spawnPods(Math.min(this.wave - 3, 3)); - } - } - onWaveComplete() { - // Bonus for surviving humanoids - if (!this.planetDestroyed) { - const alive = this.humanoids.filter(h => h.state !== 'dead').length; - if (alive > 0) { - const bonus = 500 * alive; - this.addScore(bonus, W / 2, H / 2); - } - } - this.waveDelay = 2000; - } - /* ================================================================ - EXTRA LIFE - ================================================================ */ - checkExtraLife() { - if (this.score >= this.nextExtraLife) { - this.lives++; - this.syncLivesToHUD(); - this.nextExtraLife += EXTRA_LIFE_SCORE; - try { - this.sound.play('snd_player1up', { volume: 0.5 }); - } - catch { } - // Flash notification - const txt = this.add.text(W / 2, H * 0.3, 'EXTRA LIFE!', { - fontFamily: '"Press Start 2P", monospace', - fontSize: '18px', - color: '#00ff00', - stroke: '#000', - strokeThickness: 3, - }).setOrigin(0.5, 0.5).setDepth(950); - this.tweens.add({ - targets: txt, - y: H * 0.25, - alpha: 0, - duration: 1500, - onComplete: () => txt.destroy(), - }); - } - } - /* ================================================================ - EXPLOSIONS - ================================================================ */ - spawnExplosion(worldX, worldY, color, count) { - const sx = this.worldToScreenX(worldX); - if (sx < -200 || sx > W + 200) - return; // off-screen, skip - this.spawnParticleExplosion(sx, worldY, color, count); - } - /* ================================================================ - RENDERING - ================================================================ */ - renderGame() { - const g = this.gameGfx; - g.clear(); - // Draw terrain - this.renderTerrain(); - // Draw humanoids - this.renderHumanoids(g); - // Draw enemies - this.renderEnemies(g); - // Draw mines - this.renderMines(g); - // Draw bullets - this.renderBullets(g); - // Draw player - if (this.shipAlive) { - const blink = this.invincibleTimer > 0 && Math.sin(performance.now() / 80) < 0; - this.shipSprite.setAlpha(blink ? 0.2 : 1); - this.renderPlayer(g); - } - else { - this.shipSprite.setVisible(false); - } - // Draw radar - this.renderRadar(); - // Draw smart bomb HUD - this.renderSmartBombHUD(); - } - renderTerrain() { - const tg = this.terrainGfx; - tg.clear(); - if (this.planetDestroyed) - return; - // Draw terrain that's visible on screen - const startWorldX = this.cameraX - 20; - const endWorldX = this.cameraX + W + 20; - // Mountain line — orange/brown to match original arcade - tg.lineStyle(2, 0xcc8800, 1); - tg.beginPath(); - let firstPoint = true; - for (let wx = startWorldX; wx <= endWorldX; wx += TERRAIN_SAMPLE / 2) { - const wrappedX = this.wrapWorldX(wx); - const sy = this.getTerrainY(wrappedX); - const sx = wx - this.cameraX; - if (firstPoint) { - tg.moveTo(sx, sy); - firstPoint = false; - } - else { - tg.lineTo(sx, sy); - } - } - tg.strokePath(); - // Subtle fill below terrain — dark brown - tg.fillStyle(0x331800, 0.3); - tg.beginPath(); - firstPoint = true; - for (let wx = startWorldX; wx <= endWorldX; wx += TERRAIN_SAMPLE / 2) { - const wrappedX = this.wrapWorldX(wx); - const sy = this.getTerrainY(wrappedX); - const sx = wx - this.cameraX; - if (firstPoint) { - tg.moveTo(sx, sy); - firstPoint = false; - } - else { - tg.lineTo(sx, sy); - } - } - // Close polygon at bottom - tg.lineTo(endWorldX - this.cameraX, H); - tg.lineTo(startWorldX - this.cameraX, H); - tg.closePath(); - tg.fillPath(); - } - renderPlayer(g) { - const sx = this.worldToScreenX(this.playerX); - this.shipSprite.setPosition(sx, this.playerY); - this.shipSprite.setTexture(this.facingRight ? 'def-ship-r' : 'def-ship-l'); - this.shipSprite.setVisible(true); - // Engine exhaust — fires from the REAR of the ship (opposite of facing direction) - if (this.cursors.left.isDown || this.cursors.right.isDown) { - const shipHalfW = 118 * this.spriteScale / 2; - const shipHalfH = 53 * this.spriteScale / 2; - // Exhaust shoots out behind the ship - const exhaustDir = this.facingRight ? -1 : 1; - const exhaustX = sx + exhaustDir * shipHalfW; - // Main exhaust flame — large, flickering - const flameLen = 15 + Math.random() * 25; // variable length - const flameW = flameLen * SCALE; - const flameH = (6 + Math.random() * 4) * SCALE; - const fx = exhaustDir > 0 ? exhaustX : exhaustX - flameW; - // Outer glow (orange) - g.fillStyle(0xff6600, 0.3 + Math.random() * 0.2); - g.fillRect(fx - 2 * SCALE, this.playerY - flameH * 0.7, flameW + 4 * SCALE, flameH * 1.4); - // Core flame (magenta/pink — matches ship engine) - g.fillStyle(0xff00ff, 0.5 + Math.random() * 0.4); - g.fillRect(fx, this.playerY - flameH * 0.4, flameW * 0.8, flameH * 0.8); - // Hot center (white/yellow) - g.fillStyle(0xffff88, 0.4 + Math.random() * 0.4); - const coreW = flameW * 0.4; - const coreX = exhaustDir > 0 ? exhaustX : exhaustX - coreW; - g.fillRect(coreX, this.playerY - flameH * 0.2, coreW, flameH * 0.4); - // Random sparks/particles - for (let i = 0; i < 3; i++) { - const sparkX = exhaustX + exhaustDir * (Math.random() * flameW * 1.2); - const sparkY = this.playerY + (Math.random() - 0.5) * flameH * 1.5; - const sparkSize = (1 + Math.random() * 2) * SCALE; - g.fillStyle(Math.random() > 0.5 ? 0xff4400 : 0xff00ff, 0.3 + Math.random() * 0.5); - g.fillRect(sparkX, sparkY, sparkSize, sparkSize); - } - } - } - renderEnemies(g) { - for (const e of this.enemies) { - if (!e.alive) { - if (e.sprite) - e.sprite.setVisible(false); - continue; - } - const sx = this.worldToScreenX(e.x); - if (sx < -60 || sx > W + 60) { - if (e.sprite) - e.sprite.setVisible(false); - continue; - } - if (e.sprite) { - e.sprite.setPosition(sx, e.y); - e.sprite.setVisible(true); - // Mutant pulse effect - if (e.type === 'mutant') { - e.sprite.setAlpha(0.7 + Math.sin(performance.now() / 200) * 0.3); - } - } - } - } - renderHumanoids(g) { - for (const h of this.humanoids) { - if (h.state === 'dead') { - if (h.sprite) - h.sprite.setVisible(false); - continue; - } - const sx = this.worldToScreenX(h.x); - if (sx < -30 || sx > W + 30) { - if (h.sprite) - h.sprite.setVisible(false); - continue; - } - if (h.sprite) { - h.sprite.setPosition(sx, h.y); - h.sprite.setVisible(true); - // Color tint based on state - if (h.state === 'rescued') - h.sprite.setTint(0x00ff00); - else if (h.state === 'falling') - h.sprite.setTint(0xff8800); - else if (h.state === 'grabbed') - h.sprite.setTint(0xff4444); - else - h.sprite.clearTint(); - } - } - } - renderBullets(g) { - const bs = Math.max(3, Math.round(4 * SCALE)); // bullet size scales with screen - for (const b of this.bullets) { - const sx = this.worldToScreenX(b.x); - if (sx < -200 || sx > W + 200) - continue; - if (b.isEnemy) { - g.fillStyle(0xff0000); - g.fillRect(sx - bs, b.y - bs, bs * 2, bs * 2); - } - else { - // Long dashed laser beam — scales with screen - const dir = b.vx > 0 ? 1 : -1; - const beamLen = Math.round(120 * SCALE); - const segLen = Math.round(14 * SCALE); - const gapLen = Math.round(6 * SCALE); - const thick = Math.max(3, Math.round(4 * SCALE)); - for (let i = 0; i < beamLen; i += segLen + gapLen) { - const segX = sx + (dir > 0 ? -i - segLen : i); - g.fillStyle(0xff4400, 1); - g.fillRect(segX, b.y - Math.floor(thick / 2), segLen, thick); - } - // Bright tip - const tipS = Math.max(4, Math.round(5 * SCALE)); - g.fillStyle(0xffff00, 1); - g.fillRect(sx - tipS, b.y - Math.floor(tipS / 2), tipS * 2, tipS); - } - } - } - renderMines(g) { - for (const m of this.mines) { - const sx = this.worldToScreenX(m.x); - if (sx < -20 || sx > W + 20) - continue; - // Blink effect - const visible = Math.sin(m.blinkTimer * 0.008) > -0.3; - if (visible) { - g.fillStyle(0xff0000); - const ms = PX * 1.5; - g.fillRect(sx - ms, m.y - ms, ms * 2, ms * 2); - } - } - } - renderRadar() { - const rg = this.radarGfx; - rg.clear(); - // Background - rg.fillStyle(0x000000, 0.5); - rg.fillRect(0, RADAR_Y, W, RADAR_H); - // Blue border lines (left and right edges, like original) - rg.lineStyle(2, 0x0044ff, 0.9); - rg.beginPath(); - rg.moveTo(W * 0.3, RADAR_Y); - rg.lineTo(W * 0.3, RADAR_Y + RADAR_H); - rg.strokePath(); - rg.beginPath(); - rg.moveTo(W * 0.7, RADAR_Y); - rg.lineTo(W * 0.7, RADAR_Y + RADAR_H); - rg.strokePath(); - // Top and bottom border - rg.lineStyle(1, 0x0044ff, 0.6); - rg.strokeRect(0, RADAR_Y, W, RADAR_H); - const scaleX = W / WORLD_W; - const scaleY = RADAR_H / H; - // Terrain on radar — orange to match main terrain - if (!this.planetDestroyed) { - rg.lineStyle(1, 0xcc8800, 0.6); - rg.beginPath(); - let first = true; - for (let i = 0; i < this.terrainHeights.length; i += 4) { - const wx = i * TERRAIN_SAMPLE; - const rx = wx * scaleX; - const ry = RADAR_Y + this.terrainHeights[i] * scaleY; - if (first) { - rg.moveTo(rx, ry); - first = false; - } - else - rg.lineTo(rx, ry); - } - rg.strokePath(); - } - // Blips - const blipSize = 3; - // Humanoids (cyan) - rg.fillStyle(0x00ffff); - for (const h of this.humanoids) { - if (h.state === 'dead') - continue; - rg.fillRect(h.x * scaleX, RADAR_Y + h.y * scaleY, blipSize, blipSize); - } - // Enemies - for (const e of this.enemies) { - if (!e.alive) - continue; - const color = e.type === 'mutant' ? 0xff00ff : - e.type === 'bomber' ? 0xffff00 : - e.type === 'baiter' ? 0x00ff44 : - e.type === 'swarmer' ? 0xffff00 : - e.type === 'pod' ? 0xcc00cc : - 0x00ff00; - rg.fillStyle(color); - rg.fillRect(e.x * scaleX, RADAR_Y + e.y * scaleY, blipSize, blipSize); - } - // Player (white crosshair, like original — larger for visibility) - const px = this.playerX * scaleX; - const py = RADAR_Y + this.playerY * scaleY; - rg.fillStyle(0xffffff); - rg.fillRect(px - 1, py - 4, 3, 9); // vertical bar - rg.fillRect(px - 4, py - 1, 9, 3); // horizontal bar - } - renderSmartBombHUD() { - const hg = this.hudExtraGfx; - hg.clear(); - // Draw smart bomb count below radar - const bombY = RADAR_Y + RADAR_H + 4; - for (let i = 0; i < this.smartBombs; i++) { - hg.fillStyle(0xff4400); - hg.fillRect(8 + i * 14, bombY, 10, 8); - hg.lineStyle(1, 0xff8800); - hg.strokeRect(8 + i * 14, bombY, 10, 8); - } - } - /* ================================================================ - CLEANUP - ================================================================ */ - shutdown() { - super.shutdown(); - // Stop looping sounds - try { - this.sound.stopByKey('snd_thrust'); - } - catch { } - this.thrustSoundPlaying = false; - // Destroy enemy sprites - for (const e of this.enemies) { - e.sprite = this.destroyObj(e.sprite); - } - // Destroy humanoid sprites - for (const h of this.humanoids) { - h.sprite = this.destroyObj(h.sprite); - } - // Destroy player sprite - this.shipSprite = this.destroyObj(this.shipSprite); - // Destroy graphics objects - this.destroyObj(this.gameGfx); - this.destroyObj(this.radarGfx); - this.destroyObj(this.terrainGfx); - this.destroyObj(this.hudExtraGfx); - } -} -//# sourceMappingURL=PlanetGuardian.js.map \ No newline at end of file diff --git a/extensions/arcade-canvas/package-lock.json b/extensions/arcade-canvas/package-lock.json deleted file mode 100644 index e7e7b176..00000000 --- a/extensions/arcade-canvas/package-lock.json +++ /dev/null @@ -1,275 +0,0 @@ -{ - "name": "arcade-canvas", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "arcade-canvas", - "version": "1.0.0", - "license": "MIT", - "dependencies": { - "@github/copilot-sdk": "latest" - } - }, - "node_modules/@github/copilot": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.63.tgz", - "integrity": "sha512-e8DRYiWJQc4kepVXsXjC8vpDU2FXS/TfR+Z6p/KAojfcwIUZzKMAfCV5D1lD25hV4CryVH1Z9t7mHqChickj0Q==", - "license": "SEE LICENSE IN LICENSE.md", - "dependencies": { - "detect-libc": "^2.1.2", - "os-theme": "^0.0.8" - }, - "bin": { - "copilot": "npm-loader.js" - }, - "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.63", - "@github/copilot-darwin-x64": "1.0.63", - "@github/copilot-linux-arm64": "1.0.63", - "@github/copilot-linux-x64": "1.0.63", - "@github/copilot-linuxmusl-arm64": "1.0.63", - "@github/copilot-linuxmusl-x64": "1.0.63", - "@github/copilot-win32-arm64": "1.0.63", - "@github/copilot-win32-x64": "1.0.63" - } - }, - "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.63.tgz", - "integrity": "sha512-z6CMBxNDlKvT6bvOpqhu4M2bhb0daEbVwSe9SN9WfDUJbt7bpoL7OKKas428iyPSWHoL2WXwxSsy/FjIwSLV6w==", - "cpu": [ - "arm64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "darwin" - ], - "bin": { - "copilot-darwin-arm64": "copilot" - } - }, - "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.63.tgz", - "integrity": "sha512-YKd7cXZgAGxhudzrtWdWh2NS35p2G5bV22Gz3jhEyBTqmq45o4sD4OwO87+UpkvM+3nZpwsHaLd3a+ILYX6OXg==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "darwin" - ], - "bin": { - "copilot-darwin-x64": "copilot" - } - }, - "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.63.tgz", - "integrity": "sha512-A3DOeEfmsJH9j1N+QLc7WXmESBskbezmhDyhyAJcHkw0ngRbKctuWQf/evUHFMh/kgwy1Lr/+9jXJm3NZqr0MA==", - "cpu": [ - "arm64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ], - "bin": { - "copilot-linux-arm64": "copilot" - } - }, - "node_modules/@github/copilot-linux-x64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.63.tgz", - "integrity": "sha512-OMKfZJRoDaJOV7vuWX/nFPNdLa9/H+nhajdE83v4YT9mKLXr86aWrkXE3pPoDYsKWvgQFHg4APA6oZPao0Fyow==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ], - "bin": { - "copilot-linux-x64": "copilot" - } - }, - "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.63.tgz", - "integrity": "sha512-jcIo6B3uHgcOluNfUHp+6atShKKrXYBPLaRyF6aDT699lwI83gW9KTDuEvDs5FDg8qWsWFfOl+al2dkWDYD3CQ==", - "cpu": [ - "arm64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ], - "bin": { - "copilot-linuxmusl-arm64": "copilot" - } - }, - "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.63.tgz", - "integrity": "sha512-BEdBbEF3fG7VqXzuaAY4JtmbdGSkpJFeb2ZQYaMpq7OP3aS7ssGe1cCX8ehZNegcMM/eb4GC6PXNXsvl3X/PAQ==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ], - "bin": { - "copilot-linuxmusl-x64": "copilot" - } - }, - "node_modules/@github/copilot-sdk": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.1.tgz", - "integrity": "sha512-w6AaS0WqqTE/3iyUrZznvgCLQhsUF7ZmEVCneacuHCfOzlH0r6ww9WUmyA0zgqmXO75V0IYrkIcnFke/qJkkDg==", - "license": "MIT", - "dependencies": { - "@github/copilot": "^1.0.61", - "vscode-jsonrpc": "^8.2.1", - "zod": "^4.3.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.63.tgz", - "integrity": "sha512-7FqUwOmtoeBoOn4zkKQqRL+WGFwektVRSr5Po2FvPAbKxGXGyFXApZTmRLqVcHhMKDRzMb8KLST1LU1TMTY/wg==", - "cpu": [ - "arm64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "win32" - ], - "bin": { - "copilot-win32-arm64": "copilot.exe" - } - }, - "node_modules/@github/copilot-win32-x64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.63.tgz", - "integrity": "sha512-RC/6y9KHdw/YRCrCEksF2RzbeblfBUNE7bkYZxygaQGYThuv1GeZL2YD2jVqxC2LxKzsUmWGvwEMxerfR6pmeQ==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "win32" - ], - "bin": { - "copilot-win32-x64": "copilot.exe" - } - }, - "node_modules/@os-theme/darwin-arm64": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/@os-theme/darwin-arm64/-/darwin-arm64-0.0.8.tgz", - "integrity": "sha512-gMsOs+8Ju396a5yyMWigkbA0dMTxD78U3HzG3mlpiAyn6hfd5dbyI4VGP+sfTB82KGgWLzIhWWTFX5UYY6iX0A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@os-theme/linux-x64": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/@os-theme/linux-x64/-/linux-x64-0.0.8.tgz", - "integrity": "sha512-zvjmBUiSQPjM1RbhpsfCDYMJxW4eLlGmkFPnpteC/03X2lz6CjiX2hfbN2EWLxXjNnIje3Jqaen8IsqEnWrRBg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@os-theme/win32-x64": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/@os-theme/win32-x64/-/win32-x64-0.0.8.tgz", - "integrity": "sha512-N3yxKNbVl2IBa/ncDuq55QhwqwUjnYLJxDKMEmYeJbLIV950qZNojPw3scXA6PbfxPZfIiRa8iz1pzNg9XxP8w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/os-theme": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/os-theme/-/os-theme-0.0.8.tgz", - "integrity": "sha512-u1q3bLSv5uMHNIiPItkfDrHXu6ZFs2juwqxWREFM/uVBa+7Kkhy2v49LmJev2JcinGwqiEccElB/XsH9gwasuA==", - "license": "MIT", - "optionalDependencies": { - "@os-theme/darwin-arm64": "0.0.8", - "@os-theme/linux-x64": "0.0.8", - "@os-theme/win32-x64": "0.0.8" - }, - "peerDependencies": { - "typescript": "^5" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "license": "Apache-2.0", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/vscode-jsonrpc": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", - "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/extensions/arcade-canvas/package.json b/extensions/arcade-canvas/package.json deleted file mode 100644 index 1c7734e0..00000000 --- a/extensions/arcade-canvas/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "arcade-canvas", - "version": "1.0.0", - "main": "extension.mjs", - "author": "Dan Wahlin", - "license": "MIT", - "type": "module", - "dependencies": { - "@github/copilot-sdk": "latest" - }, - "description": "Play five retro Phaser mini-games in a Copilot canvas while agents work.", - "keywords": [ - "arcade-games", - "copilot-canvas", - "interactive-canvas", - "phaser", - "retro-games", - "session-breaks" - ] -} diff --git a/extensions/backlog-swipe-triage/.github/plugin/plugin.json b/extensions/backlog-swipe-triage/.github/plugin/plugin.json index 4132f63b..e10d6fb3 100644 --- a/extensions/backlog-swipe-triage/.github/plugin/plugin.json +++ b/extensions/backlog-swipe-triage/.github/plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "backlog-swipe-triage", "description": "Quickly swipe through backlog issues to triage decisions like assign, needs-info, defer, close, or ignore.", - "version": "1.0.1", + "version": "1.0.2", "author": { "name": "James Montemagno", "url": "https://github.com/jamesmontemagno" diff --git a/extensions/backlog-swipe-triage/extensions/README.md b/extensions/backlog-swipe-triage/extensions/README.md deleted file mode 100644 index 291e6ea9..00000000 --- a/extensions/backlog-swipe-triage/extensions/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# Backlog Swipe Triage - -Swipe-driven backlog triage canvas for reviewing open issues, applying quick decisions, and starting implementation sessions. - -## Assets - -- `assets/preview.png` — preferred screenshot path for the triage experience. -- `assets/swipe-canvas-triage.png` — existing reference screenshot kept for compatibility. diff --git a/extensions/backlog-swipe-triage/README.md b/extensions/backlog-swipe-triage/extensions/backlog-swipe-triage/README.md similarity index 100% rename from extensions/backlog-swipe-triage/README.md rename to extensions/backlog-swipe-triage/extensions/backlog-swipe-triage/README.md diff --git a/extensions/backlog-swipe-triage/extensions/assets/preview.png b/extensions/backlog-swipe-triage/extensions/backlog-swipe-triage/assets/preview.png similarity index 100% rename from extensions/backlog-swipe-triage/extensions/assets/preview.png rename to extensions/backlog-swipe-triage/extensions/backlog-swipe-triage/assets/preview.png diff --git a/extensions/backlog-swipe-triage/extension.mjs b/extensions/backlog-swipe-triage/extensions/backlog-swipe-triage/extension.mjs similarity index 100% rename from extensions/backlog-swipe-triage/extension.mjs rename to extensions/backlog-swipe-triage/extensions/backlog-swipe-triage/extension.mjs diff --git a/extensions/backlog-swipe-triage/extensions/package-lock.json b/extensions/backlog-swipe-triage/extensions/backlog-swipe-triage/package-lock.json similarity index 100% rename from extensions/backlog-swipe-triage/extensions/package-lock.json rename to extensions/backlog-swipe-triage/extensions/backlog-swipe-triage/package-lock.json diff --git a/extensions/backlog-swipe-triage/extensions/package.json b/extensions/backlog-swipe-triage/extensions/backlog-swipe-triage/package.json similarity index 100% rename from extensions/backlog-swipe-triage/extensions/package.json rename to extensions/backlog-swipe-triage/extensions/backlog-swipe-triage/package.json diff --git a/extensions/backlog-swipe-triage/extensions/extension.mjs b/extensions/backlog-swipe-triage/extensions/extension.mjs deleted file mode 100644 index e5c3af73..00000000 --- a/extensions/backlog-swipe-triage/extensions/extension.mjs +++ /dev/null @@ -1,2183 +0,0 @@ -import { createServer } from "node:http"; -import { joinSession, createCanvas } from "@github/copilot-sdk/extension"; -import { promises as fs } from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; - -const servers = new Map(); -const extensionDir = fileURLToPath(new URL(".", import.meta.url)); -const artifactsDir = path.join(extensionDir, "artifacts"); -const stateFile = path.join(artifactsDir, "backlog-triage-state.json"); -const decisions = ["assign_agent", "needs_info", "not_now", "close", "ignore"]; -const execFileAsync = promisify(execFile); -const MAX_SYNC_ISSUES = 200; -const defaultFilters = { - timeWindow: "any", - labels: [], - assignees: [], - query: "", - sortBy: "updated-desc", -}; -const filterSchema = { - type: "object", - properties: { - timeWindow: { type: "string", enum: ["any", "1d", "3d", "7d", "14d", "30d", "90d"] }, - labels: { type: "array", items: { type: "string" } }, - assignees: { type: "array", items: { type: "string" } }, - query: { type: "string" }, - sortBy: { type: "string", enum: ["updated-desc", "updated-asc", "created-desc", "created-asc", "title-asc", "random"] }, - }, - additionalProperties: false, -}; -let activeSession = null; -const MAX_REQUEST_BODY_BYTES = 1024 * 1024; - -let storage = { boards: {} }; -let storageLoaded = false; -let persistStorageQueue = Promise.resolve(); - -async function ensureStorageLoaded() { - if (storageLoaded) { - return; - } - await fs.mkdir(artifactsDir, { recursive: true }); - try { - const raw = await fs.readFile(stateFile, "utf8"); - storage = JSON.parse(raw); - } catch (error) { - if (error && error.code !== "ENOENT") { - throw error; - } - storage = { boards: {} }; - } - storageLoaded = true; -} - -async function persistStorage() { - await fs.mkdir(artifactsDir, { recursive: true }); - const snapshot = JSON.stringify(storage, null, 2); - persistStorageQueue = persistStorageQueue - .catch(() => undefined) - .then(async () => { - const tempStateFile = `${stateFile}.tmp-${process.pid}-${Date.now()}`; - await fs.writeFile(tempStateFile, snapshot, "utf8"); - await fs.rename(tempStateFile, stateFile); - }); - await persistStorageQueue; -} - -function normalizeText(value, fallback = "") { - return typeof value === "string" ? value.trim() : fallback; -} - -function captureCwd(ctx) { - const dir = ctx?.session?.workingDirectory; - return typeof dir === "string" && dir.trim() ? dir : null; -} - -function cwdForContext(ctx) { - return captureCwd(ctx) || servers.get(ctx?.instanceId)?.cwd || null; -} - -function escapeHtml(value) { - return normalizeText(value).replace(/[&<>"']/g, (char) => { - if (char === "&") return "&"; - if (char === "<") return "<"; - if (char === ">") return ">"; - if (char === '"') return """; - return "'"; - }); -} - -function normalizeStringArray(values) { - if (!Array.isArray(values)) { - return []; - } - return values.map((value) => normalizeText(value)).filter(Boolean); -} - -function normalizeFilters(raw, fallback = defaultFilters) { - const merged = raw && typeof raw === "object" ? { ...fallback, ...raw } : { ...fallback }; - const legacyAssignee = normalizeText(merged.assignee); - return { - timeWindow: ["any", "1d", "3d", "7d", "14d", "30d", "90d"].includes(merged.timeWindow) ? merged.timeWindow : "any", - labels: normalizeStringArray(merged.labels), - assignees: legacyAssignee ? [legacyAssignee] : normalizeStringArray(merged.assignees), - query: normalizeText(merged.query).toLowerCase(), - sortBy: ["updated-desc", "updated-asc", "created-desc", "created-asc", "title-asc", "random"].includes(merged.sortBy) - ? merged.sortBy - : "updated-desc", - }; -} - -function parseDateToMs(value) { - const timestamp = Date.parse(value || ""); - return Number.isFinite(timestamp) ? timestamp : 0; -} - -function getTimeWindowMs(timeWindow) { - if (timeWindow === "1d") return 1 * 24 * 60 * 60 * 1000; - if (timeWindow === "3d") return 3 * 24 * 60 * 60 * 1000; - if (timeWindow === "7d") return 7 * 24 * 60 * 60 * 1000; - if (timeWindow === "14d") return 14 * 24 * 60 * 60 * 1000; - if (timeWindow === "30d") return 30 * 24 * 60 * 60 * 1000; - if (timeWindow === "90d") return 90 * 24 * 60 * 60 * 1000; - return 0; -} - -function getIssueLabels(issue) { - return Array.isArray(issue?.labels) ? issue.labels.map((label) => normalizeText(label?.name).toLowerCase()).filter(Boolean) : []; -} - -function getIssueAssignees(issue) { - return Array.isArray(issue?.assignees) - ? issue.assignees.map((assignee) => normalizeText(assignee?.login).toLowerCase()).filter(Boolean) - : []; -} - -function issueMatchesFilters(issue, filters) { - const now = Date.now(); - const cutoffWindow = getTimeWindowMs(filters.timeWindow); - if (cutoffWindow > 0) { - const updatedAtMs = parseDateToMs(issue.updatedAt); - if (!updatedAtMs || now - updatedAtMs > cutoffWindow) { - return false; - } - } - - const issueLabels = getIssueLabels(issue); - const requiredLabels = filters.labels.map((label) => label.toLowerCase()); - if (requiredLabels.length > 0) { - if (!requiredLabels.some((label) => issueLabels.includes(label))) { - return false; - } - } - - const assigneeFilters = normalizeStringArray(filters.assignees).map((assignee) => assignee.toLowerCase()); - if (assigneeFilters.length > 0) { - const assignees = getIssueAssignees(issue); - const isUnassignedMatch = assigneeFilters.includes("unassigned") && assignees.length === 0; - const hasNamedMatch = assigneeFilters.some((wanted) => wanted !== "unassigned" && assignees.includes(wanted)); - if (!isUnassignedMatch && !hasNamedMatch) { - return false; - } - } - - if (filters.query) { - const haystack = `${normalizeText(issue.title)} ${normalizeText(issue.body || "")}`.toLowerCase(); - if (!haystack.includes(filters.query)) { - return false; - } - } - - return true; -} - -function sortIssues(issues, sortBy) { - const sorted = [...issues]; - if (sortBy === "random") { - for (let i = sorted.length - 1; i > 0; i -= 1) { - const j = Math.floor(Math.random() * (i + 1)); - [sorted[i], sorted[j]] = [sorted[j], sorted[i]]; - } - return sorted; - } - sorted.sort((left, right) => { - if (sortBy === "created-asc") { - return parseDateToMs(left.createdAt) - parseDateToMs(right.createdAt); - } - if (sortBy === "created-desc") { - return parseDateToMs(right.createdAt) - parseDateToMs(left.createdAt); - } - if (sortBy === "updated-asc") { - return parseDateToMs(left.updatedAt) - parseDateToMs(right.updatedAt); - } - if (sortBy === "title-asc") { - return normalizeText(left.title).localeCompare(normalizeText(right.title)); - } - return parseDateToMs(right.updatedAt) - parseDateToMs(left.updatedAt); - }); - return sorted; -} - -function normalizeItem(raw, index) { - const idFromInput = normalizeText(raw?.id); - const title = normalizeText(raw?.title, `Item ${index + 1}`); - const id = idFromInput || title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") || `item-${index + 1}`; - return { - id, - title, - description: normalizeText(raw?.description), - details: normalizeText(raw?.details), - repo: normalizeText(raw?.repo), - number: normalizeText(raw?.number), - url: normalizeText(raw?.url), - labels: normalizeStringArray(raw?.labels), - assignees: normalizeStringArray(raw?.assignees), - createdAt: normalizeText(raw?.createdAt), - updatedAt: normalizeText(raw?.updatedAt), - author: normalizeText(raw?.author), - }; -} - -function getOrCreateBoard(boardId) { - if (!storage.boards[boardId]) { - storage.boards[boardId] = { - id: boardId, - title: "Backlog Triage", - items: [], - decisions: {}, - workStatus: {}, - filters: { ...defaultFilters }, - updatedAt: new Date().toISOString(), - }; - } - if (!storage.boards[boardId].workStatus || typeof storage.boards[boardId].workStatus !== "object") { - storage.boards[boardId].workStatus = {}; - } - return storage.boards[boardId]; -} - -function setBoardItems(board, items, replace = true) { - const normalized = Array.isArray(items) ? items.map((item, index) => normalizeItem(item, index)) : []; - const repoFromItems = normalized.find((item) => normalizeText(item.repo)); - if (repoFromItems) { - board.repo = repoFromItems.repo; - } - if (replace) { - board.items = normalized; - } else { - const existingById = new Map(board.items.map((item) => [item.id, item])); - for (const item of normalized) { - existingById.set(item.id, item); - } - board.items = [...existingById.values()]; - } - board.updatedAt = new Date().toISOString(); -} - -function applyBoardDecision(board, itemId, decision, extra = {}) { - if (!decisions.includes(decision)) { - throw new Error(`Unsupported decision "${decision}"`); - } - const item = board.items.find((candidate) => candidate.id === itemId); - if (!item) { - throw new Error(`Item "${itemId}" not found on board "${board.id}"`); - } - board.decisions[itemId] = { - decision, - agent: normalizeText(extra.agent), - note: normalizeText(extra.note), - at: new Date().toISOString(), - }; - board.updatedAt = new Date().toISOString(); -} - -function resetBoardDecisions(board) { - board.decisions = {}; - board.updatedAt = new Date().toISOString(); -} - -function buildItemWorkStatus(board, item) { - const statuses = []; - const assignees = normalizeStringArray(item?.assignees); - if (assignees.length > 0) { - statuses.push({ label: `Assigned: ${assignees.join(", ")}` }); - } - const decision = board.decisions?.[item.id]; - const triageAgent = normalizeText(decision?.decision === "assign_agent" ? decision?.agent : ""); - if (assignees.length === 0 && triageAgent) { - statuses.push({ label: `Assigned in triage: ${triageAgent}` }); - } - const work = board.workStatus?.[item.id]; - if (work?.sessionState === "active") { - const sessionName = normalizeText(work.sessionName); - statuses.push({ label: sessionName ? `Session active: ${sessionName}` : "Session active" }); - } else if (work?.sessionState === "starting") { - statuses.push({ label: "Session starting" }); - } else if (work?.sessionState === "requested") { - const sessionName = normalizeText(work.sessionName); - statuses.push({ label: sessionName ? `Session requested: ${sessionName}` : "Session requested" }); - } - return statuses; -} - -function buildBoardState(board) { - const allLabels = [...new Set(board.items.flatMap((item) => (Array.isArray(item.labels) ? item.labels : [])))].sort((a, b) => - a.localeCompare(b), - ); - const hasUnassigned = board.items.some((item) => !Array.isArray(item.assignees) || item.assignees.length === 0); - const allAssignees = [ - ...new Set(board.items.flatMap((item) => (Array.isArray(item.assignees) ? item.assignees : []))), - ].sort((a, b) => a.localeCompare(b)); - if (hasUnassigned) { - allAssignees.unshift("unassigned"); - } - const pending = []; - const resolved = []; - for (const item of board.items) { - const itemWithStatus = { ...item, workStatus: buildItemWorkStatus(board, item) }; - const result = board.decisions[item.id]; - if (result) { - resolved.push({ ...itemWithStatus, result }); - } else { - pending.push(itemWithStatus); - } - } - return { - boardId: board.id, - title: board.title, - repo: normalizeText(board.repo), - syncedAt: normalizeText(board.syncedAt), - filters: normalizeFilters(board.filters, defaultFilters), - availableLabels: allLabels, - availableAssignees: allAssignees, - pending, - resolved, - decisionCounts: resolved.reduce((counts, item) => { - const key = item.result.decision; - counts[key] = (counts[key] || 0) + 1; - return counts; - }, {}), - updatedAt: board.updatedAt, - }; -} - -function buildIssueDetails(issue) { - const parts = []; - const author = normalizeText(issue.author?.login); - if (author) { - parts.push(`Author: ${author}`); - } - if (normalizeText(issue.createdAt)) { - parts.push(`Created: ${normalizeText(issue.createdAt).slice(0, 10)}`); - } - if (normalizeText(issue.updatedAt)) { - parts.push(`Updated: ${normalizeText(issue.updatedAt).slice(0, 10)}`); - } - return parts.join(" | "); -} - -function buildIssueDescription(issue) { - const body = normalizeText(issue.body); - if (!body) { - return ""; - } - const normalized = body - .replace(/\r/g, "") - .replace(/!\[.*?\]\(.*?\)/g, "") - .replace(/\n{2,}/g, "\n\n") - .trim(); - if (normalized.length <= 2200) { - return normalized; - } - return `${normalized.slice(0, 2197).trimEnd()}...`; -} - -async function runGhJson(args, cwd) { - const result = await execFileAsync("gh", args, { - cwd, - windowsHide: true, - maxBuffer: 8 * 1024 * 1024, - }); - return JSON.parse(result.stdout); -} - -async function runGh(args, cwd) { - const result = await execFileAsync("gh", args, { - cwd, - windowsHide: true, - maxBuffer: 8 * 1024 * 1024, - }); - return result.stdout; -} - -async function closeGithubIssue(board, item, note, cwd = process.cwd()) { - const issueNumber = normalizeText(item?.number); - const repo = normalizeText(board?.repo || item?.repo); - if (!issueNumber || !repo) { - throw new Error("Cannot close issue on GitHub because repo or issue number is missing."); - } - const args = ["issue", "close", issueNumber, "--repo", repo]; - const comment = normalizeText(note); - if (comment) { - args.push("--comment", comment); - } - try { - await runGh(args, cwd); - } catch (error) { - const stderr = normalizeText(error?.stderr || ""); - if (stderr.toLowerCase().includes("already closed")) { - return; - } - throw new Error(stderr || `Failed to close issue #${issueNumber} in ${repo}.`); - } -} - -async function commentGithubIssue(board, item, note, cwd = process.cwd()) { - const repo = normalizeText(board?.repo || item?.repo); - const issueNumber = extractIssueNumber(item); - const comment = normalizeText(note); - if (!repo || !issueNumber) { - throw new Error("Cannot comment on issue because repo or issue number is missing."); - } - if (!comment) { - return; - } - try { - await runGh(["issue", "comment", issueNumber, "--repo", repo, "--body", comment], cwd); - } catch (error) { - const stderr = normalizeText(error?.stderr || ""); - throw new Error(stderr || `Failed to comment on issue #${issueNumber} in ${repo}.`); - } -} - -function extractIssueNumber(item) { - const explicit = normalizeText(item?.number); - if (/^\d+$/.test(explicit)) { - return explicit; - } - const idMatch = normalizeText(item?.id).match(/^issue-(\d+)$/i); - if (idMatch) { - return idMatch[1]; - } - const titleMatch = normalizeText(item?.title).match(/^#(\d+)\b/); - if (titleMatch) { - return titleMatch[1]; - } - return ""; -} - -async function startImplementationSession(board, item, agent, note) { - if (!activeSession) { - throw new Error("Copilot session is unavailable for starting implementation sessions."); - } - const repo = normalizeText(board?.repo || item?.repo); - const issueNumber = extractIssueNumber(item); - if (!repo || !issueNumber) { - throw new Error("Cannot start implementation session because repo or issue number is missing."); - } - const rawTitle = normalizeText(item?.title); - const issueTitle = rawTitle.replace(new RegExp(`^#${issueNumber}\\s*`), "").trim() || rawTitle || `Issue #${issueNumber}`; - const summary = normalizeText(item?.description); - const kickoffLines = [ - `Implement GitHub issue #${issueNumber}: ${issueTitle}`, - `Repository: ${repo}`, - ]; - if (summary) { - kickoffLines.push(`Context: ${summary}`); - } - if (normalizeText(note)) { - kickoffLines.push(`Triage note: ${normalizeText(note)}`); - } - kickoffLines.push( - "Deliver a complete fix with code changes, run relevant validation, and open a PR-ready branch state with a concise summary.", - ); - const kickoffPrompt = kickoffLines.join("\n"); - const sessionRequest = [ - `Create a new implementation project session for GitHub issue #${issueNumber} in ${repo}.`, - "Use the open_issue_session tool with these exact fields:", - `- repo_full_name: ${JSON.stringify(repo)}`, - `- issue_number: ${Number(issueNumber)}`, - `- issue_title: ${JSON.stringify(issueTitle)}`, - '- kickoff_mode: "autopilot"', - '- coordinate_with_creator: true', - '- notify_on_idle: "once"', - `- kickoff_prompt: ${JSON.stringify(kickoffPrompt)}`, - "", - "After the tool call succeeds, reply with a one-line confirmation including the new session name.", - ].join("\n"); - await activeSession.send({ - prompt: sessionRequest, - mode: "immediate", - displayPrompt: `Start implementation session for #${issueNumber}`, - }); - return { - sessionState: "requested", - sessionName: `Issue #${issueNumber}`, - issueNumber, - agent: normalizeText(agent), - requestedAt: new Date().toISOString(), - }; -} - -function pruneDecisionsForCurrentItems(board) { - const currentIds = new Set(board.items.map((item) => item.id)); - for (const itemId of Object.keys(board.decisions)) { - if (!currentIds.has(itemId)) { - delete board.decisions[itemId]; - } - } - if (board.workStatus && typeof board.workStatus === "object") { - for (const itemId of Object.keys(board.workStatus)) { - if (!currentIds.has(itemId)) { - delete board.workStatus[itemId]; - } - } - } -} - -async function syncBoardFromRepo(board, filtersInput, cwd = null) { - const commandCwd = cwd || process.cwd(); - let repo = normalizeText(board.repo); - if (!repo && cwd) { - const repoData = await runGhJson(["repo", "view", "--json", "nameWithOwner"], cwd); - repo = normalizeText(repoData?.nameWithOwner); - } - if (!repo) { - throw new Error("Repository is not configured. Open the canvas with a repo or call sync_from_repo with { repo: \"owner/name\" }."); - } - const filters = normalizeFilters(filtersInput, board.filters || defaultFilters); - - const issues = await runGhJson( - [ - "issue", - "list", - "--repo", - repo, - "--state", - "open", - "--limit", - String(MAX_SYNC_ISSUES), - "--json", - "number,title,url,labels,assignees,createdAt,updatedAt,author,body", - ], - commandCwd, - ); - - const filteredIssues = Array.isArray(issues) ? sortIssues(issues.filter((issue) => issueMatchesFilters(issue, filters)), filters.sortBy) : []; - const items = filteredIssues.map((issue) => ({ - id: `issue-${issue.number}`, - title: `#${issue.number} ${normalizeText(issue.title, "Untitled issue")}`, - description: buildIssueDescription(issue), - details: buildIssueDetails(issue), - repo, - number: String(issue.number), - url: normalizeText(issue.url), - labels: Array.isArray(issue.labels) ? issue.labels.map((label) => normalizeText(label?.name)).filter(Boolean) : [], - assignees: Array.isArray(issue.assignees) ? issue.assignees.map((assignee) => normalizeText(assignee?.login)).filter(Boolean) : [], - createdAt: normalizeText(issue.createdAt), - updatedAt: normalizeText(issue.updatedAt), - author: normalizeText(issue.author?.login), - })); - - setBoardItems(board, items, true); - pruneDecisionsForCurrentItems(board); - board.source = "repo"; - board.repo = repo; - board.filters = filters; - board.syncedAt = new Date().toISOString(); -} - -function renderHtml(instanceId, title) { - const safeTitle = escapeHtml(title || "Backlog Swipe Triage"); - const safeInstanceId = escapeHtml(instanceId || "default"); - return ` - - - - - ${safeTitle} - - - -
-
-
-

${safeTitle}

-
- Instance: ${safeInstanceId} - Loading board… -
-
-
-
-
-
- - -
-
- -
- All labels -
-
-
-
- -
- All assignees -
-
-
-
- - -
-
- - -
-
- - -
-
-
-
-
-

-
Issue
-
-
-
-
-
-
-
-
-
-
-
-
-
Done
-
-
-
-
-
- - Applying action… -
-
-
-
Swipe-up quick responses
- - - - - - - - - -
-
-
- - - - - -
-
-
- Decision summary -
-
-
- Swipe mappings: left=close, right=assign agent, - up=more options, down=ignore. Arrow keys work too. -
-
- - -`; -} - -function readJson(req, maxBytes = MAX_REQUEST_BODY_BYTES) { - return new Promise((resolve, reject) => { - const chunks = []; - let totalBytes = 0; - let settled = false; - req.on("data", (chunk) => { - if (settled) { - return; - } - totalBytes += chunk.length; - if (totalBytes > maxBytes) { - settled = true; - const error = new Error(`Request body exceeds ${maxBytes} bytes.`); - error.statusCode = 413; - req.destroy(error); - reject(error); - return; - } - chunks.push(chunk); - }); - req.on("end", () => { - if (settled) { - return; - } - const raw = Buffer.concat(chunks).toString("utf8"); - if (!raw) { - resolve({}); - return; - } - try { - resolve(JSON.parse(raw)); - } catch (error) { - error.statusCode = 400; - reject(error); - } - }); - req.on("error", (error) => { - if (settled) { - return; - } - settled = true; - reject(error); - }); - }); -} - -async function handleServerRequest(instanceId, req, res) { - const entry = servers.get(instanceId); - if (!entry) { - res.statusCode = 404; - res.end("Instance not found"); - return; - } - - await ensureStorageLoaded(); - const board = getOrCreateBoard(entry.boardId); - board.title = entry.title; - const cwd = entry.cwd || null; - - if (req.method === "GET" && req.url === "/") { - res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.end(renderHtml(instanceId, board.title)); - return; - } - - if (req.method === "GET" && req.url === "/state") { - res.setHeader("Content-Type", "application/json; charset=utf-8"); - res.end(JSON.stringify(buildBoardState(board))); - return; - } - - if (req.method === "POST" && req.url === "/sync") { - try { - const payload = await readJson(req); - const repo = normalizeText(payload?.repo); - if (repo) { - board.repo = repo; - } - if (payload?.resetDecisions === true) { - resetBoardDecisions(board); - } - await syncBoardFromRepo(board, payload?.filters, cwd); - await persistStorage(); - res.setHeader("Content-Type", "application/json; charset=utf-8"); - res.end(JSON.stringify(buildBoardState(board))); - } catch (error) { - res.statusCode = error?.statusCode || 500; - res.end(error instanceof Error ? error.message : "Failed to sync from repo"); - } - return; - } - - if (req.method === "POST" && req.url === "/decision") { - let payload; - try { - payload = await readJson(req); - } catch (error) { - res.statusCode = error?.statusCode || 400; - res.end( - error?.statusCode === 413 - ? "Request body too large" - : "Invalid JSON payload", - ); - return; - } - - const itemId = normalizeText(payload?.itemId); - const decision = normalizeText(payload?.decision); - const item = board.items.find((candidate) => candidate.id === itemId); - if (!itemId || !decision) { - res.statusCode = 400; - res.end("itemId and decision are required"); - return; - } - if (!item) { - res.statusCode = 404; - res.end(`Item "${itemId}" not found`); - return; - } - if (decision === "close") { - await closeGithubIssue(board, item, payload?.note, cwd || process.cwd()); - } - if (payload?.quickResponse === true && decision !== "close" && normalizeText(payload?.note)) { - await commentGithubIssue(board, item, payload?.note, cwd || process.cwd()); - } - if (decision === "assign_agent") { - const sessionStatus = await startImplementationSession(board, item, payload?.agent, payload?.note); - board.workStatus[itemId] = { - ...sessionStatus, - agent: normalizeText(payload?.agent), - }; - } - - applyBoardDecision(board, itemId, decision, { - agent: payload?.agent, - note: payload?.note, - }); - await persistStorage(); - res.setHeader("Content-Type", "application/json; charset=utf-8"); - res.end(JSON.stringify(buildBoardState(board))); - return; - } - - res.statusCode = 404; - res.end("Not found"); -} - -async function startServer(instanceId, cwd) { - const server = createServer((req, res) => { - handleServerRequest(instanceId, req, res).catch((error) => { - if (res.headersSent) { - res.end(); - return; - } - res.statusCode = error?.statusCode || 500; - res.end(error instanceof Error ? error.message : "Internal server error"); - }); - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - const address = server.address(); - const port = typeof address === "object" && address ? address.port : 0; - return { server, url: `http://127.0.0.1:${port}/`, cwd }; -} - -const session = await joinSession({ - canvases: [ - createCanvas({ - id: "backlog-swipe-triage", - displayName: "Backlog Swipe Triage", - description: "Tinder-style backlog triage with swipe directions for assign, needs info, not now, close, and ignore.", - inputSchema: { - type: "object", - properties: { - boardId: { type: "string", minLength: 1 }, - title: { type: "string", minLength: 1 }, - syncFromRepo: { type: "boolean" }, - repo: { type: "string", minLength: 1 }, - filters: filterSchema, - items: { - type: "array", - items: { - type: "object", - properties: { - id: { type: "string" }, - title: { type: "string" }, - details: { type: "string" }, - repo: { type: "string" }, - number: { type: "string" }, - url: { type: "string" }, - }, - required: ["title"], - additionalProperties: true, - }, - }, - }, - additionalProperties: false, - }, - actions: [ - { - name: "sync_from_repo", - description: "Load open issues from the current repository into the triage board.", - inputSchema: { - type: "object", - properties: { - boardId: { type: "string", minLength: 1 }, - title: { type: "string" }, - repo: { type: "string", minLength: 1 }, - filters: filterSchema, - }, - required: ["boardId"], - additionalProperties: false, - }, - handler: async (ctx) => { - const cwd = cwdForContext(ctx); - await ensureStorageLoaded(); - const board = getOrCreateBoard(normalizeText(ctx.input?.boardId, "default")); - const title = normalizeText(ctx.input?.title); - if (title) { - board.title = title; - } - const repo = normalizeText(ctx.input?.repo); - if (repo) { - board.repo = repo; - } - await syncBoardFromRepo(board, ctx.input?.filters, cwd); - await persistStorage(); - return buildBoardState(board); - }, - }, - { - name: "seed_backlog", - description: "Seed or update backlog items for a triage board.", - inputSchema: { - type: "object", - properties: { - boardId: { type: "string", minLength: 1 }, - title: { type: "string" }, - replace: { type: "boolean" }, - items: { - type: "array", - items: { - type: "object", - properties: { - id: { type: "string" }, - title: { type: "string" }, - details: { type: "string" }, - repo: { type: "string" }, - number: { type: "string" }, - url: { type: "string" }, - }, - required: ["title"], - additionalProperties: true, - }, - }, - }, - required: ["boardId", "items"], - additionalProperties: false, - }, - handler: async (ctx) => { - await ensureStorageLoaded(); - const boardId = normalizeText(ctx.input?.boardId, "default"); - const board = getOrCreateBoard(boardId); - const title = normalizeText(ctx.input?.title); - if (title) { - board.title = title; - } - setBoardItems(board, ctx.input?.items, ctx.input?.replace !== false); - await persistStorage(); - return buildBoardState(board); - }, - }, - { - name: "apply_decision", - description: "Apply a triage decision to a backlog item.", - inputSchema: { - type: "object", - properties: { - boardId: { type: "string", minLength: 1 }, - itemId: { type: "string", minLength: 1 }, - decision: { type: "string", enum: decisions }, - agent: { type: "string" }, - note: { type: "string" }, - commentOnIssue: { type: "boolean" }, - }, - required: ["boardId", "itemId", "decision"], - additionalProperties: false, - }, - handler: async (ctx) => { - const cwd = cwdForContext(ctx); - await ensureStorageLoaded(); - const board = getOrCreateBoard(normalizeText(ctx.input?.boardId, "default")); - const itemId = normalizeText(ctx.input?.itemId); - const item = board.items.find((candidate) => candidate.id === itemId); - const decision = normalizeText(ctx.input?.decision); - if (!item) { - throw new Error(`Item "${itemId}" not found`); - } - if (decision === "close") { - await closeGithubIssue(board, item, ctx.input?.note, cwd || process.cwd()); - } - if (ctx.input?.commentOnIssue === true && decision !== "close" && normalizeText(ctx.input?.note)) { - await commentGithubIssue(board, item, ctx.input?.note, cwd || process.cwd()); - } - if (decision === "assign_agent") { - const sessionStatus = await startImplementationSession(board, item, ctx.input?.agent, ctx.input?.note); - board.workStatus[itemId] = { - ...sessionStatus, - agent: normalizeText(ctx.input?.agent), - }; - } - applyBoardDecision(board, itemId, decision, { - agent: ctx.input?.agent, - note: ctx.input?.note, - }); - await persistStorage(); - return buildBoardState(board); - }, - }, - { - name: "get_board", - description: "Get pending and triaged items for a triage board.", - inputSchema: { - type: "object", - properties: { - boardId: { type: "string", minLength: 1 }, - }, - required: ["boardId"], - additionalProperties: false, - }, - handler: async (ctx) => { - await ensureStorageLoaded(); - const board = getOrCreateBoard(normalizeText(ctx.input?.boardId, "default")); - return buildBoardState(board); - }, - }, - ], - open: async (ctx) => { - let entry = servers.get(ctx.instanceId); - const cwd = cwdForContext(ctx); - await ensureStorageLoaded(); - const boardId = normalizeText(ctx.input?.boardId, "default"); - const board = getOrCreateBoard(boardId); - const title = normalizeText(ctx.input?.title, board.title || "Backlog Triage"); - board.title = title; - const repo = normalizeText(ctx.input?.repo); - if (repo) { - board.repo = repo; - } - if (ctx.input?.filters && typeof ctx.input.filters === "object") { - board.filters = normalizeFilters(ctx.input.filters, board.filters || defaultFilters); - } else if (!board.filters) { - board.filters = { ...defaultFilters }; - } - const syncFromRepo = ctx.input?.syncFromRepo !== false; - if (Array.isArray(ctx.input?.items) && ctx.input.items.length > 0) { - setBoardItems(board, ctx.input.items, true); - await persistStorage(); - } else if (syncFromRepo) { - await syncBoardFromRepo(board, board.filters, cwd); - await persistStorage(); - } - - if (!entry) { - entry = await startServer(ctx.instanceId, cwd); - servers.set(ctx.instanceId, entry); - } - entry.boardId = boardId; - entry.title = title; - entry.cwd = cwd; - return { - title, - status: "Swipe to triage backlog", - url: entry.url, - }; - }, - onClose: async (ctx) => { - const entry = servers.get(ctx.instanceId); - if (entry) { - servers.delete(ctx.instanceId); - await new Promise((resolve) => entry.server.close(() => resolve())); - } - }, - }), - ], -}); -activeSession = session; diff --git a/extensions/backlog-swipe-triage/package-lock.json b/extensions/backlog-swipe-triage/package-lock.json deleted file mode 100644 index 38325be1..00000000 --- a/extensions/backlog-swipe-triage/package-lock.json +++ /dev/null @@ -1,218 +0,0 @@ -{ - "name": "backlog-swipe-triage", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "backlog-swipe-triage", - "version": "1.0.0", - "dependencies": { - "@github/copilot-sdk": "1.0.1" - } - }, - "node_modules/@github/copilot": { - "version": "1.0.61", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.61.tgz", - "integrity": "sha512-E4f7YXTL2uUZY/ypnfsUruAeSgrHx3AGYEbm5N0DrpzPqoNAZqV6kHEWM4vu+W/nGvydIfPxmOTqaMEhM8r0Uw==", - "license": "SEE LICENSE IN LICENSE.md", - "dependencies": { - "detect-libc": "^2.1.2" - }, - "bin": { - "copilot": "npm-loader.js" - }, - "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.61", - "@github/copilot-darwin-x64": "1.0.61", - "@github/copilot-linux-arm64": "1.0.61", - "@github/copilot-linux-x64": "1.0.61", - "@github/copilot-linuxmusl-arm64": "1.0.61", - "@github/copilot-linuxmusl-x64": "1.0.61", - "@github/copilot-win32-arm64": "1.0.61", - "@github/copilot-win32-x64": "1.0.61" - } - }, - "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.61", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.61.tgz", - "integrity": "sha512-10prvjHRXB0SD28NsIpzdNDgLquQYUwaH5Ev9KVdIWdBPAvlQsHmQ4JSCyD/UILc/nrrr02CKUgum+mZRKUKIg==", - "cpu": [ - "arm64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "darwin" - ], - "bin": { - "copilot-darwin-arm64": "copilot" - } - }, - "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.61", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.61.tgz", - "integrity": "sha512-NXUjageJ3mxDfHtXGYu//XhJ+dhJFYObT4R3jeWgIHhd+4lX7FlC754nwlBP/ZuVhJ3ND22JK9sua9d2F3Cbwg==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "darwin" - ], - "bin": { - "copilot-darwin-x64": "copilot" - } - }, - "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.61", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.61.tgz", - "integrity": "sha512-dwB2+QSMr622JkePeK56M7YWXsTT/DQzKfpDq8Lk2kmGU052RZAarRmt8gcNm4anofN7pMSrqc3YHj1TM84MFw==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ], - "bin": { - "copilot-linux-arm64": "copilot" - } - }, - "node_modules/@github/copilot-linux-x64": { - "version": "1.0.61", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.61.tgz", - "integrity": "sha512-q6n8R8oybvuCmmkP+43w809Wpud/wwRi/fFSZEYJagiNGmYJ00SDkrfJxHbZsAFMpaJC+oTswqzJHjRoZbO74w==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ], - "bin": { - "copilot-linux-x64": "copilot" - } - }, - "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.61", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.61.tgz", - "integrity": "sha512-yWo7JXnZS11eJpm68E1RWKMR47EwzPKj3V7GX0EMTd8Fw0T2Aurk9wt9p3c9w0v02nTO1DqJhi68KVWJPdVqvA==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ], - "bin": { - "copilot-linuxmusl-arm64": "copilot" - } - }, - "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.61", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.61.tgz", - "integrity": "sha512-nHzx27Ac4B0fpD9CcmvyrGOBEMJ01CPRgVRP0yAl4wpU4cM2I6+9TPyfYThlWDqZqiUKGXC1ZRQ+B8cJREVGmA==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ], - "bin": { - "copilot-linuxmusl-x64": "copilot" - } - }, - "node_modules/@github/copilot-sdk": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.1.tgz", - "integrity": "sha512-w6AaS0WqqTE/3iyUrZznvgCLQhsUF7ZmEVCneacuHCfOzlH0r6ww9WUmyA0zgqmXO75V0IYrkIcnFke/qJkkDg==", - "license": "MIT", - "dependencies": { - "@github/copilot": "^1.0.61", - "vscode-jsonrpc": "^8.2.1", - "zod": "^4.3.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.61", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.61.tgz", - "integrity": "sha512-k6knzI+K5HlZeJDS/yeJAfoYD4xcURWfuqunpTCyk1pDbIFxmrLSqR/TDi7KNlpsf883n5WqpnB06K5kysdHHQ==", - "cpu": [ - "arm64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "win32" - ], - "bin": { - "copilot-win32-arm64": "copilot.exe" - } - }, - "node_modules/@github/copilot-win32-x64": { - "version": "1.0.61", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.61.tgz", - "integrity": "sha512-L6NZ6o73VZFHd7OoRaztV3Prh1PbW9HXqYsAx+XywNALQvE1u489WBUC1ggfYBW5MTBCf8mxSkYQdb3Am2omsw==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "win32" - ], - "bin": { - "copilot-win32-x64": "copilot.exe" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/vscode-jsonrpc": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", - "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/extensions/backlog-swipe-triage/package.json b/extensions/backlog-swipe-triage/package.json deleted file mode 100644 index 34f71265..00000000 --- a/extensions/backlog-swipe-triage/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "backlog-swipe-triage", - "version": "1.0.0", - "type": "module", - "main": "extension.mjs", - "dependencies": { - "@github/copilot-sdk": "1.0.1" - }, - "description": "Users quickly swipe through backlog issues to triage decisions like assign, needs-info, defer, close, or ignore.", - "keywords": [ - "backlog-triage", - "swipe-interface", - "issue-prioritization", - "github-issues", - "agent-assignment", - "workflow-automation" - ] -} diff --git a/extensions/chromium-control-canvas/.github/plugin/plugin.json b/extensions/chromium-control-canvas/.github/plugin/plugin.json index 117a0cee..999b39c5 100644 --- a/extensions/chromium-control-canvas/.github/plugin/plugin.json +++ b/extensions/chromium-control-canvas/.github/plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "chromium-control-canvas", "description": "Opens a real Chromium window you can navigate and interact with from a Copilot canvas control panel and agent actions.", - "version": "1.0.1", + "version": "1.0.2", "author": { "name": "Andrea Griffiths", "url": "https://github.com/AndreaGriffiths11" diff --git a/extensions/chromium-control-canvas/extensions/README.md b/extensions/chromium-control-canvas/extensions/README.md deleted file mode 100644 index 0cb4a701..00000000 --- a/extensions/chromium-control-canvas/extensions/README.md +++ /dev/null @@ -1,90 +0,0 @@ -# Chromium Control Canvas - -A GitHub Copilot canvas that drives a real **headful Chromium** window via Playwright. -The host app's built-in `browser` canvas is WebKit (WKWebView); this gives you actual -Chromium, controllable both from the panel UI and by the agent. - -The canvas panel is a control strip (URL bar, back/forward/reload, screenshot). A separate -Chromium window does the real rendering, because you can't embed Chromium inside a WebKit -iframe. - -## Files - -- `extension.mjs` — the extension: canvas declaration, Playwright launch, a loopback HTTP - server for the panel, and the agent actions. -- `index.html` — the control strip UI the panel renders. -- `package.json` — declares the `playwright` dependency and `"type": "module"`. -- `copilot-extension.json` — name/version metadata. - -## Prerequisites - -- **Node.js 20.19 or newer** (the Copilot SDK requires `node ^20.19.0 || >=22.12.0`). - The extension runs as a Node child process. -- The app's **canvas / UI-extensions experiment enabled**. Without it, the extension - loads but the canvas never appears in the panel. Enable it in the app's - Settings → Experiments. (This may not be available to all accounts.) - -## Install - -Drop this folder at `~/.copilot/extensions/chromium-control-canvas/` (user scope) or in a repo's -`.github/extensions/chromium-control-canvas/` (project scope), then install dependencies and the -Chromium binary from inside the folder you copied: - -```sh -# User scope -cd ~/.copilot/extensions/chromium-control-canvas - -# Or project scope, from the repository root -cd .github/extensions/chromium-control-canvas - -npm install # playwright is declared in package.json -npx playwright install chromium # downloads the browser, a few hundred MB -``` - -Reload extensions in the app, then open the `chromium-control-canvas` canvas. - -Note: copying the extension files only places the source. It does **not** run the -commands above or enable the experiment, so those steps are still required on first -setup. - -## Attach to your own Chrome - -By default the canvas launches the bundled Chromium with a persistent profile. To drive -a Chrome you already have running instead, start it with a debug port and pass `cdpUrl` -when opening the canvas: - -```sh -google-chrome --remote-debugging-port=9222 # then open the canvas with cdpUrl: http://localhost:9222 -``` - -In this mode the extension connects over CDP and never launches or kills your browser; -closing the canvas just disconnects. - -## Agent actions - -- `navigate { url }` — go to a URL or search query (blocklist-guarded). -- `back` / `forward` / `reload` — history navigation. -- `current_url` — current URL and page title. -- `snapshot` — structured list of visible interactive elements, each with a stable ref. -- `click { ref | selector }` — click an element by snapshot ref or CSS selector. -- `type { ref | selector, text, submit? }` — fill an input; optionally press Enter. -- `screenshot { fullPage? }` — save a PNG to `artifacts/` and return its path and size. - -## Notes - -- A persistent profile is stored under - `$COPILOT_HOME/extensions/chromium-control-canvas/profile` (default - `~/.copilot/extensions/chromium-control-canvas/profile`) so logins survive restarts. - **Do not commit or share this folder** — it contains real session cookies. -- Raw `evaluate` (arbitrary in-page JS) is intentionally omitted. -- `navigate` is checked against a blocklist, and a request interceptor also blocks - navigations to blocked hosts that happen via in-page redirects. The shipped - `BLOCKLIST` entries are illustrative examples, not real coverage — edit the list in - `extension.mjs` to fit your environment. -- The loopback control server requires a per-launch token (templated into the panel), - so other pages in your browser can't drive it. -- Typed text (e.g. passwords) is redacted in `audit.log`, and password field values are - excluded from snapshots. -- Generated at runtime and not part of the source: `node_modules/` in the copied - extension folder, plus `profile/`, `artifacts/`, and `audit.log` under - `$COPILOT_HOME/extensions/chromium-control-canvas/`. diff --git a/extensions/chromium-control-canvas/README.md b/extensions/chromium-control-canvas/extensions/chromium-control-canvas/README.md similarity index 100% rename from extensions/chromium-control-canvas/README.md rename to extensions/chromium-control-canvas/extensions/chromium-control-canvas/README.md diff --git a/extensions/chromium-control-canvas/extensions/assets/preview.png b/extensions/chromium-control-canvas/extensions/chromium-control-canvas/assets/preview.png similarity index 100% rename from extensions/chromium-control-canvas/extensions/assets/preview.png rename to extensions/chromium-control-canvas/extensions/chromium-control-canvas/assets/preview.png diff --git a/extensions/chromium-control-canvas/copilot-extension.json b/extensions/chromium-control-canvas/extensions/chromium-control-canvas/copilot-extension.json similarity index 100% rename from extensions/chromium-control-canvas/copilot-extension.json rename to extensions/chromium-control-canvas/extensions/chromium-control-canvas/copilot-extension.json diff --git a/extensions/chromium-control-canvas/extension.mjs b/extensions/chromium-control-canvas/extensions/chromium-control-canvas/extension.mjs similarity index 100% rename from extensions/chromium-control-canvas/extension.mjs rename to extensions/chromium-control-canvas/extensions/chromium-control-canvas/extension.mjs diff --git a/extensions/chromium-control-canvas/extensions/index.html b/extensions/chromium-control-canvas/extensions/chromium-control-canvas/index.html similarity index 100% rename from extensions/chromium-control-canvas/extensions/index.html rename to extensions/chromium-control-canvas/extensions/chromium-control-canvas/index.html diff --git a/extensions/chromium-control-canvas/extensions/package-lock.json b/extensions/chromium-control-canvas/extensions/chromium-control-canvas/package-lock.json similarity index 100% rename from extensions/chromium-control-canvas/extensions/package-lock.json rename to extensions/chromium-control-canvas/extensions/chromium-control-canvas/package-lock.json diff --git a/extensions/chromium-control-canvas/extensions/package.json b/extensions/chromium-control-canvas/extensions/chromium-control-canvas/package.json similarity index 100% rename from extensions/chromium-control-canvas/extensions/package.json rename to extensions/chromium-control-canvas/extensions/chromium-control-canvas/package.json diff --git a/extensions/chromium-control-canvas/extensions/copilot-extension.json b/extensions/chromium-control-canvas/extensions/copilot-extension.json deleted file mode 100644 index 2c4915b9..00000000 --- a/extensions/chromium-control-canvas/extensions/copilot-extension.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "chromium-control-canvas", - "version": 1 -} diff --git a/extensions/chromium-control-canvas/extensions/extension.mjs b/extensions/chromium-control-canvas/extensions/extension.mjs deleted file mode 100644 index cc7e97da..00000000 --- a/extensions/chromium-control-canvas/extensions/extension.mjs +++ /dev/null @@ -1,675 +0,0 @@ -// Extension: chromium-control-canvas -// Launches a real headful Chromium window via Playwright and uses the canvas -// panel as a control strip (URL bar, back, forward, reload, screenshot, -// snapshot/click/type). -// -// Why this shape: the host app renders canvases in a WebKit (WKWebView) webview, -// not Chromium. To get a true Chromium engine we run the browser as a separate -// headful window owned by this extension process and drive it with Playwright. -// The canvas iframe is only the control surface; it POSTs commands to a -// per-instance loopback HTTP server, which calls Playwright on the page. The -// same handlers are exposed as agent-callable canvas actions. -// -// Patterns borrowed from AndreaGriffiths11/claw-relay (grep "[claw-relay]" below -// for the exact spots): -// - persistent profile so logins survive restarts -// - optional connect-to-existing-Chrome over CDP instead of relaunching -// - a real action set (snapshot/click/type/screenshot), not just navigation -// - ref-based element resolution from a snapshot, or raw CSS selector -// - a site blocklist guard and a JSONL audit log -// Intentionally omitted: raw `evaluate` (arbitrary JS execution). - -import { randomUUID } from "node:crypto"; -import { appendFile, mkdir, readFile, readlink, rm, stat, writeFile } from "node:fs/promises"; -import { createServer } from "node:http"; -import { homedir } from "node:os"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -import { - CanvasError, - createCanvas, - joinSession, -} from "@github/copilot-sdk/extension"; -import { chromium } from "playwright"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -const COPILOT_HOME = process.env.COPILOT_HOME || join(homedir(), ".copilot"); -const EXT_HOME = join(COPILOT_HOME, "extensions", "chromium-control-canvas"); -// [claw-relay] Persistent profile: cookies/logins survive canvas closes, -// reloads, and sessions, so a hand-login in the window sticks. -const PROFILE_DIR = join(EXT_HOME, "profile"); -const ARTIFACTS_DIR = join(EXT_HOME, "artifacts"); -const AUDIT_LOG = join(EXT_HOME, "audit.log"); - -// [claw-relay] Site blocklist guard. -// Sites the agent may never drive. Edit this list to taste. Patterns match the -// hostname; a leading "*." matches any subdomain. Navigation to a blocked host -// is refused before Chromium is told to go there. -const BLOCKLIST = [ - "*.bank.com", - "*.chase.com", - "*.paypal.com", - "accounts.google.com", -]; - -// One Chromium context + control-strip server per open canvas instance. -const instances = new Map(); // instanceId -> { context, browser, page, server, url, mode } - -// Per-launch secret, templated into index.html and required on every state route -// so cross-origin pages in the user's normal browser can't POST to this server. -const TOKEN = randomUUID(); - -let log = (..._args) => {}; - -function hostMatches(host, pattern) { - if (pattern.startsWith("*.")) { - const base = pattern.slice(2); - return host === base || host.endsWith(`.${base}`); - } - return host === pattern; -} - -function blockedReason(targetUrl) { - let host; - try { - host = new URL(targetUrl).hostname; - } catch (_) { - return null; - } - const hit = BLOCKLIST.find((p) => hostMatches(host, p)); - return hit ? `${host} is blocked by the Chromium Control Canvas blocklist (${hit})` : null; -} - -function normalizeUrl(input) { - const raw = String(input ?? "").trim(); - if (!raw) return "about:blank"; - if (raw === "about:blank") return raw; - const scheme = raw.match(/^([a-z][a-z0-9+.-]*):/i)?.[1]?.toLowerCase(); - if (scheme) { - if (scheme === "http" || scheme === "https") return raw; - throw new CanvasError("unsupported_scheme", "Only http and https URLs are supported."); - } - // Local dev servers have no dot; send them to http, not search. - if (/^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?([/?#]|$)/i.test(raw)) return `http://${raw}`; - if (!/\s/.test(raw) && /\.[a-z]{2,}/i.test(raw)) return `https://${raw}`; - return `https://www.google.com/search?q=${encodeURIComponent(raw)}`; -} - -// [claw-relay] JSONL audit log: one line per action (panel or agent) for a -// reviewable trail of what drove the browser. -async function audit(entry) { - const line = JSON.stringify({ at: new Date().toISOString(), ...entry }); - await mkdir(EXT_HOME, { recursive: true }).catch(() => {}); - await appendFile(AUDIT_LOG, `${line}\n`).catch(() => {}); -} - -// Keep secrets out of the audit log: redact free-text fields (e.g. a typed -// password) while preserving the rest of the entry for the trail. -function redactInput(input) { - if (input && typeof input === "object" && "text" in input) { - return { ...input, text: "[redacted]" }; - } - return input; -} - -async function pageState(page) { - let url = "about:blank"; - let title = ""; - try { - url = page.url(); - } catch (_) {} - try { - title = await page.title(); - } catch (_) {} - return { url, title }; -} - -function getInstance(instanceId) { - const entry = instances.get(instanceId); - if (!entry) { - throw new CanvasError( - "no_instance", - "No open Chromium canvas for this instance. Open the canvas first.", - ); - } - return entry; -} - -// Return a usable page for the instance, recovering if the user closed the tab -// or window. Reuses an open tab, opens a new one in the live context, or (for -// persistent mode) relaunches the browser in place. -async function livePage(entry) { - if (entry.page && !entry.page.isClosed()) return entry.page; - try { - const open = entry.context?.pages().find((p) => !p.isClosed()); - entry.page = open || (await entry.context.newPage()); - return entry.page; - } catch (_) { - // Context/browser is gone below. - } - if (entry.mode === "persistent") { - // Memoize the relaunch so two concurrent panel requests after a crash - // don't race launchPersistentContext into the lock error we handle below. - if (!entry.relaunching) { - entry.relaunching = (async () => { - await mkdir(PROFILE_DIR, { recursive: true }); - await clearStaleLockIfDead(); - const context = await chromium.launchPersistentContext(PROFILE_DIR, PERSISTENT_OPTS); - await installGuards(context); - entry.context = context; - entry.page = context.pages()[0] || (await context.newPage()); - return entry.page; - })(); - entry.relaunching.then( - () => { - entry.relaunching = null; - }, - () => { - entry.relaunching = null; - }, - ); - } - return entry.relaunching; - } - throw new CanvasError( - "disconnected", - "The Chrome connection was lost. Close this panel and reopen the canvas.", - ); -} - -async function navigate(page, rawUrl) { - const url = normalizeUrl(rawUrl); - const reason = blockedReason(url); - if (reason) throw new CanvasError("site_blocked", reason); - await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 }); - return pageState(page); -} - -// [claw-relay] ref-based element resolution. Resolve an element target to a -// Playwright selector. `selector` wins over `ref` when both are given. `ref` -// values come from a prior snapshot and are stamped onto the DOM as -// data-cc-ref attributes. -function resolveTarget(input) { - if (input?.selector) return input.selector; - if (input?.ref) return `[data-cc-ref="${String(input.ref).replace(/"/g, "")}"]`; - throw new CanvasError("bad_target", "Provide a `ref` (from snapshot) or a `selector`."); -} - -// [claw-relay] Accessibility-style page snapshot: enumerate visible interactive -// elements and stamp each with a stable ref (e1, e2, ...) the agent can target. -const SNAPSHOT_FN = () => { - const sel = [ - "a[href]", - "button", - "input", - "textarea", - "select", - "[role=button]", - "[role=link]", - "[role=textbox]", - "[role=checkbox]", - "[onclick]", - '[contenteditable=""]', - '[contenteditable="true"]', - ].join(","); - const out = []; - let i = 0; - // Clear refs from a prior snapshot so a renumbered ref can't match a stale - // element that scrolled out of view. - for (const stamped of document.querySelectorAll("[data-cc-ref]")) { - stamped.removeAttribute("data-cc-ref"); - } - for (const el of document.querySelectorAll(sel)) { - const rect = el.getBoundingClientRect(); - const style = getComputedStyle(el); - const visible = - rect.width > 0 && - rect.height > 0 && - style.visibility !== "hidden" && - style.display !== "none"; - if (!visible) continue; - i += 1; - if (i > 200) break; - const ref = `e${i}`; - el.setAttribute("data-cc-ref", ref); - const name = ( - el.getAttribute("aria-label") || - el.getAttribute("placeholder") || - (el.type === "password" ? "" : el.value) || - el.innerText || - el.getAttribute("title") || - "" - ) - .trim() - .replace(/\s+/g, " ") - .slice(0, 80); - out.push({ - ref, - tag: el.tagName.toLowerCase(), - type: el.getAttribute("type") || el.getAttribute("role") || "", - name, - }); - } - return out; -}; - -async function snapshot(page) { - const elements = await page.evaluate(SNAPSHOT_FN); - return { ...(await pageState(page)), elements }; -} - -async function clickTarget(page, input) { - const selector = resolveTarget(input); - await page.click(selector, { timeout: 15000 }); - return pageState(page); -} - -async function typeTarget(page, input) { - const text = String(input?.text ?? ""); - const selector = resolveTarget(input); - const locator = page.locator(selector).first(); - await locator.fill(text, { timeout: 15000 }); - if (input?.submit) await locator.press("Enter").catch(() => {}); - return pageState(page); -} - -async function screenshot(page, opts = {}) { - await mkdir(ARTIFACTS_DIR, { recursive: true }); - const stamp = new Date().toISOString().replace(/[:.]/g, "-"); - const name = `shot-${stamp}.png`; - const buffer = await page.screenshot({ fullPage: !!opts.fullPage }); - await writeFile(join(ARTIFACTS_DIR, name), buffer); - return { name, path: join(ARTIFACTS_DIR, name), size: buffer.length }; -} - -function sendJson(res, status, body) { - res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); - res.end(JSON.stringify(body)); -} - -function publicErrorMessage(err, fallback) { - return err instanceof CanvasError ? err.message : fallback; -} - -async function readBody(req) { - const chunks = []; - for await (const chunk of req) chunks.push(chunk); - return Buffer.concat(chunks).toString("utf-8"); -} - -async function readJson(req) { - try { - const body = await readBody(req); - return body ? JSON.parse(body) : {}; - } catch (_) { - throw new CanvasError("bad_json", "Invalid JSON request body."); - } -} - -function makeHandler(entry) { - return async function handleRequest(req, res) { - const reqUrl = new URL(req.url, "http://127.0.0.1"); - const { pathname } = reqUrl; - - if (req.method === "GET" && (pathname === "/" || pathname === "/index.html")) { - if (reqUrl.searchParams.get("token") !== TOKEN) { - sendJson(res, 403, { error: "forbidden" }); - return; - } - const html = (await readFile(join(__dirname, "index.html"), "utf-8")).replaceAll("__TOKEN__", TOKEN); - res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); - res.end(html); - return; - } - - if (req.method === "GET" && pathname.startsWith("/shot/")) { - if (req.headers["x-canvas-token"] !== TOKEN && reqUrl.searchParams.get("token") !== TOKEN) { - sendJson(res, 403, { error: "forbidden" }); - return; - } - const name = decodeURIComponent(pathname.slice("/shot/".length)); - if (!/^shot-[\w-]+\.png$/.test(name)) { - sendJson(res, 400, { error: "invalid name" }); - return; - } - const shotPath = join(ARTIFACTS_DIR, name); - const exists = await stat(shotPath).then(() => true, () => false); - if (!exists) { - sendJson(res, 404, { error: "not found" }); - return; - } - const bytes = await readFile(shotPath); - res.writeHead(200, { "Content-Type": "image/png", "Cache-Control": "no-store" }); - res.end(bytes); - return; - } - - // Every state route below requires the per-launch token templated into - // index.html. A cross-origin page can't read it, and the custom header - // also forces a CORS preflight this server never answers with allow. - if (req.headers["x-canvas-token"] !== TOKEN) { - sendJson(res, 403, { error: "forbidden" }); - return; - } - - const page = await livePage(entry); - - if (req.method === "GET" && pathname === "/state") { - sendJson(res, 200, { ...(await pageState(page)), mode: entry.mode }); - return; - } - - if (req.method === "POST" && pathname === "/navigate") { - try { - const body = await readJson(req); - const state = await navigate(page, body?.url); - await audit({ source: "panel", instanceId: entry.instanceId, action: "navigate", input: body?.url, url: state.url, ok: true }); - sendJson(res, 200, state); - } catch (err) { - await audit({ source: "panel", instanceId: entry.instanceId, action: "navigate", ok: false, error: publicErrorMessage(err, "Navigation failed.") }); - sendJson(res, 200, { ...(await pageState(page)), error: publicErrorMessage(err, "Navigation failed.") }); - } - return; - } - - const simple = { "/back": "goBack", "/forward": "goForward", "/reload": "reload" }; - if (req.method === "POST" && simple[pathname]) { - const actionName = pathname.slice(1); - try { - await page[simple[pathname]]({ waitUntil: "domcontentloaded" }); - const state = await pageState(page); - await audit({ source: "panel", instanceId: entry.instanceId, action: actionName, url: state.url, ok: true }); - sendJson(res, 200, state); - } catch (err) { - await audit({ source: "panel", instanceId: entry.instanceId, action: actionName, ok: false, error: publicErrorMessage(err, `${actionName} failed.`) }); - sendJson(res, 200, { ...(await pageState(page)), error: publicErrorMessage(err, `${actionName} failed.`) }); - } - return; - } - - if (req.method === "POST" && pathname === "/screenshot") { - try { - const shot = await screenshot(page); - await audit({ source: "panel", instanceId: entry.instanceId, action: "screenshot", url: page.url(), ok: true }); - sendJson(res, 200, shot); - } catch (err) { - await audit({ source: "panel", instanceId: entry.instanceId, action: "screenshot", ok: false, error: publicErrorMessage(err, "Screenshot failed.") }); - sendJson(res, 200, { error: publicErrorMessage(err, "Screenshot failed.") }); - } - return; - } - - sendJson(res, 404, { error: "not found" }); - }; -} - -async function startServer(entry) { - const handler = makeHandler(entry); - const server = createServer((req, res) => { - handler(req, res).catch((err) => { - sendJson(res, 500, { error: publicErrorMessage(err, "Request failed.") }); - }); - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - const address = server.address(); - const port = typeof address === "object" && address ? address.port : 0; - return { server, url: `http://127.0.0.1:${port}/?token=${encodeURIComponent(TOKEN)}` }; -} - -const PERSISTENT_OPTS = { headless: false, viewport: null }; - -// [claw-relay] Enforce the blocklist on real navigations, not just explicit -// navigate() calls, so in-page redirects are caught too. Every request -// round-trips through Node, which is fine at agent/human browsing pace. -async function installGuards(context) { - await context.route("**/*", (route) => { - const req = route.request(); - if (req.isNavigationRequest() && blockedReason(req.url())) { - return route.abort("blockedbyclient"); - } - return route.continue(); - }); -} - -// Chromium writes a SingletonLock symlink (target: "-") into the -// profile while a window owns it. A reload or killed process can leave that -// lock behind even though no Chromium is running. If the referenced PID is -// dead, the lock is stale and safe to clear; if it's alive, a real window -// owns the profile and we must not touch it. -async function clearStaleLockIfDead() { - const lockPath = join(PROFILE_DIR, "SingletonLock"); - let target; - try { - target = await readlink(lockPath); - } catch (_) { - return false; // no lock present - } - const pid = Number(target.split("-").pop()); - if (Number.isFinite(pid) && pid > 0) { - try { - process.kill(pid, 0); // throws if the process is gone - return false; // alive -> a real window owns the profile - } catch (err) { - if (err?.code === "EPERM") return false; // exists, not ours -> leave it - } - } - for (const f of ["SingletonLock", "SingletonSocket", "SingletonCookie"]) { - await rm(join(PROFILE_DIR, f), { force: true }).catch(() => {}); - } - return true; -} - -async function openInstance(instanceId, input) { - const cdpUrl = input?.cdpUrl; - let context; - let browser = null; - let mode; - if (cdpUrl) { - // [claw-relay] Connect to an already-running Chrome over CDP (started with - // --remote-debugging-port) instead of launching our own window. - browser = await chromium.connectOverCDP(cdpUrl); - context = browser.contexts()[0] || (await browser.newContext()); - mode = "cdp"; - } else { - // One shared persistent profile can only back one live Chromium window. - // Refuse a second persistent instance with a readable message instead of - // a cryptic Playwright lock dump. - for (const e of instances.values()) { - if (e.mode === "persistent") { - throw new CanvasError( - "profile_busy", - "A Chromium canvas is already open. Close it before opening another — they share one logged-in profile.", - ); - } - } - // Persistent profile: same Chromium user-data-dir every time, so logins - // you complete by hand in the window survive across sessions. - await mkdir(PROFILE_DIR, { recursive: true }); - try { - context = await chromium.launchPersistentContext(PROFILE_DIR, PERSISTENT_OPTS); - } catch (err) { - if (!/existing browser session/i.test(String(err?.message))) throw err; - const cleared = await clearStaleLockIfDead(); - if (!cleared) { - throw new CanvasError( - "profile_busy", - "The Chromium profile is in use by another live window. Close that Chromium window first.", - ); - } - context = await chromium.launchPersistentContext(PROFILE_DIR, PERSISTENT_OPTS); - } - mode = "persistent"; - } - await installGuards(context); - const page = context.pages()[0] || (await context.newPage()); - const entry = { instanceId, context, browser, page, mode }; - if (input?.url) { - await navigate(page, input.url); - } - const { server, url } = await startServer(entry); - entry.server = server; - entry.url = url; - instances.set(instanceId, entry); - await audit({ instanceId, action: "open", mode, url: input?.url || "about:blank" }); - return entry; -} - -async function closeInstance(instanceId) { - const entry = instances.get(instanceId); - if (!entry) return; - instances.delete(instanceId); - await new Promise((resolve) => { - entry.server.closeAllConnections?.(); - entry.server.close(() => resolve()); - }).catch(() => {}); - if (entry.mode === "cdp") { - // Don't kill the user's own Chrome; just disconnect. - await entry.browser?.close().catch(() => {}); - } else { - await entry.context.close().catch(() => {}); - } - await audit({ instanceId, action: "close", mode: entry.mode }); -} - -// Wrap a canvas action handler so every agent-driven call is audited. -function action(name, description, run, inputSchema) { - return { - name, - description, - ...(inputSchema ? { inputSchema } : {}), - handler: async (ctx) => { - const entry = getInstance(ctx.instanceId); - try { - const page = await livePage(entry); - const result = await run(page, ctx.input, entry); - const state = result?.url ? result : await pageState(page); - await audit({ source: "agent", instanceId: ctx.instanceId, action: name, input: redactInput(ctx.input), url: state.url, ok: true }); - return result; - } catch (err) { - await audit({ source: "agent", instanceId: ctx.instanceId, action: name, input: redactInput(ctx.input), ok: false, error: publicErrorMessage(err, "Action failed.") }); - throw err; - } - }, - }; -} - -const session = await joinSession({ - canvases: [ - createCanvas({ - id: "chromium-control-canvas", - displayName: "Chromium Control Canvas", - description: - "Opens a real Chromium window you can navigate and interact with from a Copilot canvas control panel and agent actions.", - inputSchema: { - type: "object", - properties: { - url: { type: "string", description: "Optional URL to open on launch." }, - cdpUrl: { - type: "string", - description: - "Optional CDP endpoint (e.g. http://localhost:9222) to attach to an existing Chrome instead of launching one.", - }, - }, - }, - actions: [ - action( - "navigate", - "Navigate to a URL or search query (blocklist enforced).", - (page, input) => navigate(page, input?.url), - { - type: "object", - properties: { url: { type: "string", description: "URL (https assumed) or search query." } }, - required: ["url"], - }, - ), - action("back", "Go back in history.", async (page) => { - await page.goBack({ waitUntil: "domcontentloaded" }).catch(() => {}); - return pageState(page); - }), - action("forward", "Go forward in history.", async (page) => { - await page.goForward({ waitUntil: "domcontentloaded" }).catch(() => {}); - return pageState(page); - }), - action("reload", "Reload the current page.", async (page) => { - await page.reload({ waitUntil: "domcontentloaded" }).catch(() => {}); - return pageState(page); - }), - action("current_url", "Get the current URL and page title.", (page) => pageState(page)), - action( - "snapshot", - "List visible interactive elements with stable refs (e1, e2, ...) for click/type.", - (page) => snapshot(page), - ), - action( - "click", - "Click an element by `ref` (from snapshot) or CSS `selector`.", - (page, input) => clickTarget(page, input), - { - type: "object", - properties: { - ref: { type: "string", description: "Element ref from a snapshot, e.g. e3." }, - selector: { type: "string", description: "CSS selector (takes priority over ref)." }, - }, - }, - ), - action( - "type", - "Fill text into an input by `ref` or `selector`; set submit to press Enter.", - (page, input) => typeTarget(page, input), - { - type: "object", - properties: { - text: { type: "string", description: "Text to enter." }, - ref: { type: "string", description: "Element ref from a snapshot." }, - selector: { type: "string", description: "CSS selector (takes priority over ref)." }, - submit: { type: "boolean", description: "Press Enter after filling." }, - }, - required: ["text"], - }, - ), - action( - "screenshot", - "Capture a PNG of the page, saved under the extension artifacts dir.", - (page, input) => screenshot(page, { fullPage: input?.fullPage }), - { - type: "object", - properties: { fullPage: { type: "boolean", description: "Capture the full scrollable page." } }, - }, - ), - ], - open: async (ctx) => { - let entry = instances.get(ctx.instanceId); - if (!entry) { - entry = await openInstance(ctx.instanceId, ctx.input || {}); - log(`Launched Chromium (${entry.mode}) for instance ${ctx.instanceId}`, { - level: "info", - ephemeral: true, - }); - } - return { title: "Chromium Control Canvas", url: entry.url }; - }, - onClose: async (ctx) => { - await closeInstance(ctx.instanceId); - }, - }), - ], -}); - -log = (message, opts) => session.log(message, opts); - -// Close Chromium contexts on shutdown so reloading the extension doesn't orphan -// the window and leave a stale profile lock behind. The runtime sends SIGTERM -// (then SIGKILL after ~5s), so keep teardown fast. -let shuttingDown = false; -async function shutdown() { - if (shuttingDown) return; - shuttingDown = true; - await Promise.allSettled( - [...instances.keys()].map((id) => closeInstance(id)), - ); - process.exit(0); -} -process.on("SIGTERM", shutdown); -process.on("SIGINT", shutdown); diff --git a/extensions/chromium-control-canvas/index.html b/extensions/chromium-control-canvas/index.html deleted file mode 100644 index ab3bb2b6..00000000 --- a/extensions/chromium-control-canvas/index.html +++ /dev/null @@ -1,401 +0,0 @@ - - - - - -Chromium - - - -
- -
-
-
- -
- 🌐 - -
- - -
-
- - about:blank -
-
-
- -
-
- -
-
- Screenshot - -
-
-
- This panel drives a separate Chromium window. Navigate above and - a snapshot of the page shows up here. Log in by hand in that window; the profile - persists. The agent can also snapshot, click, and - type. -
-
-
- -
- The preview updates after you navigate from this panel. Agent click and - type actions go straight to the page and bypass this panel, so the - preview can lag during agent work — focus the panel to resync. -
-
- - - - diff --git a/extensions/chromium-control-canvas/package-lock.json b/extensions/chromium-control-canvas/package-lock.json deleted file mode 100644 index dc195aba..00000000 --- a/extensions/chromium-control-canvas/package-lock.json +++ /dev/null @@ -1,252 +0,0 @@ -{ - "name": "chromium-control-canvas", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "chromium-control-canvas", - "version": "1.0.0", - "license": "MIT", - "dependencies": { - "@github/copilot-sdk": "latest", - "playwright": "^1.60.0" - } - }, - "node_modules/@github/copilot": { - "version": "1.0.61", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.61.tgz", - "integrity": "sha512-E4f7YXTL2uUZY/ypnfsUruAeSgrHx3AGYEbm5N0DrpzPqoNAZqV6kHEWM4vu+W/nGvydIfPxmOTqaMEhM8r0Uw==", - "license": "SEE LICENSE IN LICENSE.md", - "dependencies": { - "detect-libc": "^2.1.2" - }, - "bin": { - "copilot": "npm-loader.js" - }, - "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.61", - "@github/copilot-darwin-x64": "1.0.61", - "@github/copilot-linux-arm64": "1.0.61", - "@github/copilot-linux-x64": "1.0.61", - "@github/copilot-linuxmusl-arm64": "1.0.61", - "@github/copilot-linuxmusl-x64": "1.0.61", - "@github/copilot-win32-arm64": "1.0.61", - "@github/copilot-win32-x64": "1.0.61" - } - }, - "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.61", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.61.tgz", - "integrity": "sha512-10prvjHRXB0SD28NsIpzdNDgLquQYUwaH5Ev9KVdIWdBPAvlQsHmQ4JSCyD/UILc/nrrr02CKUgum+mZRKUKIg==", - "cpu": [ - "arm64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "darwin" - ], - "bin": { - "copilot-darwin-arm64": "copilot" - } - }, - "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.61", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.61.tgz", - "integrity": "sha512-NXUjageJ3mxDfHtXGYu//XhJ+dhJFYObT4R3jeWgIHhd+4lX7FlC754nwlBP/ZuVhJ3ND22JK9sua9d2F3Cbwg==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "darwin" - ], - "bin": { - "copilot-darwin-x64": "copilot" - } - }, - "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.61", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.61.tgz", - "integrity": "sha512-dwB2+QSMr622JkePeK56M7YWXsTT/DQzKfpDq8Lk2kmGU052RZAarRmt8gcNm4anofN7pMSrqc3YHj1TM84MFw==", - "cpu": [ - "arm64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ], - "bin": { - "copilot-linux-arm64": "copilot" - } - }, - "node_modules/@github/copilot-linux-x64": { - "version": "1.0.61", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.61.tgz", - "integrity": "sha512-q6n8R8oybvuCmmkP+43w809Wpud/wwRi/fFSZEYJagiNGmYJ00SDkrfJxHbZsAFMpaJC+oTswqzJHjRoZbO74w==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ], - "bin": { - "copilot-linux-x64": "copilot" - } - }, - "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.61", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.61.tgz", - "integrity": "sha512-yWo7JXnZS11eJpm68E1RWKMR47EwzPKj3V7GX0EMTd8Fw0T2Aurk9wt9p3c9w0v02nTO1DqJhi68KVWJPdVqvA==", - "cpu": [ - "arm64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ], - "bin": { - "copilot-linuxmusl-arm64": "copilot" - } - }, - "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.61", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.61.tgz", - "integrity": "sha512-nHzx27Ac4B0fpD9CcmvyrGOBEMJ01CPRgVRP0yAl4wpU4cM2I6+9TPyfYThlWDqZqiUKGXC1ZRQ+B8cJREVGmA==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ], - "bin": { - "copilot-linuxmusl-x64": "copilot" - } - }, - "node_modules/@github/copilot-sdk": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.1.tgz", - "integrity": "sha512-w6AaS0WqqTE/3iyUrZznvgCLQhsUF7ZmEVCneacuHCfOzlH0r6ww9WUmyA0zgqmXO75V0IYrkIcnFke/qJkkDg==", - "license": "MIT", - "dependencies": { - "@github/copilot": "^1.0.61", - "vscode-jsonrpc": "^8.2.1", - "zod": "^4.3.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.61", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.61.tgz", - "integrity": "sha512-k6knzI+K5HlZeJDS/yeJAfoYD4xcURWfuqunpTCyk1pDbIFxmrLSqR/TDi7KNlpsf883n5WqpnB06K5kysdHHQ==", - "cpu": [ - "arm64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "win32" - ], - "bin": { - "copilot-win32-arm64": "copilot.exe" - } - }, - "node_modules/@github/copilot-win32-x64": { - "version": "1.0.61", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.61.tgz", - "integrity": "sha512-L6NZ6o73VZFHd7OoRaztV3Prh1PbW9HXqYsAx+XywNALQvE1u489WBUC1ggfYBW5MTBCf8mxSkYQdb3Am2omsw==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "win32" - ], - "bin": { - "copilot-win32-x64": "copilot.exe" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/playwright": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", - "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.60.0" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", - "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/vscode-jsonrpc": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", - "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/extensions/chromium-control-canvas/package.json b/extensions/chromium-control-canvas/package.json deleted file mode 100644 index 5b6197b2..00000000 --- a/extensions/chromium-control-canvas/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "chromium-control-canvas", - "version": "1.0.0", - "main": "extension.mjs", - "author": "Andrea Griffiths", - "license": "MIT", - "type": "module", - "dependencies": { - "@github/copilot-sdk": "latest", - "playwright": "^1.60.0" - }, - "description": "Opens a real Chromium window you can navigate and interact with from a Copilot canvas control panel and agent actions.", - "keywords": [ - "chromium-browser", - "playwright-automation", - "browser-control", - "interactive-canvas", - "web-navigation", - "screenshots", - "ui-testing" - ] -} diff --git a/extensions/color-orb/.github/plugin/plugin.json b/extensions/color-orb/.github/plugin/plugin.json index e94bc132..8a59c8b0 100644 --- a/extensions/color-orb/.github/plugin/plugin.json +++ b/extensions/color-orb/.github/plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "color-orb", "description": "A visual orb that users can ask the agent to recolor while showing a live activity log in the canvas.", - "version": "1.0.1", + "version": "1.0.2", "author": { "name": "Aaron Powell", "url": "https://github.com/aaronpowell" diff --git a/extensions/color-orb/extensions/assets/preview.png b/extensions/color-orb/extensions/color-orb/assets/preview.png similarity index 100% rename from extensions/color-orb/extensions/assets/preview.png rename to extensions/color-orb/extensions/color-orb/assets/preview.png diff --git a/extensions/color-orb/extension.mjs b/extensions/color-orb/extensions/color-orb/extension.mjs similarity index 100% rename from extensions/color-orb/extension.mjs rename to extensions/color-orb/extensions/color-orb/extension.mjs diff --git a/extensions/color-orb/extensions/package-lock.json b/extensions/color-orb/extensions/color-orb/package-lock.json similarity index 100% rename from extensions/color-orb/extensions/package-lock.json rename to extensions/color-orb/extensions/color-orb/package-lock.json diff --git a/extensions/color-orb/extensions/package.json b/extensions/color-orb/extensions/color-orb/package.json similarity index 100% rename from extensions/color-orb/extensions/package.json rename to extensions/color-orb/extensions/color-orb/package.json diff --git a/extensions/color-orb/extensions/extension.mjs b/extensions/color-orb/extensions/extension.mjs deleted file mode 100644 index 1dd4c9d2..00000000 --- a/extensions/color-orb/extensions/extension.mjs +++ /dev/null @@ -1,289 +0,0 @@ -import http from "node:http"; -import { createCanvas, joinSession } from "@github/copilot-sdk/extension"; - -// In-memory state (ephemeral per provider process) -let currentColor = "#6c63ff"; -let logEntries = []; -const sseClients = new Set(); - -function broadcast(event, data) { - for (const res of sseClients) { - res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); - } -} - -// --- Loopback HTTP server for the iframe --- -const server = http.createServer((req, res) => { - if (req.method === "GET" && req.url === "/") { - res.writeHead(200, { "Content-Type": "text/html" }); - res.end(getHTML()); - return; - } - - if (req.method === "GET" && req.url === "/events") { - res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }); - // Send current state immediately - res.write(`event: color\ndata: ${JSON.stringify({ color: currentColor })}\n\n`); - res.write(`event: log\ndata: ${JSON.stringify({ entries: logEntries })}\n\n`); - sseClients.add(res); - req.on("close", () => sseClients.delete(res)); - return; - } - - if (req.method === "POST" && req.url === "/request-change") { - const entry = { time: new Date().toLocaleTimeString(), message: "🖱️ User clicked — requesting a color change..." }; - logEntries.push(entry); - broadcast("log", { entries: logEntries }); - if (session) { - session.send({ - prompt: "The user clicked the 'Ask Agent to Change Color' button on the Color Orb canvas. Pick a random, fun color and use the set_color canvas action to change the orb, then use log_message to tell them what color you chose and why.", - }); - } - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ ok: true })); - return; - } - - if (req.method === "POST" && req.url === "/clear-log") { - logEntries = []; - broadcast("log", { entries: logEntries }); - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ ok: true })); - return; - } - - res.writeHead(404); - res.end("Not found"); -}); - -const port = await new Promise((resolve) => { - server.listen(0, "127.0.0.1", () => resolve(server.address().port)); -}); - -let session; - -const canvas = createCanvas({ - id: "color-orb", - displayName: "Color Orb", - description: "An interactive orb whose color can be changed by the agent. The user clicks a button to request a color change, then the agent sets the new color.", - actions: [ - { - name: "set_color", - description: "Set the orb color. Accepts any valid CSS color (hex, named, rgb, hsl).", - inputSchema: { - type: "object", - properties: { - color: { type: "string", description: "CSS color value, e.g. '#ff6347' or 'tomato'" }, - }, - required: ["color"], - }, - handler({ input }) { - currentColor = input.color; - broadcast("color", { color: currentColor }); - return { color: currentColor }; - }, - }, - { - name: "log_message", - description: "Append a message to the canvas log area visible to the user.", - inputSchema: { - type: "object", - properties: { - message: { type: "string", description: "The message to display in the log" }, - }, - required: ["message"], - }, - handler({ input }) { - const entry = { time: new Date().toLocaleTimeString(), message: input.message }; - logEntries.push(entry); - broadcast("log", { entries: logEntries }); - return { ok: true }; - }, - }, - { - name: "clear_log", - description: "Clear all messages from the canvas log.", - inputSchema: { type: "object", properties: {} }, - handler() { - logEntries = []; - broadcast("log", { entries: logEntries }); - return { ok: true }; - }, - }, - ], - open({ instanceId }) { - return { - url: `http://127.0.0.1:${port}`, - title: "Color Orb", - status: "ready", - }; - }, -}); - -session = await joinSession({ canvases: [canvas] }); - -function getHTML() { - return ` - - - - - - - - -
-
-
color-orb
-
-
-
-
- - -
-
-
-
waiting for input…
-
-
- - - -`; -} diff --git a/extensions/color-orb/package-lock.json b/extensions/color-orb/package-lock.json deleted file mode 100644 index fd2a9dae..00000000 --- a/extensions/color-orb/package-lock.json +++ /dev/null @@ -1,218 +0,0 @@ -{ - "name": "color-orb", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "color-orb", - "version": "1.0.0", - "dependencies": { - "@github/copilot-sdk": "latest" - } - }, - "node_modules/@github/copilot": { - "version": "1.0.55-7", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.55-7.tgz", - "integrity": "sha512-TczFrIaHH2sel6FM007H4FzT+Ipkj++I5u8Vx2ECWz9u24H7WOx/RpWcp6ExnSY1KSK1MtXaGcniAuqVi8Khaw==", - "license": "SEE LICENSE IN LICENSE.md", - "dependencies": { - "detect-libc": "^2.1.2" - }, - "bin": { - "copilot": "npm-loader.js" - }, - "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.55-7", - "@github/copilot-darwin-x64": "1.0.55-7", - "@github/copilot-linux-arm64": "1.0.55-7", - "@github/copilot-linux-x64": "1.0.55-7", - "@github/copilot-linuxmusl-arm64": "1.0.55-7", - "@github/copilot-linuxmusl-x64": "1.0.55-7", - "@github/copilot-win32-arm64": "1.0.55-7", - "@github/copilot-win32-x64": "1.0.55-7" - } - }, - "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.55-7", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.55-7.tgz", - "integrity": "sha512-QReU4F5+W0x/Nuc6qO+xYPeNnRjuHIIAeMBc1S+RFQ0T+YWynxRzNHGs9ZkUiIcLJ1F/y8GDq6sq7760Cn+onQ==", - "cpu": [ - "arm64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "darwin" - ], - "bin": { - "copilot-darwin-arm64": "copilot" - } - }, - "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.55-7", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.55-7.tgz", - "integrity": "sha512-qQ0d+XyvIPbNiaIydHBSCTQfWK5s0x1XnlrUKSzadgOnsFobGeldLSKtB159zJEiz0F/in5ythiUGJjWoAQVrA==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "darwin" - ], - "bin": { - "copilot-darwin-x64": "copilot" - } - }, - "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.55-7", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.55-7.tgz", - "integrity": "sha512-+2zlHahK3fUfkrnlHqbdQsZMPZwRfchoTxDZd9UHbEhQF7eNLzYN+7frWs6AZujU+h/1i92+mcLT18AQXI3KxQ==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ], - "bin": { - "copilot-linux-arm64": "copilot" - } - }, - "node_modules/@github/copilot-linux-x64": { - "version": "1.0.55-7", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.55-7.tgz", - "integrity": "sha512-SGmvWcJHIKDIsjYZdFQloGw3Re6r2N1Zv1VuB1yV1ClVqfG5i5pTvai6vzX8d3WgGgRzrkLksDrzZKR27zJZ7A==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ], - "bin": { - "copilot-linux-x64": "copilot" - } - }, - "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.55-7", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.55-7.tgz", - "integrity": "sha512-rJkZLvz4KeGoLgyX6gcONgTNfFxeoQvN4jaAXlbD1nFP3hJbLTuY0CB4fBHmZWktrPkRL/j5aDGxrcIcl+Xg3A==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ], - "bin": { - "copilot-linuxmusl-arm64": "copilot" - } - }, - "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.55-7", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.55-7.tgz", - "integrity": "sha512-uPb08qgJHY1QW2YhA1OBJ9PB0CDwCvtuttWbeZ+AW+qfFVsvBpARU1cdEl/xT4IXMhBFoJiePv3BnLGjVZtoWA==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ], - "bin": { - "copilot-linuxmusl-x64": "copilot" - } - }, - "node_modules/@github/copilot-sdk": { - "version": "1.0.0-beta.9", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.0-beta.9.tgz", - "integrity": "sha512-D4yiGL4/faFCjL7bozhX7bgxt/x1wp2LZ2p9Tw+xrA5hbcLh5Be5kPen+bFA8NbVfgt1G2djDYFZlrZjXXmcBw==", - "license": "MIT", - "dependencies": { - "@github/copilot": "^1.0.55-5", - "vscode-jsonrpc": "^8.2.1", - "zod": "^4.3.6" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.55-7", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.55-7.tgz", - "integrity": "sha512-mb4Sg2sJjmK9Rq8XCRuhoIOjUScB5p2Ct9ZtTbC3ipvONWMOMjYPbLvC8K9GAHcYcHLdv98hvzv3+qjBhb5tZQ==", - "cpu": [ - "arm64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "win32" - ], - "bin": { - "copilot-win32-arm64": "copilot.exe" - } - }, - "node_modules/@github/copilot-win32-x64": { - "version": "1.0.55-7", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.55-7.tgz", - "integrity": "sha512-GL9jAtkn2Kx4IO9ZfTiMC3LFd539KuuOx3uOIKciWKMuCvcfct0rdVkXlDr+EnrmPzu1A4PavcJ0RScpI39jUQ==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "win32" - ], - "bin": { - "copilot-win32-x64": "copilot.exe" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/vscode-jsonrpc": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", - "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/extensions/color-orb/package.json b/extensions/color-orb/package.json deleted file mode 100644 index a28cd5c3..00000000 --- a/extensions/color-orb/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "color-orb", - "version": "1.0.0", - "type": "module", - "main": "extension.mjs", - "dependencies": { - "@github/copilot-sdk": "latest" - }, - "description": "Gives users a visual orb they can ask the agent to recolor while showing a live activity log in the canvas.", - "keywords": [ - "color-picker", - "interactive-demo", - "agent-actions", - "realtime-updates", - "sse-events", - "visual-feedback" - ] -} diff --git a/extensions/connector-namespaces/.github/plugin/plugin.json b/extensions/connector-namespaces/.github/plugin/plugin.json index e563f9a8..1a90eebf 100644 --- a/extensions/connector-namespaces/.github/plugin/plugin.json +++ b/extensions/connector-namespaces/.github/plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "connector-namespaces", "description": "Browse, connect, and open MCP connectors from an Azure Connector Namespace.", - "version": "1.1.1", + "version": "1.1.2", "author": { "name": "Alex Yang", "url": "https://github.com/alexyaang" diff --git a/extensions/connector-namespaces/extensions/LICENSE b/extensions/connector-namespaces/extensions/LICENSE deleted file mode 100644 index 22aed37e..00000000 --- a/extensions/connector-namespaces/extensions/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) Microsoft Corporation. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/extensions/connector-namespaces/extensions/README.md b/extensions/connector-namespaces/extensions/README.md deleted file mode 100644 index f7346247..00000000 --- a/extensions/connector-namespaces/extensions/README.md +++ /dev/null @@ -1,85 +0,0 @@ -# MCP Connectors — Copilot CLI Canvas Extension - -A GitHub Copilot CLI **canvas extension** that lets you browse and add MCP -connectors from an Azure **Connector Namespace** directly inside a Copilot CLI -session. Search by name or category, sign in to a connector, then restart the -session to make its tools available to the agent. - -> The canvas talks to public Azure Resource Manager (`management.azure.com`) -> using the signed-in Azure CLI account. The extension does not register its own -> Entra application or persist Azure credentials. - -## Prerequisites - -- **GitHub Copilot CLI** (the host that loads canvas extensions). -- **Azure CLI**, signed in with `az login`. The extension asks Azure CLI for a - short-lived ARM access token and refreshes it through the same broker. -- **An Azure subscription with a Connector Namespace** — resource type - `Microsoft.Web/connectorGateways` (API version `2026-05-01-preview`). This is - a preview resource provider; you must have access to it for the catalog to - load. Without it the extension installs fine but has nothing to show. - -## Install - -Install it from the public Awesome Copilot repository: - -``` -install_extension https://github.com/github/awesome-copilot/tree/main/extensions/connector-namespaces -``` - -For a reproducible install, swap `main` for a reviewed commit SHA from this -repository. - -The destination **scope** is chosen at install time: - -- **user** (default) — installs globally for you at - `$COPILOT_HOME/extensions/connector-namespaces/`. The usual choice for a - personal tool. -- **project** — installs into the current repo. -- **session** — scoped to a single CLI session. - -## Usage - -1. Open the **MCP Connectors** canvas from Copilot CLI. -2. The canvas loads subscriptions from your signed-in Azure CLI account. Pick an - Azure **subscription** and a **Connector Namespace**. The choice is saved for - future sessions (change it any time via **Change namespace**). -3. Browse or filter the connector catalog, then **Connect**. A browser tab - opens for Microsoft sign-in; complete it and the canvas updates on its own. -4. Connected connectors move into **My MCPs**. Use **Sandbox** on a tile to open - that server directly in the namespace MCP playground. -5. Restart the Copilot CLI session so the agent can load the connected tools. - -The extension registers the native `connector_namespaces_open_playground` tool, -so GitHub Copilot can open a named connector from **My MCPs** without installing -an additional Agent Skill. - -## How it works - -- `extension.mjs` — entry point; declares the canvas, `open_sandbox` action, and - native `connector_namespaces_open_playground` tool. -- `server.mjs` — a loopback HTTP server (bound to `127.0.0.1` only) that serves - the canvas UI and the JSON/OAuth endpoints the iframe calls. -- `armClient.mjs` — thin ARM client (token brokered by Azure CLI, public ARM - base only, SSRF-guarded path segments). -- `catalog.mjs` — fetches and curates the connector list for a namespace. -- `install.mjs` — the connect/install pipeline (managed-API connection, consent, - rollback on cancel, and native HTTPS MCP config registration). -- `renderer.mjs` — all canvas HTML/CSS/client JS. -- `sandbox.mjs` — builds namespace playground links and resolves named My MCPs. -- `state.mjs` — saved namespace and connector state. - -## Privacy & security - -- ARM tokens come from `az account get-access-token`, stay in process memory, - and are never logged or written by the extension. Azure CLI owns sign-in and - credential storage. -- All servers bind to loopback (`127.0.0.1`) and are never exposed externally. -- ARM requests go only to `https://management.azure.com/`; path segments are - validated to prevent SSRF-style host smuggling. -- The minted gateway API key is stored in the selected Copilot MCP config and - sent to its validated HTTPS endpoint as the `X-API-Key` header. - -## License - -[MIT](./LICENSE) © Microsoft Corporation. diff --git a/extensions/connector-namespaces/extensions/armClient.mjs b/extensions/connector-namespaces/extensions/armClient.mjs deleted file mode 100644 index dc0f9fc4..00000000 --- a/extensions/connector-namespaces/extensions/armClient.mjs +++ /dev/null @@ -1,444 +0,0 @@ -// ARM API client — fetches real connector data with Azure CLI credentials. - -import { exec, execFile } from "node:child_process"; -import { constants as fsConstants, promises as fs } from "node:fs"; -import { homedir, platform } from "node:os"; -import { basename, delimiter, dirname, isAbsolute, join, resolve, sep } from "node:path"; -import { promisify } from "node:util"; - -const API_VERSION = "2026-05-01-preview"; -const RG_API_VERSION = "2021-04-01"; -const MSI_API_VERSION = "2023-01-31"; -const SUBS_API_VERSION = "2020-01-01"; - -const execFileAsync = promisify(execFile); -const execAsync = promisify(exec); -const ARM_RESOURCE = "https://management.azure.com/"; -const EXPIRY_SKEW_MS = 5 * 60 * 1000; -const LEGACY_AUTH_CACHE = join( - process.env.COPILOT_HOME || join(homedir(), ".copilot"), - "extensions", - "connector-namespaces", - "artifacts", - "auth-cache.json", -); - -let s_auth = null; // { token, expiresAt } -let s_authInFlight = null; -let s_legacyAuthCacheRemoved = false; - -export function parseAzureCliToken(stdout) { - let data; - try { - data = JSON.parse(stdout); - } catch { - throw new Error("Azure CLI returned invalid token JSON."); - } - const token = data?.accessToken; - const epochSeconds = Number(data?.expires_on); - const expiresAt = Number.isFinite(epochSeconds) && epochSeconds > 0 - ? epochSeconds * 1000 - : Date.parse(data?.expiresOn); - if (typeof token !== "string" || token.length === 0 || !Number.isFinite(expiresAt)) { - throw new Error("Azure CLI returned an incomplete ARM token."); - } - return { token, expiresAt }; -} - -async function removeLegacyAuthCache() { - if (s_legacyAuthCacheRemoved) return; - try { - await fs.unlink(LEGACY_AUTH_CACHE); - } catch (error) { - if (error?.code !== "ENOENT") { - throw new Error(`Could not remove the legacy connector credential cache at ${LEGACY_AUTH_CACHE}: ${error.message}`); - } - } - s_legacyAuthCacheRemoved = true; -} - -function windowsSystemExecutable(name) { - const systemRoot = process.env.SystemRoot; - if (systemRoot && isAbsolute(systemRoot)) return join(systemRoot, "System32", name); - throw new Error(`Could not resolve the Windows system executable ${name}.`); -} - -async function trustedExecutablePath(path, expectedName, workspaceRoot = process.cwd()) { - if (!isAbsolute(path) || /["\r\n]/.test(path)) return null; - let candidate; - let workspace; - try { - [candidate, workspace] = await Promise.all([ - fs.realpath(path), - fs.realpath(workspaceRoot), - ]); - if (!(await fs.stat(candidate)).isFile()) return null; - if (platform() !== "win32") await fs.access(candidate, fsConstants.X_OK); - } catch { - return null; - } - const insensitive = platform() === "win32"; - const normalize = (value) => insensitive ? value.toLowerCase() : value; - const normalizedCandidate = normalize(resolve(candidate)); - const normalizedWorkspace = normalize(resolve(workspace)); - const workspacePrefix = normalizedWorkspace.endsWith(sep) - ? normalizedWorkspace - : `${normalizedWorkspace}${sep}`; - if ( - normalize(basename(candidate)) !== normalize(expectedName) || - normalizedCandidate === normalizedWorkspace || - normalizedCandidate.startsWith(workspacePrefix) - ) return null; - return candidate; -} - -async function resolveWindowsAzureCli() { - const trustedCwd = homedir(); - const { stdout } = await execFileAsync( - windowsSystemExecutable("where.exe"), - ["az.cmd"], - { cwd: trustedCwd, encoding: "utf8", windowsHide: true, timeout: 10_000, maxBuffer: 64 * 1024 }, - ); - for (const path of stdout.split(/\r?\n/).map((line) => line.trim())) { - if (/[%]/.test(path)) continue; - const candidate = await trustedExecutablePath(path, "az.cmd"); - if (candidate) return candidate; - } - throw new Error("Azure CLI was not found outside the current workspace."); -} - -export async function resolvePosixAzureCli(pathValue = process.env.PATH || "", workspaceRoot = process.cwd()) { - for (const directory of pathValue.split(delimiter)) { - const candidate = await trustedExecutablePath(resolve(directory || workspaceRoot, "az"), "az", workspaceRoot); - if (candidate) return candidate; - } - throw new Error("Azure CLI was not found outside the current workspace."); -} - -export async function resolveSystemExecutable(name, workspaceRoot = process.cwd()) { - const candidates = platform() === "win32" - ? [windowsSystemExecutable(name)] - : [join("/usr/bin", name), join("/bin", name), join("/usr/local/bin", name)]; - for (const path of candidates) { - const candidate = await trustedExecutablePath(path, name, workspaceRoot); - if (candidate) return candidate; - } - throw new Error(`Could not resolve the trusted system executable ${name}.`); -} - -async function acquireToken() { - await removeLegacyAuthCache(); - try { - const windows = platform() === "win32"; - const azureCli = windows ? await resolveWindowsAzureCli() : await resolvePosixAzureCli(); - const options = { cwd: homedir(), encoding: "utf8", windowsHide: true, timeout: 60_000, maxBuffer: 1024 * 1024 }; - const { stdout } = windows - ? await execAsync( - `"${azureCli}" account get-access-token --resource https://management.azure.com/ --output json --only-show-errors`, - { ...options, shell: windowsSystemExecutable("cmd.exe") }, - ) - : await execFileAsync( - azureCli, - ["account", "get-access-token", "--resource", ARM_RESOURCE, "--output", "json", "--only-show-errors"], - options, - ); - s_auth = parseAzureCliToken(stdout); - return s_auth.token; - } catch (error) { - const detail = String(error?.stderr || error?.message || "").trim(); - throw new Error( - `Azure CLI authentication failed. Install Azure CLI and run "az login" before opening the canvas.${detail ? ` ${detail}` : ""}`, - ); - } -} - -export async function getToken() { - if (s_auth && s_auth.expiresAt - EXPIRY_SKEW_MS > Date.now()) return s_auth.token; - if (s_authInFlight) return s_authInFlight; - s_authInFlight = acquireToken().finally(() => { - s_authInFlight = null; - }); - return s_authInFlight; -} - -/** - * List all enabled Azure subscriptions the user has access to. - */ -// The set of enabled subscriptions is stable for a session, so cache it — the -// first /setup pays the ARM round-trip once and every "Change namespace" -// afterwards serves from memory. -let s_subsCache = null; // { subs, expiresAt } -const SUBS_TTL_MS = 30 * 60 * 1000; - -export async function listSubscriptions() { - const now = Date.now(); - if (s_subsCache && s_subsCache.expiresAt > now) return s_subsCache.subs; - const token = await getToken(); - const url = `https://management.azure.com/subscriptions?api-version=${SUBS_API_VERSION}`; - const raw = await paginateAll(url, token); - const subs = raw - .filter((s) => s.state === "Enabled") - .map((s) => ({ id: s.subscriptionId, name: s.displayName, tenantId: s.tenantId, state: s.state })); - s_subsCache = { subs, expiresAt: now + SUBS_TTL_MS }; - return subs; -} - -// ARM resource identifiers are a restricted charset (letters, digits and a few -// punctuation chars). Validating each path segment against this allowlist before -// it enters a URL rejects anything containing "/", "?", "#", "@" or ":" — the -// characters that could otherwise alter the request path or redirect the host — -// and acts as a taint barrier so config/file-derived names cannot reach fetch -// unvalidated. -const ARM_SEGMENT = /^[A-Za-z0-9._()-]{1,256}$/; - -export function armSegment(value) { - const s = String(value); - if (s === "." || s === ".." || !ARM_SEGMENT.test(s)) { - throw new Error(`Invalid ARM resource identifier: ${s}`); - } - return s; -} - -function buildBaseUrl(subscriptionId, resourceGroup, gatewayName) { - return `https://management.azure.com/subscriptions/${armSegment(subscriptionId)}/resourceGroups/${armSegment(resourceGroup)}/providers/Microsoft.Web/connectorGateways/${armSegment(gatewayName)}`; -} - -// Hard host allowlist: every request this client makes targets ARM and only -// ARM. The trailing slash matters — it blocks suffix/userinfo bypasses such as -// "https://management.azure.com.evil.com/" and "https://management.azure.com@evil.com/", -// neither of which starts with this exact prefix. This guards the paginated -// nextLink (a server-supplied value) which does not pass through armSegment. -const ARM_BASE = "https://management.azure.com/"; - -// Returns the URL only if it targets ARM, otherwise throws. Used by callers -// (e.g. install.mjs) that build ARM URLs before handing them here. -export function assertArmHost(rawUrl) { - const url = String(rawUrl); - if (!url.startsWith(ARM_BASE)) { - throw new Error(`Refusing to call non-ARM URL: ${url}`); - } - return url; -} - -async function armFetch(url, token) { - // Guard the exact value handed to fetch so a tainted path segment or a - // server-supplied nextLink can never redirect the call off ARM. - if (!url.startsWith(ARM_BASE)) { - throw new Error(`Refusing to call non-ARM URL: ${url}`); - } - const res = await fetch(url, { - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - }); - if (!res.ok) { - const body = await res.text(); - throw new Error(`ARM ${res.status}: ${body.slice(0, 300)}`); - } - return res.json(); -} - -// ARM normally returns distinct nextLink URLs that terminate, but a buggy or -// hostile endpoint could return a repeating/self-referential nextLink. Guard -// against an unbounded loop with a seen-set and a hard page cap. -const MAX_PAGES = 1000; - -async function paginateAll(url, token) { - const results = []; - const seen = new Set(); - let nextUrl = url; - let pages = 0; - while (nextUrl) { - if (seen.has(nextUrl) || pages >= MAX_PAGES) break; - seen.add(nextUrl); - pages++; - const data = await armFetch(nextUrl, token); - if (data.value) results.push(...data.value); - nextUrl = data.nextLink || null; - } - return results; -} - -/** - * List connector gateways in a subscription. - * Uses $top=10 and stops after the first page for speed. - * Pass fetchAll=true to paginate through everything. - */ -export async function listConnectorGateways(subscriptionId, { fetchAll = false } = {}) { - const token = await getToken(); - const url = `https://management.azure.com/subscriptions/${armSegment(subscriptionId)}/providers/Microsoft.Web/connectorGateways?api-version=${API_VERSION}&$top=10`; - if (fetchAll) return { items: await paginateAll(url, token), hasMore: false }; - // First page only — much faster - const data = await armFetch(url, token); - const items = data.value || []; - return { items, hasMore: !!data.nextLink }; -} - -/** - * List managed APIs (traditional connectors) - */ -export async function listManagedApis(subscriptionId, resourceGroup, gatewayName) { - const token = await getToken(); - const url = `${buildBaseUrl(subscriptionId, resourceGroup, gatewayName)}/managedApis?api-version=${API_VERSION}`; - return paginateAll(url, token); -} - -/** - * List managed hosted MCP servers - */ -export async function listManagedHostedMcpServers(subscriptionId, resourceGroup, gatewayName) { - const token = await getToken(); - const url = `${buildBaseUrl(subscriptionId, resourceGroup, gatewayName)}/managedHostedMcpServers?api-version=${API_VERSION}`; - return paginateAll(url, token); -} - -/** - * List managed MCP operations - */ -export async function listManagedMcpOperations(subscriptionId, resourceGroup, gatewayName) { - const token = await getToken(); - const url = `${buildBaseUrl(subscriptionId, resourceGroup, gatewayName)}/managedMcpOperations?api-version=${API_VERSION}`; - return paginateAll(url, token); -} - -// --------------------------------------------------------------------------- -// Create connector namespace (provisioning flow) -// --------------------------------------------------------------------------- - -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); - -// Write helper (PUT/PATCH/DELETE) that mirrors armFetch's host guard but keeps -// the parsed error body so callers can surface ARM's message verbatim. -async function armWrite(method, url, body, extraHeaders = {}) { - if (!url.startsWith(ARM_BASE)) { - throw new Error(`Refusing to call non-ARM URL: ${url}`); - } - const token = await getToken(); - const headers = { Authorization: `Bearer ${token}` }; - Object.assign(headers, extraHeaders); - if (body !== undefined) headers["Content-Type"] = "application/json"; - const res = await fetch(url, { - method, - headers, - body: body !== undefined ? JSON.stringify(body) : undefined, - }); - const text = await res.text(); - let parsed; - try { parsed = text ? JSON.parse(text) : undefined; } catch { parsed = text; } - if (!res.ok) { - const msg = parsed?.error?.message ?? parsed?.message ?? text ?? `HTTP ${res.status}`; - const err = new Error(`ARM ${method} ${res.status}: ${String(msg).slice(0, 300)}`); - err.status = res.status; - throw err; - } - return parsed; -} - -/** - * List resource groups in a subscription (sorted by name). - */ -export async function listResourceGroups(subscriptionId) { - const token = await getToken(); - const url = `https://management.azure.com/subscriptions/${armSegment(subscriptionId)}/resourcegroups?api-version=${RG_API_VERSION}`; - const items = await paginateAll(url, token); - return items - .map((rg) => ({ name: rg.name, location: rg.location })) - .sort((a, b) => a.name.localeCompare(b.name)); -} - -/** - * Create a resource group without updating an existing group on a name race. - */ -export async function createResourceGroup(subscriptionId, name, location) { - const url = `https://management.azure.com/subscriptions/${armSegment(subscriptionId)}/resourcegroups/${armSegment(name)}?api-version=${RG_API_VERSION}`; - return armWrite("PUT", url, { location }, { "If-None-Match": "*" }); -} - -/** - * List user-assigned managed identities across a subscription (sorted by name). - */ -export async function listUserAssignedIdentities(subscriptionId) { - const token = await getToken(); - const url = `https://management.azure.com/subscriptions/${armSegment(subscriptionId)}/providers/Microsoft.ManagedIdentity/userAssignedIdentities?api-version=${MSI_API_VERSION}`; - const items = await paginateAll(url, token); - return items - .map((id) => { - const parts = String(id.id).split("/"); - const rgIdx = parts.findIndex((p) => p.toLowerCase() === "resourcegroups"); - return { - id: id.id, - name: id.name, - resourceGroup: rgIdx >= 0 ? parts[rgIdx + 1] || "" : "", - location: id.location || "", - }; - }) - .sort((a, b) => a.name.localeCompare(b.name)); -} - -/** - * Check whether a connector namespace name is free in the given resource group. - * Returns true when available (ARM 404), false when taken (200). Uses fetch - * directly so the 404 isn't thrown the way armFetch would. - */ -export async function checkConnectorGatewayNameAvailable(subscriptionId, resourceGroup, gatewayName) { - const token = await getToken(); - const url = `${buildBaseUrl(subscriptionId, resourceGroup, gatewayName)}?api-version=${API_VERSION}`; - const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); - if (res.status === 404) return true; - if (res.ok) return false; - const body = await res.text(); - throw new Error(`ARM ${res.status}: ${body.slice(0, 200)}`); -} - -// ARM `identity` block — mirrors the portal's buildIdentityPayload so the PUT -// body is always explicit ({ type: "None" } when nothing is configured). -export function buildGatewayIdentity(enableSystem, userAssignedIds = []) { - const hasUser = userAssignedIds.length > 0; - const type = enableSystem && hasUser - ? "SystemAssigned,UserAssigned" - : enableSystem - ? "SystemAssigned" - : hasUser - ? "UserAssigned" - : "None"; - const identity = { type }; - if (hasUser) { - identity.userAssignedIdentities = Object.fromEntries(userAssignedIds.map((id) => [id, {}])); - } - return identity; -} - -export async function waitForProvisioning(initialResult, gatewayName, fetchLatest, { - maxPolls = 60, - delay = () => sleep(3000), -} = {}) { - let result = initialResult; - let state; - for (let poll = 0; poll <= maxPolls; poll++) { - state = result?.properties?.provisioningState; - if (state === "Succeeded") return result; - if (state === "Failed" || state === "Canceled") { - throw new Error(`Provisioning ${state} for "${gatewayName}".`); - } - if (poll === maxPolls) break; - await delay(); - result = await fetchLatest(); - } - throw new Error(`Provisioning timed out for "${gatewayName}" (last state: ${state ?? "unknown"}).`); -} - -/** - * Create a connector namespace and poll until the - * provisioningState reaches a terminal value. Throws on Failed/Canceled. - * Returns the final resource object. - */ -export async function createConnectorGateway(subscriptionId, resourceGroup, gatewayName, { location, identity }) { - const token = await getToken(); - const url = `${buildBaseUrl(subscriptionId, resourceGroup, gatewayName)}?api-version=${API_VERSION}`; - const body = { location, properties: {}, identity }; - const result = await armWrite("PUT", url, body, { "If-None-Match": "*" }); - // ~3 min ceiling (60 * 3s). A 202 may have no body, so every state other - // than explicit Succeeded enters the polling path. - return waitForProvisioning(result, gatewayName, () => armFetch(url, token)); -} diff --git a/extensions/connector-namespaces/extensions/assets/preview-connected.png b/extensions/connector-namespaces/extensions/assets/preview-connected.png deleted file mode 100644 index 4050d878..00000000 Binary files a/extensions/connector-namespaces/extensions/assets/preview-connected.png and /dev/null differ diff --git a/extensions/connector-namespaces/extensions/catalog.mjs b/extensions/connector-namespaces/extensions/catalog.mjs deleted file mode 100644 index 79f4165c..00000000 --- a/extensions/connector-namespaces/extensions/catalog.mjs +++ /dev/null @@ -1,70 +0,0 @@ -// Catalog — fetches MCP connectors from the gateway. -// -// The gateway exposes ~1600 managed APIs (the full Logic Apps connector -// catalog). MCP servers are a small subset (~43) and there is no `kind` or -// capability flag that distinguishes them. The only reliable signal is the -// string "mcp" appearing in the API's name OR its display name — and those are -// genuinely independent signals: `workiqsharepoint` has no "mcp" in its name -// (display name "Work IQ SharePoint MCP"), while `hginsightsmcp` has "mcp" in -// its name but a display name of "HG Insights Connect". Matching either keeps -// the full set without an allowlist that has to be hand-maintained. - -import { listManagedApis } from "./armClient.mjs"; -import { CATEGORY } from "./categories.mjs"; - -function isMcpServer(api) { - const name = api.name || ""; - const displayName = api.properties?.generalInformation?.displayName || ""; - return /mcp/i.test(name) || /mcp/i.test(displayName); -} - -// Microsoft first-party servers (a365*/d365*/workiq* names, or a Microsoft- -// branded display name) group under "Microsoft"; everything else is a partner -// server. Derived rather than hardcoded so new servers categorize themselves. -function categoryFor(name, displayName) { - const n = (name || "").toLowerCase(); - const d = (displayName || "").toLowerCase(); - const isMicrosoft = - /^(a365|d365|workiq)/.test(n) || - d.startsWith("microsoft") || - d.startsWith("work iq") || - d.startsWith("dynamics 365"); - return isMicrosoft ? CATEGORY.microsoft : CATEGORY.partner; -} - -let cachedCatalog = null; -let cacheKey = null; - -export function invalidateCache() { - cachedCatalog = null; - cacheKey = null; -} - -export async function fetchCatalog(subscriptionId, resourceGroup, gatewayName) { - const key = `${subscriptionId}/${resourceGroup}/${gatewayName}`; - if (cachedCatalog && cacheKey === key) return cachedCatalog; - - const apis = await listManagedApis(subscriptionId, resourceGroup, gatewayName); - - const catalog = apis - .filter(isMcpServer) - .map((a) => { - const props = a.properties || {}; - const general = props.generalInformation || {}; - const metadata = props.metadata || {}; - const displayName = general.displayName || a.name; - return { - id: a.name, - apiName: a.name, - displayName, - description: general.description || "", - iconUri: general.iconUri || "", - brandColor: metadata.brandColor || "", - category: categoryFor(a.name, displayName), - }; - }); - - cachedCatalog = catalog; - cacheKey = key; - return catalog; -} diff --git a/extensions/connector-namespaces/extensions/categories.mjs b/extensions/connector-namespaces/extensions/categories.mjs deleted file mode 100644 index 57cd9592..00000000 --- a/extensions/connector-namespaces/extensions/categories.mjs +++ /dev/null @@ -1,15 +0,0 @@ -// Catalog category values. -// -// `category` is a routing key, not display text: the renderer partitions catalog -// items into the Microsoft vs Partners sections by comparing against these exact -// values (see renderCatalogHtml in renderer.mjs). Kept as a frozen enum here, -// rather than free-form strings scattered across the producer, renderer, and -// tests, so the routing contract lives in one place. -// -// Zero-dependency on purpose: renderer.mjs imports this, and renderer.test.mjs -// loads renderer.mjs as a pure string-rendering gate. Sourcing the enum from -// catalog.mjs instead would drag armClient.mjs (the ARM SDK) into that gate. -export const CATEGORY = Object.freeze({ - microsoft: "Microsoft", - partner: "Partners", -}); diff --git a/extensions/connector-namespaces/LICENSE b/extensions/connector-namespaces/extensions/connector-namespaces/LICENSE similarity index 100% rename from extensions/connector-namespaces/LICENSE rename to extensions/connector-namespaces/extensions/connector-namespaces/LICENSE diff --git a/extensions/connector-namespaces/README.md b/extensions/connector-namespaces/extensions/connector-namespaces/README.md similarity index 100% rename from extensions/connector-namespaces/README.md rename to extensions/connector-namespaces/extensions/connector-namespaces/README.md diff --git a/extensions/connector-namespaces/armClient.mjs b/extensions/connector-namespaces/extensions/connector-namespaces/armClient.mjs similarity index 100% rename from extensions/connector-namespaces/armClient.mjs rename to extensions/connector-namespaces/extensions/connector-namespaces/armClient.mjs diff --git a/extensions/connector-namespaces/assets/preview-connected.png b/extensions/connector-namespaces/extensions/connector-namespaces/assets/preview-connected.png similarity index 100% rename from extensions/connector-namespaces/assets/preview-connected.png rename to extensions/connector-namespaces/extensions/connector-namespaces/assets/preview-connected.png diff --git a/extensions/connector-namespaces/extensions/assets/preview.png b/extensions/connector-namespaces/extensions/connector-namespaces/assets/preview.png similarity index 100% rename from extensions/connector-namespaces/extensions/assets/preview.png rename to extensions/connector-namespaces/extensions/connector-namespaces/assets/preview.png diff --git a/extensions/connector-namespaces/catalog.mjs b/extensions/connector-namespaces/extensions/connector-namespaces/catalog.mjs similarity index 100% rename from extensions/connector-namespaces/catalog.mjs rename to extensions/connector-namespaces/extensions/connector-namespaces/catalog.mjs diff --git a/extensions/connector-namespaces/categories.mjs b/extensions/connector-namespaces/extensions/connector-namespaces/categories.mjs similarity index 100% rename from extensions/connector-namespaces/categories.mjs rename to extensions/connector-namespaces/extensions/connector-namespaces/categories.mjs diff --git a/extensions/connector-namespaces/copilot-extension.json b/extensions/connector-namespaces/extensions/connector-namespaces/copilot-extension.json similarity index 100% rename from extensions/connector-namespaces/copilot-extension.json rename to extensions/connector-namespaces/extensions/connector-namespaces/copilot-extension.json diff --git a/extensions/connector-namespaces/createPage.mjs b/extensions/connector-namespaces/extensions/connector-namespaces/createPage.mjs similarity index 100% rename from extensions/connector-namespaces/createPage.mjs rename to extensions/connector-namespaces/extensions/connector-namespaces/createPage.mjs diff --git a/extensions/connector-namespaces/extension.mjs b/extensions/connector-namespaces/extensions/connector-namespaces/extension.mjs similarity index 100% rename from extensions/connector-namespaces/extension.mjs rename to extensions/connector-namespaces/extensions/connector-namespaces/extension.mjs diff --git a/extensions/connector-namespaces/extensions/install.mjs b/extensions/connector-namespaces/extensions/connector-namespaces/install.mjs similarity index 100% rename from extensions/connector-namespaces/extensions/install.mjs rename to extensions/connector-namespaces/extensions/connector-namespaces/install.mjs diff --git a/extensions/connector-namespaces/extensions/install.reauth.test.mjs b/extensions/connector-namespaces/extensions/connector-namespaces/install.reauth.test.mjs similarity index 100% rename from extensions/connector-namespaces/extensions/install.reauth.test.mjs rename to extensions/connector-namespaces/extensions/connector-namespaces/install.reauth.test.mjs diff --git a/extensions/connector-namespaces/extensions/install.test.mjs b/extensions/connector-namespaces/extensions/connector-namespaces/install.test.mjs similarity index 100% rename from extensions/connector-namespaces/extensions/install.test.mjs rename to extensions/connector-namespaces/extensions/connector-namespaces/install.test.mjs diff --git a/extensions/connector-namespaces/extensions/mcp-http-probe.test.mjs b/extensions/connector-namespaces/extensions/connector-namespaces/mcp-http-probe.test.mjs similarity index 100% rename from extensions/connector-namespaces/extensions/mcp-http-probe.test.mjs rename to extensions/connector-namespaces/extensions/connector-namespaces/mcp-http-probe.test.mjs diff --git a/extensions/connector-namespaces/extensions/package.json b/extensions/connector-namespaces/extensions/connector-namespaces/package.json similarity index 100% rename from extensions/connector-namespaces/extensions/package.json rename to extensions/connector-namespaces/extensions/connector-namespaces/package.json diff --git a/extensions/connector-namespaces/extensions/preview/.gitignore b/extensions/connector-namespaces/extensions/connector-namespaces/preview/.gitignore similarity index 100% rename from extensions/connector-namespaces/extensions/preview/.gitignore rename to extensions/connector-namespaces/extensions/connector-namespaces/preview/.gitignore diff --git a/extensions/connector-namespaces/extensions/preview/README.md b/extensions/connector-namespaces/extensions/connector-namespaces/preview/README.md similarity index 100% rename from extensions/connector-namespaces/extensions/preview/README.md rename to extensions/connector-namespaces/extensions/connector-namespaces/preview/README.md diff --git a/extensions/connector-namespaces/extensions/preview/fixtures.mjs b/extensions/connector-namespaces/extensions/connector-namespaces/preview/fixtures.mjs similarity index 100% rename from extensions/connector-namespaces/extensions/preview/fixtures.mjs rename to extensions/connector-namespaces/extensions/connector-namespaces/preview/fixtures.mjs diff --git a/extensions/connector-namespaces/extensions/preview/server.mjs b/extensions/connector-namespaces/extensions/connector-namespaces/preview/server.mjs similarity index 100% rename from extensions/connector-namespaces/extensions/preview/server.mjs rename to extensions/connector-namespaces/extensions/connector-namespaces/preview/server.mjs diff --git a/extensions/connector-namespaces/extensions/preview/shots.mjs b/extensions/connector-namespaces/extensions/connector-namespaces/preview/shots.mjs similarity index 100% rename from extensions/connector-namespaces/extensions/preview/shots.mjs rename to extensions/connector-namespaces/extensions/connector-namespaces/preview/shots.mjs diff --git a/extensions/connector-namespaces/extensions/renderer.mjs b/extensions/connector-namespaces/extensions/connector-namespaces/renderer.mjs similarity index 100% rename from extensions/connector-namespaces/extensions/renderer.mjs rename to extensions/connector-namespaces/extensions/connector-namespaces/renderer.mjs diff --git a/extensions/connector-namespaces/extensions/renderer.test.mjs b/extensions/connector-namespaces/extensions/connector-namespaces/renderer.test.mjs similarity index 100% rename from extensions/connector-namespaces/extensions/renderer.test.mjs rename to extensions/connector-namespaces/extensions/connector-namespaces/renderer.test.mjs diff --git a/extensions/connector-namespaces/extensions/review-fixes.test.mjs b/extensions/connector-namespaces/extensions/connector-namespaces/review-fixes.test.mjs similarity index 100% rename from extensions/connector-namespaces/extensions/review-fixes.test.mjs rename to extensions/connector-namespaces/extensions/connector-namespaces/review-fixes.test.mjs diff --git a/extensions/connector-namespaces/extensions/sandbox.mjs b/extensions/connector-namespaces/extensions/connector-namespaces/sandbox.mjs similarity index 100% rename from extensions/connector-namespaces/extensions/sandbox.mjs rename to extensions/connector-namespaces/extensions/connector-namespaces/sandbox.mjs diff --git a/extensions/connector-namespaces/extensions/sandbox.test.mjs b/extensions/connector-namespaces/extensions/connector-namespaces/sandbox.test.mjs similarity index 100% rename from extensions/connector-namespaces/extensions/sandbox.test.mjs rename to extensions/connector-namespaces/extensions/connector-namespaces/sandbox.test.mjs diff --git a/extensions/connector-namespaces/extensions/server.mjs b/extensions/connector-namespaces/extensions/connector-namespaces/server.mjs similarity index 100% rename from extensions/connector-namespaces/extensions/server.mjs rename to extensions/connector-namespaces/extensions/connector-namespaces/server.mjs diff --git a/extensions/connector-namespaces/extensions/server.test.mjs b/extensions/connector-namespaces/extensions/connector-namespaces/server.test.mjs similarity index 100% rename from extensions/connector-namespaces/extensions/server.test.mjs rename to extensions/connector-namespaces/extensions/connector-namespaces/server.test.mjs diff --git a/extensions/connector-namespaces/extensions/state.mjs b/extensions/connector-namespaces/extensions/connector-namespaces/state.mjs similarity index 100% rename from extensions/connector-namespaces/extensions/state.mjs rename to extensions/connector-namespaces/extensions/connector-namespaces/state.mjs diff --git a/extensions/connector-namespaces/extensions/state.test.mjs b/extensions/connector-namespaces/extensions/connector-namespaces/state.test.mjs similarity index 100% rename from extensions/connector-namespaces/extensions/state.test.mjs rename to extensions/connector-namespaces/extensions/connector-namespaces/state.test.mjs diff --git a/extensions/connector-namespaces/extensions/test/README.md b/extensions/connector-namespaces/extensions/connector-namespaces/test/README.md similarity index 100% rename from extensions/connector-namespaces/extensions/test/README.md rename to extensions/connector-namespaces/extensions/connector-namespaces/test/README.md diff --git a/extensions/connector-namespaces/extensions/test/mcp-probe.mjs b/extensions/connector-namespaces/extensions/connector-namespaces/test/mcp-probe.mjs similarity index 100% rename from extensions/connector-namespaces/extensions/test/mcp-probe.mjs rename to extensions/connector-namespaces/extensions/connector-namespaces/test/mcp-probe.mjs diff --git a/extensions/connector-namespaces/extensions/test/safe-tools.mjs b/extensions/connector-namespaces/extensions/connector-namespaces/test/safe-tools.mjs similarity index 100% rename from extensions/connector-namespaces/extensions/test/safe-tools.mjs rename to extensions/connector-namespaces/extensions/connector-namespaces/test/safe-tools.mjs diff --git a/extensions/connector-namespaces/extensions/test/safe-tools.test.mjs b/extensions/connector-namespaces/extensions/connector-namespaces/test/safe-tools.test.mjs similarity index 100% rename from extensions/connector-namespaces/extensions/test/safe-tools.test.mjs rename to extensions/connector-namespaces/extensions/connector-namespaces/test/safe-tools.test.mjs diff --git a/extensions/connector-namespaces/extensions/test/smoke.mjs b/extensions/connector-namespaces/extensions/connector-namespaces/test/smoke.mjs similarity index 100% rename from extensions/connector-namespaces/extensions/test/smoke.mjs rename to extensions/connector-namespaces/extensions/connector-namespaces/test/smoke.mjs diff --git a/extensions/connector-namespaces/extensions/copilot-extension.json b/extensions/connector-namespaces/extensions/copilot-extension.json deleted file mode 100644 index 3949dbe4..00000000 --- a/extensions/connector-namespaces/extensions/copilot-extension.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "connector-namespaces", - "version": 1 -} diff --git a/extensions/connector-namespaces/extensions/createPage.mjs b/extensions/connector-namespaces/extensions/createPage.mjs deleted file mode 100644 index 3eb86659..00000000 --- a/extensions/connector-namespaces/extensions/createPage.mjs +++ /dev/null @@ -1,403 +0,0 @@ -// Renderer for the "Create connector namespace" wizard page. Mirrors the -// portal's CreateConnectorGatewayPage: subscription -> resource group -// (existing or new) -> region -> name (live availability) -> managed identity -// (system + user-assigned) -> real ARM provisioning. - -import { baseStyles, brandMark } from "./renderer.mjs"; - -function esc(s) { - return String(s || "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); -} - -// Connector namespace regions — kept in sync with the portal's -// CONNECTOR_NAMESPACE_REGIONS list (constants.ts). -const REGIONS = [ - ["australiaeast", "Australia East"], ["brazilsouth", "Brazil South"], - ["canadacentral", "Canada Central"], ["canadaeast", "Canada East"], - ["centralindia", "Central India"], ["centralus", "Central US"], - ["eastasia", "East Asia"], ["eastus", "East US"], ["eastus2", "East US 2"], - ["francecentral", "France Central"], ["germanywestcentral", "Germany West Central"], - ["italynorth", "Italy North"], ["japaneast", "Japan East"], - ["koreacentral", "Korea Central"], ["northcentralus", "North Central US"], - ["northeurope", "North Europe"], ["norwayeast", "Norway East"], - ["polandcentral", "Poland Central"], ["southafricanorth", "South Africa North"], - ["southcentralus", "South Central US"], ["southindia", "South India"], - ["southeastasia", "Southeast Asia"], ["spaincentral", "Spain Central"], - ["swedencentral", "Sweden Central"], ["switzerlandnorth", "Switzerland North"], - ["uaenorth", "UAE North"], ["uksouth", "UK South"], - ["westcentralus", "West Central US"], ["westus2", "West US 2"], - ["westus3", "West US 3"], -]; - -const DEFAULT_REGION = "eastus"; - -export function renderCreateNamespaceHtml(subscriptions, preselectedSub = "", capabilityToken = "") { - const subOptions = subscriptions.map((s) => - `` - ).join(""); - - const regionOptions = REGIONS.map(([v, l]) => - `` - ).join(""); - - return ` - -Create Connector Namespace${baseStyles()} - - -
-

${brandMark(28, "create")}Create connector namespace

-
Provisions a real Azure connector namespace (Microsoft.Web/connectorGateways) in your subscription.
-
- -
- - -
- -
- -
- - -
- - -
Pick the resource group the namespace will live in.
-
- -
- - -
- -
- - -
-
- -
- -
- - -
-
- - -
- -
- -
- - -
- -
- -`; -} diff --git a/extensions/connector-namespaces/extensions/extension.mjs b/extensions/connector-namespaces/extensions/extension.mjs deleted file mode 100644 index fe6f502e..00000000 --- a/extensions/connector-namespaces/extensions/extension.mjs +++ /dev/null @@ -1,101 +0,0 @@ -// Canvas extension entry point — MCP Connectors browser. - -import { joinSession, createCanvas } from "@github/copilot-sdk/extension"; -import { getServerConfig, startServer, stopServer } from "./server.mjs"; -import { getSavedConfig, loadSavedConfig, saveConfig } from "./state.mjs"; -import { fetchCatalog } from "./catalog.mjs"; -import { getInstalledState, openInBrowser, setWorkspaceRoot } from "./install.mjs"; -import { buildSandboxUrl, resolveSandboxConnector } from "./sandbox.mjs"; - -// Load any previously saved connector namespace config on startup -loadSavedConfig(); - -async function openPlayground(server, instanceId) { - const config = instanceId ? getServerConfig(instanceId) : getSavedConfig(); - if (!config) return { opened: false, reason: "no_namespace_configured" }; - const catalog = await fetchCatalog(config.subscriptionId, config.resourceGroup, config.gatewayName); - const installedState = await getInstalledState(config); - const resolved = resolveSandboxConnector(catalog, installedState, server); - if (!resolved.connector) return { opened: false, ...resolved }; - const url = buildSandboxUrl(config, resolved.connector.id); - await openInBrowser(url); - return { opened: true, server: resolved.connector, url }; -} - -const session = await joinSession({ - tools: [ - { - name: "connector_namespaces_open_playground", - description: "Open a named connector from My MCPs in the Azure Connector Namespace playground.", - parameters: { - type: "object", - properties: { - server: { - type: "string", - description: "Connector display name or server ID from My MCPs", - }, - }, - required: ["server"], - }, - handler: async ({ server }) => JSON.stringify(await openPlayground(server)), - }, - ], - canvases: [ - createCanvas({ - id: "connector-namespaces", - displayName: "MCP Connectors", - description: "Browse, connect, and open MCP connectors in the Azure Connector Namespace Sandbox.", - inputSchema: { - type: "object", - properties: { - subscriptionId: { type: "string", description: "Azure subscription ID (optional \u2014 if omitted, uses saved config or shows picker)" }, - resourceGroup: { type: "string", description: "Resource group name" }, - gatewayName: { type: "string", description: "Connector namespace name" }, - }, - }, - actions: [ - { - name: "open_sandbox", - description: "Open a named connector from My MCPs in the Azure Connector Namespace Sandbox", - inputSchema: { - type: "object", - properties: { - server: { - type: "string", - description: "Connector display name or server ID from My MCPs", - }, - }, - required: ["server"], - }, - handler: async (ctx) => openPlayground(ctx.input.server, ctx.instanceId), - }, - ], - open: async (ctx) => { - let config; - // If explicit input provided, use it and save for future - if (ctx.input && ctx.input.subscriptionId && ctx.input.resourceGroup && ctx.input.gatewayName) { - config = { - subscriptionId: ctx.input.subscriptionId, - resourceGroup: ctx.input.resourceGroup, - gatewayName: ctx.input.gatewayName, - }; - saveConfig(config); - } - // A saved config seeds a new panel only. Rehydrating an existing - // panel keeps its active namespace even if another panel changed - // the persisted default. - const entry = await startServer( - ctx.instanceId, - config ? { config } : { defaultConfig: getSavedConfig() }, - ); - return { title: "MCP Connectors", url: entry.url }; - }, - onClose: async (ctx) => { - await stopServer(ctx.instanceId); - }, - }), - ], -}); - -// Tell the install pipeline where the workspace .mcp.json lives (if any). -setWorkspaceRoot(session.workspacePath); diff --git a/extensions/connector-namespaces/install.mjs b/extensions/connector-namespaces/install.mjs deleted file mode 100644 index 74264d6f..00000000 --- a/extensions/connector-namespaces/install.mjs +++ /dev/null @@ -1,1007 +0,0 @@ -// Install flow — creates connection, handles OAuth, creates MCP server config, -// mints API key, and writes to ~/.copilot/mcp-config.json. - -import { promises as fs } from "node:fs"; -import { spawn } from "node:child_process"; -import { randomBytes } from "node:crypto"; -import { homedir, platform } from "node:os"; -import { dirname, join } from "node:path"; -import { getToken, assertArmHost, armSegment, resolveSystemExecutable } from "./armClient.mjs"; - -const COPILOT_HOME = process.env.COPILOT_HOME || join(homedir(), ".copilot"); - -// Two scopes the Copilot CLI reads MCP servers from: -// profile -> ~/.copilot/mcp-config.json (private, follows you everywhere) -// workspace -> /.mcp.json (shared with the repo, git-tracked) -const PROFILE_MCP_PATH = join(COPILOT_HOME, "mcp-config.json"); -const PENDING_CLEANUP_DIR = join(COPILOT_HOME, "extensions", "connector-namespaces", "artifacts", "pending-cleanup"); -let s_workspaceRoot = null; - -export function setWorkspaceRoot(path) { - s_workspaceRoot = path || null; -} - -export function getWorkspaceRoot() { - return s_workspaceRoot; -} - -function mcpConfigPath(scope) { - if (scope === "workspace") { - if (!s_workspaceRoot) throw new Error("No workspace folder is available for this session."); - return join(s_workspaceRoot, ".mcp.json"); - } - return PROFILE_MCP_PATH; -} - -const CONFIG_LOCK_TIMEOUT_MS = 10_000; -const CONFIG_LOCK_STALE_MS = 30_000; - -// Serialize in-process writes, then hold an exclusive lock file so separate -// Copilot sessions cannot overwrite each other's MCP config changes. -let s_configLock = Promise.resolve(); -async function acquireConfigLock(path) { - const lockPath = `${path}.lock`; - await fs.mkdir(dirname(path), { recursive: true, mode: 0o700 }); - const deadline = Date.now() + CONFIG_LOCK_TIMEOUT_MS; - for (;;) { - const owner = `${process.pid}:${randomBytes(12).toString("hex")}\n`; - try { - const handle = await fs.open(lockPath, "wx", 0o600); - await handle.writeFile(owner, "utf8"); - return async () => { - await handle.close(); - try { - if (await fs.readFile(lockPath, "utf8") === owner) { - await fs.unlink(lockPath); - } - } catch (error) { - if (error?.code !== "ENOENT") throw error; - } - }; - } catch (error) { - if (error?.code !== "EEXIST") throw error; - try { - const stat = await fs.stat(lockPath); - if (Date.now() - stat.mtimeMs > CONFIG_LOCK_STALE_MS) { - const stalePath = `${lockPath}.${process.pid}.${randomBytes(6).toString("hex")}.stale`; - await fs.rename(lockPath, stalePath); - await fs.unlink(stalePath); - continue; - } - } catch (statError) { - if (statError?.code === "ENOENT") continue; - throw statError; - } - if (Date.now() >= deadline) { - throw new Error(`Timed out waiting for the MCP config lock at ${lockPath}.`); - } - await sleep(50); - } - } -} - -export async function waitForConnected(config, connName, options = {}) { - const maxPolls = options.maxPolls ?? 20; - const delay = options.delay ?? sleep; - const getStatus = options.getStatus ?? getConnectionStatus; - let status = "Unknown"; - for (let i = 0; i < maxPolls; i++) { - status = await getStatus(config, connName); - if (status === "Connected") return status; - if (i + 1 < maxPolls) await delay(1000); - } - throw new Error(`Connection ended in state "${status}".`); -} - -function withConfigLock(path, fn) { - const run = s_configLock.then(async () => { - const release = await acquireConfigLock(path); - try { - return await fn(); - } finally { - await release(); - } - }, async () => { - const release = await acquireConfigLock(path); - try { - return await fn(); - } finally { - await release(); - } - }); - s_configLock = run.then(() => {}, () => {}); - return run; -} - -async function readPendingCleanups(gateway, apiName) { - let names; - try { - names = await fs.readdir(PENDING_CLEANUP_DIR); - } catch (error) { - if (error?.code === "ENOENT") return []; - throw error; - } - - const records = []; - for (const name of names) { - if (!name.endsWith(".json")) continue; - const path = join(PENDING_CLEANUP_DIR, name); - let record; - try { - record = JSON.parse(await fs.readFile(path, "utf8")); - } catch (error) { - if (error?.code === "ENOENT") continue; - throw error; - } - if (!record || typeof record !== "object" || Array.isArray(record)) { - throw new Error("Pending connector cleanup data is invalid."); - } - if (record.gatewayId === gateway && record.apiName === apiName) { - records.push({ ...record, path }); - } - } - return records; -} - -async function getPendingCleanup(gateway, apiName) { - const records = await readPendingCleanups(gateway, apiName); - if (!records.length) return null; - return { - configNames: records.flatMap((record) => Array.isArray(record.configNames) ? record.configNames : []), - connectionNames: records.flatMap((record) => Array.isArray(record.connectionNames) ? record.connectionNames : []), - journalFiles: records.map((record) => record.path), - }; -} - -async function savePendingCleanup(record) { - await fs.mkdir(PENDING_CLEANUP_DIR, { recursive: true, mode: 0o700 }); - await fs.chmod(PENDING_CLEANUP_DIR, 0o700).catch(() => {}); - const id = randomBytes(16).toString("hex"); - const tempPath = join(PENDING_CLEANUP_DIR, `${id}.tmp`); - const path = join(PENDING_CLEANUP_DIR, `${id}.json`); - await fs.writeFile(tempPath, JSON.stringify(record, null, 2) + "\n", { encoding: "utf8", mode: 0o600, flag: "wx" }); - try { - await fs.chmod(tempPath, 0o600).catch(() => {}); - await fs.rename(tempPath, path); - return path; - } catch (error) { - try { - await fs.unlink(tempPath); - } catch (cleanupError) { - if (cleanupError?.code !== "ENOENT") { - throw new AggregateError([error, cleanupError], "Failed to save connector cleanup retry data."); - } - } - throw error; - } -} - -async function clearPendingCleanups(paths) { - for (const path of new Set(paths)) { - try { - await fs.unlink(path); - } catch (error) { - if (error?.code !== "ENOENT") throw error; - } - } -} - -// Validate the MCP endpoint URL before persisting it alongside an API key. The -// value comes from an authenticated ARM read of the user's own gateway, so this -// is defense in depth: require https, reject embedded credentials, and block -// obvious internal/link-local hosts. -export function assertSafeMcpTarget(rawUrl) { - let u; - try { u = new URL(rawUrl); } catch { throw new Error("MCP endpoint URL is not a valid URL."); } - if (u.protocol !== "https:") throw new Error("MCP endpoint URL must use https."); - if (u.username || u.password) throw new Error("MCP endpoint URL must not embed credentials."); - const host = u.hostname.replace(/^\[|\]$/g, "").toLowerCase(); - const isIpv6 = host.includes(":"); - const blocked = - host === "localhost" || host.endsWith(".localhost") || - host === "metadata.google.internal" || host === "0.0.0.0" || - (isIpv6 && (host === "::1" || host.startsWith("fe80:") || host.startsWith("fc") || host.startsWith("fd"))) || - /^(127|10)\./.test(host) || - /^169\.254\./.test(host) || - /^192\.168\./.test(host) || - /^172\.(1[6-9]|2\d|3[01])\./.test(host); - if (blocked) throw new Error(`MCP endpoint URL host is not allowed: ${host}`); -} - -// --------------------------------------------------------------------------- -// ARM helpers (using the shared token) -// --------------------------------------------------------------------------- - -// ARM occasionally answers with a transient 5xx/429 (backend blip, throttling) -// that clears on a retry. A single one of these shouldn't nuke a whole connect -// flow, so arm() retries them a few times with backoff before surfacing. -const ARM_TRANSIENT = new Set([429, 500, 502, 503, 504]); -const ARM_MAX_ATTEMPTS = 3; -const ARM_BACKOFF_MS = 500; - -async function arm(method, url, body) { - const token = await getToken(); - const headers = { Authorization: `Bearer ${token}`, Accept: "application/json" }; - if (body !== undefined) headers["Content-Type"] = "application/json"; - const fullUrl = url.startsWith("http") ? url : `https://management.azure.com${url}`; - // Guard the exact value handed to fetch so a tainted path segment can never - // redirect the call off ARM. assertArmHost throws unless fullUrl targets - // https://management.azure.com/. - const safeUrl = assertArmHost(fullUrl); - - for (let attempt = 1; ; attempt++) { - const res = await fetch(safeUrl, { - method, - headers, - body: body !== undefined ? JSON.stringify(body) : undefined, - }); - const text = await res.text(); - let parsed; - try { parsed = text ? JSON.parse(text) : undefined; } catch { parsed = text; } - if (res.ok) return parsed; - - const msg = parsed?.error?.message ?? parsed?.message ?? text ?? `HTTP ${res.status}`; - const err = new Error(`ARM ${method} ${res.status}: ${msg}`); - err.status = res.status; - - // Every ARM call we make is idempotent (GET/PUT/DELETE) or a list-shaped - // POST (listConsentLinks, listApiKey) that returns the same value on - // retry, so retrying a transient failure can't spawn duplicate side - // effects. A 500 is never treated like a 404 elsewhere, so a blip can't - // trigger teardown of a live resource. - if (!ARM_TRANSIENT.has(res.status) || attempt >= ARM_MAX_ATTEMPTS) throw err; - await sleep(ARM_BACKOFF_MS * Math.pow(3, attempt - 1)); // 0.5s, then 1.5s - } -} - -// DELETE that tolerates "already gone" (404) but surfaces every other failure -// instead of silently swallowing it. -async function armDelete(url) { - try { - return await arm("DELETE", url); - } catch (e) { - if (e.status === 404) return undefined; - throw e; - } -} - -// --------------------------------------------------------------------------- -// Naming helpers -// --------------------------------------------------------------------------- - -function shortId() { return randomBytes(3).toString("hex"); } -function sanitize(s) { return String(s).replace(/[^a-zA-Z0-9]+/g, "").slice(0, 24) || "x"; } -function generateName(displayName) { return `${sanitize(displayName)}-${shortId()}`; } -function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } - -// --------------------------------------------------------------------------- -// Connector metadata (connection parameters + agentic operation id) -// --------------------------------------------------------------------------- - -const MANAGED_API_VERSION = "2022-09-01-preview"; -const metaCache = new Map(); // apiName + swagger requirement -> Promise - -export function loadConnectorMeta(config, apiName, location, requireSwagger = true) { - const sub = armSegment(config.subscriptionId); - const cacheKey = `${sub}:${location}:${apiName}:${requireSwagger}`; - if (metaCache.has(cacheKey)) return metaCache.get(cacheKey); - const promise = (async () => { - const base = `/subscriptions/${sub}/providers/Microsoft.Web/locations/${armSegment(location)}/managedApis/${armSegment(apiName)}`; - const metaRequest = arm("GET", `${base}?api-version=${MANAGED_API_VERSION}`); - const swaggerRequest = requireSwagger - ? arm("GET", `${base}?api-version=${MANAGED_API_VERSION}&export=true`) - : Promise.resolve(undefined); - const [meta, swagger] = await Promise.all([metaRequest, swaggerRequest]); - return { - connectionParameters: meta?.properties?.connectionParameters ?? null, - connectionParameterSets: meta?.properties?.connectionParameterSets ?? null, - opId: swagger ? getMcpServerOperationId(swagger) : undefined, - }; - })(); - // Cache the in-flight promise so a fast Connect click reuses the prewarm - // fetch instead of starting a second swagger export. Evict on hard failure - // so a transient error doesn't poison the cache. - promise.catch(() => metaCache.delete(cacheKey)); - metaCache.set(cacheKey, promise); - return promise; -} - -// Fire-and-forget pre-warm so the slow swagger fetch happens while the user is -// reading the catalog, not when they click Connect. Concurrency is bounded so a -// large catalog (~43 MCP servers, each 2 ARM GETs) doesn't burst ~86 parallel -// requests on open and trip rate limits. Items are warmed in catalog order, so -// the servers nearest the top of the view warm first. -export function prewarmMeta(config, apiNames, location) { - Promise.resolve(location || getGatewayLocation(config)).then(async (loc) => { - const sub = armSegment(config.subscriptionId); - const pending = apiNames.filter((name) => !metaCache.has(`${sub}:${loc}:${name}:true`)); - const CONCURRENCY = 5; - let next = 0; - const worker = async () => { - while (next < pending.length) { - const apiName = pending[next++]; - await loadConnectorMeta(config, apiName, loc).catch(() => {}); - } - }; - const poolSize = Math.min(CONCURRENCY, pending.length); - await Promise.all(Array.from({ length: poolSize }, worker)); - }).catch(() => {}); -} - -function getMcpServerOperationId(swagger) { - if (!swagger?.paths) return undefined; - for (const methods of Object.values(swagger.paths)) { - if (!methods || typeof methods !== "object") continue; - const post = methods.post; - if (!post?.operationId) continue; - const tags = (post.tags ?? []).map((t) => String(t).toLowerCase()); - if (tags.includes("deprecated")) continue; - if (tags.includes("agentic")) return post.operationId; - } - return undefined; -} - -// Find the OAuth connection parameter name. The consent call 500s if we send a -// parameterName the connector doesn't declare, so derive it from metadata. -function findOAuthParam(meta, redirectUrl) { - const fallback = { parameterName: "token", redirectUrl }; - let params; - if (meta?.connectionParameterSets?.values?.length) { - params = meta.connectionParameterSets.values[0].parameters; - } else { - params = meta?.connectionParameters; - } - if (!params) return fallback; - for (const [name, param] of Object.entries(params)) { - if (param?.type === "oauthSetting" || param?.oAuthSettings) { - return { parameterName: name, redirectUrl }; - } - } - return fallback; -} - -// --------------------------------------------------------------------------- -// JWT decode (to get user oid/tid for access policy) -// --------------------------------------------------------------------------- - -function decodeJwtPayload(token) { - const parts = token.split("."); - if (parts.length < 2) throw new Error("Invalid JWT"); - const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); - const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4)); - return JSON.parse(Buffer.from(b64 + pad, "base64").toString("utf8")); -} - -async function getUserContext() { - const token = await getToken(); - const claims = decodeJwtPayload(token); - return { objectId: claims.oid, tenantId: claims.tid }; -} - -// --------------------------------------------------------------------------- -// Connection management -// --------------------------------------------------------------------------- - -function gatewayId(config) { - return `/subscriptions/${armSegment(config.subscriptionId)}/resourceGroups/${armSegment(config.resourceGroup)}/providers/Microsoft.Web/connectorGateways/${armSegment(config.gatewayName)}`; -} - -const API_VERSION = "2026-05-01-preview"; - -const s_locationCache = new Map(); // gatewayId -> location (immutable per gateway) - -export async function getGatewayLocation(config) { - const id = gatewayId(config); - const cached = s_locationCache.get(id); - if (cached) return cached; - const gw = await arm("GET", `${id}?api-version=${API_VERSION}`); - const loc = (gw.location ?? "").toLowerCase().replace(/\s+/g, ""); - if (loc) s_locationCache.set(id, loc); - return loc; -} - -export async function createConnection(config, apiName, displayName, location) { - const connName = generateName(displayName); - await arm("PUT", `${gatewayId(config)}/connections/${connName}?api-version=${API_VERSION}`, { - location, - properties: { displayName, connectorName: apiName }, - }); - // Grant current user access policy - try { - const { objectId, tenantId } = await getUserContext(); - await arm("PUT", `${gatewayId(config)}/connections/${connName}/accessPolicies/user-${shortId()}?api-version=${API_VERSION}`, { - location, - properties: { principal: { type: "ActiveDirectory", identity: { objectId, tenantId } } }, - }); - } catch { /* non-fatal */ } - return connName; -} - -export async function getConsentUrl(config, connName, callbackUrl, oauthParam) { - const param = oauthParam || { parameterName: "token", redirectUrl: callbackUrl }; - const res = await arm("POST", `${gatewayId(config)}/connections/${armSegment(connName)}/listConsentLinks?api-version=${API_VERSION}`, { - parameters: [{ parameterName: param.parameterName, redirectUrl: param.redirectUrl }], - }); - return res?.value?.[0]?.link || null; -} - -export async function getConnectionStatus(config, connName) { - const conn = await arm("GET", `${gatewayId(config)}/connections/${armSegment(connName)}?api-version=${API_VERSION}`); - return conn?.properties?.statuses?.[0]?.status ?? conn?.properties?.overallStatus ?? "Unknown"; -} - -export async function createMcpServerConfig(config, apiName, displayName, connName, location, opId, configName = generateName(displayName)) { - if (!opId) { - throw new Error(`Cannot configure "${displayName}" as an MCP server: no agentic operation was found in the connector's definition. The connector may not expose an MCP-streamable endpoint, or its swagger failed to load.`); - } - const created = await arm("PUT", `${gatewayId(config)}/mcpserverConfigs/${configName}?api-version=${API_VERSION}`, { - kind: "ManagedMcpServer", - location, - properties: { - description: displayName, - state: "Enabled", - disableApiKeyAuth: false, - // TextOnlyContent must stay false: when true the dataplane wraps tools/list and - // tools/call responses in a base64 "$content" envelope that spec-compliant MCP - // clients cannot parse, so zero tools load. - settings: { TextOnlyContent: false }, - connectors: [{ - name: apiName, - connectionName: connName, - displayName, - operations: [{ name: opId, displayName, description: "" }], - }], - }, - }); - return { configName, endpointUrl: created?.properties?.mcpEndpointUrl || null }; -} - -export async function mintApiKey(config, configName) { - const notAfter = new Date(Date.now() + 365 * 24 * 3600_000).toISOString(); - const res = await arm("POST", `${gatewayId(config)}/listApiKey?api-version=${API_VERSION}`, { - keyType: "Primary", - notAfter, - scope: configName, - }); - return res.key; -} - -export async function getMcpEndpointUrl(config, configName) { - const cfg = await arm("GET", `${gatewayId(config)}/mcpserverConfigs/${armSegment(configName)}?api-version=${API_VERSION}`); - return cfg?.properties?.mcpEndpointUrl || null; -} - -// --------------------------------------------------------------------------- -// MCP config writer -// --------------------------------------------------------------------------- - -async function readMcpConfigAt(path) { - try { - const raw = await fs.readFile(path, "utf8"); - let parsed = JSON.parse(raw); - // Reject arrays and primitives before treating this as a config object. - // JSON.parse can return either (a hand-edited "[]" or a bare number), - // and both break the write path: a string key set on an array is - // silently dropped by JSON.stringify (the new entry would vanish), and - // a primitive throws on property assignment. Fall back to a fresh - // object so writeMcpEntry always persists. - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) parsed = {}; - if (!parsed.mcpServers || typeof parsed.mcpServers !== "object" || Array.isArray(parsed.mcpServers)) parsed.mcpServers = {}; - return parsed; - } catch (e) { - if (e.code === "ENOENT") return { mcpServers: {} }; - throw e; - } -} - -async function writeMcpConfigAt(path, cfg) { - const directory = dirname(path); - const temporary = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`; - await fs.mkdir(directory, { recursive: true, mode: 0o700 }); - await fs.chmod(directory, 0o700).catch(() => {}); - try { - await fs.writeFile(temporary, JSON.stringify(cfg, null, 2) + "\n", { encoding: "utf8", mode: 0o600 }); - await fs.chmod(temporary, 0o600).catch(() => {}); - await fs.rename(temporary, path); - await fs.chmod(path, 0o600).catch(() => {}); - } finally { - await fs.unlink(temporary).catch((error) => { - if (error?.code !== "ENOENT") throw error; - }); - } -} - -export async function writeMcpEntry(name, url, key, scope = "profile", meta = null) { - assertSafeMcpTarget(url); - const path = mcpConfigPath(scope); - return withConfigLock(path, async () => { - const cfg = await readMcpConfigAt(path); - cfg.mcpServers[name] = { - url, - headers: { "X-API-Key": key }, - }; - // Stamp ARM provenance as a sibling metadata key. The underscore prefix - // marks it as "metadata, not part of the MCP launch spec" — the CLI - // tolerates and preserves unknown sibling keys across restarts. - if (meta) cfg.mcpServers[name]._connectorNamespace = meta; - await writeMcpConfigAt(path, cfg); - }); -} - -// Remove the entry from whichever scope(s) it lives in. -export async function removeMcpEntry(name) { - let removed = false; - for (const scope of ["profile", "workspace"]) { - let path; - try { path = mcpConfigPath(scope); } catch { continue; } - const removedAtPath = await withConfigLock(path, async () => { - const cfg = await readMcpConfigAt(path); - if (Object.prototype.hasOwnProperty.call(cfg.mcpServers, name)) { - delete cfg.mcpServers[name]; - await writeMcpConfigAt(path, cfg); - return true; - } - return false; - }); - removed ||= removedAtPath; - } - return removed; -} - -async function deleteMcpServerConfigs(config, configNames) { - for (const configName of configNames) { - const url = `${gatewayId(config)}/mcpserverConfigs/${armSegment(configName)}?api-version=${API_VERSION}`; - await armDelete(url); - let pending = true; - for (let i = 0; i < 20; i++) { - try { - await arm("GET", url); - } catch (error) { - if (error?.status === 404) { - pending = false; - break; - } - throw error; - } - await sleep(750); - } - if (pending) throw new Error(`Timed out waiting for connector configuration "${configName}" deletion.`); - } -} - -async function cleanupConnectorResources(config, apiName, configNames, connectionNames, priorJournalFiles = []) { - configNames = [...new Set(configNames.filter(Boolean))]; - connectionNames = [...new Set(connectionNames.filter(Boolean))]; - const gateway = gatewayId(config); - const journalFile = await savePendingCleanup({ gatewayId: gateway, apiName, configNames, connectionNames }); - const journalFiles = [...priorJournalFiles, journalFile]; - - await deleteMcpServerConfigs(config, configNames); - for (const connectionName of connectionNames) { - await armDelete(`${gateway}/connections/${armSegment(connectionName)}?api-version=${API_VERSION}`); - } - for (const configName of configNames) { - await removeMcpEntry(configName); - } - await clearPendingCleanups(journalFiles); -} - -// Remove an installed connector: delete its mcpserverConfig, its connection, -// and its CLI entry. apiName is resolved against the current installed state. -export async function uninstallConnector(config, apiName) { - const state = await getInstalledState(config); - const entry = state[apiName]; - const gateway = gatewayId(config); - const pending = await getPendingCleanup(gateway, apiName); - if (!entry && !pending) return { ok: true, removed: false }; - - const candidates = entry ? (entry._candidates ?? [entry]) : []; - const configNames = [ - ...(pending?.configNames ?? []), - ...candidates.map((candidate) => candidate.configName), - ]; - const connectionNames = [ - ...(pending?.connectionNames ?? []), - ...candidates.map((candidate) => candidate.connectionName), - ]; - await cleanupConnectorResources(config, apiName, configNames, connectionNames, pending?.journalFiles); - - return { ok: true, removed: true }; -} - -// Local-only remove: drop just the CLI mcp entry, leaving the namespace -// resources (mcpserverConfig + connection) intact. This is the default -// "Remove" action — it unwires the connector from Copilot without deleting -// anything on Azure. Fast and local; no armDelete, no convergence poll. -export async function removeLocalEntry(config, apiName) { - const state = await getInstalledState(config); - const entry = state[apiName]; - if (!entry) return { ok: true, removed: false }; - const candidates = entry._candidates ?? [entry]; - for (const candidate of candidates) { - if (candidate.inCli && candidate.configName) await removeMcpEntry(candidate.configName); - } - return { ok: true, removed: true }; -} - -// Best-effort rollback of a connection created during an install the user then -// cancelled. At that point no mcpserverConfig exists yet, so uninstallConnector -// can't see it — delete the orphaned connection directly so the tile honestly -// returns to "Connect" and we don't leak a half-made connection on the namespace. -export async function deleteConnection(config, connName) { - if (!connName) return { ok: true, removed: false }; - await armDelete(`${gatewayId(config)}/connections/${armSegment(connName)}?api-version=${API_VERSION}`); - return { ok: true, removed: true }; -} - -async function throwAfterCleanup(error, cleanups) { - for (const cleanup of cleanups) { - try { - await cleanup(); - } catch (cleanupError) { - throw new AggregateError( - [error, cleanupError], - `${error.message} Cleanup also failed: ${cleanupError.message}`, - ); - } - } - throw error; -} - -// --------------------------------------------------------------------------- -// Full install pipeline -// --------------------------------------------------------------------------- - -function oauthCallbackUrl(callbackBase, connName, capabilityToken = "") { - const url = new URL(`${callbackBase}${encodeURIComponent(connName)}`); - if (capabilityToken) { - url.searchParams.set("cn_token", capabilityToken); - } - return url.toString(); -} - -export async function installConnector(config, apiName, displayName, callbackBase, scope = "profile", capabilityToken = "") { - const pending = await getPendingCleanup(gatewayId(config), apiName); - if (pending) { - await cleanupConnectorResources( - config, - apiName, - pending.configNames, - pending.connectionNames, - pending.journalFiles, - ); - } - const location = await getGatewayLocation(config); - const meta = await loadConnectorMeta(config, apiName, location); - - // 1. Create connection - const connName = await createConnection(config, apiName, displayName, location); - let finishStarted = false; - try { - // The OAuth redirect must carry the connName so the loopback callback keys - // pendingOAuth by the same value the client polls on. - const callbackUrl = oauthCallbackUrl(callbackBase, connName, capabilityToken); - - // 2. Quick wait for the connection to converge — some connectors come up - // Connected without any OAuth (e.g. service principal / key based). - await sleep(800); - const status = await getConnectionStatus(config, connName); - if (status === "Connected") { - finishStarted = true; - return await finishInstall(config, apiName, displayName, connName, location, scope); - } - - // 3. Needs OAuth — derive the correct consent parameter from metadata. - const oauthParam = findOAuthParam(meta, callbackUrl); - const consentUrl = await getConsentUrl(config, connName, callbackUrl, oauthParam); - if (consentUrl) { - return { needsConsent: true, consentUrl, connName, location, freshConnection: true }; - } - - // 4. No consent link and not Connected — try to finish anyway. - finishStarted = true; - return await finishInstall(config, apiName, displayName, connName, location, scope); - } catch (error) { - if (finishStarted) throw error; - return throwAfterCleanup(error, [() => deleteConnection(config, connName)]); - } -} - -export async function finishInstall(config, apiName, displayName, connName, location, scope = "profile") { - let configName; - try { - if (!location) location = await getGatewayLocation(config); - const meta = await loadConnectorMeta(config, apiName, location); - - // Poll connection status up to ~20s for Connected. - const status = await waitForConnected(config, connName); - - // Create MCP server config (endpoint URL comes back on the PUT response). - let endpointUrl; - configName = generateName(displayName); - ({ endpointUrl } = await createMcpServerConfig(config, apiName, displayName, connName, location, meta.opId, configName)); - - // Endpoint URL can lag — poll the config a few times if missing. - for (let i = 0; !endpointUrl && i < 5; i++) { - await sleep(1000); - endpointUrl = await getMcpEndpointUrl(config, configName); - } - if (!endpointUrl) throw new Error(`MCP endpoint URL not available (connection status: ${status}).`); - - // Mint key and write the CLI entry, stamped with ARM provenance so the - // install can be recognised regardless of which connector namespace is - // active when state is next derived. - const key = await mintApiKey(config, configName); - const gw = gatewayId(config); - await writeMcpEntry(configName, endpointUrl, key, scope, { - gatewayId: gw, - mcpServerConfigId: `${gw}/mcpserverConfigs/${armSegment(configName)}`, - connectionId: `${gw}/connections/${armSegment(connName)}`, - apiName, - }); - - return { ok: true, configName, connName, endpointUrl, scope }; - } catch (error) { - const cleanups = []; - if (configName) { - cleanups.push(() => deleteMcpServerConfigs(config, [configName])); - } - cleanups.push(() => deleteConnection(config, connName)); - return throwAfterCleanup(error, cleanups); - } -} - -// --------------------------------------------------------------------------- -// Re-authenticate pipeline (reuse the EXISTING connection + config) -// --------------------------------------------------------------------------- - -// Re-run consent for a connector that's already installed, WITHOUT minting a new -// connection or a new mcpserverConfig. This is what the "Re-authenticate" button -// hits; wiring it to the plain install path is exactly what spawned duplicate -// configs. We resolve the selected install-state candidate (post phase-1 -// selection, that's the config the local session actually points at), re-consent -// its existing connection, and rebind THAT config locally. -// -// Falls back to a fresh installConnector only when there's genuinely nothing to -// re-auth against: no known connection, or the stored connection was deleted -// server-side (listConsentLinks 404s). In the 404 case we drop the orphaned -// config + local entry first so the fallback install can't leave a duplicate. -export async function reauthConnector(config, apiName, displayName, callbackBase, scope = "profile", capabilityToken = "") { - return reauthConnectorWithAttempts(config, apiName, displayName, callbackBase, scope, capabilityToken, new Set()); -} - -async function reauthConnectorWithAttempts(config, apiName, displayName, callbackBase, scope, capabilityToken, attemptedConfigNames) { - const state = await getInstalledState(config); - const selected = state[apiName]; - const candidates = selected?._candidates ?? (selected ? [selected] : []); - const entry = candidates.find((candidate) => !attemptedConfigNames.has(candidate.configName)); - const connName = entry?.connectionName; - - // Nothing installed to re-auth against — treat it as a first-time Connect. - if (!connName) { - return installConnector(config, apiName, displayName, callbackBase, scope, capabilityToken); - } - - const location = await getGatewayLocation(config); - const meta = await loadConnectorMeta(config, apiName, location, false); - const callbackUrl = oauthCallbackUrl(callbackBase, connName, capabilityToken); - const oauthParam = findOAuthParam(meta, callbackUrl); - - let consentUrl; - try { - consentUrl = await getConsentUrl(config, connName, callbackUrl, oauthParam); - } catch (err) { - // The stored connection is gone (deleted in the portal). Clean up the - // now-orphaned config + local entry, then fall through to a clean - // install so we don't strand a dead "Re-authenticate" tile. - if (err.status === 404) { - attemptedConfigNames.add(entry.configName); - const siblingUsesConnection = candidates.some( - (candidate) => candidate.configName !== entry.configName && candidate.connectionName === connName, - ); - await cleanupConnectorResources( - config, - apiName, - entry.configName ? [entry.configName] : [], - siblingUsesConnection ? [] : [connName], - ); - return reauthConnectorWithAttempts( - config, - apiName, - displayName, - callbackBase, - scope, - capabilityToken, - attemptedConfigNames, - ); - } - throw err; - } - - // Re-consent the existing connection; finish rebinds the SAME config. - // configName is carried through so the finish step never creates a new one. - if (consentUrl) { - return { needsConsent: true, consentUrl, connName, location, configName: entry.configName, reauth: true, freshConnection: false }; - } - - // Already consentable without a redirect — just rebind the existing config. - return finishReauth(config, apiName, displayName, connName, entry.configName, location, scope); -} - -// Finish a re-auth: rebind an EXISTING mcpserverConfig to the local CLI. Unlike -// finishInstall this never calls createMcpServerConfig, so a re-auth can't spawn -// a duplicate — it reuses configName, mints a fresh key, and rewrites the entry. -export async function finishReauth(config, apiName, displayName, connName, configName, location, scope = "profile") { - // Defensive: with no config to bind there's nothing to reuse — fall back to - // a normal finish (which creates one). Shouldn't happen on the reauth path. - if (!configName) { - return finishInstall(config, apiName, displayName, connName, location, scope); - } - - // Wait for the re-consented connection to converge (up to ~20s). - const status = await waitForConnected(config, connName); - - // Reuse the existing config's endpoint — poll a few times if it lags. - let endpointUrl = await getMcpEndpointUrl(config, configName); - for (let i = 0; !endpointUrl && i < 5; i++) { - await sleep(1000); - endpointUrl = await getMcpEndpointUrl(config, configName); - } - if (!endpointUrl) throw new Error(`MCP endpoint URL not available (connection status: ${status}).`); - - const key = await mintApiKey(config, configName); - const gw = gatewayId(config); - await writeMcpEntry(configName, endpointUrl, key, scope, { - gatewayId: gw, - mcpServerConfigId: `${gw}/mcpserverConfigs/${armSegment(configName)}`, - connectionId: `${gw}/connections/${armSegment(connName)}`, - apiName, - }); - - return { ok: true, configName, connName, endpointUrl, scope, reauth: true }; -} - -// --------------------------------------------------------------------------- -// Installed-state derivation (source of truth = the gateway + CLI config) -// --------------------------------------------------------------------------- - -export async function getInstalledState(config) { - const wsPath = s_workspaceRoot ? join(s_workspaceRoot, ".mcp.json") : null; - const [configsRes, connectionsRes, profileCfg, workspaceCfg] = await Promise.all([ - arm("GET", `${gatewayId(config)}/mcpserverConfigs?api-version=${API_VERSION}`), - arm("GET", `${gatewayId(config)}/connections?api-version=${API_VERSION}`), - readMcpConfigAt(PROFILE_MCP_PATH), - wsPath ? readMcpConfigAt(wsPath) : Promise.resolve({ mcpServers: {} }), - ]); - - const connByName = new Map(); - for (const c of connectionsRes.value ?? []) connByName.set(c.name, c); - - const profileKeys = new Set(Object.keys(profileCfg.mcpServers ?? {})); - const workspaceKeys = new Set(Object.keys(workspaceCfg.mcpServers ?? {})); - - return deriveInstalledState(configsRes.value ?? [], connByName, profileKeys, workspaceKeys, wsPath); -} - -// Pure derivation, split out so it can be unit-tested without live ARM. -// A single apiName can have MULTIPLE gateway configs (a portal-side add, a -// duplicate Connect, a re-auth that minted a fresh config). Collect every -// config per apiName, then pick ONE deterministically instead of letting ARM -// list order decide (last-wins) — that overwrite is what stranded a tile on -// "Re-authenticate" while a sibling config was actually Connected. -export function deriveInstalledState(configs, connByName, profileKeys, workspaceKeys, wsPath) { - const candidatesByApi = {}; - for (const cfg of configs ?? []) { - const connector = cfg.properties?.connectors?.[0]; - const apiName = connector?.name; - if (!apiName) continue; - const connName = connector?.connectionName; - const conn = connName ? connByName.get(connName) : null; - const connectionStatus = conn?.properties?.statuses?.[0]?.status ?? conn?.properties?.overallStatus ?? "Unknown"; - const inWorkspace = workspaceKeys.has(cfg.name); - const inProfile = profileKeys.has(cfg.name); - (candidatesByApi[apiName] ??= []).push({ - installed: true, - configName: cfg.name, - connectionName: connName || null, - connectionStatus, - inCli: inProfile || inWorkspace, - cliScope: inWorkspace ? "workspace" : (inProfile ? "profile" : null), - cliPath: inWorkspace ? wsPath : (inProfile ? PROFILE_MCP_PATH : null), - }); - } - - // Prefer the config the local session actually points at, and prefer a - // Connected one: inCli && Connected > inCli > Connected > any. Config name - // breaks ties so ARM list order cannot change the selected resource. Keeps the flat - // one-entry-per-apiName shape the renderer + tests expect. - const rank = (e) => (e.inCli ? 2 : 0) + (e.connectionStatus === "Connected" ? 1 : 0); - const byApi = {}; - for (const [apiName, list] of Object.entries(candidatesByApi)) { - list.sort((a, b) => rank(b) - rank(a) || a.configName.localeCompare(b.configName)); - const best = list[0]; - // Internal-only signal for logging; the renderer ignores unknown fields. - byApi[apiName] = list.length > 1 ? { ...best, _configCount: list.length, _candidates: list } : best; - } - return byApi; -} - -// --------------------------------------------------------------------------- -// Browser opener -// --------------------------------------------------------------------------- - -async function launchDetached(command, args) { - await new Promise((resolve, reject) => { - const child = spawn(command, args, { detached: true, stdio: "ignore" }); - child.once("error", reject); - child.once("spawn", () => { - child.unref(); - resolve(); - }); - }); -} - -export async function openInBrowser(url) { - // Only ever hand an http(s) URL to the OS shell — guards against the - // consent URL being anything that could be reinterpreted as a command. - let safe; - try { - const u = new URL(url); - if (u.protocol !== "http:" && u.protocol !== "https:") return; - safe = u.toString(); - } catch { - return; - } - const p = platform(); - if (p === "win32") { - // rundll32 hands the URL to the default protocol handler as a single - // literal argv with no shell parsing — avoids cmd.exe `start` metachar - // and quoting pitfalls. - await launchDetached(await resolveSystemExecutable("rundll32.exe"), ["url.dll,FileProtocolHandler", safe]); - } else if (p === "darwin") { - await launchDetached(await resolveSystemExecutable("open"), [safe]); - } else { - await launchDetached(await resolveSystemExecutable("xdg-open"), [safe]); - } -} - -// --------------------------------------------------------------------------- -// Config file opener -// --------------------------------------------------------------------------- - -// Hand a local file path to the OS so it opens in the user's default handler -// for that type (typically their editor for .json). Single literal argv on -// every platform — no shell, so a path with spaces or metachars is safe. -async function openPath(filePath) { - const p = platform(); - if (p === "win32") { - // FileProtocolHandler also accepts plain file paths and routes them to - // the registered default app, same no-shell guarantee as openInBrowser. - await launchDetached(await resolveSystemExecutable("rundll32.exe"), ["url.dll,FileProtocolHandler", filePath]); - } else if (p === "darwin") { - await launchDetached(await resolveSystemExecutable("open"), [filePath]); - } else { - await launchDetached(await resolveSystemExecutable("xdg-open"), [filePath]); - } -} - -// Open the MCP config this canvas writes to (the profile scope — -// ~/.copilot/mcp-config.json). Creates an empty, correctly-shaped config if -// none exists yet so the editor never opens a missing file. Returns the path -// either way so the UI can show where it lives even if the OS open is a no-op. -export async function openMcpConfigFile() { - const path = PROFILE_MCP_PATH; - try { - await fs.access(path); - } catch { - try { - await fs.mkdir(dirname(path), { recursive: true }); - await fs.writeFile(path, JSON.stringify({ mcpServers: {} }, null, 2) + "\n", { encoding: "utf8", mode: 0o600 }); - await fs.chmod(path, 0o600).catch(() => {}); - } catch (err) { - return { ok: false, path, error: err.message }; - } - } - await openPath(path); - return { ok: true, path }; -} diff --git a/extensions/connector-namespaces/install.reauth.test.mjs b/extensions/connector-namespaces/install.reauth.test.mjs deleted file mode 100644 index 50da0470..00000000 --- a/extensions/connector-namespaces/install.reauth.test.mjs +++ /dev/null @@ -1,558 +0,0 @@ -// Phase 2 regression: Re-authenticate must re-consent the EXISTING connection and -// mint NO new resources. -// -// Before the fix, the "Re-authenticate" button ran the full install path, so it -// created a fresh connection + a fresh mcpserverConfig on every click. A teammate -// saw a new Dynamics config appear on the namespace each time they re-authed, while -// the panel stayed stuck on "Re-authenticate". This test stubs ARM and proves -// reauthConnector adopts the local session's connection and issues ZERO PUTs. -// -// Run: node --test extensions/connector-namespaces/install.reauth.test.mjs - -import { test, after } from "node:test"; -import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; -import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; -import { delimiter, join } from "node:path"; -import { tmpdir } from "node:os"; - -// Isolate COPILOT_HOME before importing install.mjs because its paths are bound at -// module-eval time. Put a fake Azure CLI on PATH so getToken() stays offline, and -// seed a profile config so the local entry reads as inCli. -const TMP = mkdtempSync(join(tmpdir(), "cn-reauth-")); -process.env.COPILOT_HOME = TMP; -process.env.USERPROFILE = TMP; // homedir() on Windows -process.env.HOME = TMP; // homedir() on posix - -const binDir = join(TMP, "bin"); -mkdirSync(binDir, { recursive: true }); -const tokenJson = JSON.stringify({ accessToken: "fake-token", expires_on: Math.floor(Date.now() / 1000) + 3600 }); -writeFileSync(join(binDir, "az"), `#!/usr/bin/env node\nprocess.stdout.write(${JSON.stringify(tokenJson)});\n`); -chmodSync(join(binDir, "az"), 0o755); -writeFileSync(join(binDir, "az.cmd"), `@echo ${tokenJson}\r\n`); -process.env.PATH = `${binDir}${delimiter}${process.env.PATH || ""}`; - -const legacyAuthCache = join(TMP, "extensions", "connector-namespaces", "artifacts", "auth-cache.json"); -mkdirSync(join(TMP, "extensions", "connector-namespaces", "artifacts"), { recursive: true }); -writeFileSync(legacyAuthCache, JSON.stringify({ accessToken: "legacy", refreshToken: "legacy" })); - -writeFileSync( - join(TMP, "mcp-config.json"), - JSON.stringify({ mcpServers: { "docusign-bbb": { type: "http", url: "https://example/mcp" } } }), -); - -// Dynamic import AFTER the env is set. A static top-level import would be hoisted -// and evaluate install.mjs (binding the paths to the real home) before the env -// assignments run. -const { - deleteConnection, - finishInstall, - getInstalledState, - installConnector, - loadConnectorMeta, - reauthConnector, - removeMcpEntry, - uninstallConnector, -} = await import("./install.mjs"); - -after(() => { - try { - rmSync(TMP, { recursive: true, force: true }); - } catch { - /* best-effort temp cleanup */ - } -}); - -test("re-authenticate re-consents the existing connection and mints no new resources", async (t) => { - const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" }; - - // Two configs for one apiName — the bug scenario. configA is a portal-added - // sibling that is NOT in the local CLI; configB is the one the local session - // points at. Both connections are Connected, so selection turns on inCli: - // deriveInstalledState must pick configB, and the re-consent must target conn-b. - const configA = { name: "docusign-aaa", properties: { connectors: [{ name: "docusign", connectionName: "conn-a" }] } }; - const configB = { name: "docusign-bbb", properties: { connectors: [{ name: "docusign", connectionName: "conn-b" }] } }; - const connA = { name: "conn-a", properties: { statuses: [{ status: "Connected" }] } }; - const connB = { name: "conn-b", properties: { statuses: [{ status: "Connected" }] } }; - - const calls = []; - const realFetch = globalThis.fetch; - globalThis.fetch = async (urlArg, opts = {}) => { - const url = String(urlArg); - const method = (opts.method || "GET").toUpperCase(); - calls.push({ method, url }); - const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) }); - - if (method === "POST" && url.includes("/listConsentLinks")) return ok({ value: [{ link: "https://consent.example/redir" }] }); - if (url.includes("/managedApis/") && !url.includes("export=true")) { - return ok({ properties: { connectionParameters: { token: { type: "oauthSetting" } } } }); - } - if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: [configA, configB] }); - if (method === "GET" && /\/connections\?/.test(url)) return ok({ value: [connA, connB] }); - if (method === "GET" && /\/connectorGateways\/[^/?]+\?/.test(url)) return ok({ location: "eastus" }); - throw new Error(`unexpected ARM call: ${method} ${url}`); - }; - t.after(() => { - globalThis.fetch = realFetch; - }); - - const result = await reauthConnector(config, "docusign", "DocuSign", "https://cb/?c="); - assert.equal(existsSync(legacyAuthCache), false, "the legacy refresh-token cache must be removed without reading it"); - - // Adopts the existing connection, stops at consent, carries the selected config - // through so finish never mints a new one. - assert.equal(result.needsConsent, true); - assert.equal(result.reauth, true); - assert.equal(result.freshConnection, false); - assert.equal(result.connName, "conn-b"); // the inCli config's connection - assert.equal(result.configName, "docusign-bbb"); // never a fresh generateName() - - // The core guarantee: nothing was minted. createConnection and - // createMcpServerConfig are the only PUTs on the install path; re-auth issues none. - const puts = calls.filter((c) => c.method === "PUT"); - assert.deepEqual(puts, [], `expected zero PUTs, saw: ${puts.map((p) => p.url).join(", ")}`); - - // And it re-consented the SELECTED connection, not the portal sibling. - const consent = calls.find((c) => c.url.includes("/listConsentLinks")); - assert.ok(consent && consent.url.includes("/connections/conn-b/"), "consent must target conn-b"); - assert.ok( - !calls.some((c) => c.url.includes("/connections/conn-a/listConsentLinks")), - "must not touch the sibling connection conn-a", - ); - assert.ok(!calls.some((c) => c.url.includes("export=true")), "reauth must not request unused swagger"); -}); - -test("missing selected connection re-evaluates a valid duplicate before installing", async (t) => { - const configPath = join(TMP, "mcp-config.json"); - writeFileSync( - configPath, - JSON.stringify({ mcpServers: { "api-dead": { type: "http", url: "https://example.com/mcp" } } }), - ); - const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" }; - const dead = { name: "api-dead", properties: { connectors: [{ name: "shared-api", connectionName: "conn-dead" }] } }; - const live = { name: "api-live", properties: { connectors: [{ name: "shared-api", connectionName: "conn-live" }] } }; - const calls = []; - const realFetch = globalThis.fetch; - globalThis.fetch = async (urlArg, opts = {}) => { - const url = String(urlArg); - const method = (opts.method || "GET").toUpperCase(); - calls.push({ method, url }); - const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) }); - if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: [dead, live] }); - if (method === "GET" && /\/connections\?/.test(url)) { - return ok({ value: [ - { name: "conn-dead", properties: { statuses: [{ status: "Unknown" }] } }, - { name: "conn-live", properties: { statuses: [{ status: "Connected" }] } }, - ] }); - } - if (method === "GET" && /\/connectorGateways\/[^/?]+\?/.test(url)) return ok({ location: "eastus" }); - if (method === "GET" && url.includes("/managedApis/shared-api")) { - return ok({ properties: { connectionParameters: { token: { type: "oauthSetting" } } } }); - } - if (method === "POST" && url.includes("/connections/conn-dead/listConsentLinks")) { - return { ok: false, status: 404, text: async () => "gone" }; - } - if (method === "POST" && url.includes("/connections/conn-live/listConsentLinks")) { - return ok({ value: [{ link: "https://consent.example/live" }] }); - } - if (method === "DELETE" && url.includes("/mcpserverConfigs/api-dead")) return ok({}); - if (method === "GET" && url.includes("/mcpserverConfigs/api-dead")) { - return { ok: false, status: 404, text: async () => "gone" }; - } - if (method === "DELETE" && url.includes("/connections/conn-dead")) return ok({}); - throw new Error(`unexpected ARM call: ${method} ${url}`); - }; - t.after(() => { - globalThis.fetch = realFetch; - writeFileSync(configPath, JSON.stringify({ mcpServers: {} })); - }); - - const result = await reauthConnector(config, "shared-api", "Shared API", "https://cb/?c="); - assert.equal(result.needsConsent, true); - assert.equal(result.configName, "api-live"); - assert.equal(result.connName, "conn-live"); - assert.ok(calls.some((call) => call.url.includes("/connections/conn-dead/listConsentLinks"))); - assert.ok(calls.some((call) => call.url.includes("/connections/conn-live/listConsentLinks"))); - assert.equal(calls.some((call) => call.method === "PUT"), false, "valid siblings must prevent a fresh install"); -}); - -test("cross-process MCP config writes preserve every entry", async () => { - const configPath = join(TMP, "mcp-config.json"); - writeFileSync(configPath, JSON.stringify({ mcpServers: {} })); - const installUrl = new URL("./install.mjs", import.meta.url).href; - const names = Array.from({ length: 8 }, (_, index) => `parallel-${index}`); - - const runWriter = (name) => new Promise((resolve, reject) => { - const metadata = { - gatewayId: "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/connectorGateways/gateway", - mcpServerConfigId: `/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/connectorGateways/gateway/mcpserverConfigs/${name}`, - connectionId: `/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/connectorGateways/gateway/connections/${name}`, - apiName: name, - }; - const script = [ - `import { writeMcpEntry } from ${JSON.stringify(installUrl)};`, - `await writeMcpEntry(${JSON.stringify(name)}, ${JSON.stringify(`https://example.com/${name}`)}, ${JSON.stringify(`key-${name}`)}, "profile", ${JSON.stringify(metadata)});`, - ].join("\n"); - const child = spawn(process.execPath, ["--input-type=module", "--eval", script], { - env: { ...process.env, COPILOT_HOME: TMP, HOME: TMP, USERPROFILE: TMP }, - stdio: ["ignore", "ignore", "pipe"], - }); - let stderr = ""; - child.stderr.on("data", (chunk) => { stderr += chunk; }); - child.once("error", reject); - child.once("exit", (code) => { - if (code === 0) resolve(); - else reject(new Error(`config writer exited ${code}: ${stderr}`)); - }); - }); - - await Promise.all(names.map(runWriter)); - const stored = JSON.parse(readFileSync(configPath, "utf8")).mcpServers; - assert.deepEqual(Object.keys(stored).sort(), [...names].sort()); - for (const name of names) { - assert.equal(stored[name].url, `https://example.com/${name}`); - assert.equal(stored[name].headers["X-API-Key"], `key-${name}`); - assert.deepEqual(Object.keys(stored[name]).sort(), ["_connectorNamespace", "headers", "url"]); - assert.equal(stored[name]._connectorNamespace.apiName, name); - assert.match(stored[name]._connectorNamespace.gatewayId, /connectorGateways\/gateway$/); - } - assert.equal(existsSync(`${configPath}.lock`), false); - - await Promise.all(names.map((name) => removeMcpEntry(name))); - assert.deepEqual(JSON.parse(readFileSync(configPath, "utf8")).mcpServers, {}); -}); - -test("connector metadata failures are evicted and retried", async (t) => { - const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" }; - const realFetch = globalThis.fetch; - let calls = 0; - globalThis.fetch = async (urlArg) => { - const url = String(urlArg); - assert.ok(!url.includes("export=true"), "swagger must not be requested when it is not required"); - calls++; - if (calls === 1) return { ok: false, status: 400, text: async () => "temporary metadata failure" }; - return { - ok: true, - status: 200, - text: async () => JSON.stringify({ properties: { connectionParameters: {} } }), - }; - }; - t.after(() => { - globalThis.fetch = realFetch; - }); - - await assert.rejects(loadConnectorMeta(config, "retry-meta", "eastus", false), /metadata failure/); - const meta = await loadConnectorMeta(config, "retry-meta", "eastus", false); - assert.equal(calls, 2); - assert.deepEqual(meta.connectionParameters, {}); -}); - -test("uninstall surfaces connection deletion failures", async (t) => { - writeFileSync( - join(TMP, "mcp-config.json"), - JSON.stringify({ mcpServers: { "docusign-bbb": { type: "http", url: "https://example/mcp" } } }), - ); - const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" }; - const remoteConfig = { name: "docusign-bbb", properties: { connectors: [{ name: "docusign", connectionName: "conn-b" }] } }; - const connection = { name: "conn-b", properties: { statuses: [{ status: "Connected" }] } }; - const realFetch = globalThis.fetch; - const operations = []; - globalThis.fetch = async (urlArg, opts = {}) => { - const url = String(urlArg); - const method = (opts.method || "GET").toUpperCase(); - operations.push(`${method} ${url}`); - const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) }); - if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: [remoteConfig] }); - if (method === "GET" && /\/connections\?/.test(url)) return ok({ value: [connection] }); - if (method === "DELETE" && url.includes("/mcpserverConfigs/")) return ok({}); - if (method === "GET" && url.includes("/mcpserverConfigs/")) { - return { ok: false, status: 404, text: async () => "gone" }; - } - if (method === "DELETE" && url.includes("/connections/")) { - return { ok: false, status: 400, text: async () => "delete denied" }; - } - throw new Error(`unexpected ARM call: ${method} ${url}`); - }; - t.after(() => { - globalThis.fetch = realFetch; - }); - - await assert.rejects(uninstallConnector(config, "docusign"), /delete denied/); - const configDelete = operations.findIndex((item) => item.startsWith("DELETE ") && item.includes("/mcpserverConfigs/")); - const connectionDelete = operations.findIndex((item) => item.startsWith("DELETE ") && item.includes("/connections/")); - assert.ok(configDelete !== -1 && configDelete < connectionDelete, "configs must be confirmed deleted before their connections"); - - const pendingCleanup = join(TMP, "extensions", "connector-namespaces", "artifacts", "pending-cleanup"); - assert.equal(readdirSync(pendingCleanup).filter((name) => name.endsWith(".json")).length, 1, "failed deletion must persist enough state to retry"); - - globalThis.fetch = async (urlArg, opts = {}) => { - const url = String(urlArg); - const method = (opts.method || "GET").toUpperCase(); - const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) }); - if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: [] }); - if (method === "GET" && /\/connections\?/.test(url)) return ok({ value: [] }); - if (method === "DELETE") return ok({}); - if (method === "GET" && url.includes("/mcpserverConfigs/")) { - return { ok: false, status: 404, text: async () => "gone" }; - } - throw new Error(`unexpected ARM call: ${method} ${url}`); - }; - assert.deepEqual(await uninstallConnector(config, "docusign"), { ok: true, removed: true }); - assert.equal(readdirSync(pendingCleanup).filter((name) => name.endsWith(".json")).length, 0, "successful retry must clear the cleanup journal"); -}); - -test("uninstall surfaces convergence polling failures", async (t) => { - writeFileSync( - join(TMP, "mcp-config.json"), - JSON.stringify({ mcpServers: { "docusign-bbb": { type: "http", url: "https://example/mcp" } } }), - ); - const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" }; - const remoteConfig = { name: "docusign-bbb", properties: { connectors: [{ name: "docusign", connectionName: "conn-b" }] } }; - const connection = { name: "conn-b", properties: { statuses: [{ status: "Connected" }] } }; - const realFetch = globalThis.fetch; - globalThis.fetch = async (urlArg, opts = {}) => { - const url = String(urlArg); - const method = (opts.method || "GET").toUpperCase(); - const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) }); - if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: [remoteConfig] }); - if (method === "GET" && url.includes("/mcpserverConfigs/")) { - return { ok: false, status: 400, text: async () => "poll denied" }; - } - if (method === "GET" && /\/connections\?/.test(url)) return ok({ value: [connection] }); - if (method === "DELETE") return ok({}); - throw new Error(`unexpected ARM call: ${method} ${url}`); - }; - t.after(() => { - globalThis.fetch = realFetch; - }); - - await assert.rejects(uninstallConnector(config, "docusign"), /poll denied/); -}); - -test("concurrent failed uninstalls retain independent retry records", async (t) => { - const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "concurrent-gw" }; - const configs = [ - { name: "alpha-config", properties: { connectors: [{ name: "alpha", connectionName: "alpha-conn" }] } }, - { name: "beta-config", properties: { connectors: [{ name: "beta", connectionName: "beta-conn" }] } }, - ]; - const connections = [ - { name: "alpha-conn", properties: { statuses: [{ status: "Connected" }] } }, - { name: "beta-conn", properties: { statuses: [{ status: "Connected" }] } }, - ]; - const realFetch = globalThis.fetch; - globalThis.fetch = async (urlArg, opts = {}) => { - const url = String(urlArg); - const method = (opts.method || "GET").toUpperCase(); - const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) }); - if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: configs }); - if (method === "GET" && /\/connections\?/.test(url)) return ok({ value: connections }); - if (method === "DELETE" && url.includes("/mcpserverConfigs/")) return ok({}); - if (method === "GET" && url.includes("/mcpserverConfigs/")) { - return { ok: false, status: 404, text: async () => "gone" }; - } - if (method === "DELETE" && url.includes("/connections/")) { - return { ok: false, status: 400, text: async () => "delete denied" }; - } - throw new Error(`unexpected ARM call: ${method} ${url}`); - }; - t.after(() => { - globalThis.fetch = realFetch; - }); - - const results = await Promise.allSettled([ - uninstallConnector(config, "alpha"), - uninstallConnector(config, "beta"), - ]); - assert.deepEqual(results.map((result) => result.status), ["rejected", "rejected"]); - - const pendingCleanup = join(TMP, "extensions", "connector-namespaces", "artifacts", "pending-cleanup"); - const paths = readdirSync(pendingCleanup) - .filter((name) => name.endsWith(".json")) - .map((name) => join(pendingCleanup, name)); - const records = paths.map((path) => ({ path, ...JSON.parse(readFileSync(path, "utf8")) })) - .filter((record) => record.gatewayId.includes("/connectorGateways/concurrent-gw")); - assert.deepEqual(new Set(records.map((record) => record.apiName)), new Set(["alpha", "beta"])); - for (const record of records) unlinkSync(record.path); -}); - -test("local MCP config read failures block cleanup", async () => { - const configPath = join(TMP, "mcp-config.json"); - writeFileSync(configPath, "{invalid json"); - try { - await assert.rejects(removeMcpEntry("docusign-bbb"), SyntaxError); - } finally { - writeFileSync(configPath, JSON.stringify({ mcpServers: {} })); - } -}); - -test("installed state propagates local MCP config read failures", async (t) => { - const configPath = join(TMP, "mcp-config.json"); - writeFileSync(configPath, "{invalid json"); - const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "state-fail-gw" }; - const realFetch = globalThis.fetch; - globalThis.fetch = async (urlArg, opts = {}) => { - const url = String(urlArg); - const method = (opts.method || "GET").toUpperCase(); - const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) }); - if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: [] }); - if (method === "GET" && /\/connections\?/.test(url)) return ok({ value: [] }); - throw new Error(`unexpected ARM call: ${method} ${url}`); - }; - t.after(() => { - globalThis.fetch = realFetch; - writeFileSync(configPath, JSON.stringify({ mcpServers: {} })); - }); - - await assert.rejects(getInstalledState(config), SyntaxError); -}); - -test("missing-connection reauth journals cleanup and the next install retries it", async (t) => { - const configPath = join(TMP, "mcp-config.json"); - writeFileSync( - configPath, - JSON.stringify({ mcpServers: { "missing-config": { type: "http", url: "https://example/mcp" } } }), - ); - const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "missing-conn-gw" }; - const remoteConfig = { - name: "missing-config", - properties: { connectors: [{ name: "missing-api", connectionName: "missing-conn" }] }, - }; - const realFetch = globalThis.fetch; - let retrying = false; - globalThis.fetch = async (urlArg, opts = {}) => { - const url = String(urlArg); - const method = (opts.method || "GET").toUpperCase(); - const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) }); - if (method === "DELETE" && url.includes("/mcpserverConfigs/")) return ok({}); - if (method === "GET" && url.includes("/mcpserverConfigs/missing-config")) { - return { ok: false, status: 404, text: async () => "gone" }; - } - if (method === "DELETE" && url.includes("/connections/missing-conn")) { - return { ok: false, status: 404, text: async () => "gone" }; - } - if (retrying && method === "GET" && url.includes("/managedApis/missing-api")) { - return { ok: false, status: 400, text: async () => "stop after cleanup" }; - } - if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: [remoteConfig] }); - if (method === "GET" && /\/connections\?/.test(url)) return ok({ value: [] }); - if (method === "GET" && /\/connectorGateways\/[^/?]+\?/.test(url)) return ok({ location: "eastus" }); - if (method === "GET" && url.includes("/managedApis/missing-api")) return ok({ properties: {} }); - if (method === "POST" && url.includes("/connections/missing-conn/listConsentLinks")) { - writeFileSync(configPath, "{invalid json"); - return { ok: false, status: 404, text: async () => "connection gone" }; - } - throw new Error(`unexpected ARM call: ${method} ${url}`); - }; - t.after(() => { - globalThis.fetch = realFetch; - writeFileSync(configPath, JSON.stringify({ mcpServers: {} })); - }); - - await assert.rejects( - reauthConnector(config, "missing-api", "Missing API", "https://cb/?c="), - SyntaxError, - ); - - const pendingCleanup = join(TMP, "extensions", "connector-namespaces", "artifacts", "pending-cleanup"); - const matchingRecords = () => readdirSync(pendingCleanup) - .filter((name) => name.endsWith(".json")) - .map((name) => JSON.parse(readFileSync(join(pendingCleanup, name), "utf8"))) - .filter((record) => record.gatewayId.includes("/connectorGateways/missing-conn-gw") && record.apiName === "missing-api"); - assert.equal(matchingRecords().length, 1, "failed reauth cleanup must retain retry data"); - - writeFileSync( - configPath, - JSON.stringify({ mcpServers: { "missing-config": { type: "http", url: "https://example/mcp" } } }), - ); - retrying = true; - await assert.rejects( - installConnector(config, "missing-api", "Missing API", "https://cb/?c="), - /stop after cleanup/, - ); - assert.equal(matchingRecords().length, 0, "the next install must consume successful pending cleanup"); - const localConfig = JSON.parse(readFileSync(configPath, "utf8")); - assert.equal(localConfig.mcpServers["missing-config"], undefined, "pending cleanup must remove the stale local entry"); -}); - -test("fresh-connection rollback surfaces deletion failures", async (t) => { - const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" }; - const realFetch = globalThis.fetch; - globalThis.fetch = async () => ({ ok: false, status: 400, text: async () => "rollback denied" }); - t.after(() => { - globalThis.fetch = realFetch; - }); - - await assert.rejects(deleteConnection(config, "fresh-conn"), /rollback denied/); -}); - -test("finish status failures roll back the fresh connection", async (t) => { - const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" }; - const realFetch = globalThis.fetch; - const calls = []; - globalThis.fetch = async (urlArg, opts = {}) => { - const url = String(urlArg); - const method = (opts.method || "GET").toUpperCase(); - calls.push({ method, url }); - const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) }); - if (method === "GET" && url.includes("/managedApis/") && url.includes("export=true")) { - return ok({ paths: { "/mcp": { post: { operationId: "op", tags: ["agentic"] } } } }); - } - if (method === "GET" && url.includes("/managedApis/")) return ok({ properties: {} }); - if (method === "GET" && url.includes("/connections/fresh-status?")) { - return { ok: false, status: 400, text: async () => "status denied" }; - } - if (method === "DELETE" && url.includes("/connections/fresh-status?")) return ok({}); - throw new Error(`unexpected ARM call: ${method} ${url}`); - }; - t.after(() => { - globalThis.fetch = realFetch; - }); - - await assert.rejects( - finishInstall(config, "status-fail", "Status Fail", "fresh-status", "eastus"), - /status denied/, - ); - assert.ok(calls.some((call) => call.method === "DELETE" && call.url.includes("/connections/fresh-status?"))); -}); - -test("failed config cleanup preserves its referenced connection", async (t) => { - const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" }; - const realFetch = globalThis.fetch; - const calls = []; - globalThis.fetch = async (urlArg, opts = {}) => { - const url = String(urlArg); - const method = (opts.method || "GET").toUpperCase(); - calls.push({ method, url }); - const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) }); - if (method === "GET" && url.includes("/managedApis/") && url.includes("export=true")) { - return ok({ paths: { "/mcp": { post: { operationId: "op", tags: ["agentic"] } } } }); - } - if (method === "GET" && url.includes("/managedApis/")) return ok({ properties: {} }); - if (method === "GET" && url.includes("/connections/fresh-config?")) { - return ok({ properties: { statuses: [{ status: "Connected" }] } }); - } - if (method === "PUT" && url.includes("/mcpserverConfigs/")) { - return ok({ properties: { mcpEndpointUrl: "https://example.com/mcp" } }); - } - if (method === "POST" && url.includes("/listApiKey?")) { - return { ok: false, status: 400, text: async () => "key denied" }; - } - if (method === "DELETE" && url.includes("/mcpserverConfigs/")) { - return { ok: false, status: 400, text: async () => "config cleanup denied" }; - } - if (method === "DELETE" && url.includes("/connections/")) return ok({}); - throw new Error(`unexpected ARM call: ${method} ${url}`); - }; - t.after(() => { - globalThis.fetch = realFetch; - }); - - await assert.rejects( - finishInstall(config, "cleanup-order", "Cleanup Order", "fresh-config", "eastus"), - /config cleanup denied/, - ); - assert.ok( - !calls.some((call) => call.method === "DELETE" && call.url.includes("/connections/")), - "a surviving config must keep its referenced connection", - ); -}); diff --git a/extensions/connector-namespaces/install.test.mjs b/extensions/connector-namespaces/install.test.mjs deleted file mode 100644 index 5e51de54..00000000 --- a/extensions/connector-namespaces/install.test.mjs +++ /dev/null @@ -1,253 +0,0 @@ -// Regression guards for install-state selection. -// -// Run: node --test extensions/connector-namespaces/install.test.mjs -// -// These exist because getInstalledState used to collapse N gateway configs for -// one apiName down to a single tile via ARM list order (last-wins). A portal -// add, a duplicate Connect, or a re-auth would mint a sibling config; whichever -// ARM happened to return last owned the tile, so a tile could show -// "Re-authenticate" while a different config for the same connector was already -// Connected. deriveInstalledState now picks deterministically: -// inCli && Connected > inCli > Connected > any, configName wins ties. - -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; - -import { deriveInstalledState, getConsentUrl, getConnectionStatus, getMcpEndpointUrl, waitForConnected } from "./install.mjs"; - -// removeLocalEntry does file I/O (getInstalledState reads ARM + mcp configs, -// removeMcpEntry edits them) and calls both as same-module functions, so there -// is no import seam to stub. The invariant that matters — the default "Remove" -// only unlinks the CLI entry and NEVER deletes the Azure resource — is a -// source contract, so we assert it against the function body the same way -// renderer.test.mjs guards its CSS/HTML strings. -function functionBody(source, name) { - const exported = source.indexOf(`export async function ${name}(`); - const start = exported !== -1 ? exported : source.indexOf(`async function ${name}(`); - if (start === -1) return null; - const open = source.indexOf("{", start); - if (open === -1) return null; - let depth = 0; - for (let i = open; i < source.length; i++) { - const ch = source[i]; - if (ch === "{") depth++; - else if (ch === "}") { - depth--; - if (depth === 0) return source.slice(open + 1, i); - } - } - return null; -} - -const installSource = readFileSync(fileURLToPath(new URL("./install.mjs", import.meta.url)), "utf8"); - -// Build a fake ARM mcpserverConfig list entry. -function cfg(name, apiName, connName) { - return { name, properties: { connectors: [{ name: apiName, connectionName: connName }] } }; -} - -// Build a connName -> connection map with a given status. -function conns(...entries) { - const m = new Map(); - for (const [connName, status] of entries) { - m.set(connName, { name: connName, properties: { statuses: [{ status }] } }); - } - return m; -} - -test("picks inCli+Connected over a not-inCli sibling that appears LAST (not last-wins)", () => { - // good config is FIRST; a broken sibling is LAST. Old last-wins would pick - // the last one — the fix must pick the good one regardless of order. - const configs = [ - cfg("good", "shared-api", "connGood"), - cfg("bad", "shared-api", "connBad"), - ]; - const connByName = conns(["connGood", "Connected"], ["connBad", "Unknown"]); - const profileKeys = new Set(["good"]); // only the good config is in the CLI - const state = deriveInstalledState(configs, connByName, profileKeys, new Set(), null); - - assert.equal(state["shared-api"].configName, "good"); - assert.equal(state["shared-api"].connectionName, "connGood"); - assert.equal(state["shared-api"].connectionStatus, "Connected"); - assert.equal(state["shared-api"].inCli, true); - assert.equal(state["shared-api"]._configCount, 2); - assert.deepEqual(state["shared-api"]._candidates.map((item) => item.configName), ["good", "bad"]); -}); - -test("inCli beats a Connected-but-not-inCli sibling", () => { - // A is the config the local session points at but not yet Connected; B is - // Connected on ARM but not in the CLI. Prefer A so remove/re-auth act on the - // resource the user's session actually uses. - const configs = [ - cfg("a-incli", "api", "connA"), - cfg("b-connected", "api", "connB"), - ]; - const connByName = conns(["connA", "Unknown"], ["connB", "Connected"]); - const state = deriveInstalledState(configs, connByName, new Set(["a-incli"]), new Set(), null); - - assert.equal(state["api"].configName, "a-incli"); - assert.equal(state["api"].inCli, true); -}); - -test("inCli && Connected beats inCli-only", () => { - const configs = [ - cfg("incli-unknown", "api", "connU"), - cfg("incli-connected", "api", "connC"), - ]; - const connByName = conns(["connU", "Unknown"], ["connC", "Connected"]); - const state = deriveInstalledState(configs, connByName, new Set(["incli-unknown", "incli-connected"]), new Set(), null); - - assert.equal(state["api"].configName, "incli-connected"); - assert.equal(state["api"].connectionStatus, "Connected"); -}); - -test("config name breaks equal-rank ties independently of ARM list order", () => { - const configs = [ - cfg("z-config", "api", "connZ"), - cfg("a-config", "api", "connA"), - ]; - const connByName = conns(["connZ", "Connected"], ["connA", "Connected"]); - const local = new Set(["z-config", "a-config"]); - - const forward = deriveInstalledState(configs, connByName, local, new Set(), null); - const reverse = deriveInstalledState([...configs].reverse(), connByName, local, new Set(), null); - - assert.equal(forward.api.configName, "a-config"); - assert.equal(reverse.api.configName, "a-config"); -}); - -test("connection convergence reports non-connected terminal results as failures", async () => { - const states = ["Connecting", "Error"]; - const delays = []; - await assert.rejects( - waitForConnected({}, "conn", { - maxPolls: 2, - getStatus: async () => states.shift(), - delay: async (ms) => delays.push(ms), - }), - /Connection ended in state "Error"/, - ); - assert.deepEqual(delays, [1000]); - assert.equal( - await waitForConnected({}, "conn", { - getStatus: async () => "Connected", - delay: async () => assert.fail("connected state must not sleep"), - }), - "Connected", - ); -}); - -test("single config passes through with no _configCount", () => { - const configs = [cfg("only", "api", "conn1")]; - const connByName = conns(["conn1", "Connected"]); - const state = deriveInstalledState(configs, connByName, new Set(["only"]), new Set(), null); - - assert.equal(state["api"].configName, "only"); - assert.equal(state["api"]._configCount, undefined); -}); - -test("workspace membership counts as inCli and sets scope/path", () => { - const configs = [cfg("ws", "api", "conn1")]; - const connByName = conns(["conn1", "Connected"]); - const state = deriveInstalledState(configs, connByName, new Set(), new Set(["ws"]), "/repo/.mcp.json"); - - assert.equal(state["api"].inCli, true); - assert.equal(state["api"].cliScope, "workspace"); - assert.equal(state["api"].cliPath, "/repo/.mcp.json"); -}); - -test("connectionStatus falls back to overallStatus then Unknown", () => { - const configs = [cfg("c1", "api1", "connOverall"), cfg("c2", "api2", "connMissing")]; - const connByName = new Map([ - ["connOverall", { name: "connOverall", properties: { overallStatus: "Connected" } }], - ]); - const state = deriveInstalledState(configs, connByName, new Set(), new Set(), null); - - assert.equal(state["api1"].connectionStatus, "Connected"); // from overallStatus - assert.equal(state["api2"].connectionStatus, "Unknown"); // no connection at all -}); - -test("configs with no connector are skipped", () => { - const configs = [ - { name: "broken", properties: { connectors: [] } }, - cfg("ok", "api", "conn1"), - ]; - const connByName = conns(["conn1", "Connected"]); - const state = deriveInstalledState(configs, connByName, new Set(["ok"]), new Set(), null); - - assert.equal(Object.keys(state).length, 1); - assert.equal(state["api"].configName, "ok"); -}); - -test("removeLocalEntry unlinks the local CLI entry via removeMcpEntry", () => { - const body = functionBody(installSource, "removeLocalEntry"); - assert.ok(body, "removeLocalEntry function not found in install.mjs"); - assert.match(body, /removeMcpEntry\s*\(/, "removeLocalEntry must call removeMcpEntry to drop the CLI entry"); - assert.match(body, /entry\._candidates/, "removeLocalEntry must process duplicate CLI configs"); - assert.match(body, /candidate\.inCli/, "removeLocalEntry must unlink every local candidate"); -}); - -test("uninstallConnector deletes every duplicate namespace config", () => { - const body = functionBody(installSource, "uninstallConnector"); - const cleanup = functionBody(installSource, "cleanupConnectorResources"); - assert.ok(body, "uninstallConnector function not found in install.mjs"); - assert.ok(cleanup, "cleanupConnectorResources function not found in install.mjs"); - assert.match(body, /entry\._candidates/, "namespace deletion must process duplicate configs"); - assert.match(body, /cleanupConnectorResources\s*\(/, "uninstall must delegate all collected candidates to shared cleanup"); - assert.match(cleanup, /deleteMcpServerConfigs\(config, configNames\)/); - assert.match(cleanup, /for \(const connectionName of connectionNames\)/); - assert.match(cleanup, /for \(const configName of configNames\)/); -}); - -test("removeLocalEntry never deletes the namespace resource (no armDelete)", () => { - const body = functionBody(installSource, "removeLocalEntry"); - assert.ok(body, "removeLocalEntry function not found in install.mjs"); - // The default Remove must stay local-only. If someone routes it through - // uninstallConnector or adds an ARM delete, this fails — which is the point. - assert.doesNotMatch(body, /armDelete\s*\(/, "removeLocalEntry must not call armDelete"); - assert.doesNotMatch(body, /uninstallConnector\s*\(/, "removeLocalEntry must not delegate to uninstallConnector"); -}); - -// --- ARM path-injection guard (client-reachable read sinks) --- -// -// finishInstall/finishReauth feed client-supplied body.connName / body.configName -// into getConsentUrl, getConnectionStatus, and getMcpEndpointUrl, which build ARM -// URLs. Those names must pass through armSegment() so a traversal / query payload -// can't escape the intended resource path (SSRF / path injection). armSegment -// throws synchronously while the URL is built, before any token or network call, -// so these run fully offline and deterministic. A valid config is used so the -// gatewayId() wrap doesn't throw first — only the bad NAME should reject. -const validConfig = { subscriptionId: "s", resourceGroup: "r", gatewayName: "g" }; -const badNames = ["../../evil", "evil/../../secret", "x?injected=1"]; - -test("getConnectionStatus rejects traversal/injection connName before any ARM call", async () => { - for (const bad of badNames) { - await assert.rejects( - () => getConnectionStatus(validConfig, bad), - /Invalid ARM resource identifier/, - `getConnectionStatus should reject connName ${JSON.stringify(bad)}`, - ); - } -}); - -test("getConsentUrl rejects traversal/injection connName before any ARM call", async () => { - for (const bad of badNames) { - await assert.rejects( - () => getConsentUrl(validConfig, bad, "http://127.0.0.1:0/auth/callback/x"), - /Invalid ARM resource identifier/, - `getConsentUrl should reject connName ${JSON.stringify(bad)}`, - ); - } -}); - -test("getMcpEndpointUrl rejects traversal/injection configName before any ARM call", async () => { - for (const bad of badNames) { - await assert.rejects( - () => getMcpEndpointUrl(validConfig, bad), - /Invalid ARM resource identifier/, - `getMcpEndpointUrl should reject configName ${JSON.stringify(bad)}`, - ); - } -}); diff --git a/extensions/connector-namespaces/mcp-http-probe.test.mjs b/extensions/connector-namespaces/mcp-http-probe.test.mjs deleted file mode 100644 index 009f1086..00000000 --- a/extensions/connector-namespaces/mcp-http-probe.test.mjs +++ /dev/null @@ -1,58 +0,0 @@ -import assert from "node:assert/strict"; -import { createServer } from "node:http"; -import test from "node:test"; -import { probe } from "./test/mcp-probe.mjs"; - -test("native HTTP probe carries API key and MCP session through an SSE handshake", async (t) => { - const apiKeys = []; - const sessionIds = []; - const methods = []; - const server = createServer(async (req, res) => { - const chunks = []; - for await (const chunk of req) chunks.push(chunk); - const message = JSON.parse(Buffer.concat(chunks).toString("utf8")); - apiKeys.push(req.headers["x-api-key"]); - sessionIds.push(req.headers["mcp-session-id"] || null); - methods.push(message.method); - - if (message.method === "notifications/initialized") { - res.writeHead(202); - res.end(); - return; - } - - let result; - if (message.method === "initialize") { - res.setHeader("Mcp-Session-Id", "session-1"); - result = { - protocolVersion: "2025-06-18", - serverInfo: { name: "test-server", version: "1.0.0" }, - capabilities: { tools: {} }, - }; - } else if (message.method === "tools/list") { - result = { tools: [{ name: "ListTeams", inputSchema: { type: "object" } }] }; - } else { - result = { content: [{ type: "text", text: "ok" }] }; - } - - res.setHeader("Content-Type", "text/event-stream"); - res.end(`event: message\ndata: ${JSON.stringify({ jsonrpc: "2.0", id: message.id, result })}\n\n`); - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - t.after(() => new Promise((resolve) => server.close(resolve))); - - const { port } = server.address(); - const result = await probe({ - apiName: "WorkIQTeams", - displayName: "WorkIQ Teams", - url: `http://127.0.0.1:${port}/mcp`, - key: "secret", - }); - - assert.equal(result.ok, true); - assert.equal(result.toolCount, 1); - assert.equal(result.toolCalled, "ListTeams"); - assert.deepEqual(methods, ["initialize", "notifications/initialized", "tools/list", "tools/call"]); - assert.deepEqual(apiKeys, ["secret", "secret", "secret", "secret"]); - assert.deepEqual(sessionIds, [null, "session-1", "session-1", "session-1"]); -}); diff --git a/extensions/connector-namespaces/package.json b/extensions/connector-namespaces/package.json deleted file mode 100644 index ac0c28b0..00000000 --- a/extensions/connector-namespaces/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "connector-namespaces", - "version": "1.1.0", - "type": "module", - "main": "extension.mjs", - "description": "Browse, connect, and open MCP connectors from your Azure Connector Namespace in Sandbox.", - "keywords": [ - "azure", - "connector-namespace", - "mcp", - "mcp-connectors", - "model-context-protocol", - "tool-discovery" - ], - "license": "MIT", - "dependencies": { - "@github/copilot-sdk": "1.0.6" - } -} diff --git a/extensions/connector-namespaces/preview/.gitignore b/extensions/connector-namespaces/preview/.gitignore deleted file mode 100644 index 0bac4c45..00000000 --- a/extensions/connector-namespaces/preview/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# Screenshot evidence is throwaway, regenerated on demand by shots.mjs. -shots/ diff --git a/extensions/connector-namespaces/preview/README.md b/extensions/connector-namespaces/preview/README.md deleted file mode 100644 index 14b839b1..00000000 --- a/extensions/connector-namespaces/preview/README.md +++ /dev/null @@ -1,112 +0,0 @@ -# connector-namespaces preview harness - -A standalone way to **see** every canvas state without launching the Copilot -app. It imports the real, pure renderer functions from `../renderer.mjs` and -serves each state on a fixed loopback port, with every `/api/*` endpoint stubbed -so you can force the states that keep regressing (the connecting spinner and the -"Restart your Copilot session…" banner). - -This exists because those two bugs have each shipped multiple times: - -- the sign-in spinner freezing (an unscoped `animation:none` leaking out of the - reduced-motion block), and -- the restart-banner dismiss button doing nothing (a CSS specificity bug that - let `.restart-banner{display:flex}` beat `[hidden]`). - -Both are static CSS facts, so the **deterministic gate is `../renderer.test.mjs`** -(run with `node --test`). This harness is the human-visual layer on top of it: -load a state in a browser, or capture screenshots with `agent-browser`. - -## Run the preview server - -```sh -node extensions/connector-namespaces/preview/server.mjs -``` - -It binds to `http://127.0.0.1:7331`. Open that URL in any browser. The server is -a plain HTTP process (not the JSON-RPC extension provider), so it logs every hit -to stdout — that's expected and fine here. - -### State routes - -| URL | State | -| --- | --- | -| `/` or `/catalog` | Configured catalog (mock gateway + connectors) | -| `/setup` | First-run gateway picker (`renderSetupHtml`) | -| `/error` | Error screen (`renderErrorHtml`) | - -### State-forcing query flags (on the catalog route) - -The catalog page hydrates from `/api/state` on load, so loading one of these -sets the state the very next `/api/state` returns: - -| Flag | Effect | -| --- | --- | -| `/?restart=1` | `/api/state` returns `pendingRestart:true` → restart banner visible on load | -| `/?installed=1` | One connector shows as already installed/connected | - -Flags combine, e.g. `/?installed=1&restart=1`. - -> The active state is a single module-level flag (last catalog load wins). It's a -> single-user preview, so just load the page you want, then it's sticky until the -> next catalog load. - -### Stubbed endpoints - -`/api/state`, `/api/gateways`, `/api/select-gateway`, `/api/install` (returns -`needsConsent` to force the connecting spinner), `/api/finish-install`, -`/api/ack-restart` (the dismiss action), `/oauth-status` (stays pending so the -modal spinner keeps animating), `/api/uninstall`, `/api/rollback-connection`, -and `/api/open-url` (a deliberate **no-op** here — it must never actually launch -a browser tab). - -## Capture screenshots (optional) - -The screenshot driver uses [`agent-browser`](https://www.npmjs.com/package/agent-browser), -the same headless-Chromium verification tool that `arikbidny/ralph-copilot-cli` -uses. It is **not** required — if it isn't installed the driver prints an install -hint and exits 0. - -Install it once: - -```sh -npm i -g agent-browser && agent-browser install -``` - -Then, with the server running in another terminal: - -```sh -node extensions/connector-namespaces/preview/shots.mjs -``` - -Screenshots are written to `preview/shots/`: - -- `catalog.png`, `catalog-restart-banner.png`, `catalog-installed.png`, - `setup.png`, `error.png` — the static states. -- `connecting-spinner.png` — after clicking **Connect**; verify the `.si-spin` - ring is mid-rotation, not frozen. -- `banner-before-dismiss.png` / `banner-after-dismiss.png` — verify the banner is - present in the first and **gone** in the second. - -`preview/shots/` is throwaway visual evidence; it is not committed. - -## Files - -| File | Purpose | -| --- | --- | -| `server.mjs` | Standalone preview server (fixed port 7331) | -| `fixtures.mjs` | Deterministic mock subscriptions / gateways / catalog / state | -| `shots.mjs` | `agent-browser` screenshot driver (degrades gracefully) | - -## Relationship to the test guard - -`shots.mjs` proves a state *looks* right today and is handy when chasing a new -bug. It cannot prove an animation is *running* from a single frame. The -regression gate that actually blocks the recurring bugs is the CSS-structure -assertion in `../renderer.test.mjs`: - -```sh -node --test extensions/connector-namespaces/renderer.test.mjs -``` - -Keep that green; use this harness to eyeball changes. diff --git a/extensions/connector-namespaces/preview/fixtures.mjs b/extensions/connector-namespaces/preview/fixtures.mjs deleted file mode 100644 index 0fce100b..00000000 --- a/extensions/connector-namespaces/preview/fixtures.mjs +++ /dev/null @@ -1,117 +0,0 @@ -// Deterministic fixtures for the standalone canvas preview server. -// -// These mirror the exact response shapes the inline client script in -// renderer.mjs expects, so the preview server can drive every canvas state -// (setup / catalog / error / connecting-spinner / restart-banner) with no -// Copilot app, no ARM, and no real OAuth. Keep these shapes in sync with the -// fetch() handlers in renderer.mjs if those response contracts change. - -import { CATEGORY } from "../categories.mjs"; - -export const subscriptions = [ - { id: "00000000-0000-0000-0000-000000000001", name: "Contoso Production" }, - { id: "11111111-1111-1111-1111-111111111111", name: "Contoso Dev/Test" }, -]; - -// /api/gateways?subscriptionId=... -> { gateways: [{ id, name, location }], hasMore } -// The client splits id on "/" and reads the segment after "resourceGroups", -// so the id must contain a resourceGroups segment. -export const gateways = [ - { - id: "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/rg-connectors/providers/Microsoft.ConnectorNamespaces/connectorNamespaces/contoso-ns", - name: "contoso-ns", - location: "eastus", - }, - { - id: "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/rg-shared/providers/Microsoft.ConnectorNamespaces/connectorNamespaces/shared-ns", - name: "shared-ns", - location: "westus2", - }, -]; - -// Active namespace shown in the catalog header (config.gatewayName / resourceGroup). -export const config = { - subscriptionId: "00000000-0000-0000-0000-000000000001", - gatewayName: "contoso-ns", - resourceGroup: "rg-connectors", -}; - -// Catalog tiles. Shape per item: { category, displayName, apiName, description, -// iconUri?, brandColor? }. At least one item must be connectable so the -// connect -> spinner flow can be exercised. -// -// The renderer routes items by category: exactly `category === CATEGORY.microsoft` -// lands in the Microsoft section, everything else in Partners. Keep a mix of -// both here so the preview exercises the full 3-section layout (My MCPs / -// Microsoft / Partners) rather than dumping every tile into one section. -export const catalog = [ - { - category: CATEGORY.microsoft, - displayName: "Microsoft Teams", - apiName: "teams", - description: "Send messages, manage chats and channels.", - brandColor: "#5059c9", - }, - { - category: CATEGORY.microsoft, - displayName: "Outlook Mail", - apiName: "outlook", - description: "Read, send, and organize email.", - brandColor: "#0a66c2", - }, - { - category: CATEGORY.microsoft, - displayName: "SharePoint", - apiName: "sharepoint", - description: "Browse sites, lists, and documents.", - brandColor: "#038387", - }, - { - category: CATEGORY.partner, - displayName: "GitHub", - apiName: "github", - description: "Manage repos, issues, and pull requests.", - brandColor: "#24292e", - }, - { - category: CATEGORY.partner, - displayName: "Stripe", - apiName: "stripe", - description: "Payments, customers, and invoices.", - brandColor: "#635bff", - }, -]; - -// /api/state -> { state: { apiName: InstallState }, pendingRestart } -// InstallState: { installed, connectionStatus, inCli, cliPath?, cliScope? } -// Default state: nothing installed, no pending restart. The catalog renders -// every tile with a "Connect" button. -export const stateEmpty = { - state: {}, - pendingRestart: false, -}; - -// One connector already added (shows "Added" + Remove), restart pending so the -// banner is visible on load. Drives both the "added" tile and the banner state. -export const stateInstalledRestart = { - state: { - sharepoint: { - installed: true, - connectionStatus: "Connected", - inCli: true, - cliPath: "~/.copilot/mcp-config.json", - cliScope: "profile", - }, - }, - pendingRestart: true, -}; - -// Install response that forces the connecting flow. needsConsent keeps the -// sign-in modal (with the .si-spin spinner) open; /oauth-status then stays -// pending so the spinner keeps animating for a screenshot. -export const installNeedsConsent = { - needsConsent: true, - connName: "preview-conn", - consentUrl: "http://127.0.0.1:7331/fake-consent", - location: "eastus", -}; diff --git a/extensions/connector-namespaces/preview/server.mjs b/extensions/connector-namespaces/preview/server.mjs deleted file mode 100644 index d56a14c6..00000000 --- a/extensions/connector-namespaces/preview/server.mjs +++ /dev/null @@ -1,151 +0,0 @@ -// Standalone preview server for the connector-namespaces connector catalog. -// -// Renders every canvas state with no Copilot app, no ARM, and no real OAuth by -// importing the *pure* HTML builders from renderer.mjs and stubbing every -// /api/* endpoint the inline client script calls. Point any browser (or the -// agent-browser driver in shots.mjs) at it to see exactly what ships. -// -// Run: node extensions/connector-namespaces/preview/server.mjs -// Then open http://127.0.0.1:7331/ (catalog), /setup, /error. -// -// This process is NOT the JSON-RPC extension provider, so console.log here is -// fine and intentional — it is how you watch which stubbed endpoints get hit. - -import { createServer } from "node:http"; - -import { - renderCatalogHtml, - renderSetupHtml, - renderErrorHtml, -} from "../renderer.mjs"; -import * as fixtures from "./fixtures.mjs"; - -const HOST = "127.0.0.1"; -const PORT = 7331; -const INSTANCE = "preview"; - -// Whatever /api/state should report next. The catalog route updates this from -// its query flags so a page load can force the banner / "added" tile on, and a -// real Connect click flips pendingRestart on via showRestartBanner(). -let activeState = fixtures.stateEmpty; - -function selectState(query) { - const restart = query.get("restart") === "1"; - const installed = query.get("installed") === "1"; - if (restart && installed) return fixtures.stateInstalledRestart; - if (installed) return { state: fixtures.stateInstalledRestart.state, pendingRestart: false }; - if (restart) return { state: {}, pendingRestart: true }; - return fixtures.stateEmpty; -} - -function sendHtml(res, body) { - res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.end(body); -} - -function sendJson(res, obj, status = 200) { - res.statusCode = status; - res.setHeader("Content-Type", "application/json"); - res.end(JSON.stringify(obj)); -} - -async function readBody(req) { - const chunks = []; - for await (const chunk of req) chunks.push(chunk); - if (!chunks.length) return {}; - try { - return JSON.parse(Buffer.concat(chunks).toString("utf8")); - } catch { - return {}; - } -} - -const server = createServer(async (req, res) => { - const url = new URL(req.url, `http://${HOST}:${PORT}`); - const path = url.pathname; - const q = url.searchParams; - // Strip CR/LF/tab so a crafted request line can't forge extra log entries. - console.log(`${req.method} ${req.url}`.replace(/[\r\n\t]/g, " ")); - - // --- Page routes --------------------------------------------------------- - if (req.method === "GET" && (path === "/" || path === "/catalog")) { - activeState = selectState(q); - return sendHtml( - res, - renderCatalogHtml(INSTANCE, fixtures.catalog, { - filter: q.get("filter") || "", - category: q.get("category") || "all", - source: q.get("source") || "", - config: fixtures.config, - }), - ); - } - if (req.method === "GET" && path === "/setup") { - return sendHtml(res, renderSetupHtml(fixtures.subscriptions)); - } - if (req.method === "GET" && path === "/error") { - return sendHtml(res, renderErrorHtml(q.get("message") || "Something went wrong loading connectors.")); - } - if (req.method === "GET" && path === "/fake-consent") { - return sendHtml(res, "ConsentFake Microsoft consent page (preview). Close this tab."); - } - - // --- Stubbed API endpoints ---------------------------------------------- - if (req.method === "GET" && path === "/api/state") { - return sendJson(res, activeState); - } - if (req.method === "GET" && path === "/api/gateways") { - return sendJson(res, { gateways: fixtures.gateways, hasMore: false }); - } - if (req.method === "GET" && path === "/oauth-status") { - // Stay pending forever so the connecting spinner keeps animating for a - // screenshot. Flip to { done: true } if you want the full success flow. - return sendJson(res, { done: false }); - } - - if (req.method === "POST") { - await readBody(req); - switch (path) { - case "/api/select-gateway": - return sendJson(res, { ok: true }); - case "/api/install": - return sendJson(res, fixtures.installNeedsConsent); - case "/api/finish-install": - activeState = { ...activeState, pendingRestart: true }; - return sendJson(res, { ok: true }); - case "/api/ack-restart": - activeState = { ...activeState, pendingRestart: false }; - return sendJson(res, { ok: true }); - case "/api/uninstall": - return sendJson(res, { ok: true }); - case "/api/open-url": - // Preview no-op: do NOT actually launch a browser tab. - return sendJson(res, { ok: true }); - case "/api/rollback-connection": - return sendJson(res, { ok: true }); - default: - return sendJson(res, { error: `unstubbed POST ${path}` }, 404); - } - } - - res.statusCode = 404; - res.end("not found"); -}); - -server.on("error", (err) => { - if (err.code === "EADDRINUSE") { - console.error(`Port ${PORT} is already in use. Stop the other process or change PORT in server.mjs.`); - process.exit(1); - } - throw err; -}); - -server.listen(PORT, HOST, () => { - console.log(`canvas preview server: http://${HOST}:${PORT}/`); - console.log(" / catalog (empty state)"); - console.log(" /?restart=1 catalog with restart banner visible"); - console.log(" /?installed=1 catalog with one connector added"); - console.log(" /setup namespace picker"); - console.log(" /error error state"); - console.log("Press Ctrl+C to stop."); -}); diff --git a/extensions/connector-namespaces/preview/shots.mjs b/extensions/connector-namespaces/preview/shots.mjs deleted file mode 100644 index aab8f17b..00000000 --- a/extensions/connector-namespaces/preview/shots.mjs +++ /dev/null @@ -1,96 +0,0 @@ -// agent-browser screenshot driver for the canvas preview server. -// -// Captures every canvas state to ./shots/ and drives the two interaction flows -// that keep regressing: -// 1. catalog -> click Connect -> sign-in modal with the spinning .si-spin -// 2. restart banner visible -> click dismiss -> banner gone -// -// Requires the preview server to be running: -// node extensions/connector-namespaces/preview/server.mjs -// And agent-browser installed: -// npm i -g agent-browser && agent-browser install -// -// If agent-browser is not installed, this script prints how to install it and -// exits 0 (so it never breaks an unattended run). This is a visual-evidence -// helper; the deterministic regression gate is renderer.test.mjs. - -import { spawnSync } from "node:child_process"; -import { mkdirSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const SHOTS = join(HERE, "shots"); -const BASE = "http://127.0.0.1:7331"; - -function hasAgentBrowser() { - const probe = spawnSync("agent-browser", ["--version"], { encoding: "utf8", shell: true }); - return probe.status === 0; -} - -function ab(args) { - const r = spawnSync("agent-browser", args, { encoding: "utf8", shell: true }); - if (r.status !== 0) { - console.error(`agent-browser ${args.join(" ")} failed:\n${r.stderr || r.stdout}`); - } - return r; -} - -function serverUp() { - // Node 18+ has global fetch. Confirm the preview server is reachable. - return fetch(`${BASE}/api/state`).then(() => true).catch(() => false); -} - -async function main() { - if (!hasAgentBrowser()) { - console.log("agent-browser is not installed -> skipping screenshots."); - console.log("Install it with: npm i -g agent-browser && agent-browser install"); - console.log("Then re-run: node extensions/connector-namespaces/preview/shots.mjs"); - process.exit(0); - } - - if (!(await serverUp())) { - console.error("preview server is not reachable at " + BASE); - console.error("start it first: node extensions/connector-namespaces/preview/server.mjs"); - process.exit(1); - } - - mkdirSync(SHOTS, { recursive: true }); - - // Static states. - const states = [ - ["catalog", `${BASE}/`], - ["catalog-restart-banner", `${BASE}/?restart=1`], - ["catalog-installed", `${BASE}/?installed=1`], - ["setup", `${BASE}/setup`], - ["error", `${BASE}/error`], - ]; - for (const [name, target] of states) { - ab(["open", target]); - ab(["screenshot", join(SHOTS, `${name}.png`)]); - console.log(`captured ${name}`); - } - - // Flow 1: connect -> connecting spinner. The preview /api/install returns - // needsConsent and /oauth-status stays pending, so the .si-spin modal - // spinner keeps animating. Best-effort selector; adjust if markup changes. - ab(["open", `${BASE}/`]); - ab(["click", ".item-add[data-api]"]); - ab(["screenshot", join(SHOTS, "connecting-spinner.png")]); - console.log("captured connecting-spinner (verify the spinner is mid-rotation)"); - - // Flow 2: banner -> dismiss -> gone. Screenshot before and after the click - // so a frozen/broken dismiss button is visible as a diff. - ab(["open", `${BASE}/?restart=1`]); - ab(["screenshot", join(SHOTS, "banner-before-dismiss.png")]); - ab(["click", ".restart-banner .rb-dismiss"]); - ab(["screenshot", join(SHOTS, "banner-after-dismiss.png")]); - console.log("captured banner-before-dismiss / banner-after-dismiss (after should have no banner)"); - - console.log(`\nshots written to ${SHOTS}`); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/extensions/connector-namespaces/renderer.mjs b/extensions/connector-namespaces/renderer.mjs deleted file mode 100644 index e4ddf609..00000000 --- a/extensions/connector-namespaces/renderer.mjs +++ /dev/null @@ -1,1563 +0,0 @@ -// Renderers for the connector namespace picker and connector catalog pages. -// Styled to match the reference connector extension UI. - -import { CATEGORY } from "./categories.mjs"; -import { buildSandboxUrl } from "./sandbox.mjs"; - -const CONNECT_ICON = ''; - -// Official Azure Connector Namespace mark — a gray viewfinder frame wrapping -// two interlocking blue-gradient chain links. Path + gradient data is lifted -// verbatim from the portal's ConnectorNamespaceIcon brand asset. idSuffix keeps -// the gradient element IDs unique when the mark renders more than once per page. -export function brandMark(size = 28, idSuffix = "m") { - const g0 = `cn-g0-${idSuffix}`; - const g1 = `cn-g1-${idSuffix}`; - return ``; -} - -export function baseStyles() { - return ``; -} - -// --------------------------------------------------------------------------- -// Setup / Namespace Picker -// --------------------------------------------------------------------------- - -export function renderSetupHtml(subscriptions, notice = "", capabilityToken = "") { - const subOptions = subscriptions.map((s) => - `` - ).join(""); - - return ` - -Select Connector Namespace${baseStyles()} - -
-

${brandMark(30, "setup")}Select a Connector Namespace

-
Choose which connector namespace to browse. This choice is saved for future sessions.
-
-${notice ? `
${esc(notice)}
` : ""} -
- -
-
- - -
- -
-
Select a subscription to see available connector namespaces.
-
-`; -} - -// --------------------------------------------------------------------------- -// Catalog -// --------------------------------------------------------------------------- - -const CSS_HEX_COLOR = /^#[0-9a-fA-F]{6}$/; - -function iconBackgroundStyle(brandColor) { - const color = String(brandColor || "").trim(); - return CSS_HEX_COLOR.test(color) ? ` style="background:${color}22"` : ""; -} - -export function renderCatalogHtml(instanceId, catalog, { filter, category, source, config }, capabilityToken = "") { - const renderItem = (c) => { - // Items carry their home grid so hydrateState can move them into - // "My MCPs" when added and back to Microsoft/Partner on remove. - const home = c.category === CATEGORY.microsoft ? "microsoft" : "partner"; - const icon = c.iconUri - ? `
` - : `
${esc(c.displayName.charAt(0))}
`; - // Button state is hydrated client-side from /api/state on load. - const btn = ``; - const haystack = esc((c.displayName + " " + (c.description || "")).toLowerCase()); - const sandboxUrl = esc(buildSandboxUrl(config, c.apiName)); - return `
${icon}
${esc(c.displayName)}
${esc(c.description)}
${btn}
`; - }; - - const byName = (a, b) => a.displayName.localeCompare(b.displayName); - const microsoft = catalog.filter((c) => c.category === CATEGORY.microsoft).sort(byName); - const partner = catalog.filter((c) => c.category !== CATEGORY.microsoft).sort(byName); - - const section = (key, title, rows, { collapsed, hidden }) => { - const cls = ["section", "collapsible"]; - if (collapsed) cls.push("collapsed"); - if (hidden) cls.push("is-hidden"); - const n = rows.length; - return `
` - + `` - + `
${rows.map(renderItem).join("")}
` - + `
`; - }; - - // Server paints the first-run layout: My MCPs hidden+empty (filled by - // hydrateState), Microsoft expanded so there's something to browse, Partner - // collapsed. updateSections() flips to the steady layout on the first hydrate - // if anything is already added. - let sectionsHtml = - section("mine", "My MCPs", [], { collapsed: false, hidden: true }) + - section("microsoft", "Microsoft", microsoft, { collapsed: false, hidden: microsoft.length === 0 }) + - section("partner", "Partners", partner, { collapsed: true, hidden: partner.length === 0 }); - - if (!catalog.length) { - sectionsHtml = `
No connectors available.
`; - } - - return ` - -Connectors${baseStyles()} - -
-
-

${brandMark(24, "cat")}Connectors

-
-
Namespace ${esc(config.gatewayName)} · RG ${esc(config.resourceGroup)}
-
- - - -
-
-
- - - - -
- -${sectionsHtml} - -`; -} - -// --------------------------------------------------------------------------- -// Error -// --------------------------------------------------------------------------- - -export function renderErrorHtml(message) { - return ` -Error${baseStyles()} -

Error

-
${esc(message)}
-`; -} - -// --------------------------------------------------------------------------- -function esc(s) { - return String(s ?? "").replace(/[&<>"']/g, (c) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); -} diff --git a/extensions/connector-namespaces/renderer.test.mjs b/extensions/connector-namespaces/renderer.test.mjs deleted file mode 100644 index d0c464e4..00000000 --- a/extensions/connector-namespaces/renderer.test.mjs +++ /dev/null @@ -1,393 +0,0 @@ -// Regression guards for the connector-catalog renderer. -// -// Run: node --test extensions/connector-namespaces/renderer.test.mjs -// -// These tests exist because two UX bugs kept coming back: -// 1. A `@media (prefers-reduced-motion: reduce)` rule froze functional -// loaders without a visible fallback. Reduced motion now stops the -// animation while forcing each loader into a visible static busy state; -// nearby text continues to communicate progress. -// 2. The "Restart your Copilot session" banner ignoring Dismiss. The real -// root cause was CSS specificity: `.restart-banner{display:flex}` is an -// author rule with the same (0,1,0) specificity as the UA -// `[hidden]{display:none}` rule, so it overrode the hidden attribute and -// `restartBanner.hidden=true` did nothing. The fix is a global -// `[hidden]{display:none !important}` reset. A client-side -// `restartDismissed` flag also keeps a late hydrateState() from re-showing -// it. The guards below fail if either the CSS reset or the JS gate -// disappears. - -import { test } from "node:test"; -import assert from "node:assert/strict"; - -import { baseStyles, renderCatalogHtml, renderSetupHtml } from "./renderer.mjs"; -import { renderCreateNamespaceHtml } from "./createPage.mjs"; -import { CATEGORY } from "./categories.mjs"; - -// Pull the balanced body of the prefers-reduced-motion media block out of a -// stylesheet string (non-greedy regex can't handle the nested rule braces). -// CSS comments are stripped so the guards test declarations rather than prose. -function reducedMotionBlock(css) { - const start = css.indexOf("@media (prefers-reduced-motion: reduce)"); - if (start === -1) return null; - const open = css.indexOf("{", start); - if (open === -1) return null; - let depth = 0; - for (let i = open; i < css.length; i++) { - if (css[i] === "{") depth++; - else if (css[i] === "}" && --depth === 0) { - return css.slice(open + 1, i).replace(/\/\*[\s\S]*?\*\//g, ""); - } - } - return null; -} - -function catalogHtml() { - return renderCatalogHtml("test-instance", [], { - filter: "", - category: "all", - source: "", - config: { subscriptionId: "sub", gatewayName: "ns", resourceGroup: "rg" }, - }); -} - -test("setup subscription label names its select", () => { - const html = renderSetupHtml([], "", "token"); - assert.match(html, /