diff --git a/.codespellrc b/.codespellrc index 1a42b532..d38ea35d 100644 --- a/.codespellrc +++ b/.codespellrc @@ -45,7 +45,9 @@ # Wee, Sherif - proper name (Wee, Sherif, contributor names should not be flagged as typos) # queston - intentional misspelling example in skills/arize-dataset/SKILL.md demonstrating typo detection in field names - + +# extenions - intentional misspelled key name used in plugin validators to detect invalid manifests + # nin - MongoDB $nin operator in security instructions NoSQL injection detection regex # Vertexes - FreeCAD shape sub-elements used as property of obj.Shape @@ -60,7 +62,7 @@ # Vally/vally - Name of product -ignore-words-list = numer,wit,aks,edn,ser,ois,gir,rouge,categor,aline,ative,afterall,deques,dateA,dateB,TE,FillIn,alle,vai,LOD,InOut,pixelX,aNULL,Wee,Sherif,queston,Vertexes,nin,FO,CAF,Parth,ans,gud,Vally,vally +ignore-words-list = numer,wit,aks,edn,ser,ois,gir,rouge,categor,aline,ative,afterall,deques,dateA,dateB,TE,FillIn,alle,vai,LOD,InOut,pixelX,aNULL,Wee,Sherif,queston,extenions,Vertexes,nin,FO,CAF,Parth,ans,gud,Vally,vally # Skip certain files and directories # *.tm7 - MTM DataContract exports; embedded base64 icon blobs trigger false positives diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 03689693..991790aa 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.0" + "version": "1.0.1" }, { "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.0" + "version": "1.0.1" }, { "name": "arcade-canvas", "source": "extensions/arcade-canvas", "description": "Play five retro Phaser mini-games in a Copilot canvas while agents work.", - "version": "1.0.0" + "version": "1.0.1" }, { "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.0" + "version": "1.0.1" }, { "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.0" + "version": "1.0.1" }, { "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.0" + "version": "1.0.1" }, { "name": "connector-namespaces", "source": "extensions/connector-namespaces", "description": "Browse, connect, and open MCP connectors from an Azure Connector Namespace.", - "version": "1.1.0" + "version": "1.1.1" }, { "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.0" + "version": "1.0.1" }, { "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.0" + "version": "1.0.1" }, { "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.0" + "version": "1.0.1" }, { "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.0" + "version": "1.0.1" }, { "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.0" + "version": "1.0.1" }, { "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.0" + "version": "1.0.1" }, { "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.0" + "version": "1.0.1" }, { "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.0" + "version": "1.0.1" }, { "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.0" + "version": "1.0.1" }, { "name": "typescript-mcp-development", @@ -1311,7 +1311,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.0" + "version": "1.0.1" }, { "name": "winappcli", @@ -1375,7 +1375,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.0" + "version": "1.0.1" } ] } diff --git a/.github/workflows/external-plugin-command-router.yml b/.github/workflows/external-plugin-command-router.yml index 1b8c317a..5769f3b5 100644 --- a/.github/workflows/external-plugin-command-router.yml +++ b/.github/workflows/external-plugin-command-router.yml @@ -753,6 +753,7 @@ jobs: vally_lint_status: 'infra_error', smoke_status: 'infra_error', version_match_status: 'infra_error', + canvas_structure_status: 'infra_error', failure_class: 'infra', summary: 'Quality-gate workflow failed unexpectedly. Re-run intake to retry.', }; @@ -764,6 +765,7 @@ jobs: vally_lint_status: 'infra_error', smoke_status: 'infra_error', version_match_status: 'infra_error', + canvas_structure_status: 'infra_error', failure_class: 'infra', summary: 'Quality-gate workflow did not return results. Re-run intake to retry.', }; diff --git a/.github/workflows/external-plugin-intake.yml b/.github/workflows/external-plugin-intake.yml index c3859a31..ca7852dd 100644 --- a/.github/workflows/external-plugin-intake.yml +++ b/.github/workflows/external-plugin-intake.yml @@ -116,6 +116,7 @@ jobs: vally_lint_status: 'infra_error', smoke_status: 'infra_error', version_match_status: 'infra_error', + canvas_structure_status: 'infra_error', failure_class: 'infra', summary: 'Quality-gate workflow failed unexpectedly. Re-run intake to retry.', }; @@ -127,6 +128,7 @@ jobs: vally_lint_status: 'infra_error', smoke_status: 'infra_error', version_match_status: 'infra_error', + canvas_structure_status: 'infra_error', failure_class: 'infra', summary: 'Quality-gate workflow did not return results. Re-run intake to retry.', }; diff --git a/.github/workflows/external-plugin-pr-quality-gates.yml b/.github/workflows/external-plugin-pr-quality-gates.yml index 807cb27d..3010788b 100644 --- a/.github/workflows/external-plugin-pr-quality-gates.yml +++ b/.github/workflows/external-plugin-pr-quality-gates.yml @@ -158,6 +158,7 @@ jobs: failure_class: 'infra', checked_plugins: [], version_match_status: 'infra_error', + canvas_structure_status: 'infra_error', summary: 'External plugin PR change detection failed unexpectedly. Re-run this workflow.', }; } else if (shouldRun) { @@ -167,6 +168,7 @@ jobs: failure_class: 'infra', checked_plugins: [], version_match_status: 'infra_error', + canvas_structure_status: 'infra_error', summary: 'External plugin PR quality checks failed unexpectedly. Re-run this workflow.', }; } else if (process.env.QUALITY_RESULT_JSON) { @@ -177,6 +179,7 @@ jobs: failure_class: 'infra', checked_plugins: [], version_match_status: 'infra_error', + canvas_structure_status: 'infra_error', summary: 'External plugin PR quality checks did not return a result payload.', }; } @@ -245,15 +248,16 @@ jobs: const sourceUrl = String(entry?.source_tree_url || ''); const locator = String(entry?.source?.sha || entry?.source?.ref || 'repository'); const sourceCell = sourceUrl ? `[${locator}](${sourceUrl})` : locator; - return `| ${name} | ${quality.vally_lint_status || 'not_run'} | ${quality.smoke_status || 'not_run'} | ${quality.version_match_status || 'not_run'} | ${quality.overall_status || 'not_run'} | ${sourceCell} |`; + return `| ${name} | ${quality.vally_lint_status || 'not_run'} | ${quality.smoke_status || 'not_run'} | ${quality.version_match_status || 'not_run'} | ${quality.canvas_structure_status || 'not_run'} | ${quality.overall_status || 'not_run'} | ${sourceCell} |`; }) - : ['| _none_ | not_run | not_run | not_run | not_run | _n/a_ |']; + : ['| _none_ | not_run | not_run | not_run | not_run | not_run | _n/a_ |']; const failureDetails = checkedPlugins.flatMap((entry) => { const name = String(entry?.name || 'unknown'); const quality = entry?.quality || {}; const shouldShowVally = quality.vally_lint_status === 'fail' || quality.vally_lint_status === 'infra_error' || String(quality.vally_lint_output || '').trim().length > 0; const shouldShowSmoke = quality.smoke_status === 'fail' || quality.smoke_status === 'infra_error' || String(quality.smoke_output || '').trim().length > 0; const shouldShowVersionMatch = quality.version_match_status === 'fail' || quality.version_match_status === 'infra_error' || String(quality.version_match_output || '').trim().length > 0; + const shouldShowCanvasStructure = quality.canvas_structure_status === 'fail' || quality.canvas_structure_status === 'infra_error' || String(quality.canvas_structure_output || '').trim().length > 0; const details = []; if (shouldShowVally) { @@ -265,6 +269,9 @@ jobs: if (shouldShowVersionMatch) { details.push(formatGateOutput(name, 'version match', quality.version_match_status, quality.version_match_output)); } + if (shouldShowCanvasStructure) { + details.push(formatGateOutput(name, 'canvas structure', quality.canvas_structure_status, quality.canvas_structure_output)); + } return details; }); @@ -277,8 +284,8 @@ jobs: '', '### Per-plugin quality summary', '', - '| Plugin | vally lint | install smoke test | version match | overall | source tree |', - '|---|---|---|---|---|---|', + '| Plugin | vally lint | install smoke test | version match | canvas structure | overall | source tree |', + '|---|---|---|---|---|---|---|', ...rows, '', ...(failureDetails.length > 0 diff --git a/AGENTS.md b/AGENTS.md index 7eedca71..3a17693c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,7 +89,7 @@ All agent files (`*.agent.md`) and instruction files (`*.instructions.md`) must - Extension `plugin.json` **must** follow the convention: - `name`, `description`, `version` are required - `logo` **must** be exactly `"assets/preview.png"` (enforced convention) - - `extensions` **must** be exactly `"."` (per [copilot-agent-runtime#9929](https://github.com/github/copilot-agent-runtime/pull/9929)) + - `extensions` **must** be exactly `"."` in source manifests (materialization rewrites this to `"extensions"` for distribution output) - Optional: `author`, `keywords` fields - **Must not** include `x-awesome-copilot` field (use convention-based `assets/preview.png` only) - Each extension must have `assets/preview.png` as the primary visual asset diff --git a/eng/clean-materialized-plugins.mjs b/eng/clean-materialized-plugins.mjs index 9379d78a..2fcbacc2 100644 --- a/eng/clean-materialized-plugins.mjs +++ b/eng/clean-materialized-plugins.mjs @@ -6,6 +6,7 @@ import { fileURLToPath } from "url"; import { ROOT_FOLDER } from "./constants.mjs"; const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins"); +const EXTENSIONS_DIR = path.join(ROOT_FOLDER, "extensions"); const MATERIALIZED_SPECS = { agents: { path: "agents", @@ -89,6 +90,30 @@ function cleanPlugin(pluginPath) { return { removed, manifestUpdated }; } +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")); + if (plugin.extensions === "extensions") { + plugin.extensions = "."; + fs.writeFileSync(pluginJsonPath, JSON.stringify(plugin, null, 2) + "\n", "utf8"); + manifestUpdated = true; + console.log(` Updated ${path.basename(extensionPath)}/.github/plugin/plugin.json`); + } + } + + const target = path.join(extensionPath, "extensions"); + if (!fs.existsSync(target) || !fs.statSync(target).isDirectory()) { + return { removed: 0, manifestUpdated }; + } + + const count = countFiles(target); + fs.rmSync(target, { recursive: true, force: true }); + console.log(` Removed ${path.basename(extensionPath)}/extensions/ (${count} files)`); + return { removed: count, manifestUpdated }; +} + function countFiles(dir) { let count = 0; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { @@ -171,6 +196,25 @@ function main() { } } + if (fs.existsSync(EXTENSIONS_DIR)) { + const extensionDirs = fs.readdirSync(EXTENSIONS_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + + for (const dirName of extensionDirs) { + const extensionPath = path.join(EXTENSIONS_DIR, dirName); + if (!fs.existsSync(path.join(extensionPath, "extension.mjs"))) { + continue; + } + const { removed, manifestUpdated } = cleanMaterializedExtensionPlugin(extensionPath); + total += removed; + if (manifestUpdated) { + manifestsUpdated++; + } + } + } + console.log(); if (total === 0 && manifestsUpdated === 0) { console.log("✅ No materialized files found. Plugins are already clean."); diff --git a/eng/external-plugin-intake.mjs b/eng/external-plugin-intake.mjs index 794e36f5..fa9696e1 100644 --- a/eng/external-plugin-intake.mjs +++ b/eng/external-plugin-intake.mjs @@ -459,6 +459,55 @@ async function validateCanvasPluginMetadata(plugin, errors, warnings, token) { ); } + if (manifest.extenions !== undefined) { + errors.push( + `submission: plugins tagged with "canvas" must use "extensions" (found misspelled key "extenions") in "${manifestPath}"`, + ); + } + + if (manifest.extensions !== undefined && manifest.extensions !== "extensions") { + errors.push( + `submission: plugins tagged with "canvas" may omit "extensions", but if provided it must be "extensions" in "${manifestPath}"`, + ); + } + + const extensionContainerPath = joinRepoPath(pluginRoot, "extensions"); + const extensionContainerResponse = await fetchGitHubFile(repo, extensionContainerPath, releaseLocator, token); + if (extensionContainerResponse.kind === "notFound") { + errors.push( + `submission: plugins tagged with "canvas" must include an "extensions" directory at ${releaseLocatorDescription}`, + ); + } else if (extensionContainerResponse.kind === "apiError") { + warnings.push( + `submission: could not verify "extensions" directory in GitHub repository "${repo}" at ${releaseLocatorDescription}; a maintainer should re-run intake`, + ); + } else if ( + !( + extensionContainerResponse.data?.type === "dir" + || Array.isArray(extensionContainerResponse.data) + ) + ) { + errors.push( + `submission: "extensions" must be a directory in ${releaseLocatorDescription}`, + ); + } + + const extensionEntryPath = joinRepoPath(pluginRoot, "extensions", "extension.mjs"); + const extensionEntryResponse = await fetchGitHubFile(repo, extensionEntryPath, releaseLocator, token); + if (extensionEntryResponse.kind === "notFound") { + errors.push( + `submission: plugins tagged with "canvas" must include "extensions/extension.mjs" at ${releaseLocatorDescription}`, + ); + } else if (extensionEntryResponse.kind === "apiError") { + warnings.push( + `submission: could not verify "extensions/extension.mjs" in GitHub repository "${repo}" at ${releaseLocatorDescription}; a maintainer should re-run intake`, + ); + } else if (extensionEntryResponse.data?.type !== "file") { + errors.push( + `submission: "extensions/extension.mjs" must be a file in ${releaseLocatorDescription}`, + ); + } + const previewPath = joinRepoPath(pluginRoot, EXTERNAL_CANVAS_PREVIEW_PATH); const previewResponse = await fetchGitHubFile(repo, previewPath, releaseLocator, token); if (previewResponse.kind === "notFound") { @@ -580,11 +629,13 @@ function normalizeQualityGateResult(rawResult) { vally_lint_status: "not_run", smoke_status: "not_run", version_match_status: "not_run", + canvas_structure_status: "not_run", failure_class: "none", summary: "", vally_lint_output: "", smoke_output: "", version_match_output: "", + canvas_structure_output: "", }; if (!rawResult || typeof rawResult !== "object" || Array.isArray(rawResult)) { @@ -601,6 +652,7 @@ function buildQualityGatesCommentSection(qualityResult) { const vallyState = qualityResult.vally_lint_status || "not_run"; const smokeState = qualityResult.smoke_status || "not_run"; const versionMatchState = qualityResult.version_match_status || "not_run"; + const canvasStructureState = qualityResult.canvas_structure_status || "not_run"; const summaryText = String(qualityResult.summary || "").trim() || "_No quality gate details were provided._"; const sections = [ @@ -611,6 +663,7 @@ function buildQualityGatesCommentSection(qualityResult) { `| vally lint | ${vallyState} |`, `| install smoke test | ${smokeState} |`, `| version match | ${versionMatchState} |`, + `| canvas structure | ${canvasStructureState} |`, "", summaryText, ]; @@ -660,6 +713,21 @@ function buildQualityGatesCommentSection(qualityResult) { ); } + const canvasStructureOutput = String(qualityResult.canvas_structure_output || "").trim(); + if (canvasStructureOutput) { + sections.push( + "", + "
", + "Canvas structure output", + "", + "```text", + canvasStructureOutput, + "```", + "", + "
", + ); + } + return sections.join("\n"); } diff --git a/eng/external-plugin-pr-quality-gates.mjs b/eng/external-plugin-pr-quality-gates.mjs index 5cbf4c9b..85cd6d9c 100644 --- a/eng/external-plugin-pr-quality-gates.mjs +++ b/eng/external-plugin-pr-quality-gates.mjs @@ -86,7 +86,7 @@ export async function runExternalPluginPrQualityGates(plugins) { ? "No changed external plugin entries were detected in plugins/external.json." : checkedPlugins .map((entry) => - `- ${entry.name}: vally-lint=${entry.quality.vally_lint_status}, install-smoke=${entry.quality.smoke_status}, version-match=${entry.quality.version_match_status}, overall=${entry.quality.overall_status}` + `- ${entry.name}: vally-lint=${entry.quality.vally_lint_status}, install-smoke=${entry.quality.smoke_status}, version-match=${entry.quality.version_match_status}, canvas-structure=${entry.quality.canvas_structure_status}, overall=${entry.quality.overall_status}` ) .join("\n"); diff --git a/eng/external-plugin-quality-gates.mjs b/eng/external-plugin-quality-gates.mjs index 358ecdda..507cc28f 100644 --- a/eng/external-plugin-quality-gates.mjs +++ b/eng/external-plugin-quality-gates.mjs @@ -8,6 +8,7 @@ import { spawnSync } from "child_process"; import { runLint, LintConsoleReporter } from "@microsoft/vally"; const MAX_OUTPUT_LENGTH = 12000; +const EXTERNAL_CANVAS_KEYWORD = "canvas"; const INFRA_ERROR_PATTERNS = [ /\b401\b/, @@ -69,6 +70,12 @@ function normalizePluginPath(pluginPath) { return normalized; } +function hasCanvasKeyword(plugin) { + return (plugin?.keywords ?? []).some( + (keyword) => String(keyword).trim().toLowerCase() === EXTERNAL_CANVAS_KEYWORD, + ); +} + function resolveFetchSpec(pluginSource) { if (pluginSource.sha) { return pluginSource.sha; @@ -467,6 +474,148 @@ function runVersionMatchGate(repoDir, plugin, primaryFetchSpec) { }; } +function checkPathExistsAtLocator(repoDir, locator, repoPath, expectedType) { + const result = runCommand("git", ["cat-file", "-e", `${locator}:${repoPath}`], { cwd: repoDir }); + if (result.exitCode === 0) { + if (!expectedType) { + return { exists: true, output: "" }; + } + + const typeResult = runCommand("git", ["cat-file", "-t", `${locator}:${repoPath}`], { cwd: repoDir }); + if (typeResult.exitCode !== 0) { + return { + exists: false, + output: `Unable to verify path "${repoPath}" type at "${locator}": ${typeResult.output}`, + }; + } + + const actualType = String(typeResult.stdout ?? "").trim(); + if (actualType !== expectedType) { + return { + exists: false, + output: "", + kindMismatch: true, + actualType, + }; + } + + return { exists: true, output: "" }; + } + + const normalizedOutput = String(result.output ?? "").toLowerCase(); + if ( + normalizedOutput.includes("not a valid object name") + || normalizedOutput.includes("path '") + || normalizedOutput.includes("does not exist") + ) { + return { exists: false, output: "" }; + } + + return { + exists: false, + output: `Unable to verify path "${repoPath}" at "${locator}": ${result.output}`, + }; +} + +export function runCanvasStructureGate(repoDir, plugin, primaryFetchSpec) { + if (!hasCanvasKeyword(plugin)) { + return { + status: "not_run", + output: "Canvas structure gate skipped because plugin is not tagged with \"canvas\".", + }; + } + + const normalizedPluginPath = normalizePluginPath(plugin?.source?.path || "/"); + const locators = [plugin?.source?.ref, plugin?.source?.sha] + .filter((value) => typeof value === "string" && value.trim().length > 0) + .map((value) => value.trim()) + .filter((value, index, values) => values.indexOf(value) === index); + + if (locators.length === 0) { + return { + status: "not_run", + output: "Canvas structure gate skipped because neither source.ref nor source.sha was provided.", + }; + } + + const extensionsDir = toPosixPath(normalizedPluginPath, "extensions"); + const extensionEntryPoint = toPosixPath(extensionsDir, "extension.mjs"); + + let hasFailure = false; + let hasInfraError = false; + const messages = []; + + for (const locator of locators) { + if (locator !== primaryFetchSpec) { + const fetchResult = fetchLocatorIntoRepo(repoDir, locator); + if (fetchResult.status === "fail") { + hasFailure = true; + messages.push(`- ${locator}: ${fetchResult.output}`); + continue; + } + + if (fetchResult.status === "infra_error") { + hasInfraError = true; + messages.push(`- ${locator}: ${fetchResult.output}`); + continue; + } + } + + const extensionDirCheck = checkPathExistsAtLocator(repoDir, locator, extensionsDir, "tree"); + if (extensionDirCheck.output) { + hasInfraError = true; + messages.push(`- ${locator}: ${extensionDirCheck.output}`); + continue; + } + if (!extensionDirCheck.exists) { + hasFailure = true; + if (extensionDirCheck.kindMismatch) { + messages.push(`- ${locator}: "${extensionsDir}" must be a directory.`); + } else { + messages.push(`- ${locator}: missing required canvas extension directory "${extensionsDir}".`); + } + continue; + } + + const extensionEntryCheck = checkPathExistsAtLocator(repoDir, locator, extensionEntryPoint, "blob"); + if (extensionEntryCheck.output) { + hasInfraError = true; + messages.push(`- ${locator}: ${extensionEntryCheck.output}`); + continue; + } + if (!extensionEntryCheck.exists) { + hasFailure = true; + if (extensionEntryCheck.kindMismatch) { + messages.push(`- ${locator}: "${extensionEntryPoint}" must be a file.`); + } else { + messages.push(`- ${locator}: missing required canvas extension entry point "${extensionEntryPoint}".`); + } + continue; + } + + messages.push(`- ${locator}: found "${extensionsDir}" with entry point "${extensionEntryPoint}".`); + } + + if (hasInfraError) { + return { + status: "infra_error", + output: messages.join("\n"), + }; + } + + if (hasFailure) { + return { + status: "fail", + output: messages.join("\n"), + }; + } + + return { + status: "pass", + output: messages.join("\n"), + }; +} + function toOverallStatus(states) { if (states.includes("infra_error")) { return "infra_error"; @@ -497,11 +646,13 @@ export async function runExternalPluginQualityGates(plugin) { vally_lint_status: "not_run", smoke_status: "not_run", version_match_status: "not_run", + canvas_structure_status: "not_run", failure_class: "none", summary: "", vally_lint_output: "", smoke_output: "", version_match_output: "", + canvas_structure_output: "", }; try { @@ -513,10 +664,14 @@ export async function runExternalPluginQualityGates(plugin) { result.vally_lint_status = "fail"; result.smoke_status = "fail"; result.version_match_status = "fail"; + result.canvas_structure_status = hasCanvasKeyword(plugin) ? "fail" : "not_run"; result.overall_status = "fail"; result.failure_class = "submitter_fixes"; result.summary = `Plugin path "${plugin.source?.path || "/"}" was not found in the submitted repository snapshot.`; result.version_match_output = result.summary; + if (hasCanvasKeyword(plugin)) { + result.canvas_structure_output = result.summary; + } return result; } @@ -524,6 +679,10 @@ export async function runExternalPluginQualityGates(plugin) { result.version_match_status = versionMatchResult.status; result.version_match_output = versionMatchResult.output; + const canvasStructureResult = runCanvasStructureGate(repoDir, plugin, fetchSpec); + result.canvas_structure_status = canvasStructureResult.status; + result.canvas_structure_output = canvasStructureResult.output; + const vallyResult = await runVallyLintGate(pluginRoot); result.vally_lint_status = vallyResult.status; result.vally_lint_output = vallyResult.output; @@ -532,12 +691,18 @@ export async function runExternalPluginQualityGates(plugin) { result.smoke_status = smokeResult.status; result.smoke_output = smokeResult.output; - result.overall_status = toOverallStatus([result.vally_lint_status, result.smoke_status, result.version_match_status]); + result.overall_status = toOverallStatus([ + result.vally_lint_status, + result.smoke_status, + result.version_match_status, + result.canvas_structure_status, + ]); result.failure_class = toFailureClass(result.overall_status); result.summary = [ `- vally lint: ${result.vally_lint_status}`, `- install smoke test: ${result.smoke_status}`, `- version match: ${result.version_match_status}`, + `- canvas structure: ${result.canvas_structure_status}`, `- overall: ${result.overall_status}`, ].join("\n"); diff --git a/eng/external-plugin-quality-gates.test.mjs b/eng/external-plugin-quality-gates.test.mjs new file mode 100644 index 00000000..5f619fae --- /dev/null +++ b/eng/external-plugin-quality-gates.test.mjs @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { spawnSync } from "child_process"; +import { after, test } from "node:test"; +import { runCanvasStructureGate } from "./external-plugin-quality-gates.mjs"; + +const tempDirs = []; + +after(() => { + for (const dir of tempDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +function runGit(repoDir, ...args) { + const result = spawnSync("git", args, { cwd: repoDir, encoding: "utf8" }); + if (result.status !== 0) { + throw new Error(`git ${args.join(" ")} failed: ${result.stdout}\n${result.stderr}`); + } + return String(result.stdout ?? "").trim(); +} + +function createTempRepo() { + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "external-plugin-quality-")); + tempDirs.push(repoDir); + + runGit(repoDir, "init", "-q"); + runGit(repoDir, "config", "user.name", "Copilot Test"); + runGit(repoDir, "config", "user.email", "copilot@example.com"); + return repoDir; +} + +function commitAll(repoDir, message) { + runGit(repoDir, "add", "-A"); + runGit(repoDir, "commit", "-m", message, "--quiet"); + return runGit(repoDir, "rev-parse", "HEAD"); +} + +test("runCanvasStructureGate passes when extensions/extension.mjs exists", () => { + const repoDir = createTempRepo(); + fs.mkdirSync(path.join(repoDir, "extensions"), { recursive: true }); + fs.writeFileSync(path.join(repoDir, "extensions", "extension.mjs"), "export default {};\n"); + const sha = commitAll(repoDir, "Add canvas extension container"); + + const plugin = { + name: "canvas-plugin", + keywords: ["canvas"], + source: { + source: "github", + repo: "owner/repo", + sha, + }, + }; + + const result = runCanvasStructureGate(repoDir, plugin, sha); + assert.equal(result.status, "pass"); + assert.match(result.output, /found "extensions"/); +}); + +test("runCanvasStructureGate fails when extension entrypoint is only at repo root", () => { + const repoDir = createTempRepo(); + fs.writeFileSync(path.join(repoDir, "extension.mjs"), "export default {};\n"); + const sha = commitAll(repoDir, "Add root extension entrypoint"); + + const plugin = { + name: "canvas-plugin", + keywords: ["canvas"], + source: { + source: "github", + repo: "owner/repo", + sha, + }, + }; + + const result = runCanvasStructureGate(repoDir, plugin, sha); + assert.equal(result.status, "fail"); + assert.match(result.output, /missing required canvas extension directory "extensions"/); +}); + +test("runCanvasStructureGate fails when extension entrypoint path is a directory", () => { + const repoDir = createTempRepo(); + fs.mkdirSync(path.join(repoDir, "extensions", "extension.mjs"), { recursive: true }); + fs.writeFileSync(path.join(repoDir, "extensions", "extension.mjs", "placeholder.txt"), "not-a-module\n"); + const sha = commitAll(repoDir, "Add invalid extension entrypoint directory"); + + const plugin = { + name: "canvas-plugin", + keywords: ["canvas"], + source: { + source: "github", + repo: "owner/repo", + sha, + }, + }; + + const result = runCanvasStructureGate(repoDir, plugin, sha); + assert.equal(result.status, "fail"); + assert.match(result.output, /"extensions\/extension\.mjs" must be a file/); +}); diff --git a/eng/materialize-plugins.mjs b/eng/materialize-plugins.mjs index 978c250f..2d91cb61 100644 --- a/eng/materialize-plugins.mjs +++ b/eng/materialize-plugins.mjs @@ -2,9 +2,11 @@ import fs from "fs"; import path from "path"; +import { fileURLToPath } from "url"; import { ROOT_FOLDER } from "./constants.mjs"; const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins"); +const EXTENSIONS_DIR = path.join(ROOT_FOLDER, "extensions"); /** * Recursively copy a directory. @@ -22,6 +24,17 @@ function copyDirRecursive(src, dest) { } } +function copyEntryRecursive(srcPath, destPath) { + const stats = fs.statSync(srcPath); + if (stats.isDirectory()) { + copyDirRecursive(srcPath, destPath); + return; + } + + fs.mkdirSync(path.dirname(destPath), { recursive: true }); + fs.copyFileSync(srcPath, destPath); +} + /** * Resolve a plugin-relative path to the repo-root source file. * @@ -45,6 +58,48 @@ function resolveSource(relPath) { return null; } +export function materializeExtensionPlugin(extensionPath) { + const pluginJsonPath = path.join(extensionPath, ".github", "plugin", "plugin.json"); + if (!fs.existsSync(pluginJsonPath)) { + return { copiedEntries: 0, manifestUpdated: false, skipped: true }; + } + + let metadata; + try { + metadata = JSON.parse(fs.readFileSync(pluginJsonPath, "utf8")); + } catch (err) { + throw new Error(`Failed to parse ${pluginJsonPath}: ${err.message}`); + } + + const extensionContainerPath = path.join(extensionPath, "extensions"); + fs.rmSync(extensionContainerPath, { recursive: true, force: true }); + fs.mkdirSync(extensionContainerPath, { recursive: true }); + + let copiedEntries = 0; + for (const entry of fs.readdirSync(extensionPath, { withFileTypes: true })) { + if (entry.name === ".github" || entry.name === "extensions") { + continue; + } + + copyEntryRecursive( + path.join(extensionPath, entry.name), + path.join(extensionContainerPath, entry.name) + ); + copiedEntries++; + } + + let manifestUpdated = false; + if (metadata.extensions !== "extensions") { + metadata.extensions = "extensions"; + manifestUpdated = true; + } + if (manifestUpdated) { + fs.writeFileSync(pluginJsonPath, JSON.stringify(metadata, null, 2) + "\n", "utf8"); + } + + return { copiedEntries, manifestUpdated, skipped: false }; +} + function materializePlugins() { console.log("Materializing plugin files...\n"); @@ -61,6 +116,8 @@ function materializePlugins() { let totalAgents = 0; let totalSkills = 0; let totalExtensions = 0; + let totalExtensionPlugins = 0; + let totalExtensionPluginEntries = 0; let warnings = 0; let errors = 0; @@ -185,7 +242,36 @@ function materializePlugins() { } } - console.log(`\nDone. Copied ${totalAgents} agents, ${totalSkills} skills, ${totalExtensions} extensions.`); + if (fs.existsSync(EXTENSIONS_DIR)) { + const extensionDirs = fs.readdirSync(EXTENSIONS_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + + for (const dirName of extensionDirs) { + const extensionPath = path.join(EXTENSIONS_DIR, dirName); + if (!fs.existsSync(path.join(extensionPath, "extension.mjs"))) { + continue; + } + + try { + const result = materializeExtensionPlugin(extensionPath); + if (result.skipped) { + continue; + } + + totalExtensionPlugins++; + totalExtensionPluginEntries += result.copiedEntries; + console.log(`✓ ${dirName}: materialized extension bundle into ./extensions (${result.copiedEntries} entries)`); + } catch (err) { + console.error(`Error: Failed to materialize extension plugin ${dirName}: ${err.message}`); + errors++; + } + } + } + + console.log(`\nDone. Copied ${totalAgents} agents, ${totalSkills} skills, ${totalExtensions} plugin extension refs.`); + console.log(`Materialized ${totalExtensionPlugins} extension plugins (${totalExtensionPluginEntries} top-level entries).`); if (warnings > 0) { console.log(`${warnings} warning(s).`); } @@ -195,4 +281,8 @@ function materializePlugins() { } } -materializePlugins(); +export { materializePlugins }; + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + materializePlugins(); +} diff --git a/eng/materialize-plugins.test.mjs b/eng/materialize-plugins.test.mjs new file mode 100644 index 00000000..7f42981c --- /dev/null +++ b/eng/materialize-plugins.test.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { after, test } from "node:test"; +import { materializeExtensionPlugin } from "./materialize-plugins.mjs"; + +const tempDirs = []; + +after(() => { + for (const dir of tempDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("materializeExtensionPlugin writes extension bundles to ./extensions and rewrites manifest", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "materialize-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"); + + const result = materializeExtensionPlugin(pluginDir); + + 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(fs.existsSync(path.join(pluginDir, "extensions", ".github")), false); + + const pluginManifest = JSON.parse( + fs.readFileSync(path.join(pluginDir, ".github", "plugin", "plugin.json"), "utf8") + ); + assert.equal(pluginManifest.extensions, "extensions"); +}); diff --git a/eng/validate-plugins.mjs b/eng/validate-plugins.mjs index fc8003b3..7161df06 100755 --- a/eng/validate-plugins.mjs +++ b/eng/validate-plugins.mjs @@ -305,9 +305,14 @@ function validateExtensionManifest(folderName) { errors.push("x-awesome-copilot field must not be present (use convention-based logo instead)"); } - // Extension convention: extensions field must be "." + if (parsed.extenions !== undefined) { + errors.push('use "extensions" field (found misspelled key "extenions")'); + } + + // Extension convention: source manifests keep extensions at repository root. + // Materialization rewrites this to "extensions" on distribution branches. if (parsed.extensions !== ".") { - errors.push('extensions field must be exactly "." (extension convention)'); + errors.push('extensions field must be exactly "." in source manifests (extension convention)'); } return { errors, plugin: parsedPlugin }; diff --git a/extensions/accessibility-kanban/.github/plugin/plugin.json b/extensions/accessibility-kanban/.github/plugin/plugin.json index 824a71c5..87e98225 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.0", + "version": "1.0.1", "author": { "name": "Aaron Powell", "url": "https://github.com/aaronpowell" @@ -15,5 +15,5 @@ "status-tracking" ], "logo": "assets/preview.png", - "extensions": "." + "extensions": "extensions" } diff --git a/extensions/accessibility-kanban/extensions/assets/preview.png b/extensions/accessibility-kanban/extensions/assets/preview.png new file mode 100644 index 00000000..3a2e8ae3 Binary files /dev/null and b/extensions/accessibility-kanban/extensions/assets/preview.png differ diff --git a/extensions/accessibility-kanban/extensions/extension.mjs b/extensions/accessibility-kanban/extensions/extension.mjs new file mode 100644 index 00000000..777f205a --- /dev/null +++ b/extensions/accessibility-kanban/extensions/extension.mjs @@ -0,0 +1,728 @@ +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/extensions/package.json b/extensions/accessibility-kanban/extensions/package.json new file mode 100644 index 00000000..48b33dbd --- /dev/null +++ b/extensions/accessibility-kanban/extensions/package.json @@ -0,0 +1,18 @@ +{ + "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/extensions/public/index.html b/extensions/accessibility-kanban/extensions/public/index.html new file mode 100644 index 00000000..01338af9 --- /dev/null +++ b/extensions/accessibility-kanban/extensions/public/index.html @@ -0,0 +1,754 @@ + + + + + +Repository Issues Kanban + + + + + +
+
+ + +
+ + +
+ +
+
+ + + + + + + diff --git a/extensions/apng-studio/.github/plugin/plugin.json b/extensions/apng-studio/.github/plugin/plugin.json index 6c46943c..a356e61c 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.0", + "version": "1.0.1", "author": { "name": "Andrea Griffiths", "url": "https://github.com/AndreaGriffiths11" @@ -15,5 +15,5 @@ "interactive-canvas" ], "logo": "assets/preview.png", - "extensions": "." + "extensions": "extensions" } diff --git a/extensions/apng-studio/extensions/.gitignore b/extensions/apng-studio/extensions/.gitignore new file mode 100644 index 00000000..5db7b303 --- /dev/null +++ b/extensions/apng-studio/extensions/.gitignore @@ -0,0 +1,11 @@ +# 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 new file mode 100644 index 00000000..da487c02 --- /dev/null +++ b/extensions/apng-studio/extensions/LICENSE @@ -0,0 +1,21 @@ +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 new file mode 100644 index 00000000..27a71767 --- /dev/null +++ b/extensions/apng-studio/extensions/README.md @@ -0,0 +1,97 @@ +# 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/extensions/apng.mjs b/extensions/apng-studio/extensions/apng.mjs new file mode 100644 index 00000000..64fffc5b --- /dev/null +++ b/extensions/apng-studio/extensions/apng.mjs @@ -0,0 +1,310 @@ +// 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 new file mode 100644 index 00000000..e36882c8 Binary files /dev/null and b/extensions/apng-studio/extensions/assets/demo.png differ diff --git a/extensions/apng-studio/extensions/assets/preview.png b/extensions/apng-studio/extensions/assets/preview.png new file mode 100644 index 00000000..78cf5755 Binary files /dev/null and b/extensions/apng-studio/extensions/assets/preview.png differ diff --git a/extensions/apng-studio/extensions/copilot-extension.json b/extensions/apng-studio/extensions/copilot-extension.json new file mode 100644 index 00000000..5841b689 --- /dev/null +++ b/extensions/apng-studio/extensions/copilot-extension.json @@ -0,0 +1,4 @@ +{ + "name": "apng-studio", + "version": 1 +} diff --git a/extensions/apng-studio/extensions/extension.mjs b/extensions/apng-studio/extensions/extension.mjs new file mode 100644 index 00000000..5dc4bb4a --- /dev/null +++ b/extensions/apng-studio/extensions/extension.mjs @@ -0,0 +1,1234 @@ +// 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/extensions/qr.mjs b/extensions/apng-studio/extensions/qr.mjs new file mode 100644 index 00000000..e90e0703 --- /dev/null +++ b/extensions/apng-studio/extensions/qr.mjs @@ -0,0 +1,438 @@ +// 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/extensions/web/app.js b/extensions/apng-studio/extensions/web/app.js new file mode 100644 index 00000000..7ace7abc --- /dev/null +++ b/extensions/apng-studio/extensions/web/app.js @@ -0,0 +1,669 @@ +"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/extensions/web/index.html b/extensions/apng-studio/extensions/web/index.html new file mode 100644 index 00000000..2576082a --- /dev/null +++ b/extensions/apng-studio/extensions/web/index.html @@ -0,0 +1,189 @@ + + + + + + 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/extensions/web/styles.css b/extensions/apng-studio/extensions/web/styles.css new file mode 100644 index 00000000..9fc2a3d7 --- /dev/null +++ b/extensions/apng-studio/extensions/web/styles.css @@ -0,0 +1,620 @@ +: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 8bf1d4cb..4c372598 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.0", + "version": "1.0.1", "author": { "name": "Dan Wahlin", "url": "https://github.com/DanWahlin" @@ -15,5 +15,5 @@ "session-breaks" ], "logo": "assets/preview.png", - "extensions": "." + "extensions": "extensions" } diff --git a/extensions/arcade-canvas/extensions/README.md b/extensions/arcade-canvas/extensions/README.md new file mode 100644 index 00000000..398ff9e2 --- /dev/null +++ b/extensions/arcade-canvas/extensions/README.md @@ -0,0 +1,68 @@ +# 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/extensions/assets/asset-.md b/extensions/arcade-canvas/extensions/assets/asset-.md new file mode 100644 index 00000000..88354f6f --- /dev/null +++ b/extensions/arcade-canvas/extensions/assets/asset-.md @@ -0,0 +1,6 @@ +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 new file mode 100644 index 00000000..1c26723c Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/canvas-background.webp 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 new file mode 100644 index 00000000..019e5366 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/cosmic-rocks/sounds/sfx_explosion.ogg 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 new file mode 100644 index 00000000..7a9a4d2f Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/cosmic-rocks/sounds/sfx_laser1.ogg 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 new file mode 100644 index 00000000..496968f8 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/cosmic-rocks/sounds/sfx_lose.ogg 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 new file mode 100644 index 00000000..20274928 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/cosmic-rocks/sounds/sfx_twoTone.ogg differ diff --git a/extensions/arcade-canvas/extensions/assets/defender/baiter.png b/extensions/arcade-canvas/extensions/assets/defender/baiter.png new file mode 100644 index 00000000..d52ec970 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/defender/baiter.png differ diff --git a/extensions/arcade-canvas/extensions/assets/defender/bomber.png b/extensions/arcade-canvas/extensions/assets/defender/bomber.png new file mode 100644 index 00000000..77608d79 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/defender/bomber.png differ diff --git a/extensions/arcade-canvas/extensions/assets/defender/humanoid.png b/extensions/arcade-canvas/extensions/assets/defender/humanoid.png new file mode 100644 index 00000000..7c9d97aa Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/defender/humanoid.png differ diff --git a/extensions/arcade-canvas/extensions/assets/defender/lander.png b/extensions/arcade-canvas/extensions/assets/defender/lander.png new file mode 100644 index 00000000..d0c6b8b3 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/defender/lander.png differ diff --git a/extensions/arcade-canvas/extensions/assets/defender/mutant.png b/extensions/arcade-canvas/extensions/assets/defender/mutant.png new file mode 100644 index 00000000..959b5b77 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/defender/mutant.png 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 new file mode 100644 index 00000000..8656e3e5 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/defender/planet-guard-sprites.png differ diff --git a/extensions/arcade-canvas/extensions/assets/defender/pod.png b/extensions/arcade-canvas/extensions/assets/defender/pod.png new file mode 100644 index 00000000..5b175afd Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/defender/pod.png differ diff --git a/extensions/arcade-canvas/extensions/assets/defender/ship.png b/extensions/arcade-canvas/extensions/assets/defender/ship.png new file mode 100644 index 00000000..7cd194d4 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/defender/ship.png differ diff --git a/extensions/arcade-canvas/extensions/assets/defender/ship_left.png b/extensions/arcade-canvas/extensions/assets/defender/ship_left.png new file mode 100644 index 00000000..5f613e6f Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/defender/ship_left.png 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 new file mode 100644 index 00000000..3e3f1677 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_baiterwarning.ogg 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 new file mode 100644 index 00000000..2e7766e2 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_bonus.ogg 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 new file mode 100644 index 00000000..48f06d10 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_enemydead.ogg 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 new file mode 100644 index 00000000..6ba909aa Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_enemyshoot.ogg 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 new file mode 100644 index 00000000..b1cd5f52 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_enemyshoot2.ogg 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 new file mode 100644 index 00000000..004f63f5 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_explode.ogg 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 new file mode 100644 index 00000000..c21e4be3 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_humanoiddead.ogg 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 new file mode 100644 index 00000000..7c88c7b0 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_laser.ogg 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 new file mode 100644 index 00000000..11d4015e Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_player1up.ogg 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 new file mode 100644 index 00000000..39e8f0a0 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_playerdead.ogg 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 new file mode 100644 index 00000000..7b6a4c47 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_start.ogg 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 new file mode 100644 index 00000000..92e18e0d Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_thurst.ogg 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 new file mode 100644 index 00000000..96a56290 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/defender/sounds/sound_warning.ogg differ diff --git a/extensions/arcade-canvas/extensions/assets/defender/swarmer.png b/extensions/arcade-canvas/extensions/assets/defender/swarmer.png new file mode 100644 index 00000000..41b439af Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/defender/swarmer.png 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 new file mode 100644 index 00000000..019e5366 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/galaxy-blaster/sounds/sfx_explosion.ogg 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 new file mode 100644 index 00000000..7a9a4d2f Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/galaxy-blaster/sounds/sfx_laser1.ogg 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 new file mode 100644 index 00000000..6a2d4c5a Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/galaxy-blaster/sounds/sfx_laser2.ogg 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 new file mode 100644 index 00000000..496968f8 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/galaxy-blaster/sounds/sfx_lose.ogg 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 new file mode 100644 index 00000000..e3a7a514 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/galaxy-blaster/sounds/sfx_shieldDown.ogg 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 new file mode 100644 index 00000000..49fdb6cc Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/galaxy-blaster/sounds/sfx_shieldUp.ogg 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 new file mode 100644 index 00000000..20274928 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/galaxy-blaster/sounds/sfx_twoTone.ogg 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 new file mode 100644 index 00000000..3f6250d3 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/galaxy-blaster/sounds/sfx_zap.ogg 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 new file mode 100644 index 00000000..d9c3fd42 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/galaxy-blaster/space_bg.png 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 new file mode 100644 index 00000000..daad6d14 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/galaxy-blaster/space_sheet-2-black.png 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 new file mode 100644 index 00000000..1dc51868 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/galaxy-blaster/space_sheet-2.png 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 new file mode 100644 index 00000000..c7751658 --- /dev/null +++ b/extensions/arcade-canvas/extensions/assets/galaxy-blaster/space_sheet-2.xml @@ -0,0 +1,297 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 new file mode 100644 index 00000000..8c58b86c Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/galaxy-blaster/space_sheet.png 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 new file mode 100644 index 00000000..71e1ccf1 --- /dev/null +++ b/extensions/arcade-canvas/extensions/assets/galaxy-blaster/space_sheet.xml @@ -0,0 +1,296 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/extensions/arcade-canvas/extensions/assets/icon.png b/extensions/arcade-canvas/extensions/assets/icon.png new file mode 100644 index 00000000..43d071c5 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/icon.png differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/background.png b/extensions/arcade-canvas/extensions/assets/ninja-runner/background.png new file mode 100644 index 00000000..9715a333 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/background.png 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 new file mode 100644 index 00000000..b1d517ed Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/big_bush.png differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/bridge.png b/extensions/arcade-canvas/extensions/assets/ninja-runner/bridge.png new file mode 100644 index 00000000..b34648d2 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/bridge.png 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 new file mode 100644 index 00000000..5983f8d9 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/brown_block.png differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/clouds.png b/extensions/arcade-canvas/extensions/assets/ninja-runner/clouds.png new file mode 100644 index 00000000..e69c8707 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/clouds.png 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 new file mode 100644 index 00000000..56166bb0 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/coin_sheet.png 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 new file mode 100644 index 00000000..0b7dd760 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/dirt_block.png 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 new file mode 100644 index 00000000..81d85d06 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/enemy_short_strip.png 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 new file mode 100644 index 00000000..50993a03 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/enemy_strip.png 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 new file mode 100644 index 00000000..7ea3effa Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/enemy_tall_strip.png differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/flag.png b/extensions/arcade-canvas/extensions/assets/ninja-runner/flag.png new file mode 100644 index 00000000..627396a3 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/flag.png 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 new file mode 100644 index 00000000..51c2a71d Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/grass_block.png 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 new file mode 100644 index 00000000..12c18859 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/heart_sheet.png 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 new file mode 100644 index 00000000..f555564c Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/hill_0.png 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 new file mode 100644 index 00000000..41117d27 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/hill_1.png 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 new file mode 100644 index 00000000..6039534c Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/impact_sheet.png differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/platform.png b/extensions/arcade-canvas/extensions/assets/ninja-runner/platform.png new file mode 100644 index 00000000..65e01a5b Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/platform.png 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 new file mode 100644 index 00000000..d283d3e4 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/player_strip.png 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 new file mode 100644 index 00000000..4248ee22 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/qblock_new.png 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 new file mode 100644 index 00000000..4537481b Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/small_bush.png 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 new file mode 100644 index 00000000..64c74137 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundBlowClub.m4a 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 new file mode 100644 index 00000000..70fa9200 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundBlowDull.m4a 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 new file mode 100644 index 00000000..fe27d29e Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundBonus.m4a 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 new file mode 100644 index 00000000..17f68dc0 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundBounce.m4a 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 new file mode 100644 index 00000000..de0201b4 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundClick.m4a 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 new file mode 100644 index 00000000..a618d277 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundCoin.m4a 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 new file mode 100644 index 00000000..e60c1a1b Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundCountdown.m4a 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 new file mode 100644 index 00000000..3169fd36 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundDeath.m4a 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 new file mode 100644 index 00000000..52e7c450 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundEnemyDeath.m4a 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 new file mode 100644 index 00000000..62dc862d Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundEnemyHit.m4a 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 new file mode 100644 index 00000000..e0f75433 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundEnemyShot.m4a 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 new file mode 100644 index 00000000..bd9ab8f9 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundExplosionLarge.m4a 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 new file mode 100644 index 00000000..a1deff03 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundExplosionSmall.m4a 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 new file mode 100644 index 00000000..18bca3a2 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundFallDull.m4a 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 new file mode 100644 index 00000000..985d0023 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundFallLoud.m4a 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 new file mode 100644 index 00000000..1baec410 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundFlapHeavy.m4a 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 new file mode 100644 index 00000000..ed9d6e91 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundFlapLight.m4a 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 new file mode 100644 index 00000000..6eef0fa1 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundGameOver.m4a 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 new file mode 100644 index 00000000..eecc63ff Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundHurryUp.m4a 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 new file mode 100644 index 00000000..dca05b48 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundJump1.m4a 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 new file mode 100644 index 00000000..4ed62c9c Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundJump2.m4a 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 new file mode 100644 index 00000000..aa36cdb7 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundJumpHah.m4a 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 new file mode 100644 index 00000000..280d6f13 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundLand1.m4a 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 new file mode 100644 index 00000000..e29c317b Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundLand2.m4a 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 new file mode 100644 index 00000000..890d6192 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundLandHeavy.m4a 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 new file mode 100644 index 00000000..8b3d9179 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundLaser.m4a 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 new file mode 100644 index 00000000..7a54bc32 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundMechanism.m4a 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 new file mode 100644 index 00000000..2f5a5f50 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundMissile.m4a 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 new file mode 100644 index 00000000..8713726d Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundObjectFall.m4a 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 new file mode 100644 index 00000000..ea0833a8 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundOpenDoor.m4a 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 new file mode 100644 index 00000000..f7171133 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundPlayerHit.m4a 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 new file mode 100644 index 00000000..7120a4ba Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundReachGoal.m4a 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 new file mode 100644 index 00000000..e0e6b35a Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundShootDull.m4a 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 new file mode 100644 index 00000000..11d19112 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundShootRegular.m4a 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 new file mode 100644 index 00000000..513fe130 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundSlide.m4a 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 new file mode 100644 index 00000000..0b0cae7d Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundSpecialSkill.m4a 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 new file mode 100644 index 00000000..d67a38a7 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundStartLevel.m4a 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 new file mode 100644 index 00000000..a44d81a5 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundSwim.m4a 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 new file mode 100644 index 00000000..df2c7687 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundWandMagic.m4a 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 new file mode 100644 index 00000000..106c546e Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/sounds/SoundWind.m4a differ diff --git a/extensions/arcade-canvas/extensions/assets/ninja-runner/spikes.png b/extensions/arcade-canvas/extensions/assets/ninja-runner/spikes.png new file mode 100644 index 00000000..08a63516 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/ninja-runner/spikes.png differ diff --git a/extensions/arcade-canvas/extensions/assets/preview.png b/extensions/arcade-canvas/extensions/assets/preview.png new file mode 100644 index 00000000..65d9bd8b Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/preview.png 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 new file mode 100644 index 00000000..9d6881b9 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/sounds/agent-arcade-voice.mp3 differ diff --git a/extensions/arcade-canvas/extensions/assets/tray_icon.png b/extensions/arcade-canvas/extensions/assets/tray_icon.png new file mode 100644 index 00000000..ec26e637 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/tray_icon.png differ diff --git a/extensions/arcade-canvas/extensions/assets/tray_icon_small.png b/extensions/arcade-canvas/extensions/assets/tray_icon_small.png new file mode 100644 index 00000000..4348d753 Binary files /dev/null and b/extensions/arcade-canvas/extensions/assets/tray_icon_small.png differ diff --git a/extensions/arcade-canvas/extensions/copilot-extension.json b/extensions/arcade-canvas/extensions/copilot-extension.json new file mode 100644 index 00000000..c980a34e --- /dev/null +++ b/extensions/arcade-canvas/extensions/copilot-extension.json @@ -0,0 +1,4 @@ +{ + "name": "arcade-canvas", + "version": 1 +} diff --git a/extensions/arcade-canvas/extensions/extension.mjs b/extensions/arcade-canvas/extensions/extension.mjs new file mode 100644 index 00000000..c8c56b0c --- /dev/null +++ b/extensions/arcade-canvas/extensions/extension.mjs @@ -0,0 +1,546 @@ +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/extensions/game/game.js b/extensions/arcade-canvas/extensions/game/game.js new file mode 100644 index 00000000..7ed254bf --- /dev/null +++ b/extensions/arcade-canvas/extensions/game/game.js @@ -0,0 +1,178 @@ +// 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/extensions/game/phaser.min.js b/extensions/arcade-canvas/extensions/game/phaser.min.js new file mode 100644 index 00000000..7647718e --- /dev/null +++ b/extensions/arcade-canvas/extensions/game/phaser.min.js @@ -0,0 +1,17 @@ +(() => { + 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/extensions/game/phaser.min.js.gz b/extensions/arcade-canvas/extensions/game/phaser.min.js.gz new file mode 100644 index 00000000..53c42968 Binary files /dev/null and b/extensions/arcade-canvas/extensions/game/phaser.min.js.gz differ diff --git a/extensions/arcade-canvas/extensions/game/scenes/AlienOnslaught.js b/extensions/arcade-canvas/extensions/game/scenes/AlienOnslaught.js new file mode 100644 index 00000000..c8f77f82 --- /dev/null +++ b/extensions/arcade-canvas/extensions/game/scenes/AlienOnslaught.js @@ -0,0 +1,812 @@ +// 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/extensions/game/scenes/BaseScene.js b/extensions/arcade-canvas/extensions/game/scenes/BaseScene.js new file mode 100644 index 00000000..b5b89731 --- /dev/null +++ b/extensions/arcade-canvas/extensions/game/scenes/BaseScene.js @@ -0,0 +1,785 @@ +// 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/extensions/game/scenes/CosmicRocks.js b/extensions/arcade-canvas/extensions/game/scenes/CosmicRocks.js new file mode 100644 index 00000000..0518d7ca --- /dev/null +++ b/extensions/arcade-canvas/extensions/game/scenes/CosmicRocks.js @@ -0,0 +1,697 @@ +// 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/extensions/game/scenes/GalaxyBlaster.js b/extensions/arcade-canvas/extensions/game/scenes/GalaxyBlaster.js new file mode 100644 index 00000000..4575b583 --- /dev/null +++ b/extensions/arcade-canvas/extensions/game/scenes/GalaxyBlaster.js @@ -0,0 +1,1307 @@ +// 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/extensions/game/scenes/NinjaRunner.js b/extensions/arcade-canvas/extensions/game/scenes/NinjaRunner.js new file mode 100644 index 00000000..64424a0b --- /dev/null +++ b/extensions/arcade-canvas/extensions/game/scenes/NinjaRunner.js @@ -0,0 +1,2811 @@ +// 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/extensions/game/scenes/PlanetGuardian.js b/extensions/arcade-canvas/extensions/game/scenes/PlanetGuardian.js new file mode 100644 index 00000000..bdcddeea --- /dev/null +++ b/extensions/arcade-canvas/extensions/game/scenes/PlanetGuardian.js @@ -0,0 +1,1818 @@ +// 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/extensions/package-lock.json b/extensions/arcade-canvas/extensions/package-lock.json new file mode 100644 index 00000000..e7e7b176 --- /dev/null +++ b/extensions/arcade-canvas/extensions/package-lock.json @@ -0,0 +1,275 @@ +{ + "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/extensions/package.json b/extensions/arcade-canvas/extensions/package.json new file mode 100644 index 00000000..1c7734e0 --- /dev/null +++ b/extensions/arcade-canvas/extensions/package.json @@ -0,0 +1,20 @@ +{ + "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 d45e7e39..4132f63b 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.0", + "version": "1.0.1", "author": { "name": "James Montemagno", "url": "https://github.com/jamesmontemagno" @@ -15,5 +15,5 @@ "workflow-automation" ], "logo": "assets/preview.png", - "extensions": "." + "extensions": "extensions" } diff --git a/extensions/backlog-swipe-triage/extensions/README.md b/extensions/backlog-swipe-triage/extensions/README.md new file mode 100644 index 00000000..291e6ea9 --- /dev/null +++ b/extensions/backlog-swipe-triage/extensions/README.md @@ -0,0 +1,8 @@ +# 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/extensions/assets/preview.png b/extensions/backlog-swipe-triage/extensions/assets/preview.png new file mode 100644 index 00000000..23a7e4f0 Binary files /dev/null and b/extensions/backlog-swipe-triage/extensions/assets/preview.png differ diff --git a/extensions/backlog-swipe-triage/extensions/extension.mjs b/extensions/backlog-swipe-triage/extensions/extension.mjs new file mode 100644 index 00000000..e5c3af73 --- /dev/null +++ b/extensions/backlog-swipe-triage/extensions/extension.mjs @@ -0,0 +1,2183 @@ +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/extensions/package-lock.json b/extensions/backlog-swipe-triage/extensions/package-lock.json new file mode 100644 index 00000000..38325be1 --- /dev/null +++ b/extensions/backlog-swipe-triage/extensions/package-lock.json @@ -0,0 +1,218 @@ +{ + "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/extensions/package.json b/extensions/backlog-swipe-triage/extensions/package.json new file mode 100644 index 00000000..34f71265 --- /dev/null +++ b/extensions/backlog-swipe-triage/extensions/package.json @@ -0,0 +1,18 @@ +{ + "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 c8a2fa72..117a0cee 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.0", + "version": "1.0.1", "author": { "name": "Andrea Griffiths", "url": "https://github.com/AndreaGriffiths11" @@ -16,5 +16,5 @@ "web-navigation" ], "logo": "assets/preview.png", - "extensions": "." + "extensions": "extensions" } diff --git a/extensions/chromium-control-canvas/extensions/README.md b/extensions/chromium-control-canvas/extensions/README.md new file mode 100644 index 00000000..0cb4a701 --- /dev/null +++ b/extensions/chromium-control-canvas/extensions/README.md @@ -0,0 +1,90 @@ +# 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/extensions/assets/preview.png b/extensions/chromium-control-canvas/extensions/assets/preview.png new file mode 100644 index 00000000..f87b051a Binary files /dev/null and b/extensions/chromium-control-canvas/extensions/assets/preview.png differ diff --git a/extensions/chromium-control-canvas/extensions/copilot-extension.json b/extensions/chromium-control-canvas/extensions/copilot-extension.json new file mode 100644 index 00000000..2c4915b9 --- /dev/null +++ b/extensions/chromium-control-canvas/extensions/copilot-extension.json @@ -0,0 +1,4 @@ +{ + "name": "chromium-control-canvas", + "version": 1 +} diff --git a/extensions/chromium-control-canvas/extensions/extension.mjs b/extensions/chromium-control-canvas/extensions/extension.mjs new file mode 100644 index 00000000..cc7e97da --- /dev/null +++ b/extensions/chromium-control-canvas/extensions/extension.mjs @@ -0,0 +1,675 @@ +// 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/extensions/index.html b/extensions/chromium-control-canvas/extensions/index.html new file mode 100644 index 00000000..ab3bb2b6 --- /dev/null +++ b/extensions/chromium-control-canvas/extensions/index.html @@ -0,0 +1,401 @@ + + + + + +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/extensions/package-lock.json b/extensions/chromium-control-canvas/extensions/package-lock.json new file mode 100644 index 00000000..dc195aba --- /dev/null +++ b/extensions/chromium-control-canvas/extensions/package-lock.json @@ -0,0 +1,252 @@ +{ + "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/extensions/package.json b/extensions/chromium-control-canvas/extensions/package.json new file mode 100644 index 00000000..5b6197b2 --- /dev/null +++ b/extensions/chromium-control-canvas/extensions/package.json @@ -0,0 +1,22 @@ +{ + "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 c162c294..e94bc132 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.0", + "version": "1.0.1", "author": { "name": "Aaron Powell", "url": "https://github.com/aaronpowell" @@ -15,5 +15,5 @@ "visual-feedback" ], "logo": "assets/preview.png", - "extensions": "." + "extensions": "extensions" } diff --git a/extensions/color-orb/extensions/assets/preview.png b/extensions/color-orb/extensions/assets/preview.png new file mode 100644 index 00000000..3294126a Binary files /dev/null and b/extensions/color-orb/extensions/assets/preview.png differ diff --git a/extensions/color-orb/extensions/extension.mjs b/extensions/color-orb/extensions/extension.mjs new file mode 100644 index 00000000..1dd4c9d2 --- /dev/null +++ b/extensions/color-orb/extensions/extension.mjs @@ -0,0 +1,289 @@ +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/extensions/package-lock.json b/extensions/color-orb/extensions/package-lock.json new file mode 100644 index 00000000..fd2a9dae --- /dev/null +++ b/extensions/color-orb/extensions/package-lock.json @@ -0,0 +1,218 @@ +{ + "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/extensions/package.json b/extensions/color-orb/extensions/package.json new file mode 100644 index 00000000..a28cd5c3 --- /dev/null +++ b/extensions/color-orb/extensions/package.json @@ -0,0 +1,18 @@ +{ + "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 18b8c116..e563f9a8 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.0", + "version": "1.1.1", "author": { "name": "Alex Yang", "url": "https://github.com/alexyaang" @@ -15,5 +15,5 @@ "tool-discovery" ], "logo": "assets/preview.png", - "extensions": "." + "extensions": "extensions" } diff --git a/extensions/connector-namespaces/extensions/LICENSE b/extensions/connector-namespaces/extensions/LICENSE new file mode 100644 index 00000000..22aed37e --- /dev/null +++ b/extensions/connector-namespaces/extensions/LICENSE @@ -0,0 +1,21 @@ +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 new file mode 100644 index 00000000..f7346247 --- /dev/null +++ b/extensions/connector-namespaces/extensions/README.md @@ -0,0 +1,85 @@ +# 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 new file mode 100644 index 00000000..dc0f9fc4 --- /dev/null +++ b/extensions/connector-namespaces/extensions/armClient.mjs @@ -0,0 +1,444 @@ +// 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 new file mode 100644 index 00000000..4050d878 Binary files /dev/null and b/extensions/connector-namespaces/extensions/assets/preview-connected.png differ diff --git a/extensions/connector-namespaces/extensions/assets/preview.png b/extensions/connector-namespaces/extensions/assets/preview.png new file mode 100644 index 00000000..259e2794 Binary files /dev/null and b/extensions/connector-namespaces/extensions/assets/preview.png differ diff --git a/extensions/connector-namespaces/extensions/catalog.mjs b/extensions/connector-namespaces/extensions/catalog.mjs new file mode 100644 index 00000000..79f4165c --- /dev/null +++ b/extensions/connector-namespaces/extensions/catalog.mjs @@ -0,0 +1,70 @@ +// 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 new file mode 100644 index 00000000..57cd9592 --- /dev/null +++ b/extensions/connector-namespaces/extensions/categories.mjs @@ -0,0 +1,15 @@ +// 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/extensions/copilot-extension.json b/extensions/connector-namespaces/extensions/copilot-extension.json new file mode 100644 index 00000000..3949dbe4 --- /dev/null +++ b/extensions/connector-namespaces/extensions/copilot-extension.json @@ -0,0 +1,4 @@ +{ + "name": "connector-namespaces", + "version": 1 +} diff --git a/extensions/connector-namespaces/extensions/createPage.mjs b/extensions/connector-namespaces/extensions/createPage.mjs new file mode 100644 index 00000000..3eb86659 --- /dev/null +++ b/extensions/connector-namespaces/extensions/createPage.mjs @@ -0,0 +1,403 @@ +// 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 new file mode 100644 index 00000000..fe6f502e --- /dev/null +++ b/extensions/connector-namespaces/extensions/extension.mjs @@ -0,0 +1,101 @@ +// 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/extensions/install.mjs b/extensions/connector-namespaces/extensions/install.mjs new file mode 100644 index 00000000..74264d6f --- /dev/null +++ b/extensions/connector-namespaces/extensions/install.mjs @@ -0,0 +1,1007 @@ +// 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/extensions/install.reauth.test.mjs b/extensions/connector-namespaces/extensions/install.reauth.test.mjs new file mode 100644 index 00000000..50da0470 --- /dev/null +++ b/extensions/connector-namespaces/extensions/install.reauth.test.mjs @@ -0,0 +1,558 @@ +// 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/extensions/install.test.mjs b/extensions/connector-namespaces/extensions/install.test.mjs new file mode 100644 index 00000000..5e51de54 --- /dev/null +++ b/extensions/connector-namespaces/extensions/install.test.mjs @@ -0,0 +1,253 @@ +// 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/extensions/mcp-http-probe.test.mjs b/extensions/connector-namespaces/extensions/mcp-http-probe.test.mjs new file mode 100644 index 00000000..009f1086 --- /dev/null +++ b/extensions/connector-namespaces/extensions/mcp-http-probe.test.mjs @@ -0,0 +1,58 @@ +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/extensions/package.json b/extensions/connector-namespaces/extensions/package.json new file mode 100644 index 00000000..ac0c28b0 --- /dev/null +++ b/extensions/connector-namespaces/extensions/package.json @@ -0,0 +1,19 @@ +{ + "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/extensions/preview/.gitignore b/extensions/connector-namespaces/extensions/preview/.gitignore new file mode 100644 index 00000000..0bac4c45 --- /dev/null +++ b/extensions/connector-namespaces/extensions/preview/.gitignore @@ -0,0 +1,2 @@ +# Screenshot evidence is throwaway, regenerated on demand by shots.mjs. +shots/ diff --git a/extensions/connector-namespaces/extensions/preview/README.md b/extensions/connector-namespaces/extensions/preview/README.md new file mode 100644 index 00000000..14b839b1 --- /dev/null +++ b/extensions/connector-namespaces/extensions/preview/README.md @@ -0,0 +1,112 @@ +# 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/extensions/preview/fixtures.mjs b/extensions/connector-namespaces/extensions/preview/fixtures.mjs new file mode 100644 index 00000000..0fce100b --- /dev/null +++ b/extensions/connector-namespaces/extensions/preview/fixtures.mjs @@ -0,0 +1,117 @@ +// 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/extensions/preview/server.mjs b/extensions/connector-namespaces/extensions/preview/server.mjs new file mode 100644 index 00000000..d56a14c6 --- /dev/null +++ b/extensions/connector-namespaces/extensions/preview/server.mjs @@ -0,0 +1,151 @@ +// 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/extensions/preview/shots.mjs b/extensions/connector-namespaces/extensions/preview/shots.mjs new file mode 100644 index 00000000..aab8f17b --- /dev/null +++ b/extensions/connector-namespaces/extensions/preview/shots.mjs @@ -0,0 +1,96 @@ +// 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/extensions/renderer.mjs b/extensions/connector-namespaces/extensions/renderer.mjs new file mode 100644 index 00000000..e4ddf609 --- /dev/null +++ b/extensions/connector-namespaces/extensions/renderer.mjs @@ -0,0 +1,1563 @@ +// 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/extensions/renderer.test.mjs b/extensions/connector-namespaces/extensions/renderer.test.mjs new file mode 100644 index 00000000..d0c464e4 --- /dev/null +++ b/extensions/connector-namespaces/extensions/renderer.test.mjs @@ -0,0 +1,393 @@ +// 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, /